mirror of
https://github.com/git-pkgs/proxy.git
synced 2026-11-09 15:49:16 -05:00
- Track per-caller request and byte counts in a bounded table - Export proxy_client_requests_total and proxy_client_response_bytes_total over a closed label set; addresses are never labels - Gate the sources card behind ui_request_sources, off by default, since /ui carries no authentication of its own - Move access_log.trust_forwarded_for to a top-level key - Keep remote as the TCP peer and add remote_ip alongside it Part 6 of 6 splitting #381 up. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
120 lines
3.2 KiB
Go
120 lines
3.2 KiB
Go
// Package accesslog writes proxy activity as JSON Lines.
|
|
package accesslog
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"fmt"
|
|
"net/url"
|
|
"os"
|
|
"sync"
|
|
"time"
|
|
)
|
|
|
|
const (
|
|
accessLogFileMode os.FileMode = 0o600
|
|
|
|
// EventRequest identifies the response sent by the proxy to a client.
|
|
EventRequest = "request"
|
|
// EventUpstream identifies one HTTP exchange with an upstream service.
|
|
EventUpstream = "upstream"
|
|
)
|
|
|
|
type requestIDKey struct{}
|
|
|
|
// Entry is one proxy activity record.
|
|
type Entry struct {
|
|
Time time.Time `json:"time"`
|
|
Event string `json:"event"`
|
|
RequestID string `json:"request_id,omitempty"`
|
|
Method string `json:"method"`
|
|
Path string `json:"path,omitempty"`
|
|
URL string `json:"url,omitempty"`
|
|
StatusCode int `json:"status_code,omitempty"`
|
|
DurationMS int64 `json:"duration_ms"`
|
|
RemoteAddr string `json:"remote_addr,omitempty"`
|
|
// RemoteIP is the address the request is attributed to: the TCP peer, or
|
|
// the leftmost X-Forwarded-For entry when that header is trusted.
|
|
RemoteIP string `json:"remote_ip,omitempty"`
|
|
// UserAgent is recorded verbatim; Client is it reduced to a known tool
|
|
// name, so log queries can group without parsing.
|
|
UserAgent string `json:"user_agent,omitempty"`
|
|
Client string `json:"client,omitempty"`
|
|
Ecosystem string `json:"ecosystem,omitempty"`
|
|
// Bytes is always emitted, including as 0, so a log pipeline summing the
|
|
// field does not have to treat a bodyless response as null.
|
|
Bytes int64 `json:"bytes"`
|
|
Error string `json:"error,omitempty"`
|
|
}
|
|
|
|
// Logger appends complete JSON objects to a file, one per line.
|
|
type Logger struct {
|
|
mu sync.Mutex
|
|
file *os.File
|
|
encoder *json.Encoder
|
|
}
|
|
|
|
// Open opens path for append, creating it with owner-only permissions when needed.
|
|
func Open(path string) (*Logger, error) {
|
|
file, err := os.OpenFile(path, os.O_APPEND|os.O_CREATE|os.O_WRONLY, accessLogFileMode)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("opening access log: %w", err)
|
|
}
|
|
|
|
return &Logger{
|
|
file: file,
|
|
encoder: json.NewEncoder(file),
|
|
}, nil
|
|
}
|
|
|
|
// Write appends an entry to the log.
|
|
func (l *Logger) Write(entry Entry) error {
|
|
if entry.Time.IsZero() {
|
|
entry.Time = time.Now().UTC()
|
|
}
|
|
|
|
l.mu.Lock()
|
|
defer l.mu.Unlock()
|
|
|
|
if err := l.encoder.Encode(entry); err != nil {
|
|
return fmt.Errorf("writing access log: %w", err)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// Close closes the log file after any active writer finishes.
|
|
func (l *Logger) Close() error {
|
|
l.mu.Lock()
|
|
defer l.mu.Unlock()
|
|
|
|
if err := l.file.Close(); err != nil {
|
|
return fmt.Errorf("closing access log: %w", err)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// WithRequestID stores a proxy request ID in ctx.
|
|
func WithRequestID(ctx context.Context, requestID string) context.Context {
|
|
return context.WithValue(ctx, requestIDKey{}, requestID)
|
|
}
|
|
|
|
// RequestID returns the proxy request ID stored in ctx.
|
|
func RequestID(ctx context.Context) string {
|
|
requestID, _ := ctx.Value(requestIDKey{}).(string)
|
|
return requestID
|
|
}
|
|
|
|
// URLWithoutSecrets returns a URL without user information, query values, or fragments.
|
|
func URLWithoutSecrets(value *url.URL) string {
|
|
if value == nil {
|
|
return ""
|
|
}
|
|
|
|
clean := *value
|
|
clean.User = nil
|
|
clean.RawQuery = ""
|
|
clean.ForceQuery = false
|
|
clean.Fragment = ""
|
|
clean.RawFragment = ""
|
|
return clean.String()
|
|
}
|