Files
pulse/internal/logging/broadcast.go
T
pulse-triage[bot] ca0d4fbdce Keep slow log viewers off the synchronous logging path
Skip saturated live-view deliveries without writing stderr under the broadcaster lock. Bound oversized-entry allocation before copying. Preserve the configured sinks, capped history, writer result and subscriber lifecycle, with unread-pipe, allocation and concurrent-lifecycle controls.

Contract-Neutral: Existing best-effort system-log delivery, line and channel limits, writer results and lifecycle APIs are unchanged; remove per-drop stderr I/O and copying beyond the retained prefix.
Change-source: pulse-maintainer
2026-10-09 09:52:53 +01:00

142 lines
3.4 KiB
Go

package logging
import (
"container/ring"
"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
)
// 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.
var msg string
if len(p) > maxBroadcastMessageBytes {
keep := maxBroadcastMessageBytes - len(broadcastTruncationTag)
msg = string(p[:keep]) + broadcastTruncationTag
} else {
msg = string(p)
}
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 _, ch := range b.subscribers {
select {
case ch <- msg:
// Sent successfully
default:
// A passive log viewer must not stall the producer or other viewers.
// Skip only this delivery; bounded history and normal log sinks are
// independent. Do not warn here: stderr can block too, and
// doing so under this lock would also stall subscribe and shutdown.
}
}
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()
}