Telemetry up-gating hardening (code portion of #32): - Context field whitelist: telemetry `context` is filtered to a small set of non-content diagnostic keys (route/retryable/phase/exit_code/duration_ms/ attempt) before persistence. Unknown keys — including potentially identifying ones (email, full file path, prompt, raw IP) — are dropped, so a client regression cannot land arbitrary JSON in the store. Empty/unparseable/no-allowed-key context is dropped to "". - Per-field size cap: stack_top and context are truncated to 8KiB after redaction (backstop against unbounded blobs within batch limits). - Retention: daily master-only task deletes telemetry rows older than HEICODE_TELEMETRY_RETENTION_DAYS (default 30; <=0 disables). HEICODE_TELEMETRY_RETENTION_INTERVAL_HOURS (default 24) sets cadence. model.DeleteTelemetryEventsBefore(cutoff) + controller.StartTelemetryRetentionTask() wired into main.go under IsMasterNode. - GET /api/heicode/config telemetry block now surfaces retention_days for client/admin transparency. Tests: whitelist drop/keep, size cap, redaction-within-allowed-key. go build/vet clean; controller telemetry tests pass. Affects: Manager only (telemetry ingest + retention). No billing/consume-log change (telemetry still never bills). Privacy-doc disclosure + production enable-checklist portions of #32 tracked in heicodeDocs sync (#34) / desktop client API docs (#35). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
213 lines
7.9 KiB
Go
213 lines
7.9 KiB
Go
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)})
|
|
}
|