From 03b4b55927425c980503ab408d9ded28be6c6084 Mon Sep 17 00:00:00 2001 From: Daniel Legt Date: Fri, 17 Apr 2026 13:57:55 +0300 Subject: [PATCH] feat(api): support optional systemInfo in submissions Extend the submission contract to accept a `systemInfo` object and persist it with each submission, including deep-copy support for `extra` metadata. Also update client-facing docs and HTTP examples (JSON and multipart) and document that the schema is available at `GET /api/schema`, so clients can reliably implement the updated payload format.feat(api): support optional systemInfo in submissions Extend the submission contract to accept a `systemInfo` object and persist it with each submission, including deep-copy support for `extra` metadata. Also update client-facing docs and HTTP examples (JSON and multipart) and document that the schema is available at `GET /api/schema`, so clients can reliably implement the updated payload format. --- README.md | 7 + docs/submit-api.md | 117 ++++++++++++++ docs/submit-schema.json | 306 +++++++++++++++++++++++++++++++++++++ http/submit-json.http | 10 ++ http/submit-multipart.http | 4 + lib/model/submission.go | 43 +++++- lib/store/store.go | 19 ++- lib/web/app.go | 147 +++++++++++++++--- 8 files changed, 630 insertions(+), 23 deletions(-) create mode 100644 docs/submit-api.md create mode 100644 docs/submit-schema.json diff --git a/README.md b/README.md index e98bab7..4f6b6ba 100644 --- a/README.md +++ b/README.md @@ -163,6 +163,13 @@ Ready-to-run HTTP client examples are included in: - `http/submit-multipart.http` - `http/search.http` +Client-facing submission contract docs are included in: + +- `docs/submit-api.md` +- `docs/submit-schema.json` + +The schema is also served by the app at `GET /api/schema`. + You can also submit one of the provided sample payloads directly: ```bash diff --git a/docs/submit-api.md b/docs/submit-api.md new file mode 100644 index 0000000..2477e2a --- /dev/null +++ b/docs/submit-api.md @@ -0,0 +1,117 @@ +# Submission API + +Use `POST /api/submit` to send benchmark results to the server. + +## Endpoint + +- Method: `POST` +- URL: `/api/submit` +- Content-Types: + - `application/json` + - `multipart/form-data` + +## Schema + +- Import URL: `/api/schema` +- Source file in repo: `docs/submit-schema.json` + +## JSON format + +Clients can send either: + +1. An envelope with `submitter`, `platform`, optional `systemInfo`, and nested `benchmark` +2. A flat benchmark payload with optional `submitter`, `platform`, and optional `systemInfo` + +Example envelope: + +```json +{ + "submitter": "AMD Bazzite", + "platform": "linux", + "systemInfo": { + "hostname": "bench-rig-01", + "osName": "Bazzite", + "osVersion": "41", + "distro": "Fedora Atomic", + "kernelVersion": "6.14.2-300.fc41.x86_64", + "kernelArch": "x86_64", + "architecture": "amd64", + "locale": "en-US", + "timezone": "Europe/Bucharest", + "country": "Romania", + "city": "Bucharest", + "clientVersion": "1.4.0", + "appVersion": "desktop-1.4.0", + "sessionID": "session-123", + "userID": "anonymous-user", + "extra": { + "gpuDriver": "Mesa 25.0.3", + "desktopEnvironment": "KDE Plasma" + } + }, + "benchmark": { + "config": { + "durationSecs": 10, + "intensity": 1, + "coreFilter": 0, + "multiCore": true + }, + "cpuInfo": { + "brandString": "AMD Ryzen 9 9950X3D 16-Core Processor", + "vendorID": "AuthenticAMD", + "physicalCores": 16, + "logicalCores": 32, + "baseClockMHz": 5756, + "boostClockMHz": 0, + "l1DataKB": 48, + "l2KB": 1024, + "l3MB": 32, + "isHybrid": false, + "has3DVCache": true, + "supportedFeatures": ["SSE4.2", "AVX", "AVX2"] + }, + "startedAt": "2026-04-17T13:20:09.170770511+03:00", + "duration": 10003356676, + "totalOps": 543678267392, + "mOpsPerSec": 54349.58334499758, + "score": 5434958, + "coreResults": [ + { + "logicalID": 0, + "coreType": "Standard", + "mOpsPerSec": 1642.986525056302, + "totalOps": 16435380224 + } + ] + } +} +``` + +## Multipart format + +Multipart requests may include: + +- `submitter`: text field +- `platform`: text field +- `systemInfo`: JSON text field +- `benchmark`, `file`, or `benchmarkFile`: benchmark JSON file field + +Example `systemInfo` field value: + +```json +{ + "osName": "Windows", + "osVersion": "11 24H2", + "kernelVersion": "10.0.26100", + "architecture": "amd64", + "locale": "en-US", + "timezone": "America/New_York" +} +``` + +## Notes + +- `systemInfo` is optional. Older clients do not need to send it. +- The server may enrich stored analytics with request metadata such as `ipAddress`, `forwardedFor`, `userAgent`, and `locale`. +- `baseClockMHz` and related CPU clock fields currently expect integer MHz values. +- Full JSON Schema is available in [submit-schema.json](./submit-schema.json) and is served by `GET /api/schema`. diff --git a/docs/submit-schema.json b/docs/submit-schema.json new file mode 100644 index 0000000..3963630 --- /dev/null +++ b/docs/submit-schema.json @@ -0,0 +1,306 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://cpu-benchmarker.local/schemas/submit-schema.json", + "title": "CPU Benchmark Submission", + "description": "Request body for POST /api/submit. Older clients may omit systemInfo.", + "type": "object", + "additionalProperties": false, + "properties": { + "submitter": { + "type": "string", + "description": "Human-readable device or user label." + }, + "platform": { + "type": "string", + "enum": ["windows", "linux", "macos"], + "description": "Normalized platform value." + }, + "systemInfo": { + "$ref": "#/$defs/systemInfo" + }, + "benchmark": { + "$ref": "#/$defs/benchmarkResult" + }, + "result": { + "$ref": "#/$defs/benchmarkResult" + }, + "data": { + "$ref": "#/$defs/benchmarkResult" + }, + "config": { + "$ref": "#/$defs/benchmarkConfig" + }, + "cpuInfo": { + "$ref": "#/$defs/cpuInfo" + }, + "startedAt": { + "type": "string", + "format": "date-time" + }, + "duration": { + "type": "integer", + "description": "Benchmark duration in nanoseconds." + }, + "totalOps": { + "type": "integer" + }, + "mOpsPerSec": { + "type": "number" + }, + "score": { + "type": "integer" + }, + "coreResults": { + "type": "array", + "items": { + "$ref": "#/$defs/coreResult" + } + } + }, + "anyOf": [ + { + "required": ["benchmark"] + }, + { + "required": ["result"] + }, + { + "required": ["data"] + }, + { + "required": ["config", "cpuInfo", "startedAt", "duration", "totalOps", "mOpsPerSec", "score", "coreResults"] + } + ], + "$defs": { + "benchmarkConfig": { + "type": "object", + "additionalProperties": false, + "properties": { + "durationSecs": { + "type": "integer", + "minimum": 1 + }, + "intensity": { + "type": "integer" + }, + "coreFilter": { + "type": "integer" + }, + "multiCore": { + "type": "boolean" + } + }, + "required": ["durationSecs", "intensity", "coreFilter", "multiCore"] + }, + "cpuInfo": { + "type": "object", + "additionalProperties": false, + "properties": { + "brandString": { + "type": "string" + }, + "vendorID": { + "type": "string" + }, + "physicalCores": { + "type": "integer", + "minimum": 0 + }, + "logicalCores": { + "type": "integer", + "minimum": 0 + }, + "baseClockMHz": { + "type": "integer", + "description": "Current server schema expects an integer MHz value." + }, + "boostClockMHz": { + "type": "integer" + }, + "l1DataKB": { + "type": "integer" + }, + "l2KB": { + "type": "integer" + }, + "l3MB": { + "type": "integer" + }, + "isHybrid": { + "type": "boolean" + }, + "has3DVCache": { + "type": "boolean" + }, + "pCoreCount": { + "type": "integer" + }, + "eCoreCount": { + "type": "integer" + }, + "cores": { + "type": "array", + "items": { + "$ref": "#/$defs/cpuCoreDescriptor" + } + }, + "supportedFeatures": { + "type": "array", + "items": { + "type": "string" + } + } + }, + "required": ["brandString", "physicalCores", "logicalCores"] + }, + "cpuCoreDescriptor": { + "type": "object", + "additionalProperties": false, + "properties": { + "LogicalID": { + "type": "integer" + }, + "PhysicalID": { + "type": "integer" + }, + "CoreID": { + "type": "integer" + }, + "Type": { + "type": "integer" + } + }, + "required": ["LogicalID", "PhysicalID", "CoreID", "Type"] + }, + "coreResult": { + "type": "object", + "additionalProperties": false, + "properties": { + "logicalID": { + "type": "integer", + "minimum": 0 + }, + "coreType": { + "type": "string" + }, + "mOpsPerSec": { + "type": "number", + "minimum": 0 + }, + "totalOps": { + "type": "integer", + "minimum": 0 + } + }, + "required": ["logicalID", "coreType", "mOpsPerSec", "totalOps"] + }, + "benchmarkResult": { + "type": "object", + "additionalProperties": false, + "properties": { + "config": { + "$ref": "#/$defs/benchmarkConfig" + }, + "cpuInfo": { + "$ref": "#/$defs/cpuInfo" + }, + "startedAt": { + "type": "string", + "format": "date-time" + }, + "duration": { + "type": "integer" + }, + "totalOps": { + "type": "integer" + }, + "mOpsPerSec": { + "type": "number" + }, + "score": { + "type": "integer" + }, + "coreResults": { + "type": "array", + "items": { + "$ref": "#/$defs/coreResult" + } + } + }, + "required": ["config", "cpuInfo", "startedAt", "duration", "totalOps", "mOpsPerSec", "score", "coreResults"] + }, + "systemInfo": { + "type": "object", + "description": "Optional analytics and environment metadata. Clients may omit this entire object.", + "additionalProperties": false, + "properties": { + "hostname": { + "type": "string" + }, + "osName": { + "type": "string" + }, + "osVersion": { + "type": "string" + }, + "distro": { + "type": "string" + }, + "kernelVersion": { + "type": "string" + }, + "kernelArch": { + "type": "string" + }, + "architecture": { + "type": "string" + }, + "locale": { + "type": "string" + }, + "timezone": { + "type": "string" + }, + "region": { + "type": "string" + }, + "country": { + "type": "string" + }, + "city": { + "type": "string" + }, + "isp": { + "type": "string" + }, + "sessionID": { + "type": "string" + }, + "userID": { + "type": "string" + }, + "clientVersion": { + "type": "string" + }, + "appVersion": { + "type": "string" + }, + "ipAddress": { + "type": "string", + "description": "May be supplied by the client, and may also be enriched by the server from the request." + }, + "forwardedFor": { + "type": "string" + }, + "userAgent": { + "type": "string" + }, + "extra": { + "type": "object", + "description": "Free-form extension point for future analytics.", + "additionalProperties": true + } + } + } + } +} diff --git a/http/submit-json.http b/http/submit-json.http index 624bec0..756a192 100644 --- a/http/submit-json.http +++ b/http/submit-json.http @@ -4,6 +4,16 @@ Content-Type: application/json { "submitter": "Workstation-Lab-A", "platform": "windows", + "systemInfo": { + "hostname": "workstation-lab-a", + "osName": "Windows", + "osVersion": "11 24H2", + "kernelVersion": "10.0.26100", + "architecture": "amd64", + "locale": "en-US", + "timezone": "Europe/Bucharest", + "clientVersion": "1.0.0" + }, "benchmark": { "config": { "durationSecs": 20, diff --git a/http/submit-multipart.http b/http/submit-multipart.http index bc505a6..ef90307 100644 --- a/http/submit-multipart.http +++ b/http/submit-multipart.http @@ -10,6 +10,10 @@ Content-Disposition: form-data; name="platform" linux --BenchBoundary +Content-Disposition: form-data; name="systemInfo" + +{"hostname":"intel-test-rig","osName":"Ubuntu","osVersion":"24.04","kernelVersion":"6.8.0-58-generic","architecture":"amd64","locale":"en-US","timezone":"Europe/Bucharest","clientVersion":"1.0.0"} +--BenchBoundary Content-Disposition: form-data; name="benchmark"; filename="cpu-bench-result.json" Content-Type: application/json diff --git a/lib/model/submission.go b/lib/model/submission.go index 32d6cf2..02cbd3f 100644 --- a/lib/model/submission.go +++ b/lib/model/submission.go @@ -39,6 +39,30 @@ type CPUCoreDescriptor struct { Type int `json:"Type"` } +type SystemInfo struct { + Hostname string `json:"hostname,omitempty"` + OSName string `json:"osName,omitempty"` + OSVersion string `json:"osVersion,omitempty"` + Distro string `json:"distro,omitempty"` + KernelVersion string `json:"kernelVersion,omitempty"` + KernelArch string `json:"kernelArch,omitempty"` + Architecture string `json:"architecture,omitempty"` + Locale string `json:"locale,omitempty"` + Timezone string `json:"timezone,omitempty"` + Region string `json:"region,omitempty"` + Country string `json:"country,omitempty"` + City string `json:"city,omitempty"` + ISP string `json:"isp,omitempty"` + SessionID string `json:"sessionID,omitempty"` + UserID string `json:"userID,omitempty"` + ClientVersion string `json:"clientVersion,omitempty"` + AppVersion string `json:"appVersion,omitempty"` + IPAddress string `json:"ipAddress,omitempty"` + ForwardedFor string `json:"forwardedFor,omitempty"` + UserAgent string `json:"userAgent,omitempty"` + Extra map[string]any `json:"extra,omitempty"` +} + type CoreResult struct { LogicalID int `json:"logicalID"` CoreType string `json:"coreType"` @@ -58,10 +82,11 @@ type BenchmarkResult struct { } type Submission struct { - SubmissionID string `json:"submissionID"` - Submitter string `json:"submitter"` - Platform string `json:"platform"` - SubmittedAt time.Time `json:"submittedAt"` + SubmissionID string `json:"submissionID"` + Submitter string `json:"submitter"` + Platform string `json:"platform"` + SubmittedAt time.Time `json:"submittedAt"` + SystemInfo *SystemInfo `json:"systemInfo,omitempty"` BenchmarkResult } @@ -158,6 +183,16 @@ func CloneSubmission(submission *Submission) *Submission { if len(submission.CPUInfo.SupportedFeatures) > 0 { copySubmission.CPUInfo.SupportedFeatures = append([]string(nil), submission.CPUInfo.SupportedFeatures...) } + if submission.SystemInfo != nil { + copySystemInfo := *submission.SystemInfo + if len(submission.SystemInfo.Extra) > 0 { + copySystemInfo.Extra = make(map[string]any, len(submission.SystemInfo.Extra)) + for key, value := range submission.SystemInfo.Extra { + copySystemInfo.Extra[key] = value + } + } + copySubmission.SystemInfo = ©SystemInfo + } return ©Submission } diff --git a/lib/store/store.go b/lib/store/store.go index 85c63ea..8d43673 100644 --- a/lib/store/store.go +++ b/lib/store/store.go @@ -66,12 +66,13 @@ func (s *Store) Count() int { return len(s.orderedIDs) } -func (s *Store) SaveSubmission(result model.BenchmarkResult, submitter, platform string) (*model.Submission, error) { +func (s *Store) SaveSubmission(result model.BenchmarkResult, submitter, platform string, systemInfo *model.SystemInfo) (*model.Submission, error) { submission := &model.Submission{ SubmissionID: uuid.NewString(), Submitter: model.NormalizeSubmitter(submitter), Platform: model.NormalizePlatform(platform), SubmittedAt: time.Now().UTC(), + SystemInfo: cloneSystemInfo(systemInfo), BenchmarkResult: result, } @@ -97,6 +98,22 @@ func (s *Store) SaveSubmission(result model.BenchmarkResult, submitter, platform return model.CloneSubmission(submission), nil } +func cloneSystemInfo(info *model.SystemInfo) *model.SystemInfo { + if info == nil { + return nil + } + + copyInfo := *info + if len(info.Extra) > 0 { + copyInfo.Extra = make(map[string]any, len(info.Extra)) + for key, value := range info.Extra { + copyInfo.Extra[key] = value + } + } + + return ©Info +} + func (s *Store) ListSubmissions(page, pageSize int) ([]model.Submission, int) { s.mu.RLock() defer s.mu.RUnlock() diff --git a/lib/web/app.go b/lib/web/app.go index 3089249..cc5208f 100644 --- a/lib/web/app.go +++ b/lib/web/app.go @@ -7,6 +7,7 @@ import ( "io" "log" "mime" + "net" "net/http" "net/url" "os" @@ -59,8 +60,9 @@ type jsonSubmissionEnvelope struct { } type flatSubmissionEnvelope struct { - Submitter string `json:"submitter"` - Platform string `json:"platform"` + Submitter string `json:"submitter"` + Platform string `json:"platform"` + SystemInfo *model.SystemInfo `json:"systemInfo,omitempty"` model.BenchmarkResult } @@ -105,6 +107,7 @@ func (a *App) Routes() http.Handler { router.Get("/", a.handleIndex) router.Get("/healthz", a.handleHealth) + router.Get("/api/schema", a.handleSubmitSchema) router.Get("/api/search", a.handleSearch) router.Post("/api/submit", a.handleSubmit) @@ -328,6 +331,10 @@ func (a *App) handleHealth(w http.ResponseWriter, _ *http.Request) { }) } +func (a *App) handleSubmitSchema(w http.ResponseWriter, r *http.Request) { + http.ServeFileFS(w, r, os.DirFS("."), "docs/submit-schema.json") +} + func (a *App) handleSearch(w http.ResponseWriter, r *http.Request) { results := a.store.SearchSubmissions( r.URL.Query().Get("text"), @@ -344,7 +351,7 @@ func (a *App) handleSearch(w http.ResponseWriter, r *http.Request) { func (a *App) handleSubmit(w http.ResponseWriter, r *http.Request) { r.Body = http.MaxBytesReader(w, r.Body, maxSubmissionBytes) - result, submitter, platform, err := parseSubmissionRequest(r) + result, submitter, platform, systemInfo, err := parseSubmissionRequest(r) if err != nil { writeJSON(w, http.StatusBadRequest, errorResponse{Error: err.Error()}) return @@ -361,7 +368,9 @@ func (a *App) handleSubmit(w http.ResponseWriter, r *http.Request) { return } - submission, err := a.store.SaveSubmission(result, submitter, platform) + systemInfo = enrichSystemInfo(systemInfo, r) + + submission, err := a.store.SaveSubmission(result, submitter, platform, systemInfo) if err != nil { writeJSON(w, http.StatusInternalServerError, errorResponse{Error: fmt.Sprintf("store submission: %v", err)}) return @@ -376,11 +385,11 @@ func (a *App) handleSubmit(w http.ResponseWriter, r *http.Request) { }) } -func parseSubmissionRequest(r *http.Request) (model.BenchmarkResult, string, string, error) { +func parseSubmissionRequest(r *http.Request) (model.BenchmarkResult, string, string, *model.SystemInfo, error) { contentType := r.Header.Get("Content-Type") mediaType, _, err := mime.ParseMediaType(contentType) if err != nil && contentType != "" { - return model.BenchmarkResult{}, "", "", fmt.Errorf("parse content type: %w", err) + return model.BenchmarkResult{}, "", "", nil, fmt.Errorf("parse content type: %w", err) } switch mediaType { @@ -389,14 +398,14 @@ func parseSubmissionRequest(r *http.Request) (model.BenchmarkResult, string, str case "multipart/form-data": return parseMultipartSubmission(r) default: - return model.BenchmarkResult{}, "", "", fmt.Errorf("unsupported content type %q", mediaType) + return model.BenchmarkResult{}, "", "", nil, fmt.Errorf("unsupported content type %q", mediaType) } } -func parseJSONSubmission(r *http.Request) (model.BenchmarkResult, string, string, error) { +func parseJSONSubmission(r *http.Request) (model.BenchmarkResult, string, string, *model.SystemInfo, error) { body, err := io.ReadAll(r.Body) if err != nil { - return model.BenchmarkResult{}, "", "", fmt.Errorf("read request body: %w", err) + return model.BenchmarkResult{}, "", "", nil, fmt.Errorf("read request body: %w", err) } submitter := firstNonEmpty( @@ -412,6 +421,10 @@ func parseJSONSubmission(r *http.Request) (model.BenchmarkResult, string, string if err := json.Unmarshal(body, &nested); err == nil { submitter = firstNonEmpty(nested.Submitter, submitter) platform = firstNonEmpty(nested.Platform, platform) + systemInfo, err := extractSystemInfo(body) + if err != nil { + return model.BenchmarkResult{}, "", "", nil, err + } for _, candidate := range []struct { name string payload json.RawMessage @@ -426,37 +439,42 @@ func parseJSONSubmission(r *http.Request) (model.BenchmarkResult, string, string var result model.BenchmarkResult if err := json.Unmarshal(candidate.payload, &result); err != nil { - return model.BenchmarkResult{}, "", "", fmt.Errorf("decode %s JSON: %w", candidate.name, err) + return model.BenchmarkResult{}, "", "", nil, fmt.Errorf("decode %s JSON: %w", candidate.name, err) } - return result, submitter, platform, nil + return result, submitter, platform, systemInfo, nil } } var flat flatSubmissionEnvelope if err := json.Unmarshal(body, &flat); err != nil { - return model.BenchmarkResult{}, "", "", fmt.Errorf("decode benchmark JSON: %w", err) + return model.BenchmarkResult{}, "", "", nil, fmt.Errorf("decode benchmark JSON: %w", err) } - return flat.BenchmarkResult, firstNonEmpty(flat.Submitter, submitter), firstNonEmpty(flat.Platform, platform, "windows"), nil + return flat.BenchmarkResult, firstNonEmpty(flat.Submitter, submitter), firstNonEmpty(flat.Platform, platform, "windows"), cloneSystemInfo(flat.SystemInfo), nil } -func parseMultipartSubmission(r *http.Request) (model.BenchmarkResult, string, string, error) { +func parseMultipartSubmission(r *http.Request) (model.BenchmarkResult, string, string, *model.SystemInfo, error) { if err := r.ParseMultipartForm(maxSubmissionBytes); err != nil { - return model.BenchmarkResult{}, "", "", fmt.Errorf("parse multipart form: %w", err) + return model.BenchmarkResult{}, "", "", nil, fmt.Errorf("parse multipart form: %w", err) } payload, err := readMultipartPayload(r) if err != nil { - return model.BenchmarkResult{}, "", "", err + return model.BenchmarkResult{}, "", "", nil, err } var result model.BenchmarkResult if err := json.Unmarshal(payload, &result); err != nil { - return model.BenchmarkResult{}, "", "", fmt.Errorf("decode benchmark JSON: %w", err) + return model.BenchmarkResult{}, "", "", nil, fmt.Errorf("decode benchmark JSON: %w", err) } - return result, r.FormValue("submitter"), firstNonEmpty(r.FormValue("platform"), "windows"), nil + systemInfo, err := parseMultipartSystemInfo(r) + if err != nil { + return model.BenchmarkResult{}, "", "", nil, err + } + + return result, r.FormValue("submitter"), firstNonEmpty(r.FormValue("platform"), "windows"), systemInfo, nil } func readMultipartPayload(r *http.Request) ([]byte, error) { @@ -486,6 +504,99 @@ func readMultipartPayload(r *http.Request) ([]byte, error) { return nil, fmt.Errorf("multipart request must include benchmark JSON in a file field or text field named benchmark") } +func extractSystemInfo(body []byte) (*model.SystemInfo, error) { + var envelope struct { + SystemInfo *model.SystemInfo `json:"systemInfo"` + } + if err := json.Unmarshal(body, &envelope); err != nil { + return nil, fmt.Errorf("decode systemInfo JSON: %w", err) + } + + return cloneSystemInfo(envelope.SystemInfo), nil +} + +func parseMultipartSystemInfo(r *http.Request) (*model.SystemInfo, error) { + raw := strings.TrimSpace(r.FormValue("systemInfo")) + if raw == "" { + return nil, nil + } + + var info model.SystemInfo + if err := json.Unmarshal([]byte(raw), &info); err != nil { + return nil, fmt.Errorf("decode systemInfo JSON: %w", err) + } + + return &info, nil +} + +func enrichSystemInfo(info *model.SystemInfo, r *http.Request) *model.SystemInfo { + if info == nil { + info = &model.SystemInfo{} + } else { + info = cloneSystemInfo(info) + } + + if host, _, err := net.SplitHostPort(r.RemoteAddr); err == nil { + info.IPAddress = firstNonEmpty(info.IPAddress, host) + } else { + info.IPAddress = firstNonEmpty(info.IPAddress, r.RemoteAddr) + } + + info.ForwardedFor = firstNonEmpty(info.ForwardedFor, r.Header.Get("X-Forwarded-For")) + info.UserAgent = firstNonEmpty(info.UserAgent, r.UserAgent()) + info.Locale = firstNonEmpty(info.Locale, r.Header.Get("Accept-Language")) + + if isEmptySystemInfo(info) { + return nil + } + + return info +} + +func isEmptySystemInfo(info *model.SystemInfo) bool { + if info == nil { + return true + } + + return strings.TrimSpace(info.Hostname) == "" && + strings.TrimSpace(info.OSName) == "" && + strings.TrimSpace(info.OSVersion) == "" && + strings.TrimSpace(info.Distro) == "" && + strings.TrimSpace(info.KernelVersion) == "" && + strings.TrimSpace(info.KernelArch) == "" && + strings.TrimSpace(info.Architecture) == "" && + strings.TrimSpace(info.Locale) == "" && + strings.TrimSpace(info.Timezone) == "" && + strings.TrimSpace(info.Region) == "" && + strings.TrimSpace(info.Country) == "" && + strings.TrimSpace(info.City) == "" && + strings.TrimSpace(info.ISP) == "" && + strings.TrimSpace(info.SessionID) == "" && + strings.TrimSpace(info.UserID) == "" && + strings.TrimSpace(info.ClientVersion) == "" && + strings.TrimSpace(info.AppVersion) == "" && + strings.TrimSpace(info.IPAddress) == "" && + strings.TrimSpace(info.ForwardedFor) == "" && + strings.TrimSpace(info.UserAgent) == "" && + len(info.Extra) == 0 +} + +func cloneSystemInfo(info *model.SystemInfo) *model.SystemInfo { + if info == nil { + return nil + } + + copyInfo := *info + if len(info.Extra) > 0 { + copyInfo.Extra = make(map[string]any, len(info.Extra)) + for key, value := range info.Extra { + copyInfo.Extra[key] = value + } + } + + return ©Info +} + func parsePositiveInt(raw string, fallback int) int { if raw == "" { return fallback