Files
heicode-mananger/heicode/controller/heicode_telemetry.go
T
chenchenandClaude Opus 4.8 2e37495133 feat(telemetry): retention purge + context field whitelist + size caps (#32)
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>
2026-06-10 00:40:23 +08:00

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)})
}