1
0
mirror of https://github.com/taigrr/log-socket synced 2026-03-20 16:02:28 -07:00

10 Commits

Author SHA1 Message Date
cac3d127ee chore: update to Go 1.26, resolve slog LogValuer values 2026-03-02 03:48:00 +00:00
90471833d0 feat(slog): add slog.Handler adapter for log-socket
Implements log/slog.Handler that routes structured log records into the
log-socket broadcasting system. Supports namespaces, WithAttrs,
WithGroup, and configurable minimum level.

Also adds Broadcast() as a public entry point for adapter packages
that construct log.Entry values directly.
2026-03-02 03:44:15 +00:00
703f80c5f6 docs: add example programs for common usage patterns
Adds four focused examples in examples/ directory:
- basic: drop-in logger with web UI
- namespaces: namespace-based logging by component
- client: programmatic log client with filtering
- log-levels: all log levels and filtering

Fixes #7
2026-02-20 08:02:17 +00:00
47bfb5ed98 fix: stderr namespace filter, fileInfo depth, panic guard, race safety
- fix(stderr): CreateClient() with no args so stderr sees all namespaces,
  not just 'default'. Previously namespaced logs were invisible on stderr.
- fix(logger): fileInfo depth offset to 2+FileInfoDepth so Logger methods
  report the actual caller, not the log package itself. Default depth (0)
  now correctly shows caller file:line.
- fix(panic): guard args[0] access with len(args) > 0 instead of >= 0.
  Panic/Panicf/Panicln would index out of range when called with no args.
- fix(createLog): nil/initialized check before channel send to prevent
  race with concurrent Destroy calls.
- chore: bump go.mod to 1.25.6
2026-02-17 08:31:21 +00:00
6a709d3963 feat(log): add colored terminal output without external packages (#16)
Adds ANSI color-coded log levels for terminal output:
- TRACE: Gray
- DEBUG: Cyan
- INFO: Green
- NOTICE: Blue
- WARN: Yellow
- ERROR/PANIC/FATAL: Red (bold for PANIC/FATAL)

Colors are auto-detected based on terminal capability and can be
controlled via SetColorEnabled()/ColorEnabled().

Fixes #9
2026-02-17 03:27:53 -05:00
0a78d1ce90 test: add comprehensive benchmark suite (#18)
Adds benchmarks for all log levels (Trace, Debug, Info, Warn, Error),
formatted logging, multiple client fan-out, parallel writes, namespace
filtering, and component-level benchmarks (fileInfo, entry creation).

Uses isolated namespaces to avoid interference with the stderr client.

Fixes #6
2026-02-17 03:25:12 -05:00
93dd230be0 update test version 2025-11-10 16:08:40 -05:00
f86c2dbf46 enable namespaced logging 2025-11-10 16:07:28 -05:00
Daniel Merja
9c571ca955 Update screenshot image in the browser directory 2025-06-29 19:53:24 -07:00
Daniel Merja
45ba154d1e Update Go version to 1.24.4 and overhaul viewer.html with a complete redesign for improved log display and user interaction. (#13) 2025-06-29 02:11:12 -07:00
21 changed files with 2420 additions and 259 deletions

View File

@@ -11,7 +11,7 @@ jobs:
- name: Set up Go
uses: actions/setup-go@v4
with:
go-version: "1.21"
go-version: "1.25"
- name: Install dependencies
run: go get .

501
CRUSH.md Normal file
View File

@@ -0,0 +1,501 @@
# CRUSH.md - Log Socket v2 Development Guide
This document provides context and conventions for working on **log-socket v2** - a real-time log viewer with WebSocket support and namespace filtering, written in Go.
## Project Overview
Log Socket v2 is a Go library and standalone application that provides:
- Real-time log streaming via WebSocket
- **Namespace-based log organization** (NEW in v2)
- Web-based log viewer with namespace filtering
- Support for multiple log levels (TRACE, DEBUG, INFO, NOTICE, WARN, ERROR, PANIC, FATAL)
- Client architecture allowing multiple subscribers to filtered log streams
**Key insight**: This is both a library (importable Go package) and a standalone application. The main.go serves as an example implementation.
## Essential Commands
### Build
```bash
go build -v ./...
```
### Run Server
```bash
# Default (runs on 0.0.0.0:8080)
go run main.go
# Custom address
go run main.go -addr localhost:8080
```
Once running, open browser to `http://localhost:8080` to view logs.
### Test
```bash
go test -v ./...
```
### Install
```bash
go install github.com/taigrr/log-socket/v2@latest
```
### Dependencies
```bash
go get .
```
## Project Structure
```
.
├── main.go # Example server with multiple namespaces
├── log/ # Core logging package
│ ├── log.go # Package-level logging functions + namespace tracking
│ ├── logger.go # Logger type with namespace support
│ ├── types.go # Type definitions (includes Namespace fields)
│ └── log_test.go # Tests
├── ws/ # WebSocket server
│ ├── server.go # LogSocketHandler with namespace filtering
│ └── namespaces.go # HTTP handler for namespace list API
└── browser/ # Web UI
├── browser.go # HTTP handler serving embedded HTML
└── viewer.html # Embedded web interface with namespace filter
```
## Major Changes in v2
### Module Path
- **v1**: `github.com/taigrr/log-socket`
- **v2**: `github.com/taigrr/log-socket/v2`
### Namespace Support
**Core concept**: Namespaces allow organizing logs by component, service, or domain (e.g., "api", "database", "auth").
#### Types Changes
**Entry** now includes:
```go
type Entry struct {
Timestamp time.Time `json:"timestamp"`
Output string `json:"output"`
File string `json:"file"`
Level string `json:"level"`
Namespace string `json:"namespace"` // NEW
level Level
}
```
**Client** now uses a slice for filtering:
```go
type Client struct {
LogLevel Level `json:"level"`
Namespaces []string `json:"namespaces"` // Empty = all namespaces
writer LogWriter
initialized bool
}
```
**Logger** has namespace field:
```go
type Logger struct {
FileInfoDepth int
Namespace string // NEW
}
```
#### API Changes
**CreateClient** now variadic:
```go
// v1
func CreateClient() *Client
// v2
func CreateClient(namespaces ...string) *Client
// Examples:
client := log.CreateClient() // All namespaces
client := log.CreateClient("api") // Single namespace
client := log.CreateClient("api", "database") // Multiple namespaces
```
**NewLogger** constructor added:
```go
func NewLogger(namespace string) *Logger
// Example:
apiLogger := log.NewLogger("api")
apiLogger.Info("API request received")
```
### Namespace Tracking
Global namespace registry tracks all used namespaces:
```go
var (
namespaces map[string]bool
namespacesMux sync.RWMutex
)
func GetNamespaces() []string
```
Namespaces are automatically registered when logs are created.
### WebSocket Changes
**Query parameter for filtering**:
```
ws://localhost:8080/ws?namespaces=api,database
```
The handler parses comma-separated namespace list and creates a filtered client.
### Web UI Changes
- Namespace filter input field added to controls
- Namespace column added to log table
- Reconnect button to apply namespace filter
- WebSocket URL includes namespace query parameter
## Code Organization & Architecture
### Log Package (`log/`)
Dual API remains, but with namespace support:
1. **Package-level functions**: Use "default" namespace
- `log.Info()`, `log.Debug()`, etc.
- All entry creations include `Namespace: DefaultNamespace`
2. **Logger instances**: Use custom namespace
- Create with `log.NewLogger(namespace)`
- All entry creations include `Namespace: l.Namespace`
### Client Architecture (Updated)
**Client filtering by namespace**:
1. **Empty Namespaces slice**: Receives all logs regardless of namespace
2. **Non-empty Namespaces**: Only receives logs matching one of the specified namespaces
**matchesNamespace helper**:
```go
func (c *Client) matchesNamespace(namespace string) bool {
// Empty Namespaces slice means match all
if len(c.Namespaces) == 0 {
return true
}
for _, ns := range c.Namespaces {
if ns == namespace {
return true
}
}
return false
}
```
**Entry flow with namespace filtering**:
1. Log function called with namespace
2. `Entry` created with namespace field
3. Namespace registered in global map
4. `createLog()` sends to all clients
5. Each client checks `matchesNamespace()`
6. Only matching clients receive the entry
### WebSocket Handler (`ws/`)
**Namespace parameter parsing**:
```go
namespacesParam := r.URL.Query().Get("namespaces")
var namespaces []string
if namespacesParam != "" {
namespaces = strings.Split(namespacesParam, ",")
}
lc := logger.CreateClient(namespaces...)
```
**Namespaces API handler** (`ws/namespaces.go`):
```go
func NamespacesHandler(w http.ResponseWriter, r *http.Request) {
namespaces := logger.GetNamespaces()
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(map[string]interface{}{
"namespaces": namespaces,
})
}
```
### Browser Package (`browser/`)
Still embeds viewer.html, but HTML now includes:
- Namespace filter input
- Namespace column in grid (5 columns instead of 4)
- `reconnectWithNamespace()` method
- WebSocket URL construction with query parameter
## Go Version & Dependencies
- **Go version**: 1.24.4 (specified in go.mod)
- **Only external dependency**: `github.com/gorilla/websocket v1.5.3`
## Naming Conventions & Style
### Namespaces
- Use lowercase strings: `"api"`, `"database"`, `"auth"`, `"cache"`
- Default constant: `DefaultNamespace = "default"`
- Comma-separated in query params: `?namespaces=api,database`
### Log Levels
Unchanged from v1 - still use uppercase strings and iota constants.
### Variable Names
- Use descriptive names (`apiLogger`, `dbLogger`, `namespaces`)
- Exception: Loop variables, short-lived scopes
## Important Patterns & Gotchas
### 1. Namespace Tracking is Automatic
When any log is created, its namespace is automatically added to the global registry:
```go
func createLog(e Entry) {
// Track namespace
namespacesMux.Lock()
namespaces[e.Namespace] = true
namespacesMux.Unlock()
// ... rest of function
}
```
**No manual registration needed** - just log and the namespace appears.
### 2. Empty Namespace List = All Logs
Both for clients and WebSocket connections:
```go
client := log.CreateClient() // Gets ALL logs
ws://localhost:8080/ws // Gets ALL logs
```
This is the default behavior to maintain backward compatibility.
### 3. Client Namespace Filtering is Inclusive (OR)
If a client has multiple namespaces, it receives logs matching ANY of them:
```go
client := log.CreateClient("api", "database")
// Receives logs from "api" OR "database", not "auth"
```
### 4. Namespace Field Always Set
All logging functions set namespace:
- Package functions: `DefaultNamespace`
- Logger methods: `l.Namespace`
**Never nil or empty** - there's always a namespace.
### 5. WebSocket Namespace Reconnection
Changing namespace filter requires reconnecting WebSocket:
```javascript
reconnectWithNamespace() {
if (this.ws) {
this.ws.onclose = null; // Prevent auto-reconnect
this.ws.close();
this.ws = null;
}
this.reconnectAttempts = 0;
this.connectWebSocket(); // Creates new connection with new filter
}
```
UI provides "Reconnect" button for this purpose.
### 6. Stderr Client Uses All Namespaces
The built-in stderr client (created in `init()`) listens to all namespaces:
```go
stderrClient = CreateClient(DefaultNamespace)
```
But only prints logs matching its own namespace in `logStdErr()`:
```go
if e.level >= c.LogLevel && c.matchesNamespace(e.Namespace) {
fmt.Fprintf(os.Stderr, "%s\t%s\t[%s]\t%s\t%s\n", ...)
}
```
**Wait, that's a bug!** The stderr client is created with `DefaultNamespace` but should be created with no namespaces to see all logs. Let me check this.
Actually looking at the code:
```go
stderrClient = CreateClient(DefaultNamespace)
```
This means stderr client only sees "default" namespace logs. This might be intentional, but seems like a bug. Should probably be:
```go
stderrClient = CreateClient() // No args = all namespaces
```
### 7. Grid Layout Updated
The log viewer grid changed from 4 to 5 columns:
```css
/* v1 */
grid-template-columns: 180px 80px 1fr 120px;
/* v2 */
grid-template-columns: 180px 80px 100px 1fr 120px;
```
Order: Timestamp, Level, Namespace, Message, Source
## Testing
### Test Updates for v2
All `CreateClient()` calls in tests now pass namespace:
```go
// v1
c := CreateClient()
// v2
c := CreateClient("test")
c := CreateClient(DefaultNamespace)
```
Tests verify namespace appears in output (see stderr format).
### Running Tests
```bash
go test -v ./...
```
All existing tests pass with namespace support added.
## CI/CD
GitHub Actions workflow (`.github/workflows/ci.yaml`):
- Still uses Go 1.21 (should update to 1.24.4 to match go.mod)
- No changes needed for v2 functionality
## Common Tasks
### Adding a New Namespace
No code changes needed! Just create a logger:
```go
cacheLogger := log.NewLogger("cache")
cacheLogger.Info("Cache initialized")
```
Namespace automatically tracked and available via API.
### Creating Namespace-Specific Client
```go
// Subscribe only to API logs
apiClient := log.CreateClient("api")
defer apiClient.Destroy()
for {
entry := apiClient.Get()
// Only receives logs from "api" namespace
processAPILog(entry)
}
```
### Filtering WebSocket by Namespace
Frontend:
```javascript
// Set namespace filter
document.getElementById('namespaceFilter').value = 'api,database';
// Click reconnect button or call:
logViewer.reconnectWithNamespace();
```
Backend automatically creates filtered client based on query param.
### Getting All Active Namespaces
```go
namespaces := log.GetNamespaces()
// Returns: ["default", "api", "database", "auth", ...]
```
Or via HTTP:
```bash
GET /api/namespaces
```
Returns:
```json
{
"namespaces": ["default", "api", "database", "auth"]
}
```
## Migration from v1 to v2
### Import Paths
```go
// v1
import "github.com/taigrr/log-socket/log"
import "github.com/taigrr/log-socket/ws"
import "github.com/taigrr/log-socket/browser"
// v2
import "github.com/taigrr/log-socket/v2/log"
import "github.com/taigrr/log-socket/v2/ws"
import "github.com/taigrr/log-socket/v2/browser"
```
### CreateClient Calls
```go
// v1
client := log.CreateClient()
// v2 - same behavior
client := log.CreateClient() // Empty = all namespaces
// v2 - new filtering capability
client := log.CreateClient("api")
client := log.CreateClient("api", "database")
```
### Default() Logger
```go
// v1
logger := log.Default()
// v2 - uses default namespace
logger := log.Default()
// v2 - new namespaced option
logger := log.NewLogger("api")
```
### WebSocket URL
```
v1: ws://host/ws
v2: ws://host/ws # All namespaces (backward compatible)
v2: ws://host/ws?namespaces=api # Filtered
v2: ws://host/ws?namespaces=api,database # Multiple
```
## Repository Context
- **License**: Check LICENSE file
- **Funding**: GitHub sponsors (.github/FUNDING.yml)
- **Open Source**: github.com/taigrr/log-socket
- **Version**: v2.x.x (major version bump for breaking changes)
## Future Agent Notes
- **v2 is a breaking change**: Major version bump follows Go modules convention
- Namespace support is the primary new feature
- Backward compatible behavior: empty namespace list = all logs
- Namespace tracking is automatic via global registry
- Web UI has been significantly updated for namespace support
- Possible bug: stderr client might need to use `CreateClient()` instead of `CreateClient(DefaultNamespace)` to see all logs
- All tests updated and passing
- Example in main.go demonstrates multiple namespaces

247
README.md
View File

@@ -1,11 +1,27 @@
# Log Socket
# Log Socket v2
A real-time log viewer with WebSocket support, written in Go. This tool provides a web-based interface for viewing and filtering logs in real-time.
A real-time log viewer with WebSocket support and namespace filtering, written in Go.
## What's New in v2
**Breaking Changes:**
- Module path changed to `github.com/taigrr/log-socket/v2`
- `CreateClient()` now accepts variadic `namespaces ...string` parameter
- `Logger` type now includes `Namespace` field
- New `NewLogger(namespace string)` constructor for namespaced loggers
**New Features:**
- **Namespace support**: Organize logs by namespace (api, database, auth, etc.)
- **Namespace filtering**: Subscribe to specific namespaces via WebSocket
- **Frontend namespace selector**: Filter logs by namespace in the web UI
- **Namespace API**: GET `/api/namespaces` to list all active namespaces
- **Colored terminal output**: Log levels are color-coded in terminal (no external packages)
## Features
- Real-time log streaming via WebSocket
- Web-based log viewer with filtering capabilities
- **Namespace-based log organization**
- Support for multiple log levels (TRACE, DEBUG, INFO, WARN, ERROR, PANIC, FATAL)
- Color-coded log levels for better visibility
- Auto-scrolling with toggle option
@@ -16,47 +32,228 @@ A real-time log viewer with WebSocket support, written in Go. This tool provides
## Installation
```bash
go install github.com/taigrr/log-socket@latest
go install github.com/taigrr/log-socket/v2@latest
```
## Example Preview
## Quick Start
1. Start the server:
```go
package main
import (
"net/http"
logger "github.com/taigrr/log-socket/v2/log"
"github.com/taigrr/log-socket/v2/ws"
"github.com/taigrr/log-socket/v2/browser"
)
func main() {
defer logger.Flush()
// Set up HTTP handlers
http.HandleFunc("/ws", ws.LogSocketHandler)
http.HandleFunc("/api/namespaces", ws.NamespacesHandler)
http.HandleFunc("/", browser.LogSocketViewHandler)
// Use default namespace
logger.Info("Application started")
// Create namespaced loggers
apiLogger := logger.NewLogger("api")
dbLogger := logger.NewLogger("database")
apiLogger.Info("API server ready")
dbLogger.Debug("Database connected")
logger.Fatal(http.ListenAndServe(":8080", nil))
}
```
## Usage
### Starting the Server
```bash
log-socket
```
By default, the server runs on `0.0.0.0:8080`. You can specify a different address using the `-addr` flag:
By default, the server runs on `0.0.0.0:8080`. Specify a different address:
```bash
log-socket -addr localhost:8080
```
2. Open your browser and navigate to `http://localhost:8080`
### Web Interface
![Log Socket Web Interface](browser/screenshot.png)
Open your browser and navigate to `http://localhost:8080`
## Logging Interface
**Namespace Filtering:**
- The namespace dropdown is automatically populated from `/api/namespaces`
- Select one or more namespaces to filter (hold Ctrl/Cmd to multi-select)
- Default is "All Namespaces" (shows everything)
- Click "Reconnect" to apply the filter
The package provides a comprehensive logging interface with the following methods:
## API
- `Trace/Tracef/Traceln`: For trace-level logging
- `Debug/Debugf/Debugln`: For debug-level logging
- `Info/Infof/Infoln`: For info-level logging
- `Notice/Noticef/Noticeln`: For notice-level logging
- `Warn/Warnf/Warnln`: For warning-level logging
- `Error/Errorf/Errorln`: For error-level logging
- `Panic/Panicf/Panicln`: For panic-level logging
- `Fatal/Fatalf/Fatalln`: For fatal-level logging
### Logging Interface
The package provides two ways to log:
#### 1. Package-level functions (default namespace)
```go
logger.Trace("trace message")
logger.Debug("debug message")
logger.Info("info message")
logger.Notice("notice message")
logger.Warn("warning message")
logger.Error("error message")
logger.Panic("panic message") // Logs and panics
logger.Fatal("fatal message") // Logs and exits
```
Each has formatted (`f`) and line (`ln`) variants:
```go
logger.Infof("User %s logged in", username)
logger.Infoln("This adds a newline")
```
#### 2. Namespaced loggers
```go
apiLogger := logger.NewLogger("api")
apiLogger.Info("Request received")
dbLogger := logger.NewLogger("database")
dbLogger.Warn("Slow query detected")
```
### Creating Clients with Namespace Filters
```go
// Listen to all namespaces
client := logger.CreateClient()
// Listen to specific namespace
client := logger.CreateClient("api")
// Listen to multiple namespaces
client := logger.CreateClient("api", "database", "auth")
```
### WebSocket API
#### Log Stream Endpoint
**URL:** `ws://localhost:8080/ws`
**Query Parameters:**
- `namespaces` (optional): Comma-separated list of namespaces to filter
**Examples:**
```
ws://localhost:8080/ws # All namespaces
ws://localhost:8080/ws?namespaces=api # Only "api" namespace
ws://localhost:8080/ws?namespaces=api,database # Multiple namespaces
```
**Message Format:**
```json
{
"timestamp": "2024-11-10T15:42:49.777298-05:00",
"output": "API request received",
"file": "main.go:42",
"level": "INFO",
"namespace": "api"
}
```
#### Namespaces List Endpoint
**URL:** `GET http://localhost:8080/api/namespaces`
**Response:**
```json
{
"namespaces": ["default", "api", "database", "auth"]
}
```
## Web Interface Features
- **Filtering**: Type in the search box to filter logs
- **Auto-scroll**: Toggle auto-scrolling with the checkbox
- **Namespace Dropdown**: Dynamically populated from `/api/namespaces`, multi-select support
- **Text Search**: Filter logs by content, level, namespace, or source file
- **Auto-scroll**: Toggle auto-scrolling with checkbox
- **Download**: Save all logs as a JSON file
- **Clear**: Remove all logs from the viewer
- **Color Coding**: Different log levels are color-coded for easy identification
- **Color Coding**: Different log levels are color-coded
- **Reconnect**: Reconnect WebSocket with new namespace filter
## Terminal Colors
Log output to stderr is automatically colorized when writing to a terminal. Colors are disabled when output is piped or redirected to a file.
### Color Scheme
| Level | Color |
|--------|--------------|
| TRACE | Gray |
| DEBUG | Cyan |
| INFO | Green |
| NOTICE | Blue |
| WARN | Yellow |
| ERROR | Red |
| PANIC | Bold Red |
| FATAL | Bold Red |
### Controlling Colors
```go
// Disable colors (e.g., for CI/CD or file output)
logger.SetColorEnabled(false)
// Enable colors explicitly
logger.SetColorEnabled(true)
// Check current state
if logger.ColorEnabled() {
// colors are on
}
```
Colors are implemented using standard ANSI escape codes with no external dependencies.
## Migration from v1
### Import Path
```go
// v1
import "github.com/taigrr/log-socket/log"
// v2
import "github.com/taigrr/log-socket/v2/log"
```
### CreateClient Changes
```go
// v1
client := log.CreateClient()
// v2 - specify namespace(s) or leave empty for all
client := log.CreateClient() // All namespaces
client := log.CreateClient("api") // Single namespace
client := log.CreateClient("api", "db") // Multiple namespaces
```
### New Logger Constructor
```go
// v2 only - create namespaced logger
apiLogger := log.NewLogger("api")
apiLogger.Info("Message in api namespace")
```
## Dependencies
@@ -64,6 +261,8 @@ The package provides a comprehensive logging interface with the following method
## Notes
The web interface is not meant to be used as-is.
It functions perfectly well for some scenarios, but it is broken out into a different package intentionally, such that users can add their own as they see fit.
It's mostly here to provide an example of how to consume the websocket data and display it.
The web interface is provided as an example implementation. Users are encouraged to customize it for their specific needs. The WebSocket endpoint (`/ws`) can be consumed by any WebSocket client.
## License
See LICENSE file for details.

Binary file not shown.

Before

Width:  |  Height:  |  Size: 196 KiB

After

Width:  |  Height:  |  Size: 117 KiB

View File

@@ -1,196 +1,706 @@
<!DOCTYPE html>
<html>
<html lang="en">
<head>
<meta charset="utf-8">
</head>
<body>
<center>
<input type="text" id="search" onkeyup="filterTable()" placeholder="Filter...">
<input type="checkbox" id="shouldScroll" checked>Enable Autoscroll<br>
<table id="logHeaders" style="text-align:left; width:80%;" >
<tbody>
<tr class="header">
<th style="width:20%;">TimeStamp</th>
<th style="width:5%;">Level</th>
<th style="width:65%;">Output</th>
<th style="width:10%;">Source</th>
</tr>
</tbody>
</table>
<div id="tableWrapper">
<table id="logs" style="text-align:left; width:100%;" >
<tbody id="tbodylogs">
<tr class="header">
<th style="width:20%;"></th>
<th style="width:5%;"></th>
<th style="width:65%;"></th>
<th style="width:10%;"></th>
</tr>
</tbody>
</table>
</div>
<br>
<input class="button" type="button" id="download" value="Download Logs" style="background-color:#3f51b5;"/>
<input class="button" type="button" id="delete" value="Delete Logs" style="background-color:#f44336"/>
</center>
</body>
<footer>
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Log Viewer</title>
<style>
#tableWrapper{
overflow-y: scroll;
display: flow-root;
width: 80%;
height: 80vh;
:root {
--primary-color: #2196F3;
--error-color: #f44336;
--warning-color: #ff9800;
--success-color: #4caf50;
--info-color: #2196F3;
--debug-color: #9c27b0;
--trace-color: #607d8b;
--bg-primary: #ffffff;
--bg-secondary: #f5f5f5;
--text-primary: #333333;
--text-secondary: #666666;
--border-color: #e0e0e0;
--shadow: 0 2px 4px rgba(0,0,0,0.1);
--shadow-hover: 0 4px 8px rgba(0,0,0,0.15);
}
td,tr{
height: min-content;
* {
box-sizing: border-box;
}
body {
margin: 0;
padding: 0;
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', sans-serif;
background-color: var(--bg-secondary);
color: var(--text-primary);
line-height: 1.6;
}
.container {
max-width: 1400px;
margin: 0 auto;
padding: 20px;
}
.header {
background: var(--bg-primary);
padding: 20px;
border-radius: 8px;
box-shadow: var(--shadow);
margin-bottom: 20px;
}
.header h1 {
margin: 0 0 20px 0;
color: var(--text-primary);
font-size: 24px;
font-weight: 600;
}
.controls {
display: flex;
gap: 15px;
align-items: center;
flex-wrap: wrap;
}
.search-container {
position: relative;
flex: 1;
min-width: 250px;
}
.search-input {
width: 100%;
padding: 10px 15px;
border: 2px solid var(--border-color);
border-radius: 6px;
font-size: 14px;
transition: border-color 0.2s ease;
}
.search-input:focus {
outline: none;
border-color: var(--primary-color);
}
.checkbox-container {
display: flex;
align-items: center;
gap: 8px;
user-select: none;
}
.checkbox-container input[type="checkbox"] {
width: 16px;
height: 16px;
}
.checkbox-container label {
font-size: 14px;
color: var(--text-secondary);
cursor: pointer;
}
.log-container {
background: var(--bg-primary);
border-radius: 8px;
box-shadow: var(--shadow);
overflow: hidden;
margin-bottom: 20px;
}
.log-header {
background: var(--bg-secondary);
padding: 12px 0;
border-bottom: 1px solid var(--border-color);
font-weight: 600;
font-size: 12px;
text-transform: uppercase;
letter-spacing: 0.5px;
}
.log-table {
width: 100%;
border-collapse: collapse;
}
.log-row {
display: grid;
grid-template-columns: 180px 80px 100px 1fr 120px;
gap: 15px;
padding: 10px 15px;
border-bottom: 1px solid var(--border-color);
transition: background-color 0.2s ease;
}
.log-row:hover {
background-color: rgba(0,0,0,0.02);
}
.log-cell {
display: flex;
align-items: center;
font-size: 13px;
word-break: break-word;
}
.log-cell.timestamp {
font-family: 'Monaco', 'Menlo', monospace;
color: var(--text-secondary);
font-size: 12px;
}
.log-cell.level {
font-weight: 600;
text-align: center;
justify-content: center;
}
.log-cell.output {
font-family: 'Monaco', 'Menlo', monospace;
white-space: pre-wrap;
}
.log-cell.source {
font-size: 11px;
color: var(--text-secondary);
}
.log-level-error { background-color: #ffebee; color: var(--error-color); }
.log-level-warn { background-color: #fff3e0; color: var(--warning-color); }
.log-level-info { background-color: #e3f2fd; color: var(--info-color); }
.log-level-debug { background-color: #f3e5f5; color: var(--debug-color); }
.log-level-trace { background-color: #eceff1; color: var(--trace-color); }
.log-viewer {
height: 60vh;
overflow-y: auto;
overflow-x: hidden;
}
.log-viewer::-webkit-scrollbar {
width: 8px;
}
.log-viewer::-webkit-scrollbar-track {
background: var(--bg-secondary);
}
.log-viewer::-webkit-scrollbar-thumb {
background: #ccc;
border-radius: 4px;
}
.log-viewer::-webkit-scrollbar-thumb:hover {
background: #999;
}
.actions {
display: flex;
gap: 10px;
justify-content: center;
flex-wrap: wrap;
}
.btn {
padding: 10px 20px;
border: none;
border-radius: 6px;
font-size: 14px;
font-weight: 500;
cursor: pointer;
transition: all 0.2s ease;
text-decoration: none;
display: inline-flex;
align-items: center;
gap: 8px;
}
.btn:hover {
transform: translateY(-1px);
box-shadow: var(--shadow-hover);
}
.btn:active {
transform: translateY(0);
}
.btn-primary {
background-color: var(--primary-color);
color: white;
}
.btn-danger {
background-color: var(--error-color);
color: white;
}
.status-bar {
background: var(--bg-primary);
padding: 10px 20px;
border-radius: 6px;
box-shadow: var(--shadow);
display: flex;
justify-content: space-between;
align-items: center;
font-size: 12px;
color: var(--text-secondary);
}
.connection-status {
display: flex;
align-items: center;
gap: 8px;
}
.status-indicator {
width: 8px;
height: 8px;
border-radius: 50%;
background-color: var(--error-color);
}
.status-indicator.connected {
background-color: var(--success-color);
}
.empty-state {
text-align: center;
padding: 40px 20px;
color: var(--text-secondary);
}
.empty-state h3 {
margin: 0 0 10px 0;
font-size: 18px;
}
.empty-state p {
margin: 0;
font-size: 14px;
}
@media (max-width: 768px) {
.container {
padding: 10px;
}
.controls {
flex-direction: column;
align-items: stretch;
}
.log-row {
grid-template-columns: 1fr;
gap: 5px;
}
.log-cell.timestamp::before {
content: 'Time: ';
font-weight: 600;
}
.log-cell.level::before {
content: 'Level: ';
font-weight: 600;
}
.log-cell.source::before {
content: 'Source: ';
font-weight: 600;
}
.log-header {
display: none;
}
.button{
display: inline-block;
width: 5vw;
height: 5vh;
}
</style>
</head>
<body>
<div class="container">
<header class="header">
<h1>📊 Log Viewer</h1>
<div class="controls">
<div class="search-container">
<select
id="namespaceFilter"
class="search-input"
multiple
size="1"
aria-label="Filter by namespace"
>
<option value="">All Namespaces (loading...)</option>
</select>
</div>
<div class="search-container">
<input
type="text"
id="search"
class="search-input"
placeholder="🔍 Filter logs..."
aria-label="Filter logs"
>
</div>
<div class="checkbox-container">
<input type="checkbox" id="shouldScroll" checked>
<label for="shouldScroll">Auto-scroll</label>
</div>
<button id="reconnectBtn" class="btn btn-primary">
🔄 Reconnect
</button>
</div>
</header>
<div class="log-container">
<div class="log-header">
<div class="log-row">
<div class="log-cell">Timestamp</div>
<div class="log-cell">Level</div>
<div class="log-cell">Namespace</div>
<div class="log-cell">Message</div>
<div class="log-cell">Source</div>
</div>
</div>
<div id="logViewer" class="log-viewer">
<div id="emptyState" class="empty-state">
<h3>No logs yet</h3>
<p>Waiting for log entries...</p>
</div>
</div>
</div>
<div class="actions">
<button id="downloadBtn" class="btn btn-primary">
📥 Download Logs
</button>
<button id="clearBtn" class="btn btn-danger">
🗑️ Clear Logs
</button>
</div>
<div class="status-bar">
<div class="connection-status">
<span class="status-indicator" id="statusIndicator"></span>
<span id="connectionStatus">Connecting...</span>
</div>
<div id="logCount">0 logs</div>
</div>
</div>
<script>
var logTable = document.getElementById("logs");
var logTableB = document.getElementById("tbodylogs");
var ws = null;
var application = "demo-commit"
var logs = [];
function sleep(ms) {
return new Promise(resolve => setTimeout(resolve, ms));
}
function download(filename, text) {
var element = document.createElement('a');
element.setAttribute('href', 'data:text/plain;charset=utf-8,' + encodeURIComponent(text));
element.setAttribute('download', filename);
element.style.display = 'none';
document.body.appendChild(element);
element.click();
document.body.removeChild(element);
}
function openSocket() {
if (ws) {
return false;
}
ws = new WebSocket("{{.}}");
ws.onclose = async function(evt) {
ws = null;
while(ws == null){
openSocket()
await sleep(5000);
}
}
ws.onmessage = function(evt) {
var entry = JSON.parse(evt.data)
logs.push(entry)
var row = document.createElement('tr');
var ts = document.createElement('td');
var tst = document.createTextNode(entry.timestamp);
ts.appendChild(tst);
row.appendChild(ts);
var ts = document.createElement('td');
var tst = document.createTextNode(entry.level);
ts.appendChild(tst);
row.appendChild(ts);
var ts = document.createElement('td');
var tst = document.createTextNode(entry.output);
ts.appendChild(tst);
row.appendChild(ts);
var ts = document.createElement('td');
var tst = document.createTextNode(entry.file);
ts.appendChild(tst);
row.appendChild(ts);
var bg="";
switch(entry.level){
case "INFO":
bg="white";
break;
case "ERROR":
bg="#f44336";
break;
case "WARN":
bg="#fb8c00"
break;
case "TRACE":
bg="#E1F5FE"
break;
case "DEBUG":
bg="#B3E5FC"
break;
default:
bg="white"
break;
}
row.style.backgroundColor=bg
logTableB.append(row)
filterTable()
}
ws.onerror = function(evt) {
if (evt != null && evt.data != null){
// handle error here
}
}
}
function clearTable(){
if(!window.confirm("Are you sure you want to delete all logs?")){
return
}
logs = []
class LogViewer {
constructor() {
this.ws = null;
this.logs = [];
this.filteredLogs = [];
this.isConnected = false;
this.reconnectAttempts = 0;
this.maxReconnectAttempts = 5;
while (logTableB.childNodes.length > 1) {
logTableB.removeChild(logTableB.childNodes[1]);
this.initializeElements();
this.attachEventListeners();
this.fetchNamespaces();
this.connectWebSocket();
this.startAutoScroll();
}
}
function filterTable() {
var cols, input, filter, table, tr, td, i, txtValue, w;
input = document.getElementById("search");
filter = input.value;
table = logTableB;
tr = table.getElementsByTagName("tr");
for (i = 1; i < tr.length; i++) {
cols = tr[i].getElementsByTagName("td");
var visible = false;
for (w = 0; w < cols.length; w++){
if (!visible && cols[w]) {
td = cols[w]
txtValue = td.textContent || td.innerText;
if (txtValue.indexOf(filter) > -1) {
visible = true
}
}
}
if(visible){
tr[i].style.display = "";
initializeElements() {
this.logViewer = document.getElementById('logViewer');
this.emptyState = document.getElementById('emptyState');
this.namespaceFilter = document.getElementById('namespaceFilter');
this.searchInput = document.getElementById('search');
this.scrollCheckbox = document.getElementById('shouldScroll');
this.downloadBtn = document.getElementById('downloadBtn');
this.clearBtn = document.getElementById('clearBtn');
this.reconnectBtn = document.getElementById('reconnectBtn');
this.statusIndicator = document.getElementById('statusIndicator');
this.connectionStatus = document.getElementById('connectionStatus');
this.logCount = document.getElementById('logCount');
}
attachEventListeners() {
this.searchInput.addEventListener('input', this.debounce(() => this.filterLogs(), 300));
this.namespaceFilter.addEventListener('change', () => this.reconnectWithNamespace());
this.downloadBtn.addEventListener('click', () => this.downloadLogs());
this.clearBtn.addEventListener('click', () => this.clearLogs());
this.reconnectBtn.addEventListener('click', () => this.reconnectWithNamespace());
}
async fetchNamespaces() {
try {
const response = await fetch('/api/namespaces');
const data = await response.json();
this.updateNamespaceFilter(data.namespaces || []);
} catch (error) {
console.error('Failed to fetch namespaces:', error);
}
}
updateNamespaceFilter(namespaces) {
// Clear existing options
this.namespaceFilter.innerHTML = '';
// Add "All" option
const allOption = document.createElement('option');
allOption.value = '';
allOption.textContent = 'All Namespaces';
allOption.selected = true;
this.namespaceFilter.appendChild(allOption);
// Add namespace options
namespaces.sort().forEach(ns => {
const option = document.createElement('option');
option.value = ns;
option.textContent = ns;
this.namespaceFilter.appendChild(option);
});
}
connectWebSocket() {
if (this.ws) return;
try {
let wsUrl = "{{.}}";
// Get selected namespace options from multi-select
const selectedOptions = Array.from(this.namespaceFilter.selectedOptions)
.map(opt => opt.value)
.filter(val => val !== ''); // Remove empty "All" option
// Add namespace filter if specific namespaces selected
if (selectedOptions.length > 0) {
const namespaces = selectedOptions.join(',');
const separator = wsUrl.includes('?') ? '&' : '?';
wsUrl += `${separator}namespaces=${encodeURIComponent(namespaces)}`;
}
this.ws = new WebSocket(wsUrl);
this.updateConnectionStatus('Connecting...', false);
this.ws.onopen = () => {
this.isConnected = true;
this.reconnectAttempts = 0;
this.updateConnectionStatus('Connected', true);
};
this.ws.onmessage = (event) => {
try {
const logEntry = JSON.parse(event.data);
this.addLogEntry(logEntry);
} catch (error) {
console.error('Failed to parse log entry:', error);
}
};
this.ws.onclose = () => {
this.isConnected = false;
this.ws = null;
this.updateConnectionStatus('Disconnected', false);
this.scheduleReconnect();
};
this.ws.onerror = (error) => {
console.error('WebSocket error:', error);
this.updateConnectionStatus('Connection Error', false);
};
} catch (error) {
console.error('Failed to create WebSocket:', error);
this.updateConnectionStatus('Connection Failed', false);
this.scheduleReconnect();
}
}
reconnectWithNamespace() {
if (this.ws) {
this.ws.onclose = null; // Prevent auto-reconnect
this.ws.close();
this.ws = null;
}
this.reconnectAttempts = 0;
this.connectWebSocket();
}
scheduleReconnect() {
if (this.reconnectAttempts < this.maxReconnectAttempts) {
this.reconnectAttempts++;
const delay = Math.min(1000 * Math.pow(2, this.reconnectAttempts), 30000);
this.updateConnectionStatus(`Reconnecting in ${Math.ceil(delay/1000)}s...`, false);
setTimeout(() => {
this.connectWebSocket();
}, delay);
} else {
tr[i].style.display = "none";
}
this.updateConnectionStatus('Connection Failed', false);
}
}
function pageScroll() {
if (document.getElementById('shouldScroll').checked) {
document.getElementById('tableWrapper').scrollBy(0,10);
addLogEntry(entry) {
this.logs.push(entry);
this.updateLogCount();
if (this.matchesFilter(entry)) {
this.renderLogEntry(entry);
this.hideEmptyState();
}
setTimeout(pageScroll,10);
}
document.getElementById("delete").addEventListener("click", function(){
renderLogEntry(entry) {
const logRow = document.createElement('div');
logRow.className = `log-row log-level-${entry.level.toLowerCase()}`;
clearTable()
}, false);
document.getElementById("download").addEventListener("click", function(){
logRow.innerHTML = `
<div class="log-cell timestamp">${this.formatTimestamp(entry.timestamp)}</div>
<div class="log-cell level">${entry.level}</div>
<div class="log-cell namespace">${this.escapeHtml(entry.namespace || 'default')}</div>
<div class="log-cell output">${this.escapeHtml(entry.output)}</div>
<div class="log-cell source">${this.escapeHtml(entry.file || 'N/A')}</div>
`;
download(application+'.json',JSON.stringify(logs));
}, false);
openSocket();
pageScroll();
this.logViewer.appendChild(logRow);
}
formatTimestamp(timestamp) {
try {
const date = new Date(timestamp);
return date.toLocaleString();
} catch {
return timestamp;
}
}
escapeHtml(text) {
const div = document.createElement('div');
div.textContent = text;
return div.innerHTML;
}
filterLogs() {
const query = this.searchInput.value.toLowerCase().trim();
// Clear current display
this.clearLogDisplay();
if (!query) {
// Show all logs
this.logs.forEach(log => this.renderLogEntry(log));
} else {
// Show filtered logs
const filtered = this.logs.filter(log =>
this.matchesQuery(log, query)
);
filtered.forEach(log => this.renderLogEntry(log));
}
if (this.logViewer.children.length === 0 ||
(this.logViewer.children.length === 1 && this.logViewer.contains(this.emptyState))) {
this.showEmptyState();
} else {
this.hideEmptyState();
}
}
matchesFilter(entry) {
const query = this.searchInput.value.toLowerCase().trim();
return !query || this.matchesQuery(entry, query);
}
matchesQuery(entry, query) {
return (
entry.output.toLowerCase().includes(query) ||
entry.level.toLowerCase().includes(query) ||
(entry.namespace && entry.namespace.toLowerCase().includes(query)) ||
(entry.file && entry.file.toLowerCase().includes(query)) ||
entry.timestamp.toLowerCase().includes(query)
);
}
clearLogDisplay() {
while (this.logViewer.firstChild && this.logViewer.firstChild !== this.emptyState) {
this.logViewer.removeChild(this.logViewer.firstChild);
}
}
showEmptyState() {
if (!this.logViewer.contains(this.emptyState)) {
this.logViewer.appendChild(this.emptyState);
}
}
hideEmptyState() {
if (this.logViewer.contains(this.emptyState)) {
this.logViewer.removeChild(this.emptyState);
}
}
clearLogs() {
if (!confirm('Are you sure you want to clear all logs?')) return;
this.logs = [];
this.clearLogDisplay();
this.showEmptyState();
this.updateLogCount();
}
downloadLogs() {
if (this.logs.length === 0) {
alert('No logs to download');
return;
}
const dataStr = JSON.stringify(this.logs, null, 2);
const dataBlob = new Blob([dataStr], { type: 'application/json' });
const link = document.createElement('a');
link.href = URL.createObjectURL(dataBlob);
link.download = `logs-${new Date().toISOString().split('T')[0]}.json`;
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
URL.revokeObjectURL(link.href);
}
updateConnectionStatus(status, connected) {
this.connectionStatus.textContent = status;
this.statusIndicator.classList.toggle('connected', connected);
}
updateLogCount() {
const count = this.logs.length;
this.logCount.textContent = `${count} log${count !== 1 ? 's' : ''}`;
}
startAutoScroll() {
const autoScroll = () => {
if (this.scrollCheckbox.checked && this.isConnected) {
this.logViewer.scrollTop = this.logViewer.scrollHeight;
}
requestAnimationFrame(autoScroll);
};
autoScroll();
}
debounce(func, wait) {
let timeout;
return function executedFunction(...args) {
const later = () => {
clearTimeout(timeout);
func(...args);
};
clearTimeout(timeout);
timeout = setTimeout(later, wait);
};
}
}
// Initialize the log viewer when the page loads
document.addEventListener('DOMContentLoaded', () => {
new LogViewer();
});
</script>
</footer>
</body>
</html>

36
examples/basic/main.go Normal file
View File

@@ -0,0 +1,36 @@
// Example: basic usage of log-socket as a drop-in logger.
//
// This demonstrates using the package-level logging functions,
// which work similarly to the standard library's log package.
package main
import (
"fmt"
"net/http"
"github.com/taigrr/log-socket/v2/browser"
logger "github.com/taigrr/log-socket/v2/log"
"github.com/taigrr/log-socket/v2/ws"
)
func main() {
defer logger.Flush()
// Set the minimum log level (default is LTrace, showing everything)
logger.SetLogLevel(logger.LDebug)
// Package-level functions log to the "default" namespace
logger.Info("Application starting up")
logger.Debug("Debug mode enabled")
logger.Warnf("Config file not found at %s, using defaults", "/etc/app/config.yaml")
logger.Errorf("Failed to connect to database: %s", "connection refused")
// Print/Printf/Println are aliases for Info
logger.Println("This is equivalent to Infoln")
// Start the web UI so you can view logs at http://localhost:8080
http.HandleFunc("/ws", ws.LogSocketHandler)
http.HandleFunc("/", browser.LogSocketViewHandler)
fmt.Println("Log viewer available at http://localhost:8080")
logger.Fatal(http.ListenAndServe("0.0.0.0:8080", nil))
}

63
examples/client/main.go Normal file
View File

@@ -0,0 +1,63 @@
// Example: programmatic log client with namespace filtering.
//
// This shows how to create a Client that receives log entries
// programmatically, optionally filtered to specific namespaces.
// Useful for building custom log processors, alerting, or forwarding.
package main
import (
"fmt"
"time"
logger "github.com/taigrr/log-socket/v2/log"
)
func main() {
defer logger.Flush()
// Create a client that receives ALL log entries
allLogs := logger.CreateClient()
allLogs.SetLogLevel(logger.LInfo)
// Create a client that only receives "database" and "auth" logs
securityLogs := logger.CreateClient("database", "auth")
securityLogs.SetLogLevel(logger.LWarn) // Only warnings and above
dbLog := logger.NewLogger("database")
authLog := logger.NewLogger("auth")
apiLog := logger.NewLogger("api")
// Process all logs
go func() {
for {
entry := allLogs.Get()
fmt.Printf("[ALL] %s [%s] %s: %s\n",
entry.Timestamp.Format(time.TimeOnly),
entry.Namespace, entry.Level, entry.Output)
}
}()
// Process only security-relevant warnings/errors
go func() {
for {
entry := securityLogs.Get()
if entry.Level == "ERROR" || entry.Level == "WARN" {
fmt.Printf("🚨 SECURITY ALERT [%s] %s: %s\n",
entry.Namespace, entry.Level, entry.Output)
}
}
}()
// Generate some logs
for i := 0; i < 5; i++ {
apiLog.Info("API request processed")
dbLog.Info("Query executed successfully")
dbLog.Warn("Connection pool running low")
authLog.Error("Brute force attempt detected")
time.Sleep(1 * time.Second)
}
// Clean up clients when done
allLogs.Destroy()
securityLogs.Destroy()
}

View File

@@ -0,0 +1,48 @@
// Example: log level filtering and all available levels.
//
// log-socket supports 8 log levels from TRACE (most verbose)
// to FATAL (least verbose). Setting a log level filters out
// everything below it.
package main
import (
"fmt"
logger "github.com/taigrr/log-socket/v2/log"
)
func main() {
defer logger.Flush()
fmt.Println("=== All log levels (TRACE and above) ===")
logger.SetLogLevel(logger.LTrace)
logger.Trace("Detailed execution trace — variable x = 42")
logger.Debug("Processing request for user_id=123")
logger.Info("Server started on :8080")
logger.Notice("Configuration reloaded")
logger.Warn("Disk usage at 85%")
logger.Error("Failed to send email: SMTP timeout")
// logger.Panic("...") — would panic
// logger.Fatal("...") — would os.Exit(1)
fmt.Println("\n=== Formatted variants ===")
logger.Infof("Request took %dms", 42)
logger.Warnf("Retrying in %d seconds (attempt %d/%d)", 5, 2, 3)
logger.Errorf("HTTP %d: %s", 503, "Service Unavailable")
fmt.Println("\n=== Only WARN and above ===")
logger.SetLogLevel(logger.LWarn)
logger.Debug("This will NOT appear")
logger.Info("This will NOT appear either")
logger.Warn("This WILL appear")
logger.Error("This WILL appear too")
fmt.Println("\n=== Per-logger levels via namespaced loggers ===")
logger.SetLogLevel(logger.LTrace) // Reset global
appLog := logger.NewLogger("app")
appLog.Info("Namespaced loggers inherit the global output but tag entries")
appLog.Warnf("Something needs attention in the %s subsystem", "app")
}

View File

@@ -0,0 +1,73 @@
// Example: namespace-based logging for organizing logs by component.
//
// Namespaces let you tag log entries by subsystem (api, database, auth, etc.)
// and filter them in the web UI or via programmatic clients.
package main
import (
"fmt"
"math/rand"
"net/http"
"time"
"github.com/taigrr/log-socket/v2/browser"
logger "github.com/taigrr/log-socket/v2/log"
"github.com/taigrr/log-socket/v2/ws"
)
func main() {
defer logger.Flush()
// Create loggers for different subsystems
apiLog := logger.NewLogger("api")
dbLog := logger.NewLogger("database")
authLog := logger.NewLogger("auth")
cacheLog := logger.NewLogger("cache")
// Simulate application activity
go func() {
for {
apiLog.Infof("GET /api/users — 200 OK (%dms)", rand.Intn(200))
apiLog.Debugf("Request headers: Accept=application/json")
time.Sleep(1 * time.Second)
}
}()
go func() {
for {
dbLog.Infof("SELECT * FROM users — %d rows", rand.Intn(100))
if rand.Float64() < 0.3 {
dbLog.Warn("Slow query detected (>500ms)")
}
time.Sleep(2 * time.Second)
}
}()
go func() {
for {
if rand.Float64() < 0.7 {
authLog.Info("User login successful")
} else {
authLog.Error("Failed login attempt from 192.168.1.42")
}
time.Sleep(3 * time.Second)
}
}()
go func() {
for {
cacheLog.Tracef("Cache hit ratio: %.1f%%", rand.Float64()*100)
if rand.Float64() < 0.1 {
cacheLog.Warn("Cache eviction triggered")
}
time.Sleep(5 * time.Second)
}
}()
// The /api/namespaces endpoint lists all active namespaces
http.HandleFunc("/ws", ws.LogSocketHandler)
http.HandleFunc("/api/namespaces", ws.NamespacesHandler)
http.HandleFunc("/", browser.LogSocketViewHandler)
fmt.Println("Log viewer with namespace filtering at http://localhost:8080")
logger.Fatal(http.ListenAndServe("0.0.0.0:8080", nil))
}

4
go.mod
View File

@@ -1,5 +1,5 @@
module github.com/taigrr/log-socket
module github.com/taigrr/log-socket/v2
go 1.21
go 1.26.0
require github.com/gorilla/websocket v1.5.3

171
log/bench_test.go Normal file
View File

@@ -0,0 +1,171 @@
package log
import (
"fmt"
"testing"
)
const benchNS = "bench-isolated-ns"
// benchClient creates a client with a continuous drain goroutine.
// Returns the client and a stop function to call after b.StopTimer().
func benchClient(ns string) (*Client, func()) {
c := CreateClient(ns)
c.SetLogLevel(LTrace)
done := make(chan struct{})
go func() {
for {
select {
case <-done:
return
case <-c.writer:
}
}
}()
return c, func() {
close(done)
c.Destroy()
}
}
// BenchmarkCreateClient measures client creation overhead.
func BenchmarkCreateClient(b *testing.B) {
for i := 0; i < b.N; i++ {
c := CreateClient("bench")
c.Destroy()
}
}
// BenchmarkTrace benchmarks Logger.Trace.
func BenchmarkTrace(b *testing.B) {
l := NewLogger(benchNS)
_, stop := benchClient(benchNS)
defer stop()
b.ResetTimer()
for i := 0; i < b.N; i++ {
l.Trace("benchmark trace message")
}
}
// BenchmarkTracef benchmarks formatted Logger.Tracef.
func BenchmarkTracef(b *testing.B) {
l := NewLogger(benchNS)
_, stop := benchClient(benchNS)
defer stop()
b.ResetTimer()
for i := 0; i < b.N; i++ {
l.Tracef("benchmark trace message %d", i)
}
}
// BenchmarkDebug benchmarks Logger.Debug.
func BenchmarkDebug(b *testing.B) {
l := NewLogger(benchNS)
_, stop := benchClient(benchNS)
defer stop()
b.ResetTimer()
for i := 0; i < b.N; i++ {
l.Debug("benchmark debug message")
}
}
// BenchmarkInfo benchmarks Logger.Info.
func BenchmarkInfo(b *testing.B) {
l := NewLogger(benchNS)
_, stop := benchClient(benchNS)
defer stop()
b.ResetTimer()
for i := 0; i < b.N; i++ {
l.Info("benchmark info message")
}
}
// BenchmarkInfof benchmarks Logger.Infof with formatting.
func BenchmarkInfof(b *testing.B) {
l := NewLogger(benchNS)
_, stop := benchClient(benchNS)
defer stop()
b.ResetTimer()
for i := 0; i < b.N; i++ {
l.Infof("user %s performed action %d", "testuser", i)
}
}
// BenchmarkWarn benchmarks Logger.Warn.
func BenchmarkWarn(b *testing.B) {
l := NewLogger(benchNS)
_, stop := benchClient(benchNS)
defer stop()
b.ResetTimer()
for i := 0; i < b.N; i++ {
l.Warn("benchmark warn message")
}
}
// BenchmarkError benchmarks Logger.Error.
func BenchmarkError(b *testing.B) {
l := NewLogger(benchNS)
_, stop := benchClient(benchNS)
defer stop()
b.ResetTimer()
for i := 0; i < b.N; i++ {
l.Error("benchmark error message")
}
}
// BenchmarkMultipleClients measures fan-out to multiple consumers.
func BenchmarkMultipleClients(b *testing.B) {
const numClients = 5
l := NewLogger(benchNS)
stops := make([]func(), numClients)
for i := range stops {
_, stops[i] = benchClient(benchNS)
}
b.ResetTimer()
for i := 0; i < b.N; i++ {
l.Info("benchmark multi-client")
}
b.StopTimer()
for _, stop := range stops {
stop()
}
}
// BenchmarkParallelInfo benchmarks concurrent Info calls from multiple goroutines.
func BenchmarkParallelInfo(b *testing.B) {
l := NewLogger(benchNS)
_, stop := benchClient(benchNS)
defer stop()
b.ResetTimer()
b.RunParallel(func(pb *testing.PB) {
for pb.Next() {
l.Info("parallel benchmark info")
}
})
}
// BenchmarkNamespaceFiltering benchmarks logging when the consumer
// filters by a different namespace (messages are not delivered).
func BenchmarkNamespaceFiltering(b *testing.B) {
l := NewLogger(benchNS)
_, stop := benchClient("completely-different-ns")
defer stop()
b.ResetTimer()
for i := 0; i < b.N; i++ {
l.Info("filtered out message")
}
}
// BenchmarkFileInfo measures the cost of runtime.Caller for file info.
func BenchmarkFileInfo(b *testing.B) {
for i := 0; i < b.N; i++ {
fileInfo(1)
}
}
// BenchmarkEntryCreation measures raw fmt.Sprint overhead (baseline).
func BenchmarkEntryCreation(b *testing.B) {
for i := 0; i < b.N; i++ {
_ = fmt.Sprint("benchmark message ", i)
}
}

99
log/color.go Normal file
View File

@@ -0,0 +1,99 @@
package log
import (
"os"
"sync"
)
// ANSI color codes for terminal output
const (
colorReset = "\033[0m"
colorRed = "\033[31m"
colorGreen = "\033[32m"
colorYellow = "\033[33m"
colorBlue = "\033[34m"
colorPurple = "\033[35m"
colorCyan = "\033[36m"
colorWhite = "\033[37m"
colorGray = "\033[90m"
// Bold variants
colorBoldRed = "\033[1;31m"
colorBoldYellow = "\033[1;33m"
colorBoldWhite = "\033[1;37m"
)
var (
colorEnabled = true
colorEnabledOnce sync.Once
colorMux sync.RWMutex
)
// SetColorEnabled enables or disables colored output for stderr logging.
// By default, color is enabled when stderr is a terminal.
func SetColorEnabled(enabled bool) {
colorMux.Lock()
colorEnabled = enabled
colorMux.Unlock()
}
// ColorEnabled returns whether colored output is currently enabled.
func ColorEnabled() bool {
colorMux.RLock()
defer colorMux.RUnlock()
return colorEnabled
}
// isTerminal checks if the given file descriptor is a terminal.
// This is a simple heuristic that works on Unix-like systems.
func isTerminal(f *os.File) bool {
stat, err := f.Stat()
if err != nil {
return false
}
return (stat.Mode() & os.ModeCharDevice) != 0
}
// initColorEnabled sets the default color state based on whether stderr is a terminal.
func initColorEnabled() {
colorEnabledOnce.Do(func() {
colorEnabled = isTerminal(os.Stderr)
})
}
// levelColor returns the ANSI color code for a given log level.
func levelColor(level Level) string {
switch level {
case LTrace:
return colorGray
case LDebug:
return colorCyan
case LInfo:
return colorGreen
case LNotice:
return colorBlue
case LWarn:
return colorYellow
case LError:
return colorRed
case LPanic:
return colorBoldRed
case LFatal:
return colorBoldRed
default:
return colorReset
}
}
// colorize wraps text with ANSI color codes if color is enabled.
func colorize(text string, color string) string {
if !ColorEnabled() {
return text
}
return color + text + colorReset
}
// colorizeLevelText returns the level string with appropriate color.
func colorizeLevelText(level string, lvl Level) string {
return colorize(level, levelColor(lvl))
}

View File

@@ -16,9 +16,13 @@ var (
stderrClient *Client
cleanup sync.Once
stderrFinished chan bool
namespaces map[string]bool
namespacesMux sync.RWMutex
)
func init() {
namespaces = make(map[string]bool)
initColorEnabled()
stderrClient = CreateClient()
stderrClient.SetLogLevel(LTrace)
stderrFinished = make(chan bool, 1)
@@ -27,16 +31,33 @@ func init() {
func (c *Client) logStdErr() {
for e := range c.writer {
if e.level >= c.LogLevel {
fmt.Fprintf(os.Stderr, "%s\t%s\t%s\t%s\n", e.Timestamp.String(), e.Level, e.Output, e.File)
if e.level >= c.LogLevel && c.matchesNamespace(e.Namespace) {
levelStr := colorizeLevelText(e.Level, e.level)
nsStr := colorize("["+e.Namespace+"]", colorPurple)
fileStr := colorize(e.File, colorGray)
fmt.Fprintf(os.Stderr, "%s\t%s\t%s\t%s\t%s\n", e.Timestamp.String(), levelStr, nsStr, e.Output, fileStr)
}
}
stderrFinished <- true
}
func CreateClient() *Client {
func (c *Client) matchesNamespace(namespace string) bool {
// Empty Namespaces slice means match all
if len(c.Namespaces) == 0 {
return true
}
for _, ns := range c.Namespaces {
if ns == namespace {
return true
}
}
return false
}
func CreateClient(namespaces ...string) *Client {
var client Client
client.initialized = true
client.Namespaces = namespaces
client.writer = make(LogWriter, 1000)
sliceTex.Lock()
clients = append(clients, &client)
@@ -53,19 +74,19 @@ func Flush() {
}
func (c *Client) Destroy() error {
var otherClients []*Client
if !c.initialized {
panic(errors.New("cannot delete uninitialized client, did you use CreateClient?"))
}
sliceTex.Lock()
c.writer = nil
c.initialized = false
var otherClients []*Client
for _, x := range clients {
if x.initialized {
if x != c && x.initialized {
otherClients = append(otherClients, x)
}
}
clients = otherClients
c.writer = nil
sliceTex.Unlock()
return nil
}
@@ -78,9 +99,21 @@ func (c *Client) GetLogLevel() Level {
}
func createLog(e Entry) {
// Track namespace
namespacesMux.Lock()
namespaces[e.Namespace] = true
namespacesMux.Unlock()
sliceTex.Lock()
for _, c := range clients {
func(c *Client, e Entry) {
if c.writer == nil || !c.initialized {
return
}
// Filter by namespace if client has filters specified
if !c.matchesNamespace(e.Namespace) {
return
}
select {
case c.writer <- e:
// try to clear out one of the older entries
@@ -96,6 +129,18 @@ func createLog(e Entry) {
sliceTex.Unlock()
}
// GetNamespaces returns a list of all namespaces that have been used
func GetNamespaces() []string {
namespacesMux.RLock()
defer namespacesMux.RUnlock()
result := make([]string, 0, len(namespaces))
for ns := range namespaces {
result = append(result, ns)
}
return result
}
func SetLogLevel(level Level) {
stderrClient.LogLevel = level
}
@@ -123,6 +168,7 @@ func Trace(args ...any) {
Output: output,
File: fileInfo(2),
Level: "TRACE",
Namespace: DefaultNamespace,
level: LTrace,
}
createLog(e)
@@ -137,6 +183,7 @@ func Tracef(format string, args ...any) {
File: fileInfo(2),
Level: "TRACE",
level: LTrace,
Namespace: DefaultNamespace,
}
createLog(e)
}
@@ -150,6 +197,7 @@ func Traceln(args ...any) {
File: fileInfo(2),
Level: "TRACE",
level: LTrace,
Namespace: DefaultNamespace,
}
createLog(e)
}
@@ -163,6 +211,7 @@ func Debug(args ...any) {
File: fileInfo(2),
Level: "DEBUG",
level: LDebug,
Namespace: DefaultNamespace,
}
createLog(e)
}
@@ -176,6 +225,7 @@ func Debugf(format string, args ...any) {
File: fileInfo(2),
Level: "DEBUG",
level: LDebug,
Namespace: DefaultNamespace,
}
createLog(e)
}
@@ -189,6 +239,7 @@ func Debugln(args ...any) {
File: fileInfo(2),
Level: "DEBUG",
level: LDebug,
Namespace: DefaultNamespace,
}
createLog(e)
}
@@ -202,6 +253,7 @@ func Info(args ...any) {
File: fileInfo(2),
Level: "INFO",
level: LInfo,
Namespace: DefaultNamespace,
}
createLog(e)
}
@@ -215,6 +267,7 @@ func Infof(format string, args ...any) {
File: fileInfo(2),
Level: "INFO",
level: LInfo,
Namespace: DefaultNamespace,
}
createLog(e)
}
@@ -228,6 +281,7 @@ func Infoln(args ...any) {
File: fileInfo(2),
Level: "INFO",
level: LInfo,
Namespace: DefaultNamespace,
}
createLog(e)
}
@@ -241,6 +295,7 @@ func Notice(args ...any) {
File: fileInfo(2),
Level: "NOTICE",
level: LNotice,
Namespace: DefaultNamespace,
}
createLog(e)
}
@@ -254,6 +309,7 @@ func Noticef(format string, args ...any) {
File: fileInfo(2),
Level: "NOTICE",
level: LNotice,
Namespace: DefaultNamespace,
}
createLog(e)
}
@@ -267,6 +323,7 @@ func Noticeln(args ...any) {
File: fileInfo(2),
Level: "NOTICE",
level: LNotice,
Namespace: DefaultNamespace,
}
createLog(e)
}
@@ -280,6 +337,7 @@ func Warn(args ...any) {
File: fileInfo(2),
Level: "WARN",
level: LWarn,
Namespace: DefaultNamespace,
}
createLog(e)
}
@@ -293,6 +351,7 @@ func Warnf(format string, args ...any) {
File: fileInfo(2),
Level: "WARN",
level: LWarn,
Namespace: DefaultNamespace,
}
createLog(e)
}
@@ -306,6 +365,7 @@ func Warnln(args ...any) {
File: fileInfo(2),
Level: "WARN",
level: LWarn,
Namespace: DefaultNamespace,
}
createLog(e)
}
@@ -319,6 +379,7 @@ func Error(args ...any) {
File: fileInfo(2),
Level: "ERROR",
level: LError,
Namespace: DefaultNamespace,
}
createLog(e)
}
@@ -332,6 +393,7 @@ func Errorf(format string, args ...any) {
File: fileInfo(2),
Level: "ERROR",
level: LError,
Namespace: DefaultNamespace,
}
createLog(e)
}
@@ -345,6 +407,7 @@ func Errorln(args ...any) {
File: fileInfo(2),
Level: "ERROR",
level: LError,
Namespace: DefaultNamespace,
}
createLog(e)
}
@@ -358,9 +421,10 @@ func Panic(args ...any) {
File: fileInfo(2),
Level: "PANIC",
level: LPanic,
Namespace: DefaultNamespace,
}
createLog(e)
if len(args) >= 0 {
if len(args) > 0 {
switch args[0].(type) {
case error:
panic(args[0])
@@ -381,9 +445,10 @@ func Panicf(format string, args ...any) {
File: fileInfo(2),
Level: "PANIC",
level: LPanic,
Namespace: DefaultNamespace,
}
createLog(e)
if len(args) >= 0 {
if len(args) > 0 {
switch args[0].(type) {
case error:
panic(args[0])
@@ -403,9 +468,10 @@ func Panicln(args ...any) {
File: fileInfo(2),
Level: "PANIC",
level: LPanic,
Namespace: DefaultNamespace,
}
createLog(e)
if len(args) >= 0 {
if len(args) > 0 {
switch args[0].(type) {
case error:
panic(args[0])
@@ -426,6 +492,7 @@ func Fatal(args ...any) {
File: fileInfo(2),
Level: "FATAL",
level: LFatal,
Namespace: DefaultNamespace,
}
createLog(e)
Flush()
@@ -441,6 +508,7 @@ func Fatalf(format string, args ...any) {
File: fileInfo(2),
Level: "FATAL",
level: LFatal,
Namespace: DefaultNamespace,
}
createLog(e)
Flush()
@@ -455,6 +523,7 @@ func Fatalln(args ...any) {
File: fileInfo(2),
Level: "FATAL",
level: LFatal,
Namespace: DefaultNamespace,
}
createLog(e)
Flush()
@@ -487,3 +556,37 @@ func fileInfo(skip int) string {
}
return fmt.Sprintf("%s:%d", file, line)
}
// Broadcast sends an [Entry] to all registered clients. This is the public
// entry point used by adapter packages (such as the slog handler) that
// construct entries themselves. The unexported level field is inferred from
// [Entry.Level] when not already set.
func Broadcast(e Entry) {
if e.level == 0 && e.Level != "" && e.Level != "TRACE" {
e.level = parseLevelString(e.Level)
}
createLog(e)
}
func parseLevelString(s string) Level {
switch s {
case "TRACE":
return LTrace
case "DEBUG":
return LDebug
case "INFO":
return LInfo
case "NOTICE":
return LNotice
case "WARN":
return LWarn
case "ERROR":
return LError
case "PANIC":
return LPanic
case "FATAL":
return LFatal
default:
return LInfo
}
}

View File

@@ -13,7 +13,7 @@ func TestCreateDestroy(t *testing.T) {
t.Errorf("Expected 1 client, but found %d", len(clients))
}
// Create a new client, ensure it's added
c := CreateClient()
c := CreateClient("test")
if len(clients) != 2 {
t.Errorf("Expected 2 clients, but found %d", len(clients))
}
@@ -27,7 +27,7 @@ func TestCreateDestroy(t *testing.T) {
// SetLogLevel set log level of logger
func TestSetLogLevel(t *testing.T) {
logLevels := [...]Level{LTrace, LDebug, LInfo, LWarn, LError, LPanic, LFatal}
c := CreateClient()
c := CreateClient("test")
for _, x := range logLevels {
c.SetLogLevel(x)
if c.GetLogLevel() != x {
@@ -38,7 +38,7 @@ func TestSetLogLevel(t *testing.T) {
}
func BenchmarkDebugSerial(b *testing.B) {
c := CreateClient()
c := CreateClient("test")
var x sync.WaitGroup
x.Add(b.N)
for i := 0; i < b.N; i++ {
@@ -55,7 +55,7 @@ func BenchmarkDebugSerial(b *testing.B) {
// Trace ensure logs come out in the right order
func TestOrder(t *testing.T) {
testString := "Testing trace: "
c := CreateClient()
c := CreateClient(DefaultNamespace)
c.SetLogLevel(LTrace)
for i := 0; i < 5000; i++ {

View File

@@ -8,7 +8,14 @@ import (
)
func Default() *Logger {
return &Logger{FileInfoDepth: 0}
return &Logger{FileInfoDepth: 0, Namespace: DefaultNamespace}
}
func NewLogger(namespace string) *Logger {
if namespace == "" {
namespace = DefaultNamespace
}
return &Logger{FileInfoDepth: 0, Namespace: namespace}
}
func (l *Logger) SetInfoDepth(depth int) {
@@ -21,9 +28,10 @@ func (l Logger) Trace(args ...any) {
e := Entry{
Timestamp: time.Now(),
Output: output,
File: fileInfo(l.FileInfoDepth),
File: fileInfo(2 + l.FileInfoDepth),
Level: "TRACE",
level: LTrace,
Namespace: l.Namespace,
}
createLog(e)
}
@@ -34,9 +42,10 @@ func (l Logger) Tracef(format string, args ...any) {
e := Entry{
Timestamp: time.Now(),
Output: output,
File: fileInfo(l.FileInfoDepth),
File: fileInfo(2 + l.FileInfoDepth),
Level: "TRACE",
level: LTrace,
Namespace: l.Namespace,
}
createLog(e)
}
@@ -47,9 +56,10 @@ func (l Logger) Traceln(args ...any) {
e := Entry{
Timestamp: time.Now(),
Output: output,
File: fileInfo(l.FileInfoDepth),
File: fileInfo(2 + l.FileInfoDepth),
Level: "TRACE",
level: LTrace,
Namespace: l.Namespace,
}
createLog(e)
}
@@ -60,9 +70,10 @@ func (l Logger) Debug(args ...any) {
e := Entry{
Timestamp: time.Now(),
Output: output,
File: fileInfo(l.FileInfoDepth),
File: fileInfo(2 + l.FileInfoDepth),
Level: "DEBUG",
level: LDebug,
Namespace: l.Namespace,
}
createLog(e)
}
@@ -73,9 +84,10 @@ func (l Logger) Debugf(format string, args ...any) {
e := Entry{
Timestamp: time.Now(),
Output: output,
File: fileInfo(l.FileInfoDepth),
File: fileInfo(2 + l.FileInfoDepth),
Level: "DEBUG",
level: LDebug,
Namespace: l.Namespace,
}
createLog(e)
}
@@ -86,9 +98,10 @@ func (l Logger) Info(args ...any) {
e := Entry{
Timestamp: time.Now(),
Output: output,
File: fileInfo(l.FileInfoDepth),
File: fileInfo(2 + l.FileInfoDepth),
Level: "INFO",
level: LInfo,
Namespace: l.Namespace,
}
createLog(e)
}
@@ -99,9 +112,10 @@ func (l Logger) Infof(format string, args ...any) {
e := Entry{
Timestamp: time.Now(),
Output: output,
File: fileInfo(l.FileInfoDepth),
File: fileInfo(2 + l.FileInfoDepth),
Level: "INFO",
level: LInfo,
Namespace: l.Namespace,
}
createLog(e)
}
@@ -112,9 +126,10 @@ func (l Logger) Infoln(args ...any) {
e := Entry{
Timestamp: time.Now(),
Output: output,
File: fileInfo(l.FileInfoDepth),
File: fileInfo(2 + l.FileInfoDepth),
Level: "INFO",
level: LInfo,
Namespace: l.Namespace,
}
createLog(e)
}
@@ -125,9 +140,10 @@ func (l Logger) Notice(args ...any) {
e := Entry{
Timestamp: time.Now(),
Output: output,
File: fileInfo(l.FileInfoDepth),
File: fileInfo(2 + l.FileInfoDepth),
Level: "NOTICE",
level: LNotice,
Namespace: l.Namespace,
}
createLog(e)
}
@@ -138,9 +154,10 @@ func (l Logger) Noticef(format string, args ...any) {
e := Entry{
Timestamp: time.Now(),
Output: output,
File: fileInfo(l.FileInfoDepth),
File: fileInfo(2 + l.FileInfoDepth),
Level: "NOTICE",
level: LNotice,
Namespace: l.Namespace,
}
createLog(e)
}
@@ -151,9 +168,10 @@ func (l Logger) Noticeln(args ...any) {
e := Entry{
Timestamp: time.Now(),
Output: output,
File: fileInfo(l.FileInfoDepth),
File: fileInfo(2 + l.FileInfoDepth),
Level: "NOTICE",
level: LNotice,
Namespace: l.Namespace,
}
createLog(e)
}
@@ -164,9 +182,10 @@ func (l Logger) Warn(args ...any) {
e := Entry{
Timestamp: time.Now(),
Output: output,
File: fileInfo(l.FileInfoDepth),
File: fileInfo(2 + l.FileInfoDepth),
Level: "WARN",
level: LWarn,
Namespace: l.Namespace,
}
createLog(e)
}
@@ -177,9 +196,10 @@ func (l Logger) Warnf(format string, args ...any) {
e := Entry{
Timestamp: time.Now(),
Output: output,
File: fileInfo(l.FileInfoDepth),
File: fileInfo(2 + l.FileInfoDepth),
Level: "WARN",
level: LWarn,
Namespace: l.Namespace,
}
createLog(e)
}
@@ -190,9 +210,10 @@ func (l Logger) Warnln(args ...any) {
e := Entry{
Timestamp: time.Now(),
Output: output,
File: fileInfo(l.FileInfoDepth),
File: fileInfo(2 + l.FileInfoDepth),
Level: "WARN",
level: LWarn,
Namespace: l.Namespace,
}
createLog(e)
}
@@ -203,9 +224,10 @@ func (l Logger) Error(args ...any) {
e := Entry{
Timestamp: time.Now(),
Output: output,
File: fileInfo(l.FileInfoDepth),
File: fileInfo(2 + l.FileInfoDepth),
Level: "ERROR",
level: LError,
Namespace: l.Namespace,
}
createLog(e)
}
@@ -216,9 +238,10 @@ func (l Logger) Errorf(format string, args ...any) {
e := Entry{
Timestamp: time.Now(),
Output: output,
File: fileInfo(l.FileInfoDepth),
File: fileInfo(2 + l.FileInfoDepth),
Level: "ERROR",
level: LError,
Namespace: l.Namespace,
}
createLog(e)
}
@@ -229,9 +252,10 @@ func (l Logger) Errorln(args ...any) {
e := Entry{
Timestamp: time.Now(),
Output: output,
File: fileInfo(l.FileInfoDepth),
File: fileInfo(2 + l.FileInfoDepth),
Level: "ERROR",
level: LError,
Namespace: l.Namespace,
}
createLog(e)
}
@@ -242,12 +266,13 @@ func (l Logger) Panic(args ...any) {
e := Entry{
Timestamp: time.Now(),
Output: output,
File: fileInfo(l.FileInfoDepth),
File: fileInfo(2 + l.FileInfoDepth),
Level: "PANIC",
level: LPanic,
Namespace: l.Namespace,
}
createLog(e)
if len(args) >= 0 {
if len(args) > 0 {
switch args[0].(type) {
case error:
panic(args[0])
@@ -265,12 +290,13 @@ func (l Logger) Panicf(format string, args ...any) {
e := Entry{
Timestamp: time.Now(),
Output: output,
File: fileInfo(l.FileInfoDepth),
File: fileInfo(2 + l.FileInfoDepth),
Level: "PANIC",
level: LPanic,
Namespace: l.Namespace,
}
createLog(e)
if len(args) >= 0 {
if len(args) > 0 {
switch args[0].(type) {
case error:
panic(args[0])
@@ -288,12 +314,13 @@ func (l Logger) Panicln(args ...any) {
e := Entry{
Timestamp: time.Now(),
Output: output,
File: fileInfo(l.FileInfoDepth),
File: fileInfo(2 + l.FileInfoDepth),
Level: "PANIC",
level: LPanic,
Namespace: l.Namespace,
}
createLog(e)
if len(args) >= 0 {
if len(args) > 0 {
switch args[0].(type) {
case error:
panic(args[0])
@@ -311,9 +338,10 @@ func (l Logger) Fatal(args ...any) {
e := Entry{
Timestamp: time.Now(),
Output: output,
File: fileInfo(l.FileInfoDepth),
File: fileInfo(2 + l.FileInfoDepth),
Level: "FATAL",
level: LFatal,
Namespace: l.Namespace,
}
createLog(e)
Flush()
@@ -326,9 +354,10 @@ func (l Logger) Fatalf(format string, args ...any) {
e := Entry{
Timestamp: time.Now(),
Output: output,
File: fileInfo(l.FileInfoDepth),
File: fileInfo(2 + l.FileInfoDepth),
Level: "FATAL",
level: LFatal,
Namespace: l.Namespace,
}
createLog(e)
Flush()
@@ -341,9 +370,10 @@ func (l Logger) Fatalln(args ...any) {
e := Entry{
Timestamp: time.Now(),
Output: output,
File: fileInfo(l.FileInfoDepth),
File: fileInfo(2 + l.FileInfoDepth),
Level: "FATAL",
level: LFatal,
Namespace: l.Namespace,
}
createLog(e)
Flush()

View File

@@ -13,12 +13,15 @@ const (
LFatal
)
const DefaultNamespace = "default"
type (
LogWriter chan Entry
Level int
Client struct {
LogLevel Level `json:"level"`
Namespaces []string `json:"namespaces"` // Empty slice means all namespaces
writer LogWriter
initialized bool
}
@@ -27,9 +30,11 @@ type (
Output string `json:"output"`
File string `json:"file"`
Level string `json:"level"`
Namespace string `json:"namespace"`
level Level
}
Logger struct {
FileInfoDepth int
Namespace string
}
)

30
main.go
View File

@@ -5,20 +5,33 @@ import (
"net/http"
"time"
"github.com/taigrr/log-socket/browser"
logger "github.com/taigrr/log-socket/log"
"github.com/taigrr/log-socket/ws"
"github.com/taigrr/log-socket/v2/browser"
logger "github.com/taigrr/log-socket/v2/log"
"github.com/taigrr/log-socket/v2/ws"
)
var addr = flag.String("addr", "0.0.0.0:8080", "http service address")
func generateLogs() {
// Create loggers for different namespaces
apiLogger := logger.NewLogger("api")
dbLogger := logger.NewLogger("database")
authLogger := logger.NewLogger("auth")
for {
logger.Info("This is an info log!")
logger.Trace("This is a trace log!")
logger.Debug("This is a debug log!")
logger.Warn("This is a warn log!")
logger.Error("This is an error log!")
logger.Info("This is a default namespace log!")
apiLogger.Info("API request received")
apiLogger.Debug("Processing API call")
dbLogger.Info("Database query executed")
dbLogger.Warn("Slow query detected")
authLogger.Info("User authentication successful")
authLogger.Error("Failed login attempt detected")
logger.Trace("This is a trace log in default namespace!")
logger.Warn("This is a warning in default namespace!")
time.Sleep(2 * time.Second)
}
}
@@ -27,6 +40,7 @@ func main() {
defer logger.Flush()
flag.Parse()
http.HandleFunc("/ws", ws.LogSocketHandler)
http.HandleFunc("/api/namespaces", ws.NamespacesHandler)
http.HandleFunc("/", browser.LogSocketViewHandler)
go generateLogs()
logger.Fatal(http.ListenAndServe(*addr, nil))

165
slog/handler.go Normal file
View File

@@ -0,0 +1,165 @@
// Package slog provides a [log/slog.Handler] that routes structured log
// records into the log-socket broadcasting system, giving every slog-based
// caller free WebSocket streaming and the browser viewer UI.
package slog
import (
"context"
"fmt"
"log/slog"
"runtime"
"strings"
"github.com/taigrr/log-socket/v2/log"
)
// Handler implements [slog.Handler] by converting each [slog.Record] into a
// log-socket [log.Entry] and feeding it through the normal broadcast path.
//
// Attributes accumulated via [Handler.WithAttrs] are prepended to the log
// message as key=value pairs. Groups set via [Handler.WithGroup] prefix
// attribute keys with "group.".
type Handler struct {
namespace string
level slog.Level
attrs []slog.Attr
groups []string
}
// ensure interface compliance at compile time.
var _ slog.Handler = (*Handler)(nil)
// Option configures a [Handler].
type Option func(*Handler)
// WithNamespace sets the log-socket namespace for entries produced by this
// handler. If empty, [log.DefaultNamespace] is used.
func WithNamespace(ns string) Option {
return func(h *Handler) {
h.namespace = ns
}
}
// WithLevel sets the minimum slog level the handler will accept.
func WithLevel(l slog.Level) Option {
return func(h *Handler) {
h.level = l
}
}
// NewHandler returns a new [Handler] that writes to the log-socket broadcast
// system. Options may be used to set the namespace and minimum level.
func NewHandler(opts ...Option) *Handler {
h := &Handler{
namespace: log.DefaultNamespace,
level: slog.LevelDebug,
}
for _, o := range opts {
o(h)
}
return h
}
// Enabled reports whether the handler is configured to process records at
// the given level.
func (h *Handler) Enabled(_ context.Context, level slog.Level) bool {
return level >= h.level
}
// Handle converts r into a log-socket Entry and broadcasts it.
func (h *Handler) Handle(_ context.Context, r slog.Record) error {
var b strings.Builder
b.WriteString(r.Message)
// Append pre-collected attrs.
for _, a := range h.attrs {
writeAttr(&b, h.groups, a)
}
// Append record-level attrs.
r.Attrs(func(a slog.Attr) bool {
writeAttr(&b, h.groups, a)
return true
})
file := "???"
if r.PC != 0 {
fs := runtime.CallersFrames([]uintptr{r.PC})
f, _ := fs.Next()
if f.File != "" {
short := f.File
if idx := strings.LastIndex(short, "/"); idx >= 0 {
short = short[idx+1:]
}
file = fmt.Sprintf("%s:%d", short, f.Line)
}
}
e := log.Entry{
Timestamp: r.Time,
Output: b.String(),
File: file,
Level: slogLevelToString(r.Level),
Namespace: h.namespace,
}
log.Broadcast(e)
return nil
}
// WithAttrs returns a new Handler with the given attributes appended.
func (h *Handler) WithAttrs(attrs []slog.Attr) slog.Handler {
h2 := h.clone()
h2.attrs = append(h2.attrs, attrs...)
return h2
}
// WithGroup returns a new Handler where subsequent attributes are nested
// under the given group name.
func (h *Handler) WithGroup(name string) slog.Handler {
if name == "" {
return h
}
h2 := h.clone()
h2.groups = append(h2.groups, name)
return h2
}
func (h *Handler) clone() *Handler {
h2 := &Handler{
namespace: h.namespace,
level: h.level,
attrs: make([]slog.Attr, len(h.attrs)),
groups: make([]string, len(h.groups)),
}
copy(h2.attrs, h.attrs)
copy(h2.groups, h.groups)
return h2
}
func writeAttr(b *strings.Builder, groups []string, a slog.Attr) {
a.Value = a.Value.Resolve()
if a.Equal(slog.Attr{}) {
return
}
b.WriteByte(' ')
for _, g := range groups {
b.WriteString(g)
b.WriteByte('.')
}
b.WriteString(a.Key)
b.WriteByte('=')
b.WriteString(a.Value.String())
}
func slogLevelToString(l slog.Level) string {
switch {
case l >= slog.LevelError:
return "ERROR"
case l >= slog.LevelWarn:
return "WARN"
case l >= slog.LevelInfo:
return "INFO"
default:
return "DEBUG"
}
}

118
slog/handler_test.go Normal file
View File

@@ -0,0 +1,118 @@
package slog
import (
"context"
"log/slog"
"testing"
"time"
"github.com/taigrr/log-socket/v2/log"
)
// getWithTimeout reads from a log client with a timeout to avoid hanging tests.
func getWithTimeout(c *log.Client, timeout time.Duration) (log.Entry, bool) {
ch := make(chan log.Entry, 1)
go func() { ch <- c.Get() }()
select {
case e := <-ch:
return e, true
case <-time.After(timeout):
return log.Entry{}, false
}
}
func TestHandler_Enabled(t *testing.T) {
h := NewHandler(WithLevel(slog.LevelWarn))
if h.Enabled(context.Background(), slog.LevelInfo) {
t.Error("expected Info to be disabled when min level is Warn")
}
if !h.Enabled(context.Background(), slog.LevelWarn) {
t.Error("expected Warn to be enabled")
}
if !h.Enabled(context.Background(), slog.LevelError) {
t.Error("expected Error to be enabled")
}
}
func TestHandler_Handle(t *testing.T) {
c := log.CreateClient()
defer c.Destroy()
c.SetLogLevel(log.LTrace)
h := NewHandler(WithNamespace("test-ns"))
logger := slog.New(h)
logger.Info("hello world", "key", "value")
e, ok := getWithTimeout(c, time.Second)
if !ok {
t.Fatal("timed out waiting for log entry")
}
if e.Namespace != "test-ns" {
t.Errorf("namespace = %q, want %q", e.Namespace, "test-ns")
}
if e.Level != "INFO" {
t.Errorf("level = %q, want INFO", e.Level)
}
if e.Output == "" {
t.Error("output should not be empty")
}
}
func TestHandler_WithAttrs(t *testing.T) {
c := log.CreateClient()
defer c.Destroy()
c.SetLogLevel(log.LTrace)
h := NewHandler()
h2 := h.WithAttrs([]slog.Attr{slog.String("service", "api")})
logger := slog.New(h2)
logger.Info("request")
e, ok := getWithTimeout(c, time.Second)
if !ok {
t.Fatal("timed out")
}
if e.Output != "request service=api" {
t.Errorf("output = %q, want %q", e.Output, "request service=api")
}
}
func TestHandler_WithGroup(t *testing.T) {
c := log.CreateClient()
defer c.Destroy()
c.SetLogLevel(log.LTrace)
h := NewHandler()
h2 := h.WithGroup("http").WithAttrs([]slog.Attr{slog.Int("status", 200)})
logger := slog.New(h2)
logger.Info("done")
e, ok := getWithTimeout(c, time.Second)
if !ok {
t.Fatal("timed out")
}
if e.Output != "done http.status=200" {
t.Errorf("output = %q, want %q", e.Output, "done http.status=200")
}
}
func TestSlogLevelMapping(t *testing.T) {
tests := []struct {
level slog.Level
want string
}{
{slog.LevelDebug, "DEBUG"},
{slog.LevelInfo, "INFO"},
{slog.LevelWarn, "WARN"},
{slog.LevelError, "ERROR"},
}
for _, tt := range tests {
got := slogLevelToString(tt.level)
if got != tt.want {
t.Errorf("slogLevelToString(%v) = %q, want %q", tt.level, got, tt.want)
}
}
}

17
ws/namespaces.go Normal file
View File

@@ -0,0 +1,17 @@
package ws
import (
"encoding/json"
"net/http"
logger "github.com/taigrr/log-socket/v2/log"
)
// NamespacesHandler returns a JSON list of all namespaces that have been used
func NamespacesHandler(w http.ResponseWriter, r *http.Request) {
namespaces := logger.GetNamespaces()
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(map[string]interface{}{
"namespaces": namespaces,
})
}

View File

@@ -3,9 +3,10 @@ package ws
import (
"encoding/json"
"net/http"
"strings"
"github.com/gorilla/websocket"
logger "github.com/taigrr/log-socket/log"
logger "github.com/taigrr/log-socket/v2/log"
)
var upgrader = websocket.Upgrader{} // use default options
@@ -15,13 +16,21 @@ func SetUpgrader(u websocket.Upgrader) {
}
func LogSocketHandler(w http.ResponseWriter, r *http.Request) {
// Get namespaces from query parameter, comma-separated
// Empty or missing means all namespaces
namespacesParam := r.URL.Query().Get("namespaces")
var namespaces []string
if namespacesParam != "" {
namespaces = strings.Split(namespacesParam, ",")
}
c, err := upgrader.Upgrade(w, r, nil)
if err != nil {
logger.Error("upgrade:", err)
return
}
defer c.Close()
lc := logger.CreateClient()
lc := logger.CreateClient(namespaces...)
defer lc.Destroy()
lc.SetLogLevel(logger.LTrace)
logger.Info("Websocket client attached.")