Files
warmbly/internal/api/middleware/reporting.go
T

75 lines
2.4 KiB
Go

package middleware
import (
"fmt"
"net/http"
"runtime/debug"
"github.com/gin-gonic/gin"
"github.com/warmbly/warmbly/internal/observability/errs"
)
// panicStackLimit bounds the stack attached to a report. A goroutine dump can
// run to tens of kilobytes and the answer is always in the first few frames.
const panicStackLimit = 8 << 10
// Recovery replaces gin's own so a panicking request is reported rather than
// only logged.
//
// It is what makes an error searchable back to where it happened: the report
// carries the route, the request id the client was handed, and the workspace
// and user whose request it was, so an issue in the dashboard's own error
// tracking and a support message quoting a request id are the same incident.
//
// The stack travels as an extra rather than as the event's own frames. A
// deferred recover runs after the panicking frames have unwound, so the stack
// the SDK can collect here names this middleware; debug.Stack still holds the
// real one, which is the same stack gin logs.
func Recovery() gin.HandlerFunc {
return gin.CustomRecovery(func(c *gin.Context, recovered any) {
stack := debug.Stack()
if len(stack) > panicStackLimit {
stack = stack[:panicStackLimit]
}
opts := []errs.Option{errs.Extra("stack", string(stack))}
for key, value := range requestTags(c) {
opts = append(opts, errs.Tag(key, value))
}
errs.Recover(recovered, opts...)
c.AbortWithStatus(http.StatusInternalServerError)
})
}
// requestTags is what a report should say about the request it came from.
//
// Only ids: no header, no body, no query string. A route pattern rather than
// the path, so /campaigns/:id is one issue instead of one per campaign.
func requestTags(c *gin.Context) map[string]string {
tags := map[string]string{
"http_method": c.Request.Method,
"http_route": routeOf(c),
}
if id := c.GetString(RequestIDContextKey); id != "" {
tags["request_id"] = id
}
if v, ok := c.Get(OrganizationIDKey); ok {
tags["organization_id"] = fmt.Sprintf("%v", v)
}
if id := c.GetString(UserIDKey); id != "" {
tags["user_id"] = id
}
return tags
}
// routeOf is the matched route pattern, falling back to "unmatched" rather than
// to the raw path: a 404's path is attacker-supplied and would make one issue
// per probe.
func routeOf(c *gin.Context) string {
if route := c.FullPath(); route != "" {
return route
}
return "unmatched"
}