Skip to content

Commit 18846c6

Browse files
authored
feat: NAMS Phase 9 -- observability (spans, metrics, health checks) (#138)
Instruments the NAMS backend with spans/metrics matching the engineering plan's own naming (memory.backend.operations/.duration/.failures/.retries/.rate_limited/.unknown_write_outcomes/ .context_items/.context_truncated), using BCL diagnostics directly to keep AgentMemory.Nams at zero sibling references (B9). Adds a framework-agnostic INamsHealthCheck distinguishing Unhealthy from Degraded (rate-limited), backed by a new INamsClient.ListEntitiesAsync -- the one safe, side-effect-free, no-precondition read this client can use as a connectivity probe. Reviewed every log call site in AgentMemory.Nams/AgentMemory.AgentFramework.Nams for secret/PII/content leakage: clean by design, no changes needed.
1 parent 7d15a3a commit 18846c6

24 files changed

Lines changed: 958 additions & 14 deletions
Lines changed: 92 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,92 @@
1+
# NAMS Phase 9 — Observability — Planning and Implementation Plan
2+
3+
**Prepared:** 2026-07-17
4+
**Branch:** `nams/phase9-observability`
5+
**Purpose:** Executes engineering plan Phase 9 ("Observability and operations"). Per §12's recommended
6+
delivery sequence, this comes right after "Live sandbox proof" (today's earlier live validation +
7+
`NamsAgent` sample) and before the optional Phase 7 convergence.
8+
9+
## 1. Design decisions
10+
11+
- **BCL diagnostics directly, no `AgentMemory.Observability` dependency.** That package decorates the
12+
direct backend's Core interfaces (`IMemoryService`, extractors) via `TryAddScoped` wrapping -- none of
13+
which exist on the NAMS path. `AgentMemory.Nams` (B9) stays at zero sibling references by using
14+
`System.Diagnostics.ActivitySource`/`System.Diagnostics.Metrics.Meter` directly -- pure BCL, not an
15+
external package, so B9's boundary rule is untouched.
16+
- **Spans/metrics live at two layers.** `Neo4jNamsClientAdapter` (the actual HTTP-call boundary) emits
17+
`memory.backend.operations`/`.duration`/`.failures`/`.retries`/`.rate_limited`, one per real REST call
18+
(`resolve_conversation`, `get_context`, `store_turn`, `search_entities`, `list_entities`). `.context_items`/
19+
`.context_truncated` are emitted from `NamsRecallService` (it alone knows about budget truncation) and
20+
`.unknown_write_outcomes` from `NamsPersistenceService` (it alone classifies that outcome). All metric
21+
tags are the plan's own low-cardinality set (`backend`, `operation`, `status`, `failure_kind`) -- never an
22+
owner/workspace/conversation ID, memory text, or a prompt.
23+
- **Cancellation is not counted as a failure**, per the plan's own test list -- `Neo4jNamsClientAdapter`
24+
records a distinct `status=cancelled` on the operations counter and never touches the failures counter for
25+
caller-requested cancellation.
26+
- **A new `INamsClient.ListEntitiesAsync`** (`GET /v1/entities`, confirmed live) was added specifically to
27+
give the health check a safe, side-effect-free, no-precondition probe -- `SearchEntitiesAsync` requires a
28+
non-empty query (confirmed live: an empty query returns 400) and `GetContextAsync` requires an existing
29+
conversation, so neither works as a default connectivity probe.
30+
- **Health check is a small, framework-agnostic interface** (`INamsHealthCheck`/`NamsHealthCheckResult`/
31+
`NamsHealthStatus`), not an ASP.NET Core `IHealthCheck` -- avoids a new package dependency for every
32+
consumer; a host that wants the ASP.NET Core shape adapts this thin result itself. Distinguishes
33+
`Unhealthy` (auth/network/timeout/unexpected) from `Degraded` (rate-limited) per the plan's own
34+
requirement ("status distinction between unhealthy and degraded/rate-limited"). Never performs a
35+
destructive write, by construction (only ever calls the new read-only `ListEntitiesAsync`).
36+
- **Retry counting via a callback, not a metrics dependency in `NamsRetryPolicy`.** `ExecuteAsync` gained an
37+
optional `Action? onRetry` parameter, invoked once per actual retry -- keeps the retry policy itself free
38+
of any metrics-type dependency; the adapter supplies the callback.
39+
40+
## 2. Explicitly out of scope
41+
42+
- **Data-governance verification** (conversation/user deletion behavior, export, region/classification
43+
restrictions) -- the plan's own list requires either live testing against real deletion behavior or an
44+
organizational decision neither of which this PR can make alone.
45+
- **`docs/security/production-checklist.md`** -- reviewed, deliberately left untouched. It's entirely
46+
direct-Neo4j-backend-scoped (threat-model TT-01 through TT-11) and NAMS isn't released yet (no preview,
47+
Phase 12 not started) -- grafting a half-formed NAMS item onto it now would be premature.
48+
- **Phase 8 (MCP tools)** and further **Phase 10 test-matrix expansion** -- separate phases, next in this
49+
session's queue.
50+
51+
## 3. Log review (plan's own checklist item)
52+
53+
Reviewed every `_logger.Log*` call site in `AgentMemory.Nams` and `AgentMemory.AgentFramework.Nams`
54+
(`grep` across both packages). None log an API key, recalled memory content, or a raw message body --
55+
only conversation/session/application IDs and static messages. `NamsClientExceptionMapper.Redact()` already
56+
scrubs the configured API key from any exception message before it can reach a log call (a Phase 2
57+
guarantee, unchanged here). No changes needed -- clean by design, not clean by omission.
58+
59+
## 4. New/changed files
60+
61+
- `src/AgentMemory.Nams/Observability/` (new folder): `NamsActivitySource.cs`, `NamsMetrics.cs`,
62+
`NamsMetricTags.cs`, `NamsHealthStatus.cs`, `NamsHealthCheckResult.cs`, `INamsHealthCheck.cs`,
63+
`NamsHealthCheck.cs`.
64+
- `src/AgentMemory.Nams/Client/INamsClient.cs` / `Neo4jNamsClientAdapter.cs` -- new `ListEntitiesAsync`;
65+
`InvokeAsync` instrumented with spans/metrics per operation.
66+
- `src/AgentMemory.Nams/Client/NamsRetryPolicy.cs` -- new optional `onRetry` callback parameter.
67+
- `src/AgentMemory.Nams/Recall/NamsRecallService.cs` -- records `context_items`/`context_truncated`.
68+
- `src/AgentMemory.Nams/Persistence/NamsPersistenceService.cs` -- records `unknown_write_outcomes`.
69+
- `src/AgentMemory.Nams/NamsServiceCollectionExtensions.cs` -- registers `NamsMetrics`, `INamsHealthCheck`.
70+
- Test updates: every existing `INamsClient` test fake gained `ListEntitiesAsync`; every direct
71+
`NamsRecallService`/`NamsPersistenceService`/`Neo4jNamsClientAdapter` construction site gained a
72+
`NamsMetrics` argument.
73+
- New tests: `NamsMetricsTests.cs`, `NamsMetricTagsTests.cs`, `NamsHealthCheckTests.cs`,
74+
`NamsObservabilityTests.cs` (+ `NamsObservabilityCollection.cs`, mirroring the existing
75+
`AgentMemory.Tests.Unit.Observability.ObservabilityCollection` pattern for tests that attach a
76+
process-wide `MeterListener`) -- verifies actual metric recording, not just that construction doesn't
77+
throw: success/duration, failure+failure_kind, rate-limited, retries, cancellation-not-a-failure,
78+
context items/truncation, unknown write outcomes. Plus 2 new `NamsRetryPolicy` tests for the `onRetry`
79+
callback itself.
80+
81+
## 5. Verification
82+
83+
- `dotnet build AgentMemory.slnx -c Release` -- 0 warnings, 0 errors.
84+
- `dotnet test tests/AgentMemory.Tests.Unit` -- full suite green (189 Nams-filtered, ~30 net new).
85+
86+
## 6. Definition of done
87+
88+
- [x] Spans + metrics instrumented at the client/recall/persistence layers.
89+
- [x] Health check built and covered.
90+
- [x] Log review complete, no changes needed.
91+
- [x] Full unit suite green.
92+
- [ ] Self-reviewed, PR opened, CI green, merged to `main`.

src/AgentMemory.Nams/Client/INamsClient.cs

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -34,4 +34,13 @@ Task<IReadOnlyList<NamsEntity>> SearchEntitiesAsync(
3434
string? type,
3535
int limit,
3636
CancellationToken cancellationToken);
37+
38+
/// <summary>
39+
/// Lists entities in the workspace with no query -- confirmed against the live NAMS REST API
40+
/// (<c>GET /v1/entities</c>) as part of Phase 9. Unlike <see cref="SearchEntitiesAsync"/> (which requires
41+
/// a non-empty query -- NAMS returns 400 for an empty one), this needs no precondition and no existing
42+
/// conversation, making it the one safe, side-effect-free read this client can use for a connectivity
43+
/// health probe.
44+
/// </summary>
45+
Task<IReadOnlyList<NamsEntity>> ListEntitiesAsync(int limit, CancellationToken cancellationToken);
3746
}

src/AgentMemory.Nams/Client/NamsRetryPolicy.cs

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -24,12 +24,16 @@ public NamsRetryPolicy(int maxAttempts, TimeSpan baseDelay, ILogger? logger = nu
2424
}
2525

2626
/// <summary><paramref name="requestFactory"/> is invoked once per attempt -- an <see cref="HttpRequestMessage"/>
27-
/// can only be sent once, so each retry needs a fresh instance.</summary>
27+
/// can only be sent once, so each retry needs a fresh instance. <paramref name="onRetry"/> (Phase 9), if
28+
/// supplied, is invoked once per actual retry -- after the transient response/exception is observed, right
29+
/// before the retry delay -- so a caller can record a metric without this policy taking a hard dependency
30+
/// on any metrics type.</summary>
2831
public async Task<HttpResponseMessage> ExecuteAsync(
2932
Func<HttpRequestMessage> requestFactory,
3033
HttpClient httpClient,
3134
bool isIdempotent,
32-
CancellationToken cancellationToken)
35+
CancellationToken cancellationToken,
36+
Action? onRetry = null)
3337
{
3438
if (!isIdempotent)
3539
return await httpClient.SendAsync(requestFactory(), cancellationToken).ConfigureAwait(false);
@@ -47,6 +51,7 @@ public async Task<HttpResponseMessage> ExecuteAsync(
4751
"Transient NAMS response {Status}; retry {Next}/{Max} after {Delay}.",
4852
(int)response.StatusCode, attempt + 1, _maxAttempts, delay);
4953
response.Dispose();
54+
onRetry?.Invoke();
5055
await Task.Delay(delay, cancellationToken).ConfigureAwait(false);
5156
}
5257
catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested)
@@ -58,6 +63,7 @@ public async Task<HttpResponseMessage> ExecuteAsync(
5863
// Network failure, or a per-request timeout (a TaskCanceledException whose token was NOT the
5964
// caller's -- caller cancellation is handled by the guarded catch above).
6065
_logger?.LogDebug(ex, "Transient NAMS failure; retry {Next}/{Max}.", attempt + 1, _maxAttempts);
66+
onRetry?.Invoke();
6167
await Task.Delay(BackoffFor(attempt), cancellationToken).ConfigureAwait(false);
6268
}
6369
}

src/AgentMemory.Nams/Client/Neo4jNamsClientAdapter.cs

Lines changed: 61 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,11 @@
1+
using System.Diagnostics;
12
using System.Net.Http.Json;
23
using System.Text.Json;
34
using System.Text.Json.Serialization;
45
using Microsoft.Extensions.Logging;
56
using Microsoft.Extensions.Options;
67
using AgentMemory.Nams.Domain;
8+
using AgentMemory.Nams.Observability;
79

810
namespace AgentMemory.Nams.Client;
911

@@ -27,25 +29,30 @@ internal sealed class Neo4jNamsClientAdapter : INamsClient
2729
private readonly HttpClient _httpClient;
2830
private readonly NamsRetryPolicy _retryPolicy;
2931
private readonly string? _apiKeyForRedaction;
32+
private readonly NamsMetrics _metrics;
3033

31-
public Neo4jNamsClientAdapter(HttpClient httpClient, IOptions<NamsOptions> options, ILogger<Neo4jNamsClientAdapter> logger)
34+
public Neo4jNamsClientAdapter(
35+
HttpClient httpClient, IOptions<NamsOptions> options, ILogger<Neo4jNamsClientAdapter> logger, NamsMetrics metrics)
3236
{
3337
_httpClient = httpClient;
3438
var namsOptions = options.Value;
3539
_retryPolicy = new NamsRetryPolicy(namsOptions.MaxRetryAttempts, namsOptions.InitialRetryDelay, logger);
3640
_apiKeyForRedaction = namsOptions.ApiKey;
41+
_metrics = metrics;
3742
}
3843

3944
public Task<NamsConversation> CreateConversationAsync(
4045
string? userId, IReadOnlyDictionary<string, string>? metadata, CancellationToken cancellationToken) =>
4146
InvokeAsync(
47+
"resolve_conversation",
4248
() => BuildJsonRequest(HttpMethod.Post, "conversations", new CreateConversationRequestBody(userId, metadata)),
4349
isIdempotent: false,
4450
DeserializeAsync<NamsConversation>,
4551
cancellationToken);
4652

4753
public Task<NamsContext> GetContextAsync(string conversationId, CancellationToken cancellationToken) =>
4854
InvokeAsync(
55+
"get_context",
4956
() => new HttpRequestMessage(HttpMethod.Get, $"conversations/{Uri.EscapeDataString(conversationId)}/context"),
5057
isIdempotent: true,
5158
DeserializeAsync<NamsContext>,
@@ -56,6 +63,7 @@ public async Task<IReadOnlyList<NamsMessage>> AddMessagesAsync(
5663
{
5764
var path = $"conversations/{Uri.EscapeDataString(conversationId)}/messages/bulk";
5865
var response = await InvokeAsync(
66+
"store_turn",
5967
() => BuildJsonRequest(HttpMethod.Post, path, new AddMessagesBulkRequestBody(messages)),
6068
isIdempotent: false,
6169
DeserializeAsync<AddMessagesBatchResponseBody>,
@@ -70,37 +78,82 @@ public async Task<IReadOnlyList<NamsEntity>> SearchEntitiesAsync(
7078
// idempotent for retry purposes (matches the engineering plan's retry matrix, which lists "Search" as
7179
// always-retryable regardless of the verb it happens to use).
7280
var response = await InvokeAsync(
81+
"search_entities",
7382
() => BuildJsonRequest(HttpMethod.Post, "entities/search", new SearchEntitiesRequestBody(query, type, limit)),
7483
isIdempotent: true,
7584
DeserializeAsync<SearchEntitiesResponseBody>,
7685
cancellationToken).ConfigureAwait(false);
7786
return response.Entities;
7887
}
7988

89+
public async Task<IReadOnlyList<NamsEntity>> ListEntitiesAsync(int limit, CancellationToken cancellationToken)
90+
{
91+
var response = await InvokeAsync(
92+
"list_entities",
93+
() => new HttpRequestMessage(HttpMethod.Get, $"entities?limit={limit}"),
94+
isIdempotent: true,
95+
DeserializeAsync<ListEntitiesResponseBody>,
96+
cancellationToken).ConfigureAwait(false);
97+
return response.Entities;
98+
}
99+
80100
private async Task<T> InvokeAsync<T>(
101+
string operationName,
81102
Func<HttpRequestMessage> requestFactory,
82103
bool isIdempotent,
83104
Func<Stream, CancellationToken, Task<T>> deserialize,
84105
CancellationToken cancellationToken)
85106
{
107+
using var activity = NamsActivitySource.Instance.StartActivity($"agentmemory.nams.{operationName}");
108+
var stopwatch = Stopwatch.StartNew();
86109
try
87110
{
88-
using var response = await _retryPolicy.ExecuteAsync(requestFactory, _httpClient, isIdempotent, cancellationToken)
111+
using var response = await _retryPolicy.ExecuteAsync(
112+
requestFactory, _httpClient, isIdempotent, cancellationToken,
113+
onRetry: () => _metrics.BackendRetries.Add(1, NamsMetricTags.Operation(operationName)))
89114
.ConfigureAwait(false);
90115
var result = await NamsClientExceptionMapper.MapResponseAsync(response, deserialize, _apiKeyForRedaction, cancellationToken)
91116
.ConfigureAwait(false);
92-
return result.IsSuccess ? result.Value! : throw NamsClientExceptionMapper.ToException(result);
117+
if (result.IsSuccess)
118+
{
119+
RecordOutcome(operationName, stopwatch.Elapsed, activity, status: "succeeded");
120+
return result.Value!;
121+
}
122+
123+
var failure = NamsClientExceptionMapper.ToException(result);
124+
RecordFailure(operationName, stopwatch.Elapsed, activity, failure.FailureKind);
125+
throw failure;
93126
}
94127
catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested)
95128
{
96-
throw; // caller cancellation -- never wrap
129+
// Cancellation is not counted as a service failure (Phase 9's own test list).
130+
RecordOutcome(operationName, stopwatch.Elapsed, activity, status: "cancelled");
131+
throw;
97132
}
98133
catch (Exception ex) when (ex is HttpRequestException or TaskCanceledException)
99134
{
100-
throw NamsClientExceptionMapper.FromTransportException(ex, _apiKeyForRedaction);
135+
var failure = NamsClientExceptionMapper.FromTransportException(ex, _apiKeyForRedaction);
136+
RecordFailure(operationName, stopwatch.Elapsed, activity, failure.FailureKind);
137+
throw failure;
101138
}
102139
}
103140

141+
private void RecordOutcome(string operationName, TimeSpan elapsed, Activity? activity, string status)
142+
{
143+
_metrics.BackendOperations.Add(1, NamsMetricTags.OperationStatus(operationName, status));
144+
_metrics.BackendDurationMs.Record(elapsed.TotalMilliseconds, NamsMetricTags.Operation(operationName));
145+
activity?.SetStatus(status == "succeeded" || status == "cancelled" ? ActivityStatusCode.Ok : ActivityStatusCode.Error);
146+
}
147+
148+
private void RecordFailure(string operationName, TimeSpan elapsed, Activity? activity, NamsFailureKind failureKind)
149+
{
150+
RecordOutcome(operationName, elapsed, activity, status: "failed");
151+
_metrics.BackendFailures.Add(1, NamsMetricTags.OperationFailureKind(operationName, failureKind));
152+
if (failureKind == NamsFailureKind.RateLimited)
153+
_metrics.BackendRateLimited.Add(1, NamsMetricTags.Operation(operationName));
154+
activity?.SetStatus(ActivityStatusCode.Error, failureKind.ToString());
155+
}
156+
104157
private static HttpRequestMessage BuildJsonRequest<TBody>(HttpMethod method, string relativePath, TBody body) =>
105158
new(method, relativePath) { Content = JsonContent.Create(body, options: JsonOptions) };
106159

@@ -128,4 +181,7 @@ private sealed record SearchEntitiesRequestBody(
128181
private sealed record SearchEntitiesResponseBody(
129182
[property: JsonPropertyName("entities")] IReadOnlyList<NamsEntity> Entities,
130183
[property: JsonPropertyName("searchType")] string? SearchType);
184+
185+
private sealed record ListEntitiesResponseBody(
186+
[property: JsonPropertyName("entities")] IReadOnlyList<NamsEntity> Entities);
131187
}

src/AgentMemory.Nams/NamsServiceCollectionExtensions.cs

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55
using AgentMemory.Nams.Client;
66
using AgentMemory.Nams.Identity;
77
using AgentMemory.Nams.Internal;
8+
using AgentMemory.Nams.Observability;
89
using AgentMemory.Nams.Persistence;
910
using AgentMemory.Nams.Recall;
1011

@@ -47,6 +48,9 @@ public static IServiceCollection AddNamsAgentMemory(
4748
// more than once -- each call just adds another equivalent configure/validate step).
4849
services.TryAddSingleton<NamsBackendDescriptor>();
4950

51+
services.TryAddSingleton<NamsMetrics>();
52+
services.TryAddSingleton<INamsHealthCheck, NamsHealthCheck>();
53+
5054
services.TryAddSingleton<INamsAccessTokenProvider, StaticApiKeyNamsAccessTokenProvider>();
5155
services.TryAddTransient<NamsAuthenticationHandler>();
5256
services.AddHttpClient<INamsClient, Neo4jNamsClientAdapter>((serviceProvider, httpClient) =>
Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
namespace AgentMemory.Nams.Observability;
2+
3+
/// <summary>
4+
/// A lightweight, host-agnostic health probe for the NAMS backend (engineering plan Phase 9). Deliberately
5+
/// not an ASP.NET Core <c>IHealthCheck</c> -- this package takes no dependency on
6+
/// <c>Microsoft.Extensions.Diagnostics.HealthChecks</c>, so a host that wants one adapts this thin result
7+
/// itself. Never performs a destructive write (plan: "no destructive write probe by default").
8+
/// </summary>
9+
public interface INamsHealthCheck
10+
{
11+
Task<NamsHealthCheckResult> CheckAsync(CancellationToken cancellationToken);
12+
}
Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
using System.Diagnostics;
2+
3+
namespace AgentMemory.Nams.Observability;
4+
5+
/// <summary>
6+
/// Centralized <see cref="ActivitySource"/> for distributed tracing across NAMS backend operations
7+
/// (engineering plan Phase 9). Uses the BCL diagnostics APIs directly rather than taking a dependency on
8+
/// <c>AgentMemory.Observability</c> -- that package decorates the direct-Neo4j-backend's Core interfaces
9+
/// (<c>IMemoryService</c>, extractors, etc.), none of which this package can reference (B9: zero sibling
10+
/// <c>AgentMemory.*</c> references).
11+
/// </summary>
12+
public static class NamsActivitySource
13+
{
14+
/// <summary>The name used when registering the activity source with OpenTelemetry.</summary>
15+
public const string Name = "AgentMemory.Nams";
16+
17+
/// <summary>Shared instance for all NAMS backend operation spans.</summary>
18+
public static readonly ActivitySource Instance = new(Name, "1.0.0");
19+
}

0 commit comments

Comments
 (0)