Skip to content
Open
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
16 changes: 16 additions & 0 deletions docs/Observability.md
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,22 @@ Consider enabling [HTTP client instrumentation](https://www.nuget.org/packages/O
calls made by your application including those done by the OpenAI SDK.
Check out [OpenTelemetry documentation](https://opentelemetry.io/docs/languages/net/getting-started/) for more details.

### Semantic convention version

By default, the instrumentation emits telemetry following [OpenTelemetry GenAI Semantic Conventions v1.27.0](https://github.com/open-telemetry/semantic-conventions/tree/v1.27.0/docs/gen-ai).

To opt in to the latest experimental GenAI semantic conventions, set the `OTEL_SEMCONV_STABILITY_OPT_IN` environment variable to include `gen_ai_latest_experimental` (comma-separated if combined with other values):

```
OTEL_SEMCONV_STABILITY_OPT_IN=gen_ai_latest_experimental
```

When this opt-in is enabled, the instrumentation emits attributes following the latest conventions. Notable changes include:

- The `gen_ai.system` attribute is replaced by `gen_ai.provider.name`.

The default behavior (without the opt-in) remains unchanged and continues to emit v1.27.0 conventions.

### Available sources and meters

The following sources and meters are available:
Expand Down
12 changes: 10 additions & 2 deletions src/Utility/Telemetry/OpenTelemetryConstants.cs
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,12 @@

internal class OpenTelemetryConstants
{
// follow OpenTelemetry GenAI semantic conventions:
// https://github.com/open-telemetry/semantic-conventions/tree/v1.27.0/docs/gen-ai
// OpenTelemetry GenAI semantic conventions.

// Default (v1.27.0): https://github.com/open-telemetry/semantic-conventions/tree/v1.27.0/docs/gen-ai

// Set OTEL_SEMCONV_STABILITY_OPT_IN=gen_ai_latest_experimental to use the latest conventions
// (https://github.com/open-telemetry/semantic-conventions/tree/main/docs/gen-ai).

public const string ErrorTypeKey = "error.type";
public const string ServerAddressKey = "server.address";
Expand All @@ -23,9 +27,13 @@ internal class OpenTelemetryConstants
public const string GenAiResponseFinishReasonKey = "gen_ai.response.finish_reasons";
public const string GenAiResponseModelKey = "gen_ai.response.model";

// v1.27.0: gen_ai.system
public const string GenAiSystemKey = "gen_ai.system";
public const string GenAiSystemValue = "openai";

// Latest (replaces gen_ai.system): gen_ai.provider.name
public const string GenAiProviderNameKey = "gen_ai.provider.name";

public const string GenAiTokenTypeKey = "gen_ai.token.type";

public const string GenAiUsageInputTokensKey = "gen_ai.usage.input_tokens";
Expand Down
18 changes: 9 additions & 9 deletions src/Utility/Telemetry/OpenTelemetryScope.cs
Original file line number Diff line number Diff line change
Expand Up @@ -39,24 +39,25 @@ private OpenTelemetryScope(
private static bool IsChatEnabled => s_chatSource.HasListeners() || s_tokens.Enabled || s_duration.Enabled;

public static OpenTelemetryScope StartChat(string model, string operationName,
string serverAddress, int serverPort, ChatCompletionOptions options)
string serverAddress, int serverPort, ChatCompletionOptions options,
string systemKey)
{
if (IsChatEnabled)
{
var scope = new OpenTelemetryScope(model, operationName, serverAddress, serverPort);
scope.StartChat(options);
scope.StartChat(options, systemKey);
return scope;
}

return null;
}

private void StartChat(ChatCompletionOptions options)
private void StartChat(ChatCompletionOptions options, string systemKey)
{
_duration = Stopwatch.StartNew();
_commonTags = new TagList
{
{ GenAiSystemKey, GenAiSystemValue },
{ systemKey, GenAiSystemValue },
{ GenAiRequestModelKey, _requestModel },
{ ServerAddressKey, _serverAddress },
{ ServerPortKey, _serverPort },
Expand Down Expand Up @@ -103,11 +104,10 @@ public void Dispose()

private void RecordCommonAttributes()
{
_activity.SetTag(GenAiSystemKey, GenAiSystemValue);
_activity.SetTag(GenAiRequestModelKey, _requestModel);
_activity.SetTag(ServerAddressKey, _serverAddress);
_activity.SetTag(ServerPortKey, _serverPort);
_activity.SetTag(GenAiOperationNameKey, _operationName);
foreach (var tag in _commonTags)
{
_activity.SetTag(tag.Key, tag.Value);
}
}

private void RecordMetrics(string responseModel, string errorType, int? inputTokensUsage, int? outputTokensUsage)
Expand Down
43 changes: 43 additions & 0 deletions src/Utility/Telemetry/OpenTelemetrySemconvStabilityOptIn.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
using System;

namespace OpenAI.Telemetry;

/// <summary>
/// Reads the OTEL_SEMCONV_STABILITY_OPT_IN environment variable to determine
/// which version of the OpenTelemetry GenAI semantic conventions to emit.
///
/// See https://github.com/open-telemetry/semantic-conventions for details on
/// the transition policy for existing instrumentations.
/// </summary>
internal static class OpenTelemetrySemconvStabilityOptIn
{
private const string EnvVarName = "OTEL_SEMCONV_STABILITY_OPT_IN";
private const string GenAiLatestExperimentalValue = "gen_ai_latest_experimental";

/// <summary>
/// When true, the instrumentation emits the latest experimental GenAI
/// semantic conventions.
/// When false (default), the instrumentation continues to emit v1.27.0 conventions.
/// </summary>
public static bool IsLatestGenAiSemconvEnabled => ParseOptIn();

private static bool ParseOptIn()
{
string value = Environment.GetEnvironmentVariable(EnvVarName);
if (string.IsNullOrEmpty(value))
{
return false;
}

// The env var is a comma-separated list of opt-in values.
foreach (string part in value.Split(','))
{
if (part.Trim().Equals(GenAiLatestExperimentalValue, StringComparison.OrdinalIgnoreCase))
{
return true;
}
}

return false;
}
}
6 changes: 5 additions & 1 deletion src/Utility/Telemetry/OpenTelemetrySource.cs
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,10 @@ internal class OpenTelemetrySource
private readonly bool IsOTelEnabled = AppContextSwitchHelper
.GetConfigValue("OpenAI.Experimental.EnableOpenTelemetry", "OPENAI_EXPERIMENTAL_ENABLE_OPEN_TELEMETRY");

private readonly string _systemKey = OpenTelemetrySemconvStabilityOptIn.IsLatestGenAiSemconvEnabled
? OpenTelemetryConstants.GenAiProviderNameKey
: OpenTelemetryConstants.GenAiSystemKey;

private readonly string _serverAddress;
private readonly int _serverPort;
private readonly string _model;
Expand All @@ -23,7 +27,7 @@ public OpenTelemetrySource(string model, Uri endpoint)
public OpenTelemetryScope StartChatScope(ChatCompletionOptions completionsOptions)
{
return IsOTelEnabled
? OpenTelemetryScope.StartChat(_model, ChatOperationName, _serverAddress, _serverPort, completionsOptions)
? OpenTelemetryScope.StartChat(_model, ChatOperationName, _serverAddress, _serverPort, completionsOptions, _systemKey)
: null;
}

Expand Down
42 changes: 25 additions & 17 deletions tests/Telemetry/ChatTelemetryTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -51,10 +51,12 @@ public void SwitchOffAllTelemetryOn()
Assert.That(Activity.Current, Is.Null);
}

[Test]
public void MetricsOnTracingOff()
[TestCase(false)]
[TestCase(true)]
public void MetricsOnTracingOff(bool useLatestSemconv)
{
using var _ = TestAppContextSwitchHelper.EnableOpenTelemetry();
using var _semconv = useLatestSemconv ? TestSemconvOptIn.EnableLatestGenAiSemconv() : null;

var telemetry = new OpenTelemetrySource(RequestModel, new Uri(Endpoint));

Expand All @@ -76,14 +78,16 @@ public void MetricsOnTracingOff()
scope.RecordChatCompletion(response);
scope.Dispose();

ValidateDuration(meterListener, response, elapsedMin.Elapsed, elapsedMax.Elapsed);
ValidateUsage(meterListener, response, PromptTokens, CompletionTokens);
ValidateDuration(meterListener, response, elapsedMin.Elapsed, elapsedMax.Elapsed, useLatestSemconv);
ValidateUsage(meterListener, response, PromptTokens, CompletionTokens, useLatestSemconv);
}

[Test]
public void MetricsOnTracingOffException()
[TestCase(false)]
[TestCase(true)]
public void MetricsOnTracingOffException(bool useLatestSemconv)
{
using var _ = TestAppContextSwitchHelper.EnableOpenTelemetry();
using var _semconv = useLatestSemconv ? TestSemconvOptIn.EnableLatestGenAiSemconv() : null;

var telemetry = new OpenTelemetrySource(RequestModel, new Uri(Endpoint));
using var meterListener = new TestMeterListener("OpenAI.ChatClient");
Expand All @@ -93,14 +97,16 @@ public void MetricsOnTracingOffException()
scope.RecordException(new TaskCanceledException());
}

ValidateDuration(meterListener, null, TimeSpan.MinValue, TimeSpan.MaxValue);
ValidateDuration(meterListener, null, TimeSpan.MinValue, TimeSpan.MaxValue, useLatestSemconv);
Assert.That(meterListener.GetMeasurements("gen_ai.client.token.usage"), Is.Null);
}

[Test]
public void TracingOnMetricsOff()
[TestCase(false)]
[TestCase(true)]
public void TracingOnMetricsOff(bool useLatestSemconv)
{
using var _ = TestAppContextSwitchHelper.EnableOpenTelemetry();
using var _semconv = useLatestSemconv ? TestSemconvOptIn.EnableLatestGenAiSemconv() : null;

var telemetry = new OpenTelemetrySource(RequestModel, new Uri(Endpoint));
using var listener = new TestActivityListener("OpenAI.ChatClient");
Expand All @@ -123,7 +129,7 @@ public void TracingOnMetricsOff()
Assert.That(Activity.Current, Is.Null);
Assert.That(listener.Activities.Count, Is.EqualTo(1));

ValidateChatActivity(listener.Activities.Single(), chatCompletion, RequestModel, Host, Port);
ValidateChatActivity(listener.Activities.Single(), chatCompletion, RequestModel, Host, Port, useLatestSemconv: useLatestSemconv);
}

[Test]
Expand Down Expand Up @@ -154,10 +160,12 @@ public void ChatTracingAllAttributes()
ValidateChatActivity(listener.Activities.Single(), chatCompletion, RequestModel, Host, Port);
}

[Test]
public void ChatTracingException()
[TestCase(false)]
[TestCase(true)]
public void ChatTracingException(bool useLatestSemconv)
{
using var _ = TestAppContextSwitchHelper.EnableOpenTelemetry();
using var _semconv = useLatestSemconv ? TestSemconvOptIn.EnableLatestGenAiSemconv() : null;

var telemetry = new OpenTelemetrySource(RequestModel, new Uri(Endpoint));
using var listener = new TestActivityListener("OpenAI.ChatClient");
Expand All @@ -170,7 +178,7 @@ public void ChatTracingException()

Assert.That(Activity.Current, Is.Null);

ValidateChatActivity(listener.Activities.Single(), error, RequestModel, Host, Port);
ValidateChatActivity(listener.Activities.Single(), error, RequestModel, Host, Port, useLatestSemconv: useLatestSemconv);
}

[Test]
Expand Down Expand Up @@ -240,7 +248,7 @@ private void SetMessages(ChatCompletionOptions options, params ChatMessage[] mes
messagesProperty.SetValue(options, messages.ToList());
}

private void ValidateDuration(TestMeterListener listener, ChatCompletion response, TimeSpan durationMin, TimeSpan durationMax)
private void ValidateDuration(TestMeterListener listener, ChatCompletion response, TimeSpan durationMin, TimeSpan durationMax, bool useLatestSemconv = false)
{
var duration = listener.GetInstrument("gen_ai.client.operation.duration");
Assert.That(duration, Is.Not.Null);
Expand All @@ -255,10 +263,10 @@ private void ValidateDuration(TestMeterListener listener, ChatCompletion respons
Assert.That((double)measurement.value, Is.GreaterThanOrEqualTo(durationMin.TotalSeconds));
Assert.That((double)measurement.value, Is.LessThanOrEqualTo(durationMax.TotalSeconds));

ValidateChatMetricTags(measurement, response, RequestModel, Host, Port);
ValidateChatMetricTags(measurement, response, RequestModel, Host, Port, useLatestSemconv: useLatestSemconv);
}

private void ValidateUsage(TestMeterListener listener, ChatCompletion response, int inputTokens, int outputTokens)
private void ValidateUsage(TestMeterListener listener, ChatCompletion response, int inputTokens, int outputTokens, bool useLatestSemconv = false)
{
var usage = listener.GetInstrument("gen_ai.client.token.usage");
Assert.That(usage, Is.Not.Null);
Expand All @@ -271,7 +279,7 @@ private void ValidateUsage(TestMeterListener listener, ChatCompletion response,
foreach (var measurement in measurements)
{
Assert.That(measurement.value, Is.InstanceOf<long>());
ValidateChatMetricTags(measurement, response, RequestModel, Host, Port);
ValidateChatMetricTags(measurement, response, RequestModel, Host, Port, useLatestSemconv: useLatestSemconv);
}

Assert.That(measurements[0].tags.TryGetValue("gen_ai.token.type", out var type));
Expand Down
19 changes: 15 additions & 4 deletions tests/Telemetry/TestActivityListener.cs
Original file line number Diff line number Diff line change
Expand Up @@ -35,12 +35,23 @@ public void Dispose()
_listener.Dispose();
}

public static void ValidateChatActivity(Activity activity, ChatCompletion response, string requestModel = "gpt-4o-mini", string host = "api.openai.com", int port = 443)
public static void ValidateChatActivity(Activity activity, ChatCompletion response, string requestModel = "gpt-4o-mini", string host = "api.openai.com", int port = 443, bool useLatestSemconv = false)
{
Assert.That(activity, Is.Not.Null);
Assert.That(activity.DisplayName, Is.EqualTo($"chat {requestModel}"));
Assert.That(activity.GetTagItem("gen_ai.operation.name"), Is.EqualTo("chat"));
Assert.That(activity.GetTagItem("gen_ai.system"), Is.EqualTo("openai"));

if (useLatestSemconv)
{
Assert.That(activity.GetTagItem("gen_ai.provider.name"), Is.EqualTo("openai"));
Assert.That(activity.GetTagItem("gen_ai.system"), Is.Null);
}
else
{
Assert.That(activity.GetTagItem("gen_ai.system"), Is.EqualTo("openai"));
Assert.That(activity.GetTagItem("gen_ai.provider.name"), Is.Null);
}

Assert.That(activity.GetTagItem("gen_ai.request.model"), Is.EqualTo(requestModel));

Assert.That(activity.GetTagItem("server.address"), Is.EqualTo(host));
Expand All @@ -64,9 +75,9 @@ public static void ValidateChatActivity(Activity activity, ChatCompletion respon
}
}

public static void ValidateChatActivity(Activity activity, Exception ex, string requestModel = "gpt-4o-mini", string host = "api.openai.com", int port = 443)
public static void ValidateChatActivity(Activity activity, Exception ex, string requestModel = "gpt-4o-mini", string host = "api.openai.com", int port = 443, bool useLatestSemconv = false)
{
ValidateChatActivity(activity, (ChatCompletion)null, requestModel, host, port);
ValidateChatActivity(activity, (ChatCompletion)null, requestModel, host, port, useLatestSemconv);
Assert.That(activity.GetTagItem("error.type"), Is.EqualTo(ex.GetType().FullName));
}
}
18 changes: 14 additions & 4 deletions tests/Telemetry/TestMeterListener.cs
Original file line number Diff line number Diff line change
Expand Up @@ -60,9 +60,19 @@ public void Dispose()
_listener.Dispose();
}

public static void ValidateChatMetricTags(TestMeasurement measurement, ChatCompletion response, string requestModel = "gpt-4o-mini", string host = "api.openai.com", int port = 443)
public static void ValidateChatMetricTags(TestMeasurement measurement, ChatCompletion response, string requestModel = "gpt-4o-mini", string host = "api.openai.com", int port = 443, bool useLatestSemconv = false)
{
Assert.That(measurement.tags["gen_ai.system"], Is.EqualTo("openai"));
if (useLatestSemconv)
{
Assert.That(measurement.tags["gen_ai.provider.name"], Is.EqualTo("openai"));
Assert.That(measurement.tags.ContainsKey("gen_ai.system"), Is.False);
}
else
{
Assert.That(measurement.tags["gen_ai.system"], Is.EqualTo("openai"));
Assert.That(measurement.tags.ContainsKey("gen_ai.provider.name"), Is.False);
}

Assert.That(measurement.tags["gen_ai.operation.name"], Is.EqualTo("chat"));
Assert.That(measurement.tags["server.address"], Is.EqualTo(host));
Assert.That(measurement.tags["gen_ai.request.model"], Is.EqualTo(requestModel));
Expand All @@ -75,9 +85,9 @@ public static void ValidateChatMetricTags(TestMeasurement measurement, ChatCompl
}
}

public static void ValidateChatMetricTags(TestMeasurement measurement, Exception ex, string requestModel = "gpt-4o-mini", string host = "api.openai.com", int port = 443)
public static void ValidateChatMetricTags(TestMeasurement measurement, Exception ex, string requestModel = "gpt-4o-mini", string host = "api.openai.com", int port = 443, bool useLatestSemconv = false)
{
ValidateChatMetricTags(measurement, (ChatCompletion)null, requestModel, host, port);
ValidateChatMetricTags(measurement, (ChatCompletion)null, requestModel, host, port, useLatestSemconv);
Assert.That(measurement.tags.ContainsKey("error.type"));
Assert.That(measurement.tags["error.type"], Is.EqualTo(ex.GetType().FullName));
}
Expand Down
35 changes: 35 additions & 0 deletions tests/Telemetry/TestSemconvOptIn.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
using System;

namespace OpenAI.Tests.Telemetry;

/// <summary>
/// Test helper that enables the latest GenAI semantic conventions by setting
/// the OTEL_SEMCONV_STABILITY_OPT_IN environment variable.
/// Restores the original value on dispose.
/// Must be used before constructing <see cref="OpenAI.Telemetry.OpenTelemetrySource"/>.
/// </summary>
internal class TestSemconvOptIn : IDisposable
{
private const string EnvVarName = "OTEL_SEMCONV_STABILITY_OPT_IN";

private readonly string _originalEnvValue;

private TestSemconvOptIn(string envValue)
{
_originalEnvValue = Environment.GetEnvironmentVariable(EnvVarName);
Environment.SetEnvironmentVariable(EnvVarName, envValue);
}

/// <summary>
/// Enables the latest GenAI semantic conventions for the duration of the test.
/// </summary>
public static IDisposable EnableLatestGenAiSemconv()
{
return new TestSemconvOptIn("gen_ai_latest_experimental");
}

public void Dispose()
{
Environment.SetEnvironmentVariable(EnvVarName, _originalEnvValue);
}
}