Skip to content

telemetry

import "github.com/stablekernel/crucible/telemetry"

Package telemetry is the Crucible suite’s vendor-neutral tracing and metrics interface.

Import path: github.com/stablekernel/crucible/telemetry

telemetry defines a small, stable set of interfaces — Tracer/Span, Meter/Counter/Histogram/Gauge, and the Attr attribute type — that the suite’s IO modules (sink, broker, store) depend on for observability. It imports only the Go standard library and forces no vendor SDK on any consumer. That is the whole point: a consumer brings their own tracing/metrics backend through a thin adapter, and a consumer that brings nothing gets silent, zero-overhead behavior from the built-in no-op defaults.

This is the “thin seams, no-op defaults, no forced dependencies” rule from the suite’s engineering standards, applied to telemetry: the core interface forces no dependency, the default does nothing, and vendor wiring lives in optional, separately-versioned adapter sub-modules.

Tracing:

Tracer.Start(ctx, name, attrs...) -> (ctx, Span)
Span.SetAttributes(attrs...)
Span.RecordError(err)
Span.SetStatus(code, msg)
Span.End()

Metrics:

Meter.Counter(name, opts...) -> Counter.Add(ctx, n int64, attrs...)
Meter.Histogram(name, opts...) -> Histogram.Record(ctx, v float64, attrs...)
Meter.Gauge(name, opts...) -> Gauge.Record(ctx, v float64, attrs...)

Counters are monotonic int64 deltas; histograms and gauges carry float64 samples. Instrument metadata (unit, description) is supplied with the additive InstrumentOption helpers WithUnit and WithDescription.

Attr is an alias for the standard library’s slog.Attr, so attribute values are slog.Value — the stdlib’s allocation-optimized tagged union. Build attributes with the typed constructors re-exported here:

telemetry.String("payload.type", "Order")
telemetry.Int64("size", 100)
telemetry.Float64("latency_ms", 3.2)
telemetry.Bool("retried", true)

The scalar constructors (String, Int64, Int, Uint64, Float64, Bool, Duration, Time) are zero-allocation: slog.Value stores their value inline, with no interface box. Type safety is preserved — an adapter reads Value.Kind and the typed accessors (Value.String, Value.Int64, …) rather than type-switching on any. Any is the documented escape hatch for an arbitrary value; it boxes into an interface and so allocates, so reach for it only when no typed constructor fits.

Tracer.Start returns a context carrying the new span. Propagating that context into nested work is how spans parent: a downstream module’s span, started from the returned context, nests under the caller’s span automatically. This is the only coupling between modules — there is no shared global tracer, no package-level state.

NopTracer and NopMeter return implementations that record nothing, allocate nothing per call, never panic, and are safe to call concurrently and after a span has ended. Provider (see options.go) bundles a Tracer and Meter for a consuming module’s config; Nop returns one wired to the no-op pair, so an unconfigured module is silent by default.

A consuming module embeds a Provider in its config, seeds it with Nop, and exposes WithTracer/WithMeter options:

cfg.tel = telemetry.Nop().Apply(
telemetry.WithTracer(myTracer),
telemetry.WithMeter(myMeter),
)

Adapters translate this interface to a concrete backend and ship as separate, optional sub-modules so the core never imports a vendor SDK:

  • telemetry/slog — a standard-library log/slog adapter (zero external deps) that emits spans and metrics as structured logs. Shipped here; it proves the seam end to end. Because Attr is slog.Attr, this adapter is conversion-free: attributes pass straight to the slog handler.
  • telemetry/otel — shipped. Lives in its own sub-module with its own go.mod that requires the OpenTelemetry SDK. Bridges Tracer/Meter onto an OpenTelemetry trace.Tracer and metric.Meter; Span.SetStatus maps to codes.Ok/Error/Unset; WithUnit/WithDescription are honored via the OpenTelemetry instrument options. Attributes are converted with a switch over Attr.Value.Kind.
  • telemetry/datadog — shipped. Lives in its own sub-module with its own go.mod that requires dd-trace-go / datadog-go. Bridges Tracer onto dd-trace-go spans and Meter onto DogStatsD; Span.SetStatus(StatusError) marks the span errored. Attributes are converted with a switch over Attr.Value.Kind, reading the typed accessor for each scalar kind and Value.Any only for the KindAny escape hatch.

Instrument and span names use dotted lower-snake with a module prefix (sink.sunk, sink.flush_latency_ms, state.transitions). Every module follows this so metrics and traces line up across the suite.

No os.Exit, no log.Fatal, no panic on operational paths. The interface carries no error returns on the hot path; recording telemetry never fails a caller’s operation.

Stability label: experimental (pre-v1).

Package telemetry defines Crucible’s vendor-neutral tracing and metrics interfaces. See doc.go for the package overview.

Example

Example shows how an IO module instruments an operation against the vendor-neutral interface. With no telemetry provided, the no-op default makes every call silent and allocation-free.

package main
import (
"context"
"fmt"
"github.com/stablekernel/crucible/telemetry"
)
func main() {
// A consuming module holds a Provider, defaulted to the silent no-op pair.
// A real consumer would pass telemetry.WithTracer/WithMeter to swap in their
// backend (for example the slog adapter); here we keep the default.
tel := telemetry.Nop().Apply()
ctx := context.Background()
// Trace the operation. The returned context is propagated to nested work so
// downstream spans parent under this one.
ctx, span := tel.Tracer.Start(ctx, "sink.Sink",
telemetry.String("payload.type", "Order"),
)
defer span.End()
// Metric instruments mirror what the sink module emits. Attributes are built
// with the typed constructors — scalars are zero-allocation.
sunk := tel.Meter.Counter("sink.sunk", telemetry.WithDescription("records sunk"))
latency := tel.Meter.Histogram("sink.flush_latency_ms", telemetry.WithUnit("ms"))
sunk.Add(ctx, 1, telemetry.String("outlet", "dynamo"))
latency.Record(ctx, 3.2)
span.SetStatus(telemetry.StatusOK, "")
fmt.Println("emitted")
}
emitted

The typed attribute constructors are re-exported directly from log/slog. Each returns an Attr (= slog.Attr); the scalar constructors are zero-allocation because slog.Value stores their value inline without an interface box.

Any is the explicit escape hatch for an arbitrary value: it boxes into an interface and so allocates. Reach for it only when no typed constructor fits.

var (
// String builds a string attribute. Zero-allocation.
String = slog.String
// Int64 builds an int64 attribute. Zero-allocation.
Int64 = slog.Int64
// Int builds an int attribute (stored as int64). Zero-allocation.
Int = slog.Int
// Uint64 builds a uint64 attribute. Zero-allocation.
Uint64 = slog.Uint64
// Float64 builds a float64 attribute. Zero-allocation.
Float64 = slog.Float64
// Bool builds a bool attribute. Zero-allocation.
Bool = slog.Bool
// Duration builds a time.Duration attribute. Zero-allocation.
Duration = slog.Duration
// Time builds a time.Time attribute. Zero-allocation.
Time = slog.Time
// Any builds an attribute from an arbitrary value. This is the documented
// escape hatch: it boxes the value into an interface and therefore allocates.
// Prefer a typed constructor whenever one fits.
Any = slog.Any
)

Attr is a single key/value telemetry attribute (an OpenTelemetry “attribute”, a Datadog “tag”, a structured-log field). It is an alias for the standard library’s slog.Attr, so its Value is a slog.Value — the stdlib’s allocation-optimized tagged union. Scalar attributes (string, int64, float64, bool, duration, time) are stored inline without boxing, and remain type-safe via Value.Kind and the typed accessors; only Any boxes an arbitrary value into an interface. Adapters read Value.Kind to down-convert to their backend.

Construct attributes with the typed constructors (String, Int64, Float64, Bool, …) rather than building the struct directly. Keep keys to dotted lower-snake with a module prefix (for example “payload.type”, “sink.outlet”); see the package README for the naming convention shared across the suite.

type Attr = slog.Attr

Counter is a monotonic, integer-valued metric. Counts are non-negative; the suite uses int64 deltas because every counted thing (records sunk, failures, drops) is whole-numbered, which keeps adapter mappings exact.

type Counter interface {
// Add increments the counter by n (n >= 0) with the given attributes.
Add(ctx context.Context, n int64, attrs ...Attr)
}

Gauge records the current value of a quantity that rises and falls. It is synchronous: the consumer calls Record whenever the value changes. An observable/callback variant is intentionally omitted from the core to keep the interface small; it can be added additively by an adapter if needed.

type Gauge interface {
// Record sets the gauge's current value to v with the given attributes.
Record(ctx context.Context, v float64, attrs ...Attr)
}

Histogram records a distribution of float64 samples (latencies, sizes). Values are float64 so sub-unit measurements (for example fractional milliseconds) are not truncated.

type Histogram interface {
// Record adds the sample v with the given attributes.
Record(ctx context.Context, v float64, attrs ...Attr)
}

InstrumentConfig is the resolved metadata for an instrument, for adapters to read when constructing their backend instrument.

type InstrumentConfig struct {
// Unit is the instrument unit, or "" if unset.
Unit string
// Description is the human-readable description, or "" if unset.
Description string
}

func ResolveInstrument(opts ...InstrumentOption) InstrumentConfig

ResolveInstrument applies opts and returns the resolved InstrumentConfig. Adapters call this to honor WithUnit/WithDescription uniformly.

InstrumentOption configures a metric instrument at creation time. Options are additive: unknown-to-an-adapter metadata is simply ignored, never an error.

type InstrumentOption func(*instrumentConfig)

func WithDescription(desc string) InstrumentOption

WithDescription sets a human-readable description of the instrument.

func WithUnit(unit string) InstrumentOption

WithUnit sets the instrument’s unit (for example “ms”, “By”, “{record}”), following the UCUM conventions OpenTelemetry uses.

Meter creates metric instruments. Instruments are identified by name; calling the same constructor with the same name and options is expected to return an equivalent instrument (adapters may cache and return the same handle). Pass instrument metadata (unit, description) via InstrumentOption.

type Meter interface {
// Counter returns a monotonic int64 counter. Counters only ever increase;
// for values that rise and fall, use Gauge.
Counter(name string, opts ...InstrumentOption) Counter
// Histogram returns a float64 distribution instrument for recording sampled
// values such as latencies or sizes.
Histogram(name string, opts ...InstrumentOption) Histogram
// Gauge returns a synchronous float64 gauge for a value that rises and falls
// (for example the number of instances currently in a given state). The
// caller sets the current value via Gauge.Record at the moment it changes.
Gauge(name string, opts ...InstrumentOption) Gauge
}

func NopMeter() Meter

NopMeter returns a Meter whose instruments do nothing. It is the default Meter for any consumer that provides none. The returned Meter is safe for concurrent use.

Option mutates a Provider. Modules can re-export these directly, or wrap them in their own option type when their config holds more than telemetry.

type Option func(*Provider)

func WithMeter(m Meter) Option

WithMeter sets the Provider’s Meter. A nil meter is ignored, preserving the no-op default.

func WithTracer(t Tracer) Option

WithTracer sets the Provider’s Tracer. A nil tracer is ignored, preserving the no-op default rather than introducing a nil that call sites would have to guard.

Provider bundles a Tracer and a Meter. A consuming module keeps one in its config; the zero value is not ready for use — seed it with Nop and apply options. Provider always holds non-nil interfaces after Nop, so call sites never need nil checks.

type Provider struct {
Tracer Tracer
Meter Meter
}

func Nop() Provider

Nop returns a Provider wired to the no-op Tracer and Meter. Use it as a module config’s default so unconfigured telemetry is silent and allocation- free.

func (p Provider) Apply(opts ...Option) Provider

Apply returns a copy of p with opts applied. It never mutates the receiver, so a module can keep an immutable default Provider and derive per-construction copies from it.

Span is a single traced operation. It is obtained from Tracer.Start and must be ended exactly once. All methods MUST be safe to call on a span returned by a no-op tracer and after End has been called.

type Span interface {
// SetAttributes adds or overwrites attributes on the span.
SetAttributes(attrs ...Attr)
// RecordError records err as an error event on the span. It does not itself
// set the span's status; call SetStatus(StatusError, …) for that when the
// error is terminal for the operation.
RecordError(err error)
// SetStatus sets the span's outcome. The final call wins.
SetStatus(code StatusCode, msg string)
// End finishes the span. Calls after the first are no-ops.
End()
}

StatusCode classifies the outcome of a span, distinct from any error event recorded on it via Span.RecordError. It maps onto OpenTelemetry’s status code and Datadog’s error flag.

type StatusCode int

const (
// StatusUnset is the default: the span carries no explicit outcome.
StatusUnset StatusCode = iota
// StatusOK marks the operation as having completed successfully.
StatusOK
// StatusError marks the operation as having failed.
StatusError
)

Tracer starts spans. It is the only entry point for tracing; a Span is always obtained from Start, never constructed directly.

type Tracer interface {
// Start begins a span named name, returning a context carrying the span and
// the span itself. The returned context MUST be propagated to nested work so
// that downstream spans (in this module or another) parent under this one.
// The caller MUST call End on the returned span, conventionally via defer.
Start(ctx context.Context, name string, attrs ...Attr) (context.Context, Span)
}

func NopTracer() Tracer

NopTracer returns a Tracer whose spans do nothing. It is the default Tracer for any consumer that provides none. The returned Tracer is safe for concurrent use.

Generated by gomarkdoc