Enhance interaction recording and analysis features

This commit is contained in:
Tobias Gesellchen
2026-02-15 16:52:44 +01:00
parent 505e6dd760
commit a453059d6d
13 changed files with 1182 additions and 29 deletions
+45
View File
@@ -4,6 +4,7 @@ import (
"io"
"net/http/httptest"
"os"
"path/filepath"
"strings"
"testing"
)
@@ -64,6 +65,7 @@ func TestLoggingProxy_LogRequest(t *testing.T) {
defer func() { _ = os.Unsetenv("LOG_PROXY_BODY") }()
lp := NewLoggingProxy("http://example.com", true)
lp.LogBody = true
body := "test body content"
req := httptest.NewRequest("POST", "http://example.com/api", strings.NewReader(body))
@@ -77,4 +79,47 @@ func TestLoggingProxy_LogRequest(t *testing.T) {
if string(readBody) != body {
t.Errorf("Request body was consumed or changed, got %q, want %q", string(readBody), body)
}
// Test truncation
lp.MaxBodySize = 4
req2 := httptest.NewRequest("POST", "http://example.com/api", strings.NewReader("1234567890"))
req2.Header.Set("Content-Type", "text/plain")
lp.LogRequest(req2)
}
func TestLoggingProxy_LogResponse(t *testing.T) {
lp := NewLoggingProxy("http://example.com", true)
lp.LogBody = true
body := "response content"
req := httptest.NewRequest("GET", "http://example.com/api", nil)
w := httptest.NewRecorder()
w.Header().Set("Content-Type", "text/plain")
_, _ = w.WriteString(body)
res := w.Result()
res.Request = req
lp.LogResponse(res)
// Check if body is still readable
readBody, _ := io.ReadAll(res.Body)
if string(readBody) != body {
t.Errorf("Response body was consumed or changed, got %q, want %q", string(readBody), body)
}
// Test with recorder
tmpDir, _ := os.MkdirTemp("", "proxy-recorder-test")
defer os.RemoveAll(tmpDir)
recorder := NewRecorder(tmpDir)
lp.SetRecorder(recorder)
lp.RecordEnabled = true
lp.LogResponse(res)
// Verify recording exists
interactionsDir := filepath.Join(tmpDir, "interactions", recorder.SessionID, "upstream", "api")
files, _ := os.ReadDir(interactionsDir)
if len(files) == 0 {
t.Error("LogResponse did not record the interaction")
}
}
+211
View File
@@ -26,6 +26,26 @@ type Recorder struct {
mu sync.Mutex
}
// InteractionStats represents statistics for recorded interactions.
type InteractionStats struct {
TotalRequests int `json:"total_requests"`
ByService map[string]int `json:"by_service"`
BySession map[string]int `json:"by_session"`
}
// Interaction represents a single recorded HTTP interaction.
type Interaction struct {
ID string `json:"id"`
Session string `json:"session"`
Category string `json:"category"`
Method string `json:"method"`
Path string `json:"path"`
File string `json:"file"`
Counter int `json:"counter"`
Status int `json:"status"`
Timestamp string `json:"timestamp"`
}
// NewRecorder creates a new HTTP interaction recorder.
func NewRecorder(baseDir string) *Recorder {
sessionID := time.Now().Format("20060102-150405") + "-" + fmt.Sprintf("%d", os.Getpid())
@@ -221,3 +241,194 @@ func (r *Recorder) updateEnvFile(newVars map[string]string) error {
return os.WriteFile(envFile, data, 0644)
}
// GetInteractionStats returns statistics about recorded interactions.
func (r *Recorder) GetInteractionStats() (*InteractionStats, error) {
stats := &InteractionStats{
ByService: make(map[string]int),
BySession: make(map[string]int),
}
interactionsDir := filepath.Join(r.BaseDir, "interactions")
if _, err := os.Stat(interactionsDir); os.IsNotExist(err) {
return stats, nil
}
err := filepath.Walk(interactionsDir, func(path string, info os.FileInfo, err error) error {
if err != nil {
return err
}
if !info.IsDir() && strings.HasSuffix(info.Name(), ".http") {
stats.TotalRequests++
// Extract category (self/upstream) and session from path
// Path is like: .../interactions/<session>/<category>/...
rel, err := filepath.Rel(interactionsDir, path)
if err != nil {
return err
}
parts := strings.Split(rel, string(filepath.Separator))
if len(parts) >= 2 {
sessionID := parts[0]
category := parts[1]
stats.BySession[sessionID]++
stats.ByService[category]++
}
}
return nil
})
return stats, err
}
// ListInteractions returns a list of recorded interactions.
func (r *Recorder) ListInteractions(sessionFilter, categoryFilter, sinceFilter string) ([]Interaction, error) {
interactions := make([]Interaction, 0)
interactionsDir := filepath.Join(r.BaseDir, "interactions")
if _, err := os.Stat(interactionsDir); os.IsNotExist(err) {
return interactions, nil
}
err := filepath.Walk(interactionsDir, func(path string, info os.FileInfo, err error) error {
if err != nil {
return err
}
if info.IsDir() || !strings.HasSuffix(info.Name(), ".http") {
return nil
}
rel, err := filepath.Rel(interactionsDir, path)
if err != nil {
return err
}
parts := strings.Split(rel, string(filepath.Separator))
if len(parts) < 3 {
return nil
}
sessionID, category := parts[0], parts[1]
if (sessionFilter != "" && sessionID != sessionFilter) || (categoryFilter != "" && category != categoryFilter) {
return nil
}
interaction, ok := r.parseInteractionFile(rel, path, parts)
if !ok {
return nil
}
if sinceFilter != "" && interaction.Timestamp != "" {
fullTS := r.getFullTimestamp(sessionID, interaction.ID)
normalizedSince := strings.ReplaceAll(strings.ReplaceAll(sinceFilter, ":", "-"), " ", "-")
if fullTS != "" && fullTS < normalizedSince {
return nil
}
}
interactions = append(interactions, interaction)
return nil
})
return interactions, err
}
func (r *Recorder) parseInteractionFile(rel, path string, parts []string) (Interaction, bool) {
sessionID, category := parts[0], parts[1]
filename := parts[len(parts)-1]
fnParts := strings.Split(strings.TrimSuffix(filename, ".http"), "-")
date := ""
if len(sessionID) >= 8 {
date = sessionID[0:4] + "-" + sessionID[4:6] + "-" + sessionID[6:8]
}
timestamp := ""
if len(fnParts) >= 4 {
timeStr := fnParts[1] + ":" + fnParts[2] + ":" + fnParts[3]
timestamp = timeStr
if date != "" {
timestamp = date + " " + timeStr
}
}
requestPath := "/" + strings.Join(parts[2:len(parts)-1], "/")
if requestPath == "/root" {
requestPath = "/"
}
method, counter := "UNKNOWN", 0
if len(fnParts) >= 1 {
_, _ = fmt.Sscanf(fnParts[0], "%d", &counter)
}
if len(fnParts) >= 5 {
method = fnParts[4]
}
return Interaction{
ID: filename,
Session: sessionID,
Category: category,
Method: method,
Path: requestPath,
File: rel,
Counter: counter,
Status: r.peekStatus(path),
Timestamp: timestamp,
}, true
}
func (r *Recorder) getFullTimestamp(sessionID, filename string) string {
if len(sessionID) < 8 {
return ""
}
date := sessionID[0:4] + "-" + sessionID[4:6] + "-" + sessionID[6:8]
fnParts := strings.Split(strings.TrimSuffix(filename, ".http"), "-")
if len(fnParts) < 4 {
return ""
}
return date + "-" + fnParts[1] + "-" + fnParts[2] + "-" + fnParts[3]
}
func (r *Recorder) peekStatus(path string) int {
content, err := os.ReadFile(path)
if err != nil {
return 0
}
lines := strings.Split(string(content), "\n")
for _, line := range lines {
if !strings.Contains(line, "// Response:") {
continue
}
trimmedLine := strings.TrimPrefix(strings.TrimSpace(line), "//")
trimmedLine = strings.TrimPrefix(strings.TrimSpace(trimmedLine), "Response:")
trimmedLine = strings.TrimSpace(trimmedLine)
status := 0
_, _ = fmt.Sscanf(trimmedLine, "%d", &status)
return status
}
return 0
}
// GetInteractionContent returns the raw content of a recorded interaction.
func (r *Recorder) GetInteractionContent(relPath string) ([]byte, error) {
fullPath := filepath.Join(r.BaseDir, "interactions", relPath)
return os.ReadFile(fullPath)
}
+361
View File
@@ -1,7 +1,9 @@
package proxy
import (
"bytes"
"encoding/json"
"io"
"net/http"
"net/url"
"os"
@@ -339,3 +341,362 @@ func TestRecorder_EnvFile(t *testing.T) {
t.Errorf("Expected ip to be 192.168.178.35, got %s", content["session"]["ip"])
}
}
func TestRecorder_GetInteractionStats(t *testing.T) {
tmpDir, err := os.MkdirTemp("", "recorder-stats-test")
if err != nil {
t.Fatalf("failed to create temp dir: %v", err)
}
defer os.RemoveAll(tmpDir)
r := NewRecorder(tmpDir)
r.SessionID = "20260215-120000-12345"
// Create some dummy interactions
files := []string{
"interactions/20260215-120000-12345/self/setup/0001-12-00-01.000-GET.http",
"interactions/20260215-120000-12345/upstream/marge/0002-12-00-02.000-POST.http",
"interactions/20260215-130000-67890/self/setup/0001-13-00-01.000-GET.http",
}
for _, f := range files {
path := filepath.Join(tmpDir, f)
os.MkdirAll(filepath.Dir(path), 0755)
os.WriteFile(path, []byte("test"), 0644)
}
stats, err := r.GetInteractionStats()
if err != nil {
t.Fatalf("GetInteractionStats failed: %v", err)
}
if stats.TotalRequests != 3 {
t.Errorf("Expected 3 total requests, got %d", stats.TotalRequests)
}
if stats.ByService["self"] != 2 {
t.Errorf("Expected 2 self requests, got %d", stats.ByService["self"])
}
if stats.ByService["upstream"] != 1 {
t.Errorf("Expected 1 upstream request, got %d", stats.ByService["upstream"])
}
if stats.BySession["20260215-120000-12345"] != 2 {
t.Errorf("Expected 2 requests for session 1, got %d", stats.BySession["20260215-120000-12345"])
}
}
func TestRecorder_ListInteractions(t *testing.T) {
tmpDir, err := os.MkdirTemp("", "recorder-list-test")
if err != nil {
t.Fatalf("failed to create temp dir: %v", err)
}
defer os.RemoveAll(tmpDir)
r := NewRecorder(tmpDir)
session1 := "20260215-120000-12345"
session2 := "20260215-130000-67890"
// Create some dummy interactions
files := []struct {
path string
content string
}{
{
path: filepath.Join("interactions", session1, "self", "setup", "0001-12-00-01.555-GET.http"),
content: "### GET /setup\n\n> {% \n // Response: 200 OK\n%}\n",
},
{
path: filepath.Join("interactions", session1, "upstream", "marge", "0002-12-00-02.000-POST.http"),
content: "### POST /marge\n\n> {% \n // Response: 201 Created\n%}\n",
},
{
path: filepath.Join("interactions", session2, "self", "info", "0001-13-00-05.000-GET.http"),
content: "### GET /info\n\n> {% \n // Response: 404 Not Found\n%}\n",
},
}
for _, f := range files {
path := filepath.Join(tmpDir, f.path)
os.MkdirAll(filepath.Dir(path), 0755)
os.WriteFile(path, []byte(f.content), 0644)
}
t.Run("List_all", func(t *testing.T) {
list, err := r.ListInteractions("", "", "")
if err != nil {
t.Fatalf("ListInteractions failed: %v", err)
}
if len(list) != 3 {
t.Errorf("Expected 3 interactions, got %d", len(list))
}
})
t.Run("Filter_by_session", func(t *testing.T) {
list, err := r.ListInteractions(session1, "", "")
if err != nil {
t.Fatalf("ListInteractions failed: %v", err)
}
if len(list) != 2 {
t.Errorf("Expected 2 interactions for session1, got %d", len(list))
}
for _, i := range list {
if i.Session != session1 {
t.Errorf("Expected session %s, got %s", session1, i.Session)
}
}
})
t.Run("Filter_by_category", func(t *testing.T) {
list, err := r.ListInteractions("", "upstream", "")
if err != nil {
t.Fatalf("ListInteractions failed: %v", err)
}
if len(list) != 1 {
t.Errorf("Expected 1 upstream interaction, got %d", len(list))
}
if list[0].Category != "upstream" {
t.Errorf("Expected category upstream, got %s", list[0].Category)
}
})
t.Run("Check_enhanced_fields", func(t *testing.T) {
list, err := r.ListInteractions(session1, "self", "")
if err != nil {
t.Fatalf("ListInteractions failed: %v", err)
}
if len(list) == 0 {
t.Fatal("Expected at least one interaction")
}
i := list[0]
if i.Counter != 1 {
t.Errorf("Expected counter 1, got %d", i.Counter)
}
if i.Status != 200 {
t.Errorf("Expected status 200, got %d", i.Status)
}
if i.Method != "GET" {
t.Errorf("Expected method GET, got %s", i.Method)
}
if i.Timestamp != "2026-02-15 12:00:01.555" {
t.Errorf("Expected timestamp 2026-02-15 12:00:01.555, got %s", i.Timestamp)
}
if i.Path != "/setup" {
t.Errorf("Expected path /setup, got %s", i.Path)
}
})
t.Run("Filter_by_since", func(t *testing.T) {
// session1 has 2026-02-15 12:00:01.555 and 12:00:02.000
// session2 has 2026-02-15 13:00:05.000
list, err := r.ListInteractions("", "", "2026-02-15 12:30:00")
if err != nil {
t.Fatalf("ListInteractions failed: %v", err)
}
if len(list) != 1 {
t.Errorf("Expected 1 interaction since 12:30:00, got %d", len(list))
}
if list[0].Session != session2 {
t.Errorf("Expected session2, got %s", list[0].Session)
}
list, err = r.ListInteractions("", "", "2026-02-15 12:00:01.600")
if err != nil {
t.Fatalf("ListInteractions failed: %v", err)
}
// Should include 12:00:02.000 and 13:00:05.000
if len(list) != 2 {
t.Errorf("Expected 2 interactions since 12:00:01.600, got %d", len(list))
}
})
}
func TestRecorder_GetInteractionContent(t *testing.T) {
tmpDir, err := os.MkdirTemp("", "recorder-content-test")
if err != nil {
t.Fatalf("failed to create temp dir: %v", err)
}
defer os.RemoveAll(tmpDir)
r := NewRecorder(tmpDir)
relPath := filepath.Join(r.SessionID, "self", "test", "0001-GET.http")
fullPath := filepath.Join(tmpDir, "interactions", relPath)
os.MkdirAll(filepath.Dir(fullPath), 0755)
expectedContent := "test content"
os.WriteFile(fullPath, []byte(expectedContent), 0644)
content, err := r.GetInteractionContent(relPath)
if err != nil {
t.Fatalf("GetInteractionContent failed: %v", err)
}
if string(content) != expectedContent {
t.Errorf("Expected %s, got %s", expectedContent, string(content))
}
_, err = r.GetInteractionContent("non-existent")
if err == nil {
t.Error("Expected error for non-existent file, got nil")
}
}
func TestRecorder_Record_FullExchange(t *testing.T) {
tmpDir, err := os.MkdirTemp("", "recorder-full-test")
if err != nil {
t.Fatalf("failed to create temp dir: %v", err)
}
defer os.RemoveAll(tmpDir)
r := NewRecorder(tmpDir)
req := &http.Request{
Method: "POST",
URL: &url.URL{
Path: "/test",
},
Header: make(http.Header),
Body: io.NopCloser(strings.NewReader("request body")),
}
req.Header.Set("Content-Type", "text/plain")
res := &http.Response{
StatusCode: 200,
Header: make(http.Header),
Body: io.NopCloser(strings.NewReader("response body")),
Request: req,
}
res.Header.Set("Content-Type", "application/json")
err = r.Record("self", req, res)
if err != nil {
t.Fatalf("Record failed: %v", err)
}
// Verify file content
interactionsDir := filepath.Join(tmpDir, "interactions", r.SessionID, "self", "test")
files, _ := os.ReadDir(interactionsDir)
if len(files) == 0 {
t.Fatal("No recording file found")
}
content, _ := os.ReadFile(filepath.Join(interactionsDir, files[0].Name()))
contentStr := string(content)
if !strings.Contains(contentStr, "request body") {
t.Error("Recording does not contain request body")
}
if !strings.Contains(contentStr, "Response: 200 OK") {
t.Error("Recording does not contain response status")
}
if !strings.Contains(contentStr, "response body") {
t.Error("Recording does not contain response body")
}
}
func TestRecorder_Record_BinaryResponse(t *testing.T) {
tmpDir, err := os.MkdirTemp("", "recorder-binary-test")
if err != nil {
t.Fatalf("failed to create temp dir: %v", err)
}
defer os.RemoveAll(tmpDir)
r := NewRecorder(tmpDir)
req := &http.Request{
Method: "GET",
URL: &url.URL{Path: "/image"},
}
res := &http.Response{
StatusCode: 200,
Header: make(http.Header),
Body: io.NopCloser(bytes.NewBuffer([]byte{0x00, 0x01, 0x02, 0x03})),
Request: req,
}
res.Header.Set("Content-Type", "image/png")
err = r.Record("self", req, res)
if err != nil {
t.Fatalf("Record failed: %v", err)
}
interactionsDir := filepath.Join(tmpDir, "interactions", r.SessionID, "self", "image")
files, _ := os.ReadDir(interactionsDir)
content, _ := os.ReadFile(filepath.Join(interactionsDir, files[0].Name()))
contentStr := string(content)
if !strings.Contains(contentStr, "[Binary response body: 4 bytes]") {
t.Error("Recording does not correctly report binary response")
}
}
func TestRecorder_ListInteractions_FullTimestamp(t *testing.T) {
tmpDir, err := os.MkdirTemp("", "recorder-full-ts-test")
if err != nil {
t.Fatalf("failed to create temp dir: %v", err)
}
defer os.RemoveAll(tmpDir)
r := NewRecorder(tmpDir)
sessionID := "20260215-100000-12345"
r.SessionID = sessionID
// Create some dummy recordings
basePath := filepath.Join(tmpDir, "interactions", sessionID, "self", "test")
os.MkdirAll(basePath, 0755)
files := []string{
"0001-10-00-01.000-GET.http",
"0002-11-00-00.000-GET.http",
}
for _, f := range files {
os.WriteFile(filepath.Join(basePath, f), []byte("test"), 0644)
}
t.Run("Check_Full_Timestamp_Display", func(t *testing.T) {
interactions, err := r.ListInteractions(sessionID, "", "")
if err != nil {
t.Fatalf("ListInteractions failed: %v", err)
}
if len(interactions) != 2 {
t.Fatalf("Expected 2 interactions, got %d", len(interactions))
}
expectedTS := "2026-02-15 10:00:01.000"
if interactions[0].Timestamp != expectedTS {
t.Errorf("Expected timestamp %s, got %s", expectedTS, interactions[0].Timestamp)
}
})
t.Run("Filter_By_Full_Date_Time", func(t *testing.T) {
// Filter for interactions since 10:30:00 on that day
interactions, err := r.ListInteractions(sessionID, "", "2026-02-15 10:30:00")
if err != nil {
t.Fatalf("ListInteractions failed: %v", err)
}
if len(interactions) != 1 {
t.Fatalf("Expected 1 interaction, got %d", len(interactions))
}
if interactions[0].ID != "0002-11-00-00.000-GET.http" {
t.Errorf("Expected 0002-..., got %s", interactions[0].ID)
}
})
t.Run("Filter_By_Date_Only", func(t *testing.T) {
// Filter for interactions since the day before
interactions, err := r.ListInteractions(sessionID, "", "2026-02-14")
if err != nil {
t.Fatalf("ListInteractions failed: %v", err)
}
if len(interactions) != 2 {
t.Fatalf("Expected 2 interactions, got %d", len(interactions))
}
})
}