Skip to content

Commit fdedf65

Browse files
Copilotstephentoub
andcommitted
Add TTS OpenTelemetry exception logging and changelog entry
Co-authored-by: stephentoub <[email protected]>
1 parent da88f01 commit fdedf65

3 files changed

Lines changed: 72 additions & 2 deletions

File tree

src/Libraries/Microsoft.Extensions.AI/CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22

33
## NOT YET RELEASED
44

5+
- Added `gen_ai.client.operation.exception` log event emission via `ILogger` across all OpenTelemetry instrumentation classes.
56
- Added `time_to_first_chunk` and `time_per_output_chunk` streaming metrics to `OpenTelemetryChatClient`.
67
- Fixed `OpenTelemetryChatClient` to emit tool definitions even when EnableSensitiveData is false.
78
- Updated the OpenTelemetry instrumentation to conform to the latest 1.49 draft specification of the Semantic Conventions for Generative AI systems.

src/Libraries/Microsoft.Extensions.AI/TextToSpeech/OpenTelemetryTextToSpeechClient.cs

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -38,17 +38,19 @@ public sealed class OpenTelemetryTextToSpeechClient : DelegatingTextToSpeechClie
3838
private readonly string? _serverAddress;
3939
private readonly int _serverPort;
4040

41+
private readonly ILogger? _logger;
42+
4143
/// <summary>Initializes a new instance of the <see cref="OpenTelemetryTextToSpeechClient"/> class.</summary>
4244
/// <param name="innerClient">The underlying <see cref="ITextToSpeechClient"/>.</param>
4345
/// <param name="logger">The <see cref="ILogger"/> to use for emitting any logging data from the client.</param>
4446
/// <param name="sourceName">An optional source name that will be used on the telemetry data.</param>
45-
#pragma warning disable IDE0060 // Remove unused parameter; it exists for consistency with IChatClient and future use
4647
public OpenTelemetryTextToSpeechClient(ITextToSpeechClient innerClient, ILogger? logger = null, string? sourceName = null)
47-
#pragma warning restore IDE0060
4848
: base(innerClient)
4949
{
5050
Debug.Assert(innerClient is not null, "Should have been validated by the base ctor");
5151

52+
_logger = logger;
53+
5254
if (innerClient!.GetService<TextToSpeechClientMetadata>() is TextToSpeechClientMetadata metadata)
5355
{
5456
_defaultModelId = metadata.DefaultModelId;
@@ -287,6 +289,11 @@ private void TraceResponse(
287289
_ = activity?
288290
.AddTag(OpenTelemetryConsts.Error.Type, error.GetType().FullName)
289291
.SetStatus(ActivityStatusCode.Error, error.Message);
292+
293+
if (_logger is not null)
294+
{
295+
OpenTelemetryLog.OperationException(_logger, error);
296+
}
290297
}
291298

292299
if (response is not null && activity is not null)

test/Libraries/Microsoft.Extensions.AI.Tests/TextToSpeech/OpenTelemetryTextToSpeechClientTests.cs

Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,8 @@
77
using System.Runtime.CompilerServices;
88
using System.Threading;
99
using System.Threading.Tasks;
10+
using Microsoft.Extensions.Logging;
11+
using Microsoft.Extensions.Logging.Testing;
1012
using OpenTelemetry.Trace;
1113
using Xunit;
1214

@@ -127,4 +129,64 @@ static async IAsyncEnumerable<TextToSpeechResponseUpdate> TestClientStreamAsync(
127129

128130
Assert.True(activity.Duration.TotalMilliseconds > 0);
129131
}
132+
133+
[Theory]
134+
[InlineData(false)]
135+
[InlineData(true)]
136+
public async Task ExceptionLogged_Async(bool streaming)
137+
{
138+
var sourceName = Guid.NewGuid().ToString();
139+
var activities = new List<Activity>();
140+
using var tracerProvider = OpenTelemetry.Sdk.CreateTracerProviderBuilder()
141+
.AddSource(sourceName)
142+
.AddInMemoryExporter(activities)
143+
.Build();
144+
145+
var collector = new FakeLogCollector();
146+
using var loggerFactory = LoggerFactory.Create(b => b.AddProvider(new FakeLoggerProvider(collector)));
147+
148+
var expectedException = new InvalidOperationException("test exception message");
149+
150+
using var innerClient = new TestTextToSpeechClient
151+
{
152+
GetAudioAsyncCallback = (text, options, cancellationToken) => throw expectedException,
153+
GetStreamingAudioAsyncCallback = (text, options, cancellationToken) => throw expectedException,
154+
GetServiceCallback = (serviceType, serviceKey) =>
155+
serviceType == typeof(TextToSpeechClientMetadata) ? new TextToSpeechClientMetadata("testservice", new Uri("http://localhost:12345"), "testmodel") :
156+
null,
157+
};
158+
159+
using var client = innerClient
160+
.AsBuilder()
161+
.UseOpenTelemetry(loggerFactory, sourceName)
162+
.Build();
163+
164+
if (streaming)
165+
{
166+
await Assert.ThrowsAsync<InvalidOperationException>(async () =>
167+
{
168+
await foreach (var update in client.GetStreamingAudioAsync("Hello"))
169+
{
170+
_ = update;
171+
}
172+
});
173+
}
174+
else
175+
{
176+
await Assert.ThrowsAsync<InvalidOperationException>(() =>
177+
client.GetAudioAsync("Hello"));
178+
}
179+
180+
var activity = Assert.Single(activities);
181+
182+
// Existing error behavior is preserved
183+
Assert.Equal(expectedException.GetType().FullName, activity.GetTagItem("error.type"));
184+
Assert.Equal(ActivityStatusCode.Error, activity.Status);
185+
186+
// Exception is logged via ILogger
187+
var logEntry = Assert.Single(collector.GetSnapshot());
188+
Assert.Equal("gen_ai.client.operation.exception", logEntry.Id.Name);
189+
Assert.Equal(LogLevel.Warning, logEntry.Level);
190+
Assert.Same(expectedException, logEntry.Exception);
191+
}
130192
}

0 commit comments

Comments
 (0)