mirror of
https://github.com/coder/coder.git
synced 2026-09-24 15:04:27 +08:00
Blink helped here but it's suggestion was to have a set map of sensitive fields based on predefined constants in various files, such as the api token string names. For now we'll add additional query param logging for fields we know are safe/that we want to log, such as query pagination/limit fields and ID list counts which may help identify P99 DB query latencies. --------- Signed-off-by: Callum Styan <callumstyan@gmail.com>
266 lines
7.0 KiB
Go
266 lines
7.0 KiB
Go
package loggermw
|
|
|
|
import (
|
|
"context"
|
|
"fmt"
|
|
"net/http"
|
|
"net/url"
|
|
"strconv"
|
|
"strings"
|
|
"sync"
|
|
"time"
|
|
|
|
"github.com/go-chi/chi/v5"
|
|
|
|
"cdr.dev/slog"
|
|
"github.com/coder/coder/v2/coderd/httpapi"
|
|
"github.com/coder/coder/v2/coderd/rbac"
|
|
"github.com/coder/coder/v2/coderd/tracing"
|
|
)
|
|
|
|
var (
|
|
safeParams = []string{"page", "limit", "offset"}
|
|
countParams = []string{"ids", "template_ids"}
|
|
)
|
|
|
|
func safeQueryParams(params url.Values) []slog.Field {
|
|
if len(params) == 0 {
|
|
return nil
|
|
}
|
|
|
|
fields := make([]slog.Field, 0, len(params))
|
|
for key, values := range params {
|
|
// Check if this parameter should be included
|
|
for _, pattern := range safeParams {
|
|
if strings.EqualFold(key, pattern) {
|
|
// Prepend query parameters in the log line to ensure we don't have issues with collisions
|
|
// in case any other internal logging fields already log fields with similar names
|
|
fieldName := "query_" + key
|
|
|
|
// Log the actual values for non-sensitive parameters
|
|
if len(values) == 1 {
|
|
fields = append(fields, slog.F(fieldName, values[0]))
|
|
continue
|
|
}
|
|
fields = append(fields, slog.F(fieldName, values))
|
|
}
|
|
}
|
|
// Some query params we just want to log the count of the params length
|
|
for _, pattern := range countParams {
|
|
if !strings.EqualFold(key, pattern) {
|
|
continue
|
|
}
|
|
count := 0
|
|
|
|
// Prepend query parameters in the log line to ensure we don't have issues with collisions
|
|
// in case any other internal logging fields already log fields with similar names
|
|
fieldName := "query_" + key
|
|
|
|
// Count comma-separated values for CSV format
|
|
for _, v := range values {
|
|
if strings.Contains(v, ",") {
|
|
count += len(strings.Split(v, ","))
|
|
continue
|
|
}
|
|
count++
|
|
}
|
|
// For logging we always want strings
|
|
fields = append(fields, slog.F(fieldName+"_count", strconv.Itoa(count)))
|
|
}
|
|
}
|
|
return fields
|
|
}
|
|
|
|
func Logger(log slog.Logger) func(next http.Handler) http.Handler {
|
|
return func(next http.Handler) http.Handler {
|
|
return http.HandlerFunc(func(rw http.ResponseWriter, r *http.Request) {
|
|
start := time.Now()
|
|
|
|
sw, ok := rw.(*tracing.StatusWriter)
|
|
if !ok {
|
|
panic(fmt.Sprintf("ResponseWriter not a *tracing.StatusWriter; got %T", rw))
|
|
}
|
|
|
|
httplog := log.With(
|
|
slog.F("host", httpapi.RequestHost(r)),
|
|
slog.F("path", r.URL.Path),
|
|
slog.F("proto", r.Proto),
|
|
slog.F("remote_addr", r.RemoteAddr),
|
|
// Include the start timestamp in the log so that we have the
|
|
// source of truth. There is at least a theoretical chance that
|
|
// there can be a delay between `next.ServeHTTP` ending and us
|
|
// actually logging the request. This can also be useful when
|
|
// filtering logs that started at a certain time (compared to
|
|
// trying to compute the value).
|
|
slog.F("start", start),
|
|
)
|
|
|
|
// Add safe query parameters to the log
|
|
if queryFields := safeQueryParams(r.URL.Query()); len(queryFields) > 0 {
|
|
httplog = httplog.With(queryFields...)
|
|
}
|
|
|
|
logContext := NewRequestLogger(httplog, r.Method, start)
|
|
|
|
ctx := WithRequestLogger(r.Context(), logContext)
|
|
|
|
next.ServeHTTP(sw, r.WithContext(ctx))
|
|
|
|
// Don't log successful health check requests.
|
|
if r.URL.Path == "/api/v2" && sw.Status == http.StatusOK {
|
|
return
|
|
}
|
|
|
|
// For status codes 500 and higher we
|
|
// want to log the response body.
|
|
if sw.Status >= http.StatusInternalServerError {
|
|
logContext.WithFields(
|
|
slog.F("response_body", string(sw.ResponseBody())),
|
|
)
|
|
}
|
|
|
|
logContext.WriteLog(r.Context(), sw.Status)
|
|
})
|
|
}
|
|
}
|
|
|
|
type RequestLogger interface {
|
|
WithFields(fields ...slog.Field)
|
|
WriteLog(ctx context.Context, status int)
|
|
WithAuthContext(actor rbac.Subject)
|
|
}
|
|
|
|
type SlogRequestLogger struct {
|
|
log slog.Logger
|
|
written bool
|
|
message string
|
|
start time.Time
|
|
// Protects actors map for concurrent writes.
|
|
mu sync.RWMutex
|
|
actors map[rbac.SubjectType]rbac.Subject
|
|
}
|
|
|
|
var _ RequestLogger = &SlogRequestLogger{}
|
|
|
|
func NewRequestLogger(log slog.Logger, message string, start time.Time) RequestLogger {
|
|
return &SlogRequestLogger{
|
|
log: log,
|
|
written: false,
|
|
message: message,
|
|
start: start,
|
|
actors: make(map[rbac.SubjectType]rbac.Subject),
|
|
}
|
|
}
|
|
|
|
func (c *SlogRequestLogger) WithFields(fields ...slog.Field) {
|
|
c.log = c.log.With(fields...)
|
|
}
|
|
|
|
func (c *SlogRequestLogger) WithAuthContext(actor rbac.Subject) {
|
|
c.mu.Lock()
|
|
defer c.mu.Unlock()
|
|
c.actors[actor.Type] = actor
|
|
}
|
|
|
|
func (c *SlogRequestLogger) addAuthContextFields() {
|
|
c.mu.RLock()
|
|
defer c.mu.RUnlock()
|
|
|
|
usr, ok := c.actors[rbac.SubjectTypeUser]
|
|
if ok {
|
|
c.log = c.log.With(
|
|
slog.F("requestor_id", usr.ID),
|
|
slog.F("requestor_name", usr.FriendlyName),
|
|
slog.F("requestor_email", usr.Email),
|
|
)
|
|
} else {
|
|
// If there is no user, we log the requestor name for the first
|
|
// actor in a defined order.
|
|
for _, v := range actorLogOrder {
|
|
subj, ok := c.actors[v]
|
|
if !ok {
|
|
continue
|
|
}
|
|
c.log = c.log.With(
|
|
slog.F("requestor_name", subj.FriendlyName),
|
|
)
|
|
break
|
|
}
|
|
}
|
|
}
|
|
|
|
var actorLogOrder = []rbac.SubjectType{
|
|
rbac.SubjectTypeAutostart,
|
|
rbac.SubjectTypeCryptoKeyReader,
|
|
rbac.SubjectTypeCryptoKeyRotator,
|
|
rbac.SubjectTypeJobReaper,
|
|
rbac.SubjectTypeNotifier,
|
|
rbac.SubjectTypePrebuildsOrchestrator,
|
|
rbac.SubjectTypeSubAgentAPI,
|
|
rbac.SubjectTypeProvisionerd,
|
|
rbac.SubjectTypeResourceMonitor,
|
|
rbac.SubjectTypeSystemReadProvisionerDaemons,
|
|
rbac.SubjectTypeSystemRestricted,
|
|
}
|
|
|
|
func (c *SlogRequestLogger) WriteLog(ctx context.Context, status int) {
|
|
if c.written {
|
|
return
|
|
}
|
|
c.written = true
|
|
end := time.Now()
|
|
|
|
// Right before we write the log, we try to find the user in the actors
|
|
// and add the fields to the log.
|
|
c.addAuthContextFields()
|
|
|
|
logger := c.log.With(
|
|
slog.F("took", end.Sub(c.start)),
|
|
slog.F("status_code", status),
|
|
slog.F("latency_ms", float64(end.Sub(c.start)/time.Millisecond)),
|
|
)
|
|
|
|
// If the request is routed, add the route parameters to the log.
|
|
if chiCtx := chi.RouteContext(ctx); chiCtx != nil {
|
|
urlParams := chiCtx.URLParams
|
|
routeParamsFields := make([]slog.Field, 0, len(urlParams.Keys))
|
|
|
|
for k, v := range urlParams.Keys {
|
|
if urlParams.Values[k] != "" {
|
|
routeParamsFields = append(routeParamsFields, slog.F("params_"+v, urlParams.Values[k]))
|
|
}
|
|
}
|
|
|
|
if len(routeParamsFields) > 0 {
|
|
logger = logger.With(routeParamsFields...)
|
|
}
|
|
}
|
|
|
|
// We already capture most of this information in the span (minus
|
|
// the response body which we don't want to capture anyways).
|
|
tracing.RunWithoutSpan(ctx, func(ctx context.Context) {
|
|
// We should not log at level ERROR for 5xx status codes because 5xx
|
|
// includes proxy errors etc. It also causes slogtest to fail
|
|
// instantly without an error message by default.
|
|
if status >= http.StatusInternalServerError {
|
|
logger.Warn(ctx, c.message)
|
|
} else {
|
|
logger.Debug(ctx, c.message)
|
|
}
|
|
})
|
|
}
|
|
|
|
type logContextKey struct{}
|
|
|
|
func WithRequestLogger(ctx context.Context, rl RequestLogger) context.Context {
|
|
return context.WithValue(ctx, logContextKey{}, rl)
|
|
}
|
|
|
|
func RequestLoggerFromContext(ctx context.Context) RequestLogger {
|
|
val := ctx.Value(logContextKey{})
|
|
if logCtx, ok := val.(RequestLogger); ok {
|
|
return logCtx
|
|
}
|
|
return nil
|
|
}
|