feat(log): Formatted console logging with file output

This commit is contained in:
Björn Benouarets
2025-11-10 05:20:25 +01:00
parent 3e78775b0f
commit 7b3c176d20
12 changed files with 1344 additions and 0 deletions

21
LICENSE Normal file
View File

@@ -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.

580
README.md
View File

@@ -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.

22
colors.go Normal file
View File

@@ -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,
}

105
encoder.go Normal file
View File

@@ -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)
}

10
example/app.log Normal file
View File

@@ -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

7
example/go.mod Normal file
View File

@@ -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

83
example/main.go Normal file
View File

@@ -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",
})
}

9
level.go Normal file
View File

@@ -0,0 +1,9 @@
package masterlog
var levelNames = map[Level]string{
LevelTrace: "TRC",
LevelDebug: "DBG",
LevelInfo: "INF",
LevelWarn: "WRN",
LevelError: "ERR",
}

237
logger.go Normal file
View File

@@ -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...)
}

133
pseudonymizer.go Normal file
View File

@@ -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
}

53
types.go Normal file
View File

@@ -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)
}

84
writer.go Normal file
View File

@@ -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
}