package companion import ( "context" "fmt" "strings" "time" ) type ConsoleLogStreamClient interface { StreamLogEvents(context.Context, func(LogStreamEvent) error) error } type ConsoleLogStore interface { EnsureSchema(context.Context) error StoreConsoleRecords(context.Context, []ConsoleRecord) (int, error) StoreSemanticEventBatch(context.Context, SemanticEventBatch) (int, error) } type ConsoleLogCollector struct { Client ConsoleLogStreamClient Store ConsoleLogStore ServerInstanceID string CorrelationSecret string Backoff time.Duration OnSemanticEvents func(context.Context, SemanticEventBatch) error } func NewConsoleLogCollector(client ConsoleLogStreamClient, store ConsoleLogStore, serverInstanceID string, correlationSecret string) *ConsoleLogCollector { return &ConsoleLogCollector{Client: client, Store: store, ServerInstanceID: serverInstanceID, CorrelationSecret: correlationSecret, Backoff: 2 * time.Second} } func (collector *ConsoleLogCollector) Run(ctx context.Context) error { if collector == nil || collector.Client == nil || collector.Store == nil || strings.TrimSpace(collector.ServerInstanceID) == "" { return fmt.Errorf("console log collector is not configured") } if err := collector.Store.EnsureSchema(ctx); err != nil { return err } backoff := collector.Backoff if backoff <= 0 { backoff = 2 * time.Second } for { err := collector.Client.StreamLogEvents(ctx, collector.handleEvent(ctx)) if ctx.Err() != nil { return ctx.Err() } if err != nil { select { case <-ctx.Done(): return ctx.Err() case <-time.After(backoff): } continue } } } func (collector *ConsoleLogCollector) handleEvent(ctx context.Context) func(LogStreamEvent) error { return func(event LogStreamEvent) error { record, ok := consoleRecordFromLogEvent(collector.ServerInstanceID, event) if !ok { return nil } if _, err := collector.Store.StoreConsoleRecords(ctx, []ConsoleRecord{record}); err != nil { return err } batch := ParseConsoleRecords(collector.ServerInstanceID, []ConsoleRecord{record}, collector.CorrelationSecret) if _, err := collector.Store.StoreSemanticEventBatch(ctx, batch); err != nil { return err } if collector.OnSemanticEvents != nil { if err := collector.OnSemanticEvents(ctx, batch); err != nil { return err } } return nil } } func consoleRecordFromLogEvent(serverInstanceID string, event LogStreamEvent) (ConsoleRecord, bool) { if event.ServerInstanceID != serverInstanceID || event.Entry.Seq == 0 || strings.TrimSpace(event.Entry.Line) == "" { return ConsoleRecord{}, false } stream := consoleStreamName(event.StreamKey) if stream == "" { if !knownPluginLogStream(event.StreamKey) { return ConsoleRecord{}, false } stream = strings.ToLower(strings.TrimSpace(event.StreamKey)) } occurredAt := event.Entry.Timestamp if occurredAt.IsZero() { occurredAt = time.Now().UTC() } return ConsoleRecord{ServerID: serverInstanceID, Stream: stream, Sequence: event.Entry.Seq, OccurredAt: occurredAt.UTC(), Text: event.Entry.Line}, true } func knownPluginLogStream(streamKey string) bool { switch strings.ToLower(strings.TrimSpace(streamKey)) { case "scum.login", "scum.chat", "scum.server", "scum.kill", "scum.trade", "scum.admin", "scum.performance": return true default: return false } } func consoleStreamName(streamKey string) string { key := strings.ToLower(strings.TrimSpace(streamKey)) if strings.Contains(key, "stderr") || strings.HasSuffix(key, ".err") || strings.HasSuffix(key, "-err") { return "stderr" } if strings.Contains(key, "stdout") || strings.Contains(key, "console") || strings.HasSuffix(key, ".out") || strings.HasSuffix(key, "-out") { return "stdout" } return "" }