Make SCUM logs live relay only
This commit is contained in:
@@ -30,6 +30,8 @@ func main() {
|
||||
trajectoryStatus := &companion.TrajectoryCollectionStatus{}
|
||||
collector, cleanup := buildTrajectoryCollector(config, trajectoryStatus)
|
||||
defer cleanup()
|
||||
consoleCollector, consoleCleanup := buildConsoleLogCollector(config, client)
|
||||
defer consoleCleanup()
|
||||
|
||||
registry := companion.NewHandlerRegistry(defaultHandlerAvailability(config), companion.RuntimeAdapter{
|
||||
BoundServerID: config.Component.ServerInstanceID,
|
||||
@@ -52,11 +54,14 @@ func main() {
|
||||
Health: trajectoryStatus.HealthReport,
|
||||
}
|
||||
|
||||
errorsCh := make(chan error, 2)
|
||||
errorsCh := make(chan error, 3)
|
||||
go func() { errorsCh <- runtime.Run(ctx) }()
|
||||
if collector != nil {
|
||||
go func() { errorsCh <- collector.Run(ctx, trajectoryStatus) }()
|
||||
}
|
||||
if consoleCollector != nil {
|
||||
go func() { errorsCh <- consoleCollector.Run(ctx) }()
|
||||
}
|
||||
|
||||
err = <-errorsCh
|
||||
stop()
|
||||
@@ -75,6 +80,18 @@ func loadConfig() (companion.Config, error) {
|
||||
return companion.LoadConfig(file)
|
||||
}
|
||||
|
||||
func buildConsoleLogCollector(config companion.Config, client *companion.Client) (*companion.ConsoleLogCollector, func()) {
|
||||
cleanup := func() {}
|
||||
store, err := companion.OpenSCUMSQLStoreFromEnv(companion.PlatformMySQLDSNEnvironment)
|
||||
if err != nil {
|
||||
log.Printf("SCUM companion console log store unavailable: %v", err)
|
||||
return nil, cleanup
|
||||
}
|
||||
cleanup = func() { _ = store.Close() }
|
||||
secret := os.Getenv(config.Proof.MaterialEnv)
|
||||
return companion.NewConsoleLogCollector(client, store, config.Component.ServerInstanceID, secret), cleanup
|
||||
}
|
||||
|
||||
func buildTrajectoryCollector(config companion.Config, status *companion.TrajectoryCollectionStatus) (*companion.TrajectoryCollector, func()) {
|
||||
cleanup := func() {}
|
||||
if !config.Trajectory.Enabled {
|
||||
|
||||
@@ -0,0 +1,100 @@
|
||||
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
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
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 == "" {
|
||||
return ConsoleRecord{}, false
|
||||
}
|
||||
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 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 ""
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
package companion
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
type recordingConsoleLogStore struct {
|
||||
ensureCalls int
|
||||
records []ConsoleRecord
|
||||
batches []SemanticEventBatch
|
||||
}
|
||||
|
||||
func (store *recordingConsoleLogStore) EnsureSchema(context.Context) error {
|
||||
store.ensureCalls++
|
||||
return nil
|
||||
}
|
||||
|
||||
func (store *recordingConsoleLogStore) StoreConsoleRecords(_ context.Context, records []ConsoleRecord) (int, error) {
|
||||
store.records = append(store.records, records...)
|
||||
return len(records), nil
|
||||
}
|
||||
|
||||
func (store *recordingConsoleLogStore) StoreSemanticEventBatch(_ context.Context, batch SemanticEventBatch) (int, error) {
|
||||
store.batches = append(store.batches, batch)
|
||||
return len(batch.Events), nil
|
||||
}
|
||||
|
||||
func TestConsoleLogCollectorStoresLiveConsoleEventAndSemanticBatch(t *testing.T) {
|
||||
stamp := time.Date(2026, 8, 31, 4, 0, 0, 0, time.UTC)
|
||||
store := &recordingConsoleLogStore{}
|
||||
collector := NewConsoleLogCollector(nil, store, "server-1", "correlation-secret")
|
||||
handle := collector.handleEvent(context.Background())
|
||||
|
||||
if err := handle(LogStreamEvent{ServerInstanceID: "server-1", StreamID: "stream-1", Source: "process", StreamKey: "stdout", Entry: LogEntry{Seq: 11, Timestamp: stamp, Line: "SCUM LOGIN 76561198000000001 10.0.0.1"}}); err != nil {
|
||||
t.Fatalf("handle log event: %v", err)
|
||||
}
|
||||
if len(store.records) != 1 || store.records[0].ServerID != "server-1" || store.records[0].Stream != "stdout" || store.records[0].Sequence != 11 {
|
||||
t.Fatalf("collector did not store raw console record: %#v", store.records)
|
||||
}
|
||||
if len(store.batches) != 1 || len(store.batches[0].Events) != 1 {
|
||||
t.Fatalf("collector did not store semantic event batch: %#v", store.batches)
|
||||
}
|
||||
event := store.batches[0].Events[0]
|
||||
if event.Type != "scum.login" || event.PlayerID != "76561198000000001" || event.NetworkCorrelation == "" || event.NetworkCorrelation == "10.0.0.1" {
|
||||
t.Fatalf("unexpected semantic event: %#v", event)
|
||||
}
|
||||
}
|
||||
|
||||
func TestConsoleLogCollectorIgnoresNonConsoleLiveLogEvents(t *testing.T) {
|
||||
store := &recordingConsoleLogStore{}
|
||||
collector := NewConsoleLogCollector(nil, store, "server-1", "correlation-secret")
|
||||
handle := collector.handleEvent(context.Background())
|
||||
|
||||
if err := handle(LogStreamEvent{ServerInstanceID: "server-1", StreamID: "stream-1", Source: "process", StreamKey: "scum.file", Entry: LogEntry{Seq: 12, Timestamp: time.Now().UTC(), Line: "not console"}}); err != nil {
|
||||
t.Fatalf("handle non-console event: %v", err)
|
||||
}
|
||||
if len(store.records) != 0 || len(store.batches) != 0 {
|
||||
t.Fatalf("non-console event was stored: records=%#v batches=%#v", store.records, store.batches)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,127 @@
|
||||
package companion
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
func (store *SCUMSQLStore) StoreConsoleRecords(ctx context.Context, records []ConsoleRecord) (int, error) {
|
||||
if store == nil || store.db == nil {
|
||||
return 0, fmt.Errorf("SCUM plugin SQL store is not configured")
|
||||
}
|
||||
normalized := make([]ConsoleRecord, 0, len(records))
|
||||
for _, record := range records {
|
||||
value, err := normalizeConsoleRecord(record)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
normalized = append(normalized, value)
|
||||
}
|
||||
if len(normalized) == 0 {
|
||||
return 0, nil
|
||||
}
|
||||
tx, err := store.db.BeginTx(ctx, nil)
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("begin SCUM console log write: %w", err)
|
||||
}
|
||||
defer tx.Rollback()
|
||||
stamp := time.Now().UTC()
|
||||
for _, record := range normalized {
|
||||
if _, err := tx.ExecContext(ctx, scumConsoleLogInsertSQL, scumConsoleRecordKey(record), record.ServerID, record.Stream, record.Sequence, record.OccurredAt, record.Text, stamp, stamp); err != nil {
|
||||
return 0, fmt.Errorf("write SCUM console log: %w", err)
|
||||
}
|
||||
}
|
||||
if err := tx.Commit(); err != nil {
|
||||
return 0, fmt.Errorf("commit SCUM console logs: %w", err)
|
||||
}
|
||||
return len(normalized), nil
|
||||
}
|
||||
|
||||
func (store *SCUMSQLStore) StoreSemanticEventBatch(ctx context.Context, batch SemanticEventBatch) (int, error) {
|
||||
if store == nil || store.db == nil {
|
||||
return 0, fmt.Errorf("SCUM plugin SQL store is not configured")
|
||||
}
|
||||
normalized := make([]SemanticEvent, 0, len(batch.Events))
|
||||
for _, event := range batch.Events {
|
||||
value, err := normalizeSemanticEvent(event)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
normalized = append(normalized, value)
|
||||
}
|
||||
if len(normalized) == 0 {
|
||||
return 0, nil
|
||||
}
|
||||
tx, err := store.db.BeginTx(ctx, nil)
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("begin SCUM semantic event write: %w", err)
|
||||
}
|
||||
defer tx.Rollback()
|
||||
stamp := time.Now().UTC()
|
||||
for _, event := range normalized {
|
||||
if _, err := tx.ExecContext(ctx, scumSemanticEventInsertSQL, scumSemanticEventRecordKey(event), event.ServerID, event.Sequence, event.Type, event.PlayerID, nullText(event.DisplayName), event.OccurredAt, nullText(event.NetworkCorrelation), stamp, stamp); err != nil {
|
||||
return 0, fmt.Errorf("write SCUM semantic event: %w", err)
|
||||
}
|
||||
}
|
||||
if err := tx.Commit(); err != nil {
|
||||
return 0, fmt.Errorf("commit SCUM semantic events: %w", err)
|
||||
}
|
||||
return len(normalized), nil
|
||||
}
|
||||
|
||||
func normalizeConsoleRecord(record ConsoleRecord) (ConsoleRecord, error) {
|
||||
record.ServerID = strings.TrimSpace(record.ServerID)
|
||||
record.Stream = strings.TrimSpace(record.Stream)
|
||||
record.Text = strings.TrimRight(record.Text, "\r\n")
|
||||
if record.ServerID == "" || (record.Stream != "stdout" && record.Stream != "stderr") || record.Sequence == 0 || record.OccurredAt.IsZero() || strings.TrimSpace(record.Text) == "" || len(record.Text) > 8192 {
|
||||
return ConsoleRecord{}, fmt.Errorf("SCUM console record is invalid")
|
||||
}
|
||||
record.OccurredAt = record.OccurredAt.UTC()
|
||||
return record, nil
|
||||
}
|
||||
|
||||
func normalizeSemanticEvent(event SemanticEvent) (SemanticEvent, error) {
|
||||
event.ServerID = strings.TrimSpace(event.ServerID)
|
||||
event.Type = strings.TrimSpace(event.Type)
|
||||
event.PlayerID = strings.TrimSpace(event.PlayerID)
|
||||
event.DisplayName = strings.TrimSpace(event.DisplayName)
|
||||
event.NetworkCorrelation = strings.TrimSpace(event.NetworkCorrelation)
|
||||
if event.ServerID == "" || event.Sequence == 0 || event.Type == "" || event.PlayerID == "" || event.OccurredAt.IsZero() || len(event.Type) > 80 || len(event.PlayerID) > 80 || len(event.DisplayName) > 120 || len(event.NetworkCorrelation) > 128 {
|
||||
return SemanticEvent{}, fmt.Errorf("SCUM semantic event is invalid")
|
||||
}
|
||||
event.OccurredAt = event.OccurredAt.UTC()
|
||||
return event, nil
|
||||
}
|
||||
|
||||
func scumConsoleRecordKey(record ConsoleRecord) string {
|
||||
digest := sha256.Sum256([]byte(strings.Join([]string{record.ServerID, record.Stream, fmt.Sprintf("%d", record.Sequence)}, "\x00")))
|
||||
return hex.EncodeToString(digest[:])
|
||||
}
|
||||
|
||||
func scumSemanticEventRecordKey(event SemanticEvent) string {
|
||||
digest := sha256.Sum256([]byte(strings.Join([]string{event.ServerID, event.Type, event.PlayerID, fmt.Sprintf("%d", event.Sequence)}, "\x00")))
|
||||
return hex.EncodeToString(digest[:])
|
||||
}
|
||||
|
||||
const scumConsoleLogInsertSQL = `
|
||||
INSERT INTO scum_console_logs (
|
||||
record_key, server_instance_id, stream, sequence, occurred_at, line_text, created_at, updated_at
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?)
|
||||
ON DUPLICATE KEY UPDATE
|
||||
occurred_at = VALUES(occurred_at),
|
||||
line_text = VALUES(line_text),
|
||||
updated_at = VALUES(updated_at)`
|
||||
|
||||
const scumSemanticEventInsertSQL = `
|
||||
INSERT INTO scum_semantic_events (
|
||||
record_key, server_instance_id, sequence, event_type, player_id, display_name, occurred_at, network_correlation, created_at, updated_at
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
ON DUPLICATE KEY UPDATE
|
||||
display_name = VALUES(display_name),
|
||||
occurred_at = VALUES(occurred_at),
|
||||
network_correlation = VALUES(network_correlation),
|
||||
updated_at = VALUES(updated_at)`
|
||||
@@ -0,0 +1,125 @@
|
||||
package companion
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
const logEventsPath = "/api/v1/game-client-bridge/companion/logs/events"
|
||||
|
||||
type LogEntry struct {
|
||||
Seq uint64 `json:"seq"`
|
||||
Timestamp time.Time `json:"timestamp"`
|
||||
Level string `json:"level,omitempty"`
|
||||
Line string `json:"line"`
|
||||
Fields map[string]string `json:"fields,omitempty"`
|
||||
Redacted bool `json:"redacted"`
|
||||
}
|
||||
|
||||
type LogStreamEvent struct {
|
||||
ServerInstanceID string `json:"serverInstanceId"`
|
||||
StreamID string `json:"streamId"`
|
||||
Source string `json:"source"`
|
||||
StreamKey string `json:"streamKey"`
|
||||
LogSessionID string `json:"logSessionId,omitempty"`
|
||||
SessionStartedAt time.Time `json:"sessionStartedAt,omitempty"`
|
||||
LatestSeq uint64 `json:"latestSeq"`
|
||||
Entry LogEntry `json:"entry"`
|
||||
}
|
||||
|
||||
type logStreamRequest struct {
|
||||
SessionToken string `json:"sessionToken"`
|
||||
}
|
||||
|
||||
func (client *Client) StreamLogEvents(ctx context.Context, handle func(LogStreamEvent) error) error {
|
||||
if handle == nil {
|
||||
return fmt.Errorf("log event handler is required")
|
||||
}
|
||||
token, err := client.currentSession()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
encoded, err := json.Marshal(logStreamRequest{SessionToken: token})
|
||||
if err != nil {
|
||||
return fmt.Errorf("encode log stream request: %w", err)
|
||||
}
|
||||
if len(encoded) > maxRequestBytes {
|
||||
return fmt.Errorf("platform request exceeds the bounded payload size")
|
||||
}
|
||||
request, err := http.NewRequestWithContext(ctx, http.MethodPost, client.config.Platform.BaseURL+logEventsPath, bytes.NewReader(encoded))
|
||||
if err != nil {
|
||||
return fmt.Errorf("create log stream request: %w", err)
|
||||
}
|
||||
request.Header.Set("Accept", "text/event-stream")
|
||||
request.Header.Set("Content-Type", "application/json")
|
||||
response, err := client.httpClient.Do(request)
|
||||
if err != nil {
|
||||
return fmt.Errorf("open log stream: %w", err)
|
||||
}
|
||||
defer response.Body.Close()
|
||||
if response.StatusCode != http.StatusOK {
|
||||
_, _ = io.Copy(io.Discard, io.LimitReader(response.Body, 4096))
|
||||
return HTTPError{StatusCode: response.StatusCode, ExpectedStatus: http.StatusOK}
|
||||
}
|
||||
return readLogEventStream(ctx, response.Body, handle)
|
||||
}
|
||||
|
||||
func readLogEventStream(ctx context.Context, body io.Reader, handle func(LogStreamEvent) error) error {
|
||||
reader := bufio.NewReader(body)
|
||||
var eventName string
|
||||
var dataLines []string
|
||||
flush := func() error {
|
||||
if len(dataLines) == 0 {
|
||||
eventName = ""
|
||||
return nil
|
||||
}
|
||||
name := eventName
|
||||
if name == "" {
|
||||
name = "message"
|
||||
}
|
||||
payload := strings.Join(dataLines, "\n")
|
||||
eventName = ""
|
||||
dataLines = nil
|
||||
if name != "log" {
|
||||
return nil
|
||||
}
|
||||
var event LogStreamEvent
|
||||
if err := json.Unmarshal([]byte(payload), &event); err != nil {
|
||||
return fmt.Errorf("decode log event: %w", err)
|
||||
}
|
||||
return handle(event)
|
||||
}
|
||||
for {
|
||||
line, err := reader.ReadString('\n')
|
||||
if len(line) > 0 {
|
||||
line = strings.TrimRight(line, "\r\n")
|
||||
switch {
|
||||
case line == "":
|
||||
if flushErr := flush(); flushErr != nil {
|
||||
return flushErr
|
||||
}
|
||||
case strings.HasPrefix(line, ":"):
|
||||
case strings.HasPrefix(line, "event:"):
|
||||
eventName = strings.TrimSpace(strings.TrimPrefix(line, "event:"))
|
||||
case strings.HasPrefix(line, "data:"):
|
||||
dataLines = append(dataLines, strings.TrimSpace(strings.TrimPrefix(line, "data:")))
|
||||
}
|
||||
}
|
||||
if err != nil {
|
||||
if ctx != nil && ctx.Err() != nil {
|
||||
return ctx.Err()
|
||||
}
|
||||
if err == io.EOF {
|
||||
return flush()
|
||||
}
|
||||
return fmt.Errorf("read log stream: %w", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
package companion
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"io"
|
||||
"net/http"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestClientStreamLogEventsUsesComponentSessionBodyAndSSE(t *testing.T) {
|
||||
stamp := time.Date(2026, 8, 31, 2, 0, 0, 0, time.UTC)
|
||||
config := loadTestConfig(t)
|
||||
client := newTestClient(t, config, roundTripFunc(func(request *http.Request) (*http.Response, error) {
|
||||
if request.Method != http.MethodPost || request.URL.Scheme != "https" || request.URL.Host != "platform.example.test" || request.URL.Path != logEventsPath {
|
||||
t.Fatalf("unexpected log stream request: %s %s", request.Method, request.URL.String())
|
||||
}
|
||||
if request.Header.Get("Authorization") != "" {
|
||||
t.Fatalf("component session must stay in the typed JSON body, got Authorization header")
|
||||
}
|
||||
if request.Header.Get("Accept") != "text/event-stream" || request.Header.Get("Content-Type") != "application/json" {
|
||||
t.Fatalf("unexpected log stream headers: %+v", request.Header)
|
||||
}
|
||||
var body logStreamRequest
|
||||
decodeRequest(t, request, &body)
|
||||
if body.SessionToken != "session-token" {
|
||||
t.Fatalf("unexpected session token body: %#v", body)
|
||||
}
|
||||
logPayload, _ := json.Marshal(LogStreamEvent{ServerInstanceID: "server-1", StreamID: "stream-1", Source: "process", StreamKey: "stdout", LogSessionID: "session-live", SessionStartedAt: stamp, LatestSeq: 7, Entry: LogEntry{Seq: 7, Timestamp: stamp.Add(time.Second), Line: "SCUM LOGIN 76561198000000001 10.0.0.1", Redacted: true}})
|
||||
bodyText := strings.Join([]string{
|
||||
"event: ready",
|
||||
"data: {\"serverInstanceId\":\"server-1\"}",
|
||||
"",
|
||||
"event: log",
|
||||
"data: " + string(logPayload),
|
||||
"",
|
||||
": heartbeat",
|
||||
"",
|
||||
}, "\n")
|
||||
return &http.Response{StatusCode: http.StatusOK, Header: http.Header{"Content-Type": []string{"text/event-stream"}}, Body: io.NopCloser(strings.NewReader(bodyText)), Request: request}, nil
|
||||
}), stamp)
|
||||
client.mu.Lock()
|
||||
client.sessionToken = "session-token"
|
||||
client.sessionExpiresAt = stamp.Add(time.Hour)
|
||||
client.mu.Unlock()
|
||||
|
||||
var events []LogStreamEvent
|
||||
if err := client.StreamLogEvents(context.Background(), func(event LogStreamEvent) error {
|
||||
events = append(events, event)
|
||||
return nil
|
||||
}); err != nil {
|
||||
t.Fatalf("stream log events: %v", err)
|
||||
}
|
||||
if len(events) != 1 || events[0].Entry.Seq != 7 || events[0].Entry.Line != "SCUM LOGIN 76561198000000001 10.0.0.1" {
|
||||
t.Fatalf("unexpected streamed log events: %#v", events)
|
||||
}
|
||||
}
|
||||
|
||||
func TestReadLogEventStreamRejectsMalformedLogEvent(t *testing.T) {
|
||||
err := readLogEventStream(context.Background(), strings.NewReader("event: log\ndata: {not-json}\n\n"), func(LogStreamEvent) error { return nil })
|
||||
if err == nil || !strings.Contains(err.Error(), "decode log event") {
|
||||
t.Fatalf("expected malformed log event rejection, got %v", err)
|
||||
}
|
||||
}
|
||||
@@ -348,6 +348,33 @@ CREATE TABLE IF NOT EXISTS scum_trajectories (
|
||||
UNIQUE KEY scum_trajectories_sample_uq (server_instance_id, subject_type, subject_id, sampled_at),
|
||||
KEY scum_trajectories_subject_idx (server_instance_id, subject_type, subject_id, observed_at),
|
||||
KEY scum_trajectories_sampled_idx (server_instance_id, sampled_at)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci`, `
|
||||
CREATE TABLE IF NOT EXISTS scum_console_logs (
|
||||
record_key CHAR(64) PRIMARY KEY,
|
||||
server_instance_id VARCHAR(96) NOT NULL,
|
||||
stream VARCHAR(16) NOT NULL,
|
||||
sequence BIGINT UNSIGNED NOT NULL,
|
||||
occurred_at DATETIME(6) NOT NULL,
|
||||
line_text TEXT NOT NULL,
|
||||
created_at DATETIME(6) NOT NULL,
|
||||
updated_at DATETIME(6) NOT NULL,
|
||||
UNIQUE KEY scum_console_logs_stream_uq (server_instance_id, stream, sequence),
|
||||
KEY scum_console_logs_time_idx (server_instance_id, occurred_at)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci`, `
|
||||
CREATE TABLE IF NOT EXISTS scum_semantic_events (
|
||||
record_key CHAR(64) PRIMARY KEY,
|
||||
server_instance_id VARCHAR(96) NOT NULL,
|
||||
sequence BIGINT UNSIGNED NOT NULL,
|
||||
event_type VARCHAR(80) NOT NULL,
|
||||
player_id VARCHAR(80) NOT NULL,
|
||||
display_name VARCHAR(120) NULL,
|
||||
occurred_at DATETIME(6) NOT NULL,
|
||||
network_correlation VARCHAR(128) NULL,
|
||||
created_at DATETIME(6) NOT NULL,
|
||||
updated_at DATETIME(6) NOT NULL,
|
||||
UNIQUE KEY scum_semantic_events_uq (server_instance_id, event_type, player_id, sequence),
|
||||
KEY scum_semantic_events_player_idx (server_instance_id, player_id, occurred_at),
|
||||
KEY scum_semantic_events_type_idx (server_instance_id, event_type, occurred_at)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci`}
|
||||
|
||||
const scumTrajectoryInsertSQL = `
|
||||
|
||||
@@ -102,6 +102,61 @@ func TestSCUMSQLStoreWritesTrajectorySamplesWithoutCoordinateConversion(t *testi
|
||||
}
|
||||
}
|
||||
|
||||
func TestSCUMSQLStoreWritesConsoleAndSemanticEventsToPluginTables(t *testing.T) {
|
||||
db, recorder := newRecordingSQLDB(t, "console-db")
|
||||
store, err := NewSCUMSQLStore(db)
|
||||
if err != nil {
|
||||
t.Fatalf("create SCUM SQL store: %v", err)
|
||||
}
|
||||
stamp := time.Date(2026, 8, 31, 3, 0, 0, 0, time.UTC)
|
||||
records := []ConsoleRecord{{ServerID: "server-1", Stream: "stdout", Sequence: 9, OccurredAt: stamp, Text: "SCUM LOGIN 76561198000000001 10.0.0.1"}}
|
||||
written, err := store.StoreConsoleRecords(context.Background(), records)
|
||||
if err != nil || written != 1 {
|
||||
t.Fatalf("store console records: written=%d err=%v", written, err)
|
||||
}
|
||||
batch := ParseConsoleRecords("server-1", records, "correlation-secret")
|
||||
if len(batch.Events) != 1 || batch.Events[0].NetworkCorrelation == "" {
|
||||
t.Fatalf("expected one correlated semantic event: %#v", batch)
|
||||
}
|
||||
semanticWritten, err := store.StoreSemanticEventBatch(context.Background(), batch)
|
||||
if err != nil || semanticWritten != 1 {
|
||||
t.Fatalf("store semantic events: written=%d err=%v", semanticWritten, err)
|
||||
}
|
||||
if recorder.commits != 2 {
|
||||
t.Fatalf("console and semantic writes did not commit once each: %d", recorder.commits)
|
||||
}
|
||||
|
||||
consoleIndex := findStatement(recorder.statements, "INSERT INTO scum_console_logs")
|
||||
if consoleIndex < 0 {
|
||||
t.Fatalf("missing console insert statement: %v", recorder.statements)
|
||||
}
|
||||
consoleInsert := recorder.statements[consoleIndex]
|
||||
if !strings.Contains(consoleInsert, "line_text") || strings.Contains(consoleInsert, "platform_logs") {
|
||||
t.Fatalf("console SQL must write the SCUM plugin table only: %s", consoleInsert)
|
||||
}
|
||||
consoleArgs := recorder.args[consoleIndex]
|
||||
if consoleArgs[1].Value != "server-1" || consoleArgs[2].Value != "stdout" || !driverNumberEquals(consoleArgs[3].Value, 9) || consoleArgs[5].Value != records[0].Text {
|
||||
t.Fatalf("unexpected console insert args: %+v", consoleArgs)
|
||||
}
|
||||
|
||||
semanticIndex := findStatement(recorder.statements, "INSERT INTO scum_semantic_events")
|
||||
if semanticIndex < 0 {
|
||||
t.Fatalf("missing semantic event insert statement: %v", recorder.statements)
|
||||
}
|
||||
semanticInsert := recorder.statements[semanticIndex]
|
||||
if strings.Contains(semanticInsert, "platform_logs") || strings.Contains(semanticInsert, "run_logs") {
|
||||
t.Fatalf("semantic SQL must write the SCUM plugin table only: %s", semanticInsert)
|
||||
}
|
||||
semanticArgs := recorder.args[semanticIndex]
|
||||
if semanticArgs[1].Value != "server-1" || !driverNumberEquals(semanticArgs[2].Value, 9) || semanticArgs[3].Value != "scum.login" || semanticArgs[4].Value != "76561198000000001" {
|
||||
t.Fatalf("unexpected semantic insert args: %+v", semanticArgs)
|
||||
}
|
||||
correlation, ok := semanticArgs[7].Value.(string)
|
||||
if !ok || correlation == "10.0.0.1" || len(correlation) != 64 {
|
||||
t.Fatalf("semantic event stored raw or missing network correlation: %+v", semanticArgs[7])
|
||||
}
|
||||
}
|
||||
|
||||
func TestTrajectorySamplesFromSCUMRowsKeepWorldCoordinates(t *testing.T) {
|
||||
sampledAt := time.Date(2026, 8, 28, 8, 0, 0, 0, time.UTC)
|
||||
positionSamples, err := TrajectorySamplesFromPositionRows("server-1", []map[string]any{
|
||||
@@ -168,3 +223,16 @@ func findStatement(statements []string, prefix string) int {
|
||||
}
|
||||
return -1
|
||||
}
|
||||
|
||||
func driverNumberEquals(value any, want int64) bool {
|
||||
switch typed := value.(type) {
|
||||
case int:
|
||||
return int64(typed) == want
|
||||
case int64:
|
||||
return typed == want
|
||||
case uint64:
|
||||
return typed == uint64(want)
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user