Skip to content

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:

docker logs otelcol

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

go run .
docker logs otelcol

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:

    settings := otelcore.Settings{Enabled: true} // no endpoint
OTEL_EXPORTER_OTLP_ENDPOINT=http://localhost:4318 go run .

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:

go run .

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

docker rm -f otelcol

Where to go next