feat(agent): add OTLP observability plugin (observability/otel)#48184
feat(agent): add OTLP observability plugin (observability/otel)#48184Mandark-droid wants to merge 3 commits into
Conversation
9c61dc1 to
9480865
Compare
tonydwb
left a comment
There was a problem hiding this comment.
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
|
Thanks for the review and approval, @tonydwb! I implemented your README suggestion in
Docs only - no code or behaviour change. The CI workflows are showing |
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.
db8b49c to
3fb5423
Compare
… + Artemis cross-process trace stitching
|
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:
Please consider publishing this as a standalone plugin repository installable under Closed as not-planned per standing maintainer policy ( |
|
Thanks @teknium1 — understood, the maintenance-ownership rationale makes sense. Republished as a standalone plugin repo, as suggested: hermes plugins install Mandark-droid/hermes-otel-plugin
hermes plugins enable otelAlso pip-installable ( Thanks @tonydwb for the earlier code review! |
…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).
feat(agent): add OTLP observability plugin (
observability/otel)Summary
Adds a third bundled observability plugin,
observability/otel, thatexports Hermes's existing observability events over OpenTelemetry (OTLP).
It slots in beside the two shipped plugins —
observability/langfuseandobservability/nemo_relay— and makes no changes to Hermes core. Hermesalready 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:
gen_ai.*) — the vendor-neutral trace model, sotelemetry flows to any OTel backend (an OpenTelemetry Collector, Jaeger,
Grafana Tempo, Honeycomb, …) with no lock-in.
user_prompt/session_start/api_response/tool_result) — the event model agent-coding dashboardsaggregate 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.
local models), cost is estimated from the model's parameter size, so spans
and log records carry a real
cost_usdinstead of$0.Why
observability/langfuseandobservability/nemo_relaycover Langfuse and theNVIDIA 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 thelog-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_oteladds (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-instrumentemitter additionally unlocks four signals thatare genuinely unique — neither a raw OTel SDK nor the common GenAI
instrumentors (OpenLLMetry / Traceloop, OpenInference / Arize, OpenLIT, Langfuse)
emit them:
energy_kwh,co2_emissions_gco2e, region-aware (codecarbon).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:
Span mapping
gen_ai.system = hermeson every span;service.namedefaults toagent.coding.hermes(configurable). Prompt/response content is captured onlywhen
HERMES_OTEL_CAPTURE_CONTENT=true(off by default — privacy-gated).Log-record mapping (emitted in parallel with the spans)
event.nameuser_promptprompt(content-gated),prompt_length(always),modelsession_startsession.id,modelapi_responsemodel,input_tokens,output_tokens,cost_usd,finish_reason,ttft_mstool_resulttool_name,tool_type,status_code,decision_typeuser_promptmirrors Claude Code'sclaude_code.user_prompt, so the dashboard'sprompt count and prompt-text drill-down populate for Hermes sessions. It
is emitted from
pre_llm_call(Hermes delivers the prompt there, not aton_session_start), once per turn. Without it a session would show tool callsand API responses but no prompts — the dashboard reads log records, and the
prompt otherwise lives only on the
invoke_agentspan asgen_ai.prompt, whichlog-record-based dashboards never see.
Identity (
service.name,user.id, and anyteam.id/organization.idfromOTEL_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.pymirrors thegenai-otel-instrumentmethodology: parse the parametersize 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 computecost = in_tok/1000 * promptPrice + out_tok/1000 * completionPrice. Used onlyas a fallback when the agent reported no cost (an agent-provided
cost_usdalways wins); cost is omitted entirely when the size can't be inferred (no fake
$0).Hook wiring (matches the bundled observability plugins)
on_session_startsession_startlog (does not open the span — that is per-turn, below)pre_llm_callinvoke_agentspan (first call of a turn; tool-loop calls reuse it) + stamp the prompt (gen_ai.prompt) on the span and emit theuser_promptlog record (once per turn) so dashboards show the prompt text + counttransform_llm_outputgen_ai.completion) + close the turn span — a read-only observer (returnsNone, 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 onon_session_end/on_session_finalize/on_session_resetpost_api_requestchatspan +api_responselog (model,usage,finish_reason, duration→TTFT, cost)post_tool_callexecute_toolspan +tool_resultlog (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 adaptedto the real contract:
hermes.plugins.ObservabilityPluginregister(ctx). TheOtelObservabilityPluginclass andPLUGINsingleton were removed.on_turn_start/end,on_llm_call_end,on_tool_call_endon_session_start/on_session_end/on_session_finalize/on_session_reset,post_api_request(per LLM API call — carriesusage/finish_reason),post_tool_call. Methods renamed accordingly.**kwargskeyword args, not an object. Accessors rewritten to readsession_id,model,usage(CanonicalUsage dict:input_tokens/output_tokens/…),finish_reason,assistant_response,tool_name,args,result,status.PLUGINhandleregister(ctx)+ctx.register_hook(name, fn)+plugin.yamlmanifest (bundled directory discovery, opt-in viahermes plugins enable). Entry-point/pyproject.tomlremoved;plugin.yamladded matching the langfuse/nemo_relay format.configure(config)HERMES_OTEL_*), matching every other observability plugin. Config delivered through a fail-open singleton gate (_get_emitter()), like_get_langfuse()/_get_runtime(). The sameHERMES_OTEL_*vars now also drive the logs pipeline and cost estimation — no new config surface.session_start/api_response/tool_result), not spansgenai-otel-instrumentevent model.log_emitter.pyemits 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.$0genai_otel.cost_calculator.cost.pymirrors its size→tier methodology verbatim; used only as a fallback when the agent reported nocost_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
opentelemetry-{api,sdk}+ the OTLP HTTPexporter (which provides both the span and log exporters). No new
dependency for the logs/cost scope.
genai-otel-instrumentis an optionalextra (preferred, auto-detected at runtime) — imported, never vendored, so the
MIT license stays clean.
no-op (cached
_INIT_FAILEDsentinel). A malformed event or a dead OTLPendpoint 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.
LoggerProvider, so it registersan
atexitforce_flush+shutdown— without it the batch processor candrop late-turn records (the
api_response/tool_resultemitted just beforethe CLI exits).
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.
__init__.py).emitter.py,log_emitter.py, andcost.pyare pure and unit-tested with in-memoryexporters — no Hermes import, no network.
Testing instructions
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 correctparent/child nesting (
chatandexecute_toolunderinvoke_agent); the logrecords carry the dashboard
event.name/ token / cost / tool fields; theuser_promptrecord carries the prompt text (content-gated) +prompt_lengthand 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
Nonewhen unknown); content-capture is gated; every path fails open; andregister(ctx)wires exactly the hooks declared inplugin.yaml. It drives thefull hook surface end-to-end from
**kwargs-style Hermes events, asserting spans,logs, and cost together.
End-to-end (optional, requires a collector)
Platforms tested
Run in the project venv (CPython 3.11.15) on Windows 11:
scripts/run_tests.shexecs and CI uses)scripts/run_tests_parallel.py tests/plugins/test_otel_plugin.pypython scripts/check-windows-footguns.py plugins/observability/otel tests/plugins/test_otel_plugin.pyPLW1514)ruff check plugins/observability/otel tests/plugins/test_otel_plugin.pyscripts/run_tests.shitself probes a POSIX-layout venv (<venv>/bin/activate),which a native-Windows venv (
<venv>/Scripts/) doesn't have, so on Windows therunner it execs —
scripts/run_tests_parallel.py— was invoked directly with thesame deterministic env the wrapper sets (
TZ=UTC,LANG=C.UTF-8,PYTHONHASHSEED=0). On Linux/macOS/WSL2scripts/run_tests.shworks unchanged.path I/O (network export only —
provider.pydeliberately avoidsget_hermes_home()since there is no filesystem work), uses no POSIX-onlycalls, and the footgun checker is clean. CI runs all three gates on Ubuntu.
Scope (deliberately narrow)
gen_ai.*) and dashboard-shaped OTLP logrecords and local-model cost (model-size pricing) — all three driven
by the same
HERMES_OTEL_*config, no new config surface.records use the established agent-coding event model.
hermes plugins enable observability/otel); fails open when theOTel SDK is absent. The logs/cost paths are best-effort — they never disable
spans.
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:
observability/langfuse,observability/nemo_relay) — this is the OTel sibling.