1+ using System . Diagnostics ;
12using System . Net . Http . Json ;
23using System . Text . Json ;
34using System . Text . Json . Serialization ;
45using Microsoft . Extensions . Logging ;
56using Microsoft . Extensions . Options ;
67using AgentMemory . Nams . Domain ;
8+ using AgentMemory . Nams . Observability ;
79
810namespace 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}
0 commit comments