package afdata
import (
"bytes"
"encoding/json"
"fmt"
"math"
"net/url"
"reflect"
"sort"
"strconv"
"strings"
"time"
"unicode/utf16"
)
const maxSafeInteger = uint64(9007199254740991)
type LogLevel string
const (
LogLevelDebug LogLevel = "debug"
LogLevelInfo LogLevel = "info"
LogLevelWarn LogLevel = "warn"
LogLevelError LogLevel = "error"
)
type Event struct {
envelope map[string]any
}
type BuilderError struct {
msg string
}
func (e *BuilderError) Error() string {
return e.msg
}
func (e Event) MarshalJSON() ([]byte, error) {
return json.Marshal(e.envelope)
}
func (e Event) Value() map[string]any {
return e.envelope
}
func BuildCLIError(message string, hint string) (Event, error) {
builder := NewJSONError("cli_error", message)
if hint != "" {
builder.Hint(hint)
}
return builder.Build()
}
type JSONResultBuilder struct {
result any
trace any
}
func NewJSONResult(result any) *JSONResultBuilder {
return &JSONResultBuilder{result: result}
}
func (b *JSONResultBuilder) Trace(trace any) *JSONResultBuilder {
b.trace = trace
return b
}
func (b *JSONResultBuilder) Build() Event {
envelope := map[string]any{
"kind": "result",
"result": b.result,
"trace": normalizeTrace(b.trace),
}
return Event{envelope: envelope}
}
func normalizeTrace(trace any) any {
if trace == nil {
return map[string]any{}
}
if obj, ok := trace.(map[string]any); ok {
return obj
}
if obj, err := marshalToObject(trace); err == nil {
return obj
}
return trace
}
func marshalToObject(value any) (map[string]any, error) {
data, err := json.Marshal(value)
if err != nil {
return nil, err
}
dec := json.NewDecoder(bytes.NewReader(data))
dec.UseNumber()
var obj map[string]any
if err := dec.Decode(&obj); err != nil {
return nil, err
}
return obj, nil
}
type JSONErrorBuilder struct {
code string
message string
retryable bool
hint string
fields map[string]any
trace any
errs []error
fieldErrs []string
}
func NewJSONError(code string, message string) *JSONErrorBuilder {
b := &JSONErrorBuilder{
code: code,
message: message,
retryable: false,
fields: make(map[string]any),
}
if code == "" {
b.errs = append(b.errs, &BuilderError{msg: "error code must be non-empty"})
}
if message == "" {
b.errs = append(b.errs, &BuilderError{msg: "error message must be non-empty"})
}
return b
}
func (b *JSONErrorBuilder) Retryable() *JSONErrorBuilder {
b.retryable = true
return b
}
func (b *JSONErrorBuilder) RetryableIf(flag bool) *JSONErrorBuilder {
b.retryable = flag
return b
}
func (b *JSONErrorBuilder) Hint(text string) *JSONErrorBuilder {
b.hint = text
return b
}
func (b *JSONErrorBuilder) Field(name string, value any) *JSONErrorBuilder {
if isReservedErrorField(name) {
b.fieldErrs = append(b.fieldErrs, fmt.Sprintf("field %q is reserved", name))
return b
}
b.fields[name] = value
return b
}
func (b *JSONErrorBuilder) Fields(obj map[string]any) *JSONErrorBuilder {
if obj == nil {
return b
}
for k, v := range obj {
b.Field(k, v)
}
return b
}
func (b *JSONErrorBuilder) Extend(value any) *JSONErrorBuilder {
if value == nil {
return b
}
obj, err := marshalToObject(value)
if err != nil {
b.errs = append(b.errs, &BuilderError{msg: "extend: value must serialize to a JSON object"})
return b
}
b.Fields(obj)
return b
}
func (b *JSONErrorBuilder) Trace(trace any) *JSONErrorBuilder {
b.trace = trace
return b
}
func (b *JSONErrorBuilder) Build() (Event, error) {
if len(b.errs) > 0 {
return Event{}, b.errs[0]
}
if len(b.fieldErrs) > 0 {
return Event{}, &BuilderError{msg: b.fieldErrs[0]}
}
errorPayload := make(map[string]any)
for k, v := range b.fields {
errorPayload[k] = v
}
errorPayload["code"] = b.code
errorPayload["message"] = b.message
errorPayload["retryable"] = b.retryable
if b.hint != "" {
errorPayload["hint"] = b.hint
}
traceObj := map[string]any{}
if b.trace != nil {
if obj, ok := b.trace.(map[string]any); ok {
traceObj = obj
} else {
obj, err := marshalToObject(b.trace)
if err != nil {
return Event{}, &BuilderError{msg: "trace must be a JSON object"}
}
traceObj = obj
}
}
envelope := map[string]any{
"kind": "error",
"error": errorPayload,
"trace": traceObj,
}
return Event{envelope: envelope}, nil
}
func isReservedErrorField(name string) bool {
return name == "code" || name == "message" || name == "hint" || name == "retryable"
}
type JSONProgressBuilder struct {
payload any
trace any
}
func NewJSONProgress(payload any) *JSONProgressBuilder {
return &JSONProgressBuilder{payload: payload}
}
func (b *JSONProgressBuilder) Trace(trace any) *JSONProgressBuilder {
b.trace = trace
return b
}
func (b *JSONProgressBuilder) Build() Event {
envelope := map[string]any{
"kind": "progress",
"progress": b.payload,
"trace": normalizeTrace(b.trace),
}
return Event{envelope: envelope}
}
type JSONLogBuilder struct {
payload any
trace any
}
func NewJSONLog(payload any) *JSONLogBuilder {
return &JSONLogBuilder{payload: payload}
}
func (b *JSONLogBuilder) Trace(trace any) *JSONLogBuilder {
b.trace = trace
return b
}
func (b *JSONLogBuilder) Build() Event {
envelope := map[string]any{
"kind": "log",
"log": b.payload,
"trace": normalizeTrace(b.trace),
}
return Event{envelope: envelope}
}
func ValidateProtocolEvent(event any, strict bool) error {
obj, ok := event.(map[string]any)
if !ok {
return fmt.Errorf("event must be a JSON object")
}
kind, ok := obj["kind"].(string)
if !ok {
return fmt.Errorf("event.kind must be one of result, error, progress, log")
}
switch kind {
case "result", "error", "progress", "log":
default:
return fmt.Errorf("unsupported event kind %q", kind)
}
if _, ok := obj[kind]; !ok {
return fmt.Errorf("event payload field %q is required", kind)
}
for key := range obj {
if key != "kind" && key != kind && key != "trace" {
return fmt.Errorf("unexpected top-level field %q", key)
}
}
trace, hasTrace := obj["trace"]
if hasTrace {
if _, ok := trace.(map[string]any); !ok {
return fmt.Errorf("event.trace must be a JSON object when present")
}
}
if strict && !hasTrace {
return fmt.Errorf("event.trace is required by the strict profile")
}
if kind == "error" {
if err := validateErrorPayload(obj["error"]); err != nil {
return err
}
}
if !strict {
return nil
}
switch kind {
case "error":
return validateStrictErrorPayload(obj["error"])
default:
return nil
}
}
func validateErrorPayload(value any) error {
errorPayload, ok := value.(map[string]any)
if !ok {
return fmt.Errorf("event.error must be a JSON object")
}
code, ok := errorPayload["code"].(string)
if !ok || code == "" {
return fmt.Errorf("event.error.code must be a non-empty string")
}
message, ok := errorPayload["message"].(string)
if !ok || message == "" {
return fmt.Errorf("event.error.message must be a non-empty string")
}
if hint, ok := errorPayload["hint"]; ok {
if _, ok := hint.(string); !ok {
return fmt.Errorf("event.error.hint must be a string when present")
}
}
return nil
}
func ValidateProtocolStream(events []any, strict bool) error {
terminalSeen := false
for idx, event := range events {
if err := ValidateProtocolEvent(event, strict); err != nil {
return fmt.Errorf("event %d: %w", idx, err)
}
kind := event.(map[string]any)["kind"].(string)
switch kind {
case "log", "progress":
if terminalSeen {
return fmt.Errorf("event %d: non-terminal event after terminal", idx)
}
case "result", "error":
if terminalSeen {
return fmt.Errorf("event %d: duplicate terminal event", idx)
}
terminalSeen = true
default:
return fmt.Errorf("event %d: unsupported event kind %q", idx, kind)
}
}
if !terminalSeen {
return fmt.Errorf("event stream must contain exactly one terminal result or error")
}
return nil
}
func validateStrictErrorPayload(value any) error {
errorPayload, ok := value.(map[string]any)
if !ok {
return fmt.Errorf("event.error must be a JSON object")
}
retryable, ok := errorPayload["retryable"]
if !ok {
return fmt.Errorf("event.error.retryable is required by the strict profile")
}
if _, ok := retryable.(bool); !ok {
return fmt.Errorf("event.error.retryable must be a boolean")
}
return nil
}
func isNonNegativeInteger(value any) bool {
v := reflect.ValueOf(value)
if !v.IsValid() {
return false
}
switch v.Kind() {
case reflect.Int, reflect.Int8, reflect.Int16, reflect.Int32, reflect.Int64:
return v.Int() >= 0
case reflect.Uint, reflect.Uint8, reflect.Uint16, reflect.Uint32, reflect.Uint64:
return true
case reflect.Float32, reflect.Float64:
f := v.Float()
return f >= 0 && math.Trunc(f) == f
default:
return false
}
}
type RedactionPolicy string
const (
RedactionAll RedactionPolicy = "All"
RedactionTraceOnly RedactionPolicy = "TraceOnly"
RedactionOff RedactionPolicy = "Off"
)
type Redactor struct {
SecretNames []string
Policy RedactionPolicy
}
func (r Redactor) Value(v any) any {
sanitized := sanitizeForJSON(v)
context := newRedactionContext(r)
return applyRedactionPolicyWithContext(sanitized, r.Policy, context)
}
func (r Redactor) URL(rawURL string) string {
context := newRedactionContext(r)
if redacted, ok := redactURLInStr(rawURL, context); ok {
return redacted
}
return rawURL
}
func (r Redactor) Argv(args []string) []string {
out := make([]string, 0, len(args))
if r.Policy == RedactionOff {
return append(out, args...)
}
context := newRedactionContext(r)
redactNext := false
for _, arg := range args {
if redactNext {
redactNext = false
if !strings.HasPrefix(arg, "-") {
out = append(out, "***")
continue
}
}
if rest, ok := strings.CutPrefix(arg, "--"); ok {
if name, _, found := strings.Cut(rest, "="); found {
if isSecretFlagName(name, context) {
out = append(out, "--"+name+"=***")
continue
}
} else if isSecretFlagName(rest, context) {
redactNext = true
}
}
out = append(out, arg)
}
return out
}
func isSecretFlagName(flagName string, context redactionContext) bool {
normalized := strings.ReplaceAll(flagName, "-", "_")
return context.isSecretKey(normalized) || context.isSecretKey(flagName)
}
type PlainStyle string
const (
PlainStyleReadable PlainStyle = "Readable"
PlainStyleRaw PlainStyle = "Raw"
)
type OutputOptions struct {
Redaction Redactor
Style PlainStyle
}
func OutputOptionsForPolicy(p RedactionPolicy) OutputOptions {
return OutputOptions{Redaction: Redactor{Policy: p}}
}
func renderJSON(value any, options OutputOptions) string {
return marshalOutputJSON(options.Redaction.Value(value))
}
func marshalOutputJSON(value any) string {
out, err := json.Marshal(value)
if err != nil {
fallback, _ := json.Marshal(map[string]any{
"error": "output_json_failed",
"detail": err.Error(),
})
return string(fallback)
}
return string(out)
}
func renderYaml(value any, options OutputOptions) string {
lines := []string{"---"}
v := options.Redaction.Value(value)
renderYamlRaw(v, 0, &lines)
return strings.Join(lines, "\n")
}
func renderPlain(value any, options OutputOptions) string {
var pairs [][2]string
v := options.Redaction.Value(value)
if options.Style == PlainStyleRaw {
collectPlainPairsRaw(v, "", &pairs)
} else {
collectPlainPairs(v, "", &pairs)
}
sort.Slice(pairs, func(i, j int) bool {
return jcsLess(pairs[i][0], pairs[j][0])
})
parts := make([]string, len(pairs))
for i, p := range pairs {
parts[i] = fmt.Sprintf("%s=%s", quoteLogfmtKey(p[0]), quoteLogfmtValue(p[1]))
}
return strings.Join(parts, " ")
}
func RedactedValue(value any) any {
return Redactor{}.Value(value)
}
func RedactURLSecrets(rawURL string) string {
return Redactor{}.URL(rawURL)
}
func RedactArgv(args []string) []string {
return Redactor{}.Argv(args)
}
func NormalizeUTCOffset(s string) (string, bool) {
s = strings.TrimSpace(s)
if strings.EqualFold(s, "UTC") || strings.EqualFold(s, "Z") {
return "UTC", true
}
if s == "" || (s[0] != '+' && s[0] != '-') {
return "", false
}
sign := s[0]
hours, minutes, ok := parseUTCOffsetBody(s[1:])
if !ok || hours > 23 || minutes > 59 {
return "", false
}
if hours == 0 && minutes == 0 {
return "UTC", true
}
return fmt.Sprintf("%c%02d:%02d", sign, hours, minutes), true
}
func IsValidRFC3339Date(s string) bool {
if len(s) != 10 || s[4] != '-' || s[7] != '-' {
return false
}
year, ok := parseASCIIInt(s[0:4])
if !ok {
return false
}
month, ok := parseASCIIInt(s[5:7])
if !ok {
return false
}
day, ok := parseASCIIInt(s[8:10])
if !ok {
return false
}
return month >= 1 && month <= 12 && day >= 1 && day <= daysInMonth(year, month)
}
func IsValidRFC3339Time(s string) bool {
if len(s) < 8 || s[2] != ':' || s[5] != ':' {
return false
}
hour, ok := parseASCIIInt(s[0:2])
if !ok {
return false
}
minute, ok := parseASCIIInt(s[3:5])
if !ok {
return false
}
second, ok := parseASCIIInt(s[6:8])
if !ok {
return false
}
if hour > 23 || minute > 59 || second > 59 {
return false
}
if len(s) == 8 {
return true
}
if s[8] != '.' || len(s) == 9 {
return false
}
return isASCIIDigits(s[9:])
}
func IsValidRFC3339(s string) bool {
if len(s) < 20 || !isASCIIString(s) {
return false
}
if !IsValidRFC3339Date(s[0:10]) {
return false
}
if s[10] != 'T' && s[10] != 't' {
return false
}
rest := s[11:]
var partial string
if last := rest[len(rest)-1]; last == 'Z' || last == 'z' {
partial = rest[:len(rest)-1]
} else {
if len(rest) < 6 || !isRFC3339NumOffset(rest[len(rest)-6:]) {
return false
}
partial = rest[:len(rest)-6]
}
return IsValidRFC3339Time(partial)
}
func isRFC3339NumOffset(o string) bool {
if len(o) != 6 || (o[0] != '+' && o[0] != '-') || o[3] != ':' {
return false
}
hours, ok := parseASCIIInt(o[1:3])
if !ok {
return false
}
minutes, ok := parseASCIIInt(o[4:6])
if !ok {
return false
}
return hours <= 23 && minutes <= 59
}
func isASCIIString(s string) bool {
for i := 0; i < len(s); i++ {
if s[i] >= 0x80 {
return false
}
}
return true
}
func IsValidBCP47(s string) bool {
if s == "" {
return false
}
for index, subtag := range strings.Split(s, "-") {
if len(subtag) < 1 || len(subtag) > 8 || !isASCIIAlnumString(subtag) {
return false
}
if index == 0 {
isLanguage := len(subtag) >= 2 && len(subtag) <= 3 && isASCIIAlphaString(subtag)
isSpecial := subtag == "x" || subtag == "i"
if !isLanguage && !isSpecial {
return false
}
}
}
return true
}
func isASCIIAlnumString(s string) bool {
for i := 0; i < len(s); i++ {
if !isASCIIAlphanumeric(s[i]) {
return false
}
}
return true
}
func isASCIIAlphaString(s string) bool {
for i := 0; i < len(s); i++ {
if !isASCIIAlpha(s[i]) {
return false
}
}
return true
}
func parseUTCOffsetBody(body string) (int, int, bool) {
if body == "" {
return 0, 0, false
}
if strings.Contains(body, ":") {
parts := strings.Split(body, ":")
if len(parts) != 2 || parts[0] == "" || len(parts[0]) > 2 || len(parts[1]) != 2 {
return 0, 0, false
}
hours, ok := parseASCIIInt(parts[0])
if !ok {
return 0, 0, false
}
minutes, ok := parseASCIIInt(parts[1])
return hours, minutes, ok
}
if !isASCIIDigits(body) {
return 0, 0, false
}
switch len(body) {
case 1, 2:
hours, ok := parseASCIIInt(body)
return hours, 0, ok
case 4:
hours, ok := parseASCIIInt(body[:2])
if !ok {
return 0, 0, false
}
minutes, ok := parseASCIIInt(body[2:])
return hours, minutes, ok
default:
return 0, 0, false
}
}
func parseASCIIInt(s string) (int, bool) {
if !isASCIIDigits(s) {
return 0, false
}
n, err := strconv.Atoi(s)
if err != nil {
return 0, false
}
return n, true
}
func isASCIIDigits(s string) bool {
if s == "" {
return false
}
for i := 0; i < len(s); i++ {
if s[i] < '0' || s[i] > '9' {
return false
}
}
return true
}
func daysInMonth(year int, month int) int {
switch month {
case 1, 3, 5, 7, 8, 10, 12:
return 31
case 4, 6, 9, 11:
return 30
case 2:
if isLeapYear(year) {
return 29
}
return 28
default:
return 0
}
}
func isLeapYear(year int) bool {
return year%4 == 0 && (year%100 != 0 || year%400 == 0)
}
type redactionContext struct {
secretNames map[string]struct{}
}
func newRedactionContext(r Redactor) redactionContext {
names := make(map[string]struct{}, len(r.SecretNames))
for _, name := range r.SecretNames {
names[name] = struct{}{}
}
return redactionContext{secretNames: names}
}
func (c redactionContext) isSecretKey(key string) bool {
if keyHasSecretSuffix(key) {
return true
}
if len(c.secretNames) == 0 {
return false
}
_, ok := c.secretNames[key]
return ok
}
func keyHasSecretSuffix(key string) bool {
return strings.HasSuffix(key, "_secret") || strings.HasSuffix(key, "_SECRET")
}
func keyHasURLSuffix(key string) bool {
return strings.HasSuffix(key, "_url") || strings.HasSuffix(key, "_URL")
}
const maxDepth = 256
const maxDepthMarker = "<afdata:max-depth>"
func redactSecretsWithContext(value any, context redactionContext) any {
return redactSecretsWithContextDepth(value, context, 0)
}
func redactSecretsWithContextDepth(value any, context redactionContext, depth int) any {
if depth >= maxDepth {
return maxDepthMarker
}
switch v := value.(type) {
case map[string]any:
for k := range v {
switch {
case context.isSecretKey(k):
v[k] = "***"
case keyHasURLSuffix(k):
if s, ok := v[k].(string); ok {
v[k] = redactURLFieldValue(s, context)
} else {
v[k] = redactSecretsWithContextDepth(v[k], context, depth+1)
}
default:
v[k] = redactSecretsWithContextDepth(v[k], context, depth+1)
}
}
return v
case []any:
for i, item := range v {
v[i] = redactSecretsWithContextDepth(item, context, depth+1)
}
return v
default:
return value
}
}
func applyRedactionPolicyWithContext(value any, redactionPolicy RedactionPolicy, context redactionContext) any {
switch redactionPolicy {
case RedactionTraceOnly:
if obj, ok := value.(map[string]any); ok {
if trace, exists := obj["trace"]; exists {
obj["trace"] = redactSecretsWithContext(trace, context)
}
}
return value
case RedactionOff:
return value
default:
return redactSecretsWithContext(value, context)
}
}
func redactURLFieldValue(s string, context redactionContext) string {
if redacted, ok := redactURLInStr(s, context); ok {
return redacted
}
trimmed := strings.TrimSpace(s)
if trimmed != s {
if redacted, ok := redactURLInStr(trimmed, context); ok {
return redacted
}
}
hasWhitespace := strings.IndexFunc(s, func(r rune) bool {
return r == ' ' || r == '\t' || r == '\n' || r == '\r' || r == '\f' || r == '\v'
}) >= 0
if hasWhitespace || strings.Contains(s, "@") {
return "***"
}
return s
}
func redactURLInStr(s string, context redactionContext) (string, bool) {
if !strings.Contains(s, "://") || !isSingleURL(s) {
return "", false
}
schemeSep := strings.Index(s, "://")
if schemeSep < 0 {
return "", false
}
scheme := s[:schemeSep]
rest := s[schemeSep+3:]
authEnd := strings.IndexAny(rest, "/?#")
if authEnd < 0 {
authEnd = len(rest)
}
authority := rest[:authEnd]
remainder := rest[authEnd:]
newAuthority := redactUserinfoPassword(authority)
newRemainder := remainder
if q := strings.Index(remainder, "?"); q >= 0 {
path := remainder[:q]
queryBody := remainder[q+1:]
query := queryBody
fragment := ""
if h := strings.Index(queryBody, "#"); h >= 0 {
query = queryBody[:h]
fragment = queryBody[h:]
}
newRemainder = path + "?" + redactQuery(query, context) + fragment
}
return scheme + "://" + newAuthority + newRemainder, true
}
func redactUserinfoPassword(authority string) string {
at := strings.LastIndex(authority, "@")
if at < 0 {
return authority
}
userinfo := authority[:at]
colon := strings.Index(userinfo, ":")
if colon < 0 {
return authority
}
return authority[:colon] + ":***" + authority[at:]
}
func redactQuery(query string, context redactionContext) string {
segments := strings.Split(query, "&")
for i, segment := range segments {
eq := strings.Index(segment, "=")
if eq < 0 {
continue
}
rawKey := segment[:eq]
name := formDecodeName(segment)
if context.isSecretKey(name) {
segments[i] = rawKey + "=***"
}
}
return strings.Join(segments, "&")
}
func formDecodeName(segment string) string {
eq := strings.Index(segment, "=")
rawKey := segment
if eq >= 0 {
rawKey = segment[:eq]
}
decoded, err := url.QueryUnescape(rawKey)
if err != nil {
return rawKey
}
return decoded
}
func isSingleURL(s string) bool {
for i := 0; i < len(s); i++ {
if isASCIIWhitespace(s[i]) {
return false
}
}
if len(s) == 0 || !isASCIIAlpha(s[0]) {
return false
}
i := 1
for i < len(s) {
c := s[i]
if isASCIIAlphanumeric(c) || c == '+' || c == '-' || c == '.' {
i++
} else {
break
}
}
return strings.HasPrefix(s[i:], "://")
}
func isASCIIWhitespace(b byte) bool {
switch b {
case '\t', '\n', '\v', '\f', '\r', ' ':
return true
}
return false
}
func isASCIIAlpha(b byte) bool {
return (b >= 'a' && b <= 'z') || (b >= 'A' && b <= 'Z')
}
func isASCIIAlphanumeric(b byte) bool {
return isASCIIAlpha(b) || (b >= '0' && b <= '9')
}
func stripSuffixCI(key, suffixLower string) (string, bool) {
if strings.HasSuffix(key, suffixLower) {
return key[:len(key)-len(suffixLower)], true
}
suffixUpper := strings.ToUpper(suffixLower)
if strings.HasSuffix(key, suffixUpper) {
return key[:len(key)-len(suffixUpper)], true
}
return "", false
}
func tryStripGenericCents(key string) (stripped, code string, ok bool) {
code = extractCurrencyCode(key)
if code == "" {
return "", "", false
}
suffixLen := len(code) + len("_cents") + 1 stripped = key[:len(key)-suffixLen]
if stripped == "" {
return "", "", false
}
return stripped, code, true
}
func tryStripGenericMicro(key string) (stripped, code string, ok bool) {
code = extractCurrencyCodeMicro(key)
if code == "" {
return "", "", false
}
suffixLen := len(code) + len("_micro") + 1 stripped = key[:len(key)-suffixLen]
if stripped == "" {
return "", "", false
}
return stripped, code, true
}
type processedField struct {
key string
value any
formatted string
isFormatted bool
}
func tryProcessField(key string, value any) (string, string, bool) {
if stripped, ok := stripSuffixCI(key, "_epoch_ms"); ok {
if n, ok := asInt64(value); ok {
if formatted, ok := formatRFC3339Ms(n); ok {
return stripped, formatted, true
}
}
return "", "", false
}
if stripped, ok := stripSuffixCI(key, "_epoch_s"); ok {
if n, ok := asInt64(value); ok {
if n > math.MaxInt64/1000 || n < math.MinInt64/1000 {
return "", "", false }
if formatted, ok := formatRFC3339Ms(n * 1000); ok {
return stripped, formatted, true
}
}
return "", "", false
}
if stripped, ok := stripSuffixCI(key, "_epoch_ns"); ok {
if n, ok := asDecimalInt64(value); ok {
ms := n / 1_000_000
if n%1_000_000 < 0 {
ms--
}
if formatted, ok := formatRFC3339Ms(ms); ok {
return stripped, formatted, true
}
}
return "", "", false
}
if stripped, ok := stripSuffixCI(key, "_usd_cents"); ok {
if n, ok := asNonNegInt64(value); ok {
return stripped, fmt.Sprintf("$%d.%02d", n/100, n%100), true
}
return "", "", false
}
if stripped, ok := stripSuffixCI(key, "_eur_cents"); ok {
if n, ok := asNonNegInt64(value); ok {
return stripped, fmt.Sprintf("\u20ac%d.%02d", n/100, n%100), true
}
return "", "", false
}
if stripped, code, ok := tryStripGenericCents(key); ok {
if n, ok := asNonNegInt64(value); ok {
return stripped, fmt.Sprintf("%d.%02d %s", n/100, n%100, strings.ToUpper(code)), true
}
return "", "", false
}
if stripped, code, ok := tryStripGenericMicro(key); ok {
if n, ok := asNonNegInt64(value); ok {
return stripped, fmt.Sprintf("%d.%06d %s", n/1_000_000, n%1_000_000, strings.ToUpper(code)), true
}
return "", "", false
}
if stripped, ok := stripSuffixCI(key, "_rfc3339"); ok {
if s, ok := value.(string); ok {
return stripped, s, true
}
return "", "", false
}
if stripped, ok := stripSuffixCI(key, "_minutes"); ok {
if _, ok := asFloat64(value); ok {
return stripped, plainScalar(value) + " minutes", true
}
return "", "", false
}
if stripped, ok := stripSuffixCI(key, "_hours"); ok {
if _, ok := asFloat64(value); ok {
return stripped, plainScalar(value) + " hours", true
}
return "", "", false
}
if stripped, ok := stripSuffixCI(key, "_days"); ok {
if _, ok := asFloat64(value); ok {
return stripped, plainScalar(value) + " days", true
}
return "", "", false
}
if stripped, ok := stripSuffixCI(key, "_msats"); ok {
if text, ok := decimalIntText(value); ok {
return stripped, text + "msats", true
}
return "", "", false
}
if stripped, ok := stripSuffixCI(key, "_sats"); ok {
if text, ok := decimalIntText(value); ok {
return stripped, text + "sats", true
}
return "", "", false
}
if stripped, ok := stripSuffixCI(key, "_bytes"); ok {
if n, ok := asNonNegInt64(value); ok {
return stripped, formatBytesHuman(n), true
}
return "", "", false
}
if stripped, ok := stripSuffixCI(key, "_percent"); ok {
if _, ok := asFloat64(value); ok {
return stripped, plainScalar(value) + "%", true
}
return "", "", false
}
if stripped, ok := stripSuffixCI(key, "_jpy"); ok {
if n, ok := asNonNegInt64(value); ok {
return stripped, fmt.Sprintf("\u00a5%s", formatWithCommas(uint64(n))), true
}
return "", "", false
}
if stripped, ok := stripSuffixCI(key, "_ns"); ok {
if _, ok := asFloat64(value); ok {
return stripped, plainScalar(value) + "ns", true
}
return "", "", false
}
if stripped, ok := stripSuffixCI(key, "_us"); ok {
if _, ok := asFloat64(value); ok {
return stripped, plainScalar(value) + "\u03bcs", true
}
return "", "", false
}
if stripped, ok := stripSuffixCI(key, "_ms"); ok {
if formatted, ok := formatMsValue(value); ok {
return stripped, formatted, true
}
return "", "", false
}
if stripped, ok := stripSuffixCI(key, "_s"); ok {
if _, ok := asFloat64(value); ok {
return stripped, plainScalar(value) + "s", true
}
return "", "", false
}
return "", "", false
}
func processObjectFields(m map[string]any) []processedField {
type entry struct {
stripped string
original string
value any
formatted string
isFormatted bool
}
entries := make([]entry, 0, len(m))
for k, v := range m {
if stripped, ok := stripSuffixCI(k, "_secret"); ok {
entries = append(entries, entry{stripped, k, v, "", false})
continue
}
if stripped, formatted, ok := tryProcessField(k, v); ok {
entries = append(entries, entry{stripped, k, v, formatted, true})
} else {
entries = append(entries, entry{k, k, v, "", false})
}
}
counts := make(map[string]int)
for _, e := range entries {
counts[e.stripped]++
}
result := make([]processedField, len(entries))
for i, e := range entries {
displayKey := e.stripped
isFormatted := e.isFormatted
formatted := e.formatted
if counts[e.stripped] > 1 && e.original != e.stripped {
displayKey = e.original
isFormatted = false
formatted = ""
}
result[i] = processedField{displayKey, e.value, formatted, isFormatted}
}
sort.Slice(result, func(i, j int) bool {
return jcsLess(result[i].key, result[j].key)
})
return result
}
func formatMsAsSeconds(ms float64) string {
formatted := fmt.Sprintf("%.3f", ms/1000)
trimmed := strings.TrimRight(formatted, "0")
if strings.HasSuffix(trimmed, ".") {
return trimmed + "0s"
}
return trimmed + "s"
}
func formatMsValue(value any) (string, bool) {
n, ok := asFloat64(value)
if !ok {
return "", false
}
if math.Abs(n) >= 1000 {
return formatMsAsSeconds(n), true
}
return plainScalar(value) + "ms", true
}
const minRFC3339Ms int64 = -62135596800000
const maxRFC3339Ms int64 = 253402300799999
func formatRFC3339Ms(ms int64) (string, bool) {
if ms < minRFC3339Ms || ms > maxRFC3339Ms {
return "", false
}
sec := ms / 1000
rem := ms % 1000
if rem < 0 {
sec--
rem += 1000
}
nsec := rem * 1_000_000
t := time.Unix(sec, nsec).UTC()
return t.Format("2006-01-02T15:04:05.000Z"), true
}
func formatBytesHuman(bytes int64) string {
const KiB = 1024.0
const MiB = KiB * 1024
const GiB = MiB * 1024
const TiB = GiB * 1024
b := float64(bytes)
switch {
case b >= TiB:
return fmt.Sprintf("%.1fTiB", b/TiB)
case b >= GiB:
return fmt.Sprintf("%.1fGiB", b/GiB)
case b >= MiB:
return fmt.Sprintf("%.1fMiB", b/MiB)
case b >= KiB:
return fmt.Sprintf("%.1fKiB", b/KiB)
default:
return fmt.Sprintf("%dB", bytes)
}
}
func formatWithCommas(n uint64) string {
s := fmt.Sprintf("%d", n)
if len(s) <= 3 {
return s
}
var result strings.Builder
for i, c := range s {
if i > 0 && (len(s)-i)%3 == 0 {
result.WriteByte(',')
}
result.WriteRune(c)
}
return result.String()
}
func extractCurrencyCode(key string) string {
var withoutCents string
if strings.HasSuffix(key, "_cents") {
withoutCents = key[:len(key)-6]
} else if strings.HasSuffix(key, "_CENTS") {
withoutCents = key[:len(key)-6]
} else {
return ""
}
return extractCurrencyCodeFromStem(withoutCents)
}
func extractCurrencyCodeMicro(key string) string {
var withoutMicro string
if strings.HasSuffix(key, "_micro") {
withoutMicro = key[:len(key)-6]
} else if strings.HasSuffix(key, "_MICRO") {
withoutMicro = key[:len(key)-6]
} else {
return ""
}
return extractCurrencyCodeFromStem(withoutMicro)
}
func extractCurrencyCodeFromStem(stem string) string {
idx := strings.LastIndex(stem, "_")
if idx < 0 {
return ""
}
code := stem[idx+1:]
if code == "" || len(code) < 3 || len(code) > 4 {
return ""
}
for i := 0; i < len(code); i++ {
c := code[i]
if !((c >= 'a' && c <= 'z') || (c >= 'A' && c <= 'Z')) {
return ""
}
}
return code
}
func renderYamlRaw(value any, indent int, lines *[]string) {
prefix := strings.Repeat(" ", indent)
switch v := value.(type) {
case map[string]any:
for _, key := range sortedObjectKeys(v) {
renderYamlFieldRaw(prefix, key, v[key], indent, lines)
}
case []any:
renderYamlArrayRaw(v, indent, lines)
default:
*lines = append(*lines, fmt.Sprintf("%s%s", prefix, yamlScalar(value)))
}
}
func renderYamlFieldRaw(prefix, key string, value any, indent int, lines *[]string) {
switch v := value.(type) {
case map[string]any:
if len(v) > 0 {
*lines = append(*lines, fmt.Sprintf("%s%s:", prefix, yamlKey(key)))
renderYamlRaw(v, indent+1, lines)
} else {
*lines = append(*lines, fmt.Sprintf("%s%s: {}", prefix, yamlKey(key)))
}
case []any:
if len(v) == 0 {
*lines = append(*lines, fmt.Sprintf("%s%s: []", prefix, yamlKey(key)))
} else {
*lines = append(*lines, fmt.Sprintf("%s%s:", prefix, yamlKey(key)))
renderYamlArrayRaw(v, indent+1, lines)
}
default:
*lines = append(*lines, fmt.Sprintf("%s%s: %s", prefix, yamlKey(key), yamlScalar(value)))
}
}
func renderYamlArrayRaw(arr []any, indent int, lines *[]string) {
prefix := strings.Repeat(" ", indent)
for _, item := range arr {
switch v := item.(type) {
case map[string]any:
if len(v) > 0 {
*lines = append(*lines, fmt.Sprintf("%s-", prefix))
renderYamlRaw(v, indent+1, lines)
} else {
*lines = append(*lines, fmt.Sprintf("%s- {}", prefix))
}
case []any:
if len(v) > 0 {
*lines = append(*lines, fmt.Sprintf("%s-", prefix))
renderYamlArrayRaw(v, indent+1, lines)
} else {
*lines = append(*lines, fmt.Sprintf("%s- []", prefix))
}
default:
*lines = append(*lines, fmt.Sprintf("%s- %s", prefix, yamlScalar(item)))
}
}
}
func escapeYamlStr(s string) string {
s = strings.ReplaceAll(s, `\`, `\\`)
s = strings.ReplaceAll(s, `"`, `\"`)
s = strings.ReplaceAll(s, "\n", `\n`)
s = strings.ReplaceAll(s, "\r", `\r`)
s = strings.ReplaceAll(s, "\t", `\t`)
s = strings.ReplaceAll(s, "\f", `\f`)
s = strings.ReplaceAll(s, "\v", `\v`)
return s
}
func yamlKey(key string) string {
if isSafeKey(key) {
return key
}
return `"` + escapeYamlStr(key) + `"`
}
func quoteLogfmtKey(key string) string {
if isSafeKey(key) {
return key
}
return quoteLogfmtValue(key)
}
func isSafeKey(key string) bool {
if key == "" {
return false
}
for i := 0; i < len(key); i++ {
c := key[i]
if !((c >= 'a' && c <= 'z') || (c >= 'A' && c <= 'Z') || (c >= '0' && c <= '9') || c == '_' || c == '-' || c == '.') {
return false
}
}
return true
}
func yamlScalar(value any) string {
switch v := value.(type) {
case string:
return fmt.Sprintf(`"%s"`, escapeYamlStr(v))
case nil:
return "null"
case bool:
if v {
return "true"
}
return "false"
case int:
return strconv.Itoa(v)
case int64:
return strconv.FormatInt(v, 10)
case float64:
return formatFloatCanonical(v)
case json.Number:
return v.String()
case map[string]any, []any:
return fmt.Sprintf(`"%s"`, escapeYamlStr(canonicalJSON(value)))
default:
return fmt.Sprintf(`"<unsupported:%T>"`, value)
}
}
func collectPlainPairs(value any, prefix string, pairs *[][2]string) {
m, ok := value.(map[string]any)
if !ok {
return
}
for _, pf := range processObjectFields(m) {
fullKey := pf.key
if prefix != "" {
fullKey = prefix + "." + pf.key
}
if pf.isFormatted {
*pairs = append(*pairs, [2]string{fullKey, pf.formatted})
} else {
switch v := pf.value.(type) {
case map[string]any:
collectPlainPairs(v, fullKey, pairs)
case []any:
parts := make([]string, len(v))
for i, item := range v {
parts[i] = plainScalar(item)
}
*pairs = append(*pairs, [2]string{fullKey, strings.Join(parts, ",")})
case nil:
*pairs = append(*pairs, [2]string{fullKey, ""})
default:
*pairs = append(*pairs, [2]string{fullKey, plainScalar(pf.value)})
}
}
}
}
func collectPlainPairsRaw(value any, prefix string, pairs *[][2]string) {
m, ok := value.(map[string]any)
if !ok {
return
}
for _, key := range sortedObjectKeys(m) {
fullKey := key
if prefix != "" {
fullKey = prefix + "." + key
}
switch v := m[key].(type) {
case map[string]any:
collectPlainPairsRaw(v, fullKey, pairs)
case []any:
parts := make([]string, len(v))
for i, item := range v {
parts[i] = plainScalarRaw(item)
}
*pairs = append(*pairs, [2]string{fullKey, strings.Join(parts, ",")})
case nil:
*pairs = append(*pairs, [2]string{fullKey, ""})
default:
*pairs = append(*pairs, [2]string{fullKey, plainScalar(v)})
}
}
}
func plainScalar(value any) string {
switch v := value.(type) {
case string:
return v
case nil:
return "null"
case bool:
if v {
return "true"
}
return "false"
case int:
return strconv.Itoa(v)
case int64:
return strconv.FormatInt(v, 10)
case float64:
return formatFloatCanonical(v)
case json.Number:
return v.String()
case map[string]any, []any:
return canonicalJSON(value)
default:
return fmt.Sprintf("<unsupported:%T>", value)
}
}
func plainScalarRaw(value any) string {
switch value.(type) {
case map[string]any, []any:
return canonicalJSON(value)
}
return plainScalar(value)
}
func formatFloatCanonical(v float64) string {
if math.IsInf(v, 0) || math.IsNaN(v) {
return strconv.FormatFloat(v, 'g', -1, 64)
}
if math.Trunc(v) == v && math.Abs(v) < 1e21 {
return strconv.FormatFloat(v, 'f', 0, 64)
}
return normalizeExponent(strconv.FormatFloat(v, 'g', -1, 64))
}
func normalizeExponent(s string) string {
e := strings.IndexAny(s, "eE")
if e < 0 {
return s
}
mantissa := s[:e]
exp := s[e+1:]
sign := ""
if strings.HasPrefix(exp, "+") || strings.HasPrefix(exp, "-") {
sign = exp[:1]
exp = exp[1:]
}
exp = strings.TrimLeft(exp, "0")
if exp == "" {
exp = "0"
}
return mantissa + "e" + sign + exp
}
func quoteLogfmtValue(value string) string {
if value == "" {
return ""
}
needsQuote := false
for _, r := range value {
if r == '=' || r == '"' || r == '\\' || r == '\u00a0' || r == '\v' || r == '\f' || r == '\n' || r == '\r' || r == '\t' || r == ' ' {
needsQuote = true
break
}
}
if !needsQuote {
return value
}
escaped := strings.ReplaceAll(value, `\`, `\\`)
escaped = strings.ReplaceAll(escaped, `"`, `\"`)
escaped = strings.ReplaceAll(escaped, "\n", `\n`)
escaped = strings.ReplaceAll(escaped, "\r", `\r`)
escaped = strings.ReplaceAll(escaped, "\t", `\t`)
escaped = strings.ReplaceAll(escaped, "\f", `\f`)
escaped = strings.ReplaceAll(escaped, "\v", `\v`)
return `"` + escaped + `"`
}
func canonicalJSON(value any) string {
b, err := json.Marshal(sortJSONValue(value))
if err != nil {
return fmt.Sprintf("<unsupported:%T>", value)
}
return string(b)
}
func sortJSONValue(value any) any {
switch v := value.(type) {
case map[string]any:
out := make(map[string]any, len(v))
for _, key := range sortedObjectKeys(v) {
out[key] = sortJSONValue(v[key])
}
return out
case []any:
out := make([]any, len(v))
for i, item := range v {
out[i] = sortJSONValue(item)
}
return out
default:
return value
}
}
func asInt64(value any) (int64, bool) {
switch v := value.(type) {
case int:
return int64(v), true
case int64:
return v, true
case float64:
if v == math.Trunc(v) && !math.IsInf(v, 0) {
return int64(v), true
}
case json.Number:
if n, err := v.Int64(); err == nil {
return n, true
}
}
return 0, false
}
func asNonNegInt64(value any) (int64, bool) {
n, ok := asInt64(value)
if ok && n >= 0 {
return n, true
}
return 0, false
}
func asDecimalInt64(value any) (int64, bool) {
if s, ok := value.(string); ok && isDecimalIntegerString(s) {
n, err := strconv.ParseInt(s, 10, 64)
return n, err == nil
}
return asInt64(value)
}
func decimalIntText(value any) (string, bool) {
if s, ok := value.(string); ok && isDecimalIntegerString(s) {
return s, true
}
if n, ok := asInt64(value); ok {
return strconv.FormatInt(n, 10), true
}
return "", false
}
func isDecimalIntegerString(s string) bool {
if strings.HasPrefix(s, "-") {
s = strings.TrimPrefix(s, "-")
}
if s == "" {
return false
}
for _, r := range s {
if r < '0' || r > '9' {
return false
}
}
return true
}
func asFloat64(value any) (float64, bool) {
switch v := value.(type) {
case int:
return float64(v), true
case int64:
return float64(v), true
case float64:
return v, true
case json.Number:
if n, err := v.Float64(); err == nil {
return n, true
}
}
return 0, false
}
func normalize(value any) any {
switch value.(type) {
case map[string]any, []any, string, float64, bool, nil, json.Number:
return value
}
b, err := json.Marshal(value)
if err != nil {
return value
}
dec := json.NewDecoder(bytes.NewReader(b))
dec.UseNumber()
var result any
if err := dec.Decode(&result); err != nil {
return value
}
return result
}
func sanitizeForJSON(value any) any {
return sanitizeForJSONWithVisited(value, map[visitKey]struct{}{}, 0)
}
type visitKey struct {
kind reflect.Kind
ptr uintptr
}
func sanitizeForJSONWithVisited(value any, visited map[visitKey]struct{}, depth int) any {
if depth >= maxDepth {
return maxDepthMarker
}
switch v := value.(type) {
case map[string]any:
rv := reflect.ValueOf(v)
key := visitKey{kind: rv.Kind(), ptr: rv.Pointer()}
if key.ptr != 0 {
if _, seen := visited[key]; seen {
return "<unsupported:circular>"
}
visited[key] = struct{}{}
defer delete(visited, key)
}
out := make(map[string]any, len(v))
for k, item := range v {
out[k] = sanitizeForJSONWithVisited(item, visited, depth+1)
}
return out
case []any:
if len(v) > 0 {
rv := reflect.ValueOf(v)
key := visitKey{kind: rv.Kind(), ptr: rv.Pointer()}
if _, seen := visited[key]; seen {
return "<unsupported:circular>"
}
visited[key] = struct{}{}
defer delete(visited, key)
}
out := make([]any, len(v))
for i, item := range v {
out[i] = sanitizeForJSONWithVisited(item, visited, depth+1)
}
return out
}
normalized := normalize(value)
if _, err := json.Marshal(normalized); err == nil {
return normalized
}
return fmt.Sprintf("<unsupported:%T>", value)
}
func jcsLess(a, b string) bool {
ua := utf16.Encode([]rune(a))
ub := utf16.Encode([]rune(b))
for i := 0; i < len(ua) && i < len(ub); i++ {
if ua[i] != ub[i] {
return ua[i] < ub[i]
}
}
return len(ua) < len(ub)
}
func sortedObjectKeys(m map[string]any) []string {
keys := make([]string, 0, len(m))
for key := range m {
keys = append(keys, key)
}
sort.Slice(keys, func(i, j int) bool {
return jcsLess(keys[i], keys[j])
})
return keys
}