Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 16 additions & 1 deletion docs/features/telemetry.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,11 +4,12 @@ MCPProxy collects anonymous usage statistics to help improve the product. This p

## What is collected

MCPProxy sends a **daily heartbeat** containing only aggregate, non-identifying information. The current schema is **version 5** (`schema_version: 5` in the JSON payload); the schema is forward-compatible so older consumers simply ignore fields they don't recognize.
MCPProxy sends a **daily heartbeat** containing only aggregate, non-identifying information. The current schema is **version 6** (`schema_version: 6` in the JSON payload); the schema is forward-compatible so older consumers simply ignore fields they don't recognize.

| Field | Example | Purpose |
|-------|---------|---------|
| `anonymous_id` | `550e8400-...` | Random UUID for deduplication (not linked to you) |
| `machine_id` | `9f86d081...` (64-hex) | Stable, **non-reversible** salted hash of the OS machine id — dedups ephemeral installs whose `anonymous_id` churns every run (schema v6). Empty/omitted when unreadable. Never the raw machine id |
| `version` | `0.21.3` | Track version adoption |
| `edition` | `personal` | Understand edition usage |
| `os` | `darwin` | Platform distribution |
Expand Down Expand Up @@ -43,13 +44,27 @@ The following is **never** collected:
- User identity, email, or account information
- Tool call content, arguments, or responses
- Any user-generated content
- The **raw** OS machine id or any reversible hardware identifier (only the salted, non-reversible `machine_id` hash is sent — see below)

## Anonymous ID

The anonymous ID is a random UUID (v4) generated on first run. It has **no correlation** to your hardware, user account, or identity. It exists solely to deduplicate heartbeats (so we don't count the same install twice in a day).

You can delete it by removing the `telemetry.anonymous_id` field from your config — a new random ID will be generated on next startup.

## Machine ID (schema v6)

The `anonymous_id` above is a UUID persisted in the config file. In **ephemeral environments** — throwaway `HOME`s, layered Docker builds, CI runners — the config (and therefore the UUID) is regenerated on every run, so a single machine can masquerade as hundreds of distinct installs. That inflates our install counts and defeats deduplication.

`machine_id` fixes this without collecting anything identifying:

- It is a **salted, non-reversible hash** — `HMAC-SHA256` keyed by the OS machine id, scoped by an mcpproxy-specific application key. The **raw machine id is never transmitted**; only the hash leaves your machine.
- The application-specific key means the value **cannot be correlated** with any other application's telemetry that hashes the same OS machine id.
- It is **stable per physical machine**, so ephemeral installs collapse to one identity for counting.
- If the OS machine id **cannot be read** (a container without `/etc/machine-id`, a permission error, or an exotic platform), the field is simply **omitted** — the heartbeat is never blocked, and the backend treats an absent value as "unknown".

`machine_id` respects the **same opt-out** as every other field: when telemetry is disabled (see below), the entire heartbeat — including `machine_id` — is never sent.

## One-time opt-out signal

When telemetry transitions from **enabled to disabled** (via the CLI, the config
Expand Down
3 changes: 2 additions & 1 deletion go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ require (
github.com/blevesearch/bleve/v2 v2.6.0
github.com/charmbracelet/bubbletea v1.3.10
github.com/charmbracelet/lipgloss v1.1.0
github.com/denisbrodbeck/machineid v1.0.1
github.com/dop251/goja v0.0.0-20260305124333-6a7976c22267
github.com/evanw/esbuild v0.28.1
github.com/gen2brain/beeep v0.11.2
Expand Down Expand Up @@ -39,6 +40,7 @@ require (
golang.org/x/sync v0.21.0
golang.org/x/sys v0.46.0
golang.org/x/term v0.44.0
golang.org/x/text v0.37.0
gopkg.in/natefinch/lumberjack.v2 v2.2.1
gopkg.in/yaml.v3 v3.0.1
pgregory.net/rapid v1.3.0
Expand Down Expand Up @@ -142,7 +144,6 @@ require (
go.yaml.in/yaml/v2 v2.4.2 // indirect
go.yaml.in/yaml/v3 v3.0.4 // indirect
golang.org/x/net v0.55.0 // indirect
golang.org/x/text v0.37.0 // indirect
golang.org/x/tools v0.45.0 // indirect
google.golang.org/genproto/googleapis/api v0.0.0-20260526163538-3dc84a4a5aaa // indirect
google.golang.org/genproto/googleapis/rpc v0.0.0-20260526163538-3dc84a4a5aaa // indirect
Expand Down
2 changes: 2 additions & 0 deletions go.sum
Original file line number Diff line number Diff line change
Expand Up @@ -85,6 +85,8 @@ github.com/danieljoos/wincred v1.2.3/go.mod h1:6qqX0WNrS4RzPZ1tnroDzq9kY3fu1KwE7
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/denisbrodbeck/machineid v1.0.1 h1:geKr9qtkB876mXguW2X6TU4ZynleN6ezuMSRhl4D7AQ=
github.com/denisbrodbeck/machineid v1.0.1/go.mod h1:dJUwb7PTidGDeYyUBmXZ2GphQBbjJCrnectwCyxcUSI=
github.com/dlclark/regexp2 v1.11.4 h1:rPYF9/LECdNymJufQKmri9gV604RvvABwgOA8un7yAo=
github.com/dlclark/regexp2 v1.11.4/go.mod h1:DHkYz0B9wPfa6wondMfaivmHpzrQ3v9q8cnmRbL6yW8=
github.com/dop251/goja v0.0.0-20260305124333-6a7976c22267 h1:Kfmq11A6DLHD8XoOeljWjzWg/rrujeaLHWSb8u7+2qQ=
Expand Down
37 changes: 37 additions & 0 deletions internal/telemetry/anonymity_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,8 @@ import (
"errors"
"strings"
"testing"

"github.com/denisbrodbeck/machineid"
)

// TestScanForPII_BlockedPrefix_UsersPath asserts that a payload containing a
Expand Down Expand Up @@ -152,6 +154,41 @@ func TestPopulateBlockedValuesFrom(t *testing.T) {
}
}

// TestScanForPII_RawMachineIDBlocked asserts that when the raw OS machine id is
// added to BlockedValues (defense-in-depth, as the telemetry service does for
// hostname/tokens), a payload accidentally carrying the raw id is rejected. The
// hashed machine_id we DO emit is unrelated to the raw value and passes. Skips
// when the OS machine id is unreadable on this host.
func TestScanForPII_RawMachineIDBlocked(t *testing.T) {
raw, err := machineid.ID()
if err != nil || len(raw) < 3 {
t.Skipf("OS machine id unavailable on this host (%v); skipping", err)
}

prev := BlockedValues
BlockedValues = []string{raw}
defer func() { BlockedValues = prev }()

// A payload leaking the raw id must be caught.
leak := []byte(`{"anonymous_id":"abc","machine_id":"` + raw + `"}`)
if scanErr := ScanForPII(leak); scanErr == nil {
t.Fatalf("expected violation when raw machine id present, got nil")
} else if !errors.Is(scanErr, ErrAnonymityViolation) {
t.Fatalf("expected ErrAnonymityViolation, got %v", scanErr)
}

// The hashed value we actually emit must NOT contain the raw id and must
// pass the scan.
hashed := protectedMachineID()
if hashed != "" && strings.Contains(hashed, raw) {
t.Fatalf("hashed machine_id leaked the raw id")
}
clean := []byte(`{"anonymous_id":"abc","machine_id":"` + hashed + `"}`)
if scanErr := ScanForPII(clean); scanErr != nil {
t.Errorf("hashed machine_id payload should pass scan, got %v", scanErr)
}
}

// TestScanForPII_AllBlockedPrefixes covers each of the default blocked
// prefixes to guard against a regression that silently drops one.
func TestScanForPII_AllBlockedPrefixes(t *testing.T) {
Expand Down
61 changes: 61 additions & 0 deletions internal/telemetry/machine_id.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
package telemetry

import (
"sync"

"github.com/denisbrodbeck/machineid"
)

// machineIDAppKey scopes the hashed machine id to mcpproxy. machineid.ProtectedID
// feeds it as the HMAC-SHA256 message keyed by the OS machine id, so the emitted
// value is a non-reversible per-app hash: it cannot be reversed to the raw id and
// cannot be correlated with any other application that hashes the same OS machine
// id with a different key.
const machineIDAppKey = "mcpproxy-telemetry"

// machineIDProvider is the seam used to obtain the app-scoped, non-reversible
// machine-id hash. Production points it at protectedMachineID; tests override it
// to inject deterministic values or simulate an unreadable machine id. It mirrors
// the function-pointer injection style used elsewhere in this package
// (see populateBlockedValuesFrom, DetectEnvKind).
var machineIDProvider = protectedMachineID

// protectedMachineID returns HMAC-SHA256(osMachineID, machineIDAppKey) as a
// lowercase hex string (64 chars). On any failure to read the OS machine id
// (permission errors, missing /etc/machine-id in a minimal container, an exotic
// platform) it returns "" — the caller then omits the field, and the backend
// treats empty as "unknown". The raw OS machine id is NEVER returned or
// transmitted, only the salted hash.
func protectedMachineID() string {
id, err := machineid.ProtectedID(machineIDAppKey)
if err != nil {
return ""
}
return id
}

// machineIDOnce guards the cached machine-id hash so repeated heartbeats reuse
// one value without re-probing the OS each time.
var (
machineIDOnce sync.Once
machineIDCached string
)

// resolveMachineID computes the app-scoped machine-id hash once per process and
// caches it. Subsequent heartbeats reuse the cached value, guaranteeing the
// field is stable across payload builds without re-probing the OS. Empty string
// when the OS machine id is unavailable.
func resolveMachineID() string {
machineIDOnce.Do(func() {
machineIDCached = machineIDProvider()
})
return machineIDCached
}

// resetMachineIDForTest clears the cached machine-id hash so tests can re-run
// resolveMachineID with a freshly injected machineIDProvider. MUST NOT be called
// from production code — guard calls behind _test.go files only.
func resetMachineIDForTest() {
machineIDOnce = sync.Once{}
machineIDCached = ""
}
179 changes: 179 additions & 0 deletions internal/telemetry/machine_id_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,179 @@
package telemetry

import (
"encoding/json"
"strings"
"testing"
"time"

"github.com/denisbrodbeck/machineid"
"go.uber.org/zap"

"github.com/smart-mcp-proxy/mcpproxy-go/internal/config"
)

// isLowerHex reports whether s is a non-empty string of lowercase hex digits.
func isLowerHex(s string) bool {
if s == "" {
return false
}
for _, r := range s {
switch {
case r >= '0' && r <= '9':
case r >= 'a' && r <= 'f':
default:
return false
}
}
return true
}

// newMachineIDTestService builds a minimal telemetry Service suitable for
// exercising BuildPayload in machine-id tests.
func newMachineIDTestService(t *testing.T) *Service {
t.Helper()
cfg := &config.Config{
Telemetry: &config.TelemetryConfig{
AnonymousID: "fixed-id",
AnonymousIDCreatedAt: time.Now().UTC().Format(time.RFC3339),
},
}
return New(cfg, "", "v1.2.3", "personal", zap.NewNop())
}

// TestMachineID_PresentAndStable asserts the machine_id field is populated and
// identical across two payload builds on the same machine.
func TestMachineID_PresentAndStable(t *testing.T) {
const fixed = "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef"
prev := machineIDProvider
machineIDProvider = func() string { return fixed }
resetMachineIDForTest()
defer func() {
machineIDProvider = prev
resetMachineIDForTest()
}()

svc := newMachineIDTestService(t)

p1 := svc.BuildPayload()
p2 := svc.BuildPayload()

if p1.MachineID == "" {
t.Fatalf("machine_id is empty, want %q", fixed)
}
if p1.MachineID != fixed {
t.Errorf("machine_id = %q, want %q", p1.MachineID, fixed)
}
if p1.MachineID != p2.MachineID {
t.Errorf("machine_id not stable across builds: %q != %q", p1.MachineID, p2.MachineID)
}
}

// TestMachineID_OmittedWhenUnavailable asserts that when the OS machine id
// cannot be read, the field is empty and omitted from the serialized JSON
// (never failing the heartbeat).
func TestMachineID_OmittedWhenUnavailable(t *testing.T) {
prev := machineIDProvider
machineIDProvider = func() string { return "" }
resetMachineIDForTest()
defer func() {
machineIDProvider = prev
resetMachineIDForTest()
}()

svc := newMachineIDTestService(t)
payload := svc.BuildPayload()

if payload.MachineID != "" {
t.Errorf("machine_id = %q, want empty when unavailable", payload.MachineID)
}

data, err := json.Marshal(payload)
if err != nil {
t.Fatalf("marshal failed: %v", err)
}
if strings.Contains(string(data), "machine_id") {
t.Errorf("empty machine_id should be omitted from JSON, got: %s", data)
}
}

// TestResolveMachineID_CachedOncePerProcess asserts resolveMachineID probes the
// provider at most once and caches the result.
func TestResolveMachineID_CachedOncePerProcess(t *testing.T) {
calls := 0
prev := machineIDProvider
machineIDProvider = func() string {
calls++
return "cached-value"
}
resetMachineIDForTest()
defer func() {
machineIDProvider = prev
resetMachineIDForTest()
}()

_ = resolveMachineID()
_ = resolveMachineID()
_ = resolveMachineID()

if calls != 1 {
t.Errorf("machineIDProvider called %d times, want 1 (result must be cached)", calls)
}
}

// TestProtectedMachineID_NotRawAndHex verifies the production hash is a
// lowercase-hex string, is not the raw OS machine id, and is scoped to the app
// key (differs from the raw id and from a bare hash of another app key). Skips
// when the OS machine id is unreadable (e.g. CI containers without
// /etc/machine-id) — the graceful-fallback path is covered separately.
func TestProtectedMachineID_NotRawAndHex(t *testing.T) {
raw, err := machineid.ID()
if err != nil || raw == "" {
t.Skipf("OS machine id unavailable on this host (%v); skipping raw-comparison", err)
}

got := protectedMachineID()
if got == "" {
t.Fatal("protectedMachineID returned empty despite a readable OS machine id")
}
if !isLowerHex(got) {
t.Errorf("protectedMachineID = %q, want lowercase hex", got)
}
// HMAC-SHA256 hex is 64 chars.
if len(got) != 64 {
t.Errorf("protectedMachineID length = %d, want 64 (SHA-256 hex)", len(got))
}
if strings.Contains(got, raw) || got == raw {
t.Errorf("protectedMachineID leaked the raw machine id")
}
}

// TestProtectedMachineID_RawNeverInPayload builds a real payload (production
// provider) and asserts the raw OS machine id never appears in the serialized
// JSON, while the hashed machine_id does. Skips when the raw id is unreadable.
func TestProtectedMachineID_RawNeverInPayload(t *testing.T) {
raw, err := machineid.ID()
if err != nil || raw == "" {
t.Skipf("OS machine id unavailable on this host (%v); skipping", err)
}

// Ensure the production provider is in effect and the cache is fresh.
resetMachineIDForTest()
defer resetMachineIDForTest()

svc := newMachineIDTestService(t)
payload := svc.BuildPayload()

data, err := json.Marshal(payload)
if err != nil {
t.Fatalf("marshal failed: %v", err)
}
js := string(data)

if strings.Contains(js, raw) {
t.Errorf("PRIVACY VIOLATION: payload contains the raw machine id")
}
if payload.MachineID != "" && !strings.Contains(js, payload.MachineID) {
t.Errorf("hashed machine_id missing from serialized payload")
}
}
2 changes: 1 addition & 1 deletion internal/telemetry/payload_privacy_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -144,7 +144,7 @@ func TestPayloadHasNoForbiddenSubstrings(t *testing.T) {
// Sanity check: the payload should still contain the legitimate fields,
// otherwise we've over-redacted.
for _, required := range []string{
`"schema_version":5`,
`"schema_version":6`,
`"surface_requests"`,
`"builtin_tool_calls"`,
`"upstream_tool_call_count_bucket"`,
Expand Down
6 changes: 3 additions & 3 deletions internal/telemetry/payload_v2_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -88,8 +88,8 @@ func TestHeartbeatPayloadV2Marshal(t *testing.T) {

payload := svc.BuildPayload()

if payload.SchemaVersion != 5 {
t.Errorf("schema_version = %d, want 5", payload.SchemaVersion)
if payload.SchemaVersion != 6 {
t.Errorf("schema_version = %d, want 6", payload.SchemaVersion)
}
if payload.AnonymousID != "fixed-id" {
t.Errorf("anonymous_id = %q", payload.AnonymousID)
Expand Down Expand Up @@ -135,7 +135,7 @@ func TestHeartbeatPayloadV2Marshal(t *testing.T) {
}
js := string(data)
for _, key := range []string{
`"schema_version":5`,
`"schema_version":6`,
`"surface_requests"`,
`"builtin_tool_calls"`,
`"upstream_tool_call_count_bucket":"11-100"`,
Expand Down
Loading
Loading