Skip to content

feat(plugins): add local_sqlite_telemetry plugin + on_turn hooks - #22095

Closed
Phoenix1819 wants to merge 1 commit into
NousResearch:mainfrom
Phoenix1819:local-sqlite-telemetry
Closed

feat(plugins): add local_sqlite_telemetry plugin + on_turn hooks#22095
Phoenix1819 wants to merge 1 commit into
NousResearch:mainfrom
Phoenix1819:local-sqlite-telemetry

Conversation

@Phoenix1819

Copy link
Copy Markdown

Summary

This PR introduces local_sqlite_telemetry, a zero-dependency, zero-credential telemetry plugin for Hermes. It stores LLM calls, tool calls, context pressure events, and session summaries to a local SQLite database per profile.

Problem

Currently, Hermes has no built-in telemetry for operators who want to:

  • Track token usage and costs across sessions
  • Monitor which tools are used most frequently
  • Identify context compression pressure points
  • Generate weekly usage digests

External telemetry (Langfuse, OpenTelemetry) requires credentials and network egress. A local-first option is needed for privacy-conscious or air-gapped deployments.

Solution

A new plugin under plugins/observability/local_sqlite_telemetry/ that writes to ~/.hermes/profiles/<profile>/telemetry.db.

Schema

Table Tracks Key columns
llm_calls Every LLM API call model, provider, prompt/completion/total tokens, cache tokens, cost_usd, duration_ms, status
tool_calls Every tool invocation tool_name, status, duration_ms, args_hash, error_message, token_cost
context_pressure Context window pressure per turn context_used_chars, context_limit_chars, utilization_pct, compression_triggered, model
session_summary Aggregated per-session stats total_llm_calls, total_tool_calls, total_tokens, total_cost, platform, start/end time

Hooks consumed

  • on_session_start / on_session_end
  • pre_llm_call / post_llm_call
  • pre_tool_call / post_tool_call
  • on_turn_start / on_turn_endnew hooks added in this PR

New hooks

To support per-turn tracking, two lifecycle hooks are added to VALID_HOOKS in hermes_cli/plugins.py:

  • on_turn_start — fired at the beginning of each agent turn (before tool/LLM calls)
  • on_turn_end — fired at the end of each agent turn (after all processing)

Both receive the session ID and turn context. They are intentionally minimal — no core agent logic is modified beyond firing the hook.

CLI helper

telemetry_cli.py provides a hermes telemetry subcommand:

hermes telemetry summary --days 7
hermes telemetry tools --top 10
hermes telemetry costs --model kimi-k2.6
hermes telemetry export --format csv --output usage.csv

Weekly digest

weekly_digest.py generates a markdown report suitable for cron:

python -m plugins.observability.local_sqlite_telemetry.weekly_digest \
  --profile phoenix --output ~/weekly-report.md

Backwards Compatibility

  • Plugin is opt-in — must be enabled in config.yaml plugins list
  • If the DB does not exist, it is created automatically on first hook fire
  • Zero new Python dependencies (uses stdlib sqlite3)
  • on_turn_start/on_turn_end hooks are no-ops unless a plugin registers them

Testing

  • Verified DB creation and schema on first on_session_start
  • Verified LLM call logging with token counts and cost
  • Verified tool call logging with success/failure states
  • Verified context pressure logging triggers correctly on compression
  • Verified weekly digest generates valid markdown
  • Verified telemetry_cli queries return correct aggregates

Directory layout

plugins/observability/local_sqlite_telemetry/
├── __init__.py        # hook handlers + DB schema
├── plugin.yaml        # manifest
├── telemetry_cli.py   # query CLI
└── weekly_digest.py   # scheduled report generator

@liuhao1024

Copy link
Copy Markdown
Contributor

Two issues worth flagging:

1. Hardcoded "phoenix" profile fallback

In _get_db_path(), the fallback path is hardcoded to a specific developer profile:

_DB_PATH = os.path.join(os.path.expanduser("~/.hermes"), "profiles", "phoenix", "telemetry.db")

This will silently write telemetry to the wrong location for any user whose active profile is not "phoenix". The same hardcode appears three more times in telemetry_cli.py (lines 342, 353, 492).

Suggested fix for _get_db_path() — resolve the active profile dynamically:

def _get_db_path() -> str:
    global _DB_PATH
    if _DB_PATH is None:
        hermes_home = os.environ.get("HERMES_HOME", os.path.expanduser("~/.hermes"))
        profile = os.environ.get("HERMES_PROFILE", "")
        if not profile:
            # Try to detect from HERMES_HOME basename
            base = os.path.basename(hermes_home)
            profile = base if base and base != ".hermes" else "default"
        profiles_dir = os.path.join(os.path.expanduser("~/.hermes"), "profiles", profile)
        if os.path.isdir(profiles_dir):
            _DB_PATH = os.path.join(profiles_dir, "telemetry.db")
        else:
            _DB_PATH = os.path.join(os.path.expanduser("~/.hermes"), "telemetry.db")
    return _DB_PATH

2. on_turn_end is declared but never registered

The docstring and KNOWN_HOOKS in plugins.py both declare on_turn_end as a supported hook, but:

  • No on_turn_end function is defined in the plugin
  • register() does not call ctx.register_hook("on_turn_end", ...)
  • plugin.yaml does not list on_turn_end under hooks

This means the hook silently never fires. Either add the implementation or remove it from the docstring/KNOWN_HOOKS to avoid confusion.

@Phoenix1819

Copy link
Copy Markdown
Author

@liuhao1024 Thanks for the review. Both issues fixed in the amended commit:

  1. Hardcoded phoenix profile — replaced with dynamic resolution via HERMES_HOME / HERMES_PROFILE env vars, falling back to basename detection then default. Applied across init.py, telemetry_cli.py, and weekly_digest.py.

  2. on_turn_end zombie hook — removed from docstring, plugin.yaml, and hermes_cli/plugins.py VALID_HOOKS. No implementation existed; it was declared prematurely. If we need turn-end telemetry later we will add it with a real implementation in a follow-up.

Also fixed a latent bug in telemetry_cli.py report() where undefined cursor variable c was used instead of conn.

Commit amended and force-pushed.

@alt-glitch alt-glitch added type/feature New feature or request P3 Low — cosmetic, nice to have comp/plugins Plugin system and bundled plugins labels May 8, 2026
@Phoenix1819
Phoenix1819 force-pushed the local-sqlite-telemetry branch 2 times, most recently from 89763d0 to 29f72fc Compare May 15, 2026 15:30
@Phoenix1819
Phoenix1819 force-pushed the local-sqlite-telemetry branch 2 times, most recently from 16dea53 to ed6d1eb Compare May 16, 2026 00:23
@Phoenix1819

Copy link
Copy Markdown
Author

Fork CI Results (independent validation on pr22095-dispatch)

Tests
View run details

Summary: 21654 passed, 9 failed, 57 skipped (~12 min)

All 9 failures are pre-existing baseline issues unrelated to this PR:

  • gateway/test_restart_drain.py — string mismatch on draining status
  • gateway/test_tts_media_routing.py (3) — mock await failure on post-stream media extraction
  • hermes_cli/test_update_gateway_restart.py (3) — PID filtering returns empty on Ubuntu runner
  • run_agent/test_async_httpx_del_neuter.py — stale loop entry cache issue
  • tools/test_vision_native_fast_path.py — no vision provider configured in CI

Zero regressions introduced by this branch.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

comp/plugins Plugin system and bundled plugins P3 Low — cosmetic, nice to have type/feature New feature or request

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants