- service: reject supplied child-profile attribution with a distinct ErrChildProfileForbidden (403 child_profile_forbidden) instead of silently dropping it as if the profile were not found; a profile that is simply not the user's still drops attribution unchanged - repo: add a manifest-free list projection (reportListSelectSQL / scanReportSummary) for admin list and retention/stale cleanup queries so they no longer drag the full manifest JSONB per row; keep the full projection for GetByID/DeleteByID and mark Manifest omitempty - cleanup: delete/mark the DB row before the blob in retention and stale loops so a mid-run DB failure can't leave a ready report pointing at a missing bundle; blob-delete failures are logged with bucket/keys for orphan cleanup to reap rather than aborting the run (shared helper with the admin DeleteReport path) - admin: reject diagnostics settings where max_bytes_per_user would fall below max_bundle_bytes (and the reciprocal), which would make every max-size upload fail quota - router/demo: route POST /diagnostics/reports through DemoGuard and block the reports prefix in demo mode while keeping GET /diagnostics/status available Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_012e3QjbPo96ed9Mn2qRiUkh
147 lines
4.1 KiB
Go
147 lines
4.1 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"`
|
|
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
|
|
}
|