Otel memory instrumentation - #617
Conversation
…-semconv v0.1.0)
Greenfield OTel instrumentation for the AgenticMixin surface, emitting spans,
metrics, and logs on the core memory operations per memory-semconv v0.1.0
(ISI-1068 §2):
- write = commit_results() -> memory.write span + input.size_bytes,
extracted.facts_count, per-tier items.*
- search = progressive_retrieve() -> memory.read span + query.k, results.count,
top_similarity
- list = list_all_recall_files() -> memory.read span (operation=list)
- embed = child memory.embed spans around every embedding call, with token
counts when the provider reports usage
Signals (semconv-exact):
- metrics: memory_operation_duration_seconds, memory_recall_results_count
(histograms); memory_items_total, memory_bytes_total (observable gauges);
memory_embed_token_total (counter). Labels held to the spec's budget
(operation/store_kind/sut_name/embedder_model) — high-cardinality context
stays on spans only.
- logs: one correlated OTel log record per operation via the SDK logging bridge.
- resource: memory.sut.name/version/architecture/store_backend.
PII discipline: the raw search query is never emitted by default (semconv
§2.4); read spans carry memory.query.length instead, with memory.query.text
gated behind MEMU_OTEL_CAPTURE_QUERY_TEXT for operators who accept collector
scrubbing.
memU depends on opentelemetry-api only (no-op unless an SDK is wired via
memu.observability.init_telemetry); the SDK + OTLP exporters live in the new
`observability` extra. Instrumentation reads providers from a library-owned
registry so it isn't bound to OTel's set-once globals.
Validated in-process against InMemory span/metric/log exporters (semconv
conformance + cardinality budget). 19 new tests; observability package at 93%
coverage, ruff + mypy clean.
Refs ISI-1924, ISI-1914
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…→memU traces Previously each memory operation started its own root span, so an agent invoking memU produced disconnected single-op traces. Add inbound trace-context propagation so memU joins the caller's trace: - `propagation.py`: extract W3C context from `TRACEPARENT`/`TRACESTATE` env vars (the OTel-standard subprocess carrier) and `env_with_current_context()` for the caller side. - `entrypoint.cli_telemetry()`: wrap a CLI invocation in a SERVER-kind `memu.<command>` span parented to the extracted context, and wire the SDK (no-op unless an OTLP endpoint is configured). The existing `memory.*` spans nest under it automatically. - Hook it into `memu.cli.main` so real `memu` invocations emit a connected trace. Result: agent.query → memu.<cmd> (SERVER) → memory.read/write → memory.embed as one trace across process/service boundaries. Verified in Dynatrace (one trace id spanning services `memu-e2e-agent` and `memu`) and by unit tests asserting the memory span joins an inbound traceparent's trace id and nests under the CLI span. 91% observability coverage, ruff + mypy clean. Refs ISI-1924, ISI-1914 Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
There was a problem hiding this comment.
Pull request overview
This PR introduces an OpenTelemetry-based observability layer for memU’s memory operations, including W3C trace-context propagation across the agent → memu CLI subprocess boundary, plus in-process tests validating spans/metrics/logs against the proposed memory-semconv v0.1.0 contract.
Changes:
- Add a new
memu.observabilitypackage (semconv constants, span/metric/log helpers, OTLP SDK bootstrap, and env-based trace-context propagation). - Instrument the CLI entrypoint with a SERVER span and instrument
AgenticMixinread/write/embed paths withmemory.*spans and metrics. - Add a comprehensive in-process test suite validating emitted telemetry.
Reviewed changes
Copilot reviewed 13 out of 14 changed files in this pull request and generated 2 comments.
Show a summary per file
| File | Description |
|---|---|
| uv.lock | Adds OTel-related locked dependencies and an observability extra resolution. |
| pyproject.toml | Adds opentelemetry-api as a base dependency and an observability extra for SDK/exporters; adds a ruff ignore for semconv constants. |
| src/memu/cli.py | Wraps each CLI invocation in cli_telemetry(...) for a top-level SERVER span. |
| src/memu/app/agentic.py | Wraps list/retrieve/commit and embed calls in memory-semconv spans and records metrics. |
| src/memu/observability/init.py | Exposes observability public API surface. |
| src/memu/observability/config.py | Adds env-driven enablement + PII gate for query text + store-backend override. |
| src/memu/observability/entrypoint.py | Implements CLI-level SERVER span + lifecycle wiring. |
| src/memu/observability/instruments.py | Defines metric instruments and label-cardinality guardrails. |
| src/memu/observability/operation.py | Implements per-operation span/metric/log lifecycle and traced_embed. |
| src/memu/observability/propagation.py | Implements env-var carrier extraction/injection for W3C trace-context. |
| src/memu/observability/providers.py | Adds a provider registry to avoid reliance on OTel process globals. |
| src/memu/observability/semconv.py | Centralizes span/attribute/metric names for memory-semconv v0.1.0. |
| src/memu/observability/telemetry.py | Implements SDK bootstrap and OTLP wiring helpers. |
| tests/test_observability.py | Adds in-process tests validating spans/metrics/logs and trace joining across boundaries. |
Suppressed comments (2)
src/memu/observability/telemetry.py:70
build_resource()will currently crash with anAttributeErrorif it’s called without the OpenTelemetry SDK installed (after guarding the module import). Since it’s a public API (__all__), it should fail fast with a clear install hint when the SDK isn’t available.
def build_resource(resource_attributes: Mapping[str, str] | None, store_backend: str | None) -> Resource:
"""Assemble the per-process resource with the ``memory.sut.*`` identity."""
attrs: dict[str, Any] = {
SERVICE_NAME: semconv.SUT_NAME,
src/memu/observability/telemetry.py:116
init_telemetry()should explicitly reject calls whenopentelemetry-sdkisn’t installed, instead of failing later with confusing attribute/NameErrors. This also matches the stated contract that the SDK is optional and only required when you actually wire/export telemetry.
def init_telemetry(
*,
resource_attributes: Mapping[str, str] | None = None,
store_backend: str | None = None,
span_exporter: SpanExporter | None = None,
metric_reader: MetricReader | None = None,
log_processor: LogRecordProcessor | None = None,
set_global: bool = True,
force: bool = False,
) -> TelemetryHandle:
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| from opentelemetry import metrics, trace | ||
| from opentelemetry._logs import get_logger_provider, set_logger_provider | ||
| from opentelemetry.sdk._logs import LoggerProvider, LoggingHandler, LogRecordProcessor | ||
| from opentelemetry.sdk._logs.export import BatchLogRecordProcessor | ||
| from opentelemetry.sdk.metrics import MeterProvider | ||
| from opentelemetry.sdk.metrics.export import MetricReader, PeriodicExportingMetricReader | ||
| from opentelemetry.sdk.resources import SERVICE_NAME, Resource | ||
| from opentelemetry.sdk.trace import TracerProvider | ||
| from opentelemetry.sdk.trace.export import BatchSpanProcessor, SpanExporter, SpanProcessor |
| yield | ||
| return | ||
|
|
||
| handle = init_telemetry() |
…global (ISI-1994) Address Copilot review on NevaMind-AI#617: - telemetry.py imported opentelemetry.sdk.* at module load, but memU only hard-depends on opentelemetry-api (the SDK lives in the `observability` extra). Every `memu` invocation (memu.cli -> entrypoint -> telemetry) then raised ModuleNotFoundError: opentelemetry.sdk for users who never opted into export. Defer all SDK imports into the provider-installing functions; keep type references under TYPE_CHECKING (safe via `from __future__ annotations`). init_telemetry() still raises loudly only when actually called without the SDK. - entrypoint.cli_telemetry() called init_telemetry() with the default set_global=True and then handle.shutdown(), leaving telemetry._handle set to a torn-down handle so a later init_telemetry() in the same process returned a dead handle. The CLI needs no process globals (providers registry is populated regardless), so pass set_global=False. - add test_cli_imports_without_observability_extra: subprocess-isolated regression guarding the SDK-absent import path. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
|
Thanks — solid work, and the CrewAI + Ollama e2e is much appreciated. That said, we can't take this in as-is. memU's telemetry direction is being set by #613, which routes CLI analytics and error reporting to the memu-service backend API rather than OTLP — and that work lives in a private backend repo, so it's not something external contributors can meaningfully participate in. Merging a parallel OTel path now would put us on two telemetry surfaces to maintain. The |
📝 Pull Request Summary
Adds OpenTelemetry instrumentation to memU's memory operations (span/attribute names following memory-semconv v0.1.0) and W3C trace-context propagation across the agent→memU process boundary, so a single
memuCLI invocation joins the caller's distributed trace end-to-end. Includes a real CrewAI + Ollama end-to-end driver that produces one continuousagent → memorytrace (the memU analog of the Memobase CrewAI demo).✅ What does this PR do?
memu.observabilitypackage (src/memu/observability/): asemconvnaming contract,memory_operation/traced_embedinstrumentation, an OTLP SDK wiring layer (init_telemetry), aconfiggating the SDK, andproviders/instrumentsfor metrics.propagation.py):extract_context_from_env()parents memU's spans under the caller'sTRACEPARENT/TRACESTATE;env_with_current_context()lets an agent/host adapter inject its active trace into amemusubprocess. Because memU is CLI/subprocess-invoked (no in-process HTTP server), the env-var carrier is the OTel-idiomatic choice.entrypoint.py+cli.py): eachmemuinvocation is wrapped in onememu.<command>SERVER span parented to the inbound trace, so thememory.*spans it emits nest into one end-to-endagent → memorytrace instead of standing alone. Flushes on exit for short-lived CLI processes.app/agentic.py): commit/retrieve/update/delete and embedding are wrapped inmemory.write/memory.read/memory.embedINTERNAL spans carryingmemory.operation,memory.query.length,memory.input.size_bytes,memory.top_similarity,memory.results.count. PII-safe: emits query length, not query text (toggleable via config).observabilityextra (pyproject.toml): hard-depends onopentelemetry-apionly (no-op without an SDK); the SDK + OTLP gRPC exporter live in the[observability]extra.scripts/):otel_e2e_crewai_agent.pydrives a real CrewAI crew (LLM = Ollama on Mac Studio) whose memory read/write goes through the instrumented memU; plusotel_e2e_memory_server.py,otel_e2e_trace_render.py,otel_validation_run.py, andotel_validate_*.sh.README-otel-e2e-crewai.mddocuments the pinned environment and the resulting span tree.Resulting trace (two services, one

trace_id):🤔 Why is this change needed?
memucalls landed as disconnected single-op traces.agent → memorylatency, recall quality, and embedding cost observable in one span tree, with memory-specific semantic attributes that let a backend (e.g. Dynatrace/Grail) reason about memory behavior specifically.🔍 Type of Change
✅ PR Quality Checklist
feat(observability): …)scripts/README-otel-e2e-crewai.md)opentelemetry-apias a hard dep and an opt-inobservabilityextra)📌 Optional
Verification
pytest tests/test_observability.py→ 31 passed (context extraction/injection, search span joining the inbound trace, CLI SERVER span under an agent trace, no-op-when-disabled).scripts/otel_e2e_crewai_agent.pyagainst Ollama (llama4:scout) + memU → onetrace_idacross both services, confirmed in Dynatrace via DQL.