< Summary

Information
Class: NexusLabs.Needlr.AgentFramework.Langfuse.LangfuseApiClient
Assembly: NexusLabs.Needlr.AgentFramework.Langfuse
File(s): /home/runner/work/needlr/needlr/src/NexusLabs.Needlr.AgentFramework.Langfuse/LangfuseApiClient.cs
Line coverage
95%
Covered lines: 59
Uncovered lines: 3
Coverable lines: 62
Total lines: 168
Line coverage: 95.1%
Branch coverage
88%
Covered branches: 16
Total branches: 18
Branch coverage: 88.8%
Method coverage

Feature is only available for sponsors

Upgrade to PRO version

Metrics

MethodBranch coverage Crap Score Cyclomatic complexity Line coverage
.cctor()100%11100%
.ctor(...)75%44100%
PostAsync()100%11100%
PostAsync()100%11100%
GetAsync()100%11100%
GetOrDefaultAsync()100%22100%
SendAsync()100%11100%
SendAsync()100%8889.47%
ReadAsync()75%4483.33%

File(s)

/home/runner/work/needlr/needlr/src/NexusLabs.Needlr.AgentFramework.Langfuse/LangfuseApiClient.cs

#LineLine coverage
 1using System.Net.Http.Json;
 2using System.Text.Json;
 3using System.Text.Json.Serialization;
 4
 5namespace NexusLabs.Needlr.AgentFramework.Langfuse;
 6
 7/// <summary>
 8/// Minimal typed transport over the Langfuse public REST API (<c>/api/public/*</c>). Backs the
 9/// dataset, experiment, score-config, and comment features. Authenticates with HTTP Basic auth and
 10/// turns non-success responses into <see cref="LangfuseException"/>; per-feature mapping and
 11/// failure policy live in the recorders that compose it.
 12/// </summary>
 13/// <remarks>
 14/// This is deliberately separate from <see cref="LangfuseScoreApiClient"/>, which predates it and
 15/// owns the hot score-ingestion path. The underlying <see cref="HttpClient"/> is owned by the
 16/// caller and disposed with it.
 17/// </remarks>
 18internal sealed class LangfuseApiClient
 19{
 120    private static readonly JsonSerializerOptions SerializerOptions = new()
 121    {
 122        PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
 123        DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull,
 124    };
 25
 26    private readonly HttpClient _httpClient;
 27    private readonly Uri _baseUrl;
 28
 1629    public LangfuseApiClient(HttpClient httpClient, Uri baseUrl, string authorizationHeaderValue)
 30    {
 1631        ArgumentNullException.ThrowIfNull(httpClient);
 1632        ArgumentNullException.ThrowIfNull(baseUrl);
 1633        ArgumentException.ThrowIfNullOrWhiteSpace(authorizationHeaderValue);
 34
 1635        _httpClient = httpClient;
 1636        _baseUrl = baseUrl;
 37
 1638        if (_httpClient.DefaultRequestHeaders.Authorization is null)
 39        {
 1540            var space = authorizationHeaderValue.IndexOf(' ');
 1541            _httpClient.DefaultRequestHeaders.Authorization = space > 0
 1542                ? new System.Net.Http.Headers.AuthenticationHeaderValue(
 1543                    authorizationHeaderValue[..space],
 1544                    authorizationHeaderValue[(space + 1)..])
 1545                : new System.Net.Http.Headers.AuthenticationHeaderValue(authorizationHeaderValue);
 46        }
 1647    }
 48
 49    /// <summary>Sends a POST and ignores the response body.</summary>
 50    /// <typeparam name="TRequest">The request payload type.</typeparam>
 51    /// <param name="relativePath">The API path relative to the base URL (no leading slash).</param>
 52    /// <param name="payload">The request payload, serialized as camelCase JSON.</param>
 53    /// <param name="cancellationToken">A cancellation token.</param>
 54    /// <exception cref="LangfuseException">The request failed or returned a non-success status.</exception>
 55    public async Task PostAsync<TRequest>(string relativePath, TRequest payload, CancellationToken cancellationToken)
 56    {
 1257        using var response = await SendAsync(HttpMethod.Post, relativePath, payload, cancellationToken)
 1258            .ConfigureAwait(false);
 959    }
 60
 61    /// <summary>Sends a POST and deserializes the response body.</summary>
 62    /// <typeparam name="TRequest">The request payload type.</typeparam>
 63    /// <typeparam name="TResponse">The response type.</typeparam>
 64    /// <param name="relativePath">The API path relative to the base URL (no leading slash).</param>
 65    /// <param name="payload">The request payload, serialized as camelCase JSON.</param>
 66    /// <param name="cancellationToken">A cancellation token.</param>
 67    /// <returns>The deserialized response, or <see langword="null"/> when the body is empty.</returns>
 68    /// <exception cref="LangfuseException">The request failed or returned a non-success status.</exception>
 69    public async Task<TResponse?> PostAsync<TRequest, TResponse>(string relativePath, TRequest payload, CancellationToke
 70    {
 171        using var response = await SendAsync(HttpMethod.Post, relativePath, payload, cancellationToken)
 172            .ConfigureAwait(false);
 173        return await ReadAsync<TResponse>(response, cancellationToken).ConfigureAwait(false);
 174    }
 75
 76    /// <summary>Sends a GET and deserializes the response body.</summary>
 77    /// <typeparam name="TResponse">The response type.</typeparam>
 78    /// <param name="relativePath">The API path relative to the base URL (no leading slash).</param>
 79    /// <param name="cancellationToken">A cancellation token.</param>
 80    /// <returns>The deserialized response, or <see langword="null"/> when the body is empty.</returns>
 81    /// <exception cref="LangfuseException">The request failed or returned a non-success status.</exception>
 82    public async Task<TResponse?> GetAsync<TResponse>(string relativePath, CancellationToken cancellationToken)
 83    {
 784        using var response = await SendAsync<object?>(HttpMethod.Get, relativePath, payload: null, allowNotFound: false,
 785            .ConfigureAwait(false);
 786        return await ReadAsync<TResponse>(response, cancellationToken).ConfigureAwait(false);
 787    }
 88
 89    /// <summary>
 90    /// Sends a GET and deserializes the body, returning <see langword="null"/> on <c>404 Not
 91    /// Found</c> instead of throwing. Used for existence checks (for example "does this dataset
 92    /// already exist?").
 93    /// </summary>
 94    /// <typeparam name="TResponse">The response type.</typeparam>
 95    /// <param name="relativePath">The API path relative to the base URL (no leading slash).</param>
 96    /// <param name="cancellationToken">A cancellation token.</param>
 97    /// <returns>The deserialized response, or <see langword="null"/> when not found or empty.</returns>
 98    /// <exception cref="LangfuseException">The request failed or returned a non-success status other than 404.</excepti
 99    public async Task<TResponse?> GetOrDefaultAsync<TResponse>(string relativePath, CancellationToken cancellationToken)
 100    {
 2101        using var response = await SendAsync<object?>(HttpMethod.Get, relativePath, payload: null, allowNotFound: true, 
 2102            .ConfigureAwait(false);
 103
 2104        return response.StatusCode is System.Net.HttpStatusCode.NotFound
 2105            ? default
 2106            : await ReadAsync<TResponse>(response, cancellationToken).ConfigureAwait(false);
 2107    }
 108
 109    private async Task<HttpResponseMessage> SendAsync<TRequest>(
 110        HttpMethod method,
 111        string relativePath,
 112        TRequest payload,
 113        CancellationToken cancellationToken)
 13114        => await SendAsync(method, relativePath, payload, allowNotFound: false, cancellationToken).ConfigureAwait(false)
 115
 116    private async Task<HttpResponseMessage> SendAsync<TRequest>(
 117        HttpMethod method,
 118        string relativePath,
 119        TRequest payload,
 120        bool allowNotFound,
 121        CancellationToken cancellationToken)
 122    {
 22123        ArgumentException.ThrowIfNullOrWhiteSpace(relativePath);
 124
 22125        var uri = new Uri(_baseUrl, relativePath);
 22126        using var request = new HttpRequestMessage(method, uri);
 22127        if (payload is not null)
 128        {
 13129            request.Content = JsonContent.Create(payload, mediaType: null, SerializerOptions);
 130        }
 131
 132        HttpResponseMessage response;
 133        try
 134        {
 22135            response = await _httpClient.SendAsync(request, cancellationToken).ConfigureAwait(false);
 22136        }
 0137        catch (Exception ex) when (ex is HttpRequestException or TaskCanceledException)
 138        {
 0139            throw new LangfuseException($"Langfuse request {method} '{uri}' failed.", ex);
 140        }
 141
 22142        if (response.IsSuccessStatusCode
 22143            || (allowNotFound && response.StatusCode is System.Net.HttpStatusCode.NotFound))
 144        {
 19145            return response;
 146        }
 147
 3148        var status = (int)response.StatusCode;
 3149        var reason = response.ReasonPhrase;
 3150        var body = await response.Content.ReadAsStringAsync(cancellationToken).ConfigureAwait(false);
 3151        response.Dispose();
 152
 3153        throw new LangfuseException(
 3154            $"Langfuse rejected {method} '{uri}' with status {status} ({reason}): {body}");
 19155    }
 156
 157    private static async Task<TResponse?> ReadAsync<TResponse>(HttpResponseMessage response, CancellationToken cancellat
 158    {
 9159        if (response.Content.Headers.ContentLength is 0)
 160        {
 0161            return default;
 162        }
 163
 9164        return await response.Content
 9165            .ReadFromJsonAsync<TResponse>(SerializerOptions, cancellationToken)
 9166            .ConfigureAwait(false);
 9167    }
 168}