Skip to content

Commit 25b785a

Browse files
CopilotJamesNK
andauthored
Add support for LangSmith OpenTelemetry genai standard attributes (dotnet#12861)
Co-authored-by: JamesNK <303201+JamesNK@users.noreply.github.com> Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com> Co-authored-by: James Newton-King <james@newtonking.com>
1 parent 4b815da commit 25b785a

File tree

5 files changed

+405
-0
lines changed

5 files changed

+405
-0
lines changed

playground/Stress/Stress.ApiService/Program.cs

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -537,6 +537,45 @@ async IAsyncEnumerable<string> WriteOutput()
537537
return "Created GenAI trace";
538538
});
539539

540+
app.MapGet("/genai-langchain-trace", async () =>
541+
{
542+
var source = new ActivitySource("Services.Api", "1.0.0");
543+
544+
var activity = source.StartActivity("langchain llm call", ActivityKind.Client);
545+
if (activity != null)
546+
{
547+
activity.SetTag("gen_ai.system", "langchain");
548+
activity.SetTag("gen_ai.provider.name", "openai");
549+
activity.SetTag("gen_ai.response.model", "gpt-4");
550+
activity.SetTag("gen_ai.usage.input_tokens", 150);
551+
activity.SetTag("gen_ai.usage.output_tokens", 75);
552+
553+
// LangSmith/LangChain format uses flattened indexed attributes
554+
// Prompt messages
555+
activity.SetTag("gen_ai.prompt.0.role", "system");
556+
activity.SetTag("gen_ai.prompt.0.content", "You are a helpful AI assistant that provides accurate and concise information.");
557+
558+
activity.SetTag("gen_ai.prompt.1.role", "user");
559+
activity.SetTag("gen_ai.prompt.1.content", "What is the capital of France?");
560+
561+
activity.SetTag("gen_ai.prompt.2.role", "assistant");
562+
activity.SetTag("gen_ai.prompt.2.content", "The capital of France is Paris. It is located in the north-central part of the country and is known for its art, culture, and history.");
563+
564+
activity.SetTag("gen_ai.prompt.3.message.role", "user");
565+
activity.SetTag("gen_ai.prompt.3.message.content", "What about Germany?");
566+
567+
activity.SetTag("gen_ai.completion.1.message.role", "assistant");
568+
activity.SetTag("gen_ai.completion.1.message.content", "The capital of Germany is Berlin. It is located in the northeastern part of the country and serves as the political and cultural center.");
569+
}
570+
571+
// Avoid zero seconds span.
572+
await Task.Delay(100);
573+
574+
activity?.Stop();
575+
576+
return "Created LangChain GenAI trace";
577+
});
578+
540579
async Task SimulateWorkAsync(ActivitySource source, int index, int millisecondsDelay = 2)
541580
{
542581
using var activity = source.StartActivity($"WorkIteration{index + 1}");

playground/Stress/Stress.AppHost/Program.cs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -88,6 +88,7 @@
8888
serviceBuilder.WithHttpCommand("/nested-trace-spans", "Out of order nested spans", commandOptions: new() { Method = HttpMethod.Get, IconName = "ContentViewGalleryLightning" });
8989
serviceBuilder.WithHttpCommand("/exemplars-no-span", "Examplars with no span", commandOptions: new() { Method = HttpMethod.Get, IconName = "ContentViewGalleryLightning" });
9090
serviceBuilder.WithHttpCommand("/genai-trace", "Gen AI trace", commandOptions: new() { Method = HttpMethod.Get, IconName = "ContentViewGalleryLightning" });
91+
serviceBuilder.WithHttpCommand("/genai-langchain-trace", "Gen AI LangChain trace", commandOptions: new() { Method = HttpMethod.Get, IconName = "ContentViewGalleryLightning" });
9192
serviceBuilder.WithHttpCommand("/genai-trace-display-error", "Gen AI trace display error", commandOptions: new() { Method = HttpMethod.Get, IconName = "ContentViewGalleryLightning" });
9293
serviceBuilder.WithHttpCommand("/log-formatting", "Log formatting", commandOptions: new() { Method = HttpMethod.Get, IconName = "ContentViewGalleryLightning" });
9394
serviceBuilder.WithHttpCommand("/big-nested-trace", "Big nested trace", commandOptions: new() { Method = HttpMethod.Get, IconName = "ContentViewGalleryLightning" });

src/Aspire.Dashboard/Model/GenAI/GenAIHelpers.cs

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,10 @@ public static class GenAIHelpers
1717
public const string GenAIUsageInputTokens = "gen_ai.usage.input_tokens";
1818
public const string GenAIUsageOutputTokens = "gen_ai.usage.output_tokens";
1919

20+
// LangSmith OpenTelemetry genai standard attributes (flattened format)
21+
public const string GenAIPromptPrefix = "gen_ai.prompt.";
22+
public const string GenAICompletionPrefix = "gen_ai.completion.";
23+
2024
public static bool IsGenAISpan(KeyValuePair<string, string>[] attributes)
2125
{
2226
return attributes.GetValueWithFallback(GenAISystem, GenAIProviderName) is { Length: > 0 };

src/Aspire.Dashboard/Model/GenAI/GenAIVisualizerDialogViewModel.cs

Lines changed: 111 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -232,6 +232,15 @@ private static void CreateMessages(GenAIVisualizerDialogViewModel viewModel, Tel
232232
currentIndex++;
233233
}
234234
}
235+
236+
if (viewModel.Items.Count > 0)
237+
{
238+
return;
239+
}
240+
241+
// Final fallback: attempt to parse LangSmith OpenTelemetry genai standard attributes.
242+
// LangSmith uses a flattened format with indexed attributes like gen_ai.prompt.0.role, gen_ai.prompt.0.content, etc.
243+
ParseLangSmithFormat(viewModel, ref currentIndex);
235244
}
236245

237246
private static int ParseMessages(GenAIVisualizerDialogViewModel viewModel, string messages, string description, bool isOutput, ref int currentIndex)
@@ -254,6 +263,108 @@ private static int ParseMessages(GenAIVisualizerDialogViewModel viewModel, strin
254263
return currentIndex;
255264
}
256265

266+
// Parse LangSmith OpenTelemetry genai standard attributes format.
267+
// LangSmith uses a flattened format with indexed attributes:
268+
// gen_ai.prompt.0.role, gen_ai.prompt.0.content, gen_ai.prompt.1.role, etc.
269+
// gen_ai.completion.0.role, gen_ai.completion.0.content, etc.
270+
private static void ParseLangSmithFormat(GenAIVisualizerDialogViewModel viewModel, ref int currentIndex)
271+
{
272+
var attributes = viewModel.Span.Attributes;
273+
274+
// Group attributes by prefix (prompt or completion) and index
275+
var promptMessages = ExtractIndexedMessages(attributes, GenAIHelpers.GenAIPromptPrefix);
276+
var completionMessages = ExtractIndexedMessages(attributes, GenAIHelpers.GenAICompletionPrefix);
277+
278+
// Parse prompt messages (inputs)
279+
foreach (var (index, message) in promptMessages.OrderBy(kvp => kvp.Key))
280+
{
281+
var role = GetMessageRole(message, defaultRole: "user");
282+
var content = GetMessageContent(message);
283+
284+
if (content != null)
285+
{
286+
var parts = new List<GenAIItemPartViewModel>
287+
{
288+
GenAIItemPartViewModel.CreateMessagePart(new TextPart { Content = content })
289+
};
290+
291+
var type = role switch
292+
{
293+
"system" => GenAIItemType.SystemMessage,
294+
"user" => GenAIItemType.UserMessage,
295+
"assistant" => GenAIItemType.AssistantMessage,
296+
"tool" => GenAIItemType.ToolMessage,
297+
_ => GenAIItemType.UserMessage
298+
};
299+
300+
viewModel.Items.Add(CreateMessage(viewModel, currentIndex, type, parts, internalId: null));
301+
currentIndex++;
302+
}
303+
}
304+
305+
// Parse completion messages (outputs)
306+
foreach (var (index, message) in completionMessages.OrderBy(kvp => kvp.Key))
307+
{
308+
var role = GetMessageRole(message, defaultRole: "assistant");
309+
var content = GetMessageContent(message);
310+
311+
if (content != null)
312+
{
313+
var parts = new List<GenAIItemPartViewModel>
314+
{
315+
GenAIItemPartViewModel.CreateMessagePart(new TextPart { Content = content })
316+
};
317+
318+
viewModel.Items.Add(CreateMessage(viewModel, currentIndex, GenAIItemType.OutputMessage, parts, internalId: null));
319+
currentIndex++;
320+
}
321+
}
322+
323+
// Extract role from message dictionary with fallback to message.role and default
324+
static string GetMessageRole(Dictionary<string, string> message, string defaultRole)
325+
{
326+
return message.TryGetValue("role", out var r) ? r : message.GetValueOrDefault("message.role", defaultRole);
327+
}
328+
329+
// Extract content from message dictionary with fallback to message.content
330+
static string? GetMessageContent(Dictionary<string, string> message)
331+
{
332+
return message.TryGetValue("content", out var c) ? c : message.GetValueOrDefault("message.content");
333+
}
334+
}
335+
336+
// Extract messages from indexed span attributes like gen_ai.prompt.0.role, gen_ai.prompt.0.content
337+
private static Dictionary<int, Dictionary<string, string>> ExtractIndexedMessages(KeyValuePair<string, string>[] attributes, string prefix)
338+
{
339+
var messages = new Dictionary<int, Dictionary<string, string>>();
340+
341+
foreach (var attr in attributes)
342+
{
343+
if (attr.Key.StartsWith(prefix, StringComparison.Ordinal))
344+
{
345+
// Extract index and field name from attribute key
346+
// Format: gen_ai.prompt.{index}.{field}
347+
var remainder = attr.Key.AsSpan(prefix.Length);
348+
var dotIndex = remainder.IndexOf('.');
349+
350+
if (dotIndex > 0 && int.TryParse(remainder.Slice(0, dotIndex), out var messageIndex))
351+
{
352+
var fieldName = remainder.Slice(dotIndex + 1).ToString();
353+
354+
if (!messages.TryGetValue(messageIndex, out var message))
355+
{
356+
message = new Dictionary<string, string>();
357+
messages[messageIndex] = message;
358+
}
359+
360+
message[fieldName] = attr.Value;
361+
}
362+
}
363+
}
364+
365+
return messages;
366+
}
367+
257368
private static GenAIItemViewModel CreateMessage(GenAIVisualizerDialogViewModel viewModel, int currentIndex, GenAIItemType type, List<GenAIItemPartViewModel> parts, long? internalId)
258369
{
259370
return new GenAIItemViewModel

0 commit comments

Comments
 (0)