Pulse/internal/logging/broadcast.go
2026-03-18 16:06:30 +00:00

151 lines
3.6 KiB
Go

package logging
import (
"container/ring"
"fmt"
"io"
"os"
"sync"
"github.com/google/uuid"
"github.com/rs/zerolog"
)
// DefaultBufferSize is the number of log lines to keep in memory.
const (
DefaultBufferSize = 1000
// Cap each broadcasted log line to bound per-entry memory usage.
maxBroadcastMessageBytes = 16 * 1024
broadcastTruncationTag = "...[truncated]"
)
var (
broadcaster *LogBroadcaster
broadcastMu sync.Once
broadcastWarnWriter io.Writer = os.Stderr
)
// LogBroadcaster captures log writes, buffers them, and broadcasts them to subscribers.
type LogBroadcaster struct {
mu sync.RWMutex
buffer *ring.Ring
subscribers map[string]chan string
}
// GetBroadcaster returns the singleton broadcaster instance.
func GetBroadcaster() *LogBroadcaster {
broadcastMu.Do(func() {
broadcaster = &LogBroadcaster{
buffer: ring.New(DefaultBufferSize),
subscribers: make(map[string]chan string),
}
})
return broadcaster
}
// Write implements io.Writer. It writes to the internal buffer and notifies subscribers.
func (b *LogBroadcaster) Write(p []byte) (n int, err error) {
// Copy at most a bounded prefix so oversized log lines cannot inflate memory.
msg := string(p)
if len(p) > maxBroadcastMessageBytes {
keep := maxBroadcastMessageBytes - len(broadcastTruncationTag)
if keep < 0 {
keep = 0
}
msg = string(p[:keep]) + broadcastTruncationTag
}
b.mu.Lock()
defer b.mu.Unlock()
// 1. Write to ring buffer
b.buffer.Value = msg
b.buffer = b.buffer.Next()
// 2. Broadcast to subscribers
for subscriberID, ch := range b.subscribers {
select {
case ch <- msg:
// Sent successfully
default:
// Channel blocked, too slow consumer.
// In a real production system we might drop the client,
// here we just skip this message for them to avoid blocking writer.
// Ideally we should warn or close their channel.
fmt.Fprintf(
broadcastWarnWriter,
"logging: subscriber_blocked subscriber_id=%s action=drop_message\n",
subscriberID,
)
}
}
return len(p), nil
}
// Subscribe adds a new subscriber and returns a channel of log lines
// plus a snapshot of the current history.
func (b *LogBroadcaster) Subscribe() (string, chan string, []string) {
b.mu.Lock()
defer b.mu.Unlock()
subscriberID := uuid.NewString()
ch := make(chan string, 1000) // Large buffer to prevent blocking
b.subscribers[subscriberID] = ch
return subscriberID, ch, ringHistorySnapshot(b.buffer)
}
// Unsubscribe removes a subscriber.
func (b *LogBroadcaster) Unsubscribe(subscriberID string) {
b.mu.Lock()
defer b.mu.Unlock()
if ch, ok := b.subscribers[subscriberID]; ok {
close(ch)
delete(b.subscribers, subscriberID)
}
}
// Shutdown closes all subscriber channels and clears the subscriber set.
func (b *LogBroadcaster) Shutdown() {
b.mu.Lock()
defer b.mu.Unlock()
for id, ch := range b.subscribers {
close(ch)
delete(b.subscribers, id)
}
}
// GetHistory returns the current in-memory log log history.
func (b *LogBroadcaster) GetHistory() []string {
b.mu.RLock()
defer b.mu.RUnlock()
return ringHistorySnapshot(b.buffer)
}
func ringHistorySnapshot(buffer *ring.Ring) []string {
history := make([]string, 0, DefaultBufferSize)
buffer.Do(func(value any) {
msg, ok := value.(string)
if !ok {
return
}
history = append(history, msg)
})
return history
}
// SetGlobalLevel updates the global zerolog level at runtime.
func SetGlobalLevel(level string) {
mu.Lock()
defer mu.Unlock()
zerolog.SetGlobalLevel(parseLevel(level))
}
// GetGlobalLevel returns the current global level string.
func GetGlobalLevel() string {
return zerolog.GlobalLevel().String()
}