Files
silo-server/internal/diagnostics/contract/contract.go
T
5fdb5d73ac feat(diagnostics): expand client log attribute registry (#649)
Adds the attribute keys the Apple and Android clients need to record
startup, network, auth, and playback behavior in diagnostics reports.

The four playback keys were already being emitted by the Apple client
but were never registered here, so they were silently dropped by this
collector and hard-rejected by the hosted one. Registering them makes
the canonical registry the superset it was always meant to be.

position_seconds is deliberately registered as position_ms (integer):
attrValueType has only string and integer, and a millisecond ordinal
avoids adding a float type for a single key. launch_type is a string
for the same reason, rather than a bool.

New keys:
  playback:  session_id, play_method, reason, position_ms
  lifecycle: phase, duration_ms, outcome, reason, launch_type
  network:   outcome, error_code, attempt

Purely additive; no existing key changes type or is removed.
TestAttrRegistryStaysInSync keeps the Go map and the JSON in lockstep.

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-15 11:39:17 -07:00

1082 lines
30 KiB
Go

package contract
import (
"bytes"
"encoding/json"
"fmt"
"io"
"regexp"
"slices"
"strconv"
"strings"
"time"
)
const (
SchemaVersion = 1
MaxStackExcerptBytes = 8192
// MaxLogLineBytes bounds a single newline-delimited entry line
// (logs.jsonl / breadcrumbs.jsonl) during streaming bundle validation. It
// is well above the per-field caps a log line can legitimately reach (msg
// 2048, tag/run 128, plus registered attrs) so it never rejects a valid
// line, while keeping per-line memory bounded when validating.
MaxLogLineBytes = 64 * 1024
)
var (
manifestRequiredFields = []string{
"schema_version",
"report",
"destination",
"consent",
"device_summary",
"playback_session_ids",
"log_summary",
"archive",
}
manifestReportRequiredFields = []string{
"type",
"captured_at",
"capture_session_id",
"app_version",
"app_build",
"platform",
"os_version",
}
manifestDestinationRequiredFields = []string{"server_instance_id"}
manifestConsentRequiredFields = []string{"mode", "notice_version"}
manifestCrashRequiredFields = []string{"summary", "source", "provenance", "occurred_at"}
manifestDeviceSummaryFields = []string{"manufacturer", "model", "os", "form_factor"}
manifestLogSummaryRequiredFields = []string{"lines", "bytes_gz", "dropped_lines", "categories", "debug_logging"}
manifestArchiveRequiredFields = []string{"entries", "bytes", "uncompressed_bytes", "sha256"}
reportTypes = []string{"crash", "anr", "native_crash", "hang", "abnormal_exit", "manual"}
platforms = []string{"android", "android-tv", "ios", "tvos"}
crashSources = []string{"ueh", "exit_info", "metrickit", "exit_sentinel"}
crashProvenances = []string{"pre_failure", "post_restart", "metric_reporting_period"}
consentModes = []string{"prompt", "always", "manual"}
logLevels = []string{"V", "D", "I", "W", "E"}
logCategories = []string{"playback", "focus", "network", "lifecycle", "browse", "cast", "download", "crash", "other"}
deviceProvenances = []string{
"pre_failure",
"post_restart",
}
deviceRequiredFields = []string{"captured_at", "provenance", "identity", "display", "audio", "video_codecs", "network"}
ArchiveEntryAllowlist = []string{
"manifest.json",
"device.json",
"logs.jsonl",
"crash/summary.json",
"crash/stack.txt",
"crash/tombstone.pb",
"crash/metrickit.json",
"breadcrumbs.jsonl",
}
sha256Pattern = regexp.MustCompile(`^[A-Fa-f0-9]{64}$`)
)
type Manifest struct {
SchemaVersion int
Report Report
Destination Destination
Consent Consent
Crash *Crash
DeviceSummary DeviceSummary
PlaybackSessionIDs []string
LogSummary LogSummary
Archive Archive
}
type Report struct {
Type string
CapturedAt time.Time
CaptureSessionID string
AppVersion string
AppBuild string
Platform string
OSVersion string
ProfileID string
}
type Destination struct {
ServerInstanceID string
}
type Consent struct {
Mode string
NoticeVersion int
}
type Crash struct {
Summary string
StackExcerpt string
Thread string
Foreground *bool
Source string
Provenance string
OccurredAt time.Time
}
type DeviceSummary struct {
Manufacturer string
Model string
OS string
FormFactor string
}
type LogSummary struct {
Lines int64
BytesGz int64
DroppedLines int64
Categories []string
DebugLogging bool
}
type Archive struct {
Entries []string
// Bytes is the size of the bundle part as transmitted (the gzip stream).
Bytes int64
// UncompressedBytes is the total size of the decompressed tar stream,
// including headers, end-of-archive blocks, and record padding — the
// byte count a client observes between its tar writer and gzip writer.
UncompressedBytes int64
// SHA256 is the hex digest of the bundle part as transmitted.
SHA256 string
}
type DeviceSnapshot struct {
CapturedAt time.Time
Provenance string
Identity json.RawMessage
Display json.RawMessage
Audio json.RawMessage
VideoCodecs json.RawMessage
Network json.RawMessage
OriginalJSON json.RawMessage
}
type LogLine struct {
Timestamp time.Time
Run string
Level string
Category string
Tag string
Message string
Attrs map[string]any
}
type manifestWire struct {
SchemaVersion *int `json:"schema_version"`
Report *reportWire `json:"report"`
Destination *destinationWire `json:"destination"`
Consent *consentWire `json:"consent"`
Crash *crashWire `json:"crash"`
DeviceSummary *deviceSummaryWire `json:"device_summary"`
PlaybackSessionIDs *[]string `json:"playback_session_ids"`
LogSummary *logSummaryWire `json:"log_summary"`
Archive *archiveWire `json:"archive"`
}
type reportWire struct {
Type *string `json:"type"`
CapturedAt *string `json:"captured_at"`
CaptureSessionID *string `json:"capture_session_id"`
AppVersion *string `json:"app_version"`
AppBuild *string `json:"app_build"`
Platform *string `json:"platform"`
OSVersion *string `json:"os_version"`
ProfileID *string `json:"profile_id"`
}
type destinationWire struct {
ServerInstanceID *string `json:"server_instance_id"`
}
type consentWire struct {
Mode *string `json:"mode"`
NoticeVersion *int `json:"notice_version"`
}
type crashWire struct {
Summary *string `json:"summary"`
StackExcerpt *string `json:"stack_excerpt"`
Thread *string `json:"thread"`
Foreground *bool `json:"foreground"`
Source *string `json:"source"`
Provenance *string `json:"provenance"`
OccurredAt *string `json:"occurred_at"`
}
type deviceSummaryWire struct {
Manufacturer *string `json:"manufacturer"`
Model *string `json:"model"`
OS *string `json:"os"`
FormFactor *string `json:"form_factor"`
}
type logSummaryWire struct {
Lines *int64 `json:"lines"`
BytesGz *int64 `json:"bytes_gz"`
DroppedLines *int64 `json:"dropped_lines"`
Categories *[]string `json:"categories"`
DebugLogging *bool `json:"debug_logging"`
}
type archiveWire struct {
Entries *[]string `json:"entries"`
Bytes *int64 `json:"bytes"`
UncompressedBytes *int64 `json:"uncompressed_bytes"`
SHA256 *string `json:"sha256"`
}
type logLineWire struct {
Timestamp *string `json:"ts"`
Run *string `json:"run"`
Level *string `json:"lvl"`
Category *string `json:"cat"`
Tag *string `json:"tag"`
Message *string `json:"msg"`
Attrs json.RawMessage `json:"attrs"`
}
type attrValueType string
const (
attrString attrValueType = "string"
attrInteger attrValueType = "integer"
)
var attrRegistry = map[string]map[string]attrValueType{
"playback": {
"sink": attrString,
"fmt": attrString,
"decoder": attrString,
"width": attrInteger,
"height": attrInteger,
"hdr_mode": attrString,
"bitrate_kbps": attrInteger,
"dropped_frames": attrInteger,
"audio_underruns": attrInteger,
"session_id": attrString,
"play_method": attrString,
"reason": attrString,
"position_ms": attrInteger,
},
"focus": {
"target": attrString,
"action": attrString,
},
"network": {
"method": attrString,
"path": attrString,
"status": attrInteger,
"duration_ms": attrInteger,
"outcome": attrString,
"error_code": attrString,
"attempt": attrInteger,
},
"lifecycle": {
"state": attrString,
"phase": attrString,
"duration_ms": attrInteger,
"outcome": attrString,
"reason": attrString,
"launch_type": attrString,
},
"crash": {
"fingerprint": attrString,
"source": attrString,
},
}
func ValidateManifest(data []byte) (Manifest, error) {
var wire manifestWire
if err := decodeJSON(data, &wire); err != nil {
return Manifest{}, err
}
if wire.SchemaVersion == nil {
return Manifest{}, requiredError("manifest.schema_version")
}
if *wire.SchemaVersion != SchemaVersion {
return Manifest{}, fieldError("manifest.schema_version", "unsupported value %d", *wire.SchemaVersion)
}
if wire.Report == nil {
return Manifest{}, requiredError("manifest.report")
}
report, err := validateReport(*wire.Report)
if err != nil {
return Manifest{}, err
}
if wire.Destination == nil {
return Manifest{}, requiredError("manifest.destination")
}
destination, err := validateDestination(*wire.Destination)
if err != nil {
return Manifest{}, err
}
if wire.Consent == nil {
return Manifest{}, requiredError("manifest.consent")
}
consent, err := validateConsent(*wire.Consent)
if err != nil {
return Manifest{}, err
}
// Per the spec the crash object is present for every event report type and
// absent for manual captures. Enforce both directions so an event report
// can't be accepted with no crash summary/source/occurred_at to diagnose.
var crash *Crash
switch {
case report.Type == "manual":
if wire.Crash != nil {
return Manifest{}, fieldError("manifest.crash", "must be absent for manual reports")
}
case wire.Crash == nil:
return Manifest{}, requiredError("manifest.crash")
default:
validated, err := validateCrash(*wire.Crash)
if err != nil {
return Manifest{}, err
}
crash = &validated
}
if wire.DeviceSummary == nil {
return Manifest{}, requiredError("manifest.device_summary")
}
deviceSummary, err := validateDeviceSummary(*wire.DeviceSummary)
if err != nil {
return Manifest{}, err
}
if wire.PlaybackSessionIDs == nil {
return Manifest{}, requiredError("manifest.playback_session_ids")
}
playbackSessionIDs, err := validateStringSlice("manifest.playback_session_ids", *wire.PlaybackSessionIDs, 20, 1, 128, false)
if err != nil {
return Manifest{}, err
}
if wire.LogSummary == nil {
return Manifest{}, requiredError("manifest.log_summary")
}
logSummary, err := validateLogSummary(*wire.LogSummary)
if err != nil {
return Manifest{}, err
}
if wire.Archive == nil {
return Manifest{}, requiredError("manifest.archive")
}
archive, err := validateArchive(*wire.Archive)
if err != nil {
return Manifest{}, err
}
return Manifest{
SchemaVersion: *wire.SchemaVersion,
Report: report,
Destination: destination,
Consent: consent,
Crash: crash,
DeviceSummary: deviceSummary,
PlaybackSessionIDs: playbackSessionIDs,
LogSummary: logSummary,
Archive: archive,
}, nil
}
func ValidateDevice(data []byte) (DeviceSnapshot, error) {
var raw map[string]json.RawMessage
if err := decodeJSON(data, &raw); err != nil {
return DeviceSnapshot{}, err
}
capturedAt, err := requiredRawDateTime(raw, "captured_at", "device.captured_at")
if err != nil {
return DeviceSnapshot{}, err
}
provenance, err := requiredRawString(raw, "provenance", "device.provenance", 1, 32)
if err != nil {
return DeviceSnapshot{}, err
}
if err := validateEnum("device.provenance", provenance, deviceProvenances); err != nil {
return DeviceSnapshot{}, err
}
identity, err := requiredRawSection(raw, "identity", "device.identity", sectionObject)
if err != nil {
return DeviceSnapshot{}, err
}
display, err := requiredRawSection(raw, "display", "device.display", sectionObject)
if err != nil {
return DeviceSnapshot{}, err
}
audio, err := requiredRawSection(raw, "audio", "device.audio", sectionObject)
if err != nil {
return DeviceSnapshot{}, err
}
videoCodecs, err := requiredRawSection(raw, "video_codecs", "device.video_codecs", sectionArray)
if err != nil {
return DeviceSnapshot{}, err
}
network, err := requiredRawSection(raw, "network", "device.network", sectionObject)
if err != nil {
return DeviceSnapshot{}, err
}
return DeviceSnapshot{
CapturedAt: capturedAt,
Provenance: provenance,
Identity: append(json.RawMessage(nil), identity...),
Display: append(json.RawMessage(nil), display...),
Audio: append(json.RawMessage(nil), audio...),
VideoCodecs: append(json.RawMessage(nil), videoCodecs...),
Network: append(json.RawMessage(nil), network...),
OriginalJSON: append(json.RawMessage(nil), bytes.TrimSpace(data)...),
}, nil
}
func ValidateLogLine(data []byte) (LogLine, error) {
var wire logLineWire
if err := decodeJSON(data, &wire); err != nil {
return LogLine{}, err
}
timestamp, err := validateDateTime("log.ts", wire.Timestamp)
if err != nil {
return LogLine{}, err
}
run, err := validateRequiredString("log.run", wire.Run, 1, 128)
if err != nil {
return LogLine{}, err
}
level, err := validateRequiredString("log.lvl", wire.Level, 1, 1)
if err != nil {
return LogLine{}, err
}
if err := validateEnum("log.lvl", level, logLevels); err != nil {
return LogLine{}, err
}
category, err := validateRequiredString("log.cat", wire.Category, 1, 32)
if err != nil {
return LogLine{}, err
}
if err := validateEnum("log.cat", category, logCategories); err != nil {
return LogLine{}, err
}
tag, err := validateRequiredString("log.tag", wire.Tag, 1, 128)
if err != nil {
return LogLine{}, err
}
msg, err := validateRequiredString("log.msg", wire.Message, 1, 2048)
if err != nil {
return LogLine{}, err
}
attrs, err := validateAttrs(category, wire.Attrs)
if err != nil {
return LogLine{}, err
}
return LogLine{
Timestamp: timestamp,
Run: run,
Level: level,
Category: category,
Tag: tag,
Message: msg,
Attrs: attrs,
}, nil
}
func ArchiveEntryAllowed(name string) bool {
return slices.Contains(ArchiveEntryAllowlist, name)
}
func validateReport(w reportWire) (Report, error) {
reportType, err := validateRequiredString("manifest.report.type", w.Type, 1, 32)
if err != nil {
return Report{}, err
}
if err := validateEnum("manifest.report.type", reportType, reportTypes); err != nil {
return Report{}, err
}
capturedAt, err := validateDateTime("manifest.report.captured_at", w.CapturedAt)
if err != nil {
return Report{}, err
}
captureSessionID, err := validateRequiredString("manifest.report.capture_session_id", w.CaptureSessionID, 1, 128)
if err != nil {
return Report{}, err
}
appVersion, err := validateRequiredString("manifest.report.app_version", w.AppVersion, 1, 64)
if err != nil {
return Report{}, err
}
appBuild, err := validateRequiredString("manifest.report.app_build", w.AppBuild, 1, 64)
if err != nil {
return Report{}, err
}
platform, err := validateRequiredString("manifest.report.platform", w.Platform, 1, 32)
if err != nil {
return Report{}, err
}
if err := validateEnum("manifest.report.platform", platform, platforms); err != nil {
return Report{}, err
}
osVersion, err := validateRequiredString("manifest.report.os_version", w.OSVersion, 1, 128)
if err != nil {
return Report{}, err
}
profileID, err := validateOptionalString("manifest.report.profile_id", w.ProfileID, 128)
if err != nil {
return Report{}, err
}
return Report{
Type: reportType,
CapturedAt: capturedAt,
CaptureSessionID: captureSessionID,
AppVersion: appVersion,
AppBuild: appBuild,
Platform: platform,
OSVersion: osVersion,
ProfileID: profileID,
}, nil
}
func validateDestination(w destinationWire) (Destination, error) {
serverInstanceID, err := validateRequiredString("manifest.destination.server_instance_id", w.ServerInstanceID, 1, 128)
if err != nil {
return Destination{}, err
}
return Destination{ServerInstanceID: serverInstanceID}, nil
}
func validateConsent(w consentWire) (Consent, error) {
mode, err := validateRequiredString("manifest.consent.mode", w.Mode, 1, 32)
if err != nil {
return Consent{}, err
}
if err := validateEnum("manifest.consent.mode", mode, consentModes); err != nil {
return Consent{}, err
}
if w.NoticeVersion == nil {
return Consent{}, requiredError("manifest.consent.notice_version")
}
if *w.NoticeVersion < 1 {
return Consent{}, fieldError("manifest.consent.notice_version", "must be >= 1")
}
return Consent{Mode: mode, NoticeVersion: *w.NoticeVersion}, nil
}
func validateCrash(w crashWire) (Crash, error) {
summary, err := validateRequiredString("manifest.crash.summary", w.Summary, 1, MaxStackExcerptBytes)
if err != nil {
return Crash{}, err
}
stackExcerpt, err := validateOptionalString("manifest.crash.stack_excerpt", w.StackExcerpt, MaxStackExcerptBytes)
if err != nil {
return Crash{}, err
}
thread, err := validateOptionalString("manifest.crash.thread", w.Thread, 128)
if err != nil {
return Crash{}, err
}
source, err := validateRequiredString("manifest.crash.source", w.Source, 1, 32)
if err != nil {
return Crash{}, err
}
if err := validateEnum("manifest.crash.source", source, crashSources); err != nil {
return Crash{}, err
}
provenance, err := validateRequiredString("manifest.crash.provenance", w.Provenance, 1, 32)
if err != nil {
return Crash{}, err
}
if err := validateEnum("manifest.crash.provenance", provenance, crashProvenances); err != nil {
return Crash{}, err
}
occurredAt, err := validateDateTime("manifest.crash.occurred_at", w.OccurredAt)
if err != nil {
return Crash{}, err
}
return Crash{
Summary: summary,
StackExcerpt: stackExcerpt,
Thread: thread,
Foreground: w.Foreground,
Source: source,
Provenance: provenance,
OccurredAt: occurredAt,
}, nil
}
func validateDeviceSummary(w deviceSummaryWire) (DeviceSummary, error) {
manufacturer, err := validateRequiredString("manifest.device_summary.manufacturer", w.Manufacturer, 1, 128)
if err != nil {
return DeviceSummary{}, err
}
model, err := validateRequiredString("manifest.device_summary.model", w.Model, 1, 128)
if err != nil {
return DeviceSummary{}, err
}
osVersion, err := validateRequiredString("manifest.device_summary.os", w.OS, 1, 128)
if err != nil {
return DeviceSummary{}, err
}
formFactor, err := validateRequiredString("manifest.device_summary.form_factor", w.FormFactor, 1, 64)
if err != nil {
return DeviceSummary{}, err
}
return DeviceSummary{
Manufacturer: manufacturer,
Model: model,
OS: osVersion,
FormFactor: formFactor,
}, nil
}
func validateLogSummary(w logSummaryWire) (LogSummary, error) {
lines, err := validateNonNegativeInt64("manifest.log_summary.lines", w.Lines)
if err != nil {
return LogSummary{}, err
}
bytesGz, err := validateNonNegativeInt64("manifest.log_summary.bytes_gz", w.BytesGz)
if err != nil {
return LogSummary{}, err
}
droppedLines, err := validateNonNegativeInt64("manifest.log_summary.dropped_lines", w.DroppedLines)
if err != nil {
return LogSummary{}, err
}
if w.Categories == nil {
return LogSummary{}, requiredError("manifest.log_summary.categories")
}
categories, err := validateStringSlice("manifest.log_summary.categories", *w.Categories, 9, 1, 32, true)
if err != nil {
return LogSummary{}, err
}
for i, category := range categories {
if err := validateEnum(fmt.Sprintf("manifest.log_summary.categories[%d]", i), category, logCategories); err != nil {
return LogSummary{}, err
}
}
if w.DebugLogging == nil {
return LogSummary{}, requiredError("manifest.log_summary.debug_logging")
}
return LogSummary{
Lines: lines,
BytesGz: bytesGz,
DroppedLines: droppedLines,
Categories: categories,
DebugLogging: *w.DebugLogging,
}, nil
}
func validateArchive(w archiveWire) (Archive, error) {
if w.Entries == nil {
return Archive{}, requiredError("manifest.archive.entries")
}
entries, err := validateStringSlice("manifest.archive.entries", *w.Entries, len(ArchiveEntryAllowlist), 1, 64, true)
if err != nil {
return Archive{}, err
}
hasManifest := false
for i, entry := range entries {
if !ArchiveEntryAllowed(entry) {
return Archive{}, fieldError(fmt.Sprintf("manifest.archive.entries[%d]", i), "unsupported value %q", entry)
}
if entry == "manifest.json" {
hasManifest = true
}
}
if !hasManifest {
return Archive{}, fieldError("manifest.archive.entries", "must include manifest.json")
}
bytesCount, err := validateNonNegativeInt64("manifest.archive.bytes", w.Bytes)
if err != nil {
return Archive{}, err
}
uncompressedBytes, err := validateNonNegativeInt64("manifest.archive.uncompressed_bytes", w.UncompressedBytes)
if err != nil {
return Archive{}, err
}
sha256Value, err := validateRequiredString("manifest.archive.sha256", w.SHA256, 64, 64)
if err != nil {
return Archive{}, err
}
if !sha256Pattern.MatchString(sha256Value) {
return Archive{}, fieldError("manifest.archive.sha256", "must be 64 hexadecimal characters")
}
return Archive{
Entries: entries,
Bytes: bytesCount,
UncompressedBytes: uncompressedBytes,
SHA256: strings.ToLower(sha256Value),
}, nil
}
type sectionKind int
const (
sectionObject sectionKind = iota
sectionArray
)
func requiredRawSection(raw map[string]json.RawMessage, key, path string, kind sectionKind) (json.RawMessage, error) {
value, ok := raw[key]
if !ok {
return nil, requiredError(path)
}
if isRawNull(value) {
return nil, fieldError(path, "must not be null")
}
if sentinel, ok, err := rawSentinel(value); err != nil {
return nil, err
} else if ok {
if sentinel != "unknown" && sentinel != "not_collected" {
return nil, fieldError(path, "unsupported sentinel %q", sentinel)
}
return append(json.RawMessage(nil), value...), nil
}
switch kind {
case sectionObject:
if err := validateDeviceObject(path, value, 0); err != nil {
return nil, err
}
case sectionArray:
if err := validateVideoCodecs(path, value); err != nil {
return nil, err
}
default:
return nil, fieldError(path, "unsupported section kind")
}
return append(json.RawMessage(nil), value...), nil
}
func validateVideoCodecs(path string, raw json.RawMessage) error {
var items []json.RawMessage
if err := decodeJSON(raw, &items); err != nil {
return fieldError(path, "must be an array or unknown/not_collected")
}
if len(items) > 128 {
return fieldError(path, "must contain <= 128 items")
}
for i, item := range items {
if err := validateDeviceObject(fmt.Sprintf("%s[%d]", path, i), item, 0); err != nil {
return err
}
}
return nil
}
func validateDeviceObject(path string, raw json.RawMessage, depth int) error {
if depth > 8 {
return fieldError(path, "is nested too deeply")
}
var obj map[string]json.RawMessage
if err := decodeJSON(raw, &obj); err != nil {
return fieldError(path, "must be an object or unknown/not_collected")
}
if len(obj) > 64 {
return fieldError(path, "must contain <= 64 properties")
}
for key, value := range obj {
if err := validateStringBytes(path+" key", key, 1, 128); err != nil {
return err
}
if err := validateDeviceValue(path+"."+key, value, depth+1); err != nil {
return err
}
}
return nil
}
func validateDeviceValue(path string, raw json.RawMessage, depth int) error {
if depth > 8 {
return fieldError(path, "is nested too deeply")
}
if isRawNull(raw) {
return nil
}
if _, ok, err := rawSentinel(raw); err != nil {
return err
} else if ok {
var s string
if err := json.Unmarshal(raw, &s); err != nil {
return fieldError(path, "must be a valid string")
}
return validateStringBytes(path, s, 0, 512)
}
switch firstRawByte(raw) {
case '{':
return validateDeviceObject(path, raw, depth+1)
case '[':
var items []json.RawMessage
if err := decodeJSON(raw, &items); err != nil {
return fieldError(path, "must be an array")
}
if len(items) > 128 {
return fieldError(path, "must contain <= 128 items")
}
for i, item := range items {
if err := validateDeviceValue(fmt.Sprintf("%s[%d]", path, i), item, depth+1); err != nil {
return err
}
}
return nil
case '"':
var s string
if err := json.Unmarshal(raw, &s); err != nil {
return fieldError(path, "must be a valid string")
}
return validateStringBytes(path, s, 0, 512)
case 't', 'f':
var b bool
if err := json.Unmarshal(raw, &b); err != nil {
return fieldError(path, "must be a boolean")
}
return nil
default:
var n json.Number
if err := decodeJSON(raw, &n); err != nil {
return fieldError(path, "must be a string, number, boolean, null, array, or object")
}
if _, err := strconv.ParseFloat(n.String(), 64); err != nil {
return fieldError(path, "must be a valid number")
}
return nil
}
}
func validateAttrs(category string, raw json.RawMessage) (map[string]any, error) {
if len(bytes.TrimSpace(raw)) == 0 {
return nil, nil
}
if isRawNull(raw) {
return nil, fieldError("log.attrs", "must be an object")
}
var attrs map[string]json.RawMessage
if err := decodeJSON(raw, &attrs); err != nil {
return nil, fieldError("log.attrs", "must be an object")
}
registered := attrRegistry[category]
if len(registered) == 0 {
return nil, nil
}
filtered := make(map[string]any)
for key, value := range attrs {
valueType, ok := registered[key]
if !ok {
continue
}
decoded, err := validateAttrValue("log.attrs."+key, value, valueType)
if err != nil {
return nil, err
}
filtered[key] = decoded
}
if len(filtered) == 0 {
return nil, nil
}
return filtered, nil
}
func validateAttrValue(path string, raw json.RawMessage, valueType attrValueType) (any, error) {
switch valueType {
case attrString:
var s string
if err := json.Unmarshal(raw, &s); err != nil {
return nil, fieldError(path, "must be a string")
}
if err := validateStringBytes(path, s, 0, 512); err != nil {
return nil, err
}
return s, nil
case attrInteger:
if b := firstRawByte(raw); b != '-' && (b < '0' || b > '9') {
return nil, fieldError(path, "must be an integer")
}
var n json.Number
if err := decodeJSON(raw, &n); err != nil {
return nil, fieldError(path, "must be an integer")
}
i, err := strconv.ParseInt(n.String(), 10, 64)
if err != nil {
return nil, fieldError(path, "must be an integer")
}
return i, nil
default:
return nil, fieldError(path, "has unsupported registered type %q", valueType)
}
}
func validateDateTime(path string, value *string) (time.Time, error) {
s, err := validateRequiredString(path, value, 1, 64)
if err != nil {
return time.Time{}, err
}
parsed, err := time.Parse(time.RFC3339Nano, s)
if err != nil {
return time.Time{}, fieldError(path, "must be RFC3339 date-time")
}
return parsed, nil
}
func requiredRawDateTime(raw map[string]json.RawMessage, key, path string) (time.Time, error) {
s, err := requiredRawString(raw, key, path, 1, 64)
if err != nil {
return time.Time{}, err
}
parsed, err := time.Parse(time.RFC3339Nano, s)
if err != nil {
return time.Time{}, fieldError(path, "must be RFC3339 date-time")
}
return parsed, nil
}
func requiredRawString(raw map[string]json.RawMessage, key, path string, minBytes, maxBytes int) (string, error) {
value, ok := raw[key]
if !ok {
return "", requiredError(path)
}
var s string
if err := json.Unmarshal(value, &s); err != nil {
return "", fieldError(path, "must be a string")
}
if err := validateStringBytes(path, s, minBytes, maxBytes); err != nil {
return "", err
}
return s, nil
}
func validateRequiredString(path string, value *string, minBytes, maxBytes int) (string, error) {
if value == nil {
return "", requiredError(path)
}
if err := validateStringBytes(path, *value, minBytes, maxBytes); err != nil {
return "", err
}
return *value, nil
}
func validateOptionalString(path string, value *string, maxBytes int) (string, error) {
if value == nil {
return "", nil
}
if err := validateStringBytes(path, *value, 0, maxBytes); err != nil {
return "", err
}
return *value, nil
}
func validateStringBytes(path, value string, minBytes, maxBytes int) error {
if len(value) < minBytes {
return fieldError(path, "must be at least %d bytes", minBytes)
}
if maxBytes > 0 && len(value) > maxBytes {
return fieldError(path, "exceeds %d bytes", maxBytes)
}
return nil
}
func validateNonNegativeInt64(path string, value *int64) (int64, error) {
if value == nil {
return 0, requiredError(path)
}
if *value < 0 {
return 0, fieldError(path, "must be >= 0")
}
return *value, nil
}
func validateStringSlice(path string, values []string, maxItems, minStringBytes, maxStringBytes int, unique bool) ([]string, error) {
if maxItems > 0 && len(values) > maxItems {
return nil, fieldError(path, "must contain <= %d items", maxItems)
}
if unique {
seen := make(map[string]struct{}, len(values))
for i, value := range values {
if _, ok := seen[value]; ok {
return nil, fieldError(fmt.Sprintf("%s[%d]", path, i), "must be unique")
}
seen[value] = struct{}{}
}
}
for i, value := range values {
if err := validateStringBytes(fmt.Sprintf("%s[%d]", path, i), value, minStringBytes, maxStringBytes); err != nil {
return nil, err
}
}
return append([]string(nil), values...), nil
}
func validateEnum(path, value string, allowed []string) error {
if !slices.Contains(allowed, value) {
return fieldError(path, "unsupported value %q", value)
}
return nil
}
func decodeJSON(data []byte, out any) error {
dec := json.NewDecoder(bytes.NewReader(data))
dec.UseNumber()
if err := dec.Decode(out); err != nil {
return fmt.Errorf("invalid JSON: %w", err)
}
var trailing any
if err := dec.Decode(&trailing); err != io.EOF {
return fmt.Errorf("invalid JSON: trailing data")
}
return nil
}
func rawSentinel(raw json.RawMessage) (string, bool, error) {
if firstRawByte(raw) != '"' {
return "", false, nil
}
var s string
if err := json.Unmarshal(raw, &s); err != nil {
return "", false, fieldError("device", "contains invalid string")
}
if s == "unknown" || s == "not_collected" {
return s, true, nil
}
return s, true, nil
}
func firstRawByte(raw json.RawMessage) byte {
trimmed := bytes.TrimSpace(raw)
if len(trimmed) == 0 {
return 0
}
return trimmed[0]
}
func isRawNull(raw json.RawMessage) bool {
return bytes.Equal(bytes.TrimSpace(raw), []byte("null"))
}
func requiredError(path string) error {
return fieldError(path, "required")
}
func fieldError(path, format string, args ...any) error {
return fmt.Errorf("%s: %s", path, fmt.Sprintf(format, args...))
}