diff --git a/.gitignore b/.gitignore index 1512392..4642540 100644 --- a/.gitignore +++ b/.gitignore @@ -19,6 +19,7 @@ _cgo_* _obj _test _testmain.go +*.protoset /VERSION.cache /bin/ @@ -29,7 +30,6 @@ _testmain.go /misc/cgo/life/run.out /misc/cgo/stdio/run.out /misc/cgo/testso/main -/pkg/ /src/*.*/ /src/cmd/cgo/zdefaultcc.go /src/cmd/dist/dist @@ -45,4 +45,4 @@ _testmain.go /test/garbage/*.out /test/pass.out /test/run.out -/test/times.out \ No newline at end of file +/test/times.out diff --git a/cmd/examples/README.md b/cmd/examples/README.md index d6d0b5b..4ed99fd 100644 --- a/cmd/examples/README.md +++ b/cmd/examples/README.md @@ -6,3 +6,4 @@ This directory contains example applications demonstrating GoVisual's features. - [Basic Example](basic/) - Simple example demonstrating core GoVisual functionality - [OpenTelemetry Example](otel/) - Example demonstrating OpenTelemetry integration +- [gRPC Agent Example](grpc_agent/) - Example demonstrating how to use GoVisual with a gRPC agent \ No newline at end of file diff --git a/cmd/examples/grpc_agent/README.md b/cmd/examples/grpc_agent/README.md new file mode 100644 index 0000000..14a2f24 --- /dev/null +++ b/cmd/examples/grpc_agent/README.md @@ -0,0 +1,639 @@ +# GoVisual Agent Architecture + +This document explains how to use the new agent architecture in GoVisual to monitor distributed services. + +## What is the Agent Architecture? + +The agent architecture allows GoVisual to collect request data from multiple services (potentially running on different machines) and visualize them in a central dashboard. This is particularly useful for microservice architectures or distributed systems where multiple services need to be monitored. + +### Key Components + +1. **Agents**: Lightweight components that attach to services (gRPC, HTTP) to collect request/response data +2. **Transports**: Mechanisms for sending data from agents to the visualization server +3. **Visualization Server**: Central server that receives, stores, and displays the request data + +## Getting Started + +### 1. Setting Up the Visualization Server + +Start by initializing a visualization server that will display the dashboard and receive agent data: + +```go +package main + +import ( + "log" + "net/http" + + "github.com/doganarif/govisual" + "github.com/doganarif/govisual/internal/server" +) + +func main() { + // Create a store for visualization data + store, err := govisual.NewStore( + govisual.WithMaxRequests(1000), + govisual.WithMemoryStorage(), + ) + if err != nil { + log.Fatalf("Failed to create store: %v", err) + } + + // Create a server mux + mux := http.NewServeMux() + + // Add homepage + mux.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) { + w.Write([]byte("

GoVisual Dashboard

Go to /__viz to see the dashboard

")) + }) + + // Register agent API endpoints + agentAPI := server.NewAgentAPI(store) + agentAPI.RegisterHandlers(mux) + + // Wrap with GoVisual + handler := govisual.Wrap( + mux, + govisual.WithMaxRequests(1000), + govisual.WithSharedStore(store), + ) + + // Start HTTP server + log.Println("Starting dashboard server on :8080") + if err := http.ListenAndServe(":8080", handler); err != nil { + log.Fatalf("HTTP server error: %v", err) + } +} +``` + +For NATS transport, you also need to add a NATS handler: + +```go +// Set up NATS handler if using NATS transport +natsHandler, err := server.NewNATSHandler(store, "nats://localhost:4222") +if err != nil { + log.Fatalf("Failed to create NATS handler: %v", err) +} + +if err := natsHandler.Start(); err != nil { + log.Fatalf("Failed to start NATS handler: %v", err) +} +defer natsHandler.Stop() +``` + +### 2. Setting Up gRPC Agents + +Here's how to set up a gRPC agent with different transport options: + +#### Shared Store Transport (Local Services) + +```go +package main + +import ( + "log" + "net" + + "github.com/doganarif/govisual" + "google.golang.org/grpc" + "your-service/proto" +) + +func main() { + // Create or access shared store + sharedStore, err := govisual.NewStore( + govisual.WithMaxRequests(100), + govisual.WithMemoryStorage(), + ) + if err != nil { + log.Fatalf("Failed to create store: %v", err) + } + + // Create store transport + transport := govisual.NewStoreTransport(sharedStore) + + // Create gRPC agent + agent := govisual.NewGRPCAgent(transport, + govisual.WithGRPCRequestDataLogging(true), + govisual.WithGRPCResponseDataLogging(true), + ) + + // Create gRPC server with agent + server := govisual.NewGRPCServer(agent) + proto.RegisterYourServiceServer(server, &YourServiceImpl{}) + + // Start server + lis, err := net.Listen("tcp", ":9000") + if err != nil { + log.Fatalf("Failed to listen: %v", err) + } + log.Println("Starting gRPC server on :9000") + if err := server.Serve(lis); err != nil { + log.Fatalf("Failed to serve: %v", err) + } +} +``` + +#### HTTP Transport (Remote Services) + +```go +// Create HTTP transport +transport := govisual.NewHTTPTransport("http://dashboard-server:8080/api/agent/logs", + govisual.WithTimeout(5*time.Second), + govisual.WithMaxRetries(3), +) + +// Create gRPC agent +agent := govisual.NewGRPCAgent(transport, + govisual.WithGRPCRequestDataLogging(true), + govisual.WithGRPCResponseDataLogging(true), + govisual.WithBatchingEnabled(true), + govisual.WithBatchSize(10), + govisual.WithBatchInterval(3*time.Second), +) +``` + +#### NATS Transport (Distributed Systems) + +```go +// Create NATS transport +transport, err := govisual.NewNATSTransport("nats://nats-server:4222", + govisual.WithMaxRetries(5), + govisual.WithCredentials(map[string]string{ + "username": "user", + "password": "pass", + }), +) +if err != nil { + log.Fatalf("Failed to create NATS transport: %v", err) +} + +// Create gRPC agent +agent := govisual.NewGRPCAgent(transport, + govisual.WithGRPCRequestDataLogging(true), + govisual.WithGRPCResponseDataLogging(true), +) +``` + +### 3. Setting Up HTTP Agents + +For HTTP services, use the HTTP agent: + +```go +// Create transport +transport := govisual.NewHTTPTransport("http://dashboard-server:8080/api/agent/logs") + +// Create HTTP agent +agent := govisual.NewHTTPAgent(transport, + govisual.WithHTTPRequestBodyLogging(true), + govisual.WithHTTPResponseBodyLogging(true), + govisual.WithMaxBodySize(1024*1024), // 1MB + govisual.WithIgnorePaths("/health", "/metrics"), + govisual.WithIgnoreExtensions(".jpg", ".png", ".css"), +) + +// Apply as middleware to your HTTP server +mux := http.NewServeMux() +mux.HandleFunc("/", yourHandler) + +// Wrap with agent middleware +http.ListenAndServe(":8000", agent.Middleware(mux)) +``` + +## Configuration Options + +### Agent Options + +#### Common Options + +```go +// Set maximum buffer size for when transport is unavailable +govisual.WithMaxBufferSize(100) + +// Enable batching to reduce transport overhead +govisual.WithBatchingEnabled(true) + +// Set batch size +govisual.WithBatchSize(20) + +// Set batch interval +govisual.WithBatchInterval(5*time.Second) + +// Add filtering to exclude certain requests +govisual.WithFilter(func(log *model.RequestLog) bool { + // Skip health check endpoints + if log.Type == model.TypeHTTP && log.Path == "/health" { + return false + } + return true +}) + +// Add processing to modify or clean up logs before transport +govisual.WithProcessor(func(log *model.RequestLog) *model.RequestLog { + // Redact sensitive information + if log.Type == model.TypeHTTP && strings.Contains(log.Path, "/auth") { + log.RequestBody = "[REDACTED]" + } + return log +}) +``` + +#### gRPC Agent Options + +```go +// Log request message data +govisual.WithGRPCRequestDataLogging(true) + +// Log response message data +govisual.WithGRPCResponseDataLogging(true) + +// Ignore specific gRPC methods +govisual.WithIgnoreGRPCMethods( + "/health.HealthService/Check", + "/grpc.reflection.v1.ReflectionService/*", +) +``` + +#### HTTP Agent Options + +```go +// Log request bodies +govisual.WithHTTPRequestBodyLogging(true) + +// Log response bodies +govisual.WithHTTPResponseBodyLogging(true) + +// Set maximum body size to log +govisual.WithMaxBodySize(512*1024) // 512KB + +// Ignore specific paths +govisual.WithIgnorePaths("/health", "/metrics", "/favicon.ico") + +// Ignore specific file extensions +govisual.WithIgnoreExtensions(".jpg", ".png", ".gif", ".css", ".js") + +// Transform paths before logging (e.g., to normalize UUIDs) +govisual.WithPathTransformer(func(path string) string { + // Replace UUIDs with placeholders + return regexp.MustCompile(`/users/[0-9a-f-]{36}`). + ReplaceAllString(path, "/users/:id") +}) +``` + +### Transport Options + +```go +// Set endpoint for HTTP transport +govisual.WithEndpoint("http://dashboard-server:8080/api/agent/logs") + +// Add authentication credentials +govisual.WithCredentials(map[string]string{ + "token": "your-auth-token", + "api_key": "your-api-key", +}) + +// Configure retries +govisual.WithMaxRetries(5) +govisual.WithRetryWait(2*time.Second) + +// Set timeout +govisual.WithTimeout(10*time.Second) + +// Set buffer size for when transport is unavailable +govisual.WithBufferSize(200) +``` + +## Deployment Scenarios + +### Single Service (Development) + +For local development with a single service, use the shared store transport: + +``` +┌──────────────────┐ +│ Single Service │ +│ │ +│ ┌─────────────┐ │ +│ │ gRPC/HTTP │ │ +│ │ Agent │ │ +│ └─────┬───────┘ │ +│ │ │ +│ ┌─────▼───────┐ │ +│ │ Shared │ │ +│ │ Store │ │ +│ └─────┬───────┘ │ +│ │ │ +│ ┌─────▼───────┐ │ +│ │ Dashboard │ │ +│ └─────────────┘ │ +└──────────────────┘ +``` + +### Multiple Services (Single Machine) + +For multiple services on a single machine, use the shared store transport: + +``` +┌──────────────────┐ ┌──────────────────┐ +│ Service A (gRPC)│ │ Service B (HTTP)│ +│ │ │ │ +│ ┌─────────────┐ │ │ ┌─────────────┐ │ +│ │ gRPC Agent │ │ │ │ HTTP Agent │ │ +│ └──────┬──────┘ │ │ └──────┬──────┘ │ +└────────┼─────────┘ └────────┼─────────┘ + │ │ + ▼ ▼ +┌───────────────────────────────────────┐ +│ Shared Store │ +└─────────────────┬─────────────────────┘ + │ +┌─────────────────▼─────────────────────┐ +│ Dashboard Server │ +└───────────────────────────────────────┘ +``` + +### Distributed Services (Multiple Machines) + +For distributed services, use the HTTP or NATS transport: + +``` +┌──────────────────┐ ┌──────────────────┐ +│ Service A (gRPC)│ │ Service B (HTTP)│ +│ │ │ │ +│ ┌─────────────┐ │ │ ┌─────────────┐ │ +│ │ gRPC Agent │ │ │ │ HTTP Agent │ │ +│ └──────┬──────┘ │ │ └──────┬──────┘ │ +└────────┼─────────┘ └────────┼─────────┘ + │ │ + │ │ + ▼ ▼ +┌───────────────────────────────────────┐ +│ Transport Layer │ +│ (NATS or HTTP Transport) │ +└─────────────────┬─────────────────────┘ + │ + │ +┌─────────────────▼─────────────────────┐ +│ Dashboard Server │ +│ │ +│ ┌────────────────────────────────────┐ │ +│ │ Store Backend │ │ +│ └────────────────────────────────────┘ │ +└───────────────────────────────────────┘ +``` + +## Best Practices + +### Security Considerations + +1. **Data Privacy**: Use the processor option to redact sensitive information from logs before transport: + +```go +govisual.WithProcessor(func(log *model.RequestLog) *model.RequestLog { + // Redact authentication tokens + if log.Type == model.TypeHTTP { + // Redact Authorization headers + if auth, ok := log.RequestHeaders["Authorization"]; ok { + log.RequestHeaders["Authorization"] = []string{"[REDACTED]"} + } + + // Redact sensitive JSON fields in bodies + if strings.Contains(log.Path, "/users") || strings.Contains(log.Path, "/accounts") { + // Use a JSON parser to selectively redact fields + if strings.Contains(log.RequestBody, "password") || + strings.Contains(log.RequestBody, "credit_card") { + // Replace with redacted version + log.RequestBody = redactSensitiveJSON(log.RequestBody) + } + } + } + return log +}) +``` + +2. **Transport Security**: Use secure connections for remote transports: + +```go +// For HTTP transport +transport := govisual.NewHTTPTransport("https://dashboard-server:8080/api/agent/logs", + govisual.WithCredentials(map[string]string{ + "token": "your-secure-token", + }), +) + +// For NATS transport with TLS +transport, err := govisual.NewNATSTransport("nats://nats-server:4222", + govisual.WithCredentials(map[string]string{ + "token": "your-nats-token", + }), + // Add TLS configurations +) +``` + +### Performance Considerations + +1. **Batching**: Enable batching to reduce network overhead for remote transports: + +```go +agent := govisual.NewGRPCAgent(transport, + govisual.WithBatchingEnabled(true), + govisual.WithBatchSize(20), + govisual.WithBatchInterval(5*time.Second), +) +``` + +2. **Filtering**: Filter out high-volume, low-value requests to reduce load: + +```go +govisual.WithFilter(func(log *model.RequestLog) bool { + // Skip static assets + if log.Type == model.TypeHTTP { + if strings.HasPrefix(log.Path, "/static/") || + strings.HasPrefix(log.Path, "/assets/") { + return false + } + } + + // Skip health checks and metrics endpoints + if log.Type == model.TypeGRPC && + (strings.Contains(log.GRPCService, "Health") || + strings.Contains(log.GRPCService, "Metrics")) { + return false + } + + return true +}) +``` + +3. **Body Size Limits**: Limit the size of request/response bodies to prevent memory issues: + +```go +// For HTTP agent +govisual.WithMaxBodySize(512*1024) // 512KB limit + +// For gRPC agent, process large messages +govisual.WithProcessor(func(log *model.RequestLog) *model.RequestLog { + // Truncate large request/response data + const maxSize = 1024 * 1024 // 1MB + + if log.Type == model.TypeGRPC { + if len(log.GRPCRequestData) > maxSize { + log.GRPCRequestData = log.GRPCRequestData[:maxSize] + "... [TRUNCATED]" + } + + if len(log.GRPCResponseData) > maxSize { + log.GRPCResponseData = log.GRPCResponseData[:maxSize] + "... [TRUNCATED]" + } + } + + return log +}) +``` + +### Monitoring & Troubleshooting + +Add logging to help debug agent and transport issues: + +```go +// Create a processor that logs when errors occur +govisual.WithProcessor(func(log *model.RequestLog) *model.RequestLog { + if log.Error != "" { + internalLogger.Debugf("Request error captured: %s, path: %s", log.Error, log.Path) + } + + // For gRPC status codes other than OK + if log.Type == model.TypeGRPC && log.GRPCStatusCode != 0 { + internalLogger.Debugf("gRPC request failed: status=%d, desc=%s, method=%s/%s", + log.GRPCStatusCode, log.GRPCStatusDesc, log.GRPCService, log.GRPCMethod) + } + + return log +}) +``` + +## Advanced Use Cases + +### Custom Transport Implementation + +You can implement your own transport by implementing the `transport.Transport` interface: + +```go +type CustomTransport struct { + // Custom fields +} + +func NewCustomTransport() *CustomTransport { + return &CustomTransport{} +} + +func (t *CustomTransport) Send(log *model.RequestLog) error { + // Implement your custom sending logic + return nil +} + +func (t *CustomTransport) SendBatch(logs []*model.RequestLog) error { + // Implement your custom batch sending logic + return nil +} + +func (t *CustomTransport) Close() error { + // Clean up resources + return nil +} +``` + +### Multiple Transport Targets + +To send data to multiple visualization servers, create a composite transport: + +```go +type CompositeTransport struct { + transports []transport.Transport +} + +func NewCompositeTransport(transports ...transport.Transport) *CompositeTransport { + return &CompositeTransport{ + transports: transports, + } +} + +func (t *CompositeTransport) Send(log *model.RequestLog) error { + var lastErr error + for _, transport := range t.transports { + if err := transport.Send(log); err != nil { + lastErr = err + } + } + return lastErr +} + +func (t *CompositeTransport) SendBatch(logs []*model.RequestLog) error { + var lastErr error + for _, transport := range t.transports { + if err := transport.SendBatch(logs); err != nil { + lastErr = err + } + } + return lastErr +} + +func (t *CompositeTransport) Close() error { + var lastErr error + for _, transport := range t.transports { + if err := transport.Close(); err != nil { + lastErr = err + } + } + return lastErr +} + +// Usage +httpTransport := govisual.NewHTTPTransport("http://dashboard-1:8080/api/agent/logs") +natsTransport, _ := govisual.NewNATSTransport("nats://nats-server:4222") +compositeTransport := NewCompositeTransport(httpTransport, natsTransport) + +agent := govisual.NewGRPCAgent(compositeTransport) +``` + +## Compatibility with Existing Code + +The agent architecture is designed to be backward compatible with the existing GoVisual API. You can gradually migrate your codebase to use agents: + +### Before (Direct Dashboard) + +```go +grpcServer := grpc.NewServer( + govisual.WrapGRPCServer( + govisual.WithGRPC(true), + govisual.WithGRPCRequestDataLogging(true), + govisual.WithGRPCResponseDataLogging(true), + )...) +``` + +### After (Agent Architecture) + +```go +// Create transport +transport := govisual.NewStoreTransport(sharedStore) + +// Create agent +agent := govisual.NewGRPCAgent(transport, + govisual.WithGRPCRequestDataLogging(true), + govisual.WithGRPCResponseDataLogging(true), +) + +// Create server with agent +grpcServer := govisual.NewGRPCServer(agent) +``` + +## Conclusion + +The agent architecture provides a flexible way to collect and visualize request data from distributed services. By separating data collection (agents) from visualization (dashboard), GoVisual can now monitor complex, multi-service architectures while providing options for different deployment scenarios. + +Choose the right transport mechanism based on your deployment needs: + +- **Shared Store**: For services running on the same machine +- **HTTP Transport**: For remote services with direct HTTP connectivity +- **NATS Transport**: For distributed systems where a message broker is available + +The architecture is designed to be extensible, allowing for custom transport implementations and advanced use cases. diff --git a/cmd/examples/grpc_agent/amalgo.txt b/cmd/examples/grpc_agent/amalgo.txt new file mode 100644 index 0000000..fe0bab4 --- /dev/null +++ b/cmd/examples/grpc_agent/amalgo.txt @@ -0,0 +1,1579 @@ +## Generated with Amalgo at: 2025-05-14 20:41:24 + +## File Tree + +└── grpc_agent/ + ├── README.md + ├── grpc_server.go + ├── main.go + └── gen/ + └── greeter/ + └── v1/ + ├── greeter.pb.go + └── greeter_grpc.pb.go + +## File Contents + +--- Start File: grpc_agent/README.md +# GoVisual Agent Architecture + +This document explains how to use the new agent architecture in GoVisual to monitor distributed services. + +## What is the Agent Architecture? + +The agent architecture allows GoVisual to collect request data from multiple services (potentially running on different machines) and visualize them in a central dashboard. This is particularly useful for microservice architectures or distributed systems where multiple services need to be monitored. + +### Key Components + +1. **Agents**: Lightweight components that attach to services (gRPC, HTTP) to collect request/response data +2. **Transports**: Mechanisms for sending data from agents to the visualization server +3. **Visualization Server**: Central server that receives, stores, and displays the request data + +## Getting Started + +### 1. Setting Up the Visualization Server + +Start by initializing a visualization server that will display the dashboard and receive agent data: + +```go +package main + +import ( + "log" + "net/http" + + "github.com/doganarif/govisual" + "github.com/doganarif/govisual/internal/server" +) + +func main() { + // Create a store for visualization data + store, err := govisual.NewStore( + govisual.WithMaxRequests(1000), + govisual.WithMemoryStorage(), + ) + if err != nil { + log.Fatalf("Failed to create store: %v", err) + } + + // Create a server mux + mux := http.NewServeMux() + + // Add homepage + mux.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) { + w.Write([]byte("

GoVisual Dashboard

Go to /__viz to see the dashboard

")) + }) + + // Register agent API endpoints + agentAPI := server.NewAgentAPI(store) + agentAPI.RegisterHandlers(mux) + + // Wrap with GoVisual + handler := govisual.Wrap( + mux, + govisual.WithMaxRequests(1000), + govisual.WithSharedStore(store), + ) + + // Start HTTP server + log.Println("Starting dashboard server on :8080") + if err := http.ListenAndServe(":8080", handler); err != nil { + log.Fatalf("HTTP server error: %v", err) + } +} +``` + +For NATS transport, you also need to add a NATS handler: + +```go +// Set up NATS handler if using NATS transport +natsHandler, err := server.NewNATSHandler(store, "nats://localhost:4222") +if err != nil { + log.Fatalf("Failed to create NATS handler: %v", err) +} + +if err := natsHandler.Start(); err != nil { + log.Fatalf("Failed to start NATS handler: %v", err) +} +defer natsHandler.Stop() +``` + +### 2. Setting Up gRPC Agents + +Here's how to set up a gRPC agent with different transport options: + +#### Shared Store Transport (Local Services) + +```go +package main + +import ( + "log" + "net" + + "github.com/doganarif/govisual" + "google.golang.org/grpc" + "your-service/proto" +) + +func main() { + // Create or access shared store + sharedStore, err := govisual.NewStore( + govisual.WithMaxRequests(100), + govisual.WithMemoryStorage(), + ) + if err != nil { + log.Fatalf("Failed to create store: %v", err) + } + + // Create store transport + transport := govisual.NewStoreTransport(sharedStore) + + // Create gRPC agent + agent := govisual.NewGRPCAgent(transport, + govisual.WithGRPCRequestDataLogging(true), + govisual.WithGRPCResponseDataLogging(true), + ) + + // Create gRPC server with agent + server := govisual.NewGRPCServer(agent) + proto.RegisterYourServiceServer(server, &YourServiceImpl{}) + + // Start server + lis, err := net.Listen("tcp", ":9000") + if err != nil { + log.Fatalf("Failed to listen: %v", err) + } + log.Println("Starting gRPC server on :9000") + if err := server.Serve(lis); err != nil { + log.Fatalf("Failed to serve: %v", err) + } +} +``` + +#### HTTP Transport (Remote Services) + +```go +// Create HTTP transport +transport := govisual.NewHTTPTransport("http://dashboard-server:8080/api/agent/logs", + govisual.WithTimeout(5*time.Second), + govisual.WithMaxRetries(3), +) + +// Create gRPC agent +agent := govisual.NewGRPCAgent(transport, + govisual.WithGRPCRequestDataLogging(true), + govisual.WithGRPCResponseDataLogging(true), + govisual.WithBatchingEnabled(true), + govisual.WithBatchSize(10), + govisual.WithBatchInterval(3*time.Second), +) +``` + +#### NATS Transport (Distributed Systems) + +```go +// Create NATS transport +transport, err := govisual.NewNATSTransport("nats://nats-server:4222", + govisual.WithMaxRetries(5), + govisual.WithCredentials(map[string]string{ + "username": "user", + "password": "pass", + }), +) +if err != nil { + log.Fatalf("Failed to create NATS transport: %v", err) +} + +// Create gRPC agent +agent := govisual.NewGRPCAgent(transport, + govisual.WithGRPCRequestDataLogging(true), + govisual.WithGRPCResponseDataLogging(true), +) +``` + +### 3. Setting Up HTTP Agents + +For HTTP services, use the HTTP agent: + +```go +// Create transport +transport := govisual.NewHTTPTransport("http://dashboard-server:8080/api/agent/logs") + +// Create HTTP agent +agent := govisual.NewHTTPAgent(transport, + govisual.WithHTTPRequestBodyLogging(true), + govisual.WithHTTPResponseBodyLogging(true), + govisual.WithMaxBodySize(1024*1024), // 1MB + govisual.WithIgnorePaths("/health", "/metrics"), + govisual.WithIgnoreExtensions(".jpg", ".png", ".css"), +) + +// Apply as middleware to your HTTP server +mux := http.NewServeMux() +mux.HandleFunc("/", yourHandler) + +// Wrap with agent middleware +http.ListenAndServe(":8000", agent.Middleware(mux)) +``` + +## Configuration Options + +### Agent Options + +#### Common Options + +```go +// Set maximum buffer size for when transport is unavailable +govisual.WithMaxBufferSize(100) + +// Enable batching to reduce transport overhead +govisual.WithBatchingEnabled(true) + +// Set batch size +govisual.WithBatchSize(20) + +// Set batch interval +govisual.WithBatchInterval(5*time.Second) + +// Add filtering to exclude certain requests +govisual.WithFilter(func(log *model.RequestLog) bool { + // Skip health check endpoints + if log.Type == model.TypeHTTP && log.Path == "/health" { + return false + } + return true +}) + +// Add processing to modify or clean up logs before transport +govisual.WithProcessor(func(log *model.RequestLog) *model.RequestLog { + // Redact sensitive information + if log.Type == model.TypeHTTP && strings.Contains(log.Path, "/auth") { + log.RequestBody = "[REDACTED]" + } + return log +}) +``` + +#### gRPC Agent Options + +```go +// Log request message data +govisual.WithGRPCRequestDataLogging(true) + +// Log response message data +govisual.WithGRPCResponseDataLogging(true) + +// Ignore specific gRPC methods +govisual.WithIgnoreGRPCMethods( + "/health.HealthService/Check", + "/grpc.reflection.v1.ReflectionService/*", +) +``` + +#### HTTP Agent Options + +```go +// Log request bodies +govisual.WithHTTPRequestBodyLogging(true) + +// Log response bodies +govisual.WithHTTPResponseBodyLogging(true) + +// Set maximum body size to log +govisual.WithMaxBodySize(512*1024) // 512KB + +// Ignore specific paths +govisual.WithIgnorePaths("/health", "/metrics", "/favicon.ico") + +// Ignore specific file extensions +govisual.WithIgnoreExtensions(".jpg", ".png", ".gif", ".css", ".js") + +// Transform paths before logging (e.g., to normalize UUIDs) +govisual.WithPathTransformer(func(path string) string { + // Replace UUIDs with placeholders + return regexp.MustCompile(`/users/[0-9a-f-]{36}`). + ReplaceAllString(path, "/users/:id") +}) +``` + +### Transport Options + +```go +// Set endpoint for HTTP transport +govisual.WithEndpoint("http://dashboard-server:8080/api/agent/logs") + +// Add authentication credentials +govisual.WithCredentials(map[string]string{ + "token": "your-auth-token", + "api_key": "your-api-key", +}) + +// Configure retries +govisual.WithMaxRetries(5) +govisual.WithRetryWait(2*time.Second) + +// Set timeout +govisual.WithTimeout(10*time.Second) + +// Set buffer size for when transport is unavailable +govisual.WithBufferSize(200) +``` + +## Deployment Scenarios + +### Single Service (Development) + +For local development with a single service, use the shared store transport: + +``` +┌──────────────────┐ +│ Single Service │ +│ │ +│ ┌─────────────┐ │ +│ │ gRPC/HTTP │ │ +│ │ Agent │ │ +│ └─────┬───────┘ │ +│ │ │ +│ ┌─────▼───────┐ │ +│ │ Shared │ │ +│ │ Store │ │ +│ └─────┬───────┘ │ +│ │ │ +│ ┌─────▼───────┐ │ +│ │ Dashboard │ │ +│ └─────────────┘ │ +└──────────────────┘ +``` + +### Multiple Services (Single Machine) + +For multiple services on a single machine, use the shared store transport: + +``` +┌──────────────────┐ ┌──────────────────┐ +│ Service A (gRPC)│ │ Service B (HTTP)│ +│ │ │ │ +│ ┌─────────────┐ │ │ ┌─────────────┐ │ +│ │ gRPC Agent │ │ │ │ HTTP Agent │ │ +│ └──────┬──────┘ │ │ └──────┬──────┘ │ +└────────┼─────────┘ └────────┼─────────┘ + │ │ + ▼ ▼ +┌───────────────────────────────────────┐ +│ Shared Store │ +└─────────────────┬─────────────────────┘ + │ +┌─────────────────▼─────────────────────┐ +│ Dashboard Server │ +└───────────────────────────────────────┘ +``` + +### Distributed Services (Multiple Machines) + +For distributed services, use the HTTP or NATS transport: + +``` +┌──────────────────┐ ┌──────────────────┐ +│ Service A (gRPC)│ │ Service B (HTTP)│ +│ │ │ │ +│ ┌─────────────┐ │ │ ┌─────────────┐ │ +│ │ gRPC Agent │ │ │ │ HTTP Agent │ │ +│ └──────┬──────┘ │ │ └──────┬──────┘ │ +└────────┼─────────┘ └────────┼─────────┘ + │ │ + │ │ + ▼ ▼ +┌───────────────────────────────────────┐ +│ Transport Layer │ +│ (NATS or HTTP Transport) │ +└─────────────────┬─────────────────────┘ + │ + │ +┌─────────────────▼─────────────────────┐ +│ Dashboard Server │ +│ │ +│ ┌────────────────────────────────────┐ │ +│ │ Store Backend │ │ +│ └────────────────────────────────────┘ │ +└───────────────────────────────────────┘ +``` + +## Best Practices + +### Security Considerations + +1. **Data Privacy**: Use the processor option to redact sensitive information from logs before transport: + +```go +govisual.WithProcessor(func(log *model.RequestLog) *model.RequestLog { + // Redact authentication tokens + if log.Type == model.TypeHTTP { + // Redact Authorization headers + if auth, ok := log.RequestHeaders["Authorization"]; ok { + log.RequestHeaders["Authorization"] = []string{"[REDACTED]"} + } + + // Redact sensitive JSON fields in bodies + if strings.Contains(log.Path, "/users") || strings.Contains(log.Path, "/accounts") { + // Use a JSON parser to selectively redact fields + if strings.Contains(log.RequestBody, "password") || + strings.Contains(log.RequestBody, "credit_card") { + // Replace with redacted version + log.RequestBody = redactSensitiveJSON(log.RequestBody) + } + } + } + return log +}) +``` + +2. **Transport Security**: Use secure connections for remote transports: + +```go +// For HTTP transport +transport := govisual.NewHTTPTransport("https://dashboard-server:8080/api/agent/logs", + govisual.WithCredentials(map[string]string{ + "token": "your-secure-token", + }), +) + +// For NATS transport with TLS +transport, err := govisual.NewNATSTransport("nats://nats-server:4222", + govisual.WithCredentials(map[string]string{ + "token": "your-nats-token", + }), + // Add TLS configurations +) +``` + +### Performance Considerations + +1. **Batching**: Enable batching to reduce network overhead for remote transports: + +```go +agent := govisual.NewGRPCAgent(transport, + govisual.WithBatchingEnabled(true), + govisual.WithBatchSize(20), + govisual.WithBatchInterval(5*time.Second), +) +``` + +2. **Filtering**: Filter out high-volume, low-value requests to reduce load: + +```go +govisual.WithFilter(func(log *model.RequestLog) bool { + // Skip static assets + if log.Type == model.TypeHTTP { + if strings.HasPrefix(log.Path, "/static/") || + strings.HasPrefix(log.Path, "/assets/") { + return false + } + } + + // Skip health checks and metrics endpoints + if log.Type == model.TypeGRPC && + (strings.Contains(log.GRPCService, "Health") || + strings.Contains(log.GRPCService, "Metrics")) { + return false + } + + return true +}) +``` + +3. **Body Size Limits**: Limit the size of request/response bodies to prevent memory issues: + +```go +// For HTTP agent +govisual.WithMaxBodySize(512*1024) // 512KB limit + +// For gRPC agent, process large messages +govisual.WithProcessor(func(log *model.RequestLog) *model.RequestLog { + // Truncate large request/response data + const maxSize = 1024 * 1024 // 1MB + + if log.Type == model.TypeGRPC { + if len(log.GRPCRequestData) > maxSize { + log.GRPCRequestData = log.GRPCRequestData[:maxSize] + "... [TRUNCATED]" + } + + if len(log.GRPCResponseData) > maxSize { + log.GRPCResponseData = log.GRPCResponseData[:maxSize] + "... [TRUNCATED]" + } + } + + return log +}) +``` + +### Monitoring & Troubleshooting + +Add logging to help debug agent and transport issues: + +```go +// Create a processor that logs when errors occur +govisual.WithProcessor(func(log *model.RequestLog) *model.RequestLog { + if log.Error != "" { + internalLogger.Debugf("Request error captured: %s, path: %s", log.Error, log.Path) + } + + // For gRPC status codes other than OK + if log.Type == model.TypeGRPC && log.GRPCStatusCode != 0 { + internalLogger.Debugf("gRPC request failed: status=%d, desc=%s, method=%s/%s", + log.GRPCStatusCode, log.GRPCStatusDesc, log.GRPCService, log.GRPCMethod) + } + + return log +}) +``` + +## Advanced Use Cases + +### Custom Transport Implementation + +You can implement your own transport by implementing the `transport.Transport` interface: + +```go +type CustomTransport struct { + // Custom fields +} + +func NewCustomTransport() *CustomTransport { + return &CustomTransport{} +} + +func (t *CustomTransport) Send(log *model.RequestLog) error { + // Implement your custom sending logic + return nil +} + +func (t *CustomTransport) SendBatch(logs []*model.RequestLog) error { + // Implement your custom batch sending logic + return nil +} + +func (t *CustomTransport) Close() error { + // Clean up resources + return nil +} +``` + +### Multiple Transport Targets + +To send data to multiple visualization servers, create a composite transport: + +```go +type CompositeTransport struct { + transports []transport.Transport +} + +func NewCompositeTransport(transports ...transport.Transport) *CompositeTransport { + return &CompositeTransport{ + transports: transports, + } +} + +func (t *CompositeTransport) Send(log *model.RequestLog) error { + var lastErr error + for _, transport := range t.transports { + if err := transport.Send(log); err != nil { + lastErr = err + } + } + return lastErr +} + +func (t *CompositeTransport) SendBatch(logs []*model.RequestLog) error { + var lastErr error + for _, transport := range t.transports { + if err := transport.SendBatch(logs); err != nil { + lastErr = err + } + } + return lastErr +} + +func (t *CompositeTransport) Close() error { + var lastErr error + for _, transport := range t.transports { + if err := transport.Close(); err != nil { + lastErr = err + } + } + return lastErr +} + +// Usage +httpTransport := govisual.NewHTTPTransport("http://dashboard-1:8080/api/agent/logs") +natsTransport, _ := govisual.NewNATSTransport("nats://nats-server:4222") +compositeTransport := NewCompositeTransport(httpTransport, natsTransport) + +agent := govisual.NewGRPCAgent(compositeTransport) +``` + +## Compatibility with Existing Code + +The agent architecture is designed to be backward compatible with the existing GoVisual API. You can gradually migrate your codebase to use agents: + +### Before (Direct Dashboard) + +```go +grpcServer := grpc.NewServer( + govisual.WrapGRPCServer( + govisual.WithGRPC(true), + govisual.WithGRPCRequestDataLogging(true), + govisual.WithGRPCResponseDataLogging(true), + )...) +``` + +### After (Agent Architecture) + +```go +// Create transport +transport := govisual.NewStoreTransport(sharedStore) + +// Create agent +agent := govisual.NewGRPCAgent(transport, + govisual.WithGRPCRequestDataLogging(true), + govisual.WithGRPCResponseDataLogging(true), +) + +// Create server with agent +grpcServer := govisual.NewGRPCServer(agent) +``` + +## Conclusion + +The agent architecture provides a flexible way to collect and visualize request data from distributed services. By separating data collection (agents) from visualization (dashboard), GoVisual can now monitor complex, multi-service architectures while providing options for different deployment scenarios. + +Choose the right transport mechanism based on your deployment needs: + +- **Shared Store**: For services running on the same machine +- **HTTP Transport**: For remote services with direct HTTP connectivity +- **NATS Transport**: For distributed systems where a message broker is available + +The architecture is designed to be extensible, allowing for custom transport implementations and advanced use cases. + +--- End File: grpc_agent/README.md + +--- Start File: grpc_agent/gen/greeter/v1/greeter.pb.go +// Code generated by protoc-gen-go. DO NOT EDIT. +// versions: +// protoc-gen-go v1.36.6 +// protoc (unknown) +// source: greeter/v1/greeter.proto + +package greeterv1 + +import ( + protoreflect "google.golang.org/protobuf/reflect/protoreflect" + protoimpl "google.golang.org/protobuf/runtime/protoimpl" + reflect "reflect" + sync "sync" + unsafe "unsafe" +) + +const ( + // Verify that this generated code is sufficiently up-to-date. + _ = protoimpl.EnforceVersion(20 - protoimpl.MinVersion) + // Verify that runtime/protoimpl is sufficiently up-to-date. + _ = protoimpl.EnforceVersion(protoimpl.MaxVersion - 20) +) + +// The request message containing the user's name. +type HelloRequest struct { + state protoimpl.MessageState `protogen:"open.v1"` + Name string `protobuf:"bytes,1,opt,name=name,proto3" json:"name,omitempty"` + Message string `protobuf:"bytes,2,opt,name=message,proto3" json:"message,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *HelloRequest) Reset() { + *x = HelloRequest{} + mi := &file_greeter_v1_greeter_proto_msgTypes[0] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *HelloRequest) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*HelloRequest) ProtoMessage() {} + +func (x *HelloRequest) ProtoReflect() protoreflect.Message { + mi := &file_greeter_v1_greeter_proto_msgTypes[0] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use HelloRequest.ProtoReflect.Descriptor instead. +func (*HelloRequest) Descriptor() ([]byte, []int) { + return file_greeter_v1_greeter_proto_rawDescGZIP(), []int{0} +} + +func (x *HelloRequest) GetName() string { + if x != nil { + return x.Name + } + return "" +} + +func (x *HelloRequest) GetMessage() string { + if x != nil { + return x.Message + } + return "" +} + +// The response message containing the greetings. +type HelloReply struct { + state protoimpl.MessageState `protogen:"open.v1"` + Message string `protobuf:"bytes,1,opt,name=message,proto3" json:"message,omitempty"` + Timestamp int64 `protobuf:"varint,2,opt,name=timestamp,proto3" json:"timestamp,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *HelloReply) Reset() { + *x = HelloReply{} + mi := &file_greeter_v1_greeter_proto_msgTypes[1] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *HelloReply) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*HelloReply) ProtoMessage() {} + +func (x *HelloReply) ProtoReflect() protoreflect.Message { + mi := &file_greeter_v1_greeter_proto_msgTypes[1] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use HelloReply.ProtoReflect.Descriptor instead. +func (*HelloReply) Descriptor() ([]byte, []int) { + return file_greeter_v1_greeter_proto_rawDescGZIP(), []int{1} +} + +func (x *HelloReply) GetMessage() string { + if x != nil { + return x.Message + } + return "" +} + +func (x *HelloReply) GetTimestamp() int64 { + if x != nil { + return x.Timestamp + } + return 0 +} + +var File_greeter_v1_greeter_proto protoreflect.FileDescriptor + +const file_greeter_v1_greeter_proto_rawDesc = "" + + "\n" + + "\x18greeter/v1/greeter.proto\x12\n" + + "greeter.v1\"<\n" + + "\fHelloRequest\x12\x12\n" + + "\x04name\x18\x01 \x01(\tR\x04name\x12\x18\n" + + "\amessage\x18\x02 \x01(\tR\amessage\"D\n" + + "\n" + + "HelloReply\x12\x18\n" + + "\amessage\x18\x01 \x01(\tR\amessage\x12\x1c\n" + + "\ttimestamp\x18\x02 \x01(\x03R\ttimestamp2\xa4\x02\n" + + "\x0eGreeterService\x12>\n" + + "\bSayHello\x12\x18.greeter.v1.HelloRequest\x1a\x16.greeter.v1.HelloReply\"\x00\x12F\n" + + "\x0eSayHelloStream\x12\x18.greeter.v1.HelloRequest\x1a\x16.greeter.v1.HelloReply\"\x000\x01\x12E\n" + + "\rCollectHellos\x12\x18.greeter.v1.HelloRequest\x1a\x16.greeter.v1.HelloReply\"\x00(\x01\x12C\n" + + "\tChatHello\x12\x18.greeter.v1.HelloRequest\x1a\x16.greeter.v1.HelloReply\"\x00(\x010\x01B\xad\x01\n" + + "\x0ecom.greeter.v1B\fGreeterProtoP\x01ZDgithub.com/doganarif/govisual/examples/grpc/gen/greeter/v1;greeterv1\xa2\x02\x03GXX\xaa\x02\n" + + "Greeter.V1\xca\x02\n" + + "Greeter\\V1\xe2\x02\x16Greeter\\V1\\GPBMetadata\xea\x02\vGreeter::V1b\x06proto3" + +var ( + file_greeter_v1_greeter_proto_rawDescOnce sync.Once + file_greeter_v1_greeter_proto_rawDescData []byte +) + +func file_greeter_v1_greeter_proto_rawDescGZIP() []byte { + file_greeter_v1_greeter_proto_rawDescOnce.Do(func() { + file_greeter_v1_greeter_proto_rawDescData = protoimpl.X.CompressGZIP(unsafe.Slice(unsafe.StringData(file_greeter_v1_greeter_proto_rawDesc), len(file_greeter_v1_greeter_proto_rawDesc))) + }) + return file_greeter_v1_greeter_proto_rawDescData +} + +var file_greeter_v1_greeter_proto_msgTypes = make([]protoimpl.MessageInfo, 2) +var file_greeter_v1_greeter_proto_goTypes = []any{ + (*HelloRequest)(nil), // 0: greeter.v1.HelloRequest + (*HelloReply)(nil), // 1: greeter.v1.HelloReply +} +var file_greeter_v1_greeter_proto_depIdxs = []int32{ + 0, // 0: greeter.v1.GreeterService.SayHello:input_type -> greeter.v1.HelloRequest + 0, // 1: greeter.v1.GreeterService.SayHelloStream:input_type -> greeter.v1.HelloRequest + 0, // 2: greeter.v1.GreeterService.CollectHellos:input_type -> greeter.v1.HelloRequest + 0, // 3: greeter.v1.GreeterService.ChatHello:input_type -> greeter.v1.HelloRequest + 1, // 4: greeter.v1.GreeterService.SayHello:output_type -> greeter.v1.HelloReply + 1, // 5: greeter.v1.GreeterService.SayHelloStream:output_type -> greeter.v1.HelloReply + 1, // 6: greeter.v1.GreeterService.CollectHellos:output_type -> greeter.v1.HelloReply + 1, // 7: greeter.v1.GreeterService.ChatHello:output_type -> greeter.v1.HelloReply + 4, // [4:8] is the sub-list for method output_type + 0, // [0:4] is the sub-list for method input_type + 0, // [0:0] is the sub-list for extension type_name + 0, // [0:0] is the sub-list for extension extendee + 0, // [0:0] is the sub-list for field type_name +} + +func init() { file_greeter_v1_greeter_proto_init() } +func file_greeter_v1_greeter_proto_init() { + if File_greeter_v1_greeter_proto != nil { + return + } + type x struct{} + out := protoimpl.TypeBuilder{ + File: protoimpl.DescBuilder{ + GoPackagePath: reflect.TypeOf(x{}).PkgPath(), + RawDescriptor: unsafe.Slice(unsafe.StringData(file_greeter_v1_greeter_proto_rawDesc), len(file_greeter_v1_greeter_proto_rawDesc)), + NumEnums: 0, + NumMessages: 2, + NumExtensions: 0, + NumServices: 1, + }, + GoTypes: file_greeter_v1_greeter_proto_goTypes, + DependencyIndexes: file_greeter_v1_greeter_proto_depIdxs, + MessageInfos: file_greeter_v1_greeter_proto_msgTypes, + }.Build() + File_greeter_v1_greeter_proto = out.File + file_greeter_v1_greeter_proto_goTypes = nil + file_greeter_v1_greeter_proto_depIdxs = nil +} + +--- End File: grpc_agent/gen/greeter/v1/greeter.pb.go + +--- Start File: grpc_agent/gen/greeter/v1/greeter_grpc.pb.go +// Code generated by protoc-gen-go-grpc. DO NOT EDIT. +// versions: +// - protoc-gen-go-grpc v1.5.1 +// - protoc (unknown) +// source: greeter/v1/greeter.proto + +package greeterv1 + +import ( + context "context" + grpc "google.golang.org/grpc" + codes "google.golang.org/grpc/codes" + status "google.golang.org/grpc/status" +) + +// This is a compile-time assertion to ensure that this generated file +// is compatible with the grpc package it is being compiled against. +// Requires gRPC-Go v1.64.0 or later. +const _ = grpc.SupportPackageIsVersion9 + +const ( + GreeterService_SayHello_FullMethodName = "/greeter.v1.GreeterService/SayHello" + GreeterService_SayHelloStream_FullMethodName = "/greeter.v1.GreeterService/SayHelloStream" + GreeterService_CollectHellos_FullMethodName = "/greeter.v1.GreeterService/CollectHellos" + GreeterService_ChatHello_FullMethodName = "/greeter.v1.GreeterService/ChatHello" +) + +// GreeterServiceClient is the client API for GreeterService service. +// +// For semantics around ctx use and closing/ending streaming RPCs, please refer to https://pkg.go.dev/google.golang.org/grpc/?tab=doc#ClientConn.NewStream. +// +// The greeting service definition. +type GreeterServiceClient interface { + // Sends a greeting (Unary RPC) + SayHello(ctx context.Context, in *HelloRequest, opts ...grpc.CallOption) (*HelloReply, error) + // Sends multiple greetings in response to a single request (Server streaming RPC) + SayHelloStream(ctx context.Context, in *HelloRequest, opts ...grpc.CallOption) (grpc.ServerStreamingClient[HelloReply], error) + // Collects multiple greetings and responds once (Client streaming RPC) + CollectHellos(ctx context.Context, opts ...grpc.CallOption) (grpc.ClientStreamingClient[HelloRequest, HelloReply], error) + // Chat with greetings back and forth (Bidirectional streaming RPC) + ChatHello(ctx context.Context, opts ...grpc.CallOption) (grpc.BidiStreamingClient[HelloRequest, HelloReply], error) +} + +type greeterServiceClient struct { + cc grpc.ClientConnInterface +} + +func NewGreeterServiceClient(cc grpc.ClientConnInterface) GreeterServiceClient { + return &greeterServiceClient{cc} +} + +func (c *greeterServiceClient) SayHello(ctx context.Context, in *HelloRequest, opts ...grpc.CallOption) (*HelloReply, error) { + cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...) + out := new(HelloReply) + err := c.cc.Invoke(ctx, GreeterService_SayHello_FullMethodName, in, out, cOpts...) + if err != nil { + return nil, err + } + return out, nil +} + +func (c *greeterServiceClient) SayHelloStream(ctx context.Context, in *HelloRequest, opts ...grpc.CallOption) (grpc.ServerStreamingClient[HelloReply], error) { + cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...) + stream, err := c.cc.NewStream(ctx, &GreeterService_ServiceDesc.Streams[0], GreeterService_SayHelloStream_FullMethodName, cOpts...) + if err != nil { + return nil, err + } + x := &grpc.GenericClientStream[HelloRequest, HelloReply]{ClientStream: stream} + if err := x.ClientStream.SendMsg(in); err != nil { + return nil, err + } + if err := x.ClientStream.CloseSend(); err != nil { + return nil, err + } + return x, nil +} + +// This type alias is provided for backwards compatibility with existing code that references the prior non-generic stream type by name. +type GreeterService_SayHelloStreamClient = grpc.ServerStreamingClient[HelloReply] + +func (c *greeterServiceClient) CollectHellos(ctx context.Context, opts ...grpc.CallOption) (grpc.ClientStreamingClient[HelloRequest, HelloReply], error) { + cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...) + stream, err := c.cc.NewStream(ctx, &GreeterService_ServiceDesc.Streams[1], GreeterService_CollectHellos_FullMethodName, cOpts...) + if err != nil { + return nil, err + } + x := &grpc.GenericClientStream[HelloRequest, HelloReply]{ClientStream: stream} + return x, nil +} + +// This type alias is provided for backwards compatibility with existing code that references the prior non-generic stream type by name. +type GreeterService_CollectHellosClient = grpc.ClientStreamingClient[HelloRequest, HelloReply] + +func (c *greeterServiceClient) ChatHello(ctx context.Context, opts ...grpc.CallOption) (grpc.BidiStreamingClient[HelloRequest, HelloReply], error) { + cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...) + stream, err := c.cc.NewStream(ctx, &GreeterService_ServiceDesc.Streams[2], GreeterService_ChatHello_FullMethodName, cOpts...) + if err != nil { + return nil, err + } + x := &grpc.GenericClientStream[HelloRequest, HelloReply]{ClientStream: stream} + return x, nil +} + +// This type alias is provided for backwards compatibility with existing code that references the prior non-generic stream type by name. +type GreeterService_ChatHelloClient = grpc.BidiStreamingClient[HelloRequest, HelloReply] + +// GreeterServiceServer is the server API for GreeterService service. +// All implementations should embed UnimplementedGreeterServiceServer +// for forward compatibility. +// +// The greeting service definition. +type GreeterServiceServer interface { + // Sends a greeting (Unary RPC) + SayHello(context.Context, *HelloRequest) (*HelloReply, error) + // Sends multiple greetings in response to a single request (Server streaming RPC) + SayHelloStream(*HelloRequest, grpc.ServerStreamingServer[HelloReply]) error + // Collects multiple greetings and responds once (Client streaming RPC) + CollectHellos(grpc.ClientStreamingServer[HelloRequest, HelloReply]) error + // Chat with greetings back and forth (Bidirectional streaming RPC) + ChatHello(grpc.BidiStreamingServer[HelloRequest, HelloReply]) error +} + +// UnimplementedGreeterServiceServer should be embedded to have +// forward compatible implementations. +// +// NOTE: this should be embedded by value instead of pointer to avoid a nil +// pointer dereference when methods are called. +type UnimplementedGreeterServiceServer struct{} + +func (UnimplementedGreeterServiceServer) SayHello(context.Context, *HelloRequest) (*HelloReply, error) { + return nil, status.Errorf(codes.Unimplemented, "method SayHello not implemented") +} +func (UnimplementedGreeterServiceServer) SayHelloStream(*HelloRequest, grpc.ServerStreamingServer[HelloReply]) error { + return status.Errorf(codes.Unimplemented, "method SayHelloStream not implemented") +} +func (UnimplementedGreeterServiceServer) CollectHellos(grpc.ClientStreamingServer[HelloRequest, HelloReply]) error { + return status.Errorf(codes.Unimplemented, "method CollectHellos not implemented") +} +func (UnimplementedGreeterServiceServer) ChatHello(grpc.BidiStreamingServer[HelloRequest, HelloReply]) error { + return status.Errorf(codes.Unimplemented, "method ChatHello not implemented") +} +func (UnimplementedGreeterServiceServer) testEmbeddedByValue() {} + +// UnsafeGreeterServiceServer may be embedded to opt out of forward compatibility for this service. +// Use of this interface is not recommended, as added methods to GreeterServiceServer will +// result in compilation errors. +type UnsafeGreeterServiceServer interface { + mustEmbedUnimplementedGreeterServiceServer() +} + +func RegisterGreeterServiceServer(s grpc.ServiceRegistrar, srv GreeterServiceServer) { + // If the following call pancis, it indicates UnimplementedGreeterServiceServer was + // embedded by pointer and is nil. This will cause panics if an + // unimplemented method is ever invoked, so we test this at initialization + // time to prevent it from happening at runtime later due to I/O. + if t, ok := srv.(interface{ testEmbeddedByValue() }); ok { + t.testEmbeddedByValue() + } + s.RegisterService(&GreeterService_ServiceDesc, srv) +} + +func _GreeterService_SayHello_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) { + in := new(HelloRequest) + if err := dec(in); err != nil { + return nil, err + } + if interceptor == nil { + return srv.(GreeterServiceServer).SayHello(ctx, in) + } + info := &grpc.UnaryServerInfo{ + Server: srv, + FullMethod: GreeterService_SayHello_FullMethodName, + } + handler := func(ctx context.Context, req interface{}) (interface{}, error) { + return srv.(GreeterServiceServer).SayHello(ctx, req.(*HelloRequest)) + } + return interceptor(ctx, in, info, handler) +} + +func _GreeterService_SayHelloStream_Handler(srv interface{}, stream grpc.ServerStream) error { + m := new(HelloRequest) + if err := stream.RecvMsg(m); err != nil { + return err + } + return srv.(GreeterServiceServer).SayHelloStream(m, &grpc.GenericServerStream[HelloRequest, HelloReply]{ServerStream: stream}) +} + +// This type alias is provided for backwards compatibility with existing code that references the prior non-generic stream type by name. +type GreeterService_SayHelloStreamServer = grpc.ServerStreamingServer[HelloReply] + +func _GreeterService_CollectHellos_Handler(srv interface{}, stream grpc.ServerStream) error { + return srv.(GreeterServiceServer).CollectHellos(&grpc.GenericServerStream[HelloRequest, HelloReply]{ServerStream: stream}) +} + +// This type alias is provided for backwards compatibility with existing code that references the prior non-generic stream type by name. +type GreeterService_CollectHellosServer = grpc.ClientStreamingServer[HelloRequest, HelloReply] + +func _GreeterService_ChatHello_Handler(srv interface{}, stream grpc.ServerStream) error { + return srv.(GreeterServiceServer).ChatHello(&grpc.GenericServerStream[HelloRequest, HelloReply]{ServerStream: stream}) +} + +// This type alias is provided for backwards compatibility with existing code that references the prior non-generic stream type by name. +type GreeterService_ChatHelloServer = grpc.BidiStreamingServer[HelloRequest, HelloReply] + +// GreeterService_ServiceDesc is the grpc.ServiceDesc for GreeterService service. +// It's only intended for direct use with grpc.RegisterService, +// and not to be introspected or modified (even as a copy) +var GreeterService_ServiceDesc = grpc.ServiceDesc{ + ServiceName: "greeter.v1.GreeterService", + HandlerType: (*GreeterServiceServer)(nil), + Methods: []grpc.MethodDesc{ + { + MethodName: "SayHello", + Handler: _GreeterService_SayHello_Handler, + }, + }, + Streams: []grpc.StreamDesc{ + { + StreamName: "SayHelloStream", + Handler: _GreeterService_SayHelloStream_Handler, + ServerStreams: true, + }, + { + StreamName: "CollectHellos", + Handler: _GreeterService_CollectHellos_Handler, + ClientStreams: true, + }, + { + StreamName: "ChatHello", + Handler: _GreeterService_ChatHello_Handler, + ServerStreams: true, + ClientStreams: true, + }, + }, + Metadata: "greeter/v1/greeter.proto", +} + +--- End File: grpc_agent/gen/greeter/v1/greeter_grpc.pb.go + +--- Start File: grpc_agent/grpc_server.go +package main + +import ( + "context" + pb_greeter "example/gen/greeter/v1" + "fmt" + "log" + "time" +) + +// Server is used to implement the GreeterServiceServer. +type Server struct { + pb_greeter.UnimplementedGreeterServiceServer +} + +// SayHello implements the SayHello RPC method. +func (s *Server) SayHello(ctx context.Context, req *pb_greeter.HelloRequest) (*pb_greeter.HelloReply, error) { + log.Printf("Received: %v", req.GetName()) + return &pb_greeter.HelloReply{ + Message: "Hello " + req.GetName(), + Timestamp: time.Now().Unix(), + }, nil +} + +// SayHelloStream implements the server streaming RPC method. +func (s *Server) SayHelloStream(req *pb_greeter.HelloRequest, stream pb_greeter.GreeterService_SayHelloStreamServer) error { + log.Printf("Received stream request: %v", req.GetName()) + + for i := 0; i < 5; i++ { + if err := stream.Send(&pb_greeter.HelloReply{ + Message: fmt.Sprintf("Hello %s! (response #%d)", req.GetName(), i+1), + Timestamp: time.Now().Unix(), + }); err != nil { + return err + } + time.Sleep(100 * time.Millisecond) + } + + return nil +} + +// CollectHellos implements the client streaming RPC method. +func (s *Server) CollectHellos(stream pb_greeter.GreeterService_CollectHellosServer) error { + var names []string + + for { + req, err := stream.Recv() + if err != nil { + break + } + names = append(names, req.GetName()) + } + + var message string + if len(names) == 0 { + message = "Hello to nobody!" + } else if len(names) == 1 { + message = fmt.Sprintf("Hello to %s!", names[0]) + } else { + message = fmt.Sprintf("Hello to %d people!", len(names)) + } + + return stream.SendAndClose(&pb_greeter.HelloReply{ + Message: message, + Timestamp: time.Now().Unix(), + }) +} + +// ChatHello implements the bidirectional streaming RPC method. +func (s *Server) ChatHello(stream pb_greeter.GreeterService_ChatHelloServer) error { + for { + req, err := stream.Recv() + if err != nil { + return nil + } + + if err := stream.Send(&pb_greeter.HelloReply{ + Message: "Hello " + req.GetName() + "!", + Timestamp: time.Now().Unix(), + }); err != nil { + return err + } + } +} + +--- End File: grpc_agent/grpc_server.go + +--- Start File: grpc_agent/main.go +package main + +import ( + "context" + pb_greeter "example/gen/greeter/v1" + "flag" + "fmt" + "log/slog" + "net" + "net/http" + "os" + "os/signal" + "strings" + "syscall" + "time" + + "github.com/doganarif/govisual" + "github.com/doganarif/govisual/pkg/agent" + "github.com/doganarif/govisual/pkg/server" + "github.com/doganarif/govisual/pkg/store" + "github.com/doganarif/govisual/pkg/transport" + "google.golang.org/grpc" + "google.golang.org/grpc/credentials/insecure" +) + +var ( + port = flag.Int("port", 8080, "HTTP server port (for dashboard)") + grpcPort = flag.Int("grpc-port", 9090, "gRPC server port") + agentMode = flag.String("agent-mode", "store", "Agent mode: store, nats, http") + natsURL = flag.String("nats-url", "nats://localhost:4222", "NATS server URL. Only used with agent-mode 'nats'") + httpURL = flag.String("http-url", "http://localhost:8080/api/agent/logs", "HTTP endpoint URL. Only used with agent-mode 'http'") +) + +func main() { + flag.Parse() + + err := run() + if err != nil { + fmt.Printf("failed to run: %v\n", err) + os.Exit(1) + } +} + +func run() error { + log := slog.New(slog.NewTextHandler(os.Stdout, nil)) + sharedStore := store.NewInMemoryStore(100) + + var transportObj transport.Transport + var err error + switch *agentMode { + case "store": + log.Info("Using shared store transport") + transportObj = transport.NewStoreTransport(sharedStore) + case "nats": + log.Info("Using NATS transport", slog.String("url", *natsURL)) + transportObj, err = transport.NewNATSTransport(*natsURL) + if err != nil { + return fmt.Errorf("creating NATS transport: %w", err) + } + case "http": + log.Info("Using HTTP transport", slog.String("url", *httpURL)) + transportObj = transport.NewHTTPTransport(*httpURL, + transport.WithTimeout(5*time.Second), + transport.WithMaxRetries(3), + ) + default: + return fmt.Errorf("unknown agent mode %q", *agentMode) + } + + // Create gRPC agent with the improved option system + grpcAgent := agent.NewGRPCAgent(transportObj, + agent.WithGRPCRequestDataLogging(true), + agent.WithGRPCResponseDataLogging(true), + agent.WithBatchingEnabled(true).ForGRPC(), + agent.WithBatchSize(5).ForGRPC(), + agent.WithBatchInterval(1*time.Second).ForGRPC(), + ) + + // Start the gRPC server with the agent + lis, err := net.Listen("tcp", fmt.Sprintf(":%d", *grpcPort)) + if err != nil { + return fmt.Errorf("listening to gRPC: %w", err) + } + + grpcServer := server.NewGRPCServer(grpcAgent) + pb_greeter.RegisterGreeterServiceServer(grpcServer, &Server{}) + + log.Info("Starting gRPC server with visualisation", slog.Int("port", *grpcPort)) + + go func() { + err := grpcServer.Serve(lis) + if err != nil { + log.Error("failed to serve gRPC", slog.Any("err", err)) + os.Exit(1) + } + }() + + // Start the HTTP dashboard server + mux := http.NewServeMux() + + // Add a simple homepage + mux.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != "/" { + http.NotFound(w, r) + return + } + w.Header().Set("Content-Type", "text/html") + w.Write([]byte(fmt.Sprintf(` + + + GoVisual gRPC Agent Example + + + +
+

GoVisual gRPC Agent Example

+
+

Dashboard

+

Visit /__viz to access the request visualizer dashboard.

+
+
+

Configuration

+
+ gRPC Server: localhost:%d
+ Agent Mode: %s
+ %s +
+
+
+

Test the gRPC Service

+

An initial test request has been made automatically. You can make additional requests using a gRPC client.

+

The service provides the following methods:

+ +

You can use a tool like grpcui or grpcurl to test these methods.

+
+
+ + + `, *grpcPort, *agentMode, getExtraInfo()))) + }) + + // Add API endpoints for agent communication + if *agentMode != "store" { + // Create and register agent API handler + agentAPI := server.NewAgentAPI(sharedStore) + agentAPI.RegisterHandlers(mux) + } + + // Wrap with GoVisual for dashboard + handler := govisual.Wrap(mux, + govisual.WithMaxRequests(100), + govisual.WithRequestBodyLogging(true), + govisual.WithResponseBodyLogging(true), + govisual.WithSharedStore(sharedStore), + ) + + // Start NATS handler if using NATS transport + var natsHandler *server.NATSHandler + if *agentMode == "nats" { + natsHandler, err = server.NewNATSHandler(sharedStore, *natsURL) + if err != nil { + return fmt.Errorf("creating NATS handler: %w", err) + } + + err = natsHandler.Start() + if err != nil { + return fmt.Errorf("starting NATS handler: %w", err) + } + } + + httpServer := &http.Server{ + Addr: fmt.Sprintf(":%d", *port), + Handler: handler, + } + + log.Info("Started dashboard server", slog.Int("port", *port), slog.String("dashboard_addr", fmt.Sprintf("http://localhost:%d/__viz", *port))) + + go func() { + err := httpServer.ListenAndServe() + if err != nil && err != http.ErrServerClosed { + log.Error("failed to serve HTTP", slog.Any("err", err)) + os.Exit(1) + } + }() + + // Make test requests to show different gRPC method types + go func() { + time.Sleep(500 * time.Millisecond) + + // Create gRPC client + conn, err := grpc.NewClient( + fmt.Sprintf("localhost:%d", *grpcPort), + grpc.WithTransportCredentials(insecure.NewCredentials()), + ) + if err != nil { + log.Error("Failed to connect: %v", err) + return + } + defer conn.Close() + + client := pb_greeter.NewGreeterServiceClient(conn) + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + + // Test 1: Unary RPC + log.Info("Testing unary RPC (SayHello)") + resp, err := client.SayHello(ctx, &pb_greeter.HelloRequest{ + Name: "Agent Test", + Message: "This is a test message", + }) + if err != nil { + log.Error("SayHello failed", slog.Any("err", err)) + } else { + log.Info("Received unary RPC response", slog.String("msg", resp.GetMessage()), slog.Int64("timestamp", resp.GetTimestamp())) + } + + // Test 2: Server streaming RPC + log.Info("Testing server streaming RPC (SayHelloStream)") + stream, err := client.SayHelloStream(ctx, &pb_greeter.HelloRequest{ + Name: "Stream Test", + Message: "Testing server streaming", + }) + if err != nil { + log.Error("SayHelloStream failed", slog.Any("err", err)) + } else { + for { + resp, err := stream.Recv() + if err != nil { + break + } + log.Info("Received server stream response", slog.String("msg", resp.GetMessage()), slog.Int64("timestamp", resp.GetTimestamp())) + } + } + + // Test 3: Client streaming RPC + log.Info("Testing client streaming RPC (CollectHellos)") + clientStream, err := client.CollectHellos(ctx) + if err != nil { + log.Error("CollectHellos failed", slog.Any("err", err)) + } else { + // Send multiple messages + for i := 1; i <= 3; i++ { + name := fmt.Sprintf("Person %d", i) + if err := clientStream.Send(&pb_greeter.HelloRequest{ + Name: name, + Message: fmt.Sprintf("Message from %s", name), + }); err != nil { + log.Error("failed to send client stream message", slog.Any("err", err)) + break + } + time.Sleep(100 * time.Millisecond) + } + + // Close and receive response + resp, err := clientStream.CloseAndRecv() + if err != nil { + log.Error("failed to close client stream", slog.Any("err", err)) + } else { + log.Info("Received client stream response", slog.String("msg", resp.GetMessage()), slog.Int64("timestamp", resp.GetTimestamp())) + } + } + + // Test 4: Bidirectional streaming RPC + log.Info("Testing bidirectional streaming RPC (ChatHello)") + bidiStream, err := client.ChatHello(ctx) + if err != nil { + log.Error("ChatHello failed", slog.Any("err", err)) + } else { + // Send and receive in goroutines + done := make(chan bool) + + // Receiving goroutine + go func() { + for { + resp, err := bidiStream.Recv() + if err != nil { + break + } + log.Info("Received Bidi response", slog.String("msg", resp.GetMessage()), slog.Int64("timestamp", resp.GetTimestamp())) + } + done <- true + }() + + // Send messages + for i := 1; i <= 3; i++ { + name := fmt.Sprintf("ChatPerson %d", i) + if err := bidiStream.Send(&pb_greeter.HelloRequest{ + Name: name, + Message: fmt.Sprintf("Bidi message from %s", name), + }); err != nil { + log.Error("failed to send bidi message: %v", err) + break + } + time.Sleep(200 * time.Millisecond) + } + + // Close sending + bidiStream.CloseSend() + <-done + } + + log.Info("All gRPC tests completed") + }() + + // Wait for termination signal + signalChan := make(chan os.Signal, 1) + signal.Notify(signalChan, os.Interrupt, syscall.SIGTERM) + <-signalChan + + log.Info("Shutdown signal received") + + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + + if err := httpServer.Shutdown(ctx); err != nil { + log.Info("HTTP server shutdown error: %v", err) + } + + grpcServer.GracefulStop() + lis.Close() + + if natsHandler != nil { + err = natsHandler.Stop() + if err != nil { + log.Error("failed to stop NATS handler", slog.Any("err", err)) + } + } + + err = grpcAgent.Close() + if err != nil { + log.Error("failed to close agent", slog.Any("err", err)) + } + + err = transportObj.Close() + if err != nil { + log.Error("failed to close transport", slog.Any("err", err)) + } + + err = sharedStore.Close() + if err != nil { + log.Error("failed to close store", slog.Any("err", err)) + } + + log.Info("Servers shut down successfully") + return nil +} + +// getExtraInfo returns extra information for the homepage based on the agent mode +func getExtraInfo() string { + var info strings.Builder + + switch *agentMode { + case "store": + info.WriteString("Transport: In-memory shared store (direct)") + case "nats": + info.WriteString(fmt.Sprintf("Transport: NATS messaging via %s", *natsURL)) + case "http": + info.WriteString(fmt.Sprintf("Transport: HTTP via %s", *httpURL)) + } + + return info.String() +} + +--- End File: grpc_agent/main.go diff --git a/cmd/examples/grpc_agent/buf.gen.yaml b/cmd/examples/grpc_agent/buf.gen.yaml new file mode 100644 index 0000000..9b66f5e --- /dev/null +++ b/cmd/examples/grpc_agent/buf.gen.yaml @@ -0,0 +1,17 @@ +version: v2 +managed: + enabled: true + override: + - file_option: go_package_prefix + value: github.com/doganarif/govisual/examples/grpc/gen +plugins: + - remote: buf.build/protocolbuffers/go + out: gen + opt: paths=source_relative + - remote: buf.build/grpc/go + out: gen + opt: + - paths=source_relative + - require_unimplemented_servers=false +inputs: + - directory: proto diff --git a/cmd/examples/grpc_agent/buf.yaml b/cmd/examples/grpc_agent/buf.yaml new file mode 100644 index 0000000..32c05ea --- /dev/null +++ b/cmd/examples/grpc_agent/buf.yaml @@ -0,0 +1,11 @@ +version: v2 +modules: + - path: proto +lint: + use: + - STANDARD + ignore: + - proto/google/type/datetime.proto +breaking: + use: + - FILE diff --git a/cmd/examples/grpc_agent/gen/greeter/v1/greeter.pb.go b/cmd/examples/grpc_agent/gen/greeter/v1/greeter.pb.go new file mode 100644 index 0000000..a1884de --- /dev/null +++ b/cmd/examples/grpc_agent/gen/greeter/v1/greeter.pb.go @@ -0,0 +1,207 @@ +// Code generated by protoc-gen-go. DO NOT EDIT. +// versions: +// protoc-gen-go v1.36.6 +// protoc (unknown) +// source: greeter/v1/greeter.proto + +package greeterv1 + +import ( + protoreflect "google.golang.org/protobuf/reflect/protoreflect" + protoimpl "google.golang.org/protobuf/runtime/protoimpl" + reflect "reflect" + sync "sync" + unsafe "unsafe" +) + +const ( + // Verify that this generated code is sufficiently up-to-date. + _ = protoimpl.EnforceVersion(20 - protoimpl.MinVersion) + // Verify that runtime/protoimpl is sufficiently up-to-date. + _ = protoimpl.EnforceVersion(protoimpl.MaxVersion - 20) +) + +// The request message containing the user's name. +type HelloRequest struct { + state protoimpl.MessageState `protogen:"open.v1"` + Name string `protobuf:"bytes,1,opt,name=name,proto3" json:"name,omitempty"` + Message string `protobuf:"bytes,2,opt,name=message,proto3" json:"message,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *HelloRequest) Reset() { + *x = HelloRequest{} + mi := &file_greeter_v1_greeter_proto_msgTypes[0] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *HelloRequest) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*HelloRequest) ProtoMessage() {} + +func (x *HelloRequest) ProtoReflect() protoreflect.Message { + mi := &file_greeter_v1_greeter_proto_msgTypes[0] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use HelloRequest.ProtoReflect.Descriptor instead. +func (*HelloRequest) Descriptor() ([]byte, []int) { + return file_greeter_v1_greeter_proto_rawDescGZIP(), []int{0} +} + +func (x *HelloRequest) GetName() string { + if x != nil { + return x.Name + } + return "" +} + +func (x *HelloRequest) GetMessage() string { + if x != nil { + return x.Message + } + return "" +} + +// The response message containing the greetings. +type HelloReply struct { + state protoimpl.MessageState `protogen:"open.v1"` + Message string `protobuf:"bytes,1,opt,name=message,proto3" json:"message,omitempty"` + Timestamp int64 `protobuf:"varint,2,opt,name=timestamp,proto3" json:"timestamp,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *HelloReply) Reset() { + *x = HelloReply{} + mi := &file_greeter_v1_greeter_proto_msgTypes[1] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *HelloReply) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*HelloReply) ProtoMessage() {} + +func (x *HelloReply) ProtoReflect() protoreflect.Message { + mi := &file_greeter_v1_greeter_proto_msgTypes[1] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use HelloReply.ProtoReflect.Descriptor instead. +func (*HelloReply) Descriptor() ([]byte, []int) { + return file_greeter_v1_greeter_proto_rawDescGZIP(), []int{1} +} + +func (x *HelloReply) GetMessage() string { + if x != nil { + return x.Message + } + return "" +} + +func (x *HelloReply) GetTimestamp() int64 { + if x != nil { + return x.Timestamp + } + return 0 +} + +var File_greeter_v1_greeter_proto protoreflect.FileDescriptor + +const file_greeter_v1_greeter_proto_rawDesc = "" + + "\n" + + "\x18greeter/v1/greeter.proto\x12\n" + + "greeter.v1\"<\n" + + "\fHelloRequest\x12\x12\n" + + "\x04name\x18\x01 \x01(\tR\x04name\x12\x18\n" + + "\amessage\x18\x02 \x01(\tR\amessage\"D\n" + + "\n" + + "HelloReply\x12\x18\n" + + "\amessage\x18\x01 \x01(\tR\amessage\x12\x1c\n" + + "\ttimestamp\x18\x02 \x01(\x03R\ttimestamp2\xa4\x02\n" + + "\x0eGreeterService\x12>\n" + + "\bSayHello\x12\x18.greeter.v1.HelloRequest\x1a\x16.greeter.v1.HelloReply\"\x00\x12F\n" + + "\x0eSayHelloStream\x12\x18.greeter.v1.HelloRequest\x1a\x16.greeter.v1.HelloReply\"\x000\x01\x12E\n" + + "\rCollectHellos\x12\x18.greeter.v1.HelloRequest\x1a\x16.greeter.v1.HelloReply\"\x00(\x01\x12C\n" + + "\tChatHello\x12\x18.greeter.v1.HelloRequest\x1a\x16.greeter.v1.HelloReply\"\x00(\x010\x01B\xad\x01\n" + + "\x0ecom.greeter.v1B\fGreeterProtoP\x01ZDgithub.com/doganarif/govisual/examples/grpc/gen/greeter/v1;greeterv1\xa2\x02\x03GXX\xaa\x02\n" + + "Greeter.V1\xca\x02\n" + + "Greeter\\V1\xe2\x02\x16Greeter\\V1\\GPBMetadata\xea\x02\vGreeter::V1b\x06proto3" + +var ( + file_greeter_v1_greeter_proto_rawDescOnce sync.Once + file_greeter_v1_greeter_proto_rawDescData []byte +) + +func file_greeter_v1_greeter_proto_rawDescGZIP() []byte { + file_greeter_v1_greeter_proto_rawDescOnce.Do(func() { + file_greeter_v1_greeter_proto_rawDescData = protoimpl.X.CompressGZIP(unsafe.Slice(unsafe.StringData(file_greeter_v1_greeter_proto_rawDesc), len(file_greeter_v1_greeter_proto_rawDesc))) + }) + return file_greeter_v1_greeter_proto_rawDescData +} + +var file_greeter_v1_greeter_proto_msgTypes = make([]protoimpl.MessageInfo, 2) +var file_greeter_v1_greeter_proto_goTypes = []any{ + (*HelloRequest)(nil), // 0: greeter.v1.HelloRequest + (*HelloReply)(nil), // 1: greeter.v1.HelloReply +} +var file_greeter_v1_greeter_proto_depIdxs = []int32{ + 0, // 0: greeter.v1.GreeterService.SayHello:input_type -> greeter.v1.HelloRequest + 0, // 1: greeter.v1.GreeterService.SayHelloStream:input_type -> greeter.v1.HelloRequest + 0, // 2: greeter.v1.GreeterService.CollectHellos:input_type -> greeter.v1.HelloRequest + 0, // 3: greeter.v1.GreeterService.ChatHello:input_type -> greeter.v1.HelloRequest + 1, // 4: greeter.v1.GreeterService.SayHello:output_type -> greeter.v1.HelloReply + 1, // 5: greeter.v1.GreeterService.SayHelloStream:output_type -> greeter.v1.HelloReply + 1, // 6: greeter.v1.GreeterService.CollectHellos:output_type -> greeter.v1.HelloReply + 1, // 7: greeter.v1.GreeterService.ChatHello:output_type -> greeter.v1.HelloReply + 4, // [4:8] is the sub-list for method output_type + 0, // [0:4] is the sub-list for method input_type + 0, // [0:0] is the sub-list for extension type_name + 0, // [0:0] is the sub-list for extension extendee + 0, // [0:0] is the sub-list for field type_name +} + +func init() { file_greeter_v1_greeter_proto_init() } +func file_greeter_v1_greeter_proto_init() { + if File_greeter_v1_greeter_proto != nil { + return + } + type x struct{} + out := protoimpl.TypeBuilder{ + File: protoimpl.DescBuilder{ + GoPackagePath: reflect.TypeOf(x{}).PkgPath(), + RawDescriptor: unsafe.Slice(unsafe.StringData(file_greeter_v1_greeter_proto_rawDesc), len(file_greeter_v1_greeter_proto_rawDesc)), + NumEnums: 0, + NumMessages: 2, + NumExtensions: 0, + NumServices: 1, + }, + GoTypes: file_greeter_v1_greeter_proto_goTypes, + DependencyIndexes: file_greeter_v1_greeter_proto_depIdxs, + MessageInfos: file_greeter_v1_greeter_proto_msgTypes, + }.Build() + File_greeter_v1_greeter_proto = out.File + file_greeter_v1_greeter_proto_goTypes = nil + file_greeter_v1_greeter_proto_depIdxs = nil +} diff --git a/cmd/examples/grpc_agent/gen/greeter/v1/greeter_grpc.pb.go b/cmd/examples/grpc_agent/gen/greeter/v1/greeter_grpc.pb.go new file mode 100644 index 0000000..bb27974 --- /dev/null +++ b/cmd/examples/grpc_agent/gen/greeter/v1/greeter_grpc.pb.go @@ -0,0 +1,236 @@ +// Code generated by protoc-gen-go-grpc. DO NOT EDIT. +// versions: +// - protoc-gen-go-grpc v1.5.1 +// - protoc (unknown) +// source: greeter/v1/greeter.proto + +package greeterv1 + +import ( + context "context" + grpc "google.golang.org/grpc" + codes "google.golang.org/grpc/codes" + status "google.golang.org/grpc/status" +) + +// This is a compile-time assertion to ensure that this generated file +// is compatible with the grpc package it is being compiled against. +// Requires gRPC-Go v1.64.0 or later. +const _ = grpc.SupportPackageIsVersion9 + +const ( + GreeterService_SayHello_FullMethodName = "/greeter.v1.GreeterService/SayHello" + GreeterService_SayHelloStream_FullMethodName = "/greeter.v1.GreeterService/SayHelloStream" + GreeterService_CollectHellos_FullMethodName = "/greeter.v1.GreeterService/CollectHellos" + GreeterService_ChatHello_FullMethodName = "/greeter.v1.GreeterService/ChatHello" +) + +// GreeterServiceClient is the client API for GreeterService service. +// +// For semantics around ctx use and closing/ending streaming RPCs, please refer to https://pkg.go.dev/google.golang.org/grpc/?tab=doc#ClientConn.NewStream. +// +// The greeting service definition. +type GreeterServiceClient interface { + // Sends a greeting (Unary RPC) + SayHello(ctx context.Context, in *HelloRequest, opts ...grpc.CallOption) (*HelloReply, error) + // Sends multiple greetings in response to a single request (Server streaming RPC) + SayHelloStream(ctx context.Context, in *HelloRequest, opts ...grpc.CallOption) (grpc.ServerStreamingClient[HelloReply], error) + // Collects multiple greetings and responds once (Client streaming RPC) + CollectHellos(ctx context.Context, opts ...grpc.CallOption) (grpc.ClientStreamingClient[HelloRequest, HelloReply], error) + // Chat with greetings back and forth (Bidirectional streaming RPC) + ChatHello(ctx context.Context, opts ...grpc.CallOption) (grpc.BidiStreamingClient[HelloRequest, HelloReply], error) +} + +type greeterServiceClient struct { + cc grpc.ClientConnInterface +} + +func NewGreeterServiceClient(cc grpc.ClientConnInterface) GreeterServiceClient { + return &greeterServiceClient{cc} +} + +func (c *greeterServiceClient) SayHello(ctx context.Context, in *HelloRequest, opts ...grpc.CallOption) (*HelloReply, error) { + cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...) + out := new(HelloReply) + err := c.cc.Invoke(ctx, GreeterService_SayHello_FullMethodName, in, out, cOpts...) + if err != nil { + return nil, err + } + return out, nil +} + +func (c *greeterServiceClient) SayHelloStream(ctx context.Context, in *HelloRequest, opts ...grpc.CallOption) (grpc.ServerStreamingClient[HelloReply], error) { + cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...) + stream, err := c.cc.NewStream(ctx, &GreeterService_ServiceDesc.Streams[0], GreeterService_SayHelloStream_FullMethodName, cOpts...) + if err != nil { + return nil, err + } + x := &grpc.GenericClientStream[HelloRequest, HelloReply]{ClientStream: stream} + if err := x.ClientStream.SendMsg(in); err != nil { + return nil, err + } + if err := x.ClientStream.CloseSend(); err != nil { + return nil, err + } + return x, nil +} + +// This type alias is provided for backwards compatibility with existing code that references the prior non-generic stream type by name. +type GreeterService_SayHelloStreamClient = grpc.ServerStreamingClient[HelloReply] + +func (c *greeterServiceClient) CollectHellos(ctx context.Context, opts ...grpc.CallOption) (grpc.ClientStreamingClient[HelloRequest, HelloReply], error) { + cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...) + stream, err := c.cc.NewStream(ctx, &GreeterService_ServiceDesc.Streams[1], GreeterService_CollectHellos_FullMethodName, cOpts...) + if err != nil { + return nil, err + } + x := &grpc.GenericClientStream[HelloRequest, HelloReply]{ClientStream: stream} + return x, nil +} + +// This type alias is provided for backwards compatibility with existing code that references the prior non-generic stream type by name. +type GreeterService_CollectHellosClient = grpc.ClientStreamingClient[HelloRequest, HelloReply] + +func (c *greeterServiceClient) ChatHello(ctx context.Context, opts ...grpc.CallOption) (grpc.BidiStreamingClient[HelloRequest, HelloReply], error) { + cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...) + stream, err := c.cc.NewStream(ctx, &GreeterService_ServiceDesc.Streams[2], GreeterService_ChatHello_FullMethodName, cOpts...) + if err != nil { + return nil, err + } + x := &grpc.GenericClientStream[HelloRequest, HelloReply]{ClientStream: stream} + return x, nil +} + +// This type alias is provided for backwards compatibility with existing code that references the prior non-generic stream type by name. +type GreeterService_ChatHelloClient = grpc.BidiStreamingClient[HelloRequest, HelloReply] + +// GreeterServiceServer is the server API for GreeterService service. +// All implementations should embed UnimplementedGreeterServiceServer +// for forward compatibility. +// +// The greeting service definition. +type GreeterServiceServer interface { + // Sends a greeting (Unary RPC) + SayHello(context.Context, *HelloRequest) (*HelloReply, error) + // Sends multiple greetings in response to a single request (Server streaming RPC) + SayHelloStream(*HelloRequest, grpc.ServerStreamingServer[HelloReply]) error + // Collects multiple greetings and responds once (Client streaming RPC) + CollectHellos(grpc.ClientStreamingServer[HelloRequest, HelloReply]) error + // Chat with greetings back and forth (Bidirectional streaming RPC) + ChatHello(grpc.BidiStreamingServer[HelloRequest, HelloReply]) error +} + +// UnimplementedGreeterServiceServer should be embedded to have +// forward compatible implementations. +// +// NOTE: this should be embedded by value instead of pointer to avoid a nil +// pointer dereference when methods are called. +type UnimplementedGreeterServiceServer struct{} + +func (UnimplementedGreeterServiceServer) SayHello(context.Context, *HelloRequest) (*HelloReply, error) { + return nil, status.Errorf(codes.Unimplemented, "method SayHello not implemented") +} +func (UnimplementedGreeterServiceServer) SayHelloStream(*HelloRequest, grpc.ServerStreamingServer[HelloReply]) error { + return status.Errorf(codes.Unimplemented, "method SayHelloStream not implemented") +} +func (UnimplementedGreeterServiceServer) CollectHellos(grpc.ClientStreamingServer[HelloRequest, HelloReply]) error { + return status.Errorf(codes.Unimplemented, "method CollectHellos not implemented") +} +func (UnimplementedGreeterServiceServer) ChatHello(grpc.BidiStreamingServer[HelloRequest, HelloReply]) error { + return status.Errorf(codes.Unimplemented, "method ChatHello not implemented") +} +func (UnimplementedGreeterServiceServer) testEmbeddedByValue() {} + +// UnsafeGreeterServiceServer may be embedded to opt out of forward compatibility for this service. +// Use of this interface is not recommended, as added methods to GreeterServiceServer will +// result in compilation errors. +type UnsafeGreeterServiceServer interface { + mustEmbedUnimplementedGreeterServiceServer() +} + +func RegisterGreeterServiceServer(s grpc.ServiceRegistrar, srv GreeterServiceServer) { + // If the following call pancis, it indicates UnimplementedGreeterServiceServer was + // embedded by pointer and is nil. This will cause panics if an + // unimplemented method is ever invoked, so we test this at initialization + // time to prevent it from happening at runtime later due to I/O. + if t, ok := srv.(interface{ testEmbeddedByValue() }); ok { + t.testEmbeddedByValue() + } + s.RegisterService(&GreeterService_ServiceDesc, srv) +} + +func _GreeterService_SayHello_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) { + in := new(HelloRequest) + if err := dec(in); err != nil { + return nil, err + } + if interceptor == nil { + return srv.(GreeterServiceServer).SayHello(ctx, in) + } + info := &grpc.UnaryServerInfo{ + Server: srv, + FullMethod: GreeterService_SayHello_FullMethodName, + } + handler := func(ctx context.Context, req interface{}) (interface{}, error) { + return srv.(GreeterServiceServer).SayHello(ctx, req.(*HelloRequest)) + } + return interceptor(ctx, in, info, handler) +} + +func _GreeterService_SayHelloStream_Handler(srv interface{}, stream grpc.ServerStream) error { + m := new(HelloRequest) + if err := stream.RecvMsg(m); err != nil { + return err + } + return srv.(GreeterServiceServer).SayHelloStream(m, &grpc.GenericServerStream[HelloRequest, HelloReply]{ServerStream: stream}) +} + +// This type alias is provided for backwards compatibility with existing code that references the prior non-generic stream type by name. +type GreeterService_SayHelloStreamServer = grpc.ServerStreamingServer[HelloReply] + +func _GreeterService_CollectHellos_Handler(srv interface{}, stream grpc.ServerStream) error { + return srv.(GreeterServiceServer).CollectHellos(&grpc.GenericServerStream[HelloRequest, HelloReply]{ServerStream: stream}) +} + +// This type alias is provided for backwards compatibility with existing code that references the prior non-generic stream type by name. +type GreeterService_CollectHellosServer = grpc.ClientStreamingServer[HelloRequest, HelloReply] + +func _GreeterService_ChatHello_Handler(srv interface{}, stream grpc.ServerStream) error { + return srv.(GreeterServiceServer).ChatHello(&grpc.GenericServerStream[HelloRequest, HelloReply]{ServerStream: stream}) +} + +// This type alias is provided for backwards compatibility with existing code that references the prior non-generic stream type by name. +type GreeterService_ChatHelloServer = grpc.BidiStreamingServer[HelloRequest, HelloReply] + +// GreeterService_ServiceDesc is the grpc.ServiceDesc for GreeterService service. +// It's only intended for direct use with grpc.RegisterService, +// and not to be introspected or modified (even as a copy) +var GreeterService_ServiceDesc = grpc.ServiceDesc{ + ServiceName: "greeter.v1.GreeterService", + HandlerType: (*GreeterServiceServer)(nil), + Methods: []grpc.MethodDesc{ + { + MethodName: "SayHello", + Handler: _GreeterService_SayHello_Handler, + }, + }, + Streams: []grpc.StreamDesc{ + { + StreamName: "SayHelloStream", + Handler: _GreeterService_SayHelloStream_Handler, + ServerStreams: true, + }, + { + StreamName: "CollectHellos", + Handler: _GreeterService_CollectHellos_Handler, + ClientStreams: true, + }, + { + StreamName: "ChatHello", + Handler: _GreeterService_ChatHello_Handler, + ServerStreams: true, + ClientStreams: true, + }, + }, + Metadata: "greeter/v1/greeter.proto", +} diff --git a/cmd/examples/grpc_agent/go.mod b/cmd/examples/grpc_agent/go.mod new file mode 100644 index 0000000..1a1328f --- /dev/null +++ b/cmd/examples/grpc_agent/go.mod @@ -0,0 +1,41 @@ +module example + +go 1.24.0 + +require ( + github.com/doganarif/govisual v0.1.8 + google.golang.org/grpc v1.72.0 + google.golang.org/protobuf v1.36.6 +) + +replace github.com/doganarif/govisual => ../../../ + +require ( + github.com/cenkalti/backoff/v4 v4.2.1 // indirect + github.com/cespare/xxhash/v2 v2.3.0 // indirect + github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f // indirect + github.com/go-logr/logr v1.4.2 // indirect + github.com/go-logr/stdr v1.2.2 // indirect + github.com/go-redis/redis/v8 v8.11.5 // indirect + github.com/google/uuid v1.6.0 // indirect + github.com/grpc-ecosystem/grpc-gateway/v2 v2.19.0 // indirect + github.com/klauspost/compress v1.18.0 // indirect + github.com/lib/pq v1.10.9 // indirect + github.com/nats-io/nats.go v1.42.0 // indirect + github.com/nats-io/nkeys v0.4.11 // indirect + github.com/nats-io/nuid v1.0.1 // indirect + go.opentelemetry.io/auto/sdk v1.1.0 // indirect + go.opentelemetry.io/otel v1.34.0 // indirect + go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.24.0 // indirect + go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.24.0 // indirect + go.opentelemetry.io/otel/metric v1.34.0 // indirect + go.opentelemetry.io/otel/sdk v1.34.0 // indirect + go.opentelemetry.io/otel/trace v1.34.0 // indirect + go.opentelemetry.io/proto/otlp v1.1.0 // indirect + golang.org/x/crypto v0.37.0 // indirect + golang.org/x/net v0.35.0 // indirect + golang.org/x/sys v0.32.0 // indirect + golang.org/x/text v0.24.0 // indirect + google.golang.org/genproto/googleapis/api v0.0.0-20250218202821-56aae31c358a // indirect + google.golang.org/genproto/googleapis/rpc v0.0.0-20250218202821-56aae31c358a // indirect +) diff --git a/cmd/examples/grpc_agent/go.sum b/cmd/examples/grpc_agent/go.sum new file mode 100644 index 0000000..cbb74af --- /dev/null +++ b/cmd/examples/grpc_agent/go.sum @@ -0,0 +1,89 @@ +github.com/cenkalti/backoff/v4 v4.2.1 h1:y4OZtCnogmCPw98Zjyt5a6+QwPLGkiQsYW5oUqylYbM= +github.com/cenkalti/backoff/v4 v4.2.1/go.mod h1:Y3VNntkOUPxTVeUxJ/G5vcM//AlwfmyYozVcomhLiZE= +github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs= +github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= +github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f h1:lO4WD4F/rVNCu3HqELle0jiPLLBs70cWOduZpkS1E78= +github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f/go.mod h1:cuUVRXasLTGF7a8hSLbxyZXjz+1KgoB3wDUb6vlszIc= +github.com/fsnotify/fsnotify v1.4.9 h1:hsms1Qyu0jgnwNXIxa+/V/PDsU6CfLf6CNO8H7IWoS4= +github.com/fsnotify/fsnotify v1.4.9/go.mod h1:znqG4EE+3YCdAaPaxE2ZRY/06pZUdp0tY4IgpuI1SZQ= +github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A= +github.com/go-logr/logr v1.4.2 h1:6pFjapn8bFcIbiKo3XT4j/BhANplGihG6tvd+8rYgrY= +github.com/go-logr/logr v1.4.2/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= +github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag= +github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE= +github.com/go-redis/redis/v8 v8.11.5 h1:AcZZR7igkdvfVmQTPnu9WE37LRrO/YrBH5zWyjDC0oI= +github.com/go-redis/redis/v8 v8.11.5/go.mod h1:gREzHqY1hg6oD9ngVRbLStwAWKhA0FEgq8Jd4h5lpwo= +github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek= +github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps= +github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI= +github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= +github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= +github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/grpc-ecosystem/grpc-gateway/v2 v2.19.0 h1:Wqo399gCIufwto+VfwCSvsnfGpF/w5E9CNxSwbpD6No= +github.com/grpc-ecosystem/grpc-gateway/v2 v2.19.0/go.mod h1:qmOFXW2epJhM0qSnUUYpldc7gVz2KMQwJ/QYCDIa7XU= +github.com/klauspost/compress v1.18.0 h1:c/Cqfb0r+Yi+JtIEq73FWXVkRonBlf0CRNYc8Zttxdo= +github.com/klauspost/compress v1.18.0/go.mod h1:2Pp+KzxcywXVXMr50+X0Q/Lsb43OQHYWRCY2AiWywWQ= +github.com/lib/pq v1.10.9 h1:YXG7RB+JIjhP29X+OtkiDnYaXQwpS4JEWq7dtCCRUEw= +github.com/lib/pq v1.10.9/go.mod h1:AlVN5x4E4T544tWzH6hKfbfQvm3HdbOxrmggDNAPY9o= +github.com/mattn/go-sqlite3 v1.14.28 h1:ThEiQrnbtumT+QMknw63Befp/ce/nUPgBPMlRFEum7A= +github.com/mattn/go-sqlite3 v1.14.28/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y= +github.com/nats-io/nats.go v1.42.0 h1:ynIMupIOvf/ZWH/b2qda6WGKGNSjwOUutTpWRvAmhaM= +github.com/nats-io/nats.go v1.42.0/go.mod h1:iRWIPokVIFbVijxuMQq4y9ttaBTMe0SFdlZfMDd+33g= +github.com/nats-io/nkeys v0.4.11 h1:q44qGV008kYd9W1b1nEBkNzvnWxtRSQ7A8BoqRrcfa0= +github.com/nats-io/nkeys v0.4.11/go.mod h1:szDimtgmfOi9n25JpfIdGw12tZFYXqhGxjhVxsatHVE= +github.com/nats-io/nuid v1.0.1 h1:5iA8DT8V7q8WK2EScv2padNa/rTESc1KdnPw4TC2paw= +github.com/nats-io/nuid v1.0.1/go.mod h1:19wcPz3Ph3q0Jbyiqsd0kePYG7A95tJPxeL+1OSON2c= +github.com/nxadm/tail v1.4.8 h1:nPr65rt6Y5JFSKQO7qToXr7pePgD6Gwiw05lkbyAQTE= +github.com/nxadm/tail v1.4.8/go.mod h1:+ncqLTQzXmGhMZNUePPaPqPvBxHAIsmXswZKocGu+AU= +github.com/onsi/ginkgo v1.16.5 h1:8xi0RTUf59SOSfEtZMvwTvXYMzG4gV23XVHOZiXNtnE= +github.com/onsi/ginkgo v1.16.5/go.mod h1:+E8gABHa3K6zRBolWtd+ROzc/U5bkGt0FwiG042wbpU= +github.com/onsi/gomega v1.18.1 h1:M1GfJqGRrBrrGGsbxzV5dqM2U2ApXefZCQpkukxYRLE= +github.com/onsi/gomega v1.18.1/go.mod h1:0q+aL8jAiMXy9hbwj2mr5GziHiwhAIQpFmmtT5hitRs= +github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA= +github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= +go.opentelemetry.io/auto/sdk v1.1.0 h1:cH53jehLUN6UFLY71z+NDOiNJqDdPRaXzTel0sJySYA= +go.opentelemetry.io/auto/sdk v1.1.0/go.mod h1:3wSPjt5PWp2RhlCcmmOial7AvC4DQqZb7a7wCow3W8A= +go.opentelemetry.io/otel v1.34.0 h1:zRLXxLCgL1WyKsPVrgbSdMN4c0FMkDAskSTQP+0hdUY= +go.opentelemetry.io/otel v1.34.0/go.mod h1:OWFPOQ+h4G8xpyjgqo4SxJYdDQ/qmRH+wivy7zzx9oI= +go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.24.0 h1:t6wl9SPayj+c7lEIFgm4ooDBZVb01IhLB4InpomhRw8= +go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.24.0/go.mod h1:iSDOcsnSA5INXzZtwaBPrKp/lWu/V14Dd+llD0oI2EA= +go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.24.0 h1:Mw5xcxMwlqoJd97vwPxA8isEaIoxsta9/Q51+TTJLGE= +go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.24.0/go.mod h1:CQNu9bj7o7mC6U7+CA/schKEYakYXWr79ucDHTMGhCM= +go.opentelemetry.io/otel/metric v1.34.0 h1:+eTR3U0MyfWjRDhmFMxe2SsW64QrZ84AOhvqS7Y+PoQ= +go.opentelemetry.io/otel/metric v1.34.0/go.mod h1:CEDrp0fy2D0MvkXE+dPV7cMi8tWZwX3dmaIhwPOaqHE= +go.opentelemetry.io/otel/sdk v1.34.0 h1:95zS4k/2GOy069d321O8jWgYsW3MzVV+KuSPKp7Wr1A= +go.opentelemetry.io/otel/sdk v1.34.0/go.mod h1:0e/pNiaMAqaykJGKbi+tSjWfNNHMTxoC9qANsCzbyxU= +go.opentelemetry.io/otel/sdk/metric v1.34.0 h1:5CeK9ujjbFVL5c1PhLuStg1wxA7vQv7ce1EK0Gyvahk= +go.opentelemetry.io/otel/sdk/metric v1.34.0/go.mod h1:jQ/r8Ze28zRKoNRdkjCZxfs6YvBTG1+YIqyFVFYec5w= +go.opentelemetry.io/otel/trace v1.34.0 h1:+ouXS2V8Rd4hp4580a8q23bg0azF2nI8cqLYnC8mh/k= +go.opentelemetry.io/otel/trace v1.34.0/go.mod h1:Svm7lSjQD7kG7KJ/MUHPVXSDGz2OX4h0M2jHBhmSfRE= +go.opentelemetry.io/proto/otlp v1.1.0 h1:2Di21piLrCqJ3U3eXGCTPHE9R8Nh+0uglSnOyxikMeI= +go.opentelemetry.io/proto/otlp v1.1.0/go.mod h1:GpBHCBWiqvVLDqmHZsoMM3C5ySeKTC7ej/RNTae6MdY= +go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto= +go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE= +golang.org/x/crypto v0.37.0 h1:kJNSjF/Xp7kU0iB2Z+9viTPMW4EqqsrywMXLJOOsXSE= +golang.org/x/crypto v0.37.0/go.mod h1:vg+k43peMZ0pUMhYmVAWysMK35e6ioLh3wB8ZCAfbVc= +golang.org/x/net v0.35.0 h1:T5GQRQb2y08kTAByq9L4/bz8cipCdA8FbRTXewonqY8= +golang.org/x/net v0.35.0/go.mod h1:EglIi67kWsHKlRzzVMUD93VMSWGFOMSZgxFjparz1Qk= +golang.org/x/sys v0.32.0 h1:s77OFDvIQeibCmezSnk/q6iAfkdiQaJi4VzroCFrN20= +golang.org/x/sys v0.32.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k= +golang.org/x/text v0.24.0 h1:dd5Bzh4yt5KYA8f9CJHCP4FB4D51c2c6JvN37xJJkJ0= +golang.org/x/text v0.24.0/go.mod h1:L8rBsPeo2pSS+xqN0d5u2ikmjtmoJbDBT1b7nHvFCdU= +google.golang.org/genproto/googleapis/api v0.0.0-20250218202821-56aae31c358a h1:nwKuGPlUAt+aR+pcrkfFRrTU1BVrSmYyYMxYbUIVHr0= +google.golang.org/genproto/googleapis/api v0.0.0-20250218202821-56aae31c358a/go.mod h1:3kWAYMk1I75K4vykHtKt2ycnOgpA6974V7bREqbsenU= +google.golang.org/genproto/googleapis/rpc v0.0.0-20250218202821-56aae31c358a h1:51aaUVRocpvUOSQKM6Q7VuoaktNIaMCLuhZB6DKksq4= +google.golang.org/genproto/googleapis/rpc v0.0.0-20250218202821-56aae31c358a/go.mod h1:uRxBH1mhmO8PGhU89cMcHaXKZqO+OfakD8QQO0oYwlQ= +google.golang.org/grpc v1.72.0 h1:S7UkcVa60b5AAQTaO6ZKamFp1zMZSU0fGDK2WZLbBnM= +google.golang.org/grpc v1.72.0/go.mod h1:wH5Aktxcg25y1I3w7H69nHfXdOG3UiadoBtjh3izSDM= +google.golang.org/protobuf v1.36.6 h1:z1NpPI8ku2WgiWnf+t9wTPsn6eP1L7ksHUlkfLvd9xY= +google.golang.org/protobuf v1.36.6/go.mod h1:jduwjTPXsFjZGTmRluh+L6NjiWu7pchiJ2/5YcXBHnY= +gopkg.in/tomb.v1 v1.0.0-20141024135613-dd632973f1e7 h1:uRGJdciOHaEIrze2W8Q3AKkepLTh2hOroT7a+7czfdQ= +gopkg.in/tomb.v1 v1.0.0-20141024135613-dd632973f1e7/go.mod h1:dt/ZhP58zS4L8KSrWDmTeBkI65Dw0HsyUHuEVlX15mw= +gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY= +gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/cmd/examples/grpc_agent/grpc_server.go b/cmd/examples/grpc_agent/grpc_server.go new file mode 100644 index 0000000..bcfb1ba --- /dev/null +++ b/cmd/examples/grpc_agent/grpc_server.go @@ -0,0 +1,80 @@ +package main + +import ( + "context" + pb_greeter "example/gen/greeter/v1" + "fmt" + "time" +) + +// Server is used to implement the GreeterServiceServer. +type Server struct { + pb_greeter.UnimplementedGreeterServiceServer +} + +// SayHello implements the SayHello RPC method. +func (s *Server) SayHello(ctx context.Context, req *pb_greeter.HelloRequest) (*pb_greeter.HelloReply, error) { + return &pb_greeter.HelloReply{ + Message: "Hello " + req.GetName(), + Timestamp: time.Now().Unix(), + }, nil +} + +// SayHelloStream implements the server streaming RPC method. +func (s *Server) SayHelloStream(req *pb_greeter.HelloRequest, stream pb_greeter.GreeterService_SayHelloStreamServer) error { + for i := 0; i < 5; i++ { + if err := stream.Send(&pb_greeter.HelloReply{ + Message: fmt.Sprintf("Hello %s! (response #%d)", req.GetName(), i+1), + Timestamp: time.Now().Unix(), + }); err != nil { + return err + } + time.Sleep(100 * time.Millisecond) + } + + return nil +} + +// CollectHellos implements the client streaming RPC method. +func (s *Server) CollectHellos(stream pb_greeter.GreeterService_CollectHellosServer) error { + var names []string + + for { + req, err := stream.Recv() + if err != nil { + break + } + names = append(names, req.GetName()) + } + + var message string + if len(names) == 0 { + message = "Hello to nobody!" + } else if len(names) == 1 { + message = fmt.Sprintf("Hello to %s!", names[0]) + } else { + message = fmt.Sprintf("Hello to %d people!", len(names)) + } + + return stream.SendAndClose(&pb_greeter.HelloReply{ + Message: message, + Timestamp: time.Now().Unix(), + }) +} + +// ChatHello implements the bidirectional streaming RPC method. +func (s *Server) ChatHello(stream pb_greeter.GreeterService_ChatHelloServer) error { + for { + req, err := stream.Recv() + if err != nil { + return nil + } + + if err := stream.Send(&pb_greeter.HelloReply{ + Message: "Hello " + req.GetName() + "!", + Timestamp: time.Now().Unix(), + }); err != nil { + return err + } + } +} diff --git a/cmd/examples/grpc_agent/main.go b/cmd/examples/grpc_agent/main.go new file mode 100644 index 0000000..2e0c786 --- /dev/null +++ b/cmd/examples/grpc_agent/main.go @@ -0,0 +1,378 @@ +package main + +import ( + "context" + pb_greeter "example/gen/greeter/v1" + "flag" + "fmt" + "log/slog" + "net" + "net/http" + "os" + "os/signal" + "strings" + "syscall" + "time" + + "github.com/doganarif/govisual" + "github.com/doganarif/govisual/pkg/agent" + "github.com/doganarif/govisual/pkg/server" + "github.com/doganarif/govisual/pkg/store" + "github.com/doganarif/govisual/pkg/transport" + "google.golang.org/grpc" + "google.golang.org/grpc/credentials/insecure" +) + +var ( + portFlaf = flag.Int("port", 8080, "HTTP server port (for dashboard)") + grpcPortFlag = flag.Int("grpc-port", 9090, "gRPC server port") + agentModeFlag = flag.String("agent-mode", "store", "Agent mode: store, nats, http") + natsURLFlag = flag.String("nats-url", "nats://localhost:4222", "NATS server URL. Only used with agent-mode 'nats'") + httpURLFlag = flag.String("http-url", "http://localhost:8080/api/agent/logs", "HTTP endpoint URL. Only used with agent-mode 'http'") +) + +func main() { + flag.Parse() + + err := run() + if err != nil { + fmt.Printf("failed to run: %v\n", err) + os.Exit(1) + } +} + +func run() error { + port := *portFlaf + grpcPort := *grpcPortFlag + agentMode := *agentModeFlag + natsURL := *natsURLFlag + httpURL := *httpURLFlag + + log := slog.New(slog.NewTextHandler(os.Stdout, nil)) + sharedStore := store.NewInMemoryStore(100) + + var transportObj transport.Transport + var err error + switch agentMode { + case "store": + log.Info("Using shared store transport") + transportObj = transport.NewStoreTransport(sharedStore) + case "nats": + log.Info("Using NATS transport", slog.String("url", natsURL)) + transportObj, err = transport.NewNATSTransport(natsURL) + if err != nil { + return fmt.Errorf("creating NATS transport: %w", err) + } + case "http": + log.Info("Using HTTP transport", slog.String("url", httpURL)) + transportObj = transport.NewHTTPTransport(httpURL, + transport.WithTimeout(5*time.Second), + transport.WithMaxRetries(3), + ) + default: + return fmt.Errorf("unknown agent mode %q", agentMode) + } + + grpcAgent := agent.NewGRPCAgent(transportObj, + agent.WithGRPCRequestDataLogging(true), + agent.WithGRPCResponseDataLogging(true), + agent.WithBatchSize(5).ForGRPC(), + agent.WithBatchInterval(1*time.Second).ForGRPC(), + ) + + lis, err := net.Listen("tcp", fmt.Sprintf(":%d", grpcPort)) + if err != nil { + return fmt.Errorf("listening to gRPC: %w", err) + } + + // This is where you can register your existing gRPC server. + // If you would like to take a different approach then you can use the interceptors directly as done in [server.NewGRPCServer]. + grpcServer := server.NewGRPCServer(grpcAgent) + pb_greeter.RegisterGreeterServiceServer(grpcServer, &Server{}) + + log.Info("Starting gRPC server", slog.Int("port", grpcPort)) + + go func() { + err := grpcServer.Serve(lis) + if err != nil { + log.Error("failed to serve gRPC", slog.Any("err", err)) + os.Exit(1) + } + }() + + // Start the HTTP dashboard server with a simple homepage. + mux := http.NewServeMux() + mux.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != "/" { + http.NotFound(w, r) + return + } + w.Header().Set("Content-Type", "text/html") + w.Write([]byte(fmt.Sprintf(` + + + GoVisual gRPC Agent Example + + + +
+

GoVisual gRPC Agent Example

+
+

Dashboard

+

Visit /__viz to access the request visualizer dashboard.

+
+
+

Configuration

+
+ gRPC Server: localhost:%d
+ Agent Mode: %s
+ %s +
+
+
+

Test the gRPC Service

+

An initial test request has been made automatically. You can make additional requests using a gRPC client.

+

The service provides the following methods:

+ +

You can use a tool like grpcui or grpcurl to test these methods.

+
+
+ + + `, grpcPort, agentMode, getExtraInfo(agentMode, natsURL, httpURL)))) + }) + + // Add API endpoints for agent communication + if agentMode != "store" { + // Create and register agent API handler + agentAPI := server.NewAgentAPI(sharedStore) + agentAPI.RegisterHandlers(mux) + } + + // Wrap with GoVisual for dashboard + handler := govisual.Wrap(mux, + govisual.WithMaxRequests(100), + govisual.WithRequestBodyLogging(true), + govisual.WithResponseBodyLogging(true), + govisual.WithSharedStore(sharedStore), + ) + + // Start NATS handler if using NATS transport + var natsHandler *server.NATSHandler + if agentMode == "nats" { + natsHandler, err = server.NewNATSHandler(sharedStore, natsURL) + if err != nil { + return fmt.Errorf("creating NATS handler: %w", err) + } + + err = natsHandler.Start() + if err != nil { + return fmt.Errorf("starting NATS handler: %w", err) + } + } + + httpServer := &http.Server{ + Addr: fmt.Sprintf(":%d", port), + Handler: handler, + } + + log.Info("Starting HTTP server", slog.String("dashboard_addr", fmt.Sprintf("http://localhost:%d/__viz", port))) + + go func() { + err := httpServer.ListenAndServe() + if err != nil && err != http.ErrServerClosed { + log.Error("failed to serve HTTP", slog.Any("err", err)) + os.Exit(1) + } + }() + + // Make test requests to show different gRPC method types + go testRPCs(log, grpcPort) + + // Wait for termination signal + signalChan := make(chan os.Signal, 1) + signal.Notify(signalChan, os.Interrupt, syscall.SIGTERM) + <-signalChan + + log.Info("Shutdown signal received") + + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + + err = httpServer.Shutdown(ctx) + if err != nil { + log.Error("failed to shutdown server", slog.Any("err", err)) + } + + grpcServer.GracefulStop() + lis.Close() + + if natsHandler != nil { + err = natsHandler.Stop() + if err != nil { + log.Error("failed to stop NATS handler", slog.Any("err", err)) + } + } + + err = grpcAgent.Close() + if err != nil { + log.Error("failed to close agent", slog.Any("err", err)) + } + + err = transportObj.Close() + if err != nil { + log.Error("failed to close transport", slog.Any("err", err)) + } + + err = sharedStore.Close() + if err != nil { + log.Error("failed to close store", slog.Any("err", err)) + } + + log.Info("Servers shut down successfully") + return nil +} + +// getExtraInfo returns extra information for the homepage based on the agent mode +func getExtraInfo(agentMode, natsURL, httpURL string) string { + var info strings.Builder + + switch agentMode { + case "store": + info.WriteString("Transport: In-memory shared store (direct)") + case "nats": + info.WriteString(fmt.Sprintf("Transport: NATS messaging via %q", natsURL)) + case "http": + info.WriteString(fmt.Sprintf("Transport: HTTP via %q", httpURL)) + } + + return info.String() +} + +func testRPCs(log *slog.Logger, grpcPort int) { + time.Sleep(500 * time.Millisecond) + + // Create gRPC client + conn, err := grpc.NewClient( + fmt.Sprintf("localhost:%d", grpcPort), + grpc.WithTransportCredentials(insecure.NewCredentials()), + ) + if err != nil { + log.Error("Failed to connect: %v", err) + return + } + defer conn.Close() + + client := pb_greeter.NewGreeterServiceClient(conn) + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + + // Test 1: Unary RPC + log.Info("Testing unary RPC (SayHello)") + _, err = client.SayHello(ctx, &pb_greeter.HelloRequest{ + Name: "Agent Test", + Message: "This is a test message", + }) + if err != nil { + log.Error("failed unary RPC (SayHello)", slog.Any("err", err)) + } + + // Test 2: Server streaming RPC + log.Info("Testing server streaming RPC (SayHelloStream)") + stream, err := client.SayHelloStream(ctx, &pb_greeter.HelloRequest{ + Name: "Stream Test", + Message: "Testing server streaming", + }) + if err != nil { + log.Error("failed server streaming RPC (SayHelloStream)", slog.Any("err", err)) + } else { + for { + _, err = stream.Recv() + if err != nil { + break + } + } + } + + // Test 3: Client streaming RPC + log.Info("Testing client streaming RPC (CollectHellos)") + clientStream, err := client.CollectHellos(ctx) + if err != nil { + log.Error("failed client streaming RPC (CollectHellos)", slog.Any("err", err)) + } else { + for i := range 3 { + name := fmt.Sprintf("Person %d", i) + err = clientStream.Send(&pb_greeter.HelloRequest{ + Name: name, + Message: fmt.Sprintf("Message from %s", name), + }) + if err != nil { + log.Error("failed to send client stream message", slog.Any("err", err)) + break + } + time.Sleep(100 * time.Millisecond) + } + + _, err := clientStream.CloseAndRecv() + if err != nil { + log.Error("failed to close client stream", slog.Any("err", err)) + } + } + + // Test 4: Bidirectional streaming RPC + log.Info("Testing bidirectional streaming RPC (ChatHello)") + bidiStream, err := client.ChatHello(ctx) + if err != nil { + log.Error("failed bidirectional streaming RPC (ChatHello)", slog.Any("err", err)) + } else { + // Send and receive in goroutines + done := make(chan bool) + + // Receiving goroutine + go func() { + for { + _, err := bidiStream.Recv() + if err != nil { + break + } + } + done <- true + }() + + // Send messages + for i := range 3 { + name := fmt.Sprintf("Person %d", i) + err := bidiStream.Send(&pb_greeter.HelloRequest{ + Name: name, + Message: fmt.Sprintf("Bidi message from %s", name), + }) + if err != nil { + log.Error("failed to send bidi message: %v", err) + break + } + time.Sleep(200 * time.Millisecond) + } + + // Close sending + bidiStream.CloseSend() + <-done + } + + log.Info("All gRPC tests completed") +} diff --git a/cmd/examples/grpc_agent/proto/greeter/v1/greeter.proto b/cmd/examples/grpc_agent/proto/greeter/v1/greeter.proto new file mode 100644 index 0000000..8c09405 --- /dev/null +++ b/cmd/examples/grpc_agent/proto/greeter/v1/greeter.proto @@ -0,0 +1,30 @@ +syntax = "proto3"; + +package greeter.v1; + +// The greeting service definition. +service GreeterService { + // Sends a greeting (Unary RPC) + rpc SayHello(HelloRequest) returns (HelloReply) {} + + // Sends multiple greetings in response to a single request (Server streaming RPC) + rpc SayHelloStream(HelloRequest) returns (stream HelloReply) {} + + // Collects multiple greetings and responds once (Client streaming RPC) + rpc CollectHellos(stream HelloRequest) returns (HelloReply) {} + + // Chat with greetings back and forth (Bidirectional streaming RPC) + rpc ChatHello(stream HelloRequest) returns (stream HelloReply) {} +} + +// The request message containing the user's name. +message HelloRequest { + string name = 1; + string message = 2; +} + +// The response message containing the greetings. +message HelloReply { + string message = 1; + int64 timestamp = 2; +} diff --git a/cmd/examples/grpc_agent/taskfile.yaml b/cmd/examples/grpc_agent/taskfile.yaml new file mode 100644 index 0000000..93d38d1 --- /dev/null +++ b/cmd/examples/grpc_agent/taskfile.yaml @@ -0,0 +1,11 @@ +version: "3" + +tasks: + generate: + cmds: + - go tool -modfile=tool.go.mod buf generate + + docs: + cmds: + - go tool -modfile=tool.go.mod buf build -o example.protoset + - go tool -modfile=tool.go.mod grpcui -plaintext -protoset=example.protoset 127.0.0.1:9090 diff --git a/cmd/examples/grpc_agent/tool.go.mod b/cmd/examples/grpc_agent/tool.go.mod new file mode 100644 index 0000000..9c4aa42 --- /dev/null +++ b/cmd/examples/grpc_agent/tool.go.mod @@ -0,0 +1,130 @@ +module example + +go 1.24.0 + +tool ( + github.com/bufbuild/buf/cmd/buf + github.com/fullstorydev/grpcui/cmd/grpcui +) + +require ( + buf.build/gen/go/bufbuild/bufplugin/protocolbuffers/go v1.36.6-20250121211742-6d880cc6cc8d.1 // indirect + buf.build/gen/go/bufbuild/protovalidate/protocolbuffers/go v1.36.6-20250307204501-0409229c3780.1 // indirect + buf.build/gen/go/bufbuild/registry/connectrpc/go v1.18.1-20250408145534-f5ce355693bb.1 // indirect + buf.build/gen/go/bufbuild/registry/protocolbuffers/go v1.36.6-20250408145534-f5ce355693bb.1 // indirect + buf.build/gen/go/pluginrpc/pluginrpc/protocolbuffers/go v1.36.6-20241007202033-cf42259fcbfc.1 // indirect + buf.build/go/bufplugin v0.8.0 // indirect + buf.build/go/protoyaml v0.3.2 // indirect + buf.build/go/spdx v0.2.0 // indirect + cel.dev/expr v0.23.1 // indirect + cloud.google.com/go/compute/metadata v0.6.0 // indirect + connectrpc.com/connect v1.18.1 // indirect + connectrpc.com/otelconnect v0.7.2 // indirect + github.com/Azure/go-ansiterm v0.0.0-20250102033503-faa5f7b0171c // indirect + github.com/Microsoft/go-winio v0.6.2 // indirect + github.com/antlr4-go/antlr/v4 v4.13.1 // indirect + github.com/bufbuild/buf v1.53.0 // indirect + github.com/bufbuild/protocompile v0.14.1 // indirect + github.com/bufbuild/protoplugin v0.0.0-20250218205857-750e09ce93e1 // indirect + github.com/bufbuild/protovalidate-go v0.9.3 // indirect + github.com/census-instrumentation/opencensus-proto v0.4.1 // indirect + github.com/cespare/xxhash/v2 v2.3.0 // indirect + github.com/cncf/xds/go v0.0.0-20241223141626-cff3c89139a3 // indirect + github.com/containerd/log v0.1.0 // indirect + github.com/containerd/stargz-snapshotter/estargz v0.16.3 // indirect + github.com/cpuguy83/go-md2man/v2 v2.0.6 // indirect + github.com/distribution/reference v0.6.0 // indirect + github.com/docker/cli v28.1.1+incompatible // indirect + github.com/docker/distribution v2.8.3+incompatible // indirect + github.com/docker/docker v28.1.1+incompatible // indirect + github.com/docker/docker-credential-helpers v0.9.3 // indirect + github.com/docker/go-connections v0.5.0 // indirect + github.com/docker/go-units v0.5.0 // indirect + github.com/envoyproxy/go-control-plane v0.13.4 // indirect + github.com/envoyproxy/go-control-plane/envoy v1.32.4 // indirect + github.com/envoyproxy/protoc-gen-validate v1.2.1 // indirect + github.com/felixge/fgprof v0.9.5 // indirect + github.com/felixge/httpsnoop v1.0.4 // indirect + github.com/fullstorydev/grpcui v1.4.3 // indirect + github.com/fullstorydev/grpcurl v1.9.3 // indirect + github.com/go-chi/chi/v5 v5.2.1 // indirect + github.com/go-logr/logr v1.4.2 // indirect + github.com/go-logr/stdr v1.2.2 // indirect + github.com/go-task/slim-sprig/v3 v3.0.0 // indirect + github.com/gofrs/flock v0.12.1 // indirect + github.com/gogo/protobuf v1.3.2 // indirect + github.com/golang/protobuf v1.5.4 // indirect + github.com/google/cel-go v0.24.1 // indirect + github.com/google/go-containerregistry v0.20.3 // indirect + github.com/google/pprof v0.0.0-20250418163039-24c5476c6587 // indirect + github.com/google/uuid v1.6.0 // indirect + github.com/inconshreveable/mousetrap v1.1.0 // indirect + github.com/jdx/go-netrc v1.0.0 // indirect + github.com/jhump/protoreflect v1.17.0 // indirect + github.com/klauspost/compress v1.18.0 // indirect + github.com/klauspost/pgzip v1.2.6 // indirect + github.com/mattn/go-isatty v0.0.20 // indirect + github.com/mitchellh/go-homedir v1.1.0 // indirect + github.com/moby/docker-image-spec v1.3.1 // indirect + github.com/moby/go-archive v0.1.0 // indirect + github.com/moby/locker v1.0.1 // indirect + github.com/moby/patternmatcher v0.6.0 // indirect + github.com/moby/sys/atomicwriter v0.1.0 // indirect + github.com/moby/sys/mount v0.3.4 // indirect + github.com/moby/sys/mountinfo v0.7.2 // indirect + github.com/moby/sys/reexec v0.1.0 // indirect + github.com/moby/sys/sequential v0.6.0 // indirect + github.com/moby/sys/user v0.4.0 // indirect + github.com/moby/sys/userns v0.1.0 // indirect + github.com/moby/term v0.5.2 // indirect + github.com/morikuni/aec v1.0.0 // indirect + github.com/onsi/ginkgo/v2 v2.23.4 // indirect + github.com/opencontainers/go-digest v1.0.0 // indirect + github.com/opencontainers/image-spec v1.1.1 // indirect + github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c // indirect + github.com/pkg/errors v0.9.1 // indirect + github.com/pkg/profile v1.7.0 // indirect + github.com/planetscale/vtprotobuf v0.6.1-0.20240319094008-0393e58bdf10 // indirect + github.com/quic-go/qpack v0.5.1 // indirect + github.com/quic-go/quic-go v0.51.0 // indirect + github.com/rs/cors v1.11.1 // indirect + github.com/russross/blackfriday/v2 v2.1.0 // indirect + github.com/segmentio/asm v1.2.0 // indirect + github.com/segmentio/encoding v0.4.1 // indirect + github.com/sirupsen/logrus v1.9.3 // indirect + github.com/spf13/cobra v1.9.1 // indirect + github.com/spf13/pflag v1.0.6 // indirect + github.com/stoewer/go-strcase v1.3.0 // indirect + github.com/tetratelabs/wazero v1.9.0 // indirect + github.com/vbatts/tar-split v0.12.1 // indirect + go.lsp.dev/jsonrpc2 v0.10.0 // indirect + go.lsp.dev/pkg v0.0.0-20210717090340-384b27a52fb2 // indirect + go.lsp.dev/protocol v0.12.0 // indirect + go.lsp.dev/uri v0.3.0 // indirect + go.opentelemetry.io/auto/sdk v1.1.0 // indirect + go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.60.0 // indirect + go.opentelemetry.io/otel v1.35.0 // indirect + go.opentelemetry.io/otel/metric v1.35.0 // indirect + go.opentelemetry.io/otel/trace v1.35.0 // indirect + go.uber.org/automaxprocs v1.6.0 // indirect + go.uber.org/mock v0.5.1 // indirect + go.uber.org/multierr v1.11.0 // indirect + go.uber.org/zap v1.27.0 // indirect + go.uber.org/zap/exp v0.3.0 // indirect + golang.org/x/crypto v0.37.0 // indirect + golang.org/x/exp v0.0.0-20250408133849-7e4ce0ab07d0 // indirect + golang.org/x/mod v0.24.0 // indirect + golang.org/x/net v0.39.0 // indirect + golang.org/x/oauth2 v0.25.0 // indirect + golang.org/x/sync v0.13.0 // indirect + golang.org/x/sys v0.32.0 // indirect + golang.org/x/term v0.31.0 // indirect + golang.org/x/text v0.24.0 // indirect + golang.org/x/tools v0.32.0 // indirect + google.golang.org/genproto/googleapis/api v0.0.0-20250414145226-207652e42e2e // indirect + google.golang.org/genproto/googleapis/rpc v0.0.0-20250414145226-207652e42e2e // indirect + google.golang.org/grpc v1.71.0 // indirect + google.golang.org/protobuf v1.36.6 // indirect + gopkg.in/yaml.v3 v3.0.1 // indirect + pluginrpc.com/pluginrpc v0.5.0 // indirect +) diff --git a/cmd/examples/grpc_agent/tool.go.sum b/cmd/examples/grpc_agent/tool.go.sum new file mode 100644 index 0000000..1d65cbd --- /dev/null +++ b/cmd/examples/grpc_agent/tool.go.sum @@ -0,0 +1,309 @@ +buf.build/gen/go/bufbuild/bufplugin/protocolbuffers/go v1.36.6-20250121211742-6d880cc6cc8d.1 h1:f6miF8tK6H+Ktad24WpnNfpHO75GRGk0rhJ1mxPXqgA= +buf.build/gen/go/bufbuild/bufplugin/protocolbuffers/go v1.36.6-20250121211742-6d880cc6cc8d.1/go.mod h1:rvbyamNtvJ4o3ExeCmaG5/6iHnu0vy0E+UQ+Ph0om8s= +buf.build/gen/go/bufbuild/protovalidate/protocolbuffers/go v1.36.6-20250307204501-0409229c3780.1 h1:zgJPqo17m28+Lf5BW4xv3PvU20BnrmTcGYrog22lLIU= +buf.build/gen/go/bufbuild/protovalidate/protocolbuffers/go v1.36.6-20250307204501-0409229c3780.1/go.mod h1:avRlCjnFzl98VPaeCtJ24RrV/wwHFzB8sWXhj26+n/U= +buf.build/gen/go/bufbuild/registry/connectrpc/go v1.18.1-20250408145534-f5ce355693bb.1 h1:T9SjIDu/MDc0w3Zsn2vTY2cAln9smvALy/x6hifEoB4= +buf.build/gen/go/bufbuild/registry/connectrpc/go v1.18.1-20250408145534-f5ce355693bb.1/go.mod h1:oPjPPfT42x42QaEpI3YoHfgcVXRg9WMZ3e0qg34HT1g= +buf.build/gen/go/bufbuild/registry/protocolbuffers/go v1.36.6-20250408145534-f5ce355693bb.1 h1:mduBaTj1Vc2XKmO5fRdpxk4K96cKERaV6nFPMHyLy6M= +buf.build/gen/go/bufbuild/registry/protocolbuffers/go v1.36.6-20250408145534-f5ce355693bb.1/go.mod h1:ee69ieBAzwc/oY/Vde0K4r6JWvrk093q4Z/FXexPMmA= +buf.build/gen/go/pluginrpc/pluginrpc/protocolbuffers/go v1.36.6-20241007202033-cf42259fcbfc.1 h1:trcsXBDm8exui7mvndZnvworCyBq1xuMnod2N0j79K8= +buf.build/gen/go/pluginrpc/pluginrpc/protocolbuffers/go v1.36.6-20241007202033-cf42259fcbfc.1/go.mod h1:OUbhXurY+VHFGn9FBxcRy8UB7HXk9NvJ2qCgifOMypQ= +buf.build/go/bufplugin v0.8.0 h1:YgR1+CNGmzR69jt85oRWTa5FioZoX/tOrHV+JxfNnnk= +buf.build/go/bufplugin v0.8.0/go.mod h1:rcm0Esd3P/GM2rtYTvz3+9Gf8w9zdo7rG8dKSxYHHIE= +buf.build/go/protoyaml v0.3.2 h1:QJF3k7btMameIadLLcK3Rry81OK3gYA5nZMXirV1Bs4= +buf.build/go/protoyaml v0.3.2/go.mod h1:rUlMqwfZeONS/BAt00wB6jV5ay/eHXUzxgiKSIyrvyc= +buf.build/go/spdx v0.2.0 h1:IItqM0/cMxvFJJumcBuP8NrsIzMs/UYjp/6WSpq8LTw= +buf.build/go/spdx v0.2.0/go.mod h1:bXdwQFem9Si3nsbNy8aJKGPoaPi5DKwdeEp5/ArZ6w8= +cel.dev/expr v0.23.1 h1:K4KOtPCJQjVggkARsjG9RWXP6O4R73aHeJMa/dmCQQg= +cel.dev/expr v0.23.1/go.mod h1:hLPLo1W4QUmuYdA72RBX06QTs6MXw941piREPl3Yfiw= +cloud.google.com/go/compute v1.23.3 h1:6sVlXXBmbd7jNX0Ipq0trII3e4n1/MsADLK6a+aiVlk= +cloud.google.com/go/compute/metadata v0.6.0 h1:A6hENjEsCDtC1k8byVsgwvVcioamEHvZ4j01OwKxG9I= +cloud.google.com/go/compute/metadata v0.6.0/go.mod h1:FjyFAW1MW0C203CEOMDTu3Dk1FlqW3Rga40jzHL4hfg= +connectrpc.com/connect v1.18.1 h1:PAg7CjSAGvscaf6YZKUefjoih5Z/qYkyaTrBW8xvYPw= +connectrpc.com/connect v1.18.1/go.mod h1:0292hj1rnx8oFrStN7cB4jjVBeqs+Yx5yDIC2prWDO8= +connectrpc.com/otelconnect v0.7.2 h1:WlnwFzaW64dN06JXU+hREPUGeEzpz3Acz2ACOmN8cMI= +connectrpc.com/otelconnect v0.7.2/go.mod h1:JS7XUKfuJs2adhCnXhNHPHLz6oAaZniCJdSF00OZSew= +github.com/Azure/go-ansiterm v0.0.0-20250102033503-faa5f7b0171c h1:udKWzYgxTojEKWjV8V+WSxDXJ4NFATAsZjh8iIbsQIg= +github.com/Azure/go-ansiterm v0.0.0-20250102033503-faa5f7b0171c/go.mod h1:xomTg63KZ2rFqZQzSB4Vz2SUXa1BpHTVz9L5PTmPC4E= +github.com/Microsoft/go-winio v0.6.2 h1:F2VQgta7ecxGYO8k3ZZz3RS8fVIXVxONVUPlNERoyfY= +github.com/Microsoft/go-winio v0.6.2/go.mod h1:yd8OoFMLzJbo9gZq8j5qaps8bJ9aShtEA8Ipt1oGCvU= +github.com/antlr4-go/antlr/v4 v4.13.1 h1:SqQKkuVZ+zWkMMNkjy5FZe5mr5WURWnlpmOuzYWrPrQ= +github.com/antlr4-go/antlr/v4 v4.13.1/go.mod h1:GKmUxMtwp6ZgGwZSva4eWPC5mS6vUAmOABFgjdkM7Nw= +github.com/bufbuild/buf v1.53.0 h1:i0OgpDkzv8yLyCokXRCgbQao/SqTnqXYtERre0Jw6Do= +github.com/bufbuild/buf v1.53.0/go.mod h1:DylfLDMblZt5mX/SEFv2VzZMgdlePArhMGYVpk4M6N0= +github.com/bufbuild/protocompile v0.14.1 h1:iA73zAf/fyljNjQKwYzUHD6AD4R8KMasmwa/FBatYVw= +github.com/bufbuild/protocompile v0.14.1/go.mod h1:ppVdAIhbr2H8asPk6k4pY7t9zB1OU5DoEw9xY/FUi1c= +github.com/bufbuild/protoplugin v0.0.0-20250218205857-750e09ce93e1 h1:V1xulAoqLqVg44rY97xOR+mQpD2N+GzhMHVwJ030WEU= +github.com/bufbuild/protoplugin v0.0.0-20250218205857-750e09ce93e1/go.mod h1:c5D8gWRIZ2HLWO3gXYTtUfw/hbJyD8xikv2ooPxnklQ= +github.com/bufbuild/protovalidate-go v0.9.3 h1:XvdtwQuppS3wjzGfpOirsqwN5ExH2+PiIuA/XZd3MTM= +github.com/bufbuild/protovalidate-go v0.9.3/go.mod h1:2lUDP6fNd3wxznRNH3Nj64VB07+PySeslamkerwP6tE= +github.com/census-instrumentation/opencensus-proto v0.4.1 h1:iKLQ0xPNFxR/2hzXZMrBo8f1j86j5WHzznCCQxV/b8g= +github.com/census-instrumentation/opencensus-proto v0.4.1/go.mod h1:4T9NM4+4Vw91VeyqjLS6ao50K5bOcLKN6Q42XnYaRYw= +github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs= +github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= +github.com/chromedp/cdproto v0.0.0-20230802225258-3cf4e6d46a89/go.mod h1:GKljq0VrfU4D5yc+2qA6OVr8pmO/MBbPEWqWQ/oqGEs= +github.com/chromedp/chromedp v0.9.2/go.mod h1:LkSXJKONWTCHAfQasKFUZI+mxqS4tZqhmtGzzhLsnLs= +github.com/chromedp/sysutil v1.0.0/go.mod h1:kgWmDdq8fTzXYcKIBqIYvRRTnYb9aNS9moAV0xufSww= +github.com/chzyer/logex v1.1.10/go.mod h1:+Ywpsq7O8HXn0nuIou7OrIPyXbp3wmkHB+jjWRnGsAI= +github.com/chzyer/logex v1.2.1/go.mod h1:JLbx6lG2kDbNRFnfkgvh4eRJRPX1QCoOIWomwysCBrQ= +github.com/chzyer/readline v0.0.0-20180603132655-2972be24d48e/go.mod h1:nSuG5e5PlCu98SY8svDHJxuZscDgtXS6KTTbou5AhLI= +github.com/chzyer/readline v1.5.1/go.mod h1:Eh+b79XXUwfKfcPLepksvw2tcLE/Ct21YObkaSkeBlk= +github.com/chzyer/test v0.0.0-20180213035817-a1ea475d72b1/go.mod h1:Q3SI9o4m/ZMnBNeIyt5eFwwo7qiLfzFZmjNmxjkiQlU= +github.com/chzyer/test v1.0.0/go.mod h1:2JlltgoNkt4TW/z9V/IzDdFaMTM2JPIi26O1pF38GC8= +github.com/cncf/xds/go v0.0.0-20241223141626-cff3c89139a3 h1:boJj011Hh+874zpIySeApCX4GeOjPl9qhRF3QuIZq+Q= +github.com/cncf/xds/go v0.0.0-20241223141626-cff3c89139a3/go.mod h1:W+zGtBO5Y1IgJhy4+A9GOqVhqLpfZi+vwmdNXUehLA8= +github.com/containerd/log v0.1.0 h1:TCJt7ioM2cr/tfR8GPbGf9/VRAX8D2B4PjzCpfX540I= +github.com/containerd/log v0.1.0/go.mod h1:VRRf09a7mHDIRezVKTRCrOq78v577GXq3bSa3EhrzVo= +github.com/containerd/stargz-snapshotter/estargz v0.16.3 h1:7evrXtoh1mSbGj/pfRccTampEyKpjpOnS3CyiV1Ebr8= +github.com/containerd/stargz-snapshotter/estargz v0.16.3/go.mod h1:uyr4BfYfOj3G9WBVE8cOlQmXAbPN9VEQpBBeJIuOipU= +github.com/cpuguy83/go-md2man/v2 v2.0.6 h1:XJtiaUW6dEEqVuZiMTn1ldk455QWwEIsMIJlo5vtkx0= +github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g= +github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/distribution/reference v0.6.0 h1:0IXCQ5g4/QMHHkarYzh5l+u8T3t73zM5QvfrDyIgxBk= +github.com/distribution/reference v0.6.0/go.mod h1:BbU0aIcezP1/5jX/8MP0YiH4SdvB5Y4f/wlDRiLyi3E= +github.com/docker/cli v28.1.1+incompatible h1:eyUemzeI45DY7eDPuwUcmDyDj1pM98oD5MdSpiItp8k= +github.com/docker/cli v28.1.1+incompatible/go.mod h1:JLrzqnKDaYBop7H2jaqPtU4hHvMKP+vjCwu2uszcLI8= +github.com/docker/distribution v2.8.3+incompatible h1:AtKxIZ36LoNK51+Z6RpzLpddBirtxJnzDrHLEKxTAYk= +github.com/docker/distribution v2.8.3+incompatible/go.mod h1:J2gT2udsDAN96Uj4KfcMRqY0/ypR+oyYUYmja8H+y+w= +github.com/docker/docker v28.1.1+incompatible h1:49M11BFLsVO1gxY9UX9p/zwkE/rswggs8AdFmXQw51I= +github.com/docker/docker v28.1.1+incompatible/go.mod h1:eEKB0N0r5NX/I1kEveEz05bcu8tLC/8azJZsviup8Sk= +github.com/docker/docker-credential-helpers v0.9.3 h1:gAm/VtF9wgqJMoxzT3Gj5p4AqIjCBS4wrsOh9yRqcz8= +github.com/docker/docker-credential-helpers v0.9.3/go.mod h1:x+4Gbw9aGmChi3qTLZj8Dfn0TD20M/fuWy0E5+WDeCo= +github.com/docker/go-connections v0.5.0 h1:USnMq7hx7gwdVZq1L49hLXaFtUdTADjXGp+uj1Br63c= +github.com/docker/go-connections v0.5.0/go.mod h1:ov60Kzw0kKElRwhNs9UlUHAE/F9Fe6GLaXnqyDdmEXc= +github.com/docker/go-units v0.5.0 h1:69rxXcBk27SvSaaxTtLh/8llcHD8vYHT7WSdRZ/jvr4= +github.com/docker/go-units v0.5.0/go.mod h1:fgPhTUdO+D/Jk86RDLlptpiXQzgHJF7gydDDbaIK4Dk= +github.com/envoyproxy/go-control-plane v0.13.4 h1:zEqyPVyku6IvWCFwux4x9RxkLOMUL+1vC9xUFv5l2/M= +github.com/envoyproxy/go-control-plane v0.13.4/go.mod h1:kDfuBlDVsSj2MjrLEtRWtHlsWIFcGyB2RMO44Dc5GZA= +github.com/envoyproxy/go-control-plane/envoy v1.32.4 h1:jb83lalDRZSpPWW2Z7Mck/8kXZ5CQAFYVjQcdVIr83A= +github.com/envoyproxy/go-control-plane/envoy v1.32.4/go.mod h1:Gzjc5k8JcJswLjAx1Zm+wSYE20UrLtt7JZMWiWQXQEw= +github.com/envoyproxy/protoc-gen-validate v1.2.1 h1:DEo3O99U8j4hBFwbJfrz9VtgcDfUKS7KJ7spH3d86P8= +github.com/envoyproxy/protoc-gen-validate v1.2.1/go.mod h1:d/C80l/jxXLdfEIhX1W2TmLfsJ31lvEjwamM4DxlWXU= +github.com/felixge/fgprof v0.9.3/go.mod h1:RdbpDgzqYVh/T9fPELJyV7EYJuHB55UTEULNun8eiPw= +github.com/felixge/fgprof v0.9.5 h1:8+vR6yu2vvSKn08urWyEuxx75NWPEvybbkBirEpsbVY= +github.com/felixge/fgprof v0.9.5/go.mod h1:yKl+ERSa++RYOs32d8K6WEXCB4uXdLls4ZaZPpayhMM= +github.com/felixge/httpsnoop v1.0.4 h1:NFTV2Zj1bL4mc9sqWACXbQFVBBg2W3GPvqp8/ESS2Wg= +github.com/felixge/httpsnoop v1.0.4/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U= +github.com/fullstorydev/grpcui v1.4.3 h1:Nu8X2sqnw9Hk/K6MxnxC16WHCVfD5T9GAnxK/GBcr6A= +github.com/fullstorydev/grpcui v1.4.3/go.mod h1:MFRnL00NjgWlNA0yrsFyEe7FvOYAENu5jxRsHx4fKC0= +github.com/fullstorydev/grpcurl v1.9.3 h1:PC1Xi3w+JAvEE2Tg2Gf2RfVgPbf9+tbuQr1ZkyVU3jk= +github.com/fullstorydev/grpcurl v1.9.3/go.mod h1:/b4Wxe8bG6ndAjlfSUjwseQReUDUvBJiFEB7UllOlUE= +github.com/go-chi/chi/v5 v5.2.1 h1:KOIHODQj58PmL80G2Eak4WdvUzjSJSm0vG72crDCqb8= +github.com/go-chi/chi/v5 v5.2.1/go.mod h1:L2yAIGWB3H+phAw1NxKwWM+7eUH/lU8pOMm5hHcoops= +github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A= +github.com/go-logr/logr v1.4.2 h1:6pFjapn8bFcIbiKo3XT4j/BhANplGihG6tvd+8rYgrY= +github.com/go-logr/logr v1.4.2/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= +github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag= +github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE= +github.com/go-task/slim-sprig/v3 v3.0.0 h1:sUs3vkvUymDpBKi3qH1YSqBQk9+9D/8M2mN1vB6EwHI= +github.com/go-task/slim-sprig/v3 v3.0.0/go.mod h1:W848ghGpv3Qj3dhTPRyJypKRiqCdHZiAzKg9hl15HA8= +github.com/gobwas/httphead v0.1.0/go.mod h1:O/RXo79gxV8G+RqlR/otEwx4Q36zl9rqC5u12GKvMCM= +github.com/gobwas/pool v0.2.1/go.mod h1:q8bcK0KcYlCgd9e7WYLm9LpyS+YeLd8JVDW6WezmKEw= +github.com/gobwas/ws v1.2.1/go.mod h1:hRKAFb8wOxFROYNsT1bqfWnhX+b5MFeJM9r2ZSwg/KY= +github.com/gofrs/flock v0.12.1 h1:MTLVXXHf8ekldpJk3AKicLij9MdwOWkZ+a/jHHZby9E= +github.com/gofrs/flock v0.12.1/go.mod h1:9zxTsyu5xtJ9DK+1tFZyibEV7y3uwDxPPfbxeeHCoD0= +github.com/gogo/protobuf v1.3.2 h1:Ov1cvc58UF3b5XjBnZv7+opcTcQFZebYjWzi34vdm4Q= +github.com/gogo/protobuf v1.3.2/go.mod h1:P1XiOD3dCwIKUDQYPy72D8LYyHL2YPYrpS2s69NZV8Q= +github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek= +github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps= +github.com/google/cel-go v0.24.1 h1:jsBCtxG8mM5wiUJDSGUqU0K7Mtr3w7Eyv00rw4DiZxI= +github.com/google/cel-go v0.24.1/go.mod h1:Hdf9TqOaTNSFQA1ybQaRqATVoK7m/zcf7IMhGXP5zI8= +github.com/google/go-cmp v0.4.0/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= +github.com/google/go-containerregistry v0.20.3 h1:oNx7IdTI936V8CQRveCjaxOiegWwvM7kqkbXTpyiovI= +github.com/google/go-containerregistry v0.20.3/go.mod h1:w00pIgBRDVUDFM6bq+Qx8lwNWK+cxgCuX1vd3PIBDNI= +github.com/google/pprof v0.0.0-20211214055906-6f57359322fd/go.mod h1:KgnwoLYCZ8IQu3XUZ8Nc/bM9CCZFOyjUNOSygVozoDg= +github.com/google/pprof v0.0.0-20240227163752-401108e1b7e7/go.mod h1:czg5+yv1E0ZGTi6S6vVK1mke0fV+FaUhNGcd6VRS9Ik= +github.com/google/pprof v0.0.0-20250418163039-24c5476c6587 h1:b/8HpQhvKLSNzH5oTXN2WkNcMl6YB5K3FRbb+i+Ml34= +github.com/google/pprof v0.0.0-20250418163039-24c5476c6587/go.mod h1:boTsfXsheKC2y+lKOCMpSfarhxDeIzfZG1jqGcPl3cA= +github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= +github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/ianlancetaylor/demangle v0.0.0-20210905161508-09a460cdf81d/go.mod h1:aYm2/VgdVmcIU8iMfdMvDMsRAQjcfZSKFby6HOFvi/w= +github.com/ianlancetaylor/demangle v0.0.0-20230524184225-eabc099b10ab/go.mod h1:gx7rwoVhcfuVKG5uya9Hs3Sxj7EIvldVofAWIUtGouw= +github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8= +github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= +github.com/jdx/go-netrc v1.0.0 h1:QbLMLyCZGj0NA8glAhxUpf1zDg6cxnWgMBbjq40W0gQ= +github.com/jdx/go-netrc v1.0.0/go.mod h1:Gh9eFQJnoTNIRHXl2j5bJXA1u84hQWJWgGh569zF3v8= +github.com/jhump/protoreflect v1.17.0 h1:qOEr613fac2lOuTgWN4tPAtLL7fUSbuJL5X5XumQh94= +github.com/jhump/protoreflect v1.17.0/go.mod h1:h9+vUUL38jiBzck8ck+6G/aeMX8Z4QUY/NiJPwPNi+8= +github.com/josharian/intern v1.0.0/go.mod h1:5DoeVV0s6jJacbCEi61lwdGj/aVlrQvzHFFd8Hwg//Y= +github.com/kisielk/errcheck v1.5.0/go.mod h1:pFxgyoBC7bSaBwPgfKdkLd5X25qrDl4LWUI2bnpBCr8= +github.com/kisielk/gotool v1.0.0/go.mod h1:XhKaO+MFFWcvkIS/tQcRk01m1F5IRFswLeQ+oQHNcck= +github.com/klauspost/compress v1.18.0 h1:c/Cqfb0r+Yi+JtIEq73FWXVkRonBlf0CRNYc8Zttxdo= +github.com/klauspost/compress v1.18.0/go.mod h1:2Pp+KzxcywXVXMr50+X0Q/Lsb43OQHYWRCY2AiWywWQ= +github.com/klauspost/pgzip v1.2.6 h1:8RXeL5crjEUFnR2/Sn6GJNWtSQ3Dk8pq4CL3jvdDyjU= +github.com/klauspost/pgzip v1.2.6/go.mod h1:Ch1tH69qFZu15pkjo5kYi6mth2Zzwzt50oCQKQE9RUs= +github.com/ledongthuc/pdf v0.0.0-20220302134840-0c2507a12d80/go.mod h1:imJHygn/1yfhB7XSJJKlFZKl/J+dCPAknuiaGOshXAs= +github.com/mailru/easyjson v0.7.7/go.mod h1:xzfreul335JAWq5oZzymOObrkdz5UnU4kGfJJLY9Nlc= +github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= +github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= +github.com/mitchellh/go-homedir v1.1.0 h1:lukF9ziXFxDFPkA1vsr5zpc1XuPDn/wFntq5mG+4E0Y= +github.com/mitchellh/go-homedir v1.1.0/go.mod h1:SfyaCUpYCn1Vlf4IUYiD9fPX4A5wJrkLzIz1N1q0pr0= +github.com/moby/docker-image-spec v1.3.1 h1:jMKff3w6PgbfSa69GfNg+zN/XLhfXJGnEx3Nl2EsFP0= +github.com/moby/docker-image-spec v1.3.1/go.mod h1:eKmb5VW8vQEh/BAr2yvVNvuiJuY6UIocYsFu/DxxRpo= +github.com/moby/go-archive v0.1.0 h1:Kk/5rdW/g+H8NHdJW2gsXyZ7UnzvJNOy6VKJqueWdcQ= +github.com/moby/go-archive v0.1.0/go.mod h1:G9B+YoujNohJmrIYFBpSd54GTUB4lt9S+xVQvsJyFuo= +github.com/moby/locker v1.0.1 h1:fOXqR41zeveg4fFODix+1Ch4mj/gT0NE1XJbp/epuBg= +github.com/moby/locker v1.0.1/go.mod h1:S7SDdo5zpBK84bzzVlKr2V0hz+7x9hWbYC/kq7oQppc= +github.com/moby/patternmatcher v0.6.0 h1:GmP9lR19aU5GqSSFko+5pRqHi+Ohk1O69aFiKkVGiPk= +github.com/moby/patternmatcher v0.6.0/go.mod h1:hDPoyOpDY7OrrMDLaYoY3hf52gNCR/YOUYxkhApJIxc= +github.com/moby/sys/atomicwriter v0.1.0 h1:kw5D/EqkBwsBFi0ss9v1VG3wIkVhzGvLklJ+w3A14Sw= +github.com/moby/sys/atomicwriter v0.1.0/go.mod h1:Ul8oqv2ZMNHOceF643P6FKPXeCmYtlQMvpizfsSoaWs= +github.com/moby/sys/mount v0.3.4 h1:yn5jq4STPztkkzSKpZkLcmjue+bZJ0u2AuQY1iNI1Ww= +github.com/moby/sys/mount v0.3.4/go.mod h1:KcQJMbQdJHPlq5lcYT+/CjatWM4PuxKe+XLSVS4J6Os= +github.com/moby/sys/mountinfo v0.7.2 h1:1shs6aH5s4o5H2zQLn796ADW1wMrIwHsyJ2v9KouLrg= +github.com/moby/sys/mountinfo v0.7.2/go.mod h1:1YOa8w8Ih7uW0wALDUgT1dTTSBrZ+HiBLGws92L2RU4= +github.com/moby/sys/reexec v0.1.0 h1:RrBi8e0EBTLEgfruBOFcxtElzRGTEUkeIFaVXgU7wok= +github.com/moby/sys/reexec v0.1.0/go.mod h1:EqjBg8F3X7iZe5pU6nRZnYCMUTXoxsjiIfHup5wYIN8= +github.com/moby/sys/sequential v0.6.0 h1:qrx7XFUd/5DxtqcoH1h438hF5TmOvzC/lspjy7zgvCU= +github.com/moby/sys/sequential v0.6.0/go.mod h1:uyv8EUTrca5PnDsdMGXhZe6CCe8U/UiTWd+lL+7b/Ko= +github.com/moby/sys/user v0.4.0 h1:jhcMKit7SA80hivmFJcbB1vqmw//wU61Zdui2eQXuMs= +github.com/moby/sys/user v0.4.0/go.mod h1:bG+tYYYJgaMtRKgEmuueC0hJEAZWwtIbZTB+85uoHjs= +github.com/moby/sys/userns v0.1.0 h1:tVLXkFOxVu9A64/yh59slHVv9ahO9UIev4JZusOLG/g= +github.com/moby/sys/userns v0.1.0/go.mod h1:IHUYgu/kao6N8YZlp9Cf444ySSvCmDlmzUcYfDHOl28= +github.com/moby/term v0.5.2 h1:6qk3FJAFDs6i/q3W/pQ97SX192qKfZgGjCQqfCJkgzQ= +github.com/moby/term v0.5.2/go.mod h1:d3djjFCrjnB+fl8NJux+EJzu0msscUP+f8it8hPkFLc= +github.com/morikuni/aec v1.0.0 h1:nP9CBfwrvYnBRgY6qfDQkygYDmYwOilePFkwzv4dU8A= +github.com/morikuni/aec v1.0.0/go.mod h1:BbKIizmSmc5MMPqRYbxO4ZU0S0+P200+tUnFx7PXmsc= +github.com/onsi/ginkgo/v2 v2.23.4 h1:ktYTpKJAVZnDT4VjxSbiBenUjmlL/5QkBEocaWXiQus= +github.com/onsi/ginkgo/v2 v2.23.4/go.mod h1:Bt66ApGPBFzHyR+JO10Zbt0Gsp4uWxu5mIOTusL46e8= +github.com/opencontainers/go-digest v1.0.0 h1:apOUWs51W5PlhuyGyz9FCeeBIOUDA/6nW8Oi/yOhh5U= +github.com/opencontainers/go-digest v1.0.0/go.mod h1:0JzlMkj0TRzQZfJkVvzbP0HBR3IKzErnv2BNG4W4MAM= +github.com/opencontainers/image-spec v1.1.1 h1:y0fUlFfIZhPF1W537XOLg0/fcx6zcHCJwooC2xJA040= +github.com/opencontainers/image-spec v1.1.1/go.mod h1:qpqAh3Dmcf36wStyyWU+kCeDgrGnAve2nCC8+7h8Q0M= +github.com/orisano/pixelmatch v0.0.0-20220722002657-fb0b55479cde/go.mod h1:nZgzbfBr3hhjoZnS66nKrHmduYNpc34ny7RK4z5/HM0= +github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c h1:+mdjkGKdHQG3305AYmdv1U2eRNDiU2ErMBj1gwrq8eQ= +github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c/go.mod h1:7rwL4CYBLnjLxUqIJNnCWiEdr3bn6IUYi15bNlnbCCU= +github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= +github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= +github.com/pkg/profile v1.7.0 h1:hnbDkaNWPCLMO9wGLdBFTIZvzDrDfBM2072E1S9gJkA= +github.com/pkg/profile v1.7.0/go.mod h1:8Uer0jas47ZQMJ7VD+OHknK4YDY07LPUC6dEvqDjvNo= +github.com/planetscale/vtprotobuf v0.6.1-0.20240319094008-0393e58bdf10 h1:GFCKgmp0tecUJ0sJuv4pzYCqS9+RGSn52M3FUwPs+uo= +github.com/planetscale/vtprotobuf v0.6.1-0.20240319094008-0393e58bdf10/go.mod h1:t/avpk3KcrXxUnYOhZhMXJlSEyie6gQbtLq5NM3loB8= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/quic-go/qpack v0.5.1 h1:giqksBPnT/HDtZ6VhtFKgoLOWmlyo9Ei6u9PqzIMbhI= +github.com/quic-go/qpack v0.5.1/go.mod h1:+PC4XFrEskIVkcLzpEkbLqq1uCoxPhQuvK5rH1ZgaEg= +github.com/quic-go/quic-go v0.51.0 h1:K8exxe9zXxeRKxaXxi/GpUqYiTrtdiWP8bo1KFya6Wc= +github.com/quic-go/quic-go v0.51.0/go.mod h1:MFlGGpcpJqRAfmYi6NC2cptDPSxRWTOGNuP4wqrWmzQ= +github.com/rs/cors v1.11.1 h1:eU3gRzXLRK57F5rKMGMZURNdIG4EoAmX8k94r9wXWHA= +github.com/rs/cors v1.11.1/go.mod h1:XyqrcTp5zjWr1wsJ8PIRZssZ8b/WMcMf71DJnit4EMU= +github.com/russross/blackfriday/v2 v2.1.0 h1:JIOH55/0cWyOuilr9/qlrm0BSXldqnqwMsf35Ld67mk= +github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= +github.com/segmentio/asm v1.2.0 h1:9BQrFxC+YOHJlTlHGkTrFWf59nbL3XnCoFLTwDCI7ys= +github.com/segmentio/asm v1.2.0/go.mod h1:BqMnlJP91P8d+4ibuonYZw9mfnzI9HfxselHZr5aAcs= +github.com/segmentio/encoding v0.4.1 h1:KLGaLSW0jrmhB58Nn4+98spfvPvmo4Ci1P/WIQ9wn7w= +github.com/segmentio/encoding v0.4.1/go.mod h1:/d03Cd8PoaDeceuhUUUQWjU0KhWjrmYrWPgtJHYZSnI= +github.com/sirupsen/logrus v1.9.3 h1:dueUQJ1C2q9oE3F7wvmSGAaVtTmUizReu6fjN8uqzbQ= +github.com/sirupsen/logrus v1.9.3/go.mod h1:naHLuLoDiP4jHNo9R0sCBMtWGeIprob74mVsIT4qYEQ= +github.com/spf13/cobra v1.9.1 h1:CXSaggrXdbHK9CF+8ywj8Amf7PBRmPCOJugH954Nnlo= +github.com/spf13/cobra v1.9.1/go.mod h1:nDyEzZ8ogv936Cinf6g1RU9MRY64Ir93oCnqb9wxYW0= +github.com/spf13/pflag v1.0.6 h1:jFzHGLGAlb3ruxLB8MhbI6A8+AQX/2eW4qeyNZXNp2o= +github.com/spf13/pflag v1.0.6/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= +github.com/stoewer/go-strcase v1.3.0 h1:g0eASXYtp+yvN9fK8sH94oCIk0fau9uV1/ZdJ0AVEzs= +github.com/stoewer/go-strcase v1.3.0/go.mod h1:fAH5hQ5pehh+j3nZfvwdk2RgEgQjAoM8wodgtPmh1xo= +github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= +github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw= +github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo= +github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= +github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= +github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU= +github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4= +github.com/tetratelabs/wazero v1.9.0 h1:IcZ56OuxrtaEz8UYNRHBrUa9bYeX9oVY93KspZZBf/I= +github.com/tetratelabs/wazero v1.9.0/go.mod h1:TSbcXCfFP0L2FGkRPxHphadXPjo1T6W+CseNNY7EkjM= +github.com/vbatts/tar-split v0.12.1 h1:CqKoORW7BUWBe7UL/iqTVvkTBOF8UvOMKOIZykxnnbo= +github.com/vbatts/tar-split v0.12.1/go.mod h1:eF6B6i6ftWQcDqEn3/iGFRFRo8cBIMSJVOpnNdfTMFA= +github.com/yuin/goldmark v1.1.27/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= +github.com/yuin/goldmark v1.2.1/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= +go.lsp.dev/jsonrpc2 v0.10.0 h1:Pr/YcXJoEOTMc/b6OTmcR1DPJ3mSWl/SWiU1Cct6VmI= +go.lsp.dev/jsonrpc2 v0.10.0/go.mod h1:fmEzIdXPi/rf6d4uFcayi8HpFP1nBF99ERP1htC72Ac= +go.lsp.dev/pkg v0.0.0-20210717090340-384b27a52fb2 h1:hCzQgh6UcwbKgNSRurYWSqh8MufqRRPODRBblutn4TE= +go.lsp.dev/pkg v0.0.0-20210717090340-384b27a52fb2/go.mod h1:gtSHRuYfbCT0qnbLnovpie/WEmqyJ7T4n6VXiFMBtcw= +go.lsp.dev/protocol v0.12.0 h1:tNprUI9klQW5FAFVM4Sa+AbPFuVQByWhP1ttNUAjIWg= +go.lsp.dev/protocol v0.12.0/go.mod h1:Qb11/HgZQ72qQbeyPfJbu3hZBH23s1sr4st8czGeDMQ= +go.lsp.dev/uri v0.3.0 h1:KcZJmh6nFIBeJzTugn5JTU6OOyG0lDOo3R9KwTxTYbo= +go.lsp.dev/uri v0.3.0/go.mod h1:P5sbO1IQR+qySTWOCnhnK7phBx+W3zbLqSMDJNTw88I= +go.opentelemetry.io/auto/sdk v1.1.0 h1:cH53jehLUN6UFLY71z+NDOiNJqDdPRaXzTel0sJySYA= +go.opentelemetry.io/auto/sdk v1.1.0/go.mod h1:3wSPjt5PWp2RhlCcmmOial7AvC4DQqZb7a7wCow3W8A= +go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.60.0 h1:sbiXRNDSWJOTobXh5HyQKjq6wUC5tNybqjIqDpAY4CU= +go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.60.0/go.mod h1:69uWxva0WgAA/4bu2Yy70SLDBwZXuQ6PbBpbsa5iZrQ= +go.opentelemetry.io/otel v1.35.0 h1:xKWKPxrxB6OtMCbmMY021CqC45J+3Onta9MqjhnusiQ= +go.opentelemetry.io/otel v1.35.0/go.mod h1:UEqy8Zp11hpkUrL73gSlELM0DupHoiq72dR+Zqel/+Y= +go.opentelemetry.io/otel/metric v1.35.0 h1:0znxYu2SNyuMSQT4Y9WDWej0VpcsxkuklLa4/siN90M= +go.opentelemetry.io/otel/metric v1.35.0/go.mod h1:nKVFgxBZ2fReX6IlyW28MgZojkoAkJGaE8CpgeAU3oE= +go.opentelemetry.io/otel/trace v1.35.0 h1:dPpEfJu1sDIqruz7BHFG3c7528f6ddfSWfFDVt/xgMs= +go.opentelemetry.io/otel/trace v1.35.0/go.mod h1:WUk7DtFp1Aw2MkvqGdwiXYDZZNvA/1J8o6xRXLrIkyc= +go.uber.org/automaxprocs v1.6.0 h1:O3y2/QNTOdbF+e/dpXNNW7Rx2hZ4sTIPyybbxyNqTUs= +go.uber.org/automaxprocs v1.6.0/go.mod h1:ifeIMSnPZuznNm6jmdzmU3/bfk01Fe2fotchwEFJ8r8= +go.uber.org/mock v0.5.1 h1:ASgazW/qBmR+A32MYFDB6E2POoTgOwT509VP0CT/fjs= +go.uber.org/mock v0.5.1/go.mod h1:ge71pBPLYDk7QIi1LupWxdAykm7KIEFchiOqd6z7qMM= +go.uber.org/multierr v1.11.0 h1:blXXJkSxSSfBVBlC76pxqeO+LN3aDfLQo+309xJstO0= +go.uber.org/multierr v1.11.0/go.mod h1:20+QtiLqy0Nd6FdQB9TLXag12DsQkrbs3htMFfDN80Y= +go.uber.org/zap v1.27.0 h1:aJMhYGrd5QSmlpLMr2MftRKl7t8J8PTZPA732ud/XR8= +go.uber.org/zap v1.27.0/go.mod h1:GB2qFLM7cTU87MWRP2mPIjqfIDnGu+VIO4V/SdhGo2E= +go.uber.org/zap/exp v0.3.0 h1:6JYzdifzYkGmTdRR59oYH+Ng7k49H9qVpWwNSsGJj3U= +go.uber.org/zap/exp v0.3.0/go.mod h1:5I384qq7XGxYyByIhHm6jg5CHkGY0nsTfbDLgDDlgJQ= +golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= +golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= +golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= +golang.org/x/crypto v0.37.0 h1:kJNSjF/Xp7kU0iB2Z+9viTPMW4EqqsrywMXLJOOsXSE= +golang.org/x/crypto v0.37.0/go.mod h1:vg+k43peMZ0pUMhYmVAWysMK35e6ioLh3wB8ZCAfbVc= +golang.org/x/exp v0.0.0-20250408133849-7e4ce0ab07d0 h1:R84qjqJb5nVJMxqWYb3np9L5ZsaDtB+a39EqjV0JSUM= +golang.org/x/exp v0.0.0-20250408133849-7e4ce0ab07d0/go.mod h1:S9Xr4PYopiDyqSyp5NjCrhFrqg6A5zA2E/iPHPhqnS8= +golang.org/x/mod v0.2.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= +golang.org/x/mod v0.3.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= +golang.org/x/mod v0.24.0 h1:ZfthKaKaT4NrhGVZHO1/WDTwGES4De8KtWO0SIbNJMU= +golang.org/x/mod v0.24.0/go.mod h1:IXM97Txy2VM4PJ3gI61r1YEk/gAj6zAHN3AdZt6S9Ww= +golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= +golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= +golang.org/x/net v0.0.0-20200226121028-0de0cce0169b/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= +golang.org/x/net v0.0.0-20201021035429-f5854403a974/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU= +golang.org/x/net v0.39.0 h1:ZCu7HMWDxpXpaiKdhzIfaltL9Lp31x/3fCP11bc6/fY= +golang.org/x/net v0.39.0/go.mod h1:X7NRbYVEA+ewNkCNyJ513WmMdQ3BineSwVtN2zD/d+E= +golang.org/x/oauth2 v0.25.0 h1:CY4y7XT9v0cRI9oupztF8AgiIu99L/ksR/Xp/6jrZ70= +golang.org/x/oauth2 v0.25.0/go.mod h1:XYTD2NtWslqkgxebSiOHnXEap4TF09sJSc7H1sXbhtI= +golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20190911185100-cd5d95a43a6e/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20201020160332-67f06af15bc9/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.13.0 h1:AauUjRAJ9OSnvULf/ARrrVywoJDy0YS2AwQ98I37610= +golang.org/x/sync v0.13.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA= +golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20200930185726-fdedc70b468f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20210616094352-59db8d763f22/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20211007075335-d3039528d8ac/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20220310020820-b874c991c1a5/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.1.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.32.0 h1:s77OFDvIQeibCmezSnk/q6iAfkdiQaJi4VzroCFrN20= +golang.org/x/sys v0.32.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k= +golang.org/x/term v0.31.0 h1:erwDkOK1Msy6offm1mOgvspSkslFnIGsFnxOKoufg3o= +golang.org/x/term v0.31.0/go.mod h1:R4BeIy7D95HzImkxGkTW1UQTtP54tio2RyHz7PwK0aw= +golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= +golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= +golang.org/x/text v0.24.0 h1:dd5Bzh4yt5KYA8f9CJHCP4FB4D51c2c6JvN37xJJkJ0= +golang.org/x/text v0.24.0/go.mod h1:L8rBsPeo2pSS+xqN0d5u2ikmjtmoJbDBT1b7nHvFCdU= +golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= +golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= +golang.org/x/tools v0.0.0-20200619180055-7c47624df98f/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE= +golang.org/x/tools v0.0.0-20210106214847-113979e3529a/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA= +golang.org/x/tools v0.32.0 h1:Q7N1vhpkQv7ybVzLFtTjvQya2ewbwNDZzUgfXGqtMWU= +golang.org/x/tools v0.32.0/go.mod h1:ZxrU41P/wAbZD8EDa6dDCa6XfpkhJ7HFMjHJXfBDu8s= +golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +google.golang.org/genproto/googleapis/api v0.0.0-20250414145226-207652e42e2e h1:UdXH7Kzbj+Vzastr5nVfccbmFsmYNygVLSPk1pEfDoY= +google.golang.org/genproto/googleapis/api v0.0.0-20250414145226-207652e42e2e/go.mod h1:085qFyf2+XaZlRdCgKNCIZ3afY2p4HHZdoIRpId8F4A= +google.golang.org/genproto/googleapis/rpc v0.0.0-20250414145226-207652e42e2e h1:ztQaXfzEXTmCBvbtWYRhJxW+0iJcz2qXfd38/e9l7bA= +google.golang.org/genproto/googleapis/rpc v0.0.0-20250414145226-207652e42e2e/go.mod h1:qQ0YXyHHx3XkvlzUtpXDkS29lDSafHMZBAZDc03LQ3A= +google.golang.org/grpc v1.71.0 h1:kF77BGdPTQ4/JZWMlb9VpJ5pa25aqvVqogsxNHHdeBg= +google.golang.org/grpc v1.71.0/go.mod h1:H0GRtasmQOh9LkFoCPDu3ZrwUtD1YGE+b2vYBYd/8Ec= +google.golang.org/protobuf v1.36.6 h1:z1NpPI8ku2WgiWnf+t9wTPsn6eP1L7ksHUlkfLvd9xY= +google.golang.org/protobuf v1.36.6/go.mod h1:jduwjTPXsFjZGTmRluh+L6NjiWu7pchiJ2/5YcXBHnY= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +pluginrpc.com/pluginrpc v0.5.0 h1:tOQj2D35hOmvHyPu8e7ohW2/QvAnEtKscy2IJYWQ2yo= +pluginrpc.com/pluginrpc v0.5.0/go.mod h1:UNWZ941hcVAoOZUn8YZsMmOZBzbUjQa3XMns8RQLp9o= diff --git a/go.mod b/go.mod index bac7f14..8466a29 100644 --- a/go.mod +++ b/go.mod @@ -5,13 +5,14 @@ go 1.24.0 require ( github.com/go-redis/redis/v8 v8.11.5 github.com/lib/pq v1.10.9 - github.com/ncruces/go-sqlite3 v0.25.1 + github.com/mattn/go-sqlite3 v1.14.28 + github.com/nats-io/nats.go v1.42.0 go.opentelemetry.io/otel v1.24.0 go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.24.0 go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.24.0 go.opentelemetry.io/otel/sdk v1.24.0 go.opentelemetry.io/otel/trace v1.24.0 - google.golang.org/grpc v1.62.1 + google.golang.org/grpc v1.64.1 ) require ( @@ -20,17 +21,17 @@ require ( github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f // indirect github.com/go-logr/logr v1.4.1 // indirect github.com/go-logr/stdr v1.2.2 // indirect - github.com/golang/protobuf v1.5.3 // indirect github.com/grpc-ecosystem/grpc-gateway/v2 v2.19.0 // indirect - github.com/mattn/go-sqlite3 v1.14.28 // indirect - github.com/ncruces/julianday v1.0.0 // indirect - github.com/tetratelabs/wazero v1.9.0 // indirect + github.com/klauspost/compress v1.18.0 // indirect + github.com/nats-io/nkeys v0.4.11 // indirect + github.com/nats-io/nuid v1.0.1 // indirect go.opentelemetry.io/otel/metric v1.24.0 // indirect go.opentelemetry.io/proto/otlp v1.1.0 // indirect - golang.org/x/net v0.20.0 // indirect + golang.org/x/crypto v0.37.0 // indirect + golang.org/x/net v0.26.0 // indirect golang.org/x/sys v0.32.0 // indirect golang.org/x/text v0.24.0 // indirect - google.golang.org/genproto/googleapis/api v0.0.0-20240123012728-ef4313101c80 // indirect - google.golang.org/genproto/googleapis/rpc v0.0.0-20240123012728-ef4313101c80 // indirect - google.golang.org/protobuf v1.32.0 // indirect + google.golang.org/genproto/googleapis/api v0.0.0-20240318140521-94a12d6c2237 // indirect + google.golang.org/genproto/googleapis/rpc v0.0.0-20240318140521-94a12d6c2237 // indirect + google.golang.org/protobuf v1.33.0 // indirect ) diff --git a/go.sum b/go.sum index efbd483..66c6427 100644 --- a/go.sum +++ b/go.sum @@ -15,22 +15,22 @@ github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag= github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE= github.com/go-redis/redis/v8 v8.11.5 h1:AcZZR7igkdvfVmQTPnu9WE37LRrO/YrBH5zWyjDC0oI= github.com/go-redis/redis/v8 v8.11.5/go.mod h1:gREzHqY1hg6oD9ngVRbLStwAWKhA0FEgq8Jd4h5lpwo= -github.com/golang/protobuf v1.5.0/go.mod h1:FsONVRAS9T7sI+LIUmWTfcYkHO4aIWwzhcaSAoJOfIk= -github.com/golang/protobuf v1.5.3 h1:KhyjKVUg7Usr/dYsdSqoFveMYd5ko72D+zANwlG1mmg= -github.com/golang/protobuf v1.5.3/go.mod h1:XVQd3VNwM+JqD3oG2Ue2ip4fOMUkwXdXDdiuN0vRsmY= -github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI= github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= github.com/grpc-ecosystem/grpc-gateway/v2 v2.19.0 h1:Wqo399gCIufwto+VfwCSvsnfGpF/w5E9CNxSwbpD6No= github.com/grpc-ecosystem/grpc-gateway/v2 v2.19.0/go.mod h1:qmOFXW2epJhM0qSnUUYpldc7gVz2KMQwJ/QYCDIa7XU= +github.com/klauspost/compress v1.18.0 h1:c/Cqfb0r+Yi+JtIEq73FWXVkRonBlf0CRNYc8Zttxdo= +github.com/klauspost/compress v1.18.0/go.mod h1:2Pp+KzxcywXVXMr50+X0Q/Lsb43OQHYWRCY2AiWywWQ= github.com/lib/pq v1.10.9 h1:YXG7RB+JIjhP29X+OtkiDnYaXQwpS4JEWq7dtCCRUEw= github.com/lib/pq v1.10.9/go.mod h1:AlVN5x4E4T544tWzH6hKfbfQvm3HdbOxrmggDNAPY9o= github.com/mattn/go-sqlite3 v1.14.28 h1:ThEiQrnbtumT+QMknw63Befp/ce/nUPgBPMlRFEum7A= github.com/mattn/go-sqlite3 v1.14.28/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y= -github.com/ncruces/go-sqlite3 v0.25.1 h1:nRK2mZ0jLNFJco8QFZ9+dCXxOGe6Re8bbG5o8gyalr8= -github.com/ncruces/go-sqlite3 v0.25.1/go.mod h1:4BtkHRLbX5hE0PhBxJ11qETTwL7M4lk0ttru9nora1E= -github.com/ncruces/julianday v1.0.0 h1:fH0OKwa7NWvniGQtxdJRxAgkBMolni2BjDHaWTxqt7M= -github.com/ncruces/julianday v1.0.0/go.mod h1:Dusn2KvZrrovOMJuOt0TNXL6tB7U2E8kvza5fFc9G7g= +github.com/nats-io/nats.go v1.42.0 h1:ynIMupIOvf/ZWH/b2qda6WGKGNSjwOUutTpWRvAmhaM= +github.com/nats-io/nats.go v1.42.0/go.mod h1:iRWIPokVIFbVijxuMQq4y9ttaBTMe0SFdlZfMDd+33g= +github.com/nats-io/nkeys v0.4.11 h1:q44qGV008kYd9W1b1nEBkNzvnWxtRSQ7A8BoqRrcfa0= +github.com/nats-io/nkeys v0.4.11/go.mod h1:szDimtgmfOi9n25JpfIdGw12tZFYXqhGxjhVxsatHVE= +github.com/nats-io/nuid v1.0.1 h1:5iA8DT8V7q8WK2EScv2padNa/rTESc1KdnPw4TC2paw= +github.com/nats-io/nuid v1.0.1/go.mod h1:19wcPz3Ph3q0Jbyiqsd0kePYG7A95tJPxeL+1OSON2c= github.com/nxadm/tail v1.4.8 h1:nPr65rt6Y5JFSKQO7qToXr7pePgD6Gwiw05lkbyAQTE= github.com/nxadm/tail v1.4.8/go.mod h1:+ncqLTQzXmGhMZNUePPaPqPvBxHAIsmXswZKocGu+AU= github.com/onsi/ginkgo v1.16.5 h1:8xi0RTUf59SOSfEtZMvwTvXYMzG4gV23XVHOZiXNtnE= @@ -41,8 +41,6 @@ github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZb github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/stretchr/testify v1.8.4 h1:CcVxjf3Q8PM0mHUKJCdn+eZZtm5yQwehR5yeSVQQcUk= github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo= -github.com/tetratelabs/wazero v1.9.0 h1:IcZ56OuxrtaEz8UYNRHBrUa9bYeX9oVY93KspZZBf/I= -github.com/tetratelabs/wazero v1.9.0/go.mod h1:TSbcXCfFP0L2FGkRPxHphadXPjo1T6W+CseNNY7EkjM= go.opentelemetry.io/otel v1.24.0 h1:0LAOdjNmQeSTzGBzduGe/rU4tZhMwL5rWgtp9Ku5Jfo= go.opentelemetry.io/otel v1.24.0/go.mod h1:W7b9Ozg4nkF5tWI5zsXkaKKDjdVjpD4oAt9Qi/MArHo= go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.24.0 h1:t6wl9SPayj+c7lEIFgm4ooDBZVb01IhLB4InpomhRw8= @@ -59,25 +57,22 @@ go.opentelemetry.io/proto/otlp v1.1.0 h1:2Di21piLrCqJ3U3eXGCTPHE9R8Nh+0uglSnOyxi go.opentelemetry.io/proto/otlp v1.1.0/go.mod h1:GpBHCBWiqvVLDqmHZsoMM3C5ySeKTC7ej/RNTae6MdY= go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto= go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE= -golang.org/x/net v0.20.0 h1:aCL9BSgETF1k+blQaYUBx9hJ9LOGP3gAVemcZlf1Kpo= -golang.org/x/net v0.20.0/go.mod h1:z8BVo6PvndSri0LbOE3hAn0apkU+1YvI6E70E9jsnvY= +golang.org/x/crypto v0.37.0 h1:kJNSjF/Xp7kU0iB2Z+9viTPMW4EqqsrywMXLJOOsXSE= +golang.org/x/crypto v0.37.0/go.mod h1:vg+k43peMZ0pUMhYmVAWysMK35e6ioLh3wB8ZCAfbVc= +golang.org/x/net v0.26.0 h1:soB7SVo0PWrY4vPW/+ay0jKDNScG2X9wFeYlXIvJsOQ= +golang.org/x/net v0.26.0/go.mod h1:5YKkiSynbBIh3p6iOc/vibscux0x38BZDkn8sCUPxHE= golang.org/x/sys v0.32.0 h1:s77OFDvIQeibCmezSnk/q6iAfkdiQaJi4VzroCFrN20= golang.org/x/sys v0.32.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k= golang.org/x/text v0.24.0 h1:dd5Bzh4yt5KYA8f9CJHCP4FB4D51c2c6JvN37xJJkJ0= golang.org/x/text v0.24.0/go.mod h1:L8rBsPeo2pSS+xqN0d5u2ikmjtmoJbDBT1b7nHvFCdU= -golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= -google.golang.org/genproto v0.0.0-20240123012728-ef4313101c80 h1:KAeGQVN3M9nD0/bQXnr/ClcEMJ968gUXJQ9pwfSynuQ= -google.golang.org/genproto v0.0.0-20240123012728-ef4313101c80/go.mod h1:cc8bqMqtv9gMOr0zHg2Vzff5ULhhL2IXP4sbcn32Dro= -google.golang.org/genproto/googleapis/api v0.0.0-20240123012728-ef4313101c80 h1:Lj5rbfG876hIAYFjqiJnPHfhXbv+nzTWfm04Fg/XSVU= -google.golang.org/genproto/googleapis/api v0.0.0-20240123012728-ef4313101c80/go.mod h1:4jWUdICTdgc3Ibxmr8nAJiiLHwQBY0UI0XZcEMaFKaA= -google.golang.org/genproto/googleapis/rpc v0.0.0-20240123012728-ef4313101c80 h1:AjyfHzEPEFp/NpvfN5g+KDla3EMojjhRVZc1i7cj+oM= -google.golang.org/genproto/googleapis/rpc v0.0.0-20240123012728-ef4313101c80/go.mod h1:PAREbraiVEVGVdTZsVWjSbbTtSyGbAgIIvni8a8CD5s= -google.golang.org/grpc v1.62.1 h1:B4n+nfKzOICUXMgyrNd19h/I9oH0L1pizfk1d4zSgTk= -google.golang.org/grpc v1.62.1/go.mod h1:IWTG0VlJLCh1SkC58F7np9ka9mx/WNkjl4PGJaiq+QE= -google.golang.org/protobuf v1.26.0-rc.1/go.mod h1:jlhhOSvTdKEhbULTjvd4ARK9grFBp09yW+WbY/TyQbw= -google.golang.org/protobuf v1.26.0/go.mod h1:9q0QmTI4eRPtz6boOQmLYwt+qCgq0jsYwAQnmE0givc= -google.golang.org/protobuf v1.32.0 h1:pPC6BG5ex8PDFnkbrGU3EixyhKcQ2aDuBS36lqK/C7I= -google.golang.org/protobuf v1.32.0/go.mod h1:c6P6GXX6sHbq/GpV6MGZEdwhWPcYBgnhAHhKbcUYpos= +google.golang.org/genproto/googleapis/api v0.0.0-20240318140521-94a12d6c2237 h1:RFiFrvy37/mpSpdySBDrUdipW/dHwsRwh3J3+A9VgT4= +google.golang.org/genproto/googleapis/api v0.0.0-20240318140521-94a12d6c2237/go.mod h1:Z5Iiy3jtmioajWHDGFk7CeugTyHtPvMHA4UTmUkyalE= +google.golang.org/genproto/googleapis/rpc v0.0.0-20240318140521-94a12d6c2237 h1:NnYq6UN9ReLM9/Y01KWNOWyI5xQ9kbIms5GGJVwS/Yc= +google.golang.org/genproto/googleapis/rpc v0.0.0-20240318140521-94a12d6c2237/go.mod h1:WtryC6hu0hhx87FDGxWCDptyssuo68sk10vYjF+T9fY= +google.golang.org/grpc v1.64.1 h1:LKtvyfbX3UGVPFcGqJ9ItpVWW6oN/2XqTxfAnwRRXiA= +google.golang.org/grpc v1.64.1/go.mod h1:hiQF4LFZelK2WKaP6W0L92zGHtiQdZxk8CrSdvyjeP0= +google.golang.org/protobuf v1.33.0 h1:uNO2rsAINq/JlFpSdYEKIZ0uKD/R9cpdv0T+yoGwGmI= +google.golang.org/protobuf v1.33.0/go.mod h1:c6P6GXX6sHbq/GpV6MGZEdwhWPcYBgnhAHhKbcUYpos= gopkg.in/tomb.v1 v1.0.0-20141024135613-dd632973f1e7 h1:uRGJdciOHaEIrze2W8Q3AKkepLTh2hOroT7a+7czfdQ= gopkg.in/tomb.v1 v1.0.0-20141024135613-dd632973f1e7/go.mod h1:dt/ZhP58zS4L8KSrWDmTeBkI65Dw0HsyUHuEVlX15mw= gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY= diff --git a/internal/constants.go b/internal/constants.go new file mode 100644 index 0000000..2f1aaf0 --- /dev/null +++ b/internal/constants.go @@ -0,0 +1,7 @@ +package internal + +const ( + NatsSubjectSingleLogMessages = "govisual.logs.single" + NatsSubjectBatchLogMessages = "govisual.logs.batch" + NatsSubjectAgentStatusMessages = "govisual.agent.status" +) diff --git a/internal/dashboard/handler.go b/internal/dashboard/handler.go index 0f26a0e..7dabc78 100644 --- a/internal/dashboard/handler.go +++ b/internal/dashboard/handler.go @@ -12,7 +12,7 @@ import ( "strings" "time" - "github.com/doganarif/govisual/internal/store" + "github.com/doganarif/govisual/pkg/store" ) // Handler is the HTTP handler for the dashboard diff --git a/internal/dashboard/templates/components/filters.html b/internal/dashboard/templates/components/filters.html index b39ff40..bd54b36 100644 --- a/internal/dashboard/templates/components/filters.html +++ b/internal/dashboard/templates/components/filters.html @@ -27,25 +27,24 @@

Filters

- +
- +
+
+ + +
+
@@ -54,4 +53,4 @@

Filters

-{{end}} +{{end}} \ No newline at end of file diff --git a/internal/dashboard/templates/components/grpc-details.html b/internal/dashboard/templates/components/grpc-details.html new file mode 100644 index 0000000..4ed5711 --- /dev/null +++ b/internal/dashboard/templates/components/grpc-details.html @@ -0,0 +1,60 @@ +{{define "grpc-details"}} + +{{end}} \ No newline at end of file diff --git a/internal/dashboard/templates/components/stats.html b/internal/dashboard/templates/components/stats.html index 83d16f4..3569d98 100644 --- a/internal/dashboard/templates/components/stats.html +++ b/internal/dashboard/templates/components/stats.html @@ -27,5 +27,23 @@

Request Statistics

Avg Response Time
+ +
+

gRPC Request Statistics

+
+
+
0
+
Total gRPC Requests
+
+
+
0
+
Success (Code 0)
+
+
+
0
+
Errors (Non-Zero)
+
+
+
-{{end}} +{{end}} \ No newline at end of file diff --git a/internal/dashboard/templates/dashboard.html b/internal/dashboard/templates/dashboard.html index 9d31ef6..ff0d19b 100644 --- a/internal/dashboard/templates/dashboard.html +++ b/internal/dashboard/templates/dashboard.html @@ -71,32 +71,70 @@ function applyFilters() { const methodFilter = document.getElementById("method-filter").value; const statusFilter = document.getElementById("status-filter").value; - const pathFilter = document - .getElementById("path-filter") - .value.toLowerCase(); + const pathFilter = document.getElementById("path-filter").value.toLowerCase(); const durationFilter = document.getElementById("duration-filter").value; + const typeFilter = document.getElementById("type-filter").value; filteredRequests = allRequests.filter((req) => { - // Method filter - if (methodFilter && req.Method !== methodFilter) { + // Type filter (HTTP vs gRPC) + if (typeFilter && req.Type !== typeFilter) { return false; } + // Method filter + if (methodFilter) { + if (req.Type === "grpc") { + // For gRPC, we filter on method type (unary, client_stream, etc.) + if (req.GRPCMethodType !== methodFilter) { + return false; + } + } else { + // For HTTP, we filter on HTTP method (GET, POST, etc.) + if (req.Method !== methodFilter) { + return false; + } + } + } + // Status code filter if (statusFilter) { - const firstDigit = Math.floor(req.StatusCode / 100); - if (statusFilter === "2xx" && firstDigit !== 2) return false; - if (statusFilter === "3xx" && firstDigit !== 3) return false; - if (statusFilter === "4xx" && firstDigit !== 4) return false; - if (statusFilter === "5xx" && firstDigit !== 5) return false; + if (req.Type === "grpc") { + // gRPC status codes are handled differently + if (statusFilter === "success" && req.GRPCStatusCode !== 0) { + return false; + } else if (statusFilter === "error" && req.GRPCStatusCode === 0) { + return false; + } else if (statusFilter.startsWith("code-")) { + const code = parseInt(statusFilter.substring(5)); + if (req.GRPCStatusCode !== code) { + return false; + } + } + } else { + // HTTP status codes + const firstDigit = Math.floor(req.StatusCode / 100); + if (statusFilter === "2xx" && firstDigit !== 2) return false; + if (statusFilter === "3xx" && firstDigit !== 3) return false; + if (statusFilter === "4xx" && firstDigit !== 4) return false; + if (statusFilter === "5xx" && firstDigit !== 5) return false; + } } - // Path filter - if (pathFilter && !req.Path.toLowerCase().includes(pathFilter)) { - return false; + // Path/service filter + if (pathFilter) { + if (req.Type === "grpc") { + const servicePath = `${req.GRPCService}/${req.GRPCMethod}`.toLowerCase(); + if (!servicePath.includes(pathFilter)) { + return false; + } + } else { + if (!req.Path.toLowerCase().includes(pathFilter)) { + return false; + } + } } - // Duration filter + // Duration filter - same for both types if ( durationFilter && (!req.Duration || req.Duration < parseInt(durationFilter)) @@ -246,6 +284,8 @@ sortedRequests.forEach((req) => { const row = document.createElement("tr"); row.setAttribute("data-id", req.ID); + row.setAttribute("data-type", req.Type); // Add type attribute for filtering + row.addEventListener("click", function (e) { // Don't navigate if clicking on a checkbox or its container if ( @@ -258,35 +298,70 @@ // Navigate to request details section and show details document.querySelector('[data-target="requests-section"]').click(); - showRequestDetails(req); - showRequestTrace(req); + + // Show appropriate details based on request type + if (req.Type === "grpc") { + showGRPCRequestDetails(req); + } else { + showRequestDetails(req); + showRequestTrace(req); + } }); const timeCell = document.createElement("td"); timeCell.textContent = new Date(req.Timestamp).toLocaleTimeString(); row.appendChild(timeCell); + const typeCell = document.createElement("td"); + if (req.Type === "grpc") { + typeCell.textContent = "gRPC"; + typeCell.className = "type-grpc"; + row.classList.add("grpc-request"); // Add class for styling + } else { + typeCell.textContent = "HTTP"; + typeCell.className = "type-http"; + } + row.appendChild(typeCell); + const methodCell = document.createElement("td"); - methodCell.textContent = req.Method; - // Add CSS class based on method - methodCell.className = "method-" + req.Method.toLowerCase(); + if (req.Type === "grpc") { + methodCell.textContent = req.GRPCMethodType || "UNARY"; + methodCell.className = "method-grpc"; + } else { + methodCell.textContent = req.Method; + methodCell.className = "method-" + req.Method.toLowerCase(); + } row.appendChild(methodCell); const pathCell = document.createElement("td"); - pathCell.textContent = req.Path + (req.Query ? "?" + req.Query : ""); + if (req.Type === "grpc") { + pathCell.textContent = `${req.GRPCService}/${req.GRPCMethod}`; + } else { + pathCell.textContent = req.Path + (req.Query ? "?" + req.Query : ""); + } row.appendChild(pathCell); const statusCell = document.createElement("td"); - statusCell.textContent = req.StatusCode; - // Add CSS class based on status code - if (req.StatusCode >= 200 && req.StatusCode < 300) { - statusCell.className = "status-success"; - } else if (req.StatusCode >= 300 && req.StatusCode < 400) { - statusCell.className = "status-redirect"; - } else if (req.StatusCode >= 400 && req.StatusCode < 500) { - statusCell.className = "status-client-error"; - } else if (req.StatusCode >= 500) { - statusCell.className = "status-server-error"; + if (req.Type === "grpc") { + statusCell.textContent = req.GRPCStatusCode; + // Add CSS class based on gRPC status code + if (req.GRPCStatusCode === 0) { + statusCell.className = "status-success"; + } else { + statusCell.className = "status-client-error"; + } + } else { + statusCell.textContent = req.StatusCode; + // Add CSS class based on HTTP status code + if (req.StatusCode >= 200 && req.StatusCode < 300) { + statusCell.className = "status-success"; + } else if (req.StatusCode >= 300 && req.StatusCode < 400) { + statusCell.className = "status-redirect"; + } else if (req.StatusCode >= 400 && req.StatusCode < 500) { + statusCell.className = "status-client-error"; + } else if (req.StatusCode >= 500) { + statusCell.className = "status-server-error"; + } } row.appendChild(statusCell); @@ -308,6 +383,211 @@ }); } + // Function to show gRPC request details + function showGRPCRequestDetails(req) { + selectedRequestId = req.ID; + + // Hide HTTP-specific trace view + document.getElementById("traceContainer").style.display = "none"; + + // Show gRPC details container + const grpcDetailsContainer = document.getElementById("grpcRequestDetails"); + if (!grpcDetailsContainer) { + // Create container if it doesn't exist yet + createGRPCDetailsContainer(); + } + + document.getElementById("grpcRequestDetails").style.display = "block"; + document.getElementById("requestDetails").style.display = "none"; // Hide HTTP details + + // Fill in basic request information + document.getElementById("grpc-detail-id").textContent = req.ID; + document.getElementById("grpc-detail-time").textContent = new Date( + req.Timestamp + ).toLocaleString(); + document.getElementById("grpc-detail-service").textContent = req.GRPCService; + document.getElementById("grpc-detail-method").textContent = req.GRPCMethod; + document.getElementById("grpc-detail-type").textContent = req.GRPCMethodType; + document.getElementById("grpc-detail-peer").textContent = req.GRPCPeer || "N/A"; + + // Status and error handling + const statusElement = document.getElementById("grpc-detail-status"); + statusElement.textContent = req.GRPCStatusCode + (req.GRPCStatusDesc ? ` (${req.GRPCStatusDesc})` : ""); + + // Apply status styling + if (req.GRPCStatusCode === 0) { + statusElement.className = "status-success"; + } else { + statusElement.className = "status-client-error"; + } + + document.getElementById("grpc-detail-duration").textContent = + req.Duration !== undefined && req.Duration !== null + ? req.Duration + " ms" + : "N/A"; + + // Handle error display + const errorContainer = document.getElementById("grpc-error-container"); + if (req.Error) { + errorContainer.style.display = "block"; + document.getElementById("grpc-detail-error").textContent = req.Error; + } else { + errorContainer.style.display = "none"; + } + + // Format and display metadata + const requestMDElement = document.getElementById("grpc-detail-request-md"); + requestMDElement.textContent = formatGRPCMetadata(req.GRPCRequestMD); + + const responseMDElement = document.getElementById("grpc-detail-response-md"); + responseMDElement.textContent = formatGRPCMetadata(req.GRPCResponseMD); + + // Handle request and response data + const requestDataContainer = document.getElementById("grpc-request-data-container"); + const requestDataElement = document.getElementById("grpc-detail-request-data"); + + if (req.GRPCRequestData) { + requestDataContainer.style.display = "block"; + requestDataElement.textContent = formatBody(req.GRPCRequestData); + } else { + requestDataContainer.style.display = "none"; + } + + const responseDataContainer = document.getElementById("grpc-response-data-container"); + const responseDataElement = document.getElementById("grpc-detail-response-data"); + + if (req.GRPCResponseData) { + responseDataContainer.style.display = "block"; + responseDataElement.textContent = formatBody(req.GRPCResponseData); + } else { + responseDataContainer.style.display = "none"; + } + + // Handle streaming messages if present + const messagesContainer = document.getElementById("grpc-messages-container"); + const messagesElement = document.getElementById("grpc-messages-list"); + + if (req.GRPCMessages && req.GRPCMessages.length > 0) { + messagesContainer.style.display = "block"; + messagesElement.innerHTML = ""; // Clear previous messages + + // Create a timeline of messages + req.GRPCMessages.forEach((msg, index) => { + const msgItem = document.createElement("div"); + msgItem.className = `grpc-message ${msg.Direction === "sent" ? "message-sent" : "message-received"}`; + + const msgHeader = document.createElement("div"); + msgHeader.className = "grpc-message-header"; + msgHeader.innerHTML = `#${index + 1} ${msg.Direction.toUpperCase()} ${new Date(msg.Timestamp).toLocaleTimeString()}.${new Date(msg.Timestamp).getMilliseconds()}`; + + const msgContent = document.createElement("pre"); + msgContent.className = "grpc-message-content"; + msgContent.textContent = formatBody(msg.Data); + + msgItem.appendChild(msgHeader); + msgItem.appendChild(msgContent); + messagesElement.appendChild(msgItem); + }); + } else { + messagesContainer.style.display = "none"; + } + } + + // Helper function to format GRPC metadata + function formatGRPCMetadata(metadata) { + if (!metadata || Object.keys(metadata).length === 0) { + return "No metadata available"; + } + + let result = ""; + for (const key in metadata) { + result += `${key}: ${metadata[key].join(", ")}\n`; + } + return result; + } + + // Function to create the gRPC details container if needed + function createGRPCDetailsContainer() { + // Create the container element + const container = document.createElement("div"); + container.id = "grpcRequestDetails"; + container.className = "card"; + container.style.display = "none"; + + // Add the content + container.innerHTML = ` +
+

gRPC Request Details

+
+
+
+ ID: +
+
+ Time: +
+
+ Service: +
+
+ Method: +
+
+ Type: +
+
+ Peer: +
+
+ Status: +
+
+ Duration: +
+ + + +

Request Metadata

+

+
+            

Response Metadata

+

+
+            
+

Request Data

+

+            
+ +
+

Response Data

+

+            
+ +
+

Stream Messages

+
+
+
+ + `; + + // Add the container to the requests section + const requestsSection = document.getElementById("requests-section"); + requestsSection.appendChild(container); + + // Add event listener for close button + document.getElementById("close-grpc-details").addEventListener("click", () => { + document.getElementById("grpcRequestDetails").style.display = "none"; + selectedRequestId = null; + + // Return to dashboard section + document.querySelector('[data-target="dashboard-section"]').click(); + }); + } + // Update statistics function updateStats(requests) { if (!requests || requests.length === 0) { @@ -316,6 +596,9 @@ document.getElementById("stat-redirect").textContent = "0"; document.getElementById("stat-client-error").textContent = "0"; document.getElementById("stat-server-error").textContent = "0"; + document.getElementById("stat-grpc-total").textContent = "0"; + document.getElementById("stat-grpc-success").textContent = "0"; + document.getElementById("stat-grpc-error").textContent = "0"; document.getElementById("stat-avg-time").textContent = "0 ms"; // Update sidebar stats @@ -323,18 +606,41 @@ return; } - let success = 0; - let redirect = 0; - let clientError = 0; - let serverError = 0; + // HTTP stats + let httpSuccess = 0; + let httpRedirect = 0; + let httpClientError = 0; + let httpServerError = 0; + let httpTotal = 0; + + // gRPC stats + let grpcSuccess = 0; + let grpcError = 0; + let grpcTotal = 0; + + // Shared stats let totalDuration = 0; let validDurationCount = 0; requests.forEach((req) => { - if (req.StatusCode >= 200 && req.StatusCode < 300) success++; - else if (req.StatusCode >= 300 && req.StatusCode < 400) redirect++; - else if (req.StatusCode >= 400 && req.StatusCode < 500) clientError++; - else if (req.StatusCode >= 500) serverError++; + if (req.Type === "grpc") { + // gRPC request + grpcTotal++; + + if (req.GRPCStatusCode === 0) { + grpcSuccess++; + } else { + grpcError++; + } + } else { + // HTTP request + httpTotal++; + + if (req.StatusCode >= 200 && req.StatusCode < 300) httpSuccess++; + else if (req.StatusCode >= 300 && req.StatusCode < 400) httpRedirect++; + else if (req.StatusCode >= 400 && req.StatusCode < 500) httpClientError++; + else if (req.StatusCode >= 500) httpServerError++; + } if (req.Duration !== undefined && req.Duration !== null) { totalDuration += req.Duration; @@ -347,18 +653,312 @@ ? Math.round(totalDuration / validDurationCount) : 0; - const successRate = - requests.length > 0 ? Math.round((success / requests.length) * 100) : 0; + // Calculate combined success rate (HTTP + gRPC) + const successCount = httpSuccess + grpcSuccess; + const totalCount = requests.length; + const successRate = totalCount > 0 ? Math.round((successCount / totalCount) * 100) : 0; + + // Update HTTP stats + document.getElementById("stat-total").textContent = httpTotal; + document.getElementById("stat-success").textContent = httpSuccess; + document.getElementById("stat-redirect").textContent = httpRedirect; + document.getElementById("stat-client-error").textContent = httpClientError; + document.getElementById("stat-server-error").textContent = httpServerError; - document.getElementById("stat-total").textContent = requests.length; - document.getElementById("stat-success").textContent = success; - document.getElementById("stat-redirect").textContent = redirect; - document.getElementById("stat-client-error").textContent = clientError; - document.getElementById("stat-server-error").textContent = serverError; + // Update gRPC stats + document.getElementById("stat-grpc-total").textContent = grpcTotal; + document.getElementById("stat-grpc-success").textContent = grpcSuccess; + document.getElementById("stat-grpc-error").textContent = grpcError; + + // Update shared stats document.getElementById("stat-avg-time").textContent = avgDuration + " ms"; // Update sidebar stats - updateSidebarStats(requests.length, successRate, avgDuration); + updateSidebarStats(totalCount, successRate, avgDuration); + } + + function addGRPCStyles() { + const styleElement = document.createElement('style'); + styleElement.textContent = ` + /* gRPC row styling */ + tr.grpc-request { + background-color: rgba(76, 175, 80, 0.05); + } + + tr.grpc-request:hover { + background-color: rgba(76, 175, 80, 0.1); + } + + /* Type indicators */ + .type-grpc { + color: #4CAF50; + font-weight: bold; + } + + .type-http { + color: #2196F3; + font-weight: bold; + } + + /* Method types */ + .method-grpc { + color: #9C27B0; + font-style: italic; + } + + /* gRPC stream messages */ + .grpc-messages { + max-height: 400px; + overflow-y: auto; + border: 1px solid var(--border-color); + border-radius: 6px; + margin-top: 10px; + } + + .grpc-message { + border-bottom: 1px solid var(--border-color); + margin-bottom: 10px; + padding-bottom: 10px; + } + + .grpc-message:last-child { + border-bottom: none; + margin-bottom: 0; + } + + .grpc-message-header { + display: flex; + justify-content: space-between; + font-weight: 600; + padding: 5px 10px; + background-color: rgba(52, 152, 219, 0.05); + border-radius: 4px; + margin-bottom: 5px; + } + + .message-sent .grpc-message-header { + background-color: rgba(52, 152, 219, 0.1); + } + + .message-received .grpc-message-header { + background-color: rgba(46, 204, 113, 0.1); + } + + .message-direction { + color: var(--primary-color); + } + + .message-sent .message-direction { + color: var(--primary-color); + } + + .message-received .message-direction { + color: var(--secondary-color); + } + + .grpc-message-content { + margin: 0; + padding: 10px; + max-height: 200px; + overflow-y: auto; + } + + /* Error text */ + .error-text { + color: var(--danger-color); + background-color: rgba(231, 76, 60, 0.05); + border-left: 3px solid var(--danger-color); + padding-left: 10px; + } + `; + document.head.appendChild(styleElement); + } + + function initializeGRPCSupport() { + addGRPCStyles(); + + // Add type filter to the filters section + const methodFilterGroup = document.querySelector(".filter-group:first-child"); + if (methodFilterGroup) { + const typeFilterGroup = document.createElement("div"); + typeFilterGroup.className = "filter-group"; + typeFilterGroup.innerHTML = ` + + + `; + + methodFilterGroup.parentNode.insertBefore(typeFilterGroup, methodFilterGroup); + } + + // Update method filter to include gRPC method types + const methodFilter = document.getElementById("method-filter"); + if (methodFilter) { + // Save current value + const currentValue = methodFilter.value; + + // Clear options + methodFilter.innerHTML = ""; + + // Add HTTP methods + const httpOptgroup = document.createElement("optgroup"); + httpOptgroup.label = "HTTP Methods"; + + const httpOptions = [ + { value: "", label: "All Methods" }, + { value: "GET", label: "GET" }, + { value: "POST", label: "POST" }, + { value: "PUT", label: "PUT" }, + { value: "DELETE", label: "DELETE" }, + { value: "PATCH", label: "PATCH" } + ]; + + httpOptions.forEach(opt => { + const option = document.createElement("option"); + option.value = opt.value; + option.textContent = opt.label; + if (opt.value === currentValue) { + option.selected = true; + } + httpOptgroup.appendChild(option); + }); + + methodFilter.appendChild(httpOptgroup); + + // Add gRPC method types + const grpcOptgroup = document.createElement("optgroup"); + grpcOptgroup.label = "gRPC Method Types"; + + const grpcOptions = [ + { value: "unary", label: "Unary" }, + { value: "client_stream", label: "Client Streaming" }, + { value: "server_stream", label: "Server Streaming" }, + { value: "bidi_stream", label: "Bidirectional Streaming" } + ]; + + grpcOptions.forEach(opt => { + const option = document.createElement("option"); + option.value = opt.value; + option.textContent = opt.label; + grpcOptgroup.appendChild(option); + }); + + methodFilter.appendChild(grpcOptgroup); + } + + // Update status filter to include gRPC statuses + const statusFilter = document.getElementById("status-filter"); + if (statusFilter) { + // Save current value + const currentValue = statusFilter.value; + + // Clear options + statusFilter.innerHTML = ""; + + // Add HTTP status codes + const httpOptgroup = document.createElement("optgroup"); + httpOptgroup.label = "HTTP Status Codes"; + + const httpOptions = [ + { value: "", label: "All Status Codes" }, + { value: "2xx", label: "2xx Success" }, + { value: "3xx", label: "3xx Redirect" }, + { value: "4xx", label: "4xx Client Error" }, + { value: "5xx", label: "5xx Server Error" } + ]; + + httpOptions.forEach(opt => { + const option = document.createElement("option"); + option.value = opt.value; + option.textContent = opt.label; + if (opt.value === currentValue) { + option.selected = true; + } + httpOptgroup.appendChild(option); + }); + + statusFilter.appendChild(httpOptgroup); + + // Add gRPC status codes + const grpcOptgroup = document.createElement("optgroup"); + grpcOptgroup.label = "gRPC Status Codes"; + + const grpcOptions = [ + { value: "success", label: "OK (0)" }, + { value: "error", label: "Any Error" }, + { value: "code-1", label: "Cancelled (1)" }, + { value: "code-2", label: "Unknown (2)" }, + { value: "code-3", label: "Invalid Argument (3)" }, + { value: "code-4", label: "Deadline Exceeded (4)" }, + { value: "code-5", label: "Not Found (5)" }, + { value: "code-6", label: "Already Exists (6)" }, + { value: "code-7", label: "Permission Denied (7)" }, + { value: "code-13", label: "Internal (13)" }, + { value: "code-14", label: "Unavailable (14)" } + ]; + + grpcOptions.forEach(opt => { + const option = document.createElement("option"); + option.value = opt.value; + option.textContent = opt.label; + grpcOptgroup.appendChild(option); + }); + + statusFilter.appendChild(grpcOptgroup); + } + + // Update table headers to add Type column + const requestsTable = document.getElementById("requests"); + if (requestsTable) { + const headerRow = requestsTable.querySelector("thead tr"); + if (headerRow) { + // Look for the Time header + const timeHeader = headerRow.querySelector("th:first-child"); + if (timeHeader) { + // Insert Type header after Time + const typeHeader = document.createElement("th"); + typeHeader.textContent = "Type"; + timeHeader.insertAdjacentElement("afterend", typeHeader); + } + } + } + + // Add gRPC stats to stats section + const statsGrid = document.querySelector(".stats-grid"); + if (statsGrid) { + // Create gRPC stat boxes + const grpcTotalBox = document.createElement("div"); + grpcTotalBox.className = "stat-box"; + grpcTotalBox.innerHTML = ` +
0
+
gRPC Total
+ `; + + const grpcSuccessBox = document.createElement("div"); + grpcSuccessBox.className = "stat-box"; + grpcSuccessBox.innerHTML = ` +
0
+
gRPC Success
+ `; + + const grpcErrorBox = document.createElement("div"); + grpcErrorBox.className = "stat-box"; + grpcErrorBox.innerHTML = ` +
0
+
gRPC Errors
+ `; + + // Append to stats grid + statsGrid.appendChild(grpcTotalBox); + statsGrid.appendChild(grpcSuccessBox); + statsGrid.appendChild(grpcErrorBox); + } + + // Create the gRPC details container + createGRPCDetailsContainer(); } // Show request details @@ -492,16 +1092,14 @@ const routePattern = document.createElement("div"); routePattern.className = "trace-detail-item"; - routePattern.innerHTML = `Pattern: ${ - req.RouteTrace.pattern || "Unknown" - }`; + routePattern.innerHTML = `Pattern: ${req.RouteTrace.pattern || "Unknown" + }`; routeDetails.appendChild(routePattern); const routePath = document.createElement("div"); routePath.className = "trace-detail-item"; - routePath.innerHTML = `Path: ${ - req.RouteTrace.path || "Unknown" - }`; + routePath.innerHTML = `Path: ${req.RouteTrace.path || "Unknown" + }`; routeDetails.appendChild(routePath); if (req.RouteTrace.params) { @@ -1363,11 +1961,11 @@ row.innerHTML = ` + name + )}" placeholder="Header name"> + value + )}" placeholder="Header value"> × `; @@ -1492,9 +2090,8 @@ let output = ""; for (const [key, values] of Object.entries(headers)) { - output += `${key}: ${ - Array.isArray(values) ? values.join(", ") : values - }\n`; + output += `${key}: ${Array.isArray(values) ? values.join(", ") : values + }\n`; } return output; @@ -1557,5 +2154,7 @@ document.getElementById("replayModal").classList.remove("active"); } }); + + document.addEventListener("DOMContentLoaded", initializeGRPCSupport); -{{end}} +{{end}} \ No newline at end of file diff --git a/internal/dashboard/templates/layout.html b/internal/dashboard/templates/layout.html index 451ce05..cc76387 100644 --- a/internal/dashboard/templates/layout.html +++ b/internal/dashboard/templates/layout.html @@ -1,1206 +1,1280 @@ - - {{template "title" .}} - + {{block "head" .}}{{end}} + + + + {{template "sidebar" .}} + +
+
+
+

Dashboard Overview

+
+ {{template "stats" .}} {{template "filters" .}} {{template + "request-table" .}} +
- .replay-status-success { - background-color: rgba(46, 204, 113, 0.2); - color: var(--success-color); - } +
+
+

Request Details

+
+ {{template "request-details" .}} {{template "trace" .}} +
- .replay-status-error { - background-color: rgba(231, 76, 60, 0.2); - color: var(--danger-color); - } +
+
+

Environment

+
+ {{template "env-info" .}} +
+
+ + {{block "content" .}}{{end}} + + +
+ + +
+ + +
+
+
Compare Requests
+
+ + +
+
+
+ +
+ Select 2 or more requests to compare +
+
+
+ +
+
+ + +
+
+
+
Replay Request
+
×
+
+
+ - .replay-spinner { - display: inline-block; - width: 20px; - height: 20px; - border: 3px solid rgba(52, 152, 219, 0.3); - border-radius: 50%; - border-top-color: var(--primary-color); - animation: spin 1s ease-in-out infinite; - margin-right: 10px; - } +
+ + +
- @keyframes spin { - to { - transform: rotate(360deg); - } - } - - {{block "head" .}}{{end}} - - - {{template "sidebar" .}} - -
-
-
-

Dashboard Overview

+
+ +
- {{template "stats" .}} {{template "filters" .}} {{template - "request-table" .}} -
-
-
-

Request Details

+
+ + + + + + + + + + + + +
NameValue
+
- {{template "request-details" .}} {{template "trace" .}} -
-
-
-

Environment

+
+ +
- {{template "env-info" .}} -
-
- {{block "content" .}}{{end}} +
+ +
+
- -
- + -
+
+
+
Response
+
+
- -
-
-
Compare Requests
-
- - +
+
-
-
- -
- Select 2 or more requests to compare + +
+
-
-
- -
-
- -
-
-
-
Replay Request
-
×
+
+ +

         
-
- - -
- - -
- -
- - -
- -
- - - - - - - - - - - - -
NameValue
- -
- -
- - -
- -
- -
-
- -
-
-
Response
-
-
- -
- -
- -
- -
- -
- -

-          
- -
- -

-          
+ +
+ +

         
- - {{block "scripts" .}}{{end}} - - - + // Add active class to clicked item + this.classList.add("active"); + + // Show the target section + const targetSection = this.getAttribute("data-target"); + document.getElementById(targetSection).classList.add("active"); + }); + }); + + // Connect clear button in sidebar to the clear function + document + .getElementById("sidebar-clear-btn") + .addEventListener("click", function () { + if (typeof clearAllRequests === "function") { + clearAllRequests(); + } + }); + + // Update sidebar stats when main stats are updated + function updateSidebarStats(totalRequests, successRate, avgTime) { + document.getElementById("sidebar-total-requests").textContent = + totalRequests; + document.getElementById("sidebar-success-rate").textContent = + successRate + "%"; + document.getElementById("sidebar-avg-time").textContent = + avgTime + " ms"; + } + + + + \ No newline at end of file diff --git a/internal/middleware/middleware.go b/internal/middleware/middleware.go index 0760f27..fd4f008 100644 --- a/internal/middleware/middleware.go +++ b/internal/middleware/middleware.go @@ -8,7 +8,7 @@ import ( "time" "github.com/doganarif/govisual/internal/model" - "github.com/doganarif/govisual/internal/store" + "github.com/doganarif/govisual/pkg/store" ) // PathMatcher defines an interface for checking if a path should be ignored @@ -48,7 +48,7 @@ func Wrap(handler http.Handler, store store.Store, logRequestBody, logResponseBo } // Create a new request log - reqLog := model.NewRequestLog(r) + reqLog := model.NewHTTPRequestLog(r) // Capture request body if enabled if logRequestBody && r.Body != nil { diff --git a/internal/model/request.go b/internal/model/request.go index 2624904..4ecb899 100644 --- a/internal/model/request.go +++ b/internal/model/request.go @@ -5,26 +5,81 @@ import ( "time" ) +// RequestType identifies the type of request being logged. +type RequestType string + +const ( + // TypeHTTP represents a standard HTTP request. + TypeHTTP RequestType = "http" + + // TypeGRPC represents a gRPC request. + TypeGRPC RequestType = "grpc" +) + +// GRPCMethodType identifies the type of gRPC method. +type GRPCMethodType string + +const ( + // UnaryMethod represents a unary gRPC method. + UnaryMethod GRPCMethodType = "unary" + + // ClientStreamMethod represents a client streaming gRPC method. + ClientStreamMethod GRPCMethodType = "client_stream" + + // ServerStreamMethod represents a server streaming gRPC method. + ServerStreamMethod GRPCMethodType = "server_stream" + + // BidiStreamMethod represents a bidirectional streaming gRPC method. + BidiStreamMethod GRPCMethodType = "bidi_stream" +) + +// RequestLog represents a captured HTTP or gRPC request. type RequestLog struct { - ID string `json:"ID"` - Timestamp time.Time `json:"Timestamp"` - Method string `json:"Method"` - Path string `json:"Path"` - Query string `json:"Query"` - RequestHeaders http.Header `json:"RequestHeaders"` - ResponseHeaders http.Header `json:"ResponseHeaders"` - StatusCode int `json:"StatusCode"` - Duration int64 `json:"Duration"` + ID string `json:"ID"` + Type RequestType `json:"Type"` + Timestamp time.Time `json:"Timestamp"` + Duration int64 `json:"Duration"` + Error string `json:"Error,omitempty"` + + // HTTP-specific fields + Method string `json:"Method,omitempty"` + Path string `json:"Path,omitempty"` + Query string `json:"Query,omitempty"` + RequestHeaders http.Header `json:"RequestHeaders,omitempty"` + ResponseHeaders http.Header `json:"ResponseHeaders,omitempty"` + StatusCode int `json:"StatusCode,omitempty"` RequestBody string `json:"RequestBody,omitempty"` ResponseBody string `json:"ResponseBody,omitempty"` - Error string `json:"Error,omitempty"` MiddlewareTrace []map[string]interface{} `json:"MiddlewareTrace,omitempty"` RouteTrace map[string]interface{} `json:"RouteTrace,omitempty"` + + // gRPC-specific fields + GRPCService string `json:"GRPCService,omitempty"` + GRPCMethod string `json:"GRPCMethod,omitempty"` + GRPCMethodType GRPCMethodType `json:"GRPCMethodType,omitempty"` + GRPCStatusCode int32 `json:"GRPCStatusCode,omitempty"` + GRPCStatusDesc string `json:"GRPCStatusDesc,omitempty"` + GRPCPeer string `json:"GRPCPeer,omitempty"` + GRPCRequestMD map[string][]string `json:"GRPCRequestMD,omitempty"` + GRPCResponseMD map[string][]string `json:"GRPCResponseMD,omitempty"` + GRPCRequestData string `json:"GRPCRequestData,omitempty"` + GRPCResponseData string `json:"GRPCResponseData,omitempty"` + GRPCMessages []GRPCMessage `json:"GRPCMessages,omitempty"` } -func NewRequestLog(req *http.Request) *RequestLog { +// GRPCMessage represents a single message in a streaming gRPC call. +type GRPCMessage struct { + Timestamp time.Time `json:"Timestamp"` + Direction string `json:"Direction"` // "sent" or "received" + Data string `json:"Data,omitempty"` + Metadata map[string][]string `json:"Metadata,omitempty"` +} + +// NewHTTPRequestLog creates a new request log entry for an HTTP request. +func NewHTTPRequestLog(req *http.Request) *RequestLog { return &RequestLog{ ID: generateID(), + Type: TypeHTTP, Timestamp: time.Now(), Method: req.Method, Path: req.URL.Path, @@ -33,6 +88,20 @@ func NewRequestLog(req *http.Request) *RequestLog { } } +// NewGRPCRequestLog creates a new request log entry for a gRPC request. +func NewGRPCRequestLog(service, method string, methodType GRPCMethodType) *RequestLog { + return &RequestLog{ + ID: generateID(), + Type: TypeGRPC, + Timestamp: time.Now(), + GRPCService: service, + GRPCMethod: method, + GRPCMethodType: methodType, + GRPCMessages: make([]GRPCMessage, 0), + } +} + +// generateID creates a unique ID for a request log. func generateID() string { return time.Now().Format("20060102-150405.000000") } diff --git a/internal/model/request_test.go b/internal/model/request_test.go index 7d75261..97b1ca4 100644 --- a/internal/model/request_test.go +++ b/internal/model/request_test.go @@ -13,7 +13,7 @@ func TestNewRequestLog(t *testing.T) { } req.Header.Set("X-Test-Header", "HeaderValue") - log := NewRequestLog(req) + log := NewHTTPRequestLog(req) if log.ID == "" { t.Error("expected ID to be generated, got empty string") diff --git a/options.go b/options.go index 088ee88..20d5251 100644 --- a/options.go +++ b/options.go @@ -5,7 +5,7 @@ import ( "path/filepath" "strings" - "github.com/doganarif/govisual/internal/store" + "github.com/doganarif/govisual/pkg/store" ) type Config struct { @@ -42,6 +42,9 @@ type Config struct { // Existing database connection for SQLite ExistingDB *sql.DB + + // Shared store instance + SharedStore store.Store } // Option is a function that modifies the configuration @@ -153,6 +156,12 @@ func WithRedisStorage(connStr string, ttlSeconds int) Option { } } +func WithSharedStore(sharedStore store.Store) Option { + return func(c *Config) { + c.SharedStore = sharedStore + } +} + // ShouldIgnorePath checks if a path should be ignored based on the configured patterns // ShouldIgnorePath checks if a path should be ignored based on the configured patterns func (c *Config) ShouldIgnorePath(path string) bool { diff --git a/pkg/agent/agent.go b/pkg/agent/agent.go new file mode 100644 index 0000000..8895de5 --- /dev/null +++ b/pkg/agent/agent.go @@ -0,0 +1,104 @@ +// Package agent defines the core agent functionality for GoVisual. +package agent + +import ( + "time" + + "github.com/doganarif/govisual/internal/model" + "github.com/doganarif/govisual/pkg/transport" +) + +// Agent represents a GoVisual data collection agent that can be +// attached to various service types (gRPC, HTTP, etc.) to collect +// request/response information. +type Agent interface { + // Process takes a request log and sends it through the configured + // transport mechanism. + Process(log *model.RequestLog) error + + // Close shuts down the agent and performs any cleanup operations. + Close() error + + // Type returns the agent type (e.g., "grpc", "http"). + Type() string +} + +// AgentConfig contains the common configuration options for all agent types. +type AgentConfig struct { + // Transport is the mechanism used to send data to the visualization server. + Transport transport.Transport + + // MaxBufferSize is the maximum number of requests to buffer if the + // transport is unavailable. + MaxBufferSize int + + // BatchingEnabled determines whether requests should be batched + // before being sent to the transport. + BatchingEnabled bool + + // BatchSize is the maximum number of requests to send in a single batch. + BatchSize int + + // BatchInterval is the maximum time to wait before sending a batch. + BatchInterval time.Duration + + // Filter is a function that determines whether a request should be processed. + Filter func(*model.RequestLog) bool + + // Processor is a function that modifies the request log before transport. + Processor func(*model.RequestLog) *model.RequestLog +} + +// BaseAgent implements the common functionality for all agent types. +type BaseAgent struct { + config AgentConfig + agentType string +} + +// NewBaseAgent creates a new agent with the given configuration. +func NewBaseAgent(agentType string, config AgentConfig) *BaseAgent { + // Set defaults if not provided + if config.MaxBufferSize <= 0 { + config.MaxBufferSize = 100 + } + if config.BatchSize <= 0 { + config.BatchSize = 10 + } + if config.BatchInterval <= 0 { + config.BatchInterval = 5 * time.Second + } + + return &BaseAgent{ + config: config, + agentType: agentType, + } +} + +// Process processes a request log and sends it through the transport. +func (a *BaseAgent) Process(log *model.RequestLog) error { + // Apply filter if exists + if a.config.Filter != nil && !a.config.Filter(log) { + return nil + } + + // Apply processor if exists + if a.config.Processor != nil { + log = a.config.Processor(log) + } + + // Send through transport + return a.config.Transport.Send(log) +} + +// Close closes the agent and its transport. +func (a *BaseAgent) Close() error { + if a.config.Transport != nil { + return a.config.Transport.Close() + } + return nil +} + +// Type returns the agent type. +func (a *BaseAgent) Type() string { + return a.agentType +} diff --git a/pkg/agent/grpc.go b/pkg/agent/grpc.go new file mode 100644 index 0000000..bb5dc8d --- /dev/null +++ b/pkg/agent/grpc.go @@ -0,0 +1,587 @@ +package agent + +import ( + "context" + "encoding/json" + "path" + "strings" + "time" + + "github.com/doganarif/govisual/internal/model" + "github.com/doganarif/govisual/pkg/transport" + "google.golang.org/grpc" + "google.golang.org/grpc/codes" + "google.golang.org/grpc/metadata" + "google.golang.org/grpc/peer" + "google.golang.org/grpc/status" +) + +// GRPCAgentConfig contains configuration options specific to gRPC agents. +type GRPCAgentConfig struct { + AgentConfig + + // LogRequestData determines whether request message data is logged. + LogRequestData bool + + // LogResponseData determines whether response message data is logged. + LogResponseData bool + + // IgnoreMethods is a list of method patterns to ignore. + IgnoreMethods []string +} + +// GRPCAgent is an agent that collects data from gRPC services. +type GRPCAgent struct { + *BaseAgent + config GRPCAgentConfig +} + +// NewGRPCAgent creates a new gRPC agent with the given transport. +func NewGRPCAgent(transport transport.Transport, opts ...GRPCOption) *GRPCAgent { + config := GRPCAgentConfig{ + AgentConfig: AgentConfig{ + Transport: transport, + }, + } + + // Apply options + for _, opt := range opts { + opt(&config) + } + + return &GRPCAgent{ + BaseAgent: NewBaseAgent("grpc", config.AgentConfig), + config: config, + } +} + +// GRPCOption is a function that configures a gRPC agent. +type GRPCOption func(*GRPCAgentConfig) + +// WithGRPCRequestDataLogging enables or disables logging of gRPC request message data. +func WithGRPCRequestDataLogging(enabled bool) GRPCOption { + return func(c *GRPCAgentConfig) { + c.LogRequestData = enabled + } +} + +// WithGRPCResponseDataLogging enables or disables logging of gRPC response message data. +func WithGRPCResponseDataLogging(enabled bool) GRPCOption { + return func(c *GRPCAgentConfig) { + c.LogResponseData = enabled + } +} + +// WithIgnoreGRPCMethods sets the gRPC method patterns to ignore. +func WithIgnoreGRPCMethods(patterns ...string) GRPCOption { + return func(c *GRPCAgentConfig) { + c.IgnoreMethods = append(c.IgnoreMethods, patterns...) + } +} + +// UnaryServerInterceptor returns a gRPC unary server interceptor for data collection. +func (a *GRPCAgent) UnaryServerInterceptor() grpc.UnaryServerInterceptor { + return func(ctx context.Context, req interface{}, info *grpc.UnaryServerInfo, handler grpc.UnaryHandler) (interface{}, error) { + // Skip if method should be ignored + if a.shouldIgnoreMethod(info.FullMethod) { + return handler(ctx, req) + } + + // Extract service and method names + service, method := parseFullMethod(info.FullMethod) + + // Create a new request log + reqLog := model.NewGRPCRequestLog(service, method, model.UnaryMethod) + + // Extract request metadata + if md, ok := metadata.FromIncomingContext(ctx); ok { + reqLog.GRPCRequestMD = metadataToMap(md) + } + + // Extract peer information + reqLog.GRPCPeer = extractPeerAddress(ctx) + + // Log request message if enabled + reqLog.GRPCRequestData = marshalMessage(req, a.config.LogRequestData) + + // Record start time + startTime := time.Now() + + // Call the handler + resp, err := handler(ctx, req) + + // Record duration + reqLog.Duration = time.Since(startTime).Milliseconds() + + // Log status code and description + st, _ := status.FromError(err) + reqLog.GRPCStatusCode = int32(st.Code()) + reqLog.GRPCStatusDesc = st.Message() + + // Log response message if enabled + if err == nil { + reqLog.GRPCResponseData = marshalMessage(resp, a.config.LogResponseData) + } else { + reqLog.Error = err.Error() + } + + // Process the request log + a.Process(reqLog) + + return resp, err + } +} + +// StreamServerInterceptor returns a gRPC stream server interceptor for data collection. +func (a *GRPCAgent) StreamServerInterceptor() grpc.StreamServerInterceptor { + return func(srv interface{}, ss grpc.ServerStream, info *grpc.StreamServerInfo, handler grpc.StreamHandler) error { + // Skip if method should be ignored + if a.shouldIgnoreMethod(info.FullMethod) { + return handler(srv, ss) + } + + // Extract service and method names + service, method := parseFullMethod(info.FullMethod) + + // Determine method type + methodType := getMethodType(info.IsClientStream, info.IsServerStream) + + // Create a new request log + reqLog := model.NewGRPCRequestLog(service, method, methodType) + + // Extract request metadata + if md, ok := metadata.FromIncomingContext(ss.Context()); ok { + reqLog.GRPCRequestMD = metadataToMap(md) + } + + // Extract peer information + reqLog.GRPCPeer = extractPeerAddress(ss.Context()) + + // Create a wrapper around the server stream + wrappedStream := &wrappedServerStream{ + ServerStream: ss, + agent: a, + reqLog: reqLog, + logRequestData: a.config.LogRequestData, + logResponseData: a.config.LogResponseData, + } + + // Record start time + startTime := time.Now() + + // Call the handler + err := handler(srv, wrappedStream) + + // Record duration + reqLog.Duration = time.Since(startTime).Milliseconds() + + // Log status code and description + st, _ := status.FromError(err) + reqLog.GRPCStatusCode = int32(st.Code()) + reqLog.GRPCStatusDesc = st.Message() + + if err != nil { + reqLog.Error = err.Error() + } + + // Process the request log + a.Process(reqLog) + + return err + } +} + +// UnaryClientInterceptor returns a gRPC unary client interceptor for data collection. +func (a *GRPCAgent) UnaryClientInterceptor() grpc.UnaryClientInterceptor { + return func(ctx context.Context, method string, req, reply interface{}, cc *grpc.ClientConn, invoker grpc.UnaryInvoker, opts ...grpc.CallOption) error { + // Skip if method should be ignored + if a.shouldIgnoreMethod(method) { + return invoker(ctx, method, req, reply, cc, opts...) + } + + // Extract service and method names + service, methodName := parseFullMethod(method) + + // Create a new request log + reqLog := model.NewGRPCRequestLog(service, methodName, model.UnaryMethod) + + // Extract request metadata + if md, ok := metadata.FromOutgoingContext(ctx); ok { + reqLog.GRPCRequestMD = metadataToMap(md) + } + + // Log request message if enabled + reqLog.GRPCRequestData = marshalMessage(req, a.config.LogRequestData) + + // Set peer information to the target + reqLog.GRPCPeer = cc.Target() + + // Record start time + startTime := time.Now() + + // Create metadata for receiving headers and trailers + var responseHeader, responseTrailer metadata.MD + opts = append(opts, + grpc.Header(&responseHeader), + grpc.Trailer(&responseTrailer), + ) + + // Call the invoker + err := invoker(ctx, method, req, reply, cc, opts...) + + // Record duration + reqLog.Duration = time.Since(startTime).Milliseconds() + + // Log response metadata + reqLog.GRPCResponseMD = metadataToMap(responseHeader) + + // Log status code and description + st, _ := status.FromError(err) + reqLog.GRPCStatusCode = int32(st.Code()) + reqLog.GRPCStatusDesc = st.Message() + + // Log response message if enabled + if err == nil { + reqLog.GRPCResponseData = marshalMessage(reply, a.config.LogResponseData) + } else { + reqLog.Error = err.Error() + } + + // Process the request log + a.Process(reqLog) + + return err + } +} + +// StreamClientInterceptor returns a gRPC stream client interceptor for data collection. +func (a *GRPCAgent) StreamClientInterceptor() grpc.StreamClientInterceptor { + return func(ctx context.Context, desc *grpc.StreamDesc, cc *grpc.ClientConn, method string, streamer grpc.Streamer, opts ...grpc.CallOption) (grpc.ClientStream, error) { + // Skip if method should be ignored + if a.shouldIgnoreMethod(method) { + return streamer(ctx, desc, cc, method, opts...) + } + + // Extract service and method names + service, methodName := parseFullMethod(method) + + // Determine method type + methodType := getMethodType(desc.ClientStreams, desc.ServerStreams) + + // Create a new request log + reqLog := model.NewGRPCRequestLog(service, methodName, methodType) + + // Extract request metadata + if md, ok := metadata.FromOutgoingContext(ctx); ok { + reqLog.GRPCRequestMD = metadataToMap(md) + } + + // Set peer information to the target + reqLog.GRPCPeer = cc.Target() + + // Record start time + startTime := time.Now() + + // Create metadata for receiving headers and trailers + var responseHeader metadata.MD + opts = append(opts, grpc.Header(&responseHeader)) + + // Call the streamer + clientStream, err := streamer(ctx, desc, cc, method, opts...) + + if err != nil { + // Record duration + reqLog.Duration = time.Since(startTime).Milliseconds() + + // Log error details + reqLog.Error = err.Error() + st, _ := status.FromError(err) + reqLog.GRPCStatusCode = int32(st.Code()) + reqLog.GRPCStatusDesc = st.Message() + + // Process the request log + a.Process(reqLog) + + return nil, err + } + + // Create a wrapper around the client stream + wrappedStream := &wrappedClientStream{ + ClientStream: clientStream, + agent: a, + reqLog: reqLog, + startTime: startTime, + responseHeader: responseHeader, + logRequestData: a.config.LogRequestData, + logResponseData: a.config.LogResponseData, + } + + return wrappedStream, nil + } +} + +// shouldIgnoreMethod checks if a method should be ignored. +func (a *GRPCAgent) shouldIgnoreMethod(fullMethod string) bool { + for _, pattern := range a.config.IgnoreMethods { + // Check for exact match + if pattern == fullMethod { + return true + } + + // Check for service-wide ignore with trailing slash + if strings.HasSuffix(pattern, "/") && strings.HasPrefix(fullMethod, pattern) { + return true + } + + // Simple path matching + matched, _ := path.Match(pattern, fullMethod) + if matched { + return true + } + } + return false +} + +// wrappedServerStream wraps a grpc.ServerStream to intercept and log messages. +type wrappedServerStream struct { + grpc.ServerStream + agent *GRPCAgent + reqLog *model.RequestLog + logRequestData bool + logResponseData bool +} + +// RecvMsg intercepts and logs incoming messages. +func (w *wrappedServerStream) RecvMsg(m interface{}) error { + err := w.ServerStream.RecvMsg(m) + + if err == nil && w.logRequestData { + // Log the received message + messageData := marshalMessage(m, true) + + w.reqLog.GRPCMessages = append(w.reqLog.GRPCMessages, model.GRPCMessage{ + Timestamp: time.Now(), + Direction: "received", + Data: messageData, + }) + + // For the first message in client streaming, also set the request data + if w.reqLog.GRPCRequestData == "" { + w.reqLog.GRPCRequestData = messageData + } + } + + return err +} + +// SendMsg intercepts and logs outgoing messages. +func (w *wrappedServerStream) SendMsg(m interface{}) error { + // Log the sent message before sending + if w.logResponseData { + messageData := marshalMessage(m, true) + + w.reqLog.GRPCMessages = append(w.reqLog.GRPCMessages, model.GRPCMessage{ + Timestamp: time.Now(), + Direction: "sent", + Data: messageData, + }) + + // For the first message in server streaming, also set the response data + if w.reqLog.GRPCResponseData == "" { + w.reqLog.GRPCResponseData = messageData + } + } + + return w.ServerStream.SendMsg(m) +} + +// wrappedClientStream wraps a grpc.ClientStream to intercept and log messages. +type wrappedClientStream struct { + grpc.ClientStream + agent *GRPCAgent + reqLog *model.RequestLog + startTime time.Time + responseHeader metadata.MD + logRequestData bool + logResponseData bool + finished bool +} + +// RecvMsg intercepts and logs incoming messages. +func (w *wrappedClientStream) RecvMsg(m interface{}) error { + err := w.ClientStream.RecvMsg(m) + + if err == nil && w.logResponseData { + // Log the received message + messageData := marshalMessage(m, true) + + w.reqLog.GRPCMessages = append(w.reqLog.GRPCMessages, model.GRPCMessage{ + Timestamp: time.Now(), + Direction: "received", + Data: messageData, + }) + + // For the first message in server streaming, also set the response data + if w.reqLog.GRPCResponseData == "" { + w.reqLog.GRPCResponseData = messageData + } + + // Update response metadata if it has changed + if header, err := w.ClientStream.Header(); err == nil { + w.reqLog.GRPCResponseMD = metadataToMap(header) + } + } else if err != nil { + w.finishStreamWithError(err) + } + + return err +} + +// SendMsg intercepts and logs outgoing messages. +func (w *wrappedClientStream) SendMsg(m interface{}) error { + // Log the sent message before sending + if w.logRequestData { + messageData := marshalMessage(m, true) + + w.reqLog.GRPCMessages = append(w.reqLog.GRPCMessages, model.GRPCMessage{ + Timestamp: time.Now(), + Direction: "sent", + Data: messageData, + }) + + // For the first message in client streaming, also set the request data + if w.reqLog.GRPCRequestData == "" { + w.reqLog.GRPCRequestData = messageData + } + } + + err := w.ClientStream.SendMsg(m) + if err != nil { + w.finishStreamWithError(err) + } + + return err +} + +// CloseSend intercepts the close send call. +func (w *wrappedClientStream) CloseSend() error { + err := w.ClientStream.CloseSend() + + // When client closes the send direction, we don't yet finish the request + // as we might still receive messages from the server + + return err +} + +// Header intercepts the header call. +func (w *wrappedClientStream) Header() (metadata.MD, error) { + md, err := w.ClientStream.Header() + if err == nil { + w.reqLog.GRPCResponseMD = metadataToMap(md) + } + return md, err +} + +// Trailer intercepts the trailer call. +func (w *wrappedClientStream) Trailer() metadata.MD { + md := w.ClientStream.Trailer() + return md +} + +// finishStreamWithError finalizes logging for a stream with an error. +func (w *wrappedClientStream) finishStreamWithError(err error) { + if !w.finished { + w.finished = true + + // Record duration + w.reqLog.Duration = time.Since(w.startTime).Milliseconds() + + // Log status code and description + st, _ := status.FromError(err) + w.reqLog.GRPCStatusCode = int32(st.Code()) + w.reqLog.GRPCStatusDesc = st.Message() + + if err != nil && err != context.Canceled { + w.reqLog.Error = err.Error() + } + + // If error is EOF, it's a normal stream end, set success code + if strings.Contains(err.Error(), "EOF") { + w.reqLog.GRPCStatusCode = int32(codes.OK) + w.reqLog.GRPCStatusDesc = "OK" + w.reqLog.Error = "" + } + + // Process the request log + w.agent.Process(w.reqLog) + } +} + +// Helper functions + +// parseFullMethod parses the full method string (/service/method) into service and method components. +func parseFullMethod(fullMethod string) (service, method string) { + if fullMethod == "" { + return "", "" + } + + // Remove leading slash + fullMethod = strings.TrimPrefix(fullMethod, "/") + + // Split into service and method + parts := strings.Split(fullMethod, "/") + if len(parts) != 2 { + return fullMethod, "" + } + + return parts[0], parts[1] +} + +// getMethodType determines the type of gRPC method based on the stream info. +func getMethodType(isClientStream, isServerStream bool) model.GRPCMethodType { + switch { + case isClientStream && isServerStream: + return model.BidiStreamMethod + case isClientStream: + return model.ClientStreamMethod + case isServerStream: + return model.ServerStreamMethod + default: + return model.UnaryMethod + } +} + +// extractPeerAddress extracts the peer address from the context. +func extractPeerAddress(ctx context.Context) string { + p, ok := peer.FromContext(ctx) + if !ok { + return "unknown" + } + return p.Addr.String() +} + +// metadataToMap converts metadata to a map. +func metadataToMap(md metadata.MD) map[string][]string { + if md == nil { + return nil + } + + result := make(map[string][]string, len(md)) + for k, v := range md { + result[k] = v + } + return result +} + +// marshalMessage attempts to marshal a message to JSON for logging. +func marshalMessage(message interface{}, shouldLog bool) string { + if !shouldLog || message == nil { + return "" + } + + data, err := json.Marshal(message) + if err != nil { + return "[failed to marshal: " + err.Error() + "]" + } + return string(data) +} diff --git a/pkg/agent/http.go b/pkg/agent/http.go new file mode 100644 index 0000000..7ef321f --- /dev/null +++ b/pkg/agent/http.go @@ -0,0 +1,276 @@ +package agent + +import ( + "bufio" + "bytes" + "fmt" + "io" + "net" + "net/http" + "strings" + "time" + + "github.com/doganarif/govisual/internal/model" + "github.com/doganarif/govisual/pkg/transport" +) + +// HTTPAgentConfig contains configuration options specific to HTTP agents. +type HTTPAgentConfig struct { + AgentConfig + + // LogRequestBody determines whether request bodies are logged. + LogRequestBody bool + + // LogResponseBody determines whether response bodies are logged. + LogResponseBody bool + + // MaxBodySize is the maximum size of request/response bodies to log, in bytes. + MaxBodySize int + + // IgnorePaths is a list of path patterns to ignore. + IgnorePaths []string + + // IgnoreExtensions is a list of file extensions to ignore (e.g., ".jpg", ".png"). + IgnoreExtensions []string + + // PathTransformer is a function that transforms request paths before logging. + PathTransformer func(string) string +} + +// HTTPAgent is an agent that collects data from HTTP services. +type HTTPAgent struct { + *BaseAgent + config HTTPAgentConfig +} + +// NewHTTPAgent creates a new HTTP agent with the given configuration. +func NewHTTPAgent(transportObj transport.Transport, opts ...HTTPOption) *HTTPAgent { + config := HTTPAgentConfig{ + AgentConfig: AgentConfig{ + Transport: transportObj, + }, + MaxBodySize: 1024 * 1024, // Default 1MB max body size + } + + // Apply options + for _, opt := range opts { + opt(&config) + } + + return &HTTPAgent{ + BaseAgent: NewBaseAgent("http", config.AgentConfig), + config: config, + } +} + +// HTTPOption is a function that configures an HTTP agent. +type HTTPOption func(*HTTPAgentConfig) + +// WithHTTPRequestBodyLogging enables or disables logging of HTTP request bodies. +func WithHTTPRequestBodyLogging(enabled bool) HTTPOption { + return func(c *HTTPAgentConfig) { + c.LogRequestBody = enabled + } +} + +// WithHTTPResponseBodyLogging enables or disables logging of HTTP response bodies. +func WithHTTPResponseBodyLogging(enabled bool) HTTPOption { + return func(c *HTTPAgentConfig) { + c.LogResponseBody = enabled + } +} + +// WithMaxBodySize sets the maximum size of request/response bodies to log. +func WithMaxBodySize(size int) HTTPOption { + return func(c *HTTPAgentConfig) { + c.MaxBodySize = size + } +} + +// WithIgnorePaths sets the path patterns to ignore. +func WithIgnorePaths(patterns ...string) HTTPOption { + return func(c *HTTPAgentConfig) { + c.IgnorePaths = append(c.IgnorePaths, patterns...) + } +} + +// WithIgnoreExtensions sets the file extensions to ignore. +func WithIgnoreExtensions(extensions ...string) HTTPOption { + return func(c *HTTPAgentConfig) { + c.IgnoreExtensions = append(c.IgnoreExtensions, extensions...) + } +} + +// WithPathTransformer sets a function that transforms request paths before logging. +func WithPathTransformer(transformer func(string) string) HTTPOption { + return func(c *HTTPAgentConfig) { + c.PathTransformer = transformer + } +} + +// Middleware returns an HTTP middleware that captures request/response data. +func (a *HTTPAgent) Middleware(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + // Skip if path should be ignored + if a.shouldIgnorePath(r.URL.Path) { + next.ServeHTTP(w, r) + return + } + + // Create a new request log + reqLog := model.NewHTTPRequestLog(r) + + // Transform path if configured + if a.config.PathTransformer != nil { + reqLog.Path = a.config.PathTransformer(reqLog.Path) + } + + // Capture request body if enabled + if a.config.LogRequestBody && r.Body != nil { + bodyBytes, err := io.ReadAll(io.LimitReader(r.Body, int64(a.config.MaxBodySize))) + if err == nil { + reqLog.RequestBody = string(bodyBytes) + // Reset the body for downstream handlers + r.Body = io.NopCloser(bytes.NewBuffer(bodyBytes)) + } + } + + // Create response writer wrapper to capture response info + rw := newResponseWriter(w, a.config.LogResponseBody, a.config.MaxBodySize) + + // Record start time + start := time.Now() + + // Call the next handler + next.ServeHTTP(rw, r) + + // Record duration + reqLog.Duration = time.Since(start).Milliseconds() + + // Capture response info + reqLog.StatusCode = rw.Status() + reqLog.ResponseHeaders = rw.Header() + + // Capture response body if enabled + if a.config.LogResponseBody { + reqLog.ResponseBody = rw.Body() + } + + // Process the request log + a.Process(reqLog) + }) +} + +// shouldIgnorePath checks if a path should be ignored. +func (a *HTTPAgent) shouldIgnorePath(path string) bool { + // Check if path is in the ignored paths list + for _, pattern := range a.config.IgnorePaths { + if pattern == path { + return true + } + + // Check for pattern matching + if strings.HasSuffix(pattern, "*") { + prefix := strings.TrimSuffix(pattern, "*") + if strings.HasPrefix(path, prefix) { + return true + } + } + } + + // Check if extension should be ignored + for _, ext := range a.config.IgnoreExtensions { + if strings.HasSuffix(path, ext) { + return true + } + } + + return false +} + +// responseWriter is a wrapper for http.ResponseWriter that captures the status code and response body. +type responseWriter struct { + http.ResponseWriter + statusCode int + buffer *bytes.Buffer + logBody bool + bodyWritten bool + maxBufferSize int +} + +// newResponseWriter creates a new response writer wrapper. +func newResponseWriter(w http.ResponseWriter, logBody bool, maxSize int) *responseWriter { + var buf *bytes.Buffer + if logBody { + buf = &bytes.Buffer{} + } + + return &responseWriter{ + ResponseWriter: w, + statusCode: http.StatusOK, // Default status code + buffer: buf, + logBody: logBody, + maxBufferSize: maxSize, + } +} + +// WriteHeader captures the status code. +func (w *responseWriter) WriteHeader(code int) { + w.statusCode = code + w.ResponseWriter.WriteHeader(code) +} + +// Write captures the response body. +func (w *responseWriter) Write(b []byte) (int, error) { + // Capture body if enabled and not exceeding max size + if w.logBody && w.buffer != nil && !w.bodyWritten && w.buffer.Len() < w.maxBufferSize { + // Only write up to the max buffer size + remaining := w.maxBufferSize - w.buffer.Len() + if remaining <= 0 { + w.bodyWritten = true + } else if len(b) <= remaining { + w.buffer.Write(b) + } else { + w.buffer.Write(b[:remaining]) + w.bodyWritten = true + } + } + + return w.ResponseWriter.Write(b) +} + +// Status returns the captured status code. +func (w *responseWriter) Status() int { + return w.statusCode +} + +// Body returns the captured response body as a string. +func (w *responseWriter) Body() string { + if w.buffer != nil { + return w.buffer.String() + } + return "" +} + +// Flush implements the http.Flusher interface. +func (w *responseWriter) Flush() { + if flusher, ok := w.ResponseWriter.(http.Flusher); ok { + flusher.Flush() + } +} + +// Hijack implements the http.Hijacker interface. +func (w *responseWriter) Hijack() (net.Conn, *bufio.ReadWriter, error) { + if hijacker, ok := w.ResponseWriter.(http.Hijacker); ok { + return hijacker.Hijack() + } + return nil, nil, fmt.Errorf("underlying ResponseWriter does not implement http.Hijacker") +} + +// Push implements the http.Pusher interface. +func (w *responseWriter) Push(target string, opts *http.PushOptions) error { + if pusher, ok := w.ResponseWriter.(http.Pusher); ok { + return pusher.Push(target, opts) + } + return fmt.Errorf("underlying ResponseWriter does not implement http.Pusher") +} diff --git a/pkg/agent/options.go b/pkg/agent/options.go new file mode 100644 index 0000000..b974552 --- /dev/null +++ b/pkg/agent/options.go @@ -0,0 +1,79 @@ +package agent + +import ( + "time" + + "github.com/doganarif/govisual/internal/model" + "github.com/doganarif/govisual/pkg/transport" +) + +// Option is a function that configures an agent. +type Option func(*AgentConfig) + +// Apply applies the option to an AgentConfig +func (o Option) Apply(config *AgentConfig) { + o(config) +} + +// ForGRPC converts a base option to a GRPC option +func (o Option) ForGRPC() GRPCOption { + return func(c *GRPCAgentConfig) { + o(&c.AgentConfig) + } +} + +// ForHTTP converts a base option to an HTTP option +func (o Option) ForHTTP() HTTPOption { + return func(c *HTTPAgentConfig) { + o(&c.AgentConfig) + } +} + +// WithTransport sets the transport mechanism for the agent. +func WithTransport(transport transport.Transport) Option { + return func(c *AgentConfig) { + c.Transport = transport + } +} + +// WithMaxBufferSize sets the maximum number of requests to buffer. +func WithMaxBufferSize(size int) Option { + return func(c *AgentConfig) { + c.MaxBufferSize = size + } +} + +// WithBatchingEnabled enables or disables request batching. +func WithBatchingEnabled(enabled bool) Option { + return func(c *AgentConfig) { + c.BatchingEnabled = enabled + } +} + +// WithBatchSize sets the maximum number of requests in a batch. +func WithBatchSize(size int) Option { + return func(c *AgentConfig) { + c.BatchSize = size + } +} + +// WithBatchInterval sets the maximum time to wait before sending a batch. +func WithBatchInterval(interval time.Duration) Option { + return func(c *AgentConfig) { + c.BatchInterval = interval + } +} + +// WithFilter sets a filter function for the agent. +func WithFilter(filter func(*model.RequestLog) bool) Option { + return func(c *AgentConfig) { + c.Filter = filter + } +} + +// WithProcessor sets a processor function for the agent. +func WithProcessor(processor func(*model.RequestLog) *model.RequestLog) Option { + return func(c *AgentConfig) { + c.Processor = processor + } +} diff --git a/pkg/server/agent_api.go b/pkg/server/agent_api.go new file mode 100644 index 0000000..7c31d3c --- /dev/null +++ b/pkg/server/agent_api.go @@ -0,0 +1,111 @@ +package server + +import ( + "encoding/json" + "net/http" + "time" + + "github.com/doganarif/govisual/internal/model" + "github.com/doganarif/govisual/pkg/store" +) + +// AgentAPI handles requests from remote agents. +type AgentAPI struct { + store store.Store +} + +// NewAgentAPI creates a new API handler for agent requests. +func NewAgentAPI(store store.Store) *AgentAPI { + return &AgentAPI{ + store: store, + } +} + +// RegisterHandlers registers HTTP handlers for agent API endpoints. +func (api *AgentAPI) RegisterHandlers(mux *http.ServeMux) { + mux.HandleFunc("/api/agent/logs", api.handleLogs) + mux.HandleFunc("/api/agent/logs/batch", api.handleBatchLogs) + mux.HandleFunc("/api/agent/status", api.handleStatus) +} + +// handleLogs handles requests to add a single request log. +func (api *AgentAPI) handleLogs(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + http.Error(w, "Method not allowed", http.StatusMethodNotAllowed) + return + } + + // Decode request log + var reqLog model.RequestLog + decoder := json.NewDecoder(r.Body) + if err := decoder.Decode(&reqLog); err != nil { + http.Error(w, "Invalid request format: "+err.Error(), http.StatusBadRequest) + return + } + + // Store the log + api.store.Add(&reqLog) + + // Respond with success + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusCreated) + json.NewEncoder(w).Encode(map[string]string{"status": "success"}) +} + +// handleBatchLogs handles requests to add multiple request logs in a batch. +func (api *AgentAPI) handleBatchLogs(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + http.Error(w, "Method not allowed", http.StatusMethodNotAllowed) + return + } + + // Decode batch of request logs + var reqLogs []*model.RequestLog + decoder := json.NewDecoder(r.Body) + if err := decoder.Decode(&reqLogs); err != nil { + http.Error(w, "Invalid request format: "+err.Error(), http.StatusBadRequest) + return + } + + // Store each log + for _, log := range reqLogs { + api.store.Add(log) + } + + // Respond with success + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusCreated) + json.NewEncoder(w).Encode(map[string]interface{}{ + "status": "success", + "count": len(reqLogs), + }) +} + +// handleStatus handles agent status check requests. +func (api *AgentAPI) handleStatus(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet { + http.Error(w, "Method not allowed", http.StatusMethodNotAllowed) + return + } + + // Get agent ID from query params + agentID := r.URL.Query().Get("id") + if agentID == "" { + http.Error(w, "Missing agent ID", http.StatusBadRequest) + return + } + + // Respond with server status + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(map[string]interface{}{ + "status": "ok", + "agent_id": agentID, + "timestamp": serverTimestamp(), + "version": "1.0.0", // TODO: Get from app version + }) +} + +// serverTimestamp returns the current server timestamp in ISO 8601 format. +func serverTimestamp() string { + return time.Now().UTC().Format(time.RFC3339) +} diff --git a/pkg/server/grpc.go b/pkg/server/grpc.go new file mode 100644 index 0000000..c760384 --- /dev/null +++ b/pkg/server/grpc.go @@ -0,0 +1,66 @@ +package server + +import ( + "context" + "fmt" + + "github.com/doganarif/govisual/pkg/agent" + "google.golang.org/grpc" + "google.golang.org/grpc/codes" + "google.golang.org/grpc/status" +) + +// NewGRPCServer creates a new gRPC server with the provided agent. +func NewGRPCServer(agent *agent.GRPCAgent) *grpc.Server { + // Create unary and stream interceptors + unaryInterceptor := agent.UnaryServerInterceptor() + streamInterceptor := agent.StreamServerInterceptor() + + // Create server with interceptors + server := grpc.NewServer( + grpc.ChainUnaryInterceptor(unaryInterceptor), + grpc.ChainStreamInterceptor(streamInterceptor), + ) + + return server +} + +// gRPCClient is a gRPC client with integrated GoVisual agent. +type gRPCClient struct { + agent *agent.GRPCAgent + conn *grpc.ClientConn +} + +// NewGRPCClient creates a new gRPC client with the provided agent. +func NewGRPCClient(target string, agent *agent.GRPCAgent, opts ...grpc.DialOption) (*grpc.ClientConn, error) { + // Create unary and stream interceptors + unaryInterceptor := agent.UnaryClientInterceptor() + streamInterceptor := agent.StreamClientInterceptor() + + // Add interceptors to dial options + opts = append(opts, + grpc.WithChainUnaryInterceptor(unaryInterceptor), + grpc.WithChainStreamInterceptor(streamInterceptor), + ) + + // Create connection + conn, err := grpc.Dial(target, opts...) + if err != nil { + return nil, fmt.Errorf("failed to dial: %w", err) + } + + return conn, nil +} + +// CloseConnection closes the gRPC client connection. +func CloseConnection(conn *grpc.ClientConn) error { + if conn != nil { + return conn.Close() + } + return nil +} + +// ErrorInterceptor is a simple interceptor that returns an error for testing purposes. +func ErrorInterceptor(ctx context.Context, req interface{}, info *grpc.UnaryServerInfo, handler grpc.UnaryHandler) (interface{}, error) { + return nil, status.Errorf(codes.Internal, "error interceptor") +} diff --git a/pkg/server/nats_handler.go b/pkg/server/nats_handler.go new file mode 100644 index 0000000..2b3cf99 --- /dev/null +++ b/pkg/server/nats_handler.go @@ -0,0 +1,164 @@ +package server + +import ( + "encoding/json" + "log" + "time" + + "github.com/doganarif/govisual/internal" + "github.com/doganarif/govisual/internal/model" + "github.com/doganarif/govisual/pkg/store" + "github.com/nats-io/nats.go" +) + +// NATSHandler handles agent messages received via NATS. +type NATSHandler struct { + store store.Store + conn *nats.Conn + subs []*nats.Subscription +} + +// NewNATSHandler creates a new NATS handler for agent messages. +func NewNATSHandler(store store.Store, serverURL string, opts ...nats.Option) (*NATSHandler, error) { + // Connect to NATS + conn, err := nats.Connect(serverURL, opts...) + if err != nil { + return nil, err + } + + return &NATSHandler{ + store: store, + conn: conn, + subs: make([]*nats.Subscription, 0), + }, nil +} + +// Start begins listening for agent messages on NATS. +func (h *NATSHandler) Start() error { + // Subscribe to single log messages + singleSub, err := h.conn.Subscribe(internal.NatsSubjectSingleLogMessages, h.handleSingleLog) + if err != nil { + return err + } + h.subs = append(h.subs, singleSub) + + // Subscribe to batch log messages + batchSub, err := h.conn.Subscribe(internal.NatsSubjectBatchLogMessages, h.handleBatchLogs) + if err != nil { + return err + } + h.subs = append(h.subs, batchSub) + + // Subscribe to agent status messages + statusSub, err := h.conn.Subscribe(internal.NatsSubjectAgentStatusMessages, h.handleAgentStatus) + if err != nil { + return err + } + h.subs = append(h.subs, statusSub) + return nil +} + +// Stop unsubscribes from all NATS channels and closes the connection. +func (h *NATSHandler) Stop() error { + // Unsubscribe from all subscriptions + for _, sub := range h.subs { + if sub != nil { + if err := sub.Unsubscribe(); err != nil { + log.Printf("Error unsubscribing from NATS: %v", err) + } + } + } + + // Close the connection + h.conn.Close() + return nil +} + +// handleSingleLog processes a single log message from an agent. +func (h *NATSHandler) handleSingleLog(msg *nats.Msg) { + var reqLog model.RequestLog + if err := json.Unmarshal(msg.Data, &reqLog); err != nil { + log.Printf("Error unmarshaling log message: %v", err) + return + } + + // Store the log + h.store.Add(&reqLog) + + // Acknowledge message receipt + if msg.Reply != "" { + response := map[string]string{ + "status": "success", + "timestamp": time.Now().UTC().Format(time.RFC3339), + } + data, err := json.Marshal(response) + if err != nil { + log.Printf("Error marshaling response: %v", err) + return + } + h.conn.Publish(msg.Reply, data) + } +} + +// handleBatchLogs processes a batch of log messages from an agent. +func (h *NATSHandler) handleBatchLogs(msg *nats.Msg) { + var reqLogs []*model.RequestLog + if err := json.Unmarshal(msg.Data, &reqLogs); err != nil { + log.Printf("Error unmarshaling batch log message: %v", err) + return + } + + // Store each log + for _, log := range reqLogs { + h.store.Add(log) + } + + // Acknowledge message receipt + if msg.Reply != "" { + response := map[string]interface{}{ + "status": "success", + "count": len(reqLogs), + "timestamp": time.Now().UTC().Format(time.RFC3339), + } + data, err := json.Marshal(response) + if err != nil { + log.Printf("Error marshaling response: %v", err) + return + } + h.conn.Publish(msg.Reply, data) + } +} + +// handleAgentStatus processes agent status messages. +func (h *NATSHandler) handleAgentStatus(msg *nats.Msg) { + var status struct { + AgentID string `json:"agent_id"` + AgentType string `json:"agent_type"` + Hostname string `json:"hostname"` + Version string `json:"version"` + } + + if err := json.Unmarshal(msg.Data, &status); err != nil { + log.Printf("Error unmarshaling agent status message: %v", err) + return + } + + // Log agent status (could be stored in a registry for monitoring) + log.Printf("Agent status received: %s (%s) on %s, version %s", + status.AgentID, status.AgentType, status.Hostname, status.Version) + + // Reply with server status + if msg.Reply != "" { + response := map[string]interface{}{ + "status": "ok", + "timestamp": time.Now().UTC().Format(time.RFC3339), + "version": "1.0.0", // TODO: Get from app version + } + data, err := json.Marshal(response) + if err != nil { + log.Printf("Error marshaling response: %v", err) + return + } + h.conn.Publish(msg.Reply, data) + } +} diff --git a/internal/store/factory.go b/pkg/store/factory.go similarity index 100% rename from internal/store/factory.go rename to pkg/store/factory.go diff --git a/internal/store/memory.go b/pkg/store/memory.go similarity index 100% rename from internal/store/memory.go rename to pkg/store/memory.go diff --git a/internal/store/memory_test.go b/pkg/store/memory_test.go similarity index 100% rename from internal/store/memory_test.go rename to pkg/store/memory_test.go diff --git a/internal/store/postgres.go b/pkg/store/postgres.go similarity index 100% rename from internal/store/postgres.go rename to pkg/store/postgres.go diff --git a/internal/store/postgres_test.go b/pkg/store/postgres_test.go similarity index 100% rename from internal/store/postgres_test.go rename to pkg/store/postgres_test.go diff --git a/internal/store/redis.go b/pkg/store/redis.go similarity index 100% rename from internal/store/redis.go rename to pkg/store/redis.go diff --git a/internal/store/redis_test.go b/pkg/store/redis_test.go similarity index 100% rename from internal/store/redis_test.go rename to pkg/store/redis_test.go diff --git a/internal/store/sqlite.go b/pkg/store/sqlite.go similarity index 100% rename from internal/store/sqlite.go rename to pkg/store/sqlite.go diff --git a/internal/store/sqlite_test.go b/pkg/store/sqlite_test.go similarity index 100% rename from internal/store/sqlite_test.go rename to pkg/store/sqlite_test.go diff --git a/internal/store/store.go b/pkg/store/store.go similarity index 100% rename from internal/store/store.go rename to pkg/store/store.go diff --git a/internal/store/store_common_test.go b/pkg/store/store_common_test.go similarity index 100% rename from internal/store/store_common_test.go rename to pkg/store/store_common_test.go diff --git a/pkg/transport/http.go b/pkg/transport/http.go new file mode 100644 index 0000000..0b20adf --- /dev/null +++ b/pkg/transport/http.go @@ -0,0 +1,238 @@ +package transport + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "net/http" + "sync" + "time" + + "github.com/doganarif/govisual/internal/model" +) + +// HTTPTransport is a transport that sends request logs via HTTP. +type HTTPTransport struct { + config *Config + client *http.Client + buffer []*model.RequestLog + bufferMutex sync.Mutex + shutdown chan struct{} + wg sync.WaitGroup + batchProcess bool +} + +// NewHTTPTransport creates a new HTTP transport. +func NewHTTPTransport(endpoint string, opts ...Option) *HTTPTransport { + config := DefaultConfig() + config.Type = TransportTypeHTTP + config.Endpoint = endpoint + + for _, opt := range opts { + opt(config) + } + + client := &http.Client{ + Timeout: config.Timeout, + } + + t := &HTTPTransport{ + config: config, + client: client, + buffer: make([]*model.RequestLog, 0, config.BufferSize), + shutdown: make(chan struct{}), + batchProcess: config.BatchSize > 1, + } + + // Start background processor if batching is enabled + if t.batchProcess { + t.startBackgroundProcessor() + } + + return t +} + +// Send sends a request log via HTTP. +func (t *HTTPTransport) Send(log *model.RequestLog) error { + // If batching is enabled, add to buffer + if t.batchProcess { + t.bufferMutex.Lock() + defer t.bufferMutex.Unlock() + + // If buffer is full, try to send immediately + if len(t.buffer) >= t.config.BufferSize { + logs := make([]*model.RequestLog, len(t.buffer)) + copy(logs, t.buffer) + t.buffer = t.buffer[:0] + go t.sendBatchWithRetry(logs) + } + + // Add log to buffer + t.buffer = append(t.buffer, log) + return nil + } + + // Send single log immediately + return t.sendSingleWithRetry(log) +} + +// SendBatch sends multiple request logs in a single batch. +func (t *HTTPTransport) SendBatch(logs []*model.RequestLog) error { + if len(logs) == 0 { + return nil + } + + return t.sendBatchWithRetry(logs) +} + +// Close closes the HTTP transport. +func (t *HTTPTransport) Close() error { + close(t.shutdown) + t.wg.Wait() + + // Send any remaining logs + t.bufferMutex.Lock() + defer t.bufferMutex.Unlock() + + if len(t.buffer) > 0 { + return t.sendBatchWithRetry(t.buffer) + } + + return nil +} + +// startBackgroundProcessor starts a goroutine that periodically processes the buffer. +func (t *HTTPTransport) startBackgroundProcessor() { + t.wg.Add(1) + go func() { + defer t.wg.Done() + + ticker := time.NewTicker(time.Second) + defer ticker.Stop() + + for { + select { + case <-ticker.C: + t.processBatch() + case <-t.shutdown: + return + } + } + }() +} + +// processBatch processes the buffer. +func (t *HTTPTransport) processBatch() { + t.bufferMutex.Lock() + defer t.bufferMutex.Unlock() + + if len(t.buffer) == 0 { + return + } + + // Create a copy of the buffer + logs := make([]*model.RequestLog, len(t.buffer)) + copy(logs, t.buffer) + t.buffer = t.buffer[:0] + + // Send batch + go t.sendBatchWithRetry(logs) +} + +// sendSingleWithRetry sends a single log with retries. +func (t *HTTPTransport) sendSingleWithRetry(log *model.RequestLog) error { + data, err := json.Marshal(log) + if err != nil { + return fmt.Errorf("failed to marshal log: %w", err) + } + + for attempt := 0; attempt <= t.config.MaxRetries; attempt++ { + if attempt > 0 { + // Wait before retrying + time.Sleep(t.config.RetryWait) + } + + req, err := http.NewRequest(http.MethodPost, t.config.Endpoint, bytes.NewReader(data)) + if err != nil { + continue + } + + req.Header.Set("Content-Type", "application/json") + t.addAuthHeaders(req) + + // Set a context with timeout + ctx, cancel := context.WithTimeout(context.Background(), t.config.Timeout) + req = req.WithContext(ctx) + + resp, err := t.client.Do(req) + cancel() + + if err != nil { + continue + } + + defer resp.Body.Close() + + if resp.StatusCode >= 200 && resp.StatusCode < 300 { + return nil + } + } + + return fmt.Errorf("failed to send log after %d attempts", t.config.MaxRetries+1) +} + +// sendBatchWithRetry sends multiple logs with retries. +func (t *HTTPTransport) sendBatchWithRetry(logs []*model.RequestLog) error { + data, err := json.Marshal(logs) + if err != nil { + return fmt.Errorf("failed to marshal logs: %w", err) + } + + for attempt := 0; attempt <= t.config.MaxRetries; attempt++ { + if attempt > 0 { + // Wait before retrying + time.Sleep(t.config.RetryWait) + } + + req, err := http.NewRequest(http.MethodPost, t.config.Endpoint+"/batch", bytes.NewReader(data)) + if err != nil { + continue + } + + req.Header.Set("Content-Type", "application/json") + t.addAuthHeaders(req) + + // Set a context with timeout + ctx, cancel := context.WithTimeout(context.Background(), t.config.Timeout) + req = req.WithContext(ctx) + + resp, err := t.client.Do(req) + cancel() + + if err != nil { + continue + } + + defer resp.Body.Close() + + if resp.StatusCode >= 200 && resp.StatusCode < 300 { + return nil + } + } + + return fmt.Errorf("failed to send batch after %d attempts", t.config.MaxRetries+1) +} + +// addAuthHeaders adds authentication headers to the request. +func (t *HTTPTransport) addAuthHeaders(req *http.Request) { + if t.config.Credentials != nil { + if token, ok := t.config.Credentials["token"]; ok { + req.Header.Set("Authorization", "Bearer "+token) + } + + if apiKey, ok := t.config.Credentials["api_key"]; ok { + req.Header.Set("X-API-Key", apiKey) + } + } +} diff --git a/pkg/transport/nats.go b/pkg/transport/nats.go new file mode 100644 index 0000000..3fec83b --- /dev/null +++ b/pkg/transport/nats.go @@ -0,0 +1,192 @@ +package transport + +import ( + "context" + "encoding/json" + "fmt" + "sync" + "time" + + "github.com/doganarif/govisual/internal" + "github.com/doganarif/govisual/internal/model" + "github.com/nats-io/nats.go" +) + +// NATSTransport is a transport that uses NATS for communication. +type NATSTransport struct { + ctx context.Context + cancel context.CancelFunc + + config *Config + conn *nats.Conn + buffer []*model.RequestLog + bufferMutex sync.Mutex + wg sync.WaitGroup +} + +// NewNATSTransport creates a new NATS transport with the given options. +func NewNATSTransport(serverURL string, opts ...Option) (*NATSTransport, error) { + config := DefaultConfig() + config.Type = TransportTypeNATS + config.Endpoint = serverURL + + for _, opt := range opts { + opt(config) + } + + // Connect to NATS + natsOpts := []nats.Option{ + nats.Name("GoVisual Agent"), + nats.Timeout(config.Timeout), + nats.ReconnectWait(config.RetryWait), + nats.MaxReconnects(config.MaxRetries), + } + + // Add credentials if provided + if config.Credentials != nil { + if username, ok := config.Credentials["username"]; ok { + if password, ok := config.Credentials["password"]; ok { + natsOpts = append(natsOpts, nats.UserInfo(username, password)) + } + } + + if token, ok := config.Credentials["token"]; ok { + natsOpts = append(natsOpts, nats.Token(token)) + } + } + + conn, err := nats.Connect(serverURL, natsOpts...) + if err != nil { + return nil, fmt.Errorf("connecting to NATS: %w", err) + } + if !conn.IsConnected() { + return nil, fmt.Errorf("NATS did not connect") + } + + ctx, cancel := context.WithCancel(context.Background()) + t := &NATSTransport{ + ctx: ctx, + cancel: cancel, + config: config, + conn: conn, + buffer: make([]*model.RequestLog, 0, config.BufferSize), + } + + // Start the background processor for batching + if config.BatchSize > 1 { + t.startBackgroundProcessor() + } + + return t, nil +} + +// Send sends a request log to NATS. +func (t *NATSTransport) Send(log *model.RequestLog) error { + // If batching is enabled, add to buffer + if t.config.BatchSize > 1 { + t.bufferMutex.Lock() + defer t.bufferMutex.Unlock() + + // If buffer is full, try to send immediately + if len(t.buffer) >= t.config.BufferSize { + logs := make([]*model.RequestLog, len(t.buffer)) + copy(logs, t.buffer) + t.buffer = t.buffer[:0] + go t.SendBatch(logs) + } + + // Add log to buffer + t.buffer = append(t.buffer, log) + return nil + } + + // Send single log immediately + return t.sendSingle(log) +} + +// SendBatch sends multiple request logs in a single batch. +func (t *NATSTransport) SendBatch(logs []*model.RequestLog) error { + if len(logs) == 0 { + return nil + } + + // Serialize the batch + data, err := json.Marshal(logs) + if err != nil { + return fmt.Errorf("failed to marshal logs: %w", err) + } + + // Send to NATS + return t.conn.Publish(internal.NatsSubjectBatchLogMessages, data) +} + +// Close closes the NATS transport. +func (t *NATSTransport) Close() error { + t.cancel() + t.wg.Wait() + + // Send any remaining logs + t.bufferMutex.Lock() + logs := t.buffer + t.buffer = nil + t.bufferMutex.Unlock() + + if len(logs) > 0 { + if err := t.SendBatch(logs); err != nil { + t.conn.Close() + return err + } + } + + t.conn.Close() + return nil +} + +// startBackgroundProcessor starts a goroutine that periodically processes the buffer. +func (t *NATSTransport) startBackgroundProcessor() { + t.wg.Add(1) + go func() { + defer t.wg.Done() + + ticker := time.NewTicker(time.Second) + defer ticker.Stop() + + for { + select { + case <-ticker.C: + t.processBatch() + case <-t.ctx.Done(): + return + } + } + }() +} + +// processBatch processes the buffer if it contains enough logs or if enough time has passed. +func (t *NATSTransport) processBatch() { + t.bufferMutex.Lock() + defer t.bufferMutex.Unlock() + + if len(t.buffer) == 0 { + return + } + + // If buffer has enough logs or it's been long enough, send them + if len(t.buffer) >= t.config.BatchSize { + logs := make([]*model.RequestLog, len(t.buffer)) + copy(logs, t.buffer) + t.buffer = t.buffer[:0] + go t.SendBatch(logs) + } +} + +// sendSingle sends a single request log. +func (t *NATSTransport) sendSingle(log *model.RequestLog) error { + data, err := json.Marshal(log) + if err != nil { + return fmt.Errorf("failed to marshal log: %w", err) + } + + // Send to NATS + return t.conn.Publish(internal.NatsSubjectSingleLogMessages, data) +} diff --git a/pkg/transport/store.go b/pkg/transport/store.go new file mode 100644 index 0000000..a9cc17f --- /dev/null +++ b/pkg/transport/store.go @@ -0,0 +1,41 @@ +// Package transport defines the transport mechanisms for GoVisual. +package transport + +import ( + "github.com/doganarif/govisual/internal/model" + "github.com/doganarif/govisual/pkg/store" +) + +// StoreTransport is a transport that uses a shared store for communication. +type StoreTransport struct { + store store.Store +} + +// NewStoreTransport creates a new store transport with the given store. +func NewStoreTransport(store store.Store) *StoreTransport { + return &StoreTransport{ + store: store, + } +} + +// Send sends a request log to the store. +func (t *StoreTransport) Send(log *model.RequestLog) error { + t.store.Add(log) + return nil +} + +// SendBatch sends multiple request logs to the store. +func (t *StoreTransport) SendBatch(logs []*model.RequestLog) error { + for _, log := range logs { + t.store.Add(log) + } + return nil +} + +// Close closes the store transport. +func (t *StoreTransport) Close() error { + if t.store != nil { + return t.store.Close() + } + return nil +} diff --git a/pkg/transport/transport.go b/pkg/transport/transport.go new file mode 100644 index 0000000..9705560 --- /dev/null +++ b/pkg/transport/transport.go @@ -0,0 +1,126 @@ +// Package transport defines the transport mechanisms for GoVisual. +package transport + +import ( + "time" + + "github.com/doganarif/govisual/internal/model" +) + +// Transport is an interface that defines how request logs are sent +// from agents to the visualization server. +type Transport interface { + // Send sends a request log to the visualization server. + Send(log *model.RequestLog) error + + // SendBatch sends multiple request logs in a single batch. + SendBatch(logs []*model.RequestLog) error + + // Close closes the transport and performs any cleanup. + Close() error +} + +// TransportType represents the type of transport mechanism. +type TransportType string + +const ( + // TransportTypeStore represents a shared store transport. + TransportTypeStore TransportType = "store" + + // TransportTypeNATS represents a NATS transport. + TransportTypeNATS TransportType = "nats" + + // TransportTypeHTTP represents an HTTP transport. + TransportTypeHTTP TransportType = "http" +) + +// Config contains common configuration for transport mechanisms. +type Config struct { + // Type is the type of transport. + Type TransportType + + // Endpoint is the destination for the transport (e.g., NATS server URL, HTTP endpoint). + Endpoint string + + // Credentials for authenticating with the transport if needed. + Credentials map[string]string + + // MaxRetries is the maximum number of retries for failed transmissions. + MaxRetries int + + // RetryWait is the time to wait between retries. + RetryWait time.Duration + + // BatchSize is the maximum number of logs to send in a single batch. + BatchSize int + + // Timeout is the maximum time to wait for a transmission to complete. + Timeout time.Duration + + // BufferSize is the maximum number of logs to buffer when the transport is unavailable. + BufferSize int +} + +// Option is a function that configures a transport. +type Option func(*Config) + +// WithEndpoint sets the endpoint for the transport. +func WithEndpoint(endpoint string) Option { + return func(c *Config) { + c.Endpoint = endpoint + } +} + +// WithCredentials sets the credentials for the transport. +func WithCredentials(credentials map[string]string) Option { + return func(c *Config) { + c.Credentials = credentials + } +} + +// WithMaxRetries sets the maximum number of retries for the transport. +func WithMaxRetries(maxRetries int) Option { + return func(c *Config) { + c.MaxRetries = maxRetries + } +} + +// WithRetryWait sets the time to wait between retries. +func WithRetryWait(retryWait time.Duration) Option { + return func(c *Config) { + c.RetryWait = retryWait + } +} + +// WithBatchSize sets the maximum number of logs in a batch. +func WithBatchSize(batchSize int) Option { + return func(c *Config) { + c.BatchSize = batchSize + } +} + +// WithTimeout sets the timeout for the transport. +func WithTimeout(timeout time.Duration) Option { + return func(c *Config) { + c.Timeout = timeout + } +} + +// WithBufferSize sets the buffer size for the transport. +func WithBufferSize(bufferSize int) Option { + return func(c *Config) { + c.BufferSize = bufferSize + } +} + +// DefaultConfig returns the default transport configuration. +func DefaultConfig() *Config { + return &Config{ + Type: TransportTypeStore, + MaxRetries: 3, + RetryWait: time.Second, + BatchSize: 10, + Timeout: 5 * time.Second, + BufferSize: 100, + } +} diff --git a/wrap.go b/wrap.go index b4d74b4..2e73c80 100644 --- a/wrap.go +++ b/wrap.go @@ -11,8 +11,8 @@ import ( "github.com/doganarif/govisual/internal/dashboard" "github.com/doganarif/govisual/internal/middleware" - "github.com/doganarif/govisual/internal/store" "github.com/doganarif/govisual/internal/telemetry" + "github.com/doganarif/govisual/pkg/store" ) // Wrap wraps an http.Handler with request visualization middleware @@ -23,34 +23,40 @@ func Wrap(handler http.Handler, opts ...Option) http.Handler { opt(config) } - // Create store based on configuration + // Create or use shared store var requestStore store.Store var err error - storeConfig := &store.StorageConfig{ - Type: config.StorageType, - Capacity: config.MaxRequests, - ConnectionString: config.ConnectionString, - TableName: config.TableName, - TTL: config.RedisTTL, - ExistingDB: config.ExistingDB, - } - - requestStore, err = store.NewStore(storeConfig) - if err != nil { - log.Printf("Failed to create configured storage backend: %v. Falling back to in-memory storage.", err) - requestStore = store.NewInMemoryStore(config.MaxRequests) - } + // Use shared store if provided + if config.SharedStore != nil { + requestStore = config.SharedStore + } else { + // Create store based on configuration + storeConfig := &store.StorageConfig{ + Type: config.StorageType, + Capacity: config.MaxRequests, + ConnectionString: config.ConnectionString, + TableName: config.TableName, + TTL: config.RedisTTL, + ExistingDB: config.ExistingDB, + } - // Set up graceful shutdown for store - go func() { - signals := make(chan os.Signal, 1) - signal.Notify(signals, syscall.SIGTERM, syscall.SIGINT) - <-signals - if err := requestStore.Close(); err != nil { - log.Printf("Error closing storage: %v", err) + requestStore, err = store.NewStore(storeConfig) + if err != nil { + log.Printf("Failed to create configured storage backend: %v. Falling back to in-memory storage.", err) + requestStore = store.NewInMemoryStore(config.MaxRequests) } - }() + + // Set up graceful shutdown for store + go func() { + signals := make(chan os.Signal, 1) + signal.Notify(signals, syscall.SIGTERM, syscall.SIGINT) + <-signals + if err := requestStore.Close(); err != nil { + log.Printf("Error closing storage: %v", err) + } + }() + } // Create middleware wrapper wrapped := middleware.Wrap(handler, requestStore, config.LogRequestBody, config.LogResponseBody, config)