Skip to content

feat(observability): OpenTelemetry instrumentation (spans + metrics + traceparent)#17

Merged
1-ztc merged 24 commits into
mainfrom
feature/observability
Jul 20, 2026
Merged

feat(observability): OpenTelemetry instrumentation (spans + metrics + traceparent)#17
1-ztc merged 24 commits into
mainfrom
feature/observability

Conversation

@1-ztc

@1-ztc 1-ztc commented Jul 20, 2026

Copy link
Copy Markdown
Contributor

What & why

Adds end-to-end OpenTelemetry observability to the agent runtime. The existing observability/ module ships a lightweight tracing API (withSpan, setTracer('console')) but only one span is actually emitted (moss.llm.turn in agent-loop-llm-call.ts), with no metrics, no trace propagation, and no file export.

This PR completes the instrumentation:

  • Span hierarchy with context propagation: moss.sessionmoss.agent.turnmoss.llm.request / moss.tool.invoke. Child spans nest under the session span via AsyncLocalStorage context, sharing one traceId.
  • Metrics (mossMetrics): session count/duration/tool-count, tool invocations + duration by status, LLM request timing.
  • W3C traceparent: injected into web-fetch / web-search outbound requests so downstream services correlate.
  • Exporters: FileSpanProcessor (local JSONL trace export) + ConsoleSpanProcessor (MOSS_TRACE=console), assembled via a new sdk.ts entrypoint.
  • Lifecycle: CLI initializes OTel at startup and flushes on exit / beforeExit.

The observability/tracing.ts is rewritten onto the OTel SDK withSpan, keeping the existing setTracer/setTraceRedactor/TraceRegistry shims for compatibility.

Type of change

  • New feature

Why the @opentelemetry/* dependencies

The upstream light tracer is a hand-rolled TraceSpan/Tracer interface with a noopTracer default. To emit real spans/metrics to OTLP collectors (and to get context propagation, exporters, and semantic conventions for free), this switches the agent to the standard OpenTelemetry SDK. Added deps: @opentelemetry/api, sdk-node, sdk-trace-base, sdk-metrics, resources, semantic-conventions, exporter-trace-otlp-http, exporter-metrics-otlp-http. Default behavior is still a no-op unless MOSS_OTEL_ENABLED / MOSS_OTEL_URL / MOSS_TRACE is set — zero overhead when off.

Checklist

  • npm run verify green (boundaries + hygiene + build + typecheck + lint + test). Note: one test (git-status-snapshot.spec.mjs) fails on Windows only with EBUSY while tearing down a %TEMP% dir locked by antivirus/indexing; it passes with an alternate TMPDIR and on Linux/CI. Not a code defect.
  • Added/updated tests — 5 observability specs (tracing, file-trace, metrics, noop, integration) + upstream feature specs (acp, exec-wait, rewind, sessions-search, todo) all pass.
  • New public exports are tagged — initObservability / shutdownObservability / propagateHeaders exported from observability/index.ts.
  • No host-path imports, secrets, real IPs, or personal identifiers.
  • Change fits Moss's scope (host-neutral runtime instrumentation).

Also included

  • fix(hygiene): the workspace-hygiene link extractor matched TS computed-key syntax inside example code blocks ([ATTR]: value,) as markdown reference definitions, flagging cfg.serviceName, etc. as broken links and blocking npm run verify. Now strips fenced/indented code blocks before scanning. Fixes the hygiene failures the new observability docs surfaced.

🤖 Generated with Claude Code

1-ztc and others added 24 commits July 16, 2026 15:01
Re-instrument D:\moss-drobotics with OpenTelemetry, correcting the
tracing/metrics mixed-mode tech debt from the from-remote fork:
tracing moves onto the official SDK so trace and metric share one
Resource, errors go through recordException, and spans are batched.
Local file trace is preserved as a FileSpanProcessor alongside the
OTLP exporter.

Co-Authored-By: Claude <noreply@anthropic.com>
13-task TDD plan implementing the 2026-07-16 observability design:
deps, metrics handle, SDK-backed withSpan, FileSpanProcessor, NodeSDK
assembly, trace-context injection, and the 6 call-site wirings
(session/turn/llm/tool spans + metrics, web traceparent), ending with
end-to-end verification against the local OTLP receiver.

Co-Authored-By: Claude <noreply@anthropic.com>
12-task TDD plan wiring OTel SDK (trace+metric+local file trace) into
moss-agent: dependencies, metrics handles, FileSpanProcessor, SDK-backed
withSpan rewrite, sdk/index entrypoint, web traceparent injection, CLI
init, and the four instrumentation sites (llm/tool/turn/session spans +
metrics), ending with an end-to-end integration spec.

Co-Authored-By: Claude <noreply@anthropic.com>
Adds 8 OpenTelemetry packages to @rdk-moss/agent:
- stable: api@1.9, resources@2.9, sdk-metrics@2.9, sdk-trace-base@2.9,
  semantic-conventions@1.43
- experimental (aligned at 0.220): sdk-node, exporter-trace-otlp-http,
  exporter-metrics-otlp-http

Note: sdk-trace-base has graduated to stable (2.9.0), not experimental —
the plan's ^0.200 assumption was corrected to the real published versions.
No code imports the deps yet; build + 80 spec files still pass.

Co-Authored-By: Claude <noreply@anthropic.com>
metrics.ts exposes a noop-by-default instrument handle (llmTokens,
llmDuration, toolInvocations, toolDuration, sessionCount, sessionDuration,
sessionToolCount). Before setGlobalMeterProvider, metrics.getMeter()
returns a noop meter, so business call-sites invoke .add()/.record()
unconditionally at zero cost. sessionToolCount corrects the from-remote
misnaming of per-turn tool count as "session.turns". Re-exported from
observability/index.ts.

Co-Authored-By: Claude <noreply@anthropic.com>
FileSpanProcessor implements the OTel SpanProcessor interface, buffering
ended ReadableSpans and flushing them to
{workspaceDir}/.moss/analytics/traces.jsonl every 30s and on shutdown.
serializeSpan maps SDK HrTime ([sec, ns]) to epoch-ms and SpanStatusCode
to ok/error. readTraceStats aggregates the JSONL for CLI reporting.

Verified against the installed sdk-trace-base@2.9.0 (ReadableSpan,
SpanProcessor, TimedEvent shapes) — the spec's BigInt HrTime was
corrected to the real [number, number] shape.

Co-Authored-By: Claude <noreply@anthropic.com>
withSpan now uses trace.getTracer() + context propagation; on error it
records the exception as a span event and sets ERROR with a redacted
message (String-coerced because redactSensitiveData returns unknown).
The fn callback receives the real OTel Span.

Legacy setTracer/setTraceRedactor/TraceRegistry/getTracer are retained
as noop shims so cli-main.ts, moss-agent.ts, and agent-loop-llm-call.ts
imports keep resolving until callers migrate to initObservability.
Added sessionAttributes builder for the session-span task.

Co-Authored-By: Claude <noreply@anthropic.com>
sdk.ts assembles a single NodeSDK: one shared Resource (service.name +
service.version from package.json), BatchSpanProcessor→OTLP trace exporter,
FileSpanProcessor for local JSONL, and a PeriodicExportingMetricReader→
OTLP metric exporter. Uses metricReaders (plural) — metricReader is
deprecated. Best-effort init: never crashes the agent.

index.ts now exports initObservability (env-gated noop when disabled),
shutdownObservability, propagateHeaders (W3C traceparent via the SDK
propagation API + defaultTextMapSetter). tracing-on ⇒ metrics-on by
default; local file trace on by default.

Co-Authored-By: Claude <noreply@anthropic.com>
Both outbound fetches now route headers through propagateHeaders, which
adds a W3C traceparent for the active span (or passes through unchanged
when no span is active). Lets the receiver correlate upstream HTTP calls
with the agent's trace tree.

Co-Authored-By: Claude <noreply@anthropic.com>
initObservability runs before MossAgent construction (no-op unless
MOSS_OTEL_ENABLED / MOSS_OTEL_URL set). shutdownObservability runs in
the main finally block and on beforeExit to flush in-flight
traces/metrics.

Co-Authored-By: Claude <noreply@anthropic.com>
Span renamed agent.llm_turn → moss.llm.request for naming consistency.
Records moss.llm.tokens (input/output, with direction + model) and
moss.llm.request.duration on success; records duration with status=error
on failure. Noop when metrics disabled.

Co-Authored-By: Claude <noreply@anthropic.com>
… metrics

Splits executeOneToolCall into a thin outer wrapper (span + metrics) and
the unchanged inner logic. Each tool call becomes one moss.tool.invoke
span carrying is_error/outcome attributes, plus moss.tool.invocations
and moss.tool.invoke.duration metrics. The inner function's control flow
and multiple early-return paths are untouched.

Co-Authored-By: Claude <noreply@anthropic.com>
Adds an imperative startSpan() / ActiveSpan API to tracing.ts for
call-sites that cannot use the callback-based withSpan (loop bodies
with continue/break/throw). Each agent-loop iteration opens a
moss.agent.turn span; executeLlmTurn and processLlmResponse run inside
runInSpanContext so child spans (moss.llm.request, moss.tool.invoke)
nest under the turn. The span is ended in the existing finally block
(runs on continue/break/throw), marking ERROR when the turn ended with
a consecutive-turn error.

Co-Authored-By: Claude <noreply@anthropic.com>
…etrics

chat() now runs its body inside a moss.session span (sessionKey + model
attributes). Session metrics — count, duration, per-turn tool count —
are recorded in a finally block so they fire on both success and error
outcomes (ok/incomplete/error). The session span is the root of the
session → turn → llm/tool trace tree.

Co-Authored-By: Claude <noreply@anthropic.com>
Enables the SDK (MOSS_OTEL_ENABLED=1), runs a nested withSpan chain
(session → turn → llm), shuts down, and asserts the FileSpanProcessor
landed all three spans in traces.jsonl. Does not require a live
receiver — OTLP sends are fire-and-forget, the file trace writes
regardless. Proves the full trace+file stack works, not just compiles.

Co-Authored-By: Claude <noreply@anthropic.com>
mossMetrics previously created its instruments at module load via
metrics.getMeter() — which returns a NOOP meter until setGlobalMeterProvider
runs. The cached noop instruments never switched to real ones after the SDK
started, so metrics were silently dropped (verified: receiver got 0 metrics
even with MOSS_OTEL_ENABLED=1).

Instruments now resolve lazily on first use (cached after first resolution),
so they bind to the real MeterProvider once the SDK starts. The e2e spec now
asserts getMeterProvider() is the noop before init and a real provider after —
a regression guard for this exact failure.

Found via manual receiver verification (D:\otel), not the spec suite.

Co-Authored-By: Claude <noreply@anthropic.com>
The moss.session root span and session metrics were in chat(), but the CLI
(oneshot / piped / TUI) all drive the agent via streamChat() →
streamChatViaAgentLoop, which never calls chat(). So the session span and
session metrics were unreachable from any CLI entry — verified: real LLM
oneshot produced only turn+llm spans, no moss.session, no session metrics.

Moved the session span (startSpan) and session metrics into
streamChatViaAgentLoop's finally, covering every entry. chat() is restored
to its original event-loop logic (the mesh/channel path now also gets a
single session span via streamChat). Verified end-to-end with a real LLM:
moss.session → moss.agent.turn → moss.llm.request tree, real token metrics,
and session metrics all land in the receiver.

Co-Authored-By: Claude <noreply@anthropic.com>
…ropagation)

The session span and the turn/llm child spans ended up in separate traces:
moss.session alone in one trace, moss.agent.turn + moss.llm.request in
another. Verified by tracing the real LLM run — turn.parent was NULL, not
the session span.

Two root causes, both fixed:
1. The session span was opened but runAgentLoop ran outside its context.
   Added runInSpanContextGen to ActiveSpan — it drives an async generator
   while re-activating the span context on each .next() resume (generators
   suspend at yield and lose the stack-local active context).
2. runAgentLoop spawns a background async IIFE that captures the context
   active at call time. It was called before the session context was
   activated. Moved the miniStream creation INSIDE the session-context
   generator so the background loop inherits the session context.

Verified end-to-end with a real LLM: the newest trace now has all three
spans in one traceId with session → turn → llm parent linkage correct.

Co-Authored-By: Claude <noreply@anthropic.com>
Two minor metric-semantic issues found during runtime verification:

1. session outcome: a hard LLM failure (HTTP 401) produced stopReason='error'
   but was labeled 'incomplete' (only 'end_turn' was 'ok', everything else
   'incomplete'). Now 'error' stopReason → outcome='error', so the session
   error-rate reflects real failures. Verified: 401 run now records
   session.count outcome=error (was incomplete).

2. tool span/metrics: denied / pre-blocked / hook-blocked / unknown-tool
   outcomes were all marked is_error and counted as status='error',
   inflating the tool error-rate. Now only a completed-and-failed execution
   is status='error'; the pre-execution outcomes count as 'blocked' (user
   refusal / guardrail / unknown tool are not execution errors). Added
   outcome_kind span attribute. Verified: successful exec → status='ok'.

Co-Authored-By: Claude <noreply@anthropic.com>
MOSS_TRACE=console (documented in cli help) was a dead switch — the old
setTracer('console') became a noop shim after the SDK rewrite. Restore it
as a real ConsoleSpanProcessor that streams span open/close to stderr with
parent-depth indentation, duration, status, and key attributes — useful for
watching the trace tree run in a terminal without a receiver.

Also fixes two lazy-resolution bugs exposed while wiring this up:
- tracing.ts bound `tracer` eagerly at module load (noop before SDK start),
  same failure mode as the metrics instruments. Now resolved lazily via
  resolveTracer() so spans bind to the real TracerProvider after SDK start.
- sdk.ts returned early when cfg.enabled=false, blocking the standalone
  console-only path. Now starts the SDK if either OTel or console is on.

Verified: real LLM oneshot with a tool call streams the full
session → turn → llm/tool tree to stderr with correct nesting.

Co-Authored-By: Claude <noreply@anthropic.com>
Resolve conflicts across 6 files where observability instrumentation
(HEAD) overlaps with main's tool-nudge + run-lifecycle changes:
- cli-main.ts, web-fetch.ts, web-search.ts: keep both sides' imports
  (propagateHeaders + ensureKeepAliveDispatcherInstalled / logLLMUsage).
- execute-tool-call.ts: keep isMossError (main) + withSpan/toolAttributes/
  mossMetrics (HEAD).
- agent-loop.ts: adopt main's shouldBufferAssistantOutput (do not suppress
  streaming for an always-installed completionGate) + pass pendingToolAborts;
  keep HEAD's turnSpan wrapping so calls close with '}))'.
- moss-agent.ts: keep HEAD's session-span metrics + sessionSpan.end(), and
  restore main's run-lifecycle calls (noteRunStarted before the loop,
  noteRunFinished/retireSteeringMessages/clearPendingStructuredValidation in
  finally). logLLMUsage import retained.

Verified: tsc --noEmit clean; observability/web/tool/loop specs pass.

Co-Authored-By: Claude <noreply@anthropic.com>
Bring in upstream's 27 commits since afb478a: exec_wait, /rewind,
sessions search/list/export, ACP stdio, prompt-cache observability,
config-profile safety fixes, redact refactor, 0.6.0 release.

Resolve 2 dependency-file conflicts (cli-main.ts / moss-agent.ts
auto-merged cleanly this round):
- packages/moss-agent/package.json: keep the 8 @opentelemetry/* deps
  (our instrumentation) and adopt upstream's @rdk-moss/core ^0.6.0.
- package-lock.json: same resolution, then npm install --package-lock-only
  to reconcile the 0.6.0 core/agent entries.

Verified: tsc --noEmit clean; observability (5) + web/tool + upstream
new-feature specs (acp, exec-wait, rewind, sessions-search, todo) all pass.

Co-Authored-By: Claude <noreply@anthropic.com>
… links

The link extractor matched TS computed-key syntax inside example code
(e.g. `[semconv.ATTR_SERVICE_NAME]: cfg.serviceName,`) as a markdown
reference definition (`[name]: url`), flagging `cfg.serviceName,` etc. as
broken links. Strip fenced (```/~~~) and indented code blocks before
extracting links so code in examples is not scanned.

Fixes the workspace-hygiene failures that blocked `npm run verify` for the
observability instrumentation docs.

Co-Authored-By: Claude <noreply@anthropic.com>
@1-ztc
1-ztc merged commit 4e624a1 into main Jul 20, 2026
3 checks passed
@1-ztc
1-ztc deleted the feature/observability branch July 20, 2026 01:29
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant