Skip to content

Commit f93aa6d

Browse files
rubysclaude
andcommitted
Add JSON access logging for tenant requests
Implement structured JSON logging equivalent to nginx log format for requests proxied to tenant applications. Key features: - Response wrapper to capture status code, body size, and request timing - JSON access log format matching nginx variables: @timestamp, client_ip, remote_user, method, uri, protocol, status, body_bytes_sent, request_id, request_time, referer, user_agent, fly_request_id, tenant_name - Automatic request ID generation when not present - Tenant name extraction from URL paths - Proper Fly-Request-Id header capture from Fly.io platform 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude <noreply@anthropic.com>
1 parent 9a8ea7f commit f93aa6d

1 file changed

Lines changed: 168 additions & 1 deletion

File tree

cmd/navigator/main.go

Lines changed: 168 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,8 @@ package main
33
import (
44
"bytes"
55
"context"
6+
"crypto/rand"
7+
"encoding/hex"
68
"encoding/json"
79
"fmt"
810
"io"
@@ -467,6 +469,144 @@ type VectorWriter struct {
467469
mutex sync.Mutex
468470
}
469471

472+
// ResponseWriter wrapper to capture response status and size
473+
type responseRecorder struct {
474+
http.ResponseWriter
475+
statusCode int
476+
size int
477+
startTime time.Time
478+
}
479+
480+
func (r *responseRecorder) WriteHeader(code int) {
481+
r.statusCode = code
482+
r.ResponseWriter.WriteHeader(code)
483+
}
484+
485+
func (r *responseRecorder) Write(data []byte) (int, error) {
486+
n, err := r.ResponseWriter.Write(data)
487+
r.size += n
488+
return n, err
489+
}
490+
491+
// AccessLogEntry represents a structured access log entry matching nginx format
492+
type AccessLogEntry struct {
493+
Timestamp string `json:"@timestamp"`
494+
ClientIP string `json:"client_ip"`
495+
RemoteUser string `json:"remote_user"`
496+
Method string `json:"method"`
497+
URI string `json:"uri"`
498+
Protocol string `json:"protocol"`
499+
Status int `json:"status"`
500+
BodyBytesSent int `json:"body_bytes_sent"`
501+
RequestID string `json:"request_id"`
502+
RequestTime string `json:"request_time"`
503+
Referer string `json:"referer"`
504+
UserAgent string `json:"user_agent"`
505+
FlyRequestID string `json:"fly_request_id"`
506+
TenantName string `json:"tenant_name,omitempty"`
507+
}
508+
509+
// logTenantRequest logs a tenant request in JSON format matching nginx log format
510+
func logTenantRequest(r *http.Request, recorder *responseRecorder, tenantName string) {
511+
// Get client IP (prefer X-Forwarded-For if available)
512+
clientIP := r.Header.Get("X-Forwarded-For")
513+
if clientIP == "" {
514+
clientIP = r.RemoteAddr
515+
// Remove port if present
516+
if host, _, err := net.SplitHostPort(clientIP); err == nil {
517+
clientIP = host
518+
}
519+
}
520+
521+
// Get remote user from basic auth if available
522+
remoteUser := "-"
523+
if username, _, ok := r.BasicAuth(); ok {
524+
remoteUser = username
525+
}
526+
527+
// Calculate request duration
528+
requestTime := fmt.Sprintf("%.3f", time.Since(recorder.startTime).Seconds())
529+
530+
// Get headers with fallbacks
531+
referer := r.Header.Get("Referer")
532+
if referer == "" {
533+
referer = "-"
534+
}
535+
userAgent := r.Header.Get("User-Agent")
536+
if userAgent == "" {
537+
userAgent = "-"
538+
}
539+
540+
entry := AccessLogEntry{
541+
Timestamp: time.Now().Format(time.RFC3339),
542+
ClientIP: clientIP,
543+
RemoteUser: remoteUser,
544+
Method: r.Method,
545+
URI: r.RequestURI,
546+
Protocol: r.Proto,
547+
Status: recorder.statusCode,
548+
BodyBytesSent: recorder.size,
549+
RequestID: r.Header.Get("X-Request-Id"),
550+
RequestTime: requestTime,
551+
Referer: referer,
552+
UserAgent: userAgent,
553+
FlyRequestID: r.Header.Get("Fly-Request-Id"),
554+
TenantName: tenantName,
555+
}
556+
557+
data, _ := json.Marshal(entry)
558+
fmt.Fprintln(os.Stdout, string(data))
559+
}
560+
561+
// extractTenantName extracts the tenant name from a URL path
562+
// Examples: "/showcase/2025/livermore/district-showcase/" -> "livermore-district-showcase"
563+
// "/2025/adelaide/adelaide-combined/" -> "adelaide-combined"
564+
func extractTenantName(path string) string {
565+
// Remove leading/trailing slashes and split by '/'
566+
path = strings.Trim(path, "/")
567+
parts := strings.Split(path, "/")
568+
569+
// Skip empty parts
570+
var validParts []string
571+
for _, part := range parts {
572+
if part != "" {
573+
validParts = append(validParts, part)
574+
}
575+
}
576+
577+
if len(validParts) < 2 {
578+
return ""
579+
}
580+
581+
// Pattern 1: /showcase/YEAR/TENANT1/TENANT2/...
582+
if validParts[0] == "showcase" && len(validParts) >= 4 {
583+
// Skip "showcase" and year, join tenant parts
584+
return strings.Join(validParts[2:4], "-")
585+
}
586+
587+
// Pattern 2: /YEAR/TENANT1/TENANT2/...
588+
if len(validParts) >= 3 {
589+
// Skip year, join tenant parts
590+
return strings.Join(validParts[1:3], "-")
591+
}
592+
593+
// Pattern 3: /YEAR/TENANT
594+
if len(validParts) >= 2 {
595+
// Skip year, return tenant
596+
return validParts[1]
597+
}
598+
599+
return ""
600+
}
601+
602+
// generateRequestID generates a random request ID similar to nginx $request_id
603+
func generateRequestID() string {
604+
bytes := make([]byte, 16)
605+
rand.Read(bytes)
606+
return hex.EncodeToString(bytes)
607+
}
608+
609+
470610
// NewVectorWriter creates a new Vector writer
471611
func NewVectorWriter(socket string) *VectorWriter {
472612
return &VectorWriter{socket: socket}
@@ -2145,7 +2285,24 @@ func CreateHandler(config *Config, manager *AppManager, auth *BasicAuth, idleMan
21452285
idleManager.RequestStarted()
21462286
defer idleManager.RequestFinished()
21472287

2148-
slog.Debug("Request received", "method", r.Method, "path", r.URL.Path)
2288+
// Generate request ID if not already present
2289+
requestID := r.Header.Get("X-Request-Id")
2290+
if requestID == "" {
2291+
requestID = generateRequestID()
2292+
r.Header.Set("X-Request-Id", requestID)
2293+
}
2294+
2295+
// Wrap response writer to capture response data for access logging
2296+
recorder := &responseRecorder{
2297+
ResponseWriter: w,
2298+
statusCode: 200, // default status code
2299+
startTime: time.Now(),
2300+
}
2301+
2302+
// Use recorder for the rest of the request processing
2303+
w = recorder
2304+
2305+
slog.Debug("Request received", "method", r.Method, "path", r.URL.Path, "request_id", requestID)
21492306

21502307
// Handle sticky sessions early (before rewrites/redirects)
21512308
if handleStickySession(w, r, config) {
@@ -2273,6 +2430,16 @@ func CreateHandler(config *Config, manager *AppManager, auth *BasicAuth, idleMan
22732430
return
22742431
}
22752432

2433+
// Extract tenant name from path for access logging
2434+
tenantName := extractTenantName(r.URL.Path)
2435+
2436+
// Schedule tenant access log after request completes
2437+
defer func() {
2438+
if tenantName != "" {
2439+
logTenantRequest(r, recorder, tenantName)
2440+
}
2441+
}()
2442+
22762443
// Proxy to web app
22772444
target, _ := url.Parse(fmt.Sprintf("http://localhost:%d", app.Port))
22782445

0 commit comments

Comments
 (0)