Export all three signals to a collector you can watch¶
By the end of this tutorial a small Go service will be exporting traces, metrics and logs to a collector running on your own machine, and you will have read the collector's output and found the same trace ID on all three.
That last part is the point. Telemetry wiring is easy to get almost right, and a backend that shows nothing does not tell you which half is broken. A collector printing what it receives does.
Allow about fifteen minutes. You need Go 1.26 or later and either Docker or Podman.
Step 1: Start a collector that prints what it receives¶
Create collector.yaml:
receivers:
otlp:
protocols:
http:
endpoint: 0.0.0.0:4318
exporters:
debug:
verbosity: detailed
service:
pipelines:
traces:
receivers: [otlp]
exporters: [debug]
metrics:
receivers: [otlp]
exporters: [debug]
logs:
receivers: [otlp]
exporters: [debug]
The debug exporter writes every received item to the collector's own log. It is not
something to run in production, but it is the clearest possible view of what your service
is actually sending.
Start it:
docker run -d --name otelcol -p 4318:4318 \
-v "$PWD/collector.yaml:/etc/otelcol-contrib/config.yaml" \
otel/opentelemetry-collector-contrib:latest
Confirm it is listening:
Look for Everything is ready. Begin running and processing data. Port 4318 is the OTLP
HTTP port — this module speaks OTLP/HTTP only, so 4317 will not do.
Step 2: Create the project¶
mkdir checkout && cd checkout
go mod init example.com/checkout
go get gitlab.com/phpboyscout/go/observability
go get go.opentelemetry.io/otel
Step 3: Build the three providers¶
Create main.go. Start with the two ingredients every provider needs — a resource that
identifies the service, and a Settings that says where to send things:
package main
import (
"context"
"log"
"log/slog"
"time"
"go.opentelemetry.io/otel"
"gitlab.com/phpboyscout/go/observability/logs"
"gitlab.com/phpboyscout/go/observability/metrics"
"gitlab.com/phpboyscout/go/observability/otelcore"
"gitlab.com/phpboyscout/go/observability/tracing"
)
func main() {
ctx := context.Background()
res := otelcore.Resource("checkout", "0.1.0")
settings := otelcore.Settings{
Enabled: true,
Endpoint: "http://localhost:4318",
}
The http:// scheme marks the connection plaintext, which is right for a collector on
your own machine and wrong for anything else.
Now the three providers. They share the same shape, and each returns an error you should not ignore:
tp, err := tracing.NewProvider(ctx, res, settings, tracing.WithSampling(1.0))
if err != nil {
log.Fatal(err)
}
mp, err := metrics.NewProvider(ctx, res, settings, metrics.WithInterval(5*time.Second))
if err != nil {
log.Fatal(err)
}
lp, err := logs.NewProvider(ctx, res, settings)
if err != nil {
log.Fatal(err)
}
WithSampling(1.0) records every trace. The default is 0.1, which for a program
producing one span means a nine-in-ten chance of seeing nothing and concluding the wiring
is broken.
WithInterval(5*time.Second) shortens the metric export interval from its 60-second
default so you are not waiting a minute for a number.
Step 4: Register the providers and build a logger¶
Nothing registers itself. Instrumentation libraries read the global providers, so set them:
otel.SetTracerProvider(tp)
otel.SetMeterProvider(mp)
logger := slog.New(logs.Handler(lp, "checkout"))
logs.Handler turns the logger provider into an ordinary slog.Handler. Be aware that
this logger writes only to the collector — nothing reaches your terminal.
Step 5: Emit one of each¶
spanCtx, span := tp.Tracer("checkout").Start(ctx, "place-order")
logger.InfoContext(spanCtx, "order placed", "order.id", "A-1001")
counter, err := mp.Meter("checkout").Int64Counter("orders.placed")
if err != nil {
log.Fatal(err)
}
counter.Add(spanCtx, 1)
span.End()
spanCtx is doing the work here. It carries the active span, and both the log record and
the metric measurement pick the trace ID out of it. Passing plain ctx instead — or
calling logger.Info rather than logger.InfoContext — produces the same telemetry with
nothing joining it up.
Step 6: Shut down, or see nothing¶
shutdownCtx, cancel := context.WithTimeout(ctx, 10*time.Second)
defer cancel()
if err := tp.Shutdown(shutdownCtx); err != nil {
log.Print(err)
}
if err := mp.Shutdown(shutdownCtx); err != nil {
log.Print(err)
}
if err := lp.Shutdown(shutdownCtx); err != nil {
log.Print(err)
}
}
Each provider buffers before exporting — spans for up to five seconds, records for one,
metrics for a full interval. A program that exits without shutting them down usually
sends nothing at all. The timeout matters too: Shutdown against an unreachable
collector will sit through the retry budget.
Step 7: Run it and read the output¶
The span:
Resource attributes:
-> service.name: Str(checkout)
-> service.version: Str(0.1.0)
Span #0
Trace ID : 5e3ae57bc33f3878454b2cf4b2bc9ada
Name : place-order
The metric, carrying that trace ID as an exemplar — the SDK attached it because the measurement was taken inside the span:
Metric #0
Descriptor:
-> Name: orders.placed
-> DataType: Sum
NumberDataPoints #0
Value: 1
Exemplars:
Exemplar #0
-> Trace ID: 5e3ae57bc33f3878454b2cf4b2bc9ada
-> Span ID: d30534fa2051042c
-> Value: 1
And the log record:
LogRecord #0
Body: Str(order placed)
Trace ID: 5e3ae57bc33f3878454b2cf4b2bc9ada
Span ID: d30534fa2051042c
One trace ID across all three. That is the thing worth checking, because it is the thing that lets a backend take you from a slow request to the log line that explains it.
Step 8: Watch the endpoint move to the environment¶
Delete the endpoint from the code and let an operator supply it:
The same telemetry arrives. An empty Endpoint is not an error — it means "somebody else
will say where", and the SDK reads the standard OTEL_EXPORTER_OTLP_* variables.
Now run it with the variable unset:
It still does not fail at startup, and still nothing arrives — the exporter falls back to
localhost:4318, which happens to be your collector. Stop the collector and try again to
see the export errors that a real misconfiguration produces. This is the one genuinely
sharp edge in the fallback: "no endpoint anywhere" is indistinguishable from "endpoint
configured badly", and neither fails at construction.
Tidy up¶
Where to go next¶
- Wire OTLP logs, metrics and traces — one shared endpoint with per-signal overrides.
- Correlate logs with traces — keeping stderr output alongside OTLP, and filtering by level.
- Configuration keys — the
telemetry.*tree to load these values from a config file rather than hard-coding them. - What this module does not do — before you go looking for a feature that is not there.