Skip to content

Commit 1f77c70

Browse files
authored
feat(http1): add diagnostic tooling for HTTP traffic capture (#11)
Introduced a new `DiagnosticWriter` for capturing raw HTTP traffic in debug builds. It writes hex dumps to a `.log` file and base64-encoded payloads to a `.json` file. Includes usage instructions and example output structure in the new `diagnostics.go` file.
1 parent f0920b4 commit 1f77c70

File tree

1 file changed

+258
-0
lines changed

1 file changed

+258
-0
lines changed
+258
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,258 @@
1+
// Package http1 provides HTTP/1.x protocol handling.
2+
// This file contains diagnostic tooling for debug builds.
3+
// Build with -tags=debug to include this functionality.
4+
//
5+
// DiagnosticWriter Usage:
6+
//
7+
// 1. Create a new writer with a target directory and unique suffix:
8+
// writer := NewDiagnosticWriter("/path/to/output", "conn123", logger)
9+
//
10+
// 2. Write raw HTTP traffic as it's captured:
11+
// writer.Write(rawData)
12+
//
13+
// 3. Close the writer when done (e.g., connection closes):
14+
// writer.Close()
15+
//
16+
// The writer generates two files per session:
17+
// - {timestamp}_{suffix}.log: Human-readable hex dumps with timestamps
18+
// - {timestamp}_{suffix}.json: Machine-parseable base64 payloads
19+
//
20+
// Example output location:
21+
//
22+
// /path/to/output/
23+
// ├── http_dump_20240423_170800_conn123.log
24+
// └── http_dump_20240423_170800_conn123.json
25+
package http1
26+
27+
import (
28+
"encoding/base64"
29+
"encoding/json"
30+
"fmt"
31+
"os"
32+
"path/filepath"
33+
"sync"
34+
"time"
35+
36+
"go.uber.org/zap"
37+
)
38+
39+
// PayloadEntry represents a captured HTTP payload with ordering metadata
40+
type PayloadEntry struct {
41+
Sequence int `json:"sequence"`
42+
Data string `json:"data"` // base64 encoded
43+
}
44+
45+
// DiagnosticWriter captures raw HTTP traffic for debugging purposes.
46+
// It writes hex dumps to a .log file and base64-encoded payloads to a .json file.
47+
type DiagnosticWriter struct {
48+
enabled bool // whether writer is active
49+
directory string // output directory for diagnostic files
50+
baseFilename string // shared prefix for .log and .json files
51+
logFilePath string // path to detailed hex dump log
52+
jsonFilePath string // path to payload JSON array
53+
logger *zap.Logger
54+
payloads [][]byte // ordered list of captured payloads
55+
payloadsMutex sync.Mutex // guards payloads slice
56+
sequence int // monotonic counter for ordering
57+
}
58+
59+
// NewDiagnosticWriter creates a writer that outputs to timestamp-based files in the given directory.
60+
// Returns a disabled writer if directory creation fails.
61+
func NewDiagnosticWriter(directory string, suffix string, logger *zap.Logger) *DiagnosticWriter {
62+
if logger == nil {
63+
logger = zap.NewNop() // Use a no-op logger if none provided
64+
}
65+
66+
// Create timestamp-based base filename (e.g., http_dump_20250423_170800)
67+
timestamp := time.Now().Format("20060102_150405")
68+
baseFilename := fmt.Sprintf("http_dump_%s_%s", timestamp, suffix)
69+
70+
// Ensure the diagnostics directory exists
71+
if err := os.MkdirAll(directory, 0755); err != nil {
72+
logger.Error("Failed to create diagnostics directory, disabling writer",
73+
zap.String("directory", directory),
74+
zap.Error(err))
75+
// Return a disabled writer if directory creation fails
76+
return &DiagnosticWriter{enabled: false, logger: logger}
77+
}
78+
79+
// Construct full paths for both log and JSON files
80+
logFilePath := filepath.Join(directory, baseFilename+".log")
81+
jsonFilePath := filepath.Join(directory, baseFilename+".json")
82+
83+
logger.Info("Diagnostic writer initialized",
84+
zap.String("logFile", logFilePath),
85+
zap.String("jsonFile", jsonFilePath))
86+
87+
return &DiagnosticWriter{
88+
enabled: true,
89+
directory: directory,
90+
baseFilename: baseFilename,
91+
logFilePath: logFilePath,
92+
jsonFilePath: jsonFilePath,
93+
logger: logger,
94+
payloads: make([][]byte, 0, 100), // Initialize slice with some capacity
95+
payloadsMutex: sync.Mutex{}, // Initialize the mutex
96+
sequence: 0, // Initialize sequence counter
97+
}
98+
}
99+
100+
// Write captures and stores a copy of the raw data, writing hex dumps to the log file.
101+
func (d *DiagnosticWriter) Write(data []byte) {
102+
if !d.enabled {
103+
return // Do nothing if the writer is disabled
104+
}
105+
106+
// --- Store payload for JSON ---
107+
// We must make a copy because the input slice 'data' might be reused
108+
// by the caller after this function returns.
109+
dataCopy := make([]byte, len(data))
110+
copy(dataCopy, data)
111+
112+
// Lock the mutex before accessing the shared payloads slice
113+
d.payloadsMutex.Lock()
114+
d.payloads = append(d.payloads, dataCopy)
115+
d.sequence++
116+
currentSeq := d.sequence
117+
d.payloadsMutex.Unlock() // Unlock immediately after appending
118+
// -----------------------------
119+
120+
// --- Append to log file (existing functionality) ---
121+
// This writes the hex dump and raw string to the .log file for each chunk
122+
d.appendToLogFile(d.logFilePath, data, currentSeq)
123+
// -------------------------------------------------
124+
}
125+
126+
// Close writes accumulated payloads to the JSON file and disables the writer.
127+
func (d *DiagnosticWriter) Close() error {
128+
if !d.enabled {
129+
return nil // Nothing to do if already closed or disabled initially
130+
}
131+
132+
d.logger.Info("Closing diagnostic writer", zap.Any("payloads", d.payloads))
133+
134+
// Lock the mutex to safely access and modify shared state (payloads, enabled)
135+
d.payloadsMutex.Lock()
136+
defer d.payloadsMutex.Unlock() // Ensure mutex is unlocked even if errors occur
137+
138+
// Mark as disabled *now* to prevent any writes happening concurrently during the file write.
139+
d.enabled = false
140+
141+
// Create ordered payload entries with sequence numbers
142+
entries := make([]PayloadEntry, len(d.payloads))
143+
for i, p := range d.payloads {
144+
entries[i] = PayloadEntry{
145+
Sequence: i + 1, // Use 1-based sequence numbers
146+
Data: base64.StdEncoding.EncodeToString(p),
147+
}
148+
}
149+
150+
// Marshal the array of PayloadEntry structs into JSON format with indentation
151+
jsonData, err := json.MarshalIndent(entries, "", " ")
152+
if err != nil {
153+
d.logger.Error("Failed to marshal payloads to JSON", zap.Error(err))
154+
// Clear slice to release memory even on error, then return
155+
d.payloads = nil
156+
return fmt.Errorf("failed to marshal JSON data: %w", err)
157+
}
158+
159+
// Write the JSON data to the target file
160+
err = os.WriteFile(d.jsonFilePath, jsonData, 0644)
161+
if err != nil {
162+
d.logger.Error("Failed to write JSON payload file", zap.String("file", d.jsonFilePath), zap.Error(err))
163+
// Clear slice and return error
164+
d.payloads = nil
165+
return fmt.Errorf("failed to write JSON file '%s': %w", d.jsonFilePath, err)
166+
}
167+
168+
d.logger.Info("Successfully wrote JSON payload file",
169+
zap.String("file", d.jsonFilePath),
170+
zap.Int("payloadCount", len(entries)))
171+
172+
// Clear the payloads slice to release the stored data from memory after successful write
173+
d.payloads = nil
174+
175+
return nil
176+
}
177+
178+
// appendToLogFile writes a timestamped hex dump and raw string representation of the data.
179+
func (d *DiagnosticWriter) appendToLogFile(filepath string, data []byte, sequence int) {
180+
// Open the log file in append mode, creating it if it doesn't exist.
181+
f, err := os.OpenFile(filepath, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0644)
182+
if err != nil {
183+
d.logger.Error("Failed to open diagnostic log file for appending", zap.String("file", filepath), zap.Error(err))
184+
return // Cannot proceed if file can't be opened
185+
}
186+
defer f.Close() // Ensure file is closed when function exits
187+
188+
// Write a separator block including a timestamp, sequence number, and chunk length
189+
timestamp := time.Now().Format("2006-01-02 15:04:05.000")
190+
separator := fmt.Sprintf("\n\n===== CHUNK #%d AT %s (LENGTH: %d) =====\n", sequence, timestamp, len(data))
191+
if _, err := f.WriteString(separator); err != nil {
192+
d.logger.Warn("Failed to write separator to log file", zap.Error(err))
193+
// Continue trying to write data even if separator fails
194+
}
195+
196+
// Generate and write the hex dump of the data
197+
hexDump := formatHexDump(data)
198+
if _, err := f.WriteString(hexDump); err != nil {
199+
d.logger.Warn("Failed to write hex dump to log file", zap.Error(err))
200+
// Continue trying to write raw string
201+
}
202+
203+
// Add the raw string representation (be cautious with binary data)
204+
// This might produce unreadable output if data is not text.
205+
strDump := fmt.Sprintf("\n----- RAW STRING -----\n%s\n----- END RAW -----\n", string(data))
206+
if _, err := f.WriteString(strDump); err != nil {
207+
d.logger.Warn("Failed to write raw string to log file", zap.Error(err))
208+
}
209+
}
210+
211+
// formatHexDump creates a standard 16-byte-width hex dump with ASCII representation.
212+
func formatHexDump(data []byte) string {
213+
var result string
214+
const bytesPerRow = 16 // Standard width for hex dumps
215+
216+
for i := 0; i < len(data); i += bytesPerRow {
217+
// Add the offset at the beginning of the line
218+
result += fmt.Sprintf("%08x ", i)
219+
220+
// Get the slice for the current row (up to bytesPerRow)
221+
chunk := data[i:]
222+
if len(chunk) > bytesPerRow {
223+
chunk = chunk[:bytesPerRow]
224+
}
225+
226+
// Add the hex byte representation
227+
for j := range bytesPerRow {
228+
if j < len(chunk) {
229+
result += fmt.Sprintf("%02x ", chunk[j]) // Print byte as 2-digit hex
230+
} else {
231+
result += " " // Pad with spaces if row is shorter
232+
}
233+
// Add an extra space halfway through the hex bytes for readability
234+
if j == bytesPerRow/2-1 {
235+
result += " "
236+
}
237+
}
238+
239+
// Add the ASCII representation part
240+
result += " |" // Separator
241+
for j := range chunk {
242+
b := chunk[j]
243+
// Use '.' for non-printable characters, otherwise print the character
244+
if b >= 32 && b <= 126 {
245+
result += string(b)
246+
} else {
247+
result += "."
248+
}
249+
}
250+
// Pad the ASCII part with spaces if the row is shorter than bytesPerRow
251+
for j := len(chunk); j < bytesPerRow; j++ {
252+
result += " "
253+
}
254+
result += "|\n" // End of ASCII part and newline
255+
}
256+
257+
return result
258+
}

0 commit comments

Comments
 (0)