Files
hermes-agent/tui_gateway/methods_shared_metrics.py
teknium1 7a06cb50d7 feat(telemetry): shared-metrics opt-in offer on every surface
One answer per profile, asked once, with the Desktop strip's three equal
choices (Send to Nous / Local only / No thanks) now offered by:

- `hermes setup`: at the end of every flow (Quick, Full, Blank Slate,
  Portal, --quick), via the setup-completed record they all funnel into.
- `hermes` / `hermes --tui`: once before an interactive chat on a profile
  that never answered. Skipped for -q, piped/JSON output, spawned actions
  (HERMES_NONINTERACTIVE), Desktop-hosted panes and managed installs.
- Web dashboard: a banner for the profile being managed, backed by
  GET/PUT /api/shared-metrics/consent.

"No thanks" is the terminal default so Enter never opts anyone in; Esc and
the banner's X leave the question open.

Also fixes the answer that could not stick: a CLI "no" equals the shipped
defaults, so save_config stripped it and every surface (Desktop strip
included) kept asking. The answer is now written to config.yaml directly,
and `decided` has one definition shared by the RPC, the CLI and the router.
2026-09-30 21:15:08 -07:00

188 lines
8.2 KiB
Python

"""Shared-metrics consent JSON-RPC handlers (the Desktop twin of ``hermes setup``'s Shared Metrics
section). Both opt-ins live in the focused profile's config.yaml exactly where the wizard writes
them — ``telemetry.shared_metrics.enabled`` (local collection) and ``.send`` (daily upload) — so the
CLI and the app can never disagree about the answer. ``shared_metrics.set`` enforces the wizard's
invariant (sending needs collection) and reconciles the consent windows in the local store on every
change, through the same single writer the wizard uses.
Bodies are rebound onto server.py's globals (method_ctx.bind_module) and reference them bare.
"""
import logging
from .method_ctx import HandlerRegistry, bind_module
logger = logging.getLogger(__name__)
_registry = HandlerRegistry()
method = _registry.method
_profile_scoped = _registry.profile_scoped
def _shared_metrics_consent(cfg) -> dict:
"""The one ``{enabled, send, decided}`` reading every surface shares (CLI offer included)."""
from hermes_cli.observability.shared_metrics_consent import consent_state
return consent_state(cfg)
def _shared_metrics_record_setup_completed(cfg) -> None:
"""Desktop has no setup-finish RPC; this first-run answer is the one backend call made once,
right after onboarding settles. A no-op (inside the events API) unless collection is on."""
from hermes_cli.observability.shared_metrics_events import record_setup_completed
model = cfg.get("model") if isinstance(cfg, dict) else None
provider = model.get("provider") if isinstance(model, dict) else None
record_setup_completed(surface="desktop", provider=provider if isinstance(provider, str) and provider else None)
@method("shared_metrics.status")
@_profile_scoped
def _(rid, params: dict) -> dict:
"""``{enabled, send, decided}`` for the focused profile. A pure read of config.yaml (no defaults
merged, so ``decided`` sees only what the user wrote)."""
try:
return _ok(rid, _shared_metrics_consent(_load_cfg()))
except Exception as e:
return _err(rid, 5095, str(e))
@method("shared_metrics.set")
@_profile_scoped
def _(rid, params: dict) -> dict:
"""Write both opt-ins at once and reconcile the consent windows. ``send`` is forced off when
``enabled`` is off (the wizard's rule: sending cannot outlive collection, and turning collection
off withdraws send consent). ``first_run`` marks the Desktop first-run answer, which also records
the setup-completed metric. Answers the stored ``{enabled, send, decided}``."""
enabled = params.get("enabled") is True
send = enabled and params.get("send") is True
try:
cfg = _load_cfg_raw()
telemetry = cfg.get("telemetry")
if not isinstance(telemetry, dict):
telemetry = cfg["telemetry"] = {}
section = telemetry.get("shared_metrics")
if not isinstance(section, dict):
section = telemetry["shared_metrics"] = {}
section["enabled"], section["send"] = enabled, send
_save_cfg(cfg)
except Exception as e:
return _err(rid, 5096, str(e))
from hermes_cli.setup import _record_send_consent_change
# Unconditional, like the wizard: a send key already false may still have an open window.
_record_send_consent_change(enabled=send)
if not enabled:
from hermes_cli.observability.shared_metrics_desktop import purge_onboarding_latches
purge_onboarding_latches()
if params.get("first_run") is True:
_shared_metrics_record_setup_completed(cfg)
return _ok(rid, _shared_metrics_consent(cfg))
@method("shared_metrics.slash_command")
@_profile_scoped
def _(rid, params: dict) -> dict:
"""The Desktop and Ink TUI dispatchers call this once per user-typed slash command, locally
handled ones included; the gateway never counts slash.exec / command.dispatch itself, so
each command lands exactly once. Always ``{ok: true}`` (the events API never raises)."""
from hermes_cli.observability.shared_metrics_events import record_slash_command
record_slash_command(command=str(params.get("command") or ""), surface=_resolve_session_platform())
return _ok(rid, {"ok": True})
@method("shared_metrics.startup_latency")
@_profile_scoped
def _(rid, params: dict) -> dict:
"""The Ink TUI (gateway ready) and Desktop (backend attached) each report their own launch ->
ready time once per launch; ``launch_id`` latches it here too, so a reconnect to this backend
never re-counts. A Desktop on a URL/cloud backend has no ``HERMES_DESKTOP`` here, so the
client's declared surface wins over env detection. Always ``{ok: true}``."""
from hermes_cli.observability.shared_metrics_startup import record_rpc_startup_latency
record_rpc_startup_latency(
client_surface=params.get("surface") or _resolve_session_platform(), elapsed_ms=params.get("elapsed_ms"),
launch_id=params.get("launch_id"),
)
return _ok(rid, {"ok": True})
# ---- v4 reliability ----
@method("shared_metrics.update_run")
@_profile_scoped
def _(rid, params: dict) -> dict:
"""Desktop reports each packaged self-update once (a pending record survives the restart that
applies it and is sent on the next backend attach). Always ``{ok: true}``."""
from hermes_cli.observability.shared_metrics_update import record_desktop_update
record_desktop_update(
outcome=params.get("outcome"), failed_stage=params.get("failed_stage"),
duration_ms=params.get("duration_ms"), mechanism=params.get("mechanism"),
from_commit_date=params.get("from_commit_date"),
)
return _ok(rid, {"ok": True})
# ---- end v4 reliability ----
# ---- v5 desktop ----
@method("shared_metrics.desktop_feature_use")
@_profile_scoped
def _(rid, params: dict) -> dict:
"""Desktop reports each area the user opened, at most once per UTC day (latched on both sides).
Always ``{ok: true}``; a no-op unless shared metrics are on."""
from hermes_cli.observability.shared_metrics_desktop import record_desktop_feature_use
record_desktop_feature_use(area=params.get("area"))
return _ok(rid, {"ok": True})
@method("shared_metrics.desktop_friction")
@_profile_scoped
def _(rid, params: dict) -> dict:
"""One Desktop friction event (dismissed notice, error toast by category, renderer crash, backend
disconnect, slow frame), capped per day. Always ``{ok: true}``."""
from hermes_cli.observability.shared_metrics_desktop import record_desktop_friction
record_desktop_friction(kind=params.get("kind"), detail=params.get("detail"))
return _ok(rid, {"ok": True})
@method("shared_metrics.desktop_onboarding")
@_profile_scoped
def _(rid, params: dict) -> dict:
"""One Desktop first-run step transition, once per (step, event) per profile. Always ``{ok: true}``."""
from hermes_cli.observability.shared_metrics_desktop import record_desktop_onboarding
record_desktop_onboarding(step=params.get("step"), event=params.get("event"))
return _ok(rid, {"ok": True})
@method("shared_metrics.desktop_dislike")
@_profile_scoped
def _(rid, params: dict) -> dict:
"""One Desktop dislike signal (quick close, cancelled flow, setting changed vs default, rage click,
undo, feature disabled), capped per signal per day. For a setting the backend reads the saved value
and compares it to the default itself; only the key and the direction are recorded."""
from hermes_cli.observability.shared_metrics_desktop import record_desktop_dislike
record_desktop_dislike(signal=params.get("signal"), target=params.get("target"), setting=params.get("setting"))
return _ok(rid, {"ok": True})
@method("shared_metrics.desktop_daily")
@_profile_scoped
def _(rid, params: dict) -> dict:
"""One finished Desktop day: mode use (Bot Mode vs Sessions, with the bot count) and button presses
per action. ``{recorded: true}`` once settled, so the client drops the day; false keeps it."""
from hermes_cli.observability.shared_metrics_desktop import record_desktop_daily
recorded = record_desktop_daily(
day=params.get("day"), modes=params.get("modes"), actions=params.get("actions"),
bot_count=params.get("bot_count"),
)
return _ok(rid, {"recorded": recorded})
# ---- end v5 desktop ----
def register(server) -> None:
bind_module(globals(), server, skip=("_",))