Files
web/requestlog/requestlog.go
T
gamertan 3a4b6db9b8
verify / verify (push) Successful in 2m55s
feat: publish Gamertan Web Foundations preview source
Export the reviewed application-neutral package set through the exact public allowlist. Development history and private application evidence remain outside this canonical source root.

Developed with material AI assistance under maintainer review.

Signed-off-by: Cole Speelman <crspeelman@gmail.com>
2026-08-16 15:05:40 -04:00

206 lines
6.0 KiB
Go

// SPDX-License-Identifier: MPL-2.0
// Package requestlog records bounded, versioned HTTP request observations.
package requestlog
import (
"context"
"errors"
"net/http"
"strings"
"time"
"unicode/utf8"
"gamertan.com/web/requestmeta"
)
const RecordVersion = 1
// Record is deliberately stable and append-log friendly. Sensitive fields are
// populated only when explicitly enabled by Policy.
type Record struct {
Version int `json:"version"`
Timestamp time.Time `json:"timestamp"`
RequestID string `json:"request_id,omitempty"`
Method string `json:"method"`
Route string `json:"route"`
Status int `json:"status"`
Bytes int64 `json:"bytes"`
DurationMicros int64 `json:"duration_micros"`
ClientIP string `json:"client_ip,omitempty"`
Path string `json:"path,omitempty"`
Query string `json:"query,omitempty"`
Referer string `json:"referer,omitempty"`
UserAgent string `json:"user_agent,omitempty"`
SessionID string `json:"session_id,omitempty"`
}
// Validate rejects records that cannot have been produced by this package's
// bounded middleware contract.
func (record Record) Validate() error {
if record.Version != RecordVersion || record.Timestamp.IsZero() || !boundedField(record.Method, 16, false) || !boundedField(record.Route, 256, false) || record.Status < 100 || record.Status > 999 || record.Bytes < 0 || record.DurationMicros < 0 {
return errors.New("requestlog: invalid record")
}
fields := []struct {
value string
limit int
}{
{record.RequestID, 64}, {record.ClientIP, 64}, {record.Path, 2048},
{record.Query, 4096}, {record.Referer, 2048}, {record.UserAgent, 1024},
{record.SessionID, 256},
}
for _, field := range fields {
if !boundedField(field.value, field.limit, true) {
return errors.New("requestlog: invalid record")
}
}
return nil
}
// Sink receives complete records after a handler returns.
type Sink interface {
WriteRecord(context.Context, Record) error
}
// SensitiveFields must be opted into field by field.
type SensitiveFields struct {
ClientIP bool
Path bool
Query bool
Referer bool
UserAgent bool
SessionID bool
}
// Policy controls classification and collection. Route must return a low-cardinality
// route label; nil produces "unclassified" rather than recording a raw path.
type Policy struct {
Route func(*http.Request) string
SessionID func(*http.Request) string
Sensitive SensitiveFields
OnSinkError func(error)
Now func() time.Time
}
func Middleware(sink Sink, policy Policy) func(http.Handler) http.Handler {
if policy.Now == nil {
policy.Now = time.Now
}
return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(response http.ResponseWriter, request *http.Request) {
started := policy.Now()
capture := &responseCapture{ResponseWriter: response, status: http.StatusOK}
defer func() {
recovered := recover()
if recovered != nil && !capture.wroteHeader {
capture.status = http.StatusInternalServerError
}
record := makeRecord(request, capture, policy, started, policy.Now())
if sink != nil {
if err := sink.WriteRecord(context.WithoutCancel(request.Context()), record); err != nil && policy.OnSinkError != nil {
policy.OnSinkError(errors.New("requestlog: sink write failed"))
}
}
if recovered != nil {
panic(recovered)
}
}()
next.ServeHTTP(capture, request)
})
}
}
func makeRecord(request *http.Request, capture *responseCapture, policy Policy, started, finished time.Time) Record {
route := "unclassified"
if policy.Route != nil {
route = bounded(policy.Route(request), 256)
if route == "" {
route = "unclassified"
}
}
duration := finished.Sub(started).Microseconds()
if duration < 0 {
duration = 0
}
record := Record{Version: RecordVersion, Timestamp: finished.UTC(), Method: bounded(request.Method, 16), Route: route, Status: capture.status, Bytes: capture.bytes, DurationMicros: duration}
if metadata, ok := requestmeta.FromContext(request.Context()); ok {
record.RequestID = metadata.RequestID
if policy.Sensitive.ClientIP && metadata.ClientIP.IsValid() {
record.ClientIP = metadata.ClientIP.String()
}
}
if policy.Sensitive.Path {
record.Path = bounded(request.URL.EscapedPath(), 2048)
}
if policy.Sensitive.Query {
record.Query = bounded(request.URL.RawQuery, 4096)
}
if policy.Sensitive.Referer {
record.Referer = bounded(request.Referer(), 2048)
}
if policy.Sensitive.UserAgent {
record.UserAgent = bounded(request.UserAgent(), 1024)
}
if policy.Sensitive.SessionID && policy.SessionID != nil {
record.SessionID = bounded(policy.SessionID(request), 256)
}
return record
}
func boundedField(value string, limit int, emptyOK bool) bool {
if (!emptyOK && value == "") || len(value) > limit || !utf8.ValidString(value) {
return false
}
for _, character := range value {
if character == 0 || character == '\r' || character == '\n' {
return false
}
}
return true
}
func bounded(value string, limit int) string {
value = strings.ToValidUTF8(value, "")
value = strings.Map(func(r rune) rune {
if r == 0 || r == '\r' || r == '\n' {
return -1
}
return r
}, value)
if len(value) <= limit {
return value
}
value = value[:limit]
for !utf8.ValidString(value) {
value = value[:len(value)-1]
}
return value
}
type responseCapture struct {
http.ResponseWriter
status int
bytes int64
wroteHeader bool
}
func (capture *responseCapture) WriteHeader(status int) {
if capture.wroteHeader {
return
}
capture.wroteHeader = true
capture.status = status
capture.ResponseWriter.WriteHeader(status)
}
func (capture *responseCapture) Write(body []byte) (int, error) {
if !capture.wroteHeader {
capture.WriteHeader(http.StatusOK)
}
written, err := capture.ResponseWriter.Write(body)
capture.bytes += int64(written)
return written, err
}
func (capture *responseCapture) Unwrap() http.ResponseWriter { return capture.ResponseWriter }