The Go package
tracepad is a thin layer of ergonomics over the OpenTelemetry Go SDK. It
owns no transport, no batching, no retry and no context propagation — those
are the OTel SDK's — and it wraps no provider client. Everything it does is
reachable with go.opentelemetry.io/otel and curl, which the rest of these
docs keep showing; what it adds is the one call that points an application at
a store, and the handful of shapes a person writes the same way every time, in
the shape Go gives them: a context.Context in, a context.Context out.
Go 1.25 or newer — the floor the OTel SDK sets. Four dependencies:
go.opentelemetry.io/otel, go.opentelemetry.io/otel/trace,
go.opentelemetry.io/otel/sdk and the OTLP/HTTP trace exporter. It is a nested module, so go get brings none of the server's
dependencies with it.
The module installs no command.
tracepadon yourPATHis the server binary; the CLI is cli.md.
Init
import tracepad "github.com/tracepad/tracepad/sdk/go"
shutdown, err := tracepad.Init(ctx)
if err != nil {
log.Fatal(err)
}
defer shutdown(ctx)
| Option | Environment | Meaning |
|---|---|---|
WithHost |
TRACEPAD_URL |
Where the store is, e.g. http://localhost:4318 |
WithKey |
TRACEPAD_API_KEY |
A secret key (tp-sk-…), sent as Bearer |
WithEnvironment |
TRACEPAD_ENVIRONMENT |
The deployment this process is |
WithRelease |
TRACEPAD_RELEASE |
The version of this deployment — per trace, WithTraceVersion |
WithExport(false) |
— | Attaches everything except the exporter |
WithExportTimeout(time.Duration) |
TRACEPAD_EXPORT_TIMEOUT (seconds) |
How long one export may take, its retries included; five seconds by default |
WithLogger |
— | Where the tracing path says what it could not do; slog.Default() otherwise |
WithTracerProvider |
— | Attach to this provider instead of the global one — for tests |
The options win over the environment, and without a host or without a key the
call returns ErrConfig — misconfiguration discovered as a 401 in a log
file an hour later is the bug report that rule prevents.
TRACEPAD_URL is the same variable the CLI and the MCP server read; the package's first name for it, TRACEPAD_HOST, still works, warns once, and loses to TRACEPAD_URL.
Standard OpenTelemetry variables (OTEL_SERVICE_NAME,
OTEL_RESOURCE_ATTRIBUTES, the batch processor's own limits) are honoured by
the OTel SDK as they are; the package neither reads them nor sets them.
WithExportTimeout bounds one export, its retries included.
OpenTelemetry's default is ten seconds for one attempt, retried for up to a
minute — long for a request that flushes before it answers — so the package's
is five, set both as the exporter's timeout and as the batch processor's
export timeout, which is what bounds the retries. With neither the option nor
TRACEPAD_EXPORT_TIMEOUT, OTEL_EXPORTER_OTLP_TRACES_TIMEOUT keeps working
underneath when it is set, with the OTel SDK's meaning: the bound of one
attempt; an OTEL_BSP_EXPORT_TIMEOUT set by the operator keeps bounding the
batch processor's export. A TRACEPAD_EXPORT_TIMEOUT that is not a number of seconds, or a
negative WithExportTimeout, is ignored with a warning, and so is the option
under WithExport(false), which adds no exporter to bound.
Init adapts to the provider it finds. If the application has already set
a global TracerProvider — otelhttp, otelgrpc, another SDK — the call
registers a batching OTLP/HTTP exporter on that provider. Our spans and
theirs then share one pipeline, one flush and one trace. Only when the global
provider is still the API's no-op default does Init build one and set it,
with service.name from OTEL_SERVICE_NAME or the executable's name, the
environment and the release on its resource, and a TraceContext propagator.
Two consequences worth knowing:
- Under a provider somebody else built,
WithEnvironmentandWithReleaseare refused with a warning: a resource is fixed when its provider is, andservice.versionis read from the resource only. SetOTEL_RESOURCE_ATTRIBUTES=deployment.environment.name=prod,service.version=1.4.0instead. - If the application already exports to Tracepad through another SDK, pass
WithExport(false). Otherwise every span arrives twice; it is upserted once and nothing is lost, but the bytes are wasted.
A second Init is a no-op with a warning. The returned shutdown flushes
the scores and the spans and, when Init built the provider, shuts it down;
a provider it adopted is the application's to close. Call it where the
application closes the rest — Go has no exit hook, and every server already
has that place.
Which key
The package sends one key for everything it does, so the key's scopes decide which calls go through:
| Calls | The key needs |
|---|---|
The exporter, Score, Prompt |
ingest |
DeleteTrace, DeleteTraces |
write |
| The eval harness: datasets, runs and their scores | ingest, read, write |
A production application needs nothing but ingest, so the key to set as its
TRACEPAD_API_KEY is one minted with that scope alone
(admin.md); the harness runs under a key of its own that holds
all three. A call the key does not cover is the server's 403, message
included — this key's scopes are ingest; DELETE /api/v1/traces needs write —
an *HTTPError from the calls that return errors, and a logged, dropped batch
from the exporter and the score queue, as a 401 is.
Steps
func answer(ctx context.Context, question string) (string, error) {
ctx, step := tracepad.Span(ctx, "answer", tracepad.WithInput(question))
defer step.End()
documents, err := retrieve(ctx, question)
if err != nil {
step.Fail(err)
return "", err
}
step.Update(tracepad.WithOutput(documents), tracepad.WithMetadata(map[string]any{"hits": len(documents)}))
...
}
Span opens a span under the context's current span and returns the context
carrying it — the OTel shape, so retrieve above starts its own spans as
children whether it uses this package, otelhttp or a bare tracer. The
caller ends it: defer step.End() is the idiom, and step.Fail(err) records
the error as an OTel event, sets the status to ERROR and ends — it exists
because a defer cannot see the error the function is about to return. A
second End does nothing, so the two lines above coexist.
| Option | Meaning |
|---|---|
WithInput(any) |
The step's input. A string is sent as it is; anything else as JSON |
WithMetadata(any) |
Free metadata: a map, or anything that encodes as a JSON object. Written one attribute per top-level key, so an Update adds its keys and replaces only its own |
WithType(string) |
One of the ten kinds; "span" by default, and an empty one keeps the default. Another spelling is sent all the same and warned about once. It sets the kind written, not the shape: the function names that, so a generation is opened with Generation (a *Call, with End(Result)) and an event with Event — Span with WithType("generation") is a plain step stored as a generation without a model. On Generation it types a model call of another kind, WithType("embedding") |
What a step costs when nothing records. A span starts with the cheap
attributes only — its kind, the model, the prompt reference — which is what a
sampler or a span processor sees. Input, output, metadata and model
parameters, everything serialised to JSON, are set after the span exists and
only if it records: with tracing off, or under a sampler that dropped the
span, none of them is serialised at all, in the opening call, in Update and
in End alike.
Metadata merges by key. It is written one attribute per top-level key,
tracepad.observation.metadata.<key> — a string, a number or a boolean as it
is, anything else as JSON — so Update(ctx, WithMetadata(map[string]any{"flag": 1}))
adds flag and replaces only it, and the keys the step opened with stay. A
key with a nil value — a nil pointer, map or slice as well — writes
nothing; it does not delete the key, since an attribute cannot be unset once
written. A struct's numbers keep the integers they are. Metadata that encodes
as no JSON object at all is written whole under
tracepad.observation.metadata. More than 32 keys in one write, or an empty key, are written whole under
tracepad.observation.metadata as well: a span holds 128 attributes by
default, and a full one drops the step's own. The store merges the whole
object with the per-key entries, and a per-key entry wins — so a large write
does not replace a key written on its own before it, and a second large write
replaces the first whole: give a step its large metadata once, when it opens,
and update a few keys at a time after. An unsigned integer past
math.MaxInt64 is its exact decimal string. A string whose text is a JSON object or array
is stored as that structure, as every per-key metadata is read.
There is no function wrapper like Python's @observe: Go has no decorators
and no way to capture a function's arguments by name, so the input is what
you hand WithInput. Payloads are serialized with encoding/json (HTML
escaping off); a value the encoder cannot express — a channel, a cycle — is a
string in the trace naming its type and the reason, rather than an error in
your function.
There is no client-side size cap. OTEL_SPAN_ATTRIBUTE_VALUE_LENGTH_LIMIT is
the OTel SDK's knob, and the server's own TRACEPAD_MAX_BODY_BYTES cut marks
the payload as every payload is marked.
The zero-duration kind:
_, miss := tracepad.Event(ctx, "cache.miss", tracepad.WithMetadata(map[string]any{"key": key}))
miss.End()
Span, Event and Generation hand out a handle carrying TraceID(),
SpanID(), Update(...), End(), Fail(err) and the OTel span itself as
Span(). With no trace behind the span — tracing off: Init never called and
no provider of the application's own — the two ids are "", not a string of zeros, as Attempt.TraceID() is before
anything has run.
Generations
ctx, call := tracepad.Generation(ctx, "chat",
tracepad.WithModel("gpt-4o-mini"),
tracepad.WithModelParameters(map[string]any{"temperature": 0.2}),
tracepad.WithInput(messages))
response, err := client.Chat.Completions.New(ctx, params)
if err != nil {
call.Fail(err)
return err
}
call.End(tracepad.Result{
Model: response.Model,
Usage: tracepad.Usage{"input_tokens": response.Usage.PromptTokens, "output_tokens": response.Usage.CompletionTokens},
Output: response.Choices[0].Message.Content,
})
End takes an explicit Result, and there is no reader over a provider's
response: Go has no dominant client with one response shape — openai-go,
anthropic-sdk-go, go-openai and a raw net/http caller each hand back a
different struct — and five lines at the call site cannot silently read the
wrong field.
| Field | Written as |
|---|---|
Model |
gen_ai.response.model |
Usage (map[string]int64) |
every entry as gen_ai.usage.<key>, verbatim — input_tokens, output_tokens, cache_read_input_tokens, cache_creation_input_tokens, reasoning_tokens are the names the store charts |
Cost (*float64) |
gen_ai.usage.cost |
Output |
the output |
The cost is the one you were charged. There is no price table here and none
in the store: OpenRouter puts the charge in the response's usage.cost, and a
provider that does not report one leaves Cost nil — the UI then shows no
data, never $0. Every field is optional and an absent one is absent from
the span; call.End(tracepad.Result{}) ends the span with what it has.
call.FirstToken() stamps the completion start the moment the first chunk
arrives — that is where the TTFT column comes from. Only the first call counts.
In a stream loop it is the three lines that stay:
stream := client.Chat.Completions.NewStreaming(ctx, params)
var text strings.Builder
for stream.Next() {
chunk := stream.Current()
if len(chunk.Choices) > 0 {
call.FirstToken()
text.WriteString(chunk.Choices[0].Delta.Content)
}
}
if err := stream.Err(); err != nil {
call.Fail(err)
return err
}
call.End(tracepad.Result{Model: model, Output: text.String()})
Whether the stream carries usage is the provider's choice, and the package
cannot make it: ask for it (stream_options.include_usage on an
OpenAI-compatible client) and fill Usage from the last chunk.
The trace around a step
A request handler rarely holds the root span — the framework does — and the one thing it knows is who the user is:
tracepad.UpdateTrace(ctx, tracepad.WithTraceName("support-chat"), tracepad.WithUserID("u-42"),
tracepad.WithSessionID("s-7"), tracepad.WithTags("support"), tracepad.WithTraceMetadata(map[string]any{"channel": "web"}),
tracepad.WithTraceVersion("retrieval-v2"))
tracepad.Update(ctx, tracepad.WithLevel("WARNING"), tracepad.WithStatusMessage("retried once"))
Both act on the context's current span, whoever started it, and the store
resolves the trace-level ones for the trace. Calls on one span add up: the tags
are merged in the order they first appeared, without repeats, and
WithTraceMetadata is merged by key across calls, the later value winning — the
package keeps what it has written on the span, under a lock, and writes the one
array and the one object whole each time, so concurrent calls lose nothing. Only
what this package wrote is merged: tags another writer set on the same span are
not readable through the OpenTelemetry API and are replaced by the first call.
Tags are bounded at 50 and metadata at 512 keys and 1 MiB, as the server bounds
them, and the first time each bites the package logs a warning. Values are
encoded when the call is made, so changing one afterwards changes nothing in the
trace, and metadata that is not an object — a string of JSON included — is
ignored with a warning. Update takes WithName,
WithInput, WithOutput, WithMetadata, WithLevel, WithStatusMessage and
WithType. Outside every span, in a process that traces, both log a warning
and do nothing — that call is a mistake. On a span that does not record —
tracing off, or a sampler's choice — they do nothing with a debug line: that
is configuration, and a warning on every call would teach an operator to
ignore the package's warnings.
UpdateTrace option |
Meaning |
|---|---|
WithTraceName(string) |
The trace's name |
WithUserID(string) |
Who the trace is for |
WithSessionID(string) |
The session it belongs to |
WithTags(...string) |
Its tags, merged with those of earlier calls on the span |
WithTraceMetadata(any) |
Free metadata on the trace, a JSON object, merged by key across calls |
WithTraceVersion(string) |
The version of this trace's own logic |
WithTraceVersion is the version of this trace's logic — a pipeline
revision, a prompt bundle, an experiment arm — and the trace listing filters on
it (version=). It sits beside WithRelease, the deployment's version set
once at Init: two arms running in one release are told apart by the version.
Scores
tracepad.Score(ctx, "helpful", tracepad.WithValue(0.9), tracepad.WithComment("cited the source"))
tracepad.Score(ctx, "verdict", tracepad.WithStringValue("pass"), tracepad.WithDataType("categorical"))
tracepad.Score(ctx, "grounded", tracepad.WithValue(1), tracepad.WithDataType("boolean"), tracepad.OnObservation())
With no target given, the target is the trace of the context's span — and its
observation too with OnObservation(). Outside a span, with no
WithTraceID, the call returns ErrNoTrace: a score that silently went
nowhere is the failure this API is worst at surfacing, and so is an empty
WithTraceID — an id stored while tracing was off, say. Where nothing traces —
no Init, a no-op global provider, no recording span in the context — the same
call returns nil and logs a debug line instead: every span is a no-op there,
and the call site is not wrong. A WithTraceID is scored either way.
Score does not call the server. It enqueues, and a goroutine posts
POST /api/v1/scores in batches of up to 100 every two seconds.
A rejected batch is retried once, then logged with the server's own message —
which names the offending item — and dropped: a scoring failure must not fail
the request that produced the trace. Pass WithID for the
idempotency the API offers.
The server records the key the package sends with as the score's
author: give the key a name that says which
program it is, and put anything finer — the judge's model, the run — in the
score's metadata.
tracepad.Flush(ctx) drains the queue and then the span processors; the
shutdown returned by Init does the same before it closes anything. Both
keep to the context's deadline: against a store that never answers, Flush
returns context.DeadlineExceeded when the context ends, and the export goes
on under its own timeout.
Prompts
support, err := tracepad.Prompt(ctx, "support-answer", tracepad.WithLabel("production"))
if err != nil {
return err
}
messages := support.Compile(map[string]any{"product": "Tracepad"}).Messages
ctx, call := tracepad.Generation(ctx, "chat", tracepad.WithPrompt(support),
tracepad.WithModel(support.Config["model"].(string)))
The value is a PromptVersion carrying Name, Version, Type, Text or
Messages, Labels and Config. Compile(vars) substitutes {placeholder}s
— in the text, or in every message's content — and hands back a Compiled
with Text or Messages filled, whichever the prompt is; {{ and }} are
literal braces, and a placeholder vars does not name is left as it is.
Nothing else: a template language is a product, and what the store stores is
plain text.
Passing the prompt to Generation records which prompt ran, so the trace can
be filtered by it.
The answer is cached per (name, label | version) for as long as the server
said — Cache-Control: max-age=60 on
every prompt — so a label move reaches the
application within a minute of being made. When the store is away, the last
answer is served stale with a warning, because a restart of your observability
must not take your chat down. With nothing cached the call returns the error:
a fallback prompt baked into the code is a prompt the trace cannot name. A
script that only fetches a prompt need not call Init: the environment is
read on the first call.
Deleting traces
filter := tracepad.TraceFilter{To: time.Now(), Environment: "loadtest"}
preview, err := tracepad.DeleteTraces(ctx, filter, "")
if err != nil {
return err
}
log.Println(preview["matched"], preview["would_delete"])
total, err := tracepad.DeleteTraces(ctx, filter, "checkout-service")
if err != nil {
return err
}
log.Println(total["deleted"], total["rounds"])
if id := step.TraceID(); id != "" { // "" with tracing off
_, err = tracepad.DeleteTrace(ctx, id, true)
}
A script's door to what traces rm does: an eval harness that
exported under the wrong key, a test suite that wants its traces gone before
the next run. Both calls are a dry run until confirmed, the way every
destructive act in the store is, and both return *HTTPError for what the
API refuses — a 404 for an unknown id, a 400 for a wrong echo or a filter
name it does not know.
DeleteTrace(ctx, id, confirm bool) takes one trace with everything attached
to it. With false the answer is the preview — would_delete,
affected_runs, confirm, note; with true the package sends the id as
the echo, because it has the id in hand, and the answer says what went.
DeleteTraces(ctx, filter, confirm string, ...DeleteOption) takes every
trace the TraceFilter matches — one field per
listing filter, Tag a slice, From and To a
time.Time sent as UTC — before To, which is required so that the set is
closed: a zero To is an error before any request. With an empty confirm
it is one dry run and the answer is the API's preview, matched counted
exactly. With the project's name — a name you type, since the package does
not know it — it deletes in rounds of at most WithRoundLimit traces (1000
by default), each given sixty seconds, repeats while the API says there is
more, and answers one total, deleted with traces, observations, scores, payloads and
annotation_items, and rounds — float64s, as in every answer the package
decodes. A filter that matches nothing is one round of zero, not an error.
Raw OTLP bodies are not touched — a raw batch holds many traces of many
kinds, and a trace cannot be cut out of one — and the preview says so in its
note. What goes, what stays and the ingest race are in
admin.md.
Evals
The package is also the harness of datasets.md: a dataset
value, a run that pins a version, and a context inside which every span
carries the run and the case it answered. It runs nothing — the cases are
your program. Its key holds ingest, read and write
(Which key).
golden := tracepad.NewDataset("support-golden")
golden.PutItems(ctx, cases) // same cases → same version, nothing written
run, err := golden.Run(ctx, "prompt v7", tracepad.WithRunMetadata(map[string]any{"prompt": "support-answer@7"}))
if err != nil {
return err
}
for item, err := range golden.Items(ctx, run.DatasetVersion) {
if err != nil {
run.Fail(ctx, err)
return err
}
itemCtx, attempt := run.Item(ctx, item)
answer := app.Answer(itemCtx, item.Input.(map[string]any)["question"].(string))
attempt.Score(ctx, "accuracy", tracepad.WithValue(judge(answer, item.ExpectedOutput)))
}
summary, err := run.Finish(ctx)
| Name | What it is |
|---|---|
NewDataset(name) |
A *Dataset. No request is made here — it is a name. |
Dataset.Create(ctx, description, metadata) |
Create it, or replace those two. |
Dataset.PutItems(ctx, items) |
One batch, one version tick → (version, changed, err). A longer list than the 10,000 items one request takes goes as consecutive writes of 10,000, each ticking the version if it changes anything: the answer is the last version and the sum of the changes, a write that fails leaves the ones before it in place, and an id the list gives twice is refused before anything is sent. An index in the server's message about a write that fails counts from the first item of that write, not of the list. Items are counted, not bytes: a write of 10,000 large items can still be over TRACEPAD_MAX_BODY_BYTES and be refused with 413, and such a list has to be cut into smaller ones by hand. |
Dataset.Items(ctx, version) |
An iter.Seq2[Item, error] over every page, whole; tracepad.CurrentVersion for the dataset as it is now — 0 is a version, the one before the first item. |
Dataset.Run(ctx, name, opts…) |
Opens a *Run; WithRunMetadata, WithRunID, WithDatasetVersion. |
Dataset.Runs(ctx), Run.Get(ctx), Run.Items(ctx, opts…), Compare(ctx, a, b) |
The server's JSON as map[string]any — no number is computed here. A run's items inline their payloads and are budget-checked, so that listing pages at the server's own size unless WithLimit names one; WithUnknown adds the traces that link to no case. |
Dataset.Delete(ctx, confirm) |
The name must be echoed, as the API asks; an empty confirm is the API's dry run. |
ScoreConfigs(ctx, configs), ScoreConfig |
PUT each, in order, synchronously. |
ItemID(key) |
sha256(key)[:32], for a natural key of your own. |
The run pins a version, and run.DatasetVersion is it. Fetching by that
number rather than by "the current one" is what makes the version the harness
fetched and the version it ran one number.
A run is closed by Finish or Fail, and there is no block to close it
for you: a run left running is reported as such forever, so the Fail on
the error path is yours to write. Both flush the scores and then the spans
before they post, so Get on the next line is over everything the run
produced; a late span still links, so a flush that ran out of time is a
number read early rather than a trace lost, and the close still posts.
run.Item(ctx, item) opens no span of its own. It returns a context
carrying the run and the item, which a span processor reads at every span's
start — so the root span may be the framework's, another SDK's or Span's,
and it is still stamped. That also says exactly how far the context reaches,
because in Go a context is the block:
| Where the work runs | Stamped |
|---|---|
| a call handed the context, and anything it calls with it | yes |
| a goroutine handed the context | yes |
a goroutine started with context.Background() |
no — it has no context to inherit |
a span started with a context from before Item |
no |
| another process, over HTTP | no — see below |
| the loop itself already inside a span of yours | stamped, but see below |
Do not trace the harness. If the loop runs inside a span of your own —
an instrumented test runner, a Span around the loop — then the case's spans
are children of it, and one trace covers the whole run. Everything is still
stamped and attempt.Score still has a trace to score (the context's first
span starts the case, not the trace's root), but the run then has one trace
for every case, and a trace links to one item: its coverage collapses to the
last case stamped. A case wants a trace of its own.
The two attributes do not travel with the trace context, by design. For a
service the context cannot reach, attempt.Attributes() is the map to
forward by your own means — a header, a field in the request.
attempt.Traces() is every root span that started inside the context, in
order — a case run three times is three traces of one item, and the run's
summary counts them all — and attempt.TraceID() is the last of them, which
is what attempt.Score scores. Scoring before anything has run is
ErrNoTrace; after the loop moves on, the Attempt is a record, and a span
started from a context taken before Item is not stamped.
Init with WithExport(false) still registers the processor: an
application exporting through another SDK wants its spans stamped all the
same. Without Init at all, the REST calls above work — the environment is
read on the first one — but nothing registers the processor, so nothing is
stamped and the run covers no case; the first run.Item says so in the log.
Testing your instrumentation
Package tracepadtest is what a test suite uses to check that the code
traces: that a step is a span with the right name, a model call a generation
with its usage, a thumbs-up a score. It is httptest's idiom — the helpers
take the test and register their own cleanup.
import "github.com/tracepad/tracepad/sdk/go/tracepadtest"
func TestTheAnswerIsTraced(t *testing.T) {
rec := tracepadtest.Capture(t)
answer(context.Background(), "why is the sky blue")
if got := rec.Attributes(t, "answer")["tracepad.observation.type"].AsString(); got != "span" {
t.Errorf("type = %q, want span", got)
}
if got := rec.Scores(); len(got) != 1 || got[0]["name"] != "helpful" {
t.Errorf("scores = %v", got)
}
}
Capture resets everything the package keeps process-wide and initialises
the package against a provider that records into memory, with export off and
nothing read from the environment; the score queue keeps each body instead of
posting it. The global provider follows the capture's, so a package-level
otel.Tracer("app") records into every capture, not only the first. The
test's cleanup — on t.Fatal too — resets the process again, and what was
captured stays readable, from a parent test after its subtest too.
On a *Recorder |
What it is |
|---|---|
Spans() |
Every finished span, in the order it ended — the SDK's own tracetest.SpanStubs |
One(t, name) |
The single span of that name; the test fails naming the spans there were otherwise |
Attributes(t, name) |
That span's attributes, by key |
Scores() |
The bodies Score would have posted: name, trace_id, value and the rest |
tracepadtest.Reset(t) returns the process to one that never called Init —
tracing off, as above — for a test of the code with tracing off:
Score returns nil, and the ids are empty. A reset shuts down a provider
Init built, takes back the propagator it set and drops the scores still
queued; an Init after it builds a provider as it would in a fresh process.
Nothing is sent: the placeholder host is tracepad.test, which does not
resolve, so a REST call a test forgot to stub — Prompt, a dataset,
DeleteTrace — fails rather than reaching a store. A provider your
application sets at start-up is replaced for the capture and not restored
after it. The capture is process-wide, so it refuses a parallel test: called after
t.Parallel, the helper fails the test saying why, and a t.Parallel after
the helper panics in the testing package.
What fails and what does not
| Path | On failure |
|---|---|
Init after configuration, Span, Generation, Update, End, the exporter, the score queue |
Logged through slog (WithLogger, or the default logger); never a panic, never an error into your code |
Prompt, Flush, shutdown, DeleteTrace, DeleteTraces, and every call of the harness above |
An error: *HTTPError{Status, Body} for a non-2xx answer, the transport's own otherwise |
Init with no host or key |
ErrConfig, wrapped with what is missing |
Score with no target — or an empty WithTraceID — outside every span while tracing: initialised, or through a provider of the application's own |
ErrNoTrace — a programming error, visible at the call site |
Score with no target where nothing traces (no Init, no-op global provider, no recording span) |
nil; a debug line — tracing is off |
Update, UpdateTrace on a span that does not record (tracing off, or sampled out) |
Nothing; a debug line |
A name that is empty, . or .. |
An error, before any request — no name the store accepts is one |
Both sentinels are for errors.Is, and *HTTPError for errors.As.
A REST call follows a redirect of a GET with the key only within the store's
origin — the same scheme, host and port; anywhere else it goes without
Authorization, as fetch has it. A write is never re-sent: its redirect is an
error that names where it pointed. The store itself never redirects, so
WithHost or TRACEPAD_URL should be the address it answers on.
Instrumentation that can break the function it observes is worse than none.
What it writes
The OTel GenAI semantic conventions where a name exists, and tracepad.* where
none does — a trace name, tags, free metadata, a trace version, an observation
kind, a prompt reference. The whole table is in
ingest.md; the tracepad.*
half is its own section. It is the same
vocabulary the Python package writes, and the
fixture testdata/otlp/014-tracepad-sdk-go.pb — written by the package's own
exporter — pins it.
What it does not do
- No provider-client wrapper and no auto-instrumentation. A wrapper per provider is a release per provider release. The OpenTelemetry GenAI instrumentations are the answer, and they work because the transport is shared: point them at the same endpoint and their spans join yours.
- No response reader and no stream wrapper. An explicit
Resultis five lines at the call site, and it cannot read the wrong field. - No price table, and no prompt templating beyond
{placeholders}. - No client type. One default the package keeps, configured by
Init;WithTracerProvideris the one per-instance knob, for tests. - The harness runs nothing. No judge, no retries, no worker pool and no
tracepad eval …command: the loop is your program, and this is the part of it that talks to the store.