Files
2026-09-01 17:15:44 +01:00

102 lines
2.6 KiB
Go

// Package observability provides credential-safe structured event encoding.
package observability
import (
"encoding/json"
"fmt"
"strings"
"time"
)
type Event struct {
Event string
QueueID string
ProposalID string
MatchID string
ServerID string
Stage string
OccurredAt time.Time
Fields map[string]any
}
func Encode(event Event) ([]byte, error) {
if event.Event == "" {
return nil, fmt.Errorf("event name is required")
}
fields := map[string]any{
"event": event.Event, "occurred_at": event.OccurredAt.UTC().Format(time.RFC3339Nano),
}
for key, value := range map[string]string{"queue_id": event.QueueID, "proposal_id": event.ProposalID, "match_id": event.MatchID, "server_id": event.ServerID, "stage": event.Stage} {
if value != "" {
fields[key] = value
}
}
for key, value := range event.Fields {
fields[key] = redact(key, value)
}
return json.Marshal(fields)
}
func redact(key string, value any) any {
lowered := strings.ToLower(key)
for _, secret := range []string{"token", "secret", "credential", "authorization", "private_key", "auth_ticket", "relay_ticket"} {
if strings.Contains(lowered, secret) {
return "[REDACTED]"
}
}
switch typed := value.(type) {
case string:
if looksLikeCredential(typed) {
return "[REDACTED]"
}
return typed
case map[string]any:
copy := make(map[string]any, len(typed))
for key, value := range typed {
copy[key] = redact(key, value)
}
return copy
case map[string]string:
copy := make(map[string]string, len(typed))
for key, value := range typed {
redacted := redact(key, value)
copy[key] = redacted.(string)
}
return copy
case []any:
copy := make([]any, len(typed))
for i, value := range typed {
copy[i] = redact("item", value)
}
return copy
case []string:
copy := make([]string, len(typed))
for i, value := range typed {
copy[i] = redact("item", value).(string)
}
return copy
default:
return value
}
}
func looksLikeCredential(value string) bool {
trimmed := strings.TrimSpace(value)
if strings.HasPrefix(strings.ToLower(trimmed), "bearer ") || strings.Contains(trimmed, "-----BEGIN ") {
return true
}
parts := strings.Split(trimmed, ".")
if len(parts) == 3 && len(parts[0]) >= 8 && len(parts[1]) >= 8 && len(parts[2]) >= 8 {
return true // compact JWT-like credential
}
if len(trimmed) < 40 || strings.ContainsAny(trimmed, " \t\r\n") {
return false
}
hasLetter, hasDigit := false, false
for _, ch := range trimmed {
hasLetter = hasLetter || (ch >= 'A' && ch <= 'Z') || (ch >= 'a' && ch <= 'z')
hasDigit = hasDigit || (ch >= '0' && ch <= '9')
}
return hasLetter && hasDigit
}