Export the reviewed application-neutral package set through the exact public allowlist. Development history and private application evidence remain outside this canonical source root. Developed with material AI assistance under maintainer review. Signed-off-by: Cole Speelman <crspeelman@gmail.com>
This commit is contained in:
@@ -0,0 +1,196 @@
|
||||
// SPDX-License-Identifier: MPL-2.0
|
||||
|
||||
// Package analytics produces bounded typed projections from requestlog records.
|
||||
// It does not expose HTTP routes or decide who may view sensitive evidence.
|
||||
package analytics
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"io"
|
||||
"sort"
|
||||
"time"
|
||||
|
||||
"gamertan.com/web/requestlog"
|
||||
)
|
||||
|
||||
type TrafficClass string
|
||||
|
||||
const (
|
||||
Public TrafficClass = "public"
|
||||
Operator TrafficClass = "operator"
|
||||
Health TrafficClass = "health"
|
||||
Automated TrafficClass = "automated"
|
||||
)
|
||||
|
||||
type Classifier func(requestlog.Record) TrafficClass
|
||||
|
||||
type Query struct {
|
||||
From, Until time.Time
|
||||
IncludeOperator bool
|
||||
MaxRecords int
|
||||
Classify Classifier
|
||||
}
|
||||
|
||||
type RouteSummary struct {
|
||||
Route string `json:"route"`
|
||||
Requests int `json:"requests"`
|
||||
Errors int `json:"errors"`
|
||||
Bytes int64 `json:"bytes"`
|
||||
DurationMicros int64 `json:"duration_micros"`
|
||||
}
|
||||
type StatusSummary struct {
|
||||
Status int `json:"status"`
|
||||
Requests int `json:"requests"`
|
||||
}
|
||||
|
||||
type SafeReport struct {
|
||||
From, Until time.Time
|
||||
Requests, Errors int
|
||||
Bytes int64
|
||||
P50Micros, P95Micros, P99Micros int64
|
||||
Routes []RouteSummary
|
||||
Statuses []StatusSummary
|
||||
Truncated bool
|
||||
}
|
||||
|
||||
type SensitiveRecord struct {
|
||||
Timestamp time.Time
|
||||
RequestID, ClientIP, Path, Query, Referer, UserAgent, SessionID string
|
||||
Status int
|
||||
DurationMicros int64
|
||||
}
|
||||
type SensitiveReport struct {
|
||||
Safe SafeReport
|
||||
Recent []SensitiveRecord
|
||||
}
|
||||
|
||||
func Safe(records []requestlog.Record, query Query) (SafeReport, error) {
|
||||
report, _, err := aggregate(records, query, false)
|
||||
return report, err
|
||||
}
|
||||
func Sensitive(records []requestlog.Record, query Query) (SensitiveReport, error) {
|
||||
safe, recent, err := aggregate(records, query, true)
|
||||
return SensitiveReport{Safe: safe, Recent: recent}, err
|
||||
}
|
||||
|
||||
func aggregate(records []requestlog.Record, query Query, includeSensitive bool) (SafeReport, []SensitiveRecord, error) {
|
||||
if query.MaxRecords == 0 {
|
||||
query.MaxRecords = 100000
|
||||
}
|
||||
if query.MaxRecords < 1 || query.MaxRecords > 1000000 {
|
||||
return SafeReport{}, nil, errors.New("analytics: invalid record limit")
|
||||
}
|
||||
if !query.From.IsZero() && !query.Until.IsZero() && !query.From.Before(query.Until) {
|
||||
return SafeReport{}, nil, errors.New("analytics: invalid time range")
|
||||
}
|
||||
report := SafeReport{From: query.From.UTC(), Until: query.Until.UTC()}
|
||||
routeMap := map[string]*RouteSummary{}
|
||||
statusMap := map[int]int{}
|
||||
durations := make([]int64, 0, min(len(records), query.MaxRecords))
|
||||
recent := make([]SensitiveRecord, 0, 100)
|
||||
processed := 0
|
||||
for _, record := range records {
|
||||
if err := record.Validate(); err != nil {
|
||||
return SafeReport{}, nil, errors.New("analytics: invalid request record")
|
||||
}
|
||||
if !query.From.IsZero() && record.Timestamp.Before(query.From) || !query.Until.IsZero() && !record.Timestamp.Before(query.Until) {
|
||||
continue
|
||||
}
|
||||
class := Public
|
||||
if query.Classify != nil {
|
||||
class = query.Classify(record)
|
||||
}
|
||||
if class == Health || class == Operator && !query.IncludeOperator {
|
||||
continue
|
||||
}
|
||||
if processed >= query.MaxRecords {
|
||||
report.Truncated = true
|
||||
break
|
||||
}
|
||||
processed++
|
||||
report.Requests++
|
||||
report.Bytes += record.Bytes
|
||||
if record.Status >= 400 {
|
||||
report.Errors++
|
||||
}
|
||||
statusMap[record.Status]++
|
||||
route := routeMap[record.Route]
|
||||
if route == nil {
|
||||
route = &RouteSummary{Route: record.Route}
|
||||
routeMap[record.Route] = route
|
||||
}
|
||||
route.Requests++
|
||||
route.Bytes += record.Bytes
|
||||
route.DurationMicros += record.DurationMicros
|
||||
if record.Status >= 400 {
|
||||
route.Errors++
|
||||
}
|
||||
durations = append(durations, record.DurationMicros)
|
||||
if includeSensitive {
|
||||
recent = append(recent, SensitiveRecord{Timestamp: record.Timestamp, RequestID: record.RequestID, ClientIP: record.ClientIP, Path: record.Path, Query: record.Query, Referer: record.Referer, UserAgent: record.UserAgent, SessionID: record.SessionID, Status: record.Status, DurationMicros: record.DurationMicros})
|
||||
if len(recent) > 100 {
|
||||
recent = recent[len(recent)-100:]
|
||||
}
|
||||
}
|
||||
}
|
||||
for _, route := range routeMap {
|
||||
report.Routes = append(report.Routes, *route)
|
||||
}
|
||||
sort.Slice(report.Routes, func(i, j int) bool {
|
||||
if report.Routes[i].Requests == report.Routes[j].Requests {
|
||||
return report.Routes[i].Route < report.Routes[j].Route
|
||||
}
|
||||
return report.Routes[i].Requests > report.Routes[j].Requests
|
||||
})
|
||||
for status, count := range statusMap {
|
||||
report.Statuses = append(report.Statuses, StatusSummary{Status: status, Requests: count})
|
||||
}
|
||||
sort.Slice(report.Statuses, func(i, j int) bool { return report.Statuses[i].Status < report.Statuses[j].Status })
|
||||
sort.Slice(durations, func(i, j int) bool { return durations[i] < durations[j] })
|
||||
report.P50Micros = percentile(durations, 50)
|
||||
report.P95Micros = percentile(durations, 95)
|
||||
report.P99Micros = percentile(durations, 99)
|
||||
return report, recent, nil
|
||||
}
|
||||
|
||||
func ReadJSONL(reader io.Reader, maxRecords int) ([]requestlog.Record, error) {
|
||||
if maxRecords < 1 || maxRecords > 1000000 {
|
||||
return nil, errors.New("analytics: invalid record limit")
|
||||
}
|
||||
scanner := bufio.NewScanner(io.LimitReader(reader, 1<<30))
|
||||
scanner.Buffer(make([]byte, 64*1024), 1<<20)
|
||||
records := make([]requestlog.Record, 0, min(maxRecords, 4096))
|
||||
for scanner.Scan() {
|
||||
if len(records) >= maxRecords {
|
||||
return nil, errors.New("analytics: record limit exceeded")
|
||||
}
|
||||
var record requestlog.Record
|
||||
if err := json.Unmarshal(scanner.Bytes(), &record); err != nil {
|
||||
return nil, errors.New("analytics: invalid JSONL record")
|
||||
}
|
||||
if err := record.Validate(); err != nil {
|
||||
return nil, errors.New("analytics: invalid JSONL record")
|
||||
}
|
||||
records = append(records, record)
|
||||
}
|
||||
if err := scanner.Err(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return records, nil
|
||||
}
|
||||
|
||||
func percentile(values []int64, percent int) int64 {
|
||||
if len(values) == 0 {
|
||||
return 0
|
||||
}
|
||||
index := (len(values)*percent+99)/100 - 1
|
||||
if index < 0 {
|
||||
index = 0
|
||||
}
|
||||
if index >= len(values) {
|
||||
index = len(values) - 1
|
||||
}
|
||||
return values[index]
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
// SPDX-License-Identifier: MPL-2.0
|
||||
|
||||
package analytics
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"gamertan.com/web/requestlog"
|
||||
)
|
||||
|
||||
func TestSafeProjectionExcludesOperatorAndSensitiveEvidence(t *testing.T) {
|
||||
now := time.Unix(100, 0).UTC()
|
||||
records := []requestlog.Record{
|
||||
{Version: 1, Timestamp: now, Method: "GET", Route: "home", Status: 200, Bytes: 100, DurationMicros: 10, ClientIP: "private", Query: "secret"},
|
||||
{Version: 1, Timestamp: now, Method: "GET", Route: "admin.analytics", Status: 200, Bytes: 200, DurationMicros: 20, ClientIP: "operator"},
|
||||
{Version: 1, Timestamp: now, Method: "GET", Route: "home", Status: 500, Bytes: 50, DurationMicros: 30},
|
||||
}
|
||||
classifier := func(record requestlog.Record) TrafficClass {
|
||||
if strings.HasPrefix(record.Route, "admin.") {
|
||||
return Operator
|
||||
}
|
||||
return Public
|
||||
}
|
||||
report, err := Safe(records, Query{Classify: classifier})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if report.Requests != 2 || report.Errors != 1 || report.Bytes != 150 || len(report.Routes) != 1 {
|
||||
t.Fatalf("report=%+v", report)
|
||||
}
|
||||
sensitive, err := Sensitive(records, Query{Classify: classifier, IncludeOperator: true})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if sensitive.Safe.Requests != 3 || len(sensitive.Recent) != 3 || sensitive.Recent[0].ClientIP != "private" {
|
||||
t.Fatalf("sensitive=%+v", sensitive)
|
||||
}
|
||||
}
|
||||
|
||||
func TestReadJSONLFailsClosed(t *testing.T) {
|
||||
if _, err := ReadJSONL(strings.NewReader("{not json}\n"), 10); err == nil {
|
||||
t.Fatal("malformed record accepted")
|
||||
}
|
||||
if _, err := ReadJSONL(strings.NewReader(`{"version":99}`+"\n"), 10); err == nil {
|
||||
t.Fatal("unsupported record accepted")
|
||||
}
|
||||
}
|
||||
|
||||
func TestSafeRejectsInvalidTimeRange(t *testing.T) {
|
||||
now := time.Unix(100, 0)
|
||||
if _, err := Safe(nil, Query{From: now, Until: now}); err == nil {
|
||||
t.Fatal("empty time range accepted")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
// SPDX-License-Identifier: MPL-2.0
|
||||
|
||||
package analytics
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func FuzzJSONL(f *testing.F) {
|
||||
f.Add([]byte(`{"version":1,"timestamp":"2026-01-01T00:00:00Z","method":"GET","route":"home","status":200}` + "\n"))
|
||||
f.Add([]byte("{bad}\n"))
|
||||
f.Fuzz(func(t *testing.T, body []byte) {
|
||||
if len(body) > 1<<20 {
|
||||
t.Skip()
|
||||
}
|
||||
records, err := ReadJSONL(bytes.NewReader(body), 100)
|
||||
if err == nil && len(records) > 100 {
|
||||
t.Fatal("record bound exceeded")
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
// SPDX-License-Identifier: MPL-2.0
|
||||
|
||||
package analytics
|
||||
|
||||
import "context"
|
||||
|
||||
// GeoEnricher is deliberately optional. Implementations may use a private local
|
||||
// database; the base analytics package performs no network lookup.
|
||||
type GeoEnricher interface {
|
||||
Lookup(context.Context, string) (GeoEvidence, error)
|
||||
}
|
||||
type GeoEvidence struct {
|
||||
CountryCode, Region, City, ASNOrganization string
|
||||
ASN uint
|
||||
Source, Confidence string
|
||||
}
|
||||
Reference in New Issue
Block a user