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.
188 lines
8.2 KiB
Python
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=("_",))
|