| | | 1 | | namespace NexusLabs.Needlr.AgentFramework.Langfuse; |
| | | 2 | | |
| | | 3 | | /// <summary> |
| | | 4 | | /// Posts comments to the Langfuse public Comments API (<c>POST /api/public/comments</c>) via the |
| | | 5 | | /// shared <see cref="LangfuseApiClient"/>. Comment failures are non-fatal — they are routed to the |
| | | 6 | | /// diagnostics callback rather than thrown — because a comment is auxiliary context, never the |
| | | 7 | | /// result of an eval. |
| | | 8 | | /// </summary> |
| | | 9 | | /// <remarks> |
| | | 10 | | /// Langfuse requires an explicit project id on each comment. The API key maps to exactly one |
| | | 11 | | /// project, so the id is resolved once from <c>GET /api/public/projects</c> and cached. |
| | | 12 | | /// </remarks> |
| | | 13 | | internal sealed class LangfuseCommentRecorder |
| | | 14 | | { |
| | | 15 | | private const string TraceObjectType = "TRACE"; |
| | | 16 | | |
| | | 17 | | private readonly LangfuseApiClient _apiClient; |
| | | 18 | | private readonly Action<string>? _diagnostics; |
| | 3 | 19 | | private readonly SemaphoreSlim _projectIdLock = new(1, 1); |
| | | 20 | | private string? _projectId; |
| | | 21 | | |
| | 3 | 22 | | public LangfuseCommentRecorder(LangfuseApiClient apiClient, Action<string>? diagnostics) |
| | | 23 | | { |
| | 3 | 24 | | ArgumentNullException.ThrowIfNull(apiClient); |
| | | 25 | | |
| | 3 | 26 | | _apiClient = apiClient; |
| | 3 | 27 | | _diagnostics = diagnostics; |
| | 3 | 28 | | } |
| | | 29 | | |
| | | 30 | | /// <summary>Attaches a comment to a trace.</summary> |
| | | 31 | | /// <param name="traceId">The trace to comment on.</param> |
| | | 32 | | /// <param name="content">The comment content (Langfuse limits this to 5000 characters).</param> |
| | | 33 | | /// <param name="cancellationToken">A cancellation token.</param> |
| | | 34 | | public async Task AddTraceCommentAsync(string traceId, string content, CancellationToken cancellationToken) |
| | | 35 | | { |
| | 4 | 36 | | ArgumentException.ThrowIfNullOrWhiteSpace(traceId); |
| | 4 | 37 | | ArgumentException.ThrowIfNullOrWhiteSpace(content); |
| | | 38 | | |
| | | 39 | | try |
| | | 40 | | { |
| | 4 | 41 | | var projectId = await ResolveProjectIdAsync(cancellationToken).ConfigureAwait(false); |
| | 4 | 42 | | if (projectId is null) |
| | | 43 | | { |
| | 0 | 44 | | _diagnostics?.Invoke( |
| | 0 | 45 | | "Langfuse comment skipped: could not resolve the project id for the configured API key."); |
| | 0 | 46 | | return; |
| | | 47 | | } |
| | | 48 | | |
| | 4 | 49 | | var request = new LangfuseCommentRequest |
| | 4 | 50 | | { |
| | 4 | 51 | | ProjectId = projectId, |
| | 4 | 52 | | ObjectType = TraceObjectType, |
| | 4 | 53 | | ObjectId = traceId, |
| | 4 | 54 | | Content = content, |
| | 4 | 55 | | }; |
| | | 56 | | |
| | 4 | 57 | | await _apiClient.PostAsync("api/public/comments", request, cancellationToken).ConfigureAwait(false); |
| | 3 | 58 | | } |
| | 1 | 59 | | catch (LangfuseException ex) |
| | | 60 | | { |
| | 1 | 61 | | _diagnostics?.Invoke($"Langfuse comment on trace '{traceId}' failed: {ex.Message}"); |
| | 1 | 62 | | } |
| | 4 | 63 | | } |
| | | 64 | | |
| | | 65 | | private async Task<string?> ResolveProjectIdAsync(CancellationToken cancellationToken) |
| | | 66 | | { |
| | 4 | 67 | | if (_projectId is not null) |
| | | 68 | | { |
| | 1 | 69 | | return _projectId; |
| | | 70 | | } |
| | | 71 | | |
| | 3 | 72 | | await _projectIdLock.WaitAsync(cancellationToken).ConfigureAwait(false); |
| | | 73 | | try |
| | | 74 | | { |
| | 3 | 75 | | if (_projectId is not null) |
| | | 76 | | { |
| | 0 | 77 | | return _projectId; |
| | | 78 | | } |
| | | 79 | | |
| | 3 | 80 | | var response = await _apiClient |
| | 3 | 81 | | .GetAsync<LangfuseProjectsResponse>("api/public/projects", cancellationToken) |
| | 3 | 82 | | .ConfigureAwait(false); |
| | | 83 | | |
| | 3 | 84 | | _projectId = response?.Data is { Count: > 0 } data && !string.IsNullOrWhiteSpace(data[0].Id) |
| | 3 | 85 | | ? data[0].Id |
| | 3 | 86 | | : null; |
| | | 87 | | |
| | 3 | 88 | | return _projectId; |
| | | 89 | | } |
| | | 90 | | finally |
| | | 91 | | { |
| | 3 | 92 | | _projectIdLock.Release(); |
| | | 93 | | } |
| | 4 | 94 | | } |
| | | 95 | | } |