- 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
151 lines
4.3 KiB
Go
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
|
|
}
|