diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..ca4cafc --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2025 SecNex + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. \ No newline at end of file diff --git a/README.md b/README.md index e848813..31f49f5 100644 --- a/README.md +++ b/README.md @@ -1,2 +1,582 @@ # MasterLog - Logging Library for Golang +A powerful, extensible, and zero-dependency logging library for Go with built-in support for structured logging, colored output, and data pseudonymization. + +## Features + +- 🎨 **Colored Console Output** - Beautiful, syntax-highlighted logs with automatic terminal detection +- 📝 **Structured Logging** - Support for custom fields and structured data +- 🔒 **Data Pseudonymization** - Built-in HMAC-based deterministic pseudonymization for sensitive data +- 📊 **Multiple Encoders** - Formatted text and JSON encoding out of the box +- 📁 **Flexible Writers** - Console and file writers with easy extensibility +- 🎯 **Zero Dependencies** - Uses only Go standard library +- 🔧 **Extensible Architecture** - Easy to add custom encoders and writers +- ⚡ **Performance** - Efficient logging with minimal overhead +- 🎛️ **Configurable Log Levels** - Trace, Debug, Info, Warn, Error + +## Installation + +```bash +go get git.secnex.io/secnex/masterlog +``` + +## Quick Start + +```go +package main + +import ( + "git.secnex.io/secnex/masterlog" +) + +func main() { + // Simple logging + masterlog.Info("Hello, World!") + + // Logging with fields + masterlog.Info("User logged in", map[string]interface{}{ + "user_id": 12345, + "ip": "192.168.1.1", + }) +} +``` + +**Output:** +``` +2025-11-10T05:06:02+01:00 INF main.go:9 > Hello, World! go_version=go1.25.3 pid=12345 +2025-11-10T05:06:02+01:00 INF main.go:12 > User logged in user_id=12345 ip=192.168.1.1 go_version=go1.25.3 pid=12345 +``` + +## Table of Contents + +- [Log Levels](#log-levels) +- [Structured Logging](#structured-logging) +- [Colored Output](#colored-output) +- [Encoders](#encoders) +- [Writers](#writers) +- [Custom Logger Instances](#custom-logger-instances) +- [Data Pseudonymization](#data-pseudonymization) +- [Extending MasterLog](#extending-masterlog) +- [API Reference](#api-reference) +- [Best Practices](#best-practices) + +## Log Levels + +MasterLog supports five log levels: + +- `TRC` (Trace) - Most verbose, for detailed debugging +- `DBG` (Debug) - Debug information +- `INF` (Info) - General informational messages +- `WRN` (Warn) - Warning messages +- `ERR` (Error) - Error messages + +### Setting Log Level + +```go +// Set level for default logger +masterlog.SetLevel(masterlog.LevelDebug) + +// Create logger with specific level +logger := masterlog.New(masterlog.LevelTrace) +logger.Trace("This will be logged") +logger.Debug("This will also be logged") +``` + +### Logging Methods + +```go +masterlog.Trace("trace message") +masterlog.Debug("debug message") +masterlog.Info("info message") +masterlog.Warn("warning message") +masterlog.Error("error message") +``` + +## Structured Logging + +MasterLog supports structured logging with custom fields. Custom fields are displayed before default fields (go_version, pid). + +```go +masterlog.Info("User action", map[string]interface{}{ + "user_id": 12345, + "action": "purchase", + "amount": 99.99, + "ip": "192.168.1.1", +}) +``` + +**Output:** +``` +2025-11-10T05:06:02+01:00 INF main.go:12 > User action user_id=12345 action=purchase amount=99.99 ip=192.168.1.1 go_version=go1.25.3 pid=12345 +``` + +### Field Ordering + +Custom fields (user-provided) are always displayed before default fields: +1. Custom fields (user-provided) +2. Default fields (go_version, pid) + +## Colored Output + +MasterLog automatically detects if output is going to a terminal and applies colors accordingly. Colors are never written to files. + +### Color Scheme + +- **Timestamp**: Dark gray +- **Log Level**: + - `TRC`: Gray + - `DBG`: Cyan + - `INF`: Green + - `WRN`: Yellow + - `ERR`: Red +- **File:Line**: Dark gray +- **Message**: White +- **Field Keys**: Turquoise +- **Field Values**: White + +### Example Output + +When logging to a terminal, you'll see beautifully colored output. When redirecting to a file or non-terminal, colors are automatically disabled. + +```go +masterlog.Info("Colored output example", map[string]interface{}{ + "key": "value", +}) +``` + +## Encoders + +Encoders determine the format of log entries. MasterLog provides two built-in encoders: + +### FormattedEncoder + +The default encoder that produces human-readable formatted text. + +```go +logger := masterlog.New() +// FormattedEncoder is used by default +logger.Info("Formatted log") +``` + +**Output:** +``` +2025-11-10T05:06:02+01:00 INF main.go:12 > Formatted log go_version=go1.25.3 pid=12345 +``` + +### JSONEncoder + +Produces JSON-formatted log entries, perfect for log aggregation systems. + +```go +logger := masterlog.New() +logger.AddEncoder(&masterlog.JSONEncoder{}) +logger.Info("JSON log", map[string]interface{}{ + "key": "value", +}) +``` + +**Output:** +```json +{"timestamp":"2025-11-10T05:06:02+01:00","level":"INF","file":"main.go","line":12,"message":"JSON log","fields":{"key":"value","go_version":"go1.25.3","pid":12345}} +``` + +**Note:** JSON encoder output is automatically excluded from console output when using the default logger to keep console logs readable. + +### Multiple Encoders + +You can use multiple encoders simultaneously: + +```go +logger := masterlog.New() +logger.AddEncoder(&masterlog.JSONEncoder{}) +// Now logs will be written in both formatted and JSON formats +logger.Info("Dual format log") +``` + +## Writers + +Writers determine where log entries are written. MasterLog provides two built-in writers: + +### ConsoleWriter + +Writes to stdout (default). Automatically detects if output is a terminal. + +```go +logger := masterlog.New() +// ConsoleWriter is used by default +logger.Info("Console log") +``` + +### FileWriter + +Writes log entries to a file. + +```go +logger := masterlog.New() +fileWriter, err := masterlog.NewFileWriter("app.log") +if err != nil { + log.Fatal(err) +} +defer fileWriter.Close() + +logger.AddWriter(fileWriter) +logger.Info("File log") +``` + +### Multiple Writers + +You can write to multiple destinations simultaneously: + +```go +logger := masterlog.New() + +// Add file writer +fileWriter, _ := masterlog.NewFileWriter("app.log") +defer fileWriter.Close() +logger.AddWriter(fileWriter) + +// Logs will be written to both console and file +logger.Info("Multi-destination log") +``` + +## Custom Logger Instances + +Create custom logger instances with specific configurations: + +```go +// Create logger with custom level +logger := masterlog.New(masterlog.LevelDebug) + +// Add JSON encoder +logger.AddEncoder(&masterlog.JSONEncoder{}) + +// Add file writer +fileWriter, _ := masterlog.NewFileWriter("custom.log") +defer fileWriter.Close() +logger.AddWriter(fileWriter) + +logger.Info("Custom logger example") +``` + +## Data Pseudonymization + +MasterLog includes built-in support for deterministic pseudonymization of sensitive data using HMAC-SHA256. This allows you to: + +- Protect sensitive information in logs +- Maintain traceability (same input always produces same pseudonymized output) +- Comply with data protection regulations + +### Basic Usage + +```go +// Create pseudonymizer with secret +pseudonymizer := masterlog.NewPseudonymizerFromString("your-secret-key") +logger := masterlog.New() +logger.SetPseudonymizer(pseudonymizer) + +// Mark fields as sensitive +logger.AddSensitiveFields("user_id", "email", "ip", "ssn") + +// Log with sensitive data +logger.Info("User logged in", map[string]interface{}{ + "user_id": 12345, // Will be pseudonymized + "email": "user@ex.com", // Will be pseudonymized + "ip": "192.168.1.1", // Will be pseudonymized + "action": "login", // Not sensitive, remains unchanged +}) +``` + +**Output:** +``` +2025-11-10T05:06:02+01:00 INF main.go:15 > User logged in user_id=a1b2c3d4 email=e5f6g7h8 ip=i9j0k1l2 action=login go_version=go1.25.3 pid=12345 +``` + +### Deterministic Behavior + +The same input always produces the same pseudonymized output, allowing you to trace the same entity across multiple log entries: + +```go +logger.Info("User action 1", map[string]interface{}{ + "user_id": 12345, // Pseudonymized to: a1b2c3d4 +}) + +logger.Info("User action 2", map[string]interface{}{ + "user_id": 12345, // Pseudonymized to: a1b2c3d4 (same as above) +}) +``` + +### Configuration Options + +#### Using Environment Variables + +For production, use environment variables to store the secret: + +```go +// Read secret from environment variable +pseudonymizer := masterlog.NewPseudonymizerFromEnv("LOG_PSEUDONYMIZER_SECRET") +logger.SetPseudonymizer(pseudonymizer) +``` + +#### Custom Hash Length + +Adjust the length of pseudonymized values (default: 8 hex characters): + +```go +pseudonymizer := masterlog.NewPseudonymizerFromString("secret") +pseudonymizer.SetHashLength(16) // Use 16 hex characters instead of 8 +``` + +#### Managing Sensitive Fields + +```go +// Add single field +logger.AddSensitiveField("user_id") + +// Add multiple fields +logger.AddSensitiveFields("email", "ip", "ssn", "phone") + +// Remove field (if needed) +pseudonymizer := masterlog.NewPseudonymizerFromString("secret") +pseudonymizer.RemoveSensitiveField("phone") +``` + +### Security Considerations + +1. **Secret Management**: Store the pseudonymization secret securely (environment variables, secrets manager) +2. **Secret Rotation**: Changing the secret will produce different pseudonymized values +3. **Hash Length**: Longer hashes provide better uniqueness but are less readable +4. **Field Selection**: Only mark truly sensitive fields to maintain log usefulness + +## Extending MasterLog + +MasterLog is designed to be easily extensible. You can create custom encoders and writers. + +### Custom Encoder + +```go +type CustomEncoder struct{} + +func (e *CustomEncoder) Encode(entry masterlog.Entry) ([]byte, error) { + // Your custom encoding logic + return []byte("custom format"), nil +} + +// Usage +logger := masterlog.New() +logger.AddEncoder(&CustomEncoder{}) +``` + +### Custom Writer + +```go +type CustomWriter struct { + // Your writer fields +} + +func (w *CustomWriter) Write(data []byte) error { + // Your custom writing logic + return nil +} + +func (w *CustomWriter) Close() error { + // Cleanup logic + return nil +} + +func (w *CustomWriter) SupportsColors() bool { + return false // or true if your writer supports colors +} + +// Usage +logger := masterlog.New() +logger.AddWriter(&CustomWriter{}) +``` + +## API Reference + +### Package-Level Functions + +#### Logging Functions + +```go +func Trace(message string, fields ...map[string]interface{}) +func Debug(message string, fields ...map[string]interface{}) +func Info(message string, fields ...map[string]interface{}) +func Warn(message string, fields ...map[string]interface{}) +func Error(message string, fields ...map[string]interface{}) +``` + +#### Configuration Functions + +```go +func SetLevel(level Level) +func AddWriter(writer Writer) +func AddEncoder(encoder Encoder) +func SetPseudonymizer(pseudonymizer *Pseudonymizer) +func AddSensitiveField(fieldName string) +func AddSensitiveFields(fieldNames ...string) +``` + +### Logger Methods + +```go +type MasterLogger struct { + // ... +} + +func New(level ...Level) *MasterLogger +func (l *MasterLogger) SetLevel(level Level) +func (l *MasterLogger) AddWriter(writer Writer) +func (l *MasterLogger) AddEncoder(encoder Encoder) +func (l *MasterLogger) SetPseudonymizer(pseudonymizer *Pseudonymizer) +func (l *MasterLogger) AddSensitiveField(fieldName string) +func (l *MasterLogger) AddSensitiveFields(fieldNames ...string) +func (l *MasterLogger) Trace(message string, fields ...map[string]interface{}) +func (l *MasterLogger) Debug(message string, fields ...map[string]interface{}) +func (l *MasterLogger) Info(message string, fields ...map[string]interface{}) +func (l *MasterLogger) Warn(message string, fields ...map[string]interface{}) +func (l *MasterLogger) Error(message string, fields ...map[string]interface{}) +``` + +### Pseudonymizer + +```go +type Pseudonymizer struct { + // ... +} + +func NewPseudonymizer(secret []byte) *Pseudonymizer +func NewPseudonymizerFromString(secret string) *Pseudonymizer +func NewPseudonymizerFromEnv(envVar string) *Pseudonymizer +func (p *Pseudonymizer) SetHashLength(length int) error +func (p *Pseudonymizer) AddSensitiveField(fieldName string) +func (p *Pseudonymizer) AddSensitiveFields(fieldNames ...string) +func (p *Pseudonymizer) RemoveSensitiveField(fieldName string) +func (p *Pseudonymizer) IsSensitive(fieldName string) bool +func (p *Pseudonymizer) Pseudonymize(value interface{}) string +func (p *Pseudonymizer) PseudonymizeFields(fields map[string]interface{}) map[string]interface{} +``` + +### Writers + +```go +func NewConsoleWriter() *ConsoleWriter +func NewFileWriter(filename string) (*FileWriter, error) +``` + +### Encoders + +```go +func NewFormattedEncoder(useColors bool) *FormattedEncoder +// JSONEncoder has no constructor, use &masterlog.JSONEncoder{} +``` + +## Best Practices + +### 1. Use Structured Logging + +Always use structured fields instead of string concatenation: + +```go +// ❌ Bad +masterlog.Info(fmt.Sprintf("User %d logged in from %s", userID, ip)) + +// ✅ Good +masterlog.Info("User logged in", map[string]interface{}{ + "user_id": userID, + "ip": ip, +}) +``` + +### 2. Set Appropriate Log Levels + +Use log levels appropriately: + +- `TRC`: Detailed trace information (usually disabled in production) +- `DBG`: Debug information (disabled in production) +- `INF`: General information about application flow +- `WRN`: Warning conditions that don't stop execution +- `ERR`: Error conditions that need attention + +### 3. Pseudonymize Sensitive Data + +Always pseudonymize sensitive information: + +```go +logger.AddSensitiveFields("user_id", "email", "ip", "ssn", "credit_card") +``` + +### 4. Use Environment Variables for Secrets + +Never hardcode secrets: + +```go +// ❌ Bad +pseudonymizer := masterlog.NewPseudonymizerFromString("hardcoded-secret") + +// ✅ Good +pseudonymizer := masterlog.NewPseudonymizerFromEnv("LOG_PSEUDONYMIZER_SECRET") +``` + +### 5. Separate Logs by Environment + +Use different configurations for development and production: + +```go +logger := masterlog.New() + +if os.Getenv("ENV") == "production" { + // Production: JSON to file, no colors + logger.AddEncoder(&masterlog.JSONEncoder{}) + fileWriter, _ := masterlog.NewFileWriter("/var/log/app.log") + logger.AddWriter(fileWriter) + + // Enable pseudonymization + pseudonymizer := masterlog.NewPseudonymizerFromEnv("LOG_SECRET") + logger.SetPseudonymizer(pseudonymizer) + logger.AddSensitiveFields("user_id", "email", "ip") +} else { + // Development: Formatted output with colors + logger.SetLevel(masterlog.LevelDebug) +} +``` + +### 6. Close File Writers + +Always close file writers when done: + +```go +fileWriter, err := masterlog.NewFileWriter("app.log") +if err != nil { + return err +} +defer fileWriter.Close() +``` + +### 7. Use Custom Logger Instances + +Create separate logger instances for different components: + +```go +var ( + apiLogger = masterlog.New(masterlog.LevelInfo) + dbLogger = masterlog.New(masterlog.LevelDebug) + authLogger = masterlog.New(masterlog.LevelWarn) +) +``` + +## Examples + +See the [example](example/) directory for complete working examples. + +## License + +This project is licensed under the MIT License - see the [LICENSE](LICENSE) file for details. + +## Contributing + +Contributions are welcome! Please feel free to submit a pull request. + +## Support + +Please feel free to open an issue if you have any questions or suggestions. You can also send an email to support@secnex.io. diff --git a/colors.go b/colors.go new file mode 100644 index 0000000..890f664 --- /dev/null +++ b/colors.go @@ -0,0 +1,22 @@ +package masterlog + +// ANSI color codes +const ( + colorReset = "\033[0m" + colorGray = "\033[90m" + colorLightGray = "\033[90m" // Dark gray for timestamp (dimmer) + colorCyan = "\033[36m" + colorTurquoise = "\033[36m" // Turquoise for field keys + colorGreen = "\033[32m" + colorYellow = "\033[33m" + colorRed = "\033[31m" + colorWhite = "\033[37m" // White for field values +) + +var levelColors = map[Level]string{ + LevelTrace: colorGray, + LevelDebug: colorCyan, + LevelInfo: colorGreen, + LevelWarn: colorYellow, + LevelError: colorRed, +} diff --git a/encoder.go b/encoder.go new file mode 100644 index 0000000..7fadc5c --- /dev/null +++ b/encoder.go @@ -0,0 +1,105 @@ +package masterlog + +import ( + "encoding/json" + "fmt" + "time" +) + +// getShortFile extracts the filename from a full path +func getShortFile(file string) string { + for i := len(file) - 1; i >= 0; i-- { + if file[i] == '/' { + return file[i+1:] + } + } + return file +} + +// FormattedEncoder encodes entries in the formatted text format +type FormattedEncoder struct { + useColors bool +} + +// NewFormattedEncoder creates a new FormattedEncoder +func NewFormattedEncoder(useColors bool) *FormattedEncoder { + return &FormattedEncoder{useColors: useColors} +} + +func (e *FormattedEncoder) Encode(entry Entry) ([]byte, error) { + // Format: 2022-09-01T10:05:03+01:00 TRC main.go:23 trace message go_version=go1.19 pid=2620043 + format := entry.Timestamp.Format(time.RFC3339) + levelStr := levelNames[entry.Level] + shortFile := getShortFile(entry.File) + + var result string + if e.useColors { + // Timestamp in light gray + timestampColor := colorLightGray + // Level in its specific color + levelColor := levelColors[entry.Level] + // File:line in light gray + fileColor := colorLightGray + // Separator in light gray + separator := ">" + // Message in white + messageColor := colorWhite + + result = fmt.Sprintf("%s%s%s %s%s%s %s%s%s:%d %s%s%s %s%s%s", + timestampColor, format, colorReset, + levelColor, levelStr, colorReset, + fileColor, shortFile, colorReset, entry.Line, + fileColor, separator, colorReset, + messageColor, entry.Message, colorReset) + } else { + result = fmt.Sprintf("%s %s %s:%d > %s", format, levelStr, shortFile, entry.Line, entry.Message) + } + + // Add fields - first custom fields, then default fields + // Custom fields (user-provided) + for key, value := range entry.CustomFields { + if e.useColors { + // Field key in turquoise, value in white + result += fmt.Sprintf(" %s%s%s=%s%v%s", colorTurquoise, key, colorReset, colorWhite, value, colorReset) + } else { + result += fmt.Sprintf(" %s=%v", key, value) + } + } + // Default fields (go_version, pid) + for key, value := range entry.DefaultFields { + if e.useColors { + // Field key in turquoise, value in white + result += fmt.Sprintf(" %s%s%s=%s%v%s", colorTurquoise, key, colorReset, colorWhite, value, colorReset) + } else { + result += fmt.Sprintf(" %s=%v", key, value) + } + } + + result += "\n" + return []byte(result), nil +} + +// JSONEncoder encodes entries in JSON format +type JSONEncoder struct{} + +func (e *JSONEncoder) Encode(entry Entry) ([]byte, error) { + type LogEntry struct { + Timestamp string `json:"timestamp"` + Level string `json:"level"` + File string `json:"file"` + Line int `json:"line"` + Message string `json:"message"` + Fields map[string]interface{} `json:"fields,omitempty"` + } + + logEntry := LogEntry{ + Timestamp: entry.Timestamp.Format(time.RFC3339), + Level: levelNames[entry.Level], + File: getShortFile(entry.File), + Line: entry.Line, + Message: entry.Message, + Fields: entry.Fields, + } + + return json.Marshal(logEntry) +} diff --git a/example/app.log b/example/app.log new file mode 100644 index 0000000..bd79ad7 --- /dev/null +++ b/example/app.log @@ -0,0 +1,10 @@ +2025-11-10T05:00:34+01:00 INF proc.go:285 > This will be written to file go_version=go1.25.3 pid=71020 +2025-11-10T05:01:36+01:00 INF proc.go:285 > This will be written to file go_version=go1.25.3 pid=71657 +2025-11-10T05:01:59+01:00 INF proc.go:285 > This will be written to file go_version=go1.25.3 pid=72132 +2025-11-10T05:02:14+01:00 INF proc.go:285 > This will be written to file go_version=go1.25.3 pid=72401 +2025-11-10T05:05:14+01:00 INF main.go:41 > This will be written to file go_version=go1.25.3 pid=73856 +2025-11-10T05:06:02+01:00 INF main.go:41 > This will be written to file go_version=go1.25.3 pid=74313 +2025-11-10T05:07:19+01:00 INF main.go:41 > This will be written to file go_version=go1.25.3 pid=75031 +2025-11-10T05:09:48+01:00 INF main.go:41 > This will be written to file go_version=go1.25.3 pid=76511 +2025-11-10T05:15:16+01:00 INF main.go:41 > This will be written to file go_version=go1.25.3 pid=79210 +2025-11-10T05:15:26+01:00 INF main.go:41 > This will be written to file go_version=go1.25.3 pid=79455 diff --git a/example/go.mod b/example/go.mod new file mode 100644 index 0000000..1289599 --- /dev/null +++ b/example/go.mod @@ -0,0 +1,7 @@ +module example + +go 1.25.3 + +replace git.secnex.io/secnex/masterlog => ../ + +require git.secnex.io/secnex/masterlog v0.0.0-00010101000000-000000000000 diff --git a/example/main.go b/example/main.go new file mode 100644 index 0000000..2f8b602 --- /dev/null +++ b/example/main.go @@ -0,0 +1,83 @@ +package main + +import ( + "git.secnex.io/secnex/masterlog" +) + +func main() { + // Example 1: Simple formatted logging to console (with colors if terminal) + masterlog.Info("Hello, World!") + + // Example 2: Logging with fields + masterlog.Info("User logged in", map[string]interface{}{ + "user_id": 12345, + "ip": "192.168.1.1", + }) + + // Example 3: Different log levels (colored output) + masterlog.Trace("trace message") + masterlog.Debug("debug message") + masterlog.Info("info message") + masterlog.Warn("warning message") + masterlog.Error("error message") + + // Example 4: Create logger with specific log level + traceLogger := masterlog.New(masterlog.LevelTrace) + traceLogger.Info("Logger created with LevelTrace") + + // Example 5: Create custom logger with JSON encoder + jsonLogger := masterlog.New() + jsonLogger.AddEncoder(&masterlog.JSONEncoder{}) + jsonLogger.Info("JSON formatted log", map[string]interface{}{ + "key": "value", + }) + + // Example 6: Create logger with file writer + fileLogger := masterlog.New() + fileWriter, err := masterlog.NewFileWriter("app.log") + if err == nil { + fileLogger.AddWriter(fileWriter) + defer fileWriter.Close() + fileLogger.Info("This will be written to file") + } + + // Example 7: Logger with multiple encoders (formatted + JSON) + multiLogger := masterlog.New() + multiLogger.AddEncoder(&masterlog.JSONEncoder{}) + multiLogger.Info("This will be logged in both formats", map[string]interface{}{ + "key": "value", + }) + + // Example 8: Set log level dynamically + masterlog.SetLevel(masterlog.LevelDebug) + masterlog.Trace("This won't be logged (level too low)") + masterlog.Debug("This will be logged") + + // Example 9: Create logger with custom level and colored output + debugLogger := masterlog.New(masterlog.LevelDebug) + debugLogger.Debug("Debug logger with colors") + debugLogger.Trace("This won't be logged") + + // Example 10: Pseudonymization of sensitive data + pseudoLogger := masterlog.New() + // Create a pseudonymizer with a secret (in production, use a secure secret from config/env) + pseudonymizer := masterlog.NewPseudonymizerFromString("my-secret-key-change-in-production") + pseudoLogger.SetPseudonymizer(pseudonymizer) + + // Mark fields as sensitive + pseudoLogger.AddSensitiveFields("user_id", "email", "ip") + + // Log with sensitive data - it will be pseudonymized + pseudoLogger.Info("User logged in", map[string]interface{}{ + "user_id": 12345, + "email": "user@example.com", + "ip": "192.168.1.1", + "action": "login", // This field is not sensitive, so it won't be pseudonymized + }) + + // Same user_id will produce the same pseudonymized value (deterministic) + pseudoLogger.Info("User performed action", map[string]interface{}{ + "user_id": 12345, // Same value, same pseudonymized output + "action": "purchase", + }) +} diff --git a/level.go b/level.go new file mode 100644 index 0000000..b3cb854 --- /dev/null +++ b/level.go @@ -0,0 +1,9 @@ +package masterlog + +var levelNames = map[Level]string{ + LevelTrace: "TRC", + LevelDebug: "DBG", + LevelInfo: "INF", + LevelWarn: "WRN", + LevelError: "ERR", +} diff --git a/logger.go b/logger.go new file mode 100644 index 0000000..9ebc261 --- /dev/null +++ b/logger.go @@ -0,0 +1,237 @@ +package masterlog + +import ( + "os" + "runtime" + "time" +) + +// MasterLogger is the main logger implementation +type MasterLogger struct { + level Level + encoders []Encoder + writers []Writer + pseudonymizer *Pseudonymizer +} + +// New creates a new logger with default console writer and formatted encoder +// If level is provided, it will be used; otherwise LevelInfo is the default +func New(level ...Level) *MasterLogger { + logLevel := LevelInfo + if len(level) > 0 { + logLevel = level[0] + } + + consoleWriter := NewConsoleWriter() + useColors := consoleWriter.isTerminal + + logger := &MasterLogger{ + level: logLevel, + encoders: []Encoder{NewFormattedEncoder(useColors)}, + writers: []Writer{consoleWriter}, + } + + return logger +} + +// SetLevel sets the minimum log level +func (l *MasterLogger) SetLevel(level Level) { + l.level = level +} + +// AddWriter adds a writer to the logger +func (l *MasterLogger) AddWriter(writer Writer) { + l.writers = append(l.writers, writer) +} + +// AddEncoder adds an encoder to the logger +func (l *MasterLogger) AddEncoder(encoder Encoder) { + l.encoders = append(l.encoders, encoder) +} + +// SetPseudonymizer sets the pseudonymizer for the logger +func (l *MasterLogger) SetPseudonymizer(pseudonymizer *Pseudonymizer) { + l.pseudonymizer = pseudonymizer +} + +// AddSensitiveField marks a field name as sensitive, so it will be pseudonymized +func (l *MasterLogger) AddSensitiveField(fieldName string) { + if l.pseudonymizer != nil { + l.pseudonymizer.AddSensitiveField(fieldName) + } +} + +// AddSensitiveFields marks multiple field names as sensitive +func (l *MasterLogger) AddSensitiveFields(fieldNames ...string) { + if l.pseudonymizer != nil { + l.pseudonymizer.AddSensitiveFields(fieldNames...) + } +} + +// log writes a log entry +// If file and line are empty/zero, they will be determined from the call stack +func (l *MasterLogger) log(level Level, message string, file string, line int, fields ...map[string]interface{}) { + if level < l.level { + return + } + + // If file/line not provided, get from call stack + if file == "" && line == 0 { + // For instance methods: 0=Caller, 1=log(), 2=Info(), 3=user code + _, file, line, _ = runtime.Caller(3) + if file == "" { + file = "unknown" + } + } + + // Collect custom fields (user-provided) + customFields := make(map[string]interface{}) + for _, fieldMap := range fields { + for k, v := range fieldMap { + customFields[k] = v + } + } + + // Collect default fields + defaultFields := map[string]interface{}{ + "go_version": runtime.Version(), + "pid": os.Getpid(), + } + + // Apply pseudonymization if enabled + if l.pseudonymizer != nil { + customFields = l.pseudonymizer.PseudonymizeFields(customFields) + defaultFields = l.pseudonymizer.PseudonymizeFields(defaultFields) + } + + // Merge all fields for compatibility + allFields := make(map[string]interface{}) + for k, v := range customFields { + allFields[k] = v + } + for k, v := range defaultFields { + allFields[k] = v + } + + entry := Entry{ + Timestamp: time.Now(), + Level: level, + File: file, + Line: line, + Message: message, + Fields: allFields, + CustomFields: customFields, + DefaultFields: defaultFields, + } + + // Encode and write with each encoder/writer combination + // For formatted encoder, we need to encode differently for each writer (with/without colors) + for _, encoder := range l.encoders { + for _, writer := range l.writers { + // Skip JSON encoder for console writers (only use formatted encoder) + if _, isJSON := encoder.(*JSONEncoder); isJSON { + if _, isConsole := writer.(*ConsoleWriter); isConsole { + continue // Don't write JSON to console + } + } + + var data []byte + var err error + + // If it's a FormattedEncoder, check if writer supports colors + if _, ok := encoder.(*FormattedEncoder); ok { + // Create a temporary encoder with the right color setting + tempEncoder := &FormattedEncoder{useColors: writer.SupportsColors()} + data, err = tempEncoder.Encode(entry) + } else { + // For other encoders (JSON, etc.), use as-is + data, err = encoder.Encode(entry) + } + + if err != nil { + continue + } + + _ = writer.Write(data) + } + } +} + +func (l *MasterLogger) Trace(message string, fields ...map[string]interface{}) { + _, file, line, _ := runtime.Caller(1) + l.log(LevelTrace, message, file, line, fields...) +} + +func (l *MasterLogger) Debug(message string, fields ...map[string]interface{}) { + _, file, line, _ := runtime.Caller(1) + l.log(LevelDebug, message, file, line, fields...) +} + +func (l *MasterLogger) Info(message string, fields ...map[string]interface{}) { + _, file, line, _ := runtime.Caller(1) + l.log(LevelInfo, message, file, line, fields...) +} + +func (l *MasterLogger) Warn(message string, fields ...map[string]interface{}) { + _, file, line, _ := runtime.Caller(1) + l.log(LevelWarn, message, file, line, fields...) +} + +func (l *MasterLogger) Error(message string, fields ...map[string]interface{}) { + _, file, line, _ := runtime.Caller(1) + l.log(LevelError, message, file, line, fields...) +} + +// Default logger instance +var defaultLogger = New(LevelInfo) + +// Package-level convenience functions +// These collect caller info before calling the instance method +func Trace(message string, fields ...map[string]interface{}) { + _, file, line, _ := runtime.Caller(1) + defaultLogger.log(LevelTrace, message, file, line, fields...) +} + +func Debug(message string, fields ...map[string]interface{}) { + _, file, line, _ := runtime.Caller(1) + defaultLogger.log(LevelDebug, message, file, line, fields...) +} + +func Info(message string, fields ...map[string]interface{}) { + _, file, line, _ := runtime.Caller(1) + defaultLogger.log(LevelInfo, message, file, line, fields...) +} + +func Warn(message string, fields ...map[string]interface{}) { + _, file, line, _ := runtime.Caller(1) + defaultLogger.log(LevelWarn, message, file, line, fields...) +} + +func Error(message string, fields ...map[string]interface{}) { + _, file, line, _ := runtime.Caller(1) + defaultLogger.log(LevelError, message, file, line, fields...) +} + +func SetLevel(level Level) { + defaultLogger.SetLevel(level) +} + +func AddWriter(writer Writer) { + defaultLogger.AddWriter(writer) +} + +func AddEncoder(encoder Encoder) { + defaultLogger.AddEncoder(encoder) +} + +func SetPseudonymizer(pseudonymizer *Pseudonymizer) { + defaultLogger.SetPseudonymizer(pseudonymizer) +} + +func AddSensitiveField(fieldName string) { + defaultLogger.AddSensitiveField(fieldName) +} + +func AddSensitiveFields(fieldNames ...string) { + defaultLogger.AddSensitiveFields(fieldNames...) +} diff --git a/pseudonymizer.go b/pseudonymizer.go new file mode 100644 index 0000000..781499c --- /dev/null +++ b/pseudonymizer.go @@ -0,0 +1,133 @@ +package masterlog + +import ( + "crypto/hmac" + "crypto/sha256" + "encoding/hex" + "fmt" + "os" + "sync" +) + +// Pseudonymizer handles deterministic pseudonymization of sensitive data +type Pseudonymizer struct { + secret []byte + mu sync.RWMutex + // sensitiveFields is a set of field names that should be pseudonymized + sensitiveFields map[string]bool + // hashLength is the length of the pseudonymized value (in hex characters) + hashLength int +} + +// NewPseudonymizer creates a new pseudonymizer with the given secret +// The secret should be kept secure and consistent across application restarts +// to ensure deterministic pseudonymization +func NewPseudonymizer(secret []byte) *Pseudonymizer { + return &Pseudonymizer{ + secret: secret, + sensitiveFields: make(map[string]bool), + hashLength: 8, // Default: 8 hex characters (4 bytes) + } +} + +// NewPseudonymizerFromString creates a new pseudonymizer from a string secret +func NewPseudonymizerFromString(secret string) *Pseudonymizer { + return NewPseudonymizer([]byte(secret)) +} + +func NewPseudonymizerFromEnv(envVar string) *Pseudonymizer { + secret := os.Getenv(envVar) + if secret == "" { + panic(fmt.Errorf("secret not found in environment variable %s", envVar)) + } + return NewPseudonymizerFromString(secret) +} + +// SetHashLength sets the length of the pseudonymized hash (in hex characters) +// Default is 8. Must be between 4 and 64 (max SHA256 hex length) +func (p *Pseudonymizer) SetHashLength(length int) error { + if length < 4 || length > 64 { + return fmt.Errorf("hash length must be between 4 and 64, got %d", length) + } + p.mu.Lock() + defer p.mu.Unlock() + p.hashLength = length + return nil +} + +// AddSensitiveField marks a field name as sensitive, so it will be pseudonymized +func (p *Pseudonymizer) AddSensitiveField(fieldName string) { + p.mu.Lock() + defer p.mu.Unlock() + p.sensitiveFields[fieldName] = true +} + +// AddSensitiveFields marks multiple field names as sensitive +func (p *Pseudonymizer) AddSensitiveFields(fieldNames ...string) { + p.mu.Lock() + defer p.mu.Unlock() + for _, name := range fieldNames { + p.sensitiveFields[name] = true + } +} + +// RemoveSensitiveField removes a field from the sensitive fields list +func (p *Pseudonymizer) RemoveSensitiveField(fieldName string) { + p.mu.Lock() + defer p.mu.Unlock() + delete(p.sensitiveFields, fieldName) +} + +// IsSensitive checks if a field name is marked as sensitive +func (p *Pseudonymizer) IsSensitive(fieldName string) bool { + p.mu.RLock() + defer p.mu.RUnlock() + return p.sensitiveFields[fieldName] +} + +// Pseudonymize deterministically pseudonymizes a value using HMAC-SHA256 +// The same input will always produce the same output (deterministic) +func (p *Pseudonymizer) Pseudonymize(value interface{}) string { + p.mu.RLock() + hashLength := p.hashLength + secret := p.secret + p.mu.RUnlock() + + // Convert value to string + valueStr := fmt.Sprintf("%v", value) + + // Create HMAC-SHA256 hash + h := hmac.New(sha256.New, secret) + h.Write([]byte(valueStr)) + hash := h.Sum(nil) + + // Convert to hex and truncate to desired length + hexHash := hex.EncodeToString(hash) + if len(hexHash) > hashLength { + hexHash = hexHash[:hashLength] + } + + return hexHash +} + +// PseudonymizeFields applies pseudonymization to sensitive fields in a map +func (p *Pseudonymizer) PseudonymizeFields(fields map[string]interface{}) map[string]interface{} { + p.mu.RLock() + sensitiveFields := make(map[string]bool) + for k, v := range p.sensitiveFields { + sensitiveFields[k] = v + } + p.mu.RUnlock() + + result := make(map[string]interface{}) + for key, value := range fields { + if sensitiveFields[key] { + // Pseudonymize sensitive fields + result[key] = p.Pseudonymize(value) + } else { + // Keep non-sensitive fields as-is + result[key] = value + } + } + return result +} diff --git a/types.go b/types.go new file mode 100644 index 0000000..ca51485 --- /dev/null +++ b/types.go @@ -0,0 +1,53 @@ +package masterlog + +import "time" + +// Level represents the log level +type Level int + +const ( + LevelTrace Level = iota + LevelDebug + LevelInfo + LevelWarn + LevelError +) + +// Entry represents a log entry +type Entry struct { + Timestamp time.Time + Level Level + File string + Line int + Message string + Fields map[string]interface{} // All fields + CustomFields map[string]interface{} // User-provided fields (to be shown first) + DefaultFields map[string]interface{} // Default fields (go_version, pid) +} + +// Encoder encodes log entries to a specific format +type Encoder interface { + Encode(entry Entry) ([]byte, error) +} + +// Writer writes encoded log entries +type Writer interface { + Write(data []byte) error + Close() error + SupportsColors() bool // Returns true if the writer supports ANSI color codes +} + +// Logger is the main logger interface +type Logger interface { + Trace(message string, fields ...map[string]interface{}) + Debug(message string, fields ...map[string]interface{}) + Info(message string, fields ...map[string]interface{}) + Warn(message string, fields ...map[string]interface{}) + Error(message string, fields ...map[string]interface{}) + SetLevel(level Level) + AddWriter(writer Writer) + AddEncoder(encoder Encoder) + SetPseudonymizer(pseudonymizer *Pseudonymizer) + AddSensitiveField(fieldName string) + AddSensitiveFields(fieldNames ...string) +} diff --git a/writer.go b/writer.go new file mode 100644 index 0000000..3421ce9 --- /dev/null +++ b/writer.go @@ -0,0 +1,84 @@ +package masterlog + +import ( + "io" + "os" +) + +// isTerminal checks if the writer is a terminal (TTY) +func isTerminal(w io.Writer) bool { + file, ok := w.(*os.File) + if !ok { + return false + } + + // Check if it's a terminal by trying to get file info + stat, err := file.Stat() + if err != nil { + return false + } + + // Check if it's a character device (terminal) + return (stat.Mode() & os.ModeCharDevice) != 0 +} + +// ConsoleWriter writes to stdout +type ConsoleWriter struct { + writer io.Writer + isTerminal bool +} + +func NewConsoleWriter() *ConsoleWriter { + writer := os.Stdout + return &ConsoleWriter{ + writer: writer, + isTerminal: isTerminal(writer), + } +} + +func (w *ConsoleWriter) Write(data []byte) error { + _, err := w.writer.Write(data) + return err +} + +func (w *ConsoleWriter) Close() error { + return nil +} + +func (w *ConsoleWriter) SupportsColors() bool { + return w.isTerminal +} + +// FileWriter writes to a file +type FileWriter struct { + file *os.File + writer io.Writer +} + +func NewFileWriter(filename string) (*FileWriter, error) { + file, err := os.OpenFile(filename, os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0644) + if err != nil { + return nil, err + } + + return &FileWriter{ + file: file, + writer: file, + }, nil +} + +func (w *FileWriter) Write(data []byte) error { + _, err := w.writer.Write(data) + return err +} + +func (w *FileWriter) Close() error { + if w.file != nil { + return w.file.Close() + } + return nil +} + +func (w *FileWriter) SupportsColors() bool { + return false // File writers never support colors +}