< Summary

Information
Class: NexusLabs.Needlr.AgentFramework.Langfuse.LangfuseScoreRecorder
Assembly: NexusLabs.Needlr.AgentFramework.Langfuse
File(s): /home/runner/work/needlr/needlr/src/NexusLabs.Needlr.AgentFramework.Langfuse/LangfuseScoreRecorder.cs
Line coverage
90%
Covered lines: 57
Uncovered lines: 6
Coverable lines: 63
Total lines: 160
Line coverage: 90.4%
Branch coverage
96%
Covered branches: 27
Total branches: 28
Branch coverage: 96.4%
Method coverage

Feature is only available for sponsors

Upgrade to PRO version

Metrics

MethodBranch coverage Crap Score Cyclomatic complexity Line coverage
.ctor(...)100%11100%
RecordNumericAsync(...)100%11100%
RecordBooleanAsync(...)100%11100%
RecordCategoricalAsync(...)100%11100%
RecordNumericAsync(...)100%11100%
RecordBooleanAsync(...)50%22100%
RecordCategoricalAsync(...)100%11100%
RecordEvaluationAsync()100%1616100%
SendAsync()100%11100%
RecordSkippedAsync(...)100%210%
RecordSkippedAsync(...)100%1180%
NormalizeName(...)100%22100%
ToSnakeCase(...)100%88100%

File(s)

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

#LineLine coverage
 1using System.Text;
 2
 3using Microsoft.Extensions.AI.Evaluation;
 4
 5namespace NexusLabs.Needlr.AgentFramework.Langfuse;
 6
 7/// <summary>
 8/// Maps typed scores and <see cref="EvaluationResult"/> metrics to Langfuse score payloads, posts
 9/// them via <see cref="LangfuseScoreApiClient"/>, and routes failures through a
 10/// <see cref="LangfuseScoreFailureSink"/>. Shared by <see cref="LangfuseScenario"/> (session path)
 11/// and <see cref="LangfuseScoreClient"/> (host path) so the mapping lives in one place.
 12/// </summary>
 13internal sealed class LangfuseScoreRecorder
 14{
 15    private const string NumericDataType = "NUMERIC";
 16    private const string BooleanDataType = "BOOLEAN";
 17    private const string CategoricalDataType = "CATEGORICAL";
 18
 19    private readonly LangfuseScoreApiClient _apiClient;
 20    private readonly LangfuseScoreFailureSink _failureSink;
 21    private readonly bool _normalizeNames;
 22
 1623    public LangfuseScoreRecorder(
 1624        LangfuseScoreApiClient apiClient,
 1625        LangfuseScoreFailureSink failureSink,
 1626        bool normalizeNames)
 27    {
 1628        ArgumentNullException.ThrowIfNull(apiClient);
 1629        ArgumentNullException.ThrowIfNull(failureSink);
 30
 1631        _apiClient = apiClient;
 1632        _failureSink = failureSink;
 1633        _normalizeNames = normalizeNames;
 1634    }
 35
 36    public Task RecordNumericAsync(string traceId, string name, double value, string? comment, CancellationToken cancell
 637        RecordNumericAsync(LangfuseScoreTarget.Trace(traceId), name, value, comment, cancellationToken);
 38
 39    public Task RecordBooleanAsync(string traceId, string name, bool value, string? comment, CancellationToken cancellat
 140        RecordBooleanAsync(LangfuseScoreTarget.Trace(traceId), name, value, comment, cancellationToken);
 41
 42    public Task RecordCategoricalAsync(string traceId, string name, string value, string? comment, CancellationToken can
 143        RecordCategoricalAsync(LangfuseScoreTarget.Trace(traceId), name, value, comment, cancellationToken);
 44
 45    public Task RecordNumericAsync(LangfuseScoreTarget target, string name, double value, string? comment, CancellationT
 746        SendAsync(target, name, value, NumericDataType, comment, cancellationToken);
 47
 48    public Task RecordBooleanAsync(LangfuseScoreTarget target, string name, bool value, string? comment, CancellationTok
 349        SendAsync(target, name, value ? 1.0 : 0.0, BooleanDataType, comment, cancellationToken);
 50
 51    public Task RecordCategoricalAsync(LangfuseScoreTarget target, string name, string value, string? comment, Cancellat
 52    {
 153        ArgumentNullException.ThrowIfNull(value);
 154        return SendAsync(target, name, value, CategoricalDataType, comment, cancellationToken);
 55    }
 56
 57    public async Task RecordEvaluationAsync(string traceId, EvaluationResult result, CancellationToken cancellationToken
 58    {
 159        ArgumentNullException.ThrowIfNull(result);
 60
 1061        foreach (var metric in result.Metrics.Values)
 62        {
 63            switch (metric)
 64            {
 65                case NumericMetric { Value: { } numeric }:
 166                    await RecordNumericAsync(traceId, metric.Name, numeric, metric.Reason, cancellationToken).ConfigureA
 167                    break;
 68                case BooleanMetric { Value: { } boolean }:
 169                    await RecordBooleanAsync(traceId, metric.Name, boolean, metric.Reason, cancellationToken).ConfigureA
 170                    break;
 71                case StringMetric { Value: { Length: > 0 } category }:
 172                    await RecordCategoricalAsync(traceId, metric.Name, category, metric.Reason, cancellationToken).Confi
 73                    break;
 74            }
 75        }
 176    }
 77
 78    private async Task SendAsync(LangfuseScoreTarget target, string name, object value, string dataType, string? comment
 79    {
 1180        ArgumentException.ThrowIfNullOrWhiteSpace(name);
 81
 1182        var score = new LangfuseScore
 1183        {
 1184            TraceId = target.TraceId,
 1185            ObservationId = target.ObservationId,
 1186            SessionId = target.SessionId,
 1187            Name = NormalizeName(name),
 1188            Value = value,
 1189            DataType = dataType,
 1190            Comment = comment,
 1191        };
 92
 93        try
 94        {
 1195            await _apiClient.CreateAsync(score, cancellationToken).ConfigureAwait(false);
 996        }
 297        catch (LangfuseException ex)
 98        {
 299            _failureSink.Record(name, target.TraceId, ex);
 1100        }
 10101    }
 102
 103    /// <summary>
 104    /// Records a score that could not be attached because no sampled trace was available (for
 105    /// example, head sampling dropped the scenario span). Routed through the failure sink so it is
 106    /// surfaced rather than silently lost.
 107    /// </summary>
 108    public Task RecordSkippedAsync(string name, CancellationToken cancellationToken) =>
 0109        RecordSkippedAsync(
 0110            name,
 0111            $"Cannot record score '{name}': no sampled trace was available to attach it to. " +
 0112            "Ensure the Langfuse session is enabled and sampling is not dropping the scenario span.",
 0113            cancellationToken);
 114
 115    /// <summary>
 116    /// Records a score that was skipped for a specific reason, routed through the failure sink so it
 117    /// is surfaced rather than silently lost.
 118    /// </summary>
 119    /// <param name="name">The score name.</param>
 120    /// <param name="message">The reason the score was skipped.</param>
 121    /// <param name="cancellationToken">A cancellation token.</param>
 122    public Task RecordSkippedAsync(string name, string message, CancellationToken cancellationToken)
 123    {
 1124        var failure = new LangfuseException(message);
 125
 126        try
 127        {
 1128            _failureSink.Record(name, null, failure);
 1129            return Task.CompletedTask;
 130        }
 131        catch (LangfuseException ex)
 132        {
 0133            return Task.FromException(ex);
 134        }
 1135    }
 136
 11137    private string NormalizeName(string name) => _normalizeNames ? ToSnakeCase(name) : name;
 138
 139    private static string ToSnakeCase(string name)
 140    {
 1141        var builder = new StringBuilder(name.Length);
 1142        var previousWasUnderscore = false;
 143
 50144        foreach (var ch in name.Trim())
 145        {
 24146            if (char.IsLetterOrDigit(ch))
 147            {
 21148                builder.Append(char.ToLowerInvariant(ch));
 21149                previousWasUnderscore = false;
 150            }
 3151            else if (!previousWasUnderscore && builder.Length > 0)
 152            {
 3153                builder.Append('_');
 3154                previousWasUnderscore = true;
 155            }
 156        }
 157
 1158        return builder.ToString().Trim('_');
 159    }
 160}