Day 70 — OpenTelemetry light
Day 70 — OpenTelemetry light
Stage VII · ~3h
Goal: Add a minimal OpenTelemetry trace path to one request: tracer provider, a root span + child span, context propagation, and export (stdout is enough). Understand how traces complement metrics and logs.
Full mesh platforms are out of scope. Today is one path, correctly instrumented—not “adopt the whole observability platform.”
Why this day exists
Histograms say “p99 is 200ms.” Traces answer why this request was 200ms:
- Time in handler vs DB vs outbound HTTP
- Error attachment on a span
- Correlation across services via W3C
traceparent
Day 69 gave Prometheus metrics (aggregates). Day 47 gave structured logs. Traces complete the triangle for individual slow or failing requests.
Theory 1 — Three pillars (and boundaries)
| Signal | Strength |
|---|---|
Logs (slog) |
Discrete events, cheap, high detail |
| Metrics | Aggregates, alerts, cheap cardinality if careful |
| Traces | Causal graph of a request |
OpenTelemetry (OTel) standardizes APIs and exporters so you are not locked to one vendor SDK forever.
Core objects
- TracerProvider — process-wide factory
- Tracer — named instrumentation library
- Span — unit of work with start/end, attributes, status
- Context — carries span; must thread through
context.Context
Request ctx
└─ span: GET /v1/hello
└─ span: doWork / db.query
Theory 2 — Minimal Go wiring (stdout exporter)
Modules evolve; pin versions that build on Go 1.26 when you run the lab. Pattern:
package main
import (
"context"
"log"
"net/http"
"time"
"go.opentelemetry.io/otel"
"go.opentelemetry.io/otel/attribute"
"go.opentelemetry.io/otel/codes"
"go.opentelemetry.io/otel/exporters/stdout/stdouttrace"
"go.opentelemetry.io/otel/sdk/resource"
sdktrace "go.opentelemetry.io/otel/sdk/trace"
semconv "go.opentelemetry.io/otel/semconv/v1.24.0"
)
func initTracer(ctx context.Context) (func(context.Context) error, error) {
exp, err := stdouttrace.New(stdouttrace.WithPrettyPrint())
if err != nil {
return nil, err
}
tp := sdktrace.NewTracerProvider(
sdktrace.WithBatcher(exp),
sdktrace.WithResource(resource.NewWithAttributes(
semconv.SchemaURL,
semconv.ServiceName("day70-demo"),
)),
)
otel.SetTracerProvider(tp)
return tp.Shutdown, nil
}func main() {
ctx := context.Background()
shutdown, err := initTracer(ctx)
if err != nil {
log.Fatal(err)
}
defer func() { _ = shutdown(context.Background()) }()
mux := http.NewServeMux()
mux.HandleFunc("GET /v1/hello", handleHello)
log.Fatal(http.ListenAndServe(":8080", mux))
}
func handleHello(w http.ResponseWriter, r *http.Request) {
ctx, span := otel.Tracer("day70/http").Start(r.Context(), "GET /v1/hello")
defer span.End()
if err := doWork(ctx); err != nil {
span.RecordError(err)
span.SetStatus(codes.Error, err.Error())
http.Error(w, "failed", 500)
return
}
span.SetAttributes(attribute.String("handler.version", "v1"))
w.WriteHeader(200)
_, _ = w.Write([]byte(`{"ok":true}`))
}
func doWork(ctx context.Context) error {
ctx, span := otel.Tracer("day70/http").Start(ctx, "doWork")
defer span.End()
// honor cancellation
select {
case <-ctx.Done():
return ctx.Err()
case <-time.After(20 * time.Millisecond):
}
span.SetAttributes(attribute.Int("work.ms", 20))
return nil
}go get go.opentelemetry.io/otel@latest \
go.opentelemetry.io/otel/sdk@latest \
go.opentelemetry.io/otel/exporters/stdout/stdouttrace@latest
go run .
curl -s localhost:8080/v1/hello
# spans print to process stdoutPinning note
In notes, record the module versions go.mod resolved. OTel packages move; your lab should still build on Go 1.26.
Theory 3 — Context is the bus
Always pass context.Context into downstream work:
// good
rows, err := db.QueryContext(ctx, q)
// bad — orphan child, broken cancellation
rows, err := db.Query(q)HTTP clients should inject headers for outbound calls (contrib middleware / otelhttp). For one-process labs, parent→child spans via Start(ctx, name) is the essential skill.
Sampling (awareness)
Production systems sample (e.g. parent-based, ratio-based). Stdout labs can use default always-on for the demo. Never sample in a way that drops all errors if your platform supports tail sampling—out of scope, but know sampling exists.
// awareness only — not required today
// sdktrace.WithSampler(sdktrace.ParentBased(sdktrace.TraceIDRatioBased(0.1)))Theory 4 — Correlation with logs and metrics
- Put trace_id into
slogattributes when a span is present (optional stretch).
- Metrics remain for aggregates; traces for outliers.
- Do not replace metrics with traces for alerting volume.
import "go.opentelemetry.io/otel/trace"
spanCtx := trace.SpanContextFromContext(ctx)
if spanCtx.IsValid() {
logger.Info("handled",
"trace_id", spanCtx.TraceID().String(),
"span_id", spanCtx.SpanID().String(),
)
}Attribute discipline
| Good attribute | Bad attribute |
|---|---|
http.route = /v1/users/{id} |
Full URL with user ids |
db.system = sqlite |
Entire SQL with PII |
handler.version = v2 |
Unbounded free-text blobs |
High-cardinality attributes explode backend cost the same way metric labels do.
Theory 5 — Auto vs manual instrumentation
| Approach | Trade-off |
|---|---|
| Manual spans | Precise names; control attributes |
otelhttp middleware |
Fast coverage of HTTP edges |
| Vendor agents | Convenient; less portable |
For this day: manual root + one child is mandatory. Middleware is optional polish.
// stretch sketch
import "go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp"
mux := http.NewServeMux()
mux.Handle("GET /v1/hello", otelhttp.NewHandler(http.HandlerFunc(handleHello), "GET /v1/hello"))Theory 6 — Errors, status, and shutdown
if err != nil {
span.RecordError(err)
span.SetStatus(codes.Error, err.Error())
return err
}
// success: default status OK; optional:
// span.SetStatus(codes.Ok, "")Process exit without Shutdown loses batched spans:
defer func() {
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
_ = shutdown(ctx)
}()Align with Day 78 graceful shutdown: flush tracer after HTTP drain.
Worked example — span tree narrative
Paste into TRACE.md something like:
Span: GET /v1/hello 25ms
attributes: handler.version=v1
Span: doWork 20ms
attributes: work.ms=20
If stdout is JSON lines, keep a short excerpt—not megabytes of logs.
Worked example — outbound HTTP child (optional)
func fetchUpstream(ctx context.Context, client *http.Client, url string) error {
ctx, span := otel.Tracer("day70/http").Start(ctx, "HTTP GET upstream")
defer span.End()
span.SetAttributes(attribute.String("peer.service", "upstream"))
req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
if err != nil {
return err
}
resp, err := client.Do(req)
if err != nil {
span.RecordError(err)
span.SetStatus(codes.Error, err.Error())
return err
}
defer resp.Body.Close()
span.SetAttributes(attribute.Int("http.status_code", resp.StatusCode))
return nil
}Without otelhttp transport, you still get a local child span; cross-process traceparent needs proper propagation.
Lab
Suggested workspace: ~/lab/90daysofx/01-go/day70
- Wire TracerProvider with stdout exporter (or OTLP if you already run a collector).
- Instrument one route: root span + child span for a dependency call (DB, HTTP, or sleep-as-work).
- Capture a sample span tree in
TRACE.md(paste stdout JSON/pretty).
- Call
Shutdownon process exit (defer with timeout).
- Note one attribute you added and why it is not high-cardinality.
- Explain in two sentences (in
TRACE.md) how this differs from Day 69 metrics.
Stretch
- Use
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp.
- Propagate to a second tiny process with W3C headers.
- Export OTLP to a local collector/Jaeger all-in-one.
- Add
trace_idto one slog line.
Common gotchas
| Gotcha | Fix |
|---|---|
Forgetting span.End() |
defer span.End() immediately after Start |
| New context without parent | Always Start(ctx, ...) from request ctx |
| Export lost on exit | Shutdown flushes batcher |
| Span name = full URL with IDs | Use route pattern |
| “OTel is too heavy” | One tracer + stdout is enough for learning |
| Mixing metrics SDK complexity | Keep Day 69 Prometheus; add OTel traces only |
| Panic before End | Still prefer defer End; recover separately (Day 14) |
| Global provider race in tests | Set provider in TestMain or inject Tracer |
Checkpoint
- TracerProvider initialized and shut down
- Root + child span on one path
- Context threaded into child work
- Example output saved in
TRACE.md
- Can explain metrics vs traces in two sentences
- No high-cardinality attributes on the demo path
Commit
git add .
git commit -m "day70: otel light trace path"Write three personal gotchas before continuing.
Tomorrow
Day 71 — CLI ergonomics: multi-command UX, flags, exit codes, and --help that respect users (and future you).