package apperr import ( "context" "sort" "time" "github.com/carlosframework/platform/internal/applog" "github.com/carlosframework/platform/internal/release" ) // Prefix is the app's errors/ subtree (spec §3.2): a sibling family of // logs/ with the same key shape below it. // An invalid account or app is refused (applog.ErrBadKeySegment). func Prefix(acct, app string) (string, error) { return applog.FamilyPrefix(applog.FamilyErrors, acct, app) } // BoxPrefix is one box's subtree within an app's errors. func BoxPrefix(acct, app, box string) (string, error) { return applog.FamilyBoxPrefix(applog.FamilyErrors, acct, app, box) } // ChunkKey names one flushed errors chunk; see applog.ChunkKey. func ChunkKey(acct, app, box, hour string, seq uint64, size int) (string, error) { return applog.FamilyChunkKey(applog.FamilyErrors, acct, app, box, hour, seq, size) } // HourOf is applog.HourOf, re-exported so shippers need one import. func HourOf(t time.Time) string { return applog.HourOf(t) } // EncodeChunk renders records as gzipped JSONL. func EncodeChunk(recs []Record) ([]byte, error) { return applog.EncodeJSONL(recs) } // DecodeChunk parses a chunk, skipping malformed lines. func DecodeChunk(b []byte) ([]Record, error) { return applog.DecodeJSONL[Record](b) } // Filter is the read-side match. A coalesced record belongs to a window // when [First, Last] overlaps [Since, Until] and is counted whole (spec // §3.2: a window edge is wrong by at most one flush interval). type Filter struct { Since, Until time.Time Host string Source string Release string FP string } // Match reports whether r survives f. func (f Filter) Match(r Record) bool { last, first := r.Last, r.First if last.IsZero() { last = r.TS } if first.IsZero() { first = r.TS } if !f.Since.IsZero() && last.Before(f.Since) { return false } if !f.Until.IsZero() && first.After(f.Until) { return false } if f.Host != "" && r.Host != f.Host { return false } if f.Source != "" && r.Source != f.Source { return false } if f.Release != "" && r.Release != f.Release { return false } if f.FP != "" && r.FP != f.FP { return false } return true } // ReadWindow assembles one app's errors/ records across boxes: every hour // intersecting the window, filtered through f, merged by (Last, Box, Seq). // collectCap bounds memory; past it the oldest go and the bool says so. func ReadWindow(ctx context.Context, s release.Store, acct, app string, f Filter, collectCap int) ([]Record, bool, error) { // A record's chunk sits under the hour of its FIRST occurrence (TS), // but its Last can be much later — a group that started before the // window opened and is still being added to at read time. ReadChunks // prunes hour directories at listing time using the SAME since/until // it is given, so listing with the exact f.Since would skip that // chunk's hour entirely. Widen the LISTING window by one hour behind // f.Since; f.Match (which tests [First, Last] against the true // window) still does the exact filtering once the chunk is read. listSince := f.Since if !listSince.IsZero() { listSince = listSince.Add(-time.Hour) } prefix, err := Prefix(acct, app) if err != nil { return nil, false, err } perBox, trimmed, err := applog.ReadChunks[Record](ctx, s, prefix, listSince, f.Until, f.Match, collectCap) if err != nil { return nil, false, err } merged := MergeRecords(perBox...) if over := len(merged) - collectCap; over > 0 { merged = merged[over:] trimmed = true } return merged, trimmed, nil } // MergeRecords folds per-source slices into one timeline ordered by // (Last, Box, Seq), oldest first. func MergeRecords(slices ...[]Record) []Record { var out []Record for _, s := range slices { out = append(out, s...) } sort.SliceStable(out, func(i, j int) bool { a, b := out[i], out[j] if !a.Last.Equal(b.Last) { return a.Last.Before(b.Last) } if a.Box != b.Box { return a.Box < b.Box } return a.Seq < b.Seq }) return out }