Let’s talk about something I’ve been thinking about a lot lately. As a developer building web services in Go, I often find myself asking a critical question: how does my application actually behave in production? We write the code, we test it locally, but once it’s live and handling real traffic, it can feel like we’re in the dark. This is exactly why I started looking closely at combining Chi, my go-to router for its clean design, with OpenTelemetry, the modern standard for observability.
When a user makes a request to your service, what happens? It travels through routes and middleware, interacts with databases or other services, and finally sends a response. Without the right tools, this whole process is invisible. You might see slow response times in your logs, but pinpointing the exact cause is like finding a needle in a haystack. This is where observability changes the game. It’s about equipping your application to tell its own story, showing you the complete picture of its health and performance.
So, how do we make Chi, a beautifully simple router, start telling that story? The magic happens through middleware. Chi is built around this concept, allowing you to wrap your HTTP handlers with layers of functionality. OpenTelemetry provides the components to create that visibility. By adding a small piece of OpenTelemetry middleware to your Chi router, you automatically start collecting valuable data, known as telemetry, for every request.
Think of a “trace” as the story of a single user request. A “span” is a chapter in that story, representing a specific operation, like a database query or a call to another service. The middleware you add creates an initial span for the incoming HTTP request. It then passes a context through your entire handler chain, allowing every subsequent operation to add its own chapter to the story, creating a connected timeline.
Here’s a basic look at how you might set this up. First, you initialize the OpenTelemetry SDK, which handles collecting and sending your data.
package main
import (
"go.opentelemetry.io/otel"
"go.opentelemetry.io/otel/exporters/stdout/stdouttrace"
sdktrace "go.opentelemetry.io/otel/sdk/trace"
)
func initTracer() (*sdktrace.TracerProvider, error) {
exp, err := stdouttrace.New(stdouttrace.WithPrettyPrint())
if err != nil {
return nil, err
}
tp := sdktrace.NewTracerProvider(
sdktrace.WithBatcher(exp),
)
otel.SetTracerProvider(tp)
return tp, nil
}
Next, you integrate a middleware with Chi. This example uses the otelchi package, which is designed specifically for this job. It wraps your router and automatically starts a span for each request, tagging it with useful information like the HTTP method and route pattern.
import (
"net/http"
"github.com/go-chi/chi/v5"
"github.com/go-chi/chi/v5/middleware"
"go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp"
"go.opentelemetry.io/otel"
)
func main() {
tp, _ := initTracer()
defer tp.Shutdown(context.Background())
r := chi.NewRouter()
r.Use(middleware.Logger)
// This is the key integration point.
r.Use(otelchi.Middleware("my-service-name"))
r.Get("/users/{id}", func(w http.ResponseWriter, r *http.Request) {
// Your handler logic here.
// The OpenTelemetry context is automatically propagated.
w.Write([]byte("Hello from Chi with OpenTelemetry!"))
})
http.ListenAndServe(":8080", r)
}
Now, every time someone hits the /users/123 endpoint, a trace is started. You can see how long the request took, if it succeeded, and what route was matched. But the real power comes from what you add next. Are you calling a database? Wrap that client with OpenTelemetry instrumentation. Making an HTTP call to another service? Use an instrumented HTTP client. Each of these steps becomes a new, child span in the trace, showing you exactly where time is spent.
What happens if one of your services starts to slow down? With traces flowing from Chi through your entire system, you can follow the request’s path and immediately see which service or database call is the bottleneck. This isn’t just for debugging failures; it’s essential for understanding performance characteristics and planning improvements.
The beauty of this setup is its portability. OpenTelemetry is a standard. You write the instrumentation code once. Today, you might send your traces to a simple console exporter for development. Tomorrow, you can switch to sending them to Jaeger, Grafana Tempo, or a commercial cloud provider, without changing your application code. Chi keeps the routing logic clean and fast, while OpenTelemetry handles the complexity of data collection.
Getting started doesn’t require a massive overhaul. You can begin by instrumenting your main router as shown. This single step gives you immediate, high-level visibility. From there, you can gradually add more instrumentation to your internal logic, enriching the traces with the specific details that matter to your team.
The goal is to shift from reacting to problems to understanding your system’s behavior proactively. When you combine Chi’s elegant design with OpenTelemetry’s comprehensive observability, you build services that are not only functional but also understandable and maintainable at scale. You move from wondering what’s happening to knowing with certainty.
Did you find this approach to visibility helpful? If you’ve tried integrating observability into your Go services, what challenges did you face? Share your thoughts in the comments below—let’s learn from each other. If this article helped clarify the path forward, please consider liking and sharing it with your network.