package controller import ( "encoding/json" "io" "net/http" "strings" "github.com/gin-gonic/gin" "github.com/heicode/manager/common" "github.com/heicode/manager/model" ) // Client error-telemetry ingest (issue #24). Contract: winos#23 // docs/integration/telemetry-ingest-endpoint.md. Body is a TOP-LEVEL JSON array // of events (NOT wrapped). Auth reuses device-pairing (UserOrV2DeviceAuth). It // is diagnostic traffic: NEVER bills (separate table, no consume log / quota). // // Default-OFF: until the privacy policy discloses account-linkable device IDs // and endpoint delivery is agreed, HEICODE_TELEMETRY_ENABLED stays false and the // endpoint answers 410 (kill switch) so the client stops sending. const ( telemetryMaxBatch = 20 telemetryMaxBodySize = 256 * 1024 headerDeviceID = "X-Heicode-Device-Id" // Per-field hard cap after redaction (#32): a backstop so a single event // can't park an unbounded blob in the telemetry store even within batch // limits. stack_top / context are truncated past this many bytes. telemetryMaxFieldBytes = 8 * 1024 ) // telemetryContextAllowedKeys whitelists the non-content diagnostic keys the // client may attach to an event's `context` (#32). Anything else is dropped // before persistence, so a client regression can't land arbitrary — possibly // identifying — JSON (prompts, code, tokens, emails, full file paths, raw IPs) // in the telemetry store. Keep in sync with the client telemetry contract // (winos#23) and docs/integration/heicode-desktop-client-api.md. Additions must // be reviewed against the "no identifying content" rule in issue #32. var telemetryContextAllowedKeys = map[string]bool{ "route": true, // logical UI route, e.g. "chat" (no params) "retryable": true, // bool "phase": true, // lifecycle phase enum "exit_code": true, // process exit code (int) "duration_ms": true, // numeric timing "attempt": true, // retry attempt count } // filterTelemetryContext keeps only whitelisted keys from the client-supplied // context object, then redacts and size-caps the result (#32). Returns "" when // the context is empty, unparseable, or has no allowed keys — telemetry is // best-effort diagnostics, so dropping an unrecognized payload is preferable to // storing arbitrary JSON. func filterTelemetryContext(raw json.RawMessage) string { if len(raw) == 0 { return "" } var obj map[string]json.RawMessage if err := common.Unmarshal(raw, &obj); err != nil { return "" // not an object (or malformed) -> drop } filtered := make(map[string]json.RawMessage, len(obj)) for k, v := range obj { if telemetryContextAllowedKeys[k] { filtered[k] = v } } if len(filtered) == 0 { return "" } b, err := common.Marshal(filtered) if err != nil { return "" } return capTelemetryField(model.RedactText(string(b))) } // capTelemetryField truncates an already-redacted field to telemetryMaxFieldBytes. func capTelemetryField(s string) string { if len(s) <= telemetryMaxFieldBytes { return s } return s[:telemetryMaxFieldBytes] } type telemetryEventIn struct { ClientId string `json:"client_id"` SchemaVersion int `json:"schema_version"` AppVersion string `json:"app_version"` Platform string `json:"platform"` OsVersion string `json:"os_version"` Arch string `json:"arch"` Locale string `json:"locale"` ErrorCategory string `json:"error_category"` ErrorCode string `json:"error_code"` ErrorMessageHash string `json:"error_message_hash"` StackHash string `json:"stack_hash"` StackTop []string `json:"stack_top"` Context json.RawMessage `json:"context"` Timestamp string `json:"timestamp"` SessionSeq int `json:"session_seq"` } // parseTelemetryBatch unmarshals the top-level JSON array of events. Pure. func parseTelemetryBatch(body []byte) ([]telemetryEventIn, error) { var events []telemetryEventIn if err := common.Unmarshal(body, &events); err != nil { return nil, err } return events, nil } // toModel maps an ingested event to its row. Deliberately tolerant (maximize // capture): unknown enums / sentinel app_version / missing fields are stored // as-is; schema_version defaults to 1; stack_top/context are kept as JSON text. func (e telemetryEventIn) toModel(userID int, deviceID string, now int64) model.TelemetryEvent { clientID := strings.TrimSpace(e.ClientId) if clientID == "" { clientID = deviceID } sv := e.SchemaVersion if sv <= 0 { sv = 1 } // Defense-in-depth (#24 review): the client already sanitizes, but we also // redact stack_top / context server-side — strip sk-/Bearer/URL tokens/JSON // secret fields before persistence, so a producer bug can't land plaintext // secrets in the telemetry store. stackTopJSON := "" if len(e.StackTop) > 0 { if b, err := common.Marshal(e.StackTop); err == nil { stackTopJSON = capTelemetryField(model.RedactText(string(b))) } } // #32: context is restricted to a key whitelist (drop arbitrary/identifying // JSON), then redacted and size-capped. ctxJSON := filterTelemetryContext(e.Context) return model.TelemetryEvent{ ReceivedAt: now, UserId: userID, ClientId: clientID, SchemaVersion: sv, AppVersion: e.AppVersion, Platform: e.Platform, OsVersion: e.OsVersion, Arch: e.Arch, Locale: e.Locale, ErrorCategory: e.ErrorCategory, ErrorCode: e.ErrorCode, ErrorMessageHash: e.ErrorMessageHash, StackHash: e.StackHash, StackTopJSON: stackTopJSON, ContextJSON: ctxJSON, EventTimestamp: e.Timestamp, SessionSeq: e.SessionSeq, } } // HeicodeTelemetryEvents: POST /api/heicode/telemetry/events (issue #24). func HeicodeTelemetryEvents(c *gin.Context) { // Kill switch / not-live gate (default OFF). 410 => client stops sending. if !common.GetEnvOrDefaultBool("HEICODE_TELEMETRY_ENABLED", false) { c.JSON(http.StatusGone, gin.H{"success": false, "message": "telemetry ingest disabled"}) return } userID := c.GetInt("id") if userID <= 0 { c.JSON(http.StatusUnauthorized, gin.H{"success": false, "message": "authentication required"}) return } deviceID := strings.TrimSpace(c.GetHeader(headerDeviceID)) if deviceID == "" { // Telemetry is desktop-only (device-paired); reject session-only callers. c.JSON(http.StatusForbidden, gin.H{"success": false, "message": "telemetry requires a paired device"}) return } body, _ := io.ReadAll(io.LimitReader(c.Request.Body, telemetryMaxBodySize+1)) if len(body) > telemetryMaxBodySize { c.JSON(http.StatusRequestEntityTooLarge, gin.H{"success": false, "message": "telemetry batch too large"}) return } events, err := parseTelemetryBatch(body) if err != nil { c.JSON(http.StatusBadRequest, gin.H{"success": false, "message": "invalid telemetry body (expect a JSON array of events)"}) return } if len(events) == 0 { c.JSON(http.StatusOK, gin.H{"success": true, "accepted": 0}) return } if len(events) > telemetryMaxBatch { c.JSON(http.StatusRequestEntityTooLarge, gin.H{"success": false, "message": "telemetry batch exceeds 20 events"}) return } now := common.GetTimestamp() rows := make([]model.TelemetryEvent, 0, len(events)) for _, e := range events { // Anti-spoof: a present client_id must equal the verified paired device. if cid := strings.TrimSpace(e.ClientId); cid != "" && cid != deviceID { c.JSON(http.StatusForbidden, gin.H{"success": false, "message": "client_id does not match paired device"}) return } rows = append(rows, e.toModel(userID, deviceID, now)) } if err := model.InsertTelemetryEvents(rows); err != nil { common.SysLog("telemetry ingest persist failed: " + err.Error()) c.JSON(http.StatusInternalServerError, gin.H{"success": false, "message": "telemetry persist failed"}) return } c.JSON(http.StatusOK, gin.H{"success": true, "accepted": len(rows)}) }