v0.1.2 update readme and config, failed attempt to fix slow client

This commit is contained in:
2025-07-01 16:34:19 -04:00
parent bd13103a81
commit a3450a9589
6 changed files with 386 additions and 182 deletions

265
README.md
View File

@ -1,3 +1,7 @@
<p align="center">
<img src="assets/logo.svg" alt="LogWisp Logo" width="200"/>
</p>
# LogWisp - Simple Log Streaming # LogWisp - Simple Log Streaming
A lightweight log streaming service that monitors files and streams updates via Server-Sent Events (SSE). A lightweight log streaming service that monitors files and streams updates via Server-Sent Events (SSE).
@ -16,6 +20,7 @@ LogWisp follows the Unix philosophy: do one thing and do it well. It monitors lo
- Environment variable support - Environment variable support
- Simple TOML configuration - Simple TOML configuration
- Atomic configuration management - Atomic configuration management
- Optional ANSI color pass-through
## Quick Start ## Quick Start
@ -34,13 +39,61 @@ LogWisp follows the Unix philosophy: do one thing and do it well. It monitors lo
curl -N http://localhost:8080/stream curl -N http://localhost:8080/stream
``` ```
## Command Line Options
```bash
logwisp [OPTIONS] [TARGET...]
OPTIONS:
-c, --color Enable color pass-through for ANSI escape codes
--config FILE Config file path (default: ~/.config/logwisp.toml)
--port PORT HTTP port (default: 8080)
--buffer-size SIZE Stream buffer size (default: 1000)
--check-interval MS File check interval in ms (default: 100)
--rate-limit Enable rate limiting
--rate-requests N Rate limit requests/sec (default: 10)
--rate-burst N Rate limit burst size (default: 20)
TARGET:
path[:pattern[:isfile]] Path to monitor (file or directory)
pattern: glob pattern for directories (default: *.log)
isfile: true/false (auto-detected if omitted)
EXAMPLES:
# Monitor current directory for *.log files
logwisp
# Monitor specific file with color support
logwisp -c /var/log/app.log
# Monitor multiple locations
logwisp /var/log:*.log /app/logs:error*.log:false /tmp/debug.log::true
# Custom port with rate limiting
logwisp --port 9090 --rate-limit --rate-requests 100 --rate-burst 200
```
## Configuration ## Configuration
LogWisp uses a three-level configuration hierarchy: LogWisp uses a three-level configuration hierarchy:
1. **Environment variables** (highest priority) 1. **Command-line arguments** (highest priority)
2. **Configuration file** (~/.config/logwisp.toml) 2. **Environment variables**
3. **Default values** (lowest priority) 3. **Configuration file** (~/.config/logwisp.toml)
4. **Default values** (lowest priority)
### Default Values
| Setting | Default | Description |
|---------|---------|-------------|
| `port` | 8080 | HTTP listen port |
| `monitor.check_interval_ms` | 100 | File check interval (milliseconds) |
| `monitor.targets` | [{"path": "./", "pattern": "*.log", "is_file": false}] | Paths to monitor |
| `stream.buffer_size` | 1000 | Per-client event buffer size |
| `stream.rate_limit.enabled` | false | Enable rate limiting |
| `stream.rate_limit.requests_per_second` | 10 | Sustained request rate |
| `stream.rate_limit.burst_size` | 20 | Maximum burst size |
| `stream.rate_limit.cleanup_interval_s` | 60 | Client cleanup interval |
### Configuration File Location ### Configuration File Location
@ -75,7 +128,7 @@ All configuration values can be overridden via environment variables:
| `LOGWISP_STREAM_RATE_LIMIT_ENABLED` | `stream.rate_limit.enabled` | Enable rate limiting | | `LOGWISP_STREAM_RATE_LIMIT_ENABLED` | `stream.rate_limit.enabled` | Enable rate limiting |
| `LOGWISP_STREAM_RATE_LIMIT_REQUESTS_PER_SEC` | `stream.rate_limit.requests_per_second` | Rate limit | | `LOGWISP_STREAM_RATE_LIMIT_REQUESTS_PER_SEC` | `stream.rate_limit.requests_per_second` | Rate limit |
| `LOGWISP_STREAM_RATE_LIMIT_BURST_SIZE` | `stream.rate_limit.burst_size` | Burst size | | `LOGWISP_STREAM_RATE_LIMIT_BURST_SIZE` | `stream.rate_limit.burst_size` | Burst size |
| `LOGWISP_STREAM_RATE_LIMIT_CLEANUP_INTERVAL` | `stream.rate_limit.cleanup_interval_s` | Cleanup interval | | `LOGWISP_STREAM_RATE_LIMIT_CLEANUP_INTERVAL_S` | `stream.rate_limit.cleanup_interval_s` | Cleanup interval |
### Monitor Targets Format ### Monitor Targets Format
@ -93,17 +146,22 @@ LOGWISP_MONITOR_TARGETS="/var/log:*.log:false,/app/app.log::true" ./logwisp
LOGWISP_MONITOR_TARGETS="/var/log:*.log:false,/opt/app/logs:app-*.log:false" ./logwisp LOGWISP_MONITOR_TARGETS="/var/log:*.log:false,/opt/app/logs:app-*.log:false" ./logwisp
``` ```
### Example Configuration ### Complete Configuration Example
```toml ```toml
# Port to listen on (default: 8080)
port = 8080 port = 8080
[monitor] [monitor]
# How often to check for file changes in milliseconds (default: 100)
check_interval_ms = 100 check_interval_ms = 100
# Monitor directory (all .log files) # Paths to monitor
# Default: [{"path": "./", "pattern": "*.log", "is_file": false}]
# Monitor all .log files in current directory
[[monitor.targets]] [[monitor.targets]]
path = "/var/log" path = "./"
pattern = "*.log" pattern = "*.log"
is_file = false is_file = false
@ -120,12 +178,24 @@ pattern = "access*.log"
is_file = false is_file = false
[stream] [stream]
# Buffer size for each client connection (default: 1000)
# Controls how many log entries can be queued per client
buffer_size = 1000 buffer_size = 1000
[stream.rate_limit] [stream.rate_limit]
enabled = true # Enable rate limiting (default: false)
enabled = false
# Requests per second per client (default: 10)
# This is the sustained rate
requests_per_second = 10 requests_per_second = 10
# Burst size - max requests at once (default: 20)
# Allows temporary bursts above the sustained rate
burst_size = 20 burst_size = 20
# How often to clean up old client limiters in seconds (default: 60)
# Clients inactive for 2x this duration are removed
cleanup_interval_s = 60 cleanup_interval_s = 60
``` ```
@ -141,7 +211,7 @@ LogWisp can pass through ANSI color escape codes from monitored logs to SSE clie
ExecStart=/opt/logwisp/bin/logwisp -c ExecStart=/opt/logwisp/bin/logwisp -c
``` ```
## How It Works ### How It Works
When color mode is enabled (`-c` flag), LogWisp preserves ANSI escape codes in log messages. These are properly JSON-escaped in the SSE stream. When color mode is enabled (`-c` flag), LogWisp preserves ANSI escape codes in log messages. These are properly JSON-escaped in the SSE stream.
@ -163,9 +233,9 @@ SSE output with `-c`:
} }
``` ```
## Client-Side Handling ### Client-Side Handling
### Terminal Clients #### Terminal Clients
For terminal-based clients (like curl), the escape codes will render as colors: For terminal-based clients (like curl), the escape codes will render as colors:
@ -174,7 +244,7 @@ For terminal-based clients (like curl), the escape codes will render as colors:
curl -N http://localhost:8080/stream | jq -r '.message' curl -N http://localhost:8080/stream | jq -r '.message'
``` ```
### Web Clients #### Web Clients
For web-based clients, you'll need to convert ANSI codes to HTML: For web-based clients, you'll need to convert ANSI codes to HTML:
@ -190,7 +260,7 @@ eventSource.onmessage = (event) => {
}; };
``` ```
### Custom Processing #### Custom Processing
```python ```python
# Python example with colorama # Python example with colorama
@ -245,13 +315,13 @@ To strip color codes instead of passing them through:
### Endpoints ### Endpoints
- `GET /stream` - Server-Sent Events stream of log entries - `GET /stream` - Server-Sent Events stream of log entries
- `GET /status` - Service status information - `GET /status` - Service status and configuration information
### Log Entry Format ### Log Entry Format
```json ```json
{ {
"time": "2024-01-01T12:00:00Z", "time": "2024-01-01T12:00:00.123456Z",
"source": "app.log", "source": "app.log",
"level": "error", "level": "error",
"message": "Something went wrong", "message": "Something went wrong",
@ -259,6 +329,48 @@ To strip color codes instead of passing them through:
} }
``` ```
### SSE Event Types
| Event | Description | Data Format |
|-------|-------------|-------------|
| `connected` | Initial connection | `{"client_id": "123456789"}` |
| `data` | Log entry | JSON log entry |
| `disconnected` | Client disconnected | `{"reason": "slow_client"}` |
| `timeout` | Client timeout | `{"reason": "client_timeout"}` |
| `:` | Heartbeat (comment) | ISO timestamp |
### Status Response Format
```json
{
"service": "LogWisp",
"version": "2.0.0",
"port": 8080,
"color_mode": false,
"config": {
"monitor": {
"check_interval_ms": 100,
"targets_count": 2
},
"stream": {
"buffer_size": 1000,
"rate_limit": {
"enabled": true,
"requests_per_second": 10,
"burst_size": 20
}
}
},
"streamer": {
"active_clients": 5,
"buffer_size": 1000,
"color_mode": false,
"total_dropped": 42
},
"rate_limiter": "Active clients: 3"
}
```
## Usage Examples ## Usage Examples
### Basic Usage ### Basic Usage
@ -266,6 +378,9 @@ To strip color codes instead of passing them through:
# Start with defaults # Start with defaults
./logwisp ./logwisp
# Monitor specific file
./logwisp /var/log/app.log
# View logs # View logs
curl -N http://localhost:8080/stream curl -N http://localhost:8080/stream
``` ```
@ -281,6 +396,9 @@ LOGWISP_STREAM_RATE_LIMIT_REQUESTS_PER_SEC=5 \
### Monitor Multiple Locations ### Monitor Multiple Locations
```bash ```bash
# Via command line
./logwisp /var/log:*.log /app/logs:*.json /tmp/debug.log
# Via environment variable # Via environment variable
LOGWISP_MONITOR_TARGETS="/var/log:*.log:false,/app/logs:*.json:false,/tmp/debug.log::true" \ LOGWISP_MONITOR_TARGETS="/var/log:*.log:false,/app/logs:*.json:false,/tmp/debug.log::true" \
./logwisp ./logwisp
@ -315,11 +433,13 @@ After=network.target
[Service] [Service]
Type=simple Type=simple
User=logwisp User=logwisp
ExecStart=/usr/local/bin/logwisp ExecStart=/usr/local/bin/logwisp -c
Restart=always Restart=always
RestartSec=5
# Environment overrides # Environment overrides
Environment="LOGWISP_PORT=8080" Environment="LOGWISP_PORT=8080"
Environment="LOGWISP_STREAM_BUFFER_SIZE=5000"
Environment="LOGWISP_STREAM_RATE_LIMIT_ENABLED=true" Environment="LOGWISP_STREAM_RATE_LIMIT_ENABLED=true"
Environment="LOGWISP_STREAM_RATE_LIMIT_REQUESTS_PER_SEC=100" Environment="LOGWISP_STREAM_RATE_LIMIT_REQUESTS_PER_SEC=100"
Environment="LOGWISP_MONITOR_TARGETS=/var/log:*.log:false,/opt/app/logs:*.log:false" Environment="LOGWISP_MONITOR_TARGETS=/var/log:*.log:false,/opt/app/logs:*.log:false"
@ -330,25 +450,92 @@ PrivateTmp=true
ProtectSystem=strict ProtectSystem=strict
ProtectHome=true ProtectHome=true
ReadOnlyPaths=/ ReadOnlyPaths=/
ReadWritePaths=/var/log ReadWritePaths=/var/log /opt/app/logs
[Install] [Install]
WantedBy=multi-user.target WantedBy=multi-user.target
``` ```
## Rate Limiting ## Performance Tuning
When enabled, rate limiting is applied per client IP address: ### Buffer Size
```toml The `stream.buffer_size` setting controls how many log entries can be queued per client:
[stream.rate_limit] - **Small buffers (100-500)**: Lower memory usage, clients skip entries during bursts
enabled = true - **Default (1000)**: Good balance for most use cases
requests_per_second = 10 # Sustained rate - **Large buffers (5000+)**: Handle burst traffic better, higher memory usage
burst_size = 20 # Allow bursts up to this size
cleanup_interval_s = 60 # Clean old clients every minute
```
Rate limiting uses the `X-Forwarded-For` header if present, falling back to `RemoteAddr`. When a client's buffer is full, new messages are skipped for that client until it catches up. The client remains connected and will receive future messages once buffer space is available.
### Check Interval
The `monitor.check_interval_ms` setting controls file polling frequency:
- **Fast (10-50ms)**: Near real-time updates, higher CPU usage
- **Default (100ms)**: Good balance
- **Slow (500ms+)**: Lower CPU usage, more latency
### Rate Limiting
When to enable rate limiting:
- Internet-facing deployments
- Shared environments
- Protection against misbehaving clients
Rate limiting applies only to establishing SSE connections, not to individual messages. Once connected, clients receive all messages (subject to buffer capacity).
## Troubleshooting
### Client Missing Messages
If clients miss messages during bursts:
1. Check `total_dropped` and `clients_with_drops` in status endpoint
2. Increase `stream.buffer_size` to handle larger bursts
3. Messages are skipped when buffer is full, but clients stay connected
### High Memory Usage
If memory usage is high:
1. Reduce `stream.buffer_size`
2. Enable rate limiting to limit concurrent connections
3. Each client uses `buffer_size * avg_message_size` memory
### Browser Stops Receiving Updates
This shouldn't happen with the current implementation. If it does:
1. Check browser developer console for errors
2. Verify no proxy/firewall is timing out the connection
3. Ensure reverse proxy (if used) doesn't buffer SSE responses
## File Rotation Detection
LogWisp automatically detects log file rotation using multiple methods:
- Inode changes (Linux/Unix)
- File size decrease
- Modification time reset
- Read position beyond file size
When rotation is detected, LogWisp:
1. Logs a rotation event
2. Resets read position to beginning
3. Continues streaming from new file
## Security Notes
1. **No built-in authentication** - Use a reverse proxy for auth
2. **No TLS support** - Use a reverse proxy for HTTPS
3. **Path validation** - Only specified paths can be monitored
4. **Directory traversal protection** - Paths containing ".." are rejected
5. **Rate limiting** - Optional but recommended for public deployments
6. **ANSI escape sequences** - Only enable color mode for trusted log sources
## Design Decisions
- **Unix philosophy**: Single purpose - stream logs
- **SSE over WebSocket**: Simpler, works everywhere, built-in reconnect
- **No database**: Stateless operation, instant startup
- **Atomic config management**: Using LixenWraith/config package
- **Graceful shutdown**: Proper cleanup on SIGINT/SIGTERM
- **Platform agnostic**: POSIX-compliant where possible
## Building from Source ## Building from Source
@ -356,32 +543,12 @@ Requirements:
- Go 1.23 or later - Go 1.23 or later
```bash ```bash
git clone https://github.com/yourusername/logwisp
cd logwisp
go mod download go mod download
go build -o logwisp ./src/cmd/logwisp go build -o logwisp ./src/cmd/logwisp
``` ```
## File Rotation Detection
LogWisp automatically detects log file rotation by:
- Monitoring file inode changes (Linux/Unix)
- Detecting file size decrease
- Resetting read position when rotation is detected
## Security Notes
1. **No built-in authentication** - Use a reverse proxy for auth
2. **No TLS support** - Use a reverse proxy for HTTPS
3. **Path validation** - Monitors only specified paths
4. **Rate limiting** - Optional but recommended for internet-facing deployments
## Design Decisions
- **Unix philosophy**: Single purpose - stream logs
- **No CLI arguments**: Configuration via file and environment only
- **SSE over WebSocket**: Simpler, works everywhere
- **Atomic config management**: Using LixenWraith/config package
- **Graceful shutdown**: Proper cleanup on SIGINT/SIGTERM
## License ## License
BSD-3-Clause BSD-3-Clause

33
assets/logo.svg Normal file
View File

@ -0,0 +1,33 @@
<svg width="200" height="200" viewBox="0 0 100 100" xmlns="http://www.w3.org/2000/svg">
<!--
LogWisp Logo - Vibrant Tech Stream
- Shape: 5 swirling streams emanating from a center point.
- Style: Wiry, energetic, and modern, suggesting data flow or a galaxy.
- Color: Radiant gradient from a bright core to a vibrant teal.
-->
<defs>
<radialGradient id="vibrant-glow" cx="50%" cy="50%" r="50%" fx="50%" fy="50%">
<stop offset="0%" stop-color="#E0FFFF" /> <!-- Bright, almost white cyan at the core -->
<stop offset="100%" stop-color="#00B0B0" /> <!-- Deeper teal/cyan at the tips -->
</radialGradient>
</defs>
<style>
.wisp-path {
fill: none;
stroke: url(#vibrant-glow);
stroke-width: 1.5;
stroke-linecap: round;
}
</style>
<g id="wisps">
<!-- A single path shape is defined, then rotated 5 times around the center (50, 50) -->
<!-- The path is a cubic Bezier curve: M(start) C(control1, control2, end) -->
<path class="wisp-path" d="M50,50 C58,30 85,25 95,50" transform="rotate(0, 50, 50)" />
<path class="wisp-path" d="M50,50 C58,30 85,25 95,50" transform="rotate(72, 50, 50)" />
<path class="wisp-path" d="M50,50 C58,30 85,25 95,50" transform="rotate(144, 50, 50)" />
<path class="wisp-path" d="M50,50 C58,30 85,25 95,50" transform="rotate(216, 50, 50)" />
<path class="wisp-path" d="M50,50 C58,30 85,25 95,50" transform="rotate(288, 50, 50)" />
</g>
</svg>

After

Width:  |  Height:  |  Size: 1.5 KiB

View File

@ -1,70 +1,112 @@
# LogWisp Configuration # LogWisp Configuration Template
# Default location: ~/.config/logwisp.toml # Default location: ~/.config/logwisp.toml
# Override with: LOGWISP_CONFIG_DIR and LOGWISP_CONFIG_FILE #
# Configuration precedence (highest to lowest):
# 1. Command-line arguments
# 2. Environment variables (LOGWISP_ prefix)
# 3. This configuration file
# 4. Built-in defaults
#
# All settings shown below with their default values
# Port to listen on # Port to listen on
# Environment: LOGWISP_PORT # Environment: LOGWISP_PORT
# CLI: --port PORT
# Default: 8080
port = 8080 port = 8080
# Environment: LOGWISP_COLOR
color = false
[monitor] [monitor]
# How often to check for file changes (milliseconds) # How often to check for file changes (milliseconds)
# Lower values = more responsive but higher CPU usage
# Environment: LOGWISP_MONITOR_CHECK_INTERVAL_MS # Environment: LOGWISP_MONITOR_CHECK_INTERVAL_MS
# CLI: --check-interval MS
# Default: 100
check_interval_ms = 100 check_interval_ms = 100
# Paths to monitor # Paths to monitor for log files
# Environment: LOGWISP_MONITOR_TARGETS (format: "path:pattern:isfile,path2:pattern2:isfile") # Environment: LOGWISP_MONITOR_TARGETS (format: "path:pattern:isfile,path2:pattern2:isfile")
# Example: LOGWISP_MONITOR_TARGETS="/var/log:*.log:false,/app/app.log::true" # CLI: logwisp [path[:pattern[:isfile]]] ...
# Default: Monitor current directory for *.log files
# Monitor all .log files in current directory
[[monitor.targets]] [[monitor.targets]]
path = "./" path = "./" # Directory or file path to monitor
pattern = "*.log" pattern = "*.log" # Glob pattern for directory monitoring (ignored for files)
is_file = false is_file = false # true = monitor specific file, false = monitor directory
# Monitor all logs in /var/log # Additional target examples (uncomment to use):
[[monitor.targets]]
path = "/var/log"
pattern = "*.log"
is_file = false
# Monitor specific application log file # # Monitor specific log file
# [[monitor.targets]] # [[monitor.targets]]
#path = "/home/user/app/app.log" # path = "/var/log/application.log"
#pattern = "" # Ignored for files # pattern = "" # Pattern ignored when is_file = true
# is_file = true # is_file = true
# Monitor nginx access logs # # Monitor system logs
# [[monitor.targets]]
# path = "/var/log"
# pattern = "*.log"
# is_file = false
# # Monitor nginx access logs with pattern
# [[monitor.targets]] # [[monitor.targets]]
# path = "/var/log/nginx" # path = "/var/log/nginx"
# pattern = "access*.log" # pattern = "access*.log"
# is_file = false # is_file = false
# Monitor systemd journal exported logs # # Monitor journal export directory
# [[monitor.targets]] # [[monitor.targets]]
# path = "/var/log/journal" # path = "/var/log/journal"
# pattern = "*.log" # pattern = "*.log"
# is_file = false # is_file = false
# # Monitor multiple application logs
# [[monitor.targets]]
# path = "/opt/myapp/logs"
# pattern = "app-*.log"
# is_file = false
[stream] [stream]
# Buffer size for each client connection # Buffer size for each client connection
# Number of log entries that can be queued per client
# When buffer is full, new messages are skipped (not sent to that client)
# Increase for burst traffic, decrease for memory conservation
# Environment: LOGWISP_STREAM_BUFFER_SIZE # Environment: LOGWISP_STREAM_BUFFER_SIZE
buffer_size = 10000 # CLI: --buffer-size SIZE
# Default: 1000
buffer_size = 1000
[stream.rate_limit] [stream.rate_limit]
# Enable rate limiting # Enable rate limiting per client IP
# Prevents resource exhaustion from misbehaving clients
# Environment: LOGWISP_STREAM_RATE_LIMIT_ENABLED # Environment: LOGWISP_STREAM_RATE_LIMIT_ENABLED
enabled = true # CLI: --rate-limit
# Default: false
enabled = false
# Requests per second per client # Sustained requests per second allowed per client
# Clients can make this many requests per second continuously
# Environment: LOGWISP_STREAM_RATE_LIMIT_REQUESTS_PER_SEC # Environment: LOGWISP_STREAM_RATE_LIMIT_REQUESTS_PER_SEC
# CLI: --rate-requests N
# Default: 10
requests_per_second = 10 requests_per_second = 10
# Burst size (max requests at once) # Maximum burst size per client
# Allows temporary bursts above the sustained rate
# Should be >= requests_per_second
# Environment: LOGWISP_STREAM_RATE_LIMIT_BURST_SIZE # Environment: LOGWISP_STREAM_RATE_LIMIT_BURST_SIZE
# CLI: --rate-burst N
# Default: 20
burst_size = 20 burst_size = 20
# How often to clean up old client limiters (seconds) # How often to clean up inactive client rate limiters (seconds)
# Environment: LOGWISP_STREAM_RATE_LIMIT_CLEANUP_INTERVAL # Clients inactive for 2x this duration are removed from tracking
cleanup_interval_s = 5 # Lower values = more frequent cleanup, higher values = less overhead
# Environment: LOGWISP_STREAM_RATE_LIMIT_CLEANUP_INTERVAL_S
# Default: 60
cleanup_interval_s = 60
# Production configuration example:
# [stream.rate_limit]
# enabled = true
# requests_per_second = 100 # Higher limit for production
# burst_size = 200 # Allow larger bursts
# cleanup_interval_s = 300 # Less frequent cleanup

View File

@ -21,9 +21,10 @@ import (
) )
func main() { func main() {
// CHANGED: Parse flags manually without init() // Parse flags manually without init()
var colorMode bool var colorMode bool
flag.BoolVar(&colorMode, "c", false, "Enable color pass-through for escape codes in logs") flag.BoolVar(&colorMode, "c", false, "Enable color pass-through for escape codes in logs")
flag.BoolVar(&colorMode, "color", false, "Enable color pass-through for escape codes in logs")
// Additional CLI flags that override config // Additional CLI flags that override config
var ( var (
@ -98,7 +99,6 @@ func main() {
var wg sync.WaitGroup var wg sync.WaitGroup
// Create components // Create components
// colorMode is now separate from config
streamer := stream.NewWithOptions(cfg.Stream.BufferSize, colorMode) streamer := stream.NewWithOptions(cfg.Stream.BufferSize, colorMode)
mon := monitor.New(streamer.Publish) mon := monitor.New(streamer.Publish)
@ -145,7 +145,7 @@ func main() {
status := map[string]interface{}{ status := map[string]interface{}{
"service": "LogWisp", "service": "LogWisp",
"version": "2.0.0", // CHANGED: Version bump for config integration "version": "2.0.0",
"port": cfg.Port, "port": cfg.Port,
"color_mode": colorMode, "color_mode": colorMode,
"config": map[string]interface{}{ "config": map[string]interface{}{
@ -191,7 +191,6 @@ func main() {
if colorMode { if colorMode {
fmt.Println("Color pass-through enabled") fmt.Println("Color pass-through enabled")
} }
// CHANGED: Log config source information
fmt.Printf("Config loaded from: %s\n", config.GetConfigPath()) fmt.Printf("Config loaded from: %s\n", config.GetConfigPath())
if err := server.ListenAndServe(); err != nil && err != http.ErrServerClosed { if err := server.ListenAndServe(); err != nil && err != http.ErrServerClosed {

View File

@ -67,18 +67,15 @@ func defaults() *Config {
} }
// Load reads configuration using lixenwraith/config Builder pattern // Load reads configuration using lixenwraith/config Builder pattern
// CHANGED: Now uses config.Builder for all source handling
func Load() (*Config, error) { func Load() (*Config, error) {
configPath := GetConfigPath() configPath := GetConfigPath()
// CHANGED: Use Builder pattern with custom environment transform
cfg, err := lconfig.NewBuilder(). cfg, err := lconfig.NewBuilder().
WithDefaults(defaults()). WithDefaults(defaults()).
WithEnvPrefix("LOGWISP_"). WithEnvPrefix("LOGWISP_").
WithFile(configPath). WithFile(configPath).
WithEnvTransform(customEnvTransform). WithEnvTransform(customEnvTransform).
WithSources( WithSources(
// CHANGED: CLI args removed here - handled separately in LoadWithCLI
lconfig.SourceEnv, lconfig.SourceEnv,
lconfig.SourceFile, lconfig.SourceFile,
lconfig.SourceDefault, lconfig.SourceDefault,
@ -107,7 +104,6 @@ func Load() (*Config, error) {
} }
// LoadWithCLI loads configuration and applies CLI arguments // LoadWithCLI loads configuration and applies CLI arguments
// CHANGED: New function that properly integrates CLI args with config package
func LoadWithCLI(cliArgs []string) (*Config, error) { func LoadWithCLI(cliArgs []string) (*Config, error) {
configPath := GetConfigPath() configPath := GetConfigPath()
@ -118,7 +114,7 @@ func LoadWithCLI(cliArgs []string) (*Config, error) {
WithDefaults(defaults()). WithDefaults(defaults()).
WithEnvPrefix("LOGWISP_"). WithEnvPrefix("LOGWISP_").
WithFile(configPath). WithFile(configPath).
WithArgs(convertedArgs). // CHANGED: Use WithArgs for CLI WithArgs(convertedArgs).
WithEnvTransform(customEnvTransform). WithEnvTransform(customEnvTransform).
WithSources( WithSources(
lconfig.SourceCLI, // CLI highest priority lconfig.SourceCLI, // CLI highest priority
@ -148,16 +144,14 @@ func LoadWithCLI(cliArgs []string) (*Config, error) {
return finalConfig, finalConfig.validate() return finalConfig, finalConfig.validate()
} }
// CHANGED: Custom environment transform that handles LOGWISP_ prefix more flexibly // customEnvTransform handles LOGWISP_ prefix environment variables
func customEnvTransform(path string) string { func customEnvTransform(path string) string {
// Standard transform // Standard transform
env := strings.ReplaceAll(path, ".", "_") env := strings.ReplaceAll(path, ".", "_")
env = strings.ToUpper(env) env = strings.ToUpper(env)
env = "LOGWISP_" + env env = "LOGWISP_" + env
// Also check for some common variations // Handle common variations
// This allows both LOGWISP_STREAM_RATE_LIMIT_REQUESTS_PER_SEC
// and LOGWISP_STREAM_RATE_LIMIT_REQUESTS_PER_SECOND
switch env { switch env {
case "LOGWISP_STREAM_RATE_LIMIT_REQUESTS_PER_SECOND": case "LOGWISP_STREAM_RATE_LIMIT_REQUESTS_PER_SECOND":
if _, exists := os.LookupEnv("LOGWISP_STREAM_RATE_LIMIT_REQUESTS_PER_SEC"); exists { if _, exists := os.LookupEnv("LOGWISP_STREAM_RATE_LIMIT_REQUESTS_PER_SEC"); exists {
@ -172,7 +166,7 @@ func customEnvTransform(path string) string {
return env return env
} }
// CHANGED: Convert CLI args to config package format // convertCLIArgs converts CLI args to config package format
func convertCLIArgs(args []string) []string { func convertCLIArgs(args []string) []string {
var converted []string var converted []string
@ -194,7 +188,6 @@ func convertCLIArgs(args []string) []string {
} }
// GetConfigPath returns the configuration file path // GetConfigPath returns the configuration file path
// CHANGED: Exported and simplified - now just returns the path, no manual env handling
func GetConfigPath() string { func GetConfigPath() string {
// Check explicit config file paths // Check explicit config file paths
if configFile := os.Getenv("LOGWISP_CONFIG_FILE"); configFile != "" { if configFile := os.Getenv("LOGWISP_CONFIG_FILE"); configFile != "" {
@ -219,7 +212,7 @@ func GetConfigPath() string {
return "logwisp.toml" return "logwisp.toml"
} }
// CHANGED: Special handling for comma-separated monitor targets env var // handleMonitorTargetsEnv handles comma-separated monitor targets env var
func handleMonitorTargetsEnv(cfg *lconfig.Config) error { func handleMonitorTargetsEnv(cfg *lconfig.Config) error {
if targetsStr := os.Getenv("LOGWISP_MONITOR_TARGETS"); targetsStr != "" { if targetsStr := os.Getenv("LOGWISP_MONITOR_TARGETS"); targetsStr != "" {
// Clear any existing targets from file/defaults // Clear any existing targets from file/defaults

View File

@ -6,6 +6,7 @@ import (
"fmt" "fmt"
"net/http" "net/http"
"sync" "sync"
"sync/atomic"
"time" "time"
"logwisp/src/internal/monitor" "logwisp/src/internal/monitor"
@ -22,13 +23,16 @@ type Streamer struct {
done chan struct{} done chan struct{}
colorMode bool colorMode bool
wg sync.WaitGroup wg sync.WaitGroup
// Metrics
totalDropped atomic.Int64
} }
type clientConnection struct { type clientConnection struct {
id string id string
channel chan monitor.LogEntry channel chan monitor.LogEntry
lastActivity time.Time lastActivity time.Time
dropped int64 // Count of dropped messages dropped atomic.Int64 // Track per-client dropped messages
} }
// New creates a new SSE streamer // New creates a new SSE streamer
@ -53,14 +57,10 @@ func NewWithOptions(bufferSize int, colorMode bool) *Streamer {
return s return s
} }
// run manages client connections with timeout cleanup // run manages client connections - SIMPLIFIED: no forced disconnections
func (s *Streamer) run() { func (s *Streamer) run() {
defer s.wg.Done() defer s.wg.Done()
// Add periodic cleanup for stale/slow clients
cleanupTicker := time.NewTicker(30 * time.Second)
defer cleanupTicker.Stop()
for { for {
select { select {
case c := <-s.register: case c := <-s.register:
@ -79,47 +79,27 @@ func (s *Streamer) run() {
case entry := <-s.broadcast: case entry := <-s.broadcast:
s.mu.RLock() s.mu.RLock()
now := time.Now() now := time.Now()
var toRemove []string
for id, client := range s.clients { for _, client := range s.clients {
select { select {
case client.channel <- entry: case client.channel <- entry:
// Successfully sent
client.lastActivity = now client.lastActivity = now
client.dropped.Store(0) // Reset dropped counter on success
default: default:
// Track dropped messages and remove slow clients // Buffer full - skip this message for this client
client.dropped++ // Don't disconnect, just track dropped messages
// Remove clients that have dropped >100 messages or been inactive >2min dropped := client.dropped.Add(1)
if client.dropped > 100 || now.Sub(client.lastActivity) > 2*time.Minute { s.totalDropped.Add(1)
toRemove = append(toRemove, id)
// Log significant drop milestones for monitoring
if dropped == 100 || dropped == 1000 || dropped%10000 == 0 {
// Could add logging here if needed
} }
} }
} }
s.mu.RUnlock() s.mu.RUnlock()
// Remove slow/stale clients outside the read lock
if len(toRemove) > 0 {
s.mu.Lock()
for _, id := range toRemove {
if client, ok := s.clients[id]; ok {
close(client.channel)
delete(s.clients, id)
}
}
s.mu.Unlock()
}
case <-cleanupTicker.C:
// Periodic cleanup of inactive clients
s.mu.Lock()
now := time.Now()
for id, client := range s.clients {
if now.Sub(client.lastActivity) > 5*time.Minute {
close(client.channel)
delete(s.clients, id)
}
}
s.mu.Unlock()
case <-s.done: case <-s.done:
s.mu.Lock() s.mu.Lock()
for _, client := range s.clients { for _, client := range s.clients {
@ -138,18 +118,21 @@ func (s *Streamer) Publish(entry monitor.LogEntry) {
case s.broadcast <- entry: case s.broadcast <- entry:
// Sent to broadcast channel // Sent to broadcast channel
default: default:
// Drop entry if broadcast buffer full, log occurrence // Broadcast buffer full - drop the message globally
// This prevents memory exhaustion under high load s.totalDropped.Add(1)
} }
} }
// ServeHTTP implements http.Handler for SSE // ServeHTTP implements http.Handler for SSE - SIMPLIFIED
func (s *Streamer) ServeHTTP(w http.ResponseWriter, r *http.Request) { func (s *Streamer) ServeHTTP(w http.ResponseWriter, r *http.Request) {
// Set SSE headers // Set SSE headers
w.Header().Set("Content-Type", "text/event-stream") w.Header().Set("Content-Type", "text/event-stream")
w.Header().Set("Cache-Control", "no-cache") w.Header().Set("Cache-Control", "no-cache")
w.Header().Set("Connection", "keep-alive") w.Header().Set("Connection", "keep-alive")
w.Header().Set("X-Accel-Buffering", "no") w.Header().Set("X-Accel-Buffering", "no") // Disable nginx buffering
// SECURITY: Prevent XSS
w.Header().Set("X-Content-Type-Options", "nosniff")
// Create client // Create client
clientID := fmt.Sprintf("%d", time.Now().UnixNano()) clientID := fmt.Sprintf("%d", time.Now().UnixNano())
@ -159,7 +142,6 @@ func (s *Streamer) ServeHTTP(w http.ResponseWriter, r *http.Request) {
id: clientID, id: clientID,
channel: ch, channel: ch,
lastActivity: time.Now(), lastActivity: time.Now(),
dropped: 0,
} }
// Register client // Register client
@ -169,41 +151,29 @@ func (s *Streamer) ServeHTTP(w http.ResponseWriter, r *http.Request) {
}() }()
// Send initial connection event // Send initial connection event
fmt.Fprintf(w, "event: connected\ndata: {\"client_id\":\"%s\"}\n\n", clientID) fmt.Fprintf(w, "event: connected\ndata: {\"client_id\":\"%s\",\"buffer_size\":%d}\n\n",
clientID, s.bufferSize)
if flusher, ok := w.(http.Flusher); ok { if flusher, ok := w.(http.Flusher); ok {
flusher.Flush() flusher.Flush()
} }
// Create ticker for heartbeat // Create ticker for heartbeat - keeps connection alive through proxies
ticker := time.NewTicker(30 * time.Second) ticker := time.NewTicker(30 * time.Second)
defer ticker.Stop() defer ticker.Stop()
// Add timeout for slow clients // Stream events until client disconnects
clientTimeout := time.NewTimer(10 * time.Minute)
defer clientTimeout.Stop()
// Stream events
for { for {
select { select {
case <-r.Context().Done(): case <-r.Context().Done():
// Client disconnected
return return
case entry, ok := <-ch: case entry, ok := <-ch:
if !ok { if !ok {
// Channel was closed (client removed due to slowness) // Channel closed
fmt.Fprintf(w, "event: disconnected\ndata: {\"reason\":\"slow_client\"}\n\n")
if flusher, ok := w.(http.Flusher); ok {
flusher.Flush()
}
return return
} }
// Reset client timeout on successful read
if !clientTimeout.Stop() {
<-clientTimeout.C
}
clientTimeout.Reset(10 * time.Minute)
// Process entry for color if needed // Process entry for color if needed
if s.colorMode { if s.colorMode {
entry = s.processColorEntry(entry) entry = s.processColorEntry(entry)
@ -220,19 +190,11 @@ func (s *Streamer) ServeHTTP(w http.ResponseWriter, r *http.Request) {
} }
case <-ticker.C: case <-ticker.C:
// Heartbeat with UTC timestamp // Send heartbeat as SSE comment
fmt.Fprintf(w, ": heartbeat %s\n\n", time.Now().UTC().Format("2006-01-02T15:04:05.000000Z07:00")) fmt.Fprintf(w, ": heartbeat %s\n\n", time.Now().UTC().Format(time.RFC3339))
if flusher, ok := w.(http.Flusher); ok { if flusher, ok := w.(http.Flusher); ok {
flusher.Flush() flusher.Flush()
} }
case <-clientTimeout.C:
// Client timeout - close connection
fmt.Fprintf(w, "event: timeout\ndata: {\"reason\":\"client_timeout\"}\n\n")
if flusher, ok := w.(http.Flusher); ok {
flusher.Flush()
}
return
} }
} }
} }
@ -246,11 +208,8 @@ func (s *Streamer) Stop() {
close(s.broadcast) close(s.broadcast)
} }
// Enhanced color processing with proper ANSI handling // processColorEntry preserves ANSI codes in JSON
func (s *Streamer) processColorEntry(entry monitor.LogEntry) monitor.LogEntry { func (s *Streamer) processColorEntry(entry monitor.LogEntry) monitor.LogEntry {
// For color mode, we preserve ANSI codes but ensure they're properly handled
// The JSON marshaling will escape them correctly for transmission
// Client-side handling is required for proper display
return entry return entry
} }
@ -263,13 +222,24 @@ func (s *Streamer) Stats() map[string]interface{} {
"active_clients": len(s.clients), "active_clients": len(s.clients),
"buffer_size": s.bufferSize, "buffer_size": s.bufferSize,
"color_mode": s.colorMode, "color_mode": s.colorMode,
"total_dropped": s.totalDropped.Load(),
} }
totalDropped := int64(0) // Include per-client dropped counts if any are significant
for _, client := range s.clients { var clientsWithDrops []map[string]interface{}
totalDropped += client.dropped for id, client := range s.clients {
dropped := client.dropped.Load()
if dropped > 0 {
clientsWithDrops = append(clientsWithDrops, map[string]interface{}{
"id": id,
"dropped": dropped,
})
}
}
if len(clientsWithDrops) > 0 {
stats["clients_with_drops"] = clientsWithDrops
} }
stats["total_dropped"] = totalDropped
return stats return stats
} }