Skip to content

Commit

Permalink
Update otel chat client / embedding generator for 1.29
Browse files Browse the repository at this point in the history
Also address feedback to include additional properties as tags.
  • Loading branch information
stephentoub committed Dec 2, 2024
1 parent 5161cb9 commit 6fabd4a
Show file tree
Hide file tree
Showing 7 changed files with 136 additions and 11 deletions.
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;
}
}
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 @@ -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
4 changes: 3 additions & 1 deletion src/Libraries/Microsoft.Extensions.AI/OpenTelemetryConsts.cs
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 @@ -49,6 +49,11 @@ public async Task ExpectedInformationLogged_Async(bool enableSensitiveData, bool
OutputTokenCount = 20,
TotalTokenCount = 42,
},
AdditionalProperties = new()
{
["system_fingerprint"] = "abcdefgh",
["AndSomethingElse"] = "value2",
},
};
},
CompleteStreamingAsyncCallback = CallbackAsync,
Expand Down Expand Up @@ -83,6 +88,11 @@ async static IAsyncEnumerable<StreamingChatCompletionUpdate> CallbackAsync(
OutputTokenCount = 20,
TotalTokenCount = 42,
})],
AdditionalProperties = new()
{
["system_fingerprint"] = "abcdefgh",
["AndSomethingElse"] = "value2",
},
};
}

Expand Down Expand Up @@ -116,6 +126,11 @@ async static IAsyncEnumerable<StreamingChatCompletionUpdate> CallbackAsync(
ResponseFormat = ChatResponseFormat.Json,
Temperature = 6.0f,
StopSequences = ["hello", "world"],
AdditionalProperties = new()
{
["service_tier"] = "value1",
["SomethingElse"] = "value2",
},
};

if (streaming)
Expand Down Expand Up @@ -149,11 +164,15 @@ async static IAsyncEnumerable<StreamingChatCompletionUpdate> CallbackAsync(
Assert.Equal(7, activity.GetTagItem("gen_ai.request.top_k"));
Assert.Equal(123, activity.GetTagItem("gen_ai.request.max_tokens"));
Assert.Equal("""["hello", "world"]""", activity.GetTagItem("gen_ai.request.stop_sequences"));
Assert.Equal("value1", activity.GetTagItem("gen_ai.testservice.request.service_tier"));
Assert.Equal("value2", activity.GetTagItem("gen_ai.testservice.request.something_else"));

Assert.Equal("id123", activity.GetTagItem("gen_ai.response.id"));
Assert.Equal("""["stop"]""", activity.GetTagItem("gen_ai.response.finish_reasons"));
Assert.Equal(10, activity.GetTagItem("gen_ai.response.input_tokens"));
Assert.Equal(20, activity.GetTagItem("gen_ai.response.output_tokens"));
Assert.Equal("abcdefgh", activity.GetTagItem("gen_ai.testservice.response.system_fingerprint"));
Assert.Equal("value2", activity.GetTagItem("gen_ai.testservice.response.and_something_else"));

Assert.True(activity.Duration.TotalMilliseconds > 0);

Expand Down

0 comments on commit 6fabd4a

Please sign in to comment.