Documentation
¶
Overview ¶
Package otel wires weft's observability to one or more OpenTelemetry destinations at once — the local sink, Weft Studio, Datadog, Langfuse, any OTLP endpoint or your own exporters — each with its own signals and content policy (ADR 0024, S2).
One Install, one set of spans and records, fanned out:
defer otel.Install(
otel.Local("weft.db"), // local sink: offline, replay-grade, content on
otel.Studio("https://studio.example", token), // content on, traces + logs
otel.Datadog(), // the local Datadog Agent's OTLP intake, content off
otel.Langfuse("https://cloud.langfuse.com", pk, sk), // traces only
)()
The rules that shape it:
- Content is captured once and shaped per destination [D2]. Every processor registered here answers the Logs API's Enabled by event name: the core asks of each pure-content kind ("weft.messages", and ADR 0028's "weft.prompt" and "weft.tools") and emits content when any destination wants it. Content-off chains clone the record first (the SDK hands every processor the same pointer), strip the body with core.StripContent, empty a request record's params.stop and drop messages, prompt and tools records; content-on chains redact and cap event and delta bodies and redact (never cap) the transcript records part by part. The core reads no environment variable — this module does, here only.
- The run tracker keeps the set of open runs and emits one weft.heartbeat record per open run per interval, so a 45 s tool call reads running and a crashed process stops heartbeating (the "interrupted" rule stays honest). Sinks never store heartbeats.
- The slog bridge (Slog wraps slog.Default; SlogHandler wraps any handler) tees every line logged on a span-carrying context to the pipeline as an app log record with that span's ids — weft.Logger's run lines, mw.Log's, the app's own — for the destinations AppLogs admits (Local and Studio with content on by default), each line shaped by the destination's content policy (Redact, MaxBytes; stripped when content is off). While it is live, heartbeats and each run's invoke_agent span carry weft.logs.bridged on the destinations that take the lines; Studio's logs route reads its absence as "no weft slog bridge was recorded for this run".
- The Local destination writes obsdb/sqlite synchronously: nothing emitted is lost, and the DB's hub is setup A's live lane.
- Install never panics and never fails the program; Start returns errors and leaves the globals alone under NoGlobal (tests).
This module depends on the OTel SDK and the OTLP/HTTP exporters; the core never does. It never imports weft/thread (the runtime link is its own module, weft/runtime, D6).
Index ¶
- Constants
- func FromSDKRecords(recs []sdklog.Record) []obsdb.Record
- func FromSDKSpans(spans []sdktrace.ReadOnlySpan) []obsdb.Span
- func Install(opts ...Option) func()
- func LocalDB() obsdb.DB
- func SlogHandler(next slog.Handler) slog.Handler
- func StudioEndpoint() (string, string)
- type ContentConfig
- type DestOption
- func AppLogs(on bool) DestOption
- func BatchDelay(d time.Duration) DestOption
- func Blocking() DestOption
- func DatadogEndpoint(url string) DestOption
- func Headers(h map[string]string) DestOption
- func Insecure() DestOption
- func NoContent() DestOption
- func NoDeltas() DestOption
- func QueueSize(n int) DestOption
- func Signals(traces, logs bool) DestOption
- func Timeout(d time.Duration) DestOption
- func WithContent(cfg ...ContentConfig) DestOption
- type LoggerProvider
- type Option
- func Content(cfg ContentConfig) Option
- func Datadog(opts ...DestOption) Option
- func DevDetail() Option
- func DevMode() Option
- func Exporters(spans sdktrace.SpanExporter, logs sdklog.Exporter, opts ...DestOption) Option
- func Heartbeat(every time.Duration) Option
- func Langfuse(host, publicKey, secretKey string, opts ...DestOption) Option
- func Local(path string, opts ...DestOption) Option
- func NoDiscovery() Option
- func NoEnv() Option
- func NoGlobal() Option
- func OTLP(endpoint string, opts ...DestOption) Option
- func RawBodies(on bool) Option
- func Resource(r *sdkresource.Resource) Option
- func Sampler(s sdktrace.Sampler) Option
- func Service(name string) Option
- func Slog() Option
- func Studio(url, token string, opts ...DestOption) Option
- type Pipeline
- func (p *Pipeline) ForceFlush(ctx context.Context) error
- func (p *Pipeline) LocalDB() obsdb.DB
- func (p *Pipeline) LoggerProvider() *LoggerProvider
- func (p *Pipeline) Shutdown(ctx context.Context) error
- func (p *Pipeline) SlogHandler(next slog.Handler) slog.Handler
- func (p *Pipeline) StudioEndpoint() (string, string)
- func (p *Pipeline) TracerProvider() *sdktrace.TracerProvider
Examples ¶
Constants ¶
const ContentLog core.ContentKind = "log"
ContentLog is the kind the destination's Redact receives for a bridged app log line (Slog, SlogHandler): its message and each of its string attributes. It is not a core kind — the core never logs content into a record; the bridge carries what the app logged.
const ContentThread core.ContentKind = "thread"
ContentThread is the kind Redact sees for a weft/thread session record's content fields (record contract §4, kind thread): who decided, a deny reason, resolved content, a grant's reason, a queued or steered message's text, a branch summary, a delegated task and its answer — each string under the body's "content" object. A content-off destination receives none of them (the object is removed, weft.content = stripped); the record's ids, enums, counts and app-chosen names (a label's name) ride either way.
Variables ¶
This section is empty.
Functions ¶
func FromSDKRecords ¶
FromSDKRecords converts SDK log records (what a log Exporter receives) into the obsdb model, matching obsdb.FromOTLPLogs value for value on the same data.
func FromSDKSpans ¶
func FromSDKSpans(spans []sdktrace.ReadOnlySpan) []obsdb.Span
FromSDKSpans converts SDK spans (what a SpanExporter receives) into the obsdb model. It lives in this module because it needs the SDK; a package under obsdb would put the SDK in obsdb's go.mod (S3.3). For the same data it produces exactly the values obsdb.FromOTLPTraces does — the golden test pins that against obsdb's OTLP fixtures.
func Install ¶
func Install(opts ...Option) func()
Install starts the pipeline and registers it globally. It never panics and never fails the program: with no options at all (and no environment destinations) it writes the local sink only (the zero-config rule), and a destination that cannot be built is logged (slog, WARN) and skipped. When no destination option was passed and every environment destination failed to build, it falls back to the local sink with one WARN rather than recording nothing (Start returns the error instead); a program that passed its own destinations gets no destination it did not name. The returned function flushes and shuts everything down; call it on exit.
Example ¶
Install is the whole setup: it registers the OTel globals the core reads, never fails the program, and returns the shutdown to call on exit. NoEnv keeps the example hermetic — no WEFT_* / OTEL_* variable can add a destination behind it.
package main
import (
"context"
"fmt"
"log"
"os"
"path/filepath"
"github.com/weftgo/weft"
"github.com/weftgo/weft/obsdb"
"github.com/weftgo/weft/obsdb/sqlite"
"github.com/weftgo/weft/otel"
"github.com/weftgo/weft/wefttest"
)
func main() {
dir, err := os.MkdirTemp("", "weft-otel-example-")
if err != nil {
log.Fatal(err)
}
defer func() { _ = os.RemoveAll(dir) }()
shutdown := otel.Install(
otel.NoEnv(),
otel.Local(filepath.Join(dir, "weft.db")),
)
// A scripted run through the installed globals — a real program
// wires nothing: the core reads the providers Install registered.
agt := weft.New(
wefttest.Script(wefttest.Say("all done")),
weft.Name("example"),
)
if _, err := agt.Generate(context.Background(), weft.Prompt("go"),
weft.Metadata(map[string]string{"weft.session.id": "example-session"})); err != nil {
log.Fatal(err)
}
shutdown() // flush every destination, close the local DB last
// The sink reopens as a plain obsdb.DB: the run row with its
// derived status, and the transcript the messages records hold.
db, err := sqlite.Open(filepath.Join(dir, "weft.db"))
if err != nil {
log.Fatal(err)
}
defer func() { _ = db.Close() }()
page, err := db.Runs(context.Background(), obsdb.RunQuery{})
if err != nil {
log.Fatal(err)
}
fmt.Println(page.Runs[0].Status, page.Runs[0].MessageCount)
}
Output: succeeded 2
func LocalDB ¶
LocalDB returns the installed pipeline's local obsdb.DB — for studio.DB(otel.LocalDB()) (setup A, [D4]). nil before Install, after its shutdown, or without a Local destination.
S2.1 names this otel.Local(), which cannot coexist with the Local(path) destination option in Go; LocalDB is the recorded deviation (notes-lane-a2.md).
func SlogHandler ¶ added in v0.17.0
SlogHandler returns a slog.Handler that tees every record to next (when next is non-nil and enables its level) and to the installed pipeline (Install, or Start without NoGlobal), looked up per record: built before Install, it starts teeing once Install has run, and stops after the pipeline's shutdown. Use it to bridge a logger the program builds itself — slog.New(otel.SlogHandler(h)) — or pass that logger to weft.Logger, mw.Log or mw.Audit; Slog does the same for slog.Default.
A bridged record is an app log record (record contract §5): the slog message as its body; the slog level as its severity (DEBUG 5, INFO 9, WARN 13, ERROR 17 — level + 9, held to 1–24) and severity text; the attributes flattened, a group's keys joined with "." (a key starting "weft." — "weft.run.id", "weft.record", which would make the record read as weft's own — is renamed "slog.weft.…"), the first 120 kept and the rest counted in weft.log.attrs_dropped; durations and times as their string forms; the trace and span ids of ctx. Only a line logged under a valid span context is teed — a line with no span names no run and stays with next alone — and only at a level some destination takes: the bridge itself has no level, every slog level (DEBUG included: weft's own lines are Debug) goes to the pipeline. Which destinations receive the lines is AppLogs': Local and Studio with content on by default, never a third party unless it opts in. Each destination shapes a line by its content policy (ContentConfig): Redact over the message and string attributes (ContentLog), each capped at MaxBytes with weft.content.truncated_bytes; a content-off destination that opted in gets the line stripped — severity, span and non-string attributes kept, message and string values emptied, weft.content "stripped". Under a full queue bridged lines are shed with the deltas, before weft's own records.
Example ¶
SlogHandler bridges a logger the program builds itself: here a tool handler's WARN, logged with the tool call's context, is stored under that call's execute_tool span — the line and the step it came from, joined in Studio. The handler follows the installed pipeline, so it may be built before Install.
package main
import (
"context"
"fmt"
"io"
"log"
"log/slog"
"os"
"path/filepath"
"github.com/weftgo/weft"
"github.com/weftgo/weft/obsdb"
"github.com/weftgo/weft/obsdb/sqlite"
"github.com/weftgo/weft/otel"
"github.com/weftgo/weft/wefttest"
)
func main() {
dir, err := os.MkdirTemp("", "weft-otel-slog-")
if err != nil {
log.Fatal(err)
}
defer func() { _ = os.RemoveAll(dir) }()
logger := slog.New(otel.SlogHandler(slog.NewTextHandler(io.Discard, nil)))
path := filepath.Join(dir, "weft.db")
shutdown := otel.Install(otel.NoEnv(), otel.Local(path))
lookup := weft.Tool("lookup", "Look an order up.", func(ctx context.Context, in struct {
ID string `json:"id"`
}) (string, error) {
logger.WarnContext(ctx, "slow lookup", "id", in.ID)
return "shipped", nil
})
agt := weft.New(wefttest.Script(
wefttest.ToolCalls(wefttest.Call{Name: "lookup", Args: `{"id":"42"}`}),
wefttest.Say("shipped")), weft.Name("example"), lookup)
res, err := agt.Generate(context.Background(), weft.Prompt("where is 42?"))
if err != nil {
log.Fatal(err)
}
shutdown()
db, err := sqlite.Open(path)
if err != nil {
log.Fatal(err)
}
defer func() { _ = db.Close() }()
page, err := db.OtherLogs(context.Background(), res.ID, obsdb.LogQuery{})
if err != nil {
log.Fatal(err)
}
spans, err := db.RunSpans(context.Background(), res.ID)
if err != nil {
log.Fatal(err)
}
names := map[string]string{}
for _, s := range spans {
names[s.SpanID] = s.Name
}
for _, l := range page.Logs {
fmt.Println(obsdb.SeverityText(l.Severity), l.Body, "·", names[l.SpanID], "· id", l.Attrs["id"])
}
}
Output: WARN slow lookup · execute_tool lookup · id 42
func StudioEndpoint ¶
StudioEndpoint returns the installed pipeline's Studio destination, which weft/runtime dials by default. "" without one (or after the pipeline's shutdown).
Types ¶
type ContentConfig ¶
type ContentConfig struct {
// MaxBytes caps one event field or delta body, a prompt record's
// text and a tools record's body (whole tool entries dropped from
// the end, so the body stays JSON); 0 = 32 KiB; negative =
// unlimited. A cut sets weft.content.truncated_bytes. The default
// applies to tools bodies too: a large catalog (an MCP server's, say)
// arrives truncated, with truncated_bytes set — raise MaxBytes for
// the local sink when the full catalog is wanted. Never applied to
// messages records — a capped transcript is not replay-grade.
MaxBytes int
// Redact is applied to a content field before the cap; nil = identity.
// It sees event and delta bodies and weft.messages transcript
// records, the latter part by part with the kind the same content
// has on the event path: text parts (every role) core.ContentText,
// reasoning core.ContentReasoning, tool-call args core.ContentArgs (an
// output that is not JSON is carried as a JSON string), tool results
// core.ContentResult. It also sees a prompt record's text
// (core.ContentPrompt) and each stop sequence of a request record
// (core.ContentStop); a tools record is capped, never redacted (ADR
// 0028 §6), and a bridged app log line's message and string
// attributes (ContentLog; Slog, SlogHandler — capped too, a panic
// drops the line). Ids, names, roles, signatures, file parts and hashes are
// not passed to it; a transcript record is redacted, never capped. It
// runs on the run's goroutine: a panic in it is contained — the event
// goes out stripped, the transcript record not at all — and counted
// as the destination's drop; so is a transcript record that cannot be
// decoded for redaction. Nothing it was given is ever sent unredacted.
Redact func(kind core.ContentKind, s string) string
}
ContentConfig carries the caps and redaction that live per destination, not in the core ([D2]: the core cannot discover them — the OTel global never hands out the SDK provider).
type DestOption ¶
type DestOption interface {
// contains filtered or unexported methods
}
DestOption configures one destination.
func AppLogs ¶ added in v0.17.0
func AppLogs(on bool) DestOption
AppLogs says whether this destination takes the slog bridge's app log records (Slog, SlogHandler): on by default for Local and Studio with content on, off for Datadog, Langfuse, OTLP and Exporters — and for a Local or Studio destination under NoContent — because an app's log lines may carry anything it logged, prompts included. AppLogs(true) is the opt-in: it admits the lines, never bypasses the destination's content policy — an app log line is content like a transcript record, so a content-on destination applies ContentConfig's Redact (ContentLog) and MaxBytes to its message and string attributes, and a content-off one receives it stripped (message and string values emptied, weft.content=stripped; severity, span and the other attributes kept). AppLogs(false) keeps them from a Local or Studio destination. Only a destination that takes the lines carries the bridge mark (weft.logs.bridged on its heartbeats and root spans). Records the app emits through the OTel Logs API itself are not the bridge's and are not governed by it.
func BatchDelay ¶
func BatchDelay(d time.Duration) DestOption
BatchDelay sets the destination's batch flush interval (Studio defaults logs 200 ms / spans 1 s; the others the SDK's defaults).
func Blocking ¶ added in v0.16.0
func Blocking() DestOption
Blocking makes a full queue wait for room instead of refusing a durable record — up to 5 s and never past the emitting run's context (a span's End, which has none, waits under its own 5 s deadline); deltas are still shed. The bound is per stall: once a wait times out (the exporter is stuck), every later record is refused at once, counted, until an export begins again — a hung exporter costs the app one 5 s wait, not one per record. Development only, like RawBodies: it takes effect only when the pipeline starts under the dev gate (WEFT_ENV=dev read unless NoEnv, or DevMode — which an explicit WEFT_ENV other than dev overrules); otherwise Start logs one WARN naming the cause ("otel: Blocking ignored: WEFT_ENV is not dev", or "… otel.NoEnv reads no WEFT_ENV") and the destination refuses as without it — observability never slows a production app. Local ignores it (no queue).
Example ¶
Blocking is development-only: under WEFT_ENV=dev (or DevMode) a full queue waits (up to 5 s) for room instead of refusing a durable record. Anywhere else — and under NoEnv without DevMode, as here — Start ignores it with one WARN naming the cause and the destination refuses as without it: observability never slows a production app. QueueSize is the production knob.
package main
import (
"context"
"log"
"log/slog"
"os"
"sync"
"go.opentelemetry.io/otel/attribute"
sdklog "go.opentelemetry.io/otel/sdk/log"
"github.com/weftgo/weft/otel"
)
func main() {
prev := slog.Default()
defer slog.SetDefault(prev)
slog.SetDefault(slog.New(slog.NewTextHandler(os.Stdout, &slog.HandlerOptions{
ReplaceAttr: func(_ []string, a slog.Attr) slog.Attr {
if a.Key == slog.TimeKey || a.Key == "hint" {
return slog.Attr{}
}
return a
},
})))
p, err := otel.Start(context.Background(),
otel.NoEnv(), otel.NoGlobal(),
otel.Exporters(nil, &eventNames{}, otel.Blocking()),
)
if err != nil {
log.Fatal(err)
}
_ = p.Shutdown(context.Background())
}
// eventNames is a log exporter that remembers the weft.event.type of
// every record it exported.
type eventNames struct {
mu sync.Mutex
types map[string]bool
}
func (e *eventNames) Export(_ context.Context, recs []sdklog.Record) error {
e.mu.Lock()
defer e.mu.Unlock()
if e.types == nil {
e.types = map[string]bool{}
}
for _, r := range recs {
r.WalkAttributes(func(kv attribute.KeyValue) bool {
if kv.Key == "weft.event.type" {
e.types[kv.Value.AsString()] = true
}
return true
})
}
return nil
}
func (e *eventNames) saw(typ string) bool {
e.mu.Lock()
defer e.mu.Unlock()
return e.types[typ]
}
func (*eventNames) Shutdown(context.Context) error { return nil }
func (*eventNames) ForceFlush(context.Context) error { return nil }
Output: level=WARN msg="otel: Blocking ignored: otel.NoEnv reads no WEFT_ENV"
func DatadogEndpoint ¶
func DatadogEndpoint(url string) DestOption
DatadogEndpoint overrides the Datadog destination's OTLP intake URL (default http://localhost:4318).
func Headers ¶
func Headers(h map[string]string) DestOption
Headers adds HTTP headers to the destination's requests (OTLP-family destinations).
func Insecure ¶
func Insecure() DestOption
Insecure allows http:// to a non-loopback host (OTLP-family destinations). Loopback http is always allowed, and so is the environment's OTEL_EXPORTER_OTLP_ENDPOINT written with http:// (the operator's opt-in; see envDestinations) — never WEFT_STUDIO_URL.
func NoContent ¶
func NoContent() DestOption
NoContent turns content off: the chain strips event and delta bodies (core.StripContent) and drops messages records before its exporter.
func NoDeltas ¶
func NoDeltas() DestOption
NoDeltas drops text/reasoning/args deltas before export, to save bandwidth when live streaming is not wanted. Deltas are never stored by the sinks either way (Q4); the core's durable sequence is untouched (they number on their own counter, D3).
func QueueSize ¶ added in v0.16.0
func QueueSize(n int) DestOption
QueueSize sets how many records (and, separately, spans) the destination holds between emission and export — its batch queue (default 16,384 for Studio, the SDK's 2,048 for the rest; n <= 0 keeps the default). A full queue never blocks the app: it refuses the record, deltas first (at half full), then ordinary records (at seven eighths), a run's run_start and run_finish last. Every refusal is counted: one WARN per destination ("weft/otel: destination queue full"), the count on the heartbeat and every run's run_start and run_finish (weft.records.dropped, which Studio's ingest health shows), and the run's gap badge, which counts the events lost against weft.run.events. Local has no queue (it writes synchronously) and ignores it. The OTEL_BLRP_MAX_QUEUE_SIZE and OTEL_BSP_MAX_QUEUE_SIZE variables do not apply to weft's destinations.
Example ¶
QueueSize sizes one destination's batch queue — here a pair of the app's own exporters. A full queue never blocks the app: it refuses records (deltas first, run_start and run_finish last), counts them (weft.records.dropped on the heartbeat and the run brackets) and warns once ("weft/otel: destination queue full"), and the run's gap badge says what was lost. Raise it for a destination that falls behind under bursts.
package main
import (
"context"
"fmt"
"log"
"sync"
"go.opentelemetry.io/otel/attribute"
sdklog "go.opentelemetry.io/otel/sdk/log"
"github.com/weftgo/weft"
"github.com/weftgo/weft/otel"
"github.com/weftgo/weft/wefttest"
)
func main() {
exp := &eventNames{}
p, err := otel.Start(context.Background(),
otel.NoEnv(), otel.NoGlobal(),
otel.Exporters(nil, exp, otel.QueueSize(1<<15)),
)
if err != nil {
log.Fatal(err)
}
agt := weft.New(wefttest.Script(wefttest.Say("done")),
weft.LoggerProvider(p.LoggerProvider()))
if _, err := agt.Generate(context.Background(), weft.Prompt("go")); err != nil {
log.Fatal(err)
}
if err := p.Shutdown(context.Background()); err != nil {
log.Fatal(err)
}
fmt.Println(exp.saw("run_start"), exp.saw("run_finish"))
}
// eventNames is a log exporter that remembers the weft.event.type of
// every record it exported.
type eventNames struct {
mu sync.Mutex
types map[string]bool
}
func (e *eventNames) Export(_ context.Context, recs []sdklog.Record) error {
e.mu.Lock()
defer e.mu.Unlock()
if e.types == nil {
e.types = map[string]bool{}
}
for _, r := range recs {
r.WalkAttributes(func(kv attribute.KeyValue) bool {
if kv.Key == "weft.event.type" {
e.types[kv.Value.AsString()] = true
}
return true
})
}
return nil
}
func (e *eventNames) saw(typ string) bool {
e.mu.Lock()
defer e.mu.Unlock()
return e.types[typ]
}
func (*eventNames) Shutdown(context.Context) error { return nil }
func (*eventNames) ForceFlush(context.Context) error { return nil }
Output: true true
func Signals ¶
func Signals(traces, logs bool) DestOption
Signals selects which signals this destination takes. The defaults are per destination (Langfuse is traces only; the rest both).
func Timeout ¶
func Timeout(d time.Duration) DestOption
Timeout sets the destination's export request timeout.
func WithContent ¶
func WithContent(cfg ...ContentConfig) DestOption
WithContent turns content on for this destination, optionally overriding the pipeline's ContentConfig.
type LoggerProvider ¶ added in v0.14.0
type LoggerProvider struct {
*sdklog.LoggerProvider
// contains filtered or unexported fields
}
LoggerProvider is the pipeline's OpenTelemetry logger provider: the SDK provider every method of which it forwards, plus the marker the core asks before it builds a raw record (ADR 0028 §12). A stock SDK provider has no marker, so an agent handed one never records wire bodies, whatever its processors answer Enabled.
func (*LoggerProvider) DevDetail ¶ added in v0.20.0
func (p *LoggerProvider) DevDetail() bool
DevDetail reports whether this pipeline records the development detail level: otel.DevDetail() and the dev gate at Start. It is the duck-typed marker the core asks before it starts a layer span or keeps a repair's original arguments (ADR 0016's 2026-10-26 amendment); the destinations still decide (Local and Studio with content on only).
func (*LoggerProvider) RawBodies ¶ added in v0.14.0
func (p *LoggerProvider) RawBodies() bool
RawBodies reports whether this pipeline records provider wire bodies: otel.RawBodies(true) and the dev gate at Start (WEFT_ENV=dev read without NoEnv, or otel.DevMode unless an explicit WEFT_ENV overrules it). It is the duck-typed marker core.Reporter.WantsRaw asks for; the destinations still decide per chain (Local and Studio only).
type Option ¶
type Option interface {
// contains filtered or unexported methods
}
Option configures the pipeline (Install/Start).
func Content ¶
func Content(cfg ContentConfig) Option
Content sets the pipeline-wide default for destinations with content on; a destination's WithContent overrides it.
func Datadog ¶
func Datadog(opts ...DestOption) Option
Datadog exports to the local Datadog Agent's OTLP intake (http://localhost:4318; the Agent binds the standard OTLP ports when its OTLP receiver is enabled). Content off, traces + logs, SDK default batching. DatadogEndpoint overrides the URL.
func DevDetail ¶ added in v0.20.0
func DevDetail() Option
DevDetail turns on the development detail level (ADR 0016's 2026-10-26 amendment): beside RawBodies, the record shows the whole middleware onion. Each model call and tool call gets one "layer <name>" span per WrapModel / WrapTools layer (weft.layer.name, weft.layer.kind, weft.layer.index; error.type where the layer returned an error), nested outer to inner under its chat or execute_tool span, a weft.repair event carries the original arguments mw.RepairJSON replaced (capped at 4 KiB), and a contained tool or middleware panic's stack rides its execute_tool span as a weft.panic event (16 KiB) — neither under content off. Off by default: a span per layer is 2–6 more spans per call for a signal most calls do not need. Like RawBodies it is for development only — it takes effect only when the pipeline starts under the dev gate (WEFT_ENV=dev read unless NoEnv, or DevMode); otherwise Start logs one WARN ("otel: DevDetail ignored: …") and the pipeline behaves as without it — and only the Local and Studio destinations with content on receive the detail: an OTLP, Datadog, Langfuse or Exporters destination gets the run without its layer spans and without the original arguments. The gate is the pipeline's LoggerProvider marker (LoggerProvider.DevDetail), which the core asks: pass Pipeline.LoggerProvider() to weft.LoggerProvider, or rely on Install's global registration. With the option the agent knows the level at New and builds the layer boundaries into its one model chain. Through the global provider it cannot (the pipeline may register after New): the first run builds a second chain, so the WrapModel constructors run twice — a stateful middleware holds two states — and a caller streaming Agent.Model() itself (weft/thread's compaction summary) gets the plain chain, without layer spans.
Example ¶
DevDetail records the whole middleware onion in development: one "layer <name>" span per WrapModel / WrapTools layer per call, nested under the call's own span, for the Local and Studio destinations.
package main
import (
"context"
"fmt"
"log"
"os"
"path/filepath"
"strings"
"github.com/weftgo/weft"
"github.com/weftgo/weft/mw"
"github.com/weftgo/weft/otel"
"github.com/weftgo/weft/wefttest"
)
func main() {
dir, err := os.MkdirTemp("", "weft-otel-devdetail-")
if err != nil {
log.Fatal(err)
}
defer func() { _ = os.RemoveAll(dir) }()
p, err := otel.Start(context.Background(),
otel.NoEnv(), otel.DevMode(), otel.NoGlobal(),
otel.Local(filepath.Join(dir, "weft.db")),
otel.DevDetail(),
)
if err != nil {
log.Fatal(err)
}
defer func() { _ = p.Shutdown(context.Background()) }()
lookup := weft.Tool("lookup", "", func(context.Context, struct{}) (string, error) { return "found", nil })
agt := weft.New(wefttest.Script(
wefttest.ToolCalls(wefttest.Call{Name: "lookup"}),
wefttest.Say("done")),
lookup, weft.WrapTools(mw.Audit(nil), mw.Allow(nil)),
weft.TracerProvider(p.TracerProvider()), weft.LoggerProvider(p.LoggerProvider()))
res, err := agt.Generate(context.Background(), weft.Prompt("look it up"))
if err != nil {
log.Fatal(err)
}
_ = p.ForceFlush(context.Background())
spans, err := p.LocalDB().RunSpans(context.Background(), res.ID)
if err != nil {
log.Fatal(err)
}
for _, s := range spans {
if strings.HasPrefix(s.Name, "layer ") {
fmt.Println(s.Name, s.Attrs["weft.layer.index"])
}
}
}
Output: layer mw.Audit 0 layer mw.Allow 1
func DevMode ¶ added in v0.19.0
func DevMode() Option
DevMode attests in code that this pipeline runs in development: it satisfies the dev gate (ADR 0028 §12) that RawBodies, DevDetail and a destination's Blocking need, without reading the environment. It is the code-side twin of WEFT_ENV=dev, for tests and harnesses that control their own process — typically beside NoEnv, which reads no WEFT_ENV and so refuses all three on its own. It unlocks those three and nothing else: no destination, discovery read or runtime link follows from it (runtime.Install keeps its own WEFT_ENV / Enabled rule). The gate still applies — a production binary never sets DevMode; the attestation has only moved from the process environment into code. The operator's environment wins: when the pipeline reads it (no NoEnv) and WEFT_ENV is set to anything but "dev", DevMode is refused with one WARN ("otel: DevMode ignored: WEFT_ENV=<value>") and the switches stay off; it opens the gate under NoEnv or with WEFT_ENV unset. Whenever DevMode is what opened it, Start logs one INFO line per switch it unlocked ("otel: raw bodies on by otel.DevMode()", "otel: dev detail on by otel.DevDetail() under otel.DevMode()", "otel: Blocking on by otel.DevMode()"), so a binary that ships it is visible.
Example ¶
DevMode is the code-side twin of WEFT_ENV=dev, for a test or harness that controls its own process: beside NoEnv — which reads no WEFT_STUDIO_URL, OTEL_* variable or discovery file, and no WEFT_ENV either — it is what lets an isolated pipeline record provider wire bodies (RawBodies) or make a destination wait for queue room (Blocking). Without it, Start would WARN "otel: RawBodies ignored: otel.NoEnv reads no WEFT_ENV" and record no weft.raw.
package main
import (
"context"
"fmt"
"log"
"os"
"path/filepath"
"github.com/weftgo/weft/otel"
)
func main() {
dir, err := os.MkdirTemp("", "weft-otel-devmode-")
if err != nil {
log.Fatal(err)
}
defer func() { _ = os.RemoveAll(dir) }()
p, err := otel.Start(context.Background(),
otel.NoEnv(), otel.DevMode(), otel.NoGlobal(),
otel.Local(filepath.Join(dir, "weft.db")),
otel.RawBodies(true),
)
if err != nil {
log.Fatal(err)
}
defer func() { _ = p.Shutdown(context.Background()) }()
fmt.Println(p.LoggerProvider().RawBodies())
}
Output: true
func Exporters ¶
func Exporters(spans sdktrace.SpanExporter, logs sdklog.Exporter, opts ...DestOption) Option
Exporters wires your own exporters (vendor-specific, stdout, tests): whichever is non-nil gets a chain. Content off, SDK default batching.
func Heartbeat ¶
Heartbeat sets how often the run tracker emits one weft.heartbeat record per open run (the default 10s; 0 or less disables). Three missed intervals are what obsdb's InterruptedAfter reads as interrupted.
func Langfuse ¶
func Langfuse(host, publicKey, secretKey string, opts ...DestOption) Option
Langfuse exports traces to Langfuse's OTLP endpoint (<host>/api/public/otel/v1/traces, Basic auth base64(pk:sk); OTLP/HTTP only — gRPC is not accepted). Traces only: weft's spans carry no content (ADR 0016 O7) and Langfuse ingests traces, so it gets timing, usage, tool names and the identity chain; span-level content is the post-v1 follow-up F1.
func Local ¶
func Local(path string, opts ...DestOption) Option
Local is the local sink: obsdb/sqlite at path ("" = $WEFT_DB or ./.weft/weft.db), written synchronously — nothing emitted is lost, and the DB's in-process hub is the live lane setup A shares with Studio. Content on, traces + logs. Naming a Local switches the discovery file off, as Studio does: the pipeline writes to the stores it names (and the environment's), never into a running Studio it found on disk — add Studio(url, token) to write to one as well.
func NoDiscovery ¶ added in v0.19.0
func NoDiscovery() Option
NoDiscovery skips the discovery file a running `weft studio` / `weft dev` wrote, and nothing else: WEFT_STUDIO_URL, the OTEL_* variables and the WEFT_ENV development gate are read as usual (NoEnv drops them all). It is the per-pipeline form of WEFT_DISCOVERY=off — for a harness or test that keeps the operator's destinations but must not write into a Studio found on disk. A pipeline that names a Studio or a Local destination in code needs no NoDiscovery: either turns the read off on its own. Like NoEnv it is otel's alone: runtime.Install's discovery read (under WEFT_ENV=dev, when the pipeline has no Studio) is governed by runtime's options and WEFT_DISCOVERY=off.
Example ¶
NoDiscovery is for a harness that keeps the operator's environment destinations (WEFT_STUDIO_URL, OTEL_EXPORTER_OTLP_ENDPOINT) but must never write into a Studio it happens to find on disk: here the pipeline exports to the harness's own exporter (and whatever the environment names), and a running `weft studio` on the machine gets nothing. A pipeline that names a Local or a Studio needs no NoDiscovery — either switches the discovery file off on its own.
package main
import (
"context"
"fmt"
"log"
"github.com/weftgo/weft"
"github.com/weftgo/weft/otel"
"github.com/weftgo/weft/wefttest"
"go.opentelemetry.io/otel/sdk/trace/tracetest"
)
func main() {
spans := tracetest.NewInMemoryExporter()
p, err := otel.Start(context.Background(), otel.NoGlobal(), otel.NoDiscovery(),
otel.Exporters(spans, nil), otel.Heartbeat(0))
if err != nil {
log.Fatal(err)
}
agt := weft.New(wefttest.Script(wefttest.Say("ok")), weft.Name("harness"),
weft.TracerProvider(p.TracerProvider()), weft.LoggerProvider(p.LoggerProvider()))
if _, err := agt.Generate(context.Background(), weft.Prompt("go")); err != nil {
log.Fatal(err)
}
if err := p.ForceFlush(context.Background()); err != nil {
log.Fatal(err)
}
fmt.Println("exported to the harness:", len(spans.GetSpans()) > 0)
_ = p.Shutdown(context.Background())
}
Output: exported to the harness: true
func NoEnv ¶
func NoEnv() Option
NoEnv ignores the WEFT_* / OTEL_* destination variables, and the discovery file a running `weft studio` / `weft dev` wrote (which otherwise stands in for WEFT_STUDIO_URL when that is unset). Explicit options are unaffected. It reads nothing, WEFT_ENV included: under NoEnv the development gate RawBodies and Blocking need holds only with DevMode (Start's WARN says "otel.NoEnv reads no WEFT_ENV" otherwise); NoDiscovery is the isolation that keeps WEFT_ENV. It is otel's alone: the runtime link's own discovery read (runtime.Install under WEFT_ENV=dev, when the pipeline has no Studio) is governed by runtime's options and WEFT_DISCOVERY=off.
func NoGlobal ¶
func NoGlobal() Option
NoGlobal leaves the OTel globals alone (Start only; Install always registers). For tests and dependency-injected programs. It isolates the globals only: it does not stop the discovery file's read — a pipeline without an explicit Studio or Local destination still joins a running Studio; NoDiscovery stops that read alone, NoEnv every environment destination.
func OTLP ¶
func OTLP(endpoint string, opts ...DestOption) Option
OTLP exports to any OTLP/HTTP endpoint (http(s)://host:port). Content off, traces + logs, SDK default batching; Headers adds request headers.
func RawBodies ¶ added in v0.14.0
RawBodies records each model call attempt's wire exchange — the provider request and response the first-party adapters sent and received, headers included minus the credential ones, each body capped at 256 KiB — as one weft.raw record per attempt (ADR 0028 §12), for the Local and Studio destinations only (never Datadog, Langfuse, OTLP or Exporters, whatever their content setting, and not a Local or Studio destination under NoContent). Off by default, and refused outside development: it takes effect only when the pipeline starts under the dev gate — WEFT_ENV is "dev" (read unless NoEnv), or DevMode attests it in code (under NoEnv, or with WEFT_ENV unset; an explicit WEFT_ENV other than dev wins, "otel: DevMode ignored: WEFT_ENV=<value>"). Otherwise Start logs one WARN line naming the cause ("otel: RawBodies ignored: WEFT_ENV is not dev", or under NoEnv without DevMode "otel: RawBodies ignored: otel.NoEnv reads no WEFT_ENV") and the pipeline behaves as if the option were absent: no destination asks for weft.raw, so the adapters buffer nothing (core.Reporter.WantsRaw is false). The gate is the pipeline's LoggerProvider marker (LoggerProvider.RawBodies): pass Pipeline.LoggerProvider() to weft.LoggerProvider, or rely on Install's global registration — a stock SDK provider never records raw bodies. Under it a Studio destination's log batch is capped at 16 records and each export is split at 12 MiB, keeping every post under Studio's 16 MiB ingest limit: a raw record is ≈ 650 KiB with two full ordinary bodies and at worst ≈ 3 MiB (bodies of control bytes, each six bytes in JSON; it is encoded without HTML escaping).
func Resource ¶
func Resource(r *sdkresource.Resource) Option
Resource merges r over the detected resource.
func Sampler ¶
Sampler sets the trace sampler (default ParentBased(AlwaysOn)). Records are log records; sampling never applies to them.
func Slog ¶ added in v0.17.0
func Slog() Option
Slog bridges the program's default logger: once the pipeline is registered, slog.SetDefault wraps the default's handler with SlogHandler (bound to this pipeline), so every slog.Default line — weft.Logger's run lines (their default is slog.Default, read at log time), mw.Log(nil), mw.Audit(slog.Default()), the app's own — that is logged under a span reaches the pipeline's app-log destinations (AppLogs) as well as where it went before. Shutdown restores the previous default and the log package's writer and flags (unless the program replaced the default since).
A program still on slog's built-in default handler (it never called slog.SetDefault) keeps its lines on the log package's writer, written by a slog.TextHandler at the level slog.SetLogLoggerLevel set (INFO unless changed; read once, at Install), each line after log.Prefix() — wrapping the built-in handler itself would deadlock the log package. The format changes while the bridge is installed: the TextHandler's key=value lines (time=… level=… msg=…) replace the built-in "2009/11/10 23:00:00 INFO msg k=v" form, and the log package's flags no longer shape them; both come back at shutdown.
One Slog pipeline at a time: a second Install(…, Slog()) wraps the first's bridge. Shut them down in reverse order; out of order, the last shutdown still skips a closed pipeline's wrap and restores what the first found. Under NoGlobal (Start only) the default is left alone, like the OTel globals; bind a handler with Pipeline.SlogHandler instead.
Example ¶
Slog bridges slog.Default: weft's own Debug run lines (weft.Logger's default is slog.Default) and mw.Log's (whose nil logger is too) reach the pipeline's Local and Studio destinations, each under the span it was logged in, and read back as the run's app logs — what Studio's runs/{id}/logs serves.
package main
import (
"context"
"fmt"
"log"
"os"
"path/filepath"
"github.com/weftgo/weft"
"github.com/weftgo/weft/mw"
"github.com/weftgo/weft/obsdb"
"github.com/weftgo/weft/obsdb/sqlite"
"github.com/weftgo/weft/otel"
"github.com/weftgo/weft/wefttest"
)
func main() {
dir, err := os.MkdirTemp("", "weft-otel-slog-")
if err != nil {
log.Fatal(err)
}
defer func() { _ = os.RemoveAll(dir) }()
path := filepath.Join(dir, "weft.db")
shutdown := otel.Install(otel.NoEnv(), otel.Local(path), otel.Slog())
agt := weft.New(wefttest.Script(wefttest.Say("all done")),
weft.Name("example"), weft.WrapModel(mw.Log(nil)))
res, err := agt.Generate(context.Background(), weft.Prompt("go"))
if err != nil {
log.Fatal(err)
}
shutdown() // flushes, and puts slog.Default back
db, err := sqlite.Open(path)
if err != nil {
log.Fatal(err)
}
defer func() { _ = db.Close() }()
page, err := db.OtherLogs(context.Background(), res.ID, obsdb.LogQuery{})
if err != nil {
log.Fatal(err)
}
for _, l := range page.Logs {
fmt.Println(obsdb.SeverityText(l.Severity), l.Body)
}
}
Output: DEBUG run start DEBUG model request DEBUG model finish DEBUG model call DEBUG run finish
func Studio ¶
func Studio(url, token string, opts ...DestOption) Option
Studio exports OTLP/HTTP protobuf to a Weft Studio with a bearer token. Content on, traces + logs, short batches (logs 200 ms, spans 1 s). Naming a Studio here switches the discovery file off (as naming a Local does): the explicit destination is the pipeline's one Studio from the file's point of view (WEFT_STUDIO_URL still adds its own, de-duplicated).
type Pipeline ¶
type Pipeline struct {
// contains filtered or unexported fields
}
Pipeline is the running fan-out: one TracerProvider and one LoggerProvider, one processor chain per destination, one run tracker. Create it with Start (or Install, which never fails).
func Start ¶
Start builds and starts the pipeline: one provider pair, the run tracker, one processor chain per destination (explicit options and environment combined, de-duplicated with the explicit destination winning). It fails if any destination fails to build — what was already built is shut down again, exporters handed to Exporters included; NoGlobal leaves the OTel globals alone (tests).
func (*Pipeline) ForceFlush ¶
ForceFlush flushes every destination, all at once: each destination's span and log chains flush on their own goroutines under ctx, so a slow or hung destination costs only its own data, never the budget of the one behind it (flushed through the providers they go one after another).
func (*Pipeline) LocalDB ¶
LocalDB returns the pipeline's local obsdb.DB — the handle setup A shares with Studio (studio.DB(otel.LocalDB())). nil without a Local destination.
The spec's S2.1 names this accessor otel.Local(), which cannot coexist with the Local(path) destination option in Go (one package, one name); LocalDB is the recorded deviation — see weft-otel-build/notes-lane-a2.md.
func (*Pipeline) LoggerProvider ¶
func (p *Pipeline) LoggerProvider() *LoggerProvider
LoggerProvider returns the pipeline's logger provider: the SDK's, carrying the raw-record marker (RawBodies).
func (*Pipeline) Shutdown ¶
Shutdown runs S2.4's order: stop heartbeats → flush every destination in parallel (5 s budget) → shut down the providers → close the local DB last. Every wait in it is bounded by ctx: a destination stuck in a write (the Local sink behind another process's lock, a hung exporter) costs the caller its context, never more. Shutdown runs once; a concurrent or later call returns the first's result, or its own ctx's error should that come first.
func (*Pipeline) SlogHandler ¶ added in v0.17.0
SlogHandler is the package-level SlogHandler bound to this pipeline rather than the installed one — for NoGlobal pipelines and tests. It marks the pipeline as bridged (weft.logs.bridged) from now on.
func (*Pipeline) StudioEndpoint ¶
StudioEndpoint returns the pipeline's Studio destination (url and token), which weft/runtime dials by default. "" without one.
func (*Pipeline) TracerProvider ¶
func (p *Pipeline) TracerProvider() *sdktrace.TracerProvider
TracerProvider returns the pipeline's tracer provider.