Skip to content

Commit 3935d3b

Browse files
committed
Update to 1.38 of the otel genai standard convention
1 parent c095888 commit 3935d3b

File tree

6 files changed

+267
-17
lines changed

6 files changed

+267
-17
lines changed

src/Libraries/Microsoft.Extensions.AI/ChatCompletion/OpenTelemetryChatClient.cs

Lines changed: 66 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -16,14 +16,16 @@
1616
using Microsoft.Extensions.Logging;
1717
using Microsoft.Shared.Diagnostics;
1818

19+
#pragma warning disable CA1307 // Specify StringComparison for clarity
20+
#pragma warning disable CA1308 // Normalize strings to uppercase
1921
#pragma warning disable SA1111 // Closing parenthesis should be on line of last parameter
2022
#pragma warning disable SA1113 // Comma should be on the same line as previous parameter
2123

2224
namespace Microsoft.Extensions.AI;
2325

2426
/// <summary>Represents a delegating chat client that implements the OpenTelemetry Semantic Conventions for Generative AI systems.</summary>
2527
/// <remarks>
26-
/// This class provides an implementation of the Semantic Conventions for Generative AI systems v1.37, defined at <see href="https://opentelemetry.io/docs/specs/semconv/gen-ai/" />.
28+
/// This class provides an implementation of the Semantic Conventions for Generative AI systems v1.38, defined at <see href="https://opentelemetry.io/docs/specs/semconv/gen-ai/" />.
2729
/// The specification is still experimental and subject to change; as such, the telemetry output by this client is also subject to change.
2830
/// </remarks>
2931
public sealed partial class OpenTelemetryChatClient : DelegatingChatClient
@@ -273,20 +275,33 @@ internal static string SerializeChatMessages(
273275
});
274276
break;
275277

276-
// These are non-standard and are using the "generic" non-text part that provides an extensibility mechanism:
277-
278-
case UriContent uc:
279-
m.Parts.Add(new OtelGenericPart { Type = "image", Content = uc.Uri.ToString() });
278+
case DataContent dc:
279+
m.Parts.Add(new OtelBlobPart
280+
{
281+
Content = dc.Base64Data.ToString(),
282+
MimeType = dc.MediaType,
283+
Modality = DeriveModalityFromMediaType(dc.MediaType),
284+
});
280285
break;
281286

282-
case DataContent dc:
283-
m.Parts.Add(new OtelGenericPart { Type = "image", Content = dc.Uri });
287+
case UriContent uc:
288+
m.Parts.Add(new OtelUriPart
289+
{
290+
Uri = uc.Uri.AbsoluteUri,
291+
MimeType = uc.MediaType,
292+
Modality = DeriveModalityFromMediaType(uc.MediaType),
293+
});
284294
break;
285295

286296
case HostedFileContent fc:
287-
m.Parts.Add(new OtelGenericPart { Type = "file", Content = fc.FileId });
297+
m.Parts.Add(new OtelFilePart
298+
{
299+
FileId = fc.FileId,
300+
});
288301
break;
289302

303+
// These are non-standard and are using the "generic" non-text part that provides an extensibility mechanism:
304+
290305
case HostedVectorStoreContent vsc:
291306
m.Parts.Add(new OtelGenericPart { Type = "vector_store", Content = vsc.VectorStoreId });
292307
break;
@@ -329,6 +344,22 @@ internal static string SerializeChatMessages(
329344
return JsonSerializer.Serialize(output, _defaultOptions.GetTypeInfo(typeof(IList<object>)));
330345
}
331346

347+
private static string? DeriveModalityFromMediaType(string mediaType)
348+
{
349+
int pos = mediaType.IndexOf('/');
350+
if (pos >= 0)
351+
{
352+
ReadOnlySpan<char> topLevel = mediaType.AsSpan(0, pos);
353+
return
354+
topLevel.Equals("image", StringComparison.OrdinalIgnoreCase) ? "image" :
355+
topLevel.Equals("audio", StringComparison.OrdinalIgnoreCase) ? "audio" :
356+
topLevel.Equals("video", StringComparison.OrdinalIgnoreCase) ? "video" :
357+
null;
358+
}
359+
360+
return null;
361+
}
362+
332363
/// <summary>Creates an activity for a chat request, or returns <see langword="null"/> if not enabled.</summary>
333364
private Activity? CreateAndConfigureActivity(ChatOptions? options)
334365
{
@@ -607,6 +638,30 @@ private sealed class OtelGenericPart
607638
public object? Content { get; set; } // should be a string when Type == "text"
608639
}
609640

641+
private sealed class OtelBlobPart
642+
{
643+
public string Type { get; set; } = "blob";
644+
public string? Content { get; set; } // base64-encoded binary data
645+
public string? MimeType { get; set; }
646+
public string? Modality { get; set; }
647+
}
648+
649+
private sealed class OtelUriPart
650+
{
651+
public string Type { get; set; } = "uri";
652+
public string? Uri { get; set; }
653+
public string? MimeType { get; set; }
654+
public string? Modality { get; set; }
655+
}
656+
657+
private sealed class OtelFilePart
658+
{
659+
public string Type { get; set; } = "file";
660+
public string? FileId { get; set; }
661+
public string? MimeType { get; set; }
662+
public string? Modality { get; set; }
663+
}
664+
610665
private sealed class OtelToolCallRequestPart
611666
{
612667
public string Type { get; set; } = "tool_call";
@@ -653,6 +708,9 @@ private static JsonSerializerOptions CreateDefaultOptions()
653708
[JsonSerializable(typeof(IList<object>))]
654709
[JsonSerializable(typeof(OtelMessage))]
655710
[JsonSerializable(typeof(OtelGenericPart))]
711+
[JsonSerializable(typeof(OtelBlobPart))]
712+
[JsonSerializable(typeof(OtelUriPart))]
713+
[JsonSerializable(typeof(OtelFilePart))]
656714
[JsonSerializable(typeof(OtelToolCallRequestPart))]
657715
[JsonSerializable(typeof(OtelToolCallResponsePart))]
658716
[JsonSerializable(typeof(IEnumerable<OtelFunction>))]

src/Libraries/Microsoft.Extensions.AI/ChatCompletion/OpenTelemetryImageGenerator.cs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,7 @@ namespace Microsoft.Extensions.AI;
1919

2020
/// <summary>Represents a delegating image generator that implements the OpenTelemetry Semantic Conventions for Generative AI systems.</summary>
2121
/// <remarks>
22-
/// This class provides an implementation of the Semantic Conventions for Generative AI systems v1.37, defined at <see href="https://opentelemetry.io/docs/specs/semconv/gen-ai/" />.
22+
/// This class provides an implementation of the Semantic Conventions for Generative AI systems v1.38, defined at <see href="https://opentelemetry.io/docs/specs/semconv/gen-ai/" />.
2323
/// The specification is still experimental and subject to change; as such, the telemetry output by this client is also subject to change.
2424
/// </remarks>
2525
[Experimental("MEAI001")]

src/Libraries/Microsoft.Extensions.AI/Embeddings/OpenTelemetryEmbeddingGenerator.cs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,7 @@ namespace Microsoft.Extensions.AI;
1818

1919
/// <summary>Represents a delegating embedding generator that implements the OpenTelemetry Semantic Conventions for Generative AI systems.</summary>
2020
/// <remarks>
21-
/// This class provides an implementation of the Semantic Conventions for Generative AI systems v1.37, defined at <see href="https://opentelemetry.io/docs/specs/semconv/gen-ai/" />.
21+
/// This class provides an implementation of the Semantic Conventions for Generative AI systems v1.38, defined at <see href="https://opentelemetry.io/docs/specs/semconv/gen-ai/" />.
2222
/// The specification is still experimental and subject to change; as such, the telemetry output by this client is also subject to change.
2323
/// </remarks>
2424
/// <typeparam name="TInput">The type of input used to produce embeddings.</typeparam>

src/Libraries/Microsoft.Extensions.AI/SpeechToText/OpenTelemetrySpeechToTextClient.cs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,7 @@ namespace Microsoft.Extensions.AI;
2121

2222
/// <summary>Represents a delegating speech-to-text client that implements the OpenTelemetry Semantic Conventions for Generative AI systems.</summary>
2323
/// <remarks>
24-
/// This class provides an implementation of the Semantic Conventions for Generative AI systems v1.37, defined at <see href="https://opentelemetry.io/docs/specs/semconv/gen-ai/" />.
24+
/// This class provides an implementation of the Semantic Conventions for Generative AI systems v1.38, defined at <see href="https://opentelemetry.io/docs/specs/semconv/gen-ai/" />.
2525
/// The specification is still experimental and subject to change; as such, the telemetry output by this client is also subject to change.
2626
/// </remarks>
2727
[Experimental("MEAI001")]

test/Libraries/Microsoft.Extensions.AI.Tests/ChatCompletion/OpenTelemetryChatClientTests.cs

Lines changed: 186 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -333,6 +333,192 @@ async static IAsyncEnumerable<ChatResponseUpdate> CallbackAsync(
333333
}
334334
}
335335

336+
[Theory]
337+
[InlineData(false)]
338+
[InlineData(true)]
339+
public async Task AllOfficialOtelContentPartTypes_SerializedCorrectly(bool streaming)
340+
{
341+
var sourceName = Guid.NewGuid().ToString();
342+
var activities = new List<Activity>();
343+
using var tracerProvider = OpenTelemetry.Sdk.CreateTracerProviderBuilder()
344+
.AddSource(sourceName)
345+
.AddInMemoryExporter(activities)
346+
.Build();
347+
348+
using var innerClient = new TestChatClient
349+
{
350+
GetResponseAsyncCallback = async (messages, options, cancellationToken) =>
351+
{
352+
await Task.Yield();
353+
return new ChatResponse(new ChatMessage(ChatRole.Assistant,
354+
[
355+
new TextContent("Assistant response text"),
356+
new TextReasoningContent("This is reasoning"),
357+
new FunctionCallContent("call-123", "GetWeather", new Dictionary<string, object?> { ["location"] = "Seattle" }),
358+
new FunctionResultContent("call-123", "72°F and sunny"),
359+
new DataContent(Convert.FromBase64String("aGVsbG8gd29ybGQ="), "image/png"),
360+
new UriContent(new Uri("https://example.com/image.jpg"), "image/jpeg"),
361+
new HostedFileContent("file-abc123"),
362+
]));
363+
},
364+
GetStreamingResponseAsyncCallback = CallbackAsync,
365+
};
366+
367+
async static IAsyncEnumerable<ChatResponseUpdate> CallbackAsync(
368+
IEnumerable<ChatMessage> messages, ChatOptions? options, [EnumeratorCancellation] CancellationToken cancellationToken)
369+
{
370+
await Task.Yield();
371+
yield return new(ChatRole.Assistant, "Assistant response text");
372+
yield return new() { Contents = [new TextReasoningContent("This is reasoning")] };
373+
yield return new() { Contents = [new FunctionCallContent("call-123", "GetWeather", new Dictionary<string, object?> { ["location"] = "Seattle" })] };
374+
yield return new() { Contents = [new FunctionResultContent("call-123", "72°F and sunny")] };
375+
yield return new() { Contents = [new DataContent(Convert.FromBase64String("aGVsbG8gd29ybGQ="), "image/png")] };
376+
yield return new() { Contents = [new UriContent(new Uri("https://example.com/image.jpg"), "image/jpeg")] };
377+
yield return new() { Contents = [new HostedFileContent("file-abc123")] };
378+
}
379+
380+
using var chatClient = innerClient
381+
.AsBuilder()
382+
.UseOpenTelemetry(null, sourceName, configure: instance =>
383+
{
384+
instance.EnableSensitiveData = true;
385+
instance.JsonSerializerOptions = TestJsonSerializerContext.Default.Options;
386+
})
387+
.Build();
388+
389+
List<ChatMessage> messages =
390+
[
391+
new(ChatRole.User,
392+
[
393+
new TextContent("User request text"),
394+
new TextReasoningContent("User reasoning"),
395+
new DataContent(Convert.FromBase64String("ZGF0YSBjb250ZW50"), "audio/mp3"),
396+
new UriContent(new Uri("https://example.com/video.mp4"), "video/mp4"),
397+
new HostedFileContent("file-xyz789"),
398+
]),
399+
new(ChatRole.Assistant, [new FunctionCallContent("call-456", "SearchFiles")]),
400+
new(ChatRole.Tool, [new FunctionResultContent("call-456", "Found 3 files")]),
401+
];
402+
403+
if (streaming)
404+
{
405+
await foreach (var update in chatClient.GetStreamingResponseAsync(messages))
406+
{
407+
await Task.Yield();
408+
}
409+
}
410+
else
411+
{
412+
await chatClient.GetResponseAsync(messages);
413+
}
414+
415+
var activity = Assert.Single(activities);
416+
Assert.NotNull(activity);
417+
418+
var inputMessages = activity.Tags.First(kvp => kvp.Key == "gen_ai.input.messages").Value;
419+
Assert.Equal(ReplaceWhitespace("""
420+
[
421+
{
422+
"role": "user",
423+
"parts": [
424+
{
425+
"type": "text",
426+
"content": "User request text"
427+
},
428+
{
429+
"type": "reasoning",
430+
"content": "User reasoning"
431+
},
432+
{
433+
"type": "blob",
434+
"content": "ZGF0YSBjb250ZW50",
435+
"mime_type": "audio/mp3",
436+
"modality": "audio"
437+
},
438+
{
439+
"type": "uri",
440+
"uri": "https://example.com/video.mp4",
441+
"mime_type": "video/mp4",
442+
"modality": "video"
443+
},
444+
{
445+
"type": "file",
446+
"file_id": "file-xyz789"
447+
}
448+
]
449+
},
450+
{
451+
"role": "assistant",
452+
"parts": [
453+
{
454+
"type": "tool_call",
455+
"id": "call-456",
456+
"name": "SearchFiles"
457+
}
458+
]
459+
},
460+
{
461+
"role": "tool",
462+
"parts": [
463+
{
464+
"type": "tool_call_response",
465+
"id": "call-456",
466+
"response": "Found 3 files"
467+
}
468+
]
469+
}
470+
]
471+
"""), ReplaceWhitespace(inputMessages));
472+
473+
var outputMessages = activity.Tags.First(kvp => kvp.Key == "gen_ai.output.messages").Value;
474+
Assert.Equal(ReplaceWhitespace("""
475+
[
476+
{
477+
"role": "assistant",
478+
"parts": [
479+
{
480+
"type": "text",
481+
"content": "Assistant response text"
482+
},
483+
{
484+
"type": "reasoning",
485+
"content": "This is reasoning"
486+
},
487+
{
488+
"type": "tool_call",
489+
"id": "call-123",
490+
"name": "GetWeather",
491+
"arguments": {
492+
"location": "Seattle"
493+
}
494+
},
495+
{
496+
"type": "tool_call_response",
497+
"id": "call-123",
498+
"response": "72°F and sunny"
499+
},
500+
{
501+
"type": "blob",
502+
"content": "aGVsbG8gd29ybGQ=",
503+
"mime_type": "image/png",
504+
"modality": "image"
505+
},
506+
{
507+
"type": "uri",
508+
"uri": "https://example.com/image.jpg",
509+
"mime_type": "image/jpeg",
510+
"modality": "image"
511+
},
512+
{
513+
"type": "file",
514+
"file_id": "file-abc123"
515+
}
516+
]
517+
}
518+
]
519+
"""), ReplaceWhitespace(outputMessages));
520+
}
521+
336522
[Fact]
337523
public async Task UnknownContentTypes_Ignored()
338524
{

test/Libraries/Microsoft.Extensions.AI.Tests/Image/OpenTelemetryImageGeneratorTests.cs

Lines changed: 12 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -125,8 +125,10 @@ public async Task ExpectedInformationLogged_Async(bool enableSensitiveData)
125125
"content": "This is the input prompt."
126126
},
127127
{
128-
"type": "image",
129-
"content": "http://example/input.png"
128+
"type": "uri",
129+
"uri": "http://example/input.png",
130+
"mime_type": "image/png",
131+
"modality": "image"
130132
}
131133
]
132134
}
@@ -139,12 +141,16 @@ public async Task ExpectedInformationLogged_Async(bool enableSensitiveData)
139141
"role": "assistant",
140142
"parts": [
141143
{
142-
"type": "image",
143-
"content": "http://example/output.png"
144+
"type": "uri",
145+
"uri": "http://example/output.png",
146+
"mime_type": "image/png",
147+
"modality": "image"
144148
},
145149
{
146-
"type": "image",
147-
"content": "data:image/png;base64,AQIDBA=="
150+
"type": "blob",
151+
"content": "AQIDBA==",
152+
"mime_type": "image/png",
153+
"modality": "image"
148154
}
149155
]
150156
}

0 commit comments

Comments
 (0)