@@ -3,6 +3,8 @@ package main
33import (
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
471611func 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