hermes-agent/hermes_cli/subcommands/telemetry.py
emozilla 3e28eaccde feat(telemetry): local-first telemetry & observability
Add a built-in telemetry system that records what the agent does — workflows,
model calls, tool calls, errors — to the local machine, powers `/insights`, and
can export to an operator-chosen destination. Default-on locally; nothing leaves
the machine unless the user exports it or opts into the aggregate plane.

Three planes with a hard wall between them:
  - local: full-fidelity observability (real model/provider/tool names), on by
    default, never leaves the machine.
  - aggregate: opt-in metadata, default off. No uploader ships — consent is
    recorded via telemetry.consent_state, and `preview` shows what would be
    produced, computed locally.
  - trajectories: full message content, opt-in, exported only to the operator's
    own destination.

Mechanism:
  - Bundled `telemetry` plugin registers observational lifecycle hooks
    (on_session_start / post_api_request / post_tool_call / on_session_finalize).
    No core call sites are edited; hooks already carry the data.
  - Fire-and-forget emitter: emit() returns in microseconds, never blocks or
    raises into a model/tool call. A daemon thread writes events to an
    append-only JSONL log and the tel_* tables in state.db (its own sqlite
    connection, separate from SessionDB).
  - tel_runs / tel_model_calls / tel_tool_calls live in the declarative
    SCHEMA_SQL and are reconciled automatically; SCHEMA_VERSION 16 -> 17.
  - metrics derives rollups for /usage and /insights; rollup builds per-run
    summaries for `hermes telemetry preview`.

Consent is config, not a parallel command surface. The config file is the root
of trust: set telemetry.consent_state with `hermes config set`, or pin any
telemetry.* key (including allow_aggregate) via managed scope, which overrides
the user's value per key. `hermes telemetry` exposes only what config cannot:
status (report), preview (query), and export.

Export:
  - exporter_bulk writes telemetry (and, when the trajectories plane is enabled,
    session content) to ndjson/json.
  - otlp_exporter streams spans to a configured OpenTelemetry Collector over
    OTLP/HTTP. The SDK is an optional extra (hermes-agent[otlp]), lazily
    installed via tools.lazy_deps on first use.
  - Secrets are always redacted on every export path
    (redact_sensitive_text(force=True)); content export is gated by the
    trajectories plane, and PII scrubbing follows telemetry.content_redaction.
    OTLP auth headers reference environment variable names, never inline values.

No outbound emission to Nous. The aggregate uploader is intentionally not built.
2026-07-24 18:54:45 +00:00

54 lines
2.5 KiB
Python

"""``hermes telemetry`` subcommand parser.
Telemetry control and inspection. ``preview`` shows the per-run summary events that
would be produced for the aggregate plane; there is no uploader, so it terminates as a
local view.
The handler is injected to avoid importing ``main`` (mirrors the insights subcommand).
"""
from __future__ import annotations
from typing import Callable
def build_telemetry_parser(subparsers, *, cmd_telemetry: Callable) -> None:
"""Attach the ``telemetry`` subcommand (with actions) to ``subparsers``."""
p = subparsers.add_parser(
"telemetry",
help="Inspect local telemetry and export it",
description=(
"Local-first telemetry. The local plane records observability on this "
"machine. The aggregate plane is opt-in (set telemetry.consent_state via "
"`hermes config set`); it has no uploader and is shown only via `preview`."
),
)
sub = p.add_subparsers(dest="telemetry_action")
sub.add_parser("status", help="Show telemetry planes, consent state, and local data volume")
prev = sub.add_parser(
"preview",
help="Show the aggregate events that would be produced (computed locally, not uploaded)",
)
prev.add_argument("--days", type=int, default=30, help="Window to roll up (default: 30)")
prev.add_argument("--limit", type=int, default=10, help="Max events to print (default: 10)")
prev.add_argument("--json", action="store_true", help="Print raw JSON events")
exp = sub.add_parser(
"export",
help="Export local telemetry (and optional content) to a file, stream, or OTLP endpoint",
)
exp.add_argument("--out", help="Output file path (use - for stdout). Not needed with --otlp.")
exp.add_argument("--format", dest="fmt", choices=["ndjson", "json"], default="ndjson",
help="Output format (default: ndjson)")
exp.add_argument("--since", type=int, default=0,
help="Only telemetry from the last N days (0 = all)")
exp.add_argument("--include-content", action="store_true",
help="Include session/message content (requires telemetry.trajectories.enabled). "
"Secrets always redacted; PII per telemetry.content_redaction.")
exp.add_argument("--otlp", action="store_true",
help="Export to the configured OTLP endpoint (telemetry.export.otlp.*) "
"instead of a file. Requires the optional 'otlp' extra.")
p.set_defaults(func=cmd_telemetry)