Files
silo-server/internal/diagnostics/types.go
T
Quick104andClaude Fable 5 9fad08a6fa fix(diagnostics): address round-6 review findings on PR #445
- AdminDiagnostics list: fix regression where rows dereferenced the
  now-omitted manifest for app_build. Project app_build server-side out
  of manifest JSONB into both list and detail responses (cheap
  COALESCE(manifest->'report'->>'app_build','')), split the TS type into
  DiagnosticReportSummary (list, no manifest) and DiagnosticReport
  (detail, with manifest), and read report.app_build in the row/detail.
- embeddedManifestMatches: decode with json.Decoder + UseNumber so large
  integers above 2^53 (e.g. log_summary.lines) can't collapse to the same
  float and falsely match; re-assert no-trailing-data strictness.
- Quota reservation (SKIP): reserving the client-claimed archive.bytes is
  sound because archiveMatches requires claimed==actual before MarkReady,
  so no stored report exceeds its reservation; documented in a code comment.
- Multipart parts: reject a wrong-name/wrong-content-type part without
  calling part.Close(), which would drain up to the bundle limit while
  holding the in-flight slot; abandon it so malformed uploads fail promptly.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012e3QjbPo96ed9Mn2qRiUkh
2026-07-21 14:23:59 -04:00

151 lines
4.3 KiB
Go

package diagnostics
import (
"context"
"encoding/json"
"errors"
"fmt"
"time"
"unicode/utf8"
)
const MaxCrashSummaryBytes = 8 * 1024
type ReportState string
const (
StateReceiving ReportState = "receiving"
StateReady ReportState = "ready"
StateFailed ReportState = "failed"
)
var (
ErrNotFound = errors.New("diagnostic report not found")
ErrQuotaExceeded = errors.New("diagnostic report quota exceeded")
ErrShortIDExhausted = errors.New("diagnostic short id collision retries exhausted")
ErrInvalidReportInput = errors.New("invalid diagnostic report input")
ErrInvalidCursor = errors.New("invalid diagnostics cursor")
)
type ReportStore interface {
InsertReceiving(ctx context.Context, input InsertReceivingInput) (InsertReceivingResult, error)
MarkReady(ctx context.Context, id string, blob BlobInfo) error
MarkFailed(ctx context.Context, id string) error
GetByID(ctx context.Context, id string) (*Report, error)
ListForAdmin(ctx context.Context, filters ListFilters) (ListResult, error)
DeleteByID(ctx context.Context, id string) (*Report, error)
RetentionCandidates(ctx context.Context, olderThan time.Time, perUserByteCap int64) ([]Report, error)
StaleReceiving(ctx context.Context, grace time.Duration) ([]Report, error)
}
type InsertReceivingInput struct {
UserID int
ProfileID *string
CapturedAt time.Time
ReportType string
Platform string
AppVersion string
CrashSummary *string
Manifest json.RawMessage
PlaybackSessionIDs []string
ExpectedBlobBytes int64
MaxReportsPerUserDay int
MaxBytesPerUser int64
Now time.Time
ShortIDGenerator func() (string, error)
ReportIDGenerator func() string
ShortIDCollisionRetries int
}
type InsertReceivingResult struct {
ID string
ShortID string
}
type BlobInfo struct {
Bucket string
Key string
Bytes int64
UncompressedBytes int64
SHA256 string
}
type Report struct {
ID string `json:"id"`
ShortID string `json:"short_id"`
UserID int `json:"user_id"`
ProfileID *string `json:"profile_id,omitempty"`
State ReportState `json:"state"`
CapturedAt time.Time `json:"captured_at"`
ReceivedAt time.Time `json:"received_at"`
ReportType string `json:"report_type"`
Platform string `json:"platform"`
AppVersion string `json:"app_version"`
// AppBuild is projected from manifest->'report'->>'app_build' so list rows
// (which omit the full manifest) can still show the build number without
// dragging every report's manifest JSONB through the DB. Empty when absent.
AppBuild string `json:"app_build"`
CrashSummary *string `json:"crash_summary,omitempty"`
Manifest json.RawMessage `json:"manifest,omitempty"`
PlaybackSessionIDs []string `json:"playback_session_ids"`
BlobBucket *string `json:"blob_bucket,omitempty"`
BlobKey *string `json:"blob_key,omitempty"`
BlobBytes *int64 `json:"blob_bytes,omitempty"`
UncompressedBytes *int64 `json:"uncompressed_bytes,omitempty"`
BlobSHA256 *string `json:"blob_sha256,omitempty"`
}
type ListFilters struct {
UserID *int
Platform string
ReportType string
From *time.Time
To *time.Time
ShortID string
Limit int
Cursor string
}
type ListResult struct {
Reports []Report `json:"reports"`
NextCursor string `json:"next_cursor,omitempty"`
}
type QuotaKind string
const (
QuotaKindReportsPerDay QuotaKind = "reports_per_day"
QuotaKindBytesPerUser QuotaKind = "bytes_per_user"
)
type QuotaError struct {
Kind QuotaKind
Limit int64
}
func (e *QuotaError) Error() string {
if e == nil {
return ErrQuotaExceeded.Error()
}
return fmt.Sprintf("%s: %s limit %d", ErrQuotaExceeded, e.Kind, e.Limit)
}
func (e *QuotaError) Unwrap() error {
return ErrQuotaExceeded
}
func truncateCrashSummary(summary *string) *string {
if summary == nil {
return nil
}
value := *summary
if len(value) <= MaxCrashSummaryBytes {
return &value
}
value = value[:MaxCrashSummaryBytes]
for !utf8.ValidString(value) && len(value) > 0 {
value = value[:len(value)-1]
}
return &value
}