From d6791d2bc698b67222a4b37c16e6b4b5b49395f9 Mon Sep 17 00:00:00 2001 From: Alex Sohn Date: Tue, 14 Oct 2025 11:56:53 -0400 Subject: [PATCH 01/86] Basic Functionality working --- .generated.NoMobile.sln | 60 ++++ Sentry-CI-Build-Linux-NoMobile.slnf | 4 + Sentry-CI-Build-Linux.slnf | 4 + Sentry-CI-Build-Windows-arm64.slnf | 4 + Sentry-CI-Build-Windows.slnf | 4 + Sentry-CI-Build-macOS.slnf | 4 + Sentry-CI-CodeQL.slnf | 1 + Sentry.sln | 63 ++++ SentryAspNetCore.slnf | 1 + SentryNoMobile.slnf | 4 + SentryNoSamples.slnf | 2 + .../Program.cs | 271 ++++++++++++++++++ .../Sentry.Samples.ME.AI.AspNetCore.csproj | 25 ++ .../Sentry.Samples.ME.AI.Console/Program.cs | 204 +++++++++++++ .../Sentry.Samples.ME.AI.Console.csproj | 25 ++ .../Extensions/ServiceCollectionExtensions.cs | 33 +++ .../Sentry.Extensions.AI.csproj | 21 ++ .../SentryAIFunctionWrapper.cs | 24 ++ .../SentryAISpanEnricher.cs | 121 ++++++++ src/Sentry.Extensions.AI/SentryChatClient.cs | 38 +++ .../Sentry.Extensions.AI.Tests.csproj | 17 ++ .../SentryChatClientTests.cs | 59 ++++ 22 files changed, 989 insertions(+) create mode 100644 samples/Sentry.Samples.ME.AI.AspNetCore/Program.cs create mode 100644 samples/Sentry.Samples.ME.AI.AspNetCore/Sentry.Samples.ME.AI.AspNetCore.csproj create mode 100644 samples/Sentry.Samples.ME.AI.Console/Program.cs create mode 100644 samples/Sentry.Samples.ME.AI.Console/Sentry.Samples.ME.AI.Console.csproj create mode 100644 src/Sentry.Extensions.AI/Extensions/ServiceCollectionExtensions.cs create mode 100644 src/Sentry.Extensions.AI/Sentry.Extensions.AI.csproj create mode 100644 src/Sentry.Extensions.AI/SentryAIFunctionWrapper.cs create mode 100644 src/Sentry.Extensions.AI/SentryAISpanEnricher.cs create mode 100644 src/Sentry.Extensions.AI/SentryChatClient.cs create mode 100644 test/Sentry.Extensions.AI.Tests/Sentry.Extensions.AI.Tests.csproj create mode 100644 test/Sentry.Extensions.AI.Tests/SentryChatClientTests.cs diff --git a/.generated.NoMobile.sln b/.generated.NoMobile.sln index 3730377a46..4dd8dc1c11 100644 --- a/.generated.NoMobile.sln +++ b/.generated.NoMobile.sln @@ -209,6 +209,14 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Sentry.SourceGenerators.Tes EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Sentry.Maui.CommunityToolkit.Mvvm.Tests", "test\Sentry.Maui.CommunityToolkit.Mvvm.Tests\Sentry.Maui.CommunityToolkit.Mvvm.Tests.csproj", "{ADC91A84-6054-42EC-8241-0D717E4C7194}" EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Sentry.Samples.ME.AI.Console", "samples\Sentry.Samples.ME.AI.Console\Sentry.Samples.ME.AI.Console.csproj", "{CE591593-E51C-498E-BC0D-5083C8197544}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Sentry.Samples.ME.AI.AspNetCore", "samples\Sentry.Samples.ME.AI.AspNetCore\Sentry.Samples.ME.AI.AspNetCore.csproj", "{3D91128A-5695-4BAE-B939-5F5F7C56A679}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Sentry.Extensions.AI", "src\Sentry.Extensions.AI\Sentry.Extensions.AI.csproj", "{AE461926-00B8-4FDD-A41D-3C83C2D9A045}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Sentry.Extensions.AI.Tests", "test\Sentry.Extensions.AI.Tests\Sentry.Extensions.AI.Tests.csproj", "{28D6E004-DC64-464F-91BE-BC0B3A8E543F}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU @@ -1252,6 +1260,54 @@ Global {ADC91A84-6054-42EC-8241-0D717E4C7194}.Release|x64.Build.0 = Release|Any CPU {ADC91A84-6054-42EC-8241-0D717E4C7194}.Release|x86.ActiveCfg = Release|Any CPU {ADC91A84-6054-42EC-8241-0D717E4C7194}.Release|x86.Build.0 = Release|Any CPU + {CE591593-E51C-498E-BC0D-5083C8197544}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {CE591593-E51C-498E-BC0D-5083C8197544}.Debug|Any CPU.Build.0 = Debug|Any CPU + {CE591593-E51C-498E-BC0D-5083C8197544}.Debug|x64.ActiveCfg = Debug|Any CPU + {CE591593-E51C-498E-BC0D-5083C8197544}.Debug|x64.Build.0 = Debug|Any CPU + {CE591593-E51C-498E-BC0D-5083C8197544}.Debug|x86.ActiveCfg = Debug|Any CPU + {CE591593-E51C-498E-BC0D-5083C8197544}.Debug|x86.Build.0 = Debug|Any CPU + {CE591593-E51C-498E-BC0D-5083C8197544}.Release|Any CPU.ActiveCfg = Release|Any CPU + {CE591593-E51C-498E-BC0D-5083C8197544}.Release|Any CPU.Build.0 = Release|Any CPU + {CE591593-E51C-498E-BC0D-5083C8197544}.Release|x64.ActiveCfg = Release|Any CPU + {CE591593-E51C-498E-BC0D-5083C8197544}.Release|x64.Build.0 = Release|Any CPU + {CE591593-E51C-498E-BC0D-5083C8197544}.Release|x86.ActiveCfg = Release|Any CPU + {CE591593-E51C-498E-BC0D-5083C8197544}.Release|x86.Build.0 = Release|Any CPU + {3D91128A-5695-4BAE-B939-5F5F7C56A679}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {3D91128A-5695-4BAE-B939-5F5F7C56A679}.Debug|Any CPU.Build.0 = Debug|Any CPU + {3D91128A-5695-4BAE-B939-5F5F7C56A679}.Debug|x64.ActiveCfg = Debug|Any CPU + {3D91128A-5695-4BAE-B939-5F5F7C56A679}.Debug|x64.Build.0 = Debug|Any CPU + {3D91128A-5695-4BAE-B939-5F5F7C56A679}.Debug|x86.ActiveCfg = Debug|Any CPU + {3D91128A-5695-4BAE-B939-5F5F7C56A679}.Debug|x86.Build.0 = Debug|Any CPU + {3D91128A-5695-4BAE-B939-5F5F7C56A679}.Release|Any CPU.ActiveCfg = Release|Any CPU + {3D91128A-5695-4BAE-B939-5F5F7C56A679}.Release|Any CPU.Build.0 = Release|Any CPU + {3D91128A-5695-4BAE-B939-5F5F7C56A679}.Release|x64.ActiveCfg = Release|Any CPU + {3D91128A-5695-4BAE-B939-5F5F7C56A679}.Release|x64.Build.0 = Release|Any CPU + {3D91128A-5695-4BAE-B939-5F5F7C56A679}.Release|x86.ActiveCfg = Release|Any CPU + {3D91128A-5695-4BAE-B939-5F5F7C56A679}.Release|x86.Build.0 = Release|Any CPU + {AE461926-00B8-4FDD-A41D-3C83C2D9A045}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {AE461926-00B8-4FDD-A41D-3C83C2D9A045}.Debug|Any CPU.Build.0 = Debug|Any CPU + {AE461926-00B8-4FDD-A41D-3C83C2D9A045}.Debug|x64.ActiveCfg = Debug|Any CPU + {AE461926-00B8-4FDD-A41D-3C83C2D9A045}.Debug|x64.Build.0 = Debug|Any CPU + {AE461926-00B8-4FDD-A41D-3C83C2D9A045}.Debug|x86.ActiveCfg = Debug|Any CPU + {AE461926-00B8-4FDD-A41D-3C83C2D9A045}.Debug|x86.Build.0 = Debug|Any CPU + {AE461926-00B8-4FDD-A41D-3C83C2D9A045}.Release|Any CPU.ActiveCfg = Release|Any CPU + {AE461926-00B8-4FDD-A41D-3C83C2D9A045}.Release|Any CPU.Build.0 = Release|Any CPU + {AE461926-00B8-4FDD-A41D-3C83C2D9A045}.Release|x64.ActiveCfg = Release|Any CPU + {AE461926-00B8-4FDD-A41D-3C83C2D9A045}.Release|x64.Build.0 = Release|Any CPU + {AE461926-00B8-4FDD-A41D-3C83C2D9A045}.Release|x86.ActiveCfg = Release|Any CPU + {AE461926-00B8-4FDD-A41D-3C83C2D9A045}.Release|x86.Build.0 = Release|Any CPU + {28D6E004-DC64-464F-91BE-BC0B3A8E543F}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {28D6E004-DC64-464F-91BE-BC0B3A8E543F}.Debug|Any CPU.Build.0 = Debug|Any CPU + {28D6E004-DC64-464F-91BE-BC0B3A8E543F}.Debug|x64.ActiveCfg = Debug|Any CPU + {28D6E004-DC64-464F-91BE-BC0B3A8E543F}.Debug|x64.Build.0 = Debug|Any CPU + {28D6E004-DC64-464F-91BE-BC0B3A8E543F}.Debug|x86.ActiveCfg = Debug|Any CPU + {28D6E004-DC64-464F-91BE-BC0B3A8E543F}.Debug|x86.Build.0 = Debug|Any CPU + {28D6E004-DC64-464F-91BE-BC0B3A8E543F}.Release|Any CPU.ActiveCfg = Release|Any CPU + {28D6E004-DC64-464F-91BE-BC0B3A8E543F}.Release|Any CPU.Build.0 = Release|Any CPU + {28D6E004-DC64-464F-91BE-BC0B3A8E543F}.Release|x64.ActiveCfg = Release|Any CPU + {28D6E004-DC64-464F-91BE-BC0B3A8E543F}.Release|x64.Build.0 = Release|Any CPU + {28D6E004-DC64-464F-91BE-BC0B3A8E543F}.Release|x86.ActiveCfg = Release|Any CPU + {28D6E004-DC64-464F-91BE-BC0B3A8E543F}.Release|x86.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE @@ -1343,5 +1399,9 @@ Global {C3CDF61C-3E28-441C-A9CE-011C89D11719} = {230B9384-90FD-4551-A5DE-1A5C197F25B6} {3A76FF7D-2F32-4EA5-8999-2FFE3C7CB893} = {6987A1CC-608E-4868-A02C-09D30C8B7B2D} {ADC91A84-6054-42EC-8241-0D717E4C7194} = {6987A1CC-608E-4868-A02C-09D30C8B7B2D} + {CE591593-E51C-498E-BC0D-5083C8197544} = {21B42F60-5802-404E-90F0-AEBCC56760C0} + {3D91128A-5695-4BAE-B939-5F5F7C56A679} = {21B42F60-5802-404E-90F0-AEBCC56760C0} + {AE461926-00B8-4FDD-A41D-3C83C2D9A045} = {230B9384-90FD-4551-A5DE-1A5C197F25B6} + {28D6E004-DC64-464F-91BE-BC0B3A8E543F} = {6987A1CC-608E-4868-A02C-09D30C8B7B2D} EndGlobalSection EndGlobal diff --git a/Sentry-CI-Build-Linux-NoMobile.slnf b/Sentry-CI-Build-Linux-NoMobile.slnf index f29fd0a74e..c1d20419d3 100644 --- a/Sentry-CI-Build-Linux-NoMobile.slnf +++ b/Sentry-CI-Build-Linux-NoMobile.slnf @@ -24,6 +24,8 @@ "samples\\Sentry.Samples.GraphQL.Server\\Sentry.Samples.GraphQL.Server.csproj", "samples\\Sentry.Samples.Hangfire\\Sentry.Samples.Hangfire.csproj", "samples\\Sentry.Samples.Log4Net\\Sentry.Samples.Log4Net.csproj", + "samples\\Sentry.Samples.ME.AI.AspNetCore\\Sentry.Samples.ME.AI.AspNetCore.csproj", + "samples\\Sentry.Samples.ME.AI.Console\\Sentry.Samples.ME.AI.Console.csproj", "samples\\Sentry.Samples.ME.Logging\\Sentry.Samples.ME.Logging.csproj", "samples\\Sentry.Samples.NLog\\Sentry.Samples.NLog.csproj", "samples\\Sentry.Samples.OpenTelemetry.AspNetCore\\Sentry.Samples.OpenTelemetry.AspNetCore.csproj", @@ -36,6 +38,7 @@ "src\\Sentry.Azure.Functions.Worker\\Sentry.Azure.Functions.Worker.csproj", "src\\Sentry.DiagnosticSource\\Sentry.DiagnosticSource.csproj", "src\\Sentry.EntityFramework\\Sentry.EntityFramework.csproj", + "src\\Sentry.Extensions.AI\\Sentry.Extensions.AI.csproj", "src\\Sentry.Extensions.Logging\\Sentry.Extensions.Logging.csproj", "src\\Sentry.Google.Cloud.Functions\\Sentry.Google.Cloud.Functions.csproj", "src\\Sentry.Hangfire\\Sentry.Hangfire.csproj", @@ -54,6 +57,7 @@ "test\\Sentry.DiagnosticSource.IntegrationTests\\Sentry.DiagnosticSource.IntegrationTests.csproj", "test\\Sentry.DiagnosticSource.Tests\\Sentry.DiagnosticSource.Tests.csproj", "test\\Sentry.EntityFramework.Tests\\Sentry.EntityFramework.Tests.csproj", + "test\\Sentry.Extensions.AI.Tests\\Sentry.Extensions.AI.Tests.csproj", "test\\Sentry.Extensions.Logging.Tests\\Sentry.Extensions.Logging.Tests.csproj", "test\\Sentry.Google.Cloud.Functions.Tests\\Sentry.Google.Cloud.Functions.Tests.csproj", "test\\Sentry.Hangfire.Tests\\Sentry.Hangfire.Tests.csproj", diff --git a/Sentry-CI-Build-Linux.slnf b/Sentry-CI-Build-Linux.slnf index b31c819bc8..974d654f39 100644 --- a/Sentry-CI-Build-Linux.slnf +++ b/Sentry-CI-Build-Linux.slnf @@ -26,6 +26,8 @@ "samples\\Sentry.Samples.Hangfire\\Sentry.Samples.Hangfire.csproj", "samples\\Sentry.Samples.Log4Net\\Sentry.Samples.Log4Net.csproj", "samples\\Sentry.Samples.Maui\\Sentry.Samples.Maui.csproj", + "samples\\Sentry.Samples.ME.AI.AspNetCore\\Sentry.Samples.ME.AI.AspNetCore.csproj", + "samples\\Sentry.Samples.ME.AI.Console\\Sentry.Samples.ME.AI.Console.csproj", "samples\\Sentry.Samples.ME.Logging\\Sentry.Samples.ME.Logging.csproj", "samples\\Sentry.Samples.NLog\\Sentry.Samples.NLog.csproj", "samples\\Sentry.Samples.OpenTelemetry.AspNetCore\\Sentry.Samples.OpenTelemetry.AspNetCore.csproj", @@ -40,6 +42,7 @@ "src\\Sentry.Bindings.Android\\Sentry.Bindings.Android.csproj", "src\\Sentry.DiagnosticSource\\Sentry.DiagnosticSource.csproj", "src\\Sentry.EntityFramework\\Sentry.EntityFramework.csproj", + "src\\Sentry.Extensions.AI\\Sentry.Extensions.AI.csproj", "src\\Sentry.Extensions.Logging\\Sentry.Extensions.Logging.csproj", "src\\Sentry.Google.Cloud.Functions\\Sentry.Google.Cloud.Functions.csproj", "src\\Sentry.Hangfire\\Sentry.Hangfire.csproj", @@ -61,6 +64,7 @@ "test\\Sentry.DiagnosticSource.IntegrationTests\\Sentry.DiagnosticSource.IntegrationTests.csproj", "test\\Sentry.DiagnosticSource.Tests\\Sentry.DiagnosticSource.Tests.csproj", "test\\Sentry.EntityFramework.Tests\\Sentry.EntityFramework.Tests.csproj", + "test\\Sentry.Extensions.AI.Tests\\Sentry.Extensions.AI.Tests.csproj", "test\\Sentry.Extensions.Logging.Tests\\Sentry.Extensions.Logging.Tests.csproj", "test\\Sentry.Google.Cloud.Functions.Tests\\Sentry.Google.Cloud.Functions.Tests.csproj", "test\\Sentry.Hangfire.Tests\\Sentry.Hangfire.Tests.csproj", diff --git a/Sentry-CI-Build-Windows-arm64.slnf b/Sentry-CI-Build-Windows-arm64.slnf index baac3fedfb..7f3b934be2 100644 --- a/Sentry-CI-Build-Windows-arm64.slnf +++ b/Sentry-CI-Build-Windows-arm64.slnf @@ -27,6 +27,8 @@ "samples\\Sentry.Samples.Hangfire\\Sentry.Samples.Hangfire.csproj", "samples\\Sentry.Samples.Log4Net\\Sentry.Samples.Log4Net.csproj", "samples\\Sentry.Samples.Maui\\Sentry.Samples.Maui.csproj", + "samples\\Sentry.Samples.ME.AI.AspNetCore\\Sentry.Samples.ME.AI.AspNetCore.csproj", + "samples\\Sentry.Samples.ME.AI.Console\\Sentry.Samples.ME.AI.Console.csproj", "samples\\Sentry.Samples.ME.Logging\\Sentry.Samples.ME.Logging.csproj", "samples\\Sentry.Samples.NLog\\Sentry.Samples.NLog.csproj", "samples\\Sentry.Samples.OpenTelemetry.AspNetCore\\Sentry.Samples.OpenTelemetry.AspNetCore.csproj", @@ -42,6 +44,7 @@ "src\\Sentry.Bindings.Android\\Sentry.Bindings.Android.csproj", "src\\Sentry.DiagnosticSource\\Sentry.DiagnosticSource.csproj", "src\\Sentry.EntityFramework\\Sentry.EntityFramework.csproj", + "src\\Sentry.Extensions.AI\\Sentry.Extensions.AI.csproj", "src\\Sentry.Extensions.Logging\\Sentry.Extensions.Logging.csproj", "src\\Sentry.Google.Cloud.Functions\\Sentry.Google.Cloud.Functions.csproj", "src\\Sentry.Hangfire\\Sentry.Hangfire.csproj", @@ -60,6 +63,7 @@ "test\\Sentry.AspNetCore.Tests\\Sentry.AspNetCore.Tests.csproj", "test\\Sentry.AspNetCore.TestUtils\\Sentry.AspNetCore.TestUtils.csproj", "test\\Sentry.Azure.Functions.Worker.Tests\\Sentry.Azure.Functions.Worker.Tests.csproj", + "test\\Sentry.Extensions.AI.Tests\\Sentry.Extensions.AI.Tests.csproj", "test\\Sentry.Extensions.Logging.Tests\\Sentry.Extensions.Logging.Tests.csproj", "test\\Sentry.Google.Cloud.Functions.Tests\\Sentry.Google.Cloud.Functions.Tests.csproj", "test\\Sentry.Hangfire.Tests\\Sentry.Hangfire.Tests.csproj", diff --git a/Sentry-CI-Build-Windows.slnf b/Sentry-CI-Build-Windows.slnf index 677a8f0608..e994826b2d 100644 --- a/Sentry-CI-Build-Windows.slnf +++ b/Sentry-CI-Build-Windows.slnf @@ -27,6 +27,8 @@ "samples\\Sentry.Samples.Hangfire\\Sentry.Samples.Hangfire.csproj", "samples\\Sentry.Samples.Log4Net\\Sentry.Samples.Log4Net.csproj", "samples\\Sentry.Samples.Maui\\Sentry.Samples.Maui.csproj", + "samples\\Sentry.Samples.ME.AI.AspNetCore\\Sentry.Samples.ME.AI.AspNetCore.csproj", + "samples\\Sentry.Samples.ME.AI.Console\\Sentry.Samples.ME.AI.Console.csproj", "samples\\Sentry.Samples.ME.Logging\\Sentry.Samples.ME.Logging.csproj", "samples\\Sentry.Samples.NLog\\Sentry.Samples.NLog.csproj", "samples\\Sentry.Samples.OpenTelemetry.AspNetCore\\Sentry.Samples.OpenTelemetry.AspNetCore.csproj", @@ -42,6 +44,7 @@ "src\\Sentry.Bindings.Android\\Sentry.Bindings.Android.csproj", "src\\Sentry.DiagnosticSource\\Sentry.DiagnosticSource.csproj", "src\\Sentry.EntityFramework\\Sentry.EntityFramework.csproj", + "src\\Sentry.Extensions.AI\\Sentry.Extensions.AI.csproj", "src\\Sentry.Extensions.Logging\\Sentry.Extensions.Logging.csproj", "src\\Sentry.Google.Cloud.Functions\\Sentry.Google.Cloud.Functions.csproj", "src\\Sentry.Hangfire\\Sentry.Hangfire.csproj", @@ -63,6 +66,7 @@ "test\\Sentry.DiagnosticSource.IntegrationTests\\Sentry.DiagnosticSource.IntegrationTests.csproj", "test\\Sentry.DiagnosticSource.Tests\\Sentry.DiagnosticSource.Tests.csproj", "test\\Sentry.EntityFramework.Tests\\Sentry.EntityFramework.Tests.csproj", + "test\\Sentry.Extensions.AI.Tests\\Sentry.Extensions.AI.Tests.csproj", "test\\Sentry.Extensions.Logging.Tests\\Sentry.Extensions.Logging.Tests.csproj", "test\\Sentry.Google.Cloud.Functions.Tests\\Sentry.Google.Cloud.Functions.Tests.csproj", "test\\Sentry.Hangfire.Tests\\Sentry.Hangfire.Tests.csproj", diff --git a/Sentry-CI-Build-macOS.slnf b/Sentry-CI-Build-macOS.slnf index 778a9a13db..8d5227346c 100644 --- a/Sentry-CI-Build-macOS.slnf +++ b/Sentry-CI-Build-macOS.slnf @@ -31,6 +31,8 @@ "samples\\Sentry.Samples.MacCatalyst\\Sentry.Samples.MacCatalyst.csproj", "samples\\Sentry.Samples.MacOS\\Sentry.Samples.MacOS.csproj", "samples\\Sentry.Samples.Maui\\Sentry.Samples.Maui.csproj", + "samples\\Sentry.Samples.ME.AI.AspNetCore\\Sentry.Samples.ME.AI.AspNetCore.csproj", + "samples\\Sentry.Samples.ME.AI.Console\\Sentry.Samples.ME.AI.Console.csproj", "samples\\Sentry.Samples.ME.Logging\\Sentry.Samples.ME.Logging.csproj", "samples\\Sentry.Samples.NLog\\Sentry.Samples.NLog.csproj", "samples\\Sentry.Samples.OpenTelemetry.AspNetCore\\Sentry.Samples.OpenTelemetry.AspNetCore.csproj", @@ -47,6 +49,7 @@ "src\\Sentry.Bindings.Cocoa\\Sentry.Bindings.Cocoa.csproj", "src\\Sentry.DiagnosticSource\\Sentry.DiagnosticSource.csproj", "src\\Sentry.EntityFramework\\Sentry.EntityFramework.csproj", + "src\\Sentry.Extensions.AI\\Sentry.Extensions.AI.csproj", "src\\Sentry.Extensions.Logging\\Sentry.Extensions.Logging.csproj", "src\\Sentry.Google.Cloud.Functions\\Sentry.Google.Cloud.Functions.csproj", "src\\Sentry.Hangfire\\Sentry.Hangfire.csproj", @@ -68,6 +71,7 @@ "test\\Sentry.DiagnosticSource.IntegrationTests\\Sentry.DiagnosticSource.IntegrationTests.csproj", "test\\Sentry.DiagnosticSource.Tests\\Sentry.DiagnosticSource.Tests.csproj", "test\\Sentry.EntityFramework.Tests\\Sentry.EntityFramework.Tests.csproj", + "test\\Sentry.Extensions.AI.Tests\\Sentry.Extensions.AI.Tests.csproj", "test\\Sentry.Extensions.Logging.Tests\\Sentry.Extensions.Logging.Tests.csproj", "test\\Sentry.Google.Cloud.Functions.Tests\\Sentry.Google.Cloud.Functions.Tests.csproj", "test\\Sentry.Hangfire.Tests\\Sentry.Hangfire.Tests.csproj", diff --git a/Sentry-CI-CodeQL.slnf b/Sentry-CI-CodeQL.slnf index 0dbf4453dd..d843499f8f 100644 --- a/Sentry-CI-CodeQL.slnf +++ b/Sentry-CI-CodeQL.slnf @@ -11,6 +11,7 @@ "src\\Sentry.Azure.Functions.Worker\\Sentry.Azure.Functions.Worker.csproj", "src\\Sentry.DiagnosticSource\\Sentry.DiagnosticSource.csproj", "src\\Sentry.EntityFramework\\Sentry.EntityFramework.csproj", + "src\\Sentry.Extensions.AI\\Sentry.Extensions.AI.csproj", "src\\Sentry.Extensions.Logging\\Sentry.Extensions.Logging.csproj", "src\\Sentry.Google.Cloud.Functions\\Sentry.Google.Cloud.Functions.csproj", "src\\Sentry.Hangfire\\Sentry.Hangfire.csproj", diff --git a/Sentry.sln b/Sentry.sln index afa5819374..fa919e0d25 100644 --- a/Sentry.sln +++ b/Sentry.sln @@ -280,6 +280,14 @@ Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "integration-test", "integra EndProject Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "net4-console", "net4-console", "{33793113-C7B5-434D-B5C1-6CA1A9587842}" EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Sentry.Samples.ME.AI.Console", "samples\Sentry.Samples.ME.AI.Console\Sentry.Samples.ME.AI.Console.csproj", "{CE591593-E51C-498E-BC0D-5083C8197544}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Sentry.Samples.ME.AI.AspNetCore", "samples\Sentry.Samples.ME.AI.AspNetCore\Sentry.Samples.ME.AI.AspNetCore.csproj", "{3D91128A-5695-4BAE-B939-5F5F7C56A679}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Sentry.Extensions.AI", "src\Sentry.Extensions.AI\Sentry.Extensions.AI.csproj", "{AE461926-00B8-4FDD-A41D-3C83C2D9A045}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Sentry.Extensions.AI.Tests", "test\Sentry.Extensions.AI.Tests\Sentry.Extensions.AI.Tests.csproj", "{28D6E004-DC64-464F-91BE-BC0B3A8E543F}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU @@ -1323,6 +1331,54 @@ Global {ADC91A84-6054-42EC-8241-0D717E4C7194}.Release|x64.Build.0 = Release|Any CPU {ADC91A84-6054-42EC-8241-0D717E4C7194}.Release|x86.ActiveCfg = Release|Any CPU {ADC91A84-6054-42EC-8241-0D717E4C7194}.Release|x86.Build.0 = Release|Any CPU + {CE591593-E51C-498E-BC0D-5083C8197544}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {CE591593-E51C-498E-BC0D-5083C8197544}.Debug|Any CPU.Build.0 = Debug|Any CPU + {CE591593-E51C-498E-BC0D-5083C8197544}.Debug|x64.ActiveCfg = Debug|Any CPU + {CE591593-E51C-498E-BC0D-5083C8197544}.Debug|x64.Build.0 = Debug|Any CPU + {CE591593-E51C-498E-BC0D-5083C8197544}.Debug|x86.ActiveCfg = Debug|Any CPU + {CE591593-E51C-498E-BC0D-5083C8197544}.Debug|x86.Build.0 = Debug|Any CPU + {CE591593-E51C-498E-BC0D-5083C8197544}.Release|Any CPU.ActiveCfg = Release|Any CPU + {CE591593-E51C-498E-BC0D-5083C8197544}.Release|Any CPU.Build.0 = Release|Any CPU + {CE591593-E51C-498E-BC0D-5083C8197544}.Release|x64.ActiveCfg = Release|Any CPU + {CE591593-E51C-498E-BC0D-5083C8197544}.Release|x64.Build.0 = Release|Any CPU + {CE591593-E51C-498E-BC0D-5083C8197544}.Release|x86.ActiveCfg = Release|Any CPU + {CE591593-E51C-498E-BC0D-5083C8197544}.Release|x86.Build.0 = Release|Any CPU + {3D91128A-5695-4BAE-B939-5F5F7C56A679}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {3D91128A-5695-4BAE-B939-5F5F7C56A679}.Debug|Any CPU.Build.0 = Debug|Any CPU + {3D91128A-5695-4BAE-B939-5F5F7C56A679}.Debug|x64.ActiveCfg = Debug|Any CPU + {3D91128A-5695-4BAE-B939-5F5F7C56A679}.Debug|x64.Build.0 = Debug|Any CPU + {3D91128A-5695-4BAE-B939-5F5F7C56A679}.Debug|x86.ActiveCfg = Debug|Any CPU + {3D91128A-5695-4BAE-B939-5F5F7C56A679}.Debug|x86.Build.0 = Debug|Any CPU + {3D91128A-5695-4BAE-B939-5F5F7C56A679}.Release|Any CPU.ActiveCfg = Release|Any CPU + {3D91128A-5695-4BAE-B939-5F5F7C56A679}.Release|Any CPU.Build.0 = Release|Any CPU + {3D91128A-5695-4BAE-B939-5F5F7C56A679}.Release|x64.ActiveCfg = Release|Any CPU + {3D91128A-5695-4BAE-B939-5F5F7C56A679}.Release|x64.Build.0 = Release|Any CPU + {3D91128A-5695-4BAE-B939-5F5F7C56A679}.Release|x86.ActiveCfg = Release|Any CPU + {3D91128A-5695-4BAE-B939-5F5F7C56A679}.Release|x86.Build.0 = Release|Any CPU + {AE461926-00B8-4FDD-A41D-3C83C2D9A045}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {AE461926-00B8-4FDD-A41D-3C83C2D9A045}.Debug|Any CPU.Build.0 = Debug|Any CPU + {AE461926-00B8-4FDD-A41D-3C83C2D9A045}.Debug|x64.ActiveCfg = Debug|Any CPU + {AE461926-00B8-4FDD-A41D-3C83C2D9A045}.Debug|x64.Build.0 = Debug|Any CPU + {AE461926-00B8-4FDD-A41D-3C83C2D9A045}.Debug|x86.ActiveCfg = Debug|Any CPU + {AE461926-00B8-4FDD-A41D-3C83C2D9A045}.Debug|x86.Build.0 = Debug|Any CPU + {AE461926-00B8-4FDD-A41D-3C83C2D9A045}.Release|Any CPU.ActiveCfg = Release|Any CPU + {AE461926-00B8-4FDD-A41D-3C83C2D9A045}.Release|Any CPU.Build.0 = Release|Any CPU + {AE461926-00B8-4FDD-A41D-3C83C2D9A045}.Release|x64.ActiveCfg = Release|Any CPU + {AE461926-00B8-4FDD-A41D-3C83C2D9A045}.Release|x64.Build.0 = Release|Any CPU + {AE461926-00B8-4FDD-A41D-3C83C2D9A045}.Release|x86.ActiveCfg = Release|Any CPU + {AE461926-00B8-4FDD-A41D-3C83C2D9A045}.Release|x86.Build.0 = Release|Any CPU + {28D6E004-DC64-464F-91BE-BC0B3A8E543F}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {28D6E004-DC64-464F-91BE-BC0B3A8E543F}.Debug|Any CPU.Build.0 = Debug|Any CPU + {28D6E004-DC64-464F-91BE-BC0B3A8E543F}.Debug|x64.ActiveCfg = Debug|Any CPU + {28D6E004-DC64-464F-91BE-BC0B3A8E543F}.Debug|x64.Build.0 = Debug|Any CPU + {28D6E004-DC64-464F-91BE-BC0B3A8E543F}.Debug|x86.ActiveCfg = Debug|Any CPU + {28D6E004-DC64-464F-91BE-BC0B3A8E543F}.Debug|x86.Build.0 = Debug|Any CPU + {28D6E004-DC64-464F-91BE-BC0B3A8E543F}.Release|Any CPU.ActiveCfg = Release|Any CPU + {28D6E004-DC64-464F-91BE-BC0B3A8E543F}.Release|Any CPU.Build.0 = Release|Any CPU + {28D6E004-DC64-464F-91BE-BC0B3A8E543F}.Release|x64.ActiveCfg = Release|Any CPU + {28D6E004-DC64-464F-91BE-BC0B3A8E543F}.Release|x64.Build.0 = Release|Any CPU + {28D6E004-DC64-464F-91BE-BC0B3A8E543F}.Release|x86.ActiveCfg = Release|Any CPU + {28D6E004-DC64-464F-91BE-BC0B3A8E543F}.Release|x86.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE @@ -1414,6 +1470,7 @@ Global {C3CDF61C-3E28-441C-A9CE-011C89D11719} = {230B9384-90FD-4551-A5DE-1A5C197F25B6} {3A76FF7D-2F32-4EA5-8999-2FFE3C7CB893} = {6987A1CC-608E-4868-A02C-09D30C8B7B2D} {ADC91A84-6054-42EC-8241-0D717E4C7194} = {6987A1CC-608E-4868-A02C-09D30C8B7B2D} +<<<<<<< HEAD {BFF081D8-7CC0-4069-99F5-5CA0D70B56AB} = {EC6ADE8A-E557-4848-8F03-519039830B5F} {5D50D425-244F-4B79-B9F5-21D26DD52DC1} = {EC6ADE8A-E557-4848-8F03-519039830B5F} {39216438-F347-427C-AB70-48DB1BA6E299} = {5D50D425-244F-4B79-B9F5-21D26DD52DC1} @@ -1421,5 +1478,11 @@ Global {E34AA22F-B42E-4D4C-B96E-426AEBC2F367} = {5D50D425-244F-4B79-B9F5-21D26DD52DC1} {94A2DCA5-F298-41FB-913A-476668EF5786} = {5D50D425-244F-4B79-B9F5-21D26DD52DC1} {33793113-C7B5-434D-B5C1-6CA1A9587842} = {94CCDBEF-5867-4C24-A305-0C2AE738AF42} +======= + {CE591593-E51C-498E-BC0D-5083C8197544} = {21B42F60-5802-404E-90F0-AEBCC56760C0} + {3D91128A-5695-4BAE-B939-5F5F7C56A679} = {21B42F60-5802-404E-90F0-AEBCC56760C0} + {AE461926-00B8-4FDD-A41D-3C83C2D9A045} = {230B9384-90FD-4551-A5DE-1A5C197F25B6} + {28D6E004-DC64-464F-91BE-BC0B3A8E543F} = {6987A1CC-608E-4868-A02C-09D30C8B7B2D} +>>>>>>> 9896852a (Basic Functionality working) EndGlobalSection EndGlobal diff --git a/SentryAspNetCore.slnf b/SentryAspNetCore.slnf index 543d1de30d..7b89d9e91c 100644 --- a/SentryAspNetCore.slnf +++ b/SentryAspNetCore.slnf @@ -11,6 +11,7 @@ "samples\\Sentry.Samples.AspNetCore.WebAPI.Profiling\\Sentry.Samples.AspNetCore.WebAPI.Profiling.csproj", "samples\\Sentry.Samples.Aws.Lambda.AspNetCoreServer\\Sentry.Samples.Aws.Lambda.AspNetCoreServer.csproj", "samples\\Sentry.Samples.Hangfire\\Sentry.Samples.Hangfire.csproj", + "samples\\Sentry.Samples.ME.AI.AspNetCore\\Sentry.Samples.ME.AI.AspNetCore.csproj", "samples\\Sentry.Samples.OpenTelemetry.AspNetCore\\Sentry.Samples.OpenTelemetry.AspNetCore.csproj", "src\\Sentry.Analyzers\\Sentry.Analyzers.csproj", "src\\Sentry.AspNetCore.Blazor.WebAssembly\\Sentry.AspNetCore.Blazor.WebAssembly.csproj", diff --git a/SentryNoMobile.slnf b/SentryNoMobile.slnf index bfc8311ad7..f78ee24300 100644 --- a/SentryNoMobile.slnf +++ b/SentryNoMobile.slnf @@ -25,6 +25,8 @@ "samples\\Sentry.Samples.Hangfire\\Sentry.Samples.Hangfire.csproj", "samples\\Sentry.Samples.Log4Net\\Sentry.Samples.Log4Net.csproj", "samples\\Sentry.Samples.MacOS\\Sentry.Samples.MacOS.csproj", + "samples\\Sentry.Samples.ME.AI.AspNetCore\\Sentry.Samples.ME.AI.AspNetCore.csproj", + "samples\\Sentry.Samples.ME.AI.Console\\Sentry.Samples.ME.AI.Console.csproj", "samples\\Sentry.Samples.ME.Logging\\Sentry.Samples.ME.Logging.csproj", "samples\\Sentry.Samples.NLog\\Sentry.Samples.NLog.csproj", "samples\\Sentry.Samples.OpenTelemetry.AspNetCore\\Sentry.Samples.OpenTelemetry.AspNetCore.csproj", @@ -38,6 +40,7 @@ "src\\Sentry.Azure.Functions.Worker\\Sentry.Azure.Functions.Worker.csproj", "src\\Sentry.DiagnosticSource\\Sentry.DiagnosticSource.csproj", "src\\Sentry.EntityFramework\\Sentry.EntityFramework.csproj", + "src\\Sentry.Extensions.AI\\Sentry.Extensions.AI.csproj", "src\\Sentry.Extensions.Logging\\Sentry.Extensions.Logging.csproj", "src\\Sentry.Google.Cloud.Functions\\Sentry.Google.Cloud.Functions.csproj", "src\\Sentry.Hangfire\\Sentry.Hangfire.csproj", @@ -57,6 +60,7 @@ "test\\Sentry.DiagnosticSource.IntegrationTests\\Sentry.DiagnosticSource.IntegrationTests.csproj", "test\\Sentry.DiagnosticSource.Tests\\Sentry.DiagnosticSource.Tests.csproj", "test\\Sentry.EntityFramework.Tests\\Sentry.EntityFramework.Tests.csproj", + "test\\Sentry.Extensions.AI.Tests\\Sentry.Extensions.AI.Tests.csproj", "test\\Sentry.Extensions.Logging.Tests\\Sentry.Extensions.Logging.Tests.csproj", "test\\Sentry.Google.Cloud.Functions.Tests\\Sentry.Google.Cloud.Functions.Tests.csproj", "test\\Sentry.Hangfire.Tests\\Sentry.Hangfire.Tests.csproj", diff --git a/SentryNoSamples.slnf b/SentryNoSamples.slnf index d3b4e187ea..f442fa2534 100644 --- a/SentryNoSamples.slnf +++ b/SentryNoSamples.slnf @@ -12,6 +12,7 @@ "src\\Sentry.Azure.Functions.Worker\\Sentry.Azure.Functions.Worker.csproj", "src\\Sentry.DiagnosticSource\\Sentry.DiagnosticSource.csproj", "src\\Sentry.EntityFramework\\Sentry.EntityFramework.csproj", + "src\\Sentry.Extensions.AI\\Sentry.Extensions.AI.csproj", "src\\Sentry.Extensions.Logging\\Sentry.Extensions.Logging.csproj", "src\\Sentry.Google.Cloud.Functions\\Sentry.Google.Cloud.Functions.csproj", "src\\Sentry.Hangfire\\Sentry.Hangfire.csproj", @@ -34,6 +35,7 @@ "test\\Sentry.DiagnosticSource.IntegrationTests\\Sentry.DiagnosticSource.IntegrationTests.csproj", "test\\Sentry.DiagnosticSource.Tests\\Sentry.DiagnosticSource.Tests.csproj", "test\\Sentry.EntityFramework.Tests\\Sentry.EntityFramework.Tests.csproj", + "test\\Sentry.Extensions.AI.Tests\\Sentry.Extensions.AI.Tests.csproj", "test\\Sentry.Extensions.Logging.Tests\\Sentry.Extensions.Logging.Tests.csproj", "test\\Sentry.Google.Cloud.Functions.Tests\\Sentry.Google.Cloud.Functions.Tests.csproj", "test\\Sentry.Hangfire.Tests\\Sentry.Hangfire.Tests.csproj", diff --git a/samples/Sentry.Samples.ME.AI.AspNetCore/Program.cs b/samples/Sentry.Samples.ME.AI.AspNetCore/Program.cs new file mode 100644 index 0000000000..0826045622 --- /dev/null +++ b/samples/Sentry.Samples.ME.AI.AspNetCore/Program.cs @@ -0,0 +1,271 @@ +#nullable enable +using System.Runtime.CompilerServices; +using System.Text; +using System.Text.Json; +using Anthropic.SDK; +using Anthropic.SDK.Constants; +using Microsoft.Extensions.AI; +using Microsoft.SemanticKernel; +using Sentry.Extensions.AI; + +var builder = WebApplication.CreateBuilder(args); + +builder.WebHost.UseSentry(options => +{ +#if !SENTRY_DSN_DEFINED_IN_ENV + // A DSN is required. You can set here in code, or you can set it in the SENTRY_DSN environment variable. + // See https://docs.sentry.io/product/sentry-basics/dsn-explainer/ + options.Dsn = SamplesShared.Dsn; +#endif + + options.Debug = true; + options.DiagnosticLevel = SentryLevel.Debug; + options.SampleRate = 1; + options.TracesSampleRate = 1.0; + options.Experimental.EnableLogs = true; +}); + +var client = new AnthropicClient().Messages + .AsBuilder() + .UseFunctionInvocation() + .Build() + .WithSentry(agentName: "Anthropic", system: "anthropic"); + +// Register the Claude API client and Sentry-instrumented chat client +builder.Services.AddKeyedSingleton("claude3_5", client); + +var app = builder.Build(); + +// Endpoint for regular AI chat +app.MapPost("/chat", async (ChatRequest request, IChatClient chatClient, ILogger logger) => +{ + logger.LogInformation("Handling chat request with message: {Message}", request.Message); + + try + { + var response = await chatClient.GetResponseAsync([ + new ChatMessage(ChatRole.User, request.Message) + ]); + + return Results.Ok(new { response = response.Messages?.FirstOrDefault()?.Text ?? "No response" }); + } + catch (Exception ex) + { + logger.LogError(ex, "Error processing chat request"); + return Results.Problem("An error occurred while processing your request"); + } +}); + +// Endpoint for streaming AI chat +// app.MapPost("/chat/stream", async (ChatRequest request, IChatClient chatClient, ILogger logger) => +// { +// logger.LogInformation("Handling streaming chat request with message: {Message}", request.Message); +// +// return Results.Stream(async stream => +// { +// try +// { +// await foreach (var update in chatClient.GetStreamingResponseAsync([ +// new ChatMessage(ChatRole.User, request.Message) +// ])) +// { +// if (!string.IsNullOrEmpty(update.Text)) +// { +// var bytes = Encoding.UTF8.GetBytes(update.Text); +// await stream.WriteAsync(bytes); +// await stream.FlushAsync(); +// } +// } +// } +// catch (Exception ex) +// { +// logger.LogError(ex, "Error processing streaming chat request"); +// var errorBytes = Encoding.UTF8.GetBytes("\n[Error occurred while streaming response]"); +// await stream.WriteAsync(errorBytes); +// } +// }, "text/plain"); +// }); + +// Health check endpoint +app.MapGet("/health", () => Results.Ok(new { status = "healthy", timestamp = DateTime.UtcNow })); + +// Simple test endpoint that demonstrates AI integration +app.MapGet("/test", async (IChatClient chatClient, ILogger logger) => +{ + logger.LogInformation("Running AI test endpoint"); + ChatOptions options = new() + { + ModelId = AnthropicModels.Claude3Haiku, + MaxOutputTokens = 512, + Tools = [AIFunctionFactory.Create((string personName) => personName switch { + "Alice" => "25", + _ => "40" + }, "GetPersonAge", "Gets the age of the person whose name is specified.")] + }; + + try + { + var response = await chatClient.GetResponseAsync("How old is Alice?", options); + + return Results.Ok(new { + message = "AI test completed successfully", + response = response.Messages?.FirstOrDefault()?.Text ?? "No response", + timestamp = DateTime.UtcNow + }); + } + catch (Exception ex) + { + logger.LogError(ex, "Error in AI test endpoint"); + return Results.Problem("An error occurred during the AI test"); + } +}); + +app.Run(); + +public record ChatRequest(string Message); + +// Claude API client using HttpClient without third-party dependencies +internal class ClaudeChatClient : IChatClient +{ + private readonly HttpClient _httpClient; + private const string ApiBaseUrl = "https://api.anthropic.com/v1/messages"; + + public ClaudeChatClient() + { + var apiKey = Environment.GetEnvironmentVariable("ANTHROPIC_API_KEY") ?? + throw new InvalidOperationException("ANTHROPIC_API_KEY environment variable is required"); + + _httpClient = new HttpClient(); + _httpClient.DefaultRequestHeaders.Add("x-api-key", apiKey); + _httpClient.DefaultRequestHeaders.Add("anthropic-version", "2023-06-01"); + } + + public async Task GetResponseAsync(IEnumerable messages, ChatOptions? options = null, + CancellationToken cancellationToken = default) + { + var requestBody = CreateRequestBody(messages, options, false); + var json = JsonSerializer.Serialize(requestBody); + var content = new StringContent(json, Encoding.UTF8, "application/json"); + + var response = await _httpClient.PostAsync(ApiBaseUrl, content, cancellationToken); + response.EnsureSuccessStatusCode(); + + var responseJson = await response.Content.ReadAsStringAsync(cancellationToken); + using var doc = JsonDocument.Parse(responseJson); + + var usage = new UsageDetails(); + var responseText = "No response"; + if (doc.RootElement.TryGetProperty("content", out var contentArray) && + contentArray.ValueKind == JsonValueKind.Array) + { + var firstContent = contentArray.EnumerateArray().FirstOrDefault(); + if (firstContent.TryGetProperty("text", out var textProperty)) + { + responseText = textProperty.GetString() ?? "No response"; + } + } + if (doc.RootElement.TryGetProperty("usage", out var usageProperty)) + { + if (usageProperty.TryGetProperty("input_tokens", out var inputTokens) && inputTokens.TryGetInt64(out var inputTokenCount)) + { + usage.InputTokenCount = inputTokenCount; + } + if (usageProperty.TryGetProperty("output_tokens", out var outputTokens) && outputTokens.TryGetInt64(out var outputTokenCount)) + { + usage.OutputTokenCount = outputTokenCount; + } + } + + var responseMessage = new ChatMessage(ChatRole.Assistant, responseText); + var chatResponse = new ChatResponse(responseMessage) + { + Usage = usage + }; + + return chatResponse; + } + + public async IAsyncEnumerable GetStreamingResponseAsync(IEnumerable messages, + ChatOptions? options = null, [EnumeratorCancellation] CancellationToken cancellationToken = default) + { + var requestBody = CreateRequestBody(messages, options, true); + var json = JsonSerializer.Serialize(requestBody); + var content = new StringContent(json, Encoding.UTF8, "application/json"); + + var response = await _httpClient.PostAsync(ApiBaseUrl, content, cancellationToken); + response.EnsureSuccessStatusCode(); + + await using var stream = await response.Content.ReadAsStreamAsync(cancellationToken); + using var reader = new StreamReader(stream); + + while (await reader.ReadLineAsync(cancellationToken) is { } line) + { + if (!line.StartsWith("data: ") || line.Length <= 6) + { + continue; + } + + var eventData = line.Substring(6); + if (eventData == "[DONE]") + { + break; + } + + ClaudeStreamEvent? streamEvent = null; + try + { + streamEvent = JsonSerializer.Deserialize(eventData); + } + catch (JsonException) + { + // Skip malformed JSON + continue; + } + + if (streamEvent?.Type == "content_block_delta" && + streamEvent.Delta?.Type == "text_delta") + { + yield return new ChatResponseUpdate(null, streamEvent.Delta.Text); + } + } + } + + private object CreateRequestBody(IEnumerable messages, ChatOptions? options, bool stream) + { + var claudeMessages = messages + .Where(m => m.Role != ChatRole.System) + .Select(m => new + { + role = m.Role == ChatRole.User ? "user" : "assistant", + content = m.Text ?? "" + }) + .ToArray(); + + return new + { + model = "claude-3-5-sonnet-20241022", + max_tokens = options?.MaxOutputTokens ?? 1000, + messages = claudeMessages, + stream = stream + }; + } + + public object? GetService(Type serviceType, object? serviceKey = null) => null; + + public void Dispose() + { + _httpClient?.Dispose(); + } +} + +internal class ClaudeStreamEvent +{ + public string? Type { get; set; } + public ClaudeStreamDelta? Delta { get; set; } +} + +internal class ClaudeStreamDelta +{ + public string? Type { get; set; } + public string? Text { get; set; } +} diff --git a/samples/Sentry.Samples.ME.AI.AspNetCore/Sentry.Samples.ME.AI.AspNetCore.csproj b/samples/Sentry.Samples.ME.AI.AspNetCore/Sentry.Samples.ME.AI.AspNetCore.csproj new file mode 100644 index 0000000000..ca28278952 --- /dev/null +++ b/samples/Sentry.Samples.ME.AI.AspNetCore/Sentry.Samples.ME.AI.AspNetCore.csproj @@ -0,0 +1,25 @@ + + + + net9.0 + + + + + + + + + + sentry-sdks + sentry-dotnet + true + true + + + + + + + + \ No newline at end of file diff --git a/samples/Sentry.Samples.ME.AI.Console/Program.cs b/samples/Sentry.Samples.ME.AI.Console/Program.cs new file mode 100644 index 0000000000..00fd45df72 --- /dev/null +++ b/samples/Sentry.Samples.ME.AI.Console/Program.cs @@ -0,0 +1,204 @@ +#nullable enable +using System.Runtime.CompilerServices; +using System.Text; +using System.Text.Json; +using Microsoft.Extensions.AI; +using Microsoft.Extensions.Logging; +using Sentry.Extensions.AI; + +SentrySdk.Init(options => + { +#if !SENTRY_DSN_DEFINED_IN_ENV + // A DSN is required. You can set here in code, or you can set it in the SENTRY_DSN environment variable. + // See https://docs.sentry.io/product/sentry-basics/dsn-explainer/ + options.Dsn = SamplesShared.Dsn; +#endif + // Set to true to SDK debugging to see the internal messages through the logging library. + options.Debug = true; + // Configure the level of Sentry internal logging + options.DiagnosticLevel = SentryLevel.Debug; + options.SampleRate = 1; + options.TracesSampleRate = 1; + } +); + +using var loggerFactory = LoggerFactory.Create(builder => builder.AddConsole()); +var logger = loggerFactory.CreateLogger(); + +logger.LogInformation("Starting Microsoft.Extensions.AI sample with Sentry instrumentation"); + +// Create Claude API client and wrap it with Sentry instrumentation +var claudeClient = new ClaudeChatClient(); +var chat = claudeClient.WithSentry(agentName: "Anthropic ", system: "anthropic"); + +logger.LogInformation("Making AI call with Sentry instrumentation..."); + +var response = await chat.GetResponseAsync([ + new ChatMessage(ChatRole.User, "Say hello from Sentry sample") +]); + +logger.LogInformation("Response: {ResponseText}", response.Messages); + +// Demonstrate streaming with Sentry instrumentation +logger.LogInformation("Making streaming AI call with Sentry instrumentation..."); + +var streamingResponse = new List(); +await foreach (var update in chat.GetStreamingResponseAsync([ + new ChatMessage(ChatRole.User, "Say hello and goodbye with streaming") + ])) +{ + streamingResponse.Add(update.Text ?? ""); +} + +logger.LogInformation("Streaming Response: {StreamingText}", string.Join("", streamingResponse)); + +logger.LogInformation("Microsoft.Extensions.AI sample completed! Check your Sentry dashboard for the trace data."); + +// Flush Sentry to ensure all transactions are sent before the app exits +await SentrySdk.FlushAsync(TimeSpan.FromSeconds(2)); + +// Claude API client using HttpClient without third-party dependencies +internal class ClaudeChatClient : IChatClient +{ + private readonly HttpClient _httpClient; + private const string ApiBaseUrl = "https://api.anthropic.com/v1/messages"; + + public ClaudeChatClient() + { + var apiKey = Environment.GetEnvironmentVariable("ANTHROPIC_API_KEY") ?? + throw new InvalidOperationException("ANTHROPIC_API_KEY environment variable is required"); + + _httpClient = new HttpClient(); + _httpClient.DefaultRequestHeaders.Add("x-api-key", apiKey); + _httpClient.DefaultRequestHeaders.Add("anthropic-version", "2023-06-01"); + } + + public async Task GetResponseAsync(IEnumerable messages, ChatOptions? options = null, + CancellationToken cancellationToken = default) + { + var requestBody = CreateRequestBody(messages, options, false); + var json = JsonSerializer.Serialize(requestBody); + var content = new StringContent(json, Encoding.UTF8, "application/json"); + + var response = await _httpClient.PostAsync(ApiBaseUrl, content, cancellationToken); + response.EnsureSuccessStatusCode(); + + var responseJson = await response.Content.ReadAsStringAsync(cancellationToken); + using var doc = JsonDocument.Parse(responseJson); + + var usage = new UsageDetails(); + var responseText = "No response"; + if (doc.RootElement.TryGetProperty("content", out var contentArray) && + contentArray.ValueKind == JsonValueKind.Array) + { + var firstContent = contentArray.EnumerateArray().FirstOrDefault(); + if (firstContent.TryGetProperty("text", out var textProperty)) + { + responseText = textProperty.GetString() ?? "No response"; + } + } + if (doc.RootElement.TryGetProperty("usage", out var usageProperty)) + { + if (usageProperty.TryGetProperty("input_tokens", out var inputTokens) && inputTokens.TryGetInt64(out var inputTokenCount)) + { + usage.InputTokenCount = inputTokenCount; + } + if (usageProperty.TryGetProperty("output_tokens", out var outputTokens) && outputTokens.TryGetInt64(out var outputTokenCount)) + { + usage.OutputTokenCount = outputTokenCount; + } + } + + var responseMessage = new ChatMessage(ChatRole.Assistant, responseText); + var chatResponse = new ChatResponse(responseMessage) + { + Usage = usage + }; + + return chatResponse; + } + + public async IAsyncEnumerable GetStreamingResponseAsync(IEnumerable messages, + ChatOptions? options = null, [EnumeratorCancellation] CancellationToken cancellationToken = default) + { + var requestBody = CreateRequestBody(messages, options, true); + var json = JsonSerializer.Serialize(requestBody); + var content = new StringContent(json, Encoding.UTF8, "application/json"); + + var response = await _httpClient.PostAsync(ApiBaseUrl, content, cancellationToken); + response.EnsureSuccessStatusCode(); + + await using var stream = await response.Content.ReadAsStreamAsync(cancellationToken); + using var reader = new StreamReader(stream); + + while (await reader.ReadLineAsync(cancellationToken) is { } line) + { + if (!line.StartsWith("data: ") || line.Length <= 6) + { + continue; + } + + var eventData = line.Substring(6); + if (eventData == "[DONE]") + { + break; + } + + ClaudeStreamEvent? streamEvent = null; + try + { + streamEvent = JsonSerializer.Deserialize(eventData); + } + catch (JsonException) + { + // Skip malformed JSON + continue; + } + + if (streamEvent?.Type == "content_block_delta" && + streamEvent.Delta?.Type == "text_delta") + { + yield return new ChatResponseUpdate(null, streamEvent.Delta.Text); + } + } + } + + private object CreateRequestBody(IEnumerable messages, ChatOptions? options, bool stream) + { + var claudeMessages = messages + .Where(m => m.Role != ChatRole.System) + .Select(m => new + { + role = m.Role == ChatRole.User ? "user" : "assistant", + content = m.Text ?? "" + }) + .ToArray(); + + return new + { + model = "claude-3-5-sonnet-20241022", + max_tokens = options?.MaxOutputTokens ?? 1000, + messages = claudeMessages, + stream = stream + }; + } + + public object? GetService(Type serviceType, object? serviceKey = null) => null; + + public void Dispose() + { + _httpClient?.Dispose(); + } +} + +internal class ClaudeStreamEvent +{ + public string? Type { get; set; } + public ClaudeStreamDelta? Delta { get; set; } +} + +internal class ClaudeStreamDelta +{ + public string? Type { get; set; } + public string? Text { get; set; } +} diff --git a/samples/Sentry.Samples.ME.AI.Console/Sentry.Samples.ME.AI.Console.csproj b/samples/Sentry.Samples.ME.AI.Console/Sentry.Samples.ME.AI.Console.csproj new file mode 100644 index 0000000000..c9dc14143d --- /dev/null +++ b/samples/Sentry.Samples.ME.AI.Console/Sentry.Samples.ME.AI.Console.csproj @@ -0,0 +1,25 @@ + + + + Exe + net9.0 + + + + + + + + + sentry-sdks + sentry-dotnet + true + true + + + + + + + + diff --git a/src/Sentry.Extensions.AI/Extensions/ServiceCollectionExtensions.cs b/src/Sentry.Extensions.AI/Extensions/ServiceCollectionExtensions.cs new file mode 100644 index 0000000000..a7917f2593 --- /dev/null +++ b/src/Sentry.Extensions.AI/Extensions/ServiceCollectionExtensions.cs @@ -0,0 +1,33 @@ +using Microsoft.Extensions.AI; +using Sentry.Extensibility; + +// ReSharper disable once CheckNamespace -- Discoverability +namespace Sentry.Extensions.AI; + +/// +/// Extensions to instrument Microsoft.Extensions.AI builders with Sentry +/// +[EditorBrowsable(EditorBrowsableState.Never)] +public static class SentryAIExtensions +{ + // + // Adds Sentry instrumentation to the ChatClientBuilder pipeline. + // + // public static ChatClientBuilder UseSentry(this ChatClientBuilder builder, string? agentName = null, string? model = null, string? system = null) + // { + // return builder.Use(inner => + // { + // // Try to get IHub from DI first, fallback to HubAdapter.Instance + // var hub = inner.GetService() ?? HubAdapter.Instance; + // return new SentryChatClient(inner, hub, agentName, model, system); + // }); + // } + + /// + /// Wraps an IChatClient with Sentry instrumentation. + /// + public static IChatClient WithSentry(this IChatClient client, string? agentName = null, string? system = null) + { + return new SentryChatClient(client, HubAdapter.Instance, agentName, system); + } +} diff --git a/src/Sentry.Extensions.AI/Sentry.Extensions.AI.csproj b/src/Sentry.Extensions.AI/Sentry.Extensions.AI.csproj new file mode 100644 index 0000000000..891c917097 --- /dev/null +++ b/src/Sentry.Extensions.AI/Sentry.Extensions.AI.csproj @@ -0,0 +1,21 @@ + + + + net9.0;net8.0 + $(PackageTags);Microsoft.Extensions.AI;AI;LLM + Microsoft.Extensions.AI integration for Sentry - captures AI Agent telemetry spans per Sentry AI Agents module. + + + + + + + + + + + + + + + diff --git a/src/Sentry.Extensions.AI/SentryAIFunctionWrapper.cs b/src/Sentry.Extensions.AI/SentryAIFunctionWrapper.cs new file mode 100644 index 0000000000..4f6154ed39 --- /dev/null +++ b/src/Sentry.Extensions.AI/SentryAIFunctionWrapper.cs @@ -0,0 +1,24 @@ +using Microsoft.Extensions.AI; + +namespace Sentry.Extensions.AI; + +public static class SentryAIFunctionWrapper +{ + private static AITool WrapToolCall(AITool aiTool, IHub hub) + { + if (aiTool is not AIFunction tool) + { + return aiTool; + } + + tool.UnderlyingMethod = () => + { + const string operation = "gen_ai.execute_tool"; + var spanName = aiTool.Name; + var toolSpan = hub.GetSpan()?.StartChild(operation, spanName) ?? hub.StartTransaction(spanName, operation); + var returnVal = tool.UnderlyingMethod.Invoke(); + return returnVal; + } + } + +} diff --git a/src/Sentry.Extensions.AI/SentryAISpanEnricher.cs b/src/Sentry.Extensions.AI/SentryAISpanEnricher.cs new file mode 100644 index 0000000000..07311d84f0 --- /dev/null +++ b/src/Sentry.Extensions.AI/SentryAISpanEnricher.cs @@ -0,0 +1,121 @@ +using Microsoft.Extensions.AI; + +namespace Sentry.Extensions.AI; + +/// +/// Populates various span attributes specific to AI +/// +public static class SentryAISpanEnricher +{ + /// + /// Enrich a span with request information + /// + /// Span to enrich + /// Messages + /// Options + /// Agent's name + /// The AI product (e.g. OpenAI, Anthropic, etc) + public static void EnrichWithRequest(ISpan span, ChatMessage[] messages, ChatOptions? options, string? agentName, string? system) + { + span.SetData("gen_ai.operation.name", "chat"); + if (system is { Length: > 0 }) + { + span.SetData("gen_ai.system", system); + } + + if (options?.ModelId is { } modelId) + { + span.SetData("gen_ai.request.model", modelId); + } + else + { + span.SetData("gen_ai.request.model", "Unknown model"); + } + + if (messages is { Length: > 0 }) + { + span.SetData("gen_ai.request.messages", FormatRequestMessage(messages)); + } + + if (options?.Tools is { } tools) + { + span.SetData("gen_ai.request.available_tools", FormatAvailableTools(tools)); + } + + if (options?.Temperature is { } temperature) + { + span.SetData("gen_ai.request.temperature", temperature); + } + + if (options?.MaxOutputTokens is { } maxOutputTokens) + { + span.SetData("gen_ai.request.max_tokens", maxOutputTokens); + } + + if (options?.TopP is { } topP) + { + span.SetData("gen_ai.request.top_p", topP); + } + + if (options?.FrequencyPenalty is { } frequencyPenalty) + { + span.SetData("gen_ai.request.frequency_penalty", frequencyPenalty); + } + + if (options?.PresencePenalty is { } presencePenalty) + { + span.SetData("gen_ai.request.presence_penalty", presencePenalty); + } + + if (agentName is { Length: > 0 }) + { + span.SetData("gen_ai.agent.name", agentName); + } + } + + /// + /// Enriches the span using the response. + /// + public static void EnrichWithResponse(ISpan span, ChatResponse response) + { + if (response.Usage is { } usage) + { + var inputTokens = usage.InputTokenCount; + var outputTokens = usage.OutputTokenCount; + + if (inputTokens.HasValue) + { + span.SetData("gen_ai.usage.input_tokens", inputTokens.Value); + } + + if (outputTokens.HasValue) + { + span.SetData("gen_ai.usage.output_tokens", outputTokens.Value); + } + + if (inputTokens.HasValue && outputTokens.HasValue) + { + span.SetData("gen_ai.usage.total_tokens", inputTokens.Value + outputTokens.Value); + } + } + + if (response.Text is { } responseText) + { + span.SetData("gen_ai.response.text", responseText); + } + + if (response.ModelId is { } modelId) + { + span.SetData("gen_ai.response.model_id", modelId); + } + } + + private static string FormatAvailableTools(IList tools) => + FormatAsJson(tools, tool => new { name = tool.Name, description = tool.Description }); + + private static string FormatRequestMessage(ChatMessage[] messages) => + FormatAsJson(messages, message => new { role = message.Role, content = message.Text }); + + private static string FormatAsJson(IEnumerable items, Func selector) => + JsonSerializer.Serialize(items.Select(selector)); +} diff --git a/src/Sentry.Extensions.AI/SentryChatClient.cs b/src/Sentry.Extensions.AI/SentryChatClient.cs new file mode 100644 index 0000000000..178bd034c5 --- /dev/null +++ b/src/Sentry.Extensions.AI/SentryChatClient.cs @@ -0,0 +1,38 @@ +using Microsoft.Extensions.AI; + +namespace Sentry.Extensions.AI; + +internal sealed class SentryChatClient( + IChatClient innerClient, + IHub hub, + string? agentName, + string? system) + : DelegatingChatClient(innerClient) +{ + public override async Task GetResponseAsync(IEnumerable messages, + ChatOptions? options = null, + CancellationToken cancellationToken = new()) + { + const string operation = "gen_ai.chat"; + var spanName = agentName is { Length: > 0 } ? $"chat {agentName}" : "chat"; + var initialSpan = hub.GetSpan()?.StartChild(operation, spanName) ?? hub.StartTransaction(spanName, operation); + + try + { + var chatMessages = messages as ChatMessage[] ?? messages.ToArray(); + SentryAISpanEnricher.EnrichWithRequest(initialSpan, chatMessages, options, agentName, system); + + var response = await base.GetResponseAsync(chatMessages, options, cancellationToken).ConfigureAwait(false); + + SentryAISpanEnricher.EnrichWithResponse(initialSpan, response); + initialSpan.Finish(SpanStatus.Ok); + return response; + } + catch (Exception ex) + { + initialSpan.Finish(ex); + hub.CaptureException(ex); + throw; + } + } +} diff --git a/test/Sentry.Extensions.AI.Tests/Sentry.Extensions.AI.Tests.csproj b/test/Sentry.Extensions.AI.Tests/Sentry.Extensions.AI.Tests.csproj new file mode 100644 index 0000000000..601422bf85 --- /dev/null +++ b/test/Sentry.Extensions.AI.Tests/Sentry.Extensions.AI.Tests.csproj @@ -0,0 +1,17 @@ + + + + net9.0;net8.0 + + + + + + + + + + + + + diff --git a/test/Sentry.Extensions.AI.Tests/SentryChatClientTests.cs b/test/Sentry.Extensions.AI.Tests/SentryChatClientTests.cs new file mode 100644 index 0000000000..f9739e1f71 --- /dev/null +++ b/test/Sentry.Extensions.AI.Tests/SentryChatClientTests.cs @@ -0,0 +1,59 @@ +#nullable enable +using Sentry.Extensions.AI; +using Microsoft.Extensions.AI; + +namespace Sentry.Extensions.AI.Tests; + +public class SentryChatClientTests +{ + [Fact] + public async Task CompleteAsync_CallsInnerClient() + { + var inner = Substitute.For(); + var message = new ChatMessage(ChatRole.Assistant, "ok"); + var chatResponse = new ChatResponse(message); + inner.GetResponseAsync(Arg.Any>(), Arg.Any(), Arg.Any()) + .Returns(Task.FromResult(chatResponse)); + + var hub = Substitute.For(); + var sentryChatClient = new SentryChatClient(inner, hub, agentName: "Agent", model: "gpt-4o-mini", system: "openai"); + + var res = await sentryChatClient.GetResponseAsync([new ChatMessage(ChatRole.User, "hi")], null); + + Assert.Equal([message], res.Messages); + await inner.Received(1).GetResponseAsync(Arg.Any>(), Arg.Any(), Arg.Any()); + } + + [Fact] + public async Task CompleteStreamingAsync_CallsInnerClient() + { + var inner = Substitute.For(); + + inner.GetStreamingResponseAsync(Arg.Any>(), Arg.Any(), Arg.Any()) + .Returns(CreateTestStreamingUpdates()); + + var hub = Substitute.For(); + var client = new SentryChatClient(inner, hub, agentName: "Agent", model: "gpt-4o-mini", system: "openai"); + + var results = new List(); + await foreach (var update in client.GetStreamingResponseAsync([new ChatMessage(ChatRole.User, "hi")], null)) + { + results.Add(update); + } + + Assert.Equal(2, results.Count); + Assert.Equal("Hello", results[0].Text); + Assert.Equal(" World!", results[1].Text); + + inner.Received(1).GetStreamingResponseAsync(Arg.Any>(), Arg.Any(), Arg.Any()); + } + + private static async IAsyncEnumerable CreateTestStreamingUpdates() + { + yield return new ChatResponseUpdate(ChatRole.System, "Hello"); + await Task.Yield(); // Make it actually async + yield return new ChatResponseUpdate(ChatRole.System, " World!"); + } +} + + From b907afb668f6b852be0d29dd4e524b68e0daa0a7 Mon Sep 17 00:00:00 2001 From: Alex Sohn Date: Fri, 17 Oct 2025 10:32:53 -0400 Subject: [PATCH 02/86] basic tool call working --- .../Extensions/SentryAIExtensions.cs | 47 +++++++++++++++++++ .../Extensions/ServiceCollectionExtensions.cs | 33 ------------- .../SentryAIFunctionWrapper.cs | 24 ---------- .../SentryAISpanEnricher.cs | 17 ++----- src/Sentry.Extensions.AI/SentryChatClient.cs | 8 ++-- .../SentryInstrumentedFunction.cs | 47 +++++++++++++++++++ 6 files changed, 101 insertions(+), 75 deletions(-) create mode 100644 src/Sentry.Extensions.AI/Extensions/SentryAIExtensions.cs delete mode 100644 src/Sentry.Extensions.AI/Extensions/ServiceCollectionExtensions.cs delete mode 100644 src/Sentry.Extensions.AI/SentryAIFunctionWrapper.cs create mode 100644 src/Sentry.Extensions.AI/SentryInstrumentedFunction.cs diff --git a/src/Sentry.Extensions.AI/Extensions/SentryAIExtensions.cs b/src/Sentry.Extensions.AI/Extensions/SentryAIExtensions.cs new file mode 100644 index 0000000000..48047d17f4 --- /dev/null +++ b/src/Sentry.Extensions.AI/Extensions/SentryAIExtensions.cs @@ -0,0 +1,47 @@ +using Microsoft.Extensions.AI; +using Sentry.Extensibility; + +// ReSharper disable once CheckNamespace -- Discoverability +namespace Sentry.Extensions.AI; + +/// +/// Extensions to instrument Microsoft.Extensions.AI builders with Sentry +/// +[EditorBrowsable(EditorBrowsableState.Never)] +public static class SentryAIExtensions +{ + /// + /// Wrap tool calls specified in with Sentry agent instrumentation + /// + public static ChatOptions WithSentry( + this ChatOptions options) + { + if (options.Tools is null || options.Tools.Count == 0) + { + return options; + } + + for (var i = 0; i < options.Tools.Count; i++) + { + var tool = options.Tools[i]; + if (tool is AIFunction fn and not SentryInstrumentedFunction) + { + options.Tools[i] = new SentryInstrumentedFunction(fn); + } + else + { + options.Tools[i] = tool; + } + } + + return options; + } + + /// + /// Wraps an IChatClient with Sentry instrumentation. + /// + public static IChatClient WithSentry(this IChatClient client) + { + return new SentryChatClient(client, HubAdapter.Instance); + } +} diff --git a/src/Sentry.Extensions.AI/Extensions/ServiceCollectionExtensions.cs b/src/Sentry.Extensions.AI/Extensions/ServiceCollectionExtensions.cs deleted file mode 100644 index a7917f2593..0000000000 --- a/src/Sentry.Extensions.AI/Extensions/ServiceCollectionExtensions.cs +++ /dev/null @@ -1,33 +0,0 @@ -using Microsoft.Extensions.AI; -using Sentry.Extensibility; - -// ReSharper disable once CheckNamespace -- Discoverability -namespace Sentry.Extensions.AI; - -/// -/// Extensions to instrument Microsoft.Extensions.AI builders with Sentry -/// -[EditorBrowsable(EditorBrowsableState.Never)] -public static class SentryAIExtensions -{ - // - // Adds Sentry instrumentation to the ChatClientBuilder pipeline. - // - // public static ChatClientBuilder UseSentry(this ChatClientBuilder builder, string? agentName = null, string? model = null, string? system = null) - // { - // return builder.Use(inner => - // { - // // Try to get IHub from DI first, fallback to HubAdapter.Instance - // var hub = inner.GetService() ?? HubAdapter.Instance; - // return new SentryChatClient(inner, hub, agentName, model, system); - // }); - // } - - /// - /// Wraps an IChatClient with Sentry instrumentation. - /// - public static IChatClient WithSentry(this IChatClient client, string? agentName = null, string? system = null) - { - return new SentryChatClient(client, HubAdapter.Instance, agentName, system); - } -} diff --git a/src/Sentry.Extensions.AI/SentryAIFunctionWrapper.cs b/src/Sentry.Extensions.AI/SentryAIFunctionWrapper.cs deleted file mode 100644 index 4f6154ed39..0000000000 --- a/src/Sentry.Extensions.AI/SentryAIFunctionWrapper.cs +++ /dev/null @@ -1,24 +0,0 @@ -using Microsoft.Extensions.AI; - -namespace Sentry.Extensions.AI; - -public static class SentryAIFunctionWrapper -{ - private static AITool WrapToolCall(AITool aiTool, IHub hub) - { - if (aiTool is not AIFunction tool) - { - return aiTool; - } - - tool.UnderlyingMethod = () => - { - const string operation = "gen_ai.execute_tool"; - var spanName = aiTool.Name; - var toolSpan = hub.GetSpan()?.StartChild(operation, spanName) ?? hub.StartTransaction(spanName, operation); - var returnVal = tool.UnderlyingMethod.Invoke(); - return returnVal; - } - } - -} diff --git a/src/Sentry.Extensions.AI/SentryAISpanEnricher.cs b/src/Sentry.Extensions.AI/SentryAISpanEnricher.cs index 07311d84f0..92e5060787 100644 --- a/src/Sentry.Extensions.AI/SentryAISpanEnricher.cs +++ b/src/Sentry.Extensions.AI/SentryAISpanEnricher.cs @@ -5,7 +5,7 @@ namespace Sentry.Extensions.AI; /// /// Populates various span attributes specific to AI /// -public static class SentryAISpanEnricher +internal static class SentryAISpanEnricher { /// /// Enrich a span with request information @@ -13,15 +13,11 @@ public static class SentryAISpanEnricher /// Span to enrich /// Messages /// Options - /// Agent's name - /// The AI product (e.g. OpenAI, Anthropic, etc) - public static void EnrichWithRequest(ISpan span, ChatMessage[] messages, ChatOptions? options, string? agentName, string? system) + internal static void EnrichWithRequest(ISpan span, ChatMessage[] messages, ChatOptions? options) { + // Currently, all top-level spans will start as "chat" + // The agent creation/invocation doesn't really work in Microsoft.Extensions.AI span.SetData("gen_ai.operation.name", "chat"); - if (system is { Length: > 0 }) - { - span.SetData("gen_ai.system", system); - } if (options?.ModelId is { } modelId) { @@ -66,11 +62,6 @@ public static void EnrichWithRequest(ISpan span, ChatMessage[] messages, ChatOpt { span.SetData("gen_ai.request.presence_penalty", presencePenalty); } - - if (agentName is { Length: > 0 }) - { - span.SetData("gen_ai.agent.name", agentName); - } } /// diff --git a/src/Sentry.Extensions.AI/SentryChatClient.cs b/src/Sentry.Extensions.AI/SentryChatClient.cs index 178bd034c5..c4fe411af6 100644 --- a/src/Sentry.Extensions.AI/SentryChatClient.cs +++ b/src/Sentry.Extensions.AI/SentryChatClient.cs @@ -4,9 +4,7 @@ namespace Sentry.Extensions.AI; internal sealed class SentryChatClient( IChatClient innerClient, - IHub hub, - string? agentName, - string? system) + IHub hub) : DelegatingChatClient(innerClient) { public override async Task GetResponseAsync(IEnumerable messages, @@ -14,13 +12,13 @@ public override async Task GetResponseAsync(IEnumerable 0 } ? $"chat {agentName}" : "chat"; + var spanName = InnerClient.GetType().Name; var initialSpan = hub.GetSpan()?.StartChild(operation, spanName) ?? hub.StartTransaction(spanName, operation); try { var chatMessages = messages as ChatMessage[] ?? messages.ToArray(); - SentryAISpanEnricher.EnrichWithRequest(initialSpan, chatMessages, options, agentName, system); + SentryAISpanEnricher.EnrichWithRequest(initialSpan, chatMessages, options); var response = await base.GetResponseAsync(chatMessages, options, cancellationToken).ConfigureAwait(false); diff --git a/src/Sentry.Extensions.AI/SentryInstrumentedFunction.cs b/src/Sentry.Extensions.AI/SentryInstrumentedFunction.cs new file mode 100644 index 0000000000..8635c2e30e --- /dev/null +++ b/src/Sentry.Extensions.AI/SentryInstrumentedFunction.cs @@ -0,0 +1,47 @@ +using Microsoft.Extensions.AI; +using Sentry.Extensibility; + +namespace Sentry.Extensions.AI; + +internal sealed class SentryInstrumentedFunction(AIFunction innerFunction) : DelegatingAIFunction(innerFunction) +{ + private readonly HubAdapter _hub = HubAdapter.Instance; + + protected override async ValueTask InvokeCoreAsync( + AIFunctionArguments arguments, + CancellationToken cancellationToken) + { + var parentSpan = _hub.GetSpan(); + const string operation = "gen_ai.execute_tool"; + var spanName = $"execute_tool {Name}"; + var currSpan = parentSpan?.StartChild(operation, spanName) ?? _hub.StartTransaction(spanName, operation); + + currSpan.SetData("gen_ai.operation.name", "execute_tool"); + currSpan.SetData("gen_ai.tool.name", Name); + + if (Description is { } description) + { + currSpan.SetData("gen_ai.tool.description", description); + } + + currSpan.SetData("gen_ai.tool.input", arguments); + + try + { + var result = await base.InvokeCoreAsync(arguments, cancellationToken).ConfigureAwait(false); + + if (result?.ToString() is { } resultString) + { + currSpan.SetData("gen_ai.tool.output", resultString); + } + + currSpan.Finish(SpanStatus.Ok); + return result; + } + catch (Exception ex) + { + currSpan.Finish(ex); + throw; + } + } +} From 8a996b8806269415bb09a50c2a39a2ddd2cb750e Mon Sep 17 00:00:00 2001 From: Alex Sohn Date: Mon, 20 Oct 2025 01:27:48 -0400 Subject: [PATCH 03/86] add tests and tool instrumentation --- .../Program.cs | 303 ++---- .../Sentry.Samples.ME.AI.Console/Program.cs | 252 ++--- .../Sentry.Samples.ME.AI.Console.csproj | 1 + .../Extensions/SentryAIExtensions.cs | 19 +- src/Sentry.Extensions.AI/SentryAIOptions.cs | 26 + .../SentryAISpanEnricher.cs | 58 +- src/Sentry.Extensions.AI/SentryChatClient.cs | 116 ++- .../SentryInstrumentedFunction.cs | 6 +- src/Sentry/Sentry.csproj | 2 + .../SentryAIExtensionsTests.cs | 205 +++++ .../SentryAIOptionsTests.cs | 112 +++ .../SentryAISpanEnricherTests.cs | 318 +++++++ .../SentryChatClientTests.cs | 12 +- .../SentryInstrumentedFunctionTests.cs | 221 +++++ .../_CVDTQ2MGH4_2025-10-20_00_35_51.trx | 863 ++++++++++++++++++ .../_CVDTQ2MGH4_2025-10-20_00_35_51[1].trx | 863 ++++++++++++++++++ 16 files changed, 2952 insertions(+), 425 deletions(-) create mode 100644 src/Sentry.Extensions.AI/SentryAIOptions.cs create mode 100644 test/Sentry.Extensions.AI.Tests/SentryAIExtensionsTests.cs create mode 100644 test/Sentry.Extensions.AI.Tests/SentryAIOptionsTests.cs create mode 100644 test/Sentry.Extensions.AI.Tests/SentryAISpanEnricherTests.cs create mode 100644 test/Sentry.Extensions.AI.Tests/SentryInstrumentedFunctionTests.cs create mode 100644 test/Sentry.Extensions.AI.Tests/TestResults/_CVDTQ2MGH4_2025-10-20_00_35_51.trx create mode 100644 test/Sentry.Extensions.AI.Tests/TestResults/_CVDTQ2MGH4_2025-10-20_00_35_51[1].trx diff --git a/samples/Sentry.Samples.ME.AI.AspNetCore/Program.cs b/samples/Sentry.Samples.ME.AI.AspNetCore/Program.cs index 0826045622..0a0b0ca997 100644 --- a/samples/Sentry.Samples.ME.AI.AspNetCore/Program.cs +++ b/samples/Sentry.Samples.ME.AI.AspNetCore/Program.cs @@ -1,11 +1,7 @@ #nullable enable -using System.Runtime.CompilerServices; -using System.Text; -using System.Text.Json; using Anthropic.SDK; using Anthropic.SDK.Constants; using Microsoft.Extensions.AI; -using Microsoft.SemanticKernel; using Sentry.Extensions.AI; var builder = WebApplication.CreateBuilder(args); @@ -29,86 +25,105 @@ .AsBuilder() .UseFunctionInvocation() .Build() - .WithSentry(agentName: "Anthropic", system: "anthropic"); + .WithSentry(options => + { + options.IncludeAIRequestMessages = false; + options.IncludeAIResponseContent = false; + }); // Register the Claude API client and Sentry-instrumented chat client -builder.Services.AddKeyedSingleton("claude3_5", client); +builder.Services.AddSingleton(client); var app = builder.Build(); -// Endpoint for regular AI chat -app.MapPost("/chat", async (ChatRequest request, IChatClient chatClient, ILogger logger) => -{ - logger.LogInformation("Handling chat request with message: {Message}", request.Message); - - try - { - var response = await chatClient.GetResponseAsync([ - new ChatMessage(ChatRole.User, request.Message) - ]); - - return Results.Ok(new { response = response.Messages?.FirstOrDefault()?.Text ?? "No response" }); - } - catch (Exception ex) - { - logger.LogError(ex, "Error processing chat request"); - return Results.Problem("An error occurred while processing your request"); - } -}); - -// Endpoint for streaming AI chat -// app.MapPost("/chat/stream", async (ChatRequest request, IChatClient chatClient, ILogger logger) => -// { -// logger.LogInformation("Handling streaming chat request with message: {Message}", request.Message); -// -// return Results.Stream(async stream => -// { -// try -// { -// await foreach (var update in chatClient.GetStreamingResponseAsync([ -// new ChatMessage(ChatRole.User, request.Message) -// ])) -// { -// if (!string.IsNullOrEmpty(update.Text)) -// { -// var bytes = Encoding.UTF8.GetBytes(update.Text); -// await stream.WriteAsync(bytes); -// await stream.FlushAsync(); -// } -// } -// } -// catch (Exception ex) -// { -// logger.LogError(ex, "Error processing streaming chat request"); -// var errorBytes = Encoding.UTF8.GetBytes("\n[Error occurred while streaming response]"); -// await stream.WriteAsync(errorBytes); -// } -// }, "text/plain"); -// }); - -// Health check endpoint -app.MapGet("/health", () => Results.Ok(new { status = "healthy", timestamp = DateTime.UtcNow })); - -// Simple test endpoint that demonstrates AI integration +// Simple test endpoint that demonstrates AI integration with multiple tools app.MapGet("/test", async (IChatClient chatClient, ILogger logger) => { - logger.LogInformation("Running AI test endpoint"); - ChatOptions options = new() + logger.LogInformation("Running AI test endpoint with multiple tools"); + var options = new ChatOptions { ModelId = AnthropicModels.Claude3Haiku, - MaxOutputTokens = 512, - Tools = [AIFunctionFactory.Create((string personName) => personName switch { - "Alice" => "25", - _ => "40" - }, "GetPersonAge", "Gets the age of the person whose name is specified.")] - }; + MaxOutputTokens = 1024, + Tools = [ + // Tool 1: Quick response with minimal delay + AIFunctionFactory.Create(async (string personName) => + { + logger.LogInformation("GetPersonAge called for {PersonName}", personName); + await Task.Delay(100); // 100ms delay + return personName switch { + "Alice" => "25", + "Bob" => "30", + "Charlie" => "35", + _ => "40" + }; + }, "GetPersonAge", "Gets the age of the person whose name is specified. Takes about 100ms to complete."), + + // Tool 2: Medium delay tool for weather + AIFunctionFactory.Create(async (string location) => + { + logger.LogInformation("GetWeather called for {Location}", location); + await Task.Delay(500); // 500ms delay + return location.ToLower() switch { + "new york" => "Sunny, 72°F", + "london" => "Cloudy, 60°F", + "tokyo" => "Rainy, 68°F", + _ => "Unknown weather conditions" + }; + }, "GetWeather", "Gets the current weather for a location. Takes about 500ms to complete."), + + // Tool 3: Slow tool for complex calculation + AIFunctionFactory.Create(async (int number) => + { + logger.LogInformation("ComplexCalculation called with {Number}", number); + await Task.Delay(1000); // 1000ms delay + var result = (number * number) + (number * 10); + return $"Complex calculation result for {number}: {result}"; + }, "ComplexCalculation", "Performs a complex mathematical calculation. Takes about 1 second to complete."), + + // Tool 4: Tool that can reference other data + AIFunctionFactory.Create(async (string personName, string location) => + { + logger.LogInformation("GetPersonInfo called for {PersonName} in {Location}", personName, location); + await Task.Delay(300); // 300ms delay + var age = personName switch { + "Alice" => "25", + "Bob" => "30", + "Charlie" => "35", + _ => "40" + }; + var weather = location.ToLower() switch { + "new york" => "Sunny, 72°F", + "london" => "Cloudy, 60°F", + "tokyo" => "Rainy, 68°F", + _ => "Unknown weather conditions" + }; + return $"{personName} (age {age}) is experiencing {weather} weather in {location}"; + }, "GetPersonInfo", "Gets comprehensive info about a person in a specific location by combining age and weather data. Takes about 300ms."), + + // Tool 5: Data aggregation tool that requests individual ages + AIFunctionFactory.Create(async (int[] ages) => + { + logger.LogInformation("CalculateAverageAge called with ages: {Ages}", string.Join(", ", ages)); + await Task.Delay(200); // 200ms delay for calculation + if (ages.Length == 0) + { + return "No ages provided"; + } + + var average = ages.Average(); + return $"Average age calculated: {average:F1} years from {ages.Length} people. Individual ages: {string.Join(", ", ages)}"; + }, "CalculateAverageAge", "Calculates the average from a list of ages. You should first get individual ages using GetPersonAge, then use this tool to calculate the average. Takes about 200ms to complete.") + ] + }.WithSentry(); try { - var response = await chatClient.GetResponseAsync("How old is Alice?", options); + var response = await chatClient.GetResponseAsync( + "Please help me with the following tasks: 1) Find Alice's age, 2) Get weather in New York, 3) Calculate a complex result for number 15, 4) Get comprehensive info for Bob in London, and 5) Calculate average age for Alice, Bob, and Charlie (first get each person's age individually using GetPersonAge, then use CalculateAverageAge with those results). Please use the appropriate tools for each task and demonstrate tool chaining where needed.", + options); return Results.Ok(new { - message = "AI test completed successfully", + message = "AI test with multiple tools completed successfully", response = response.Messages?.FirstOrDefault()?.Text ?? "No response", timestamp = DateTime.UtcNow }); @@ -121,151 +136,3 @@ }); app.Run(); - -public record ChatRequest(string Message); - -// Claude API client using HttpClient without third-party dependencies -internal class ClaudeChatClient : IChatClient -{ - private readonly HttpClient _httpClient; - private const string ApiBaseUrl = "https://api.anthropic.com/v1/messages"; - - public ClaudeChatClient() - { - var apiKey = Environment.GetEnvironmentVariable("ANTHROPIC_API_KEY") ?? - throw new InvalidOperationException("ANTHROPIC_API_KEY environment variable is required"); - - _httpClient = new HttpClient(); - _httpClient.DefaultRequestHeaders.Add("x-api-key", apiKey); - _httpClient.DefaultRequestHeaders.Add("anthropic-version", "2023-06-01"); - } - - public async Task GetResponseAsync(IEnumerable messages, ChatOptions? options = null, - CancellationToken cancellationToken = default) - { - var requestBody = CreateRequestBody(messages, options, false); - var json = JsonSerializer.Serialize(requestBody); - var content = new StringContent(json, Encoding.UTF8, "application/json"); - - var response = await _httpClient.PostAsync(ApiBaseUrl, content, cancellationToken); - response.EnsureSuccessStatusCode(); - - var responseJson = await response.Content.ReadAsStringAsync(cancellationToken); - using var doc = JsonDocument.Parse(responseJson); - - var usage = new UsageDetails(); - var responseText = "No response"; - if (doc.RootElement.TryGetProperty("content", out var contentArray) && - contentArray.ValueKind == JsonValueKind.Array) - { - var firstContent = contentArray.EnumerateArray().FirstOrDefault(); - if (firstContent.TryGetProperty("text", out var textProperty)) - { - responseText = textProperty.GetString() ?? "No response"; - } - } - if (doc.RootElement.TryGetProperty("usage", out var usageProperty)) - { - if (usageProperty.TryGetProperty("input_tokens", out var inputTokens) && inputTokens.TryGetInt64(out var inputTokenCount)) - { - usage.InputTokenCount = inputTokenCount; - } - if (usageProperty.TryGetProperty("output_tokens", out var outputTokens) && outputTokens.TryGetInt64(out var outputTokenCount)) - { - usage.OutputTokenCount = outputTokenCount; - } - } - - var responseMessage = new ChatMessage(ChatRole.Assistant, responseText); - var chatResponse = new ChatResponse(responseMessage) - { - Usage = usage - }; - - return chatResponse; - } - - public async IAsyncEnumerable GetStreamingResponseAsync(IEnumerable messages, - ChatOptions? options = null, [EnumeratorCancellation] CancellationToken cancellationToken = default) - { - var requestBody = CreateRequestBody(messages, options, true); - var json = JsonSerializer.Serialize(requestBody); - var content = new StringContent(json, Encoding.UTF8, "application/json"); - - var response = await _httpClient.PostAsync(ApiBaseUrl, content, cancellationToken); - response.EnsureSuccessStatusCode(); - - await using var stream = await response.Content.ReadAsStreamAsync(cancellationToken); - using var reader = new StreamReader(stream); - - while (await reader.ReadLineAsync(cancellationToken) is { } line) - { - if (!line.StartsWith("data: ") || line.Length <= 6) - { - continue; - } - - var eventData = line.Substring(6); - if (eventData == "[DONE]") - { - break; - } - - ClaudeStreamEvent? streamEvent = null; - try - { - streamEvent = JsonSerializer.Deserialize(eventData); - } - catch (JsonException) - { - // Skip malformed JSON - continue; - } - - if (streamEvent?.Type == "content_block_delta" && - streamEvent.Delta?.Type == "text_delta") - { - yield return new ChatResponseUpdate(null, streamEvent.Delta.Text); - } - } - } - - private object CreateRequestBody(IEnumerable messages, ChatOptions? options, bool stream) - { - var claudeMessages = messages - .Where(m => m.Role != ChatRole.System) - .Select(m => new - { - role = m.Role == ChatRole.User ? "user" : "assistant", - content = m.Text ?? "" - }) - .ToArray(); - - return new - { - model = "claude-3-5-sonnet-20241022", - max_tokens = options?.MaxOutputTokens ?? 1000, - messages = claudeMessages, - stream = stream - }; - } - - public object? GetService(Type serviceType, object? serviceKey = null) => null; - - public void Dispose() - { - _httpClient?.Dispose(); - } -} - -internal class ClaudeStreamEvent -{ - public string? Type { get; set; } - public ClaudeStreamDelta? Delta { get; set; } -} - -internal class ClaudeStreamDelta -{ - public string? Type { get; set; } - public string? Text { get; set; } -} diff --git a/samples/Sentry.Samples.ME.AI.Console/Program.cs b/samples/Sentry.Samples.ME.AI.Console/Program.cs index 00fd45df72..ff65168df2 100644 --- a/samples/Sentry.Samples.ME.AI.Console/Program.cs +++ b/samples/Sentry.Samples.ME.AI.Console/Program.cs @@ -1,204 +1,112 @@ -#nullable enable -using System.Runtime.CompilerServices; -using System.Text; -using System.Text.Json; +using Anthropic.SDK; +using Anthropic.SDK.Constants; using Microsoft.Extensions.AI; using Microsoft.Extensions.Logging; using Sentry.Extensions.AI; -SentrySdk.Init(options => +using var loggerFactory = LoggerFactory.Create(builder => builder.AddConsole()); +var logger = loggerFactory.CreateLogger(); + +logger.LogInformation("Starting Microsoft.Extensions.AI sample with Sentry instrumentation"); + +// Create Claude API client and wrap it with Sentry instrumentation +var client = new AnthropicClient().Messages + .AsBuilder() + .UseFunctionInvocation() + .Build() + .WithSentry(options => { #if !SENTRY_DSN_DEFINED_IN_ENV // A DSN is required. You can set here in code, or you can set it in the SENTRY_DSN environment variable. // See https://docs.sentry.io/product/sentry-basics/dsn-explainer/ options.Dsn = SamplesShared.Dsn; #endif - // Set to true to SDK debugging to see the internal messages through the logging library. options.Debug = true; - // Configure the level of Sentry internal logging options.DiagnosticLevel = SentryLevel.Debug; options.SampleRate = 1; options.TracesSampleRate = 1; - } -); -using var loggerFactory = LoggerFactory.Create(builder => builder.AddConsole()); -var logger = loggerFactory.CreateLogger(); - -logger.LogInformation("Starting Microsoft.Extensions.AI sample with Sentry instrumentation"); + // AI-specific settings + options.IncludeAIRequestMessages = false; + options.IncludeAIResponseContent = false; + options.InitializeSdk = true; + }); -// Create Claude API client and wrap it with Sentry instrumentation -var claudeClient = new ClaudeChatClient(); -var chat = claudeClient.WithSentry(agentName: "Anthropic ", system: "anthropic"); +logger.LogInformation("Making AI call with Sentry instrumentation and tools..."); -logger.LogInformation("Making AI call with Sentry instrumentation..."); +var options = new ChatOptions +{ + ModelId = AnthropicModels.Claude3Haiku, + MaxOutputTokens = 1024, + Tools = [ + // Tool 1: Quick response with minimal delay + AIFunctionFactory.Create(async (string personName) => + { + logger.LogInformation("GetPersonAge called for {PersonName}", personName); + await Task.Delay(100); // 100ms delay + return personName switch { + "Alice" => "25", + "Bob" => "30", + "Charlie" => "35", + _ => "40" + }; + }, "GetPersonAge", "Gets the age of the person whose name is specified. Takes about 100ms to complete."), + + // Tool 2: Medium delay tool for weather + AIFunctionFactory.Create(async (string location) => + { + logger.LogInformation("GetWeather called for {Location}", location); + await Task.Delay(500); // 500ms delay + return location.ToLower() switch { + "new york" => "Sunny, 72°F", + "london" => "Cloudy, 60°F", + "tokyo" => "Rainy, 68°F", + _ => "Unknown weather conditions" + }; + }, "GetWeather", "Gets the current weather for a location. Takes about 500ms to complete."), + + // Tool 3: Slow tool for complex calculation + AIFunctionFactory.Create(async (int number) => + { + logger.LogInformation("ComplexCalculation called with {Number}", number); + await Task.Delay(1000); // 1000ms delay + var result = (number * number) + (number * 10); + return $"Complex calculation result for {number}: {result}"; + }, "ComplexCalculation", "Performs a complex mathematical calculation. Takes about 1 second to complete.") + ] +}.WithSentry(); -var response = await chat.GetResponseAsync([ - new ChatMessage(ChatRole.User, "Say hello from Sentry sample") -]); +var response = await client.GetResponseAsync( + "Please help me with the following tasks: 1) Find Alice's age, 2) Get weather in New York, and 3) Calculate a complex result for number 15. Please use the appropriate tools for each task.", + options); -logger.LogInformation("Response: {ResponseText}", response.Messages); +logger.LogInformation("Response: {ResponseText}", response.Messages?.FirstOrDefault()?.Text ?? "No response"); // Demonstrate streaming with Sentry instrumentation logger.LogInformation("Making streaming AI call with Sentry instrumentation..."); +var streamingOptions = new ChatOptions +{ + ModelId = AnthropicModels.Claude3Haiku, + MaxOutputTokens = 1024 +}.WithSentry(); + var streamingResponse = new List(); -await foreach (var update in chat.GetStreamingResponseAsync([ - new ChatMessage(ChatRole.User, "Say hello and goodbye with streaming") - ])) +await foreach (var update in client.GetStreamingResponseAsync([ + new ChatMessage(ChatRole.User, "Write a short poem about AI and monitoring. Keep it under 50 words.") + ], streamingOptions)) { - streamingResponse.Add(update.Text ?? ""); + if (!string.IsNullOrEmpty(update.Text)) + { + Console.Write(update.Text); + streamingResponse.Add(update.Text); + } } -logger.LogInformation("Streaming Response: {StreamingText}", string.Join("", streamingResponse)); +Console.WriteLine(); // New line after streaming +logger.LogInformation("Streaming Response completed: {StreamingText}", string.Concat(streamingResponse)); logger.LogInformation("Microsoft.Extensions.AI sample completed! Check your Sentry dashboard for the trace data."); // Flush Sentry to ensure all transactions are sent before the app exits await SentrySdk.FlushAsync(TimeSpan.FromSeconds(2)); - -// Claude API client using HttpClient without third-party dependencies -internal class ClaudeChatClient : IChatClient -{ - private readonly HttpClient _httpClient; - private const string ApiBaseUrl = "https://api.anthropic.com/v1/messages"; - - public ClaudeChatClient() - { - var apiKey = Environment.GetEnvironmentVariable("ANTHROPIC_API_KEY") ?? - throw new InvalidOperationException("ANTHROPIC_API_KEY environment variable is required"); - - _httpClient = new HttpClient(); - _httpClient.DefaultRequestHeaders.Add("x-api-key", apiKey); - _httpClient.DefaultRequestHeaders.Add("anthropic-version", "2023-06-01"); - } - - public async Task GetResponseAsync(IEnumerable messages, ChatOptions? options = null, - CancellationToken cancellationToken = default) - { - var requestBody = CreateRequestBody(messages, options, false); - var json = JsonSerializer.Serialize(requestBody); - var content = new StringContent(json, Encoding.UTF8, "application/json"); - - var response = await _httpClient.PostAsync(ApiBaseUrl, content, cancellationToken); - response.EnsureSuccessStatusCode(); - - var responseJson = await response.Content.ReadAsStringAsync(cancellationToken); - using var doc = JsonDocument.Parse(responseJson); - - var usage = new UsageDetails(); - var responseText = "No response"; - if (doc.RootElement.TryGetProperty("content", out var contentArray) && - contentArray.ValueKind == JsonValueKind.Array) - { - var firstContent = contentArray.EnumerateArray().FirstOrDefault(); - if (firstContent.TryGetProperty("text", out var textProperty)) - { - responseText = textProperty.GetString() ?? "No response"; - } - } - if (doc.RootElement.TryGetProperty("usage", out var usageProperty)) - { - if (usageProperty.TryGetProperty("input_tokens", out var inputTokens) && inputTokens.TryGetInt64(out var inputTokenCount)) - { - usage.InputTokenCount = inputTokenCount; - } - if (usageProperty.TryGetProperty("output_tokens", out var outputTokens) && outputTokens.TryGetInt64(out var outputTokenCount)) - { - usage.OutputTokenCount = outputTokenCount; - } - } - - var responseMessage = new ChatMessage(ChatRole.Assistant, responseText); - var chatResponse = new ChatResponse(responseMessage) - { - Usage = usage - }; - - return chatResponse; - } - - public async IAsyncEnumerable GetStreamingResponseAsync(IEnumerable messages, - ChatOptions? options = null, [EnumeratorCancellation] CancellationToken cancellationToken = default) - { - var requestBody = CreateRequestBody(messages, options, true); - var json = JsonSerializer.Serialize(requestBody); - var content = new StringContent(json, Encoding.UTF8, "application/json"); - - var response = await _httpClient.PostAsync(ApiBaseUrl, content, cancellationToken); - response.EnsureSuccessStatusCode(); - - await using var stream = await response.Content.ReadAsStreamAsync(cancellationToken); - using var reader = new StreamReader(stream); - - while (await reader.ReadLineAsync(cancellationToken) is { } line) - { - if (!line.StartsWith("data: ") || line.Length <= 6) - { - continue; - } - - var eventData = line.Substring(6); - if (eventData == "[DONE]") - { - break; - } - - ClaudeStreamEvent? streamEvent = null; - try - { - streamEvent = JsonSerializer.Deserialize(eventData); - } - catch (JsonException) - { - // Skip malformed JSON - continue; - } - - if (streamEvent?.Type == "content_block_delta" && - streamEvent.Delta?.Type == "text_delta") - { - yield return new ChatResponseUpdate(null, streamEvent.Delta.Text); - } - } - } - - private object CreateRequestBody(IEnumerable messages, ChatOptions? options, bool stream) - { - var claudeMessages = messages - .Where(m => m.Role != ChatRole.System) - .Select(m => new - { - role = m.Role == ChatRole.User ? "user" : "assistant", - content = m.Text ?? "" - }) - .ToArray(); - - return new - { - model = "claude-3-5-sonnet-20241022", - max_tokens = options?.MaxOutputTokens ?? 1000, - messages = claudeMessages, - stream = stream - }; - } - - public object? GetService(Type serviceType, object? serviceKey = null) => null; - - public void Dispose() - { - _httpClient?.Dispose(); - } -} - -internal class ClaudeStreamEvent -{ - public string? Type { get; set; } - public ClaudeStreamDelta? Delta { get; set; } -} - -internal class ClaudeStreamDelta -{ - public string? Type { get; set; } - public string? Text { get; set; } -} diff --git a/samples/Sentry.Samples.ME.AI.Console/Sentry.Samples.ME.AI.Console.csproj b/samples/Sentry.Samples.ME.AI.Console/Sentry.Samples.ME.AI.Console.csproj index c9dc14143d..83db5db8a8 100644 --- a/samples/Sentry.Samples.ME.AI.Console/Sentry.Samples.ME.AI.Console.csproj +++ b/samples/Sentry.Samples.ME.AI.Console/Sentry.Samples.ME.AI.Console.csproj @@ -6,6 +6,7 @@ + diff --git a/src/Sentry.Extensions.AI/Extensions/SentryAIExtensions.cs b/src/Sentry.Extensions.AI/Extensions/SentryAIExtensions.cs index 48047d17f4..f05fac1822 100644 --- a/src/Sentry.Extensions.AI/Extensions/SentryAIExtensions.cs +++ b/src/Sentry.Extensions.AI/Extensions/SentryAIExtensions.cs @@ -13,6 +13,7 @@ public static class SentryAIExtensions /// /// Wrap tool calls specified in with Sentry agent instrumentation /// + /// The that contains the to instrument public static ChatOptions WithSentry( this ChatOptions options) { @@ -28,20 +29,24 @@ public static ChatOptions WithSentry( { options.Tools[i] = new SentryInstrumentedFunction(fn); } - else - { - options.Tools[i] = tool; - } } return options; } /// - /// Wraps an IChatClient with Sentry instrumentation. + /// Wraps an IChatClient with Sentry agent instrumentation. /// - public static IChatClient WithSentry(this IChatClient client) + /// + /// This method can be used either with an existing Sentry setup or as a standalone integration. + /// If Sentry is already initialized, it will use the existing configuration. + /// If not, it will initialize Sentry with the provided options. + /// + /// The to be instrumented + /// The configuration + /// The instrumented + public static IChatClient WithSentry(this IChatClient client, Action? configure = null) { - return new SentryChatClient(client, HubAdapter.Instance); + return new SentryChatClient(client, configure); } } diff --git a/src/Sentry.Extensions.AI/SentryAIOptions.cs b/src/Sentry.Extensions.AI/SentryAIOptions.cs new file mode 100644 index 0000000000..4d839e2c57 --- /dev/null +++ b/src/Sentry.Extensions.AI/SentryAIOptions.cs @@ -0,0 +1,26 @@ +namespace Sentry.Extensions.AI; + +/// +/// Sentry AI instrumentation options +/// +/// +public class SentryAIOptions : SentryOptions +{ + /// + /// Whether to include LLM request messages in spans. + /// + public bool IncludeAIRequestMessages { get; set; } = true; + + /// + /// Whether to include LLM response content in spans. + /// + public bool IncludeAIResponseContent { get; set; } = true; + + /// + /// Whether to initialize the Sentry SDK through this integration. + /// + /// + /// If you have already set up Sentry in your application, there is no need to re-initialize the Sentry SDK + /// + public bool InitializeSdk { get; set; } = false; +} diff --git a/src/Sentry.Extensions.AI/SentryAISpanEnricher.cs b/src/Sentry.Extensions.AI/SentryAISpanEnricher.cs index 92e5060787..6d8697e90d 100644 --- a/src/Sentry.Extensions.AI/SentryAISpanEnricher.cs +++ b/src/Sentry.Extensions.AI/SentryAISpanEnricher.cs @@ -1,4 +1,5 @@ using Microsoft.Extensions.AI; +using System.Text.Json; namespace Sentry.Extensions.AI; @@ -8,12 +9,13 @@ namespace Sentry.Extensions.AI; internal static class SentryAISpanEnricher { /// - /// Enrich a span with request information + /// Enriches a span with request information. /// /// Span to enrich /// Messages /// Options - internal static void EnrichWithRequest(ISpan span, ChatMessage[] messages, ChatOptions? options) + /// AI-specific options + internal static void EnrichWithRequest(ISpan span, ChatMessage[] messages, ChatOptions? options, SentryAIOptions? aiOptions = null) { // Currently, all top-level spans will start as "chat" // The agent creation/invocation doesn't really work in Microsoft.Extensions.AI @@ -28,7 +30,7 @@ internal static void EnrichWithRequest(ISpan span, ChatMessage[] messages, ChatO span.SetData("gen_ai.request.model", "Unknown model"); } - if (messages is { Length: > 0 }) + if (messages is { Length: > 0 } && (aiOptions?.IncludeAIRequestMessages ?? true)) { span.SetData("gen_ai.request.messages", FormatRequestMessage(messages)); } @@ -65,9 +67,12 @@ internal static void EnrichWithRequest(ISpan span, ChatMessage[] messages, ChatO } /// - /// Enriches the span using the response. + /// Enriches the span with response information. /// - public static void EnrichWithResponse(ISpan span, ChatResponse response) + /// Span to enrich + /// Chat response containing usage and content data + /// AI-specific options + internal static void EnrichWithResponse(ISpan span, ChatResponse response, SentryAIOptions? aiOptions = null) { if (response.Usage is { } usage) { @@ -90,7 +95,7 @@ public static void EnrichWithResponse(ISpan span, ChatResponse response) } } - if (response.Text is { } responseText) + if (response.Text is { } responseText && (aiOptions?.IncludeAIResponseContent ?? true)) { span.SetData("gen_ai.response.text", responseText); } @@ -101,6 +106,47 @@ public static void EnrichWithResponse(ISpan span, ChatResponse response) } } + /// + /// Enriches the span using the list of streamed in . + /// + /// span to enrich + /// a list of + /// AI-specific options + public static void EnrichWithStreamingResponse(ISpan span, List messages, SentryAIOptions? aiOptions = null) + { + var inputTokenCount = 0L; + var outputTokenCount = 0L; + var finalText = new StringBuilder(); + + foreach (var message in messages) + { + foreach (var content in message.Contents) + { + if (content is UsageContent {} usage) + { + inputTokenCount += usage.Details.InputTokenCount ?? 0; + outputTokenCount += usage.Details.OutputTokenCount ?? 0; + } + } + if (message.ModelId is { } modelId ) + { + span.SetData("gen_ai.response.model_id", modelId); + } + if (message.Text is { } responseText) + { + finalText.Append(responseText); + } + } + + if (aiOptions?.IncludeAIResponseContent ?? true) + { + span.SetData("gen_ai.response.text", finalText.ToString()); + } + span.SetData("gen_ai.usage.input_tokens", inputTokenCount); + span.SetData("gen_ai.usage.output_tokens", outputTokenCount); + span.SetData("gen_ai.usage.total_tokens", inputTokenCount + outputTokenCount); + } + private static string FormatAvailableTools(IList tools) => FormatAsJson(tools, tool => new { name = tool.Name, description = tool.Description }); diff --git a/src/Sentry.Extensions.AI/SentryChatClient.cs b/src/Sentry.Extensions.AI/SentryChatClient.cs index c4fe411af6..a59870c500 100644 --- a/src/Sentry.Extensions.AI/SentryChatClient.cs +++ b/src/Sentry.Extensions.AI/SentryChatClient.cs @@ -1,36 +1,132 @@ using Microsoft.Extensions.AI; +using Sentry.Extensibility; namespace Sentry.Extensions.AI; -internal sealed class SentryChatClient( - IChatClient innerClient, - IHub hub) - : DelegatingChatClient(innerClient) +internal sealed class SentryChatClient : DelegatingChatClient { + private readonly HubAdapter _hub; + private readonly SentryAIOptions _sentryAIOptions; + + public SentryChatClient(IChatClient client, Action? configure = null) : base(client) + { + _sentryAIOptions = new SentryAIOptions(); + configure?.Invoke(_sentryAIOptions); + + if (_sentryAIOptions.InitializeSdk) + { + if (!SentrySdk.IsEnabled || _sentryAIOptions.Dsn is not null) + { + // Initialize Sentry with our options/DSN + var hub = SentrySdk.InitHub(_sentryAIOptions); + SentrySdk.UseHub(hub); + } + } + + _hub = HubAdapter.Instance; + } + + /// public override async Task GetResponseAsync(IEnumerable messages, ChatOptions? options = null, CancellationToken cancellationToken = new()) { - const string operation = "gen_ai.chat"; - var spanName = InnerClient.GetType().Name; - var initialSpan = hub.GetSpan()?.StartChild(operation, spanName) ?? hub.StartTransaction(spanName, operation); + var invokeSpanName = $"invoke_agent {InnerClient.GetType().Name}"; + const string invokeOperation = "gen_ai.invoke_agent"; + var outerSpan = StartSpanOrTransaction(invokeOperation, invokeSpanName); + + const string chatOperation = "gen_ai.chat"; + var chatSpanName = options is null || string.IsNullOrEmpty(options.ModelId) ? "chat unknown model" : $"chat {options.ModelId}"; + var initialSpan = outerSpan.StartChild(chatOperation, chatSpanName); try { var chatMessages = messages as ChatMessage[] ?? messages.ToArray(); - SentryAISpanEnricher.EnrichWithRequest(initialSpan, chatMessages, options); + SentryAISpanEnricher.EnrichWithRequest(initialSpan, chatMessages, options, _sentryAIOptions); var response = await base.GetResponseAsync(chatMessages, options, cancellationToken).ConfigureAwait(false); - SentryAISpanEnricher.EnrichWithResponse(initialSpan, response); + SentryAISpanEnricher.EnrichWithResponse(initialSpan, response, _sentryAIOptions); initialSpan.Finish(SpanStatus.Ok); + outerSpan.Finish(SpanStatus.Ok); return response; } catch (Exception ex) { initialSpan.Finish(ex); - hub.CaptureException(ex); + outerSpan.Finish(ex); + _hub.CaptureException(ex); + throw; + } + } + + /// + public override IAsyncEnumerable GetStreamingResponseAsync(IEnumerable messages, + ChatOptions? options = null, + CancellationToken cancellationToken = new()) + { + var invokeSpanName = $"invoke_agent {InnerClient.GetType().Name}"; + const string invokeOperation = "gen_ai.invoke_agent"; + var outerSpan = StartSpanOrTransaction(invokeOperation, invokeSpanName); + + const string chatOperation = "gen_ai.chat"; + var chatSpanName = options is null || string.IsNullOrEmpty(options.ModelId) ? "chat unknown model" : $"chat {options.ModelId}"; + var initialSpan = outerSpan.StartChild(chatOperation, chatSpanName); + + try + { + return InstrumentStreamingResponseAsync(messages, options, outerSpan, initialSpan, cancellationToken); + } + catch (Exception ex) + { + initialSpan.Finish(ex); + outerSpan.Finish(ex); + _hub.CaptureException(ex); throw; } } + + private async IAsyncEnumerable InstrumentStreamingResponseAsync(IEnumerable messages, + ChatOptions? options, + ISpan outerSpan, + ISpan span, + [EnumeratorCancellation] CancellationToken cancellationToken) + { + var chatMessages = messages as ChatMessage[] ?? messages.ToArray(); + SentryAISpanEnricher.EnrichWithRequest(span, chatMessages, options, _sentryAIOptions); + + var responses = new List(); + var originalStream = base.GetStreamingResponseAsync(chatMessages, options, cancellationToken); + + await foreach (var chunk in originalStream.ConfigureAwait(false)) + { + responses.Add(chunk); + + yield return chunk; + } + + SentryAISpanEnricher.EnrichWithStreamingResponse(span, responses, _sentryAIOptions); + span.Finish(SpanStatus.Ok); + outerSpan.Finish(SpanStatus.Ok); + } + + /// + /// Starts a span or transaction based on whether there's an active transaction context. + /// + /// The operation name + /// The span/transaction description + /// A child span of an existing transaction if available, else a new transaction + private ISpan StartSpanOrTransaction(string operation, string description) + { + var currentSpan = _hub.GetSpan(); + + if (currentSpan?.GetTransaction() != null) + { + return currentSpan.StartChild(operation, description); + } + + var newTransaction = _hub.StartTransaction(description, operation); + _hub.ConfigureScope(scope => scope.Transaction = newTransaction); + return newTransaction; + } } diff --git a/src/Sentry.Extensions.AI/SentryInstrumentedFunction.cs b/src/Sentry.Extensions.AI/SentryInstrumentedFunction.cs index 8635c2e30e..4ab001e7c7 100644 --- a/src/Sentry.Extensions.AI/SentryInstrumentedFunction.cs +++ b/src/Sentry.Extensions.AI/SentryInstrumentedFunction.cs @@ -18,11 +18,7 @@ internal sealed class SentryInstrumentedFunction(AIFunction innerFunction) : Del currSpan.SetData("gen_ai.operation.name", "execute_tool"); currSpan.SetData("gen_ai.tool.name", Name); - - if (Description is { } description) - { - currSpan.SetData("gen_ai.tool.description", description); - } + currSpan.SetData("gen_ai.tool.description", Description); currSpan.SetData("gen_ai.tool.input", arguments); diff --git a/src/Sentry/Sentry.csproj b/src/Sentry/Sentry.csproj index 15bc8c9675..4ed0953f84 100644 --- a/src/Sentry/Sentry.csproj +++ b/src/Sentry/Sentry.csproj @@ -156,6 +156,8 @@ + + diff --git a/test/Sentry.Extensions.AI.Tests/SentryAIExtensionsTests.cs b/test/Sentry.Extensions.AI.Tests/SentryAIExtensionsTests.cs new file mode 100644 index 0000000000..d82b168f7e --- /dev/null +++ b/test/Sentry.Extensions.AI.Tests/SentryAIExtensionsTests.cs @@ -0,0 +1,205 @@ +#nullable enable +using Microsoft.Extensions.AI; +using NSubstitute; +using Sentry.Extensions.AI; + +namespace Sentry.Extensions.AI.Tests; + +public class SentryAIExtensionsTests +{ + [Fact] + public void WithSentry_ChatOptions_WithNullTools_ReturnsOriginalOptions() + { + // Arrange + var options = new ChatOptions(); + + // Act + var result = options.WithSentry(); + + // Assert + Assert.Same(options, result); + } + + [Fact] + public void WithSentry_ChatOptions_WithEmptyTools_ReturnsOriginalOptions() + { + // Arrange + var options = new ChatOptions + { + Tools = new List() + }; + + // Act + var result = options.WithSentry(); + + // Assert + Assert.Same(options, result); + } + + [Fact] + public void WithSentry_ChatOptions_WrapsAIFunctionsWithSentryInstrumentedFunction() + { + // Arrange + var mockFunction = Substitute.For(); + mockFunction.Name.Returns("TestFunction"); + mockFunction.Description.Returns("Test Description"); + + var options = new ChatOptions + { + Tools = new List { mockFunction } + }; + + // Act + var result = options.WithSentry(); + + // Assert + Assert.Same(options, result); + Assert.Single(options.Tools); + Assert.IsType(options.Tools[0]); + + var instrumentedFunction = (SentryInstrumentedFunction)options.Tools[0]; + Assert.Equal("TestFunction", instrumentedFunction.Name); + Assert.Equal("Test Description", instrumentedFunction.Description); + } + + [Fact] + public void WithSentry_ChatOptions_DoesNotDoubleWrapSentryInstrumentedFunction() + { + // Arrange + var mockFunction = Substitute.For(); + var alreadyInstrumentedFunction = new SentryInstrumentedFunction(mockFunction); + + var options = new ChatOptions + { + Tools = new List { alreadyInstrumentedFunction } + }; + + // Act + var result = options.WithSentry(); + + // Assert + Assert.Same(options, result); + Assert.Single(options.Tools); + Assert.Same(alreadyInstrumentedFunction, options.Tools[0]); + } + + [Fact] + public void WithSentry_ChatOptions_HandlesMultipleFunctions() + { + // Arrange + var mockFunction1 = Substitute.For(); + mockFunction1.Name.Returns("Function1"); + + var mockFunction2 = Substitute.For(); + mockFunction2.Name.Returns("Function2"); + + var alreadyInstrumentedFunction = new SentryInstrumentedFunction(mockFunction1); + + var options = new ChatOptions + { + Tools = new List + { + mockFunction1, + mockFunction2, + alreadyInstrumentedFunction + } + }; + + // Act + var result = options.WithSentry(); + + // Assert + Assert.Same(options, result); + Assert.Equal(3, options.Tools.Count); + + // First function should be wrapped + Assert.IsType(options.Tools[0]); + Assert.Equal("Function1", options.Tools[0].Name); + + // Second function should be wrapped + Assert.IsType(options.Tools[1]); + Assert.Equal("Function2", options.Tools[1].Name); + + // Third function was already instrumented, should remain unchanged + Assert.Same(alreadyInstrumentedFunction, options.Tools[2]); + } + + [Fact] + public void WithSentry_ChatOptions_IgnoresNonAIFunctionTools() + { + // Arrange + var mockFunction = Substitute.For(); + mockFunction.Name.Returns("TestFunction"); + + var mockNonFunction = Substitute.For(); + mockNonFunction.Name.Returns("NonFunction"); + + var options = new ChatOptions + { + Tools = new List { mockFunction, mockNonFunction } + }; + + // Act + var result = options.WithSentry(); + + // Assert + Assert.Same(options, result); + Assert.Equal(2, options.Tools.Count); + + // AIFunction should be wrapped + Assert.IsType(options.Tools[0]); + Assert.Equal("TestFunction", options.Tools[0].Name); + + // Non-AIFunction should remain unchanged + Assert.Same(mockNonFunction, options.Tools[1]); + } + + [Fact] + public void WithSentry_IChatClient_ReturnsWrappedClient() + { + // Arrange + var mockClient = Substitute.For(); + + // Act + var result = mockClient.WithSentry(); + + // Assert + Assert.IsType(result); + } + + [Fact] + public void WithSentry_IChatClient_WithConfiguration_PassesConfigurationToWrapper() + { + // Arrange + var mockClient = Substitute.For(); + var configureWasCalled = false; + + // Act + var result = mockClient.WithSentry(Configure); + + // Assert + Assert.IsType(result); + Assert.True(configureWasCalled); + return; + + void Configure(SentryAIOptions options) + { + configureWasCalled = true; + options.IncludeAIRequestMessages = false; + options.IncludeAIResponseContent = false; + } + } + + [Fact] + public void WithSentry_IChatClient_WithNullConfiguration_UsesDefaultConfiguration() + { + // Arrange + var mockClient = Substitute.For(); + + // Act + var result = mockClient.WithSentry(null); + + // Assert + Assert.IsType(result); + } +} diff --git a/test/Sentry.Extensions.AI.Tests/SentryAIOptionsTests.cs b/test/Sentry.Extensions.AI.Tests/SentryAIOptionsTests.cs new file mode 100644 index 0000000000..5207f2221a --- /dev/null +++ b/test/Sentry.Extensions.AI.Tests/SentryAIOptionsTests.cs @@ -0,0 +1,112 @@ +#nullable enable +using Sentry.Extensions.AI; + +namespace Sentry.Extensions.AI.Tests; + +public class SentryAIOptionsTests +{ + [Fact] + public void Constructor_SetsDefaultValues() + { + // Act + var options = new SentryAIOptions(); + + // Assert + Assert.True(options.IncludeAIRequestMessages); + Assert.True(options.IncludeAIResponseContent); + Assert.False(options.InitializeSdk); + } + + [Fact] + public void IncludeRequestMessages_CanBeSet() + { + // Arrange + var options = new SentryAIOptions + { + // Act + IncludeAIRequestMessages = false + }; + + // Assert + Assert.False(options.IncludeAIRequestMessages); + } + + [Fact] + public void IncludeResponseContent_CanBeSet() + { + // Arrange + var options = new SentryAIOptions + { + // Act + IncludeAIResponseContent = false + }; + + // Assert + Assert.False(options.IncludeAIResponseContent); + } + + [Fact] + public void InitializeSdk_CanBeSet() + { + // Arrange + var options = new SentryAIOptions(); + + // Act + options.InitializeSdk = true; + + // Assert + Assert.True(options.InitializeSdk); + } + + [Fact] + public void InheritsFromSentryOptions() + { + // Arrange & Act + var options = new SentryAIOptions(); + + // Assert + Assert.IsType(options, exactMatch: false); + } + + [Fact] + public void CanSetSentryOptionsProperties() + { + // Arrange + var options = new SentryAIOptions(); + + // Act + options.Dsn = "https://key@sentry.io/project"; + options.Environment = "test"; + options.Release = "1.0.0"; + + // Assert + Assert.Equal("https://key@sentry.io/project", options.Dsn); + Assert.Equal("test", options.Environment); + Assert.Equal("1.0.0", options.Release); + } + + [Theory] + [InlineData(true, true, true)] + [InlineData(true, true, false)] + [InlineData(true, false, true)] + [InlineData(true, false, false)] + [InlineData(false, true, true)] + [InlineData(false, true, false)] + [InlineData(false, false, true)] + [InlineData(false, false, false)] + public void AllPropertyCombinations_WorkCorrectly(bool includeRequest, bool includeResponse, bool initializeSdk) + { + // Arrange + var options = new SentryAIOptions(); + + // Act + options.IncludeAIRequestMessages = includeRequest; + options.IncludeAIResponseContent = includeResponse; + options.InitializeSdk = initializeSdk; + + // Assert + Assert.Equal(includeRequest, options.IncludeAIRequestMessages); + Assert.Equal(includeResponse, options.IncludeAIResponseContent); + Assert.Equal(initializeSdk, options.InitializeSdk); + } +} diff --git a/test/Sentry.Extensions.AI.Tests/SentryAISpanEnricherTests.cs b/test/Sentry.Extensions.AI.Tests/SentryAISpanEnricherTests.cs new file mode 100644 index 0000000000..aa50ae6485 --- /dev/null +++ b/test/Sentry.Extensions.AI.Tests/SentryAISpanEnricherTests.cs @@ -0,0 +1,318 @@ +#nullable enable +using Microsoft.Extensions.AI; +using NSubstitute; +using Sentry.Extensions.AI; +using System.Text.Json; + +namespace Sentry.Extensions.AI.Tests; + +public class SentryAISpanEnricherTests +{ + private readonly ISpan _mockSpan; + + public SentryAISpanEnricherTests() + { + _mockSpan = Substitute.For(); + } + + [Fact] + public void EnrichWithRequest_SetsBasicOperationName() + { + var messages = new[] { new ChatMessage(ChatRole.User, "Hello") }; + + SentryAISpanEnricher.EnrichWithRequest(_mockSpan, messages, null); + + _mockSpan.Received(1).SetData("gen_ai.operation.name", "chat"); + } + + [Fact] + public void EnrichWithRequest_SetsModelId_WhenProvided() + { + var messages = new[] { new ChatMessage(ChatRole.User, "Hello") }; + var options = new ChatOptions { ModelId = "gpt-4" }; + + SentryAISpanEnricher.EnrichWithRequest(_mockSpan, messages, options); + + _mockSpan.Received(1).SetData("gen_ai.request.model", "gpt-4"); + } + + [Fact] + public void EnrichWithRequest_SetsUnknownModel_WhenModelIdNotProvided() + { + var messages = new[] { new ChatMessage(ChatRole.User, "Hello") }; + + SentryAISpanEnricher.EnrichWithRequest(_mockSpan, messages, null); + + _mockSpan.Received(1).SetData("gen_ai.request.model", "Unknown model"); + } + + [Fact] + public void EnrichWithRequest_SetsMessages_WhenIncludeRequestMessagesIsTrue() + { + var messages = new[] { + new ChatMessage(ChatRole.User, "Hello"), + new ChatMessage(ChatRole.Assistant, "Hi there") + }; + var aiOptions = new SentryAIOptions { IncludeAIRequestMessages = true }; + + SentryAISpanEnricher.EnrichWithRequest(_mockSpan, messages, null, aiOptions); + + _mockSpan.Received(1).SetData("gen_ai.request.messages", Arg.Any()); + } + + [Fact] + public void EnrichWithRequest_DoesNotSetMessages_WhenIncludeRequestMessagesIsFalse() + { + var messages = new[] { new ChatMessage(ChatRole.User, "Hello") }; + var aiOptions = new SentryAIOptions { IncludeAIRequestMessages = false }; + + SentryAISpanEnricher.EnrichWithRequest(_mockSpan, messages, null, aiOptions); + + _mockSpan.DidNotReceive().SetData("gen_ai.request.messages", Arg.Any()); + } + + [Fact] + public void EnrichWithRequest_SetsMessages_WhenAIOptionsIsNull() + { + var messages = new[] { new ChatMessage(ChatRole.User, "Hello") }; + + SentryAISpanEnricher.EnrichWithRequest(_mockSpan, messages, null, null); + + _mockSpan.Received(1).SetData("gen_ai.request.messages", Arg.Any()); + } + + [Fact] + public void EnrichWithRequest_DoesNotSetMessages_WhenMessagesArrayIsEmpty() + { + var messages = Array.Empty(); + + SentryAISpanEnricher.EnrichWithRequest(_mockSpan, messages, null); + + _mockSpan.DidNotReceive().SetData("gen_ai.request.messages", Arg.Any()); + } + + [Fact] + public void EnrichWithRequest_SetsTools_WhenToolsProvided() + { + var messages = new[] { new ChatMessage(ChatRole.User, "Hello") }; + var tools = new List + { + AIFunctionFactory.Create(() => "test", "TestTool", "A test tool") + }; + var options = new ChatOptions { Tools = tools }; + + SentryAISpanEnricher.EnrichWithRequest(_mockSpan, messages, options); + + _mockSpan.Received(1).SetData("gen_ai.request.available_tools", Arg.Any()); + } + + [Fact] + public void EnrichWithRequest_SetsTemperature_WhenProvided() + { + var messages = new[] { new ChatMessage(ChatRole.User, "Hello") }; + var options = new ChatOptions { Temperature = 0.7f }; + + SentryAISpanEnricher.EnrichWithRequest(_mockSpan, messages, options); + + _mockSpan.Received(1).SetData("gen_ai.request.temperature", 0.7f); + } + + [Fact] + public void EnrichWithRequest_SetsMaxOutputTokens_WhenProvided() + { + var messages = new[] { new ChatMessage(ChatRole.User, "Hello") }; + var options = new ChatOptions { MaxOutputTokens = 1000 }; + + SentryAISpanEnricher.EnrichWithRequest(_mockSpan, messages, options); + + _mockSpan.Received(1).SetData("gen_ai.request.max_tokens", 1000); + } + + [Fact] + public void EnrichWithRequest_SetsTopP_WhenProvided() + { + var messages = new[] { new ChatMessage(ChatRole.User, "Hello") }; + var options = new ChatOptions { TopP = 0.9f }; + + SentryAISpanEnricher.EnrichWithRequest(_mockSpan, messages, options); + + _mockSpan.Received(1).SetData("gen_ai.request.top_p", 0.9f); + } + + [Fact] + public void EnrichWithRequest_SetsFrequencyPenalty_WhenProvided() + { + var messages = new[] { new ChatMessage(ChatRole.User, "Hello") }; + var options = new ChatOptions { FrequencyPenalty = 0.5f }; + + SentryAISpanEnricher.EnrichWithRequest(_mockSpan, messages, options); + + _mockSpan.Received(1).SetData("gen_ai.request.frequency_penalty", 0.5f); + } + + [Fact] + public void EnrichWithRequest_SetsPresencePenalty_WhenProvided() + { + var messages = new[] { new ChatMessage(ChatRole.User, "Hello") }; + var options = new ChatOptions { PresencePenalty = 0.3f }; + + SentryAISpanEnricher.EnrichWithRequest(_mockSpan, messages, options); + + _mockSpan.Received(1).SetData("gen_ai.request.presence_penalty", 0.3f); + } + + [Fact] + public void EnrichWithResponse_SetsUsageTokens_WhenUsageProvided() + { + var usage = new UsageDetails { InputTokenCount = 10, OutputTokenCount = 20 }; + var response = new ChatResponse(new ChatMessage(ChatRole.Assistant, "Hello")) + { + Usage = usage + }; + + SentryAISpanEnricher.EnrichWithResponse(_mockSpan, response); + + _mockSpan.Received(1).SetData("gen_ai.usage.input_tokens", 10L); + _mockSpan.Received(1).SetData("gen_ai.usage.output_tokens", 20L); + _mockSpan.Received(1).SetData("gen_ai.usage.total_tokens", 30L); + } + + [Fact] + public void EnrichWithResponse_DoesNotSetUsage_WhenUsageIsNull() + { + var response = new ChatResponse(new ChatMessage(ChatRole.Assistant, "Hello")); + + SentryAISpanEnricher.EnrichWithResponse(_mockSpan, response); + + _mockSpan.DidNotReceive().SetData("gen_ai.usage.input_tokens", Arg.Any()); + _mockSpan.DidNotReceive().SetData("gen_ai.usage.output_tokens", Arg.Any()); + _mockSpan.DidNotReceive().SetData("gen_ai.usage.total_tokens", Arg.Any()); + } + + [Fact] + public void EnrichWithResponse_SetsPartialTokens_WhenOnlyInputTokensProvided() + { + var usage = new UsageDetails { InputTokenCount = 10 }; + var response = new ChatResponse(new ChatMessage(ChatRole.Assistant, "Hello")) + { + Usage = usage + }; + + SentryAISpanEnricher.EnrichWithResponse(_mockSpan, response); + + _mockSpan.Received().SetData("gen_ai.usage.input_tokens", 10L); + _mockSpan.DidNotReceive().SetData("gen_ai.usage.output_tokens", Arg.Any()); + _mockSpan.DidNotReceive().SetData("gen_ai.usage.total_tokens", Arg.Any()); + } + + [Fact] + public void EnrichWithResponse_SetsResponseText_WhenIncludeResponseContentIsTrue() + { + var response = new ChatResponse(new ChatMessage(ChatRole.Assistant, "Hello world")); + var aiOptions = new SentryAIOptions { IncludeAIResponseContent = true }; + + SentryAISpanEnricher.EnrichWithResponse(_mockSpan, response, aiOptions); + + _mockSpan.Received(1).SetData("gen_ai.response.text", "Hello world"); + } + + [Fact] + public void EnrichWithResponse_DoesNotSetResponseText_WhenIncludeResponseContentIsFalse() + { + var response = new ChatResponse(new ChatMessage(ChatRole.Assistant, "Hello world")); + var aiOptions = new SentryAIOptions { IncludeAIResponseContent = false }; + + SentryAISpanEnricher.EnrichWithResponse(_mockSpan, response, aiOptions); + + _mockSpan.DidNotReceive().SetData("gen_ai.response.text", Arg.Any()); + } + + [Fact] + public void EnrichWithResponse_SetsResponseText_WhenAIOptionsIsNull() + { + var response = new ChatResponse(new ChatMessage(ChatRole.Assistant, "Hello world")); + + SentryAISpanEnricher.EnrichWithResponse(_mockSpan, response, null); + + _mockSpan.Received(1).SetData("gen_ai.response.text", "Hello world"); + } + + [Fact] + public void EnrichWithResponse_SetsModelId_WhenProvided() + { + var response = new ChatResponse(new ChatMessage(ChatRole.Assistant, "Hello")) + { + ModelId = "gpt-4-turbo" + }; + + SentryAISpanEnricher.EnrichWithResponse(_mockSpan, response); + + _mockSpan.Received(1).SetData("gen_ai.response.model_id", "gpt-4-turbo"); + } + + [Fact] + public void EnrichWithStreamingResponse_AccumulatesTokenUsage() + { + var messages = new List + { + new(ChatRole.Assistant, "Hello") + { + Contents = [new UsageContent(new UsageDetails { InputTokenCount = 5, OutputTokenCount = 10 })] + }, + new(ChatRole.Assistant, " world") + { + Contents = [new UsageContent(new UsageDetails { InputTokenCount = 3, OutputTokenCount = 5 })] + } + }; + + SentryAISpanEnricher.EnrichWithStreamingResponse(_mockSpan, messages); + + _mockSpan.Received(1).SetData("gen_ai.usage.input_tokens", 8L); + _mockSpan.Received(1).SetData("gen_ai.usage.output_tokens", 15L); + _mockSpan.Received(1).SetData("gen_ai.usage.total_tokens", 23L); + } + + [Fact] + public void EnrichWithStreamingResponse_ConcatenatesResponseText() + { + var messages = new List + { + new(ChatRole.Assistant, "Hello"), + new(ChatRole.Assistant, " world"), + new(ChatRole.Assistant, "!") + }; + + SentryAISpanEnricher.EnrichWithStreamingResponse(_mockSpan, messages); + + _mockSpan.Received(1).SetData("gen_ai.response.text", "Hello world!"); + } + + [Fact] + public void EnrichWithStreamingResponse_DoesNotSetResponseText_WhenIncludeResponseContentIsFalse() + { + var messages = new List + { + new(ChatRole.Assistant, "Hello world") + }; + var aiOptions = new SentryAIOptions { IncludeAIResponseContent = false }; + + SentryAISpanEnricher.EnrichWithStreamingResponse(_mockSpan, messages, aiOptions); + + _mockSpan.DidNotReceive().SetData("gen_ai.response.text", Arg.Any()); + } + + [Fact] + public void EnrichWithStreamingResponse_SetsModelId_FromLastMessageWithModelId() + { + var messages = new List + { + new(ChatRole.Assistant, "Hello") { ModelId = "gpt-3.5" }, + new(ChatRole.Assistant, " world") { ModelId = "gpt-4" }, + new(ChatRole.Assistant, "!") + }; + + SentryAISpanEnricher.EnrichWithStreamingResponse(_mockSpan, messages); + + _mockSpan.Received(1).SetData("gen_ai.response.model_id", "gpt-4"); + } +} diff --git a/test/Sentry.Extensions.AI.Tests/SentryChatClientTests.cs b/test/Sentry.Extensions.AI.Tests/SentryChatClientTests.cs index f9739e1f71..00d3992e09 100644 --- a/test/Sentry.Extensions.AI.Tests/SentryChatClientTests.cs +++ b/test/Sentry.Extensions.AI.Tests/SentryChatClientTests.cs @@ -15,8 +15,7 @@ public async Task CompleteAsync_CallsInnerClient() inner.GetResponseAsync(Arg.Any>(), Arg.Any(), Arg.Any()) .Returns(Task.FromResult(chatResponse)); - var hub = Substitute.For(); - var sentryChatClient = new SentryChatClient(inner, hub, agentName: "Agent", model: "gpt-4o-mini", system: "openai"); + var sentryChatClient = new SentryChatClient(inner); var res = await sentryChatClient.GetResponseAsync([new ChatMessage(ChatRole.User, "hi")], null); @@ -30,10 +29,9 @@ public async Task CompleteStreamingAsync_CallsInnerClient() var inner = Substitute.For(); inner.GetStreamingResponseAsync(Arg.Any>(), Arg.Any(), Arg.Any()) - .Returns(CreateTestStreamingUpdates()); + .Returns(CreateTestStreamingUpdatesAsync()); - var hub = Substitute.For(); - var client = new SentryChatClient(inner, hub, agentName: "Agent", model: "gpt-4o-mini", system: "openai"); + var client = new SentryChatClient(inner); var results = new List(); await foreach (var update in client.GetStreamingResponseAsync([new ChatMessage(ChatRole.User, "hi")], null)) @@ -48,10 +46,10 @@ public async Task CompleteStreamingAsync_CallsInnerClient() inner.Received(1).GetStreamingResponseAsync(Arg.Any>(), Arg.Any(), Arg.Any()); } - private static async IAsyncEnumerable CreateTestStreamingUpdates() + private static async IAsyncEnumerable CreateTestStreamingUpdatesAsync() { yield return new ChatResponseUpdate(ChatRole.System, "Hello"); - await Task.Yield(); // Make it actually async + await Task.Yield(); // Make it async yield return new ChatResponseUpdate(ChatRole.System, " World!"); } } diff --git a/test/Sentry.Extensions.AI.Tests/SentryInstrumentedFunctionTests.cs b/test/Sentry.Extensions.AI.Tests/SentryInstrumentedFunctionTests.cs new file mode 100644 index 0000000000..4a13ae10a7 --- /dev/null +++ b/test/Sentry.Extensions.AI.Tests/SentryInstrumentedFunctionTests.cs @@ -0,0 +1,221 @@ +#nullable enable +using Microsoft.Extensions.AI; +using NSubstitute; +using Sentry.Extensions.AI; +using Sentry.Testing; + +namespace Sentry.Extensions.AI.Tests; + +public class SentryInstrumentedFunctionTests +{ + [Fact] + public async Task InvokeCoreAsync_WithValidFunction_ReturnsResult() + { + // Arrange + using var sentryDisposable = SentryHelpers.InitializeSdk(); + var testFunction = AIFunctionFactory.Create(() => "test result", "TestFunction", "Test function description"); + var sentryFunction = new SentryInstrumentedFunction(testFunction); + var arguments = new AIFunctionArguments(); + + // Act + var result = await sentryFunction.InvokeAsync(arguments); + + // Assert + // AIFunctionFactory returns JsonElement, so we need to check the actual content + Assert.NotNull(result); + if (result is JsonElement jsonElement) + { + Assert.Equal("test result", jsonElement.GetString()); + } + else + { + Assert.Equal("test result", result); + } + Assert.Equal("TestFunction", sentryFunction.Name); + Assert.Equal("Test function description", sentryFunction.Description); + } + + [Fact] + public async Task InvokeCoreAsync_WithNullResult_ReturnsNull() + { + // Arrange + using var sentryDisposable = SentryHelpers.InitializeSdk(); + var testFunction = AIFunctionFactory.Create(object? () => null, "TestFunction", "Test function description"); + var sentryFunction = new SentryInstrumentedFunction(testFunction); + var arguments = new AIFunctionArguments(); + + // Act + var result = await sentryFunction.InvokeAsync(arguments); + + // Assert + // AIFunctionFactory may return JsonElement with ValueKind.Null instead of actual null + if (result is JsonElement jsonElement) + { + Assert.Equal(JsonValueKind.Null, jsonElement.ValueKind); + } + else + { + Assert.Null(result); + } + } + + [Fact] + public async Task InvokeCoreAsync_WithJsonNullResult_ReturnsJsonElement() + { + // Arrange + using var sentryDisposable = SentryHelpers.InitializeSdk(); + var jsonNullElement = JsonSerializer.Deserialize("null"); + var testFunction = AIFunctionFactory.Create(() => jsonNullElement, "TestFunction", "Test function description"); + var sentryFunction = new SentryInstrumentedFunction(testFunction); + var arguments = new AIFunctionArguments(); + + // Act + var result = await sentryFunction.InvokeAsync(arguments); + + // Assert + Assert.NotNull(result); + Assert.IsType(result); + var jsonResult = (JsonElement)result; + Assert.Equal(JsonValueKind.Null, jsonResult.ValueKind); + } + + [Fact] + public async Task InvokeCoreAsync_WithJsonElementResult_CallsToStringForSpanOutput() + { + // Arrange + using var sentryDisposable = SentryHelpers.InitializeSdk(); + var jsonElement = JsonSerializer.Deserialize("\"test output\""); + var testFunction = AIFunctionFactory.Create(() => jsonElement, "TestFunction", "Test function description"); + var sentryFunction = new SentryInstrumentedFunction(testFunction); + var arguments = new AIFunctionArguments(); + + // Act + var result = await sentryFunction.InvokeAsync(arguments); + + // Assert + Assert.NotNull(result); + Assert.IsType(result); + var jsonResult = (JsonElement)result; + Assert.Equal("test output", jsonResult.GetString()); + + // The span should have recorded the ToString() output of the JsonElement + // (This is testing the internal behavior that ToString() gets called for span data) + } + + [Fact] + public async Task InvokeCoreAsync_WithComplexResult_ReturnsObject() + { + // Arrange + using var sentryDisposable = SentryHelpers.InitializeSdk(); + var resultObject = new { message = "test", count = 42 }; + var testFunction = AIFunctionFactory.Create(() => resultObject, "TestFunction", "Test function description"); + var sentryFunction = new SentryInstrumentedFunction(testFunction); + var arguments = new AIFunctionArguments(); + + // Act + var result = await sentryFunction.InvokeAsync(arguments); + + // Assert + Assert.NotNull(result); + if (result is JsonElement jsonElement) + { + // When AIFunction serializes objects, they become JsonElements + var message = jsonElement.GetProperty("message").GetString(); + var count = jsonElement.GetProperty("count").GetInt32(); + Assert.Equal("test", message); + Assert.Equal(42, count); + } + else + { + Assert.Equal(resultObject, result); + } + } + + [Fact] + public async Task InvokeCoreAsync_WhenFunctionThrows_PropagatesException() + { + // Arrange + using var sentryDisposable = SentryHelpers.InitializeSdk(); + var expectedException = new InvalidOperationException("Test exception"); + var testFunction = AIFunctionFactory.Create(new Func(() => throw expectedException), "TestFunction", "Test function description"); + var sentryFunction = new SentryInstrumentedFunction(testFunction); + var arguments = new AIFunctionArguments(); + + // Act & Assert + var actualException = await Assert.ThrowsAsync(async () => + await sentryFunction.InvokeAsync(arguments)); + + Assert.Equal(expectedException.Message, actualException.Message); + } + + [Fact] + public async Task InvokeCoreAsync_WithCancellation_PropagatesCancellation() + { + // Arrange + using var sentryDisposable = SentryHelpers.InitializeSdk(); + var testFunction = AIFunctionFactory.Create((CancellationToken cancellationToken) => + { + cancellationToken.ThrowIfCancellationRequested(); + return "result"; + }, "TestFunction", "Test function description"); + + var sentryFunction = new SentryInstrumentedFunction(testFunction); + var arguments = new AIFunctionArguments(); + var cts = new CancellationTokenSource(); + cts.Cancel(); + + // Act & Assert + await Assert.ThrowsAsync(async () => + await sentryFunction.InvokeAsync(arguments, cts.Token)); + } + + [Fact] + public async Task InvokeCoreAsync_WithParameters_PassesParametersCorrectly() + { + // Arrange + using var sentryDisposable = SentryHelpers.InitializeSdk(); + var receivedArguments = (AIFunctionArguments?)null; + var testFunction = AIFunctionFactory.Create((AIFunctionArguments args) => + { + receivedArguments = args; + return "result"; + }, "TestFunction", "Test function description"); + + var sentryFunction = new SentryInstrumentedFunction(testFunction); + var arguments = new AIFunctionArguments { ["param1"] = "value1" }; + + // Act + await sentryFunction.InvokeAsync(arguments); + + // Assert + Assert.NotNull(receivedArguments); + Assert.Equal("value1", receivedArguments["param1"]); + } + + [Fact] + public void Constructor_PreservesInnerFunctionProperties() + { + // Arrange + var testFunction = AIFunctionFactory.Create(() => "test", "TestFunction", "Test function description"); + + // Act + var sentryFunction = new SentryInstrumentedFunction(testFunction); + + // Assert + Assert.Equal("TestFunction", sentryFunction.Name); + Assert.Equal("Test function description", sentryFunction.Description); + } +} + +internal static class SentryHelpers +{ + public static IDisposable InitializeSdk() + { + return SentrySdk.Init(options => + { + options.Dsn = "https://3f3a29aa3a3aff@fake-sentry.io:65535/2147483647"; + options.TracesSampleRate = 1.0; + options.Debug = false; + }); + } +} diff --git a/test/Sentry.Extensions.AI.Tests/TestResults/_CVDTQ2MGH4_2025-10-20_00_35_51.trx b/test/Sentry.Extensions.AI.Tests/TestResults/_CVDTQ2MGH4_2025-10-20_00_35_51.trx new file mode 100644 index 0000000000..eaf4012297 --- /dev/null +++ b/test/Sentry.Extensions.AI.Tests/TestResults/_CVDTQ2MGH4_2025-10-20_00_35_51.trx @@ -0,0 +1,863 @@ + + + + + + + + + + + + + + + + + + + + NSubstitute.Exceptions.AmbiguousArgumentsException : Cannot determine argument specifications to use. Please use specifications for all arguments of the same type. +Method signature: + InvokeCoreAsync(AIFunctionArguments, CancellationToken) +Method arguments (possible arg matchers are indicated with '*'): + InvokeCoreAsync(AIFunctionArguments, *System.Threading.CancellationToken*) +All queued specifications: + any AIFunctionArguments + any CancellationToken +Matched argument specifications: + InvokeCoreAsync(AIFunctionArguments, ???) + + at NSubstitute.Core.Arguments.ArgumentSpecificationsFactory.Create(IList`1 argumentSpecs, Object[] arguments, IParameterInfo[] parameterInfos, MethodInfo methodInfo, MatchArgs matchArgs) + at NSubstitute.Core.CallSpecificationFactory.CreateFrom(ICall call, MatchArgs matchArgs) + at NSubstitute.Routing.Handlers.RecordCallSpecificationHandler.Handle(ICall call) + at NSubstitute.Routing.Route.Handle(ICall call) + at NSubstitute.Core.CallRouter.Route(ICall call) + at NSubstitute.Proxies.CastleDynamicProxy.CastleForwardingInterceptor.Intercept(IInvocation invocation) + at Castle.DynamicProxy.AbstractInvocation.Proceed() + at NSubstitute.Proxies.CastleDynamicProxy.ProxyIdInterceptor.Intercept(IInvocation invocation) + at Castle.DynamicProxy.AbstractInvocation.Proceed() + at Castle.Proxies.AIFunctionProxy.InvokeCoreAsync(AIFunctionArguments arguments, CancellationToken cancellationToken) + at Microsoft.Extensions.AI.AIFunction.InvokeAsync(AIFunctionArguments arguments, CancellationToken cancellationToken) + at Sentry.Extensions.AI.Tests.SentryInstrumentedFunctionTests.InvokeCoreAsync_WithComplexResult_SetsOutputAsString() in /Users/alexsohn/code/sentry-dotnet/test/Sentry.Extensions.AI.Tests/SentryInstrumentedFunctionTests.cs:line 113 +--- End of stack trace from previous location --- + + + + + + + + + + + + NSubstitute.Exceptions.AmbiguousArgumentsException : Cannot determine argument specifications to use. Please use specifications for all arguments of the same type. +Method signature: + InvokeCoreAsync(AIFunctionArguments, CancellationToken) +Method arguments (possible arg matchers are indicated with '*'): + InvokeCoreAsync(AIFunctionArguments, *System.Threading.CancellationToken*) +All queued specifications: + any AIFunctionArguments + any CancellationToken +Matched argument specifications: + InvokeCoreAsync(AIFunctionArguments, ???) + + at NSubstitute.Core.Arguments.ArgumentSpecificationsFactory.Create(IList`1 argumentSpecs, Object[] arguments, IParameterInfo[] parameterInfos, MethodInfo methodInfo, MatchArgs matchArgs) + at NSubstitute.Core.CallSpecificationFactory.CreateFrom(ICall call, MatchArgs matchArgs) + at NSubstitute.Routing.Handlers.RecordCallSpecificationHandler.Handle(ICall call) + at NSubstitute.Routing.Route.Handle(ICall call) + at NSubstitute.Core.CallRouter.Route(ICall call) + at NSubstitute.Proxies.CastleDynamicProxy.CastleForwardingInterceptor.Intercept(IInvocation invocation) + at Castle.DynamicProxy.AbstractInvocation.Proceed() + at NSubstitute.Proxies.CastleDynamicProxy.ProxyIdInterceptor.Intercept(IInvocation invocation) + at Castle.DynamicProxy.AbstractInvocation.Proceed() + at Castle.Proxies.AIFunctionProxy.InvokeCoreAsync(AIFunctionArguments arguments, CancellationToken cancellationToken) + at Microsoft.Extensions.AI.AIFunction.InvokeAsync(AIFunctionArguments arguments, CancellationToken cancellationToken) + at Sentry.Extensions.AI.Tests.SentryInstrumentedFunctionTests.InvokeCoreAsync_WithoutParentSpan_CreatesTransaction() in /Users/alexsohn/code/sentry-dotnet/test/Sentry.Extensions.AI.Tests/SentryInstrumentedFunctionTests.cs:line 45 +--- End of stack trace from previous location --- + + + + + + + + + + NSubstitute.Exceptions.ReceivedCallsException : Expected to receive exactly 1 call matching: + SetData("gen_ai.usage.input_tokens", 10) +Actually received no matching calls. +Received 4 non-matching calls (non-matching arguments indicated with '*' characters): + SetData("gen_ai.usage.input_tokens", *10*) + SetData(*"gen_ai.usage.output_tokens"*, *20*) + SetData(*"gen_ai.usage.total_tokens"*, *30*) + SetData(*"gen_ai.response.text"*, *"Hello"*) + + at NSubstitute.Core.ReceivedCallsExceptionThrower.Throw(ICallSpecification callSpecification, IEnumerable`1 matchingCalls, IEnumerable`1 nonMatchingCalls, Quantity requiredQuantity) + at NSubstitute.Routing.Handlers.CheckReceivedCallsHandler.Handle(ICall call) + at NSubstitute.Routing.Route.Handle(ICall call) + at NSubstitute.Core.CallRouter.Route(ICall call) + at NSubstitute.Proxies.CastleDynamicProxy.CastleForwardingInterceptor.Intercept(IInvocation invocation) + at Castle.DynamicProxy.AbstractInvocation.Proceed() + at NSubstitute.Proxies.CastleDynamicProxy.ProxyIdInterceptor.Intercept(IInvocation invocation) + at Castle.DynamicProxy.AbstractInvocation.Proceed() + at Castle.Proxies.ObjectProxy_2.SetData(String key, Object value) + at Sentry.Extensions.AI.Tests.SentryAISpanEnricherTests.EnrichWithResponse_SetsUsageTokens_WhenUsageProvided() in /Users/alexsohn/code/sentry-dotnet/test/Sentry.Extensions.AI.Tests/SentryAISpanEnricherTests.cs:line 175 + at System.RuntimeMethodHandle.InvokeMethod(Object target, Void** arguments, Signature sig, Boolean isConstructor) + at System.Reflection.MethodBaseInvoker.InvokeWithNoArgs(Object obj, BindingFlags invokeAttr) + + + + + + + + + + + NSubstitute.Exceptions.AmbiguousArgumentsException : Cannot determine argument specifications to use. Please use specifications for all arguments of the same type. +Method signature: + InvokeCoreAsync(AIFunctionArguments, CancellationToken) +Method arguments (possible arg matchers are indicated with '*'): + InvokeCoreAsync(AIFunctionArguments, *System.Threading.CancellationToken*) +All queued specifications: + any AIFunctionArguments + any CancellationToken +Matched argument specifications: + InvokeCoreAsync(AIFunctionArguments, ???) + + at NSubstitute.Core.Arguments.ArgumentSpecificationsFactory.Create(IList`1 argumentSpecs, Object[] arguments, IParameterInfo[] parameterInfos, MethodInfo methodInfo, MatchArgs matchArgs) + at NSubstitute.Core.CallSpecificationFactory.CreateFrom(ICall call, MatchArgs matchArgs) + at NSubstitute.Routing.Handlers.RecordCallSpecificationHandler.Handle(ICall call) + at NSubstitute.Routing.Route.Handle(ICall call) + at NSubstitute.Core.CallRouter.Route(ICall call) + at NSubstitute.Proxies.CastleDynamicProxy.CastleForwardingInterceptor.Intercept(IInvocation invocation) + at Castle.DynamicProxy.AbstractInvocation.Proceed() + at NSubstitute.Proxies.CastleDynamicProxy.ProxyIdInterceptor.Intercept(IInvocation invocation) + at Castle.DynamicProxy.AbstractInvocation.Proceed() + at Castle.Proxies.AIFunctionProxy.InvokeCoreAsync(AIFunctionArguments arguments, CancellationToken cancellationToken) + at Microsoft.Extensions.AI.AIFunction.InvokeAsync(AIFunctionArguments arguments, CancellationToken cancellationToken) + at Sentry.Extensions.AI.Tests.SentryInstrumentedFunctionTests.InvokeCoreAsync_PreservesInnerFunctionName() in /Users/alexsohn/code/sentry-dotnet/test/Sentry.Extensions.AI.Tests/SentryInstrumentedFunctionTests.cs:line 135 +--- End of stack trace from previous location --- + + + + + + + + + + + + NSubstitute.Exceptions.AmbiguousArgumentsException : Cannot determine argument specifications to use. Please use specifications for all arguments of the same type. +Method signature: + InvokeCoreAsync(AIFunctionArguments, CancellationToken) +Method arguments (possible arg matchers are indicated with '*'): + InvokeCoreAsync(AIFunctionArguments, *System.Threading.CancellationToken*) +All queued specifications: + any AIFunctionArguments + any CancellationToken +Matched argument specifications: + InvokeCoreAsync(AIFunctionArguments, ???) + + at NSubstitute.Core.Arguments.ArgumentSpecificationsFactory.Create(IList`1 argumentSpecs, Object[] arguments, IParameterInfo[] parameterInfos, MethodInfo methodInfo, MatchArgs matchArgs) + at NSubstitute.Core.CallSpecificationFactory.CreateFrom(ICall call, MatchArgs matchArgs) + at NSubstitute.Routing.Handlers.RecordCallSpecificationHandler.Handle(ICall call) + at NSubstitute.Routing.Route.Handle(ICall call) + at NSubstitute.Core.CallRouter.Route(ICall call) + at NSubstitute.Proxies.CastleDynamicProxy.CastleForwardingInterceptor.Intercept(IInvocation invocation) + at Castle.DynamicProxy.AbstractInvocation.Proceed() + at NSubstitute.Proxies.CastleDynamicProxy.ProxyIdInterceptor.Intercept(IInvocation invocation) + at Castle.DynamicProxy.AbstractInvocation.Proceed() + at Castle.Proxies.AIFunctionProxy.InvokeCoreAsync(AIFunctionArguments arguments, CancellationToken cancellationToken) + at Microsoft.Extensions.AI.AIFunction.InvokeAsync(AIFunctionArguments arguments, CancellationToken cancellationToken) + at Sentry.Extensions.AI.Tests.SentryInstrumentedFunctionTests.InvokeCoreAsync_WithParentSpan_CreatesChildSpan() in /Users/alexsohn/code/sentry-dotnet/test/Sentry.Extensions.AI.Tests/SentryInstrumentedFunctionTests.cs:line 19 +--- End of stack trace from previous location --- + + + + + + + + + + + + + + + + + + NSubstitute.Exceptions.ReceivedCallsException : Expected to receive exactly 1 call matching: + SetData("gen_ai.usage.input_tokens", 10) +Actually received no matching calls. +Received 2 non-matching calls (non-matching arguments indicated with '*' characters): + SetData("gen_ai.usage.input_tokens", *10*) + SetData(*"gen_ai.response.text"*, *"Hello"*) + + at NSubstitute.Core.ReceivedCallsExceptionThrower.Throw(ICallSpecification callSpecification, IEnumerable`1 matchingCalls, IEnumerable`1 nonMatchingCalls, Quantity requiredQuantity) + at NSubstitute.Routing.Handlers.CheckReceivedCallsHandler.Handle(ICall call) + at NSubstitute.Routing.Route.Handle(ICall call) + at NSubstitute.Core.CallRouter.Route(ICall call) + at NSubstitute.Proxies.CastleDynamicProxy.CastleForwardingInterceptor.Intercept(IInvocation invocation) + at Castle.DynamicProxy.AbstractInvocation.Proceed() + at NSubstitute.Proxies.CastleDynamicProxy.ProxyIdInterceptor.Intercept(IInvocation invocation) + at Castle.DynamicProxy.AbstractInvocation.Proceed() + at Castle.Proxies.ObjectProxy_2.SetData(String key, Object value) + at Sentry.Extensions.AI.Tests.SentryAISpanEnricherTests.EnrichWithResponse_SetsPartialTokens_WhenOnlyInputTokensProvided() in /Users/alexsohn/code/sentry-dotnet/test/Sentry.Extensions.AI.Tests/SentryAISpanEnricherTests.cs:line 203 + at System.RuntimeMethodHandle.InvokeMethod(Object target, Void** arguments, Signature sig, Boolean isConstructor) + at System.Reflection.MethodBaseInvoker.InvokeWithNoArgs(Object obj, BindingFlags invokeAttr) + + + + + + + + NSubstitute.Exceptions.AmbiguousArgumentsException : Cannot determine argument specifications to use. Please use specifications for all arguments of the same type. +Method signature: + InvokeCoreAsync(AIFunctionArguments, CancellationToken) +Method arguments (possible arg matchers are indicated with '*'): + InvokeCoreAsync(AIFunctionArguments, *System.Threading.CancellationToken*) +All queued specifications: + any AIFunctionArguments + any CancellationToken +Matched argument specifications: + InvokeCoreAsync(AIFunctionArguments, ???) + + at NSubstitute.Core.Arguments.ArgumentSpecificationsFactory.Create(IList`1 argumentSpecs, Object[] arguments, IParameterInfo[] parameterInfos, MethodInfo methodInfo, MatchArgs matchArgs) + at NSubstitute.Core.CallSpecificationFactory.CreateFrom(ICall call, MatchArgs matchArgs) + at NSubstitute.Routing.Handlers.RecordCallSpecificationHandler.Handle(ICall call) + at NSubstitute.Routing.Route.Handle(ICall call) + at NSubstitute.Core.CallRouter.Route(ICall call) + at NSubstitute.Proxies.CastleDynamicProxy.CastleForwardingInterceptor.Intercept(IInvocation invocation) + at Castle.DynamicProxy.AbstractInvocation.Proceed() + at NSubstitute.Proxies.CastleDynamicProxy.ProxyIdInterceptor.Intercept(IInvocation invocation) + at Castle.DynamicProxy.AbstractInvocation.Proceed() + at Castle.Proxies.AIFunctionProxy.InvokeCoreAsync(AIFunctionArguments arguments, CancellationToken cancellationToken) + at Microsoft.Extensions.AI.AIFunction.InvokeAsync(AIFunctionArguments arguments, CancellationToken cancellationToken) + at Sentry.Extensions.AI.Tests.SentryInstrumentedFunctionTests.InvokeCoreAsync_WithCancellation_PropagatesCancellation() in /Users/alexsohn/code/sentry-dotnet/test/Sentry.Extensions.AI.Tests/SentryInstrumentedFunctionTests.cs:line 160 +--- End of stack trace from previous location --- + + + + + + + NSubstitute.Exceptions.AmbiguousArgumentsException : Cannot determine argument specifications to use. Please use specifications for all arguments of the same type. +Method signature: + InvokeCoreAsync(AIFunctionArguments, CancellationToken) +Method arguments (possible arg matchers are indicated with '*'): + InvokeCoreAsync(AIFunctionArguments, *System.Threading.CancellationToken*) +All queued specifications: + any AIFunctionArguments + any CancellationToken +Matched argument specifications: + InvokeCoreAsync(AIFunctionArguments, ???) + + at NSubstitute.Core.Arguments.ArgumentSpecificationsFactory.Create(IList`1 argumentSpecs, Object[] arguments, IParameterInfo[] parameterInfos, MethodInfo methodInfo, MatchArgs matchArgs) + at NSubstitute.Core.CallSpecificationFactory.CreateFrom(ICall call, MatchArgs matchArgs) + at NSubstitute.Routing.Handlers.RecordCallSpecificationHandler.Handle(ICall call) + at NSubstitute.Routing.Route.Handle(ICall call) + at NSubstitute.Core.CallRouter.Route(ICall call) + at NSubstitute.Proxies.CastleDynamicProxy.CastleForwardingInterceptor.Intercept(IInvocation invocation) + at Castle.DynamicProxy.AbstractInvocation.Proceed() + at NSubstitute.Proxies.CastleDynamicProxy.ProxyIdInterceptor.Intercept(IInvocation invocation) + at Castle.DynamicProxy.AbstractInvocation.Proceed() + at Castle.Proxies.AIFunctionProxy.InvokeCoreAsync(AIFunctionArguments arguments, CancellationToken cancellationToken) + at Microsoft.Extensions.AI.AIFunction.InvokeAsync(AIFunctionArguments arguments, CancellationToken cancellationToken) + at Sentry.Extensions.AI.Tests.SentryInstrumentedFunctionTests.InvokeCoreAsync_WhenInnerFunctionThrows_FinishesSpanWithException() in /Users/alexsohn/code/sentry-dotnet/test/Sentry.Extensions.AI.Tests/SentryInstrumentedFunctionTests.cs:line 68 +--- End of stack trace from previous location --- + + + + + + + + + + + + + + + NSubstitute.Exceptions.AmbiguousArgumentsException : Cannot determine argument specifications to use. Please use specifications for all arguments of the same type. +Method signature: + InvokeCoreAsync(AIFunctionArguments, CancellationToken) +Method arguments (possible arg matchers are indicated with '*'): + InvokeCoreAsync(AIFunctionArguments, *System.Threading.CancellationToken*) +All queued specifications: + any AIFunctionArguments + any CancellationToken +Matched argument specifications: + InvokeCoreAsync(AIFunctionArguments, ???) + + at NSubstitute.Core.Arguments.ArgumentSpecificationsFactory.Create(IList`1 argumentSpecs, Object[] arguments, IParameterInfo[] parameterInfos, MethodInfo methodInfo, MatchArgs matchArgs) + at NSubstitute.Core.CallSpecificationFactory.CreateFrom(ICall call, MatchArgs matchArgs) + at NSubstitute.Routing.Handlers.RecordCallSpecificationHandler.Handle(ICall call) + at NSubstitute.Routing.Route.Handle(ICall call) + at NSubstitute.Core.CallRouter.Route(ICall call) + at NSubstitute.Proxies.CastleDynamicProxy.CastleForwardingInterceptor.Intercept(IInvocation invocation) + at Castle.DynamicProxy.AbstractInvocation.Proceed() + at NSubstitute.Proxies.CastleDynamicProxy.ProxyIdInterceptor.Intercept(IInvocation invocation) + at Castle.DynamicProxy.AbstractInvocation.Proceed() + at Castle.Proxies.AIFunctionProxy.InvokeCoreAsync(AIFunctionArguments arguments, CancellationToken cancellationToken) + at Microsoft.Extensions.AI.AIFunction.InvokeAsync(AIFunctionArguments arguments, CancellationToken cancellationToken) + at Sentry.Extensions.AI.Tests.SentryInstrumentedFunctionTests.InvokeCoreAsync_WithNullResult_DoesNotSetOutput() in /Users/alexsohn/code/sentry-dotnet/test/Sentry.Extensions.AI.Tests/SentryInstrumentedFunctionTests.cs:line 90 +--- End of stack trace from previous location --- + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + [xUnit.net 00:00:00.00] xUnit.net VSTest Adapter v3.1.1+bf6400fd51 (64-bit .NET 9.0.8) +[xUnit.net 00:00:00.05] Discovering: Sentry.Extensions.AI.Tests +[xUnit.net 00:00:00.07] Discovered: Sentry.Extensions.AI.Tests +[xUnit.net 00:00:00.08] Starting: Sentry.Extensions.AI.Tests +[xUnit.net 00:00:00.19] NSubstitute.Exceptions.AmbiguousArgumentsException : Cannot determine argument specifications to use. Please use specifications for all arguments of the same type. +[xUnit.net 00:00:00.19] Method signature: +[xUnit.net 00:00:00.19] InvokeCoreAsync(AIFunctionArguments, CancellationToken) +[xUnit.net 00:00:00.19] Method arguments (possible arg matchers are indicated with '*'): +[xUnit.net 00:00:00.19] InvokeCoreAsync(AIFunctionArguments, *System.Threading.CancellationToken*) +[xUnit.net 00:00:00.19] All queued specifications: +[xUnit.net 00:00:00.19] any AIFunctionArguments +[xUnit.net 00:00:00.19] any CancellationToken +[xUnit.net 00:00:00.19] Matched argument specifications: +[xUnit.net 00:00:00.19] InvokeCoreAsync(AIFunctionArguments, ???) +[xUnit.net 00:00:00.19] +[xUnit.net 00:00:00.19] Stack Trace: +[xUnit.net 00:00:00.19] at NSubstitute.Core.Arguments.ArgumentSpecificationsFactory.Create(IList`1 argumentSpecs, Object[] arguments, IParameterInfo[] parameterInfos, MethodInfo methodInfo, MatchArgs matchArgs) +[xUnit.net 00:00:00.19] at NSubstitute.Core.CallSpecificationFactory.CreateFrom(ICall call, MatchArgs matchArgs) +[xUnit.net 00:00:00.19] at NSubstitute.Routing.Handlers.RecordCallSpecificationHandler.Handle(ICall call) +[xUnit.net 00:00:00.19] at NSubstitute.Routing.Route.Handle(ICall call) +[xUnit.net 00:00:00.19] at NSubstitute.Core.CallRouter.Route(ICall call) +[xUnit.net 00:00:00.19] at NSubstitute.Proxies.CastleDynamicProxy.CastleForwardingInterceptor.Intercept(IInvocation invocation) +[xUnit.net 00:00:00.19] at Castle.DynamicProxy.AbstractInvocation.Proceed() +[xUnit.net 00:00:00.19] at NSubstitute.Proxies.CastleDynamicProxy.ProxyIdInterceptor.Intercept(IInvocation invocation) +[xUnit.net 00:00:00.19] at Castle.DynamicProxy.AbstractInvocation.Proceed() +[xUnit.net 00:00:00.19] at Castle.Proxies.AIFunctionProxy.InvokeCoreAsync(AIFunctionArguments arguments, CancellationToken cancellationToken) +[xUnit.net 00:00:00.19] at Microsoft.Extensions.AI.AIFunction.InvokeAsync(AIFunctionArguments arguments, CancellationToken cancellationToken) +[xUnit.net 00:00:00.19] /Users/alexsohn/code/sentry-dotnet/test/Sentry.Extensions.AI.Tests/SentryInstrumentedFunctionTests.cs(68,0): at Sentry.Extensions.AI.Tests.SentryInstrumentedFunctionTests.InvokeCoreAsync_WhenInnerFunctionThrows_FinishesSpanWithException() +[xUnit.net 00:00:00.19] --- End of stack trace from previous location --- +[xUnit.net 00:00:00.20] NSubstitute.Exceptions.AmbiguousArgumentsException : Cannot determine argument specifications to use. Please use specifications for all arguments of the same type. +[xUnit.net 00:00:00.20] Method signature: +[xUnit.net 00:00:00.20] InvokeCoreAsync(AIFunctionArguments, CancellationToken) +[xUnit.net 00:00:00.20] Method arguments (possible arg matchers are indicated with '*'): +[xUnit.net 00:00:00.20] InvokeCoreAsync(AIFunctionArguments, *System.Threading.CancellationToken*) +[xUnit.net 00:00:00.20] All queued specifications: +[xUnit.net 00:00:00.20] any AIFunctionArguments +[xUnit.net 00:00:00.20] any CancellationToken +[xUnit.net 00:00:00.20] Matched argument specifications: +[xUnit.net 00:00:00.20] InvokeCoreAsync(AIFunctionArguments, ???) +[xUnit.net 00:00:00.20] +[xUnit.net 00:00:00.20] Stack Trace: +[xUnit.net 00:00:00.20] at NSubstitute.Core.Arguments.ArgumentSpecificationsFactory.Create(IList`1 argumentSpecs, Object[] arguments, IParameterInfo[] parameterInfos, MethodInfo methodInfo, MatchArgs matchArgs) +[xUnit.net 00:00:00.20] at NSubstitute.Core.CallSpecificationFactory.CreateFrom(ICall call, MatchArgs matchArgs) +[xUnit.net 00:00:00.20] at NSubstitute.Routing.Handlers.RecordCallSpecificationHandler.Handle(ICall call) +[xUnit.net 00:00:00.20] at NSubstitute.Routing.Route.Handle(ICall call) +[xUnit.net 00:00:00.20] at NSubstitute.Core.CallRouter.Route(ICall call) +[xUnit.net 00:00:00.20] at NSubstitute.Proxies.CastleDynamicProxy.CastleForwardingInterceptor.Intercept(IInvocation invocation) +[xUnit.net 00:00:00.20] at Castle.DynamicProxy.AbstractInvocation.Proceed() +[xUnit.net 00:00:00.20] at NSubstitute.Proxies.CastleDynamicProxy.ProxyIdInterceptor.Intercept(IInvocation invocation) +[xUnit.net 00:00:00.20] at Castle.DynamicProxy.AbstractInvocation.Proceed() +[xUnit.net 00:00:00.20] at Castle.Proxies.AIFunctionProxy.InvokeCoreAsync(AIFunctionArguments arguments, CancellationToken cancellationToken) +[xUnit.net 00:00:00.20] at Microsoft.Extensions.AI.AIFunction.InvokeAsync(AIFunctionArguments arguments, CancellationToken cancellationToken) +[xUnit.net 00:00:00.20] /Users/alexsohn/code/sentry-dotnet/test/Sentry.Extensions.AI.Tests/SentryInstrumentedFunctionTests.cs(135,0): at Sentry.Extensions.AI.Tests.SentryInstrumentedFunctionTests.InvokeCoreAsync_PreservesInnerFunctionName() +[xUnit.net 00:00:00.20] --- End of stack trace from previous location --- +[xUnit.net 00:00:00.20] NSubstitute.Exceptions.AmbiguousArgumentsException : Cannot determine argument specifications to use. Please use specifications for all arguments of the same type. +[xUnit.net 00:00:00.20] Method signature: +[xUnit.net 00:00:00.20] InvokeCoreAsync(AIFunctionArguments, CancellationToken) +[xUnit.net 00:00:00.20] Method arguments (possible arg matchers are indicated with '*'): +[xUnit.net 00:00:00.20] InvokeCoreAsync(AIFunctionArguments, *System.Threading.CancellationToken*) +[xUnit.net 00:00:00.20] All queued specifications: +[xUnit.net 00:00:00.20] any AIFunctionArguments +[xUnit.net 00:00:00.20] any CancellationToken +[xUnit.net 00:00:00.20] Matched argument specifications: +[xUnit.net 00:00:00.20] InvokeCoreAsync(AIFunctionArguments, ???) +[xUnit.net 00:00:00.20] +[xUnit.net 00:00:00.20] Stack Trace: +[xUnit.net 00:00:00.20] at NSubstitute.Core.Arguments.ArgumentSpecificationsFactory.Create(IList`1 argumentSpecs, Object[] arguments, IParameterInfo[] parameterInfos, MethodInfo methodInfo, MatchArgs matchArgs) +[xUnit.net 00:00:00.20] at NSubstitute.Core.CallSpecificationFactory.CreateFrom(ICall call, MatchArgs matchArgs) +[xUnit.net 00:00:00.20] at NSubstitute.Routing.Handlers.RecordCallSpecificationHandler.Handle(ICall call) +[xUnit.net 00:00:00.20] at NSubstitute.Routing.Route.Handle(ICall call) +[xUnit.net 00:00:00.20] at NSubstitute.Core.CallRouter.Route(ICall call) +[xUnit.net 00:00:00.20] at NSubstitute.Proxies.CastleDynamicProxy.CastleForwardingInterceptor.Intercept(IInvocation invocation) +[xUnit.net 00:00:00.20] at Castle.DynamicProxy.AbstractInvocation.Proceed() +[xUnit.net 00:00:00.20] at NSubstitute.Proxies.CastleDynamicProxy.ProxyIdInterceptor.Intercept(IInvocation invocation) +[xUnit.net 00:00:00.20] at Castle.DynamicProxy.AbstractInvocation.Proceed() +[xUnit.net 00:00:00.20] at Castle.Proxies.AIFunctionProxy.InvokeCoreAsync(AIFunctionArguments arguments, CancellationToken cancellationToken) +[xUnit.net 00:00:00.20] at Microsoft.Extensions.AI.AIFunction.InvokeAsync(AIFunctionArguments arguments, CancellationToken cancellationToken) +[xUnit.net 00:00:00.20] /Users/alexsohn/code/sentry-dotnet/test/Sentry.Extensions.AI.Tests/SentryInstrumentedFunctionTests.cs(19,0): at Sentry.Extensions.AI.Tests.SentryInstrumentedFunctionTests.InvokeCoreAsync_WithParentSpan_CreatesChildSpan() +[xUnit.net 00:00:00.20] --- End of stack trace from previous location --- +[xUnit.net 00:00:00.20] NSubstitute.Exceptions.AmbiguousArgumentsException : Cannot determine argument specifications to use. Please use specifications for all arguments of the same type. +[xUnit.net 00:00:00.20] Method signature: +[xUnit.net 00:00:00.20] InvokeCoreAsync(AIFunctionArguments, CancellationToken) +[xUnit.net 00:00:00.20] Method arguments (possible arg matchers are indicated with '*'): +[xUnit.net 00:00:00.20] InvokeCoreAsync(AIFunctionArguments, *System.Threading.CancellationToken*) +[xUnit.net 00:00:00.20] All queued specifications: +[xUnit.net 00:00:00.20] any AIFunctionArguments +[xUnit.net 00:00:00.20] any CancellationToken +[xUnit.net 00:00:00.20] Matched argument specifications: +[xUnit.net 00:00:00.20] InvokeCoreAsync(AIFunctionArguments, ???) +[xUnit.net 00:00:00.20] +[xUnit.net 00:00:00.20] Stack Trace: +[xUnit.net 00:00:00.20] at NSubstitute.Core.Arguments.ArgumentSpecificationsFactory.Create(IList`1 argumentSpecs, Object[] arguments, IParameterInfo[] parameterInfos, MethodInfo methodInfo, MatchArgs matchArgs) +[xUnit.net 00:00:00.20] at NSubstitute.Core.CallSpecificationFactory.CreateFrom(ICall call, MatchArgs matchArgs) +[xUnit.net 00:00:00.20] at NSubstitute.Routing.Handlers.RecordCallSpecificationHandler.Handle(ICall call) +[xUnit.net 00:00:00.20] at NSubstitute.Routing.Route.Handle(ICall call) +[xUnit.net 00:00:00.20] at NSubstitute.Core.CallRouter.Route(ICall call) +[xUnit.net 00:00:00.20] at NSubstitute.Proxies.CastleDynamicProxy.CastleForwardingInterceptor.Intercept(IInvocation invocation) +[xUnit.net 00:00:00.20] at Castle.DynamicProxy.AbstractInvocation.Proceed() +[xUnit.net 00:00:00.20] at NSubstitute.Proxies.CastleDynamicProxy.ProxyIdInterceptor.Intercept(IInvocation invocation) +[xUnit.net 00:00:00.20] at Castle.DynamicProxy.AbstractInvocation.Proceed() +[xUnit.net 00:00:00.20] at Castle.Proxies.AIFunctionProxy.InvokeCoreAsync(AIFunctionArguments arguments, CancellationToken cancellationToken) +[xUnit.net 00:00:00.20] at Microsoft.Extensions.AI.AIFunction.InvokeAsync(AIFunctionArguments arguments, CancellationToken cancellationToken) +[xUnit.net 00:00:00.20] /Users/alexsohn/code/sentry-dotnet/test/Sentry.Extensions.AI.Tests/SentryInstrumentedFunctionTests.cs(45,0): at Sentry.Extensions.AI.Tests.SentryInstrumentedFunctionTests.InvokeCoreAsync_WithoutParentSpan_CreatesTransaction() +[xUnit.net 00:00:00.20] --- End of stack trace from previous location --- +[xUnit.net 00:00:00.21] NSubstitute.Exceptions.AmbiguousArgumentsException : Cannot determine argument specifications to use. Please use specifications for all arguments of the same type. +[xUnit.net 00:00:00.21] Method signature: +[xUnit.net 00:00:00.21] InvokeCoreAsync(AIFunctionArguments, CancellationToken) +[xUnit.net 00:00:00.21] Method arguments (possible arg matchers are indicated with '*'): +[xUnit.net 00:00:00.21] InvokeCoreAsync(AIFunctionArguments, *System.Threading.CancellationToken*) +[xUnit.net 00:00:00.21] All queued specifications: +[xUnit.net 00:00:00.21] any AIFunctionArguments +[xUnit.net 00:00:00.21] any CancellationToken +[xUnit.net 00:00:00.21] Matched argument specifications: +[xUnit.net 00:00:00.21] InvokeCoreAsync(AIFunctionArguments, ???) +[xUnit.net 00:00:00.21] +[xUnit.net 00:00:00.21] Stack Trace: +[xUnit.net 00:00:00.21] at NSubstitute.Core.Arguments.ArgumentSpecificationsFactory.Create(IList`1 argumentSpecs, Object[] arguments, IParameterInfo[] parameterInfos, MethodInfo methodInfo, MatchArgs matchArgs) +[xUnit.net 00:00:00.21] at NSubstitute.Core.CallSpecificationFactory.CreateFrom(ICall call, MatchArgs matchArgs) +[xUnit.net 00:00:00.21] at NSubstitute.Routing.Handlers.RecordCallSpecificationHandler.Handle(ICall call) +[xUnit.net 00:00:00.21] at NSubstitute.Routing.Route.Handle(ICall call) +[xUnit.net 00:00:00.21] at NSubstitute.Core.CallRouter.Route(ICall call) +[xUnit.net 00:00:00.21] at NSubstitute.Proxies.CastleDynamicProxy.CastleForwardingInterceptor.Intercept(IInvocation invocation) +[xUnit.net 00:00:00.21] at Castle.DynamicProxy.AbstractInvocation.Proceed() +[xUnit.net 00:00:00.21] at NSubstitute.Proxies.CastleDynamicProxy.ProxyIdInterceptor.Intercept(IInvocation invocation) +[xUnit.net 00:00:00.21] at Castle.DynamicProxy.AbstractInvocation.Proceed() +[xUnit.net 00:00:00.21] at Castle.Proxies.AIFunctionProxy.InvokeCoreAsync(AIFunctionArguments arguments, CancellationToken cancellationToken) +[xUnit.net 00:00:00.21] at Microsoft.Extensions.AI.AIFunction.InvokeAsync(AIFunctionArguments arguments, CancellationToken cancellationToken) +[xUnit.net 00:00:00.21] /Users/alexsohn/code/sentry-dotnet/test/Sentry.Extensions.AI.Tests/SentryInstrumentedFunctionTests.cs(113,0): at Sentry.Extensions.AI.Tests.SentryInstrumentedFunctionTests.InvokeCoreAsync_WithComplexResult_SetsOutputAsString() +[xUnit.net 00:00:00.21] --- End of stack trace from previous location --- +[xUnit.net 00:00:00.21] NSubstitute.Exceptions.AmbiguousArgumentsException : Cannot determine argument specifications to use. Please use specifications for all arguments of the same type. +[xUnit.net 00:00:00.21] Method signature: +[xUnit.net 00:00:00.21] InvokeCoreAsync(AIFunctionArguments, CancellationToken) +[xUnit.net 00:00:00.21] Method arguments (possible arg matchers are indicated with '*'): +[xUnit.net 00:00:00.21] InvokeCoreAsync(AIFunctionArguments, *System.Threading.CancellationToken*) +[xUnit.net 00:00:00.21] All queued specifications: +[xUnit.net 00:00:00.21] any AIFunctionArguments +[xUnit.net 00:00:00.21] any CancellationToken +[xUnit.net 00:00:00.21] Matched argument specifications: +[xUnit.net 00:00:00.21] InvokeCoreAsync(AIFunctionArguments, ???) +[xUnit.net 00:00:00.21] +[xUnit.net 00:00:00.21] Stack Trace: +[xUnit.net 00:00:00.21] at NSubstitute.Core.Arguments.ArgumentSpecificationsFactory.Create(IList`1 argumentSpecs, Object[] arguments, IParameterInfo[] parameterInfos, MethodInfo methodInfo, MatchArgs matchArgs) +[xUnit.net 00:00:00.21] at NSubstitute.Core.CallSpecificationFactory.CreateFrom(ICall call, MatchArgs matchArgs) +[xUnit.net 00:00:00.21] at NSubstitute.Routing.Handlers.RecordCallSpecificationHandler.Handle(ICall call) +[xUnit.net 00:00:00.21] at NSubstitute.Routing.Route.Handle(ICall call) +[xUnit.net 00:00:00.21] at NSubstitute.Core.CallRouter.Route(ICall call) +[xUnit.net 00:00:00.21] at NSubstitute.Proxies.CastleDynamicProxy.CastleForwardingInterceptor.Intercept(IInvocation invocation) +[xUnit.net 00:00:00.21] at Castle.DynamicProxy.AbstractInvocation.Proceed() +[xUnit.net 00:00:00.21] at NSubstitute.Proxies.CastleDynamicProxy.ProxyIdInterceptor.Intercept(IInvocation invocation) +[xUnit.net 00:00:00.21] at Castle.DynamicProxy.AbstractInvocation.Proceed() +[xUnit.net 00:00:00.21] at Castle.Proxies.AIFunctionProxy.InvokeCoreAsync(AIFunctionArguments arguments, CancellationToken cancellationToken) +[xUnit.net 00:00:00.21] at Microsoft.Extensions.AI.AIFunction.InvokeAsync(AIFunctionArguments arguments, CancellationToken cancellationToken) +[xUnit.net 00:00:00.21] /Users/alexsohn/code/sentry-dotnet/test/Sentry.Extensions.AI.Tests/SentryInstrumentedFunctionTests.cs(90,0): at Sentry.Extensions.AI.Tests.SentryInstrumentedFunctionTests.InvokeCoreAsync_WithNullResult_DoesNotSetOutput() +[xUnit.net 00:00:00.21] --- End of stack trace from previous location --- +[xUnit.net 00:00:00.21] NSubstitute.Exceptions.AmbiguousArgumentsException : Cannot determine argument specifications to use. Please use specifications for all arguments of the same type. +[xUnit.net 00:00:00.21] Method signature: +[xUnit.net 00:00:00.21] InvokeCoreAsync(AIFunctionArguments, CancellationToken) +[xUnit.net 00:00:00.21] Method arguments (possible arg matchers are indicated with '*'): +[xUnit.net 00:00:00.21] InvokeCoreAsync(AIFunctionArguments, *System.Threading.CancellationToken*) +[xUnit.net 00:00:00.21] All queued specifications: +[xUnit.net 00:00:00.21] any AIFunctionArguments +[xUnit.net 00:00:00.21] any CancellationToken +[xUnit.net 00:00:00.21] Matched argument specifications: +[xUnit.net 00:00:00.21] InvokeCoreAsync(AIFunctionArguments, ???) +[xUnit.net 00:00:00.21] +[xUnit.net 00:00:00.21] Stack Trace: +[xUnit.net 00:00:00.21] at NSubstitute.Core.Arguments.ArgumentSpecificationsFactory.Create(IList`1 argumentSpecs, Object[] arguments, IParameterInfo[] parameterInfos, MethodInfo methodInfo, MatchArgs matchArgs) +[xUnit.net 00:00:00.21] at NSubstitute.Core.CallSpecificationFactory.CreateFrom(ICall call, MatchArgs matchArgs) +[xUnit.net 00:00:00.21] at NSubstitute.Routing.Handlers.RecordCallSpecificationHandler.Handle(ICall call) +[xUnit.net 00:00:00.21] at NSubstitute.Routing.Route.Handle(ICall call) +[xUnit.net 00:00:00.21] at NSubstitute.Core.CallRouter.Route(ICall call) +[xUnit.net 00:00:00.21] at NSubstitute.Proxies.CastleDynamicProxy.CastleForwardingInterceptor.Intercept(IInvocation invocation) +[xUnit.net 00:00:00.21] at Castle.DynamicProxy.AbstractInvocation.Proceed() +[xUnit.net 00:00:00.21] at NSubstitute.Proxies.CastleDynamicProxy.ProxyIdInterceptor.Intercept(IInvocation invocation) +[xUnit.net 00:00:00.21] at Castle.DynamicProxy.AbstractInvocation.Proceed() +[xUnit.net 00:00:00.21] at Castle.Proxies.AIFunctionProxy.InvokeCoreAsync(AIFunctionArguments arguments, CancellationToken cancellationToken) +[xUnit.net 00:00:00.21] at Microsoft.Extensions.AI.AIFunction.InvokeAsync(AIFunctionArguments arguments, CancellationToken cancellationToken) +[xUnit.net 00:00:00.21] /Users/alexsohn/code/sentry-dotnet/test/Sentry.Extensions.AI.Tests/SentryInstrumentedFunctionTests.cs(160,0): at Sentry.Extensions.AI.Tests.SentryInstrumentedFunctionTests.InvokeCoreAsync_WithCancellation_PropagatesCancellation() +[xUnit.net 00:00:00.21] --- End of stack trace from previous location --- +[xUnit.net 00:00:00.27] NSubstitute.Exceptions.ReceivedCallsException : Expected to receive exactly 1 call matching: +[xUnit.net 00:00:00.27] SetData("gen_ai.usage.input_tokens", 10) +[xUnit.net 00:00:00.27] Actually received no matching calls. +[xUnit.net 00:00:00.27] Received 4 non-matching calls (non-matching arguments indicated with '*' characters): +[xUnit.net 00:00:00.27] SetData("gen_ai.usage.input_tokens", *10*) +[xUnit.net 00:00:00.27] SetData(*"gen_ai.usage.output_tokens"*, *20*) +[xUnit.net 00:00:00.27] SetData(*"gen_ai.usage.total_tokens"*, *30*) +[xUnit.net 00:00:00.27] SetData(*"gen_ai.response.text"*, *"Hello"*) +[xUnit.net 00:00:00.27] +[xUnit.net 00:00:00.27] Stack Trace: +[xUnit.net 00:00:00.27] at NSubstitute.Core.ReceivedCallsExceptionThrower.Throw(ICallSpecification callSpecification, IEnumerable`1 matchingCalls, IEnumerable`1 nonMatchingCalls, Quantity requiredQuantity) +[xUnit.net 00:00:00.27] at NSubstitute.Routing.Handlers.CheckReceivedCallsHandler.Handle(ICall call) +[xUnit.net 00:00:00.27] at NSubstitute.Routing.Route.Handle(ICall call) +[xUnit.net 00:00:00.27] at NSubstitute.Core.CallRouter.Route(ICall call) +[xUnit.net 00:00:00.27] at NSubstitute.Proxies.CastleDynamicProxy.CastleForwardingInterceptor.Intercept(IInvocation invocation) +[xUnit.net 00:00:00.27] at Castle.DynamicProxy.AbstractInvocation.Proceed() +[xUnit.net 00:00:00.27] at NSubstitute.Proxies.CastleDynamicProxy.ProxyIdInterceptor.Intercept(IInvocation invocation) +[xUnit.net 00:00:00.27] at Castle.DynamicProxy.AbstractInvocation.Proceed() +[xUnit.net 00:00:00.27] at Castle.Proxies.ObjectProxy_2.SetData(String key, Object value) +[xUnit.net 00:00:00.27] /Users/alexsohn/code/sentry-dotnet/test/Sentry.Extensions.AI.Tests/SentryAISpanEnricherTests.cs(175,0): at Sentry.Extensions.AI.Tests.SentryAISpanEnricherTests.EnrichWithResponse_SetsUsageTokens_WhenUsageProvided() +[xUnit.net 00:00:00.27] at System.RuntimeMethodHandle.InvokeMethod(Object target, Void** arguments, Signature sig, Boolean isConstructor) +[xUnit.net 00:00:00.27] at System.Reflection.MethodBaseInvoker.InvokeWithNoArgs(Object obj, BindingFlags invokeAttr) +[xUnit.net 00:00:00.27] NSubstitute.Exceptions.ReceivedCallsException : Expected to receive exactly 1 call matching: +[xUnit.net 00:00:00.27] SetData("gen_ai.usage.input_tokens", 10) +[xUnit.net 00:00:00.27] Actually received no matching calls. +[xUnit.net 00:00:00.27] Received 2 non-matching calls (non-matching arguments indicated with '*' characters): +[xUnit.net 00:00:00.27] SetData("gen_ai.usage.input_tokens", *10*) +[xUnit.net 00:00:00.27] SetData(*"gen_ai.response.text"*, *"Hello"*) +[xUnit.net 00:00:00.27] +[xUnit.net 00:00:00.27] Stack Trace: +[xUnit.net 00:00:00.27] at NSubstitute.Core.ReceivedCallsExceptionThrower.Throw(ICallSpecification callSpecification, IEnumerable`1 matchingCalls, IEnumerable`1 nonMatchingCalls, Quantity requiredQuantity) +[xUnit.net 00:00:00.27] at NSubstitute.Routing.Handlers.CheckReceivedCallsHandler.Handle(ICall call) +[xUnit.net 00:00:00.27] at NSubstitute.Routing.Route.Handle(ICall call) +[xUnit.net 00:00:00.27] at NSubstitute.Core.CallRouter.Route(ICall call) +[xUnit.net 00:00:00.27] at NSubstitute.Proxies.CastleDynamicProxy.CastleForwardingInterceptor.Intercept(IInvocation invocation) +[xUnit.net 00:00:00.27] at Castle.DynamicProxy.AbstractInvocation.Proceed() +[xUnit.net 00:00:00.27] at NSubstitute.Proxies.CastleDynamicProxy.ProxyIdInterceptor.Intercept(IInvocation invocation) +[xUnit.net 00:00:00.27] at Castle.DynamicProxy.AbstractInvocation.Proceed() +[xUnit.net 00:00:00.27] at Castle.Proxies.ObjectProxy_2.SetData(String key, Object value) +[xUnit.net 00:00:00.27] /Users/alexsohn/code/sentry-dotnet/test/Sentry.Extensions.AI.Tests/SentryAISpanEnricherTests.cs(203,0): at Sentry.Extensions.AI.Tests.SentryAISpanEnricherTests.EnrichWithResponse_SetsPartialTokens_WhenOnlyInputTokensProvided() +[xUnit.net 00:00:00.27] at System.RuntimeMethodHandle.InvokeMethod(Object target, Void** arguments, Signature sig, Boolean isConstructor) +[xUnit.net 00:00:00.27] at System.Reflection.MethodBaseInvoker.InvokeWithNoArgs(Object obj, BindingFlags invokeAttr) +[xUnit.net 00:00:00.30] Finished: Sentry.Extensions.AI.Tests + + + + + [xUnit.net 00:00:00.19] Sentry.Extensions.AI.Tests.SentryInstrumentedFunctionTests.InvokeCoreAsync_WhenInnerFunctionThrows_FinishesSpanWithException [FAIL] + + + [xUnit.net 00:00:00.20] Sentry.Extensions.AI.Tests.SentryInstrumentedFunctionTests.InvokeCoreAsync_PreservesInnerFunctionName [FAIL] + + + [xUnit.net 00:00:00.20] Sentry.Extensions.AI.Tests.SentryInstrumentedFunctionTests.InvokeCoreAsync_WithParentSpan_CreatesChildSpan [FAIL] + + + [xUnit.net 00:00:00.20] Sentry.Extensions.AI.Tests.SentryInstrumentedFunctionTests.InvokeCoreAsync_WithoutParentSpan_CreatesTransaction [FAIL] + + + [xUnit.net 00:00:00.21] Sentry.Extensions.AI.Tests.SentryInstrumentedFunctionTests.InvokeCoreAsync_WithComplexResult_SetsOutputAsString [FAIL] + + + [xUnit.net 00:00:00.21] Sentry.Extensions.AI.Tests.SentryInstrumentedFunctionTests.InvokeCoreAsync_WithNullResult_DoesNotSetOutput [FAIL] + + + [xUnit.net 00:00:00.21] Sentry.Extensions.AI.Tests.SentryInstrumentedFunctionTests.InvokeCoreAsync_WithCancellation_PropagatesCancellation [FAIL] + + + [xUnit.net 00:00:00.27] Sentry.Extensions.AI.Tests.SentryAISpanEnricherTests.EnrichWithResponse_SetsUsageTokens_WhenUsageProvided [FAIL] + + + [xUnit.net 00:00:00.27] Sentry.Extensions.AI.Tests.SentryAISpanEnricherTests.EnrichWithResponse_SetsPartialTokens_WhenOnlyInputTokensProvided [FAIL] + + + + \ No newline at end of file diff --git a/test/Sentry.Extensions.AI.Tests/TestResults/_CVDTQ2MGH4_2025-10-20_00_35_51[1].trx b/test/Sentry.Extensions.AI.Tests/TestResults/_CVDTQ2MGH4_2025-10-20_00_35_51[1].trx new file mode 100644 index 0000000000..b92273a839 --- /dev/null +++ b/test/Sentry.Extensions.AI.Tests/TestResults/_CVDTQ2MGH4_2025-10-20_00_35_51[1].trx @@ -0,0 +1,863 @@ + + + + + + + + + + + + + NSubstitute.Exceptions.AmbiguousArgumentsException : Cannot determine argument specifications to use. Please use specifications for all arguments of the same type. +Method signature: + InvokeCoreAsync(AIFunctionArguments, CancellationToken) +Method arguments (possible arg matchers are indicated with '*'): + InvokeCoreAsync(AIFunctionArguments, *System.Threading.CancellationToken*) +All queued specifications: + any AIFunctionArguments + any CancellationToken +Matched argument specifications: + InvokeCoreAsync(AIFunctionArguments, ???) + + at NSubstitute.Core.Arguments.ArgumentSpecificationsFactory.Create(IList`1 argumentSpecs, Object[] arguments, IParameterInfo[] parameterInfos, MethodInfo methodInfo, MatchArgs matchArgs) + at NSubstitute.Core.CallSpecificationFactory.CreateFrom(ICall call, MatchArgs matchArgs) + at NSubstitute.Routing.Handlers.RecordCallSpecificationHandler.Handle(ICall call) + at NSubstitute.Routing.Route.Handle(ICall call) + at NSubstitute.Core.CallRouter.Route(ICall call) + at NSubstitute.Proxies.CastleDynamicProxy.CastleForwardingInterceptor.Intercept(IInvocation invocation) + at Castle.DynamicProxy.AbstractInvocation.Proceed() + at NSubstitute.Proxies.CastleDynamicProxy.ProxyIdInterceptor.Intercept(IInvocation invocation) + at Castle.DynamicProxy.AbstractInvocation.Proceed() + at Castle.Proxies.AIFunctionProxy.InvokeCoreAsync(AIFunctionArguments arguments, CancellationToken cancellationToken) + at Microsoft.Extensions.AI.AIFunction.InvokeAsync(AIFunctionArguments arguments, CancellationToken cancellationToken) + at Sentry.Extensions.AI.Tests.SentryInstrumentedFunctionTests.InvokeCoreAsync_PreservesInnerFunctionName() in /Users/alexsohn/code/sentry-dotnet/test/Sentry.Extensions.AI.Tests/SentryInstrumentedFunctionTests.cs:line 135 +--- End of stack trace from previous location --- + + + + + + + + + NSubstitute.Exceptions.AmbiguousArgumentsException : Cannot determine argument specifications to use. Please use specifications for all arguments of the same type. +Method signature: + InvokeCoreAsync(AIFunctionArguments, CancellationToken) +Method arguments (possible arg matchers are indicated with '*'): + InvokeCoreAsync(AIFunctionArguments, *System.Threading.CancellationToken*) +All queued specifications: + any AIFunctionArguments + any CancellationToken +Matched argument specifications: + InvokeCoreAsync(AIFunctionArguments, ???) + + at NSubstitute.Core.Arguments.ArgumentSpecificationsFactory.Create(IList`1 argumentSpecs, Object[] arguments, IParameterInfo[] parameterInfos, MethodInfo methodInfo, MatchArgs matchArgs) + at NSubstitute.Core.CallSpecificationFactory.CreateFrom(ICall call, MatchArgs matchArgs) + at NSubstitute.Routing.Handlers.RecordCallSpecificationHandler.Handle(ICall call) + at NSubstitute.Routing.Route.Handle(ICall call) + at NSubstitute.Core.CallRouter.Route(ICall call) + at NSubstitute.Proxies.CastleDynamicProxy.CastleForwardingInterceptor.Intercept(IInvocation invocation) + at Castle.DynamicProxy.AbstractInvocation.Proceed() + at NSubstitute.Proxies.CastleDynamicProxy.ProxyIdInterceptor.Intercept(IInvocation invocation) + at Castle.DynamicProxy.AbstractInvocation.Proceed() + at Castle.Proxies.AIFunctionProxy.InvokeCoreAsync(AIFunctionArguments arguments, CancellationToken cancellationToken) + at Microsoft.Extensions.AI.AIFunction.InvokeAsync(AIFunctionArguments arguments, CancellationToken cancellationToken) + at Sentry.Extensions.AI.Tests.SentryInstrumentedFunctionTests.InvokeCoreAsync_WithoutParentSpan_CreatesTransaction() in /Users/alexsohn/code/sentry-dotnet/test/Sentry.Extensions.AI.Tests/SentryInstrumentedFunctionTests.cs:line 45 +--- End of stack trace from previous location --- + + + + + + + + + + + + + + + + NSubstitute.Exceptions.AmbiguousArgumentsException : Cannot determine argument specifications to use. Please use specifications for all arguments of the same type. +Method signature: + InvokeCoreAsync(AIFunctionArguments, CancellationToken) +Method arguments (possible arg matchers are indicated with '*'): + InvokeCoreAsync(AIFunctionArguments, *System.Threading.CancellationToken*) +All queued specifications: + any AIFunctionArguments + any CancellationToken +Matched argument specifications: + InvokeCoreAsync(AIFunctionArguments, ???) + + at NSubstitute.Core.Arguments.ArgumentSpecificationsFactory.Create(IList`1 argumentSpecs, Object[] arguments, IParameterInfo[] parameterInfos, MethodInfo methodInfo, MatchArgs matchArgs) + at NSubstitute.Core.CallSpecificationFactory.CreateFrom(ICall call, MatchArgs matchArgs) + at NSubstitute.Routing.Handlers.RecordCallSpecificationHandler.Handle(ICall call) + at NSubstitute.Routing.Route.Handle(ICall call) + at NSubstitute.Core.CallRouter.Route(ICall call) + at NSubstitute.Proxies.CastleDynamicProxy.CastleForwardingInterceptor.Intercept(IInvocation invocation) + at Castle.DynamicProxy.AbstractInvocation.Proceed() + at NSubstitute.Proxies.CastleDynamicProxy.ProxyIdInterceptor.Intercept(IInvocation invocation) + at Castle.DynamicProxy.AbstractInvocation.Proceed() + at Castle.Proxies.AIFunctionProxy.InvokeCoreAsync(AIFunctionArguments arguments, CancellationToken cancellationToken) + at Microsoft.Extensions.AI.AIFunction.InvokeAsync(AIFunctionArguments arguments, CancellationToken cancellationToken) + at Sentry.Extensions.AI.Tests.SentryInstrumentedFunctionTests.InvokeCoreAsync_WithNullResult_DoesNotSetOutput() in /Users/alexsohn/code/sentry-dotnet/test/Sentry.Extensions.AI.Tests/SentryInstrumentedFunctionTests.cs:line 90 +--- End of stack trace from previous location --- + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + NSubstitute.Exceptions.AmbiguousArgumentsException : Cannot determine argument specifications to use. Please use specifications for all arguments of the same type. +Method signature: + InvokeCoreAsync(AIFunctionArguments, CancellationToken) +Method arguments (possible arg matchers are indicated with '*'): + InvokeCoreAsync(AIFunctionArguments, *System.Threading.CancellationToken*) +All queued specifications: + any AIFunctionArguments + any CancellationToken +Matched argument specifications: + InvokeCoreAsync(AIFunctionArguments, ???) + + at NSubstitute.Core.Arguments.ArgumentSpecificationsFactory.Create(IList`1 argumentSpecs, Object[] arguments, IParameterInfo[] parameterInfos, MethodInfo methodInfo, MatchArgs matchArgs) + at NSubstitute.Core.CallSpecificationFactory.CreateFrom(ICall call, MatchArgs matchArgs) + at NSubstitute.Routing.Handlers.RecordCallSpecificationHandler.Handle(ICall call) + at NSubstitute.Routing.Route.Handle(ICall call) + at NSubstitute.Core.CallRouter.Route(ICall call) + at NSubstitute.Proxies.CastleDynamicProxy.CastleForwardingInterceptor.Intercept(IInvocation invocation) + at Castle.DynamicProxy.AbstractInvocation.Proceed() + at NSubstitute.Proxies.CastleDynamicProxy.ProxyIdInterceptor.Intercept(IInvocation invocation) + at Castle.DynamicProxy.AbstractInvocation.Proceed() + at Castle.Proxies.AIFunctionProxy.InvokeCoreAsync(AIFunctionArguments arguments, CancellationToken cancellationToken) + at Microsoft.Extensions.AI.AIFunction.InvokeAsync(AIFunctionArguments arguments, CancellationToken cancellationToken) + at Sentry.Extensions.AI.Tests.SentryInstrumentedFunctionTests.InvokeCoreAsync_WhenInnerFunctionThrows_FinishesSpanWithException() in /Users/alexsohn/code/sentry-dotnet/test/Sentry.Extensions.AI.Tests/SentryInstrumentedFunctionTests.cs:line 68 +--- End of stack trace from previous location --- + + + + + + + + + + + + NSubstitute.Exceptions.ReceivedCallsException : Expected to receive exactly 1 call matching: + SetData("gen_ai.usage.input_tokens", 10) +Actually received no matching calls. +Received 4 non-matching calls (non-matching arguments indicated with '*' characters): + SetData("gen_ai.usage.input_tokens", *10*) + SetData(*"gen_ai.usage.output_tokens"*, *20*) + SetData(*"gen_ai.usage.total_tokens"*, *30*) + SetData(*"gen_ai.response.text"*, *"Hello"*) + + at NSubstitute.Core.ReceivedCallsExceptionThrower.Throw(ICallSpecification callSpecification, IEnumerable`1 matchingCalls, IEnumerable`1 nonMatchingCalls, Quantity requiredQuantity) + at NSubstitute.Routing.Handlers.CheckReceivedCallsHandler.Handle(ICall call) + at NSubstitute.Routing.Route.Handle(ICall call) + at NSubstitute.Core.CallRouter.Route(ICall call) + at NSubstitute.Proxies.CastleDynamicProxy.CastleForwardingInterceptor.Intercept(IInvocation invocation) + at Castle.DynamicProxy.AbstractInvocation.Proceed() + at NSubstitute.Proxies.CastleDynamicProxy.ProxyIdInterceptor.Intercept(IInvocation invocation) + at Castle.DynamicProxy.AbstractInvocation.Proceed() + at Castle.Proxies.ObjectProxy.SetData(String key, Object value) + at Sentry.Extensions.AI.Tests.SentryAISpanEnricherTests.EnrichWithResponse_SetsUsageTokens_WhenUsageProvided() in /Users/alexsohn/code/sentry-dotnet/test/Sentry.Extensions.AI.Tests/SentryAISpanEnricherTests.cs:line 175 + at System.RuntimeMethodHandle.InvokeMethod(Object target, Void** arguments, Signature sig, Boolean isConstructor) + at System.Reflection.MethodBaseInvoker.InvokeWithNoArgs(Object obj, BindingFlags invokeAttr) + + + + + + + + NSubstitute.Exceptions.AmbiguousArgumentsException : Cannot determine argument specifications to use. Please use specifications for all arguments of the same type. +Method signature: + InvokeCoreAsync(AIFunctionArguments, CancellationToken) +Method arguments (possible arg matchers are indicated with '*'): + InvokeCoreAsync(AIFunctionArguments, *System.Threading.CancellationToken*) +All queued specifications: + any AIFunctionArguments + any CancellationToken +Matched argument specifications: + InvokeCoreAsync(AIFunctionArguments, ???) + + at NSubstitute.Core.Arguments.ArgumentSpecificationsFactory.Create(IList`1 argumentSpecs, Object[] arguments, IParameterInfo[] parameterInfos, MethodInfo methodInfo, MatchArgs matchArgs) + at NSubstitute.Core.CallSpecificationFactory.CreateFrom(ICall call, MatchArgs matchArgs) + at NSubstitute.Routing.Handlers.RecordCallSpecificationHandler.Handle(ICall call) + at NSubstitute.Routing.Route.Handle(ICall call) + at NSubstitute.Core.CallRouter.Route(ICall call) + at NSubstitute.Proxies.CastleDynamicProxy.CastleForwardingInterceptor.Intercept(IInvocation invocation) + at Castle.DynamicProxy.AbstractInvocation.Proceed() + at NSubstitute.Proxies.CastleDynamicProxy.ProxyIdInterceptor.Intercept(IInvocation invocation) + at Castle.DynamicProxy.AbstractInvocation.Proceed() + at Castle.Proxies.AIFunctionProxy.InvokeCoreAsync(AIFunctionArguments arguments, CancellationToken cancellationToken) + at Microsoft.Extensions.AI.AIFunction.InvokeAsync(AIFunctionArguments arguments, CancellationToken cancellationToken) + at Sentry.Extensions.AI.Tests.SentryInstrumentedFunctionTests.InvokeCoreAsync_WithParentSpan_CreatesChildSpan() in /Users/alexsohn/code/sentry-dotnet/test/Sentry.Extensions.AI.Tests/SentryInstrumentedFunctionTests.cs:line 19 +--- End of stack trace from previous location --- + + + + + + + NSubstitute.Exceptions.AmbiguousArgumentsException : Cannot determine argument specifications to use. Please use specifications for all arguments of the same type. +Method signature: + InvokeCoreAsync(AIFunctionArguments, CancellationToken) +Method arguments (possible arg matchers are indicated with '*'): + InvokeCoreAsync(AIFunctionArguments, *System.Threading.CancellationToken*) +All queued specifications: + any AIFunctionArguments + any CancellationToken +Matched argument specifications: + InvokeCoreAsync(AIFunctionArguments, ???) + + at NSubstitute.Core.Arguments.ArgumentSpecificationsFactory.Create(IList`1 argumentSpecs, Object[] arguments, IParameterInfo[] parameterInfos, MethodInfo methodInfo, MatchArgs matchArgs) + at NSubstitute.Core.CallSpecificationFactory.CreateFrom(ICall call, MatchArgs matchArgs) + at NSubstitute.Routing.Handlers.RecordCallSpecificationHandler.Handle(ICall call) + at NSubstitute.Routing.Route.Handle(ICall call) + at NSubstitute.Core.CallRouter.Route(ICall call) + at NSubstitute.Proxies.CastleDynamicProxy.CastleForwardingInterceptor.Intercept(IInvocation invocation) + at Castle.DynamicProxy.AbstractInvocation.Proceed() + at NSubstitute.Proxies.CastleDynamicProxy.ProxyIdInterceptor.Intercept(IInvocation invocation) + at Castle.DynamicProxy.AbstractInvocation.Proceed() + at Castle.Proxies.AIFunctionProxy.InvokeCoreAsync(AIFunctionArguments arguments, CancellationToken cancellationToken) + at Microsoft.Extensions.AI.AIFunction.InvokeAsync(AIFunctionArguments arguments, CancellationToken cancellationToken) + at Sentry.Extensions.AI.Tests.SentryInstrumentedFunctionTests.InvokeCoreAsync_WithComplexResult_SetsOutputAsString() in /Users/alexsohn/code/sentry-dotnet/test/Sentry.Extensions.AI.Tests/SentryInstrumentedFunctionTests.cs:line 113 +--- End of stack trace from previous location --- + + + + + + + + NSubstitute.Exceptions.AmbiguousArgumentsException : Cannot determine argument specifications to use. Please use specifications for all arguments of the same type. +Method signature: + InvokeCoreAsync(AIFunctionArguments, CancellationToken) +Method arguments (possible arg matchers are indicated with '*'): + InvokeCoreAsync(AIFunctionArguments, *System.Threading.CancellationToken*) +All queued specifications: + any AIFunctionArguments + any CancellationToken +Matched argument specifications: + InvokeCoreAsync(AIFunctionArguments, ???) + + at NSubstitute.Core.Arguments.ArgumentSpecificationsFactory.Create(IList`1 argumentSpecs, Object[] arguments, IParameterInfo[] parameterInfos, MethodInfo methodInfo, MatchArgs matchArgs) + at NSubstitute.Core.CallSpecificationFactory.CreateFrom(ICall call, MatchArgs matchArgs) + at NSubstitute.Routing.Handlers.RecordCallSpecificationHandler.Handle(ICall call) + at NSubstitute.Routing.Route.Handle(ICall call) + at NSubstitute.Core.CallRouter.Route(ICall call) + at NSubstitute.Proxies.CastleDynamicProxy.CastleForwardingInterceptor.Intercept(IInvocation invocation) + at Castle.DynamicProxy.AbstractInvocation.Proceed() + at NSubstitute.Proxies.CastleDynamicProxy.ProxyIdInterceptor.Intercept(IInvocation invocation) + at Castle.DynamicProxy.AbstractInvocation.Proceed() + at Castle.Proxies.AIFunctionProxy.InvokeCoreAsync(AIFunctionArguments arguments, CancellationToken cancellationToken) + at Microsoft.Extensions.AI.AIFunction.InvokeAsync(AIFunctionArguments arguments, CancellationToken cancellationToken) + at Sentry.Extensions.AI.Tests.SentryInstrumentedFunctionTests.InvokeCoreAsync_WithCancellation_PropagatesCancellation() in /Users/alexsohn/code/sentry-dotnet/test/Sentry.Extensions.AI.Tests/SentryInstrumentedFunctionTests.cs:line 160 +--- End of stack trace from previous location --- + + + + + + + + + + NSubstitute.Exceptions.ReceivedCallsException : Expected to receive exactly 1 call matching: + SetData("gen_ai.usage.input_tokens", 10) +Actually received no matching calls. +Received 2 non-matching calls (non-matching arguments indicated with '*' characters): + SetData("gen_ai.usage.input_tokens", *10*) + SetData(*"gen_ai.response.text"*, *"Hello"*) + + at NSubstitute.Core.ReceivedCallsExceptionThrower.Throw(ICallSpecification callSpecification, IEnumerable`1 matchingCalls, IEnumerable`1 nonMatchingCalls, Quantity requiredQuantity) + at NSubstitute.Routing.Handlers.CheckReceivedCallsHandler.Handle(ICall call) + at NSubstitute.Routing.Route.Handle(ICall call) + at NSubstitute.Core.CallRouter.Route(ICall call) + at NSubstitute.Proxies.CastleDynamicProxy.CastleForwardingInterceptor.Intercept(IInvocation invocation) + at Castle.DynamicProxy.AbstractInvocation.Proceed() + at NSubstitute.Proxies.CastleDynamicProxy.ProxyIdInterceptor.Intercept(IInvocation invocation) + at Castle.DynamicProxy.AbstractInvocation.Proceed() + at Castle.Proxies.ObjectProxy.SetData(String key, Object value) + at Sentry.Extensions.AI.Tests.SentryAISpanEnricherTests.EnrichWithResponse_SetsPartialTokens_WhenOnlyInputTokensProvided() in /Users/alexsohn/code/sentry-dotnet/test/Sentry.Extensions.AI.Tests/SentryAISpanEnricherTests.cs:line 203 + at System.RuntimeMethodHandle.InvokeMethod(Object target, Void** arguments, Signature sig, Boolean isConstructor) + at System.Reflection.MethodBaseInvoker.InvokeWithNoArgs(Object obj, BindingFlags invokeAttr) + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + [xUnit.net 00:00:00.00] xUnit.net VSTest Adapter v3.1.1+bf6400fd51 (64-bit .NET 8.0.19) +[xUnit.net 00:00:00.06] Discovering: Sentry.Extensions.AI.Tests +[xUnit.net 00:00:00.08] Discovered: Sentry.Extensions.AI.Tests +[xUnit.net 00:00:00.09] Starting: Sentry.Extensions.AI.Tests +[xUnit.net 00:00:00.21] NSubstitute.Exceptions.ReceivedCallsException : Expected to receive exactly 1 call matching: +[xUnit.net 00:00:00.21] SetData("gen_ai.usage.input_tokens", 10) +[xUnit.net 00:00:00.21] Actually received no matching calls. +[xUnit.net 00:00:00.21] Received 4 non-matching calls (non-matching arguments indicated with '*' characters): +[xUnit.net 00:00:00.21] SetData("gen_ai.usage.input_tokens", *10*) +[xUnit.net 00:00:00.21] SetData(*"gen_ai.usage.output_tokens"*, *20*) +[xUnit.net 00:00:00.21] SetData(*"gen_ai.usage.total_tokens"*, *30*) +[xUnit.net 00:00:00.21] SetData(*"gen_ai.response.text"*, *"Hello"*) +[xUnit.net 00:00:00.21] +[xUnit.net 00:00:00.21] Stack Trace: +[xUnit.net 00:00:00.21] at NSubstitute.Core.ReceivedCallsExceptionThrower.Throw(ICallSpecification callSpecification, IEnumerable`1 matchingCalls, IEnumerable`1 nonMatchingCalls, Quantity requiredQuantity) +[xUnit.net 00:00:00.21] at NSubstitute.Routing.Handlers.CheckReceivedCallsHandler.Handle(ICall call) +[xUnit.net 00:00:00.21] at NSubstitute.Routing.Route.Handle(ICall call) +[xUnit.net 00:00:00.21] at NSubstitute.Core.CallRouter.Route(ICall call) +[xUnit.net 00:00:00.21] at NSubstitute.Proxies.CastleDynamicProxy.CastleForwardingInterceptor.Intercept(IInvocation invocation) +[xUnit.net 00:00:00.21] at Castle.DynamicProxy.AbstractInvocation.Proceed() +[xUnit.net 00:00:00.21] at NSubstitute.Proxies.CastleDynamicProxy.ProxyIdInterceptor.Intercept(IInvocation invocation) +[xUnit.net 00:00:00.21] at Castle.DynamicProxy.AbstractInvocation.Proceed() +[xUnit.net 00:00:00.21] at Castle.Proxies.ObjectProxy.SetData(String key, Object value) +[xUnit.net 00:00:00.21] /Users/alexsohn/code/sentry-dotnet/test/Sentry.Extensions.AI.Tests/SentryAISpanEnricherTests.cs(175,0): at Sentry.Extensions.AI.Tests.SentryAISpanEnricherTests.EnrichWithResponse_SetsUsageTokens_WhenUsageProvided() +[xUnit.net 00:00:00.21] at System.RuntimeMethodHandle.InvokeMethod(Object target, Void** arguments, Signature sig, Boolean isConstructor) +[xUnit.net 00:00:00.21] at System.Reflection.MethodBaseInvoker.InvokeWithNoArgs(Object obj, BindingFlags invokeAttr) +[xUnit.net 00:00:00.22] NSubstitute.Exceptions.ReceivedCallsException : Expected to receive exactly 1 call matching: +[xUnit.net 00:00:00.22] SetData("gen_ai.usage.input_tokens", 10) +[xUnit.net 00:00:00.22] Actually received no matching calls. +[xUnit.net 00:00:00.22] Received 2 non-matching calls (non-matching arguments indicated with '*' characters): +[xUnit.net 00:00:00.22] SetData("gen_ai.usage.input_tokens", *10*) +[xUnit.net 00:00:00.22] SetData(*"gen_ai.response.text"*, *"Hello"*) +[xUnit.net 00:00:00.22] +[xUnit.net 00:00:00.22] Stack Trace: +[xUnit.net 00:00:00.22] at NSubstitute.Core.ReceivedCallsExceptionThrower.Throw(ICallSpecification callSpecification, IEnumerable`1 matchingCalls, IEnumerable`1 nonMatchingCalls, Quantity requiredQuantity) +[xUnit.net 00:00:00.22] at NSubstitute.Routing.Handlers.CheckReceivedCallsHandler.Handle(ICall call) +[xUnit.net 00:00:00.22] at NSubstitute.Routing.Route.Handle(ICall call) +[xUnit.net 00:00:00.22] at NSubstitute.Core.CallRouter.Route(ICall call) +[xUnit.net 00:00:00.22] at NSubstitute.Proxies.CastleDynamicProxy.CastleForwardingInterceptor.Intercept(IInvocation invocation) +[xUnit.net 00:00:00.22] at Castle.DynamicProxy.AbstractInvocation.Proceed() +[xUnit.net 00:00:00.22] at NSubstitute.Proxies.CastleDynamicProxy.ProxyIdInterceptor.Intercept(IInvocation invocation) +[xUnit.net 00:00:00.22] at Castle.DynamicProxy.AbstractInvocation.Proceed() +[xUnit.net 00:00:00.22] at Castle.Proxies.ObjectProxy.SetData(String key, Object value) +[xUnit.net 00:00:00.22] /Users/alexsohn/code/sentry-dotnet/test/Sentry.Extensions.AI.Tests/SentryAISpanEnricherTests.cs(203,0): at Sentry.Extensions.AI.Tests.SentryAISpanEnricherTests.EnrichWithResponse_SetsPartialTokens_WhenOnlyInputTokensProvided() +[xUnit.net 00:00:00.22] at System.RuntimeMethodHandle.InvokeMethod(Object target, Void** arguments, Signature sig, Boolean isConstructor) +[xUnit.net 00:00:00.22] at System.Reflection.MethodBaseInvoker.InvokeWithNoArgs(Object obj, BindingFlags invokeAttr) +[xUnit.net 00:00:00.29] NSubstitute.Exceptions.AmbiguousArgumentsException : Cannot determine argument specifications to use. Please use specifications for all arguments of the same type. +[xUnit.net 00:00:00.29] Method signature: +[xUnit.net 00:00:00.29] InvokeCoreAsync(AIFunctionArguments, CancellationToken) +[xUnit.net 00:00:00.29] Method arguments (possible arg matchers are indicated with '*'): +[xUnit.net 00:00:00.29] InvokeCoreAsync(AIFunctionArguments, *System.Threading.CancellationToken*) +[xUnit.net 00:00:00.29] All queued specifications: +[xUnit.net 00:00:00.29] any AIFunctionArguments +[xUnit.net 00:00:00.29] any CancellationToken +[xUnit.net 00:00:00.29] Matched argument specifications: +[xUnit.net 00:00:00.29] InvokeCoreAsync(AIFunctionArguments, ???) +[xUnit.net 00:00:00.29] +[xUnit.net 00:00:00.29] Stack Trace: +[xUnit.net 00:00:00.29] at NSubstitute.Core.Arguments.ArgumentSpecificationsFactory.Create(IList`1 argumentSpecs, Object[] arguments, IParameterInfo[] parameterInfos, MethodInfo methodInfo, MatchArgs matchArgs) +[xUnit.net 00:00:00.29] at NSubstitute.Core.CallSpecificationFactory.CreateFrom(ICall call, MatchArgs matchArgs) +[xUnit.net 00:00:00.29] at NSubstitute.Routing.Handlers.RecordCallSpecificationHandler.Handle(ICall call) +[xUnit.net 00:00:00.29] at NSubstitute.Routing.Route.Handle(ICall call) +[xUnit.net 00:00:00.29] at NSubstitute.Core.CallRouter.Route(ICall call) +[xUnit.net 00:00:00.29] at NSubstitute.Proxies.CastleDynamicProxy.CastleForwardingInterceptor.Intercept(IInvocation invocation) +[xUnit.net 00:00:00.29] at Castle.DynamicProxy.AbstractInvocation.Proceed() +[xUnit.net 00:00:00.29] at NSubstitute.Proxies.CastleDynamicProxy.ProxyIdInterceptor.Intercept(IInvocation invocation) +[xUnit.net 00:00:00.29] at Castle.DynamicProxy.AbstractInvocation.Proceed() +[xUnit.net 00:00:00.29] at Castle.Proxies.AIFunctionProxy.InvokeCoreAsync(AIFunctionArguments arguments, CancellationToken cancellationToken) +[xUnit.net 00:00:00.29] at Microsoft.Extensions.AI.AIFunction.InvokeAsync(AIFunctionArguments arguments, CancellationToken cancellationToken) +[xUnit.net 00:00:00.29] /Users/alexsohn/code/sentry-dotnet/test/Sentry.Extensions.AI.Tests/SentryInstrumentedFunctionTests.cs(68,0): at Sentry.Extensions.AI.Tests.SentryInstrumentedFunctionTests.InvokeCoreAsync_WhenInnerFunctionThrows_FinishesSpanWithException() +[xUnit.net 00:00:00.29] --- End of stack trace from previous location --- +[xUnit.net 00:00:00.29] NSubstitute.Exceptions.AmbiguousArgumentsException : Cannot determine argument specifications to use. Please use specifications for all arguments of the same type. +[xUnit.net 00:00:00.29] Method signature: +[xUnit.net 00:00:00.29] InvokeCoreAsync(AIFunctionArguments, CancellationToken) +[xUnit.net 00:00:00.29] Method arguments (possible arg matchers are indicated with '*'): +[xUnit.net 00:00:00.29] InvokeCoreAsync(AIFunctionArguments, *System.Threading.CancellationToken*) +[xUnit.net 00:00:00.29] All queued specifications: +[xUnit.net 00:00:00.29] any AIFunctionArguments +[xUnit.net 00:00:00.29] any CancellationToken +[xUnit.net 00:00:00.29] Matched argument specifications: +[xUnit.net 00:00:00.29] InvokeCoreAsync(AIFunctionArguments, ???) +[xUnit.net 00:00:00.29] +[xUnit.net 00:00:00.29] Stack Trace: +[xUnit.net 00:00:00.29] at NSubstitute.Core.Arguments.ArgumentSpecificationsFactory.Create(IList`1 argumentSpecs, Object[] arguments, IParameterInfo[] parameterInfos, MethodInfo methodInfo, MatchArgs matchArgs) +[xUnit.net 00:00:00.29] at NSubstitute.Core.CallSpecificationFactory.CreateFrom(ICall call, MatchArgs matchArgs) +[xUnit.net 00:00:00.29] at NSubstitute.Routing.Handlers.RecordCallSpecificationHandler.Handle(ICall call) +[xUnit.net 00:00:00.29] at NSubstitute.Routing.Route.Handle(ICall call) +[xUnit.net 00:00:00.29] at NSubstitute.Core.CallRouter.Route(ICall call) +[xUnit.net 00:00:00.29] at NSubstitute.Proxies.CastleDynamicProxy.CastleForwardingInterceptor.Intercept(IInvocation invocation) +[xUnit.net 00:00:00.29] at Castle.DynamicProxy.AbstractInvocation.Proceed() +[xUnit.net 00:00:00.29] at NSubstitute.Proxies.CastleDynamicProxy.ProxyIdInterceptor.Intercept(IInvocation invocation) +[xUnit.net 00:00:00.29] at Castle.DynamicProxy.AbstractInvocation.Proceed() +[xUnit.net 00:00:00.29] at Castle.Proxies.AIFunctionProxy.InvokeCoreAsync(AIFunctionArguments arguments, CancellationToken cancellationToken) +[xUnit.net 00:00:00.29] at Microsoft.Extensions.AI.AIFunction.InvokeAsync(AIFunctionArguments arguments, CancellationToken cancellationToken) +[xUnit.net 00:00:00.29] /Users/alexsohn/code/sentry-dotnet/test/Sentry.Extensions.AI.Tests/SentryInstrumentedFunctionTests.cs(135,0): at Sentry.Extensions.AI.Tests.SentryInstrumentedFunctionTests.InvokeCoreAsync_PreservesInnerFunctionName() +[xUnit.net 00:00:00.29] --- End of stack trace from previous location --- +[xUnit.net 00:00:00.30] NSubstitute.Exceptions.AmbiguousArgumentsException : Cannot determine argument specifications to use. Please use specifications for all arguments of the same type. +[xUnit.net 00:00:00.30] Method signature: +[xUnit.net 00:00:00.30] InvokeCoreAsync(AIFunctionArguments, CancellationToken) +[xUnit.net 00:00:00.30] Method arguments (possible arg matchers are indicated with '*'): +[xUnit.net 00:00:00.30] InvokeCoreAsync(AIFunctionArguments, *System.Threading.CancellationToken*) +[xUnit.net 00:00:00.30] All queued specifications: +[xUnit.net 00:00:00.30] any AIFunctionArguments +[xUnit.net 00:00:00.30] any CancellationToken +[xUnit.net 00:00:00.30] Matched argument specifications: +[xUnit.net 00:00:00.30] InvokeCoreAsync(AIFunctionArguments, ???) +[xUnit.net 00:00:00.30] +[xUnit.net 00:00:00.30] Stack Trace: +[xUnit.net 00:00:00.30] at NSubstitute.Core.Arguments.ArgumentSpecificationsFactory.Create(IList`1 argumentSpecs, Object[] arguments, IParameterInfo[] parameterInfos, MethodInfo methodInfo, MatchArgs matchArgs) +[xUnit.net 00:00:00.30] at NSubstitute.Core.CallSpecificationFactory.CreateFrom(ICall call, MatchArgs matchArgs) +[xUnit.net 00:00:00.30] at NSubstitute.Routing.Handlers.RecordCallSpecificationHandler.Handle(ICall call) +[xUnit.net 00:00:00.30] at NSubstitute.Routing.Route.Handle(ICall call) +[xUnit.net 00:00:00.30] at NSubstitute.Core.CallRouter.Route(ICall call) +[xUnit.net 00:00:00.30] at NSubstitute.Proxies.CastleDynamicProxy.CastleForwardingInterceptor.Intercept(IInvocation invocation) +[xUnit.net 00:00:00.30] at Castle.DynamicProxy.AbstractInvocation.Proceed() +[xUnit.net 00:00:00.30] at NSubstitute.Proxies.CastleDynamicProxy.ProxyIdInterceptor.Intercept(IInvocation invocation) +[xUnit.net 00:00:00.30] at Castle.DynamicProxy.AbstractInvocation.Proceed() +[xUnit.net 00:00:00.30] at Castle.Proxies.AIFunctionProxy.InvokeCoreAsync(AIFunctionArguments arguments, CancellationToken cancellationToken) +[xUnit.net 00:00:00.30] at Microsoft.Extensions.AI.AIFunction.InvokeAsync(AIFunctionArguments arguments, CancellationToken cancellationToken) +[xUnit.net 00:00:00.30] /Users/alexsohn/code/sentry-dotnet/test/Sentry.Extensions.AI.Tests/SentryInstrumentedFunctionTests.cs(19,0): at Sentry.Extensions.AI.Tests.SentryInstrumentedFunctionTests.InvokeCoreAsync_WithParentSpan_CreatesChildSpan() +[xUnit.net 00:00:00.30] --- End of stack trace from previous location --- +[xUnit.net 00:00:00.30] NSubstitute.Exceptions.AmbiguousArgumentsException : Cannot determine argument specifications to use. Please use specifications for all arguments of the same type. +[xUnit.net 00:00:00.30] Method signature: +[xUnit.net 00:00:00.30] InvokeCoreAsync(AIFunctionArguments, CancellationToken) +[xUnit.net 00:00:00.30] Method arguments (possible arg matchers are indicated with '*'): +[xUnit.net 00:00:00.30] InvokeCoreAsync(AIFunctionArguments, *System.Threading.CancellationToken*) +[xUnit.net 00:00:00.30] All queued specifications: +[xUnit.net 00:00:00.30] any AIFunctionArguments +[xUnit.net 00:00:00.30] any CancellationToken +[xUnit.net 00:00:00.30] Matched argument specifications: +[xUnit.net 00:00:00.30] InvokeCoreAsync(AIFunctionArguments, ???) +[xUnit.net 00:00:00.30] +[xUnit.net 00:00:00.30] Stack Trace: +[xUnit.net 00:00:00.30] at NSubstitute.Core.Arguments.ArgumentSpecificationsFactory.Create(IList`1 argumentSpecs, Object[] arguments, IParameterInfo[] parameterInfos, MethodInfo methodInfo, MatchArgs matchArgs) +[xUnit.net 00:00:00.30] at NSubstitute.Core.CallSpecificationFactory.CreateFrom(ICall call, MatchArgs matchArgs) +[xUnit.net 00:00:00.30] at NSubstitute.Routing.Handlers.RecordCallSpecificationHandler.Handle(ICall call) +[xUnit.net 00:00:00.30] at NSubstitute.Routing.Route.Handle(ICall call) +[xUnit.net 00:00:00.30] at NSubstitute.Core.CallRouter.Route(ICall call) +[xUnit.net 00:00:00.30] at NSubstitute.Proxies.CastleDynamicProxy.CastleForwardingInterceptor.Intercept(IInvocation invocation) +[xUnit.net 00:00:00.30] at Castle.DynamicProxy.AbstractInvocation.Proceed() +[xUnit.net 00:00:00.30] at NSubstitute.Proxies.CastleDynamicProxy.ProxyIdInterceptor.Intercept(IInvocation invocation) +[xUnit.net 00:00:00.30] at Castle.DynamicProxy.AbstractInvocation.Proceed() +[xUnit.net 00:00:00.30] at Castle.Proxies.AIFunctionProxy.InvokeCoreAsync(AIFunctionArguments arguments, CancellationToken cancellationToken) +[xUnit.net 00:00:00.30] at Microsoft.Extensions.AI.AIFunction.InvokeAsync(AIFunctionArguments arguments, CancellationToken cancellationToken) +[xUnit.net 00:00:00.30] /Users/alexsohn/code/sentry-dotnet/test/Sentry.Extensions.AI.Tests/SentryInstrumentedFunctionTests.cs(45,0): at Sentry.Extensions.AI.Tests.SentryInstrumentedFunctionTests.InvokeCoreAsync_WithoutParentSpan_CreatesTransaction() +[xUnit.net 00:00:00.30] --- End of stack trace from previous location --- +[xUnit.net 00:00:00.30] NSubstitute.Exceptions.AmbiguousArgumentsException : Cannot determine argument specifications to use. Please use specifications for all arguments of the same type. +[xUnit.net 00:00:00.30] Method signature: +[xUnit.net 00:00:00.30] InvokeCoreAsync(AIFunctionArguments, CancellationToken) +[xUnit.net 00:00:00.30] Method arguments (possible arg matchers are indicated with '*'): +[xUnit.net 00:00:00.30] InvokeCoreAsync(AIFunctionArguments, *System.Threading.CancellationToken*) +[xUnit.net 00:00:00.30] All queued specifications: +[xUnit.net 00:00:00.30] any AIFunctionArguments +[xUnit.net 00:00:00.30] any CancellationToken +[xUnit.net 00:00:00.30] Matched argument specifications: +[xUnit.net 00:00:00.30] InvokeCoreAsync(AIFunctionArguments, ???) +[xUnit.net 00:00:00.30] +[xUnit.net 00:00:00.30] Stack Trace: +[xUnit.net 00:00:00.30] at NSubstitute.Core.Arguments.ArgumentSpecificationsFactory.Create(IList`1 argumentSpecs, Object[] arguments, IParameterInfo[] parameterInfos, MethodInfo methodInfo, MatchArgs matchArgs) +[xUnit.net 00:00:00.30] at NSubstitute.Core.CallSpecificationFactory.CreateFrom(ICall call, MatchArgs matchArgs) +[xUnit.net 00:00:00.30] at NSubstitute.Routing.Handlers.RecordCallSpecificationHandler.Handle(ICall call) +[xUnit.net 00:00:00.30] at NSubstitute.Routing.Route.Handle(ICall call) +[xUnit.net 00:00:00.30] at NSubstitute.Core.CallRouter.Route(ICall call) +[xUnit.net 00:00:00.30] at NSubstitute.Proxies.CastleDynamicProxy.CastleForwardingInterceptor.Intercept(IInvocation invocation) +[xUnit.net 00:00:00.30] at Castle.DynamicProxy.AbstractInvocation.Proceed() +[xUnit.net 00:00:00.30] at NSubstitute.Proxies.CastleDynamicProxy.ProxyIdInterceptor.Intercept(IInvocation invocation) +[xUnit.net 00:00:00.30] at Castle.DynamicProxy.AbstractInvocation.Proceed() +[xUnit.net 00:00:00.30] at Castle.Proxies.AIFunctionProxy.InvokeCoreAsync(AIFunctionArguments arguments, CancellationToken cancellationToken) +[xUnit.net 00:00:00.30] at Microsoft.Extensions.AI.AIFunction.InvokeAsync(AIFunctionArguments arguments, CancellationToken cancellationToken) +[xUnit.net 00:00:00.30] /Users/alexsohn/code/sentry-dotnet/test/Sentry.Extensions.AI.Tests/SentryInstrumentedFunctionTests.cs(113,0): at Sentry.Extensions.AI.Tests.SentryInstrumentedFunctionTests.InvokeCoreAsync_WithComplexResult_SetsOutputAsString() +[xUnit.net 00:00:00.30] --- End of stack trace from previous location --- +[xUnit.net 00:00:00.30] NSubstitute.Exceptions.AmbiguousArgumentsException : Cannot determine argument specifications to use. Please use specifications for all arguments of the same type. +[xUnit.net 00:00:00.30] Method signature: +[xUnit.net 00:00:00.30] InvokeCoreAsync(AIFunctionArguments, CancellationToken) +[xUnit.net 00:00:00.30] Method arguments (possible arg matchers are indicated with '*'): +[xUnit.net 00:00:00.30] InvokeCoreAsync(AIFunctionArguments, *System.Threading.CancellationToken*) +[xUnit.net 00:00:00.30] All queued specifications: +[xUnit.net 00:00:00.30] any AIFunctionArguments +[xUnit.net 00:00:00.30] any CancellationToken +[xUnit.net 00:00:00.30] Matched argument specifications: +[xUnit.net 00:00:00.30] InvokeCoreAsync(AIFunctionArguments, ???) +[xUnit.net 00:00:00.30] +[xUnit.net 00:00:00.30] Stack Trace: +[xUnit.net 00:00:00.30] at NSubstitute.Core.Arguments.ArgumentSpecificationsFactory.Create(IList`1 argumentSpecs, Object[] arguments, IParameterInfo[] parameterInfos, MethodInfo methodInfo, MatchArgs matchArgs) +[xUnit.net 00:00:00.30] at NSubstitute.Core.CallSpecificationFactory.CreateFrom(ICall call, MatchArgs matchArgs) +[xUnit.net 00:00:00.30] at NSubstitute.Routing.Handlers.RecordCallSpecificationHandler.Handle(ICall call) +[xUnit.net 00:00:00.30] at NSubstitute.Routing.Route.Handle(ICall call) +[xUnit.net 00:00:00.30] at NSubstitute.Core.CallRouter.Route(ICall call) +[xUnit.net 00:00:00.30] at NSubstitute.Proxies.CastleDynamicProxy.CastleForwardingInterceptor.Intercept(IInvocation invocation) +[xUnit.net 00:00:00.30] at Castle.DynamicProxy.AbstractInvocation.Proceed() +[xUnit.net 00:00:00.30] at NSubstitute.Proxies.CastleDynamicProxy.ProxyIdInterceptor.Intercept(IInvocation invocation) +[xUnit.net 00:00:00.30] at Castle.DynamicProxy.AbstractInvocation.Proceed() +[xUnit.net 00:00:00.30] at Castle.Proxies.AIFunctionProxy.InvokeCoreAsync(AIFunctionArguments arguments, CancellationToken cancellationToken) +[xUnit.net 00:00:00.30] at Microsoft.Extensions.AI.AIFunction.InvokeAsync(AIFunctionArguments arguments, CancellationToken cancellationToken) +[xUnit.net 00:00:00.30] /Users/alexsohn/code/sentry-dotnet/test/Sentry.Extensions.AI.Tests/SentryInstrumentedFunctionTests.cs(90,0): at Sentry.Extensions.AI.Tests.SentryInstrumentedFunctionTests.InvokeCoreAsync_WithNullResult_DoesNotSetOutput() +[xUnit.net 00:00:00.30] --- End of stack trace from previous location --- +[xUnit.net 00:00:00.30] NSubstitute.Exceptions.AmbiguousArgumentsException : Cannot determine argument specifications to use. Please use specifications for all arguments of the same type. +[xUnit.net 00:00:00.30] Method signature: +[xUnit.net 00:00:00.30] InvokeCoreAsync(AIFunctionArguments, CancellationToken) +[xUnit.net 00:00:00.30] Method arguments (possible arg matchers are indicated with '*'): +[xUnit.net 00:00:00.30] InvokeCoreAsync(AIFunctionArguments, *System.Threading.CancellationToken*) +[xUnit.net 00:00:00.30] All queued specifications: +[xUnit.net 00:00:00.30] any AIFunctionArguments +[xUnit.net 00:00:00.30] any CancellationToken +[xUnit.net 00:00:00.30] Matched argument specifications: +[xUnit.net 00:00:00.30] InvokeCoreAsync(AIFunctionArguments, ???) +[xUnit.net 00:00:00.30] +[xUnit.net 00:00:00.30] Stack Trace: +[xUnit.net 00:00:00.30] at NSubstitute.Core.Arguments.ArgumentSpecificationsFactory.Create(IList`1 argumentSpecs, Object[] arguments, IParameterInfo[] parameterInfos, MethodInfo methodInfo, MatchArgs matchArgs) +[xUnit.net 00:00:00.30] at NSubstitute.Core.CallSpecificationFactory.CreateFrom(ICall call, MatchArgs matchArgs) +[xUnit.net 00:00:00.30] at NSubstitute.Routing.Handlers.RecordCallSpecificationHandler.Handle(ICall call) +[xUnit.net 00:00:00.30] at NSubstitute.Routing.Route.Handle(ICall call) +[xUnit.net 00:00:00.30] at NSubstitute.Core.CallRouter.Route(ICall call) +[xUnit.net 00:00:00.30] at NSubstitute.Proxies.CastleDynamicProxy.CastleForwardingInterceptor.Intercept(IInvocation invocation) +[xUnit.net 00:00:00.30] at Castle.DynamicProxy.AbstractInvocation.Proceed() +[xUnit.net 00:00:00.30] at NSubstitute.Proxies.CastleDynamicProxy.ProxyIdInterceptor.Intercept(IInvocation invocation) +[xUnit.net 00:00:00.30] at Castle.DynamicProxy.AbstractInvocation.Proceed() +[xUnit.net 00:00:00.30] at Castle.Proxies.AIFunctionProxy.InvokeCoreAsync(AIFunctionArguments arguments, CancellationToken cancellationToken) +[xUnit.net 00:00:00.30] at Microsoft.Extensions.AI.AIFunction.InvokeAsync(AIFunctionArguments arguments, CancellationToken cancellationToken) +[xUnit.net 00:00:00.30] /Users/alexsohn/code/sentry-dotnet/test/Sentry.Extensions.AI.Tests/SentryInstrumentedFunctionTests.cs(160,0): at Sentry.Extensions.AI.Tests.SentryInstrumentedFunctionTests.InvokeCoreAsync_WithCancellation_PropagatesCancellation() +[xUnit.net 00:00:00.30] --- End of stack trace from previous location --- +[xUnit.net 00:00:00.33] Finished: Sentry.Extensions.AI.Tests + + + + + [xUnit.net 00:00:00.20] Sentry.Extensions.AI.Tests.SentryAISpanEnricherTests.EnrichWithResponse_SetsUsageTokens_WhenUsageProvided [FAIL] + + + [xUnit.net 00:00:00.22] Sentry.Extensions.AI.Tests.SentryAISpanEnricherTests.EnrichWithResponse_SetsPartialTokens_WhenOnlyInputTokensProvided [FAIL] + + + [xUnit.net 00:00:00.29] Sentry.Extensions.AI.Tests.SentryInstrumentedFunctionTests.InvokeCoreAsync_WhenInnerFunctionThrows_FinishesSpanWithException [FAIL] + + + [xUnit.net 00:00:00.29] Sentry.Extensions.AI.Tests.SentryInstrumentedFunctionTests.InvokeCoreAsync_PreservesInnerFunctionName [FAIL] + + + [xUnit.net 00:00:00.30] Sentry.Extensions.AI.Tests.SentryInstrumentedFunctionTests.InvokeCoreAsync_WithParentSpan_CreatesChildSpan [FAIL] + + + [xUnit.net 00:00:00.30] Sentry.Extensions.AI.Tests.SentryInstrumentedFunctionTests.InvokeCoreAsync_WithoutParentSpan_CreatesTransaction [FAIL] + + + [xUnit.net 00:00:00.30] Sentry.Extensions.AI.Tests.SentryInstrumentedFunctionTests.InvokeCoreAsync_WithComplexResult_SetsOutputAsString [FAIL] + + + [xUnit.net 00:00:00.30] Sentry.Extensions.AI.Tests.SentryInstrumentedFunctionTests.InvokeCoreAsync_WithNullResult_DoesNotSetOutput [FAIL] + + + [xUnit.net 00:00:00.30] Sentry.Extensions.AI.Tests.SentryInstrumentedFunctionTests.InvokeCoreAsync_WithCancellation_PropagatesCancellation [FAIL] + + + + \ No newline at end of file From ba22f43b3c275f8dd88cb82a4cf423426eec14ba Mon Sep 17 00:00:00 2001 From: Alex Sohn Date: Mon, 20 Oct 2025 01:28:48 -0400 Subject: [PATCH 04/86] remove nullable --- samples/Sentry.Samples.ME.AI.AspNetCore/Program.cs | 1 - 1 file changed, 1 deletion(-) diff --git a/samples/Sentry.Samples.ME.AI.AspNetCore/Program.cs b/samples/Sentry.Samples.ME.AI.AspNetCore/Program.cs index 0a0b0ca997..75a117da81 100644 --- a/samples/Sentry.Samples.ME.AI.AspNetCore/Program.cs +++ b/samples/Sentry.Samples.ME.AI.AspNetCore/Program.cs @@ -1,4 +1,3 @@ -#nullable enable using Anthropic.SDK; using Anthropic.SDK.Constants; using Microsoft.Extensions.AI; From 71afb95e44013dff4eefbbc1737ee980bf5b0e5a Mon Sep 17 00:00:00 2001 From: Alex Sohn Date: Mon, 20 Oct 2025 01:29:59 -0400 Subject: [PATCH 05/86] remove test results --- .../_CVDTQ2MGH4_2025-10-20_00_35_51.trx | 863 ------------------ .../_CVDTQ2MGH4_2025-10-20_00_35_51[1].trx | 863 ------------------ 2 files changed, 1726 deletions(-) delete mode 100644 test/Sentry.Extensions.AI.Tests/TestResults/_CVDTQ2MGH4_2025-10-20_00_35_51.trx delete mode 100644 test/Sentry.Extensions.AI.Tests/TestResults/_CVDTQ2MGH4_2025-10-20_00_35_51[1].trx diff --git a/test/Sentry.Extensions.AI.Tests/TestResults/_CVDTQ2MGH4_2025-10-20_00_35_51.trx b/test/Sentry.Extensions.AI.Tests/TestResults/_CVDTQ2MGH4_2025-10-20_00_35_51.trx deleted file mode 100644 index eaf4012297..0000000000 --- a/test/Sentry.Extensions.AI.Tests/TestResults/_CVDTQ2MGH4_2025-10-20_00_35_51.trx +++ /dev/null @@ -1,863 +0,0 @@ - - - - - - - - - - - - - - - - - - - - NSubstitute.Exceptions.AmbiguousArgumentsException : Cannot determine argument specifications to use. Please use specifications for all arguments of the same type. -Method signature: - InvokeCoreAsync(AIFunctionArguments, CancellationToken) -Method arguments (possible arg matchers are indicated with '*'): - InvokeCoreAsync(AIFunctionArguments, *System.Threading.CancellationToken*) -All queued specifications: - any AIFunctionArguments - any CancellationToken -Matched argument specifications: - InvokeCoreAsync(AIFunctionArguments, ???) - - at NSubstitute.Core.Arguments.ArgumentSpecificationsFactory.Create(IList`1 argumentSpecs, Object[] arguments, IParameterInfo[] parameterInfos, MethodInfo methodInfo, MatchArgs matchArgs) - at NSubstitute.Core.CallSpecificationFactory.CreateFrom(ICall call, MatchArgs matchArgs) - at NSubstitute.Routing.Handlers.RecordCallSpecificationHandler.Handle(ICall call) - at NSubstitute.Routing.Route.Handle(ICall call) - at NSubstitute.Core.CallRouter.Route(ICall call) - at NSubstitute.Proxies.CastleDynamicProxy.CastleForwardingInterceptor.Intercept(IInvocation invocation) - at Castle.DynamicProxy.AbstractInvocation.Proceed() - at NSubstitute.Proxies.CastleDynamicProxy.ProxyIdInterceptor.Intercept(IInvocation invocation) - at Castle.DynamicProxy.AbstractInvocation.Proceed() - at Castle.Proxies.AIFunctionProxy.InvokeCoreAsync(AIFunctionArguments arguments, CancellationToken cancellationToken) - at Microsoft.Extensions.AI.AIFunction.InvokeAsync(AIFunctionArguments arguments, CancellationToken cancellationToken) - at Sentry.Extensions.AI.Tests.SentryInstrumentedFunctionTests.InvokeCoreAsync_WithComplexResult_SetsOutputAsString() in /Users/alexsohn/code/sentry-dotnet/test/Sentry.Extensions.AI.Tests/SentryInstrumentedFunctionTests.cs:line 113 ---- End of stack trace from previous location --- - - - - - - - - - - - - NSubstitute.Exceptions.AmbiguousArgumentsException : Cannot determine argument specifications to use. Please use specifications for all arguments of the same type. -Method signature: - InvokeCoreAsync(AIFunctionArguments, CancellationToken) -Method arguments (possible arg matchers are indicated with '*'): - InvokeCoreAsync(AIFunctionArguments, *System.Threading.CancellationToken*) -All queued specifications: - any AIFunctionArguments - any CancellationToken -Matched argument specifications: - InvokeCoreAsync(AIFunctionArguments, ???) - - at NSubstitute.Core.Arguments.ArgumentSpecificationsFactory.Create(IList`1 argumentSpecs, Object[] arguments, IParameterInfo[] parameterInfos, MethodInfo methodInfo, MatchArgs matchArgs) - at NSubstitute.Core.CallSpecificationFactory.CreateFrom(ICall call, MatchArgs matchArgs) - at NSubstitute.Routing.Handlers.RecordCallSpecificationHandler.Handle(ICall call) - at NSubstitute.Routing.Route.Handle(ICall call) - at NSubstitute.Core.CallRouter.Route(ICall call) - at NSubstitute.Proxies.CastleDynamicProxy.CastleForwardingInterceptor.Intercept(IInvocation invocation) - at Castle.DynamicProxy.AbstractInvocation.Proceed() - at NSubstitute.Proxies.CastleDynamicProxy.ProxyIdInterceptor.Intercept(IInvocation invocation) - at Castle.DynamicProxy.AbstractInvocation.Proceed() - at Castle.Proxies.AIFunctionProxy.InvokeCoreAsync(AIFunctionArguments arguments, CancellationToken cancellationToken) - at Microsoft.Extensions.AI.AIFunction.InvokeAsync(AIFunctionArguments arguments, CancellationToken cancellationToken) - at Sentry.Extensions.AI.Tests.SentryInstrumentedFunctionTests.InvokeCoreAsync_WithoutParentSpan_CreatesTransaction() in /Users/alexsohn/code/sentry-dotnet/test/Sentry.Extensions.AI.Tests/SentryInstrumentedFunctionTests.cs:line 45 ---- End of stack trace from previous location --- - - - - - - - - - - NSubstitute.Exceptions.ReceivedCallsException : Expected to receive exactly 1 call matching: - SetData("gen_ai.usage.input_tokens", 10) -Actually received no matching calls. -Received 4 non-matching calls (non-matching arguments indicated with '*' characters): - SetData("gen_ai.usage.input_tokens", *10*) - SetData(*"gen_ai.usage.output_tokens"*, *20*) - SetData(*"gen_ai.usage.total_tokens"*, *30*) - SetData(*"gen_ai.response.text"*, *"Hello"*) - - at NSubstitute.Core.ReceivedCallsExceptionThrower.Throw(ICallSpecification callSpecification, IEnumerable`1 matchingCalls, IEnumerable`1 nonMatchingCalls, Quantity requiredQuantity) - at NSubstitute.Routing.Handlers.CheckReceivedCallsHandler.Handle(ICall call) - at NSubstitute.Routing.Route.Handle(ICall call) - at NSubstitute.Core.CallRouter.Route(ICall call) - at NSubstitute.Proxies.CastleDynamicProxy.CastleForwardingInterceptor.Intercept(IInvocation invocation) - at Castle.DynamicProxy.AbstractInvocation.Proceed() - at NSubstitute.Proxies.CastleDynamicProxy.ProxyIdInterceptor.Intercept(IInvocation invocation) - at Castle.DynamicProxy.AbstractInvocation.Proceed() - at Castle.Proxies.ObjectProxy_2.SetData(String key, Object value) - at Sentry.Extensions.AI.Tests.SentryAISpanEnricherTests.EnrichWithResponse_SetsUsageTokens_WhenUsageProvided() in /Users/alexsohn/code/sentry-dotnet/test/Sentry.Extensions.AI.Tests/SentryAISpanEnricherTests.cs:line 175 - at System.RuntimeMethodHandle.InvokeMethod(Object target, Void** arguments, Signature sig, Boolean isConstructor) - at System.Reflection.MethodBaseInvoker.InvokeWithNoArgs(Object obj, BindingFlags invokeAttr) - - - - - - - - - - - NSubstitute.Exceptions.AmbiguousArgumentsException : Cannot determine argument specifications to use. Please use specifications for all arguments of the same type. -Method signature: - InvokeCoreAsync(AIFunctionArguments, CancellationToken) -Method arguments (possible arg matchers are indicated with '*'): - InvokeCoreAsync(AIFunctionArguments, *System.Threading.CancellationToken*) -All queued specifications: - any AIFunctionArguments - any CancellationToken -Matched argument specifications: - InvokeCoreAsync(AIFunctionArguments, ???) - - at NSubstitute.Core.Arguments.ArgumentSpecificationsFactory.Create(IList`1 argumentSpecs, Object[] arguments, IParameterInfo[] parameterInfos, MethodInfo methodInfo, MatchArgs matchArgs) - at NSubstitute.Core.CallSpecificationFactory.CreateFrom(ICall call, MatchArgs matchArgs) - at NSubstitute.Routing.Handlers.RecordCallSpecificationHandler.Handle(ICall call) - at NSubstitute.Routing.Route.Handle(ICall call) - at NSubstitute.Core.CallRouter.Route(ICall call) - at NSubstitute.Proxies.CastleDynamicProxy.CastleForwardingInterceptor.Intercept(IInvocation invocation) - at Castle.DynamicProxy.AbstractInvocation.Proceed() - at NSubstitute.Proxies.CastleDynamicProxy.ProxyIdInterceptor.Intercept(IInvocation invocation) - at Castle.DynamicProxy.AbstractInvocation.Proceed() - at Castle.Proxies.AIFunctionProxy.InvokeCoreAsync(AIFunctionArguments arguments, CancellationToken cancellationToken) - at Microsoft.Extensions.AI.AIFunction.InvokeAsync(AIFunctionArguments arguments, CancellationToken cancellationToken) - at Sentry.Extensions.AI.Tests.SentryInstrumentedFunctionTests.InvokeCoreAsync_PreservesInnerFunctionName() in /Users/alexsohn/code/sentry-dotnet/test/Sentry.Extensions.AI.Tests/SentryInstrumentedFunctionTests.cs:line 135 ---- End of stack trace from previous location --- - - - - - - - - - - - - NSubstitute.Exceptions.AmbiguousArgumentsException : Cannot determine argument specifications to use. Please use specifications for all arguments of the same type. -Method signature: - InvokeCoreAsync(AIFunctionArguments, CancellationToken) -Method arguments (possible arg matchers are indicated with '*'): - InvokeCoreAsync(AIFunctionArguments, *System.Threading.CancellationToken*) -All queued specifications: - any AIFunctionArguments - any CancellationToken -Matched argument specifications: - InvokeCoreAsync(AIFunctionArguments, ???) - - at NSubstitute.Core.Arguments.ArgumentSpecificationsFactory.Create(IList`1 argumentSpecs, Object[] arguments, IParameterInfo[] parameterInfos, MethodInfo methodInfo, MatchArgs matchArgs) - at NSubstitute.Core.CallSpecificationFactory.CreateFrom(ICall call, MatchArgs matchArgs) - at NSubstitute.Routing.Handlers.RecordCallSpecificationHandler.Handle(ICall call) - at NSubstitute.Routing.Route.Handle(ICall call) - at NSubstitute.Core.CallRouter.Route(ICall call) - at NSubstitute.Proxies.CastleDynamicProxy.CastleForwardingInterceptor.Intercept(IInvocation invocation) - at Castle.DynamicProxy.AbstractInvocation.Proceed() - at NSubstitute.Proxies.CastleDynamicProxy.ProxyIdInterceptor.Intercept(IInvocation invocation) - at Castle.DynamicProxy.AbstractInvocation.Proceed() - at Castle.Proxies.AIFunctionProxy.InvokeCoreAsync(AIFunctionArguments arguments, CancellationToken cancellationToken) - at Microsoft.Extensions.AI.AIFunction.InvokeAsync(AIFunctionArguments arguments, CancellationToken cancellationToken) - at Sentry.Extensions.AI.Tests.SentryInstrumentedFunctionTests.InvokeCoreAsync_WithParentSpan_CreatesChildSpan() in /Users/alexsohn/code/sentry-dotnet/test/Sentry.Extensions.AI.Tests/SentryInstrumentedFunctionTests.cs:line 19 ---- End of stack trace from previous location --- - - - - - - - - - - - - - - - - - - NSubstitute.Exceptions.ReceivedCallsException : Expected to receive exactly 1 call matching: - SetData("gen_ai.usage.input_tokens", 10) -Actually received no matching calls. -Received 2 non-matching calls (non-matching arguments indicated with '*' characters): - SetData("gen_ai.usage.input_tokens", *10*) - SetData(*"gen_ai.response.text"*, *"Hello"*) - - at NSubstitute.Core.ReceivedCallsExceptionThrower.Throw(ICallSpecification callSpecification, IEnumerable`1 matchingCalls, IEnumerable`1 nonMatchingCalls, Quantity requiredQuantity) - at NSubstitute.Routing.Handlers.CheckReceivedCallsHandler.Handle(ICall call) - at NSubstitute.Routing.Route.Handle(ICall call) - at NSubstitute.Core.CallRouter.Route(ICall call) - at NSubstitute.Proxies.CastleDynamicProxy.CastleForwardingInterceptor.Intercept(IInvocation invocation) - at Castle.DynamicProxy.AbstractInvocation.Proceed() - at NSubstitute.Proxies.CastleDynamicProxy.ProxyIdInterceptor.Intercept(IInvocation invocation) - at Castle.DynamicProxy.AbstractInvocation.Proceed() - at Castle.Proxies.ObjectProxy_2.SetData(String key, Object value) - at Sentry.Extensions.AI.Tests.SentryAISpanEnricherTests.EnrichWithResponse_SetsPartialTokens_WhenOnlyInputTokensProvided() in /Users/alexsohn/code/sentry-dotnet/test/Sentry.Extensions.AI.Tests/SentryAISpanEnricherTests.cs:line 203 - at System.RuntimeMethodHandle.InvokeMethod(Object target, Void** arguments, Signature sig, Boolean isConstructor) - at System.Reflection.MethodBaseInvoker.InvokeWithNoArgs(Object obj, BindingFlags invokeAttr) - - - - - - - - NSubstitute.Exceptions.AmbiguousArgumentsException : Cannot determine argument specifications to use. Please use specifications for all arguments of the same type. -Method signature: - InvokeCoreAsync(AIFunctionArguments, CancellationToken) -Method arguments (possible arg matchers are indicated with '*'): - InvokeCoreAsync(AIFunctionArguments, *System.Threading.CancellationToken*) -All queued specifications: - any AIFunctionArguments - any CancellationToken -Matched argument specifications: - InvokeCoreAsync(AIFunctionArguments, ???) - - at NSubstitute.Core.Arguments.ArgumentSpecificationsFactory.Create(IList`1 argumentSpecs, Object[] arguments, IParameterInfo[] parameterInfos, MethodInfo methodInfo, MatchArgs matchArgs) - at NSubstitute.Core.CallSpecificationFactory.CreateFrom(ICall call, MatchArgs matchArgs) - at NSubstitute.Routing.Handlers.RecordCallSpecificationHandler.Handle(ICall call) - at NSubstitute.Routing.Route.Handle(ICall call) - at NSubstitute.Core.CallRouter.Route(ICall call) - at NSubstitute.Proxies.CastleDynamicProxy.CastleForwardingInterceptor.Intercept(IInvocation invocation) - at Castle.DynamicProxy.AbstractInvocation.Proceed() - at NSubstitute.Proxies.CastleDynamicProxy.ProxyIdInterceptor.Intercept(IInvocation invocation) - at Castle.DynamicProxy.AbstractInvocation.Proceed() - at Castle.Proxies.AIFunctionProxy.InvokeCoreAsync(AIFunctionArguments arguments, CancellationToken cancellationToken) - at Microsoft.Extensions.AI.AIFunction.InvokeAsync(AIFunctionArguments arguments, CancellationToken cancellationToken) - at Sentry.Extensions.AI.Tests.SentryInstrumentedFunctionTests.InvokeCoreAsync_WithCancellation_PropagatesCancellation() in /Users/alexsohn/code/sentry-dotnet/test/Sentry.Extensions.AI.Tests/SentryInstrumentedFunctionTests.cs:line 160 ---- End of stack trace from previous location --- - - - - - - - NSubstitute.Exceptions.AmbiguousArgumentsException : Cannot determine argument specifications to use. Please use specifications for all arguments of the same type. -Method signature: - InvokeCoreAsync(AIFunctionArguments, CancellationToken) -Method arguments (possible arg matchers are indicated with '*'): - InvokeCoreAsync(AIFunctionArguments, *System.Threading.CancellationToken*) -All queued specifications: - any AIFunctionArguments - any CancellationToken -Matched argument specifications: - InvokeCoreAsync(AIFunctionArguments, ???) - - at NSubstitute.Core.Arguments.ArgumentSpecificationsFactory.Create(IList`1 argumentSpecs, Object[] arguments, IParameterInfo[] parameterInfos, MethodInfo methodInfo, MatchArgs matchArgs) - at NSubstitute.Core.CallSpecificationFactory.CreateFrom(ICall call, MatchArgs matchArgs) - at NSubstitute.Routing.Handlers.RecordCallSpecificationHandler.Handle(ICall call) - at NSubstitute.Routing.Route.Handle(ICall call) - at NSubstitute.Core.CallRouter.Route(ICall call) - at NSubstitute.Proxies.CastleDynamicProxy.CastleForwardingInterceptor.Intercept(IInvocation invocation) - at Castle.DynamicProxy.AbstractInvocation.Proceed() - at NSubstitute.Proxies.CastleDynamicProxy.ProxyIdInterceptor.Intercept(IInvocation invocation) - at Castle.DynamicProxy.AbstractInvocation.Proceed() - at Castle.Proxies.AIFunctionProxy.InvokeCoreAsync(AIFunctionArguments arguments, CancellationToken cancellationToken) - at Microsoft.Extensions.AI.AIFunction.InvokeAsync(AIFunctionArguments arguments, CancellationToken cancellationToken) - at Sentry.Extensions.AI.Tests.SentryInstrumentedFunctionTests.InvokeCoreAsync_WhenInnerFunctionThrows_FinishesSpanWithException() in /Users/alexsohn/code/sentry-dotnet/test/Sentry.Extensions.AI.Tests/SentryInstrumentedFunctionTests.cs:line 68 ---- End of stack trace from previous location --- - - - - - - - - - - - - - - - NSubstitute.Exceptions.AmbiguousArgumentsException : Cannot determine argument specifications to use. Please use specifications for all arguments of the same type. -Method signature: - InvokeCoreAsync(AIFunctionArguments, CancellationToken) -Method arguments (possible arg matchers are indicated with '*'): - InvokeCoreAsync(AIFunctionArguments, *System.Threading.CancellationToken*) -All queued specifications: - any AIFunctionArguments - any CancellationToken -Matched argument specifications: - InvokeCoreAsync(AIFunctionArguments, ???) - - at NSubstitute.Core.Arguments.ArgumentSpecificationsFactory.Create(IList`1 argumentSpecs, Object[] arguments, IParameterInfo[] parameterInfos, MethodInfo methodInfo, MatchArgs matchArgs) - at NSubstitute.Core.CallSpecificationFactory.CreateFrom(ICall call, MatchArgs matchArgs) - at NSubstitute.Routing.Handlers.RecordCallSpecificationHandler.Handle(ICall call) - at NSubstitute.Routing.Route.Handle(ICall call) - at NSubstitute.Core.CallRouter.Route(ICall call) - at NSubstitute.Proxies.CastleDynamicProxy.CastleForwardingInterceptor.Intercept(IInvocation invocation) - at Castle.DynamicProxy.AbstractInvocation.Proceed() - at NSubstitute.Proxies.CastleDynamicProxy.ProxyIdInterceptor.Intercept(IInvocation invocation) - at Castle.DynamicProxy.AbstractInvocation.Proceed() - at Castle.Proxies.AIFunctionProxy.InvokeCoreAsync(AIFunctionArguments arguments, CancellationToken cancellationToken) - at Microsoft.Extensions.AI.AIFunction.InvokeAsync(AIFunctionArguments arguments, CancellationToken cancellationToken) - at Sentry.Extensions.AI.Tests.SentryInstrumentedFunctionTests.InvokeCoreAsync_WithNullResult_DoesNotSetOutput() in /Users/alexsohn/code/sentry-dotnet/test/Sentry.Extensions.AI.Tests/SentryInstrumentedFunctionTests.cs:line 90 ---- End of stack trace from previous location --- - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - [xUnit.net 00:00:00.00] xUnit.net VSTest Adapter v3.1.1+bf6400fd51 (64-bit .NET 9.0.8) -[xUnit.net 00:00:00.05] Discovering: Sentry.Extensions.AI.Tests -[xUnit.net 00:00:00.07] Discovered: Sentry.Extensions.AI.Tests -[xUnit.net 00:00:00.08] Starting: Sentry.Extensions.AI.Tests -[xUnit.net 00:00:00.19] NSubstitute.Exceptions.AmbiguousArgumentsException : Cannot determine argument specifications to use. Please use specifications for all arguments of the same type. -[xUnit.net 00:00:00.19] Method signature: -[xUnit.net 00:00:00.19] InvokeCoreAsync(AIFunctionArguments, CancellationToken) -[xUnit.net 00:00:00.19] Method arguments (possible arg matchers are indicated with '*'): -[xUnit.net 00:00:00.19] InvokeCoreAsync(AIFunctionArguments, *System.Threading.CancellationToken*) -[xUnit.net 00:00:00.19] All queued specifications: -[xUnit.net 00:00:00.19] any AIFunctionArguments -[xUnit.net 00:00:00.19] any CancellationToken -[xUnit.net 00:00:00.19] Matched argument specifications: -[xUnit.net 00:00:00.19] InvokeCoreAsync(AIFunctionArguments, ???) -[xUnit.net 00:00:00.19] -[xUnit.net 00:00:00.19] Stack Trace: -[xUnit.net 00:00:00.19] at NSubstitute.Core.Arguments.ArgumentSpecificationsFactory.Create(IList`1 argumentSpecs, Object[] arguments, IParameterInfo[] parameterInfos, MethodInfo methodInfo, MatchArgs matchArgs) -[xUnit.net 00:00:00.19] at NSubstitute.Core.CallSpecificationFactory.CreateFrom(ICall call, MatchArgs matchArgs) -[xUnit.net 00:00:00.19] at NSubstitute.Routing.Handlers.RecordCallSpecificationHandler.Handle(ICall call) -[xUnit.net 00:00:00.19] at NSubstitute.Routing.Route.Handle(ICall call) -[xUnit.net 00:00:00.19] at NSubstitute.Core.CallRouter.Route(ICall call) -[xUnit.net 00:00:00.19] at NSubstitute.Proxies.CastleDynamicProxy.CastleForwardingInterceptor.Intercept(IInvocation invocation) -[xUnit.net 00:00:00.19] at Castle.DynamicProxy.AbstractInvocation.Proceed() -[xUnit.net 00:00:00.19] at NSubstitute.Proxies.CastleDynamicProxy.ProxyIdInterceptor.Intercept(IInvocation invocation) -[xUnit.net 00:00:00.19] at Castle.DynamicProxy.AbstractInvocation.Proceed() -[xUnit.net 00:00:00.19] at Castle.Proxies.AIFunctionProxy.InvokeCoreAsync(AIFunctionArguments arguments, CancellationToken cancellationToken) -[xUnit.net 00:00:00.19] at Microsoft.Extensions.AI.AIFunction.InvokeAsync(AIFunctionArguments arguments, CancellationToken cancellationToken) -[xUnit.net 00:00:00.19] /Users/alexsohn/code/sentry-dotnet/test/Sentry.Extensions.AI.Tests/SentryInstrumentedFunctionTests.cs(68,0): at Sentry.Extensions.AI.Tests.SentryInstrumentedFunctionTests.InvokeCoreAsync_WhenInnerFunctionThrows_FinishesSpanWithException() -[xUnit.net 00:00:00.19] --- End of stack trace from previous location --- -[xUnit.net 00:00:00.20] NSubstitute.Exceptions.AmbiguousArgumentsException : Cannot determine argument specifications to use. Please use specifications for all arguments of the same type. -[xUnit.net 00:00:00.20] Method signature: -[xUnit.net 00:00:00.20] InvokeCoreAsync(AIFunctionArguments, CancellationToken) -[xUnit.net 00:00:00.20] Method arguments (possible arg matchers are indicated with '*'): -[xUnit.net 00:00:00.20] InvokeCoreAsync(AIFunctionArguments, *System.Threading.CancellationToken*) -[xUnit.net 00:00:00.20] All queued specifications: -[xUnit.net 00:00:00.20] any AIFunctionArguments -[xUnit.net 00:00:00.20] any CancellationToken -[xUnit.net 00:00:00.20] Matched argument specifications: -[xUnit.net 00:00:00.20] InvokeCoreAsync(AIFunctionArguments, ???) -[xUnit.net 00:00:00.20] -[xUnit.net 00:00:00.20] Stack Trace: -[xUnit.net 00:00:00.20] at NSubstitute.Core.Arguments.ArgumentSpecificationsFactory.Create(IList`1 argumentSpecs, Object[] arguments, IParameterInfo[] parameterInfos, MethodInfo methodInfo, MatchArgs matchArgs) -[xUnit.net 00:00:00.20] at NSubstitute.Core.CallSpecificationFactory.CreateFrom(ICall call, MatchArgs matchArgs) -[xUnit.net 00:00:00.20] at NSubstitute.Routing.Handlers.RecordCallSpecificationHandler.Handle(ICall call) -[xUnit.net 00:00:00.20] at NSubstitute.Routing.Route.Handle(ICall call) -[xUnit.net 00:00:00.20] at NSubstitute.Core.CallRouter.Route(ICall call) -[xUnit.net 00:00:00.20] at NSubstitute.Proxies.CastleDynamicProxy.CastleForwardingInterceptor.Intercept(IInvocation invocation) -[xUnit.net 00:00:00.20] at Castle.DynamicProxy.AbstractInvocation.Proceed() -[xUnit.net 00:00:00.20] at NSubstitute.Proxies.CastleDynamicProxy.ProxyIdInterceptor.Intercept(IInvocation invocation) -[xUnit.net 00:00:00.20] at Castle.DynamicProxy.AbstractInvocation.Proceed() -[xUnit.net 00:00:00.20] at Castle.Proxies.AIFunctionProxy.InvokeCoreAsync(AIFunctionArguments arguments, CancellationToken cancellationToken) -[xUnit.net 00:00:00.20] at Microsoft.Extensions.AI.AIFunction.InvokeAsync(AIFunctionArguments arguments, CancellationToken cancellationToken) -[xUnit.net 00:00:00.20] /Users/alexsohn/code/sentry-dotnet/test/Sentry.Extensions.AI.Tests/SentryInstrumentedFunctionTests.cs(135,0): at Sentry.Extensions.AI.Tests.SentryInstrumentedFunctionTests.InvokeCoreAsync_PreservesInnerFunctionName() -[xUnit.net 00:00:00.20] --- End of stack trace from previous location --- -[xUnit.net 00:00:00.20] NSubstitute.Exceptions.AmbiguousArgumentsException : Cannot determine argument specifications to use. Please use specifications for all arguments of the same type. -[xUnit.net 00:00:00.20] Method signature: -[xUnit.net 00:00:00.20] InvokeCoreAsync(AIFunctionArguments, CancellationToken) -[xUnit.net 00:00:00.20] Method arguments (possible arg matchers are indicated with '*'): -[xUnit.net 00:00:00.20] InvokeCoreAsync(AIFunctionArguments, *System.Threading.CancellationToken*) -[xUnit.net 00:00:00.20] All queued specifications: -[xUnit.net 00:00:00.20] any AIFunctionArguments -[xUnit.net 00:00:00.20] any CancellationToken -[xUnit.net 00:00:00.20] Matched argument specifications: -[xUnit.net 00:00:00.20] InvokeCoreAsync(AIFunctionArguments, ???) -[xUnit.net 00:00:00.20] -[xUnit.net 00:00:00.20] Stack Trace: -[xUnit.net 00:00:00.20] at NSubstitute.Core.Arguments.ArgumentSpecificationsFactory.Create(IList`1 argumentSpecs, Object[] arguments, IParameterInfo[] parameterInfos, MethodInfo methodInfo, MatchArgs matchArgs) -[xUnit.net 00:00:00.20] at NSubstitute.Core.CallSpecificationFactory.CreateFrom(ICall call, MatchArgs matchArgs) -[xUnit.net 00:00:00.20] at NSubstitute.Routing.Handlers.RecordCallSpecificationHandler.Handle(ICall call) -[xUnit.net 00:00:00.20] at NSubstitute.Routing.Route.Handle(ICall call) -[xUnit.net 00:00:00.20] at NSubstitute.Core.CallRouter.Route(ICall call) -[xUnit.net 00:00:00.20] at NSubstitute.Proxies.CastleDynamicProxy.CastleForwardingInterceptor.Intercept(IInvocation invocation) -[xUnit.net 00:00:00.20] at Castle.DynamicProxy.AbstractInvocation.Proceed() -[xUnit.net 00:00:00.20] at NSubstitute.Proxies.CastleDynamicProxy.ProxyIdInterceptor.Intercept(IInvocation invocation) -[xUnit.net 00:00:00.20] at Castle.DynamicProxy.AbstractInvocation.Proceed() -[xUnit.net 00:00:00.20] at Castle.Proxies.AIFunctionProxy.InvokeCoreAsync(AIFunctionArguments arguments, CancellationToken cancellationToken) -[xUnit.net 00:00:00.20] at Microsoft.Extensions.AI.AIFunction.InvokeAsync(AIFunctionArguments arguments, CancellationToken cancellationToken) -[xUnit.net 00:00:00.20] /Users/alexsohn/code/sentry-dotnet/test/Sentry.Extensions.AI.Tests/SentryInstrumentedFunctionTests.cs(19,0): at Sentry.Extensions.AI.Tests.SentryInstrumentedFunctionTests.InvokeCoreAsync_WithParentSpan_CreatesChildSpan() -[xUnit.net 00:00:00.20] --- End of stack trace from previous location --- -[xUnit.net 00:00:00.20] NSubstitute.Exceptions.AmbiguousArgumentsException : Cannot determine argument specifications to use. Please use specifications for all arguments of the same type. -[xUnit.net 00:00:00.20] Method signature: -[xUnit.net 00:00:00.20] InvokeCoreAsync(AIFunctionArguments, CancellationToken) -[xUnit.net 00:00:00.20] Method arguments (possible arg matchers are indicated with '*'): -[xUnit.net 00:00:00.20] InvokeCoreAsync(AIFunctionArguments, *System.Threading.CancellationToken*) -[xUnit.net 00:00:00.20] All queued specifications: -[xUnit.net 00:00:00.20] any AIFunctionArguments -[xUnit.net 00:00:00.20] any CancellationToken -[xUnit.net 00:00:00.20] Matched argument specifications: -[xUnit.net 00:00:00.20] InvokeCoreAsync(AIFunctionArguments, ???) -[xUnit.net 00:00:00.20] -[xUnit.net 00:00:00.20] Stack Trace: -[xUnit.net 00:00:00.20] at NSubstitute.Core.Arguments.ArgumentSpecificationsFactory.Create(IList`1 argumentSpecs, Object[] arguments, IParameterInfo[] parameterInfos, MethodInfo methodInfo, MatchArgs matchArgs) -[xUnit.net 00:00:00.20] at NSubstitute.Core.CallSpecificationFactory.CreateFrom(ICall call, MatchArgs matchArgs) -[xUnit.net 00:00:00.20] at NSubstitute.Routing.Handlers.RecordCallSpecificationHandler.Handle(ICall call) -[xUnit.net 00:00:00.20] at NSubstitute.Routing.Route.Handle(ICall call) -[xUnit.net 00:00:00.20] at NSubstitute.Core.CallRouter.Route(ICall call) -[xUnit.net 00:00:00.20] at NSubstitute.Proxies.CastleDynamicProxy.CastleForwardingInterceptor.Intercept(IInvocation invocation) -[xUnit.net 00:00:00.20] at Castle.DynamicProxy.AbstractInvocation.Proceed() -[xUnit.net 00:00:00.20] at NSubstitute.Proxies.CastleDynamicProxy.ProxyIdInterceptor.Intercept(IInvocation invocation) -[xUnit.net 00:00:00.20] at Castle.DynamicProxy.AbstractInvocation.Proceed() -[xUnit.net 00:00:00.20] at Castle.Proxies.AIFunctionProxy.InvokeCoreAsync(AIFunctionArguments arguments, CancellationToken cancellationToken) -[xUnit.net 00:00:00.20] at Microsoft.Extensions.AI.AIFunction.InvokeAsync(AIFunctionArguments arguments, CancellationToken cancellationToken) -[xUnit.net 00:00:00.20] /Users/alexsohn/code/sentry-dotnet/test/Sentry.Extensions.AI.Tests/SentryInstrumentedFunctionTests.cs(45,0): at Sentry.Extensions.AI.Tests.SentryInstrumentedFunctionTests.InvokeCoreAsync_WithoutParentSpan_CreatesTransaction() -[xUnit.net 00:00:00.20] --- End of stack trace from previous location --- -[xUnit.net 00:00:00.21] NSubstitute.Exceptions.AmbiguousArgumentsException : Cannot determine argument specifications to use. Please use specifications for all arguments of the same type. -[xUnit.net 00:00:00.21] Method signature: -[xUnit.net 00:00:00.21] InvokeCoreAsync(AIFunctionArguments, CancellationToken) -[xUnit.net 00:00:00.21] Method arguments (possible arg matchers are indicated with '*'): -[xUnit.net 00:00:00.21] InvokeCoreAsync(AIFunctionArguments, *System.Threading.CancellationToken*) -[xUnit.net 00:00:00.21] All queued specifications: -[xUnit.net 00:00:00.21] any AIFunctionArguments -[xUnit.net 00:00:00.21] any CancellationToken -[xUnit.net 00:00:00.21] Matched argument specifications: -[xUnit.net 00:00:00.21] InvokeCoreAsync(AIFunctionArguments, ???) -[xUnit.net 00:00:00.21] -[xUnit.net 00:00:00.21] Stack Trace: -[xUnit.net 00:00:00.21] at NSubstitute.Core.Arguments.ArgumentSpecificationsFactory.Create(IList`1 argumentSpecs, Object[] arguments, IParameterInfo[] parameterInfos, MethodInfo methodInfo, MatchArgs matchArgs) -[xUnit.net 00:00:00.21] at NSubstitute.Core.CallSpecificationFactory.CreateFrom(ICall call, MatchArgs matchArgs) -[xUnit.net 00:00:00.21] at NSubstitute.Routing.Handlers.RecordCallSpecificationHandler.Handle(ICall call) -[xUnit.net 00:00:00.21] at NSubstitute.Routing.Route.Handle(ICall call) -[xUnit.net 00:00:00.21] at NSubstitute.Core.CallRouter.Route(ICall call) -[xUnit.net 00:00:00.21] at NSubstitute.Proxies.CastleDynamicProxy.CastleForwardingInterceptor.Intercept(IInvocation invocation) -[xUnit.net 00:00:00.21] at Castle.DynamicProxy.AbstractInvocation.Proceed() -[xUnit.net 00:00:00.21] at NSubstitute.Proxies.CastleDynamicProxy.ProxyIdInterceptor.Intercept(IInvocation invocation) -[xUnit.net 00:00:00.21] at Castle.DynamicProxy.AbstractInvocation.Proceed() -[xUnit.net 00:00:00.21] at Castle.Proxies.AIFunctionProxy.InvokeCoreAsync(AIFunctionArguments arguments, CancellationToken cancellationToken) -[xUnit.net 00:00:00.21] at Microsoft.Extensions.AI.AIFunction.InvokeAsync(AIFunctionArguments arguments, CancellationToken cancellationToken) -[xUnit.net 00:00:00.21] /Users/alexsohn/code/sentry-dotnet/test/Sentry.Extensions.AI.Tests/SentryInstrumentedFunctionTests.cs(113,0): at Sentry.Extensions.AI.Tests.SentryInstrumentedFunctionTests.InvokeCoreAsync_WithComplexResult_SetsOutputAsString() -[xUnit.net 00:00:00.21] --- End of stack trace from previous location --- -[xUnit.net 00:00:00.21] NSubstitute.Exceptions.AmbiguousArgumentsException : Cannot determine argument specifications to use. Please use specifications for all arguments of the same type. -[xUnit.net 00:00:00.21] Method signature: -[xUnit.net 00:00:00.21] InvokeCoreAsync(AIFunctionArguments, CancellationToken) -[xUnit.net 00:00:00.21] Method arguments (possible arg matchers are indicated with '*'): -[xUnit.net 00:00:00.21] InvokeCoreAsync(AIFunctionArguments, *System.Threading.CancellationToken*) -[xUnit.net 00:00:00.21] All queued specifications: -[xUnit.net 00:00:00.21] any AIFunctionArguments -[xUnit.net 00:00:00.21] any CancellationToken -[xUnit.net 00:00:00.21] Matched argument specifications: -[xUnit.net 00:00:00.21] InvokeCoreAsync(AIFunctionArguments, ???) -[xUnit.net 00:00:00.21] -[xUnit.net 00:00:00.21] Stack Trace: -[xUnit.net 00:00:00.21] at NSubstitute.Core.Arguments.ArgumentSpecificationsFactory.Create(IList`1 argumentSpecs, Object[] arguments, IParameterInfo[] parameterInfos, MethodInfo methodInfo, MatchArgs matchArgs) -[xUnit.net 00:00:00.21] at NSubstitute.Core.CallSpecificationFactory.CreateFrom(ICall call, MatchArgs matchArgs) -[xUnit.net 00:00:00.21] at NSubstitute.Routing.Handlers.RecordCallSpecificationHandler.Handle(ICall call) -[xUnit.net 00:00:00.21] at NSubstitute.Routing.Route.Handle(ICall call) -[xUnit.net 00:00:00.21] at NSubstitute.Core.CallRouter.Route(ICall call) -[xUnit.net 00:00:00.21] at NSubstitute.Proxies.CastleDynamicProxy.CastleForwardingInterceptor.Intercept(IInvocation invocation) -[xUnit.net 00:00:00.21] at Castle.DynamicProxy.AbstractInvocation.Proceed() -[xUnit.net 00:00:00.21] at NSubstitute.Proxies.CastleDynamicProxy.ProxyIdInterceptor.Intercept(IInvocation invocation) -[xUnit.net 00:00:00.21] at Castle.DynamicProxy.AbstractInvocation.Proceed() -[xUnit.net 00:00:00.21] at Castle.Proxies.AIFunctionProxy.InvokeCoreAsync(AIFunctionArguments arguments, CancellationToken cancellationToken) -[xUnit.net 00:00:00.21] at Microsoft.Extensions.AI.AIFunction.InvokeAsync(AIFunctionArguments arguments, CancellationToken cancellationToken) -[xUnit.net 00:00:00.21] /Users/alexsohn/code/sentry-dotnet/test/Sentry.Extensions.AI.Tests/SentryInstrumentedFunctionTests.cs(90,0): at Sentry.Extensions.AI.Tests.SentryInstrumentedFunctionTests.InvokeCoreAsync_WithNullResult_DoesNotSetOutput() -[xUnit.net 00:00:00.21] --- End of stack trace from previous location --- -[xUnit.net 00:00:00.21] NSubstitute.Exceptions.AmbiguousArgumentsException : Cannot determine argument specifications to use. Please use specifications for all arguments of the same type. -[xUnit.net 00:00:00.21] Method signature: -[xUnit.net 00:00:00.21] InvokeCoreAsync(AIFunctionArguments, CancellationToken) -[xUnit.net 00:00:00.21] Method arguments (possible arg matchers are indicated with '*'): -[xUnit.net 00:00:00.21] InvokeCoreAsync(AIFunctionArguments, *System.Threading.CancellationToken*) -[xUnit.net 00:00:00.21] All queued specifications: -[xUnit.net 00:00:00.21] any AIFunctionArguments -[xUnit.net 00:00:00.21] any CancellationToken -[xUnit.net 00:00:00.21] Matched argument specifications: -[xUnit.net 00:00:00.21] InvokeCoreAsync(AIFunctionArguments, ???) -[xUnit.net 00:00:00.21] -[xUnit.net 00:00:00.21] Stack Trace: -[xUnit.net 00:00:00.21] at NSubstitute.Core.Arguments.ArgumentSpecificationsFactory.Create(IList`1 argumentSpecs, Object[] arguments, IParameterInfo[] parameterInfos, MethodInfo methodInfo, MatchArgs matchArgs) -[xUnit.net 00:00:00.21] at NSubstitute.Core.CallSpecificationFactory.CreateFrom(ICall call, MatchArgs matchArgs) -[xUnit.net 00:00:00.21] at NSubstitute.Routing.Handlers.RecordCallSpecificationHandler.Handle(ICall call) -[xUnit.net 00:00:00.21] at NSubstitute.Routing.Route.Handle(ICall call) -[xUnit.net 00:00:00.21] at NSubstitute.Core.CallRouter.Route(ICall call) -[xUnit.net 00:00:00.21] at NSubstitute.Proxies.CastleDynamicProxy.CastleForwardingInterceptor.Intercept(IInvocation invocation) -[xUnit.net 00:00:00.21] at Castle.DynamicProxy.AbstractInvocation.Proceed() -[xUnit.net 00:00:00.21] at NSubstitute.Proxies.CastleDynamicProxy.ProxyIdInterceptor.Intercept(IInvocation invocation) -[xUnit.net 00:00:00.21] at Castle.DynamicProxy.AbstractInvocation.Proceed() -[xUnit.net 00:00:00.21] at Castle.Proxies.AIFunctionProxy.InvokeCoreAsync(AIFunctionArguments arguments, CancellationToken cancellationToken) -[xUnit.net 00:00:00.21] at Microsoft.Extensions.AI.AIFunction.InvokeAsync(AIFunctionArguments arguments, CancellationToken cancellationToken) -[xUnit.net 00:00:00.21] /Users/alexsohn/code/sentry-dotnet/test/Sentry.Extensions.AI.Tests/SentryInstrumentedFunctionTests.cs(160,0): at Sentry.Extensions.AI.Tests.SentryInstrumentedFunctionTests.InvokeCoreAsync_WithCancellation_PropagatesCancellation() -[xUnit.net 00:00:00.21] --- End of stack trace from previous location --- -[xUnit.net 00:00:00.27] NSubstitute.Exceptions.ReceivedCallsException : Expected to receive exactly 1 call matching: -[xUnit.net 00:00:00.27] SetData("gen_ai.usage.input_tokens", 10) -[xUnit.net 00:00:00.27] Actually received no matching calls. -[xUnit.net 00:00:00.27] Received 4 non-matching calls (non-matching arguments indicated with '*' characters): -[xUnit.net 00:00:00.27] SetData("gen_ai.usage.input_tokens", *10*) -[xUnit.net 00:00:00.27] SetData(*"gen_ai.usage.output_tokens"*, *20*) -[xUnit.net 00:00:00.27] SetData(*"gen_ai.usage.total_tokens"*, *30*) -[xUnit.net 00:00:00.27] SetData(*"gen_ai.response.text"*, *"Hello"*) -[xUnit.net 00:00:00.27] -[xUnit.net 00:00:00.27] Stack Trace: -[xUnit.net 00:00:00.27] at NSubstitute.Core.ReceivedCallsExceptionThrower.Throw(ICallSpecification callSpecification, IEnumerable`1 matchingCalls, IEnumerable`1 nonMatchingCalls, Quantity requiredQuantity) -[xUnit.net 00:00:00.27] at NSubstitute.Routing.Handlers.CheckReceivedCallsHandler.Handle(ICall call) -[xUnit.net 00:00:00.27] at NSubstitute.Routing.Route.Handle(ICall call) -[xUnit.net 00:00:00.27] at NSubstitute.Core.CallRouter.Route(ICall call) -[xUnit.net 00:00:00.27] at NSubstitute.Proxies.CastleDynamicProxy.CastleForwardingInterceptor.Intercept(IInvocation invocation) -[xUnit.net 00:00:00.27] at Castle.DynamicProxy.AbstractInvocation.Proceed() -[xUnit.net 00:00:00.27] at NSubstitute.Proxies.CastleDynamicProxy.ProxyIdInterceptor.Intercept(IInvocation invocation) -[xUnit.net 00:00:00.27] at Castle.DynamicProxy.AbstractInvocation.Proceed() -[xUnit.net 00:00:00.27] at Castle.Proxies.ObjectProxy_2.SetData(String key, Object value) -[xUnit.net 00:00:00.27] /Users/alexsohn/code/sentry-dotnet/test/Sentry.Extensions.AI.Tests/SentryAISpanEnricherTests.cs(175,0): at Sentry.Extensions.AI.Tests.SentryAISpanEnricherTests.EnrichWithResponse_SetsUsageTokens_WhenUsageProvided() -[xUnit.net 00:00:00.27] at System.RuntimeMethodHandle.InvokeMethod(Object target, Void** arguments, Signature sig, Boolean isConstructor) -[xUnit.net 00:00:00.27] at System.Reflection.MethodBaseInvoker.InvokeWithNoArgs(Object obj, BindingFlags invokeAttr) -[xUnit.net 00:00:00.27] NSubstitute.Exceptions.ReceivedCallsException : Expected to receive exactly 1 call matching: -[xUnit.net 00:00:00.27] SetData("gen_ai.usage.input_tokens", 10) -[xUnit.net 00:00:00.27] Actually received no matching calls. -[xUnit.net 00:00:00.27] Received 2 non-matching calls (non-matching arguments indicated with '*' characters): -[xUnit.net 00:00:00.27] SetData("gen_ai.usage.input_tokens", *10*) -[xUnit.net 00:00:00.27] SetData(*"gen_ai.response.text"*, *"Hello"*) -[xUnit.net 00:00:00.27] -[xUnit.net 00:00:00.27] Stack Trace: -[xUnit.net 00:00:00.27] at NSubstitute.Core.ReceivedCallsExceptionThrower.Throw(ICallSpecification callSpecification, IEnumerable`1 matchingCalls, IEnumerable`1 nonMatchingCalls, Quantity requiredQuantity) -[xUnit.net 00:00:00.27] at NSubstitute.Routing.Handlers.CheckReceivedCallsHandler.Handle(ICall call) -[xUnit.net 00:00:00.27] at NSubstitute.Routing.Route.Handle(ICall call) -[xUnit.net 00:00:00.27] at NSubstitute.Core.CallRouter.Route(ICall call) -[xUnit.net 00:00:00.27] at NSubstitute.Proxies.CastleDynamicProxy.CastleForwardingInterceptor.Intercept(IInvocation invocation) -[xUnit.net 00:00:00.27] at Castle.DynamicProxy.AbstractInvocation.Proceed() -[xUnit.net 00:00:00.27] at NSubstitute.Proxies.CastleDynamicProxy.ProxyIdInterceptor.Intercept(IInvocation invocation) -[xUnit.net 00:00:00.27] at Castle.DynamicProxy.AbstractInvocation.Proceed() -[xUnit.net 00:00:00.27] at Castle.Proxies.ObjectProxy_2.SetData(String key, Object value) -[xUnit.net 00:00:00.27] /Users/alexsohn/code/sentry-dotnet/test/Sentry.Extensions.AI.Tests/SentryAISpanEnricherTests.cs(203,0): at Sentry.Extensions.AI.Tests.SentryAISpanEnricherTests.EnrichWithResponse_SetsPartialTokens_WhenOnlyInputTokensProvided() -[xUnit.net 00:00:00.27] at System.RuntimeMethodHandle.InvokeMethod(Object target, Void** arguments, Signature sig, Boolean isConstructor) -[xUnit.net 00:00:00.27] at System.Reflection.MethodBaseInvoker.InvokeWithNoArgs(Object obj, BindingFlags invokeAttr) -[xUnit.net 00:00:00.30] Finished: Sentry.Extensions.AI.Tests - - - - - [xUnit.net 00:00:00.19] Sentry.Extensions.AI.Tests.SentryInstrumentedFunctionTests.InvokeCoreAsync_WhenInnerFunctionThrows_FinishesSpanWithException [FAIL] - - - [xUnit.net 00:00:00.20] Sentry.Extensions.AI.Tests.SentryInstrumentedFunctionTests.InvokeCoreAsync_PreservesInnerFunctionName [FAIL] - - - [xUnit.net 00:00:00.20] Sentry.Extensions.AI.Tests.SentryInstrumentedFunctionTests.InvokeCoreAsync_WithParentSpan_CreatesChildSpan [FAIL] - - - [xUnit.net 00:00:00.20] Sentry.Extensions.AI.Tests.SentryInstrumentedFunctionTests.InvokeCoreAsync_WithoutParentSpan_CreatesTransaction [FAIL] - - - [xUnit.net 00:00:00.21] Sentry.Extensions.AI.Tests.SentryInstrumentedFunctionTests.InvokeCoreAsync_WithComplexResult_SetsOutputAsString [FAIL] - - - [xUnit.net 00:00:00.21] Sentry.Extensions.AI.Tests.SentryInstrumentedFunctionTests.InvokeCoreAsync_WithNullResult_DoesNotSetOutput [FAIL] - - - [xUnit.net 00:00:00.21] Sentry.Extensions.AI.Tests.SentryInstrumentedFunctionTests.InvokeCoreAsync_WithCancellation_PropagatesCancellation [FAIL] - - - [xUnit.net 00:00:00.27] Sentry.Extensions.AI.Tests.SentryAISpanEnricherTests.EnrichWithResponse_SetsUsageTokens_WhenUsageProvided [FAIL] - - - [xUnit.net 00:00:00.27] Sentry.Extensions.AI.Tests.SentryAISpanEnricherTests.EnrichWithResponse_SetsPartialTokens_WhenOnlyInputTokensProvided [FAIL] - - - - \ No newline at end of file diff --git a/test/Sentry.Extensions.AI.Tests/TestResults/_CVDTQ2MGH4_2025-10-20_00_35_51[1].trx b/test/Sentry.Extensions.AI.Tests/TestResults/_CVDTQ2MGH4_2025-10-20_00_35_51[1].trx deleted file mode 100644 index b92273a839..0000000000 --- a/test/Sentry.Extensions.AI.Tests/TestResults/_CVDTQ2MGH4_2025-10-20_00_35_51[1].trx +++ /dev/null @@ -1,863 +0,0 @@ - - - - - - - - - - - - - NSubstitute.Exceptions.AmbiguousArgumentsException : Cannot determine argument specifications to use. Please use specifications for all arguments of the same type. -Method signature: - InvokeCoreAsync(AIFunctionArguments, CancellationToken) -Method arguments (possible arg matchers are indicated with '*'): - InvokeCoreAsync(AIFunctionArguments, *System.Threading.CancellationToken*) -All queued specifications: - any AIFunctionArguments - any CancellationToken -Matched argument specifications: - InvokeCoreAsync(AIFunctionArguments, ???) - - at NSubstitute.Core.Arguments.ArgumentSpecificationsFactory.Create(IList`1 argumentSpecs, Object[] arguments, IParameterInfo[] parameterInfos, MethodInfo methodInfo, MatchArgs matchArgs) - at NSubstitute.Core.CallSpecificationFactory.CreateFrom(ICall call, MatchArgs matchArgs) - at NSubstitute.Routing.Handlers.RecordCallSpecificationHandler.Handle(ICall call) - at NSubstitute.Routing.Route.Handle(ICall call) - at NSubstitute.Core.CallRouter.Route(ICall call) - at NSubstitute.Proxies.CastleDynamicProxy.CastleForwardingInterceptor.Intercept(IInvocation invocation) - at Castle.DynamicProxy.AbstractInvocation.Proceed() - at NSubstitute.Proxies.CastleDynamicProxy.ProxyIdInterceptor.Intercept(IInvocation invocation) - at Castle.DynamicProxy.AbstractInvocation.Proceed() - at Castle.Proxies.AIFunctionProxy.InvokeCoreAsync(AIFunctionArguments arguments, CancellationToken cancellationToken) - at Microsoft.Extensions.AI.AIFunction.InvokeAsync(AIFunctionArguments arguments, CancellationToken cancellationToken) - at Sentry.Extensions.AI.Tests.SentryInstrumentedFunctionTests.InvokeCoreAsync_PreservesInnerFunctionName() in /Users/alexsohn/code/sentry-dotnet/test/Sentry.Extensions.AI.Tests/SentryInstrumentedFunctionTests.cs:line 135 ---- End of stack trace from previous location --- - - - - - - - - - NSubstitute.Exceptions.AmbiguousArgumentsException : Cannot determine argument specifications to use. Please use specifications for all arguments of the same type. -Method signature: - InvokeCoreAsync(AIFunctionArguments, CancellationToken) -Method arguments (possible arg matchers are indicated with '*'): - InvokeCoreAsync(AIFunctionArguments, *System.Threading.CancellationToken*) -All queued specifications: - any AIFunctionArguments - any CancellationToken -Matched argument specifications: - InvokeCoreAsync(AIFunctionArguments, ???) - - at NSubstitute.Core.Arguments.ArgumentSpecificationsFactory.Create(IList`1 argumentSpecs, Object[] arguments, IParameterInfo[] parameterInfos, MethodInfo methodInfo, MatchArgs matchArgs) - at NSubstitute.Core.CallSpecificationFactory.CreateFrom(ICall call, MatchArgs matchArgs) - at NSubstitute.Routing.Handlers.RecordCallSpecificationHandler.Handle(ICall call) - at NSubstitute.Routing.Route.Handle(ICall call) - at NSubstitute.Core.CallRouter.Route(ICall call) - at NSubstitute.Proxies.CastleDynamicProxy.CastleForwardingInterceptor.Intercept(IInvocation invocation) - at Castle.DynamicProxy.AbstractInvocation.Proceed() - at NSubstitute.Proxies.CastleDynamicProxy.ProxyIdInterceptor.Intercept(IInvocation invocation) - at Castle.DynamicProxy.AbstractInvocation.Proceed() - at Castle.Proxies.AIFunctionProxy.InvokeCoreAsync(AIFunctionArguments arguments, CancellationToken cancellationToken) - at Microsoft.Extensions.AI.AIFunction.InvokeAsync(AIFunctionArguments arguments, CancellationToken cancellationToken) - at Sentry.Extensions.AI.Tests.SentryInstrumentedFunctionTests.InvokeCoreAsync_WithoutParentSpan_CreatesTransaction() in /Users/alexsohn/code/sentry-dotnet/test/Sentry.Extensions.AI.Tests/SentryInstrumentedFunctionTests.cs:line 45 ---- End of stack trace from previous location --- - - - - - - - - - - - - - - - - NSubstitute.Exceptions.AmbiguousArgumentsException : Cannot determine argument specifications to use. Please use specifications for all arguments of the same type. -Method signature: - InvokeCoreAsync(AIFunctionArguments, CancellationToken) -Method arguments (possible arg matchers are indicated with '*'): - InvokeCoreAsync(AIFunctionArguments, *System.Threading.CancellationToken*) -All queued specifications: - any AIFunctionArguments - any CancellationToken -Matched argument specifications: - InvokeCoreAsync(AIFunctionArguments, ???) - - at NSubstitute.Core.Arguments.ArgumentSpecificationsFactory.Create(IList`1 argumentSpecs, Object[] arguments, IParameterInfo[] parameterInfos, MethodInfo methodInfo, MatchArgs matchArgs) - at NSubstitute.Core.CallSpecificationFactory.CreateFrom(ICall call, MatchArgs matchArgs) - at NSubstitute.Routing.Handlers.RecordCallSpecificationHandler.Handle(ICall call) - at NSubstitute.Routing.Route.Handle(ICall call) - at NSubstitute.Core.CallRouter.Route(ICall call) - at NSubstitute.Proxies.CastleDynamicProxy.CastleForwardingInterceptor.Intercept(IInvocation invocation) - at Castle.DynamicProxy.AbstractInvocation.Proceed() - at NSubstitute.Proxies.CastleDynamicProxy.ProxyIdInterceptor.Intercept(IInvocation invocation) - at Castle.DynamicProxy.AbstractInvocation.Proceed() - at Castle.Proxies.AIFunctionProxy.InvokeCoreAsync(AIFunctionArguments arguments, CancellationToken cancellationToken) - at Microsoft.Extensions.AI.AIFunction.InvokeAsync(AIFunctionArguments arguments, CancellationToken cancellationToken) - at Sentry.Extensions.AI.Tests.SentryInstrumentedFunctionTests.InvokeCoreAsync_WithNullResult_DoesNotSetOutput() in /Users/alexsohn/code/sentry-dotnet/test/Sentry.Extensions.AI.Tests/SentryInstrumentedFunctionTests.cs:line 90 ---- End of stack trace from previous location --- - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - NSubstitute.Exceptions.AmbiguousArgumentsException : Cannot determine argument specifications to use. Please use specifications for all arguments of the same type. -Method signature: - InvokeCoreAsync(AIFunctionArguments, CancellationToken) -Method arguments (possible arg matchers are indicated with '*'): - InvokeCoreAsync(AIFunctionArguments, *System.Threading.CancellationToken*) -All queued specifications: - any AIFunctionArguments - any CancellationToken -Matched argument specifications: - InvokeCoreAsync(AIFunctionArguments, ???) - - at NSubstitute.Core.Arguments.ArgumentSpecificationsFactory.Create(IList`1 argumentSpecs, Object[] arguments, IParameterInfo[] parameterInfos, MethodInfo methodInfo, MatchArgs matchArgs) - at NSubstitute.Core.CallSpecificationFactory.CreateFrom(ICall call, MatchArgs matchArgs) - at NSubstitute.Routing.Handlers.RecordCallSpecificationHandler.Handle(ICall call) - at NSubstitute.Routing.Route.Handle(ICall call) - at NSubstitute.Core.CallRouter.Route(ICall call) - at NSubstitute.Proxies.CastleDynamicProxy.CastleForwardingInterceptor.Intercept(IInvocation invocation) - at Castle.DynamicProxy.AbstractInvocation.Proceed() - at NSubstitute.Proxies.CastleDynamicProxy.ProxyIdInterceptor.Intercept(IInvocation invocation) - at Castle.DynamicProxy.AbstractInvocation.Proceed() - at Castle.Proxies.AIFunctionProxy.InvokeCoreAsync(AIFunctionArguments arguments, CancellationToken cancellationToken) - at Microsoft.Extensions.AI.AIFunction.InvokeAsync(AIFunctionArguments arguments, CancellationToken cancellationToken) - at Sentry.Extensions.AI.Tests.SentryInstrumentedFunctionTests.InvokeCoreAsync_WhenInnerFunctionThrows_FinishesSpanWithException() in /Users/alexsohn/code/sentry-dotnet/test/Sentry.Extensions.AI.Tests/SentryInstrumentedFunctionTests.cs:line 68 ---- End of stack trace from previous location --- - - - - - - - - - - - - NSubstitute.Exceptions.ReceivedCallsException : Expected to receive exactly 1 call matching: - SetData("gen_ai.usage.input_tokens", 10) -Actually received no matching calls. -Received 4 non-matching calls (non-matching arguments indicated with '*' characters): - SetData("gen_ai.usage.input_tokens", *10*) - SetData(*"gen_ai.usage.output_tokens"*, *20*) - SetData(*"gen_ai.usage.total_tokens"*, *30*) - SetData(*"gen_ai.response.text"*, *"Hello"*) - - at NSubstitute.Core.ReceivedCallsExceptionThrower.Throw(ICallSpecification callSpecification, IEnumerable`1 matchingCalls, IEnumerable`1 nonMatchingCalls, Quantity requiredQuantity) - at NSubstitute.Routing.Handlers.CheckReceivedCallsHandler.Handle(ICall call) - at NSubstitute.Routing.Route.Handle(ICall call) - at NSubstitute.Core.CallRouter.Route(ICall call) - at NSubstitute.Proxies.CastleDynamicProxy.CastleForwardingInterceptor.Intercept(IInvocation invocation) - at Castle.DynamicProxy.AbstractInvocation.Proceed() - at NSubstitute.Proxies.CastleDynamicProxy.ProxyIdInterceptor.Intercept(IInvocation invocation) - at Castle.DynamicProxy.AbstractInvocation.Proceed() - at Castle.Proxies.ObjectProxy.SetData(String key, Object value) - at Sentry.Extensions.AI.Tests.SentryAISpanEnricherTests.EnrichWithResponse_SetsUsageTokens_WhenUsageProvided() in /Users/alexsohn/code/sentry-dotnet/test/Sentry.Extensions.AI.Tests/SentryAISpanEnricherTests.cs:line 175 - at System.RuntimeMethodHandle.InvokeMethod(Object target, Void** arguments, Signature sig, Boolean isConstructor) - at System.Reflection.MethodBaseInvoker.InvokeWithNoArgs(Object obj, BindingFlags invokeAttr) - - - - - - - - NSubstitute.Exceptions.AmbiguousArgumentsException : Cannot determine argument specifications to use. Please use specifications for all arguments of the same type. -Method signature: - InvokeCoreAsync(AIFunctionArguments, CancellationToken) -Method arguments (possible arg matchers are indicated with '*'): - InvokeCoreAsync(AIFunctionArguments, *System.Threading.CancellationToken*) -All queued specifications: - any AIFunctionArguments - any CancellationToken -Matched argument specifications: - InvokeCoreAsync(AIFunctionArguments, ???) - - at NSubstitute.Core.Arguments.ArgumentSpecificationsFactory.Create(IList`1 argumentSpecs, Object[] arguments, IParameterInfo[] parameterInfos, MethodInfo methodInfo, MatchArgs matchArgs) - at NSubstitute.Core.CallSpecificationFactory.CreateFrom(ICall call, MatchArgs matchArgs) - at NSubstitute.Routing.Handlers.RecordCallSpecificationHandler.Handle(ICall call) - at NSubstitute.Routing.Route.Handle(ICall call) - at NSubstitute.Core.CallRouter.Route(ICall call) - at NSubstitute.Proxies.CastleDynamicProxy.CastleForwardingInterceptor.Intercept(IInvocation invocation) - at Castle.DynamicProxy.AbstractInvocation.Proceed() - at NSubstitute.Proxies.CastleDynamicProxy.ProxyIdInterceptor.Intercept(IInvocation invocation) - at Castle.DynamicProxy.AbstractInvocation.Proceed() - at Castle.Proxies.AIFunctionProxy.InvokeCoreAsync(AIFunctionArguments arguments, CancellationToken cancellationToken) - at Microsoft.Extensions.AI.AIFunction.InvokeAsync(AIFunctionArguments arguments, CancellationToken cancellationToken) - at Sentry.Extensions.AI.Tests.SentryInstrumentedFunctionTests.InvokeCoreAsync_WithParentSpan_CreatesChildSpan() in /Users/alexsohn/code/sentry-dotnet/test/Sentry.Extensions.AI.Tests/SentryInstrumentedFunctionTests.cs:line 19 ---- End of stack trace from previous location --- - - - - - - - NSubstitute.Exceptions.AmbiguousArgumentsException : Cannot determine argument specifications to use. Please use specifications for all arguments of the same type. -Method signature: - InvokeCoreAsync(AIFunctionArguments, CancellationToken) -Method arguments (possible arg matchers are indicated with '*'): - InvokeCoreAsync(AIFunctionArguments, *System.Threading.CancellationToken*) -All queued specifications: - any AIFunctionArguments - any CancellationToken -Matched argument specifications: - InvokeCoreAsync(AIFunctionArguments, ???) - - at NSubstitute.Core.Arguments.ArgumentSpecificationsFactory.Create(IList`1 argumentSpecs, Object[] arguments, IParameterInfo[] parameterInfos, MethodInfo methodInfo, MatchArgs matchArgs) - at NSubstitute.Core.CallSpecificationFactory.CreateFrom(ICall call, MatchArgs matchArgs) - at NSubstitute.Routing.Handlers.RecordCallSpecificationHandler.Handle(ICall call) - at NSubstitute.Routing.Route.Handle(ICall call) - at NSubstitute.Core.CallRouter.Route(ICall call) - at NSubstitute.Proxies.CastleDynamicProxy.CastleForwardingInterceptor.Intercept(IInvocation invocation) - at Castle.DynamicProxy.AbstractInvocation.Proceed() - at NSubstitute.Proxies.CastleDynamicProxy.ProxyIdInterceptor.Intercept(IInvocation invocation) - at Castle.DynamicProxy.AbstractInvocation.Proceed() - at Castle.Proxies.AIFunctionProxy.InvokeCoreAsync(AIFunctionArguments arguments, CancellationToken cancellationToken) - at Microsoft.Extensions.AI.AIFunction.InvokeAsync(AIFunctionArguments arguments, CancellationToken cancellationToken) - at Sentry.Extensions.AI.Tests.SentryInstrumentedFunctionTests.InvokeCoreAsync_WithComplexResult_SetsOutputAsString() in /Users/alexsohn/code/sentry-dotnet/test/Sentry.Extensions.AI.Tests/SentryInstrumentedFunctionTests.cs:line 113 ---- End of stack trace from previous location --- - - - - - - - - NSubstitute.Exceptions.AmbiguousArgumentsException : Cannot determine argument specifications to use. Please use specifications for all arguments of the same type. -Method signature: - InvokeCoreAsync(AIFunctionArguments, CancellationToken) -Method arguments (possible arg matchers are indicated with '*'): - InvokeCoreAsync(AIFunctionArguments, *System.Threading.CancellationToken*) -All queued specifications: - any AIFunctionArguments - any CancellationToken -Matched argument specifications: - InvokeCoreAsync(AIFunctionArguments, ???) - - at NSubstitute.Core.Arguments.ArgumentSpecificationsFactory.Create(IList`1 argumentSpecs, Object[] arguments, IParameterInfo[] parameterInfos, MethodInfo methodInfo, MatchArgs matchArgs) - at NSubstitute.Core.CallSpecificationFactory.CreateFrom(ICall call, MatchArgs matchArgs) - at NSubstitute.Routing.Handlers.RecordCallSpecificationHandler.Handle(ICall call) - at NSubstitute.Routing.Route.Handle(ICall call) - at NSubstitute.Core.CallRouter.Route(ICall call) - at NSubstitute.Proxies.CastleDynamicProxy.CastleForwardingInterceptor.Intercept(IInvocation invocation) - at Castle.DynamicProxy.AbstractInvocation.Proceed() - at NSubstitute.Proxies.CastleDynamicProxy.ProxyIdInterceptor.Intercept(IInvocation invocation) - at Castle.DynamicProxy.AbstractInvocation.Proceed() - at Castle.Proxies.AIFunctionProxy.InvokeCoreAsync(AIFunctionArguments arguments, CancellationToken cancellationToken) - at Microsoft.Extensions.AI.AIFunction.InvokeAsync(AIFunctionArguments arguments, CancellationToken cancellationToken) - at Sentry.Extensions.AI.Tests.SentryInstrumentedFunctionTests.InvokeCoreAsync_WithCancellation_PropagatesCancellation() in /Users/alexsohn/code/sentry-dotnet/test/Sentry.Extensions.AI.Tests/SentryInstrumentedFunctionTests.cs:line 160 ---- End of stack trace from previous location --- - - - - - - - - - - NSubstitute.Exceptions.ReceivedCallsException : Expected to receive exactly 1 call matching: - SetData("gen_ai.usage.input_tokens", 10) -Actually received no matching calls. -Received 2 non-matching calls (non-matching arguments indicated with '*' characters): - SetData("gen_ai.usage.input_tokens", *10*) - SetData(*"gen_ai.response.text"*, *"Hello"*) - - at NSubstitute.Core.ReceivedCallsExceptionThrower.Throw(ICallSpecification callSpecification, IEnumerable`1 matchingCalls, IEnumerable`1 nonMatchingCalls, Quantity requiredQuantity) - at NSubstitute.Routing.Handlers.CheckReceivedCallsHandler.Handle(ICall call) - at NSubstitute.Routing.Route.Handle(ICall call) - at NSubstitute.Core.CallRouter.Route(ICall call) - at NSubstitute.Proxies.CastleDynamicProxy.CastleForwardingInterceptor.Intercept(IInvocation invocation) - at Castle.DynamicProxy.AbstractInvocation.Proceed() - at NSubstitute.Proxies.CastleDynamicProxy.ProxyIdInterceptor.Intercept(IInvocation invocation) - at Castle.DynamicProxy.AbstractInvocation.Proceed() - at Castle.Proxies.ObjectProxy.SetData(String key, Object value) - at Sentry.Extensions.AI.Tests.SentryAISpanEnricherTests.EnrichWithResponse_SetsPartialTokens_WhenOnlyInputTokensProvided() in /Users/alexsohn/code/sentry-dotnet/test/Sentry.Extensions.AI.Tests/SentryAISpanEnricherTests.cs:line 203 - at System.RuntimeMethodHandle.InvokeMethod(Object target, Void** arguments, Signature sig, Boolean isConstructor) - at System.Reflection.MethodBaseInvoker.InvokeWithNoArgs(Object obj, BindingFlags invokeAttr) - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - [xUnit.net 00:00:00.00] xUnit.net VSTest Adapter v3.1.1+bf6400fd51 (64-bit .NET 8.0.19) -[xUnit.net 00:00:00.06] Discovering: Sentry.Extensions.AI.Tests -[xUnit.net 00:00:00.08] Discovered: Sentry.Extensions.AI.Tests -[xUnit.net 00:00:00.09] Starting: Sentry.Extensions.AI.Tests -[xUnit.net 00:00:00.21] NSubstitute.Exceptions.ReceivedCallsException : Expected to receive exactly 1 call matching: -[xUnit.net 00:00:00.21] SetData("gen_ai.usage.input_tokens", 10) -[xUnit.net 00:00:00.21] Actually received no matching calls. -[xUnit.net 00:00:00.21] Received 4 non-matching calls (non-matching arguments indicated with '*' characters): -[xUnit.net 00:00:00.21] SetData("gen_ai.usage.input_tokens", *10*) -[xUnit.net 00:00:00.21] SetData(*"gen_ai.usage.output_tokens"*, *20*) -[xUnit.net 00:00:00.21] SetData(*"gen_ai.usage.total_tokens"*, *30*) -[xUnit.net 00:00:00.21] SetData(*"gen_ai.response.text"*, *"Hello"*) -[xUnit.net 00:00:00.21] -[xUnit.net 00:00:00.21] Stack Trace: -[xUnit.net 00:00:00.21] at NSubstitute.Core.ReceivedCallsExceptionThrower.Throw(ICallSpecification callSpecification, IEnumerable`1 matchingCalls, IEnumerable`1 nonMatchingCalls, Quantity requiredQuantity) -[xUnit.net 00:00:00.21] at NSubstitute.Routing.Handlers.CheckReceivedCallsHandler.Handle(ICall call) -[xUnit.net 00:00:00.21] at NSubstitute.Routing.Route.Handle(ICall call) -[xUnit.net 00:00:00.21] at NSubstitute.Core.CallRouter.Route(ICall call) -[xUnit.net 00:00:00.21] at NSubstitute.Proxies.CastleDynamicProxy.CastleForwardingInterceptor.Intercept(IInvocation invocation) -[xUnit.net 00:00:00.21] at Castle.DynamicProxy.AbstractInvocation.Proceed() -[xUnit.net 00:00:00.21] at NSubstitute.Proxies.CastleDynamicProxy.ProxyIdInterceptor.Intercept(IInvocation invocation) -[xUnit.net 00:00:00.21] at Castle.DynamicProxy.AbstractInvocation.Proceed() -[xUnit.net 00:00:00.21] at Castle.Proxies.ObjectProxy.SetData(String key, Object value) -[xUnit.net 00:00:00.21] /Users/alexsohn/code/sentry-dotnet/test/Sentry.Extensions.AI.Tests/SentryAISpanEnricherTests.cs(175,0): at Sentry.Extensions.AI.Tests.SentryAISpanEnricherTests.EnrichWithResponse_SetsUsageTokens_WhenUsageProvided() -[xUnit.net 00:00:00.21] at System.RuntimeMethodHandle.InvokeMethod(Object target, Void** arguments, Signature sig, Boolean isConstructor) -[xUnit.net 00:00:00.21] at System.Reflection.MethodBaseInvoker.InvokeWithNoArgs(Object obj, BindingFlags invokeAttr) -[xUnit.net 00:00:00.22] NSubstitute.Exceptions.ReceivedCallsException : Expected to receive exactly 1 call matching: -[xUnit.net 00:00:00.22] SetData("gen_ai.usage.input_tokens", 10) -[xUnit.net 00:00:00.22] Actually received no matching calls. -[xUnit.net 00:00:00.22] Received 2 non-matching calls (non-matching arguments indicated with '*' characters): -[xUnit.net 00:00:00.22] SetData("gen_ai.usage.input_tokens", *10*) -[xUnit.net 00:00:00.22] SetData(*"gen_ai.response.text"*, *"Hello"*) -[xUnit.net 00:00:00.22] -[xUnit.net 00:00:00.22] Stack Trace: -[xUnit.net 00:00:00.22] at NSubstitute.Core.ReceivedCallsExceptionThrower.Throw(ICallSpecification callSpecification, IEnumerable`1 matchingCalls, IEnumerable`1 nonMatchingCalls, Quantity requiredQuantity) -[xUnit.net 00:00:00.22] at NSubstitute.Routing.Handlers.CheckReceivedCallsHandler.Handle(ICall call) -[xUnit.net 00:00:00.22] at NSubstitute.Routing.Route.Handle(ICall call) -[xUnit.net 00:00:00.22] at NSubstitute.Core.CallRouter.Route(ICall call) -[xUnit.net 00:00:00.22] at NSubstitute.Proxies.CastleDynamicProxy.CastleForwardingInterceptor.Intercept(IInvocation invocation) -[xUnit.net 00:00:00.22] at Castle.DynamicProxy.AbstractInvocation.Proceed() -[xUnit.net 00:00:00.22] at NSubstitute.Proxies.CastleDynamicProxy.ProxyIdInterceptor.Intercept(IInvocation invocation) -[xUnit.net 00:00:00.22] at Castle.DynamicProxy.AbstractInvocation.Proceed() -[xUnit.net 00:00:00.22] at Castle.Proxies.ObjectProxy.SetData(String key, Object value) -[xUnit.net 00:00:00.22] /Users/alexsohn/code/sentry-dotnet/test/Sentry.Extensions.AI.Tests/SentryAISpanEnricherTests.cs(203,0): at Sentry.Extensions.AI.Tests.SentryAISpanEnricherTests.EnrichWithResponse_SetsPartialTokens_WhenOnlyInputTokensProvided() -[xUnit.net 00:00:00.22] at System.RuntimeMethodHandle.InvokeMethod(Object target, Void** arguments, Signature sig, Boolean isConstructor) -[xUnit.net 00:00:00.22] at System.Reflection.MethodBaseInvoker.InvokeWithNoArgs(Object obj, BindingFlags invokeAttr) -[xUnit.net 00:00:00.29] NSubstitute.Exceptions.AmbiguousArgumentsException : Cannot determine argument specifications to use. Please use specifications for all arguments of the same type. -[xUnit.net 00:00:00.29] Method signature: -[xUnit.net 00:00:00.29] InvokeCoreAsync(AIFunctionArguments, CancellationToken) -[xUnit.net 00:00:00.29] Method arguments (possible arg matchers are indicated with '*'): -[xUnit.net 00:00:00.29] InvokeCoreAsync(AIFunctionArguments, *System.Threading.CancellationToken*) -[xUnit.net 00:00:00.29] All queued specifications: -[xUnit.net 00:00:00.29] any AIFunctionArguments -[xUnit.net 00:00:00.29] any CancellationToken -[xUnit.net 00:00:00.29] Matched argument specifications: -[xUnit.net 00:00:00.29] InvokeCoreAsync(AIFunctionArguments, ???) -[xUnit.net 00:00:00.29] -[xUnit.net 00:00:00.29] Stack Trace: -[xUnit.net 00:00:00.29] at NSubstitute.Core.Arguments.ArgumentSpecificationsFactory.Create(IList`1 argumentSpecs, Object[] arguments, IParameterInfo[] parameterInfos, MethodInfo methodInfo, MatchArgs matchArgs) -[xUnit.net 00:00:00.29] at NSubstitute.Core.CallSpecificationFactory.CreateFrom(ICall call, MatchArgs matchArgs) -[xUnit.net 00:00:00.29] at NSubstitute.Routing.Handlers.RecordCallSpecificationHandler.Handle(ICall call) -[xUnit.net 00:00:00.29] at NSubstitute.Routing.Route.Handle(ICall call) -[xUnit.net 00:00:00.29] at NSubstitute.Core.CallRouter.Route(ICall call) -[xUnit.net 00:00:00.29] at NSubstitute.Proxies.CastleDynamicProxy.CastleForwardingInterceptor.Intercept(IInvocation invocation) -[xUnit.net 00:00:00.29] at Castle.DynamicProxy.AbstractInvocation.Proceed() -[xUnit.net 00:00:00.29] at NSubstitute.Proxies.CastleDynamicProxy.ProxyIdInterceptor.Intercept(IInvocation invocation) -[xUnit.net 00:00:00.29] at Castle.DynamicProxy.AbstractInvocation.Proceed() -[xUnit.net 00:00:00.29] at Castle.Proxies.AIFunctionProxy.InvokeCoreAsync(AIFunctionArguments arguments, CancellationToken cancellationToken) -[xUnit.net 00:00:00.29] at Microsoft.Extensions.AI.AIFunction.InvokeAsync(AIFunctionArguments arguments, CancellationToken cancellationToken) -[xUnit.net 00:00:00.29] /Users/alexsohn/code/sentry-dotnet/test/Sentry.Extensions.AI.Tests/SentryInstrumentedFunctionTests.cs(68,0): at Sentry.Extensions.AI.Tests.SentryInstrumentedFunctionTests.InvokeCoreAsync_WhenInnerFunctionThrows_FinishesSpanWithException() -[xUnit.net 00:00:00.29] --- End of stack trace from previous location --- -[xUnit.net 00:00:00.29] NSubstitute.Exceptions.AmbiguousArgumentsException : Cannot determine argument specifications to use. Please use specifications for all arguments of the same type. -[xUnit.net 00:00:00.29] Method signature: -[xUnit.net 00:00:00.29] InvokeCoreAsync(AIFunctionArguments, CancellationToken) -[xUnit.net 00:00:00.29] Method arguments (possible arg matchers are indicated with '*'): -[xUnit.net 00:00:00.29] InvokeCoreAsync(AIFunctionArguments, *System.Threading.CancellationToken*) -[xUnit.net 00:00:00.29] All queued specifications: -[xUnit.net 00:00:00.29] any AIFunctionArguments -[xUnit.net 00:00:00.29] any CancellationToken -[xUnit.net 00:00:00.29] Matched argument specifications: -[xUnit.net 00:00:00.29] InvokeCoreAsync(AIFunctionArguments, ???) -[xUnit.net 00:00:00.29] -[xUnit.net 00:00:00.29] Stack Trace: -[xUnit.net 00:00:00.29] at NSubstitute.Core.Arguments.ArgumentSpecificationsFactory.Create(IList`1 argumentSpecs, Object[] arguments, IParameterInfo[] parameterInfos, MethodInfo methodInfo, MatchArgs matchArgs) -[xUnit.net 00:00:00.29] at NSubstitute.Core.CallSpecificationFactory.CreateFrom(ICall call, MatchArgs matchArgs) -[xUnit.net 00:00:00.29] at NSubstitute.Routing.Handlers.RecordCallSpecificationHandler.Handle(ICall call) -[xUnit.net 00:00:00.29] at NSubstitute.Routing.Route.Handle(ICall call) -[xUnit.net 00:00:00.29] at NSubstitute.Core.CallRouter.Route(ICall call) -[xUnit.net 00:00:00.29] at NSubstitute.Proxies.CastleDynamicProxy.CastleForwardingInterceptor.Intercept(IInvocation invocation) -[xUnit.net 00:00:00.29] at Castle.DynamicProxy.AbstractInvocation.Proceed() -[xUnit.net 00:00:00.29] at NSubstitute.Proxies.CastleDynamicProxy.ProxyIdInterceptor.Intercept(IInvocation invocation) -[xUnit.net 00:00:00.29] at Castle.DynamicProxy.AbstractInvocation.Proceed() -[xUnit.net 00:00:00.29] at Castle.Proxies.AIFunctionProxy.InvokeCoreAsync(AIFunctionArguments arguments, CancellationToken cancellationToken) -[xUnit.net 00:00:00.29] at Microsoft.Extensions.AI.AIFunction.InvokeAsync(AIFunctionArguments arguments, CancellationToken cancellationToken) -[xUnit.net 00:00:00.29] /Users/alexsohn/code/sentry-dotnet/test/Sentry.Extensions.AI.Tests/SentryInstrumentedFunctionTests.cs(135,0): at Sentry.Extensions.AI.Tests.SentryInstrumentedFunctionTests.InvokeCoreAsync_PreservesInnerFunctionName() -[xUnit.net 00:00:00.29] --- End of stack trace from previous location --- -[xUnit.net 00:00:00.30] NSubstitute.Exceptions.AmbiguousArgumentsException : Cannot determine argument specifications to use. Please use specifications for all arguments of the same type. -[xUnit.net 00:00:00.30] Method signature: -[xUnit.net 00:00:00.30] InvokeCoreAsync(AIFunctionArguments, CancellationToken) -[xUnit.net 00:00:00.30] Method arguments (possible arg matchers are indicated with '*'): -[xUnit.net 00:00:00.30] InvokeCoreAsync(AIFunctionArguments, *System.Threading.CancellationToken*) -[xUnit.net 00:00:00.30] All queued specifications: -[xUnit.net 00:00:00.30] any AIFunctionArguments -[xUnit.net 00:00:00.30] any CancellationToken -[xUnit.net 00:00:00.30] Matched argument specifications: -[xUnit.net 00:00:00.30] InvokeCoreAsync(AIFunctionArguments, ???) -[xUnit.net 00:00:00.30] -[xUnit.net 00:00:00.30] Stack Trace: -[xUnit.net 00:00:00.30] at NSubstitute.Core.Arguments.ArgumentSpecificationsFactory.Create(IList`1 argumentSpecs, Object[] arguments, IParameterInfo[] parameterInfos, MethodInfo methodInfo, MatchArgs matchArgs) -[xUnit.net 00:00:00.30] at NSubstitute.Core.CallSpecificationFactory.CreateFrom(ICall call, MatchArgs matchArgs) -[xUnit.net 00:00:00.30] at NSubstitute.Routing.Handlers.RecordCallSpecificationHandler.Handle(ICall call) -[xUnit.net 00:00:00.30] at NSubstitute.Routing.Route.Handle(ICall call) -[xUnit.net 00:00:00.30] at NSubstitute.Core.CallRouter.Route(ICall call) -[xUnit.net 00:00:00.30] at NSubstitute.Proxies.CastleDynamicProxy.CastleForwardingInterceptor.Intercept(IInvocation invocation) -[xUnit.net 00:00:00.30] at Castle.DynamicProxy.AbstractInvocation.Proceed() -[xUnit.net 00:00:00.30] at NSubstitute.Proxies.CastleDynamicProxy.ProxyIdInterceptor.Intercept(IInvocation invocation) -[xUnit.net 00:00:00.30] at Castle.DynamicProxy.AbstractInvocation.Proceed() -[xUnit.net 00:00:00.30] at Castle.Proxies.AIFunctionProxy.InvokeCoreAsync(AIFunctionArguments arguments, CancellationToken cancellationToken) -[xUnit.net 00:00:00.30] at Microsoft.Extensions.AI.AIFunction.InvokeAsync(AIFunctionArguments arguments, CancellationToken cancellationToken) -[xUnit.net 00:00:00.30] /Users/alexsohn/code/sentry-dotnet/test/Sentry.Extensions.AI.Tests/SentryInstrumentedFunctionTests.cs(19,0): at Sentry.Extensions.AI.Tests.SentryInstrumentedFunctionTests.InvokeCoreAsync_WithParentSpan_CreatesChildSpan() -[xUnit.net 00:00:00.30] --- End of stack trace from previous location --- -[xUnit.net 00:00:00.30] NSubstitute.Exceptions.AmbiguousArgumentsException : Cannot determine argument specifications to use. Please use specifications for all arguments of the same type. -[xUnit.net 00:00:00.30] Method signature: -[xUnit.net 00:00:00.30] InvokeCoreAsync(AIFunctionArguments, CancellationToken) -[xUnit.net 00:00:00.30] Method arguments (possible arg matchers are indicated with '*'): -[xUnit.net 00:00:00.30] InvokeCoreAsync(AIFunctionArguments, *System.Threading.CancellationToken*) -[xUnit.net 00:00:00.30] All queued specifications: -[xUnit.net 00:00:00.30] any AIFunctionArguments -[xUnit.net 00:00:00.30] any CancellationToken -[xUnit.net 00:00:00.30] Matched argument specifications: -[xUnit.net 00:00:00.30] InvokeCoreAsync(AIFunctionArguments, ???) -[xUnit.net 00:00:00.30] -[xUnit.net 00:00:00.30] Stack Trace: -[xUnit.net 00:00:00.30] at NSubstitute.Core.Arguments.ArgumentSpecificationsFactory.Create(IList`1 argumentSpecs, Object[] arguments, IParameterInfo[] parameterInfos, MethodInfo methodInfo, MatchArgs matchArgs) -[xUnit.net 00:00:00.30] at NSubstitute.Core.CallSpecificationFactory.CreateFrom(ICall call, MatchArgs matchArgs) -[xUnit.net 00:00:00.30] at NSubstitute.Routing.Handlers.RecordCallSpecificationHandler.Handle(ICall call) -[xUnit.net 00:00:00.30] at NSubstitute.Routing.Route.Handle(ICall call) -[xUnit.net 00:00:00.30] at NSubstitute.Core.CallRouter.Route(ICall call) -[xUnit.net 00:00:00.30] at NSubstitute.Proxies.CastleDynamicProxy.CastleForwardingInterceptor.Intercept(IInvocation invocation) -[xUnit.net 00:00:00.30] at Castle.DynamicProxy.AbstractInvocation.Proceed() -[xUnit.net 00:00:00.30] at NSubstitute.Proxies.CastleDynamicProxy.ProxyIdInterceptor.Intercept(IInvocation invocation) -[xUnit.net 00:00:00.30] at Castle.DynamicProxy.AbstractInvocation.Proceed() -[xUnit.net 00:00:00.30] at Castle.Proxies.AIFunctionProxy.InvokeCoreAsync(AIFunctionArguments arguments, CancellationToken cancellationToken) -[xUnit.net 00:00:00.30] at Microsoft.Extensions.AI.AIFunction.InvokeAsync(AIFunctionArguments arguments, CancellationToken cancellationToken) -[xUnit.net 00:00:00.30] /Users/alexsohn/code/sentry-dotnet/test/Sentry.Extensions.AI.Tests/SentryInstrumentedFunctionTests.cs(45,0): at Sentry.Extensions.AI.Tests.SentryInstrumentedFunctionTests.InvokeCoreAsync_WithoutParentSpan_CreatesTransaction() -[xUnit.net 00:00:00.30] --- End of stack trace from previous location --- -[xUnit.net 00:00:00.30] NSubstitute.Exceptions.AmbiguousArgumentsException : Cannot determine argument specifications to use. Please use specifications for all arguments of the same type. -[xUnit.net 00:00:00.30] Method signature: -[xUnit.net 00:00:00.30] InvokeCoreAsync(AIFunctionArguments, CancellationToken) -[xUnit.net 00:00:00.30] Method arguments (possible arg matchers are indicated with '*'): -[xUnit.net 00:00:00.30] InvokeCoreAsync(AIFunctionArguments, *System.Threading.CancellationToken*) -[xUnit.net 00:00:00.30] All queued specifications: -[xUnit.net 00:00:00.30] any AIFunctionArguments -[xUnit.net 00:00:00.30] any CancellationToken -[xUnit.net 00:00:00.30] Matched argument specifications: -[xUnit.net 00:00:00.30] InvokeCoreAsync(AIFunctionArguments, ???) -[xUnit.net 00:00:00.30] -[xUnit.net 00:00:00.30] Stack Trace: -[xUnit.net 00:00:00.30] at NSubstitute.Core.Arguments.ArgumentSpecificationsFactory.Create(IList`1 argumentSpecs, Object[] arguments, IParameterInfo[] parameterInfos, MethodInfo methodInfo, MatchArgs matchArgs) -[xUnit.net 00:00:00.30] at NSubstitute.Core.CallSpecificationFactory.CreateFrom(ICall call, MatchArgs matchArgs) -[xUnit.net 00:00:00.30] at NSubstitute.Routing.Handlers.RecordCallSpecificationHandler.Handle(ICall call) -[xUnit.net 00:00:00.30] at NSubstitute.Routing.Route.Handle(ICall call) -[xUnit.net 00:00:00.30] at NSubstitute.Core.CallRouter.Route(ICall call) -[xUnit.net 00:00:00.30] at NSubstitute.Proxies.CastleDynamicProxy.CastleForwardingInterceptor.Intercept(IInvocation invocation) -[xUnit.net 00:00:00.30] at Castle.DynamicProxy.AbstractInvocation.Proceed() -[xUnit.net 00:00:00.30] at NSubstitute.Proxies.CastleDynamicProxy.ProxyIdInterceptor.Intercept(IInvocation invocation) -[xUnit.net 00:00:00.30] at Castle.DynamicProxy.AbstractInvocation.Proceed() -[xUnit.net 00:00:00.30] at Castle.Proxies.AIFunctionProxy.InvokeCoreAsync(AIFunctionArguments arguments, CancellationToken cancellationToken) -[xUnit.net 00:00:00.30] at Microsoft.Extensions.AI.AIFunction.InvokeAsync(AIFunctionArguments arguments, CancellationToken cancellationToken) -[xUnit.net 00:00:00.30] /Users/alexsohn/code/sentry-dotnet/test/Sentry.Extensions.AI.Tests/SentryInstrumentedFunctionTests.cs(113,0): at Sentry.Extensions.AI.Tests.SentryInstrumentedFunctionTests.InvokeCoreAsync_WithComplexResult_SetsOutputAsString() -[xUnit.net 00:00:00.30] --- End of stack trace from previous location --- -[xUnit.net 00:00:00.30] NSubstitute.Exceptions.AmbiguousArgumentsException : Cannot determine argument specifications to use. Please use specifications for all arguments of the same type. -[xUnit.net 00:00:00.30] Method signature: -[xUnit.net 00:00:00.30] InvokeCoreAsync(AIFunctionArguments, CancellationToken) -[xUnit.net 00:00:00.30] Method arguments (possible arg matchers are indicated with '*'): -[xUnit.net 00:00:00.30] InvokeCoreAsync(AIFunctionArguments, *System.Threading.CancellationToken*) -[xUnit.net 00:00:00.30] All queued specifications: -[xUnit.net 00:00:00.30] any AIFunctionArguments -[xUnit.net 00:00:00.30] any CancellationToken -[xUnit.net 00:00:00.30] Matched argument specifications: -[xUnit.net 00:00:00.30] InvokeCoreAsync(AIFunctionArguments, ???) -[xUnit.net 00:00:00.30] -[xUnit.net 00:00:00.30] Stack Trace: -[xUnit.net 00:00:00.30] at NSubstitute.Core.Arguments.ArgumentSpecificationsFactory.Create(IList`1 argumentSpecs, Object[] arguments, IParameterInfo[] parameterInfos, MethodInfo methodInfo, MatchArgs matchArgs) -[xUnit.net 00:00:00.30] at NSubstitute.Core.CallSpecificationFactory.CreateFrom(ICall call, MatchArgs matchArgs) -[xUnit.net 00:00:00.30] at NSubstitute.Routing.Handlers.RecordCallSpecificationHandler.Handle(ICall call) -[xUnit.net 00:00:00.30] at NSubstitute.Routing.Route.Handle(ICall call) -[xUnit.net 00:00:00.30] at NSubstitute.Core.CallRouter.Route(ICall call) -[xUnit.net 00:00:00.30] at NSubstitute.Proxies.CastleDynamicProxy.CastleForwardingInterceptor.Intercept(IInvocation invocation) -[xUnit.net 00:00:00.30] at Castle.DynamicProxy.AbstractInvocation.Proceed() -[xUnit.net 00:00:00.30] at NSubstitute.Proxies.CastleDynamicProxy.ProxyIdInterceptor.Intercept(IInvocation invocation) -[xUnit.net 00:00:00.30] at Castle.DynamicProxy.AbstractInvocation.Proceed() -[xUnit.net 00:00:00.30] at Castle.Proxies.AIFunctionProxy.InvokeCoreAsync(AIFunctionArguments arguments, CancellationToken cancellationToken) -[xUnit.net 00:00:00.30] at Microsoft.Extensions.AI.AIFunction.InvokeAsync(AIFunctionArguments arguments, CancellationToken cancellationToken) -[xUnit.net 00:00:00.30] /Users/alexsohn/code/sentry-dotnet/test/Sentry.Extensions.AI.Tests/SentryInstrumentedFunctionTests.cs(90,0): at Sentry.Extensions.AI.Tests.SentryInstrumentedFunctionTests.InvokeCoreAsync_WithNullResult_DoesNotSetOutput() -[xUnit.net 00:00:00.30] --- End of stack trace from previous location --- -[xUnit.net 00:00:00.30] NSubstitute.Exceptions.AmbiguousArgumentsException : Cannot determine argument specifications to use. Please use specifications for all arguments of the same type. -[xUnit.net 00:00:00.30] Method signature: -[xUnit.net 00:00:00.30] InvokeCoreAsync(AIFunctionArguments, CancellationToken) -[xUnit.net 00:00:00.30] Method arguments (possible arg matchers are indicated with '*'): -[xUnit.net 00:00:00.30] InvokeCoreAsync(AIFunctionArguments, *System.Threading.CancellationToken*) -[xUnit.net 00:00:00.30] All queued specifications: -[xUnit.net 00:00:00.30] any AIFunctionArguments -[xUnit.net 00:00:00.30] any CancellationToken -[xUnit.net 00:00:00.30] Matched argument specifications: -[xUnit.net 00:00:00.30] InvokeCoreAsync(AIFunctionArguments, ???) -[xUnit.net 00:00:00.30] -[xUnit.net 00:00:00.30] Stack Trace: -[xUnit.net 00:00:00.30] at NSubstitute.Core.Arguments.ArgumentSpecificationsFactory.Create(IList`1 argumentSpecs, Object[] arguments, IParameterInfo[] parameterInfos, MethodInfo methodInfo, MatchArgs matchArgs) -[xUnit.net 00:00:00.30] at NSubstitute.Core.CallSpecificationFactory.CreateFrom(ICall call, MatchArgs matchArgs) -[xUnit.net 00:00:00.30] at NSubstitute.Routing.Handlers.RecordCallSpecificationHandler.Handle(ICall call) -[xUnit.net 00:00:00.30] at NSubstitute.Routing.Route.Handle(ICall call) -[xUnit.net 00:00:00.30] at NSubstitute.Core.CallRouter.Route(ICall call) -[xUnit.net 00:00:00.30] at NSubstitute.Proxies.CastleDynamicProxy.CastleForwardingInterceptor.Intercept(IInvocation invocation) -[xUnit.net 00:00:00.30] at Castle.DynamicProxy.AbstractInvocation.Proceed() -[xUnit.net 00:00:00.30] at NSubstitute.Proxies.CastleDynamicProxy.ProxyIdInterceptor.Intercept(IInvocation invocation) -[xUnit.net 00:00:00.30] at Castle.DynamicProxy.AbstractInvocation.Proceed() -[xUnit.net 00:00:00.30] at Castle.Proxies.AIFunctionProxy.InvokeCoreAsync(AIFunctionArguments arguments, CancellationToken cancellationToken) -[xUnit.net 00:00:00.30] at Microsoft.Extensions.AI.AIFunction.InvokeAsync(AIFunctionArguments arguments, CancellationToken cancellationToken) -[xUnit.net 00:00:00.30] /Users/alexsohn/code/sentry-dotnet/test/Sentry.Extensions.AI.Tests/SentryInstrumentedFunctionTests.cs(160,0): at Sentry.Extensions.AI.Tests.SentryInstrumentedFunctionTests.InvokeCoreAsync_WithCancellation_PropagatesCancellation() -[xUnit.net 00:00:00.30] --- End of stack trace from previous location --- -[xUnit.net 00:00:00.33] Finished: Sentry.Extensions.AI.Tests - - - - - [xUnit.net 00:00:00.20] Sentry.Extensions.AI.Tests.SentryAISpanEnricherTests.EnrichWithResponse_SetsUsageTokens_WhenUsageProvided [FAIL] - - - [xUnit.net 00:00:00.22] Sentry.Extensions.AI.Tests.SentryAISpanEnricherTests.EnrichWithResponse_SetsPartialTokens_WhenOnlyInputTokensProvided [FAIL] - - - [xUnit.net 00:00:00.29] Sentry.Extensions.AI.Tests.SentryInstrumentedFunctionTests.InvokeCoreAsync_WhenInnerFunctionThrows_FinishesSpanWithException [FAIL] - - - [xUnit.net 00:00:00.29] Sentry.Extensions.AI.Tests.SentryInstrumentedFunctionTests.InvokeCoreAsync_PreservesInnerFunctionName [FAIL] - - - [xUnit.net 00:00:00.30] Sentry.Extensions.AI.Tests.SentryInstrumentedFunctionTests.InvokeCoreAsync_WithParentSpan_CreatesChildSpan [FAIL] - - - [xUnit.net 00:00:00.30] Sentry.Extensions.AI.Tests.SentryInstrumentedFunctionTests.InvokeCoreAsync_WithoutParentSpan_CreatesTransaction [FAIL] - - - [xUnit.net 00:00:00.30] Sentry.Extensions.AI.Tests.SentryInstrumentedFunctionTests.InvokeCoreAsync_WithComplexResult_SetsOutputAsString [FAIL] - - - [xUnit.net 00:00:00.30] Sentry.Extensions.AI.Tests.SentryInstrumentedFunctionTests.InvokeCoreAsync_WithNullResult_DoesNotSetOutput [FAIL] - - - [xUnit.net 00:00:00.30] Sentry.Extensions.AI.Tests.SentryInstrumentedFunctionTests.InvokeCoreAsync_WithCancellation_PropagatesCancellation [FAIL] - - - - \ No newline at end of file From 3374ce18695f4c7e58abc4d67febe37d3647d53a Mon Sep 17 00:00:00 2001 From: Sentry Github Bot Date: Mon, 20 Oct 2025 05:54:08 +0000 Subject: [PATCH 06/86] Format code --- samples/Sentry.Samples.ME.AI.AspNetCore/Program.cs | 9 +++++---- samples/Sentry.Samples.ME.AI.Console/Program.cs | 2 +- src/Sentry.Extensions.AI/SentryAISpanEnricher.cs | 6 +++--- .../SentryAISpanEnricherTests.cs | 2 +- test/Sentry.Extensions.AI.Tests/SentryChatClientTests.cs | 2 +- .../SentryInstrumentedFunctionTests.cs | 2 +- 6 files changed, 12 insertions(+), 11 deletions(-) diff --git a/samples/Sentry.Samples.ME.AI.AspNetCore/Program.cs b/samples/Sentry.Samples.ME.AI.AspNetCore/Program.cs index 75a117da81..57aaf21f7d 100644 --- a/samples/Sentry.Samples.ME.AI.AspNetCore/Program.cs +++ b/samples/Sentry.Samples.ME.AI.AspNetCore/Program.cs @@ -8,9 +8,9 @@ builder.WebHost.UseSentry(options => { #if !SENTRY_DSN_DEFINED_IN_ENV - // A DSN is required. You can set here in code, or you can set it in the SENTRY_DSN environment variable. - // See https://docs.sentry.io/product/sentry-basics/dsn-explainer/ - options.Dsn = SamplesShared.Dsn; + // A DSN is required. You can set here in code, or you can set it in the SENTRY_DSN environment variable. + // See https://docs.sentry.io/product/sentry-basics/dsn-explainer/ + options.Dsn = SamplesShared.Dsn; #endif options.Debug = true; @@ -121,7 +121,8 @@ "Please help me with the following tasks: 1) Find Alice's age, 2) Get weather in New York, 3) Calculate a complex result for number 15, 4) Get comprehensive info for Bob in London, and 5) Calculate average age for Alice, Bob, and Charlie (first get each person's age individually using GetPersonAge, then use CalculateAverageAge with those results). Please use the appropriate tools for each task and demonstrate tool chaining where needed.", options); - return Results.Ok(new { + return Results.Ok(new + { message = "AI test with multiple tools completed successfully", response = response.Messages?.FirstOrDefault()?.Text ?? "No response", timestamp = DateTime.UtcNow diff --git a/samples/Sentry.Samples.ME.AI.Console/Program.cs b/samples/Sentry.Samples.ME.AI.Console/Program.cs index ff65168df2..b5ba116f44 100644 --- a/samples/Sentry.Samples.ME.AI.Console/Program.cs +++ b/samples/Sentry.Samples.ME.AI.Console/Program.cs @@ -1,4 +1,4 @@ -using Anthropic.SDK; +using Anthropic.SDK; using Anthropic.SDK.Constants; using Microsoft.Extensions.AI; using Microsoft.Extensions.Logging; diff --git a/src/Sentry.Extensions.AI/SentryAISpanEnricher.cs b/src/Sentry.Extensions.AI/SentryAISpanEnricher.cs index 6d8697e90d..bc97700432 100644 --- a/src/Sentry.Extensions.AI/SentryAISpanEnricher.cs +++ b/src/Sentry.Extensions.AI/SentryAISpanEnricher.cs @@ -1,5 +1,5 @@ -using Microsoft.Extensions.AI; using System.Text.Json; +using Microsoft.Extensions.AI; namespace Sentry.Extensions.AI; @@ -122,13 +122,13 @@ public static void EnrichWithStreamingResponse(ISpan span, List(result); var jsonResult = (JsonElement)result; Assert.Equal("test output", jsonResult.GetString()); - + // The span should have recorded the ToString() output of the JsonElement // (This is testing the internal behavior that ToString() gets called for span data) } From 8abbd0b9904e95b20d3162950a5887623603c283 Mon Sep 17 00:00:00 2001 From: Alex Sohn Date: Thu, 23 Oct 2025 16:41:06 -0400 Subject: [PATCH 07/86] chat spans between tool calls working --- .../Program.cs | 40 +++-- .../Sentry.Samples.ME.AI.AspNetCore.csproj | 5 +- .../Sentry.Samples.ME.AI.Console/Program.cs | 23 +-- .../Sentry.Samples.ME.AI.Console.csproj | 4 +- .../Extensions/SentryAIExtensions.cs | 2 +- src/Sentry.Extensions.AI/SentryAIOptions.cs | 5 + .../SentryAISpanEnricher.cs | 7 +- src/Sentry.Extensions.AI/SentryChatClient.cs | 144 +++++++++++------- .../SentryInstrumentedFunction.cs | 7 +- 9 files changed, 142 insertions(+), 95 deletions(-) diff --git a/samples/Sentry.Samples.ME.AI.AspNetCore/Program.cs b/samples/Sentry.Samples.ME.AI.AspNetCore/Program.cs index 57aaf21f7d..6730823374 100644 --- a/samples/Sentry.Samples.ME.AI.AspNetCore/Program.cs +++ b/samples/Sentry.Samples.ME.AI.AspNetCore/Program.cs @@ -1,5 +1,3 @@ -using Anthropic.SDK; -using Anthropic.SDK.Constants; using Microsoft.Extensions.AI; using Sentry.Extensions.AI; @@ -20,17 +18,20 @@ options.Experimental.EnableLogs = true; }); -var client = new AnthropicClient().Messages - .AsBuilder() - .UseFunctionInvocation() - .Build() +var openAIClient = new OpenAI.Chat.ChatClient("gpt-4o-mini", Environment.GetEnvironmentVariable("OPENAI_API_KEY")) + .AsIChatClient() .WithSentry(options => { - options.IncludeAIRequestMessages = false; - options.IncludeAIResponseContent = false; + // In this case, we already initialized Sentry from ASP.NET WebHost creation, we don't need to initialize + options.IncludeAIRequestMessages = true; + options.IncludeAIResponseContent = true; }); -// Register the Claude API client and Sentry-instrumented chat client +var client = new ChatClientBuilder(openAIClient) + .UseFunctionInvocation() + .Build(); + +// Register the OpenAI API client and Sentry-instrumented chat client builder.Services.AddSingleton(client); var app = builder.Build(); @@ -41,7 +42,7 @@ logger.LogInformation("Running AI test endpoint with multiple tools"); var options = new ChatOptions { - ModelId = AnthropicModels.Claude3Haiku, + ModelId = "gpt-4o-mini", MaxOutputTokens = 1024, Tools = [ // Tool 1: Quick response with minimal delay @@ -117,14 +118,23 @@ try { - var response = await chatClient.GetResponseAsync( - "Please help me with the following tasks: 1) Find Alice's age, 2) Get weather in New York, 3) Calculate a complex result for number 15, 4) Get comprehensive info for Bob in London, and 5) Calculate average age for Alice, Bob, and Charlie (first get each person's age individually using GetPersonAge, then use CalculateAverageAge with those results). Please use the appropriate tools for each task and demonstrate tool chaining where needed.", - options); + var streamingResponse = new List(); + await foreach (var update in chatClient.GetStreamingResponseAsync([ + new ChatMessage(ChatRole.User, "Please help me with the following tasks: 1) Find Alice's age, 2) Get weather in New York, 3) Calculate a complex result for number 15, 4) Get comprehensive info for Bob in London, and 5) Calculate average age for Alice, Bob, and Charlie (first get each person's age individually using GetPersonAge, then use CalculateAverageAge with those results). Please use the appropriate tools for each task and demonstrate tool chaining where needed.") + ], options)) + { + if (!string.IsNullOrEmpty(update.Text)) + { + streamingResponse.Add(update.Text); + } + } + + var fullResponse = string.Concat(streamingResponse); return Results.Ok(new { - message = "AI test with multiple tools completed successfully", - response = response.Messages?.FirstOrDefault()?.Text ?? "No response", + message = "AI test with multiple tools completed successfully (streaming)", + response = fullResponse, timestamp = DateTime.UtcNow }); } diff --git a/samples/Sentry.Samples.ME.AI.AspNetCore/Sentry.Samples.ME.AI.AspNetCore.csproj b/samples/Sentry.Samples.ME.AI.AspNetCore/Sentry.Samples.ME.AI.AspNetCore.csproj index ca28278952..e8cfeab2e1 100644 --- a/samples/Sentry.Samples.ME.AI.AspNetCore/Sentry.Samples.ME.AI.AspNetCore.csproj +++ b/samples/Sentry.Samples.ME.AI.AspNetCore/Sentry.Samples.ME.AI.AspNetCore.csproj @@ -5,9 +5,8 @@ - - + @@ -22,4 +21,4 @@ - \ No newline at end of file + diff --git a/samples/Sentry.Samples.ME.AI.Console/Program.cs b/samples/Sentry.Samples.ME.AI.Console/Program.cs index b5ba116f44..1aa3df4552 100644 --- a/samples/Sentry.Samples.ME.AI.Console/Program.cs +++ b/samples/Sentry.Samples.ME.AI.Console/Program.cs @@ -1,5 +1,3 @@ -using Anthropic.SDK; -using Anthropic.SDK.Constants; using Microsoft.Extensions.AI; using Microsoft.Extensions.Logging; using Sentry.Extensions.AI; @@ -9,11 +7,9 @@ logger.LogInformation("Starting Microsoft.Extensions.AI sample with Sentry instrumentation"); -// Create Claude API client and wrap it with Sentry instrumentation -var client = new AnthropicClient().Messages - .AsBuilder() - .UseFunctionInvocation() - .Build() +// Create OpenAI API client and wrap it with Sentry instrumentation +var openAIClient = new OpenAI.Chat.ChatClient("gpt-4o-mini", Environment.GetEnvironmentVariable("OPENAI_API_KEY")) + .AsIChatClient() .WithSentry(options => { #if !SENTRY_DSN_DEFINED_IN_ENV @@ -27,16 +23,21 @@ options.TracesSampleRate = 1; // AI-specific settings - options.IncludeAIRequestMessages = false; - options.IncludeAIResponseContent = false; + options.IncludeAIRequestMessages = true; + options.IncludeAIResponseContent = true; + // Since this is a simple console app without Sentry already set up, we need to initialize our SDK options.InitializeSdk = true; }); +var client = new ChatClientBuilder(openAIClient) + .UseFunctionInvocation() + .Build(); + logger.LogInformation("Making AI call with Sentry instrumentation and tools..."); var options = new ChatOptions { - ModelId = AnthropicModels.Claude3Haiku, + ModelId = "gpt-4o-mini", MaxOutputTokens = 1024, Tools = [ // Tool 1: Quick response with minimal delay @@ -87,7 +88,7 @@ var streamingOptions = new ChatOptions { - ModelId = AnthropicModels.Claude3Haiku, + ModelId = "gpt-4o-mini", MaxOutputTokens = 1024 }.WithSentry(); diff --git a/samples/Sentry.Samples.ME.AI.Console/Sentry.Samples.ME.AI.Console.csproj b/samples/Sentry.Samples.ME.AI.Console/Sentry.Samples.ME.AI.Console.csproj index 83db5db8a8..5b97f854d5 100644 --- a/samples/Sentry.Samples.ME.AI.Console/Sentry.Samples.ME.AI.Console.csproj +++ b/samples/Sentry.Samples.ME.AI.Console/Sentry.Samples.ME.AI.Console.csproj @@ -6,9 +6,9 @@ - - + + diff --git a/src/Sentry.Extensions.AI/Extensions/SentryAIExtensions.cs b/src/Sentry.Extensions.AI/Extensions/SentryAIExtensions.cs index f05fac1822..91e4ddd1b8 100644 --- a/src/Sentry.Extensions.AI/Extensions/SentryAIExtensions.cs +++ b/src/Sentry.Extensions.AI/Extensions/SentryAIExtensions.cs @@ -27,7 +27,7 @@ public static ChatOptions WithSentry( var tool = options.Tools[i]; if (tool is AIFunction fn and not SentryInstrumentedFunction) { - options.Tools[i] = new SentryInstrumentedFunction(fn); + options.Tools[i] = new SentryInstrumentedFunction(fn, options); } } diff --git a/src/Sentry.Extensions.AI/SentryAIOptions.cs b/src/Sentry.Extensions.AI/SentryAIOptions.cs index 4d839e2c57..7618c1f9f6 100644 --- a/src/Sentry.Extensions.AI/SentryAIOptions.cs +++ b/src/Sentry.Extensions.AI/SentryAIOptions.cs @@ -16,6 +16,11 @@ public class SentryAIOptions : SentryOptions /// public bool IncludeAIResponseContent { get; set; } = true; + /// + /// Name of the AI Agent + /// + public string AgentName { get; set; } = "Agent"; + /// /// Whether to initialize the Sentry SDK through this integration. /// diff --git a/src/Sentry.Extensions.AI/SentryAISpanEnricher.cs b/src/Sentry.Extensions.AI/SentryAISpanEnricher.cs index bc97700432..676370ea08 100644 --- a/src/Sentry.Extensions.AI/SentryAISpanEnricher.cs +++ b/src/Sentry.Extensions.AI/SentryAISpanEnricher.cs @@ -25,9 +25,10 @@ internal static void EnrichWithRequest(ISpan span, ChatMessage[] messages, ChatO { span.SetData("gen_ai.request.model", modelId); } - else + + if (aiOptions?.AgentName is { } agentName) { - span.SetData("gen_ai.request.model", "Unknown model"); + span.SetData("gen_ai.agent.name", agentName); } if (messages is { Length: > 0 } && (aiOptions?.IncludeAIRequestMessages ?? true)) @@ -102,7 +103,7 @@ internal static void EnrichWithResponse(ISpan span, ChatResponse response, Sentr if (response.ModelId is { } modelId) { - span.SetData("gen_ai.response.model_id", modelId); + span.SetData("gen_ai.response.model", modelId); } } diff --git a/src/Sentry.Extensions.AI/SentryChatClient.cs b/src/Sentry.Extensions.AI/SentryChatClient.cs index a59870c500..630db14a4e 100644 --- a/src/Sentry.Extensions.AI/SentryChatClient.cs +++ b/src/Sentry.Extensions.AI/SentryChatClient.cs @@ -7,20 +7,17 @@ internal sealed class SentryChatClient : DelegatingChatClient { private readonly HubAdapter _hub; private readonly SentryAIOptions _sentryAIOptions; + private static ISpan? RootSpan; public SentryChatClient(IChatClient client, Action? configure = null) : base(client) { _sentryAIOptions = new SentryAIOptions(); configure?.Invoke(_sentryAIOptions); - if (_sentryAIOptions.InitializeSdk) + if (_sentryAIOptions.InitializeSdk && !SentrySdk.IsEnabled) { - if (!SentrySdk.IsEnabled || _sentryAIOptions.Dsn is not null) - { - // Initialize Sentry with our options/DSN - var hub = SentrySdk.InitHub(_sentryAIOptions); - SentrySdk.UseHub(hub); - } + var hub = SentrySdk.InitHub(_sentryAIOptions); + SentrySdk.UseHub(hub); } _hub = HubAdapter.Instance; @@ -31,83 +28,87 @@ public override async Task GetResponseAsync(IEnumerable - public override IAsyncEnumerable GetStreamingResponseAsync(IEnumerable messages, + public override async IAsyncEnumerable GetStreamingResponseAsync(IEnumerable messages, ChatOptions? options = null, - CancellationToken cancellationToken = new()) + [EnumeratorCancellation] CancellationToken cancellationToken = new()) { - var invokeSpanName = $"invoke_agent {InnerClient.GetType().Name}"; - const string invokeOperation = "gen_ai.invoke_agent"; - var outerSpan = StartSpanOrTransaction(invokeOperation, invokeSpanName); - - const string chatOperation = "gen_ai.chat"; - var chatSpanName = options is null || string.IsNullOrEmpty(options.ModelId) ? "chat unknown model" : $"chat {options.ModelId}"; - var initialSpan = outerSpan.StartChild(chatOperation, chatSpanName); - - try - { - return InstrumentStreamingResponseAsync(messages, options, outerSpan, initialSpan, cancellationToken); - } - catch (Exception ex) - { - initialSpan.Finish(ex); - outerSpan.Finish(ex); - _hub.CaptureException(ex); - throw; - } - } - - private async IAsyncEnumerable InstrumentStreamingResponseAsync(IEnumerable messages, - ChatOptions? options, - ISpan outerSpan, - ISpan span, - [EnumeratorCancellation] CancellationToken cancellationToken) - { - var chatMessages = messages as ChatMessage[] ?? messages.ToArray(); - SentryAISpanEnricher.EnrichWithRequest(span, chatMessages, options, _sentryAIOptions); + var outerSpan = EnsureRootSpanExists(); + var innerSpan = CreateChatSpan(options, outerSpan); var responses = new List(); - var originalStream = base.GetStreamingResponseAsync(chatMessages, options, cancellationToken); + var chatMessages = messages as ChatMessage[] ?? messages.ToArray(); + var enumerator = base + .GetStreamingResponseAsync(chatMessages, options, cancellationToken) + .GetAsyncEnumerator(cancellationToken); - await foreach (var chunk in originalStream.ConfigureAwait(false)) + while (true) { - responses.Add(chunk); + ChatResponseUpdate? current; - yield return chunk; - } + try + { + SentryAISpanEnricher.EnrichWithRequest(innerSpan, chatMessages, options, _sentryAIOptions); + var hasNext = await enumerator.MoveNextAsync().ConfigureAwait(false); + if (!hasNext) + { + SentryAISpanEnricher.EnrichWithStreamingResponse(innerSpan, responses, _sentryAIOptions); + innerSpan.Finish(SpanStatus.Ok); + outerSpan.Finish(SpanStatus.Ok); + + if (!ContainsFunctionCalls(responses)) + { + RootSpan = null; + } + + yield break; + } + + current = enumerator.Current; + responses.Add(enumerator.Current); + } + catch (Exception ex) + { + innerSpan.Finish(ex); + outerSpan.Finish(ex); + _hub.CaptureException(ex); + RootSpan = null; + throw; + } - SentryAISpanEnricher.EnrichWithStreamingResponse(span, responses, _sentryAIOptions); - span.Finish(SpanStatus.Ok); - outerSpan.Finish(SpanStatus.Ok); + yield return current; + } } /// @@ -129,4 +130,31 @@ private ISpan StartSpanOrTransaction(string operation, string description) _hub.ConfigureScope(scope => scope.Transaction = newTransaction); return newTransaction; } + + private static bool ContainsFunctionCalls(ChatResponse response) => + response.Messages.Any(m => m.Contents?.OfType().Any() ?? false) + || response.FinishReason == ChatFinishReason.ToolCalls; + + private static bool ContainsFunctionCalls(List responses) => + responses.Any(m => m.Contents?.OfType().Any() ?? false) + || responses.Any(m => m.FinishReason == ChatFinishReason.ToolCalls); + + private ISpan EnsureRootSpanExists() + { + var invokeSpanName = $"invoke_agent {InnerClient.GetType().Name}"; + const string invokeOperation = "gen_ai.invoke_agent"; + RootSpan ??= StartSpanOrTransaction(invokeOperation, invokeSpanName); + // In ME.AI, there's not really an agent name. In other SDKs we set this, so we should do so here + RootSpan.SetData("gen_ai.agent.name", $"{InnerClient.GetType().Name}"); + return RootSpan; + } + + private static ISpan CreateChatSpan(ChatOptions? options, ISpan outerSpan) + { + const string chatOperation = "gen_ai.chat"; + var chatSpanName = options is null || string.IsNullOrEmpty(options.ModelId) + ? "chat unknown model" + : $"chat {options.ModelId}"; + return outerSpan.StartChild(chatOperation, chatSpanName); + } } diff --git a/src/Sentry.Extensions.AI/SentryInstrumentedFunction.cs b/src/Sentry.Extensions.AI/SentryInstrumentedFunction.cs index 4ab001e7c7..54caee614f 100644 --- a/src/Sentry.Extensions.AI/SentryInstrumentedFunction.cs +++ b/src/Sentry.Extensions.AI/SentryInstrumentedFunction.cs @@ -3,7 +3,8 @@ namespace Sentry.Extensions.AI; -internal sealed class SentryInstrumentedFunction(AIFunction innerFunction) : DelegatingAIFunction(innerFunction) +internal sealed class SentryInstrumentedFunction(AIFunction innerFunction, ChatOptions? aiOptions = null) + : DelegatingAIFunction(innerFunction) { private readonly HubAdapter _hub = HubAdapter.Instance; @@ -11,11 +12,13 @@ internal sealed class SentryInstrumentedFunction(AIFunction innerFunction) : Del AIFunctionArguments arguments, CancellationToken cancellationToken) { - var parentSpan = _hub.GetSpan(); const string operation = "gen_ai.execute_tool"; var spanName = $"execute_tool {Name}"; + var parentSpan = _hub.GetSpan(); var currSpan = parentSpan?.StartChild(operation, spanName) ?? _hub.StartTransaction(spanName, operation); + currSpan.SetData("gen_ai.request.model", aiOptions?.ModelId); + currSpan.SetData("gen_ai.operation.name", "execute_tool"); currSpan.SetData("gen_ai.tool.name", Name); currSpan.SetData("gen_ai.tool.description", Description); From 65066d095620a3d7fdf09a7a2327e30050026e58 Mon Sep 17 00:00:00 2001 From: Alex Sohn Date: Sat, 25 Oct 2025 23:15:01 -0400 Subject: [PATCH 08/86] fixed tests and span generation --- .../Sentry.Samples.ME.AI.Console/Program.cs | 6 +++ src/Sentry.Extensions.AI/SentryChatClient.cs | 50 ++++++------------- .../SentryInstrumentedFunction.cs | 5 +- .../SentryAISpanEnricherTests.cs | 8 +-- 4 files changed, 29 insertions(+), 40 deletions(-) diff --git a/samples/Sentry.Samples.ME.AI.Console/Program.cs b/samples/Sentry.Samples.ME.AI.Console/Program.cs index 1aa3df4552..f32cfd0ae9 100644 --- a/samples/Sentry.Samples.ME.AI.Console/Program.cs +++ b/samples/Sentry.Samples.ME.AI.Console/Program.cs @@ -35,6 +35,10 @@ logger.LogInformation("Making AI call with Sentry instrumentation and tools..."); +// This starts a new transaction and attaches it to the scope. +var transaction = SentrySdk.StartTransaction("Program Main", "function"); +SentrySdk.ConfigureScope(scope => scope.Transaction = transaction); + var options = new ChatOptions { ModelId = "gpt-4o-mini", @@ -109,5 +113,7 @@ logger.LogInformation("Microsoft.Extensions.AI sample completed! Check your Sentry dashboard for the trace data."); +transaction.Finish(); + // Flush Sentry to ensure all transactions are sent before the app exits await SentrySdk.FlushAsync(TimeSpan.FromSeconds(2)); diff --git a/src/Sentry.Extensions.AI/SentryChatClient.cs b/src/Sentry.Extensions.AI/SentryChatClient.cs index 630db14a4e..d7d2e836fb 100644 --- a/src/Sentry.Extensions.AI/SentryChatClient.cs +++ b/src/Sentry.Extensions.AI/SentryChatClient.cs @@ -7,7 +7,7 @@ internal sealed class SentryChatClient : DelegatingChatClient { private readonly HubAdapter _hub; private readonly SentryAIOptions _sentryAIOptions; - private static ISpan? RootSpan; + internal static ISpan? RootSpan; public SentryChatClient(IChatClient client, Action? configure = null) : base(client) { @@ -16,8 +16,7 @@ public SentryChatClient(IChatClient client, Action? configure = if (_sentryAIOptions.InitializeSdk && !SentrySdk.IsEnabled) { - var hub = SentrySdk.InitHub(_sentryAIOptions); - SentrySdk.UseHub(hub); + SentrySdk.Init(_sentryAIOptions); } _hub = HubAdapter.Instance; @@ -111,41 +110,16 @@ public override async IAsyncEnumerable GetStreamingResponseA } } - /// - /// Starts a span or transaction based on whether there's an active transaction context. - /// - /// The operation name - /// The span/transaction description - /// A child span of an existing transaction if available, else a new transaction - private ISpan StartSpanOrTransaction(string operation, string description) + private ISpan EnsureRootSpanExists() { - var currentSpan = _hub.GetSpan(); - - if (currentSpan?.GetTransaction() != null) + if (RootSpan == null) { - return currentSpan.StartChild(operation, description); + var invokeSpanName = $"invoke_agent {InnerClient.GetType().Name}"; + const string invokeOperation = "gen_ai.invoke_agent"; + RootSpan = _hub.StartSpan(invokeOperation, invokeSpanName); + RootSpan.SetData("gen_ai.agent.name", $"{InnerClient.GetType().Name}"); } - var newTransaction = _hub.StartTransaction(description, operation); - _hub.ConfigureScope(scope => scope.Transaction = newTransaction); - return newTransaction; - } - - private static bool ContainsFunctionCalls(ChatResponse response) => - response.Messages.Any(m => m.Contents?.OfType().Any() ?? false) - || response.FinishReason == ChatFinishReason.ToolCalls; - - private static bool ContainsFunctionCalls(List responses) => - responses.Any(m => m.Contents?.OfType().Any() ?? false) - || responses.Any(m => m.FinishReason == ChatFinishReason.ToolCalls); - - private ISpan EnsureRootSpanExists() - { - var invokeSpanName = $"invoke_agent {InnerClient.GetType().Name}"; - const string invokeOperation = "gen_ai.invoke_agent"; - RootSpan ??= StartSpanOrTransaction(invokeOperation, invokeSpanName); - // In ME.AI, there's not really an agent name. In other SDKs we set this, so we should do so here - RootSpan.SetData("gen_ai.agent.name", $"{InnerClient.GetType().Name}"); return RootSpan; } @@ -157,4 +131,12 @@ private static ISpan CreateChatSpan(ChatOptions? options, ISpan outerSpan) : $"chat {options.ModelId}"; return outerSpan.StartChild(chatOperation, chatSpanName); } + + private static bool ContainsFunctionCalls(ChatResponse response) => + response.Messages.Any(m => m.Contents?.OfType().Any() ?? false) + || response.FinishReason == ChatFinishReason.ToolCalls; + + private static bool ContainsFunctionCalls(List responses) => + responses.Any(m => m.Contents?.OfType().Any() ?? false) + || responses.Any(m => m.FinishReason == ChatFinishReason.ToolCalls); } diff --git a/src/Sentry.Extensions.AI/SentryInstrumentedFunction.cs b/src/Sentry.Extensions.AI/SentryInstrumentedFunction.cs index 54caee614f..e50589baf4 100644 --- a/src/Sentry.Extensions.AI/SentryInstrumentedFunction.cs +++ b/src/Sentry.Extensions.AI/SentryInstrumentedFunction.cs @@ -14,8 +14,9 @@ internal sealed class SentryInstrumentedFunction(AIFunction innerFunction, ChatO { const string operation = "gen_ai.execute_tool"; var spanName = $"execute_tool {Name}"; - var parentSpan = _hub.GetSpan(); - var currSpan = parentSpan?.StartChild(operation, spanName) ?? _hub.StartTransaction(spanName, operation); + var currSpan = SentryChatClient.RootSpan == null ? + _hub.StartSpan(operation, spanName) : + SentryChatClient.RootSpan.StartChild(operation, spanName); currSpan.SetData("gen_ai.request.model", aiOptions?.ModelId); diff --git a/test/Sentry.Extensions.AI.Tests/SentryAISpanEnricherTests.cs b/test/Sentry.Extensions.AI.Tests/SentryAISpanEnricherTests.cs index d95320b50f..ed3f64bfe0 100644 --- a/test/Sentry.Extensions.AI.Tests/SentryAISpanEnricherTests.cs +++ b/test/Sentry.Extensions.AI.Tests/SentryAISpanEnricherTests.cs @@ -26,7 +26,7 @@ public void EnrichWithRequest_SetsBasicOperationName() } [Fact] - public void EnrichWithRequest_SetsModelId_WhenProvided() + public void EnrichWithRequest_SetsModel_WhenProvided() { var messages = new[] { new ChatMessage(ChatRole.User, "Hello") }; var options = new ChatOptions { ModelId = "gpt-4" }; @@ -37,13 +37,13 @@ public void EnrichWithRequest_SetsModelId_WhenProvided() } [Fact] - public void EnrichWithRequest_SetsUnknownModel_WhenModelIdNotProvided() + public void EnrichWithRequest_DoesntSetModel_WhenModelIdNotProvided() { var messages = new[] { new ChatMessage(ChatRole.User, "Hello") }; SentryAISpanEnricher.EnrichWithRequest(_mockSpan, messages, null); - _mockSpan.Received(1).SetData("gen_ai.request.model", "Unknown model"); + _mockSpan.DidNotReceive().SetData("gen_ai.request.model", Arg.Any()); } [Fact] @@ -247,7 +247,7 @@ public void EnrichWithResponse_SetsModelId_WhenProvided() SentryAISpanEnricher.EnrichWithResponse(_mockSpan, response); - _mockSpan.Received(1).SetData("gen_ai.response.model_id", "gpt-4-turbo"); + _mockSpan.Received().SetData("gen_ai.response.model", "gpt-4-turbo"); } [Fact] From c7fd85c2e0d45ff2526d6dbcdf178539ee00df4d Mon Sep 17 00:00:00 2001 From: Sentry Github Bot Date: Sun, 26 Oct 2025 03:34:05 +0000 Subject: [PATCH 09/86] Format code --- src/Sentry.Extensions.AI/SentryAISpanEnricher.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Sentry.Extensions.AI/SentryAISpanEnricher.cs b/src/Sentry.Extensions.AI/SentryAISpanEnricher.cs index 676370ea08..971698e58c 100644 --- a/src/Sentry.Extensions.AI/SentryAISpanEnricher.cs +++ b/src/Sentry.Extensions.AI/SentryAISpanEnricher.cs @@ -28,7 +28,7 @@ internal static void EnrichWithRequest(ISpan span, ChatMessage[] messages, ChatO if (aiOptions?.AgentName is { } agentName) { - span.SetData("gen_ai.agent.name", agentName); + span.SetData("gen_ai.agent.name", agentName); } if (messages is { Length: > 0 } && (aiOptions?.IncludeAIRequestMessages ?? true)) From 11c0f5dbd689b225c146c42f4fd5329b8d21e972 Mon Sep 17 00:00:00 2001 From: Alex Sohn Date: Sun, 26 Oct 2025 17:00:59 -0400 Subject: [PATCH 10/86] Remove unused using in AISpanEnricher --- src/Sentry.Extensions.AI/SentryAISpanEnricher.cs | 1 - 1 file changed, 1 deletion(-) diff --git a/src/Sentry.Extensions.AI/SentryAISpanEnricher.cs b/src/Sentry.Extensions.AI/SentryAISpanEnricher.cs index 971698e58c..78e89cae0a 100644 --- a/src/Sentry.Extensions.AI/SentryAISpanEnricher.cs +++ b/src/Sentry.Extensions.AI/SentryAISpanEnricher.cs @@ -1,4 +1,3 @@ -using System.Text.Json; using Microsoft.Extensions.AI; namespace Sentry.Extensions.AI; From 6df16fbaf90dece208ae1c5b0f999da2c8fda109 Mon Sep 17 00:00:00 2001 From: Alex Sohn Date: Sun, 26 Oct 2025 17:19:06 -0400 Subject: [PATCH 11/86] Remove redundant span enrichment in GetStreamingResponseAsync --- src/Sentry.Extensions.AI/SentryChatClient.cs | 1 - 1 file changed, 1 deletion(-) diff --git a/src/Sentry.Extensions.AI/SentryChatClient.cs b/src/Sentry.Extensions.AI/SentryChatClient.cs index d7d2e836fb..febb3fffd5 100644 --- a/src/Sentry.Extensions.AI/SentryChatClient.cs +++ b/src/Sentry.Extensions.AI/SentryChatClient.cs @@ -78,7 +78,6 @@ public override async IAsyncEnumerable GetStreamingResponseA try { - SentryAISpanEnricher.EnrichWithRequest(innerSpan, chatMessages, options, _sentryAIOptions); var hasNext = await enumerator.MoveNextAsync().ConfigureAwait(false); if (!hasNext) { From 4ca0550e3455658f226b0daee88358fb21b2ece0 Mon Sep 17 00:00:00 2001 From: Alex Sohn Date: Sun, 26 Oct 2025 17:20:57 -0400 Subject: [PATCH 12/86] forgot to add span enrichment --- src/Sentry.Extensions.AI/SentryChatClient.cs | 1 + 1 file changed, 1 insertion(+) diff --git a/src/Sentry.Extensions.AI/SentryChatClient.cs b/src/Sentry.Extensions.AI/SentryChatClient.cs index febb3fffd5..16304e259d 100644 --- a/src/Sentry.Extensions.AI/SentryChatClient.cs +++ b/src/Sentry.Extensions.AI/SentryChatClient.cs @@ -71,6 +71,7 @@ public override async IAsyncEnumerable GetStreamingResponseA var enumerator = base .GetStreamingResponseAsync(chatMessages, options, cancellationToken) .GetAsyncEnumerator(cancellationToken); + SentryAISpanEnricher.EnrichWithRequest(innerSpan, chatMessages, options, _sentryAIOptions); while (true) { From 9116a3696cae46fc1d1afb833717e6acde5dc953 Mon Sep 17 00:00:00 2001 From: Alex Sohn Date: Sun, 26 Oct 2025 17:39:34 -0400 Subject: [PATCH 13/86] Change options argument name in SentryInstrumentedFunction --- src/Sentry.Extensions.AI/SentryInstrumentedFunction.cs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/Sentry.Extensions.AI/SentryInstrumentedFunction.cs b/src/Sentry.Extensions.AI/SentryInstrumentedFunction.cs index e50589baf4..724ccefba1 100644 --- a/src/Sentry.Extensions.AI/SentryInstrumentedFunction.cs +++ b/src/Sentry.Extensions.AI/SentryInstrumentedFunction.cs @@ -3,7 +3,7 @@ namespace Sentry.Extensions.AI; -internal sealed class SentryInstrumentedFunction(AIFunction innerFunction, ChatOptions? aiOptions = null) +internal sealed class SentryInstrumentedFunction(AIFunction innerFunction, ChatOptions? options = null) : DelegatingAIFunction(innerFunction) { private readonly HubAdapter _hub = HubAdapter.Instance; @@ -18,7 +18,7 @@ internal sealed class SentryInstrumentedFunction(AIFunction innerFunction, ChatO _hub.StartSpan(operation, spanName) : SentryChatClient.RootSpan.StartChild(operation, spanName); - currSpan.SetData("gen_ai.request.model", aiOptions?.ModelId); + currSpan.SetData("gen_ai.request.model", options?.ModelId); currSpan.SetData("gen_ai.operation.name", "execute_tool"); currSpan.SetData("gen_ai.tool.name", Name); From ce49cd14d77f7a4a50bdbbc16db9e3f487c3fd60 Mon Sep 17 00:00:00 2001 From: Alex Sohn Date: Sun, 26 Oct 2025 22:33:56 -0400 Subject: [PATCH 14/86] Fix wrong checks for finishing root span --- src/Sentry.Extensions.AI/SentryChatClient.cs | 25 ++++++++++---------- 1 file changed, 12 insertions(+), 13 deletions(-) diff --git a/src/Sentry.Extensions.AI/SentryChatClient.cs b/src/Sentry.Extensions.AI/SentryChatClient.cs index 16304e259d..c1be3673bf 100644 --- a/src/Sentry.Extensions.AI/SentryChatClient.cs +++ b/src/Sentry.Extensions.AI/SentryChatClient.cs @@ -40,7 +40,11 @@ public override async Task GetResponseAsync(IEnumerable GetStreamingResponseA .GetStreamingResponseAsync(chatMessages, options, cancellationToken) .GetAsyncEnumerator(cancellationToken); SentryAISpanEnricher.EnrichWithRequest(innerSpan, chatMessages, options, _sentryAIOptions); + ChatResponseUpdate? current = null; while (true) { - ChatResponseUpdate? current; - try { var hasNext = await enumerator.MoveNextAsync().ConfigureAwait(false); @@ -84,10 +87,14 @@ public override async IAsyncEnumerable GetStreamingResponseA { SentryAISpanEnricher.EnrichWithStreamingResponse(innerSpan, responses, _sentryAIOptions); innerSpan.Finish(SpanStatus.Ok); - outerSpan.Finish(SpanStatus.Ok); - if (!ContainsFunctionCalls(responses)) + // Only if currentFinishReason is to stop, then we finish the RootSpan and set it to null. + // This allows the RootSpan to persist throughout multiple `GetStreamingResponseAsync` calls + // happening before and after tool calls + var shouldFinishRootSpan = current?.FinishReason == ChatFinishReason.Stop; + if (shouldFinishRootSpan) { + outerSpan.Finish(SpanStatus.Ok); RootSpan = null; } @@ -131,12 +138,4 @@ private static ISpan CreateChatSpan(ChatOptions? options, ISpan outerSpan) : $"chat {options.ModelId}"; return outerSpan.StartChild(chatOperation, chatSpanName); } - - private static bool ContainsFunctionCalls(ChatResponse response) => - response.Messages.Any(m => m.Contents?.OfType().Any() ?? false) - || response.FinishReason == ChatFinishReason.ToolCalls; - - private static bool ContainsFunctionCalls(List responses) => - responses.Any(m => m.Contents?.OfType().Any() ?? false) - || responses.Any(m => m.FinishReason == ChatFinishReason.ToolCalls); } From 88cb6f11817785dcd3cde24cab3b6998db9dbab5 Mon Sep 17 00:00:00 2001 From: Alex Sohn Date: Sun, 26 Oct 2025 23:19:19 -0400 Subject: [PATCH 15/86] Add Env var checks for OpenAI API key in samples --- samples/Sentry.Samples.ME.AI.AspNetCore/Program.cs | 14 ++++++++++++-- samples/Sentry.Samples.ME.AI.Console/Program.cs | 11 +++++++++-- 2 files changed, 21 insertions(+), 4 deletions(-) diff --git a/samples/Sentry.Samples.ME.AI.AspNetCore/Program.cs b/samples/Sentry.Samples.ME.AI.AspNetCore/Program.cs index 6730823374..be78c32305 100644 --- a/samples/Sentry.Samples.ME.AI.AspNetCore/Program.cs +++ b/samples/Sentry.Samples.ME.AI.AspNetCore/Program.cs @@ -1,3 +1,4 @@ +#nullable enable using Microsoft.Extensions.AI; using Sentry.Extensions.AI; @@ -18,7 +19,16 @@ options.Experimental.EnableLogs = true; }); -var openAIClient = new OpenAI.Chat.ChatClient("gpt-4o-mini", Environment.GetEnvironmentVariable("OPENAI_API_KEY")) +// This sample uses Microsoft.Extensions.AI.OpenAI +// Check whether OPENAI_API_KEY env var exists +const string varName = "OPENAI_API_KEY"; +var openAiApiKey = Environment.GetEnvironmentVariable(varName); +if (openAiApiKey == null) +{ + throw new InvalidOperationException($"Environment variable for OpenAI API key '{varName}' is not set."); +} + +var openAiClient = new OpenAI.Chat.ChatClient("gpt-4o-mini", openAiApiKey) .AsIChatClient() .WithSentry(options => { @@ -27,7 +37,7 @@ options.IncludeAIResponseContent = true; }); -var client = new ChatClientBuilder(openAIClient) +var client = new ChatClientBuilder(openAiClient) .UseFunctionInvocation() .Build(); diff --git a/samples/Sentry.Samples.ME.AI.Console/Program.cs b/samples/Sentry.Samples.ME.AI.Console/Program.cs index f32cfd0ae9..d57a8076d1 100644 --- a/samples/Sentry.Samples.ME.AI.Console/Program.cs +++ b/samples/Sentry.Samples.ME.AI.Console/Program.cs @@ -7,8 +7,15 @@ logger.LogInformation("Starting Microsoft.Extensions.AI sample with Sentry instrumentation"); +const string varName = "OPENAI_API_KEY"; +var openAiApiKey = Environment.GetEnvironmentVariable(varName); +if (openAiApiKey == null) +{ + throw new InvalidOperationException($"Environment variable for OpenAI API key '{varName}' is not set."); +} + // Create OpenAI API client and wrap it with Sentry instrumentation -var openAIClient = new OpenAI.Chat.ChatClient("gpt-4o-mini", Environment.GetEnvironmentVariable("OPENAI_API_KEY")) +var openAiClient = new OpenAI.Chat.ChatClient("gpt-4o-mini", openAiApiKey) .AsIChatClient() .WithSentry(options => { @@ -29,7 +36,7 @@ options.InitializeSdk = true; }); -var client = new ChatClientBuilder(openAIClient) +var client = new ChatClientBuilder(openAiClient) .UseFunctionInvocation() .Build(); From 6f4bf826e856c11f1bd0b6960b4bb684d582627a Mon Sep 17 00:00:00 2001 From: Alex Sohn Date: Tue, 28 Oct 2025 14:00:19 -0400 Subject: [PATCH 16/86] Fix concurrency issue when multiple GetResponseAsync is called --- .../Extensions/SentryAIExtensions.cs | 6 + src/Sentry.Extensions.AI/SentryAIConstants.cs | 27 ++++ src/Sentry.Extensions.AI/SentryChatClient.cs | 128 ++++++++++++++---- .../SentryInstrumentedFunction.cs | 53 +++++--- 4 files changed, 175 insertions(+), 39 deletions(-) create mode 100644 src/Sentry.Extensions.AI/SentryAIConstants.cs diff --git a/src/Sentry.Extensions.AI/Extensions/SentryAIExtensions.cs b/src/Sentry.Extensions.AI/Extensions/SentryAIExtensions.cs index 91e4ddd1b8..be6c2e951e 100644 --- a/src/Sentry.Extensions.AI/Extensions/SentryAIExtensions.cs +++ b/src/Sentry.Extensions.AI/Extensions/SentryAIExtensions.cs @@ -22,6 +22,7 @@ public static ChatOptions WithSentry( return options; } + // We wrap tools here so we don't have to wrap them each time we grab the response for (var i = 0; i < options.Tools.Count; i++) { var tool = options.Tools[i]; @@ -31,6 +32,11 @@ public static ChatOptions WithSentry( } } + // SentrySpanStore additional property will store the dictionary to keep track of which span is + // the "agent" span, which will persist through different chat/tool calls. + options.AdditionalProperties ??= new AdditionalPropertiesDictionary(); + options.AdditionalProperties.TryAdd("SentryChatMessageAgentSpan", new ConcurrentDictionary()); + return options; } diff --git a/src/Sentry.Extensions.AI/SentryAIConstants.cs b/src/Sentry.Extensions.AI/SentryAIConstants.cs new file mode 100644 index 0000000000..ac4ad771f1 --- /dev/null +++ b/src/Sentry.Extensions.AI/SentryAIConstants.cs @@ -0,0 +1,27 @@ +using Microsoft.Extensions.AI; + +namespace Sentry.Extensions.AI; + +internal static class SentryAIConstants +{ + /// + /// + /// Sentry will add a to AdditionalAttribute in . + /// + /// + /// This constant represents the string key to get the span which represents the agent span. + /// + /// + internal const string OptionsAdditionalAttributeAgentSpanName = "SentryChatMessageAgentSpan"; + + /// + /// + /// When an LLM uses a tool, Sentry will add an argument to . + /// The additional argument will contain the request which initialized the tool call. + /// + /// + /// This constant represents the string key to get the message. + /// + /// + internal const string KeyMessageFunctionArgumentDictKey = "SentrySpanToMessageDictKey"; +} diff --git a/src/Sentry.Extensions.AI/SentryChatClient.cs b/src/Sentry.Extensions.AI/SentryChatClient.cs index c1be3673bf..dc405e3a71 100644 --- a/src/Sentry.Extensions.AI/SentryChatClient.cs +++ b/src/Sentry.Extensions.AI/SentryChatClient.cs @@ -7,7 +7,6 @@ internal sealed class SentryChatClient : DelegatingChatClient { private readonly HubAdapter _hub; private readonly SentryAIOptions _sentryAIOptions; - internal static ISpan? RootSpan; public SentryChatClient(IChatClient client, Action? configure = null) : base(client) { @@ -27,12 +26,15 @@ public override async Task GetResponseAsync(IEnumerable GetResponseAsync(IEnumerable GetResponseAsync(IEnumerable - public override async IAsyncEnumerable GetStreamingResponseAsync(IEnumerable messages, + public override async IAsyncEnumerable GetStreamingResponseAsync( + IEnumerable messages, ChatOptions? options = null, [EnumeratorCancellation] CancellationToken cancellationToken = new()) { - var outerSpan = EnsureRootSpanExists(); - var innerSpan = CreateChatSpan(options, outerSpan); + var chatMessages = messages as ChatMessage[] ?? messages.ToArray(); + var keyMessage = chatMessages[0]; + var outerSpan = CreateOrGetRootSpan(keyMessage, options); + var innerSpan = CreateChatSpan(outerSpan, options); + var spanDict = GetMessageToSpanDict(options); + SetMessageToSpanDict(keyMessage, outerSpan, options); + ChatResponseUpdate? current = null; var responses = new List(); - var chatMessages = messages as ChatMessage[] ?? messages.ToArray(); var enumerator = base .GetStreamingResponseAsync(chatMessages, options, cancellationToken) .GetAsyncEnumerator(cancellationToken); SentryAISpanEnricher.EnrichWithRequest(innerSpan, chatMessages, options, _sentryAIOptions); - ChatResponseUpdate? current = null; while (true) { try { var hasNext = await enumerator.MoveNextAsync().ConfigureAwait(false); + if (!hasNext) { SentryAISpanEnricher.EnrichWithStreamingResponse(innerSpan, responses, _sentryAIOptions); @@ -95,7 +105,11 @@ public override async IAsyncEnumerable GetStreamingResponseA if (shouldFinishRootSpan) { outerSpan.Finish(SpanStatus.Ok); - RootSpan = null; + spanDict.Remove(keyMessage, out _); + } + else if (current?.FinishReason == ChatFinishReason.ToolCalls) + { + WrapFunctionCallsInResponse(current, keyMessage); } yield break; @@ -109,7 +123,7 @@ public override async IAsyncEnumerable GetStreamingResponseA innerSpan.Finish(ex); outerSpan.Finish(ex); _hub.CaptureException(ex); - RootSpan = null; + spanDict.Remove(keyMessage, out _); throw; } @@ -117,20 +131,28 @@ public override async IAsyncEnumerable GetStreamingResponseA } } - private ISpan EnsureRootSpanExists() + /// + /// We create an entry in _spans concurrent dictionary to keep track of + /// what root span to use in consequent calls of or + /// + /// + /// + /// + private ISpan CreateOrGetRootSpan(ChatMessage message, ChatOptions? options) { - if (RootSpan == null) + var spanDict = GetMessageToSpanDict(options); + if (!spanDict.TryGetValue(message, out var rootSpan)) { var invokeSpanName = $"invoke_agent {InnerClient.GetType().Name}"; const string invokeOperation = "gen_ai.invoke_agent"; - RootSpan = _hub.StartSpan(invokeOperation, invokeSpanName); - RootSpan.SetData("gen_ai.agent.name", $"{InnerClient.GetType().Name}"); + rootSpan = _hub.StartSpan(invokeOperation, invokeSpanName); + rootSpan.SetData("gen_ai.agent.name", $"{InnerClient.GetType().Name}"); } - return RootSpan; + return rootSpan; } - private static ISpan CreateChatSpan(ChatOptions? options, ISpan outerSpan) + private static ISpan CreateChatSpan(ISpan outerSpan, ChatOptions? options) { const string chatOperation = "gen_ai.chat"; var chatSpanName = options is null || string.IsNullOrEmpty(options.ModelId) @@ -138,4 +160,64 @@ private static ISpan CreateChatSpan(ChatOptions? options, ISpan outerSpan) : $"chat {options.ModelId}"; return outerSpan.StartChild(chatOperation, chatSpanName); } + + internal static ConcurrentDictionary GetMessageToSpanDict(ChatOptions? options = null) + { + if (options?.AdditionalProperties?.TryGetValue>( + SentryAIConstants.OptionsAdditionalAttributeAgentSpanName, out var agentSpanDict) == true) + { + return agentSpanDict; + } + + // If we couldn't find the dictionary, we just initiate it now + agentSpanDict = new ConcurrentDictionary(); + if (options == null) + { + return agentSpanDict; + } + + options.AdditionalProperties = new AdditionalPropertiesDictionary(); + options.AdditionalProperties.TryAdd(SentryAIConstants.OptionsAdditionalAttributeAgentSpanName, agentSpanDict); + return agentSpanDict; + } + + private static void SetMessageToSpanDict(ChatMessage message, ISpan agentSpan, ChatOptions? options) + { + ConcurrentDictionary? agentSpanDict = null; + if (options == null || + options.AdditionalProperties?.TryGetValue(SentryAIConstants.OptionsAdditionalAttributeAgentSpanName, + out agentSpanDict) == false) + { + return; + } + + agentSpanDict?.TryAdd(message, agentSpan); + } + + private static void WrapFunctionCallsInResponse(ChatResponse response, ChatMessage keyMessage) + { + foreach (var message in response.Messages) + { + foreach (var content in message.Contents) + { + if (content is FunctionCallContent functionCall) + { + (functionCall.Arguments ??= new Dictionary()).Add( + SentryAIConstants.KeyMessageFunctionArgumentDictKey, keyMessage); + } + } + } + } + + private static void WrapFunctionCallsInResponse(ChatResponseUpdate response, ChatMessage keyMessage) + { + foreach (var content in response.Contents) + { + if (content is FunctionCallContent functionCall) + { + (functionCall.Arguments ??= new Dictionary()).Add( + SentryAIConstants.KeyMessageFunctionArgumentDictKey, keyMessage); + } + } + } } diff --git a/src/Sentry.Extensions.AI/SentryInstrumentedFunction.cs b/src/Sentry.Extensions.AI/SentryInstrumentedFunction.cs index 724ccefba1..322774d033 100644 --- a/src/Sentry.Extensions.AI/SentryInstrumentedFunction.cs +++ b/src/Sentry.Extensions.AI/SentryInstrumentedFunction.cs @@ -3,29 +3,17 @@ namespace Sentry.Extensions.AI; -internal sealed class SentryInstrumentedFunction(AIFunction innerFunction, ChatOptions? options = null) +internal sealed class SentryInstrumentedFunction(AIFunction innerFunction, ChatOptions options) : DelegatingAIFunction(innerFunction) { - private readonly HubAdapter _hub = HubAdapter.Instance; + private static readonly HubAdapter Hub = HubAdapter.Instance; protected override async ValueTask InvokeCoreAsync( AIFunctionArguments arguments, CancellationToken cancellationToken) { - const string operation = "gen_ai.execute_tool"; - var spanName = $"execute_tool {Name}"; - var currSpan = SentryChatClient.RootSpan == null ? - _hub.StartSpan(operation, spanName) : - SentryChatClient.RootSpan.StartChild(operation, spanName); - - currSpan.SetData("gen_ai.request.model", options?.ModelId); - - currSpan.SetData("gen_ai.operation.name", "execute_tool"); - currSpan.SetData("gen_ai.tool.name", Name); - currSpan.SetData("gen_ai.tool.description", Description); - - currSpan.SetData("gen_ai.tool.input", arguments); - + var currSpan = InitToolSpan(arguments); + RemoveSentryArgs(ref arguments); try { var result = await base.InvokeCoreAsync(arguments, cancellationToken).ConfigureAwait(false); @@ -44,4 +32,37 @@ internal sealed class SentryInstrumentedFunction(AIFunction innerFunction, ChatO throw; } } + + private ISpan InitToolSpan(AIFunctionArguments arguments) + { + const string operation = "gen_ai.execute_tool"; + var spanName = $"execute_tool {Name}"; + ISpan currSpan; + + if (arguments.TryGetValue(SentryAIConstants.KeyMessageFunctionArgumentDictKey, + out var keyMessage) + && keyMessage is ChatMessage message + && SentryChatClient.GetMessageToSpanDict(options).TryGetValue(message, out var agentSpan)) + { + currSpan = agentSpan.StartChild(operation, spanName); + } + else + { + // If we couldn't find the agent span, just attach it to the hub's current scope + currSpan = Hub.StartSpan(operation, spanName); + } + + currSpan.SetData("gen_ai.request.model", options?.ModelId); + currSpan.SetData("gen_ai.operation.name", "execute_tool"); + currSpan.SetData("gen_ai.tool.name", Name); + currSpan.SetData("gen_ai.tool.description", Description); + currSpan.SetData("gen_ai.tool.input", arguments); + + return currSpan; + } + + private static void RemoveSentryArgs(ref AIFunctionArguments arguments) + { + arguments.Remove(SentryAIConstants.KeyMessageFunctionArgumentDictKey); + } } From d813c82f8050db3a705f95f83628a91a2bf87b93 Mon Sep 17 00:00:00 2001 From: Alex Sohn Date: Tue, 28 Oct 2025 15:20:39 -0400 Subject: [PATCH 17/86] fix tests --- .../SentryAIExtensionsTests.cs | 6 +++-- .../SentryChatClientTests.cs | 12 ++++----- .../SentryInstrumentedFunctionTests.cs | 27 ++++++++++++------- 3 files changed, 28 insertions(+), 17 deletions(-) diff --git a/test/Sentry.Extensions.AI.Tests/SentryAIExtensionsTests.cs b/test/Sentry.Extensions.AI.Tests/SentryAIExtensionsTests.cs index d82b168f7e..a9525fda7c 100644 --- a/test/Sentry.Extensions.AI.Tests/SentryAIExtensionsTests.cs +++ b/test/Sentry.Extensions.AI.Tests/SentryAIExtensionsTests.cs @@ -67,7 +67,8 @@ public void WithSentry_ChatOptions_DoesNotDoubleWrapSentryInstrumentedFunction() { // Arrange var mockFunction = Substitute.For(); - var alreadyInstrumentedFunction = new SentryInstrumentedFunction(mockFunction); + var mockOption = Substitute.For(); + var alreadyInstrumentedFunction = new SentryInstrumentedFunction(mockFunction, mockOption); var options = new ChatOptions { @@ -93,7 +94,8 @@ public void WithSentry_ChatOptions_HandlesMultipleFunctions() var mockFunction2 = Substitute.For(); mockFunction2.Name.Returns("Function2"); - var alreadyInstrumentedFunction = new SentryInstrumentedFunction(mockFunction1); + var mockOption1 = Substitute.For(); + var alreadyInstrumentedFunction = new SentryInstrumentedFunction(mockFunction1, mockOption1); var options = new ChatOptions { diff --git a/test/Sentry.Extensions.AI.Tests/SentryChatClientTests.cs b/test/Sentry.Extensions.AI.Tests/SentryChatClientTests.cs index 1bfa250aaa..3cd5d0e9d7 100644 --- a/test/Sentry.Extensions.AI.Tests/SentryChatClientTests.cs +++ b/test/Sentry.Extensions.AI.Tests/SentryChatClientTests.cs @@ -1,6 +1,5 @@ #nullable enable using Microsoft.Extensions.AI; -using Sentry.Extensions.AI; namespace Sentry.Extensions.AI.Tests; @@ -20,7 +19,8 @@ public async Task CompleteAsync_CallsInnerClient() var res = await sentryChatClient.GetResponseAsync([new ChatMessage(ChatRole.User, "hi")], null); Assert.Equal([message], res.Messages); - await inner.Received(1).GetResponseAsync(Arg.Any>(), Arg.Any(), Arg.Any()); + await inner.Received(1).GetResponseAsync(Arg.Any>(), Arg.Any(), + Arg.Any()); } [Fact] @@ -28,7 +28,8 @@ public async Task CompleteStreamingAsync_CallsInnerClient() { var inner = Substitute.For(); - inner.GetStreamingResponseAsync(Arg.Any>(), Arg.Any(), Arg.Any()) + inner.GetStreamingResponseAsync(Arg.Any>(), Arg.Any(), + Arg.Any()) .Returns(CreateTestStreamingUpdatesAsync()); var client = new SentryChatClient(inner); @@ -43,7 +44,8 @@ public async Task CompleteStreamingAsync_CallsInnerClient() Assert.Equal("Hello", results[0].Text); Assert.Equal(" World!", results[1].Text); - inner.Received(1).GetStreamingResponseAsync(Arg.Any>(), Arg.Any(), Arg.Any()); + inner.Received(1).GetStreamingResponseAsync(Arg.Any>(), Arg.Any(), + Arg.Any()); } private static async IAsyncEnumerable CreateTestStreamingUpdatesAsync() @@ -53,5 +55,3 @@ private static async IAsyncEnumerable CreateTestStreamingUpd yield return new ChatResponseUpdate(ChatRole.System, " World!"); } } - - diff --git a/test/Sentry.Extensions.AI.Tests/SentryInstrumentedFunctionTests.cs b/test/Sentry.Extensions.AI.Tests/SentryInstrumentedFunctionTests.cs index 82e368fbca..ccc6e8c781 100644 --- a/test/Sentry.Extensions.AI.Tests/SentryInstrumentedFunctionTests.cs +++ b/test/Sentry.Extensions.AI.Tests/SentryInstrumentedFunctionTests.cs @@ -14,7 +14,8 @@ public async Task InvokeCoreAsync_WithValidFunction_ReturnsResult() // Arrange using var sentryDisposable = SentryHelpers.InitializeSdk(); var testFunction = AIFunctionFactory.Create(() => "test result", "TestFunction", "Test function description"); - var sentryFunction = new SentryInstrumentedFunction(testFunction); + var mockOption = Substitute.For(); + var sentryFunction = new SentryInstrumentedFunction(testFunction, mockOption); var arguments = new AIFunctionArguments(); // Act @@ -41,7 +42,8 @@ public async Task InvokeCoreAsync_WithNullResult_ReturnsNull() // Arrange using var sentryDisposable = SentryHelpers.InitializeSdk(); var testFunction = AIFunctionFactory.Create(object? () => null, "TestFunction", "Test function description"); - var sentryFunction = new SentryInstrumentedFunction(testFunction); + var mockOption = Substitute.For(); + var sentryFunction = new SentryInstrumentedFunction(testFunction, mockOption); var arguments = new AIFunctionArguments(); // Act @@ -66,7 +68,8 @@ public async Task InvokeCoreAsync_WithJsonNullResult_ReturnsJsonElement() using var sentryDisposable = SentryHelpers.InitializeSdk(); var jsonNullElement = JsonSerializer.Deserialize("null"); var testFunction = AIFunctionFactory.Create(() => jsonNullElement, "TestFunction", "Test function description"); - var sentryFunction = new SentryInstrumentedFunction(testFunction); + var mockOption = Substitute.For(); + var sentryFunction = new SentryInstrumentedFunction(testFunction, mockOption); var arguments = new AIFunctionArguments(); // Act @@ -86,7 +89,8 @@ public async Task InvokeCoreAsync_WithJsonElementResult_CallsToStringForSpanOutp using var sentryDisposable = SentryHelpers.InitializeSdk(); var jsonElement = JsonSerializer.Deserialize("\"test output\""); var testFunction = AIFunctionFactory.Create(() => jsonElement, "TestFunction", "Test function description"); - var sentryFunction = new SentryInstrumentedFunction(testFunction); + var mockOption = Substitute.For(); + var sentryFunction = new SentryInstrumentedFunction(testFunction, mockOption); var arguments = new AIFunctionArguments(); // Act @@ -109,7 +113,8 @@ public async Task InvokeCoreAsync_WithComplexResult_ReturnsObject() using var sentryDisposable = SentryHelpers.InitializeSdk(); var resultObject = new { message = "test", count = 42 }; var testFunction = AIFunctionFactory.Create(() => resultObject, "TestFunction", "Test function description"); - var sentryFunction = new SentryInstrumentedFunction(testFunction); + var mockOption = Substitute.For(); + var sentryFunction = new SentryInstrumentedFunction(testFunction, mockOption); var arguments = new AIFunctionArguments(); // Act @@ -138,7 +143,8 @@ public async Task InvokeCoreAsync_WhenFunctionThrows_PropagatesException() using var sentryDisposable = SentryHelpers.InitializeSdk(); var expectedException = new InvalidOperationException("Test exception"); var testFunction = AIFunctionFactory.Create(new Func(() => throw expectedException), "TestFunction", "Test function description"); - var sentryFunction = new SentryInstrumentedFunction(testFunction); + var mockOption = Substitute.For(); + var sentryFunction = new SentryInstrumentedFunction(testFunction, mockOption); var arguments = new AIFunctionArguments(); // Act & Assert @@ -159,7 +165,8 @@ public async Task InvokeCoreAsync_WithCancellation_PropagatesCancellation() return "result"; }, "TestFunction", "Test function description"); - var sentryFunction = new SentryInstrumentedFunction(testFunction); + var mockOption = Substitute.For(); + var sentryFunction = new SentryInstrumentedFunction(testFunction, mockOption); var arguments = new AIFunctionArguments(); var cts = new CancellationTokenSource(); cts.Cancel(); @@ -181,7 +188,8 @@ public async Task InvokeCoreAsync_WithParameters_PassesParametersCorrectly() return "result"; }, "TestFunction", "Test function description"); - var sentryFunction = new SentryInstrumentedFunction(testFunction); + var mockOption = Substitute.For(); + var sentryFunction = new SentryInstrumentedFunction(testFunction, mockOption); var arguments = new AIFunctionArguments { ["param1"] = "value1" }; // Act @@ -199,7 +207,8 @@ public void Constructor_PreservesInnerFunctionProperties() var testFunction = AIFunctionFactory.Create(() => "test", "TestFunction", "Test function description"); // Act - var sentryFunction = new SentryInstrumentedFunction(testFunction); + var mockOption = Substitute.For(); + var sentryFunction = new SentryInstrumentedFunction(testFunction, mockOption); // Assert Assert.Equal("TestFunction", sentryFunction.Name); From 03143d79102843b9e15258ea2047f517e3c346ae Mon Sep 17 00:00:00 2001 From: Alex Sohn Date: Tue, 28 Oct 2025 15:47:17 -0400 Subject: [PATCH 18/86] cleanup SentryChatClient --- src/Sentry.Extensions.AI/SentryChatClient.cs | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/Sentry.Extensions.AI/SentryChatClient.cs b/src/Sentry.Extensions.AI/SentryChatClient.cs index dc405e3a71..7d66bc2a0d 100644 --- a/src/Sentry.Extensions.AI/SentryChatClient.cs +++ b/src/Sentry.Extensions.AI/SentryChatClient.cs @@ -109,7 +109,7 @@ public override async IAsyncEnumerable GetStreamingResponseA } else if (current?.FinishReason == ChatFinishReason.ToolCalls) { - WrapFunctionCallsInResponse(current, keyMessage); + InjectMessageToFunctionCallArguments(current, keyMessage); } yield break; @@ -176,7 +176,7 @@ internal static ConcurrentDictionary GetMessageToSpanDict(Ch return agentSpanDict; } - options.AdditionalProperties = new AdditionalPropertiesDictionary(); + options.AdditionalProperties ??= new AdditionalPropertiesDictionary(); options.AdditionalProperties.TryAdd(SentryAIConstants.OptionsAdditionalAttributeAgentSpanName, agentSpanDict); return agentSpanDict; } @@ -209,7 +209,7 @@ private static void WrapFunctionCallsInResponse(ChatResponse response, ChatMessa } } - private static void WrapFunctionCallsInResponse(ChatResponseUpdate response, ChatMessage keyMessage) + private static void InjectMessageToFunctionCallArguments(ChatResponseUpdate response, ChatMessage keyMessage) { foreach (var content in response.Contents) { From dd02e4b35bdb39340ccd9dd1695004255a618a18 Mon Sep 17 00:00:00 2001 From: Alex Sohn Date: Thu, 30 Oct 2025 15:40:18 -0400 Subject: [PATCH 19/86] update sample OpenAI packages --- .../Program.cs | 83 +++++++++++-------- .../Sentry.Samples.ME.AI.AspNetCore.csproj | 3 +- .../Sentry.Samples.ME.AI.Console/Program.cs | 15 ---- .../Sentry.Samples.ME.AI.Console.csproj | 3 +- 4 files changed, 52 insertions(+), 52 deletions(-) diff --git a/samples/Sentry.Samples.ME.AI.AspNetCore/Program.cs b/samples/Sentry.Samples.ME.AI.AspNetCore/Program.cs index be78c32305..d77115603d 100644 --- a/samples/Sentry.Samples.ME.AI.AspNetCore/Program.cs +++ b/samples/Sentry.Samples.ME.AI.AspNetCore/Program.cs @@ -54,14 +54,15 @@ { ModelId = "gpt-4o-mini", MaxOutputTokens = 1024, - Tools = [ - // Tool 1: Quick response with minimal delay + Tools = + [ + // Tool 1: Quick response with minimal delay, but throws an error when trying to get Alice's age AIFunctionFactory.Create(async (string personName) => { logger.LogInformation("GetPersonAge called for {PersonName}", personName); await Task.Delay(100); // 100ms delay - return personName switch { - "Alice" => "25", + return personName switch + { "Bob" => "30", "Charlie" => "35", _ => "40" @@ -73,7 +74,8 @@ { logger.LogInformation("GetWeather called for {Location}", location); await Task.Delay(500); // 500ms delay - return location.ToLower() switch { + return location.ToLower() switch + { "new york" => "Sunny, 72°F", "london" => "Cloudy, 60°F", "tokyo" => "Rainy, 68°F", @@ -92,37 +94,42 @@ // Tool 4: Tool that can reference other data AIFunctionFactory.Create(async (string personName, string location) => - { - logger.LogInformation("GetPersonInfo called for {PersonName} in {Location}", personName, location); - await Task.Delay(300); // 300ms delay - var age = personName switch { - "Alice" => "25", - "Bob" => "30", - "Charlie" => "35", - _ => "40" - }; - var weather = location.ToLower() switch { - "new york" => "Sunny, 72°F", - "london" => "Cloudy, 60°F", - "tokyo" => "Rainy, 68°F", - _ => "Unknown weather conditions" - }; - return $"{personName} (age {age}) is experiencing {weather} weather in {location}"; - }, "GetPersonInfo", "Gets comprehensive info about a person in a specific location by combining age and weather data. Takes about 300ms."), + { + logger.LogInformation("GetPersonInfo called for {PersonName} in {Location}", personName, location); + await Task.Delay(300); // 300ms delay + var age = personName switch + { + "Alice" => "25", + "Bob" => "30", + "Charlie" => "35", + _ => "40" + }; + var weather = location.ToLower() switch + { + "new york" => "Sunny, 72°F", + "london" => "Cloudy, 60°F", + "tokyo" => "Rainy, 68°F", + _ => "Unknown weather conditions" + }; + return $"{personName} (age {age}) is experiencing {weather} weather in {location}"; + }, "GetPersonInfo", + "Gets comprehensive info about a person in a specific location by combining age and weather data. Takes about 300ms."), // Tool 5: Data aggregation tool that requests individual ages AIFunctionFactory.Create(async (int[] ages) => - { - logger.LogInformation("CalculateAverageAge called with ages: {Ages}", string.Join(", ", ages)); - await Task.Delay(200); // 200ms delay for calculation - if (ages.Length == 0) { - return "No ages provided"; - } - - var average = ages.Average(); - return $"Average age calculated: {average:F1} years from {ages.Length} people. Individual ages: {string.Join(", ", ages)}"; - }, "CalculateAverageAge", "Calculates the average from a list of ages. You should first get individual ages using GetPersonAge, then use this tool to calculate the average. Takes about 200ms to complete.") + logger.LogInformation("CalculateAverageAge called with ages: {Ages}", string.Join(", ", ages)); + await Task.Delay(200); // 200ms delay for calculation + if (ages.Length == 0) + { + return "No ages provided"; + } + + var average = ages.Average(); + return + $"Average age calculated: {average:F1} years from {ages.Length} people. Individual ages: {string.Join(", ", ages)}"; + }, "CalculateAverageAge", + "Calculates the average from a list of ages. You should first get individual ages using GetPersonAge, then use this tool to calculate the average. Takes about 200ms to complete.") ] }.WithSentry(); @@ -130,7 +137,17 @@ { var streamingResponse = new List(); await foreach (var update in chatClient.GetStreamingResponseAsync([ - new ChatMessage(ChatRole.User, "Please help me with the following tasks: 1) Find Alice's age, 2) Get weather in New York, 3) Calculate a complex result for number 15, 4) Get comprehensive info for Bob in London, and 5) Calculate average age for Alice, Bob, and Charlie (first get each person's age individually using GetPersonAge, then use CalculateAverageAge with those results). Please use the appropriate tools for each task and demonstrate tool chaining where needed.") + new ChatMessage(ChatRole.User, + """ + Please help me with the following tasks: + 1) Find Alice's age, + 2) Get weather in New York, + 3) Calculate a complex result for number 15, + 4) Get comprehensive info for Bob in London, + 5) Calculate average age for Alice, Bob, and Charlie + (first get each person's age individually using GetPersonAge, then use CalculateAverageAge with those results). + Please use the appropriate tools for each task and demonstrate tool chaining where needed. + """) ], options)) { if (!string.IsNullOrEmpty(update.Text)) diff --git a/samples/Sentry.Samples.ME.AI.AspNetCore/Sentry.Samples.ME.AI.AspNetCore.csproj b/samples/Sentry.Samples.ME.AI.AspNetCore/Sentry.Samples.ME.AI.AspNetCore.csproj index e8cfeab2e1..8db6a50410 100644 --- a/samples/Sentry.Samples.ME.AI.AspNetCore/Sentry.Samples.ME.AI.AspNetCore.csproj +++ b/samples/Sentry.Samples.ME.AI.AspNetCore/Sentry.Samples.ME.AI.AspNetCore.csproj @@ -5,8 +5,7 @@ - - + diff --git a/samples/Sentry.Samples.ME.AI.Console/Program.cs b/samples/Sentry.Samples.ME.AI.Console/Program.cs index d57a8076d1..87bbb09630 100644 --- a/samples/Sentry.Samples.ME.AI.Console/Program.cs +++ b/samples/Sentry.Samples.ME.AI.Console/Program.cs @@ -103,21 +103,6 @@ MaxOutputTokens = 1024 }.WithSentry(); -var streamingResponse = new List(); -await foreach (var update in client.GetStreamingResponseAsync([ - new ChatMessage(ChatRole.User, "Write a short poem about AI and monitoring. Keep it under 50 words.") - ], streamingOptions)) -{ - if (!string.IsNullOrEmpty(update.Text)) - { - Console.Write(update.Text); - streamingResponse.Add(update.Text); - } -} - -Console.WriteLine(); // New line after streaming -logger.LogInformation("Streaming Response completed: {StreamingText}", string.Concat(streamingResponse)); - logger.LogInformation("Microsoft.Extensions.AI sample completed! Check your Sentry dashboard for the trace data."); transaction.Finish(); diff --git a/samples/Sentry.Samples.ME.AI.Console/Sentry.Samples.ME.AI.Console.csproj b/samples/Sentry.Samples.ME.AI.Console/Sentry.Samples.ME.AI.Console.csproj index 5b97f854d5..533b6b336b 100644 --- a/samples/Sentry.Samples.ME.AI.Console/Sentry.Samples.ME.AI.Console.csproj +++ b/samples/Sentry.Samples.ME.AI.Console/Sentry.Samples.ME.AI.Console.csproj @@ -6,8 +6,7 @@ - - + From 2de51c637b5e2a29165bfd8f6c35924654161faa Mon Sep 17 00:00:00 2001 From: Alex Sohn Date: Thu, 30 Oct 2025 15:41:09 -0400 Subject: [PATCH 20/86] Use Activity from FICC to wrap the whole agent call --- .../Extensions/SentryAIExtensions.cs | 1 + .../Sentry.Extensions.AI.csproj | 2 +- .../SentryAIActivityListener.cs | 39 ++++ .../SentryAIActivitySource.cs | 10 ++ src/Sentry.Extensions.AI/SentryAIConstants.cs | 33 ++++ .../SentryAISpanEnricher.cs | 4 +- src/Sentry.Extensions.AI/SentryAIUtil.cs | 22 +++ src/Sentry.Extensions.AI/SentryChatClient.cs | 170 +++++------------- .../SentryInstrumentedFunction.cs | 33 +--- 9 files changed, 164 insertions(+), 150 deletions(-) create mode 100644 src/Sentry.Extensions.AI/SentryAIActivityListener.cs create mode 100644 src/Sentry.Extensions.AI/SentryAIActivitySource.cs create mode 100644 src/Sentry.Extensions.AI/SentryAIUtil.cs diff --git a/src/Sentry.Extensions.AI/Extensions/SentryAIExtensions.cs b/src/Sentry.Extensions.AI/Extensions/SentryAIExtensions.cs index be6c2e951e..aba92fb7a4 100644 --- a/src/Sentry.Extensions.AI/Extensions/SentryAIExtensions.cs +++ b/src/Sentry.Extensions.AI/Extensions/SentryAIExtensions.cs @@ -53,6 +53,7 @@ public static ChatOptions WithSentry( /// The instrumented public static IChatClient WithSentry(this IChatClient client, Action? configure = null) { + SentryAIActivityListener.Init(); return new SentryChatClient(client, configure); } } diff --git a/src/Sentry.Extensions.AI/Sentry.Extensions.AI.csproj b/src/Sentry.Extensions.AI/Sentry.Extensions.AI.csproj index 891c917097..2c57abd4f1 100644 --- a/src/Sentry.Extensions.AI/Sentry.Extensions.AI.csproj +++ b/src/Sentry.Extensions.AI/Sentry.Extensions.AI.csproj @@ -11,7 +11,7 @@ - + diff --git a/src/Sentry.Extensions.AI/SentryAIActivityListener.cs b/src/Sentry.Extensions.AI/SentryAIActivityListener.cs new file mode 100644 index 0000000000..b8a118eb82 --- /dev/null +++ b/src/Sentry.Extensions.AI/SentryAIActivityListener.cs @@ -0,0 +1,39 @@ +using Sentry.Extensibility; +using Sentry.Internal; + +namespace Sentry.Extensions.AI; + +/// +/// Listens to FunctionInvokingChatClient's Activity +/// +internal static class SentryAIActivityListener +{ + /// + /// Sentry's to tap into function invocation's Activity + /// + private static readonly ActivityListener FICCListener = new() + { + ShouldListenTo = s => s.Name.StartsWith(SentryAIConstants.SentryActivitySourceName), + Sample = (ref ActivityCreationOptions options) => + options.Name == SentryAIConstants.FICCActivityName ? + ActivitySamplingResult.AllDataAndRecorded : ActivitySamplingResult.None, + ActivityStarted = a => + { + var currSpan = HubAdapter.Instance.StartSpan(SentryAIConstants.SpanAttributes.InvokeAgentOperation, SentryAIConstants.SpanAttributes.InvokeAgentDescription); + a.SetFused(SentryAIConstants.SentryActivitySpanAttributeName, currSpan); + }, + ActivityStopped = a => + { + var currSpan = a.GetFused(SentryAIConstants.SentryActivitySpanAttributeName); + currSpan?.Finish(SpanStatus.Ok); + }, + }; + + /// + /// Initializes Sentry's to tap into FunctionInvokingChatClient's Activity + /// + internal static void Init() + { + ActivitySource.AddActivityListener(FICCListener); + } +} diff --git a/src/Sentry.Extensions.AI/SentryAIActivitySource.cs b/src/Sentry.Extensions.AI/SentryAIActivitySource.cs new file mode 100644 index 0000000000..faab008b7a --- /dev/null +++ b/src/Sentry.Extensions.AI/SentryAIActivitySource.cs @@ -0,0 +1,10 @@ +using Microsoft.Extensions.AI; + +namespace Sentry.Extensions.AI; + +/// Sentry's to be used in +internal static class SentryAIActivitySource +{ + /// Sentry's to be used in + internal static ActivitySource Instance { get; } = new(SentryAIConstants.SentryActivitySourceName); +} diff --git a/src/Sentry.Extensions.AI/SentryAIConstants.cs b/src/Sentry.Extensions.AI/SentryAIConstants.cs index ac4ad771f1..9be92f4a72 100644 --- a/src/Sentry.Extensions.AI/SentryAIConstants.cs +++ b/src/Sentry.Extensions.AI/SentryAIConstants.cs @@ -24,4 +24,37 @@ internal static class SentryAIConstants /// /// internal const string KeyMessageFunctionArgumentDictKey = "SentrySpanToMessageDictKey"; + + /// + /// The string which FunctionInvokingChatClient(FICC) uses to start the tool call . + /// + /// + /// This string is valid from version 9.10 (inclusive). Previous versions of Microsoft.Extensions.AI has + /// a lot of different strings to run StartActivity. + /// + internal const string FICCActivityName = "orchestrate_tools"; + + /// + /// The string we use to identify our . + /// + internal const string SentryActivitySourceName = "Sentry.AgentMonitoring"; + + /// + /// The string we use to retrieve the span from the using a Fused property + /// + internal const string SentryActivitySpanAttributeName = "SentryCurrSpan"; + + internal static class SpanAttributes + { + internal const string InvokeAgentOperation = "gen_ai.invoke_agent"; + internal const string InvokeAgentDescription = "invoke_agent"; + internal const string ChatOperation = "gen_ai.chat"; + internal const string ToolCallOperation = "gen_ai.execute_tool"; + internal const string RequestModel = "gen_ai.request.model"; + internal const string OperationName = "gen_ai.operation.name"; + internal const string ToolName = "gen_ai.tool.name"; + internal const string ToolDescription = "gen_ai.tool.description"; + internal const string ToolInput = "gen_ai.tool.input"; + } + } diff --git a/src/Sentry.Extensions.AI/SentryAISpanEnricher.cs b/src/Sentry.Extensions.AI/SentryAISpanEnricher.cs index 78e89cae0a..fa22a950c9 100644 --- a/src/Sentry.Extensions.AI/SentryAISpanEnricher.cs +++ b/src/Sentry.Extensions.AI/SentryAISpanEnricher.cs @@ -112,7 +112,7 @@ internal static void EnrichWithResponse(ISpan span, ChatResponse response, Sentr /// span to enrich /// a list of /// AI-specific options - public static void EnrichWithStreamingResponse(ISpan span, List messages, SentryAIOptions? aiOptions = null) + public static void EnrichWithStreamingResponses(ISpan span, List messages, SentryAIOptions? aiOptions = null) { var inputTokenCount = 0L; var outputTokenCount = 0L; @@ -138,7 +138,7 @@ public static void EnrichWithStreamingResponse(ISpan span, List(SentryAIConstants.SentryActivitySpanAttributeName) is { } span) + { + return span; + } + + currActivity = currActivity?.Parent; + } + + return null; + } +} diff --git a/src/Sentry.Extensions.AI/SentryChatClient.cs b/src/Sentry.Extensions.AI/SentryChatClient.cs index 7d66bc2a0d..3d65fe6e31 100644 --- a/src/Sentry.Extensions.AI/SentryChatClient.cs +++ b/src/Sentry.Extensions.AI/SentryChatClient.cs @@ -1,11 +1,12 @@ using Microsoft.Extensions.AI; using Sentry.Extensibility; +using Sentry.Internal; namespace Sentry.Extensions.AI; internal sealed class SentryChatClient : DelegatingChatClient { - private readonly HubAdapter _hub; + private readonly HubAdapter _hub = HubAdapter.Instance; private readonly SentryAIOptions _sentryAIOptions; public SentryChatClient(IChatClient client, Action? configure = null) : base(client) @@ -17,8 +18,6 @@ public SentryChatClient(IChatClient client, Action? configure = { SentrySdk.Init(_sentryAIOptions); } - - _hub = HubAdapter.Instance; } /// @@ -26,12 +25,10 @@ public override async Task GetResponseAsync(IEnumerable GetResponseAsync(IEnumerable @@ -73,14 +63,12 @@ public override async IAsyncEnumerable GetStreamingResponseA ChatOptions? options = null, [EnumeratorCancellation] CancellationToken cancellationToken = new()) { + // Convert to array to avoid multiple enumeration var chatMessages = messages as ChatMessage[] ?? messages.ToArray(); - var keyMessage = chatMessages[0]; - var outerSpan = CreateOrGetRootSpan(keyMessage, options); + var outerSpan = TryGetRootSpan(options); var innerSpan = CreateChatSpan(outerSpan, options); - var spanDict = GetMessageToSpanDict(options); - SetMessageToSpanDict(keyMessage, outerSpan, options); - ChatResponseUpdate? current = null; + var hasNext = true; var responses = new List(); var enumerator = base .GetStreamingResponseAsync(chatMessages, options, cancellationToken) @@ -89,29 +77,16 @@ public override async IAsyncEnumerable GetStreamingResponseA while (true) { + ChatResponseUpdate? current; try { - var hasNext = await enumerator.MoveNextAsync().ConfigureAwait(false); + hasNext = await enumerator.MoveNextAsync().ConfigureAwait(false); if (!hasNext) { - SentryAISpanEnricher.EnrichWithStreamingResponse(innerSpan, responses, _sentryAIOptions); + SentryAISpanEnricher.EnrichWithStreamingResponses(innerSpan, responses, _sentryAIOptions); innerSpan.Finish(SpanStatus.Ok); - // Only if currentFinishReason is to stop, then we finish the RootSpan and set it to null. - // This allows the RootSpan to persist throughout multiple `GetStreamingResponseAsync` calls - // happening before and after tool calls - var shouldFinishRootSpan = current?.FinishReason == ChatFinishReason.Stop; - if (shouldFinishRootSpan) - { - outerSpan.Finish(SpanStatus.Ok); - spanDict.Remove(keyMessage, out _); - } - else if (current?.FinishReason == ChatFinishReason.ToolCalls) - { - InjectMessageToFunctionCallArguments(current, keyMessage); - } - yield break; } @@ -121,103 +96,52 @@ public override async IAsyncEnumerable GetStreamingResponseA catch (Exception ex) { innerSpan.Finish(ex); - outerSpan.Finish(ex); _hub.CaptureException(ex); - spanDict.Remove(keyMessage, out _); throw; } + finally + { + // if options was null, and we don't have next text, we need to finish root span immediately + if (options == null && !hasNext) + { + outerSpan?.Finish(SpanStatus.Ok); + } + } yield return current; } } - /// - /// We create an entry in _spans concurrent dictionary to keep track of - /// what root span to use in consequent calls of or - /// - /// - /// - /// - private ISpan CreateOrGetRootSpan(ChatMessage message, ChatOptions? options) + /// + public override object? GetService(Type serviceType, object? serviceKey = null) => + serviceType == typeof(ActivitySource) + ? SentryAIActivitySource.Instance + : base.GetService(serviceType, serviceKey); + + private ISpan? TryGetRootSpan(ChatOptions? options) { - var spanDict = GetMessageToSpanDict(options); - if (!spanDict.TryGetValue(message, out var rootSpan)) + // if options is null, we are not doing tool calls, so it's safe to just return an invoke_agent span + // straight from the hub + if (options == null) { - var invokeSpanName = $"invoke_agent {InnerClient.GetType().Name}"; - const string invokeOperation = "gen_ai.invoke_agent"; - rootSpan = _hub.StartSpan(invokeOperation, invokeSpanName); - rootSpan.SetData("gen_ai.agent.name", $"{InnerClient.GetType().Name}"); + return _hub.StartSpan(SentryAIConstants.SpanAttributes.InvokeAgentOperation, + SentryAIConstants.SpanAttributes.InvokeAgentDescription); } - return rootSpan; + // In FunctionInvokingChatClient, we should be able to get the agent span from the current activity + // The activity we attached the span to may be an ancestor of the current activity, we must search the parents for the span + // If we have tools available but couldn't find a span from activities, we can't have a root agent span. + var activeSpan = SentryAIUtil.GetActivitySpan(); + return activeSpan; } - private static ISpan CreateChatSpan(ISpan outerSpan, ChatOptions? options) + private ISpan CreateChatSpan(ISpan? outerSpan, ChatOptions? options) { - const string chatOperation = "gen_ai.chat"; var chatSpanName = options is null || string.IsNullOrEmpty(options.ModelId) ? "chat unknown model" : $"chat {options.ModelId}"; - return outerSpan.StartChild(chatOperation, chatSpanName); - } - - internal static ConcurrentDictionary GetMessageToSpanDict(ChatOptions? options = null) - { - if (options?.AdditionalProperties?.TryGetValue>( - SentryAIConstants.OptionsAdditionalAttributeAgentSpanName, out var agentSpanDict) == true) - { - return agentSpanDict; - } - - // If we couldn't find the dictionary, we just initiate it now - agentSpanDict = new ConcurrentDictionary(); - if (options == null) - { - return agentSpanDict; - } - - options.AdditionalProperties ??= new AdditionalPropertiesDictionary(); - options.AdditionalProperties.TryAdd(SentryAIConstants.OptionsAdditionalAttributeAgentSpanName, agentSpanDict); - return agentSpanDict; - } - - private static void SetMessageToSpanDict(ChatMessage message, ISpan agentSpan, ChatOptions? options) - { - ConcurrentDictionary? agentSpanDict = null; - if (options == null || - options.AdditionalProperties?.TryGetValue(SentryAIConstants.OptionsAdditionalAttributeAgentSpanName, - out agentSpanDict) == false) - { - return; - } - - agentSpanDict?.TryAdd(message, agentSpan); - } - - private static void WrapFunctionCallsInResponse(ChatResponse response, ChatMessage keyMessage) - { - foreach (var message in response.Messages) - { - foreach (var content in message.Contents) - { - if (content is FunctionCallContent functionCall) - { - (functionCall.Arguments ??= new Dictionary()).Add( - SentryAIConstants.KeyMessageFunctionArgumentDictKey, keyMessage); - } - } - } - } - - private static void InjectMessageToFunctionCallArguments(ChatResponseUpdate response, ChatMessage keyMessage) - { - foreach (var content in response.Contents) - { - if (content is FunctionCallContent functionCall) - { - (functionCall.Arguments ??= new Dictionary()).Add( - SentryAIConstants.KeyMessageFunctionArgumentDictKey, keyMessage); - } - } + return outerSpan is not null + ? outerSpan.StartChild(SentryAIConstants.SpanAttributes.ChatOperation, chatSpanName) + : _hub.StartSpan(SentryAIConstants.SpanAttributes.ChatOperation, chatSpanName); } } diff --git a/src/Sentry.Extensions.AI/SentryInstrumentedFunction.cs b/src/Sentry.Extensions.AI/SentryInstrumentedFunction.cs index 322774d033..c2fecfb9f5 100644 --- a/src/Sentry.Extensions.AI/SentryInstrumentedFunction.cs +++ b/src/Sentry.Extensions.AI/SentryInstrumentedFunction.cs @@ -13,7 +13,6 @@ internal sealed class SentryInstrumentedFunction(AIFunction innerFunction, ChatO CancellationToken cancellationToken) { var currSpan = InitToolSpan(arguments); - RemoveSentryArgs(ref arguments); try { var result = await base.InvokeCoreAsync(arguments, cancellationToken).ConfigureAwait(false); @@ -35,34 +34,20 @@ internal sealed class SentryInstrumentedFunction(AIFunction innerFunction, ChatO private ISpan InitToolSpan(AIFunctionArguments arguments) { - const string operation = "gen_ai.execute_tool"; var spanName = $"execute_tool {Name}"; - ISpan currSpan; + var agentSpan = SentryAIUtil.GetActivitySpan(); - if (arguments.TryGetValue(SentryAIConstants.KeyMessageFunctionArgumentDictKey, - out var keyMessage) - && keyMessage is ChatMessage message - && SentryChatClient.GetMessageToSpanDict(options).TryGetValue(message, out var agentSpan)) - { - currSpan = agentSpan.StartChild(operation, spanName); - } - else - { + var currSpan = agentSpan != null + ? agentSpan.StartChild(SentryAIConstants.SpanAttributes.ToolCallOperation, spanName) // If we couldn't find the agent span, just attach it to the hub's current scope - currSpan = Hub.StartSpan(operation, spanName); - } + : Hub.StartSpan(SentryAIConstants.SpanAttributes.ToolCallOperation, spanName); - currSpan.SetData("gen_ai.request.model", options?.ModelId); - currSpan.SetData("gen_ai.operation.name", "execute_tool"); - currSpan.SetData("gen_ai.tool.name", Name); - currSpan.SetData("gen_ai.tool.description", Description); - currSpan.SetData("gen_ai.tool.input", arguments); + currSpan.SetData(SentryAIConstants.SpanAttributes.RequestModel, options?.ModelId); + currSpan.SetData(SentryAIConstants.SpanAttributes.OperationName, "execute_tool"); + currSpan.SetData(SentryAIConstants.SpanAttributes.ToolName, Name); + currSpan.SetData(SentryAIConstants.SpanAttributes.ToolDescription, Description); + currSpan.SetData(SentryAIConstants.SpanAttributes.ToolInput, arguments); return currSpan; } - - private static void RemoveSentryArgs(ref AIFunctionArguments arguments) - { - arguments.Remove(SentryAIConstants.KeyMessageFunctionArgumentDictKey); - } } From baac48ba0239e3e93a3d57f5af0cc11856b2ef89 Mon Sep 17 00:00:00 2001 From: Alex Sohn Date: Thu, 30 Oct 2025 15:41:51 -0400 Subject: [PATCH 21/86] update tests --- .../SentryAISpanEnricherTests.cs | 8 ++++---- test/Sentry.Extensions.AI.Tests/SentryChatClientTests.cs | 4 ++-- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/test/Sentry.Extensions.AI.Tests/SentryAISpanEnricherTests.cs b/test/Sentry.Extensions.AI.Tests/SentryAISpanEnricherTests.cs index ed3f64bfe0..a4f92c1764 100644 --- a/test/Sentry.Extensions.AI.Tests/SentryAISpanEnricherTests.cs +++ b/test/Sentry.Extensions.AI.Tests/SentryAISpanEnricherTests.cs @@ -265,7 +265,7 @@ public void EnrichWithStreamingResponse_AccumulatesTokenUsage() } }; - SentryAISpanEnricher.EnrichWithStreamingResponse(_mockSpan, messages); + SentryAISpanEnricher.EnrichWithStreamingResponses(_mockSpan, messages); _mockSpan.Received(1).SetData("gen_ai.usage.input_tokens", 8L); _mockSpan.Received(1).SetData("gen_ai.usage.output_tokens", 15L); @@ -282,7 +282,7 @@ public void EnrichWithStreamingResponse_ConcatenatesResponseText() new(ChatRole.Assistant, "!") }; - SentryAISpanEnricher.EnrichWithStreamingResponse(_mockSpan, messages); + SentryAISpanEnricher.EnrichWithStreamingResponses(_mockSpan, messages); _mockSpan.Received(1).SetData("gen_ai.response.text", "Hello world!"); } @@ -296,7 +296,7 @@ public void EnrichWithStreamingResponse_DoesNotSetResponseText_WhenIncludeRespon }; var aiOptions = new SentryAIOptions { IncludeAIResponseContent = false }; - SentryAISpanEnricher.EnrichWithStreamingResponse(_mockSpan, messages, aiOptions); + SentryAISpanEnricher.EnrichWithStreamingResponses(_mockSpan, messages, aiOptions); _mockSpan.DidNotReceive().SetData("gen_ai.response.text", Arg.Any()); } @@ -311,7 +311,7 @@ public void EnrichWithStreamingResponse_SetsModelId_FromLastMessageWithModelId() new(ChatRole.Assistant, "!") }; - SentryAISpanEnricher.EnrichWithStreamingResponse(_mockSpan, messages); + SentryAISpanEnricher.EnrichWithStreamingResponses(_mockSpan, messages); _mockSpan.Received(1).SetData("gen_ai.response.model_id", "gpt-4"); } diff --git a/test/Sentry.Extensions.AI.Tests/SentryChatClientTests.cs b/test/Sentry.Extensions.AI.Tests/SentryChatClientTests.cs index 3cd5d0e9d7..f022b178eb 100644 --- a/test/Sentry.Extensions.AI.Tests/SentryChatClientTests.cs +++ b/test/Sentry.Extensions.AI.Tests/SentryChatClientTests.cs @@ -16,7 +16,7 @@ public async Task CompleteAsync_CallsInnerClient() var sentryChatClient = new SentryChatClient(inner); - var res = await sentryChatClient.GetResponseAsync([new ChatMessage(ChatRole.User, "hi")], null); + var res = await sentryChatClient.GetResponseAsync([new ChatMessage(ChatRole.User, "hi")]); Assert.Equal([message], res.Messages); await inner.Received(1).GetResponseAsync(Arg.Any>(), Arg.Any(), @@ -35,7 +35,7 @@ public async Task CompleteStreamingAsync_CallsInnerClient() var client = new SentryChatClient(inner); var results = new List(); - await foreach (var update in client.GetStreamingResponseAsync([new ChatMessage(ChatRole.User, "hi")], null)) + await foreach (var update in client.GetStreamingResponseAsync([new ChatMessage(ChatRole.User, "hi")])) { results.Add(update); } From cb2e15dae634ab75a3a024b2e2d026d0858a7511 Mon Sep 17 00:00:00 2001 From: Alex Sohn Date: Fri, 31 Oct 2025 10:56:16 -0400 Subject: [PATCH 22/86] cleanup --- .../SentryAIActivityListener.cs | 2 +- src/Sentry.Extensions.AI/SentryAIConstants.cs | 38 ++++++-- .../SentryAISpanEnricher.cs | 88 +++++++++++++------ src/Sentry.Extensions.AI/SentryChatClient.cs | 6 +- .../SentryInstrumentedFunction.cs | 2 +- 5 files changed, 97 insertions(+), 39 deletions(-) diff --git a/src/Sentry.Extensions.AI/SentryAIActivityListener.cs b/src/Sentry.Extensions.AI/SentryAIActivityListener.cs index b8a118eb82..7ebd476c17 100644 --- a/src/Sentry.Extensions.AI/SentryAIActivityListener.cs +++ b/src/Sentry.Extensions.AI/SentryAIActivityListener.cs @@ -15,7 +15,7 @@ internal static class SentryAIActivityListener { ShouldListenTo = s => s.Name.StartsWith(SentryAIConstants.SentryActivitySourceName), Sample = (ref ActivityCreationOptions options) => - options.Name == SentryAIConstants.FICCActivityName ? + SentryAIConstants.FICCActivityNames.Contains(options.Name) ? ActivitySamplingResult.AllDataAndRecorded : ActivitySamplingResult.None, ActivityStarted = a => { diff --git a/src/Sentry.Extensions.AI/SentryAIConstants.cs b/src/Sentry.Extensions.AI/SentryAIConstants.cs index 9be92f4a72..aa8f679f6a 100644 --- a/src/Sentry.Extensions.AI/SentryAIConstants.cs +++ b/src/Sentry.Extensions.AI/SentryAIConstants.cs @@ -26,13 +26,9 @@ internal static class SentryAIConstants internal const string KeyMessageFunctionArgumentDictKey = "SentrySpanToMessageDictKey"; /// - /// The string which FunctionInvokingChatClient(FICC) uses to start the tool call . + /// The list of strings which FunctionInvokingChatClient(FICC) uses to start the tool call . /// - /// - /// This string is valid from version 9.10 (inclusive). Previous versions of Microsoft.Extensions.AI has - /// a lot of different strings to run StartActivity. - /// - internal const string FICCActivityName = "orchestrate_tools"; + internal static readonly string[] FICCActivityNames = ["orchestrate_tools", "FunctionInvokingChatClient.GetResponseAsync", "FunctionInvokingChatClient"]; /// /// The string we use to identify our . @@ -46,15 +42,41 @@ internal static class SentryAIConstants internal static class SpanAttributes { + // Operations + internal const string OperationName = "gen_ai.operation.name"; internal const string InvokeAgentOperation = "gen_ai.invoke_agent"; internal const string InvokeAgentDescription = "invoke_agent"; internal const string ChatOperation = "gen_ai.chat"; internal const string ToolCallOperation = "gen_ai.execute_tool"; + + // Agent + internal const string AgentName = "gen_ai.agent.name"; + + // Request attributes internal const string RequestModel = "gen_ai.request.model"; - internal const string OperationName = "gen_ai.operation.name"; + internal const string RequestMessages = "gen_ai.request.messages"; + internal const string RequestAvailableTools = "gen_ai.request.available_tools"; + internal const string RequestTemperature = "gen_ai.request.temperature"; + internal const string RequestMaxTokens = "gen_ai.request.max_tokens"; + internal const string RequestTopP = "gen_ai.request.top_p"; + internal const string RequestFrequencyPenalty = "gen_ai.request.frequency_penalty"; + internal const string RequestPresencePenalty = "gen_ai.request.presence_penalty"; + + // Response attributes + internal const string ResponseText = "gen_ai.response.text"; + internal const string ResponseToolCalls = "gen_ai.response.tool_calls"; + internal const string ResponseModel = "gen_ai.response.model"; + internal const string ResponseModelId = "gen_ai.response.model_id"; + + // Usage attributes + internal const string UsageInputTokens = "gen_ai.usage.input_tokens"; + internal const string UsageOutputTokens = "gen_ai.usage.output_tokens"; + internal const string UsageTotalTokens = "gen_ai.usage.total_tokens"; + + // Tool attributes internal const string ToolName = "gen_ai.tool.name"; internal const string ToolDescription = "gen_ai.tool.description"; internal const string ToolInput = "gen_ai.tool.input"; + internal const string ToolOutput = "gen_ai.tool.output"; } - } diff --git a/src/Sentry.Extensions.AI/SentryAISpanEnricher.cs b/src/Sentry.Extensions.AI/SentryAISpanEnricher.cs index fa22a950c9..a22a348426 100644 --- a/src/Sentry.Extensions.AI/SentryAISpanEnricher.cs +++ b/src/Sentry.Extensions.AI/SentryAISpanEnricher.cs @@ -14,55 +14,55 @@ internal static class SentryAISpanEnricher /// Messages /// Options /// AI-specific options - internal static void EnrichWithRequest(ISpan span, ChatMessage[] messages, ChatOptions? options, SentryAIOptions? aiOptions = null) + internal static void EnrichWithRequest(ISpan span, ChatMessage[] messages, ChatOptions? options, + SentryAIOptions? aiOptions = null) { - // Currently, all top-level spans will start as "chat" - // The agent creation/invocation doesn't really work in Microsoft.Extensions.AI - span.SetData("gen_ai.operation.name", "chat"); + // Currently, all spans will be "chat" + span.SetData(SentryAIConstants.SpanAttributes.OperationName, "chat"); if (options?.ModelId is { } modelId) { - span.SetData("gen_ai.request.model", modelId); + span.SetData(SentryAIConstants.SpanAttributes.RequestModel, modelId); } if (aiOptions?.AgentName is { } agentName) { - span.SetData("gen_ai.agent.name", agentName); + span.SetData(SentryAIConstants.SpanAttributes.AgentName, agentName); } if (messages is { Length: > 0 } && (aiOptions?.IncludeAIRequestMessages ?? true)) { - span.SetData("gen_ai.request.messages", FormatRequestMessage(messages)); + span.SetData(SentryAIConstants.SpanAttributes.RequestMessages, FormatRequestMessage(messages)); } if (options?.Tools is { } tools) { - span.SetData("gen_ai.request.available_tools", FormatAvailableTools(tools)); + span.SetData(SentryAIConstants.SpanAttributes.RequestAvailableTools, FormatAvailableTools(tools)); } if (options?.Temperature is { } temperature) { - span.SetData("gen_ai.request.temperature", temperature); + span.SetData(SentryAIConstants.SpanAttributes.RequestTemperature, temperature); } if (options?.MaxOutputTokens is { } maxOutputTokens) { - span.SetData("gen_ai.request.max_tokens", maxOutputTokens); + span.SetData(SentryAIConstants.SpanAttributes.RequestMaxTokens, maxOutputTokens); } if (options?.TopP is { } topP) { - span.SetData("gen_ai.request.top_p", topP); + span.SetData(SentryAIConstants.SpanAttributes.RequestTopP, topP); } if (options?.FrequencyPenalty is { } frequencyPenalty) { - span.SetData("gen_ai.request.frequency_penalty", frequencyPenalty); + span.SetData(SentryAIConstants.SpanAttributes.RequestFrequencyPenalty, frequencyPenalty); } if (options?.PresencePenalty is { } presencePenalty) { - span.SetData("gen_ai.request.presence_penalty", presencePenalty); + span.SetData(SentryAIConstants.SpanAttributes.RequestPresencePenalty, presencePenalty); } } @@ -81,28 +81,28 @@ internal static void EnrichWithResponse(ISpan span, ChatResponse response, Sentr if (inputTokens.HasValue) { - span.SetData("gen_ai.usage.input_tokens", inputTokens.Value); + span.SetData(SentryAIConstants.SpanAttributes.UsageInputTokens, inputTokens.Value); } if (outputTokens.HasValue) { - span.SetData("gen_ai.usage.output_tokens", outputTokens.Value); + span.SetData(SentryAIConstants.SpanAttributes.UsageOutputTokens, outputTokens.Value); } if (inputTokens.HasValue && outputTokens.HasValue) { - span.SetData("gen_ai.usage.total_tokens", inputTokens.Value + outputTokens.Value); + span.SetData(SentryAIConstants.SpanAttributes.UsageTotalTokens, inputTokens.Value + outputTokens.Value); } } if (response.Text is { } responseText && (aiOptions?.IncludeAIResponseContent ?? true)) { - span.SetData("gen_ai.response.text", responseText); + span.SetData(SentryAIConstants.SpanAttributes.ResponseText, responseText); } if (response.ModelId is { } modelId) { - span.SetData("gen_ai.response.model", modelId); + span.SetData(SentryAIConstants.SpanAttributes.ResponseModel, modelId); } } @@ -112,7 +112,8 @@ internal static void EnrichWithResponse(ISpan span, ChatResponse response, Sentr /// span to enrich /// a list of /// AI-specific options - public static void EnrichWithStreamingResponses(ISpan span, List messages, SentryAIOptions? aiOptions = null) + public static void EnrichWithStreamingResponses(ISpan span, List messages, + SentryAIOptions? aiOptions = null) { var inputTokenCount = 0L; var outputTokenCount = 0L; @@ -128,30 +129,65 @@ public static void EnrichWithStreamingResponses(ISpan span, List contents) + { + var functionContents = contents.OfType().ToArray(); + if (functionContents.Length > 0) + { + span.SetData(SentryAIConstants.SpanAttributes.ResponseToolCalls, + FormatFunctionCallContent(functionContents)); + } } - span.SetData("gen_ai.usage.input_tokens", inputTokenCount); - span.SetData("gen_ai.usage.output_tokens", outputTokenCount); - span.SetData("gen_ai.usage.total_tokens", inputTokenCount + outputTokenCount); } private static string FormatAvailableTools(IList tools) => - FormatAsJson(tools, tool => new { name = tool.Name, description = tool.Description }); + FormatAsJson(tools, tool => new + { + name = tool.Name, + description = tool.Description + }); private static string FormatRequestMessage(ChatMessage[] messages) => - FormatAsJson(messages, message => new { role = message.Role, content = message.Text }); + FormatAsJson(messages, message => new + { + role = message.Role, + content = message.Text + }); + + private static string FormatFunctionCallContent(FunctionCallContent[] content) => + FormatAsJson(content, c => new + { + name = c.Name, + type = "function_call", + arguments = JsonSerializer.Serialize(c.Arguments) + }); private static string FormatAsJson(IEnumerable items, Func selector) => JsonSerializer.Serialize(items.Select(selector)); diff --git a/src/Sentry.Extensions.AI/SentryChatClient.cs b/src/Sentry.Extensions.AI/SentryChatClient.cs index 3d65fe6e31..4c35acadbb 100644 --- a/src/Sentry.Extensions.AI/SentryChatClient.cs +++ b/src/Sentry.Extensions.AI/SentryChatClient.cs @@ -49,7 +49,8 @@ public override async Task GetResponseAsync(IEnumerable GetStreamingResponseA SentryAIConstants.SpanAttributes.InvokeAgentDescription); } - // In FunctionInvokingChatClient, we should be able to get the agent span from the current activity + // If FunctionInvokingChatClient wraps SentryChatClient, we should be able to get the agent span from the current activity // The activity we attached the span to may be an ancestor of the current activity, we must search the parents for the span - // If we have tools available but couldn't find a span from activities, we can't have a root agent span. var activeSpan = SentryAIUtil.GetActivitySpan(); return activeSpan; } diff --git a/src/Sentry.Extensions.AI/SentryInstrumentedFunction.cs b/src/Sentry.Extensions.AI/SentryInstrumentedFunction.cs index c2fecfb9f5..e202381a66 100644 --- a/src/Sentry.Extensions.AI/SentryInstrumentedFunction.cs +++ b/src/Sentry.Extensions.AI/SentryInstrumentedFunction.cs @@ -19,7 +19,7 @@ internal sealed class SentryInstrumentedFunction(AIFunction innerFunction, ChatO if (result?.ToString() is { } resultString) { - currSpan.SetData("gen_ai.tool.output", resultString); + currSpan.SetData(SentryAIConstants.SpanAttributes.ToolOutput, resultString); } currSpan.Finish(SpanStatus.Ok); From d7233eabd4af5ca708d2a5b65e1b361a9f705b42 Mon Sep 17 00:00:00 2001 From: Alex Sohn Date: Fri, 31 Oct 2025 12:55:59 -0400 Subject: [PATCH 23/86] Wrap Tool calls during request --- .../Sentry.Samples.ME.AI.AspNetCore/Program.cs | 2 +- .../Sentry.Samples.ME.AI.Console/Program.cs | 13 ++++++++----- .../Extensions/SentryAIExtensions.cs | 5 ----- src/Sentry.Extensions.AI/SentryChatClient.cs | 18 ++++++++++++++++++ 4 files changed, 27 insertions(+), 11 deletions(-) diff --git a/samples/Sentry.Samples.ME.AI.AspNetCore/Program.cs b/samples/Sentry.Samples.ME.AI.AspNetCore/Program.cs index d77115603d..06bda3e6d4 100644 --- a/samples/Sentry.Samples.ME.AI.AspNetCore/Program.cs +++ b/samples/Sentry.Samples.ME.AI.AspNetCore/Program.cs @@ -131,7 +131,7 @@ }, "CalculateAverageAge", "Calculates the average from a list of ages. You should first get individual ages using GetPersonAge, then use this tool to calculate the average. Takes about 200ms to complete.") ] - }.WithSentry(); + }; try { diff --git a/samples/Sentry.Samples.ME.AI.Console/Program.cs b/samples/Sentry.Samples.ME.AI.Console/Program.cs index 87bbb09630..60dbff8a56 100644 --- a/samples/Sentry.Samples.ME.AI.Console/Program.cs +++ b/samples/Sentry.Samples.ME.AI.Console/Program.cs @@ -50,13 +50,15 @@ { ModelId = "gpt-4o-mini", MaxOutputTokens = 1024, - Tools = [ + Tools = + [ // Tool 1: Quick response with minimal delay AIFunctionFactory.Create(async (string personName) => { logger.LogInformation("GetPersonAge called for {PersonName}", personName); await Task.Delay(100); // 100ms delay - return personName switch { + return personName switch + { "Alice" => "25", "Bob" => "30", "Charlie" => "35", @@ -69,7 +71,8 @@ { logger.LogInformation("GetWeather called for {Location}", location); await Task.Delay(500); // 500ms delay - return location.ToLower() switch { + return location.ToLower() switch + { "new york" => "Sunny, 72°F", "london" => "Cloudy, 60°F", "tokyo" => "Rainy, 68°F", @@ -86,7 +89,7 @@ return $"Complex calculation result for {number}: {result}"; }, "ComplexCalculation", "Performs a complex mathematical calculation. Takes about 1 second to complete.") ] -}.WithSentry(); +}; var response = await client.GetResponseAsync( "Please help me with the following tasks: 1) Find Alice's age, 2) Get weather in New York, and 3) Calculate a complex result for number 15. Please use the appropriate tools for each task.", @@ -101,7 +104,7 @@ { ModelId = "gpt-4o-mini", MaxOutputTokens = 1024 -}.WithSentry(); +}; logger.LogInformation("Microsoft.Extensions.AI sample completed! Check your Sentry dashboard for the trace data."); diff --git a/src/Sentry.Extensions.AI/Extensions/SentryAIExtensions.cs b/src/Sentry.Extensions.AI/Extensions/SentryAIExtensions.cs index aba92fb7a4..d67608420a 100644 --- a/src/Sentry.Extensions.AI/Extensions/SentryAIExtensions.cs +++ b/src/Sentry.Extensions.AI/Extensions/SentryAIExtensions.cs @@ -32,11 +32,6 @@ public static ChatOptions WithSentry( } } - // SentrySpanStore additional property will store the dictionary to keep track of which span is - // the "agent" span, which will persist through different chat/tool calls. - options.AdditionalProperties ??= new AdditionalPropertiesDictionary(); - options.AdditionalProperties.TryAdd("SentryChatMessageAgentSpan", new ConcurrentDictionary()); - return options; } diff --git a/src/Sentry.Extensions.AI/SentryChatClient.cs b/src/Sentry.Extensions.AI/SentryChatClient.cs index 4c35acadbb..a6be6a83c2 100644 --- a/src/Sentry.Extensions.AI/SentryChatClient.cs +++ b/src/Sentry.Extensions.AI/SentryChatClient.cs @@ -29,6 +29,7 @@ public override async Task GetResponseAsync(IEnumerable GetStreamingResponseA var chatMessages = messages as ChatMessage[] ?? messages.ToArray(); var outerSpan = TryGetRootSpan(options); var innerSpan = CreateChatSpan(outerSpan, options); + WrapTools(options); var hasNext = true; var responses = new List(); @@ -144,4 +146,20 @@ private ISpan CreateChatSpan(ISpan? outerSpan, ChatOptions? options) ? outerSpan.StartChild(SentryAIConstants.SpanAttributes.ChatOperation, chatSpanName) : _hub.StartSpan(SentryAIConstants.SpanAttributes.ChatOperation, chatSpanName); } + + private static void WrapTools(ChatOptions? options) + { + if (options?.Tools is null || options.Tools.Count == 0) + { + return; + } + // We wrap tools here so we don't have to wrap them each time we grab the response + for (var i = 0; i < options.Tools.Count; i++) + { + if (options.Tools[i] is AIFunction fn and not SentryInstrumentedFunction) + { + options.Tools[i] = new SentryInstrumentedFunction(fn, options); + } + } + } } From a4ab79a28c82692c524dcb608bddf46da298f0a6 Mon Sep 17 00:00:00 2001 From: Alex Sohn Date: Fri, 31 Oct 2025 12:56:17 -0400 Subject: [PATCH 24/86] remove unused method --- .../Extensions/SentryAIExtensions.cs | 25 ------------------- 1 file changed, 25 deletions(-) diff --git a/src/Sentry.Extensions.AI/Extensions/SentryAIExtensions.cs b/src/Sentry.Extensions.AI/Extensions/SentryAIExtensions.cs index d67608420a..743280ea78 100644 --- a/src/Sentry.Extensions.AI/Extensions/SentryAIExtensions.cs +++ b/src/Sentry.Extensions.AI/Extensions/SentryAIExtensions.cs @@ -10,31 +10,6 @@ namespace Sentry.Extensions.AI; [EditorBrowsable(EditorBrowsableState.Never)] public static class SentryAIExtensions { - /// - /// Wrap tool calls specified in with Sentry agent instrumentation - /// - /// The that contains the to instrument - public static ChatOptions WithSentry( - this ChatOptions options) - { - if (options.Tools is null || options.Tools.Count == 0) - { - return options; - } - - // We wrap tools here so we don't have to wrap them each time we grab the response - for (var i = 0; i < options.Tools.Count; i++) - { - var tool = options.Tools[i]; - if (tool is AIFunction fn and not SentryInstrumentedFunction) - { - options.Tools[i] = new SentryInstrumentedFunction(fn, options); - } - } - - return options; - } - /// /// Wraps an IChatClient with Sentry agent instrumentation. /// From 1a62b885062d9c1ae7e70c9936fcb9a443086606 Mon Sep 17 00:00:00 2001 From: Alex Sohn Date: Fri, 31 Oct 2025 12:56:41 -0400 Subject: [PATCH 25/86] add tests for ActivityListener --- .../Sentry.Extensions.AI.Tests.csproj | 2 +- .../SentryAIActivityListenerTests.cs | 92 +++++++++++++++++++ 2 files changed, 93 insertions(+), 1 deletion(-) create mode 100644 test/Sentry.Extensions.AI.Tests/SentryAIActivityListenerTests.cs diff --git a/test/Sentry.Extensions.AI.Tests/Sentry.Extensions.AI.Tests.csproj b/test/Sentry.Extensions.AI.Tests/Sentry.Extensions.AI.Tests.csproj index 601422bf85..9ddae185e0 100644 --- a/test/Sentry.Extensions.AI.Tests/Sentry.Extensions.AI.Tests.csproj +++ b/test/Sentry.Extensions.AI.Tests/Sentry.Extensions.AI.Tests.csproj @@ -5,7 +5,7 @@ - + diff --git a/test/Sentry.Extensions.AI.Tests/SentryAIActivityListenerTests.cs b/test/Sentry.Extensions.AI.Tests/SentryAIActivityListenerTests.cs new file mode 100644 index 0000000000..2e71f5c8df --- /dev/null +++ b/test/Sentry.Extensions.AI.Tests/SentryAIActivityListenerTests.cs @@ -0,0 +1,92 @@ +#nullable enable + +namespace Sentry.Extensions.AI.Tests; + +public class SentryAIActivityListenerTests +{ + private IHub Hub { get; } + private ActivitySource SentryActivitySource { get; } + private ISpan Span { get; } + + public SentryAIActivityListenerTests() + { + Hub = Substitute.For(); + SentryActivitySource = SentryAIActivitySource.Instance; + Span = Substitute.For(); + SentrySdk.UseHub(Hub); + } + + [Fact] + public void Init_AddsActivityListenerToActivitySource() + { + // Act + SentryAIActivityListener.Init(); + + // Assert + Assert.True(SentryActivitySource.HasListeners()); + } + + [Theory] + [InlineData(SentryAIConstants.SentryActivitySourceName)] + public void ShouldListenTo_ReturnsTrueForSentryActivitySource(string sourceName) + { + // Arrange + SentryAIActivityListener.Init(); + var activitySource = new ActivitySource(sourceName); + + // Act & Assert + using var activity = activitySource.StartActivity(SentryAIConstants.FICCActivityNames[0]); + Assert.NotNull(activity); + Assert.NotNull(Activity.Current); + } + + [Fact] + public void ShouldListenTo_ReturnsFalseForNonSentryActivitySource() + { + // Arrange + SentryAIActivityListener.Init(); + var activitySource = new ActivitySource("Other.ActivitySource"); + + // Act & Assert + using var activity = activitySource.StartActivity("test"); + Assert.Null(activity); // Activity should not be created for non-Sentry sources + } + + [Theory] + [InlineData("orchestrate_tools")] + [InlineData("FunctionInvokingChatClient.GetResponseAsync")] + [InlineData("FunctionInvokingChatClient")] + public void Sample_ReturnsAllDataAndRecordedForFICCActivityNames(string activityName) + { + // Arrange + SentryAIActivityListener.Init(); + + // Act + using var activity = SentryActivitySource.StartActivity(activityName); + + // Assert + Assert.NotNull(activity); + Assert.True(activity.IsAllDataRequested); + Assert.Equal(ActivitySamplingResult.AllDataAndRecorded, + activity.Recorded ? ActivitySamplingResult.AllDataAndRecorded : ActivitySamplingResult.None); + } + + [Theory] + [InlineData("some_other_activity")] + [InlineData("random_activity_name")] + public void Sample_ReturnsNoneForNonFICCActivityNames(string activityName) + { + // Arrange + SentryAIActivityListener.Init(); + + // Act + using var activity = SentryActivitySource.StartActivity(activityName); + + // Assert + // For non-FICC activity names, the activity may still be created but not recorded + if (activity != null) + { + Assert.False(activity.Recorded); + } + } +} From fca78d401fa22c1edf0473b287718a0d9a1fecbe Mon Sep 17 00:00:00 2001 From: Alex Sohn Date: Fri, 31 Oct 2025 15:46:40 -0400 Subject: [PATCH 26/86] modify spanenricher to have mandatory aioptions --- src/Sentry.Extensions.AI/SentryAIConstants.cs | 1 - .../SentryAISpanEnricher.cs | 60 ++++++------------- 2 files changed, 17 insertions(+), 44 deletions(-) diff --git a/src/Sentry.Extensions.AI/SentryAIConstants.cs b/src/Sentry.Extensions.AI/SentryAIConstants.cs index aa8f679f6a..8200ad6ff6 100644 --- a/src/Sentry.Extensions.AI/SentryAIConstants.cs +++ b/src/Sentry.Extensions.AI/SentryAIConstants.cs @@ -66,7 +66,6 @@ internal static class SpanAttributes internal const string ResponseText = "gen_ai.response.text"; internal const string ResponseToolCalls = "gen_ai.response.tool_calls"; internal const string ResponseModel = "gen_ai.response.model"; - internal const string ResponseModelId = "gen_ai.response.model_id"; // Usage attributes internal const string UsageInputTokens = "gen_ai.usage.input_tokens"; diff --git a/src/Sentry.Extensions.AI/SentryAISpanEnricher.cs b/src/Sentry.Extensions.AI/SentryAISpanEnricher.cs index a22a348426..35dfd8e826 100644 --- a/src/Sentry.Extensions.AI/SentryAISpanEnricher.cs +++ b/src/Sentry.Extensions.AI/SentryAISpanEnricher.cs @@ -15,7 +15,7 @@ internal static class SentryAISpanEnricher /// Options /// AI-specific options internal static void EnrichWithRequest(ISpan span, ChatMessage[] messages, ChatOptions? options, - SentryAIOptions? aiOptions = null) + SentryAIOptions aiOptions) { // Currently, all spans will be "chat" span.SetData(SentryAIConstants.SpanAttributes.OperationName, "chat"); @@ -69,41 +69,16 @@ internal static void EnrichWithRequest(ISpan span, ChatMessage[] messages, ChatO /// /// Enriches the span with response information. /// + /// + /// This function converts a to a list of , then + /// enriches the span with it. + /// /// Span to enrich /// Chat response containing usage and content data /// AI-specific options - internal static void EnrichWithResponse(ISpan span, ChatResponse response, SentryAIOptions? aiOptions = null) + internal static void EnrichWithResponse(ISpan span, ChatResponse response, SentryAIOptions aiOptions) { - if (response.Usage is { } usage) - { - var inputTokens = usage.InputTokenCount; - var outputTokens = usage.OutputTokenCount; - - if (inputTokens.HasValue) - { - span.SetData(SentryAIConstants.SpanAttributes.UsageInputTokens, inputTokens.Value); - } - - if (outputTokens.HasValue) - { - span.SetData(SentryAIConstants.SpanAttributes.UsageOutputTokens, outputTokens.Value); - } - - if (inputTokens.HasValue && outputTokens.HasValue) - { - span.SetData(SentryAIConstants.SpanAttributes.UsageTotalTokens, inputTokens.Value + outputTokens.Value); - } - } - - if (response.Text is { } responseText && (aiOptions?.IncludeAIResponseContent ?? true)) - { - span.SetData(SentryAIConstants.SpanAttributes.ResponseText, responseText); - } - - if (response.ModelId is { } modelId) - { - span.SetData(SentryAIConstants.SpanAttributes.ResponseModel, modelId); - } + EnrichWithStreamingResponses(span, [..response.ToChatResponseUpdates()], aiOptions); } /// @@ -113,7 +88,7 @@ internal static void EnrichWithResponse(ISpan span, ChatResponse response, Sentr /// a list of /// AI-specific options public static void EnrichWithStreamingResponses(ISpan span, List messages, - SentryAIOptions? aiOptions = null) + SentryAIOptions aiOptions) { var inputTokenCount = 0L; var outputTokenCount = 0L; @@ -132,7 +107,7 @@ public static void EnrichWithStreamingResponses(ISpan span, List contents) + private static void PopulateToolCallsInfo(IList contents, ISpan span) + { + var functionContents = contents.OfType().ToArray(); + if (functionContents.Length > 0) { - var functionContents = contents.OfType().ToArray(); - if (functionContents.Length > 0) - { - span.SetData(SentryAIConstants.SpanAttributes.ResponseToolCalls, - FormatFunctionCallContent(functionContents)); - } + span.SetData(SentryAIConstants.SpanAttributes.ResponseToolCalls, + FormatFunctionCallContent(functionContents)); } } From bb40a1117f759985850196b2699c358733bdba9e Mon Sep 17 00:00:00 2001 From: Alex Sohn Date: Fri, 31 Oct 2025 15:46:52 -0400 Subject: [PATCH 27/86] update tests --- .../SentryAIExtensionsTests.cs | 167 +------ .../SentryAIOptionsTests.cs | 13 +- .../SentryAISpanEnricherTests.cs | 448 ++++++++---------- 3 files changed, 222 insertions(+), 406 deletions(-) diff --git a/test/Sentry.Extensions.AI.Tests/SentryAIExtensionsTests.cs b/test/Sentry.Extensions.AI.Tests/SentryAIExtensionsTests.cs index a9525fda7c..c747d05104 100644 --- a/test/Sentry.Extensions.AI.Tests/SentryAIExtensionsTests.cs +++ b/test/Sentry.Extensions.AI.Tests/SentryAIExtensionsTests.cs @@ -1,161 +1,10 @@ #nullable enable using Microsoft.Extensions.AI; -using NSubstitute; -using Sentry.Extensions.AI; namespace Sentry.Extensions.AI.Tests; public class SentryAIExtensionsTests { - [Fact] - public void WithSentry_ChatOptions_WithNullTools_ReturnsOriginalOptions() - { - // Arrange - var options = new ChatOptions(); - - // Act - var result = options.WithSentry(); - - // Assert - Assert.Same(options, result); - } - - [Fact] - public void WithSentry_ChatOptions_WithEmptyTools_ReturnsOriginalOptions() - { - // Arrange - var options = new ChatOptions - { - Tools = new List() - }; - - // Act - var result = options.WithSentry(); - - // Assert - Assert.Same(options, result); - } - - [Fact] - public void WithSentry_ChatOptions_WrapsAIFunctionsWithSentryInstrumentedFunction() - { - // Arrange - var mockFunction = Substitute.For(); - mockFunction.Name.Returns("TestFunction"); - mockFunction.Description.Returns("Test Description"); - - var options = new ChatOptions - { - Tools = new List { mockFunction } - }; - - // Act - var result = options.WithSentry(); - - // Assert - Assert.Same(options, result); - Assert.Single(options.Tools); - Assert.IsType(options.Tools[0]); - - var instrumentedFunction = (SentryInstrumentedFunction)options.Tools[0]; - Assert.Equal("TestFunction", instrumentedFunction.Name); - Assert.Equal("Test Description", instrumentedFunction.Description); - } - - [Fact] - public void WithSentry_ChatOptions_DoesNotDoubleWrapSentryInstrumentedFunction() - { - // Arrange - var mockFunction = Substitute.For(); - var mockOption = Substitute.For(); - var alreadyInstrumentedFunction = new SentryInstrumentedFunction(mockFunction, mockOption); - - var options = new ChatOptions - { - Tools = new List { alreadyInstrumentedFunction } - }; - - // Act - var result = options.WithSentry(); - - // Assert - Assert.Same(options, result); - Assert.Single(options.Tools); - Assert.Same(alreadyInstrumentedFunction, options.Tools[0]); - } - - [Fact] - public void WithSentry_ChatOptions_HandlesMultipleFunctions() - { - // Arrange - var mockFunction1 = Substitute.For(); - mockFunction1.Name.Returns("Function1"); - - var mockFunction2 = Substitute.For(); - mockFunction2.Name.Returns("Function2"); - - var mockOption1 = Substitute.For(); - var alreadyInstrumentedFunction = new SentryInstrumentedFunction(mockFunction1, mockOption1); - - var options = new ChatOptions - { - Tools = new List - { - mockFunction1, - mockFunction2, - alreadyInstrumentedFunction - } - }; - - // Act - var result = options.WithSentry(); - - // Assert - Assert.Same(options, result); - Assert.Equal(3, options.Tools.Count); - - // First function should be wrapped - Assert.IsType(options.Tools[0]); - Assert.Equal("Function1", options.Tools[0].Name); - - // Second function should be wrapped - Assert.IsType(options.Tools[1]); - Assert.Equal("Function2", options.Tools[1].Name); - - // Third function was already instrumented, should remain unchanged - Assert.Same(alreadyInstrumentedFunction, options.Tools[2]); - } - - [Fact] - public void WithSentry_ChatOptions_IgnoresNonAIFunctionTools() - { - // Arrange - var mockFunction = Substitute.For(); - mockFunction.Name.Returns("TestFunction"); - - var mockNonFunction = Substitute.For(); - mockNonFunction.Name.Returns("NonFunction"); - - var options = new ChatOptions - { - Tools = new List { mockFunction, mockNonFunction } - }; - - // Act - var result = options.WithSentry(); - - // Assert - Assert.Same(options, result); - Assert.Equal(2, options.Tools.Count); - - // AIFunction should be wrapped - Assert.IsType(options.Tools[0]); - Assert.Equal("TestFunction", options.Tools[0].Name); - - // Non-AIFunction should remain unchanged - Assert.Same(mockNonFunction, options.Tools[1]); - } - [Fact] public void WithSentry_IChatClient_ReturnsWrappedClient() { @@ -177,19 +26,17 @@ public void WithSentry_IChatClient_WithConfiguration_PassesConfigurationToWrappe var configureWasCalled = false; // Act - var result = mockClient.WithSentry(Configure); + var result = mockClient.WithSentry(options => + { + configureWasCalled = true; + options.IncludeAIRequestMessages = false; + options.IncludeAIResponseContent = false; + } + ); // Assert Assert.IsType(result); Assert.True(configureWasCalled); - return; - - void Configure(SentryAIOptions options) - { - configureWasCalled = true; - options.IncludeAIRequestMessages = false; - options.IncludeAIResponseContent = false; - } } [Fact] diff --git a/test/Sentry.Extensions.AI.Tests/SentryAIOptionsTests.cs b/test/Sentry.Extensions.AI.Tests/SentryAIOptionsTests.cs index 5207f2221a..152376828c 100644 --- a/test/Sentry.Extensions.AI.Tests/SentryAIOptionsTests.cs +++ b/test/Sentry.Extensions.AI.Tests/SentryAIOptionsTests.cs @@ -97,12 +97,13 @@ public void CanSetSentryOptionsProperties() public void AllPropertyCombinations_WorkCorrectly(bool includeRequest, bool includeResponse, bool initializeSdk) { // Arrange - var options = new SentryAIOptions(); - - // Act - options.IncludeAIRequestMessages = includeRequest; - options.IncludeAIResponseContent = includeResponse; - options.InitializeSdk = initializeSdk; + var options = new SentryAIOptions + { + // Act + IncludeAIRequestMessages = includeRequest, + IncludeAIResponseContent = includeResponse, + InitializeSdk = initializeSdk + }; // Assert Assert.Equal(includeRequest, options.IncludeAIRequestMessages); diff --git a/test/Sentry.Extensions.AI.Tests/SentryAISpanEnricherTests.cs b/test/Sentry.Extensions.AI.Tests/SentryAISpanEnricherTests.cs index a4f92c1764..867969ba95 100644 --- a/test/Sentry.Extensions.AI.Tests/SentryAISpanEnricherTests.cs +++ b/test/Sentry.Extensions.AI.Tests/SentryAISpanEnricherTests.cs @@ -8,311 +8,279 @@ namespace Sentry.Extensions.AI.Tests; public class SentryAISpanEnricherTests { - private readonly ISpan _mockSpan; - - public SentryAISpanEnricherTests() - { - _mockSpan = Substitute.For(); - } - - [Fact] - public void EnrichWithRequest_SetsBasicOperationName() + private class Fixture { - var messages = new[] { new ChatMessage(ChatRole.User, "Hello") }; + private SentryOptions Options { get; } + public ISentryClient Client { get; } + public Hub Hub { get; set; } - SentryAISpanEnricher.EnrichWithRequest(_mockSpan, messages, null); + public Fixture() + { + Options = new SentryOptions + { + Dsn = ValidDsn, + TracesSampleRate = 1.0, + }; - _mockSpan.Received(1).SetData("gen_ai.operation.name", "chat"); + Hub = new Hub(Options); + Client = Substitute.For(); + } } - [Fact] - public void EnrichWithRequest_SetsModel_WhenProvided() - { - var messages = new[] { new ChatMessage(ChatRole.User, "Hello") }; - var options = new ChatOptions { ModelId = "gpt-4" }; - - SentryAISpanEnricher.EnrichWithRequest(_mockSpan, messages, options); - - _mockSpan.Received(1).SetData("gen_ai.request.model", "gpt-4"); - } + private readonly Fixture _fixture = new(); - [Fact] - public void EnrichWithRequest_DoesntSetModel_WhenModelIdNotProvided() + private static ChatMessage[] TestMessages() { - var messages = new[] { new ChatMessage(ChatRole.User, "Hello") }; - - SentryAISpanEnricher.EnrichWithRequest(_mockSpan, messages, null); + var initialMessage = new ChatMessage(ChatRole.User, "Hello"); - _mockSpan.DidNotReceive().SetData("gen_ai.request.model", Arg.Any()); + return [initialMessage]; } - [Fact] - public void EnrichWithRequest_SetsMessages_WhenIncludeRequestMessagesIsTrue() + private static ChatOptions TestChatOptions() { - var messages = new[] { - new ChatMessage(ChatRole.User, "Hello"), - new ChatMessage(ChatRole.Assistant, "Hi there") + return new ChatOptions() + { + ModelId = "SentryAI", + Tools = new List + { + AIFunctionFactory.Create((string? s) => Console.WriteLine(s), "SomeAIFunction", + "SomeAIFunctionDescription") + }, + Temperature = 0.7f, + MaxOutputTokens = 1024, + TopP = 0.9f, + FrequencyPenalty = 0.5f, + PresencePenalty = 0.3f }; - var aiOptions = new SentryAIOptions { IncludeAIRequestMessages = true }; - - SentryAISpanEnricher.EnrichWithRequest(_mockSpan, messages, null, aiOptions); - - _mockSpan.Received(1).SetData("gen_ai.request.messages", Arg.Any()); - } - - [Fact] - public void EnrichWithRequest_DoesNotSetMessages_WhenIncludeRequestMessagesIsFalse() - { - var messages = new[] { new ChatMessage(ChatRole.User, "Hello") }; - var aiOptions = new SentryAIOptions { IncludeAIRequestMessages = false }; - - SentryAISpanEnricher.EnrichWithRequest(_mockSpan, messages, null, aiOptions); - - _mockSpan.DidNotReceive().SetData("gen_ai.request.messages", Arg.Any()); - } - - [Fact] - public void EnrichWithRequest_SetsMessages_WhenAIOptionsIsNull() - { - var messages = new[] { new ChatMessage(ChatRole.User, "Hello") }; - - SentryAISpanEnricher.EnrichWithRequest(_mockSpan, messages, null, null); - - _mockSpan.Received(1).SetData("gen_ai.request.messages", Arg.Any()); } [Fact] - public void EnrichWithRequest_DoesNotSetMessages_WhenMessagesArrayIsEmpty() + public void EnrichWithRequest_SetsData() { - var messages = Array.Empty(); - - SentryAISpanEnricher.EnrichWithRequest(_mockSpan, messages, null); - - _mockSpan.DidNotReceive().SetData("gen_ai.request.messages", Arg.Any()); + // Arrange + const string spanOp = "test_operation"; + const string spanDesc = "test_description"; + var span = _fixture.Hub.StartSpan(spanOp, spanDesc); + var messages = TestMessages(); + var chatOptions = TestChatOptions(); + var aiOptions = new SentryAIOptions(); + + // Act + SentryAISpanEnricher.EnrichWithRequest(span, messages, chatOptions, aiOptions); + + // Assert + span.Data[SentryAIConstants.SpanAttributes.OperationName].Should().Be("chat"); + span.Data[SentryAIConstants.SpanAttributes.RequestModel].Should().Be("SentryAI"); + span.Data[SentryAIConstants.SpanAttributes.RequestTemperature].Should().Be(0.7f); + span.Data[SentryAIConstants.SpanAttributes.RequestMaxTokens].Should().Be(1024); + span.Data[SentryAIConstants.SpanAttributes.RequestTopP].Should().Be(0.9f); + span.Data[SentryAIConstants.SpanAttributes.RequestFrequencyPenalty].Should().Be(0.5f); + span.Data[SentryAIConstants.SpanAttributes.RequestPresencePenalty].Should().Be(0.3f); + span.Data[SentryAIConstants.SpanAttributes.RequestMessages].Should().NotBeNull(); + span.Data[SentryAIConstants.SpanAttributes.RequestAvailableTools].Should().NotBeNull(); } [Fact] - public void EnrichWithRequest_SetsTools_WhenToolsProvided() + public void EnrichWithRequest_SetsData_WithoutRequestMessages_WhenDisabled() { - var messages = new[] { new ChatMessage(ChatRole.User, "Hello") }; - var tools = new List + // Arrange + const string spanOp = "test_operation"; + const string spanDesc = "test_description"; + var span = _fixture.Hub.StartSpan(spanOp, spanDesc); + var messages = TestMessages(); + var chatOptions = TestChatOptions(); + var aiOptions = new SentryAIOptions() { - AIFunctionFactory.Create(() => "test", "TestTool", "A test tool") + IncludeAIRequestMessages = false }; - var options = new ChatOptions { Tools = tools }; - - SentryAISpanEnricher.EnrichWithRequest(_mockSpan, messages, options); - - _mockSpan.Received(1).SetData("gen_ai.request.available_tools", Arg.Any()); - } - - [Fact] - public void EnrichWithRequest_SetsTemperature_WhenProvided() - { - var messages = new[] { new ChatMessage(ChatRole.User, "Hello") }; - var options = new ChatOptions { Temperature = 0.7f }; - - SentryAISpanEnricher.EnrichWithRequest(_mockSpan, messages, options); - _mockSpan.Received(1).SetData("gen_ai.request.temperature", 0.7f); + // Act + SentryAISpanEnricher.EnrichWithRequest(span, messages, chatOptions, aiOptions); + + // Assert + span.Data[SentryAIConstants.SpanAttributes.OperationName].Should().Be("chat"); + span.Data[SentryAIConstants.SpanAttributes.RequestModel].Should().Be("SentryAI"); + span.Data[SentryAIConstants.SpanAttributes.RequestTemperature].Should().Be(0.7f); + span.Data[SentryAIConstants.SpanAttributes.RequestMaxTokens].Should().Be(1024); + span.Data[SentryAIConstants.SpanAttributes.RequestTopP].Should().Be(0.9f); + span.Data[SentryAIConstants.SpanAttributes.RequestFrequencyPenalty].Should().Be(0.5f); + span.Data[SentryAIConstants.SpanAttributes.RequestPresencePenalty].Should().Be(0.3f); + span.Data.Should().NotContainKey(SentryAIConstants.SpanAttributes.RequestMessages); + span.Data[SentryAIConstants.SpanAttributes.RequestAvailableTools].Should().NotBeNull(); } - [Fact] - public void EnrichWithRequest_SetsMaxOutputTokens_WhenProvided() - { - var messages = new[] { new ChatMessage(ChatRole.User, "Hello") }; - var options = new ChatOptions { MaxOutputTokens = 1000 }; - - SentryAISpanEnricher.EnrichWithRequest(_mockSpan, messages, options); - - _mockSpan.Received(1).SetData("gen_ai.request.max_tokens", 1000); - } [Fact] - public void EnrichWithRequest_SetsTopP_WhenProvided() + public void EnrichWithRequest_SetsBasicData_WhenChatOptionsNull() { - var messages = new[] { new ChatMessage(ChatRole.User, "Hello") }; - var options = new ChatOptions { TopP = 0.9f }; - - SentryAISpanEnricher.EnrichWithRequest(_mockSpan, messages, options); - - _mockSpan.Received(1).SetData("gen_ai.request.top_p", 0.9f); - } - - [Fact] - public void EnrichWithRequest_SetsFrequencyPenalty_WhenProvided() - { - var messages = new[] { new ChatMessage(ChatRole.User, "Hello") }; - var options = new ChatOptions { FrequencyPenalty = 0.5f }; - - SentryAISpanEnricher.EnrichWithRequest(_mockSpan, messages, options); - - _mockSpan.Received(1).SetData("gen_ai.request.frequency_penalty", 0.5f); - } - - [Fact] - public void EnrichWithRequest_SetsPresencePenalty_WhenProvided() - { - var messages = new[] { new ChatMessage(ChatRole.User, "Hello") }; - var options = new ChatOptions { PresencePenalty = 0.3f }; - - SentryAISpanEnricher.EnrichWithRequest(_mockSpan, messages, options); - - _mockSpan.Received(1).SetData("gen_ai.request.presence_penalty", 0.3f); - } - - [Fact] - public void EnrichWithResponse_SetsUsageTokens_WhenUsageProvided() - { - var usage = new UsageDetails { InputTokenCount = 10, OutputTokenCount = 20 }; - var response = new ChatResponse(new ChatMessage(ChatRole.Assistant, "Hello")) + // Arrange + const string spanOp = "test_operation"; + const string spanDesc = "test_description"; + var span = _fixture.Hub.StartSpan(spanOp, spanDesc); + var messages = TestMessages(); + var aiOptions = new SentryAIOptions() { - Usage = usage + IncludeAIRequestMessages = false }; - SentryAISpanEnricher.EnrichWithResponse(_mockSpan, response); - - _mockSpan.Received(1).SetData("gen_ai.usage.input_tokens", 10L); - _mockSpan.Received(1).SetData("gen_ai.usage.output_tokens", 20L); - _mockSpan.Received(1).SetData("gen_ai.usage.total_tokens", 30L); - } - - [Fact] - public void EnrichWithResponse_DoesNotSetUsage_WhenUsageIsNull() - { - var response = new ChatResponse(new ChatMessage(ChatRole.Assistant, "Hello")); - - SentryAISpanEnricher.EnrichWithResponse(_mockSpan, response); - - _mockSpan.DidNotReceive().SetData("gen_ai.usage.input_tokens", Arg.Any()); - _mockSpan.DidNotReceive().SetData("gen_ai.usage.output_tokens", Arg.Any()); - _mockSpan.DidNotReceive().SetData("gen_ai.usage.total_tokens", Arg.Any()); + // Act + SentryAISpanEnricher.EnrichWithRequest(span, messages, null, aiOptions); + + // Assert + span.Data[SentryAIConstants.SpanAttributes.OperationName].Should().Be("chat"); + span.Data.Should().NotContainKey(SentryAIConstants.SpanAttributes.RequestModel); + span.Data.Should().NotContainKey(SentryAIConstants.SpanAttributes.RequestTemperature); + span.Data.Should().NotContainKey(SentryAIConstants.SpanAttributes.RequestMaxTokens); + span.Data.Should().NotContainKey(SentryAIConstants.SpanAttributes.RequestTopP); + span.Data.Should().NotContainKey(SentryAIConstants.SpanAttributes.RequestFrequencyPenalty); + span.Data.Should().NotContainKey(SentryAIConstants.SpanAttributes.RequestPresencePenalty); + span.Data.Should().NotContainKey(SentryAIConstants.SpanAttributes.RequestMessages); + span.Data.Should().NotContainKey(SentryAIConstants.SpanAttributes.RequestAvailableTools); } [Fact] - public void EnrichWithResponse_SetsPartialTokens_WhenOnlyInputTokensProvided() + public void EnrichWithResponse_SetsData() { - var usage = new UsageDetails { InputTokenCount = 10 }; - var response = new ChatResponse(new ChatMessage(ChatRole.Assistant, "Hello")) + // Arrange + const string spanOp = "test_operation"; + const string spanDesc = "test_description"; + var span = _fixture.Hub.StartSpan(spanOp, spanDesc); + var response = new ChatResponse([ + new ChatMessage(ChatRole.Assistant, [ + new TextContent("Hello"), + new FunctionCallContent("test-call-id", "TestFunction", new Dictionary { ["param"] = "value" }) + ]) + ]) { - Usage = usage + ModelId = "response-model-id", + Usage = new UsageDetails + { + InputTokenCount = 50, + OutputTokenCount = 25 + }, + FinishReason = ChatFinishReason.ToolCalls }; - - SentryAISpanEnricher.EnrichWithResponse(_mockSpan, response); - - _mockSpan.Received().SetData("gen_ai.usage.input_tokens", 10L); - _mockSpan.DidNotReceive().SetData("gen_ai.usage.output_tokens", Arg.Any()); - _mockSpan.DidNotReceive().SetData("gen_ai.usage.total_tokens", Arg.Any()); + var aiOptions = new SentryAIOptions(); + + // Act + SentryAISpanEnricher.EnrichWithResponse(span, response, aiOptions); + + // Assert + span.Data[SentryAIConstants.SpanAttributes.ResponseText].Should().Be("Hello"); + span.Data[SentryAIConstants.SpanAttributes.ResponseModel].Should().Be("response-model-id"); + span.Data[SentryAIConstants.SpanAttributes.UsageInputTokens].Should().Be(50L); + span.Data[SentryAIConstants.SpanAttributes.UsageOutputTokens].Should().Be(25L); + span.Data[SentryAIConstants.SpanAttributes.UsageTotalTokens].Should().Be(75L); + span.Data[SentryAIConstants.SpanAttributes.ResponseToolCalls].Should().NotBeNull(); } - [Fact] - public void EnrichWithResponse_SetsResponseText_WhenIncludeResponseContentIsTrue() + public void EnrichWithResponse_SetsData_WithoutResponseMessages_WhenDisabled() { - var response = new ChatResponse(new ChatMessage(ChatRole.Assistant, "Hello world")); - var aiOptions = new SentryAIOptions { IncludeAIResponseContent = true }; - - SentryAISpanEnricher.EnrichWithResponse(_mockSpan, response, aiOptions); - - _mockSpan.Received(1).SetData("gen_ai.response.text", "Hello world"); - } - - [Fact] - public void EnrichWithResponse_DoesNotSetResponseText_WhenIncludeResponseContentIsFalse() - { - var response = new ChatResponse(new ChatMessage(ChatRole.Assistant, "Hello world")); - var aiOptions = new SentryAIOptions { IncludeAIResponseContent = false }; - - SentryAISpanEnricher.EnrichWithResponse(_mockSpan, response, aiOptions); - - _mockSpan.DidNotReceive().SetData("gen_ai.response.text", Arg.Any()); - } - - [Fact] - public void EnrichWithResponse_SetsResponseText_WhenAIOptionsIsNull() - { - var response = new ChatResponse(new ChatMessage(ChatRole.Assistant, "Hello world")); - - SentryAISpanEnricher.EnrichWithResponse(_mockSpan, response, null); - - _mockSpan.Received(1).SetData("gen_ai.response.text", "Hello world"); - } - - [Fact] - public void EnrichWithResponse_SetsModelId_WhenProvided() - { - var response = new ChatResponse(new ChatMessage(ChatRole.Assistant, "Hello")) + // Arrange + const string spanOp = "test_operation"; + const string spanDesc = "test_description"; + var span = _fixture.Hub.StartSpan(spanOp, spanDesc); + var response = new ChatResponse(TestMessages()) + { + ModelId = "response-model-id", + Usage = new UsageDetails + { + InputTokenCount = 50, + OutputTokenCount = 25 + } + }; + var aiOptions = new SentryAIOptions() { - ModelId = "gpt-4-turbo" + IncludeAIResponseContent = false }; - SentryAISpanEnricher.EnrichWithResponse(_mockSpan, response); + // Act + SentryAISpanEnricher.EnrichWithResponse(span, response, aiOptions); - _mockSpan.Received().SetData("gen_ai.response.model", "gpt-4-turbo"); + // Assert + span.Data.Should().NotContainKey(SentryAIConstants.SpanAttributes.ResponseText); + span.Data[SentryAIConstants.SpanAttributes.ResponseModel].Should().Be("response-model-id"); + span.Data[SentryAIConstants.SpanAttributes.UsageInputTokens].Should().Be(50); + span.Data[SentryAIConstants.SpanAttributes.UsageOutputTokens].Should().Be(25); + span.Data[SentryAIConstants.SpanAttributes.UsageTotalTokens].Should().Be(75); } [Fact] - public void EnrichWithStreamingResponse_AccumulatesTokenUsage() + public void EnrichWithStreamingResponses_SetsData() { - var messages = new List + // Arrange + const string spanOp = "test_operation"; + const string spanDesc = "test_description"; + var span = _fixture.Hub.StartSpan(spanOp, spanDesc); + + var streamingMessages = new List { - new(ChatRole.Assistant, "Hello") + new() { - Contents = [new UsageContent(new UsageDetails { InputTokenCount = 5, OutputTokenCount = 10 })] + Contents = [ + new TextContent("Hello "), + new UsageContent(new UsageDetails { InputTokenCount = 10, OutputTokenCount = 5 }) + ] }, - new(ChatRole.Assistant, " world") + new() { - Contents = [new UsageContent(new UsageDetails { InputTokenCount = 3, OutputTokenCount = 5 })] + ModelId = "streaming-model-id", + Contents = [ + new TextContent("world!"), + new UsageContent(new UsageDetails { InputTokenCount = 15, OutputTokenCount = 8 }) + ] + }, + new() + { + FinishReason = ChatFinishReason.ToolCalls, + Contents = [new FunctionCallContent("test-call-id", "TestFunction", new Dictionary { ["param"] = "value" })] } }; - SentryAISpanEnricher.EnrichWithStreamingResponses(_mockSpan, messages); - - _mockSpan.Received(1).SetData("gen_ai.usage.input_tokens", 8L); - _mockSpan.Received(1).SetData("gen_ai.usage.output_tokens", 15L); - _mockSpan.Received(1).SetData("gen_ai.usage.total_tokens", 23L); - } - - [Fact] - public void EnrichWithStreamingResponse_ConcatenatesResponseText() - { - var messages = new List - { - new(ChatRole.Assistant, "Hello"), - new(ChatRole.Assistant, " world"), - new(ChatRole.Assistant, "!") - }; + var aiOptions = new SentryAIOptions { IncludeAIResponseContent = true }; - SentryAISpanEnricher.EnrichWithStreamingResponses(_mockSpan, messages); + // Act + SentryAISpanEnricher.EnrichWithStreamingResponses(span, streamingMessages, aiOptions); - _mockSpan.Received(1).SetData("gen_ai.response.text", "Hello world!"); + // Assert + span.Data[SentryAIConstants.SpanAttributes.ResponseText].Should().Be("Hello world!"); + span.Data[SentryAIConstants.SpanAttributes.ResponseModel].Should().Be("streaming-model-id"); + span.Data[SentryAIConstants.SpanAttributes.UsageInputTokens].Should().Be(25L); + span.Data[SentryAIConstants.SpanAttributes.UsageOutputTokens].Should().Be(13L); + span.Data[SentryAIConstants.SpanAttributes.UsageTotalTokens].Should().Be(38L); + span.Data[SentryAIConstants.SpanAttributes.ResponseToolCalls].Should().NotBeNull(); } [Fact] - public void EnrichWithStreamingResponse_DoesNotSetResponseText_WhenIncludeResponseContentIsFalse() + public void EnrichWithStreamingResponses_SetsData_WithoutResponseContent_WhenDisabled() { - var messages = new List - { - new(ChatRole.Assistant, "Hello world") - }; - var aiOptions = new SentryAIOptions { IncludeAIResponseContent = false }; + // Arrange + const string spanOp = "test_operation"; + const string spanDesc = "test_description"; + var span = _fixture.Hub.StartSpan(spanOp, spanDesc); - SentryAISpanEnricher.EnrichWithStreamingResponses(_mockSpan, messages, aiOptions); - - _mockSpan.DidNotReceive().SetData("gen_ai.response.text", Arg.Any()); - } - - [Fact] - public void EnrichWithStreamingResponse_SetsModelId_FromLastMessageWithModelId() - { - var messages = new List + var streamingMessages = new List { - new(ChatRole.Assistant, "Hello") { ModelId = "gpt-3.5" }, - new(ChatRole.Assistant, " world") { ModelId = "gpt-4" }, - new(ChatRole.Assistant, "!") + new() + { + ModelId = "streaming-model-id", + Contents = [ + new TextContent("Hello world!"), + new UsageContent(new UsageDetails { InputTokenCount = 20, OutputTokenCount = 10 }) + ] + } }; - SentryAISpanEnricher.EnrichWithStreamingResponses(_mockSpan, messages); + var aiOptions = new SentryAIOptions { IncludeAIResponseContent = false }; + + // Act + SentryAISpanEnricher.EnrichWithStreamingResponses(span, streamingMessages, aiOptions); - _mockSpan.Received(1).SetData("gen_ai.response.model_id", "gpt-4"); + // Assert + span.Data.Should().NotContainKey(SentryAIConstants.SpanAttributes.ResponseText); + span.Data[SentryAIConstants.SpanAttributes.ResponseModel].Should().Be("streaming-model-id"); + span.Data[SentryAIConstants.SpanAttributes.UsageInputTokens].Should().Be(20L); + span.Data[SentryAIConstants.SpanAttributes.UsageOutputTokens].Should().Be(10L); + span.Data[SentryAIConstants.SpanAttributes.UsageTotalTokens].Should().Be(30L); } } From cfa47ec091f7b783687dc88d22862b4ace470050 Mon Sep 17 00:00:00 2001 From: Sentry Github Bot Date: Fri, 31 Oct 2025 20:03:43 +0000 Subject: [PATCH 28/86] Format code --- src/Sentry.Extensions.AI/SentryAISpanEnricher.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Sentry.Extensions.AI/SentryAISpanEnricher.cs b/src/Sentry.Extensions.AI/SentryAISpanEnricher.cs index 35dfd8e826..db68c5bc5d 100644 --- a/src/Sentry.Extensions.AI/SentryAISpanEnricher.cs +++ b/src/Sentry.Extensions.AI/SentryAISpanEnricher.cs @@ -78,7 +78,7 @@ internal static void EnrichWithRequest(ISpan span, ChatMessage[] messages, ChatO /// AI-specific options internal static void EnrichWithResponse(ISpan span, ChatResponse response, SentryAIOptions aiOptions) { - EnrichWithStreamingResponses(span, [..response.ToChatResponseUpdates()], aiOptions); + EnrichWithStreamingResponses(span, [.. response.ToChatResponseUpdates()], aiOptions); } /// From ee9a7bb360cda20380ae8d41c03a8d04f3e8a56f Mon Sep 17 00:00:00 2001 From: Alex Sohn Date: Fri, 31 Oct 2025 17:07:22 -0400 Subject: [PATCH 29/86] fix broken tool spans + cleanup unused methods --- .../Program.cs | 2 +- .../Sentry.Samples.ME.AI.Console/Program.cs | 2 +- .../Extensions/SentryAIExtensions.cs | 22 +++++++++++++++++++ .../Sentry.Extensions.AI.csproj | 5 +++++ src/Sentry.Extensions.AI/SentryAIConstants.cs | 21 ------------------ src/Sentry.Extensions.AI/SentryChatClient.cs | 18 --------------- 6 files changed, 29 insertions(+), 41 deletions(-) diff --git a/samples/Sentry.Samples.ME.AI.AspNetCore/Program.cs b/samples/Sentry.Samples.ME.AI.AspNetCore/Program.cs index 06bda3e6d4..4f69c79f73 100644 --- a/samples/Sentry.Samples.ME.AI.AspNetCore/Program.cs +++ b/samples/Sentry.Samples.ME.AI.AspNetCore/Program.cs @@ -131,7 +131,7 @@ }, "CalculateAverageAge", "Calculates the average from a list of ages. You should first get individual ages using GetPersonAge, then use this tool to calculate the average. Takes about 200ms to complete.") ] - }; + }.WithSentryToolInstrumentation(); try { diff --git a/samples/Sentry.Samples.ME.AI.Console/Program.cs b/samples/Sentry.Samples.ME.AI.Console/Program.cs index 60dbff8a56..d362f078f7 100644 --- a/samples/Sentry.Samples.ME.AI.Console/Program.cs +++ b/samples/Sentry.Samples.ME.AI.Console/Program.cs @@ -89,7 +89,7 @@ return $"Complex calculation result for {number}: {result}"; }, "ComplexCalculation", "Performs a complex mathematical calculation. Takes about 1 second to complete.") ] -}; +}.WithSentryToolInstrumentation(); var response = await client.GetResponseAsync( "Please help me with the following tasks: 1) Find Alice's age, 2) Get weather in New York, and 3) Calculate a complex result for number 15. Please use the appropriate tools for each task.", diff --git a/src/Sentry.Extensions.AI/Extensions/SentryAIExtensions.cs b/src/Sentry.Extensions.AI/Extensions/SentryAIExtensions.cs index 743280ea78..caf6c1b5c3 100644 --- a/src/Sentry.Extensions.AI/Extensions/SentryAIExtensions.cs +++ b/src/Sentry.Extensions.AI/Extensions/SentryAIExtensions.cs @@ -10,6 +10,28 @@ namespace Sentry.Extensions.AI; [EditorBrowsable(EditorBrowsableState.Never)] public static class SentryAIExtensions { + /// + /// Wrap tool calls specified in with Sentry agent instrumentation + /// + /// The that contains the to instrument + public static ChatOptions WithSentryToolInstrumentation(this ChatOptions options) + { + if (options.Tools is null || options.Tools.Count == 0) + { + return options; + } + + for (var i = 0; i < options.Tools.Count; i++) + { + if (options.Tools[i] is AIFunction fn and not SentryInstrumentedFunction) + { + options.Tools[i] = new SentryInstrumentedFunction(fn, options); + } + } + + return options; + } + /// /// Wraps an IChatClient with Sentry agent instrumentation. /// diff --git a/src/Sentry.Extensions.AI/Sentry.Extensions.AI.csproj b/src/Sentry.Extensions.AI/Sentry.Extensions.AI.csproj index 2c57abd4f1..d02ebe5adc 100644 --- a/src/Sentry.Extensions.AI/Sentry.Extensions.AI.csproj +++ b/src/Sentry.Extensions.AI/Sentry.Extensions.AI.csproj @@ -6,6 +6,11 @@ Microsoft.Extensions.AI integration for Sentry - captures AI Agent telemetry spans per Sentry AI Agents module. + + + + + diff --git a/src/Sentry.Extensions.AI/SentryAIConstants.cs b/src/Sentry.Extensions.AI/SentryAIConstants.cs index 8200ad6ff6..95bafd1239 100644 --- a/src/Sentry.Extensions.AI/SentryAIConstants.cs +++ b/src/Sentry.Extensions.AI/SentryAIConstants.cs @@ -4,27 +4,6 @@ namespace Sentry.Extensions.AI; internal static class SentryAIConstants { - /// - /// - /// Sentry will add a to AdditionalAttribute in . - /// - /// - /// This constant represents the string key to get the span which represents the agent span. - /// - /// - internal const string OptionsAdditionalAttributeAgentSpanName = "SentryChatMessageAgentSpan"; - - /// - /// - /// When an LLM uses a tool, Sentry will add an argument to . - /// The additional argument will contain the request which initialized the tool call. - /// - /// - /// This constant represents the string key to get the message. - /// - /// - internal const string KeyMessageFunctionArgumentDictKey = "SentrySpanToMessageDictKey"; - /// /// The list of strings which FunctionInvokingChatClient(FICC) uses to start the tool call . /// diff --git a/src/Sentry.Extensions.AI/SentryChatClient.cs b/src/Sentry.Extensions.AI/SentryChatClient.cs index a6be6a83c2..4c35acadbb 100644 --- a/src/Sentry.Extensions.AI/SentryChatClient.cs +++ b/src/Sentry.Extensions.AI/SentryChatClient.cs @@ -29,7 +29,6 @@ public override async Task GetResponseAsync(IEnumerable GetStreamingResponseA var chatMessages = messages as ChatMessage[] ?? messages.ToArray(); var outerSpan = TryGetRootSpan(options); var innerSpan = CreateChatSpan(outerSpan, options); - WrapTools(options); var hasNext = true; var responses = new List(); @@ -146,20 +144,4 @@ private ISpan CreateChatSpan(ISpan? outerSpan, ChatOptions? options) ? outerSpan.StartChild(SentryAIConstants.SpanAttributes.ChatOperation, chatSpanName) : _hub.StartSpan(SentryAIConstants.SpanAttributes.ChatOperation, chatSpanName); } - - private static void WrapTools(ChatOptions? options) - { - if (options?.Tools is null || options.Tools.Count == 0) - { - return; - } - // We wrap tools here so we don't have to wrap them each time we grab the response - for (var i = 0; i < options.Tools.Count; i++) - { - if (options.Tools[i] is AIFunction fn and not SentryInstrumentedFunction) - { - options.Tools[i] = new SentryInstrumentedFunction(fn, options); - } - } - } } From e174de36aff453024731def252700edf42c21fcd Mon Sep 17 00:00:00 2001 From: Alex Sohn Date: Fri, 31 Oct 2025 17:13:49 -0400 Subject: [PATCH 30/86] cleanup test usings --- test/Sentry.Extensions.AI.Tests/SentryAIOptionsTests.cs | 1 - test/Sentry.Extensions.AI.Tests/SentryAISpanEnricherTests.cs | 3 --- .../SentryInstrumentedFunctionTests.cs | 3 --- 3 files changed, 7 deletions(-) diff --git a/test/Sentry.Extensions.AI.Tests/SentryAIOptionsTests.cs b/test/Sentry.Extensions.AI.Tests/SentryAIOptionsTests.cs index 152376828c..a681451e49 100644 --- a/test/Sentry.Extensions.AI.Tests/SentryAIOptionsTests.cs +++ b/test/Sentry.Extensions.AI.Tests/SentryAIOptionsTests.cs @@ -1,5 +1,4 @@ #nullable enable -using Sentry.Extensions.AI; namespace Sentry.Extensions.AI.Tests; diff --git a/test/Sentry.Extensions.AI.Tests/SentryAISpanEnricherTests.cs b/test/Sentry.Extensions.AI.Tests/SentryAISpanEnricherTests.cs index 867969ba95..3fe4398683 100644 --- a/test/Sentry.Extensions.AI.Tests/SentryAISpanEnricherTests.cs +++ b/test/Sentry.Extensions.AI.Tests/SentryAISpanEnricherTests.cs @@ -1,8 +1,5 @@ #nullable enable -using System.Text.Json; using Microsoft.Extensions.AI; -using NSubstitute; -using Sentry.Extensions.AI; namespace Sentry.Extensions.AI.Tests; diff --git a/test/Sentry.Extensions.AI.Tests/SentryInstrumentedFunctionTests.cs b/test/Sentry.Extensions.AI.Tests/SentryInstrumentedFunctionTests.cs index ccc6e8c781..8b60d99bb3 100644 --- a/test/Sentry.Extensions.AI.Tests/SentryInstrumentedFunctionTests.cs +++ b/test/Sentry.Extensions.AI.Tests/SentryInstrumentedFunctionTests.cs @@ -1,8 +1,5 @@ #nullable enable using Microsoft.Extensions.AI; -using NSubstitute; -using Sentry.Extensions.AI; -using Sentry.Testing; namespace Sentry.Extensions.AI.Tests; From 9c9004ce36e0f2eaaf1a50cb006c2ec01f090805 Mon Sep 17 00:00:00 2001 From: Alex Sohn Date: Fri, 31 Oct 2025 23:01:16 -0400 Subject: [PATCH 31/86] fix finally clause in SentryChatClient --- src/Sentry.Extensions.AI/SentryChatClient.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Sentry.Extensions.AI/SentryChatClient.cs b/src/Sentry.Extensions.AI/SentryChatClient.cs index 4c35acadbb..ce27b11c07 100644 --- a/src/Sentry.Extensions.AI/SentryChatClient.cs +++ b/src/Sentry.Extensions.AI/SentryChatClient.cs @@ -69,7 +69,7 @@ public override async IAsyncEnumerable GetStreamingResponseA var outerSpan = TryGetRootSpan(options); var innerSpan = CreateChatSpan(outerSpan, options); - var hasNext = true; + var hasNext = false; var responses = new List(); var enumerator = base .GetStreamingResponseAsync(chatMessages, options, cancellationToken) From a5b5ff63de6991cec32bd373fead0e2a93dc261a Mon Sep 17 00:00:00 2001 From: Alex Sohn Date: Fri, 31 Oct 2025 23:41:30 -0400 Subject: [PATCH 32/86] fix activitylistener tests --- .../SentryAIActivityListenerTests.cs | 67 +++++++++++++++---- 1 file changed, 55 insertions(+), 12 deletions(-) diff --git a/test/Sentry.Extensions.AI.Tests/SentryAIActivityListenerTests.cs b/test/Sentry.Extensions.AI.Tests/SentryAIActivityListenerTests.cs index 2e71f5c8df..0746819185 100644 --- a/test/Sentry.Extensions.AI.Tests/SentryAIActivityListenerTests.cs +++ b/test/Sentry.Extensions.AI.Tests/SentryAIActivityListenerTests.cs @@ -1,21 +1,32 @@ #nullable enable +using Sentry.Extensions.AI; namespace Sentry.Extensions.AI.Tests; public class SentryAIActivityListenerTests { - private IHub Hub { get; } - private ActivitySource SentryActivitySource { get; } - private ISpan Span { get; } - - public SentryAIActivityListenerTests() + private class Fixture { - Hub = Substitute.For(); - SentryActivitySource = SentryAIActivitySource.Instance; - Span = Substitute.For(); - SentrySdk.UseHub(Hub); + private SentryOptions Options { get; } + public ISentryClient Client { get; } + public IHub Hub { get; set; } + + public Fixture() + { + Options = new SentryOptions + { + Dsn = ValidDsn, + TracesSampleRate = 1.0, + }; + + Hub = Substitute.For(); + Client = Substitute.For(); + SentrySdk.Init(Options); + } } + private readonly Fixture _fixture = new(); + [Fact] public void Init_AddsActivityListenerToActivitySource() { @@ -23,7 +34,7 @@ public void Init_AddsActivityListenerToActivitySource() SentryAIActivityListener.Init(); // Assert - Assert.True(SentryActivitySource.HasListeners()); + Assert.True(SentryAIActivitySource.Instance.HasListeners()); } [Theory] @@ -62,7 +73,7 @@ public void Sample_ReturnsAllDataAndRecordedForFICCActivityNames(string activity SentryAIActivityListener.Init(); // Act - using var activity = SentryActivitySource.StartActivity(activityName); + using var activity = SentryAIActivitySource.Instance.StartActivity(activityName); // Assert Assert.NotNull(activity); @@ -80,7 +91,7 @@ public void Sample_ReturnsNoneForNonFICCActivityNames(string activityName) SentryAIActivityListener.Init(); // Act - using var activity = SentryActivitySource.StartActivity(activityName); + using var activity = SentryAIActivitySource.Instance.StartActivity(activityName); // Assert // For non-FICC activity names, the activity may still be created but not recorded @@ -89,4 +100,36 @@ public void Sample_ReturnsNoneForNonFICCActivityNames(string activityName) Assert.False(activity.Recorded); } } + + [Fact] + public void Init_MultipleCalls_NoDuplicateListener_StartsOnlyOneTransaction() + { + // Arrange + var sent = 0; + using var _ = SentrySdk.Init(o => + { + o.Dsn = ValidDsn; + o.TracesSampleRate = 1.0; + // Count transactions just before they are sent: + o.SetBeforeSendTransaction(t => + { + Interlocked.Increment(ref sent); + return t; + }); + }); + + // Act + SentryAIActivityListener.Init(); + SentryAIActivityListener.Init(); + SentryAIActivityListener.Init(); + + var activity = SentryAIActivitySource.Instance.StartActivity(SentryAIConstants.FICCActivityNames[0]); + + // Assert + Assert.NotNull(activity); + Assert.True(SentryAIActivitySource.Instance.HasListeners()); + activity.Stop(); + + Assert.Equal(1, Volatile.Read(ref sent)); + } } From b636887a72575252fac7c6d52fe7e1175eeef9bd Mon Sep 17 00:00:00 2001 From: Alex Sohn Date: Sat, 1 Nov 2025 17:35:14 -0400 Subject: [PATCH 33/86] cleanup tests and other stuff --- .../SentryAISpanEnricher.cs | 42 ++++++--- src/Sentry.Extensions.AI/SentryChatClient.cs | 33 +++---- .../SentryAIActivityListenerTests.cs | 22 ----- .../SentryChatClientTests.cs | 94 +++++++++++++++++-- .../SentryInstrumentedFunctionTests.cs | 3 +- 5 files changed, 129 insertions(+), 65 deletions(-) diff --git a/src/Sentry.Extensions.AI/SentryAISpanEnricher.cs b/src/Sentry.Extensions.AI/SentryAISpanEnricher.cs index db68c5bc5d..011ce14f77 100644 --- a/src/Sentry.Extensions.AI/SentryAISpanEnricher.cs +++ b/src/Sentry.Extensions.AI/SentryAISpanEnricher.cs @@ -121,7 +121,7 @@ public static void EnrichWithStreamingResponses(ISpan span, List contents, ISpan span) } } - private static string FormatAvailableTools(IList tools) => - FormatAsJson(tools, tool => new + private static string FormatAvailableTools(IList tools) + { + try { - name = tool.Name, - description = tool.Description - }); + var str = FormatAsJson(tools, tool => new + { + name = tool.Name, + description = tool.Description + }); + return str; + } + catch + { + return ""; + } + } - private static string FormatRequestMessage(ChatMessage[] messages) => - FormatAsJson(messages, message => new + private static string FormatRequestMessage(ChatMessage[] messages) + { + try { - role = message.Role, - content = message.Text - }); + var str = FormatAsJson(messages, message => new + { + role = message.Role, + content = message.Text + }); + return str; + } + catch + { + return ""; + } + } private static string FormatFunctionCallContent(FunctionCallContent[] content) => FormatAsJson(content, c => new diff --git a/src/Sentry.Extensions.AI/SentryChatClient.cs b/src/Sentry.Extensions.AI/SentryChatClient.cs index ce27b11c07..e58c4af441 100644 --- a/src/Sentry.Extensions.AI/SentryChatClient.cs +++ b/src/Sentry.Extensions.AI/SentryChatClient.cs @@ -12,11 +12,18 @@ internal sealed class SentryChatClient : DelegatingChatClient public SentryChatClient(IChatClient client, Action? configure = null) : base(client) { _sentryAIOptions = new SentryAIOptions(); - configure?.Invoke(_sentryAIOptions); + try + { + configure?.Invoke(_sentryAIOptions); - if (_sentryAIOptions.InitializeSdk && !SentrySdk.IsEnabled) + if (_sentryAIOptions.InitializeSdk && !SentrySdk.IsEnabled) + { + SentrySdk.Init(_sentryAIOptions); + } + } + catch (Exception ex) { - SentrySdk.Init(_sentryAIOptions); + SentrySdk.CaptureException(ex); } } @@ -47,15 +54,6 @@ public override async Task GetResponseAsync(IEnumerable @@ -69,7 +67,6 @@ public override async IAsyncEnumerable GetStreamingResponseA var outerSpan = TryGetRootSpan(options); var innerSpan = CreateChatSpan(outerSpan, options); - var hasNext = false; var responses = new List(); var enumerator = base .GetStreamingResponseAsync(chatMessages, options, cancellationToken) @@ -81,7 +78,7 @@ public override async IAsyncEnumerable GetStreamingResponseA ChatResponseUpdate? current; try { - hasNext = await enumerator.MoveNextAsync().ConfigureAwait(false); + var hasNext = await enumerator.MoveNextAsync().ConfigureAwait(false); if (!hasNext) { @@ -100,14 +97,6 @@ public override async IAsyncEnumerable GetStreamingResponseA _hub.CaptureException(ex); throw; } - finally - { - // if options was null, and we don't have next text, we need to finish root span immediately - if (options == null && !hasNext) - { - outerSpan?.Finish(SpanStatus.Ok); - } - } yield return current; } diff --git a/test/Sentry.Extensions.AI.Tests/SentryAIActivityListenerTests.cs b/test/Sentry.Extensions.AI.Tests/SentryAIActivityListenerTests.cs index 0746819185..e60768f177 100644 --- a/test/Sentry.Extensions.AI.Tests/SentryAIActivityListenerTests.cs +++ b/test/Sentry.Extensions.AI.Tests/SentryAIActivityListenerTests.cs @@ -5,28 +5,6 @@ namespace Sentry.Extensions.AI.Tests; public class SentryAIActivityListenerTests { - private class Fixture - { - private SentryOptions Options { get; } - public ISentryClient Client { get; } - public IHub Hub { get; set; } - - public Fixture() - { - Options = new SentryOptions - { - Dsn = ValidDsn, - TracesSampleRate = 1.0, - }; - - Hub = Substitute.For(); - Client = Substitute.For(); - SentrySdk.Init(Options); - } - } - - private readonly Fixture _fixture = new(); - [Fact] public void Init_AddsActivityListenerToActivitySource() { diff --git a/test/Sentry.Extensions.AI.Tests/SentryChatClientTests.cs b/test/Sentry.Extensions.AI.Tests/SentryChatClientTests.cs index f022b178eb..c82649f201 100644 --- a/test/Sentry.Extensions.AI.Tests/SentryChatClientTests.cs +++ b/test/Sentry.Extensions.AI.Tests/SentryChatClientTests.cs @@ -5,53 +5,131 @@ namespace Sentry.Extensions.AI.Tests; public class SentryChatClientTests { + private class Fixture + { + private SentryOptions Options { get; } + public ISentryClient Client { get; } + public IHub Hub { get; set; } + + public Fixture() + { + Options = new SentryOptions + { + Dsn = ValidDsn, + TracesSampleRate = 1.0, + }; + + SentrySdk.Init(Options); + Hub = SentrySdk.CurrentHub; + Client = Substitute.For(); + } + } + + private readonly Fixture _fixture = new(); + [Fact] public async Task CompleteAsync_CallsInnerClient() { + // Arrange var inner = Substitute.For(); + var sentryChatClient = new SentryChatClient(inner); var message = new ChatMessage(ChatRole.Assistant, "ok"); var chatResponse = new ChatResponse(message); inner.GetResponseAsync(Arg.Any>(), Arg.Any(), Arg.Any()) .Returns(Task.FromResult(chatResponse)); - var sentryChatClient = new SentryChatClient(inner); - + // Act var res = await sentryChatClient.GetResponseAsync([new ChatMessage(ChatRole.User, "hi")]); + // Assert Assert.Equal([message], res.Messages); await inner.Received(1).GetResponseAsync(Arg.Any>(), Arg.Any(), Arg.Any()); } [Fact] - public async Task CompleteStreamingAsync_CallsInnerClient() + public async Task CompleteStreamingAsync_CallsInnerClient_AndSetsSpanData() { - var inner = Substitute.For(); + // Arrange - Use Fixture Hub to start transaction + var transaction = _fixture.Hub.StartTransaction("test-streaming", "test"); + _fixture.Hub.ConfigureScope(scope => scope.Transaction = transaction); + SentrySdk.ConfigureScope(scope => scope.Transaction = transaction); + + var inner = Substitute.For(); inner.GetStreamingResponseAsync(Arg.Any>(), Arg.Any(), Arg.Any()) .Returns(CreateTestStreamingUpdatesAsync()); - var client = new SentryChatClient(inner); - var results = new List(); + + // Act await foreach (var update in client.GetStreamingResponseAsync([new ChatMessage(ChatRole.User, "hi")])) { results.Add(update); } + // Assert Assert.Equal(2, results.Count); Assert.Equal("Hello", results[0].Text); Assert.Equal(" World!", results[1].Text); - inner.Received(1).GetStreamingResponseAsync(Arg.Any>(), Arg.Any(), Arg.Any()); + var spans = transaction.Spans; + var chatSpan = spans.FirstOrDefault(s => s.Operation == SentryAIConstants.SpanAttributes.ChatOperation); + Assert.NotNull(chatSpan); + Assert.Equal(SpanStatus.Ok, chatSpan.Status); + Assert.True(chatSpan.IsFinished); + Assert.Equal("chat", chatSpan.Data[SentryAIConstants.SpanAttributes.OperationName]); + Assert.Equal("Hello World!", chatSpan.Data[SentryAIConstants.SpanAttributes.ResponseText]); + } + + [Fact] + public async Task CompleteStreamingAsync_HandlesErrors_AndFinishesSpanWithException() + { + // Arrange + var transaction = _fixture.Hub.StartTransaction("test-streaming-error", "test"); + _fixture.Hub.ConfigureScope(scope => scope.Transaction = transaction); + SentrySdk.ConfigureScope(scope => scope.Transaction = transaction); + + var inner = Substitute.For(); + var expectedException = new InvalidOperationException("Streaming failed"); + inner.GetStreamingResponseAsync(Arg.Any>(), Arg.Any(), + Arg.Any()) + .Returns(CreateFailingStreamingUpdatesAsync(expectedException)); + var client = new SentryChatClient(inner); + + // Act + var actualException = await Assert.ThrowsAsync(async () => + { + await foreach (var update in client.GetStreamingResponseAsync([new ChatMessage(ChatRole.User, "hi")])) + { + // Should not reach here due to exception + } + }); + + Assert.Equal(expectedException.Message, actualException.Message); + + // Assert + var spans = transaction.Spans; + var chatSpan = spans.FirstOrDefault(s => s.Operation == SentryAIConstants.SpanAttributes.ChatOperation); + Assert.NotNull(chatSpan); + Assert.Equal(SpanStatus.InternalError, chatSpan.Status); + Assert.True(chatSpan.IsFinished); + Assert.Equal("chat", chatSpan.Data[SentryAIConstants.SpanAttributes.OperationName]); + } + + private static async IAsyncEnumerable CreateFailingStreamingUpdatesAsync(Exception exception) + { + yield return new ChatResponseUpdate(ChatRole.System, "Hello"); + await Task.Yield(); + throw exception; } private static async IAsyncEnumerable CreateTestStreamingUpdatesAsync() { yield return new ChatResponseUpdate(ChatRole.System, "Hello"); - await Task.Yield(); // Make it async + await Task.Yield(); yield return new ChatResponseUpdate(ChatRole.System, " World!"); } } diff --git a/test/Sentry.Extensions.AI.Tests/SentryInstrumentedFunctionTests.cs b/test/Sentry.Extensions.AI.Tests/SentryInstrumentedFunctionTests.cs index 8b60d99bb3..d4353cfb63 100644 --- a/test/Sentry.Extensions.AI.Tests/SentryInstrumentedFunctionTests.cs +++ b/test/Sentry.Extensions.AI.Tests/SentryInstrumentedFunctionTests.cs @@ -47,7 +47,6 @@ public async Task InvokeCoreAsync_WithNullResult_ReturnsNull() var result = await sentryFunction.InvokeAsync(arguments); // Assert - // AIFunctionFactory may return JsonElement with ValueKind.Null instead of actual null if (result is JsonElement jsonElement) { Assert.Equal(JsonValueKind.Null, jsonElement.ValueKind); @@ -219,7 +218,7 @@ public static IDisposable InitializeSdk() { return SentrySdk.Init(options => { - options.Dsn = "https://3f3a29aa3a3aff@fake-sentry.io:65535/2147483647"; + options.Dsn = ValidDsn; options.TracesSampleRate = 1.0; options.Debug = false; }); From c39153edd9ef0622f1b1ee91ac8de4f6f18707cf Mon Sep 17 00:00:00 2001 From: Alex Sohn Date: Sun, 2 Nov 2025 18:25:53 -0500 Subject: [PATCH 34/86] add await using on enumerator in GetStreamingResponseAsync --- samples/Sentry.Samples.ME.AI.Console/Program.cs | 9 --------- src/Sentry.Extensions.AI/SentryChatClient.cs | 12 +++++++++--- 2 files changed, 9 insertions(+), 12 deletions(-) diff --git a/samples/Sentry.Samples.ME.AI.Console/Program.cs b/samples/Sentry.Samples.ME.AI.Console/Program.cs index d362f078f7..39d39e327a 100644 --- a/samples/Sentry.Samples.ME.AI.Console/Program.cs +++ b/samples/Sentry.Samples.ME.AI.Console/Program.cs @@ -97,15 +97,6 @@ logger.LogInformation("Response: {ResponseText}", response.Messages?.FirstOrDefault()?.Text ?? "No response"); -// Demonstrate streaming with Sentry instrumentation -logger.LogInformation("Making streaming AI call with Sentry instrumentation..."); - -var streamingOptions = new ChatOptions -{ - ModelId = "gpt-4o-mini", - MaxOutputTokens = 1024 -}; - logger.LogInformation("Microsoft.Extensions.AI sample completed! Check your Sentry dashboard for the trace data."); transaction.Finish(); diff --git a/src/Sentry.Extensions.AI/SentryChatClient.cs b/src/Sentry.Extensions.AI/SentryChatClient.cs index e58c4af441..b6309e0b09 100644 --- a/src/Sentry.Extensions.AI/SentryChatClient.cs +++ b/src/Sentry.Extensions.AI/SentryChatClient.cs @@ -68,9 +68,15 @@ public override async IAsyncEnumerable GetStreamingResponseA var innerSpan = CreateChatSpan(outerSpan, options); var responses = new List(); - var enumerator = base + + // Incorrect Roslyn analyzer error when doing await using on IAsyncDisposable + // See: https://github.com/dotnet/roslyn-analyzers/issues/5712 +#pragma warning disable CA2007 + await using var enumerator = base .GetStreamingResponseAsync(chatMessages, options, cancellationToken) - .GetAsyncEnumerator(cancellationToken); + .ConfigureAwait(false) + .GetAsyncEnumerator(); +#pragma warning restore CA2007 SentryAISpanEnricher.EnrichWithRequest(innerSpan, chatMessages, options, _sentryAIOptions); while (true) @@ -78,7 +84,7 @@ public override async IAsyncEnumerable GetStreamingResponseA ChatResponseUpdate? current; try { - var hasNext = await enumerator.MoveNextAsync().ConfigureAwait(false); + var hasNext = await enumerator.MoveNextAsync(); if (!hasNext) { From 85df6ef8edf9e95de4b8a4177e6304b3b3785b12 Mon Sep 17 00:00:00 2001 From: Alex Sohn Date: Mon, 3 Nov 2025 13:09:56 -0500 Subject: [PATCH 35/86] fix tool error handling and add tool exceptions in AspNetCore sample --- .../Program.cs | 132 ++++++++++-------- .../SentryAIActivityListener.cs | 3 +- .../SentryAISpanEnricher.cs | 63 +++++---- src/Sentry.Extensions.AI/SentryChatClient.cs | 80 ++++++----- .../SentryInstrumentedFunction.cs | 25 ++-- 5 files changed, 171 insertions(+), 132 deletions(-) diff --git a/samples/Sentry.Samples.ME.AI.AspNetCore/Program.cs b/samples/Sentry.Samples.ME.AI.AspNetCore/Program.cs index 4f69c79f73..6be36f2bbd 100644 --- a/samples/Sentry.Samples.ME.AI.AspNetCore/Program.cs +++ b/samples/Sentry.Samples.ME.AI.AspNetCore/Program.cs @@ -41,16 +41,9 @@ .UseFunctionInvocation() .Build(); -// Register the OpenAI API client and Sentry-instrumented chat client -builder.Services.AddSingleton(client); - -var app = builder.Build(); - -// Simple test endpoint that demonstrates AI integration with multiple tools -app.MapGet("/test", async (IChatClient chatClient, ILogger logger) => +ChatOptions GetOptions(ILogger logger) { - logger.LogInformation("Running AI test endpoint with multiple tools"); - var options = new ChatOptions + return new ChatOptions { ModelId = "gpt-4o-mini", MaxOutputTokens = 1024, @@ -92,63 +85,58 @@ return $"Complex calculation result for {number}: {result}"; }, "ComplexCalculation", "Performs a complex mathematical calculation. Takes about 1 second to complete."), - // Tool 4: Tool that can reference other data - AIFunctionFactory.Create(async (string personName, string location) => - { - logger.LogInformation("GetPersonInfo called for {PersonName} in {Location}", personName, location); - await Task.Delay(300); // 300ms delay - var age = personName switch - { - "Alice" => "25", - "Bob" => "30", - "Charlie" => "35", - _ => "40" - }; - var weather = location.ToLower() switch - { - "new york" => "Sunny, 72°F", - "london" => "Cloudy, 60°F", - "tokyo" => "Rainy, 68°F", - _ => "Unknown weather conditions" - }; - return $"{personName} (age {age}) is experiencing {weather} weather in {location}"; - }, "GetPersonInfo", - "Gets comprehensive info about a person in a specific location by combining age and weather data. Takes about 300ms."), - - // Tool 5: Data aggregation tool that requests individual ages + // Tool 4: Data aggregation tool that requests individual ages AIFunctionFactory.Create(async (int[] ages) => + { + logger.LogInformation("CalculateAverageAge called with ages: {Ages}", string.Join(", ", ages)); + await Task.Delay(200); // 200ms delay for calculation + if (ages.Length == 0) { - logger.LogInformation("CalculateAverageAge called with ages: {Ages}", string.Join(", ", ages)); - await Task.Delay(200); // 200ms delay for calculation - if (ages.Length == 0) - { - return "No ages provided"; - } - - var average = ages.Average(); - return - $"Average age calculated: {average:F1} years from {ages.Length} people. Individual ages: {string.Join(", ", ages)}"; - }, "CalculateAverageAge", - "Calculates the average from a list of ages. You should first get individual ages using GetPersonAge, then use this tool to calculate the average. Takes about 200ms to complete.") + return "No ages provided"; + } + + var average = ages.Average(); + return $"Average age calculated: {average:F1} years from {ages.Length} people. Individual ages: {string.Join(", ", ages)}"; + }, "CalculateAverageAge", "Calculates the average from a list of ages. You should first get individual ages using GetPersonAge, then use this tool to calculate the average. Takes about 200ms to complete."), + + // Tool 5: Tool that will throw an error + AIFunctionFactory.Create(async () => + { + logger.LogInformation("Mysterious tool called"); + await Task.Delay(2000); + throw new TimeoutException("Mysterious tool called, but returned an error :("); + }, "MysteriousTool", "May return an error...") ] }.WithSentryToolInstrumentation(); +} + +// Register the OpenAI API client and Sentry-instrumented chat client +builder.Services.AddSingleton(client); + +var app = builder.Build(); + +// Simple test endpoint that demonstrates AI integration with multiple tools +app.MapGet("/test", async (IChatClient chatClient, ILogger logger) => +{ + logger.LogInformation("Running AI test endpoint with multiple tools"); + var testOptions = GetOptions(logger); try { var streamingResponse = new List(); await foreach (var update in chatClient.GetStreamingResponseAsync([ - new ChatMessage(ChatRole.User, - """ - Please help me with the following tasks: - 1) Find Alice's age, - 2) Get weather in New York, - 3) Calculate a complex result for number 15, - 4) Get comprehensive info for Bob in London, - 5) Calculate average age for Alice, Bob, and Charlie - (first get each person's age individually using GetPersonAge, then use CalculateAverageAge with those results). - Please use the appropriate tools for each task and demonstrate tool chaining where needed. - """) - ], options)) + new ChatMessage(ChatRole.User, + """ + Please help me with the following tasks: + 1) Find Alice's age, + 2) Get weather in New York, + 3) Calculate a complex result for number 15, + 4) Get comprehensive info for Bob in London, + 5) Calculate average age for Alice, Bob, and Charlie + (first get each person's age individually using GetPersonAge, then use CalculateAverageAge with those results). + Please use the appropriate tools for each task and demonstrate tool chaining where needed. + """) + ], testOptions)) { if (!string.IsNullOrEmpty(update.Text)) { @@ -172,4 +160,34 @@ } }); +app.MapGet("/throw", async (IChatClient chatClient, ILogger logger) => +{ + logger.LogInformation("Running AI test endpoint with a tool that will throw an exception"); + var throwOptions = GetOptions(logger); + + try + { + var update = await chatClient.GetResponseAsync([ + new ChatMessage(ChatRole.User, + """ + Please run these tools in order: + 1) Calculate a complex result for number 15, + 2) the Mysterious tool and tell me what that that returns. + """) + ], throwOptions); + + return Results.Ok(new + { + message = "AI test with multiple tools completed successfully (streaming)", + response = update.Text, + timestamp = DateTime.UtcNow + }); + } + catch (Exception ex) + { + logger.LogError(ex, "Error in AI test endpoint"); + return Results.Problem("An error occurred during the AI test"); + } +}); + app.Run(); diff --git a/src/Sentry.Extensions.AI/SentryAIActivityListener.cs b/src/Sentry.Extensions.AI/SentryAIActivityListener.cs index 7ebd476c17..0860f5e430 100644 --- a/src/Sentry.Extensions.AI/SentryAIActivityListener.cs +++ b/src/Sentry.Extensions.AI/SentryAIActivityListener.cs @@ -25,7 +25,8 @@ internal static class SentryAIActivityListener ActivityStopped = a => { var currSpan = a.GetFused(SentryAIConstants.SentryActivitySpanAttributeName); - currSpan?.Finish(SpanStatus.Ok); + // Don't pass in OK status in case there was an exception + currSpan?.Finish(); }, }; diff --git a/src/Sentry.Extensions.AI/SentryAISpanEnricher.cs b/src/Sentry.Extensions.AI/SentryAISpanEnricher.cs index 011ce14f77..786d4114a2 100644 --- a/src/Sentry.Extensions.AI/SentryAISpanEnricher.cs +++ b/src/Sentry.Extensions.AI/SentryAISpanEnricher.cs @@ -143,46 +143,47 @@ private static void PopulateToolCallsInfo(IList contents, ISpan span) private static string FormatAvailableTools(IList tools) { - try + return FormatAsJson(tools, tool => new { - var str = FormatAsJson(tools, tool => new - { - name = tool.Name, - description = tool.Description - }); - return str; - } - catch - { - return ""; - } + name = tool.Name, + description = tool.Description + }); } private static string FormatRequestMessage(ChatMessage[] messages) { - try + return FormatAsJson(messages, message => new { - var str = FormatAsJson(messages, message => new - { - role = message.Role, - content = message.Text - }); - return str; - } - catch - { - return ""; - } + role = message.Role, + content = message.Text + }); } - private static string FormatFunctionCallContent(FunctionCallContent[] content) => - FormatAsJson(content, c => new + private static string FormatFunctionCallContent(FunctionCallContent[] content) + { + return FormatAsJson(content, c => { - name = c.Name, - type = "function_call", - arguments = JsonSerializer.Serialize(c.Arguments) + string argumentsJson; + try + { + argumentsJson = JsonSerializer.Serialize(c.Arguments); + } + catch + { + argumentsJson = "{\"error\": \"serialization_failed\"}"; + } + + return new + { + name = c.Name, + type = "function_call", + arguments = argumentsJson + }; }); + } - private static string FormatAsJson(IEnumerable items, Func selector) => - JsonSerializer.Serialize(items.Select(selector)); + private static string FormatAsJson(IEnumerable items, Func selector) + { + return JsonSerializer.Serialize(items.Select(selector)); + } } diff --git a/src/Sentry.Extensions.AI/SentryChatClient.cs b/src/Sentry.Extensions.AI/SentryChatClient.cs index b6309e0b09..7ac5aa9080 100644 --- a/src/Sentry.Extensions.AI/SentryChatClient.cs +++ b/src/Sentry.Extensions.AI/SentryChatClient.cs @@ -12,18 +12,12 @@ internal sealed class SentryChatClient : DelegatingChatClient public SentryChatClient(IChatClient client, Action? configure = null) : base(client) { _sentryAIOptions = new SentryAIOptions(); - try - { - configure?.Invoke(_sentryAIOptions); + configure?.Invoke(_sentryAIOptions); - if (_sentryAIOptions.InitializeSdk && !SentrySdk.IsEnabled) - { - SentrySdk.Init(_sentryAIOptions); - } - } - catch (Exception ex) + // If user requested to initialize the SDK, and SDK is not enabled already, then use the options to init Sentry + if (_sentryAIOptions.InitializeSdk && !SentrySdk.IsEnabled) { - SentrySdk.CaptureException(ex); + SentrySdk.Init(_sentryAIOptions); } } @@ -34,24 +28,23 @@ public override async Task GetResponseAsync(IEnumerable GetStreamingResponseA { // Convert to array to avoid multiple enumeration var chatMessages = messages as ChatMessage[] ?? messages.ToArray(); - var outerSpan = TryGetRootSpan(options); - var innerSpan = CreateChatSpan(outerSpan, options); + var agentSpan = TryGetAgentSpan(options); + var chatSpan = CreateChatSpan(agentSpan, options); var responses = new List(); // Incorrect Roslyn analyzer error when doing await using on IAsyncDisposable - // See: https://github.com/dotnet/roslyn-analyzers/issues/5712 + // https://github.com/dotnet/roslyn-analyzers/issues/5712 #pragma warning disable CA2007 await using var enumerator = base .GetStreamingResponseAsync(chatMessages, options, cancellationToken) .ConfigureAwait(false) .GetAsyncEnumerator(); #pragma warning restore CA2007 - SentryAISpanEnricher.EnrichWithRequest(innerSpan, chatMessages, options, _sentryAIOptions); + SentryAISpanEnricher.EnrichWithRequest(chatSpan, chatMessages, options, _sentryAIOptions); while (true) { @@ -88,8 +81,8 @@ public override async IAsyncEnumerable GetStreamingResponseA if (!hasNext) { - SentryAISpanEnricher.EnrichWithStreamingResponses(innerSpan, responses, _sentryAIOptions); - innerSpan.Finish(SpanStatus.Ok); + SentryAISpanEnricher.EnrichWithStreamingResponses(chatSpan, responses, _sentryAIOptions); + AfterResponseCleanup(chatSpan, agentSpan, options); yield break; } @@ -99,8 +92,7 @@ public override async IAsyncEnumerable GetStreamingResponseA } catch (Exception ex) { - innerSpan.Finish(ex); - _hub.CaptureException(ex); + AfterResponseCleanup(chatSpan, agentSpan, options, ex); throw; } @@ -114,29 +106,51 @@ public override async IAsyncEnumerable GetStreamingResponseA ? SentryAIActivitySource.Instance : base.GetService(serviceType, serviceKey); - private ISpan? TryGetRootSpan(ChatOptions? options) + private void AfterResponseCleanup(ISpan chatSpan, ISpan agentSpan, ChatOptions? options, Exception? exception = null) + { + // If there was an exception, we finish all spans and return + if (exception != null) + { + chatSpan.Finish(exception); + agentSpan.Finish(exception); + _hub.CaptureException(exception); + return; + } + + chatSpan.Finish(SpanStatus.Ok); + // If we didn't have any tools available, we can just finish outer invoke_agent span. + if (options?.Tools == null) + { + agentSpan.Finish(SpanStatus.Ok); + } + } + + private ISpan TryGetAgentSpan(ChatOptions? options) { - // if options is null, we are not doing tool calls, so it's safe to just return an invoke_agent span + // if tools list is null, we are not doing tool calls, so it's safe to just return an invoke_agent span // straight from the hub - if (options == null) + if (options?.Tools == null) { return _hub.StartSpan(SentryAIConstants.SpanAttributes.InvokeAgentOperation, SentryAIConstants.SpanAttributes.InvokeAgentDescription); } - // If FunctionInvokingChatClient wraps SentryChatClient, we should be able to get the agent span from the current activity + // If FunctionInvokingChatClient(FICC) wraps SentryChatClient, we should be able to get the agent span from the current activity // The activity we attached the span to may be an ancestor of the current activity, we must search the parents for the span var activeSpan = SentryAIUtil.GetActivitySpan(); - return activeSpan; + + // If we couldn't find the Activity, then FICC is not wrapping SentryChatClient. Return a new span from the hub + return activeSpan ?? _hub.StartSpan(SentryAIConstants.SpanAttributes.InvokeAgentOperation, + SentryAIConstants.SpanAttributes.InvokeAgentDescription); } - private ISpan CreateChatSpan(ISpan? outerSpan, ChatOptions? options) + private ISpan CreateChatSpan(ISpan? agentSpan, ChatOptions? options) { var chatSpanName = options is null || string.IsNullOrEmpty(options.ModelId) ? "chat unknown model" : $"chat {options.ModelId}"; - return outerSpan is not null - ? outerSpan.StartChild(SentryAIConstants.SpanAttributes.ChatOperation, chatSpanName) + return agentSpan is not null + ? agentSpan.StartChild(SentryAIConstants.SpanAttributes.ChatOperation, chatSpanName) : _hub.StartSpan(SentryAIConstants.SpanAttributes.ChatOperation, chatSpanName); } } diff --git a/src/Sentry.Extensions.AI/SentryInstrumentedFunction.cs b/src/Sentry.Extensions.AI/SentryInstrumentedFunction.cs index e202381a66..f0f8a96fd9 100644 --- a/src/Sentry.Extensions.AI/SentryInstrumentedFunction.cs +++ b/src/Sentry.Extensions.AI/SentryInstrumentedFunction.cs @@ -1,46 +1,51 @@ using Microsoft.Extensions.AI; -using Sentry.Extensibility; namespace Sentry.Extensions.AI; internal sealed class SentryInstrumentedFunction(AIFunction innerFunction, ChatOptions options) : DelegatingAIFunction(innerFunction) { - private static readonly HubAdapter Hub = HubAdapter.Instance; - protected override async ValueTask InvokeCoreAsync( AIFunctionArguments arguments, CancellationToken cancellationToken) { - var currSpan = InitToolSpan(arguments); + var agentSpan = SentryAIUtil.GetActivitySpan(); + var toolSpan = InitToolSpan(agentSpan, arguments); try { var result = await base.InvokeCoreAsync(arguments, cancellationToken).ConfigureAwait(false); if (result?.ToString() is { } resultString) { - currSpan.SetData(SentryAIConstants.SpanAttributes.ToolOutput, resultString); + toolSpan.SetData(SentryAIConstants.SpanAttributes.ToolOutput, resultString); } - currSpan.Finish(SpanStatus.Ok); + toolSpan.Finish(SpanStatus.Ok); return result; } catch (Exception ex) { - currSpan.Finish(ex); + toolSpan.Finish(SpanStatus.InternalError); + HubAdapter.Instance.CaptureException(ex); + if (agentSpan != null) + { + // We don't finish the agent span with the exception because there will be another call to LLM. + // Python SDK currently binds the exception to the agent span, so we do so here. + HubAdapter.Instance.BindException(ex, agentSpan); + } throw; } } - private ISpan InitToolSpan(AIFunctionArguments arguments) + private ISpan InitToolSpan(ISpan? agentSpan, AIFunctionArguments arguments) { var spanName = $"execute_tool {Name}"; - var agentSpan = SentryAIUtil.GetActivitySpan(); + // If the user correctly follows the instructions, we should be able to get the agent span var currSpan = agentSpan != null ? agentSpan.StartChild(SentryAIConstants.SpanAttributes.ToolCallOperation, spanName) // If we couldn't find the agent span, just attach it to the hub's current scope - : Hub.StartSpan(SentryAIConstants.SpanAttributes.ToolCallOperation, spanName); + : HubAdapter.Instance.StartSpan(SentryAIConstants.SpanAttributes.ToolCallOperation, spanName); currSpan.SetData(SentryAIConstants.SpanAttributes.RequestModel, options?.ModelId); currSpan.SetData(SentryAIConstants.SpanAttributes.OperationName, "execute_tool"); From 3d338c1b0ebbe06e4f1e2d0a20efee7a9d690037 Mon Sep 17 00:00:00 2001 From: Alex Sohn Date: Mon, 3 Nov 2025 13:18:52 -0500 Subject: [PATCH 36/86] add comprehensive checks for spans in SentryChatClient tests --- .../SentryChatClientTests.cs | 15 +++++++++++++-- 1 file changed, 13 insertions(+), 2 deletions(-) diff --git a/test/Sentry.Extensions.AI.Tests/SentryChatClientTests.cs b/test/Sentry.Extensions.AI.Tests/SentryChatClientTests.cs index c82649f201..39c3a34091 100644 --- a/test/Sentry.Extensions.AI.Tests/SentryChatClientTests.cs +++ b/test/Sentry.Extensions.AI.Tests/SentryChatClientTests.cs @@ -28,13 +28,18 @@ public Fixture() private readonly Fixture _fixture = new(); [Fact] - public async Task CompleteAsync_CallsInnerClient() + public async Task CompleteAsync_CallsInnerClient_AndSetsData() { // Arrange + var transaction = _fixture.Hub.StartTransaction("test-nonstreaming", "test"); + _fixture.Hub.ConfigureScope(scope => scope.Transaction = transaction); + SentrySdk.ConfigureScope(scope => scope.Transaction = transaction); + var inner = Substitute.For(); var sentryChatClient = new SentryChatClient(inner); var message = new ChatMessage(ChatRole.Assistant, "ok"); var chatResponse = new ChatResponse(message); + inner.GetResponseAsync(Arg.Any>(), Arg.Any(), Arg.Any()) .Returns(Task.FromResult(chatResponse)); @@ -45,6 +50,13 @@ public async Task CompleteAsync_CallsInnerClient() Assert.Equal([message], res.Messages); await inner.Received(1).GetResponseAsync(Arg.Any>(), Arg.Any(), Arg.Any()); + var spans = transaction.Spans; + var chatSpan = spans.FirstOrDefault(s => s.Operation == SentryAIConstants.SpanAttributes.ChatOperation); + Assert.NotNull(chatSpan); + Assert.Equal(SpanStatus.Ok, chatSpan.Status); + Assert.True(chatSpan.IsFinished); + Assert.Equal("chat", chatSpan.Data[SentryAIConstants.SpanAttributes.OperationName]); + Assert.Equal("ok", chatSpan.Data[SentryAIConstants.SpanAttributes.ResponseText]); } [Fact] @@ -52,7 +64,6 @@ public async Task CompleteStreamingAsync_CallsInnerClient_AndSetsSpanData() { // Arrange - Use Fixture Hub to start transaction var transaction = _fixture.Hub.StartTransaction("test-streaming", "test"); - _fixture.Hub.ConfigureScope(scope => scope.Transaction = transaction); SentrySdk.ConfigureScope(scope => scope.Transaction = transaction); From 0244a6d2ed0efe4bb82f2c71a8c4938f5431f856 Mon Sep 17 00:00:00 2001 From: Alex Sohn Date: Mon, 3 Nov 2025 13:41:21 -0500 Subject: [PATCH 37/86] rename input/output recording option --- .../Program.cs | 4 ++-- .../Sentry.Samples.ME.AI.Console/Program.cs | 4 ++-- src/Sentry.Extensions.AI/SentryAIOptions.cs | 8 ++++---- .../SentryAISpanEnricher.cs | 4 ++-- .../SentryInstrumentedFunction.cs | 2 +- .../SentryAIExtensionsTests.cs | 4 ++-- .../SentryAIOptionsTests.cs | 20 +++++++++---------- .../SentryAISpanEnricherTests.cs | 10 +++++----- 8 files changed, 28 insertions(+), 28 deletions(-) diff --git a/samples/Sentry.Samples.ME.AI.AspNetCore/Program.cs b/samples/Sentry.Samples.ME.AI.AspNetCore/Program.cs index 6be36f2bbd..5983868a5d 100644 --- a/samples/Sentry.Samples.ME.AI.AspNetCore/Program.cs +++ b/samples/Sentry.Samples.ME.AI.AspNetCore/Program.cs @@ -33,8 +33,8 @@ .WithSentry(options => { // In this case, we already initialized Sentry from ASP.NET WebHost creation, we don't need to initialize - options.IncludeAIRequestMessages = true; - options.IncludeAIResponseContent = true; + options.RecordInputs = true; + options.RecordOutputs = true; }); var client = new ChatClientBuilder(openAiClient) diff --git a/samples/Sentry.Samples.ME.AI.Console/Program.cs b/samples/Sentry.Samples.ME.AI.Console/Program.cs index 39d39e327a..c6c18cf276 100644 --- a/samples/Sentry.Samples.ME.AI.Console/Program.cs +++ b/samples/Sentry.Samples.ME.AI.Console/Program.cs @@ -30,8 +30,8 @@ options.TracesSampleRate = 1; // AI-specific settings - options.IncludeAIRequestMessages = true; - options.IncludeAIResponseContent = true; + options.RecordInputs = true; + options.RecordOutputs = true; // Since this is a simple console app without Sentry already set up, we need to initialize our SDK options.InitializeSdk = true; }); diff --git a/src/Sentry.Extensions.AI/SentryAIOptions.cs b/src/Sentry.Extensions.AI/SentryAIOptions.cs index 7618c1f9f6..b103fa1b08 100644 --- a/src/Sentry.Extensions.AI/SentryAIOptions.cs +++ b/src/Sentry.Extensions.AI/SentryAIOptions.cs @@ -7,14 +7,14 @@ namespace Sentry.Extensions.AI; public class SentryAIOptions : SentryOptions { /// - /// Whether to include LLM request messages in spans. + /// Whether to include request messages in spans. /// - public bool IncludeAIRequestMessages { get; set; } = true; + public bool RecordInputs { get; set; } = true; /// - /// Whether to include LLM response content in spans. + /// Whether to include response content in spans. /// - public bool IncludeAIResponseContent { get; set; } = true; + public bool RecordOutputs { get; set; } = true; /// /// Name of the AI Agent diff --git a/src/Sentry.Extensions.AI/SentryAISpanEnricher.cs b/src/Sentry.Extensions.AI/SentryAISpanEnricher.cs index 786d4114a2..c77c1981e7 100644 --- a/src/Sentry.Extensions.AI/SentryAISpanEnricher.cs +++ b/src/Sentry.Extensions.AI/SentryAISpanEnricher.cs @@ -30,7 +30,7 @@ internal static void EnrichWithRequest(ISpan span, ChatMessage[] messages, ChatO span.SetData(SentryAIConstants.SpanAttributes.AgentName, agentName); } - if (messages is { Length: > 0 } && (aiOptions?.IncludeAIRequestMessages ?? true)) + if (messages is { Length: > 0 } && (aiOptions?.RecordInputs ?? true)) { span.SetData(SentryAIConstants.SpanAttributes.RequestMessages, FormatRequestMessage(messages)); } @@ -121,7 +121,7 @@ public static void EnrichWithStreamingResponses(ISpan span, List { configureWasCalled = true; - options.IncludeAIRequestMessages = false; - options.IncludeAIResponseContent = false; + options.RecordInputs = false; + options.RecordOutputs = false; } ); diff --git a/test/Sentry.Extensions.AI.Tests/SentryAIOptionsTests.cs b/test/Sentry.Extensions.AI.Tests/SentryAIOptionsTests.cs index a681451e49..73ee5a9b0c 100644 --- a/test/Sentry.Extensions.AI.Tests/SentryAIOptionsTests.cs +++ b/test/Sentry.Extensions.AI.Tests/SentryAIOptionsTests.cs @@ -11,8 +11,8 @@ public void Constructor_SetsDefaultValues() var options = new SentryAIOptions(); // Assert - Assert.True(options.IncludeAIRequestMessages); - Assert.True(options.IncludeAIResponseContent); + Assert.True(options.RecordInputs); + Assert.True(options.RecordOutputs); Assert.False(options.InitializeSdk); } @@ -23,11 +23,11 @@ public void IncludeRequestMessages_CanBeSet() var options = new SentryAIOptions { // Act - IncludeAIRequestMessages = false + RecordInputs = false }; // Assert - Assert.False(options.IncludeAIRequestMessages); + Assert.False(options.RecordInputs); } [Fact] @@ -37,11 +37,11 @@ public void IncludeResponseContent_CanBeSet() var options = new SentryAIOptions { // Act - IncludeAIResponseContent = false + RecordOutputs = false }; // Assert - Assert.False(options.IncludeAIResponseContent); + Assert.False(options.RecordOutputs); } [Fact] @@ -99,14 +99,14 @@ public void AllPropertyCombinations_WorkCorrectly(bool includeRequest, bool incl var options = new SentryAIOptions { // Act - IncludeAIRequestMessages = includeRequest, - IncludeAIResponseContent = includeResponse, + RecordInputs = includeRequest, + RecordOutputs = includeResponse, InitializeSdk = initializeSdk }; // Assert - Assert.Equal(includeRequest, options.IncludeAIRequestMessages); - Assert.Equal(includeResponse, options.IncludeAIResponseContent); + Assert.Equal(includeRequest, options.RecordInputs); + Assert.Equal(includeResponse, options.RecordOutputs); Assert.Equal(initializeSdk, options.InitializeSdk); } } diff --git a/test/Sentry.Extensions.AI.Tests/SentryAISpanEnricherTests.cs b/test/Sentry.Extensions.AI.Tests/SentryAISpanEnricherTests.cs index 3fe4398683..e105f0d180 100644 --- a/test/Sentry.Extensions.AI.Tests/SentryAISpanEnricherTests.cs +++ b/test/Sentry.Extensions.AI.Tests/SentryAISpanEnricherTests.cs @@ -88,7 +88,7 @@ public void EnrichWithRequest_SetsData_WithoutRequestMessages_WhenDisabled() var chatOptions = TestChatOptions(); var aiOptions = new SentryAIOptions() { - IncludeAIRequestMessages = false + RecordInputs = false }; // Act @@ -117,7 +117,7 @@ public void EnrichWithRequest_SetsBasicData_WhenChatOptionsNull() var messages = TestMessages(); var aiOptions = new SentryAIOptions() { - IncludeAIRequestMessages = false + RecordInputs = false }; // Act @@ -188,7 +188,7 @@ public void EnrichWithResponse_SetsData_WithoutResponseMessages_WhenDisabled() }; var aiOptions = new SentryAIOptions() { - IncludeAIResponseContent = false + RecordOutputs = false }; // Act @@ -234,7 +234,7 @@ public void EnrichWithStreamingResponses_SetsData() } }; - var aiOptions = new SentryAIOptions { IncludeAIResponseContent = true }; + var aiOptions = new SentryAIOptions { RecordOutputs = true }; // Act SentryAISpanEnricher.EnrichWithStreamingResponses(span, streamingMessages, aiOptions); @@ -268,7 +268,7 @@ public void EnrichWithStreamingResponses_SetsData_WithoutResponseContent_WhenDis } }; - var aiOptions = new SentryAIOptions { IncludeAIResponseContent = false }; + var aiOptions = new SentryAIOptions { RecordOutputs = false }; // Act SentryAISpanEnricher.EnrichWithStreamingResponses(span, streamingMessages, aiOptions); From 8a26aaa86ea1cd3f854e8681e142f7e0c63050fa Mon Sep 17 00:00:00 2001 From: Alex Sohn Date: Mon, 3 Nov 2025 13:42:56 -0500 Subject: [PATCH 38/86] minor renaming in ActivityListener --- src/Sentry.Extensions.AI/SentryAIActivityListener.cs | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/src/Sentry.Extensions.AI/SentryAIActivityListener.cs b/src/Sentry.Extensions.AI/SentryAIActivityListener.cs index 0860f5e430..228bdabb08 100644 --- a/src/Sentry.Extensions.AI/SentryAIActivityListener.cs +++ b/src/Sentry.Extensions.AI/SentryAIActivityListener.cs @@ -19,14 +19,14 @@ internal static class SentryAIActivityListener ActivitySamplingResult.AllDataAndRecorded : ActivitySamplingResult.None, ActivityStarted = a => { - var currSpan = HubAdapter.Instance.StartSpan(SentryAIConstants.SpanAttributes.InvokeAgentOperation, SentryAIConstants.SpanAttributes.InvokeAgentDescription); - a.SetFused(SentryAIConstants.SentryActivitySpanAttributeName, currSpan); + var agentSpan = HubAdapter.Instance.StartSpan(SentryAIConstants.SpanAttributes.InvokeAgentOperation, SentryAIConstants.SpanAttributes.InvokeAgentDescription); + a.SetFused(SentryAIConstants.SentryActivitySpanAttributeName, agentSpan); }, ActivityStopped = a => { - var currSpan = a.GetFused(SentryAIConstants.SentryActivitySpanAttributeName); + var agentSpan = a.GetFused(SentryAIConstants.SentryActivitySpanAttributeName); // Don't pass in OK status in case there was an exception - currSpan?.Finish(); + agentSpan?.Finish(); }, }; From 3e8064a53a1133d9c26bed1941536dcda7b6b232 Mon Sep 17 00:00:00 2001 From: Alex Sohn Date: Mon, 3 Nov 2025 14:31:52 -0500 Subject: [PATCH 39/86] Add GetResponseAsync exception testing --- .../SentryAISpanEnricher.cs | 2 +- .../SentryChatClientTests.cs | 34 +++++++++++++++++-- 2 files changed, 32 insertions(+), 4 deletions(-) diff --git a/src/Sentry.Extensions.AI/SentryAISpanEnricher.cs b/src/Sentry.Extensions.AI/SentryAISpanEnricher.cs index c77c1981e7..be8550f4f9 100644 --- a/src/Sentry.Extensions.AI/SentryAISpanEnricher.cs +++ b/src/Sentry.Extensions.AI/SentryAISpanEnricher.cs @@ -87,7 +87,7 @@ internal static void EnrichWithResponse(ISpan span, ChatResponse response, Sentr /// span to enrich /// a list of /// AI-specific options - public static void EnrichWithStreamingResponses(ISpan span, List messages, + internal static void EnrichWithStreamingResponses(ISpan span, List messages, SentryAIOptions aiOptions) { var inputTokenCount = 0L; diff --git a/test/Sentry.Extensions.AI.Tests/SentryChatClientTests.cs b/test/Sentry.Extensions.AI.Tests/SentryChatClientTests.cs index 39c3a34091..5c790a68f9 100644 --- a/test/Sentry.Extensions.AI.Tests/SentryChatClientTests.cs +++ b/test/Sentry.Extensions.AI.Tests/SentryChatClientTests.cs @@ -47,11 +47,11 @@ public async Task CompleteAsync_CallsInnerClient_AndSetsData() var res = await sentryChatClient.GetResponseAsync([new ChatMessage(ChatRole.User, "hi")]); // Assert - Assert.Equal([message], res.Messages); await inner.Received(1).GetResponseAsync(Arg.Any>(), Arg.Any(), Arg.Any()); var spans = transaction.Spans; var chatSpan = spans.FirstOrDefault(s => s.Operation == SentryAIConstants.SpanAttributes.ChatOperation); + Assert.Equal([message], res.Messages); Assert.NotNull(chatSpan); Assert.Equal(SpanStatus.Ok, chatSpan.Status); Assert.True(chatSpan.IsFinished); @@ -59,6 +59,35 @@ await inner.Received(1).GetResponseAsync(Arg.Any>(), Arg.Any< Assert.Equal("ok", chatSpan.Data[SentryAIConstants.SpanAttributes.ResponseText]); } + [Fact] + public async Task CompleteAsync_HandlesErrors_AndFinishesSpanWithException() + { + // Arrange + var transaction = _fixture.Hub.StartTransaction("test-nonstreaming", "test"); + _fixture.Hub.ConfigureScope(scope => scope.Transaction = transaction); + SentrySdk.ConfigureScope(scope => scope.Transaction = transaction); + + var inner = Substitute.For(); + var sentryChatClient = new SentryChatClient(inner); + var expectedException = new InvalidOperationException("Streaming failed"); + + inner.GetResponseAsync(Arg.Any>(), Arg.Any(), Arg.Any()) + .Throws(expectedException); + + // Act + var res = await Assert.ThrowsAsync(async () => + await sentryChatClient.GetResponseAsync([new ChatMessage(ChatRole.User, "hi")])); + + // Assert + var spans = transaction.Spans; + var chatSpan = spans.FirstOrDefault(s => s.Operation == SentryAIConstants.SpanAttributes.ChatOperation); + Assert.Equal(expectedException.Message, res.Message); + Assert.NotNull(chatSpan); + Assert.Equal(SpanStatus.InternalError, chatSpan.Status); + Assert.True(chatSpan.IsFinished); + Assert.Equal("chat", chatSpan.Data[SentryAIConstants.SpanAttributes.OperationName]); + } + [Fact] public async Task CompleteStreamingAsync_CallsInnerClient_AndSetsSpanData() { @@ -119,11 +148,10 @@ public async Task CompleteStreamingAsync_HandlesErrors_AndFinishesSpanWithExcept } }); - Assert.Equal(expectedException.Message, actualException.Message); - // Assert var spans = transaction.Spans; var chatSpan = spans.FirstOrDefault(s => s.Operation == SentryAIConstants.SpanAttributes.ChatOperation); + Assert.Equal(expectedException.Message, actualException.Message); Assert.NotNull(chatSpan); Assert.Equal(SpanStatus.InternalError, chatSpan.Status); Assert.True(chatSpan.IsFinished); From b69f1cfddefbbb4cdf0a1802529d12dce96f00ec Mon Sep 17 00:00:00 2001 From: Alex Sohn Date: Mon, 3 Nov 2025 15:15:06 -0500 Subject: [PATCH 40/86] Enrich Agent Span + tests --- src/Sentry.Extensions.AI/SentryAIConstants.cs | 6 ++ .../SentryAISpanEnricher.cs | 13 ++-- src/Sentry.Extensions.AI/SentryChatClient.cs | 15 +++-- .../SentryAISpanEnricherTests.cs | 12 ++-- .../SentryChatClientTests.cs | 59 ++++++++++++++----- 5 files changed, 72 insertions(+), 33 deletions(-) diff --git a/src/Sentry.Extensions.AI/SentryAIConstants.cs b/src/Sentry.Extensions.AI/SentryAIConstants.cs index 95bafd1239..383708d635 100644 --- a/src/Sentry.Extensions.AI/SentryAIConstants.cs +++ b/src/Sentry.Extensions.AI/SentryAIConstants.cs @@ -57,4 +57,10 @@ internal static class SpanAttributes internal const string ToolInput = "gen_ai.tool.input"; internal const string ToolOutput = "gen_ai.tool.output"; } + + internal static class SpanOperations + { + internal const string Chat = "chat"; + internal const string InvokeAgent = "invoke_agent"; + } } diff --git a/src/Sentry.Extensions.AI/SentryAISpanEnricher.cs b/src/Sentry.Extensions.AI/SentryAISpanEnricher.cs index be8550f4f9..77de4774f5 100644 --- a/src/Sentry.Extensions.AI/SentryAISpanEnricher.cs +++ b/src/Sentry.Extensions.AI/SentryAISpanEnricher.cs @@ -10,15 +10,10 @@ internal static class SentryAISpanEnricher /// /// Enriches a span with request information. /// - /// Span to enrich - /// Messages - /// Options - /// AI-specific options internal static void EnrichWithRequest(ISpan span, ChatMessage[] messages, ChatOptions? options, - SentryAIOptions aiOptions) + SentryAIOptions aiOptions, string operationName) { - // Currently, all spans will be "chat" - span.SetData(SentryAIConstants.SpanAttributes.OperationName, "chat"); + span.SetData(SentryAIConstants.SpanAttributes.OperationName, operationName); if (options?.ModelId is { } modelId) { @@ -30,7 +25,9 @@ internal static void EnrichWithRequest(ISpan span, ChatMessage[] messages, ChatO span.SetData(SentryAIConstants.SpanAttributes.AgentName, agentName); } - if (messages is { Length: > 0 } && (aiOptions?.RecordInputs ?? true)) + if (operationName == SentryAIConstants.SpanOperations.Chat + && messages is { Length: > 0 } + && (aiOptions?.RecordInputs ?? true)) { span.SetData(SentryAIConstants.SpanAttributes.RequestMessages, FormatRequestMessage(messages)); } diff --git a/src/Sentry.Extensions.AI/SentryChatClient.cs b/src/Sentry.Extensions.AI/SentryChatClient.cs index 7ac5aa9080..e9010c931f 100644 --- a/src/Sentry.Extensions.AI/SentryChatClient.cs +++ b/src/Sentry.Extensions.AI/SentryChatClient.cs @@ -33,7 +33,10 @@ public override async Task GetResponseAsync(IEnumerable GetStreamingResponseA .ConfigureAwait(false) .GetAsyncEnumerator(); #pragma warning restore CA2007 - SentryAISpanEnricher.EnrichWithRequest(chatSpan, chatMessages, options, _sentryAIOptions); + SentryAISpanEnricher.EnrichWithRequest(chatSpan, chatMessages, options, _sentryAIOptions, + SentryAIConstants.SpanOperations.Chat); + SentryAISpanEnricher.EnrichWithRequest(agentSpan, chatMessages, options, _sentryAIOptions, + SentryAIConstants.SpanOperations.InvokeAgent); while (true) { @@ -106,7 +112,8 @@ public override async IAsyncEnumerable GetStreamingResponseA ? SentryAIActivitySource.Instance : base.GetService(serviceType, serviceKey); - private void AfterResponseCleanup(ISpan chatSpan, ISpan agentSpan, ChatOptions? options, Exception? exception = null) + private void AfterResponseCleanup(ISpan chatSpan, ISpan agentSpan, ChatOptions? options, + Exception? exception = null) { // If there was an exception, we finish all spans and return if (exception != null) @@ -141,7 +148,7 @@ private ISpan TryGetAgentSpan(ChatOptions? options) // If we couldn't find the Activity, then FICC is not wrapping SentryChatClient. Return a new span from the hub return activeSpan ?? _hub.StartSpan(SentryAIConstants.SpanAttributes.InvokeAgentOperation, - SentryAIConstants.SpanAttributes.InvokeAgentDescription); + SentryAIConstants.SpanAttributes.InvokeAgentDescription); } private ISpan CreateChatSpan(ISpan? agentSpan, ChatOptions? options) diff --git a/test/Sentry.Extensions.AI.Tests/SentryAISpanEnricherTests.cs b/test/Sentry.Extensions.AI.Tests/SentryAISpanEnricherTests.cs index e105f0d180..7ba35f951f 100644 --- a/test/Sentry.Extensions.AI.Tests/SentryAISpanEnricherTests.cs +++ b/test/Sentry.Extensions.AI.Tests/SentryAISpanEnricherTests.cs @@ -63,10 +63,10 @@ public void EnrichWithRequest_SetsData() var aiOptions = new SentryAIOptions(); // Act - SentryAISpanEnricher.EnrichWithRequest(span, messages, chatOptions, aiOptions); + SentryAISpanEnricher.EnrichWithRequest(span, messages, chatOptions, aiOptions, SentryAIConstants.SpanOperations.Chat); // Assert - span.Data[SentryAIConstants.SpanAttributes.OperationName].Should().Be("chat"); + span.Data[SentryAIConstants.SpanAttributes.OperationName].Should().Be(SentryAIConstants.SpanOperations.Chat); span.Data[SentryAIConstants.SpanAttributes.RequestModel].Should().Be("SentryAI"); span.Data[SentryAIConstants.SpanAttributes.RequestTemperature].Should().Be(0.7f); span.Data[SentryAIConstants.SpanAttributes.RequestMaxTokens].Should().Be(1024); @@ -92,10 +92,10 @@ public void EnrichWithRequest_SetsData_WithoutRequestMessages_WhenDisabled() }; // Act - SentryAISpanEnricher.EnrichWithRequest(span, messages, chatOptions, aiOptions); + SentryAISpanEnricher.EnrichWithRequest(span, messages, chatOptions, aiOptions, SentryAIConstants.SpanOperations.Chat); // Assert - span.Data[SentryAIConstants.SpanAttributes.OperationName].Should().Be("chat"); + span.Data[SentryAIConstants.SpanAttributes.OperationName].Should().Be(SentryAIConstants.SpanOperations.Chat); span.Data[SentryAIConstants.SpanAttributes.RequestModel].Should().Be("SentryAI"); span.Data[SentryAIConstants.SpanAttributes.RequestTemperature].Should().Be(0.7f); span.Data[SentryAIConstants.SpanAttributes.RequestMaxTokens].Should().Be(1024); @@ -121,10 +121,10 @@ public void EnrichWithRequest_SetsBasicData_WhenChatOptionsNull() }; // Act - SentryAISpanEnricher.EnrichWithRequest(span, messages, null, aiOptions); + SentryAISpanEnricher.EnrichWithRequest(span, messages, null, aiOptions, SentryAIConstants.SpanOperations.Chat); // Assert - span.Data[SentryAIConstants.SpanAttributes.OperationName].Should().Be("chat"); + span.Data[SentryAIConstants.SpanAttributes.OperationName].Should().Be(SentryAIConstants.SpanOperations.Chat); span.Data.Should().NotContainKey(SentryAIConstants.SpanAttributes.RequestModel); span.Data.Should().NotContainKey(SentryAIConstants.SpanAttributes.RequestTemperature); span.Data.Should().NotContainKey(SentryAIConstants.SpanAttributes.RequestMaxTokens); diff --git a/test/Sentry.Extensions.AI.Tests/SentryChatClientTests.cs b/test/Sentry.Extensions.AI.Tests/SentryChatClientTests.cs index 5c790a68f9..c5e37b73f5 100644 --- a/test/Sentry.Extensions.AI.Tests/SentryChatClientTests.cs +++ b/test/Sentry.Extensions.AI.Tests/SentryChatClientTests.cs @@ -47,16 +47,23 @@ public async Task CompleteAsync_CallsInnerClient_AndSetsData() var res = await sentryChatClient.GetResponseAsync([new ChatMessage(ChatRole.User, "hi")]); // Assert + Assert.Equal([message], res.Messages); await inner.Received(1).GetResponseAsync(Arg.Any>(), Arg.Any(), Arg.Any()); - var spans = transaction.Spans; - var chatSpan = spans.FirstOrDefault(s => s.Operation == SentryAIConstants.SpanAttributes.ChatOperation); - Assert.Equal([message], res.Messages); + + var chatSpan = transaction.Spans.FirstOrDefault(s => s.Operation == SentryAIConstants.SpanAttributes.ChatOperation); + var agentSpan = transaction.Spans.FirstOrDefault(s => s.Operation == SentryAIConstants.SpanAttributes.InvokeAgentOperation); + Assert.NotNull(chatSpan); - Assert.Equal(SpanStatus.Ok, chatSpan.Status); Assert.True(chatSpan.IsFinished); - Assert.Equal("chat", chatSpan.Data[SentryAIConstants.SpanAttributes.OperationName]); + Assert.Equal(SpanStatus.Ok, chatSpan.Status); + Assert.Equal(SentryAIConstants.SpanOperations.Chat, chatSpan.Data[SentryAIConstants.SpanAttributes.OperationName]); Assert.Equal("ok", chatSpan.Data[SentryAIConstants.SpanAttributes.ResponseText]); + + Assert.NotNull(agentSpan); + Assert.True(agentSpan.IsFinished); + Assert.Equal(SpanStatus.Ok, agentSpan.Status); + Assert.Equal(SentryAIConstants.SpanOperations.InvokeAgent, agentSpan.Data[SentryAIConstants.SpanAttributes.OperationName]); } [Fact] @@ -79,13 +86,20 @@ public async Task CompleteAsync_HandlesErrors_AndFinishesSpanWithException() await sentryChatClient.GetResponseAsync([new ChatMessage(ChatRole.User, "hi")])); // Assert + Assert.Equal(expectedException.Message, res.Message); var spans = transaction.Spans; var chatSpan = spans.FirstOrDefault(s => s.Operation == SentryAIConstants.SpanAttributes.ChatOperation); - Assert.Equal(expectedException.Message, res.Message); + var agentSpan = spans.FirstOrDefault(s => s.Operation == SentryAIConstants.SpanAttributes.InvokeAgentOperation); + Assert.NotNull(chatSpan); Assert.Equal(SpanStatus.InternalError, chatSpan.Status); Assert.True(chatSpan.IsFinished); - Assert.Equal("chat", chatSpan.Data[SentryAIConstants.SpanAttributes.OperationName]); + Assert.Equal(SentryAIConstants.SpanOperations.Chat, chatSpan.Data[SentryAIConstants.SpanAttributes.OperationName]); + + Assert.NotNull(agentSpan); + Assert.True(agentSpan.IsFinished); + Assert.Equal(SpanStatus.InternalError, agentSpan.Status); + Assert.Equal(SentryAIConstants.SpanOperations.InvokeAgent, agentSpan.Data[SentryAIConstants.SpanAttributes.OperationName]); } [Fact] @@ -110,18 +124,26 @@ public async Task CompleteStreamingAsync_CallsInnerClient_AndSetsSpanData() } // Assert + inner.Received(1).GetStreamingResponseAsync(Arg.Any>(), Arg.Any(), + Arg.Any()); Assert.Equal(2, results.Count); Assert.Equal("Hello", results[0].Text); Assert.Equal(" World!", results[1].Text); - inner.Received(1).GetStreamingResponseAsync(Arg.Any>(), Arg.Any(), - Arg.Any()); + var spans = transaction.Spans; var chatSpan = spans.FirstOrDefault(s => s.Operation == SentryAIConstants.SpanAttributes.ChatOperation); + var agentSpan = spans.FirstOrDefault(s => s.Operation == SentryAIConstants.SpanAttributes.InvokeAgentOperation); + Assert.NotNull(chatSpan); - Assert.Equal(SpanStatus.Ok, chatSpan.Status); Assert.True(chatSpan.IsFinished); - Assert.Equal("chat", chatSpan.Data[SentryAIConstants.SpanAttributes.OperationName]); + Assert.Equal(SpanStatus.Ok, chatSpan.Status); + Assert.Equal(SentryAIConstants.SpanOperations.Chat, chatSpan.Data[SentryAIConstants.SpanAttributes.OperationName]); Assert.Equal("Hello World!", chatSpan.Data[SentryAIConstants.SpanAttributes.ResponseText]); + + Assert.NotNull(agentSpan); + Assert.True(agentSpan.IsFinished); + Assert.Equal(SpanStatus.Ok, agentSpan.Status); + Assert.Equal(SentryAIConstants.SpanOperations.InvokeAgent, agentSpan.Data[SentryAIConstants.SpanAttributes.OperationName]); } [Fact] @@ -149,13 +171,20 @@ public async Task CompleteStreamingAsync_HandlesErrors_AndFinishesSpanWithExcept }); // Assert - var spans = transaction.Spans; - var chatSpan = spans.FirstOrDefault(s => s.Operation == SentryAIConstants.SpanAttributes.ChatOperation); Assert.Equal(expectedException.Message, actualException.Message); + + var chatSpan = transaction.Spans.FirstOrDefault(s => s.Operation == SentryAIConstants.SpanAttributes.ChatOperation); + var agentSpan = transaction.Spans.FirstOrDefault(s => s.Operation == SentryAIConstants.SpanAttributes.InvokeAgentOperation); + Assert.NotNull(chatSpan); - Assert.Equal(SpanStatus.InternalError, chatSpan.Status); Assert.True(chatSpan.IsFinished); - Assert.Equal("chat", chatSpan.Data[SentryAIConstants.SpanAttributes.OperationName]); + Assert.Equal(SpanStatus.InternalError, chatSpan.Status); + Assert.Equal(SentryAIConstants.SpanOperations.Chat, chatSpan.Data[SentryAIConstants.SpanAttributes.OperationName]); + + Assert.NotNull(agentSpan); + Assert.True(agentSpan.IsFinished); + Assert.Equal(SpanStatus.InternalError, agentSpan.Status); + Assert.Equal(SentryAIConstants.SpanOperations.InvokeAgent, agentSpan.Data[SentryAIConstants.SpanAttributes.OperationName]); } private static async IAsyncEnumerable CreateFailingStreamingUpdatesAsync(Exception exception) From 4bc98670df7953b56d47ec4dd3156c3729b99135 Mon Sep 17 00:00:00 2001 From: Alex Sohn Date: Mon, 3 Nov 2025 16:51:12 -0500 Subject: [PATCH 41/86] Show function call results as inputs for the next chat span --- .../Program.cs | 3 +- .../SentryAISpanEnricher.cs | 38 ++++++++++++++++--- src/Sentry.Extensions.AI/SentryChatClient.cs | 2 +- 3 files changed, 34 insertions(+), 9 deletions(-) diff --git a/samples/Sentry.Samples.ME.AI.AspNetCore/Program.cs b/samples/Sentry.Samples.ME.AI.AspNetCore/Program.cs index 5983868a5d..4a3645bccb 100644 --- a/samples/Sentry.Samples.ME.AI.AspNetCore/Program.cs +++ b/samples/Sentry.Samples.ME.AI.AspNetCore/Program.cs @@ -131,8 +131,7 @@ ChatOptions GetOptions(ILogger logger) 1) Find Alice's age, 2) Get weather in New York, 3) Calculate a complex result for number 15, - 4) Get comprehensive info for Bob in London, - 5) Calculate average age for Alice, Bob, and Charlie + 4) Calculate average age for Alice, Bob, and Charlie (first get each person's age individually using GetPersonAge, then use CalculateAverageAge with those results). Please use the appropriate tools for each task and demonstrate tool chaining where needed. """) diff --git a/src/Sentry.Extensions.AI/SentryAISpanEnricher.cs b/src/Sentry.Extensions.AI/SentryAISpanEnricher.cs index 77de4774f5..afdc23bae4 100644 --- a/src/Sentry.Extensions.AI/SentryAISpanEnricher.cs +++ b/src/Sentry.Extensions.AI/SentryAISpanEnricher.cs @@ -140,7 +140,7 @@ private static void PopulateToolCallsInfo(IList contents, ISpan span) private static string FormatAvailableTools(IList tools) { - return FormatAsJson(tools, tool => new + return FormatAsJsonList(tools, tool => new { name = tool.Name, description = tool.Description @@ -149,16 +149,42 @@ private static string FormatAvailableTools(IList tools) private static string FormatRequestMessage(ChatMessage[] messages) { - return FormatAsJson(messages, message => new + return FormatAsJsonList(messages, message => { - role = message.Role, - content = message.Text + var content = message.Role == ChatRole.Tool ? FunctionCallToString(message.Contents) : message.Text; + + return new + { + role = message.Role, + content + }; }); + + object FunctionCallToString(IList toolContents) + { + List callList = []; + foreach (var toolContent in toolContents) + { + if (toolContent is not FunctionResultContent functionResultContent) + { + continue; + } + + callList.Add(new + { + call_id = functionResultContent.CallId, + output = functionResultContent.Result, + type = "function_call_output" + }); + } + + return callList; + } } private static string FormatFunctionCallContent(FunctionCallContent[] content) { - return FormatAsJson(content, c => + return FormatAsJsonList(content, c => { string argumentsJson; try @@ -179,7 +205,7 @@ private static string FormatFunctionCallContent(FunctionCallContent[] content) }); } - private static string FormatAsJson(IEnumerable items, Func selector) + private static string FormatAsJsonList(IEnumerable items, Func selector) { return JsonSerializer.Serialize(items.Select(selector)); } diff --git a/src/Sentry.Extensions.AI/SentryChatClient.cs b/src/Sentry.Extensions.AI/SentryChatClient.cs index e9010c931f..ad0e881c2c 100644 --- a/src/Sentry.Extensions.AI/SentryChatClient.cs +++ b/src/Sentry.Extensions.AI/SentryChatClient.cs @@ -94,7 +94,7 @@ public override async IAsyncEnumerable GetStreamingResponseA } current = enumerator.Current; - responses.Add(enumerator.Current); + responses.Add(current); } catch (Exception ex) { From eaa5edd3cbba976c6178dffacf117574c2a5342f Mon Sep 17 00:00:00 2001 From: Sentry Github Bot Date: Mon, 3 Nov 2025 22:17:53 +0000 Subject: [PATCH 42/86] Format code --- test/Sentry.Extensions.AI.Tests/SentryAISpanEnricherTests.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/test/Sentry.Extensions.AI.Tests/SentryAISpanEnricherTests.cs b/test/Sentry.Extensions.AI.Tests/SentryAISpanEnricherTests.cs index 7ba35f951f..e229682613 100644 --- a/test/Sentry.Extensions.AI.Tests/SentryAISpanEnricherTests.cs +++ b/test/Sentry.Extensions.AI.Tests/SentryAISpanEnricherTests.cs @@ -92,7 +92,7 @@ public void EnrichWithRequest_SetsData_WithoutRequestMessages_WhenDisabled() }; // Act - SentryAISpanEnricher.EnrichWithRequest(span, messages, chatOptions, aiOptions, SentryAIConstants.SpanOperations.Chat); + SentryAISpanEnricher.EnrichWithRequest(span, messages, chatOptions, aiOptions, SentryAIConstants.SpanOperations.Chat); // Assert span.Data[SentryAIConstants.SpanAttributes.OperationName].Should().Be(SentryAIConstants.SpanOperations.Chat); From 68f1641bf4a2c2d2b24e985efb26fc1bf5ee20a5 Mon Sep 17 00:00:00 2001 From: Alex Sohn Date: Tue, 4 Nov 2025 12:16:50 -0500 Subject: [PATCH 43/86] Only manually finish agent span if FICC activity is not detected --- src/Sentry.Extensions.AI/SentryChatClient.cs | 11 ++++++++--- 1 file changed, 8 insertions(+), 3 deletions(-) diff --git a/src/Sentry.Extensions.AI/SentryChatClient.cs b/src/Sentry.Extensions.AI/SentryChatClient.cs index ad0e881c2c..2621380ced 100644 --- a/src/Sentry.Extensions.AI/SentryChatClient.cs +++ b/src/Sentry.Extensions.AI/SentryChatClient.cs @@ -125,10 +125,15 @@ private void AfterResponseCleanup(ISpan chatSpan, ISpan agentSpan, ChatOptions? } chatSpan.Finish(SpanStatus.Ok); - // If we didn't have any tools available, we can just finish outer invoke_agent span. - if (options?.Tools == null) + + // If current activity is the one started by FunctionInvokingChatClient (FICC), + // our callback function will handle agentSpan finish. If not, we have to finish the span on our own. + // This solution won't work if the user created their custom version of FICC. However in that case, agentSpan + // will finish once the transaction it is attached to finishes. + if (Activity.Current is not { } currentActivity || + !SentryAIConstants.FICCActivityNames.Contains(currentActivity.OperationName)) { - agentSpan.Finish(SpanStatus.Ok); + agentSpan.Finish(); } } From 243f6a0031e42105b2438d9cb9e835d051377eb1 Mon Sep 17 00:00:00 2001 From: Alex Sohn Date: Tue, 4 Nov 2025 12:17:14 -0500 Subject: [PATCH 44/86] format AspNetcore sample --- .../Program.cs | 139 +++++++++--------- 1 file changed, 70 insertions(+), 69 deletions(-) diff --git a/samples/Sentry.Samples.ME.AI.AspNetCore/Program.cs b/samples/Sentry.Samples.ME.AI.AspNetCore/Program.cs index 4a3645bccb..897669e1a7 100644 --- a/samples/Sentry.Samples.ME.AI.AspNetCore/Program.cs +++ b/samples/Sentry.Samples.ME.AI.AspNetCore/Program.cs @@ -41,75 +41,6 @@ .UseFunctionInvocation() .Build(); -ChatOptions GetOptions(ILogger logger) -{ - return new ChatOptions - { - ModelId = "gpt-4o-mini", - MaxOutputTokens = 1024, - Tools = - [ - // Tool 1: Quick response with minimal delay, but throws an error when trying to get Alice's age - AIFunctionFactory.Create(async (string personName) => - { - logger.LogInformation("GetPersonAge called for {PersonName}", personName); - await Task.Delay(100); // 100ms delay - return personName switch - { - "Bob" => "30", - "Charlie" => "35", - _ => "40" - }; - }, "GetPersonAge", "Gets the age of the person whose name is specified. Takes about 100ms to complete."), - - // Tool 2: Medium delay tool for weather - AIFunctionFactory.Create(async (string location) => - { - logger.LogInformation("GetWeather called for {Location}", location); - await Task.Delay(500); // 500ms delay - return location.ToLower() switch - { - "new york" => "Sunny, 72°F", - "london" => "Cloudy, 60°F", - "tokyo" => "Rainy, 68°F", - _ => "Unknown weather conditions" - }; - }, "GetWeather", "Gets the current weather for a location. Takes about 500ms to complete."), - - // Tool 3: Slow tool for complex calculation - AIFunctionFactory.Create(async (int number) => - { - logger.LogInformation("ComplexCalculation called with {Number}", number); - await Task.Delay(1000); // 1000ms delay - var result = (number * number) + (number * 10); - return $"Complex calculation result for {number}: {result}"; - }, "ComplexCalculation", "Performs a complex mathematical calculation. Takes about 1 second to complete."), - - // Tool 4: Data aggregation tool that requests individual ages - AIFunctionFactory.Create(async (int[] ages) => - { - logger.LogInformation("CalculateAverageAge called with ages: {Ages}", string.Join(", ", ages)); - await Task.Delay(200); // 200ms delay for calculation - if (ages.Length == 0) - { - return "No ages provided"; - } - - var average = ages.Average(); - return $"Average age calculated: {average:F1} years from {ages.Length} people. Individual ages: {string.Join(", ", ages)}"; - }, "CalculateAverageAge", "Calculates the average from a list of ages. You should first get individual ages using GetPersonAge, then use this tool to calculate the average. Takes about 200ms to complete."), - - // Tool 5: Tool that will throw an error - AIFunctionFactory.Create(async () => - { - logger.LogInformation("Mysterious tool called"); - await Task.Delay(2000); - throw new TimeoutException("Mysterious tool called, but returned an error :("); - }, "MysteriousTool", "May return an error...") - ] - }.WithSentryToolInstrumentation(); -} - // Register the OpenAI API client and Sentry-instrumented chat client builder.Services.AddSingleton(client); @@ -190,3 +121,73 @@ ChatOptions GetOptions(ILogger logger) }); app.Run(); +return; + +ChatOptions GetOptions(ILogger logger) +{ + return new ChatOptions + { + ModelId = "gpt-4o-mini", + MaxOutputTokens = 1024, + Tools = + [ + // Tool 1: Quick response with minimal delay, but throws an error when trying to get Alice's age + AIFunctionFactory.Create(async (string personName) => + { + logger.LogInformation("GetPersonAge called for {PersonName}", personName); + await Task.Delay(100); // 100ms delay + return personName switch + { + "Bob" => "30", + "Charlie" => "35", + _ => "40" + }; + }, "GetPersonAge", "Gets the age of the person whose name is specified. Takes about 100ms to complete."), + + // Tool 2: Medium delay tool for weather + AIFunctionFactory.Create(async (string location) => + { + logger.LogInformation("GetWeather called for {Location}", location); + await Task.Delay(500); // 500ms delay + return location.ToLower() switch + { + "new york" => "Sunny, 72°F", + "london" => "Cloudy, 60°F", + "tokyo" => "Rainy, 68°F", + _ => "Unknown weather conditions" + }; + }, "GetWeather", "Gets the current weather for a location. Takes about 500ms to complete."), + + // Tool 3: Slow tool for complex calculation + AIFunctionFactory.Create(async (int number) => + { + logger.LogInformation("ComplexCalculation called with {Number}", number); + await Task.Delay(1000); // 1000ms delay + var result = (number * number) + (number * 10); + return $"Complex calculation result for {number}: {result}"; + }, "ComplexCalculation", "Performs a complex mathematical calculation. Takes about 1 second to complete."), + + // Tool 4: Data aggregation tool that requests individual ages + AIFunctionFactory.Create(async (int[] ages) => + { + logger.LogInformation("CalculateAverageAge called with ages: {Ages}", string.Join(", ", ages)); + await Task.Delay(200); // 200ms delay for calculation + if (ages.Length == 0) + { + return "No ages provided"; + } + + var average = ages.Average(); + return $"Average age calculated: {average:F1} years from {ages.Length} people. Individual ages: {string.Join(", ", ages)}"; + }, "CalculateAverageAge", "Calculates the average from a list of ages. You should first get individual ages using GetPersonAge, then use this tool to calculate the average. Takes about 200ms to complete."), + + // Tool 5: Tool that will throw an error + AIFunctionFactory.Create(async () => + { + logger.LogInformation("Mysterious tool called"); + await Task.Delay(2000); + throw new TimeoutException("Mysterious tool called, but returned an error :("); + }, "MysteriousTool", "May return an error...") + ] + }.WithSentryToolInstrumentation(); +} From cab8f1bef9a87e099c1fe36cb4947f4cb0d9c4a2 Mon Sep 17 00:00:00 2001 From: Alex Sohn Date: Tue, 4 Nov 2025 15:37:30 -0500 Subject: [PATCH 45/86] Change SE.AI to reference ME.AI.Abstractions --- .../Sentry.Samples.ME.AI.AspNetCore.csproj | 1 + .../Sentry.Samples.ME.AI.Console.csproj | 1 + src/Sentry.Extensions.AI/Sentry.Extensions.AI.csproj | 2 +- 3 files changed, 3 insertions(+), 1 deletion(-) diff --git a/samples/Sentry.Samples.ME.AI.AspNetCore/Sentry.Samples.ME.AI.AspNetCore.csproj b/samples/Sentry.Samples.ME.AI.AspNetCore/Sentry.Samples.ME.AI.AspNetCore.csproj index 8db6a50410..e4c8c3a6d7 100644 --- a/samples/Sentry.Samples.ME.AI.AspNetCore/Sentry.Samples.ME.AI.AspNetCore.csproj +++ b/samples/Sentry.Samples.ME.AI.AspNetCore/Sentry.Samples.ME.AI.AspNetCore.csproj @@ -5,6 +5,7 @@ + diff --git a/samples/Sentry.Samples.ME.AI.Console/Sentry.Samples.ME.AI.Console.csproj b/samples/Sentry.Samples.ME.AI.Console/Sentry.Samples.ME.AI.Console.csproj index 533b6b336b..39b28ca53d 100644 --- a/samples/Sentry.Samples.ME.AI.Console/Sentry.Samples.ME.AI.Console.csproj +++ b/samples/Sentry.Samples.ME.AI.Console/Sentry.Samples.ME.AI.Console.csproj @@ -6,6 +6,7 @@ + diff --git a/src/Sentry.Extensions.AI/Sentry.Extensions.AI.csproj b/src/Sentry.Extensions.AI/Sentry.Extensions.AI.csproj index d02ebe5adc..4062ec9e5c 100644 --- a/src/Sentry.Extensions.AI/Sentry.Extensions.AI.csproj +++ b/src/Sentry.Extensions.AI/Sentry.Extensions.AI.csproj @@ -16,7 +16,7 @@ - + From 4ae627436ba47dbdd566faac75b2c79acaae47d0 Mon Sep 17 00:00:00 2001 From: Alex Sohn Date: Tue, 4 Nov 2025 15:39:31 -0500 Subject: [PATCH 46/86] cleanup util and chat client class --- src/Sentry.Extensions.AI/SentryAIUtil.cs | 4 ++-- src/Sentry.Extensions.AI/SentryChatClient.cs | 13 +++++-------- 2 files changed, 7 insertions(+), 10 deletions(-) diff --git a/src/Sentry.Extensions.AI/SentryAIUtil.cs b/src/Sentry.Extensions.AI/SentryAIUtil.cs index e08d1261b0..87a1a98985 100644 --- a/src/Sentry.Extensions.AI/SentryAIUtil.cs +++ b/src/Sentry.Extensions.AI/SentryAIUtil.cs @@ -9,12 +9,12 @@ internal static class SentryAIUtil var currActivity = Activity.Current; while (currActivity != null) { - if (currActivity?.GetFused(SentryAIConstants.SentryActivitySpanAttributeName) is { } span) + if (currActivity.GetFused(SentryAIConstants.SentryActivitySpanAttributeName) is { } span) { return span; } - currActivity = currActivity?.Parent; + currActivity = currActivity.Parent; } return null; diff --git a/src/Sentry.Extensions.AI/SentryChatClient.cs b/src/Sentry.Extensions.AI/SentryChatClient.cs index 2621380ced..c898602334 100644 --- a/src/Sentry.Extensions.AI/SentryChatClient.cs +++ b/src/Sentry.Extensions.AI/SentryChatClient.cs @@ -1,6 +1,4 @@ using Microsoft.Extensions.AI; -using Sentry.Extensibility; -using Sentry.Internal; namespace Sentry.Extensions.AI; @@ -41,13 +39,13 @@ public override async Task GetResponseAsync(IEnumerable GetStreamingResponseA if (!hasNext) { SentryAISpanEnricher.EnrichWithStreamingResponses(chatSpan, responses, _sentryAIOptions); - AfterResponseCleanup(chatSpan, agentSpan, options); + AfterResponseCleanup(chatSpan, agentSpan); yield break; } @@ -98,7 +96,7 @@ public override async IAsyncEnumerable GetStreamingResponseA } catch (Exception ex) { - AfterResponseCleanup(chatSpan, agentSpan, options, ex); + AfterResponseCleanup(chatSpan, agentSpan, ex); throw; } @@ -112,8 +110,7 @@ public override async IAsyncEnumerable GetStreamingResponseA ? SentryAIActivitySource.Instance : base.GetService(serviceType, serviceKey); - private void AfterResponseCleanup(ISpan chatSpan, ISpan agentSpan, ChatOptions? options, - Exception? exception = null) + private void AfterResponseCleanup(ISpan chatSpan, ISpan agentSpan, Exception? exception = null) { // If there was an exception, we finish all spans and return if (exception != null) From 5d72c3def7dcb0edd7cd40152ced623aa5b7483b Mon Sep 17 00:00:00 2001 From: Alex Sohn Date: Tue, 4 Nov 2025 15:43:49 -0500 Subject: [PATCH 47/86] Add netstandard2.0 to target frameworks --- src/Sentry.Extensions.AI/Sentry.Extensions.AI.csproj | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/src/Sentry.Extensions.AI/Sentry.Extensions.AI.csproj b/src/Sentry.Extensions.AI/Sentry.Extensions.AI.csproj index 4062ec9e5c..f9a311be98 100644 --- a/src/Sentry.Extensions.AI/Sentry.Extensions.AI.csproj +++ b/src/Sentry.Extensions.AI/Sentry.Extensions.AI.csproj @@ -1,7 +1,7 @@  - net9.0;net8.0 + net9.0;net8.0;netstandard2.0 $(PackageTags);Microsoft.Extensions.AI;AI;LLM Microsoft.Extensions.AI integration for Sentry - captures AI Agent telemetry spans per Sentry AI Agents module. @@ -19,6 +19,10 @@ + + + + From bb2837f72708a7a35c188f469ab84ab064090bd3 Mon Sep 17 00:00:00 2001 From: Alex Sohn Date: Tue, 4 Nov 2025 15:45:52 -0500 Subject: [PATCH 48/86] downgrade ME.AI.Abstractions version --- src/Sentry.Extensions.AI/Sentry.Extensions.AI.csproj | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Sentry.Extensions.AI/Sentry.Extensions.AI.csproj b/src/Sentry.Extensions.AI/Sentry.Extensions.AI.csproj index f9a311be98..7c29b8b8d6 100644 --- a/src/Sentry.Extensions.AI/Sentry.Extensions.AI.csproj +++ b/src/Sentry.Extensions.AI/Sentry.Extensions.AI.csproj @@ -16,7 +16,7 @@ - + From 7ba2f00feaf9cd39a3660087b79a3c52f2c840d6 Mon Sep 17 00:00:00 2001 From: Alex Sohn Date: Tue, 4 Nov 2025 15:46:29 -0500 Subject: [PATCH 49/86] cleanup using in ActivityListener --- src/Sentry.Extensions.AI/SentryAIActivityListener.cs | 1 - 1 file changed, 1 deletion(-) diff --git a/src/Sentry.Extensions.AI/SentryAIActivityListener.cs b/src/Sentry.Extensions.AI/SentryAIActivityListener.cs index 228bdabb08..37b91c8485 100644 --- a/src/Sentry.Extensions.AI/SentryAIActivityListener.cs +++ b/src/Sentry.Extensions.AI/SentryAIActivityListener.cs @@ -1,4 +1,3 @@ -using Sentry.Extensibility; using Sentry.Internal; namespace Sentry.Extensions.AI; From b0c0c8ba6785573e262e779cca0c11ab9c105d00 Mon Sep 17 00:00:00 2001 From: Alex Sohn Date: Tue, 4 Nov 2025 17:28:24 -0500 Subject: [PATCH 50/86] update agent spans to contain initial inputs and final output --- .../Extensions/SentryAIExtensions.cs | 2 +- src/Sentry.Extensions.AI/SentryAIConstants.cs | 3 ++ .../SentryAISpanEnricher.cs | 12 ++++--- src/Sentry.Extensions.AI/SentryChatClient.cs | 2 ++ .../SentryInstrumentedFunction.cs | 3 +- .../SentryInstrumentedFunctionTests.cs | 32 ++++++------------- 6 files changed, 25 insertions(+), 29 deletions(-) diff --git a/src/Sentry.Extensions.AI/Extensions/SentryAIExtensions.cs b/src/Sentry.Extensions.AI/Extensions/SentryAIExtensions.cs index caf6c1b5c3..678ba0d70c 100644 --- a/src/Sentry.Extensions.AI/Extensions/SentryAIExtensions.cs +++ b/src/Sentry.Extensions.AI/Extensions/SentryAIExtensions.cs @@ -25,7 +25,7 @@ public static ChatOptions WithSentryToolInstrumentation(this ChatOptions options { if (options.Tools[i] is AIFunction fn and not SentryInstrumentedFunction) { - options.Tools[i] = new SentryInstrumentedFunction(fn, options); + options.Tools[i] = new SentryInstrumentedFunction(fn); } } diff --git a/src/Sentry.Extensions.AI/SentryAIConstants.cs b/src/Sentry.Extensions.AI/SentryAIConstants.cs index 383708d635..3bc1e05339 100644 --- a/src/Sentry.Extensions.AI/SentryAIConstants.cs +++ b/src/Sentry.Extensions.AI/SentryAIConstants.cs @@ -56,6 +56,9 @@ internal static class SpanAttributes internal const string ToolDescription = "gen_ai.tool.description"; internal const string ToolInput = "gen_ai.tool.input"; internal const string ToolOutput = "gen_ai.tool.output"; + + // Misc.. + internal const string Origin = "auto.ai.extensions"; } internal static class SpanOperations diff --git a/src/Sentry.Extensions.AI/SentryAISpanEnricher.cs b/src/Sentry.Extensions.AI/SentryAISpanEnricher.cs index afdc23bae4..5324db8d89 100644 --- a/src/Sentry.Extensions.AI/SentryAISpanEnricher.cs +++ b/src/Sentry.Extensions.AI/SentryAISpanEnricher.cs @@ -1,4 +1,5 @@ using Microsoft.Extensions.AI; +using Sentry.Internal; namespace Sentry.Extensions.AI; @@ -14,6 +15,7 @@ internal static void EnrichWithRequest(ISpan span, ChatMessage[] messages, ChatO SentryAIOptions aiOptions, string operationName) { span.SetData(SentryAIConstants.SpanAttributes.OperationName, operationName); + span.SetOrigin(SentryAIConstants.SpanAttributes.Origin); if (options?.ModelId is { } modelId) { @@ -25,9 +27,9 @@ internal static void EnrichWithRequest(ISpan span, ChatMessage[] messages, ChatO span.SetData(SentryAIConstants.SpanAttributes.AgentName, agentName); } - if (operationName == SentryAIConstants.SpanOperations.Chat - && messages is { Length: > 0 } - && (aiOptions?.RecordInputs ?? true)) + if (messages is { Length: > 0 } + && (aiOptions?.RecordInputs ?? true) + && !span.Data.TryGetValue(SentryAIConstants.SpanAttributes.RequestMessages, out _)) { span.SetData(SentryAIConstants.SpanAttributes.RequestMessages, FormatRequestMessage(messages)); } @@ -112,7 +114,9 @@ internal static void EnrichWithStreamingResponses(ISpan span, List GetResponseAsync(IEnumerable GetStreamingResponseA if (!hasNext) { SentryAISpanEnricher.EnrichWithStreamingResponses(chatSpan, responses, _sentryAIOptions); + SentryAISpanEnricher.EnrichWithStreamingResponses(agentSpan, responses, _sentryAIOptions); AfterResponseCleanup(chatSpan, agentSpan); yield break; diff --git a/src/Sentry.Extensions.AI/SentryInstrumentedFunction.cs b/src/Sentry.Extensions.AI/SentryInstrumentedFunction.cs index 9a083dbdaf..50d99c5100 100644 --- a/src/Sentry.Extensions.AI/SentryInstrumentedFunction.cs +++ b/src/Sentry.Extensions.AI/SentryInstrumentedFunction.cs @@ -2,7 +2,7 @@ namespace Sentry.Extensions.AI; -internal sealed class SentryInstrumentedFunction(AIFunction innerFunction, ChatOptions options) +internal sealed class SentryInstrumentedFunction(AIFunction innerFunction) : DelegatingAIFunction(innerFunction) { protected override async ValueTask InvokeCoreAsync( @@ -47,7 +47,6 @@ private ISpan InitToolSpan(ISpan? agentSpan, AIFunctionArguments arguments) // If we couldn't find the agent span, just attach it to the hub's current scope : HubAdapter.Instance.StartSpan(SentryAIConstants.SpanAttributes.ToolCallOperation, spanName); - currSpan.SetData(SentryAIConstants.SpanAttributes.RequestModel, options.ModelId); currSpan.SetData(SentryAIConstants.SpanAttributes.OperationName, "execute_tool"); currSpan.SetData(SentryAIConstants.SpanAttributes.ToolName, Name); currSpan.SetData(SentryAIConstants.SpanAttributes.ToolDescription, Description); diff --git a/test/Sentry.Extensions.AI.Tests/SentryInstrumentedFunctionTests.cs b/test/Sentry.Extensions.AI.Tests/SentryInstrumentedFunctionTests.cs index d4353cfb63..7ba3b43b3a 100644 --- a/test/Sentry.Extensions.AI.Tests/SentryInstrumentedFunctionTests.cs +++ b/test/Sentry.Extensions.AI.Tests/SentryInstrumentedFunctionTests.cs @@ -11,8 +11,7 @@ public async Task InvokeCoreAsync_WithValidFunction_ReturnsResult() // Arrange using var sentryDisposable = SentryHelpers.InitializeSdk(); var testFunction = AIFunctionFactory.Create(() => "test result", "TestFunction", "Test function description"); - var mockOption = Substitute.For(); - var sentryFunction = new SentryInstrumentedFunction(testFunction, mockOption); + var sentryFunction = new SentryInstrumentedFunction(testFunction); var arguments = new AIFunctionArguments(); // Act @@ -39,8 +38,7 @@ public async Task InvokeCoreAsync_WithNullResult_ReturnsNull() // Arrange using var sentryDisposable = SentryHelpers.InitializeSdk(); var testFunction = AIFunctionFactory.Create(object? () => null, "TestFunction", "Test function description"); - var mockOption = Substitute.For(); - var sentryFunction = new SentryInstrumentedFunction(testFunction, mockOption); + var sentryFunction = new SentryInstrumentedFunction(testFunction); var arguments = new AIFunctionArguments(); // Act @@ -64,8 +62,7 @@ public async Task InvokeCoreAsync_WithJsonNullResult_ReturnsJsonElement() using var sentryDisposable = SentryHelpers.InitializeSdk(); var jsonNullElement = JsonSerializer.Deserialize("null"); var testFunction = AIFunctionFactory.Create(() => jsonNullElement, "TestFunction", "Test function description"); - var mockOption = Substitute.For(); - var sentryFunction = new SentryInstrumentedFunction(testFunction, mockOption); + var sentryFunction = new SentryInstrumentedFunction(testFunction); var arguments = new AIFunctionArguments(); // Act @@ -85,8 +82,7 @@ public async Task InvokeCoreAsync_WithJsonElementResult_CallsToStringForSpanOutp using var sentryDisposable = SentryHelpers.InitializeSdk(); var jsonElement = JsonSerializer.Deserialize("\"test output\""); var testFunction = AIFunctionFactory.Create(() => jsonElement, "TestFunction", "Test function description"); - var mockOption = Substitute.For(); - var sentryFunction = new SentryInstrumentedFunction(testFunction, mockOption); + var sentryFunction = new SentryInstrumentedFunction(testFunction); var arguments = new AIFunctionArguments(); // Act @@ -97,9 +93,6 @@ public async Task InvokeCoreAsync_WithJsonElementResult_CallsToStringForSpanOutp Assert.IsType(result); var jsonResult = (JsonElement)result; Assert.Equal("test output", jsonResult.GetString()); - - // The span should have recorded the ToString() output of the JsonElement - // (This is testing the internal behavior that ToString() gets called for span data) } [Fact] @@ -109,8 +102,7 @@ public async Task InvokeCoreAsync_WithComplexResult_ReturnsObject() using var sentryDisposable = SentryHelpers.InitializeSdk(); var resultObject = new { message = "test", count = 42 }; var testFunction = AIFunctionFactory.Create(() => resultObject, "TestFunction", "Test function description"); - var mockOption = Substitute.For(); - var sentryFunction = new SentryInstrumentedFunction(testFunction, mockOption); + var sentryFunction = new SentryInstrumentedFunction(testFunction); var arguments = new AIFunctionArguments(); // Act @@ -139,8 +131,7 @@ public async Task InvokeCoreAsync_WhenFunctionThrows_PropagatesException() using var sentryDisposable = SentryHelpers.InitializeSdk(); var expectedException = new InvalidOperationException("Test exception"); var testFunction = AIFunctionFactory.Create(new Func(() => throw expectedException), "TestFunction", "Test function description"); - var mockOption = Substitute.For(); - var sentryFunction = new SentryInstrumentedFunction(testFunction, mockOption); + var sentryFunction = new SentryInstrumentedFunction(testFunction); var arguments = new AIFunctionArguments(); // Act & Assert @@ -161,11 +152,10 @@ public async Task InvokeCoreAsync_WithCancellation_PropagatesCancellation() return "result"; }, "TestFunction", "Test function description"); - var mockOption = Substitute.For(); - var sentryFunction = new SentryInstrumentedFunction(testFunction, mockOption); + var sentryFunction = new SentryInstrumentedFunction(testFunction); var arguments = new AIFunctionArguments(); var cts = new CancellationTokenSource(); - cts.Cancel(); + await cts.CancelAsync(); // Act & Assert await Assert.ThrowsAsync(async () => @@ -184,8 +174,7 @@ public async Task InvokeCoreAsync_WithParameters_PassesParametersCorrectly() return "result"; }, "TestFunction", "Test function description"); - var mockOption = Substitute.For(); - var sentryFunction = new SentryInstrumentedFunction(testFunction, mockOption); + var sentryFunction = new SentryInstrumentedFunction(testFunction); var arguments = new AIFunctionArguments { ["param1"] = "value1" }; // Act @@ -203,8 +192,7 @@ public void Constructor_PreservesInnerFunctionProperties() var testFunction = AIFunctionFactory.Create(() => "test", "TestFunction", "Test function description"); // Act - var mockOption = Substitute.For(); - var sentryFunction = new SentryInstrumentedFunction(testFunction, mockOption); + var sentryFunction = new SentryInstrumentedFunction(testFunction); // Assert Assert.Equal("TestFunction", sentryFunction.Name); From ce3eeb78b540cc1d09058128c677410cc8e1357e Mon Sep 17 00:00:00 2001 From: Alex Sohn Date: Tue, 4 Nov 2025 17:34:24 -0500 Subject: [PATCH 51/86] Change WithSentry to AddSentry --- samples/Sentry.Samples.ME.AI.AspNetCore/Program.cs | 4 ++-- samples/Sentry.Samples.ME.AI.Console/Program.cs | 4 ++-- src/Sentry.Extensions.AI/Extensions/SentryAIExtensions.cs | 5 ++--- src/Sentry.Extensions.AI/SentryAIConstants.cs | 2 -- test/Sentry.Extensions.AI.Tests/SentryAIExtensionsTests.cs | 6 +++--- 5 files changed, 9 insertions(+), 12 deletions(-) diff --git a/samples/Sentry.Samples.ME.AI.AspNetCore/Program.cs b/samples/Sentry.Samples.ME.AI.AspNetCore/Program.cs index 897669e1a7..3f7154a277 100644 --- a/samples/Sentry.Samples.ME.AI.AspNetCore/Program.cs +++ b/samples/Sentry.Samples.ME.AI.AspNetCore/Program.cs @@ -30,7 +30,7 @@ var openAiClient = new OpenAI.Chat.ChatClient("gpt-4o-mini", openAiApiKey) .AsIChatClient() - .WithSentry(options => + .AddSentry(options => { // In this case, we already initialized Sentry from ASP.NET WebHost creation, we don't need to initialize options.RecordInputs = true; @@ -189,5 +189,5 @@ ChatOptions GetOptions(ILogger logger) throw new TimeoutException("Mysterious tool called, but returned an error :("); }, "MysteriousTool", "May return an error...") ] - }.WithSentryToolInstrumentation(); + }.AddSentryToolInstrumentation(); } diff --git a/samples/Sentry.Samples.ME.AI.Console/Program.cs b/samples/Sentry.Samples.ME.AI.Console/Program.cs index c6c18cf276..76a5b93934 100644 --- a/samples/Sentry.Samples.ME.AI.Console/Program.cs +++ b/samples/Sentry.Samples.ME.AI.Console/Program.cs @@ -17,7 +17,7 @@ // Create OpenAI API client and wrap it with Sentry instrumentation var openAiClient = new OpenAI.Chat.ChatClient("gpt-4o-mini", openAiApiKey) .AsIChatClient() - .WithSentry(options => + .AddSentry(options => { #if !SENTRY_DSN_DEFINED_IN_ENV // A DSN is required. You can set here in code, or you can set it in the SENTRY_DSN environment variable. @@ -89,7 +89,7 @@ return $"Complex calculation result for {number}: {result}"; }, "ComplexCalculation", "Performs a complex mathematical calculation. Takes about 1 second to complete.") ] -}.WithSentryToolInstrumentation(); +}.AddSentryToolInstrumentation(); var response = await client.GetResponseAsync( "Please help me with the following tasks: 1) Find Alice's age, 2) Get weather in New York, and 3) Calculate a complex result for number 15. Please use the appropriate tools for each task.", diff --git a/src/Sentry.Extensions.AI/Extensions/SentryAIExtensions.cs b/src/Sentry.Extensions.AI/Extensions/SentryAIExtensions.cs index 678ba0d70c..a2e31c8e05 100644 --- a/src/Sentry.Extensions.AI/Extensions/SentryAIExtensions.cs +++ b/src/Sentry.Extensions.AI/Extensions/SentryAIExtensions.cs @@ -1,5 +1,4 @@ using Microsoft.Extensions.AI; -using Sentry.Extensibility; // ReSharper disable once CheckNamespace -- Discoverability namespace Sentry.Extensions.AI; @@ -14,7 +13,7 @@ public static class SentryAIExtensions /// Wrap tool calls specified in with Sentry agent instrumentation /// /// The that contains the to instrument - public static ChatOptions WithSentryToolInstrumentation(this ChatOptions options) + public static ChatOptions AddSentryToolInstrumentation(this ChatOptions options) { if (options.Tools is null || options.Tools.Count == 0) { @@ -43,7 +42,7 @@ public static ChatOptions WithSentryToolInstrumentation(this ChatOptions options /// The to be instrumented /// The configuration /// The instrumented - public static IChatClient WithSentry(this IChatClient client, Action? configure = null) + public static IChatClient AddSentry(this IChatClient client, Action? configure = null) { SentryAIActivityListener.Init(); return new SentryChatClient(client, configure); diff --git a/src/Sentry.Extensions.AI/SentryAIConstants.cs b/src/Sentry.Extensions.AI/SentryAIConstants.cs index 3bc1e05339..5d2eedc2b0 100644 --- a/src/Sentry.Extensions.AI/SentryAIConstants.cs +++ b/src/Sentry.Extensions.AI/SentryAIConstants.cs @@ -1,5 +1,3 @@ -using Microsoft.Extensions.AI; - namespace Sentry.Extensions.AI; internal static class SentryAIConstants diff --git a/test/Sentry.Extensions.AI.Tests/SentryAIExtensionsTests.cs b/test/Sentry.Extensions.AI.Tests/SentryAIExtensionsTests.cs index d41c3bdf63..f9e5c8b266 100644 --- a/test/Sentry.Extensions.AI.Tests/SentryAIExtensionsTests.cs +++ b/test/Sentry.Extensions.AI.Tests/SentryAIExtensionsTests.cs @@ -12,7 +12,7 @@ public void WithSentry_IChatClient_ReturnsWrappedClient() var mockClient = Substitute.For(); // Act - var result = mockClient.WithSentry(); + var result = mockClient.AddSentry(); // Assert Assert.IsType(result); @@ -26,7 +26,7 @@ public void WithSentry_IChatClient_WithConfiguration_PassesConfigurationToWrappe var configureWasCalled = false; // Act - var result = mockClient.WithSentry(options => + var result = mockClient.AddSentry(options => { configureWasCalled = true; options.RecordInputs = false; @@ -46,7 +46,7 @@ public void WithSentry_IChatClient_WithNullConfiguration_UsesDefaultConfiguratio var mockClient = Substitute.For(); // Act - var result = mockClient.WithSentry(null); + var result = mockClient.AddSentry(null); // Assert Assert.IsType(result); From a23120479ece96c8ef963377bdc79e7dc3e2f79a Mon Sep 17 00:00:00 2001 From: Alex Sohn Date: Tue, 4 Nov 2025 17:36:16 -0500 Subject: [PATCH 52/86] Change namespace for SentryAIExtensions to ME.AI --- src/Sentry.Extensions.AI/Extensions/SentryAIExtensions.cs | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/src/Sentry.Extensions.AI/Extensions/SentryAIExtensions.cs b/src/Sentry.Extensions.AI/Extensions/SentryAIExtensions.cs index a2e31c8e05..832cd91d0c 100644 --- a/src/Sentry.Extensions.AI/Extensions/SentryAIExtensions.cs +++ b/src/Sentry.Extensions.AI/Extensions/SentryAIExtensions.cs @@ -1,7 +1,6 @@ -using Microsoft.Extensions.AI; +using Sentry.Extensions.AI; -// ReSharper disable once CheckNamespace -- Discoverability -namespace Sentry.Extensions.AI; +namespace Microsoft.Extensions.AI; /// /// Extensions to instrument Microsoft.Extensions.AI builders with Sentry From 1834723db655523419b64ce3989fad75a4b4d137 Mon Sep 17 00:00:00 2001 From: Alex Sohn Date: Wed, 5 Nov 2025 02:25:50 -0500 Subject: [PATCH 53/86] Fix span enricher tests --- .../SentryAISpanEnricherTests.cs | 90 +++++++++---------- 1 file changed, 44 insertions(+), 46 deletions(-) diff --git a/test/Sentry.Extensions.AI.Tests/SentryAISpanEnricherTests.cs b/test/Sentry.Extensions.AI.Tests/SentryAISpanEnricherTests.cs index e229682613..649a2a3399 100644 --- a/test/Sentry.Extensions.AI.Tests/SentryAISpanEnricherTests.cs +++ b/test/Sentry.Extensions.AI.Tests/SentryAISpanEnricherTests.cs @@ -66,15 +66,15 @@ public void EnrichWithRequest_SetsData() SentryAISpanEnricher.EnrichWithRequest(span, messages, chatOptions, aiOptions, SentryAIConstants.SpanOperations.Chat); // Assert - span.Data[SentryAIConstants.SpanAttributes.OperationName].Should().Be(SentryAIConstants.SpanOperations.Chat); - span.Data[SentryAIConstants.SpanAttributes.RequestModel].Should().Be("SentryAI"); - span.Data[SentryAIConstants.SpanAttributes.RequestTemperature].Should().Be(0.7f); - span.Data[SentryAIConstants.SpanAttributes.RequestMaxTokens].Should().Be(1024); - span.Data[SentryAIConstants.SpanAttributes.RequestTopP].Should().Be(0.9f); - span.Data[SentryAIConstants.SpanAttributes.RequestFrequencyPenalty].Should().Be(0.5f); - span.Data[SentryAIConstants.SpanAttributes.RequestPresencePenalty].Should().Be(0.3f); - span.Data[SentryAIConstants.SpanAttributes.RequestMessages].Should().NotBeNull(); - span.Data[SentryAIConstants.SpanAttributes.RequestAvailableTools].Should().NotBeNull(); + span.Data.Should().ContainKey(SentryAIConstants.SpanAttributes.OperationName).WhoseValue.Should().Be(SentryAIConstants.SpanOperations.Chat); + span.Data.Should().ContainKey(SentryAIConstants.SpanAttributes.RequestModel).WhoseValue.Should().Be("SentryAI"); + span.Data.Should().ContainKey(SentryAIConstants.SpanAttributes.RequestTemperature).WhoseValue.Should().Be(0.7f); + span.Data.Should().ContainKey(SentryAIConstants.SpanAttributes.RequestMaxTokens).WhoseValue.Should().Be(1024); + span.Data.Should().ContainKey(SentryAIConstants.SpanAttributes.RequestTopP).WhoseValue.Should().Be(0.9f); + span.Data.Should().ContainKey(SentryAIConstants.SpanAttributes.RequestFrequencyPenalty).WhoseValue.Should().Be(0.5f); + span.Data.Should().ContainKey(SentryAIConstants.SpanAttributes.RequestPresencePenalty).WhoseValue.Should().Be(0.3f); + span.Data.Should().ContainKey(SentryAIConstants.SpanAttributes.RequestMessages).WhoseValue.Should().NotBeNull(); + span.Data.Should().ContainKey(SentryAIConstants.SpanAttributes.RequestAvailableTools).WhoseValue.Should().NotBeNull(); } [Fact] @@ -95,15 +95,15 @@ public void EnrichWithRequest_SetsData_WithoutRequestMessages_WhenDisabled() SentryAISpanEnricher.EnrichWithRequest(span, messages, chatOptions, aiOptions, SentryAIConstants.SpanOperations.Chat); // Assert - span.Data[SentryAIConstants.SpanAttributes.OperationName].Should().Be(SentryAIConstants.SpanOperations.Chat); - span.Data[SentryAIConstants.SpanAttributes.RequestModel].Should().Be("SentryAI"); - span.Data[SentryAIConstants.SpanAttributes.RequestTemperature].Should().Be(0.7f); - span.Data[SentryAIConstants.SpanAttributes.RequestMaxTokens].Should().Be(1024); - span.Data[SentryAIConstants.SpanAttributes.RequestTopP].Should().Be(0.9f); - span.Data[SentryAIConstants.SpanAttributes.RequestFrequencyPenalty].Should().Be(0.5f); - span.Data[SentryAIConstants.SpanAttributes.RequestPresencePenalty].Should().Be(0.3f); + span.Data.Should().ContainKey(SentryAIConstants.SpanAttributes.OperationName).WhoseValue.Should().Be(SentryAIConstants.SpanOperations.Chat); + span.Data.Should().ContainKey(SentryAIConstants.SpanAttributes.RequestModel).WhoseValue.Should().Be("SentryAI"); + span.Data.Should().ContainKey(SentryAIConstants.SpanAttributes.RequestTemperature).WhoseValue.Should().Be(0.7f); + span.Data.Should().ContainKey(SentryAIConstants.SpanAttributes.RequestMaxTokens).WhoseValue.Should().Be(1024); + span.Data.Should().ContainKey(SentryAIConstants.SpanAttributes.RequestTopP).WhoseValue.Should().Be(0.9f); + span.Data.Should().ContainKey(SentryAIConstants.SpanAttributes.RequestFrequencyPenalty).WhoseValue.Should().Be(0.5f); + span.Data.Should().ContainKey(SentryAIConstants.SpanAttributes.RequestPresencePenalty).WhoseValue.Should().Be(0.3f); span.Data.Should().NotContainKey(SentryAIConstants.SpanAttributes.RequestMessages); - span.Data[SentryAIConstants.SpanAttributes.RequestAvailableTools].Should().NotBeNull(); + span.Data.Should().ContainKey(SentryAIConstants.SpanAttributes.RequestAvailableTools).WhoseValue.Should().NotBeNull(); } @@ -124,7 +124,7 @@ public void EnrichWithRequest_SetsBasicData_WhenChatOptionsNull() SentryAISpanEnricher.EnrichWithRequest(span, messages, null, aiOptions, SentryAIConstants.SpanOperations.Chat); // Assert - span.Data[SentryAIConstants.SpanAttributes.OperationName].Should().Be(SentryAIConstants.SpanOperations.Chat); + span.Data.Should().ContainKey(SentryAIConstants.SpanAttributes.OperationName).WhoseValue.Should().Be(SentryAIConstants.SpanOperations.Chat); span.Data.Should().NotContainKey(SentryAIConstants.SpanAttributes.RequestModel); span.Data.Should().NotContainKey(SentryAIConstants.SpanAttributes.RequestTemperature); span.Data.Should().NotContainKey(SentryAIConstants.SpanAttributes.RequestMaxTokens); @@ -136,12 +136,11 @@ public void EnrichWithRequest_SetsBasicData_WhenChatOptionsNull() } [Fact] - public void EnrichWithResponse_SetsData() + public void ToolCallSpan_EnrichWithResponse_SetsData() { // Arrange - const string spanOp = "test_operation"; - const string spanDesc = "test_description"; - var span = _fixture.Hub.StartSpan(spanOp, spanDesc); + var transaction = _fixture.Hub.StartTransaction("test_transaction", "test"); + var span = transaction.StartChild(SentryAIConstants.SpanAttributes.ChatOperation, "test_desc"); var response = new ChatResponse([ new ChatMessage(ChatRole.Assistant, [ new TextContent("Hello"), @@ -163,18 +162,18 @@ public void EnrichWithResponse_SetsData() SentryAISpanEnricher.EnrichWithResponse(span, response, aiOptions); // Assert - span.Data[SentryAIConstants.SpanAttributes.ResponseText].Should().Be("Hello"); - span.Data[SentryAIConstants.SpanAttributes.ResponseModel].Should().Be("response-model-id"); - span.Data[SentryAIConstants.SpanAttributes.UsageInputTokens].Should().Be(50L); - span.Data[SentryAIConstants.SpanAttributes.UsageOutputTokens].Should().Be(25L); - span.Data[SentryAIConstants.SpanAttributes.UsageTotalTokens].Should().Be(75L); - span.Data[SentryAIConstants.SpanAttributes.ResponseToolCalls].Should().NotBeNull(); + span.Data.Should().ContainKey(SentryAIConstants.SpanAttributes.ResponseText).WhoseValue.Should().Be("Hello"); + span.Data.Should().ContainKey(SentryAIConstants.SpanAttributes.ResponseModel).WhoseValue.Should().Be("response-model-id"); + span.Data.Should().ContainKey(SentryAIConstants.SpanAttributes.UsageInputTokens).WhoseValue.Should().Be(50L); + span.Data.Should().ContainKey(SentryAIConstants.SpanAttributes.UsageOutputTokens).WhoseValue.Should().Be(25L); + span.Data.Should().ContainKey(SentryAIConstants.SpanAttributes.UsageTotalTokens).WhoseValue.Should().Be(75L); + span.Data.Should().ContainKey(SentryAIConstants.SpanAttributes.ResponseToolCalls).WhoseValue.Should().NotBeNull(); } [Fact] public void EnrichWithResponse_SetsData_WithoutResponseMessages_WhenDisabled() { // Arrange - const string spanOp = "test_operation"; + const string spanOp = SentryAIConstants.SpanAttributes.ChatOperation; const string spanDesc = "test_description"; var span = _fixture.Hub.StartSpan(spanOp, spanDesc); var response = new ChatResponse(TestMessages()) @@ -196,19 +195,18 @@ public void EnrichWithResponse_SetsData_WithoutResponseMessages_WhenDisabled() // Assert span.Data.Should().NotContainKey(SentryAIConstants.SpanAttributes.ResponseText); - span.Data[SentryAIConstants.SpanAttributes.ResponseModel].Should().Be("response-model-id"); - span.Data[SentryAIConstants.SpanAttributes.UsageInputTokens].Should().Be(50); - span.Data[SentryAIConstants.SpanAttributes.UsageOutputTokens].Should().Be(25); - span.Data[SentryAIConstants.SpanAttributes.UsageTotalTokens].Should().Be(75); + span.Data.Should().ContainKey(SentryAIConstants.SpanAttributes.ResponseModel).WhoseValue.Should().Be("response-model-id"); + span.Data.Should().ContainKey(SentryAIConstants.SpanAttributes.UsageInputTokens).WhoseValue.Should().Be(50); + span.Data.Should().ContainKey(SentryAIConstants.SpanAttributes.UsageOutputTokens).WhoseValue.Should().Be(25); + span.Data.Should().ContainKey(SentryAIConstants.SpanAttributes.UsageTotalTokens).WhoseValue.Should().Be(75); } [Fact] public void EnrichWithStreamingResponses_SetsData() { // Arrange - const string spanOp = "test_operation"; - const string spanDesc = "test_description"; - var span = _fixture.Hub.StartSpan(spanOp, spanDesc); + var transaction = _fixture.Hub.StartTransaction("test_transaction", "test"); + var span = transaction.StartChild(SentryAIConstants.SpanAttributes.ChatOperation, "test_desc"); var streamingMessages = new List { @@ -240,12 +238,12 @@ public void EnrichWithStreamingResponses_SetsData() SentryAISpanEnricher.EnrichWithStreamingResponses(span, streamingMessages, aiOptions); // Assert - span.Data[SentryAIConstants.SpanAttributes.ResponseText].Should().Be("Hello world!"); - span.Data[SentryAIConstants.SpanAttributes.ResponseModel].Should().Be("streaming-model-id"); - span.Data[SentryAIConstants.SpanAttributes.UsageInputTokens].Should().Be(25L); - span.Data[SentryAIConstants.SpanAttributes.UsageOutputTokens].Should().Be(13L); - span.Data[SentryAIConstants.SpanAttributes.UsageTotalTokens].Should().Be(38L); - span.Data[SentryAIConstants.SpanAttributes.ResponseToolCalls].Should().NotBeNull(); + span.Data.Should().ContainKey(SentryAIConstants.SpanAttributes.ResponseText).WhoseValue.Should().Be("Hello world!"); + span.Data.Should().ContainKey(SentryAIConstants.SpanAttributes.ResponseModel).WhoseValue.Should().Be("streaming-model-id"); + span.Data.Should().ContainKey(SentryAIConstants.SpanAttributes.UsageInputTokens).WhoseValue.Should().Be(25L); + span.Data.Should().ContainKey(SentryAIConstants.SpanAttributes.UsageOutputTokens).WhoseValue.Should().Be(13L); + span.Data.Should().ContainKey(SentryAIConstants.SpanAttributes.UsageTotalTokens).WhoseValue.Should().Be(38L); + span.Data.Should().ContainKey(SentryAIConstants.SpanAttributes.ResponseToolCalls).WhoseValue.Should().NotBeNull(); } [Fact] @@ -275,9 +273,9 @@ public void EnrichWithStreamingResponses_SetsData_WithoutResponseContent_WhenDis // Assert span.Data.Should().NotContainKey(SentryAIConstants.SpanAttributes.ResponseText); - span.Data[SentryAIConstants.SpanAttributes.ResponseModel].Should().Be("streaming-model-id"); - span.Data[SentryAIConstants.SpanAttributes.UsageInputTokens].Should().Be(20L); - span.Data[SentryAIConstants.SpanAttributes.UsageOutputTokens].Should().Be(10L); - span.Data[SentryAIConstants.SpanAttributes.UsageTotalTokens].Should().Be(30L); + span.Data.Should().ContainKey(SentryAIConstants.SpanAttributes.ResponseModel).WhoseValue.Should().Be("streaming-model-id"); + span.Data.Should().ContainKey(SentryAIConstants.SpanAttributes.UsageInputTokens).WhoseValue.Should().Be(20L); + span.Data.Should().ContainKey(SentryAIConstants.SpanAttributes.UsageOutputTokens).WhoseValue.Should().Be(10L); + span.Data.Should().ContainKey(SentryAIConstants.SpanAttributes.UsageTotalTokens).WhoseValue.Should().Be(30L); } } From e9b7f42c2a0ee8c218a357ce22c39128fc579cb8 Mon Sep 17 00:00:00 2001 From: Alex Sohn Date: Thu, 6 Nov 2025 11:45:00 -0500 Subject: [PATCH 54/86] Cleanup merge conflict in Sentry.sln --- Sentry.sln | 3 --- 1 file changed, 3 deletions(-) diff --git a/Sentry.sln b/Sentry.sln index fa919e0d25..57df54c485 100644 --- a/Sentry.sln +++ b/Sentry.sln @@ -1470,7 +1470,6 @@ Global {C3CDF61C-3E28-441C-A9CE-011C89D11719} = {230B9384-90FD-4551-A5DE-1A5C197F25B6} {3A76FF7D-2F32-4EA5-8999-2FFE3C7CB893} = {6987A1CC-608E-4868-A02C-09D30C8B7B2D} {ADC91A84-6054-42EC-8241-0D717E4C7194} = {6987A1CC-608E-4868-A02C-09D30C8B7B2D} -<<<<<<< HEAD {BFF081D8-7CC0-4069-99F5-5CA0D70B56AB} = {EC6ADE8A-E557-4848-8F03-519039830B5F} {5D50D425-244F-4B79-B9F5-21D26DD52DC1} = {EC6ADE8A-E557-4848-8F03-519039830B5F} {39216438-F347-427C-AB70-48DB1BA6E299} = {5D50D425-244F-4B79-B9F5-21D26DD52DC1} @@ -1478,11 +1477,9 @@ Global {E34AA22F-B42E-4D4C-B96E-426AEBC2F367} = {5D50D425-244F-4B79-B9F5-21D26DD52DC1} {94A2DCA5-F298-41FB-913A-476668EF5786} = {5D50D425-244F-4B79-B9F5-21D26DD52DC1} {33793113-C7B5-434D-B5C1-6CA1A9587842} = {94CCDBEF-5867-4C24-A305-0C2AE738AF42} -======= {CE591593-E51C-498E-BC0D-5083C8197544} = {21B42F60-5802-404E-90F0-AEBCC56760C0} {3D91128A-5695-4BAE-B939-5F5F7C56A679} = {21B42F60-5802-404E-90F0-AEBCC56760C0} {AE461926-00B8-4FDD-A41D-3C83C2D9A045} = {230B9384-90FD-4551-A5DE-1A5C197F25B6} {28D6E004-DC64-464F-91BE-BC0B3A8E543F} = {6987A1CC-608E-4868-A02C-09D30C8B7B2D} ->>>>>>> 9896852a (Basic Functionality working) EndGlobalSection EndGlobal From f2096bb3b3bad4a33248827ec628f880b995626c Mon Sep 17 00:00:00 2001 From: Alex Sohn Date: Thu, 6 Nov 2025 12:40:50 -0500 Subject: [PATCH 55/86] Cleanup if statments in SentryAIExtensions.cs --- src/Sentry.Extensions.AI/Extensions/SentryAIExtensions.cs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/Sentry.Extensions.AI/Extensions/SentryAIExtensions.cs b/src/Sentry.Extensions.AI/Extensions/SentryAIExtensions.cs index 832cd91d0c..88846139f3 100644 --- a/src/Sentry.Extensions.AI/Extensions/SentryAIExtensions.cs +++ b/src/Sentry.Extensions.AI/Extensions/SentryAIExtensions.cs @@ -14,12 +14,12 @@ public static class SentryAIExtensions /// The that contains the to instrument public static ChatOptions AddSentryToolInstrumentation(this ChatOptions options) { - if (options.Tools is null || options.Tools.Count == 0) + if (options.Tools is { Count: > 0 }) { return options; } - for (var i = 0; i < options.Tools.Count; i++) + for (var i = 0; i < options.Tools?.Count; i++) { if (options.Tools[i] is AIFunction fn and not SentryInstrumentedFunction) { From 453a7b021e21f5486a23ab746bba9a91501938a6 Mon Sep 17 00:00:00 2001 From: Alex Sohn Date: Thu, 6 Nov 2025 12:42:34 -0500 Subject: [PATCH 56/86] Remove usage of single letter variables in ActivityListener --- src/Sentry.Extensions.AI/SentryAIActivityListener.cs | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/src/Sentry.Extensions.AI/SentryAIActivityListener.cs b/src/Sentry.Extensions.AI/SentryAIActivityListener.cs index 37b91c8485..c38ce392e6 100644 --- a/src/Sentry.Extensions.AI/SentryAIActivityListener.cs +++ b/src/Sentry.Extensions.AI/SentryAIActivityListener.cs @@ -12,21 +12,21 @@ internal static class SentryAIActivityListener /// private static readonly ActivityListener FICCListener = new() { - ShouldListenTo = s => s.Name.StartsWith(SentryAIConstants.SentryActivitySourceName), + ShouldListenTo = source => source.Name.StartsWith(SentryAIConstants.SentryActivitySourceName), Sample = (ref ActivityCreationOptions options) => SentryAIConstants.FICCActivityNames.Contains(options.Name) ? ActivitySamplingResult.AllDataAndRecorded : ActivitySamplingResult.None, - ActivityStarted = a => + ActivityStarted = activity => { var agentSpan = HubAdapter.Instance.StartSpan(SentryAIConstants.SpanAttributes.InvokeAgentOperation, SentryAIConstants.SpanAttributes.InvokeAgentDescription); - a.SetFused(SentryAIConstants.SentryActivitySpanAttributeName, agentSpan); + activity.SetFused(SentryAIConstants.SentryActivitySpanAttributeName, agentSpan); }, - ActivityStopped = a => + ActivityStopped = activity => { - var agentSpan = a.GetFused(SentryAIConstants.SentryActivitySpanAttributeName); + var agentSpan = activity.GetFused(SentryAIConstants.SentryActivitySpanAttributeName); // Don't pass in OK status in case there was an exception agentSpan?.Finish(); - }, + } }; /// From fd0ccbaada417317be80da7f61e4a24fcaa3bf5f Mon Sep 17 00:00:00 2001 From: Alex Sohn Date: Thu, 6 Nov 2025 14:38:03 -0500 Subject: [PATCH 57/86] Allow IHub to be passed in when creating ActivityListener --- .../SentryAIActivityListener.cs | 8 +- .../SentryAIActivityListenerTests.cs | 78 +++++++++---------- 2 files changed, 43 insertions(+), 43 deletions(-) diff --git a/src/Sentry.Extensions.AI/SentryAIActivityListener.cs b/src/Sentry.Extensions.AI/SentryAIActivityListener.cs index c38ce392e6..765ad3030b 100644 --- a/src/Sentry.Extensions.AI/SentryAIActivityListener.cs +++ b/src/Sentry.Extensions.AI/SentryAIActivityListener.cs @@ -7,6 +7,8 @@ namespace Sentry.Extensions.AI; /// internal static class SentryAIActivityListener { + private static IHub Hub = HubAdapter.Instance; + /// /// Sentry's to tap into function invocation's Activity /// @@ -18,7 +20,7 @@ internal static class SentryAIActivityListener ActivitySamplingResult.AllDataAndRecorded : ActivitySamplingResult.None, ActivityStarted = activity => { - var agentSpan = HubAdapter.Instance.StartSpan(SentryAIConstants.SpanAttributes.InvokeAgentOperation, SentryAIConstants.SpanAttributes.InvokeAgentDescription); + var agentSpan = Hub.StartSpan(SentryAIConstants.SpanAttributes.InvokeAgentOperation, SentryAIConstants.SpanAttributes.InvokeAgentDescription); activity.SetFused(SentryAIConstants.SentryActivitySpanAttributeName, agentSpan); }, ActivityStopped = activity => @@ -32,8 +34,10 @@ internal static class SentryAIActivityListener /// /// Initializes Sentry's to tap into FunctionInvokingChatClient's Activity /// - internal static void Init() + /// Optional IHub instance to use. If not provided, HubAdapter.Instance will be used. + internal static void Init(IHub? hub = null) { + Hub = hub ?? HubAdapter.Instance; ActivitySource.AddActivityListener(FICCListener); } } diff --git a/test/Sentry.Extensions.AI.Tests/SentryAIActivityListenerTests.cs b/test/Sentry.Extensions.AI.Tests/SentryAIActivityListenerTests.cs index e60768f177..26195548b7 100644 --- a/test/Sentry.Extensions.AI.Tests/SentryAIActivityListenerTests.cs +++ b/test/Sentry.Extensions.AI.Tests/SentryAIActivityListenerTests.cs @@ -1,10 +1,21 @@ #nullable enable -using Sentry.Extensions.AI; namespace Sentry.Extensions.AI.Tests; public class SentryAIActivityListenerTests { + private class Fixture + { + public IHub Hub { get; } = Substitute.For(); + + public Fixture() + { + Hub.IsEnabled.Returns(true); + } + } + + private readonly Fixture _fixture = new(); + [Fact] public void Init_AddsActivityListenerToActivitySource() { @@ -20,25 +31,36 @@ public void Init_AddsActivityListenerToActivitySource() public void ShouldListenTo_ReturnsTrueForSentryActivitySource(string sourceName) { // Arrange - SentryAIActivityListener.Init(); + SentryAIActivityListener.Init(_fixture.Hub); var activitySource = new ActivitySource(sourceName); - // Act & Assert + // Act using var activity = activitySource.StartActivity(SentryAIConstants.FICCActivityNames[0]); + + // Assert Assert.NotNull(activity); Assert.NotNull(Activity.Current); + + // Should receive a StartTransaction since we don't already have a transaction going on + _fixture.Hub.Received(1).StartTransaction( + Arg.Any(), + Arg.Any>()); } [Fact] public void ShouldListenTo_ReturnsFalseForNonSentryActivitySource() { // Arrange - SentryAIActivityListener.Init(); + SentryAIActivityListener.Init(_fixture.Hub); var activitySource = new ActivitySource("Other.ActivitySource"); // Act & Assert using var activity = activitySource.StartActivity("test"); Assert.Null(activity); // Activity should not be created for non-Sentry sources + + _fixture.Hub.DidNotReceive().StartTransaction( + Arg.Any(), + Arg.Any>()); } [Theory] @@ -48,7 +70,7 @@ public void ShouldListenTo_ReturnsFalseForNonSentryActivitySource() public void Sample_ReturnsAllDataAndRecordedForFICCActivityNames(string activityName) { // Arrange - SentryAIActivityListener.Init(); + SentryAIActivityListener.Init(_fixture.Hub); // Act using var activity = SentryAIActivitySource.Instance.StartActivity(activityName); @@ -58,56 +80,30 @@ public void Sample_ReturnsAllDataAndRecordedForFICCActivityNames(string activity Assert.True(activity.IsAllDataRequested); Assert.Equal(ActivitySamplingResult.AllDataAndRecorded, activity.Recorded ? ActivitySamplingResult.AllDataAndRecorded : ActivitySamplingResult.None); - } - [Theory] - [InlineData("some_other_activity")] - [InlineData("random_activity_name")] - public void Sample_ReturnsNoneForNonFICCActivityNames(string activityName) - { - // Arrange - SentryAIActivityListener.Init(); - - // Act - using var activity = SentryAIActivitySource.Instance.StartActivity(activityName); - - // Assert - // For non-FICC activity names, the activity may still be created but not recorded - if (activity != null) - { - Assert.False(activity.Recorded); - } + _fixture.Hub.Received(1).StartTransaction( + Arg.Any(), + Arg.Any>()); } [Fact] public void Init_MultipleCalls_NoDuplicateListener_StartsOnlyOneTransaction() { // Arrange - var sent = 0; - using var _ = SentrySdk.Init(o => - { - o.Dsn = ValidDsn; - o.TracesSampleRate = 1.0; - // Count transactions just before they are sent: - o.SetBeforeSendTransaction(t => - { - Interlocked.Increment(ref sent); - return t; - }); - }); + SentryAIActivityListener.Init(_fixture.Hub); + SentryAIActivityListener.Init(_fixture.Hub); + SentryAIActivityListener.Init(_fixture.Hub); // Act - SentryAIActivityListener.Init(); - SentryAIActivityListener.Init(); - SentryAIActivityListener.Init(); - - var activity = SentryAIActivitySource.Instance.StartActivity(SentryAIConstants.FICCActivityNames[0]); + using var activity = SentryAIActivitySource.Instance.StartActivity(SentryAIConstants.FICCActivityNames[0]); // Assert Assert.NotNull(activity); Assert.True(SentryAIActivitySource.Instance.HasListeners()); activity.Stop(); - Assert.Equal(1, Volatile.Read(ref sent)); + _fixture.Hub.Received(1).StartTransaction( + Arg.Any(), + Arg.Any>()); } } From 88f0095c00e4d13dc429f3ed47d257d85071be97 Mon Sep 17 00:00:00 2001 From: Alex Sohn Date: Thu, 6 Nov 2025 14:43:17 -0500 Subject: [PATCH 58/86] Add a comment about where to find Activity constants --- src/Sentry.Extensions.AI/SentryAIConstants.cs | 1 + 1 file changed, 1 insertion(+) diff --git a/src/Sentry.Extensions.AI/SentryAIConstants.cs b/src/Sentry.Extensions.AI/SentryAIConstants.cs index 5d2eedc2b0..b91a5a71cf 100644 --- a/src/Sentry.Extensions.AI/SentryAIConstants.cs +++ b/src/Sentry.Extensions.AI/SentryAIConstants.cs @@ -4,6 +4,7 @@ internal static class SentryAIConstants { /// /// The list of strings which FunctionInvokingChatClient(FICC) uses to start the tool call . + /// These can be found in FunctionInvokingChatClient.cs in GetResponseAsync or GetStreamingResponseAsync function. /// internal static readonly string[] FICCActivityNames = ["orchestrate_tools", "FunctionInvokingChatClient.GetResponseAsync", "FunctionInvokingChatClient"]; From 5e43b5bc677aa5e63401ea28aa2b22ed790e5148 Mon Sep 17 00:00:00 2001 From: Alex Sohn Date: Thu, 6 Nov 2025 14:49:05 -0500 Subject: [PATCH 59/86] Simplify If statements in EnrichWithRequest --- src/Sentry.Extensions.AI/SentryAISpanEnricher.cs | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/src/Sentry.Extensions.AI/SentryAISpanEnricher.cs b/src/Sentry.Extensions.AI/SentryAISpanEnricher.cs index 5324db8d89..27a36c61c4 100644 --- a/src/Sentry.Extensions.AI/SentryAISpanEnricher.cs +++ b/src/Sentry.Extensions.AI/SentryAISpanEnricher.cs @@ -22,14 +22,15 @@ internal static void EnrichWithRequest(ISpan span, ChatMessage[] messages, ChatO span.SetData(SentryAIConstants.SpanAttributes.RequestModel, modelId); } - if (aiOptions?.AgentName is { } agentName) + if (aiOptions.AgentName is { } agentName) { span.SetData(SentryAIConstants.SpanAttributes.AgentName, agentName); } if (messages is { Length: > 0 } - && (aiOptions?.RecordInputs ?? true) - && !span.Data.TryGetValue(SentryAIConstants.SpanAttributes.RequestMessages, out _)) + && aiOptions.RecordInputs + // Only add request messages if there is none currently + && !span.Data.ContainsKey(SentryAIConstants.SpanAttributes.RequestMessages)) { span.SetData(SentryAIConstants.SpanAttributes.RequestMessages, FormatRequestMessage(messages)); } From 5b7243d39d1d4b6fbb4e4f2a4a1db1f9acdbe629 Mon Sep 17 00:00:00 2001 From: Alex Sohn Date: Thu, 6 Nov 2025 14:50:35 -0500 Subject: [PATCH 60/86] Clarify function name for FunctionResultToObject --- src/Sentry.Extensions.AI/SentryAISpanEnricher.cs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/Sentry.Extensions.AI/SentryAISpanEnricher.cs b/src/Sentry.Extensions.AI/SentryAISpanEnricher.cs index 27a36c61c4..d77533da9f 100644 --- a/src/Sentry.Extensions.AI/SentryAISpanEnricher.cs +++ b/src/Sentry.Extensions.AI/SentryAISpanEnricher.cs @@ -156,7 +156,7 @@ private static string FormatRequestMessage(ChatMessage[] messages) { return FormatAsJsonList(messages, message => { - var content = message.Role == ChatRole.Tool ? FunctionCallToString(message.Contents) : message.Text; + var content = message.Role == ChatRole.Tool ? FunctionResultToObject(message.Contents) : message.Text; return new { @@ -165,7 +165,7 @@ private static string FormatRequestMessage(ChatMessage[] messages) }; }); - object FunctionCallToString(IList toolContents) + object FunctionResultToObject(IList toolContents) { List callList = []; foreach (var toolContent in toolContents) From 017ff79f0237bcc53884daf7bd5966bc7b46b143 Mon Sep 17 00:00:00 2001 From: Alex Sohn Date: Thu, 6 Nov 2025 15:21:17 -0500 Subject: [PATCH 61/86] Fix SentryInstrumentedFunction initialization --- src/Sentry.Extensions.AI/Extensions/SentryAIExtensions.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Sentry.Extensions.AI/Extensions/SentryAIExtensions.cs b/src/Sentry.Extensions.AI/Extensions/SentryAIExtensions.cs index 88846139f3..2cc5ce9d0a 100644 --- a/src/Sentry.Extensions.AI/Extensions/SentryAIExtensions.cs +++ b/src/Sentry.Extensions.AI/Extensions/SentryAIExtensions.cs @@ -14,7 +14,7 @@ public static class SentryAIExtensions /// The that contains the to instrument public static ChatOptions AddSentryToolInstrumentation(this ChatOptions options) { - if (options.Tools is { Count: > 0 }) + if (options.Tools is { Count: 0 }) { return options; } From faef263348561cd62dc9b16cf8a907ce2c55d979 Mon Sep 17 00:00:00 2001 From: Alex Sohn Date: Thu, 6 Nov 2025 15:45:26 -0500 Subject: [PATCH 62/86] Remove FormatAsJsonList and replace with individual JsonSerializer calls --- .../SentryAISpanEnricher.cs | 17 ++++++----------- 1 file changed, 6 insertions(+), 11 deletions(-) diff --git a/src/Sentry.Extensions.AI/SentryAISpanEnricher.cs b/src/Sentry.Extensions.AI/SentryAISpanEnricher.cs index d77533da9f..4bac16de95 100644 --- a/src/Sentry.Extensions.AI/SentryAISpanEnricher.cs +++ b/src/Sentry.Extensions.AI/SentryAISpanEnricher.cs @@ -145,16 +145,16 @@ private static void PopulateToolCallsInfo(IList contents, ISpan span) private static string FormatAvailableTools(IList tools) { - return FormatAsJsonList(tools, tool => new + return JsonSerializer.Serialize(tools.Select(tool => new { name = tool.Name, description = tool.Description - }); + })); } private static string FormatRequestMessage(ChatMessage[] messages) { - return FormatAsJsonList(messages, message => + return JsonSerializer.Serialize(messages.Select(message => { var content = message.Role == ChatRole.Tool ? FunctionResultToObject(message.Contents) : message.Text; @@ -163,7 +163,7 @@ private static string FormatRequestMessage(ChatMessage[] messages) role = message.Role, content }; - }); + })); object FunctionResultToObject(IList toolContents) { @@ -189,7 +189,7 @@ object FunctionResultToObject(IList toolContents) private static string FormatFunctionCallContent(FunctionCallContent[] content) { - return FormatAsJsonList(content, c => + return JsonSerializer.Serialize(content.Select(c => { string argumentsJson; try @@ -207,11 +207,6 @@ private static string FormatFunctionCallContent(FunctionCallContent[] content) type = "function_call", arguments = argumentsJson }; - }); - } - - private static string FormatAsJsonList(IEnumerable items, Func selector) - { - return JsonSerializer.Serialize(items.Select(selector)); + })); } } From b4dc1687fd5a95a6334eca0f9b793ef671c7fe2e Mon Sep 17 00:00:00 2001 From: Alex Sohn Date: Thu, 6 Nov 2025 15:52:53 -0500 Subject: [PATCH 63/86] Change ActivitySpan mentions to FICCSpan --- src/Sentry.Extensions.AI/SentryAIActivityListener.cs | 4 ++-- src/Sentry.Extensions.AI/SentryAIConstants.cs | 4 ++-- src/Sentry.Extensions.AI/SentryAIUtil.cs | 4 ++-- src/Sentry.Extensions.AI/SentryChatClient.cs | 2 +- src/Sentry.Extensions.AI/SentryInstrumentedFunction.cs | 2 +- 5 files changed, 8 insertions(+), 8 deletions(-) diff --git a/src/Sentry.Extensions.AI/SentryAIActivityListener.cs b/src/Sentry.Extensions.AI/SentryAIActivityListener.cs index 765ad3030b..319c4f20ce 100644 --- a/src/Sentry.Extensions.AI/SentryAIActivityListener.cs +++ b/src/Sentry.Extensions.AI/SentryAIActivityListener.cs @@ -21,11 +21,11 @@ internal static class SentryAIActivityListener ActivityStarted = activity => { var agentSpan = Hub.StartSpan(SentryAIConstants.SpanAttributes.InvokeAgentOperation, SentryAIConstants.SpanAttributes.InvokeAgentDescription); - activity.SetFused(SentryAIConstants.SentryActivitySpanAttributeName, agentSpan); + activity.SetFused(SentryAIConstants.SentryFICCSpanAttributeName, agentSpan); }, ActivityStopped = activity => { - var agentSpan = activity.GetFused(SentryAIConstants.SentryActivitySpanAttributeName); + var agentSpan = activity.GetFused(SentryAIConstants.SentryFICCSpanAttributeName); // Don't pass in OK status in case there was an exception agentSpan?.Finish(); } diff --git a/src/Sentry.Extensions.AI/SentryAIConstants.cs b/src/Sentry.Extensions.AI/SentryAIConstants.cs index b91a5a71cf..f3f47f817a 100644 --- a/src/Sentry.Extensions.AI/SentryAIConstants.cs +++ b/src/Sentry.Extensions.AI/SentryAIConstants.cs @@ -14,9 +14,9 @@ internal static class SentryAIConstants internal const string SentryActivitySourceName = "Sentry.AgentMonitoring"; /// - /// The string we use to retrieve the span from the using a Fused property + /// The string we use to retrieve a Sentry span from the using a Fused property /// - internal const string SentryActivitySpanAttributeName = "SentryCurrSpan"; + internal const string SentryFICCSpanAttributeName = "SentryFICCSpan"; internal static class SpanAttributes { diff --git a/src/Sentry.Extensions.AI/SentryAIUtil.cs b/src/Sentry.Extensions.AI/SentryAIUtil.cs index 87a1a98985..78dc1d8f51 100644 --- a/src/Sentry.Extensions.AI/SentryAIUtil.cs +++ b/src/Sentry.Extensions.AI/SentryAIUtil.cs @@ -4,12 +4,12 @@ namespace Sentry.Extensions.AI; internal static class SentryAIUtil { - internal static ISpan? GetActivitySpan() + internal static ISpan? GetFICCSpan() { var currActivity = Activity.Current; while (currActivity != null) { - if (currActivity.GetFused(SentryAIConstants.SentryActivitySpanAttributeName) is { } span) + if (currActivity.GetFused(SentryAIConstants.SentryFICCSpanAttributeName) is { } span) { return span; } diff --git a/src/Sentry.Extensions.AI/SentryChatClient.cs b/src/Sentry.Extensions.AI/SentryChatClient.cs index a200bba108..4d2645386e 100644 --- a/src/Sentry.Extensions.AI/SentryChatClient.cs +++ b/src/Sentry.Extensions.AI/SentryChatClient.cs @@ -148,7 +148,7 @@ private ISpan TryGetAgentSpan(ChatOptions? options) // If FunctionInvokingChatClient(FICC) wraps SentryChatClient, we should be able to get the agent span from the current activity // The activity we attached the span to may be an ancestor of the current activity, we must search the parents for the span - var activeSpan = SentryAIUtil.GetActivitySpan(); + var activeSpan = SentryAIUtil.GetFICCSpan(); // If we couldn't find the Activity, then FICC is not wrapping SentryChatClient. Return a new span from the hub return activeSpan ?? _hub.StartSpan(SentryAIConstants.SpanAttributes.InvokeAgentOperation, diff --git a/src/Sentry.Extensions.AI/SentryInstrumentedFunction.cs b/src/Sentry.Extensions.AI/SentryInstrumentedFunction.cs index 50d99c5100..c7cd6cda0a 100644 --- a/src/Sentry.Extensions.AI/SentryInstrumentedFunction.cs +++ b/src/Sentry.Extensions.AI/SentryInstrumentedFunction.cs @@ -9,7 +9,7 @@ internal sealed class SentryInstrumentedFunction(AIFunction innerFunction) AIFunctionArguments arguments, CancellationToken cancellationToken) { - var agentSpan = SentryAIUtil.GetActivitySpan(); + var agentSpan = SentryAIUtil.GetFICCSpan(); var toolSpan = InitToolSpan(agentSpan, arguments); try { From 4218edf7553760c16c0a3df53d1dcf19e8f951d2 Mon Sep 17 00:00:00 2001 From: Alex Sohn Date: Thu, 6 Nov 2025 15:56:42 -0500 Subject: [PATCH 64/86] Expand one letter argument in FormatFunctionCallContent --- src/Sentry.Extensions.AI/SentryAISpanEnricher.cs | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/Sentry.Extensions.AI/SentryAISpanEnricher.cs b/src/Sentry.Extensions.AI/SentryAISpanEnricher.cs index 4bac16de95..71456886bc 100644 --- a/src/Sentry.Extensions.AI/SentryAISpanEnricher.cs +++ b/src/Sentry.Extensions.AI/SentryAISpanEnricher.cs @@ -189,12 +189,12 @@ object FunctionResultToObject(IList toolContents) private static string FormatFunctionCallContent(FunctionCallContent[] content) { - return JsonSerializer.Serialize(content.Select(c => + return JsonSerializer.Serialize(content.Select(functionCallContent => { string argumentsJson; try { - argumentsJson = JsonSerializer.Serialize(c.Arguments); + argumentsJson = JsonSerializer.Serialize(functionCallContent.Arguments); } catch { @@ -203,7 +203,7 @@ private static string FormatFunctionCallContent(FunctionCallContent[] content) return new { - name = c.Name, + name = functionCallContent.Name, type = "function_call", arguments = argumentsJson }; From a2a89b313a9f74f8399d64ee6a157cef8f91f825 Mon Sep 17 00:00:00 2001 From: Alex Sohn Date: Fri, 7 Nov 2025 09:43:11 -0500 Subject: [PATCH 65/86] Allow IHub to be passed in SentryInstrumentedFunction --- src/Sentry.Extensions.AI/SentryInstrumentedFunction.cs | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/src/Sentry.Extensions.AI/SentryInstrumentedFunction.cs b/src/Sentry.Extensions.AI/SentryInstrumentedFunction.cs index c7cd6cda0a..09ae82b60e 100644 --- a/src/Sentry.Extensions.AI/SentryInstrumentedFunction.cs +++ b/src/Sentry.Extensions.AI/SentryInstrumentedFunction.cs @@ -2,9 +2,10 @@ namespace Sentry.Extensions.AI; -internal sealed class SentryInstrumentedFunction(AIFunction innerFunction) +internal sealed class SentryInstrumentedFunction(AIFunction innerFunction, IHub? hub = null) : DelegatingAIFunction(innerFunction) { + private readonly IHub _hub = hub ?? HubAdapter.Instance; protected override async ValueTask InvokeCoreAsync( AIFunctionArguments arguments, CancellationToken cancellationToken) @@ -26,12 +27,12 @@ internal sealed class SentryInstrumentedFunction(AIFunction innerFunction) catch (Exception ex) { toolSpan.Finish(SpanStatus.InternalError); - HubAdapter.Instance.CaptureException(ex); + _hub.CaptureException(ex); if (agentSpan != null) { // We don't finish the agent span with the exception because there will be another call to LLM. // Python SDK currently binds the exception to the agent span, so we do so here. - HubAdapter.Instance.BindException(ex, agentSpan); + _hub.BindException(ex, agentSpan); } throw; } @@ -45,7 +46,7 @@ private ISpan InitToolSpan(ISpan? agentSpan, AIFunctionArguments arguments) var currSpan = agentSpan != null ? agentSpan.StartChild(SentryAIConstants.SpanAttributes.ToolCallOperation, spanName) // If we couldn't find the agent span, just attach it to the hub's current scope - : HubAdapter.Instance.StartSpan(SentryAIConstants.SpanAttributes.ToolCallOperation, spanName); + : _hub.StartSpan(SentryAIConstants.SpanAttributes.ToolCallOperation, spanName); currSpan.SetData(SentryAIConstants.SpanAttributes.OperationName, "execute_tool"); currSpan.SetData(SentryAIConstants.SpanAttributes.ToolName, Name); From 7d9365727232fae67382306333bef4336ea54996 Mon Sep 17 00:00:00 2001 From: Alex Sohn Date: Fri, 7 Nov 2025 10:04:10 -0500 Subject: [PATCH 66/86] Change SentryInstrumentedFunctionTests to use hub substitute --- .../SentryInstrumentedFunctionTests.cs | 92 ++++++++++++------- 1 file changed, 59 insertions(+), 33 deletions(-) diff --git a/test/Sentry.Extensions.AI.Tests/SentryInstrumentedFunctionTests.cs b/test/Sentry.Extensions.AI.Tests/SentryInstrumentedFunctionTests.cs index 7ba3b43b3a..f9ca323dd0 100644 --- a/test/Sentry.Extensions.AI.Tests/SentryInstrumentedFunctionTests.cs +++ b/test/Sentry.Extensions.AI.Tests/SentryInstrumentedFunctionTests.cs @@ -5,13 +5,24 @@ namespace Sentry.Extensions.AI.Tests; public class SentryInstrumentedFunctionTests { + private class Fixture + { + public IHub Hub { get; } = Substitute.For(); + + public Fixture() + { + Hub.IsEnabled.Returns(true); + } + } + + private readonly Fixture _fixture = new(); + [Fact] public async Task InvokeCoreAsync_WithValidFunction_ReturnsResult() { // Arrange - using var sentryDisposable = SentryHelpers.InitializeSdk(); var testFunction = AIFunctionFactory.Create(() => "test result", "TestFunction", "Test function description"); - var sentryFunction = new SentryInstrumentedFunction(testFunction); + var sentryFunction = new SentryInstrumentedFunction(testFunction, _fixture.Hub); var arguments = new AIFunctionArguments(); // Act @@ -28,17 +39,20 @@ public async Task InvokeCoreAsync_WithValidFunction_ReturnsResult() { Assert.Equal("test result", result); } + Assert.Equal("TestFunction", sentryFunction.Name); Assert.Equal("Test function description", sentryFunction.Description); + _fixture.Hub.Received(1).StartTransaction( + Arg.Any(), + Arg.Any>()); } [Fact] public async Task InvokeCoreAsync_WithNullResult_ReturnsNull() { // Arrange - using var sentryDisposable = SentryHelpers.InitializeSdk(); var testFunction = AIFunctionFactory.Create(object? () => null, "TestFunction", "Test function description"); - var sentryFunction = new SentryInstrumentedFunction(testFunction); + var sentryFunction = new SentryInstrumentedFunction(testFunction, _fixture.Hub); var arguments = new AIFunctionArguments(); // Act @@ -53,16 +67,19 @@ public async Task InvokeCoreAsync_WithNullResult_ReturnsNull() { Assert.Null(result); } + + _fixture.Hub.Received(1).StartTransaction( + Arg.Any(), + Arg.Any>()); } [Fact] public async Task InvokeCoreAsync_WithJsonNullResult_ReturnsJsonElement() { // Arrange - using var sentryDisposable = SentryHelpers.InitializeSdk(); var jsonNullElement = JsonSerializer.Deserialize("null"); var testFunction = AIFunctionFactory.Create(() => jsonNullElement, "TestFunction", "Test function description"); - var sentryFunction = new SentryInstrumentedFunction(testFunction); + var sentryFunction = new SentryInstrumentedFunction(testFunction, _fixture.Hub); var arguments = new AIFunctionArguments(); // Act @@ -73,16 +90,18 @@ public async Task InvokeCoreAsync_WithJsonNullResult_ReturnsJsonElement() Assert.IsType(result); var jsonResult = (JsonElement)result; Assert.Equal(JsonValueKind.Null, jsonResult.ValueKind); + _fixture.Hub.Received(1).StartTransaction( + Arg.Any(), + Arg.Any>()); } [Fact] public async Task InvokeCoreAsync_WithJsonElementResult_CallsToStringForSpanOutput() { // Arrange - using var sentryDisposable = SentryHelpers.InitializeSdk(); var jsonElement = JsonSerializer.Deserialize("\"test output\""); var testFunction = AIFunctionFactory.Create(() => jsonElement, "TestFunction", "Test function description"); - var sentryFunction = new SentryInstrumentedFunction(testFunction); + var sentryFunction = new SentryInstrumentedFunction(testFunction, _fixture.Hub); var arguments = new AIFunctionArguments(); // Act @@ -93,16 +112,22 @@ public async Task InvokeCoreAsync_WithJsonElementResult_CallsToStringForSpanOutp Assert.IsType(result); var jsonResult = (JsonElement)result; Assert.Equal("test output", jsonResult.GetString()); + _fixture.Hub.Received(1).StartTransaction( + Arg.Any(), + Arg.Any>()); } [Fact] public async Task InvokeCoreAsync_WithComplexResult_ReturnsObject() { // Arrange - using var sentryDisposable = SentryHelpers.InitializeSdk(); - var resultObject = new { message = "test", count = 42 }; + var resultObject = new + { + message = "test", + count = 42 + }; var testFunction = AIFunctionFactory.Create(() => resultObject, "TestFunction", "Test function description"); - var sentryFunction = new SentryInstrumentedFunction(testFunction); + var sentryFunction = new SentryInstrumentedFunction(testFunction, _fixture.Hub); var arguments = new AIFunctionArguments(); // Act @@ -122,16 +147,20 @@ public async Task InvokeCoreAsync_WithComplexResult_ReturnsObject() { Assert.Equal(resultObject, result); } + + _fixture.Hub.Received(1).StartTransaction( + Arg.Any(), + Arg.Any>()); } [Fact] public async Task InvokeCoreAsync_WhenFunctionThrows_PropagatesException() { // Arrange - using var sentryDisposable = SentryHelpers.InitializeSdk(); var expectedException = new InvalidOperationException("Test exception"); - var testFunction = AIFunctionFactory.Create(new Func(() => throw expectedException), "TestFunction", "Test function description"); - var sentryFunction = new SentryInstrumentedFunction(testFunction); + var testFunction = AIFunctionFactory.Create(new Func(() => throw expectedException), "TestFunction", + "Test function description"); + var sentryFunction = new SentryInstrumentedFunction(testFunction, _fixture.Hub); var arguments = new AIFunctionArguments(); // Act & Assert @@ -139,20 +168,22 @@ public async Task InvokeCoreAsync_WhenFunctionThrows_PropagatesException() await sentryFunction.InvokeAsync(arguments)); Assert.Equal(expectedException.Message, actualException.Message); + _fixture.Hub.Received(1).StartTransaction( + Arg.Any(), + Arg.Any>()); } [Fact] public async Task InvokeCoreAsync_WithCancellation_PropagatesCancellation() { // Arrange - using var sentryDisposable = SentryHelpers.InitializeSdk(); var testFunction = AIFunctionFactory.Create((CancellationToken cancellationToken) => { cancellationToken.ThrowIfCancellationRequested(); return "result"; }, "TestFunction", "Test function description"); - var sentryFunction = new SentryInstrumentedFunction(testFunction); + var sentryFunction = new SentryInstrumentedFunction(testFunction, _fixture.Hub); var arguments = new AIFunctionArguments(); var cts = new CancellationTokenSource(); await cts.CancelAsync(); @@ -160,13 +191,15 @@ public async Task InvokeCoreAsync_WithCancellation_PropagatesCancellation() // Act & Assert await Assert.ThrowsAsync(async () => await sentryFunction.InvokeAsync(arguments, cts.Token)); + _fixture.Hub.Received(1).StartTransaction( + Arg.Any(), + Arg.Any>()); } [Fact] public async Task InvokeCoreAsync_WithParameters_PassesParametersCorrectly() { // Arrange - using var sentryDisposable = SentryHelpers.InitializeSdk(); var receivedArguments = (AIFunctionArguments?)null; var testFunction = AIFunctionFactory.Create((AIFunctionArguments args) => { @@ -174,8 +207,11 @@ public async Task InvokeCoreAsync_WithParameters_PassesParametersCorrectly() return "result"; }, "TestFunction", "Test function description"); - var sentryFunction = new SentryInstrumentedFunction(testFunction); - var arguments = new AIFunctionArguments { ["param1"] = "value1" }; + var sentryFunction = new SentryInstrumentedFunction(testFunction, _fixture.Hub); + var arguments = new AIFunctionArguments + { + ["param1"] = "value1" + }; // Act await sentryFunction.InvokeAsync(arguments); @@ -183,6 +219,9 @@ public async Task InvokeCoreAsync_WithParameters_PassesParametersCorrectly() // Assert Assert.NotNull(receivedArguments); Assert.Equal("value1", receivedArguments["param1"]); + _fixture.Hub.Received(1).StartTransaction( + Arg.Any(), + Arg.Any>()); } [Fact] @@ -192,23 +231,10 @@ public void Constructor_PreservesInnerFunctionProperties() var testFunction = AIFunctionFactory.Create(() => "test", "TestFunction", "Test function description"); // Act - var sentryFunction = new SentryInstrumentedFunction(testFunction); + var sentryFunction = new SentryInstrumentedFunction(testFunction, _fixture.Hub); // Assert Assert.Equal("TestFunction", sentryFunction.Name); Assert.Equal("Test function description", sentryFunction.Description); } } - -internal static class SentryHelpers -{ - public static IDisposable InitializeSdk() - { - return SentrySdk.Init(options => - { - options.Dsn = ValidDsn; - options.TracesSampleRate = 1.0; - options.Debug = false; - }); - } -} From 274f7e549e187bf62d9589aa3a0e7db5683c3ea0 Mon Sep 17 00:00:00 2001 From: Alex Sohn Date: Fri, 7 Nov 2025 10:07:18 -0500 Subject: [PATCH 67/86] update changelog.md --- CHANGELOG.md | 1 + 1 file changed, 1 insertion(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 4a01c89915..e3f2f69c33 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,6 +13,7 @@ - The SDK now makes use of the new SessionEndStatus `Unhandled` when capturing an unhandled but non-terminal exception, i.e. through the UnobservedTaskExceptionIntegration ([#4633](https://github.com/getsentry/sentry-dotnet/pull/4633), [#4653](https://github.com/getsentry/sentry-dotnet/pull/4653)) - The SDK now provides a `IsSessionActive` to allow checking the session state ([#4662](https://github.com/getsentry/sentry-dotnet/pull/4662)) - The SDK now makes use of the new SessionEndStatus `Unhandled` when capturing an unhandled but non-terminal exception, i.e. through the UnobservedTaskExceptionIntegration ([#4633](https://github.com/getsentry/sentry-dotnet/pull/4633)) +- Added a new SDK `Sentry.Extensions.AI` which allows LLM usage instrumentation via `Microsoft.Extensions.AI` ([#4657](https://github.com/getsentry/sentry-dotnet/pull/4657)) ### Fixes From 07a0111a0da89a90cf8657d71fd1accc8d4c27ca Mon Sep 17 00:00:00 2001 From: Alex Sohn Date: Fri, 7 Nov 2025 10:19:59 -0500 Subject: [PATCH 68/86] Make Hub in ActivityListener volatile --- src/Sentry.Extensions.AI/SentryAIActivityListener.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Sentry.Extensions.AI/SentryAIActivityListener.cs b/src/Sentry.Extensions.AI/SentryAIActivityListener.cs index 319c4f20ce..116ed4c171 100644 --- a/src/Sentry.Extensions.AI/SentryAIActivityListener.cs +++ b/src/Sentry.Extensions.AI/SentryAIActivityListener.cs @@ -7,7 +7,7 @@ namespace Sentry.Extensions.AI; /// internal static class SentryAIActivityListener { - private static IHub Hub = HubAdapter.Instance; + private static volatile IHub Hub = HubAdapter.Instance; /// /// Sentry's to tap into function invocation's Activity From d13640c950d21a01d01b12a38816e19768079982 Mon Sep 17 00:00:00 2001 From: Alex Sohn Date: Fri, 7 Nov 2025 10:50:17 -0500 Subject: [PATCH 69/86] Remove option to initialize SentrySDK using AIOptions --- .../Sentry.Samples.ME.AI.Console/Program.cs | 26 ++++---- src/Sentry.Extensions.AI/SentryAIOptions.cs | 11 +--- src/Sentry.Extensions.AI/SentryChatClient.cs | 6 -- .../SentryAIOptionsTests.cs | 59 ++----------------- 4 files changed, 21 insertions(+), 81 deletions(-) diff --git a/samples/Sentry.Samples.ME.AI.Console/Program.cs b/samples/Sentry.Samples.ME.AI.Console/Program.cs index 76a5b93934..a7d2b7589b 100644 --- a/samples/Sentry.Samples.ME.AI.Console/Program.cs +++ b/samples/Sentry.Samples.ME.AI.Console/Program.cs @@ -14,26 +14,28 @@ throw new InvalidOperationException($"Environment variable for OpenAI API key '{varName}' is not set."); } +// Initialize Sentry SDK +SentrySdk.Init(options => +{ +#if !SENTRY_DSN_DEFINED_IN_ENV + // A DSN is required. You can set here in code, or you can set it in the SENTRY_DSN environment variable. + // See https://docs.sentry.io/product/sentry-basics/dsn-explainer/ + options.Dsn = SamplesShared.Dsn; +#endif + options.Debug = true; + options.DiagnosticLevel = SentryLevel.Debug; + options.SampleRate = 1; + options.TracesSampleRate = 1; +}); + // Create OpenAI API client and wrap it with Sentry instrumentation var openAiClient = new OpenAI.Chat.ChatClient("gpt-4o-mini", openAiApiKey) .AsIChatClient() .AddSentry(options => { -#if !SENTRY_DSN_DEFINED_IN_ENV - // A DSN is required. You can set here in code, or you can set it in the SENTRY_DSN environment variable. - // See https://docs.sentry.io/product/sentry-basics/dsn-explainer/ - options.Dsn = SamplesShared.Dsn; -#endif - options.Debug = true; - options.DiagnosticLevel = SentryLevel.Debug; - options.SampleRate = 1; - options.TracesSampleRate = 1; - // AI-specific settings options.RecordInputs = true; options.RecordOutputs = true; - // Since this is a simple console app without Sentry already set up, we need to initialize our SDK - options.InitializeSdk = true; }); var client = new ChatClientBuilder(openAiClient) diff --git a/src/Sentry.Extensions.AI/SentryAIOptions.cs b/src/Sentry.Extensions.AI/SentryAIOptions.cs index b103fa1b08..8e447a733d 100644 --- a/src/Sentry.Extensions.AI/SentryAIOptions.cs +++ b/src/Sentry.Extensions.AI/SentryAIOptions.cs @@ -3,8 +3,7 @@ namespace Sentry.Extensions.AI; /// /// Sentry AI instrumentation options /// -/// -public class SentryAIOptions : SentryOptions +public class SentryAIOptions { /// /// Whether to include request messages in spans. @@ -20,12 +19,4 @@ public class SentryAIOptions : SentryOptions /// Name of the AI Agent /// public string AgentName { get; set; } = "Agent"; - - /// - /// Whether to initialize the Sentry SDK through this integration. - /// - /// - /// If you have already set up Sentry in your application, there is no need to re-initialize the Sentry SDK - /// - public bool InitializeSdk { get; set; } = false; } diff --git a/src/Sentry.Extensions.AI/SentryChatClient.cs b/src/Sentry.Extensions.AI/SentryChatClient.cs index 4d2645386e..df20dc7c58 100644 --- a/src/Sentry.Extensions.AI/SentryChatClient.cs +++ b/src/Sentry.Extensions.AI/SentryChatClient.cs @@ -11,12 +11,6 @@ public SentryChatClient(IChatClient client, Action? configure = { _sentryAIOptions = new SentryAIOptions(); configure?.Invoke(_sentryAIOptions); - - // If user requested to initialize the SDK, and SDK is not enabled already, then use the options to init Sentry - if (_sentryAIOptions.InitializeSdk && !SentrySdk.IsEnabled) - { - SentrySdk.Init(_sentryAIOptions); - } } /// diff --git a/test/Sentry.Extensions.AI.Tests/SentryAIOptionsTests.cs b/test/Sentry.Extensions.AI.Tests/SentryAIOptionsTests.cs index 73ee5a9b0c..54c62dea0d 100644 --- a/test/Sentry.Extensions.AI.Tests/SentryAIOptionsTests.cs +++ b/test/Sentry.Extensions.AI.Tests/SentryAIOptionsTests.cs @@ -13,7 +13,6 @@ public void Constructor_SetsDefaultValues() // Assert Assert.True(options.RecordInputs); Assert.True(options.RecordOutputs); - Assert.False(options.InitializeSdk); } [Fact] @@ -44,69 +43,23 @@ public void IncludeResponseContent_CanBeSet() Assert.False(options.RecordOutputs); } - [Fact] - public void InitializeSdk_CanBeSet() - { - // Arrange - var options = new SentryAIOptions(); - - // Act - options.InitializeSdk = true; - - // Assert - Assert.True(options.InitializeSdk); - } - - [Fact] - public void InheritsFromSentryOptions() - { - // Arrange & Act - var options = new SentryAIOptions(); - - // Assert - Assert.IsType(options, exactMatch: false); - } - - [Fact] - public void CanSetSentryOptionsProperties() - { - // Arrange - var options = new SentryAIOptions(); - - // Act - options.Dsn = "https://key@sentry.io/project"; - options.Environment = "test"; - options.Release = "1.0.0"; - - // Assert - Assert.Equal("https://key@sentry.io/project", options.Dsn); - Assert.Equal("test", options.Environment); - Assert.Equal("1.0.0", options.Release); - } - [Theory] - [InlineData(true, true, true)] - [InlineData(true, true, false)] - [InlineData(true, false, true)] - [InlineData(true, false, false)] - [InlineData(false, true, true)] - [InlineData(false, true, false)] - [InlineData(false, false, true)] - [InlineData(false, false, false)] - public void AllPropertyCombinations_WorkCorrectly(bool includeRequest, bool includeResponse, bool initializeSdk) + [InlineData(true, true)] + [InlineData(true, false)] + [InlineData(false, true)] + [InlineData(false, false)] + public void AllPropertyCombinations_WorkCorrectly(bool includeRequest, bool includeResponse) { // Arrange var options = new SentryAIOptions { // Act RecordInputs = includeRequest, - RecordOutputs = includeResponse, - InitializeSdk = initializeSdk + RecordOutputs = includeResponse }; // Assert Assert.Equal(includeRequest, options.RecordInputs); Assert.Equal(includeResponse, options.RecordOutputs); - Assert.Equal(initializeSdk, options.InitializeSdk); } } From 45735c59a2f6694228a6154b466ea64b5ad0f2da Mon Sep 17 00:00:00 2001 From: Alex Sohn Date: Fri, 7 Nov 2025 11:49:34 -0500 Subject: [PATCH 70/86] Mark AI Options as experimental --- .../Program.cs | 5 +-- .../Sentry.Samples.ME.AI.Console/Program.cs | 5 +-- .../Extensions/SentryAIExtensions.cs | 16 +++++-- src/Sentry.Extensions.AI/SentryAIOptions.cs | 44 +++++++++++++++---- .../SentryAISpanEnricher.cs | 6 +-- .../SentryAIExtensionsTests.cs | 4 +- .../SentryAIOptionsTests.cs | 29 +++++++----- .../SentryAISpanEnricherTests.cs | 19 +++++--- 8 files changed, 91 insertions(+), 37 deletions(-) diff --git a/samples/Sentry.Samples.ME.AI.AspNetCore/Program.cs b/samples/Sentry.Samples.ME.AI.AspNetCore/Program.cs index 3f7154a277..e93bcc75bd 100644 --- a/samples/Sentry.Samples.ME.AI.AspNetCore/Program.cs +++ b/samples/Sentry.Samples.ME.AI.AspNetCore/Program.cs @@ -1,6 +1,5 @@ #nullable enable using Microsoft.Extensions.AI; -using Sentry.Extensions.AI; var builder = WebApplication.CreateBuilder(args); @@ -33,8 +32,8 @@ .AddSentry(options => { // In this case, we already initialized Sentry from ASP.NET WebHost creation, we don't need to initialize - options.RecordInputs = true; - options.RecordOutputs = true; + options.Experimental.RecordInputs = true; + options.Experimental.RecordOutputs = true; }); var client = new ChatClientBuilder(openAiClient) diff --git a/samples/Sentry.Samples.ME.AI.Console/Program.cs b/samples/Sentry.Samples.ME.AI.Console/Program.cs index a7d2b7589b..03883b3663 100644 --- a/samples/Sentry.Samples.ME.AI.Console/Program.cs +++ b/samples/Sentry.Samples.ME.AI.Console/Program.cs @@ -1,6 +1,5 @@ using Microsoft.Extensions.AI; using Microsoft.Extensions.Logging; -using Sentry.Extensions.AI; using var loggerFactory = LoggerFactory.Create(builder => builder.AddConsole()); var logger = loggerFactory.CreateLogger(); @@ -34,8 +33,8 @@ .AddSentry(options => { // AI-specific settings - options.RecordInputs = true; - options.RecordOutputs = true; + options.Experimental.RecordInputs = true; + options.Experimental.RecordOutputs = true; }); var client = new ChatClientBuilder(openAiClient) diff --git a/src/Sentry.Extensions.AI/Extensions/SentryAIExtensions.cs b/src/Sentry.Extensions.AI/Extensions/SentryAIExtensions.cs index 2cc5ce9d0a..536e46a224 100644 --- a/src/Sentry.Extensions.AI/Extensions/SentryAIExtensions.cs +++ b/src/Sentry.Extensions.AI/Extensions/SentryAIExtensions.cs @@ -1,4 +1,5 @@ using Sentry.Extensions.AI; +using Sentry.Infrastructure; namespace Microsoft.Extensions.AI; @@ -11,7 +12,11 @@ public static class SentryAIExtensions /// /// Wrap tool calls specified in with Sentry agent instrumentation /// + /// + /// This API is experimental, and it may change in the future. + /// /// The that contains the to instrument + [Experimental(DiagnosticId.ExperimentalFeature)] public static ChatOptions AddSentryToolInstrumentation(this ChatOptions options) { if (options.Tools is { Count: 0 }) @@ -34,13 +39,18 @@ public static ChatOptions AddSentryToolInstrumentation(this ChatOptions options) /// Wraps an IChatClient with Sentry agent instrumentation. /// /// - /// This method can be used either with an existing Sentry setup or as a standalone integration. - /// If Sentry is already initialized, it will use the existing configuration. - /// If not, it will initialize Sentry with the provided options. + /// + /// This method has to be used with an existing Sentry setup. You need to initialize SentrySDK explicitly for + /// AI Agent monitoring to work properly. + /// + /// + /// This API is experimental, and it may change in the future. + /// /// /// The to be instrumented /// The configuration /// The instrumented + [Experimental(DiagnosticId.ExperimentalFeature)] public static IChatClient AddSentry(this IChatClient client, Action? configure = null) { SentryAIActivityListener.Init(); diff --git a/src/Sentry.Extensions.AI/SentryAIOptions.cs b/src/Sentry.Extensions.AI/SentryAIOptions.cs index 8e447a733d..3400cd04fa 100644 --- a/src/Sentry.Extensions.AI/SentryAIOptions.cs +++ b/src/Sentry.Extensions.AI/SentryAIOptions.cs @@ -1,3 +1,5 @@ +using Sentry.Infrastructure; + namespace Sentry.Extensions.AI; /// @@ -6,17 +8,43 @@ namespace Sentry.Extensions.AI; public class SentryAIOptions { /// - /// Whether to include request messages in spans. + /// Experimental Sentry AI features. /// - public bool RecordInputs { get; set; } = true; + /// + /// This and related experimental APIs may change in the future. + /// + [Experimental(DiagnosticId.ExperimentalFeature)] + public SentryAIExperimentalOptions Experimental { get; set; } = new(); /// - /// Whether to include response content in spans. + /// Experimental Sentry AI options. /// - public bool RecordOutputs { get; set; } = true; + /// + /// This and related experimental APIs may change in the future. + /// + [Experimental(DiagnosticId.ExperimentalFeature)] + public sealed class SentryAIExperimentalOptions + { + internal SentryAIExperimentalOptions() + { + } - /// - /// Name of the AI Agent - /// - public string AgentName { get; set; } = "Agent"; + /// + /// Whether to include request messages in spans. + /// This API is experimental, and it may change in the future. + /// + public bool RecordInputs { get; set; } = true; + + /// + /// Whether to include response content in spans. + /// This API is experimental, and it may change in the future. + /// + public bool RecordOutputs { get; set; } = true; + + /// + /// Name of the AI Agent + /// This API is experimental, and it may change in the future. + /// + public string AgentName { get; set; } = "Agent"; + } } diff --git a/src/Sentry.Extensions.AI/SentryAISpanEnricher.cs b/src/Sentry.Extensions.AI/SentryAISpanEnricher.cs index 71456886bc..139bf03a11 100644 --- a/src/Sentry.Extensions.AI/SentryAISpanEnricher.cs +++ b/src/Sentry.Extensions.AI/SentryAISpanEnricher.cs @@ -22,13 +22,13 @@ internal static void EnrichWithRequest(ISpan span, ChatMessage[] messages, ChatO span.SetData(SentryAIConstants.SpanAttributes.RequestModel, modelId); } - if (aiOptions.AgentName is { } agentName) + if (aiOptions.Experimental.AgentName is { } agentName) { span.SetData(SentryAIConstants.SpanAttributes.AgentName, agentName); } if (messages is { Length: > 0 } - && aiOptions.RecordInputs + && aiOptions.Experimental.RecordInputs // Only add request messages if there is none currently && !span.Data.ContainsKey(SentryAIConstants.SpanAttributes.RequestMessages)) { @@ -123,7 +123,7 @@ internal static void EnrichWithStreamingResponses(ISpan span, List { configureWasCalled = true; - options.RecordInputs = false; - options.RecordOutputs = false; + options.Experimental.RecordInputs = false; + options.Experimental.RecordOutputs = false; } ); diff --git a/test/Sentry.Extensions.AI.Tests/SentryAIOptionsTests.cs b/test/Sentry.Extensions.AI.Tests/SentryAIOptionsTests.cs index 54c62dea0d..54d013913f 100644 --- a/test/Sentry.Extensions.AI.Tests/SentryAIOptionsTests.cs +++ b/test/Sentry.Extensions.AI.Tests/SentryAIOptionsTests.cs @@ -11,8 +11,8 @@ public void Constructor_SetsDefaultValues() var options = new SentryAIOptions(); // Assert - Assert.True(options.RecordInputs); - Assert.True(options.RecordOutputs); + Assert.True(options.Experimental.RecordInputs); + Assert.True(options.Experimental.RecordOutputs); } [Fact] @@ -22,11 +22,14 @@ public void IncludeRequestMessages_CanBeSet() var options = new SentryAIOptions { // Act - RecordInputs = false + Experimental = + { + RecordInputs = false + } }; // Assert - Assert.False(options.RecordInputs); + Assert.False(options.Experimental.RecordInputs); } [Fact] @@ -36,11 +39,14 @@ public void IncludeResponseContent_CanBeSet() var options = new SentryAIOptions { // Act - RecordOutputs = false + Experimental = + { + RecordOutputs = false + } }; // Assert - Assert.False(options.RecordOutputs); + Assert.False(options.Experimental.RecordOutputs); } [Theory] @@ -54,12 +60,15 @@ public void AllPropertyCombinations_WorkCorrectly(bool includeRequest, bool incl var options = new SentryAIOptions { // Act - RecordInputs = includeRequest, - RecordOutputs = includeResponse + Experimental = + { + RecordInputs = includeRequest, + RecordOutputs = includeResponse + } }; // Assert - Assert.Equal(includeRequest, options.RecordInputs); - Assert.Equal(includeResponse, options.RecordOutputs); + Assert.Equal(includeRequest, options.Experimental.RecordInputs); + Assert.Equal(includeResponse, options.Experimental.RecordOutputs); } } diff --git a/test/Sentry.Extensions.AI.Tests/SentryAISpanEnricherTests.cs b/test/Sentry.Extensions.AI.Tests/SentryAISpanEnricherTests.cs index 649a2a3399..5cd12bc00d 100644 --- a/test/Sentry.Extensions.AI.Tests/SentryAISpanEnricherTests.cs +++ b/test/Sentry.Extensions.AI.Tests/SentryAISpanEnricherTests.cs @@ -88,7 +88,10 @@ public void EnrichWithRequest_SetsData_WithoutRequestMessages_WhenDisabled() var chatOptions = TestChatOptions(); var aiOptions = new SentryAIOptions() { - RecordInputs = false + Experimental = + { + RecordInputs = false + } }; // Act @@ -117,7 +120,10 @@ public void EnrichWithRequest_SetsBasicData_WhenChatOptionsNull() var messages = TestMessages(); var aiOptions = new SentryAIOptions() { - RecordInputs = false + Experimental = + { + RecordInputs = false + } }; // Act @@ -187,7 +193,10 @@ public void EnrichWithResponse_SetsData_WithoutResponseMessages_WhenDisabled() }; var aiOptions = new SentryAIOptions() { - RecordOutputs = false + Experimental = + { + RecordOutputs = false + } }; // Act @@ -232,7 +241,7 @@ public void EnrichWithStreamingResponses_SetsData() } }; - var aiOptions = new SentryAIOptions { RecordOutputs = true }; + var aiOptions = new SentryAIOptions { Experimental = { RecordOutputs = true } }; // Act SentryAISpanEnricher.EnrichWithStreamingResponses(span, streamingMessages, aiOptions); @@ -266,7 +275,7 @@ public void EnrichWithStreamingResponses_SetsData_WithoutResponseContent_WhenDis } }; - var aiOptions = new SentryAIOptions { RecordOutputs = false }; + var aiOptions = new SentryAIOptions { Experimental = { RecordOutputs = false } }; // Act SentryAISpanEnricher.EnrichWithStreamingResponses(span, streamingMessages, aiOptions); From cffccf937058ed5de0c1e65fa33e7d2ef559e108 Mon Sep 17 00:00:00 2001 From: Alex Sohn Date: Fri, 7 Nov 2025 12:04:53 -0500 Subject: [PATCH 71/86] Add ApiApprovalTests for public facing methods --- .../Sentry.Extensions.AI.csproj | 2 +- ...iApprovalTests.Run.DotNet10_0.verified.txt | 26 +++++++++++++++++++ ...piApprovalTests.Run.DotNet8_0.verified.txt | 26 +++++++++++++++++++ ...piApprovalTests.Run.DotNet9_0.verified.txt | 26 +++++++++++++++++++ .../ApiApprovalTests.verify.cs | 12 +++++++++ .../Sentry.Extensions.AI.Tests.csproj | 2 +- 6 files changed, 92 insertions(+), 2 deletions(-) create mode 100644 test/Sentry.Extensions.AI.Tests/ApiApprovalTests.Run.DotNet10_0.verified.txt create mode 100644 test/Sentry.Extensions.AI.Tests/ApiApprovalTests.Run.DotNet8_0.verified.txt create mode 100644 test/Sentry.Extensions.AI.Tests/ApiApprovalTests.Run.DotNet9_0.verified.txt create mode 100644 test/Sentry.Extensions.AI.Tests/ApiApprovalTests.verify.cs diff --git a/src/Sentry.Extensions.AI/Sentry.Extensions.AI.csproj b/src/Sentry.Extensions.AI/Sentry.Extensions.AI.csproj index 7c29b8b8d6..b1d76c0c4c 100644 --- a/src/Sentry.Extensions.AI/Sentry.Extensions.AI.csproj +++ b/src/Sentry.Extensions.AI/Sentry.Extensions.AI.csproj @@ -1,7 +1,7 @@  - net9.0;net8.0;netstandard2.0 + $(CurrentTfms);netstandard2.0 $(PackageTags);Microsoft.Extensions.AI;AI;LLM Microsoft.Extensions.AI integration for Sentry - captures AI Agent telemetry spans per Sentry AI Agents module. diff --git a/test/Sentry.Extensions.AI.Tests/ApiApprovalTests.Run.DotNet10_0.verified.txt b/test/Sentry.Extensions.AI.Tests/ApiApprovalTests.Run.DotNet10_0.verified.txt new file mode 100644 index 0000000000..6d963b1dbb --- /dev/null +++ b/test/Sentry.Extensions.AI.Tests/ApiApprovalTests.Run.DotNet10_0.verified.txt @@ -0,0 +1,26 @@ +namespace Microsoft.Extensions.AI +{ + public static class SentryAIExtensions + { + [System.Diagnostics.CodeAnalysis.Experimental("SENTRY0001")] + public static Microsoft.Extensions.AI.IChatClient AddSentry(this Microsoft.Extensions.AI.IChatClient client, System.Action? configure = null) { } + [System.Diagnostics.CodeAnalysis.Experimental("SENTRY0001")] + public static Microsoft.Extensions.AI.ChatOptions AddSentryToolInstrumentation(this Microsoft.Extensions.AI.ChatOptions options) { } + } +} +namespace Sentry.Extensions.AI +{ + public class SentryAIOptions + { + public SentryAIOptions() { } + [System.Diagnostics.CodeAnalysis.Experimental("SENTRY0001")] + public Sentry.Extensions.AI.SentryAIOptions.SentryAIExperimentalOptions Experimental { get; set; } + [System.Diagnostics.CodeAnalysis.Experimental("SENTRY0001")] + public sealed class SentryAIExperimentalOptions + { + public string AgentName { get; set; } + public bool RecordInputs { get; set; } + public bool RecordOutputs { get; set; } + } + } +} \ No newline at end of file diff --git a/test/Sentry.Extensions.AI.Tests/ApiApprovalTests.Run.DotNet8_0.verified.txt b/test/Sentry.Extensions.AI.Tests/ApiApprovalTests.Run.DotNet8_0.verified.txt new file mode 100644 index 0000000000..6d963b1dbb --- /dev/null +++ b/test/Sentry.Extensions.AI.Tests/ApiApprovalTests.Run.DotNet8_0.verified.txt @@ -0,0 +1,26 @@ +namespace Microsoft.Extensions.AI +{ + public static class SentryAIExtensions + { + [System.Diagnostics.CodeAnalysis.Experimental("SENTRY0001")] + public static Microsoft.Extensions.AI.IChatClient AddSentry(this Microsoft.Extensions.AI.IChatClient client, System.Action? configure = null) { } + [System.Diagnostics.CodeAnalysis.Experimental("SENTRY0001")] + public static Microsoft.Extensions.AI.ChatOptions AddSentryToolInstrumentation(this Microsoft.Extensions.AI.ChatOptions options) { } + } +} +namespace Sentry.Extensions.AI +{ + public class SentryAIOptions + { + public SentryAIOptions() { } + [System.Diagnostics.CodeAnalysis.Experimental("SENTRY0001")] + public Sentry.Extensions.AI.SentryAIOptions.SentryAIExperimentalOptions Experimental { get; set; } + [System.Diagnostics.CodeAnalysis.Experimental("SENTRY0001")] + public sealed class SentryAIExperimentalOptions + { + public string AgentName { get; set; } + public bool RecordInputs { get; set; } + public bool RecordOutputs { get; set; } + } + } +} \ No newline at end of file diff --git a/test/Sentry.Extensions.AI.Tests/ApiApprovalTests.Run.DotNet9_0.verified.txt b/test/Sentry.Extensions.AI.Tests/ApiApprovalTests.Run.DotNet9_0.verified.txt new file mode 100644 index 0000000000..6d963b1dbb --- /dev/null +++ b/test/Sentry.Extensions.AI.Tests/ApiApprovalTests.Run.DotNet9_0.verified.txt @@ -0,0 +1,26 @@ +namespace Microsoft.Extensions.AI +{ + public static class SentryAIExtensions + { + [System.Diagnostics.CodeAnalysis.Experimental("SENTRY0001")] + public static Microsoft.Extensions.AI.IChatClient AddSentry(this Microsoft.Extensions.AI.IChatClient client, System.Action? configure = null) { } + [System.Diagnostics.CodeAnalysis.Experimental("SENTRY0001")] + public static Microsoft.Extensions.AI.ChatOptions AddSentryToolInstrumentation(this Microsoft.Extensions.AI.ChatOptions options) { } + } +} +namespace Sentry.Extensions.AI +{ + public class SentryAIOptions + { + public SentryAIOptions() { } + [System.Diagnostics.CodeAnalysis.Experimental("SENTRY0001")] + public Sentry.Extensions.AI.SentryAIOptions.SentryAIExperimentalOptions Experimental { get; set; } + [System.Diagnostics.CodeAnalysis.Experimental("SENTRY0001")] + public sealed class SentryAIExperimentalOptions + { + public string AgentName { get; set; } + public bool RecordInputs { get; set; } + public bool RecordOutputs { get; set; } + } + } +} \ No newline at end of file diff --git a/test/Sentry.Extensions.AI.Tests/ApiApprovalTests.verify.cs b/test/Sentry.Extensions.AI.Tests/ApiApprovalTests.verify.cs new file mode 100644 index 0000000000..9dbbb3dd23 --- /dev/null +++ b/test/Sentry.Extensions.AI.Tests/ApiApprovalTests.verify.cs @@ -0,0 +1,12 @@ +using Microsoft.Extensions.AI; + +namespace Sentry.Extensions.AI.Tests; + +public class ApiApprovalTests +{ + [Fact] + public Task Run() + { + return typeof(SentryAIExtensions).Assembly.CheckApproval(); + } +} diff --git a/test/Sentry.Extensions.AI.Tests/Sentry.Extensions.AI.Tests.csproj b/test/Sentry.Extensions.AI.Tests/Sentry.Extensions.AI.Tests.csproj index 9ddae185e0..8bb05ffc4c 100644 --- a/test/Sentry.Extensions.AI.Tests/Sentry.Extensions.AI.Tests.csproj +++ b/test/Sentry.Extensions.AI.Tests/Sentry.Extensions.AI.Tests.csproj @@ -1,7 +1,7 @@  - net9.0;net8.0 + $(CurrentTfms) From 564b4131869f7fecab9f35b066259ec0332b8694 Mon Sep 17 00:00:00 2001 From: Alex Sohn Date: Fri, 7 Nov 2025 16:33:49 -0500 Subject: [PATCH 72/86] remove unused constructor --- src/Sentry.Extensions.AI/SentryAIOptions.cs | 4 ---- 1 file changed, 4 deletions(-) diff --git a/src/Sentry.Extensions.AI/SentryAIOptions.cs b/src/Sentry.Extensions.AI/SentryAIOptions.cs index 3400cd04fa..c653da85af 100644 --- a/src/Sentry.Extensions.AI/SentryAIOptions.cs +++ b/src/Sentry.Extensions.AI/SentryAIOptions.cs @@ -25,10 +25,6 @@ public class SentryAIOptions [Experimental(DiagnosticId.ExperimentalFeature)] public sealed class SentryAIExperimentalOptions { - internal SentryAIExperimentalOptions() - { - } - /// /// Whether to include request messages in spans. /// This API is experimental, and it may change in the future. From bbf4fe6c03e4bbbc56b1c34afaa509458ce5db66 Mon Sep 17 00:00:00 2001 From: Alex Sohn Date: Fri, 7 Nov 2025 16:34:07 -0500 Subject: [PATCH 73/86] re-generate solution filters --- .generated.NoMobile.sln | 78 +++++++++++++++++++++++++++++++++++++++++ 1 file changed, 78 insertions(+) diff --git a/.generated.NoMobile.sln b/.generated.NoMobile.sln index 4dd8dc1c11..57df54c485 100644 --- a/.generated.NoMobile.sln +++ b/.generated.NoMobile.sln @@ -209,6 +209,77 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Sentry.SourceGenerators.Tes EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Sentry.Maui.CommunityToolkit.Mvvm.Tests", "test\Sentry.Maui.CommunityToolkit.Mvvm.Tests\Sentry.Maui.CommunityToolkit.Mvvm.Tests.csproj", "{ADC91A84-6054-42EC-8241-0D717E4C7194}" EndProject +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "scripts", "scripts", "{A8A97D6A-02C0-4808-9D62-DFFAB324A323}" + ProjectSection(SolutionItems) = preProject + scripts\update-cli.ps1 = scripts\update-cli.ps1 + scripts\bump-version.sh = scripts\bump-version.sh + scripts\device-test.ps1 = scripts\device-test.ps1 + scripts\dirty-check.ps1 = scripts\dirty-check.ps1 + scripts\update-java.ps1 = scripts\update-java.ps1 + scripts\bump-version.ps1 = scripts\bump-version.ps1 + scripts\parse-xunit2-xml.ps1 = scripts\parse-xunit2-xml.ps1 + scripts\build-sentry-cocoa.sh = scripts\build-sentry-cocoa.sh + scripts\update-project-xml.ps1 = scripts\update-project-xml.ps1 + scripts\build-sentry-native.ps1 = scripts\build-sentry-native.ps1 + scripts\ios-simulator-utils.ps1 = scripts\ios-simulator-utils.ps1 + scripts\commit-formatted-code.sh = scripts\commit-formatted-code.sh + scripts\accept-verifier-changes.ps1 = scripts\accept-verifier-changes.ps1 + scripts\generate-cocoa-bindings.ps1 = scripts\generate-cocoa-bindings.ps1 + scripts\generate-solution-filters.ps1 = scripts\generate-solution-filters.ps1 + scripts\generate-solution-filters-config.yaml = scripts\generate-solution-filters-config.yaml + EndProjectSection +EndProject +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = ".github", ".github", "{EC6ADE8A-E557-4848-8F03-519039830B5F}" +EndProject +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "workflows", "workflows", "{BFF081D8-7CC0-4069-99F5-5CA0D70B56AB}" + ProjectSection(SolutionItems) = preProject + .github\workflows\build.yml = .github\workflows\build.yml + .github\workflows\alpine.yml = .github\workflows\alpine.yml + .github\workflows\danger.yml = .github\workflows\danger.yml + .github\workflows\release.yml = .github\workflows\release.yml + .github\workflows\format-code.yml = .github\workflows\format-code.yml + .github\workflows\update-deps.yml = .github\workflows\update-deps.yml + .github\workflows\codeql-analysis.yml = .github\workflows\codeql-analysis.yml + .github\workflows\vulnerabilities.yml = .github\workflows\vulnerabilities.yml + .github\workflows\device-tests-ios.yml = .github\workflows\device-tests-ios.yml + .github\workflows\device-tests-android.yml = .github\workflows\device-tests-android.yml + EndProjectSection +EndProject +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "actions", "actions", "{5D50D425-244F-4B79-B9F5-21D26DD52DC1}" +EndProject +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "environment", "environment", "{39216438-F347-427C-AB70-48DB1BA6E299}" + ProjectSection(SolutionItems) = preProject + .github\actions\environment\action.yml = .github\actions\environment\action.yml + EndProjectSection +EndProject +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "buildnative", "buildnative", "{A384A71C-A46F-49DB-B7FB-5DEEFC5E6CA3}" + ProjectSection(SolutionItems) = preProject + .github\actions\buildnative\action.yml = .github\actions\buildnative\action.yml + EndProjectSection +EndProject +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "freediskspace", "freediskspace", "{E34AA22F-B42E-4D4C-B96E-426AEBC2F367}" + ProjectSection(SolutionItems) = preProject + .github\actions\freediskspace\action.yml = .github\actions\freediskspace\action.yml + EndProjectSection +EndProject +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "install-zstd", "install-zstd", "{94A2DCA5-F298-41FB-913A-476668EF5786}" + ProjectSection(SolutionItems) = preProject + .github\actions\install-zstd\action.yml = .github\actions\install-zstd\action.yml + EndProjectSection +EndProject +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "integration-test", "integration-test", "{94CCDBEF-5867-4C24-A305-0C2AE738AF42}" + ProjectSection(SolutionItems) = preProject + integration-test\common.ps1 = integration-test\common.ps1 + integration-test\aot.Tests.ps1 = integration-test\aot.Tests.ps1 + integration-test\cli.Tests.ps1 = integration-test\cli.Tests.ps1 + integration-test\runtime.Tests.ps1 = integration-test\runtime.Tests.ps1 + integration-test\pester.ps1 = integration-test\pester.ps1 + integration-test\ios.Tests.ps1 = integration-test\ios.Tests.ps1 + integration-test\msbuild.Tests.ps1 = integration-test\msbuild.Tests.ps1 + EndProjectSection +EndProject +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "net4-console", "net4-console", "{33793113-C7B5-434D-B5C1-6CA1A9587842}" +EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Sentry.Samples.ME.AI.Console", "samples\Sentry.Samples.ME.AI.Console\Sentry.Samples.ME.AI.Console.csproj", "{CE591593-E51C-498E-BC0D-5083C8197544}" EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Sentry.Samples.ME.AI.AspNetCore", "samples\Sentry.Samples.ME.AI.AspNetCore\Sentry.Samples.ME.AI.AspNetCore.csproj", "{3D91128A-5695-4BAE-B939-5F5F7C56A679}" @@ -1399,6 +1470,13 @@ Global {C3CDF61C-3E28-441C-A9CE-011C89D11719} = {230B9384-90FD-4551-A5DE-1A5C197F25B6} {3A76FF7D-2F32-4EA5-8999-2FFE3C7CB893} = {6987A1CC-608E-4868-A02C-09D30C8B7B2D} {ADC91A84-6054-42EC-8241-0D717E4C7194} = {6987A1CC-608E-4868-A02C-09D30C8B7B2D} + {BFF081D8-7CC0-4069-99F5-5CA0D70B56AB} = {EC6ADE8A-E557-4848-8F03-519039830B5F} + {5D50D425-244F-4B79-B9F5-21D26DD52DC1} = {EC6ADE8A-E557-4848-8F03-519039830B5F} + {39216438-F347-427C-AB70-48DB1BA6E299} = {5D50D425-244F-4B79-B9F5-21D26DD52DC1} + {A384A71C-A46F-49DB-B7FB-5DEEFC5E6CA3} = {5D50D425-244F-4B79-B9F5-21D26DD52DC1} + {E34AA22F-B42E-4D4C-B96E-426AEBC2F367} = {5D50D425-244F-4B79-B9F5-21D26DD52DC1} + {94A2DCA5-F298-41FB-913A-476668EF5786} = {5D50D425-244F-4B79-B9F5-21D26DD52DC1} + {33793113-C7B5-434D-B5C1-6CA1A9587842} = {94CCDBEF-5867-4C24-A305-0C2AE738AF42} {CE591593-E51C-498E-BC0D-5083C8197544} = {21B42F60-5802-404E-90F0-AEBCC56760C0} {3D91128A-5695-4BAE-B939-5F5F7C56A679} = {21B42F60-5802-404E-90F0-AEBCC56760C0} {AE461926-00B8-4FDD-A41D-3C83C2D9A045} = {230B9384-90FD-4551-A5DE-1A5C197F25B6} From dda35c9315aa461bda1b77069731c063ec4ea816 Mon Sep 17 00:00:00 2001 From: Alex Sohn Date: Fri, 7 Nov 2025 16:51:47 -0500 Subject: [PATCH 74/86] add ApiApprovalTests results --- .../ApiApprovalTests.Run.DotNet10_0.verified.txt | 1 + .../ApiApprovalTests.Run.DotNet8_0.verified.txt | 1 + .../ApiApprovalTests.Run.DotNet9_0.verified.txt | 1 + 3 files changed, 3 insertions(+) diff --git a/test/Sentry.Extensions.AI.Tests/ApiApprovalTests.Run.DotNet10_0.verified.txt b/test/Sentry.Extensions.AI.Tests/ApiApprovalTests.Run.DotNet10_0.verified.txt index 6d963b1dbb..6624f53dc2 100644 --- a/test/Sentry.Extensions.AI.Tests/ApiApprovalTests.Run.DotNet10_0.verified.txt +++ b/test/Sentry.Extensions.AI.Tests/ApiApprovalTests.Run.DotNet10_0.verified.txt @@ -18,6 +18,7 @@ namespace Sentry.Extensions.AI [System.Diagnostics.CodeAnalysis.Experimental("SENTRY0001")] public sealed class SentryAIExperimentalOptions { + public SentryAIExperimentalOptions() { } public string AgentName { get; set; } public bool RecordInputs { get; set; } public bool RecordOutputs { get; set; } diff --git a/test/Sentry.Extensions.AI.Tests/ApiApprovalTests.Run.DotNet8_0.verified.txt b/test/Sentry.Extensions.AI.Tests/ApiApprovalTests.Run.DotNet8_0.verified.txt index 6d963b1dbb..6624f53dc2 100644 --- a/test/Sentry.Extensions.AI.Tests/ApiApprovalTests.Run.DotNet8_0.verified.txt +++ b/test/Sentry.Extensions.AI.Tests/ApiApprovalTests.Run.DotNet8_0.verified.txt @@ -18,6 +18,7 @@ namespace Sentry.Extensions.AI [System.Diagnostics.CodeAnalysis.Experimental("SENTRY0001")] public sealed class SentryAIExperimentalOptions { + public SentryAIExperimentalOptions() { } public string AgentName { get; set; } public bool RecordInputs { get; set; } public bool RecordOutputs { get; set; } diff --git a/test/Sentry.Extensions.AI.Tests/ApiApprovalTests.Run.DotNet9_0.verified.txt b/test/Sentry.Extensions.AI.Tests/ApiApprovalTests.Run.DotNet9_0.verified.txt index 6d963b1dbb..6624f53dc2 100644 --- a/test/Sentry.Extensions.AI.Tests/ApiApprovalTests.Run.DotNet9_0.verified.txt +++ b/test/Sentry.Extensions.AI.Tests/ApiApprovalTests.Run.DotNet9_0.verified.txt @@ -18,6 +18,7 @@ namespace Sentry.Extensions.AI [System.Diagnostics.CodeAnalysis.Experimental("SENTRY0001")] public sealed class SentryAIExperimentalOptions { + public SentryAIExperimentalOptions() { } public string AgentName { get; set; } public bool RecordInputs { get; set; } public bool RecordOutputs { get; set; } From ed585229e8465e7997c0dcf9744158573592a2d4 Mon Sep 17 00:00:00 2001 From: Alex Sohn Date: Mon, 10 Nov 2025 09:49:13 -0500 Subject: [PATCH 75/86] Generate solution filters --- Sentry-CI-Build-Windows-arm64.slnf | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Sentry-CI-Build-Windows-arm64.slnf b/Sentry-CI-Build-Windows-arm64.slnf index a144bb42b3..cec5346daf 100644 --- a/Sentry-CI-Build-Windows-arm64.slnf +++ b/Sentry-CI-Build-Windows-arm64.slnf @@ -63,8 +63,8 @@ "test\\Sentry.AspNetCore.Tests\\Sentry.AspNetCore.Tests.csproj", "test\\Sentry.AspNetCore.TestUtils\\Sentry.AspNetCore.TestUtils.csproj", "test\\Sentry.Azure.Functions.Worker.Tests\\Sentry.Azure.Functions.Worker.Tests.csproj", - "test\\Sentry.Extensions.AI.Tests\\Sentry.Extensions.AI.Tests.csproj", "test\\Sentry.Compiler.Extensions.Tests\\Sentry.Compiler.Extensions.Tests.csproj", + "test\\Sentry.Extensions.AI.Tests\\Sentry.Extensions.AI.Tests.csproj", "test\\Sentry.Extensions.Logging.Tests\\Sentry.Extensions.Logging.Tests.csproj", "test\\Sentry.Google.Cloud.Functions.Tests\\Sentry.Google.Cloud.Functions.Tests.csproj", "test\\Sentry.Hangfire.Tests\\Sentry.Hangfire.Tests.csproj", From f3872c85e22d656fd1b9720fb78c3aa8d2303a81 Mon Sep 17 00:00:00 2001 From: Alex Sohn Date: Mon, 10 Nov 2025 10:16:46 -0500 Subject: [PATCH 76/86] Change Logging option in ASP.NET Core app --- samples/Sentry.Samples.ME.AI.AspNetCore/Program.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/samples/Sentry.Samples.ME.AI.AspNetCore/Program.cs b/samples/Sentry.Samples.ME.AI.AspNetCore/Program.cs index e93bcc75bd..a4c3448c60 100644 --- a/samples/Sentry.Samples.ME.AI.AspNetCore/Program.cs +++ b/samples/Sentry.Samples.ME.AI.AspNetCore/Program.cs @@ -15,7 +15,7 @@ options.DiagnosticLevel = SentryLevel.Debug; options.SampleRate = 1; options.TracesSampleRate = 1.0; - options.Experimental.EnableLogs = true; + options.EnableLogs = true; }); // This sample uses Microsoft.Extensions.AI.OpenAI From ac9f7ceb1f22f3e0d325f7314f830c596cbb4018 Mon Sep 17 00:00:00 2001 From: Alex Sohn Date: Mon, 10 Nov 2025 19:33:40 -0500 Subject: [PATCH 77/86] Improve early exit conditional in SentryExtensions --- src/Sentry.Extensions.AI/Extensions/SentryAIExtensions.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Sentry.Extensions.AI/Extensions/SentryAIExtensions.cs b/src/Sentry.Extensions.AI/Extensions/SentryAIExtensions.cs index 536e46a224..dd991a8372 100644 --- a/src/Sentry.Extensions.AI/Extensions/SentryAIExtensions.cs +++ b/src/Sentry.Extensions.AI/Extensions/SentryAIExtensions.cs @@ -19,7 +19,7 @@ public static class SentryAIExtensions [Experimental(DiagnosticId.ExperimentalFeature)] public static ChatOptions AddSentryToolInstrumentation(this ChatOptions options) { - if (options.Tools is { Count: 0 }) + if (options.Tools is not { Count: > 0 }) { return options; } From b99c53f255c2e60b1298dad22d40165798efd0a9 Mon Sep 17 00:00:00 2001 From: Alex Sohn Date: Wed, 12 Nov 2025 09:33:38 -0500 Subject: [PATCH 78/86] Remove outdated comment from AspNetCore example --- samples/Sentry.Samples.ME.AI.AspNetCore/Program.cs | 1 - 1 file changed, 1 deletion(-) diff --git a/samples/Sentry.Samples.ME.AI.AspNetCore/Program.cs b/samples/Sentry.Samples.ME.AI.AspNetCore/Program.cs index a4c3448c60..83f8731f28 100644 --- a/samples/Sentry.Samples.ME.AI.AspNetCore/Program.cs +++ b/samples/Sentry.Samples.ME.AI.AspNetCore/Program.cs @@ -31,7 +31,6 @@ .AsIChatClient() .AddSentry(options => { - // In this case, we already initialized Sentry from ASP.NET WebHost creation, we don't need to initialize options.Experimental.RecordInputs = true; options.Experimental.RecordOutputs = true; }); From 5745c0a4e6f655e0a6020956c07e0ceeab0ae45a Mon Sep 17 00:00:00 2001 From: Alex Sohn Date: Wed, 12 Nov 2025 09:34:20 -0500 Subject: [PATCH 79/86] Replace description for FICC Activity name with a link --- src/Sentry.Extensions.AI/SentryAIConstants.cs | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/src/Sentry.Extensions.AI/SentryAIConstants.cs b/src/Sentry.Extensions.AI/SentryAIConstants.cs index f3f47f817a..95769132e5 100644 --- a/src/Sentry.Extensions.AI/SentryAIConstants.cs +++ b/src/Sentry.Extensions.AI/SentryAIConstants.cs @@ -3,10 +3,11 @@ namespace Sentry.Extensions.AI; internal static class SentryAIConstants { /// - /// The list of strings which FunctionInvokingChatClient(FICC) uses to start the tool call . - /// These can be found in FunctionInvokingChatClient.cs in GetResponseAsync or GetStreamingResponseAsync function. + /// See: + /// https://github.com/dotnet/extensions/blob/f8f779a6ea004bb1f26649719ca77d63a9d9417c/src/Libraries/Microsoft.Extensions.AI/ChatCompletion/FunctionInvokingChatClient.cs#L272 /// - internal static readonly string[] FICCActivityNames = ["orchestrate_tools", "FunctionInvokingChatClient.GetResponseAsync", "FunctionInvokingChatClient"]; + internal static readonly string[] FICCActivityNames = + ["orchestrate_tools", "FunctionInvokingChatClient.GetResponseAsync", "FunctionInvokingChatClient"]; /// /// The string we use to identify our . From 9b440a336b5dd35987475ca584ba2269d8921e8e Mon Sep 17 00:00:00 2001 From: Alex Sohn Date: Wed, 12 Nov 2025 11:25:53 -0500 Subject: [PATCH 80/86] Change SentryAIActivityListener to non-static class to a singleton pattern --- .../Extensions/SentryAIExtensions.cs | 3 +- .../SentryAIActivityListener.cs | 58 +++++++++++-------- .../SentryAIActivityListenerTests.cs | 22 ++++--- 3 files changed, 50 insertions(+), 33 deletions(-) diff --git a/src/Sentry.Extensions.AI/Extensions/SentryAIExtensions.cs b/src/Sentry.Extensions.AI/Extensions/SentryAIExtensions.cs index dd991a8372..7a0a40f7aa 100644 --- a/src/Sentry.Extensions.AI/Extensions/SentryAIExtensions.cs +++ b/src/Sentry.Extensions.AI/Extensions/SentryAIExtensions.cs @@ -53,7 +53,8 @@ public static ChatOptions AddSentryToolInstrumentation(this ChatOptions options) [Experimental(DiagnosticId.ExperimentalFeature)] public static IChatClient AddSentry(this IChatClient client, Action? configure = null) { - SentryAIActivityListener.Init(); + // The constructor automatically adds the listener, so we can discard the instance + _ = new SentryAiActivityListener(); return new SentryChatClient(client, configure); } } diff --git a/src/Sentry.Extensions.AI/SentryAIActivityListener.cs b/src/Sentry.Extensions.AI/SentryAIActivityListener.cs index 116ed4c171..428b484fe2 100644 --- a/src/Sentry.Extensions.AI/SentryAIActivityListener.cs +++ b/src/Sentry.Extensions.AI/SentryAIActivityListener.cs @@ -5,39 +5,51 @@ namespace Sentry.Extensions.AI; /// /// Listens to FunctionInvokingChatClient's Activity /// -internal static class SentryAIActivityListener +internal class SentryAiActivityListener { - private static volatile IHub Hub = HubAdapter.Instance; + private static ActivityListener? Instance; /// - /// Sentry's to tap into function invocation's Activity + /// Initializes Sentry's to tap into FunctionInvokingChatClient's Activity /// - private static readonly ActivityListener FICCListener = new() + /// Optional IHub instance to use. If not provided, HubAdapter.Instance will be used. + public SentryAiActivityListener(IHub? hub = null) { - ShouldListenTo = source => source.Name.StartsWith(SentryAIConstants.SentryActivitySourceName), - Sample = (ref ActivityCreationOptions options) => - SentryAIConstants.FICCActivityNames.Contains(options.Name) ? - ActivitySamplingResult.AllDataAndRecorded : ActivitySamplingResult.None, - ActivityStarted = activity => + if (Instance != null) { - var agentSpan = Hub.StartSpan(SentryAIConstants.SpanAttributes.InvokeAgentOperation, SentryAIConstants.SpanAttributes.InvokeAgentDescription); - activity.SetFused(SentryAIConstants.SentryFICCSpanAttributeName, agentSpan); - }, - ActivityStopped = activity => - { - var agentSpan = activity.GetFused(SentryAIConstants.SentryFICCSpanAttributeName); - // Don't pass in OK status in case there was an exception - agentSpan?.Finish(); + return; } - }; + + var currHub = hub ?? HubAdapter.Instance; + Instance = new ActivityListener + { + ShouldListenTo = source => source.Name.StartsWith(SentryAIConstants.SentryActivitySourceName), + Sample = (ref ActivityCreationOptions options) => + SentryAIConstants.FICCActivityNames.Contains(options.Name) + ? ActivitySamplingResult.AllDataAndRecorded + : ActivitySamplingResult.None, + ActivityStarted = activity => + { + var agentSpan = currHub.StartSpan(SentryAIConstants.SpanAttributes.InvokeAgentOperation, + SentryAIConstants.SpanAttributes.InvokeAgentDescription); + activity.SetFused(SentryAIConstants.SentryFICCSpanAttributeName, agentSpan); + }, + ActivityStopped = activity => + { + var agentSpan = activity.GetFused(SentryAIConstants.SentryFICCSpanAttributeName); + // Don't pass in OK status in case there was an exception + agentSpan?.Finish(); + } + }; + ActivitySource.AddActivityListener(Instance); + } /// - /// Initializes Sentry's to tap into FunctionInvokingChatClient's Activity + /// Dispose the singleton instance (for testing purposes mostly) /// - /// Optional IHub instance to use. If not provided, HubAdapter.Instance will be used. - internal static void Init(IHub? hub = null) + internal static void Dispose() { - Hub = hub ?? HubAdapter.Instance; - ActivitySource.AddActivityListener(FICCListener); + Instance?.Dispose(); + Instance = null; } } diff --git a/test/Sentry.Extensions.AI.Tests/SentryAIActivityListenerTests.cs b/test/Sentry.Extensions.AI.Tests/SentryAIActivityListenerTests.cs index 26195548b7..6226f31b2f 100644 --- a/test/Sentry.Extensions.AI.Tests/SentryAIActivityListenerTests.cs +++ b/test/Sentry.Extensions.AI.Tests/SentryAIActivityListenerTests.cs @@ -16,11 +16,17 @@ public Fixture() private readonly Fixture _fixture = new(); + public SentryAIActivityListenerTests() + { + // Dispose ActivityListener before each test, otherwise the singleton instance will persist between tests + SentryAiActivityListener.Dispose(); + } + [Fact] public void Init_AddsActivityListenerToActivitySource() { // Act - SentryAIActivityListener.Init(); + _ = new SentryAiActivityListener(); // Assert Assert.True(SentryAIActivitySource.Instance.HasListeners()); @@ -31,7 +37,7 @@ public void Init_AddsActivityListenerToActivitySource() public void ShouldListenTo_ReturnsTrueForSentryActivitySource(string sourceName) { // Arrange - SentryAIActivityListener.Init(_fixture.Hub); + _ = new SentryAiActivityListener(_fixture.Hub); var activitySource = new ActivitySource(sourceName); // Act @@ -51,7 +57,7 @@ public void ShouldListenTo_ReturnsTrueForSentryActivitySource(string sourceName) public void ShouldListenTo_ReturnsFalseForNonSentryActivitySource() { // Arrange - SentryAIActivityListener.Init(_fixture.Hub); + _ = new SentryAiActivityListener(_fixture.Hub); var activitySource = new ActivitySource("Other.ActivitySource"); // Act & Assert @@ -65,12 +71,10 @@ public void ShouldListenTo_ReturnsFalseForNonSentryActivitySource() [Theory] [InlineData("orchestrate_tools")] - [InlineData("FunctionInvokingChatClient.GetResponseAsync")] - [InlineData("FunctionInvokingChatClient")] public void Sample_ReturnsAllDataAndRecordedForFICCActivityNames(string activityName) { // Arrange - SentryAIActivityListener.Init(_fixture.Hub); + _ = new SentryAiActivityListener(_fixture.Hub); // Act using var activity = SentryAIActivitySource.Instance.StartActivity(activityName); @@ -90,9 +94,9 @@ public void Sample_ReturnsAllDataAndRecordedForFICCActivityNames(string activity public void Init_MultipleCalls_NoDuplicateListener_StartsOnlyOneTransaction() { // Arrange - SentryAIActivityListener.Init(_fixture.Hub); - SentryAIActivityListener.Init(_fixture.Hub); - SentryAIActivityListener.Init(_fixture.Hub); + _ = new SentryAiActivityListener(_fixture.Hub); + _ = new SentryAiActivityListener(_fixture.Hub); + _ = new SentryAiActivityListener(_fixture.Hub); // Act using var activity = SentryAIActivitySource.Instance.StartActivity(SentryAIConstants.FICCActivityNames[0]); From 8fcebc901c0bc9d5663606b4ca47e539e565a908 Mon Sep 17 00:00:00 2001 From: Alex Sohn Date: Wed, 12 Nov 2025 11:42:01 -0500 Subject: [PATCH 81/86] Add thread safety to ActivityListener --- .../SentryAIActivityListener.cs | 63 ++++++++++--------- .../SentryAIActivityListenerTests.cs | 2 +- 2 files changed, 36 insertions(+), 29 deletions(-) diff --git a/src/Sentry.Extensions.AI/SentryAIActivityListener.cs b/src/Sentry.Extensions.AI/SentryAIActivityListener.cs index 428b484fe2..2c441eccd0 100644 --- a/src/Sentry.Extensions.AI/SentryAIActivityListener.cs +++ b/src/Sentry.Extensions.AI/SentryAIActivityListener.cs @@ -5,9 +5,10 @@ namespace Sentry.Extensions.AI; /// /// Listens to FunctionInvokingChatClient's Activity /// -internal class SentryAiActivityListener +internal class SentryAiActivityListener : IDisposable { private static ActivityListener? Instance; + private static readonly object Lock = new(); /// /// Initializes Sentry's to tap into FunctionInvokingChatClient's Activity @@ -15,41 +16,47 @@ internal class SentryAiActivityListener /// Optional IHub instance to use. If not provided, HubAdapter.Instance will be used. public SentryAiActivityListener(IHub? hub = null) { - if (Instance != null) + lock (Lock) { - return; - } - - var currHub = hub ?? HubAdapter.Instance; - Instance = new ActivityListener - { - ShouldListenTo = source => source.Name.StartsWith(SentryAIConstants.SentryActivitySourceName), - Sample = (ref ActivityCreationOptions options) => - SentryAIConstants.FICCActivityNames.Contains(options.Name) - ? ActivitySamplingResult.AllDataAndRecorded - : ActivitySamplingResult.None, - ActivityStarted = activity => + if (Instance != null) { - var agentSpan = currHub.StartSpan(SentryAIConstants.SpanAttributes.InvokeAgentOperation, - SentryAIConstants.SpanAttributes.InvokeAgentDescription); - activity.SetFused(SentryAIConstants.SentryFICCSpanAttributeName, agentSpan); - }, - ActivityStopped = activity => - { - var agentSpan = activity.GetFused(SentryAIConstants.SentryFICCSpanAttributeName); - // Don't pass in OK status in case there was an exception - agentSpan?.Finish(); + return; } - }; - ActivitySource.AddActivityListener(Instance); + + var currHub = hub ?? HubAdapter.Instance; + Instance = new ActivityListener + { + ShouldListenTo = source => source.Name.StartsWith(SentryAIConstants.SentryActivitySourceName), + Sample = (ref ActivityCreationOptions options) => + SentryAIConstants.FICCActivityNames.Contains(options.Name) + ? ActivitySamplingResult.AllDataAndRecorded + : ActivitySamplingResult.None, + ActivityStarted = activity => + { + var agentSpan = currHub.StartSpan(SentryAIConstants.SpanAttributes.InvokeAgentOperation, + SentryAIConstants.SpanAttributes.InvokeAgentDescription); + activity.SetFused(SentryAIConstants.SentryFICCSpanAttributeName, agentSpan); + }, + ActivityStopped = activity => + { + var agentSpan = activity.GetFused(SentryAIConstants.SentryFICCSpanAttributeName); + // Don't pass in OK status in case there was an exception + agentSpan?.Finish(); + } + }; + ActivitySource.AddActivityListener(Instance); + } } /// /// Dispose the singleton instance (for testing purposes mostly) /// - internal static void Dispose() + public void Dispose() { - Instance?.Dispose(); - Instance = null; + lock (Lock) + { + Instance?.Dispose(); + Instance = null; + } } } diff --git a/test/Sentry.Extensions.AI.Tests/SentryAIActivityListenerTests.cs b/test/Sentry.Extensions.AI.Tests/SentryAIActivityListenerTests.cs index 6226f31b2f..eaa7b79bda 100644 --- a/test/Sentry.Extensions.AI.Tests/SentryAIActivityListenerTests.cs +++ b/test/Sentry.Extensions.AI.Tests/SentryAIActivityListenerTests.cs @@ -19,7 +19,7 @@ public Fixture() public SentryAIActivityListenerTests() { // Dispose ActivityListener before each test, otherwise the singleton instance will persist between tests - SentryAiActivityListener.Dispose(); + new SentryAiActivityListener().Dispose(); } [Fact] From fc64d02fcfe3bfbbd66a07e26847cb3080bc6238 Mon Sep 17 00:00:00 2001 From: James Crosswell Date: Thu, 13 Nov 2025 13:23:35 +1300 Subject: [PATCH 82/86] Removed static dependencies --- .../Extensions/SentryAIExtensions.cs | 11 +++- .../SentryAIActivityListener.cs | 62 ------------------- .../SentryAIActivitySource.cs | 8 ++- src/Sentry.Extensions.AI/SentryAIConstants.cs | 5 -- .../SentryAiActivityListener.cs | 46 ++++++++++++++ src/Sentry.Extensions.AI/SentryChatClient.cs | 15 +++-- .../SentryAIActivityListenerTests.cs | 55 +++++----------- .../SentryAIExtensionsTests.cs | 21 ++++++- .../SentryChatClientTests.cs | 36 +++++------ 9 files changed, 122 insertions(+), 137 deletions(-) delete mode 100644 src/Sentry.Extensions.AI/SentryAIActivityListener.cs create mode 100644 src/Sentry.Extensions.AI/SentryAiActivityListener.cs diff --git a/src/Sentry.Extensions.AI/Extensions/SentryAIExtensions.cs b/src/Sentry.Extensions.AI/Extensions/SentryAIExtensions.cs index 7a0a40f7aa..5fa0de313c 100644 --- a/src/Sentry.Extensions.AI/Extensions/SentryAIExtensions.cs +++ b/src/Sentry.Extensions.AI/Extensions/SentryAIExtensions.cs @@ -51,10 +51,15 @@ public static ChatOptions AddSentryToolInstrumentation(this ChatOptions options) /// The configuration /// The instrumented [Experimental(DiagnosticId.ExperimentalFeature)] - public static IChatClient AddSentry(this IChatClient client, Action? configure = null) + public static IChatClient AddSentry(this IChatClient client, Action? configure = null) => + AddSentry(client, SentryAiActivityListener.Instance, configure); + + /// + /// Internal overload for testing + /// + internal static IChatClient AddSentry(this IChatClient client, ActivityListener listener, Action? configure = null) { - // The constructor automatically adds the listener, so we can discard the instance - _ = new SentryAiActivityListener(); + ActivitySource.AddActivityListener(listener); return new SentryChatClient(client, configure); } } diff --git a/src/Sentry.Extensions.AI/SentryAIActivityListener.cs b/src/Sentry.Extensions.AI/SentryAIActivityListener.cs deleted file mode 100644 index 2c441eccd0..0000000000 --- a/src/Sentry.Extensions.AI/SentryAIActivityListener.cs +++ /dev/null @@ -1,62 +0,0 @@ -using Sentry.Internal; - -namespace Sentry.Extensions.AI; - -/// -/// Listens to FunctionInvokingChatClient's Activity -/// -internal class SentryAiActivityListener : IDisposable -{ - private static ActivityListener? Instance; - private static readonly object Lock = new(); - - /// - /// Initializes Sentry's to tap into FunctionInvokingChatClient's Activity - /// - /// Optional IHub instance to use. If not provided, HubAdapter.Instance will be used. - public SentryAiActivityListener(IHub? hub = null) - { - lock (Lock) - { - if (Instance != null) - { - return; - } - - var currHub = hub ?? HubAdapter.Instance; - Instance = new ActivityListener - { - ShouldListenTo = source => source.Name.StartsWith(SentryAIConstants.SentryActivitySourceName), - Sample = (ref ActivityCreationOptions options) => - SentryAIConstants.FICCActivityNames.Contains(options.Name) - ? ActivitySamplingResult.AllDataAndRecorded - : ActivitySamplingResult.None, - ActivityStarted = activity => - { - var agentSpan = currHub.StartSpan(SentryAIConstants.SpanAttributes.InvokeAgentOperation, - SentryAIConstants.SpanAttributes.InvokeAgentDescription); - activity.SetFused(SentryAIConstants.SentryFICCSpanAttributeName, agentSpan); - }, - ActivityStopped = activity => - { - var agentSpan = activity.GetFused(SentryAIConstants.SentryFICCSpanAttributeName); - // Don't pass in OK status in case there was an exception - agentSpan?.Finish(); - } - }; - ActivitySource.AddActivityListener(Instance); - } - } - - /// - /// Dispose the singleton instance (for testing purposes mostly) - /// - public void Dispose() - { - lock (Lock) - { - Instance?.Dispose(); - Instance = null; - } - } -} diff --git a/src/Sentry.Extensions.AI/SentryAIActivitySource.cs b/src/Sentry.Extensions.AI/SentryAIActivitySource.cs index faab008b7a..c8834af41e 100644 --- a/src/Sentry.Extensions.AI/SentryAIActivitySource.cs +++ b/src/Sentry.Extensions.AI/SentryAIActivitySource.cs @@ -5,6 +5,10 @@ namespace Sentry.Extensions.AI; /// Sentry's to be used in internal static class SentryAIActivitySource { - /// Sentry's to be used in - internal static ActivitySource Instance { get; } = new(SentryAIConstants.SentryActivitySourceName); + internal const string SentryActivitySourceName = "Sentry.AgentMonitoring"; + + private static readonly Lazy LazyInstance = new(CreateSource); + internal static ActivitySource Instance => LazyInstance.Value; + + internal static ActivitySource CreateSource() => new(SentryActivitySourceName); } diff --git a/src/Sentry.Extensions.AI/SentryAIConstants.cs b/src/Sentry.Extensions.AI/SentryAIConstants.cs index 95769132e5..837a2eaea4 100644 --- a/src/Sentry.Extensions.AI/SentryAIConstants.cs +++ b/src/Sentry.Extensions.AI/SentryAIConstants.cs @@ -9,11 +9,6 @@ internal static class SentryAIConstants internal static readonly string[] FICCActivityNames = ["orchestrate_tools", "FunctionInvokingChatClient.GetResponseAsync", "FunctionInvokingChatClient"]; - /// - /// The string we use to identify our . - /// - internal const string SentryActivitySourceName = "Sentry.AgentMonitoring"; - /// /// The string we use to retrieve a Sentry span from the using a Fused property /// diff --git a/src/Sentry.Extensions.AI/SentryAiActivityListener.cs b/src/Sentry.Extensions.AI/SentryAiActivityListener.cs new file mode 100644 index 0000000000..fbb24eb1f7 --- /dev/null +++ b/src/Sentry.Extensions.AI/SentryAiActivityListener.cs @@ -0,0 +1,46 @@ +using Sentry.Internal; + +namespace Sentry.Extensions.AI; + +/// +/// Listens to FunctionInvokingChatClient's Activity +/// +internal static class SentryAiActivityListener +{ + /// + /// Singleton used outside of testing + /// + private static readonly Lazy LazyInstance = new(() => CreateListener()); + internal static readonly ActivityListener Instance = LazyInstance.Value; + + /// + /// Initializes Sentry's to tap into FunctionInvokingChatClient's Activity + /// + public static ActivityListener CreateListener(IHub? hub = null) + { + hub ??= HubAdapter.Instance; + + var listener = new ActivityListener + { + ShouldListenTo = source => source.Name.StartsWith(SentryAIActivitySource.SentryActivitySourceName), + Sample = (ref ActivityCreationOptions options) => + SentryAIConstants.FICCActivityNames.Contains(options.Name) + ? ActivitySamplingResult.AllDataAndRecorded + : ActivitySamplingResult.None, + ActivityStarted = activity => + { + var agentSpan = hub.StartSpan(SentryAIConstants.SpanAttributes.InvokeAgentOperation, + SentryAIConstants.SpanAttributes.InvokeAgentDescription); + activity.SetFused(SentryAIConstants.SentryFICCSpanAttributeName, agentSpan); + }, + ActivityStopped = activity => + { + var agentSpan = activity.GetFused(SentryAIConstants.SentryFICCSpanAttributeName); + // Don't pass in OK status in case there was an exception + agentSpan?.Finish(); + } + }; + ActivitySource.AddActivityListener(listener); + return listener; + } +} diff --git a/src/Sentry.Extensions.AI/SentryChatClient.cs b/src/Sentry.Extensions.AI/SentryChatClient.cs index df20dc7c58..d7f022ce0e 100644 --- a/src/Sentry.Extensions.AI/SentryChatClient.cs +++ b/src/Sentry.Extensions.AI/SentryChatClient.cs @@ -4,11 +4,20 @@ namespace Sentry.Extensions.AI; internal sealed class SentryChatClient : DelegatingChatClient { + private readonly ActivitySource _activitySource; private readonly HubAdapter _hub = HubAdapter.Instance; private readonly SentryAIOptions _sentryAIOptions; - public SentryChatClient(IChatClient client, Action? configure = null) : base(client) + public SentryChatClient(IChatClient client, Action? configure = null) : this(null, client, configure) { + } + + /// + /// Internal ovverride for testing + /// + internal SentryChatClient(ActivitySource? activitySource, IChatClient client, Action? configure = null) : base(client) + { + _activitySource = activitySource ?? SentryAIActivitySource.Instance; _sentryAIOptions = new SentryAIOptions(); configure?.Invoke(_sentryAIOptions); } @@ -102,9 +111,7 @@ public override async IAsyncEnumerable GetStreamingResponseA /// public override object? GetService(Type serviceType, object? serviceKey = null) => - serviceType == typeof(ActivitySource) - ? SentryAIActivitySource.Instance - : base.GetService(serviceType, serviceKey); + serviceType == typeof(ActivitySource) ? _activitySource : base.GetService(serviceType, serviceKey); private void AfterResponseCleanup(ISpan chatSpan, ISpan agentSpan, Exception? exception = null) { diff --git a/test/Sentry.Extensions.AI.Tests/SentryAIActivityListenerTests.cs b/test/Sentry.Extensions.AI.Tests/SentryAIActivityListenerTests.cs index eaa7b79bda..370f50a0e8 100644 --- a/test/Sentry.Extensions.AI.Tests/SentryAIActivityListenerTests.cs +++ b/test/Sentry.Extensions.AI.Tests/SentryAIActivityListenerTests.cs @@ -16,28 +16,25 @@ public Fixture() private readonly Fixture _fixture = new(); - public SentryAIActivityListenerTests() - { - // Dispose ActivityListener before each test, otherwise the singleton instance will persist between tests - new SentryAiActivityListener().Dispose(); - } - [Fact] public void Init_AddsActivityListenerToActivitySource() { + // Arrange + var source = SentryAIActivitySource.CreateSource(); + // Act - _ = new SentryAiActivityListener(); + using var listener = SentryAiActivityListener.CreateListener(_fixture.Hub); // Assert - Assert.True(SentryAIActivitySource.Instance.HasListeners()); + Assert.True(source.HasListeners()); } - [Theory] - [InlineData(SentryAIConstants.SentryActivitySourceName)] - public void ShouldListenTo_ReturnsTrueForSentryActivitySource(string sourceName) + [Fact] + public void ShouldListenTo_ReturnsTrueForSentryActivitySource() { // Arrange - _ = new SentryAiActivityListener(_fixture.Hub); + var sourceName = SentryAIActivitySource.SentryActivitySourceName; + using var listener = SentryAiActivityListener.CreateListener(_fixture.Hub); var activitySource = new ActivitySource(sourceName); // Act @@ -57,7 +54,7 @@ public void ShouldListenTo_ReturnsTrueForSentryActivitySource(string sourceName) public void ShouldListenTo_ReturnsFalseForNonSentryActivitySource() { // Arrange - _ = new SentryAiActivityListener(_fixture.Hub); + using var listener = SentryAiActivityListener.CreateListener(_fixture.Hub); var activitySource = new ActivitySource("Other.ActivitySource"); // Act & Assert @@ -69,15 +66,16 @@ public void ShouldListenTo_ReturnsFalseForNonSentryActivitySource() Arg.Any>()); } - [Theory] - [InlineData("orchestrate_tools")] - public void Sample_ReturnsAllDataAndRecordedForFICCActivityNames(string activityName) + [Fact] + public void Sample_ReturnsAllDataAndRecordedForFICCActivityNames() { // Arrange - _ = new SentryAiActivityListener(_fixture.Hub); + var activityName = "orchestrate_tools"; + using var listener = SentryAiActivityListener.CreateListener(_fixture.Hub); + var source = SentryAIActivitySource.CreateSource(); // Act - using var activity = SentryAIActivitySource.Instance.StartActivity(activityName); + using var activity = source.StartActivity(activityName); // Assert Assert.NotNull(activity); @@ -89,25 +87,4 @@ public void Sample_ReturnsAllDataAndRecordedForFICCActivityNames(string activity Arg.Any(), Arg.Any>()); } - - [Fact] - public void Init_MultipleCalls_NoDuplicateListener_StartsOnlyOneTransaction() - { - // Arrange - _ = new SentryAiActivityListener(_fixture.Hub); - _ = new SentryAiActivityListener(_fixture.Hub); - _ = new SentryAiActivityListener(_fixture.Hub); - - // Act - using var activity = SentryAIActivitySource.Instance.StartActivity(SentryAIConstants.FICCActivityNames[0]); - - // Assert - Assert.NotNull(activity); - Assert.True(SentryAIActivitySource.Instance.HasListeners()); - activity.Stop(); - - _fixture.Hub.Received(1).StartTransaction( - Arg.Any(), - Arg.Any>()); - } } diff --git a/test/Sentry.Extensions.AI.Tests/SentryAIExtensionsTests.cs b/test/Sentry.Extensions.AI.Tests/SentryAIExtensionsTests.cs index d96138739c..9b886b146f 100644 --- a/test/Sentry.Extensions.AI.Tests/SentryAIExtensionsTests.cs +++ b/test/Sentry.Extensions.AI.Tests/SentryAIExtensionsTests.cs @@ -5,14 +5,27 @@ namespace Sentry.Extensions.AI.Tests; public class SentryAIExtensionsTests { + private class Fixture + { + public IHub Hub { get; } = Substitute.For(); + + public Fixture() + { + Hub.IsEnabled.Returns(true); + } + } + + private readonly Fixture _fixture = new(); + [Fact] public void WithSentry_IChatClient_ReturnsWrappedClient() { // Arrange var mockClient = Substitute.For(); + using var listener = SentryAiActivityListener.CreateListener(_fixture.Hub); // Act - var result = mockClient.AddSentry(); + var result = mockClient.AddSentry(listener); // Assert Assert.IsType(result); @@ -23,10 +36,11 @@ public void WithSentry_IChatClient_WithConfiguration_PassesConfigurationToWrappe { // Arrange var mockClient = Substitute.For(); + using var listener = SentryAiActivityListener.CreateListener(_fixture.Hub); var configureWasCalled = false; // Act - var result = mockClient.AddSentry(options => + var result = mockClient.AddSentry(listener, options => { configureWasCalled = true; options.Experimental.RecordInputs = false; @@ -44,9 +58,10 @@ public void WithSentry_IChatClient_WithNullConfiguration_UsesDefaultConfiguratio { // Arrange var mockClient = Substitute.For(); + using var listener = SentryAiActivityListener.CreateListener(_fixture.Hub); // Act - var result = mockClient.AddSentry(null); + var result = mockClient.AddSentry(listener, null); // Assert Assert.IsType(result); diff --git a/test/Sentry.Extensions.AI.Tests/SentryChatClientTests.cs b/test/Sentry.Extensions.AI.Tests/SentryChatClientTests.cs index c5e37b73f5..b4330b6a9c 100644 --- a/test/Sentry.Extensions.AI.Tests/SentryChatClientTests.cs +++ b/test/Sentry.Extensions.AI.Tests/SentryChatClientTests.cs @@ -8,8 +8,9 @@ public class SentryChatClientTests private class Fixture { private SentryOptions Options { get; } - public ISentryClient Client { get; } - public IHub Hub { get; set; } + public IHub Hub { get; } + public ActivitySource Source { get; } = SentryAIActivitySource.CreateSource(); + public IChatClient InnerClient = Substitute.For(); public Fixture() { @@ -21,8 +22,9 @@ public Fixture() SentrySdk.Init(Options); Hub = SentrySdk.CurrentHub; - Client = Substitute.For(); } + + public SentryChatClient GetSut() => new SentryChatClient(Source, InnerClient); } private readonly Fixture _fixture = new(); @@ -35,20 +37,19 @@ public async Task CompleteAsync_CallsInnerClient_AndSetsData() _fixture.Hub.ConfigureScope(scope => scope.Transaction = transaction); SentrySdk.ConfigureScope(scope => scope.Transaction = transaction); - var inner = Substitute.For(); - var sentryChatClient = new SentryChatClient(inner); var message = new ChatMessage(ChatRole.Assistant, "ok"); var chatResponse = new ChatResponse(message); - inner.GetResponseAsync(Arg.Any>(), Arg.Any(), Arg.Any()) + _fixture.InnerClient.GetResponseAsync(Arg.Any>(), Arg.Any(), Arg.Any()) .Returns(Task.FromResult(chatResponse)); + var sentryChatClient = _fixture.GetSut(); // Act var res = await sentryChatClient.GetResponseAsync([new ChatMessage(ChatRole.User, "hi")]); // Assert Assert.Equal([message], res.Messages); - await inner.Received(1).GetResponseAsync(Arg.Any>(), Arg.Any(), + await _fixture.InnerClient.Received(1).GetResponseAsync(Arg.Any>(), Arg.Any(), Arg.Any()); var chatSpan = transaction.Spans.FirstOrDefault(s => s.Operation == SentryAIConstants.SpanAttributes.ChatOperation); @@ -74,11 +75,10 @@ public async Task CompleteAsync_HandlesErrors_AndFinishesSpanWithException() _fixture.Hub.ConfigureScope(scope => scope.Transaction = transaction); SentrySdk.ConfigureScope(scope => scope.Transaction = transaction); - var inner = Substitute.For(); - var sentryChatClient = new SentryChatClient(inner); + var sentryChatClient = _fixture.GetSut(); var expectedException = new InvalidOperationException("Streaming failed"); - inner.GetResponseAsync(Arg.Any>(), Arg.Any(), Arg.Any()) + _fixture.InnerClient.GetResponseAsync(Arg.Any>(), Arg.Any(), Arg.Any()) .Throws(expectedException); // Act @@ -110,21 +110,20 @@ public async Task CompleteStreamingAsync_CallsInnerClient_AndSetsSpanData() _fixture.Hub.ConfigureScope(scope => scope.Transaction = transaction); SentrySdk.ConfigureScope(scope => scope.Transaction = transaction); - var inner = Substitute.For(); - inner.GetStreamingResponseAsync(Arg.Any>(), Arg.Any(), + _fixture.InnerClient.GetStreamingResponseAsync(Arg.Any>(), Arg.Any(), Arg.Any()) .Returns(CreateTestStreamingUpdatesAsync()); - var client = new SentryChatClient(inner); + var sentryChatClient = _fixture.GetSut(); var results = new List(); // Act - await foreach (var update in client.GetStreamingResponseAsync([new ChatMessage(ChatRole.User, "hi")])) + await foreach (var update in sentryChatClient.GetStreamingResponseAsync([new ChatMessage(ChatRole.User, "hi")])) { results.Add(update); } // Assert - inner.Received(1).GetStreamingResponseAsync(Arg.Any>(), Arg.Any(), + _fixture.InnerClient.Received(1).GetStreamingResponseAsync(Arg.Any>(), Arg.Any(), Arg.Any()); Assert.Equal(2, results.Count); Assert.Equal("Hello", results[0].Text); @@ -154,17 +153,16 @@ public async Task CompleteStreamingAsync_HandlesErrors_AndFinishesSpanWithExcept _fixture.Hub.ConfigureScope(scope => scope.Transaction = transaction); SentrySdk.ConfigureScope(scope => scope.Transaction = transaction); - var inner = Substitute.For(); var expectedException = new InvalidOperationException("Streaming failed"); - inner.GetStreamingResponseAsync(Arg.Any>(), Arg.Any(), + _fixture.InnerClient.GetStreamingResponseAsync(Arg.Any>(), Arg.Any(), Arg.Any()) .Returns(CreateFailingStreamingUpdatesAsync(expectedException)); - var client = new SentryChatClient(inner); + var sentryChatClient = _fixture.GetSut(); // Act var actualException = await Assert.ThrowsAsync(async () => { - await foreach (var update in client.GetStreamingResponseAsync([new ChatMessage(ChatRole.User, "hi")])) + await foreach (var update in sentryChatClient.GetStreamingResponseAsync([new ChatMessage(ChatRole.User, "hi")])) { // Should not reach here due to exception } From 1bb5629df1fcaa33091c04a0a216beb7ef78791b Mon Sep 17 00:00:00 2001 From: Alex Sohn Date: Thu, 13 Nov 2025 11:19:38 -0500 Subject: [PATCH 83/86] Fix Capitalization in AI for SentryAIActivityListener --- src/Sentry.Extensions.AI/Extensions/SentryAIExtensions.cs | 2 +- ...yAiActivityListener.cs => SentryAIActivityListener.cs} | 2 +- .../SentryAIActivityListenerTests.cs | 8 ++++---- .../Sentry.Extensions.AI.Tests/SentryAIExtensionsTests.cs | 6 +++--- 4 files changed, 9 insertions(+), 9 deletions(-) rename src/Sentry.Extensions.AI/{SentryAiActivityListener.cs => SentryAIActivityListener.cs} (97%) diff --git a/src/Sentry.Extensions.AI/Extensions/SentryAIExtensions.cs b/src/Sentry.Extensions.AI/Extensions/SentryAIExtensions.cs index 5fa0de313c..9f6c1d8195 100644 --- a/src/Sentry.Extensions.AI/Extensions/SentryAIExtensions.cs +++ b/src/Sentry.Extensions.AI/Extensions/SentryAIExtensions.cs @@ -52,7 +52,7 @@ public static ChatOptions AddSentryToolInstrumentation(this ChatOptions options) /// The instrumented [Experimental(DiagnosticId.ExperimentalFeature)] public static IChatClient AddSentry(this IChatClient client, Action? configure = null) => - AddSentry(client, SentryAiActivityListener.Instance, configure); + AddSentry(client, SentryAIActivityListener.Instance, configure); /// /// Internal overload for testing diff --git a/src/Sentry.Extensions.AI/SentryAiActivityListener.cs b/src/Sentry.Extensions.AI/SentryAIActivityListener.cs similarity index 97% rename from src/Sentry.Extensions.AI/SentryAiActivityListener.cs rename to src/Sentry.Extensions.AI/SentryAIActivityListener.cs index fbb24eb1f7..33150184de 100644 --- a/src/Sentry.Extensions.AI/SentryAiActivityListener.cs +++ b/src/Sentry.Extensions.AI/SentryAIActivityListener.cs @@ -5,7 +5,7 @@ namespace Sentry.Extensions.AI; /// /// Listens to FunctionInvokingChatClient's Activity /// -internal static class SentryAiActivityListener +internal static class SentryAIActivityListener { /// /// Singleton used outside of testing diff --git a/test/Sentry.Extensions.AI.Tests/SentryAIActivityListenerTests.cs b/test/Sentry.Extensions.AI.Tests/SentryAIActivityListenerTests.cs index 370f50a0e8..9b94974df6 100644 --- a/test/Sentry.Extensions.AI.Tests/SentryAIActivityListenerTests.cs +++ b/test/Sentry.Extensions.AI.Tests/SentryAIActivityListenerTests.cs @@ -23,7 +23,7 @@ public void Init_AddsActivityListenerToActivitySource() var source = SentryAIActivitySource.CreateSource(); // Act - using var listener = SentryAiActivityListener.CreateListener(_fixture.Hub); + using var listener = SentryAIActivityListener.CreateListener(_fixture.Hub); // Assert Assert.True(source.HasListeners()); @@ -34,7 +34,7 @@ public void ShouldListenTo_ReturnsTrueForSentryActivitySource() { // Arrange var sourceName = SentryAIActivitySource.SentryActivitySourceName; - using var listener = SentryAiActivityListener.CreateListener(_fixture.Hub); + using var listener = SentryAIActivityListener.CreateListener(_fixture.Hub); var activitySource = new ActivitySource(sourceName); // Act @@ -54,7 +54,7 @@ public void ShouldListenTo_ReturnsTrueForSentryActivitySource() public void ShouldListenTo_ReturnsFalseForNonSentryActivitySource() { // Arrange - using var listener = SentryAiActivityListener.CreateListener(_fixture.Hub); + using var listener = SentryAIActivityListener.CreateListener(_fixture.Hub); var activitySource = new ActivitySource("Other.ActivitySource"); // Act & Assert @@ -71,7 +71,7 @@ public void Sample_ReturnsAllDataAndRecordedForFICCActivityNames() { // Arrange var activityName = "orchestrate_tools"; - using var listener = SentryAiActivityListener.CreateListener(_fixture.Hub); + using var listener = SentryAIActivityListener.CreateListener(_fixture.Hub); var source = SentryAIActivitySource.CreateSource(); // Act diff --git a/test/Sentry.Extensions.AI.Tests/SentryAIExtensionsTests.cs b/test/Sentry.Extensions.AI.Tests/SentryAIExtensionsTests.cs index 9b886b146f..bd0f25856c 100644 --- a/test/Sentry.Extensions.AI.Tests/SentryAIExtensionsTests.cs +++ b/test/Sentry.Extensions.AI.Tests/SentryAIExtensionsTests.cs @@ -22,7 +22,7 @@ public void WithSentry_IChatClient_ReturnsWrappedClient() { // Arrange var mockClient = Substitute.For(); - using var listener = SentryAiActivityListener.CreateListener(_fixture.Hub); + using var listener = SentryAIActivityListener.CreateListener(_fixture.Hub); // Act var result = mockClient.AddSentry(listener); @@ -36,7 +36,7 @@ public void WithSentry_IChatClient_WithConfiguration_PassesConfigurationToWrappe { // Arrange var mockClient = Substitute.For(); - using var listener = SentryAiActivityListener.CreateListener(_fixture.Hub); + using var listener = SentryAIActivityListener.CreateListener(_fixture.Hub); var configureWasCalled = false; // Act @@ -58,7 +58,7 @@ public void WithSentry_IChatClient_WithNullConfiguration_UsesDefaultConfiguratio { // Arrange var mockClient = Substitute.For(); - using var listener = SentryAiActivityListener.CreateListener(_fixture.Hub); + using var listener = SentryAIActivityListener.CreateListener(_fixture.Hub); // Act var result = mockClient.AddSentry(listener, null); From 7675d3215c5d0f5886b7f793c15637a45d94c2e0 Mon Sep 17 00:00:00 2001 From: Alex Sohn Date: Mon, 17 Nov 2025 11:52:57 -0500 Subject: [PATCH 84/86] use default cancellation token --- src/Sentry.Extensions.AI/SentryChatClient.cs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/Sentry.Extensions.AI/SentryChatClient.cs b/src/Sentry.Extensions.AI/SentryChatClient.cs index d7f022ce0e..fd8b81faac 100644 --- a/src/Sentry.Extensions.AI/SentryChatClient.cs +++ b/src/Sentry.Extensions.AI/SentryChatClient.cs @@ -25,7 +25,7 @@ internal SentryChatClient(ActivitySource? activitySource, IChatClient client, Ac /// public override async Task GetResponseAsync(IEnumerable messages, ChatOptions? options = null, - CancellationToken cancellationToken = new()) + CancellationToken cancellationToken = default) { // Convert to array to avoid multiple enumeration var chatMessages = messages as ChatMessage[] ?? messages.ToArray(); @@ -58,7 +58,7 @@ public override async Task GetResponseAsync(IEnumerable GetStreamingResponseAsync( IEnumerable messages, ChatOptions? options = null, - [EnumeratorCancellation] CancellationToken cancellationToken = new()) + [EnumeratorCancellation] CancellationToken cancellationToken = default) { // Convert to array to avoid multiple enumeration var chatMessages = messages as ChatMessage[] ?? messages.ToArray(); From efa3b3b72b63cc5e7cc7340ae6041dc943109cbf Mon Sep 17 00:00:00 2001 From: Alex Sohn Date: Mon, 17 Nov 2025 12:08:08 -0500 Subject: [PATCH 85/86] Use older version of System.Diagnostics.DiagnosticSource --- src/Sentry.Extensions.AI/Sentry.Extensions.AI.csproj | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Sentry.Extensions.AI/Sentry.Extensions.AI.csproj b/src/Sentry.Extensions.AI/Sentry.Extensions.AI.csproj index b1d76c0c4c..95754ae836 100644 --- a/src/Sentry.Extensions.AI/Sentry.Extensions.AI.csproj +++ b/src/Sentry.Extensions.AI/Sentry.Extensions.AI.csproj @@ -20,7 +20,7 @@ - + From 3dce4d1054b8c090b6aa5b76787aced012d20983 Mon Sep 17 00:00:00 2001 From: Alex Sohn <44201357+alexsohn1126@users.noreply.github.com> Date: Mon, 17 Nov 2025 12:12:31 -0500 Subject: [PATCH 86/86] Update CHANGELOG.md --- CHANGELOG.md | 23 +---------------------- 1 file changed, 1 insertion(+), 22 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 4949654366..d6c0df2ec7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -14,28 +14,6 @@ - CaptureFeedback now returns a `SentryId` and a `CaptureFeedbackResult` out parameter that indicate whether feedback was captured successfully and what the reason for failure was otherwise ([#4613](https://github.com/getsentry/sentry-dotnet/pull/4613)) - Deprecated `Sentry.Azure.Functions.Worker` as very few people were using it and the functionality can easily be replaced with OpenTelemetry. We've replaced our integration with a sample showing how to do this using our OpenTelemetry package instead. ([#4693](https://github.com/getsentry/sentry-dotnet/pull/4693)) - UWP support has been dropped. Future efforts will likely focus on WinUI 3, in line with Microsoft's recommendations for building Windows UI apps. ([#4686](https://github.com/getsentry/sentry-dotnet/pull/4686)) -- The _Structured Logs_ APIs are now stable: removed `Experimental` from `SentryOptions` ([#4699](https://github.com/getsentry/sentry-dotnet/pull/4699)) - -### Features - -- Implemented instance isolation so that multiple instances of the Sentry SDK can be instantiated inside the same process when using the Caching Transport ([#4498](https://github.com/getsentry/sentry-dotnet/pull/4498)) -- The SDK now makes use of the new SessionEndStatus `Unhandled` when capturing an unhandled but non-terminal exception, i.e. through the UnobservedTaskExceptionIntegration ([#4633](https://github.com/getsentry/sentry-dotnet/pull/4633), [#4653](https://github.com/getsentry/sentry-dotnet/pull/4653)) -- The SDK now provides a `IsSessionActive` to allow checking the session state ([#4662](https://github.com/getsentry/sentry-dotnet/pull/4662)) -- The SDK now makes use of the new SessionEndStatus `Unhandled` when capturing an unhandled but non-terminal exception, i.e. through the UnobservedTaskExceptionIntegration ([#4633](https://github.com/getsentry/sentry-dotnet/pull/4633)) -- Added a new SDK `Sentry.Extensions.AI` which allows LLM usage instrumentation via `Microsoft.Extensions.AI` ([#4657](https://github.com/getsentry/sentry-dotnet/pull/4657)) -- Added experimental support for Session Replay on iOS ([#4664](https://github.com/getsentry/sentry-dotnet/pull/4664)) -- Extended the App context by `app_memory` that can hold the amount of memory used by the application in bytes. ([#4707](https://github.com/getsentry/sentry-dotnet/pull/4707)) - -### Fixes - -- The `Serilog` integration captures _Structured Logs_ (when enabled) independently of captured Events and added Breadcrumbs ([#4691](https://github.com/getsentry/sentry-dotnet/pull/4691)) -- Deliver system breadcrumbs in the main thread on Android ([#4671](https://github.com/getsentry/sentry-dotnet/pull/4671)) -- Memory leak when finishing an unsampled Transaction that has started unsampled Spans ([#4717](https://github.com/getsentry/sentry-dotnet/pull/4717)) - -## 6.0.0-preview.2 - -### BREAKING CHANGES - - `BreadcrumbLevel.Critical` has been renamed to `BreadcrumbLevel.Fatal` for consistency with the other Sentry SDKs ([#4605](https://github.com/getsentry/sentry-dotnet/pull/4605)) - SentryOptions.IsEnvironmentUser now defaults to false on MAUI. The means the User.Name will no longer be set, by default, to the name of the device ([#4606](https://github.com/getsentry/sentry-dotnet/pull/4606)) - Remove unnecessary files from SentryCocoaFramework before packing ([#4602](https://github.com/getsentry/sentry-dotnet/pull/4602)) @@ -56,6 +34,7 @@ - The SDK now makes use of the new SessionEndStatus `Unhandled` when capturing an unhandled but non-terminal exception, i.e. through the UnobservedTaskExceptionIntegration ([#4633](https://github.com/getsentry/sentry-dotnet/pull/4633), [#4653](https://github.com/getsentry/sentry-dotnet/pull/4653)) - Implemented instance isolation so that multiple instances of the Sentry SDK can be instantiated inside the same process when using the Caching Transport ([#4498](https://github.com/getsentry/sentry-dotnet/pull/4498)) - Extended the App context by `app_memory` that can hold the amount of memory used by the application in bytes. ([#4707](https://github.com/getsentry/sentry-dotnet/pull/4707)) +- Added a new SDK `Sentry.Extensions.AI` which allows LLM usage instrumentation via `Microsoft.Extensions.AI` ([#4657](https://github.com/getsentry/sentry-dotnet/pull/4657)) ### Fixes