diff --git a/CRUSH.md b/CRUSH.md new file mode 100644 index 0000000..b3cee5a --- /dev/null +++ b/CRUSH.md @@ -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 diff --git a/README.md b/README.md index ba3ef48..7b3b6e9 100644 --- a/README.md +++ b/README.md @@ -1,11 +1,26 @@ -# 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 ## 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 +31,194 @@ 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 - ```bash - log-socket - ``` +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" +) - By default, the server runs on `0.0.0.0:8080`. You can specify a different address using the `-addr` flag: +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)) +} +``` - ```bash - log-socket -addr localhost:8080 - ``` +## Usage -2. Open your browser and navigate to `http://localhost:8080` +### Starting the Server - ![Log Socket Web Interface](browser/screenshot.png) +```bash +log-socket +``` -## Logging Interface +By default, the server runs on `0.0.0.0:8080`. Specify a different address: -The package provides a comprehensive logging interface with the following methods: +```bash +log-socket -addr localhost:8080 +``` -- `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 +### Web Interface + +Open your browser and navigate to `http://localhost:8080` + +**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 + +## API + +### 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 + +## 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 +226,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. diff --git a/browser/viewer.html b/browser/viewer.html index 5d00c05..c8b98fc 100644 --- a/browser/viewer.html +++ b/browser/viewer.html @@ -126,7 +126,7 @@ .log-row { display: grid; - grid-template-columns: 180px 80px 1fr 120px; + grid-template-columns: 180px 80px 100px 1fr 120px; gap: 15px; padding: 10px 15px; border-bottom: 1px solid var(--border-color); @@ -321,6 +321,17 @@

📊 Log Viewer

+
+ +
+
@@ -342,6 +356,7 @@
Timestamp
Level
+
Namespace
Message
Source
@@ -385,6 +400,7 @@ this.initializeElements(); this.attachEventListeners(); + this.fetchNamespaces(); this.connectWebSocket(); this.startAutoScroll(); } @@ -392,10 +408,12 @@ 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'); @@ -403,15 +421,61 @@ 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 { - this.ws = new WebSocket("{{.}}"); + 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 = () => { @@ -448,6 +512,16 @@ } } + 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++; @@ -480,6 +554,7 @@ logRow.innerHTML = `
${this.formatTimestamp(entry.timestamp)}
${entry.level}
+
${this.escapeHtml(entry.namespace || 'default')}
${this.escapeHtml(entry.output)}
${this.escapeHtml(entry.file || 'N/A')}
`; @@ -536,6 +611,7 @@ 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) ); diff --git a/go.mod b/go.mod index 735fd33..dfc8bd3 100644 --- a/go.mod +++ b/go.mod @@ -1,5 +1,5 @@ -module github.com/taigrr/log-socket +module github.com/taigrr/log-socket/v2 -go 1.24.4 +go 1.25.4 require github.com/gorilla/websocket v1.5.3 diff --git a/log/log.go b/log/log.go index daaa339..a204501 100644 --- a/log/log.go +++ b/log/log.go @@ -16,10 +16,13 @@ var ( stderrClient *Client cleanup sync.Once stderrFinished chan bool + namespaces map[string]bool + namespacesMux sync.RWMutex ) func init() { - stderrClient = CreateClient() + namespaces = make(map[string]bool) + stderrClient = CreateClient(DefaultNamespace) stderrClient.SetLogLevel(LTrace) stderrFinished = make(chan bool, 1) go stderrClient.logStdErr() @@ -27,16 +30,30 @@ 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) { + fmt.Fprintf(os.Stderr, "%s\t%s\t[%s]\t%s\t%s\n", e.Timestamp.String(), e.Level, e.Namespace, e.Output, e.File) } } 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) @@ -78,9 +95,18 @@ 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) { + // 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 +122,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 +161,7 @@ func Trace(args ...any) { Output: output, File: fileInfo(2), Level: "TRACE", + Namespace: DefaultNamespace, level: LTrace, } createLog(e) @@ -137,6 +176,7 @@ func Tracef(format string, args ...any) { File: fileInfo(2), Level: "TRACE", level: LTrace, + Namespace: DefaultNamespace, } createLog(e) } @@ -150,6 +190,7 @@ func Traceln(args ...any) { File: fileInfo(2), Level: "TRACE", level: LTrace, + Namespace: DefaultNamespace, } createLog(e) } @@ -163,6 +204,7 @@ func Debug(args ...any) { File: fileInfo(2), Level: "DEBUG", level: LDebug, + Namespace: DefaultNamespace, } createLog(e) } @@ -176,6 +218,7 @@ func Debugf(format string, args ...any) { File: fileInfo(2), Level: "DEBUG", level: LDebug, + Namespace: DefaultNamespace, } createLog(e) } @@ -189,6 +232,7 @@ func Debugln(args ...any) { File: fileInfo(2), Level: "DEBUG", level: LDebug, + Namespace: DefaultNamespace, } createLog(e) } @@ -202,6 +246,7 @@ func Info(args ...any) { File: fileInfo(2), Level: "INFO", level: LInfo, + Namespace: DefaultNamespace, } createLog(e) } @@ -215,6 +260,7 @@ func Infof(format string, args ...any) { File: fileInfo(2), Level: "INFO", level: LInfo, + Namespace: DefaultNamespace, } createLog(e) } @@ -228,6 +274,7 @@ func Infoln(args ...any) { File: fileInfo(2), Level: "INFO", level: LInfo, + Namespace: DefaultNamespace, } createLog(e) } @@ -241,6 +288,7 @@ func Notice(args ...any) { File: fileInfo(2), Level: "NOTICE", level: LNotice, + Namespace: DefaultNamespace, } createLog(e) } @@ -254,6 +302,7 @@ func Noticef(format string, args ...any) { File: fileInfo(2), Level: "NOTICE", level: LNotice, + Namespace: DefaultNamespace, } createLog(e) } @@ -267,6 +316,7 @@ func Noticeln(args ...any) { File: fileInfo(2), Level: "NOTICE", level: LNotice, + Namespace: DefaultNamespace, } createLog(e) } @@ -280,6 +330,7 @@ func Warn(args ...any) { File: fileInfo(2), Level: "WARN", level: LWarn, + Namespace: DefaultNamespace, } createLog(e) } @@ -293,6 +344,7 @@ func Warnf(format string, args ...any) { File: fileInfo(2), Level: "WARN", level: LWarn, + Namespace: DefaultNamespace, } createLog(e) } @@ -306,6 +358,7 @@ func Warnln(args ...any) { File: fileInfo(2), Level: "WARN", level: LWarn, + Namespace: DefaultNamespace, } createLog(e) } @@ -319,6 +372,7 @@ func Error(args ...any) { File: fileInfo(2), Level: "ERROR", level: LError, + Namespace: DefaultNamespace, } createLog(e) } @@ -332,6 +386,7 @@ func Errorf(format string, args ...any) { File: fileInfo(2), Level: "ERROR", level: LError, + Namespace: DefaultNamespace, } createLog(e) } @@ -345,6 +400,7 @@ func Errorln(args ...any) { File: fileInfo(2), Level: "ERROR", level: LError, + Namespace: DefaultNamespace, } createLog(e) } @@ -358,6 +414,7 @@ func Panic(args ...any) { File: fileInfo(2), Level: "PANIC", level: LPanic, + Namespace: DefaultNamespace, } createLog(e) if len(args) >= 0 { @@ -381,6 +438,7 @@ func Panicf(format string, args ...any) { File: fileInfo(2), Level: "PANIC", level: LPanic, + Namespace: DefaultNamespace, } createLog(e) if len(args) >= 0 { @@ -403,6 +461,7 @@ func Panicln(args ...any) { File: fileInfo(2), Level: "PANIC", level: LPanic, + Namespace: DefaultNamespace, } createLog(e) if len(args) >= 0 { @@ -426,6 +485,7 @@ func Fatal(args ...any) { File: fileInfo(2), Level: "FATAL", level: LFatal, + Namespace: DefaultNamespace, } createLog(e) Flush() @@ -441,6 +501,7 @@ func Fatalf(format string, args ...any) { File: fileInfo(2), Level: "FATAL", level: LFatal, + Namespace: DefaultNamespace, } createLog(e) Flush() @@ -455,6 +516,7 @@ func Fatalln(args ...any) { File: fileInfo(2), Level: "FATAL", level: LFatal, + Namespace: DefaultNamespace, } createLog(e) Flush() diff --git a/log/log_test.go b/log/log_test.go index 472f1ce..c6d26ec 100644 --- a/log/log_test.go +++ b/log/log_test.go @@ -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++ { diff --git a/log/logger.go b/log/logger.go index 28096c2..a027eaf 100644 --- a/log/logger.go +++ b/log/logger.go @@ -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) { @@ -24,6 +31,7 @@ func (l Logger) Trace(args ...any) { File: fileInfo(l.FileInfoDepth), Level: "TRACE", level: LTrace, + Namespace: l.Namespace, } createLog(e) } @@ -37,6 +45,7 @@ func (l Logger) Tracef(format string, args ...any) { File: fileInfo(l.FileInfoDepth), Level: "TRACE", level: LTrace, + Namespace: l.Namespace, } createLog(e) } @@ -50,6 +59,7 @@ func (l Logger) Traceln(args ...any) { File: fileInfo(l.FileInfoDepth), Level: "TRACE", level: LTrace, + Namespace: l.Namespace, } createLog(e) } @@ -63,6 +73,7 @@ func (l Logger) Debug(args ...any) { File: fileInfo(l.FileInfoDepth), Level: "DEBUG", level: LDebug, + Namespace: l.Namespace, } createLog(e) } @@ -76,6 +87,7 @@ func (l Logger) Debugf(format string, args ...any) { File: fileInfo(l.FileInfoDepth), Level: "DEBUG", level: LDebug, + Namespace: l.Namespace, } createLog(e) } @@ -89,6 +101,7 @@ func (l Logger) Info(args ...any) { File: fileInfo(l.FileInfoDepth), Level: "INFO", level: LInfo, + Namespace: l.Namespace, } createLog(e) } @@ -102,6 +115,7 @@ func (l Logger) Infof(format string, args ...any) { File: fileInfo(l.FileInfoDepth), Level: "INFO", level: LInfo, + Namespace: l.Namespace, } createLog(e) } @@ -115,6 +129,7 @@ func (l Logger) Infoln(args ...any) { File: fileInfo(l.FileInfoDepth), Level: "INFO", level: LInfo, + Namespace: l.Namespace, } createLog(e) } @@ -128,6 +143,7 @@ func (l Logger) Notice(args ...any) { File: fileInfo(l.FileInfoDepth), Level: "NOTICE", level: LNotice, + Namespace: l.Namespace, } createLog(e) } @@ -141,6 +157,7 @@ func (l Logger) Noticef(format string, args ...any) { File: fileInfo(l.FileInfoDepth), Level: "NOTICE", level: LNotice, + Namespace: l.Namespace, } createLog(e) } @@ -154,6 +171,7 @@ func (l Logger) Noticeln(args ...any) { File: fileInfo(l.FileInfoDepth), Level: "NOTICE", level: LNotice, + Namespace: l.Namespace, } createLog(e) } @@ -167,6 +185,7 @@ func (l Logger) Warn(args ...any) { File: fileInfo(l.FileInfoDepth), Level: "WARN", level: LWarn, + Namespace: l.Namespace, } createLog(e) } @@ -180,6 +199,7 @@ func (l Logger) Warnf(format string, args ...any) { File: fileInfo(l.FileInfoDepth), Level: "WARN", level: LWarn, + Namespace: l.Namespace, } createLog(e) } @@ -193,6 +213,7 @@ func (l Logger) Warnln(args ...any) { File: fileInfo(l.FileInfoDepth), Level: "WARN", level: LWarn, + Namespace: l.Namespace, } createLog(e) } @@ -206,6 +227,7 @@ func (l Logger) Error(args ...any) { File: fileInfo(l.FileInfoDepth), Level: "ERROR", level: LError, + Namespace: l.Namespace, } createLog(e) } @@ -219,6 +241,7 @@ func (l Logger) Errorf(format string, args ...any) { File: fileInfo(l.FileInfoDepth), Level: "ERROR", level: LError, + Namespace: l.Namespace, } createLog(e) } @@ -232,6 +255,7 @@ func (l Logger) Errorln(args ...any) { File: fileInfo(l.FileInfoDepth), Level: "ERROR", level: LError, + Namespace: l.Namespace, } createLog(e) } @@ -245,6 +269,7 @@ func (l Logger) Panic(args ...any) { File: fileInfo(l.FileInfoDepth), Level: "PANIC", level: LPanic, + Namespace: l.Namespace, } createLog(e) if len(args) >= 0 { @@ -268,6 +293,7 @@ func (l Logger) Panicf(format string, args ...any) { File: fileInfo(l.FileInfoDepth), Level: "PANIC", level: LPanic, + Namespace: l.Namespace, } createLog(e) if len(args) >= 0 { @@ -291,6 +317,7 @@ func (l Logger) Panicln(args ...any) { File: fileInfo(l.FileInfoDepth), Level: "PANIC", level: LPanic, + Namespace: l.Namespace, } createLog(e) if len(args) >= 0 { @@ -314,6 +341,7 @@ func (l Logger) Fatal(args ...any) { File: fileInfo(l.FileInfoDepth), Level: "FATAL", level: LFatal, + Namespace: l.Namespace, } createLog(e) Flush() @@ -329,6 +357,7 @@ func (l Logger) Fatalf(format string, args ...any) { File: fileInfo(l.FileInfoDepth), Level: "FATAL", level: LFatal, + Namespace: l.Namespace, } createLog(e) Flush() @@ -344,6 +373,7 @@ func (l Logger) Fatalln(args ...any) { File: fileInfo(l.FileInfoDepth), Level: "FATAL", level: LFatal, + Namespace: l.Namespace, } createLog(e) Flush() diff --git a/log/types.go b/log/types.go index 460072c..dcaf7bd 100644 --- a/log/types.go +++ b/log/types.go @@ -13,12 +13,15 @@ const ( LFatal ) +const DefaultNamespace = "default" + type ( LogWriter chan Entry Level int Client struct { - LogLevel Level `json:"level"` + 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 } ) diff --git a/main.go b/main.go index 51e07a2..954285d 100644 --- a/main.go +++ b/main.go @@ -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)) diff --git a/ws/namespaces.go b/ws/namespaces.go new file mode 100644 index 0000000..6a90189 --- /dev/null +++ b/ws/namespaces.go @@ -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, + }) +} diff --git a/ws/server.go b/ws/server.go index 059c30d..e7e3a24 100644 --- a/ws/server.go +++ b/ws/server.go @@ -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.")