HTTP handlers in Go are relatively easy to write but you can fall into a few issues potentially. Take this very simple handler below as an example:

func (wo *workspaceHandler) list(w http.ResponseWriter, r *http.Request) {
 ctx, span, rid := getTracer(r.Context(), r, "list")
 defer span.End()

 user := getUser(r.Context())

 workspace := getWorkspace(r.Context())

 logger := wo.logger.WithField("request_id", rid).
  WithField("method", "list").
  WithField("user", user.ID).
  WithField("workspace_id = ?", workspace.ID)

 logger.Debug("Listing all workspaces")

 workspaces, err := wo.workspaceRepo.List(ctx, user)
 if err != nil {
  logger.WithError(err).Error("could not list workspaces ")

  span.RecordError(err)
  span.SetStatus(codes.Error, "could not list workspaces")
  _ = render.Render(w, r, newAPIError(http.StatusInternalServerError,
   "an error occurred while listing your workspaces"))
  return
 }

 if err := someOtherOperation(); err != nil {
  logger.WithError(err).Error("could not list workspaces ")

  span.RecordError(err)
  span.SetStatus(codes.Error, "could not list workspaces")
  _ = render.Render(w, r, newAPIError(http.StatusInternalServerError,
   "an error occurred while listing your workspaces"))
  return

 }

 span.SetStatus(codes.Ok, "successfully listed workspace")
 _ = render.Render(w, r, &listWorkspaces{
  Workspaces:       workspaces,
  APIStatus:        newAPIStatus(http.StatusOK, "Fetched list of your workspaces"),
  CurrentWorkspace: currentWorkspace,
 })
}

Can you spot what can potentially go wrong here? If you cannot, I will point out two issues:

  • Every time, we encounter an error, you have always to add a naked return after you have sent the HTTP response. Else the rest of the handler will continue to run and you will end up with an unintended state. You will also write the response which is usually an error by the way

  • Code repetition. For every error path, you have to record the status of the span with span.SetStatus, you also have to initialize the user and workspace variables amongst others. If this was a fairly non-trivial project, you might have maybe 8-10 handlers. Having to do the same thing across all of them isn't a great idea especially when you think of changing specific things to how your tracing or logging is set up

How I avoid this

The first thing I do is set up enum types.

type Status uint8

const (
 StatusSuccess Status = iota
 StatusFailed
)

Please use go-enum library instead of writing by hand in production software.

Once I have my enums set up, I can then go ahead to define a custom http handler type. While it is a custom type, please note it will still satisfy the standard http.HandlerFunc type from the standard library and thus can be used with various routers in the ecosystem.

type HTTPHandler func(
 context.Context,
 trace.Span,
 *logrus.Entry,
 http.ResponseWriter,
 *http.Request) (render.Renderer, Status)

func WrapHTTPHandler(
 handler HTTPHandler,
 cfg config.Config,
 spanName string) http.HandlerFunc {

 h, _ := os.Hostname()

 return func(w http.ResponseWriter, r *http.Request) {

  ctx, span, rid := getTracer(r.Context(), r, spanName, cfg.Otel.IsEnabled)
  defer span.End()

  logger := logrus.WithField("host", h).
   WithField("app", "http").
   WithField("method", spanName).
   WithField("request_id", rid).
   WithContext(ctx)

  // add common log labels
  if doesWorkspaceExistInContext(r.Context()) {
   logger = logger.WithField("workspace_id", getWorkspaceFromContext(r.Context()).ID)
  }

  if doesUserExistInContext(r.Context()) {
   logger = logger.WithField("user_id", getUserFromContext(r.Context()).ID)
  }

  resp, status := handler(ctx, span, logger, w, r)
  switch status {
  case StatusFailed:

   span.SetStatus(codes.Error, "")

  case StatusSuccess:
   span.SetStatus(codes.Ok, "")

  default:
   _ = render.Render(w, r, newAPIStatus(500, "unknown error"))
   return
  }

  _ = render.Render(w, r, resp)
 }
}

We have introduced a new HTTPHandler type which our handlers must implement right now instead of http.HandlerFunc, but to keep it compatible with the rest of the ecosystem, there is a wrapper that allows us to match http.HandlerFunc still.

It is also important to note that both issues I mentioned above have been resolved. We only have one place where we set up Opentelemetry tracing now across our handlers. Even if we have 1,000 routes and handlers, we only need to change in one place. Same thing with the logging details.

Okay, what does an handler look like now

func (a *authHandler) Login(
 ctx context.Context,
 span trace.Span,
 logger *logrus.Entry,
 w http.ResponseWriter,
 r *http.Request) (render.Renderer, Status) {

 provider := chi.URLParam(r, "provider")

 logger = logger.WithField("provider", provider)

 span.SetAttributes(attribute.String("auth.provider", provider))

 logger.Debug("Authenticating user")

 if provider != "google" || provider != "github" {
  return newAPIStatus(http.StatusBadRequest, "unspported provider"), StatusFailed
 }

 // you filled this somehow with data from the req body
 req := new(authenticateUserRequest)
 if err := req.Validate(); err != nil {
  return newAPIStatus(http.StatusBadRequest, err.Error()), StatusFailed
 }

 token, err := a.googleCfg.Validate(ctx, socialauth.ValidateOptions{
  Code: req.Code,
 })
 if err != nil {
  logger.WithError(err).Error("could not exchange token")
  return newAPIStatus(400, "could not verify your sign in with Google"), StatusFailed
 }


 user := &User{
  Email:    Email(u.Email),
  FullName: u.Name,
 }

 err = a.userRepo.Create(ctx, user)
 if err != nil {
  logger.WithError(err).Error("an error occurred while creating user")
  return newAPIStatus(500, "an error occurred while creating user"), StatusFailed
 }

 resp := createdUserResponse{
  User:      user,
  APIStatus: newAPIStatus(http.StatusOK, "user Successfully created"),
 }

 return resp, StatusSuccess
}

In this handler, you can see that a return value is always enforced hence mitigating the need to always verify all your handlers did the right thing and that you have no leaks.

In this instance, the OTEL span has been used to add more metadata/attributes to the span. Same thing as the logger, it has been used to generate more useful logs. That really is the essence of this pattern to me. It provides a base template to prevent repetition across the board but allows each handler to go further with whatever custom details they'd love to implement themselves.

It is also important to know you can extend this as much as you want or as much as it makes sense for your use-case

How do I use this with my router?

// Using the chi router as an example
r.Post("/", WrapHTTPHandler(auth.Login, cfg, "Auth.Login"))

Okay, what exactly does getTracer do? I have seen it a few times

func getTracer(ctx context.Context, r *http.Request,
 operationName string, isEnabled bool) (context.Context, trace.Span, string) {

 rid := retrieveRequestID(r)
 if !isEnabled {
  ctx, span := noopTracer.Start(ctx, operationName)
  return ctx, span, rid
 }

 ctx, span := tracer.Start(ctx, operationName)

 span.SetAttributes(attribute.String("request_id", rid))
 return ctx, span, rid
}