Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
79 changes: 79 additions & 0 deletions Anthropic.SDK.Tests/Logging.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,79 @@
using System;
using System.Collections.Generic;
using System.Diagnostics;
using System.Linq;
using System.Text;
using System.Threading.Tasks;
using Anthropic.SDK.Constants;
using Anthropic.SDK.Messaging;

namespace Anthropic.SDK.Tests
{
[TestClass]
public class Logging
{
[TestMethod]
public async Task TestLoggingInterceptor()
{
var client = new AnthropicClient(requestInterceptor: new MyCustomInterceptor());
var messages = new List<Message>();
messages.Add(new Message(RoleType.User, "Write me a sonnet about the Statue of Liberty"));
var parameters = new MessageParameters()
{
Messages = messages,
MaxTokens = 512,
Model = AnthropicModels.Claude4Sonnet,
Stream = false,
Temperature = 1.0m,
};
var res = await client.Messages.GetClaudeMessageAsync(parameters);
Assert.IsNotNull(res.Message.ToString());
}

[TestMethod]
public async Task TestLoggingInterceptorStreaming()
{
var client = new AnthropicClient(requestInterceptor: new MyCustomInterceptor());
var messages = new List<Message>();
messages.Add(new Message(RoleType.User, "Write me a sonnet about the Statue of Liberty"));
var parameters = new MessageParameters()
{
Messages = messages,
MaxTokens = 512,
Model = AnthropicModels.Claude4Sonnet,
Stream = true,
Temperature = 1.0m,
};
var outputs = new List<MessageResponse>();
await foreach (var res in client.Messages.StreamClaudeMessageAsync(parameters))
{
if (res.Delta != null)
{
Debug.Write(res.Delta.Text);
}

outputs.Add(res);
}
}
}

public class MyCustomInterceptor : IRequestInterceptor
{
public async Task<HttpResponseMessage> InvokeAsync(
HttpRequestMessage request,
Func<HttpRequestMessage, CancellationToken, Task<HttpResponseMessage>> next,
CancellationToken cancellationToken = default)
{
// Custom logic before the request
Debug.WriteLine($"Sending request to {request.RequestUri}");

// Execute the request
var response = await next(request, cancellationToken);

// Custom logic after the request
Debug.WriteLine($"Received response: {response.StatusCode}");

return response;
}
}
}
33 changes: 32 additions & 1 deletion Anthropic.SDK/AnthropicClient.cs
Original file line number Diff line number Diff line change
Expand Up @@ -45,7 +45,18 @@
/// </summary>
internal HttpClient HttpClient { get; set; }

/// <summary>
/// Optional request interceptor for custom retry logic, logging, or other cross-cutting concerns.
/// </summary>
private readonly IRequestInterceptor? _requestInterceptor;

/// <summary>

Check warning on line 53 in Anthropic.SDK/AnthropicClient.cs

View workflow job for this annotation

GitHub Actions / build

The annotation for nullable reference types should only be used in code within a '#nullable' annotations context.
/// Gets the request interceptor if one was provided.
/// Used internally by endpoints to intercept HTTP requests.
/// </summary>
internal IRequestInterceptor? RequestInterceptor => _requestInterceptor;

/// <summary>

Check warning on line 59 in Anthropic.SDK/AnthropicClient.cs

View workflow job for this annotation

GitHub Actions / build

The annotation for nullable reference types should only be used in code within a '#nullable' annotations context.
/// Creates a new entry point to the Anthropic API, handling auth and allowing access to the various API endpoints
/// </summary>
/// <param name="apiKeys">
Expand All @@ -54,15 +65,35 @@
/// potentially loading from environment vars.
/// </param>
/// <param name="client">A <see cref="HttpClient"/>.</param>
/// <param name="requestInterceptor">
/// Optional request interceptor for advanced scenarios like custom retry logic, logging, or auditing.
/// Most users don't need this - the SDK works perfectly without it.
/// Only provide this if you need to intercept and customize HTTP requests before they're sent.
/// </param>
/// <remarks>
/// <para>
/// <see cref="AnthropicClient"/> implements <see cref="IDisposable"/> to manage the lifecycle of the resources it uses, including <see cref="HttpClient"/>.
/// When you initialize <see cref="AnthropicClient"/>, it will create an internal <see cref="HttpClient"/> instance if one is not provided.
/// This internal HttpClient is disposed of when AnthropicClient is disposed of.
/// If you provide an external HttpClient instance to AnthropicClient, you are responsible for managing its disposal.
/// </para>
/// <para>
/// <b>Request Interceptor:</b> This is an advanced feature for scenarios requiring custom request handling.
/// Common use cases include:
/// <list type="bullet">
/// <item>Custom retry logic beyond what HttpClient provides</item>
/// <item>Special logging/tracing requirements</item>
/// <item>Company-specific audit requirements</item>
/// <item>Request/response modification or inspection</item>
/// </list>
/// If you're already handling resilience at the HttpClient level (via Polly, HttpClientFactory policies, etc.),
/// you typically don't need a request interceptor.
/// </para>
/// </remarks>
public AnthropicClient(APIAuthentication apiKeys = null, HttpClient client = null)
public AnthropicClient(APIAuthentication apiKeys = null, HttpClient client = null, IRequestInterceptor requestInterceptor = null)
{
HttpClient = SetupClient(client);
_requestInterceptor = requestInterceptor;
this.Auth = apiKeys.ThisOrDefault();
Messages = new MessagesEndpoint(this);
Batches = new BatchesEndpoint(this);
Expand Down
27 changes: 25 additions & 2 deletions Anthropic.SDK/BaseEndpoint.cs
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,11 @@ public abstract class BaseEndpoint
/// </summary>
protected abstract HttpClient GetClient();

/// <summary>
/// Gets the optional request interceptor for adding custom logic to HTTP requests.
/// </summary>
protected abstract IRequestInterceptor GetRequestInterceptor();

/// <summary>
/// Helper method to read the response content as a string.
/// </summary>
Expand Down Expand Up @@ -152,10 +157,28 @@ protected async Task<HttpResponseMessage> HttpRequestRaw(string url = null, Http
}
}

response = await GetClient().SendAsync(req,
// Use interceptor if provided, otherwise make direct HTTP call
var interceptor = GetRequestInterceptor();
if (interceptor != null)
{
response = await interceptor.InvokeAsync(
req,
(request, ct) => GetClient().SendAsync(
request,
streaming ? HttpCompletionOption.ResponseHeadersRead : HttpCompletionOption.ResponseContentRead,
ct),
ctx)
.ConfigureAwait(false);
}
else
{
// Default path - no interception
response = await GetClient().SendAsync(
req,
streaming ? HttpCompletionOption.ResponseHeadersRead : HttpCompletionOption.ResponseContentRead,
ctx)
.ConfigureAwait(false);
.ConfigureAwait(false);
}

if (response.IsSuccessStatusCode)
{
Expand Down
8 changes: 8 additions & 0 deletions Anthropic.SDK/EndpointBase.cs
Original file line number Diff line number Diff line change
Expand Up @@ -91,6 +91,14 @@ protected override HttpClient GetClient()
return client;
}

/// <summary>
/// Gets the optional request interceptor for adding custom logic to HTTP requests.
/// </summary>
protected override IRequestInterceptor GetRequestInterceptor()
{
return Client.RequestInterceptor;
}

private static void AddHeaderIfNotPresent(HttpRequestHeaders headers, string name, string value)
{
if (!headers.Contains(name))
Expand Down
205 changes: 205 additions & 0 deletions Anthropic.SDK/Examples/LoggingInterceptor.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,205 @@
using System;
using System.Diagnostics;
using System.Net.Http;
using System.Threading;
using System.Threading.Tasks;

namespace Anthropic.SDK.Examples
{
/// <summary>
/// Example implementation of IRequestInterceptor that logs HTTP request and response details.
/// This is a reference implementation showing how to add logging, metrics, and diagnostics.
/// </summary>
/// <remarks>
/// This interceptor logs request/response information for debugging and monitoring purposes.
/// It measures request duration and captures status codes, URLs, and optional request/response bodies.
///
/// Usage:
/// <code>
/// var loggingInterceptor = new LoggingInterceptor(
/// logRequestBody: true,
/// logResponseBody: true
/// );
///
/// var client = new AnthropicClient(
/// apiKeys: new APIAuthentication("your-api-key"),
/// requestInterceptor: loggingInterceptor
/// );
/// </code>
/// </remarks>
public class LoggingInterceptor : IRequestInterceptor
{
private readonly bool _logRequestBody;
private readonly bool _logResponseBody;

/// <summary>
/// Creates a new LoggingInterceptor with the specified logging options.
/// </summary>
/// <param name="logRequestBody">Whether to log request body content (default: false)</param>
/// <param name="logResponseBody">Whether to log response body content (default: false)</param>
public LoggingInterceptor(
bool logRequestBody = false,
bool logResponseBody = false)
{
_logRequestBody = logRequestBody;
_logResponseBody = logResponseBody;
}

/// <summary>
/// Intercepts the HTTP request and logs request/response details.
/// </summary>
public async Task<HttpResponseMessage> InvokeAsync(
HttpRequestMessage request,
Func<HttpRequestMessage, CancellationToken, Task<HttpResponseMessage>> next,
CancellationToken cancellationToken = default)
{
var requestId = Guid.NewGuid().ToString("N").Substring(0, 8);
var stopwatch = Stopwatch.StartNew();

try
{
// Log request
await LogRequestAsync(requestId, request, cancellationToken).ConfigureAwait(false);

// Execute the request
var response = await next(request, cancellationToken).ConfigureAwait(false);

stopwatch.Stop();

// Log response
await LogResponseAsync(requestId, response, stopwatch.ElapsedMilliseconds, cancellationToken).ConfigureAwait(false);

return response;
}
catch (Exception ex)
{
stopwatch.Stop();
LogException(requestId, ex, stopwatch.ElapsedMilliseconds);
throw;
}
}

/// <summary>
/// Logs HTTP request details.
/// </summary>
private async Task LogRequestAsync(string requestId, HttpRequestMessage request, CancellationToken cancellationToken)
{
var logMessage = $"[{requestId}] → {request.Method} {request.RequestUri}";

if (_logRequestBody && request.Content != null)
{
try
{
// Buffer content to memory so it can be read multiple times
await request.Content.LoadIntoBufferAsync().ConfigureAwait(false);

var contentLength = request.Content.Headers.ContentLength ?? 0;

// Only log bodies under 10KB to avoid performance issues
if (contentLength > 0 && contentLength < 10_000)
{
#if NET6_0_OR_GREATER
var content = await request.Content.ReadAsStringAsync(cancellationToken).ConfigureAwait(false);
#else
var content = await request.Content.ReadAsStringAsync().ConfigureAwait(false);
#endif
logMessage += Environment.NewLine + $"Request Body ({contentLength} bytes): {content}";
}
else if (contentLength == 0)
{
logMessage += Environment.NewLine + "Request Body: [Empty]";
}
else if (contentLength >= 10_000)
{
logMessage += Environment.NewLine + $"Request Body: [Too large to log - {contentLength} bytes]";
}
}
catch (Exception ex)
{
logMessage += Environment.NewLine + $"Request Body: [Failed to read - {ex.Message}]";
}
}

LogRequest(requestId, request.Method.Method, request.RequestUri?.ToString(), logMessage);
}

/// <summary>
/// Logs HTTP response details.
/// </summary>
private async Task LogResponseAsync(
string requestId,
HttpResponseMessage response,
long elapsedMs,
CancellationToken cancellationToken)
{
var logMessage = $"[{requestId}] ← {(int)response.StatusCode} {response.ReasonPhrase} ({elapsedMs}ms)";

if (_logResponseBody && response.Content != null)
{
try
{
// Buffer content to memory so it can be read by both logger and caller
await response.Content.LoadIntoBufferAsync().ConfigureAwait(false);

var contentLength = response.Content.Headers.ContentLength ?? 0;

// Only log bodies under 10KB
if (contentLength > 0 && contentLength < 10_000)
{
#if NET6_0_OR_GREATER
var content = await response.Content.ReadAsStringAsync(cancellationToken).ConfigureAwait(false);
#else
var content = await response.Content.ReadAsStringAsync().ConfigureAwait(false);
#endif
logMessage += Environment.NewLine + $"Response Body ({contentLength} bytes): {content}";
}
else if (contentLength == 0)
{
logMessage += Environment.NewLine + "Response Body: [Empty]";
}
else if (contentLength >= 10_000)
{
logMessage += Environment.NewLine + $"Response Body: [Too large to log - {contentLength} bytes]";
}
}
catch (Exception ex)
{
// Don't fail the request if logging fails
logMessage += Environment.NewLine + $"Response Body: [Failed to read - {ex.Message}]";
}
}

LogResponse(requestId, (int)response.StatusCode, response.ReasonPhrase, elapsedMs, logMessage);
}

/// <summary>
/// Logs exceptions that occur during request execution.
/// Override this method to implement custom logging.
/// </summary>
protected virtual void LogException(string requestId, Exception exception, long elapsedMs)
{
// Default: no logging. Override in derived class for custom logging.
// Example: Console.WriteLine($"[{requestId}] ✗ Exception after {elapsedMs}ms: {exception.Message}");
}

/// <summary>
/// Logs outgoing HTTP request.
/// Override this method to implement custom logging.
/// </summary>
protected virtual void LogRequest(string requestId, string method, string url, string fullMessage)
{
// Default: no logging. Override in derived class for custom logging.
// Example: Console.WriteLine(fullMessage);
}

/// <summary>
/// Logs incoming HTTP response.
/// Override this method to implement custom logging.
/// </summary>
protected virtual void LogResponse(string requestId, int statusCode, string reasonPhrase, long elapsedMs, string fullMessage)
{
// Default: no logging. Override in derived class for custom logging.
// Example: Console.WriteLine(fullMessage);
}
}
}
Loading
Loading