Skip to content

feat(agent): add OTLP observability plugin (observability/otel)#48184

Closed
Mandark-droid wants to merge 3 commits into
NousResearch:mainfrom
Mandark-droid:feat/otel-observability-plugin
Closed

feat(agent): add OTLP observability plugin (observability/otel)#48184
Mandark-droid wants to merge 3 commits into
NousResearch:mainfrom
Mandark-droid:feat/otel-observability-plugin

Conversation

@Mandark-droid

@Mandark-droid Mandark-droid commented Jun 18, 2026

Copy link
Copy Markdown

feat(agent): add OTLP observability plugin (observability/otel)

Summary

Adds a third bundled observability plugin, observability/otel, that
exports Hermes's existing observability events over OpenTelemetry (OTLP).
It slots in beside the two shipped plugins — observability/langfuse and
observability/nemo_relay — and makes no changes to Hermes core. Hermes
already produces exactly the right events (a turn, per-LLM-API-call
usage/cost/finish-reason, per-tool-call results); this plugin adds an OTLP sink
for them and exports three complementary signals:

  1. GenAI-convention spans (gen_ai.*) — the vendor-neutral trace model, so
    telemetry flows to any OTel backend (an OpenTelemetry Collector, Jaeger,
    Grafana Tempo, Honeycomb, …) with no lock-in.
  2. Dashboard-shaped OTLP log records (user_prompt / session_start /
    api_response / tool_result) — the event model agent-coding dashboards
    aggregate on, so a Hermes session shows up in Activity / Sessions /
    Leaderboards views — including its prompts — the same way a Claude Code /
    Codex / Copilot session does.
  3. Local-model cost — when the backend returns no price (Ollama / HF / vLLM
    local models), cost is estimated from the model's parameter size, so spans
    and log records carry a real cost_usd instead of $0.

Why

observability/langfuse and observability/nemo_relay cover Langfuse and the
NVIDIA NeMo relay, but there is no OpenTelemetry path. OTLP is the de-facto open
standard; an OTLP plugin lets Hermes telemetry flow to any OTel backend with
no vendor lock-in. Emitting both the standard gen_ai.* spans and the
log-record event model means Hermes interoperates with conventions-aware tracing
backends and populates agent-coding dashboards — and the model-size cost
fallback means local-model runs report real cost rather than $0.

What genai_otel adds (beyond a vanilla OTel SDK or other GenAI instrumentors)

The plugin runs on a plain OTel SDK by default (spans, tokens, latency,
finish reasons, log records — portable to any OTLP backend). Routing through the
optional genai-otel-instrument emitter additionally unlocks four signals that
are genuinely unique — neither a raw OTel SDK nor the common GenAI
instrumentors (OpenLLMetry / Traceloop, OpenInference / Arize, OpenLIT, Langfuse)
emit them:

  • On-prem GPU metrics — per-GPU utilization, power, memory, temperature, clocks, throttle state (nvidia-ml-py / amdsmi).
  • Energy + CO2energy_kwh, co2_emissions_gco2e, region-aware (codecarbon).
  • Local-model cost — Ollama / HF / vLLM cost estimated from parameter size when the backend returns no price.
  • Inline eval / guardrails — PII, toxicity, bias, prompt-injection, restricted-topics, hallucination scored as span attributes + metrics at instrumentation time (no separate eval pipeline).

All of it runs in-process and on-prem — no data leaves the host, no SaaS
observability backend, no separate eval service. The token/latency/cost fields
stay standard OTel GenAI conventions; the four signals above are extra
attributes/metrics a conventions-aware backend can use or ignore. See the plugin
README for the full comparison table + setup.

What changed

New plugin only — one directory plus its test, no core edits:

plugins/observability/otel/__init__.py     Hermes plugin: register(ctx) + lifecycle hooks
plugins/observability/otel/emitter.py      Hermes-agnostic OTel GenAI span builder
plugins/observability/otel/log_emitter.py  Hermes-agnostic OTel log-record builder (dashboard events)
plugins/observability/otel/cost.py         local-model cost estimation (model size → price tier)
plugins/observability/otel/provider.py     tracer + logger bootstrap (genai_otel preferred, OTel SDK fallback)
plugins/observability/otel/plugin.yaml     manifest
plugins/observability/otel/README.md       enable / configure / verify / disable
tests/plugins/test_otel_plugin.py          manifest + in-memory-exporter tests (39 tests)

Span mapping

invoke_agent          per Hermes turn   (gen_ai.conversation.id = session id)
  ├── chat            per LLM API call  (gen_ai.usage.*_tokens, cost_usd,
  │                                      gen_ai.response.finish_reasons, TTFT)
  └── execute_tool    per tool call     (gen_ai.tool.name, gen_ai.tool.type, errors)

gen_ai.system = hermes on every span; service.name defaults to
agent.coding.hermes (configurable). Prompt/response content is captured only
when HERMES_OTEL_CAPTURE_CONTENT=true (off by default — privacy-gated).

Log-record mapping (emitted in parallel with the spans)

event.name Per Key attributes
user_prompt Hermes turn prompt (content-gated), prompt_length (always), model
session_start session session.id, model
api_response LLM API call model, input_tokens, output_tokens, cost_usd, finish_reason, ttft_ms
tool_result tool call tool_name, tool_type, status_code, decision_type

user_prompt mirrors Claude Code's claude_code.user_prompt, so the dashboard's
prompt count and prompt-text drill-down populate for Hermes sessions. It
is emitted from pre_llm_call (Hermes delivers the prompt there, not at
on_session_start), once per turn. Without it a session would show tool calls
and API responses but no prompts — the dashboard reads log records, and the
prompt otherwise lives only on the invoke_agent span as gen_ai.prompt, which
log-record-based dashboards never see.

Identity (service.name, user.id, and any team.id/organization.id from
OTEL_RESOURCE_ATTRIBUTES) is placed on the OTel resource so per-user /
per-org leaderboard aggregations work. Same privacy gate as spans. Log emission
is best-effort — a logs-pipeline failure never disables spans.

Cost estimation

cost.py mirrors the genai-otel-instrument methodology: parse the parameter
size from the model name (llama3.1:8b → 8 B, qwen3:0.6b → 0.6 B,
smollm2:360m → 0.36 B), map it to a per-1K-token price tier, and compute
cost = in_tok/1000 * promptPrice + out_tok/1000 * completionPrice. Used only
as a fallback when the agent reported no cost (an agent-provided cost_usd
always wins); cost is omitted entirely when the size can't be inferred (no fake
$0).

Hook wiring (matches the bundled observability plugins)

Hermes hook Maps to
on_session_start track the active session + emit the session_start log (does not open the span — that is per-turn, below)
pre_llm_call open the per-turn invoke_agent span (first call of a turn; tool-loop calls reuse it) + stamp the prompt (gen_ai.prompt) on the span and emit the user_prompt log record (once per turn) so dashboards show the prompt text + count
transform_llm_output stamp the response (gen_ai.completion) + close the turn span — a read-only observer (returns None, never alters the output). The span is one per turn and exports at turn end, so prompt- and response-side eval (PII / injection / restricted-topics on input; toxicity / hallucination / PII on output) runs every turn and survives an abrupt shutdown, when content capture is on
on_session_end / on_session_finalize / on_session_reset close any still-open turn span (fallback for an interrupted turn)
post_api_request chat span + api_response log (model, usage, finish_reason, duration→TTFT, cost)
post_tool_call execute_tool span + tool_result log (tool_name, args, result, status/error)

Confirmed assumptions

The scaffold for this plugin was written against the observable behaviour of
the two shipped observability plugins, with five integration points flagged for
confirmation against the real Hermes plugin contract. All five were confirmed
against plugins/observability/langfuse, plugins/observability/nemo_relay,
and website/docs/guides/build-a-hermes-plugin.md, and the plugin was adapted
to the real contract:

# Assumption (scaffold) Confirmed contract (this PR)
A1 Subclass a base like hermes.plugins.ObservabilityPlugin No base class. A plugin is a plain module exposing register(ctx). The OtelObservabilityPlugin class and PLUGIN singleton were removed.
A2 Hooks on_turn_start/end, on_llm_call_end, on_tool_call_end Real hooks: on_session_start / on_session_end / on_session_finalize / on_session_reset, post_api_request (per LLM API call — carries usage/finish_reason), post_tool_call. Methods renamed accordingly.
A3 Each hook receives an event object **kwargs keyword args, not an object. Accessors rewritten to read session_id, model, usage (CanonicalUsage dict: input_tokens/output_tokens/…), finish_reason, assistant_response, tool_name, args, result, status.
A4 Entry-point group / subclass discovery + PLUGIN handle register(ctx) + ctx.register_hook(name, fn) + plugin.yaml manifest (bundled directory discovery, opt-in via hermes plugins enable). Entry-point/pyproject.toml removed; plugin.yaml added matching the langfuse/nemo_relay format.
A5 Config via constructor kwargs / configure(config) Env vars (HERMES_OTEL_*), matching every other observability plugin. Config delivered through a fail-open singleton gate (_get_emitter()), like _get_langfuse() / _get_runtime(). The same HERMES_OTEL_* vars now also drive the logs pipeline and cost estimation — no new config surface.
A6 The agent-coding dashboard reads log records (session_start/api_response/tool_result), not spans Confirmed against the TraceVerse genai-otel-instrument event model. log_emitter.py emits those records via a parallel OTLP logs pipeline (provider.build_logger, <endpoint>/v1/logs), with identity on the resource. Best-effort — a logs failure never disables spans.
A7 Local backends (Ollama/HF/vLLM) report no price, so cost must be estimated from model size to avoid $0 Confirmed against genai_otel.cost_calculator. cost.py mirrors its size→tier methodology verbatim; used only as a fallback when the agent reported no cost_usd, omitted when size is unknown.

The Hermes-agnostic span-mapping core (emitter.py), log-record-mapping core
(log_emitter.py), cost estimator (cost.py), and the tracer/logger bootstrap
(provider.py) are all Hermes-free and unit-tested in isolation — only
__init__.py (the plugin glue) is written against the real Hermes contract.

Design notes

  • Minimal hard dependencies. Only opentelemetry-{api,sdk} + the OTLP HTTP
    exporter (which provides both the span and log exporters). No new
    dependency for the logs/cost scope. genai-otel-instrument is an optional
    extra (preferred, auto-detected at runtime) — imported, never vendored, so the
    MIT license stays clean.
  • Fails open like the siblings. If the OTel SDK isn't importable, the hooks
    no-op (cached _INIT_FAILED sentinel). A malformed event or a dead OTLP
    endpoint degrades to a no-op tracer — it never raises out of a hook. The logs
    pipeline and cost estimator are independently best-effort: if build_logger()
    or cost estimation fails, spans still emit.
  • Logs flushed at exit. The plugin owns its LoggerProvider, so it registers
    an atexit force_flush + shutdown — without it the batch processor can
    drop late-turn records (the api_response / tool_result emitted just before
    the CLI exits).
  • Thread-safe singleton. The emitter (and the log emitter built alongside it)
    is built once under a double-checked lock (concurrent agent sessions don't race
    to build two providers), per the Build-a-Plugin guide's lazy-singleton guidance.
  • Hermes coupling isolated to one file (__init__.py). emitter.py,
    log_emitter.py, and cost.py are pure and unit-tested with in-memory
    exporters — no Hermes import, no network.

Testing instructions

# From the hermes-agent checkout, in the project venv (`uv sync --extra all`,
# plus the `dev` group for pytest/ruff):
scripts/run_tests.sh tests/plugins/test_otel_plugin.py    # 39 tests
python scripts/check-windows-footguns.py plugins/observability/otel tests/plugins/test_otel_plugin.py
ruff check plugins/observability/otel tests/plugins/test_otel_plugin.py

The test suite uses OTel's in-memory span and log exporters (no network) and
asserts: the produced spans carry the correct gen_ai.* attributes with correct
parent/child nesting (chat and execute_tool under invoke_agent); the log
records carry the dashboard event.name / token / cost / tool fields; the
user_prompt record carries the prompt text (content-gated) + prompt_length
and is emitted exactly once per turn even across a tool loop; local-model
cost is estimated from model size across the price tiers and boundaries (and is
None when unknown); content-capture is gated; every path fails open; and
register(ctx) wires exactly the hooks declared in plugin.yaml. It drives the
full hook surface end-to-end from **kwargs-style Hermes events, asserting spans,
logs, and cost together.

End-to-end (optional, requires a collector)

pip install opentelemetry-sdk opentelemetry-exporter-otlp-proto-http
hermes plugins enable observability/otel
HERMES_OTEL_ENDPOINT=http://localhost:4318 hermes chat -q "hello"
# → an "invoke_agent" trace with chat/execute_tool children appears in your OTel
#   backend, AND session_start/api_response/tool_result log records (with cost)
#   appear on the logs endpoint.

Platforms tested

Run in the project venv (CPython 3.11.15) on Windows 11:

Canonical gate Command Result
Tests (per-file parallel runner — the path scripts/run_tests.sh execs and CI uses) scripts/run_tests_parallel.py tests/plugins/test_otel_plugin.py 39 passed, 0 failed
Windows footguns python scripts/check-windows-footguns.py plugins/observability/otel tests/plugins/test_otel_plugin.py clean (6 files scanned)
Lint (blocking CI rule PLW1514) ruff check plugins/observability/otel tests/plugins/test_otel_plugin.py All checks passed

scripts/run_tests.sh itself probes a POSIX-layout venv (<venv>/bin/activate),
which a native-Windows venv (<venv>/Scripts/) doesn't have, so on Windows the
runner it execs — scripts/run_tests_parallel.py — was invoked directly with the
same deterministic env the wrapper sets (TZ=UTC, LANG=C.UTF-8,
PYTHONHASHSEED=0). On Linux/macOS/WSL2 scripts/run_tests.sh works unchanged.

  • Linux / macOS / WSL2: no OS-specific code paths; the plugin performs no
    path I/O (network export only — provider.py deliberately avoids
    get_hermes_home() since there is no filesystem work), uses no POSIX-only
    calls, and the footgun checker is clean. CI runs all three gates on Ubuntu.

Scope (deliberately narrow)

  • ✅ One new bundled plugin, beside the existing two observability plugins.
  • ✅ Exports OTel spans (gen_ai.*) and dashboard-shaped OTLP log
    records
    and local-model cost (model-size pricing) — all three driven
    by the same HERMES_OTEL_* config, no new config surface.
  • ✅ No changes to Hermes core, config schema, or the other plugins.
  • ✅ Standard OTel GenAI conventions for spans — no bespoke schema; the log
    records use the established agent-coding event model.
  • ✅ Opt-in (hermes plugins enable observability/otel); fails open when the
    OTel SDK is absent. The logs/cost paths are best-effort — they never disable
    spans.
  • ❌ No new core dependencies forced on users who don't enable the plugin (the
    logs + cost scope adds zero dependencies beyond the OTLP HTTP exporter the
    spans already need).

Related issues

No tracking issue — this is a self-initiated contribution that fills the
OpenTelemetry gap alongside the two shipped observability plugins. Context:

  • Hermes built-in observability plugins (observability/langfuse,
    observability/nemo_relay) — this is the OTel sibling.
  • OpenTelemetry GenAI semantic conventions (the attribute schema emitted).

@alt-glitch alt-glitch added type/feature New feature or request comp/plugins Plugin system and bundled plugins P3 Low — cosmetic, nice to have telemetry Touches outbound telemetry, usage attribution, or analytics — needs opt-in gating before merge labels Jun 18, 2026
@Mandark-droid
Mandark-droid force-pushed the feat/otel-observability-plugin branch 3 times, most recently from 9c61dc1 to 9480865 Compare June 18, 2026 04:19

@tonydwb tonydwb left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Code Review Summary

Verdict: Approved

New OTLP observability plugin exporting Hermes events via OpenTelemetry GenAI conventions. Spans, dashboard-shaped log records, and local-model cost estimation.

Looks Good

  • Well-structured plugin architecture with clear separation (emitter, log_emitter, cost, provider)
  • Comprehensive README with detailed configuration and verification steps
  • Privacy-gated content capture (off by default)
  • Proper error handling with fail-open behavior
  • Production-quality debug logging with exc_info=True
  • Unit-tested with in-memory exporters
  • Clean upgrade path via genai-otel-instrument integration

Suggestion

  • The README is extensive (233 lines) — consider splitting into inline docs and a quickstart guide if the plugin evolves.

Reviewed by Hermes Agent

@Mandark-droid

Copy link
Copy Markdown
Author

Thanks for the review and approval, @tonydwb!

I implemented your README suggestion in a931c411:

  • The README is now a focused quickstart (install -> enable -> configure -> verify), down from 233 to ~99 lines.
  • The extensive GPU / CO2 / eval (genai_otel) setup moved to ADVANCED.md, linked from the README.
  • The span / log-record / cost reference now points to the module docstrings (emitter.py, log_emitter.py, cost.py, provider.py) as the source of truth, so the deep docs live inline next to the code, as you suggested.

Docs only - no code or behaviour change.

The CI workflows are showing action_required (the usual fork-PR gate) - whenever you get a chance, could you approve the workflow runs so the checks can complete? Happy to address anything they surface. Thanks again!

Add a bundled observability/otel plugin that exports Hermes's existing
observability events over OpenTelemetry (OTLP). It slots in beside
observability/langfuse and observability/nemo_relay with no changes to
Hermes core, and exports three complementary signals:

  1. GenAI-convention spans (gen_ai.*) - the standard trace model for any
     OTel backend (Collector, Jaeger, Tempo, Honeycomb, ...).
  2. Dashboard-shaped OTLP log records (session_start / api_response /
     tool_result) so a Hermes session populates agent-coding dashboards
     the same way Claude Code / Codex / Copilot sessions do.
  3. Local-model cost: when the backend returns no price (Ollama / HF /
     vLLM), cost is estimated from the model's parameter size using the
     genai-otel-instrument methodology, so spans and logs carry a real
     cost_usd instead of $0. An agent-provided cost always wins.

Span model (one invoke_agent span PER TURN):
  invoke_agent   per turn       (gen_ai.conversation.id = session id)
    chat         per LLM call   (usage tokens, cost_usd, finish_reasons, TTFT)
    execute_tool per tool call  (gen_ai.tool.name/type, errors)

Hooks wired via register(ctx): on_session_start/end/finalize/reset,
pre_llm_call, transform_llm_output, post_api_request, post_tool_call. The
invoke_agent span is one per TURN: pre_llm_call opens it (stamping the prompt
as gen_ai.prompt) and transform_llm_output closes it (stamping the final
response as gen_ai.completion), so each turn's span exports at turn end. This
makes prompt- and response-side evaluation (PII / prompt-injection /
restricted-topics on the input; toxicity / hallucination / PII on the output)
run every turn and survive an abrupt shutdown, when content capture is on.
post_api_request carries only token/finish metadata (not the response text);
transform_llm_output is a read-only observer that returns None and never
alters the output. Config via HERMES_OTEL_* env vars. Fails open when the
OTel SDK is absent (cached sentinel); logs and cost paths are best-effort and
never disable spans or raise out of a hook. Prefers the optional
genai-otel-instrument emitter (which also unlocks on-prem GPU/energy/CO2
metrics and an inline eval/guardrail suite - see README), falls back to a
plain OTel SDK + OTLP HTTP exporter. Content capture is privacy-gated (off by
default).

Hermes coupling is isolated to __init__.py; the span-mapping (emitter.py),
log-record-mapping (log_emitter.py), and cost (cost.py) cores are pure and
unit-tested with in-memory exporters.

Tests: tests/plugins/test_otel_plugin.py (36 tests) cover the manifest,
gen_ai.* span attributes, parent/child nesting, tool-error recording,
content-capture gating, per-turn prompt + response capture via pre_llm_call /
transform_llm_output, the dashboard log-record shapes, local-model cost
estimation (size tiers / boundaries / unknown), fail-open behaviour, and the
register(ctx) hook surface end-to-end.
…back)

Addresses tonydwb's review note that the 233-line README is extensive. The
README is now a focused quickstart (install / enable / configure / verify) plus
an Architecture section that points to the module docstrings
(emitter / log_emitter / cost / provider) as the source of truth for the
span / log-record / cost reference. The extensive GPU / CO2 / eval (genai_otel)
setup moves to ADVANCED.md, linked from the README. No code or behaviour change.
The plugin stamped the prompt only on the invoke_agent span (gen_ai.prompt).
Dashboards built from OTLP log records (e.g. the claude_code.* event model)
never read spans, so a Hermes session showed tool calls and API responses but
no prompts.

Add OtelLogEmitter.user_prompt(): a `user_prompt` record (matching Claude
Code's claude_code.user_prompt) carrying attributes.prompt (content-gated) and
attributes.prompt_length (always). Wire it into pre_llm_call so it fires once
per turn -- the existing tool-loop-continuation guard dedupes later calls in
the same turn. Bump plugin to 0.3.0; add log-emitter + adapter tests.

Verified end-to-end against an OTLP collector with HERMES_OTEL_CAPTURE_CONTENT=true:
the record lands with the prompt text.
@Mandark-droid
Mandark-droid force-pushed the feat/otel-observability-plugin branch from db8b49c to 3fb5423 Compare June 24, 2026 10:07
cphowiehuang added a commit to elmtree-askmo/hermes-agent that referenced this pull request Jul 2, 2026
@teknium1

Copy link
Copy Markdown
Contributor

Thank you for the thorough implementation and the follow-up README refinement. This is an automated hermes-sweeper review.

The current repository policy requires this integration to remain outside the core tree:

  • This PR adds a bundled plugins/observability/otel OTLP backend (plugins/observability/otel/plugin.yaml:1; README.md:17-29).
  • AGENTS.md:126-136 explicitly lists observability backends among third-party integrations that must ship as standalone plugins because of the ongoing maintenance coupling.
  • The developer plugin guide states the same rule at website/docs/developer-guide/plugins/index.md:39-40.

Please consider publishing this as a standalone plugin repository installable under ~/.hermes/plugins/ or through a pip entry point, and promoting it in #plugins-skills-and-skins. The policy is about maintenance ownership, not the quality of this work.


Closed as not-planned per standing maintainer policy (in-tree-provider-integration). This is a design-direction decision, not a code-quality judgment — see the Contribution Rubric in AGENTS.md for what the project is looking for. If you believe this policy was misapplied to your change, comment here and a maintainer will take a look.

@teknium1 teknium1 closed this Jul 14, 2026
@teknium1 teknium1 added the sweeper:not-planned Sweeper: closed per standing maintainer policy (design direction) label Jul 14, 2026
@Mandark-droid

Copy link
Copy Markdown
Author

Thanks @teknium1 — understood, the maintenance-ownership rationale makes sense. Republished as a standalone plugin repo, as suggested:

https://github.com/Mandark-droid/hermes-otel-plugin

hermes plugins install Mandark-droid/hermes-otel-plugin
hermes plugins enable otel

Also pip-installable (hermes_agent.plugins entry point) or a plain clone into ~/.hermes/plugins/. Same code as this PR — GenAI-convention spans + dashboard-shaped log records + local-model cost estimation, all 39 tests passing, CI on Ubuntu + Windows — now maintained there under MIT. I'll promote it in #plugins-skills-and-skins.

Thanks @tonydwb for the earlier code review!

Mandark-droid added a commit to Mandark-droid/genai_otel_instrument that referenced this pull request Jul 15, 2026
…tel-plugin repo

Upstream NousResearch/hermes-agent#48184 was approved on code review, then
closed per the project's standing policy that observability backends ship
as standalone plugin repos. The integration now lives at
https://github.com/Mandark-droid/hermes-otel-plugin — update the README
ecosystem table (Open PR -> Shipped standalone plugin) and add the 1.6.1
changelog entry. Docs-only, no code changes.

Note: check-added-large-files and mixed-line-ending hooks skipped — blocked
by a Windows Application Control policy (WinError 4551), both conditions
verified manually (16KB/159KB files, pure-LF endings).
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 sweeper:not-planned Sweeper: closed per standing maintainer policy (design direction) telemetry Touches outbound telemetry, usage attribution, or analytics — needs opt-in gating before merge type/feature New feature or request

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants