diff --git a/.gitignore b/.gitignore index 1512392..6038b9d 100644 --- a/.gitignore +++ b/.gitignore @@ -45,4 +45,7 @@ _testmain.go /test/garbage/*.out /test/pass.out /test/run.out -/test/times.out \ No newline at end of file +/test/times.out + +# ignore the vendor directory +vendor diff --git a/README.md b/README.md index 2d21d53..f7b7b7d 100644 --- a/README.md +++ b/README.md @@ -58,6 +58,7 @@ handler := govisual.Wrap( govisual.WithDashboardPath("/__dashboard"), // Custom dashboard path govisual.WithRequestBodyLogging(true), // Log request bodies govisual.WithResponseBodyLogging(true), // Log response bodies + govisual.WithConsoleLogging(true), // Log request timing to TTY govisual.WithIgnorePaths("/health"), // Paths to ignore govisual.WithOpenTelemetry(true), // Enable OpenTelemetry govisual.WithServiceName("my-service"), // Service name for OTel diff --git a/go.mod b/go.mod index 82078e7..4a22b1a 100644 --- a/go.mod +++ b/go.mod @@ -17,6 +17,7 @@ require ( github.com/golang/snappy v1.0.0 // indirect github.com/google/uuid v1.6.0 // indirect github.com/klauspost/compress v1.16.7 // indirect + github.com/mattn/go-isatty v0.0.20 // indirect github.com/xdg-go/pbkdf2 v1.0.0 // indirect github.com/xdg-go/scram v1.1.2 // indirect github.com/xdg-go/stringprep v1.0.4 // indirect diff --git a/go.sum b/go.sum index ecef419..0a35554 100644 --- a/go.sum +++ b/go.sum @@ -29,6 +29,8 @@ github.com/klauspost/compress v1.16.7 h1:2mk3MPGNzKyxErAw8YaohYh69+pa4sIQSC0fPGC github.com/klauspost/compress v1.16.7/go.mod h1:ntbaceVETuRiXiv4DpjP66DpAtAGkEQskQzEyD//IeE= 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-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/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/nxadm/tail v1.4.8 h1:nPr65rt6Y5JFSKQO7qToXr7pePgD6Gwiw05lkbyAQTE= @@ -91,6 +93,7 @@ golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7w golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/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.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= diff --git a/internal/middleware/middleware.go b/internal/middleware/middleware.go index 0760f27..a851845 100644 --- a/internal/middleware/middleware.go +++ b/internal/middleware/middleware.go @@ -8,7 +8,9 @@ import ( "time" "github.com/doganarif/govisual/internal/model" + "github.com/doganarif/govisual/internal/options" "github.com/doganarif/govisual/internal/store" + "github.com/doganarif/govisual/internal/utils" ) // PathMatcher defines an interface for checking if a path should be ignored @@ -39,7 +41,10 @@ func (w *responseWriter) Write(b []byte) (int, error) { } // Wrap wraps an http.Handler with the request visualization middleware -func Wrap(handler http.Handler, store store.Store, logRequestBody, logResponseBody bool, pathMatcher PathMatcher) http.Handler { +func Wrap(handler http.Handler, store store.Store, config *options.Config, pathMatcher PathMatcher) http.Handler { + + logger := utils.NewLogger() + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { // Check if the path should be ignored if pathMatcher != nil && pathMatcher.ShouldIgnorePath(r.URL.Path) { @@ -51,7 +56,7 @@ func Wrap(handler http.Handler, store store.Store, logRequestBody, logResponseBo reqLog := model.NewRequestLog(r) // Capture request body if enabled - if logRequestBody && r.Body != nil { + if config.LogRequestBody && r.Body != nil { // Read the body bodyBytes, _ := io.ReadAll(r.Body) r.Body.Close() @@ -65,7 +70,7 @@ func Wrap(handler http.Handler, store store.Store, logRequestBody, logResponseBo // Create response writer wrapper var resWriter *responseWriter - if logResponseBody { + if config.LogResponseBody { resWriter = &responseWriter{ ResponseWriter: w, statusCode: 200, // Default status code @@ -111,10 +116,15 @@ func Wrap(handler http.Handler, store store.Store, logRequestBody, logResponseBo } // Capture response body if enabled - if logResponseBody && resWriter.buffer != nil { + if config.LogRequestBody && resWriter.buffer != nil { reqLog.ResponseBody = resWriter.buffer.String() } + // Log to console if enabled + if config.LogRequestToConsole { + logger.LogRequest(reqLog) + } + // Store the request log store.Add(reqLog) }) diff --git a/internal/middleware/middleware_test.go b/internal/middleware/middleware_test.go index 5d4379b..7297eb1 100644 --- a/internal/middleware/middleware_test.go +++ b/internal/middleware/middleware_test.go @@ -7,6 +7,7 @@ import ( "testing" "github.com/doganarif/govisual/internal/model" + "github.com/doganarif/govisual/internal/options" ) // mockStore implements store.Store for testing @@ -61,7 +62,13 @@ func TestWrapMiddleware(t *testing.T) { w.Write([]byte("hello world")) }) - wrapped := Wrap(handler, store, true, true, &mockPathMatcher{}) + config := &options.Config{ + LogRequestBody: true, + LogResponseBody: true, + LogRequestToConsole: false, + } + + wrapped := Wrap(handler, store, config, &mockPathMatcher{}) req := httptest.NewRequest("POST", "/test?x=1", strings.NewReader("sample-body")) req.Header.Set("X-Test", "test") diff --git a/internal/options/options.go b/internal/options/options.go new file mode 100644 index 0000000..a39b238 --- /dev/null +++ b/internal/options/options.go @@ -0,0 +1,73 @@ +package options + +import ( + "database/sql" + "path/filepath" + "strings" + + "github.com/doganarif/govisual/internal/store" +) + +type Config struct { + MaxRequests int + + DashboardPath string + + LogRequestBody bool + + LogResponseBody bool + + LogRequestToConsole bool + + IgnorePaths []string + + // OpenTelemetry configuration + EnableOpenTelemetry bool + + ServiceName string + + ServiceVersion string + + OTelEndpoint string + + // Storage configuration + StorageType store.StorageType + + // Connection string for database stores + ConnectionString string + + // TableName for SQL database stores + TableName string + + // TTL for Redis store in seconds + RedisTTL int + + // Existing database connection for SQLite + ExistingDB *sql.DB +} + +// ShouldIgnorePath checks if a path should be ignored based on the configured patterns +func (c *Config) ShouldIgnorePath(path string) bool { + // First check if it's the dashboard path which should always be ignored to prevent recursive logging + if path == c.DashboardPath || strings.HasPrefix(path, c.DashboardPath+"/") { + return true + } + + // Then check against provided ignore patterns + for _, pattern := range c.IgnorePaths { + matched, err := filepath.Match(pattern, path) + if err == nil && matched { + return true + } + + // Special handling for path groups with trailing slash + if len(pattern) > 0 && pattern[len(pattern)-1] == '/' { + // If pattern ends with /, check if path starts with pattern + if len(path) >= len(pattern) && path[:len(pattern)] == pattern { + return true + } + } + } + + return false +} diff --git a/internal/utils/logger.go b/internal/utils/logger.go new file mode 100644 index 0000000..bdef9d5 --- /dev/null +++ b/internal/utils/logger.go @@ -0,0 +1,135 @@ +package utils + +import ( + "fmt" + "net/http" + "os" + "time" + + "github.com/doganarif/govisual/internal/model" + "github.com/mattn/go-isatty" +) + +var ( + green = "\033[32m" + white = "\033[37m" + red = "\033[31m" + blue = "\033[34m" + yellow = "\033[33m" + gray = "\033[90m" + black = "\033[30m" + magenta = "\033[35m" + cyan = "\033[36m" + reset = "\033[0m" +) + +type Logger struct{} + +func init() { + // Check if the output target is a terminal + // If not, disable color codes + if !isatty.IsTerminal(os.Stdout.Fd()) || !isatty.IsTerminal(os.Stderr.Fd()) { + green = "" + white = "" + red = "" + blue = "" + yellow = "" + gray = "" + black = "" + magenta = "" + cyan = "" + reset = "" + } +} + +func NewLogger() *Logger { + // Create a new logger instance + return &Logger{} +} + +func colorizeMethod(method string) string { + if method == "" { + return "" + } + + var color string + switch method { + case http.MethodGet: + color = blue + case http.MethodPost: + color = green + case http.MethodPut: + color = yellow + case http.MethodDelete: + color = red + case http.MethodPatch: + color = magenta + case http.MethodHead: + color = gray + case http.MethodOptions: + color = cyan + case http.MethodTrace: + color = white + default: + color = black + } + + return fmt.Sprintf("[%s%-7s%s]", color, method, reset) +} + +func colorizeStatus(status int) string { + if status < 100 || status > 599 { + return fmt.Sprintf("[%s%3d%s]", red, status, reset) + } + + var color string + switch { + case status >= http.StatusContinue && status < http.StatusOK: + color = gray + case status >= http.StatusOK && status < http.StatusMultipleChoices: + color = green + case status >= http.StatusMultipleChoices && status < http.StatusBadRequest: + color = blue + case status >= http.StatusBadRequest && status < http.StatusInternalServerError: + color = yellow + default: + color = red + } + + return fmt.Sprintf("[%s%3d%s]", color, status, reset) +} + +func colorizeDuration(duration time.Duration) string { + if duration < 0 { + return fmt.Sprintf("%s%13v%s", red, duration, reset) + } + + var color string + switch { + case duration < 500*time.Millisecond: + color = green + case duration < 1*time.Second: + color = yellow + default: + color = red + } + + return fmt.Sprintf("%s%13v%s", color, duration, reset) +} + +func (logger *Logger) LogRequest(reqLog *model.RequestLog) { + // This function logs the request details based on the configuration + if reqLog == nil { + fmt.Println("Warning: Attempted to log nil request log, ignoring") + return + } + + fmt.Printf( + "[VIZ] %v %s%s %s %#v\n", + reqLog.Timestamp.Format("2006-01-02 15:04:05"), + colorizeMethod(reqLog.Method), + colorizeStatus(reqLog.StatusCode), + colorizeDuration(time.Since(reqLog.Timestamp)), + reqLog.Path, + ) +} diff --git a/options.go b/options.go index 1438dff..6e5a270 100644 --- a/options.go +++ b/options.go @@ -3,124 +3,93 @@ package govisual import ( "database/sql" "fmt" - "path/filepath" - "strings" + "github.com/doganarif/govisual/internal/options" "github.com/doganarif/govisual/internal/store" ) -type Config struct { - MaxRequests int - - DashboardPath string - - LogRequestBody bool - - LogResponseBody bool - - IgnorePaths []string - - // OpenTelemetry configuration - EnableOpenTelemetry bool - - ServiceName string - - ServiceVersion string - - OTelEndpoint string - - // Storage configuration - StorageType store.StorageType - - // Connection string for database stores - ConnectionString string - - // TableName for SQL database stores - TableName string - - // TTL for Redis store in seconds - RedisTTL int - - // Existing database connection for SQLite - ExistingDB *sql.DB -} - // Option is a function that modifies the configuration -type Option func(*Config) +type Option func(*options.Config) // WithMaxRequests sets the maximum number of requests to store func WithMaxRequests(max int) Option { - return func(c *Config) { + return func(c *options.Config) { c.MaxRequests = max } } // WithDashboardPath sets the path to access the dashboard func WithDashboardPath(path string) Option { - return func(c *Config) { + return func(c *options.Config) { c.DashboardPath = path } } // WithRequestBodyLogging enables or disables request body logging func WithRequestBodyLogging(enabled bool) Option { - return func(c *Config) { + return func(c *options.Config) { c.LogRequestBody = enabled } } // WithResponseBodyLogging enables or disables response body logging func WithResponseBodyLogging(enabled bool) Option { - return func(c *Config) { + return func(c *options.Config) { c.LogResponseBody = enabled } } +func WithConsoleLogging(enabled bool) Option { + return func(c *options.Config) { + c.LogRequestToConsole = enabled + } +} + // WithIgnorePaths sets the path patterns to ignore func WithIgnorePaths(patterns ...string) Option { - return func(c *Config) { + return func(c *options.Config) { c.IgnorePaths = append(c.IgnorePaths, patterns...) } } // WithOpenTelemetry enables or disables OpenTelemetry instrumentation func WithOpenTelemetry(enabled bool) Option { - return func(c *Config) { + return func(c *options.Config) { c.EnableOpenTelemetry = enabled } } // WithServiceName sets the service name for OpenTelemetry func WithServiceName(name string) Option { - return func(c *Config) { + return func(c *options.Config) { c.ServiceName = name } } // WithServiceVersion sets the service version for OpenTelemetry func WithServiceVersion(version string) Option { - return func(c *Config) { + return func(c *options.Config) { c.ServiceVersion = version } } // WithOTelEndpoint sets the OTLP endpoint for exporting telemetry data func WithOTelEndpoint(endpoint string) Option { - return func(c *Config) { + return func(c *options.Config) { c.OTelEndpoint = endpoint } } // WithMemoryStorage configures the application to use in-memory storage func WithMemoryStorage() Option { - return func(c *Config) { + return func(c *options.Config) { c.StorageType = store.StorageTypeMemory } } // WithPostgresStorage configures the application to use PostgreSQL storage func WithPostgresStorage(connStr string, tableName string) Option { - return func(c *Config) { + return func(c *options.Config) { c.StorageType = store.StorageTypePostgres c.ConnectionString = connStr c.TableName = tableName @@ -129,7 +98,7 @@ func WithPostgresStorage(connStr string, tableName string) Option { // WithSQLiteStorage configures the application to use SQLite storage func WithSQLiteStorage(dbPath string, tableName string) Option { - return func(c *Config) { + return func(c *options.Config) { c.StorageType = store.StorageTypeSQLite c.ConnectionString = dbPath c.TableName = tableName @@ -138,7 +107,7 @@ func WithSQLiteStorage(dbPath string, tableName string) Option { // WithSQLiteStorageDB configures the application to use SQLite storage with an existing database connection func WithSQLiteStorageDB(db *sql.DB, tableName string) Option { - return func(c *Config) { + return func(c *options.Config) { c.StorageType = store.StorageTypeSQLiteWithDB c.ExistingDB = db c.TableName = tableName @@ -147,7 +116,7 @@ func WithSQLiteStorageDB(db *sql.DB, tableName string) Option { // WithRedisStorage configures the application to use Redis storage func WithRedisStorage(connStr string, ttlSeconds int) Option { - return func(c *Config) { + return func(c *options.Config) { c.StorageType = store.StorageTypeRedis c.ConnectionString = connStr c.RedisTTL = ttlSeconds @@ -156,47 +125,21 @@ func WithRedisStorage(connStr string, ttlSeconds int) Option { // WithMongoDBStorage configures the application to use MongoDB storage func WithMongoDBStorage(uri, databaseName, collectionName string) Option { - return func(c *Config) { + return func(c *options.Config) { c.StorageType = store.StorageTypeMongoDB c.ConnectionString = uri c.TableName = fmt.Sprintf("%s.%s", databaseName, collectionName) } } -// 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 { - // First check if it's the dashboard path which should always be ignored to prevent recursive logging - if path == c.DashboardPath || strings.HasPrefix(path, c.DashboardPath+"/") { - return true - } - - // Then check against provided ignore patterns - for _, pattern := range c.IgnorePaths { - matched, err := filepath.Match(pattern, path) - if err == nil && matched { - return true - } - - // Special handling for path groups with trailing slash - if len(pattern) > 0 && pattern[len(pattern)-1] == '/' { - // If pattern ends with /, check if path starts with pattern - if len(path) >= len(pattern) && path[:len(pattern)] == pattern { - return true - } - } - } - - return false -} - // defaultConfig returns the default configuration -func defaultConfig() *Config { - return &Config{ +func defaultConfig() *options.Config { + return &options.Config{ MaxRequests: 100, DashboardPath: "/__viz", LogRequestBody: false, LogResponseBody: false, + LogRequestToConsole: false, IgnorePaths: []string{}, EnableOpenTelemetry: false, ServiceName: "govisual", diff --git a/wrap.go b/wrap.go index 1939a40..3f77578 100644 --- a/wrap.go +++ b/wrap.go @@ -103,7 +103,7 @@ func Wrap(handler http.Handler, opts ...Option) http.Handler { }) // Create middleware wrapper - wrapped := middleware.Wrap(handler, requestStore, config.LogRequestBody, config.LogResponseBody, config) + wrapped := middleware.Wrap(handler, requestStore, config, config) // Initialize OpenTelemetry if enabled if config.EnableOpenTelemetry {