Your application is running, but are you sure you know what's inside?
\n\nImagine: you've launched a Go microservice into production. It doesn't crash, it doesn't return 500 errors, but users complain that the page loads "kind of slowly." You check the monitoring — CPU 30%, memory is normal. What to do? Without observability in Go, you are a ship captain without a map or compass.
\n\nObservability is not just "logs." It's three pillars: tracing, metrics, and logs. Together, they give you a superpower: understanding what's happening in the system, even if you didn't expect a problem. In this article, we'll break down each of these aspects using Go code examples. You won't just read theory — you'll get ready-made recipes that you can implement today.
\n\nWhy Go is the ideal language for observability?
\n\nGo was created for backend systems where performance and reliability are important. The standard library (net/http, database/sql) already knows how to expose metrics and support contexts for tracing. And the OpenTelemetry Go and Prometheus Go ecosystem makes implementing observability simple and elegant.
But the main thing is that there is no "magic" in Go. You write code that can be measured, traced, and logged without pain. Let's see how it's done.
\n\nTracing in Go: OpenTelemetry in Action
\n\nTracing is the story of one request from entry to exit of the system. It shows how long each step took: a database query, an external API call, data processing. In Go, tracing is built on contexts (context.Context).
Setting up the OpenTelemetry SDK
\n\nInstall the packages:
\ngo get go.opentelemetry.io/otel \\\n go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp \\\n go.opentelemetry.io/otel/sdk/trace\n\nNow initialize the tracer:
\n\npackage main\n\nimport (\n "context"\n "log"\n "go.opentelemetry.io/otel"\n "go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp"\n "go.opentelemetry.io/otel/sdk/trace"\n)\n\nfunc initTracer() {\n exporter, err := otlptracehttp.New(context.Background())\n if err != nil {\n log.Fatal(err)\n }\n\n tp := trace.NewTracerProvider(\n trace.WithBatcher(exporter),\n trace.WithSampler(trace.AlwaysSample()),\n )\n otel.SetTracerProvider(tp)\n}\n\nCreating a span in an HTTP handler
\n\nHere's what tracing looks like in real code:
\n\nfunc userHandler(w http.ResponseWriter, r *http.Request) {\n tracer := otel.Tracer("user-service")\n ctx, span := tracer.Start(r.Context(), "getUser")\n defer span.End()\n\n // Simulating a database query\n user, err := getUserFromDB(ctx, r.URL.Query().Get("id"))\n if err != nil {\n span.RecordError(err)\n span.SetStatus(codes.Error, err.Error())\n http.Error(w, err.Error(), 500)\n return\n }\n\n span.SetAttributes(\n attribute.String("user.id", user.ID),\n attribute.Int64("user.age", int64(user.Age)),\n )\n\n // Nested span for an external call\n _, childSpan := tracer.Start(ctx, "callAnalyticsAPI")\n defer childSpan.End()\n\n callAnalyticsAPI(user)\n \n w.Write([]byte("OK"))\n}\n\nEach span contains a name, start and end time, attributes, and status. In Jaeger or Grafana Tempo, you will see a complete map of the request.
\n\nPractical Tip
\nDon't forget to pass the context throughout the entire application. In Go, this is done automatically if you use context.Context in function signatures. OpenTelemetry Go will automatically pick up the context from the HTTP request if you use middleware.
Metrics: Prometheus and Export in Go
\n\nMetrics are numbers that tell you about the state of the system: RPS, latency, error count, usage