// 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 }