Skip to content

Commit 556517a

Browse files
committed
wip: http server test, one failing
1 parent d9f727f commit 556517a

File tree

5 files changed

+1055
-13
lines changed

5 files changed

+1055
-13
lines changed

collector/http_server.go

Lines changed: 281 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,281 @@
1+
package collector
2+
3+
import (
4+
"bufio"
5+
"fmt"
6+
"net"
7+
"net/http"
8+
"sync"
9+
"time"
10+
11+
"github.com/gofrs/uuid"
12+
)
13+
14+
// HTTPServerOptions configures the HTTP server collector
15+
type HTTPServerOptions struct {
16+
// MaxBodyBufferPool is the maximum size in bytes of the buffer pool
17+
MaxBodyBufferPool int64
18+
19+
// MaxBodySize is the maximum size in bytes of a single body
20+
MaxBodySize int64
21+
22+
// CaptureRequestBody indicates whether to capture request bodies
23+
CaptureRequestBody bool
24+
25+
// CaptureResponseBody indicates whether to capture response bodies
26+
CaptureResponseBody bool
27+
28+
// SkipPaths is a list of path prefixes to skip for request collection
29+
// Useful for excluding static files or the dashboard itself
30+
SkipPaths []string
31+
}
32+
33+
// DefaultHTTPServerOptions returns default options for the HTTP server collector
34+
func DefaultHTTPServerOptions() HTTPServerOptions {
35+
return HTTPServerOptions{
36+
MaxBodyBufferPool: DefaultBodyBufferSize,
37+
MaxBodySize: DefaultMaxBodySize,
38+
CaptureRequestBody: true,
39+
CaptureResponseBody: true,
40+
SkipPaths: []string{"/_devlog/"},
41+
}
42+
}
43+
44+
// HTTPServerRequest represents a captured HTTP server request/response pair
45+
type HTTPServerRequest struct {
46+
ID uuid.UUID
47+
Method string
48+
Path string
49+
URL string
50+
RemoteAddr string
51+
RequestTime time.Time
52+
ResponseTime time.Time
53+
StatusCode int
54+
RequestSize int64
55+
ResponseSize int64
56+
RequestHeaders http.Header
57+
ResponseHeaders http.Header
58+
RequestBody *Body
59+
ResponseBody *Body
60+
Error error
61+
}
62+
63+
// Duration returns the duration of the request
64+
func (r HTTPServerRequest) Duration() time.Duration {
65+
return r.ResponseTime.Sub(r.RequestTime)
66+
}
67+
68+
// HTTPServerCollector collects incoming HTTP requests
69+
type HTTPServerCollector struct {
70+
buffer *RingBuffer[HTTPServerRequest]
71+
mu sync.RWMutex
72+
bodyPool *BodyBufferPool
73+
options HTTPServerOptions
74+
}
75+
76+
// NewHTTPServerCollector creates a new collector for incoming HTTP requests
77+
func NewHTTPServerCollector(capacity uint64) *HTTPServerCollector {
78+
return NewHTTPServerCollectorWithOptions(capacity, DefaultHTTPServerOptions())
79+
}
80+
81+
// NewHTTPServerCollectorWithOptions creates a new collector with specified options
82+
func NewHTTPServerCollectorWithOptions(capacity uint64, options HTTPServerOptions) *HTTPServerCollector {
83+
return &HTTPServerCollector{
84+
buffer: NewRingBuffer[HTTPServerRequest](capacity),
85+
bodyPool: NewBodyBufferPool(options.MaxBodyBufferPool, options.MaxBodySize),
86+
options: options,
87+
}
88+
}
89+
90+
// GetRequests returns the most recent n HTTP server requests
91+
func (c *HTTPServerCollector) GetRequests(n uint64) []HTTPServerRequest {
92+
return c.buffer.GetRecords(n)
93+
}
94+
95+
// Add adds an HTTP server request to the collector
96+
func (c *HTTPServerCollector) Add(req HTTPServerRequest) {
97+
c.buffer.Add(req)
98+
}
99+
100+
// Middleware returns an http.Handler middleware that captures request/response data
101+
func (c *HTTPServerCollector) Middleware(next http.Handler) http.Handler {
102+
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
103+
// Check if this path should be skipped
104+
for _, prefix := range c.options.SkipPaths {
105+
if len(prefix) > 0 && len(r.URL.Path) >= len(prefix) && r.URL.Path[:len(prefix)] == prefix {
106+
// Skip this path
107+
next.ServeHTTP(w, r)
108+
return
109+
}
110+
}
111+
112+
// Generate a unique ID for this request
113+
id := generateID()
114+
115+
// Record start time
116+
requestTime := time.Now()
117+
118+
// Create a request record
119+
httpReq := HTTPServerRequest{
120+
ID: id,
121+
Method: r.Method,
122+
Path: r.URL.Path,
123+
URL: r.URL.String(),
124+
RemoteAddr: r.RemoteAddr,
125+
RequestTime: requestTime,
126+
RequestHeaders: cloneHeader(r.Header),
127+
}
128+
129+
// Capture the request body if present and configured to do so
130+
var requestBody *Body
131+
if r.Body != nil && c.options.CaptureRequestBody {
132+
// Save the original body
133+
originalBody := r.Body
134+
135+
// Create a body wrapper
136+
requestBody = NewBody(originalBody, c.bodyPool)
137+
138+
// Replace the request body with our wrapper
139+
r.Body = requestBody
140+
141+
// Store in our request record
142+
httpReq.RequestBody = requestBody
143+
}
144+
145+
// Create a response writer wrapper to capture the response
146+
crw := &captureResponseWriter{
147+
ResponseWriter: w,
148+
bodyPool: c.bodyPool,
149+
captureBody: c.options.CaptureResponseBody,
150+
}
151+
152+
// Call the next handler
153+
next.ServeHTTP(crw, r)
154+
155+
// Record end time
156+
responseTime := time.Now()
157+
httpReq.ResponseTime = responseTime
158+
159+
// Capture response data
160+
httpReq.StatusCode = crw.statusCode
161+
httpReq.ResponseHeaders = crw.Header()
162+
httpReq.ResponseBody = crw.body
163+
164+
// Add request size if available
165+
if requestBody != nil {
166+
httpReq.RequestSize = requestBody.Size()
167+
}
168+
169+
// Add response size if available
170+
if crw.body != nil {
171+
httpReq.ResponseSize = crw.body.Size()
172+
}
173+
174+
// Add to the collector
175+
c.Add(httpReq)
176+
})
177+
}
178+
179+
// captureResponseWriter is a wrapper for http.ResponseWriter that captures the response
180+
type captureResponseWriter struct {
181+
http.ResponseWriter
182+
statusCode int
183+
body *Body
184+
bodyPool *BodyBufferPool
185+
captureBody bool
186+
wroteHeader bool
187+
bodyCapturing bool
188+
}
189+
190+
// WriteHeader implements http.ResponseWriter
191+
func (crw *captureResponseWriter) WriteHeader(statusCode int) {
192+
if crw.wroteHeader {
193+
return
194+
}
195+
crw.wroteHeader = true
196+
crw.statusCode = statusCode
197+
crw.ResponseWriter.WriteHeader(statusCode)
198+
}
199+
200+
// Write implements http.ResponseWriter
201+
func (crw *captureResponseWriter) Write(b []byte) (int, error) {
202+
if !crw.wroteHeader {
203+
crw.WriteHeader(http.StatusOK)
204+
}
205+
206+
// If we're capturing the body and haven't set up the body capture yet
207+
if crw.captureBody && !crw.bodyCapturing {
208+
// Create a buffer to capture the response body
209+
buf := crw.bodyPool.GetBuffer()
210+
crw.body = &Body{
211+
buffer: buf,
212+
pool: crw.bodyPool,
213+
maxSize: crw.bodyPool.maxBufferSize,
214+
isFullyCaptured: true, // Since we're capturing directly, not via a reader
215+
}
216+
crw.bodyCapturing = true
217+
}
218+
219+
// First write to the original response writer
220+
n, err := crw.ResponseWriter.Write(b)
221+
if err != nil {
222+
return n, err
223+
}
224+
225+
// If we're capturing the body, store a copy in our buffer
226+
if crw.captureBody && crw.bodyCapturing && crw.body != nil {
227+
crw.body.mu.Lock()
228+
// Only write to buffer if we haven't exceeded max size
229+
if crw.body.size < crw.body.maxSize {
230+
// Determine how much we can write without exceeding max size
231+
toWrite := n
232+
if crw.body.size+int64(n) > crw.body.maxSize {
233+
toWrite = int(crw.body.maxSize - crw.body.size)
234+
crw.body.isTruncated = true
235+
}
236+
237+
if toWrite > 0 {
238+
// Write to the buffer directly
239+
crw.body.buffer.Write(b[:toWrite])
240+
crw.body.size += int64(toWrite)
241+
}
242+
}
243+
crw.body.mu.Unlock()
244+
}
245+
246+
return n, nil
247+
}
248+
249+
// Flush implements http.Flusher if the original response writer implements it
250+
func (crw *captureResponseWriter) Flush() {
251+
if flusher, ok := crw.ResponseWriter.(http.Flusher); ok {
252+
flusher.Flush()
253+
}
254+
}
255+
256+
// Hijack implements http.Hijacker if the original response writer implements it
257+
func (crw *captureResponseWriter) Hijack() (net.Conn, *bufio.ReadWriter, error) {
258+
if hijacker, ok := crw.ResponseWriter.(http.Hijacker); ok {
259+
return hijacker.Hijack()
260+
}
261+
return nil, nil, fmt.Errorf("response writer does not implement http.Hijacker")
262+
}
263+
264+
// Push implements http.Pusher if the original response writer implements it
265+
func (crw *captureResponseWriter) Push(target string, opts *http.PushOptions) error {
266+
if pusher, ok := crw.ResponseWriter.(http.Pusher); ok {
267+
return pusher.Push(target, opts)
268+
}
269+
return fmt.Errorf("response writer does not implement http.Pusher")
270+
}
271+
272+
// Helper to clone an http.Header, similar to Header.Clone() in newer Go versions
273+
func cloneHeader(h http.Header) http.Header {
274+
h2 := make(http.Header, len(h))
275+
for k, vv := range h {
276+
vv2 := make([]string, len(vv))
277+
copy(vv2, vv)
278+
h2[k] = vv2
279+
}
280+
return h2
281+
}

0 commit comments

Comments
 (0)