package logging import ( "errors" "fmt" "io" "log/slog" "net/url" "os" "regexp" "strings" ) const ( maxErrorDepth = 16 maxFieldBytes = 4096 redacted = "[redacted]" ) var ( urlPattern = regexp.MustCompile(`https?://[^\s"'<>]+`) bearerPattern = regexp.MustCompile(`(?i)\bBearer\s+[^\s"',;]+`) secretPattern = regexp.MustCompile(`(?i)\b((?:access_token|refresh_token|accessJwt|refreshJwt|api_key|service_key|client_secret|encryption_key|password|authorization|cookie)["']?\s*[:=]\s*["']?)[^\s"',;}]+`) ) func Setup(service string) error { level := slog.LevelInfo switch strings.ToLower(os.Getenv("PROVENANCE_LOG_LEVEL")) { case "", "info": case "debug": level = slog.LevelDebug case "warn": level = slog.LevelWarn case "error": level = slog.LevelError default: return errors.New("PROVENANCE_LOG_LEVEL must be debug, info, warn, or error") } secrets := []string{os.Getenv("PROVENANCE_SERVICE_KEY"), os.Getenv("PROVENANCE_ENCRYPTION_KEY"), os.Getenv("JETSTREAM_API_KEY")} if path := os.Getenv("JETSTREAM_API_KEY_FILE"); path != "" { if data, err := os.ReadFile(path); err == nil { secrets = append(secrets, strings.TrimSpace(string(data))) } } slog.SetDefault(New(os.Stderr, level, service, secrets)) return nil } // Redact shared credentials and URL queries at the output boundary. func New(w io.Writer, level slog.Level, service string, secrets []string) *slog.Logger { secrets = append([]string(nil), secrets...) clean := func(value string) string { return sanitize(value, secrets) } handler := slog.NewJSONHandler(w, &slog.HandlerOptions{Level: level, ReplaceAttr: func(_ []string, attr slog.Attr) slog.Attr { if sensitive(attr.Key) { return slog.String(attr.Key, redacted) } if err, ok := attr.Value.Any().(error); ok { attr.Value = Error(err) } if attr.Value.Kind() == slog.KindString { attr.Value = slog.StringValue(clean(attr.Value.String())) } return attr }}) return slog.New(handler).With("service", service) } // Preserve causes hidden by wrappers such as domain.IndexError. Keep public error responses unchanged. func Error(err error) slog.Value { if err == nil { return slog.GroupValue() } var messages, types []string pending := []error{err} for depth := 0; len(pending) > 0 && depth < maxErrorDepth; depth++ { current := pending[len(pending)-1] pending = pending[:len(pending)-1] if current == nil { continue } message := current.Error() if !strings.Contains(strings.Join(messages, ": "), message) { messages = append(messages, message) } types = append(types, fmt.Sprintf("%T", current)) switch wrapped := current.(type) { case interface{ Unwrap() []error }: pending = append(pending, wrapped.Unwrap()...) case interface{ Unwrap() error }: pending = append(pending, wrapped.Unwrap()) } } return slog.GroupValue(slog.String("message", strings.Join(messages, ": ")), slog.Any("types", types)) } func sensitive(key string) bool { switch strings.ToLower(key) { case "authorization", "cookie", "set-cookie", "password", "secret", "api_key", "service_key", "encryption_key", "access_token", "refresh_token", "accessjwt", "refreshjwt", "client_secret": return true } return false } func sanitize(value string, secrets []string) string { for _, secret := range secrets { if secret == "" { continue } for _, encoded := range []string{secret, url.QueryEscape(secret), url.PathEscape(secret)} { value = strings.ReplaceAll(value, encoded, redacted) } } value = bearerPattern.ReplaceAllString(value, "Bearer "+redacted) value = secretPattern.ReplaceAllString(value, "${1}"+redacted) value = urlPattern.ReplaceAllStringFunc(value, func(raw string) string { parsed, err := url.Parse(raw) if err != nil { if !strings.ContainsAny(raw, "?#@") { return raw } return "[redacted_url]" } parsed.User, parsed.RawQuery, parsed.Fragment = nil, "", "" parsed.ForceQuery = false return parsed.String() }) if len(value) > maxFieldBytes { value = value[:maxFieldBytes] + "..." } return value }