Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Update otel chat client / embedding generator for 1.29 draft of the spec #5712

Merged
merged 6 commits into from
Dec 4, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,10 @@ namespace Microsoft.Extensions.AI;
public class ChatClientMetadata
{
/// <summary>Initializes a new instance of the <see cref="ChatClientMetadata"/> class.</summary>
/// <param name="providerName">The name of the chat completion provider, if applicable.</param>
/// <param name="providerName">
/// The name of the chat completion provider, if applicable. Where possible, this should map to the
/// appropriate name defined in the OpenTelemetry Semantic Conventions for Generative AI systems.
/// </param>
/// <param name="providerUri">The URL for accessing the chat completion provider, if applicable.</param>
/// <param name="modelId">The ID of the chat completion model used, if applicable.</param>
public ChatClientMetadata(string? providerName = null, Uri? providerUri = null, string? modelId = null)
Expand All @@ -20,12 +23,19 @@ public ChatClientMetadata(string? providerName = null, Uri? providerUri = null,
}

/// <summary>Gets the name of the chat completion provider.</summary>
/// <remarks>
/// Where possible, this maps to the appropriate name defined in the
/// OpenTelemetry Semantic Conventions for Generative AI systems.
/// </remarks>
public string? ProviderName { get; }

/// <summary>Gets the URL for accessing the chat completion provider.</summary>
public Uri? ProviderUri { get; }

/// <summary>Gets the ID of the model used by this chat completion provider.</summary>
/// <remarks>This value can be null if either the name is unknown or there are multiple possible models associated with this instance.</remarks>
/// <remarks>
/// This value can be null if either the name is unknown or there are multiple possible models associated with this instance.
/// An individual request may override this value via <see cref="ChatOptions.ModelId"/>.
/// </remarks>
public string? ModelId { get; }
}
Original file line number Diff line number Diff line change
Expand Up @@ -136,17 +136,33 @@ private static void AddMessagesToCompletion(Dictionary<int, ChatMessage> message
{
if (messages.Count <= 1)
{
// Add the single message if there is one.
foreach (var entry in messages)
{
AddMessage(completion, coalesceContent, entry);
}

// In the vast majority case where there's only one choice, promote any additional properties
// from the single message to the chat completion, making them more discoverable and more similar
// to how they're typically surfaced from non-streaming services.
if (completion.Choices.Count == 1 &&
completion.Choices[0].AdditionalProperties is { } messageProps)
{
completion.Choices[0].AdditionalProperties = null;
completion.AdditionalProperties = messageProps;
}
stephentoub marked this conversation as resolved.
Show resolved Hide resolved
}
else
{
// Add all of the messages, sorted by choice index.
foreach (var entry in messages.OrderBy(entry => entry.Key))
{
AddMessage(completion, coalesceContent, entry);
}

// If there are multiple choices, we don't promote additional properties from the individual messages.
// At a minimum, we'd want to know which choice the additional properties applied to, and if there were
// conflicting values across the choices, it would be unclear which one should be used.
}

static void AddMessage(ChatCompletion completion, bool coalesceContent, KeyValuePair<int, ChatMessage> entry)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,11 @@ namespace Microsoft.Extensions.AI;
public class EmbeddingGeneratorMetadata
{
/// <summary>Initializes a new instance of the <see cref="EmbeddingGeneratorMetadata"/> class.</summary>
/// <param name="providerName">The name of the embedding generation provider, if applicable.</param>

/// <param name="providerName">
/// The name of the embedding generation provider, if applicable. Where possible, this should map to the
/// appropriate name defined in the OpenTelemetry Semantic Conventions for Generative AI systems.
/// </param>
/// <param name="providerUri">The URL for accessing the embedding generation provider, if applicable.</param>
/// <param name="modelId">The ID of the embedding generation model used, if applicable.</param>
/// <param name="dimensions">The number of dimensions in vectors produced by this generator, if applicable.</param>
Expand All @@ -22,15 +26,26 @@ public EmbeddingGeneratorMetadata(string? providerName = null, Uri? providerUri
}

/// <summary>Gets the name of the embedding generation provider.</summary>
/// <remarks>
/// Where possible, this maps to the appropriate name defined in the
/// OpenTelemetry Semantic Conventions for Generative AI systems.
/// </remarks>
public string? ProviderName { get; }

/// <summary>Gets the URL for accessing the embedding generation provider.</summary>
public Uri? ProviderUri { get; }

/// <summary>Gets the ID of the model used by this embedding generation provider.</summary>
/// <remarks>This value can be null if either the name is unknown or there are multiple possible models associated with this instance.</remarks>
/// <remarks>
/// This value can be null if either the name is unknown or there are multiple possible models associated with this instance.
/// An individual request may override this value via <see cref="EmbeddingGenerationOptions.ModelId"/>.
/// </remarks>
public string? ModelId { get; }

/// <summary>Gets the number of dimensions in the embeddings produced by this instance.</summary>
/// <remarks>
/// This value can be null if either the number of dimensions is unknown or there are multiple possible lengths associated with this instance.
/// An individual request may override this value via <see cref="EmbeddingGenerationOptions.Dimensions"/>.
/// </remarks>
public int? Dimensions { get; }
}
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@
<PropertyGroup>
<Stage>preview</Stage>
<SuppressFinalPackageVersion>true</SuppressFinalPackageVersion>
<MinCodeCoverage>83</MinCodeCoverage>
<MinCodeCoverage>84</MinCodeCoverage>
<MinMutationScore>0</MinMutationScore>
</PropertyGroup>

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@
<PropertyGroup>
<Stage>preview</Stage>
<SuppressFinalPackageVersion>true</SuppressFinalPackageVersion>
<MinCodeCoverage>83</MinCodeCoverage>
<MinCodeCoverage>91</MinCodeCoverage>
<MinMutationScore>0</MinMutationScore>
</PropertyGroup>

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,7 @@ namespace Microsoft.Extensions.AI;

/// <summary>Represents a delegating chat client that implements the OpenTelemetry Semantic Conventions for Generative AI systems.</summary>
/// <remarks>
/// The draft specification this follows is available at <see href="https://opentelemetry.io/docs/specs/semconv/gen-ai/" />.
/// This class provides an implementation of the Semantic Conventions for Generative AI systems v1.29, defined at <see href="https://opentelemetry.io/docs/specs/semconv/gen-ai/" />.
/// The specification is still experimental and subject to change; as such, the telemetry output by this client is also subject to change.
/// </remarks>
public sealed partial class OpenTelemetryChatClient : DelegatingChatClient
Expand Down Expand Up @@ -288,6 +288,19 @@ public override async IAsyncEnumerable<StreamingChatCompletionUpdate> CompleteSt
{
_ = activity.AddTag(OpenTelemetryConsts.GenAI.Request.PerProvider(_system, "seed"), seed);
}

if (options.AdditionalProperties is { } props)
{
// Log all additional request options as per-provider tags. This is non-normative, but it covers cases where
// there's a per-provider specification in a best-effort manner (e.g. gen_ai.openai.request.service_tier),
// and more generally cases where there's additional useful information to be logged.
foreach (KeyValuePair<string, object?> prop in props)
{
_ = activity.AddTag(
OpenTelemetryConsts.GenAI.Request.PerProvider(_system, JsonNamingPolicy.SnakeCaseLower.ConvertName(prop.Key)),
prop.Value);
}
}
}
}
}
Expand Down Expand Up @@ -375,6 +388,22 @@ private void TraceCompletion(
{
_ = activity.AddTag(OpenTelemetryConsts.GenAI.Response.OutputTokens, outputTokens);
}

if (_system is not null)
{
// Log all additional response properties as per-provider tags. This is non-normative, but it covers cases where
// there's a per-provider specification in a best-effort manner (e.g. gen_ai.openai.response.system_fingerprint),
// and more generally cases where there's additional useful information to be logged.
if (completion.AdditionalProperties is { } props)
{
foreach (KeyValuePair<string, object?> prop in props)
{
_ = activity.AddTag(
OpenTelemetryConsts.GenAI.Response.PerProvider(_system, JsonNamingPolicy.SnakeCaseLower.ConvertName(prop.Key)),
prop.Value);
}
}
}
}
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
using System.Diagnostics;
using System.Diagnostics.Metrics;
using System.Linq;
using System.Text.Json;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.Extensions.Logging;
Expand All @@ -15,8 +16,8 @@ namespace Microsoft.Extensions.AI;

/// <summary>Represents a delegating embedding generator that implements the OpenTelemetry Semantic Conventions for Generative AI systems.</summary>
/// <remarks>
/// The draft specification this follows is available at <see href="https://opentelemetry.io/docs/specs/semconv/gen-ai/" />.
/// The specification is still experimental and subject to change; as such, the telemetry output by this generator is also subject to change.
/// This class provides an implementation of the Semantic Conventions for Generative AI systems v1.29, defined at <see href="https://opentelemetry.io/docs/specs/semconv/gen-ai/" />.
/// The specification is still experimental and subject to change; as such, the telemetry output by this client is also subject to change.
/// </remarks>
/// <typeparam name="TInput">The type of input used to produce embeddings.</typeparam>
/// <typeparam name="TEmbedding">The type of embedding generated.</typeparam>
Expand All @@ -29,6 +30,7 @@ public sealed class OpenTelemetryEmbeddingGenerator<TInput, TEmbedding> : Delega
private readonly Histogram<int> _tokenUsageHistogram;
private readonly Histogram<double> _operationDurationHistogram;

private readonly string? _system;
private readonly string? _modelId;
private readonly string? _modelProvider;
private readonly string? _endpointAddress;
Expand All @@ -49,6 +51,7 @@ public OpenTelemetryEmbeddingGenerator(IEmbeddingGenerator<TInput, TEmbedding> i
Debug.Assert(innerGenerator is not null, "Should have been validated by the base ctor.");

EmbeddingGeneratorMetadata metadata = innerGenerator!.Metadata;
_system = metadata.ProviderName;
_modelId = metadata.ModelId;
_modelProvider = metadata.ProviderName;
_endpointAddress = metadata.ProviderUri?.GetLeftPart(UriPartial.Path);
Expand Down Expand Up @@ -126,11 +129,11 @@ protected override void Dispose(bool disposing)
string? modelId = options?.ModelId ?? _modelId;

activity = _activitySource.StartActivity(
string.IsNullOrWhiteSpace(modelId) ? OpenTelemetryConsts.GenAI.Embed : $"{OpenTelemetryConsts.GenAI.Embed} {modelId}",
string.IsNullOrWhiteSpace(modelId) ? OpenTelemetryConsts.GenAI.Embeddings : $"{OpenTelemetryConsts.GenAI.Embeddings} {modelId}",
ActivityKind.Client,
default(ActivityContext),
[
new(OpenTelemetryConsts.GenAI.Operation.Name, OpenTelemetryConsts.GenAI.Embed),
new(OpenTelemetryConsts.GenAI.Operation.Name, OpenTelemetryConsts.GenAI.Embeddings),
new(OpenTelemetryConsts.GenAI.Request.Model, modelId),
new(OpenTelemetryConsts.GenAI.SystemName, _modelProvider),
]);
Expand All @@ -148,6 +151,23 @@ protected override void Dispose(bool disposing)
{
_ = activity.AddTag(OpenTelemetryConsts.GenAI.Request.EmbeddingDimensions, dimensions);
}

if (options is not null &&
_system is not null)
{
// Log all additional request options as per-provider tags. This is non-normative, but it covers cases where
// there's a per-provider specification in a best-effort manner (e.g. gen_ai.openai.request.service_tier),
// and more generally cases where there's additional useful information to be logged.
if (options.AdditionalProperties is { } props)
{
foreach (KeyValuePair<string, object?> prop in props)
{
_ = activity.AddTag(
OpenTelemetryConsts.GenAI.Request.PerProvider(_system, JsonNamingPolicy.SnakeCaseLower.ConvertName(prop.Key)),
prop.Value);
}
}
}
}
}

Expand Down Expand Up @@ -212,12 +232,26 @@ private void TraceCompletion(
{
_ = activity.AddTag(OpenTelemetryConsts.GenAI.Response.Model, responseModelId);
}

// Log all additional response properties as per-provider tags. This is non-normative, but it covers cases where
// there's a per-provider specification in a best-effort manner (e.g. gen_ai.openai.response.system_fingerprint),
// and more generally cases where there's additional useful information to be logged.
if (_system is not null &&
embeddings?.AdditionalProperties is { } props)
{
foreach (KeyValuePair<string, object?> prop in props)
{
_ = activity.AddTag(
OpenTelemetryConsts.GenAI.Response.PerProvider(_system, JsonNamingPolicy.SnakeCaseLower.ConvertName(prop.Key)),
prop.Value);
}
}
}
}

private void AddMetricTags(ref TagList tags, string? requestModelId, string? responseModelId)
{
tags.Add(OpenTelemetryConsts.GenAI.Operation.Name, OpenTelemetryConsts.GenAI.Embed);
tags.Add(OpenTelemetryConsts.GenAI.Operation.Name, OpenTelemetryConsts.GenAI.Embeddings);

if (requestModelId is not null)
{
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@
<PropertyGroup>
<Stage>preview</Stage>
<SuppressFinalPackageVersion>true</SuppressFinalPackageVersion>
<MinCodeCoverage>83</MinCodeCoverage>
<MinCodeCoverage>88</MinCodeCoverage>
<MinMutationScore>0</MinMutationScore>
</PropertyGroup>

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,7 @@ public static class GenAI
public const string SystemName = "gen_ai.system";

public const string Chat = "chat";
public const string Embed = "embed";
public const string Embeddings = "embeddings";

public static class Assistant
{
Expand Down Expand Up @@ -81,6 +81,8 @@ public static class Response
public const string InputTokens = "gen_ai.response.input_tokens";
public const string Model = "gen_ai.response.model";
public const string OutputTokens = "gen_ai.response.output_tokens";

public static string PerProvider(string providerName, string parameterName) => $"gen_ai.{providerName}.response.{parameterName}";
}

public static class System
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@ public TestEmbeddingGenerator()
GetServiceCallback = DefaultGetServiceCallback;
}

public EmbeddingGeneratorMetadata Metadata { get; } = new();
public EmbeddingGeneratorMetadata Metadata { get; set; } = new();

public Func<IEnumerable<string>, EmbeddingGenerationOptions?, CancellationToken, Task<GeneratedEmbeddings<Embedding<float>>>>? GenerateAsyncCallback { get; set; }

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -78,6 +78,24 @@ public void DoesNotAllowFactoriesToReturnNull()
Assert.Contains("entry at index 0", ex.Message);
}

[Fact]
public void UsesEmptyServiceProviderWhenNoServicesProvided()
{
using var innerClient = new TestChatClient();
ChatClientBuilder builder = new(innerClient);
builder.Use((innerClient, serviceProvider) =>
{
Assert.Null(serviceProvider.GetService(typeof(object)));

var keyedServiceProvider = Assert.IsAssignableFrom<IKeyedServiceProvider>(serviceProvider);
Assert.Null(keyedServiceProvider.GetKeyedService(typeof(object), "key"));
Assert.Throws<InvalidOperationException>(() => keyedServiceProvider.GetRequiredKeyedService(typeof(object), "key"));

return innerClient;
});
builder.Build();
}

private sealed class InnerClientCapturingChatClient(string name, IChatClient innerClient) : DelegatingChatClient(innerClient)
{
#pragma warning disable S3604 // False positive: Member initializer values should not be redundant
Expand Down
Loading