diff --git a/.generated.NoMobile.sln b/.generated.NoMobile.sln index d5e24c17e9..583e415999 100644 --- a/.generated.NoMobile.sln +++ b/.generated.NoMobile.sln @@ -276,6 +276,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 @@ -1295,6 +1303,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 @@ -1391,5 +1447,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} EndGlobalSection EndGlobal diff --git a/CHANGELOG.md b/CHANGELOG.md index 6b1ca3156e..d6c0df2ec7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -34,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 diff --git a/Sentry-CI-Build-Linux-NoMobile.slnf b/Sentry-CI-Build-Linux-NoMobile.slnf index 04b4b49290..a0bba5a7b5 100644 --- a/Sentry-CI-Build-Linux-NoMobile.slnf +++ b/Sentry-CI-Build-Linux-NoMobile.slnf @@ -23,6 +23,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.Compiler.Extensions\\Sentry.Compiler.Extensions.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", @@ -53,6 +56,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 f53160c6a9..c3cb98016e 100644 --- a/Sentry-CI-Build-Linux.slnf +++ b/Sentry-CI-Build-Linux.slnf @@ -25,6 +25,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.Compiler.Extensions\\Sentry.Compiler.Extensions.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.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 b83f3c8428..9ee5420055 100644 --- a/Sentry-CI-Build-Windows-arm64.slnf +++ b/Sentry-CI-Build-Windows-arm64.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", @@ -42,6 +44,7 @@ "src\\Sentry.Compiler.Extensions\\Sentry.Compiler.Extensions.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", @@ -59,6 +62,7 @@ "test\\Sentry.AspNetCore.Tests\\Sentry.AspNetCore.Tests.csproj", "test\\Sentry.AspNetCore.TestUtils\\Sentry.AspNetCore.TestUtils.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", diff --git a/Sentry-CI-Build-Windows.slnf b/Sentry-CI-Build-Windows.slnf index ac202e9d31..46f5fd978f 100644 --- a/Sentry-CI-Build-Windows.slnf +++ b/Sentry-CI-Build-Windows.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", @@ -42,6 +44,7 @@ "src\\Sentry.Compiler.Extensions\\Sentry.Compiler.Extensions.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", @@ -62,6 +65,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 8ed63506e9..ab4d5302ab 100644 --- a/Sentry-CI-Build-macOS.slnf +++ b/Sentry-CI-Build-macOS.slnf @@ -30,6 +30,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.Compiler.Extensions\\Sentry.Compiler.Extensions.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", @@ -67,6 +70,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 c8234ff35e..2f60ff4e6f 100644 --- a/Sentry-CI-CodeQL.slnf +++ b/Sentry-CI-CodeQL.slnf @@ -11,6 +11,7 @@ "src\\Sentry.Compiler.Extensions\\Sentry.Compiler.Extensions.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 cf1c80a04f..e04424c66c 100644 --- a/Sentry.sln +++ b/Sentry.sln @@ -275,6 +275,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 @@ -1294,6 +1302,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 @@ -1390,5 +1446,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} 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 b8ce932b8c..e4754f322d 100644 --- a/SentryNoMobile.slnf +++ b/SentryNoMobile.slnf @@ -24,6 +24,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.Compiler.Extensions\\Sentry.Compiler.Extensions.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", @@ -56,6 +59,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 df020bdb52..d6231a7e06 100644 --- a/SentryNoSamples.slnf +++ b/SentryNoSamples.slnf @@ -12,6 +12,7 @@ "src\\Sentry.Compiler.Extensions\\Sentry.Compiler.Extensions.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", @@ -33,6 +34,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..83f8731f28 --- /dev/null +++ b/samples/Sentry.Samples.ME.AI.AspNetCore/Program.cs @@ -0,0 +1,191 @@ +#nullable enable +using Microsoft.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.EnableLogs = true; +}); + +// 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() + .AddSentry(options => + { + options.Experimental.RecordInputs = true; + options.Experimental.RecordOutputs = true; + }); + +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(); + +// 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) 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)) + { + streamingResponse.Add(update.Text); + } + } + + var fullResponse = string.Concat(streamingResponse); + + return Results.Ok(new + { + message = "AI test with multiple tools completed successfully (streaming)", + response = fullResponse, + 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.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(); +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...") + ] + }.AddSentryToolInstrumentation(); +} 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..e4c8c3a6d7 --- /dev/null +++ b/samples/Sentry.Samples.ME.AI.AspNetCore/Sentry.Samples.ME.AI.AspNetCore.csproj @@ -0,0 +1,24 @@ + + + + net9.0 + + + + + + + + + sentry-sdks + sentry-dotnet + true + true + + + + + + + + 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..03883b3663 --- /dev/null +++ b/samples/Sentry.Samples.ME.AI.Console/Program.cs @@ -0,0 +1,106 @@ +using Microsoft.Extensions.AI; +using Microsoft.Extensions.Logging; + +using var loggerFactory = LoggerFactory.Create(builder => builder.AddConsole()); +var logger = loggerFactory.CreateLogger(); + +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."); +} + +// 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 => + { + // AI-specific settings + options.Experimental.RecordInputs = true; + options.Experimental.RecordOutputs = true; + }); + +var client = new ChatClientBuilder(openAiClient) + .UseFunctionInvocation() + .Build(); + +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", + 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.") + ] +}.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.", + options); + +logger.LogInformation("Response: {ResponseText}", response.Messages?.FirstOrDefault()?.Text ?? "No response"); + +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/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..39b28ca53d --- /dev/null +++ b/samples/Sentry.Samples.ME.AI.Console/Sentry.Samples.ME.AI.Console.csproj @@ -0,0 +1,26 @@ + + + + Exe + net9.0 + + + + + + + + + + sentry-sdks + sentry-dotnet + true + true + + + + + + + + diff --git a/src/Sentry.Extensions.AI/Extensions/SentryAIExtensions.cs b/src/Sentry.Extensions.AI/Extensions/SentryAIExtensions.cs new file mode 100644 index 0000000000..9f6c1d8195 --- /dev/null +++ b/src/Sentry.Extensions.AI/Extensions/SentryAIExtensions.cs @@ -0,0 +1,65 @@ +using Sentry.Extensions.AI; +using Sentry.Infrastructure; + +namespace Microsoft.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 + /// + /// + /// 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 not { 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); + } + } + + return options; + } + + /// + /// Wraps an IChatClient with Sentry agent instrumentation. + /// + /// + /// + /// 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) => + AddSentry(client, SentryAIActivityListener.Instance, configure); + + /// + /// Internal overload for testing + /// + internal static IChatClient AddSentry(this IChatClient client, ActivityListener listener, Action? configure = null) + { + ActivitySource.AddActivityListener(listener); + 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 new file mode 100644 index 0000000000..95754ae836 --- /dev/null +++ b/src/Sentry.Extensions.AI/Sentry.Extensions.AI.csproj @@ -0,0 +1,30 @@ + + + + $(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/src/Sentry.Extensions.AI/SentryAIActivityListener.cs b/src/Sentry.Extensions.AI/SentryAIActivityListener.cs new file mode 100644 index 0000000000..33150184de --- /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/SentryAIActivitySource.cs b/src/Sentry.Extensions.AI/SentryAIActivitySource.cs new file mode 100644 index 0000000000..c8834af41e --- /dev/null +++ b/src/Sentry.Extensions.AI/SentryAIActivitySource.cs @@ -0,0 +1,14 @@ +using Microsoft.Extensions.AI; + +namespace Sentry.Extensions.AI; + +/// Sentry's to be used in +internal static class SentryAIActivitySource +{ + 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 new file mode 100644 index 0000000000..837a2eaea4 --- /dev/null +++ b/src/Sentry.Extensions.AI/SentryAIConstants.cs @@ -0,0 +1,64 @@ +namespace Sentry.Extensions.AI; + +internal static class SentryAIConstants +{ + /// + /// 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"]; + + /// + /// The string we use to retrieve a Sentry span from the using a Fused property + /// + internal const string SentryFICCSpanAttributeName = "SentryFICCSpan"; + + 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 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"; + + // 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"; + + // Misc.. + internal const string Origin = "auto.ai.extensions"; + } + + internal static class SpanOperations + { + internal const string Chat = "chat"; + internal const string InvokeAgent = "invoke_agent"; + } +} diff --git a/src/Sentry.Extensions.AI/SentryAIOptions.cs b/src/Sentry.Extensions.AI/SentryAIOptions.cs new file mode 100644 index 0000000000..c653da85af --- /dev/null +++ b/src/Sentry.Extensions.AI/SentryAIOptions.cs @@ -0,0 +1,46 @@ +using Sentry.Infrastructure; + +namespace Sentry.Extensions.AI; + +/// +/// Sentry AI instrumentation options +/// +public class SentryAIOptions +{ + /// + /// Experimental Sentry AI features. + /// + /// + /// This and related experimental APIs may change in the future. + /// + [Experimental(DiagnosticId.ExperimentalFeature)] + public SentryAIExperimentalOptions Experimental { get; set; } = new(); + + /// + /// Experimental Sentry AI options. + /// + /// + /// This and related experimental APIs may change in the future. + /// + [Experimental(DiagnosticId.ExperimentalFeature)] + public sealed class SentryAIExperimentalOptions + { + /// + /// 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 new file mode 100644 index 0000000000..139bf03a11 --- /dev/null +++ b/src/Sentry.Extensions.AI/SentryAISpanEnricher.cs @@ -0,0 +1,212 @@ +using Microsoft.Extensions.AI; +using Sentry.Internal; + +namespace Sentry.Extensions.AI; + +/// +/// Populates various span attributes specific to AI +/// +internal static class SentryAISpanEnricher +{ + /// + /// Enriches a span with request information. + /// + internal static void EnrichWithRequest(ISpan span, ChatMessage[] messages, ChatOptions? options, + SentryAIOptions aiOptions, string operationName) + { + span.SetData(SentryAIConstants.SpanAttributes.OperationName, operationName); + span.SetOrigin(SentryAIConstants.SpanAttributes.Origin); + + if (options?.ModelId is { } modelId) + { + span.SetData(SentryAIConstants.SpanAttributes.RequestModel, modelId); + } + + if (aiOptions.Experimental.AgentName is { } agentName) + { + span.SetData(SentryAIConstants.SpanAttributes.AgentName, agentName); + } + + if (messages is { Length: > 0 } + && aiOptions.Experimental.RecordInputs + // Only add request messages if there is none currently + && !span.Data.ContainsKey(SentryAIConstants.SpanAttributes.RequestMessages)) + { + span.SetData(SentryAIConstants.SpanAttributes.RequestMessages, FormatRequestMessage(messages)); + } + + if (options?.Tools is { } tools) + { + span.SetData(SentryAIConstants.SpanAttributes.RequestAvailableTools, FormatAvailableTools(tools)); + } + + if (options?.Temperature is { } temperature) + { + span.SetData(SentryAIConstants.SpanAttributes.RequestTemperature, temperature); + } + + if (options?.MaxOutputTokens is { } maxOutputTokens) + { + span.SetData(SentryAIConstants.SpanAttributes.RequestMaxTokens, maxOutputTokens); + } + + if (options?.TopP is { } topP) + { + span.SetData(SentryAIConstants.SpanAttributes.RequestTopP, topP); + } + + if (options?.FrequencyPenalty is { } frequencyPenalty) + { + span.SetData(SentryAIConstants.SpanAttributes.RequestFrequencyPenalty, frequencyPenalty); + } + + if (options?.PresencePenalty is { } presencePenalty) + { + span.SetData(SentryAIConstants.SpanAttributes.RequestPresencePenalty, presencePenalty); + } + } + + /// + /// 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) + { + EnrichWithStreamingResponses(span, [.. response.ToChatResponseUpdates()], aiOptions); + } + + /// + /// Enriches the span using the list of streamed in . + /// + /// span to enrich + /// a list of + /// AI-specific options + internal static void EnrichWithStreamingResponses(ISpan span, List messages, + SentryAIOptions aiOptions) + { + 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(SentryAIConstants.SpanAttributes.ResponseModel, modelId); + } + + if (message.Text is { } responseText) + { + finalText.Append(responseText); + } + + // Only set tool call info in chat spans + if (message.FinishReason == ChatFinishReason.ToolCalls + && span.Operation == SentryAIConstants.SpanAttributes.ChatOperation) + { + PopulateToolCallsInfo(message.Contents, span); + } + } + + if (aiOptions.Experimental.RecordOutputs) + { + span.SetData(SentryAIConstants.SpanAttributes.ResponseText, finalText.ToString()); + } + + span.SetData(SentryAIConstants.SpanAttributes.UsageInputTokens, inputTokenCount); + span.SetData(SentryAIConstants.SpanAttributes.UsageOutputTokens, outputTokenCount); + span.SetData(SentryAIConstants.SpanAttributes.UsageTotalTokens, inputTokenCount + outputTokenCount); + } + + private static void PopulateToolCallsInfo(IList contents, ISpan span) + { + var functionContents = contents.OfType().ToArray(); + if (functionContents.Length > 0) + { + span.SetData(SentryAIConstants.SpanAttributes.ResponseToolCalls, + FormatFunctionCallContent(functionContents)); + } + } + + private static string FormatAvailableTools(IList tools) + { + return JsonSerializer.Serialize(tools.Select(tool => new + { + name = tool.Name, + description = tool.Description + })); + } + + private static string FormatRequestMessage(ChatMessage[] messages) + { + return JsonSerializer.Serialize(messages.Select(message => + { + var content = message.Role == ChatRole.Tool ? FunctionResultToObject(message.Contents) : message.Text; + + return new + { + role = message.Role, + content + }; + })); + + object FunctionResultToObject(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 JsonSerializer.Serialize(content.Select(functionCallContent => + { + string argumentsJson; + try + { + argumentsJson = JsonSerializer.Serialize(functionCallContent.Arguments); + } + catch + { + argumentsJson = "{\"error\": \"serialization_failed\"}"; + } + + return new + { + name = functionCallContent.Name, + type = "function_call", + arguments = argumentsJson + }; + })); + } +} diff --git a/src/Sentry.Extensions.AI/SentryAIUtil.cs b/src/Sentry.Extensions.AI/SentryAIUtil.cs new file mode 100644 index 0000000000..78dc1d8f51 --- /dev/null +++ b/src/Sentry.Extensions.AI/SentryAIUtil.cs @@ -0,0 +1,22 @@ +using Sentry.Internal; + +namespace Sentry.Extensions.AI; + +internal static class SentryAIUtil +{ + internal static ISpan? GetFICCSpan() + { + var currActivity = Activity.Current; + while (currActivity != null) + { + if (currActivity.GetFused(SentryAIConstants.SentryFICCSpanAttributeName) 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 new file mode 100644 index 0000000000..fd8b81faac --- /dev/null +++ b/src/Sentry.Extensions.AI/SentryChatClient.cs @@ -0,0 +1,168 @@ +using Microsoft.Extensions.AI; + +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) : 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); + } + + /// + public override async Task GetResponseAsync(IEnumerable messages, + ChatOptions? options = null, + CancellationToken cancellationToken = default) + { + // Convert to array to avoid multiple enumeration + var chatMessages = messages as ChatMessage[] ?? messages.ToArray(); + var agentSpan = TryGetAgentSpan(options); + var chatSpan = CreateChatSpan(agentSpan, options); + + try + { + SentryAISpanEnricher.EnrichWithRequest(chatSpan, chatMessages, options, _sentryAIOptions, + SentryAIConstants.SpanOperations.Chat); + SentryAISpanEnricher.EnrichWithRequest(agentSpan, chatMessages, options, _sentryAIOptions, + SentryAIConstants.SpanOperations.InvokeAgent); + + var response = await base.GetResponseAsync(chatMessages, options, cancellationToken).ConfigureAwait(false); + + SentryAISpanEnricher.EnrichWithResponse(chatSpan, response, _sentryAIOptions); + SentryAISpanEnricher.EnrichWithResponse(agentSpan, response, _sentryAIOptions); + AfterResponseCleanup(chatSpan, agentSpan); + + return response; + } + catch (Exception ex) + { + AfterResponseCleanup(chatSpan, agentSpan, ex); + throw; + } + } + + /// + public override async IAsyncEnumerable GetStreamingResponseAsync( + IEnumerable messages, + ChatOptions? options = null, + [EnumeratorCancellation] CancellationToken cancellationToken = default) + { + // Convert to array to avoid multiple enumeration + var chatMessages = messages as ChatMessage[] ?? messages.ToArray(); + var agentSpan = TryGetAgentSpan(options); + var chatSpan = CreateChatSpan(agentSpan, options); + + var responses = new List(); + + // Incorrect Roslyn analyzer error when doing await using on IAsyncDisposable + // 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(chatSpan, chatMessages, options, _sentryAIOptions, + SentryAIConstants.SpanOperations.Chat); + SentryAISpanEnricher.EnrichWithRequest(agentSpan, chatMessages, options, _sentryAIOptions, + SentryAIConstants.SpanOperations.InvokeAgent); + + while (true) + { + ChatResponseUpdate? current; + try + { + var hasNext = await enumerator.MoveNextAsync(); + + if (!hasNext) + { + SentryAISpanEnricher.EnrichWithStreamingResponses(chatSpan, responses, _sentryAIOptions); + SentryAISpanEnricher.EnrichWithStreamingResponses(agentSpan, responses, _sentryAIOptions); + AfterResponseCleanup(chatSpan, agentSpan); + + yield break; + } + + current = enumerator.Current; + responses.Add(current); + } + catch (Exception ex) + { + AfterResponseCleanup(chatSpan, agentSpan, ex); + throw; + } + + yield return current; + } + } + + /// + public override object? GetService(Type serviceType, object? serviceKey = null) => + serviceType == typeof(ActivitySource) ? _activitySource : base.GetService(serviceType, serviceKey); + + private void AfterResponseCleanup(ISpan chatSpan, ISpan agentSpan, 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 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(); + } + } + + private ISpan TryGetAgentSpan(ChatOptions? options) + { + // 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?.Tools == null) + { + return _hub.StartSpan(SentryAIConstants.SpanAttributes.InvokeAgentOperation, + SentryAIConstants.SpanAttributes.InvokeAgentDescription); + } + + // 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.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, + SentryAIConstants.SpanAttributes.InvokeAgentDescription); + } + + private ISpan CreateChatSpan(ISpan? agentSpan, ChatOptions? options) + { + var chatSpanName = options is null || string.IsNullOrEmpty(options.ModelId) + ? "chat unknown model" + : $"chat {options.ModelId}"; + 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 new file mode 100644 index 0000000000..09ae82b60e --- /dev/null +++ b/src/Sentry.Extensions.AI/SentryInstrumentedFunction.cs @@ -0,0 +1,58 @@ +using Microsoft.Extensions.AI; + +namespace Sentry.Extensions.AI; + +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) + { + var agentSpan = SentryAIUtil.GetFICCSpan(); + var toolSpan = InitToolSpan(agentSpan, arguments); + try + { + var result = await base.InvokeCoreAsync(arguments, cancellationToken).ConfigureAwait(false); + + if (result?.ToString() is { } resultString) + { + toolSpan.SetData(SentryAIConstants.SpanAttributes.ToolOutput, resultString); + } + + toolSpan.Finish(SpanStatus.Ok); + return result; + } + catch (Exception ex) + { + toolSpan.Finish(SpanStatus.InternalError); + _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. + _hub.BindException(ex, agentSpan); + } + throw; + } + } + + private ISpan InitToolSpan(ISpan? agentSpan, AIFunctionArguments arguments) + { + var spanName = $"execute_tool {Name}"; + + // 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); + + 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; + } +} diff --git a/src/Sentry/Sentry.csproj b/src/Sentry/Sentry.csproj index 59c1406c54..f409a46a43 100644 --- a/src/Sentry/Sentry.csproj +++ b/src/Sentry/Sentry.csproj @@ -154,6 +154,8 @@ + + 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..6624f53dc2 --- /dev/null +++ b/test/Sentry.Extensions.AI.Tests/ApiApprovalTests.Run.DotNet10_0.verified.txt @@ -0,0 +1,27 @@ +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 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..6624f53dc2 --- /dev/null +++ b/test/Sentry.Extensions.AI.Tests/ApiApprovalTests.Run.DotNet8_0.verified.txt @@ -0,0 +1,27 @@ +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 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..6624f53dc2 --- /dev/null +++ b/test/Sentry.Extensions.AI.Tests/ApiApprovalTests.Run.DotNet9_0.verified.txt @@ -0,0 +1,27 @@ +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 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 new file mode 100644 index 0000000000..8bb05ffc4c --- /dev/null +++ b/test/Sentry.Extensions.AI.Tests/Sentry.Extensions.AI.Tests.csproj @@ -0,0 +1,17 @@ + + + + $(CurrentTfms) + + + + + + + + + + + + + diff --git a/test/Sentry.Extensions.AI.Tests/SentryAIActivityListenerTests.cs b/test/Sentry.Extensions.AI.Tests/SentryAIActivityListenerTests.cs new file mode 100644 index 0000000000..9b94974df6 --- /dev/null +++ b/test/Sentry.Extensions.AI.Tests/SentryAIActivityListenerTests.cs @@ -0,0 +1,90 @@ +#nullable enable + +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() + { + // Arrange + var source = SentryAIActivitySource.CreateSource(); + + // Act + using var listener = SentryAIActivityListener.CreateListener(_fixture.Hub); + + // Assert + Assert.True(source.HasListeners()); + } + + [Fact] + public void ShouldListenTo_ReturnsTrueForSentryActivitySource() + { + // Arrange + var sourceName = SentryAIActivitySource.SentryActivitySourceName; + using var listener = SentryAIActivityListener.CreateListener(_fixture.Hub); + var activitySource = new ActivitySource(sourceName); + + // 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 + using var listener = SentryAIActivityListener.CreateListener(_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>()); + } + + [Fact] + public void Sample_ReturnsAllDataAndRecordedForFICCActivityNames() + { + // Arrange + var activityName = "orchestrate_tools"; + using var listener = SentryAIActivityListener.CreateListener(_fixture.Hub); + var source = SentryAIActivitySource.CreateSource(); + + // Act + using var activity = source.StartActivity(activityName); + + // Assert + Assert.NotNull(activity); + Assert.True(activity.IsAllDataRequested); + Assert.Equal(ActivitySamplingResult.AllDataAndRecorded, + activity.Recorded ? ActivitySamplingResult.AllDataAndRecorded : ActivitySamplingResult.None); + + _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 new file mode 100644 index 0000000000..bd0f25856c --- /dev/null +++ b/test/Sentry.Extensions.AI.Tests/SentryAIExtensionsTests.cs @@ -0,0 +1,69 @@ +#nullable enable +using Microsoft.Extensions.AI; + +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(listener); + + // Assert + Assert.IsType(result); + } + + [Fact] + public void WithSentry_IChatClient_WithConfiguration_PassesConfigurationToWrapper() + { + // Arrange + var mockClient = Substitute.For(); + using var listener = SentryAIActivityListener.CreateListener(_fixture.Hub); + var configureWasCalled = false; + + // Act + var result = mockClient.AddSentry(listener, options => + { + configureWasCalled = true; + options.Experimental.RecordInputs = false; + options.Experimental.RecordOutputs = false; + } + ); + + // Assert + Assert.IsType(result); + Assert.True(configureWasCalled); + } + + [Fact] + public void WithSentry_IChatClient_WithNullConfiguration_UsesDefaultConfiguration() + { + // Arrange + var mockClient = Substitute.For(); + using var listener = SentryAIActivityListener.CreateListener(_fixture.Hub); + + // Act + var result = mockClient.AddSentry(listener, 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..54d013913f --- /dev/null +++ b/test/Sentry.Extensions.AI.Tests/SentryAIOptionsTests.cs @@ -0,0 +1,74 @@ +#nullable enable + +namespace Sentry.Extensions.AI.Tests; + +public class SentryAIOptionsTests +{ + [Fact] + public void Constructor_SetsDefaultValues() + { + // Act + var options = new SentryAIOptions(); + + // Assert + Assert.True(options.Experimental.RecordInputs); + Assert.True(options.Experimental.RecordOutputs); + } + + [Fact] + public void IncludeRequestMessages_CanBeSet() + { + // Arrange + var options = new SentryAIOptions + { + // Act + Experimental = + { + RecordInputs = false + } + }; + + // Assert + Assert.False(options.Experimental.RecordInputs); + } + + [Fact] + public void IncludeResponseContent_CanBeSet() + { + // Arrange + var options = new SentryAIOptions + { + // Act + Experimental = + { + RecordOutputs = false + } + }; + + // Assert + Assert.False(options.Experimental.RecordOutputs); + } + + [Theory] + [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 + Experimental = + { + RecordInputs = includeRequest, + RecordOutputs = includeResponse + } + }; + + // Assert + 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 new file mode 100644 index 0000000000..5cd12bc00d --- /dev/null +++ b/test/Sentry.Extensions.AI.Tests/SentryAISpanEnricherTests.cs @@ -0,0 +1,290 @@ +#nullable enable +using Microsoft.Extensions.AI; + +namespace Sentry.Extensions.AI.Tests; + +public class SentryAISpanEnricherTests +{ + private class Fixture + { + private SentryOptions Options { get; } + public ISentryClient Client { get; } + public Hub Hub { get; set; } + + public Fixture() + { + Options = new SentryOptions + { + Dsn = ValidDsn, + TracesSampleRate = 1.0, + }; + + Hub = new Hub(Options); + Client = Substitute.For(); + } + } + + private readonly Fixture _fixture = new(); + + private static ChatMessage[] TestMessages() + { + var initialMessage = new ChatMessage(ChatRole.User, "Hello"); + + return [initialMessage]; + } + + private static ChatOptions TestChatOptions() + { + 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 + }; + } + + [Fact] + public void EnrichWithRequest_SetsData() + { + // 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, SentryAIConstants.SpanOperations.Chat); + + // Assert + 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] + public void EnrichWithRequest_SetsData_WithoutRequestMessages_WhenDisabled() + { + // 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() + { + Experimental = + { + RecordInputs = false + } + }; + + // Act + SentryAISpanEnricher.EnrichWithRequest(span, messages, chatOptions, aiOptions, SentryAIConstants.SpanOperations.Chat); + + // Assert + 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.Should().ContainKey(SentryAIConstants.SpanAttributes.RequestAvailableTools).WhoseValue.Should().NotBeNull(); + } + + + [Fact] + public void EnrichWithRequest_SetsBasicData_WhenChatOptionsNull() + { + // 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() + { + Experimental = + { + RecordInputs = false + } + }; + + // Act + SentryAISpanEnricher.EnrichWithRequest(span, messages, null, aiOptions, SentryAIConstants.SpanOperations.Chat); + + // Assert + 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); + 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 ToolCallSpan_EnrichWithResponse_SetsData() + { + // Arrange + 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"), + new FunctionCallContent("test-call-id", "TestFunction", new Dictionary { ["param"] = "value" }) + ]) + ]) + { + ModelId = "response-model-id", + Usage = new UsageDetails + { + InputTokenCount = 50, + OutputTokenCount = 25 + }, + FinishReason = ChatFinishReason.ToolCalls + }; + var aiOptions = new SentryAIOptions(); + + // Act + SentryAISpanEnricher.EnrichWithResponse(span, response, aiOptions); + + // Assert + 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 = SentryAIConstants.SpanAttributes.ChatOperation; + 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() + { + Experimental = + { + RecordOutputs = false + } + }; + + // Act + SentryAISpanEnricher.EnrichWithResponse(span, response, aiOptions); + + // Assert + span.Data.Should().NotContainKey(SentryAIConstants.SpanAttributes.ResponseText); + 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 + var transaction = _fixture.Hub.StartTransaction("test_transaction", "test"); + var span = transaction.StartChild(SentryAIConstants.SpanAttributes.ChatOperation, "test_desc"); + + var streamingMessages = new List + { + new() + { + Contents = [ + new TextContent("Hello "), + new UsageContent(new UsageDetails { InputTokenCount = 10, OutputTokenCount = 5 }) + ] + }, + new() + { + 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" })] + } + }; + + var aiOptions = new SentryAIOptions { Experimental = { RecordOutputs = true } }; + + // Act + SentryAISpanEnricher.EnrichWithStreamingResponses(span, streamingMessages, aiOptions); + + // Assert + 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] + public void EnrichWithStreamingResponses_SetsData_WithoutResponseContent_WhenDisabled() + { + // Arrange + const string spanOp = "test_operation"; + const string spanDesc = "test_description"; + var span = _fixture.Hub.StartSpan(spanOp, spanDesc); + + var streamingMessages = new List + { + new() + { + ModelId = "streaming-model-id", + Contents = [ + new TextContent("Hello world!"), + new UsageContent(new UsageDetails { InputTokenCount = 20, OutputTokenCount = 10 }) + ] + } + }; + + var aiOptions = new SentryAIOptions { Experimental = { RecordOutputs = false } }; + + // Act + SentryAISpanEnricher.EnrichWithStreamingResponses(span, streamingMessages, aiOptions); + + // Assert + span.Data.Should().NotContainKey(SentryAIConstants.SpanAttributes.ResponseText); + 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); + } +} diff --git a/test/Sentry.Extensions.AI.Tests/SentryChatClientTests.cs b/test/Sentry.Extensions.AI.Tests/SentryChatClientTests.cs new file mode 100644 index 0000000000..b4330b6a9c --- /dev/null +++ b/test/Sentry.Extensions.AI.Tests/SentryChatClientTests.cs @@ -0,0 +1,201 @@ +#nullable enable +using Microsoft.Extensions.AI; + +namespace Sentry.Extensions.AI.Tests; + +public class SentryChatClientTests +{ + private class Fixture + { + private SentryOptions Options { get; } + public IHub Hub { get; } + public ActivitySource Source { get; } = SentryAIActivitySource.CreateSource(); + public IChatClient InnerClient = Substitute.For(); + + public Fixture() + { + Options = new SentryOptions + { + Dsn = ValidDsn, + TracesSampleRate = 1.0, + }; + + SentrySdk.Init(Options); + Hub = SentrySdk.CurrentHub; + } + + public SentryChatClient GetSut() => new SentryChatClient(Source, InnerClient); + } + + private readonly Fixture _fixture = new(); + + [Fact] + 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 message = new ChatMessage(ChatRole.Assistant, "ok"); + var chatResponse = new ChatResponse(message); + + _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 _fixture.InnerClient.Received(1).GetResponseAsync(Arg.Any>(), Arg.Any(), + Arg.Any()); + + 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.True(chatSpan.IsFinished); + 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] + 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 sentryChatClient = _fixture.GetSut(); + var expectedException = new InvalidOperationException("Streaming failed"); + + _fixture.InnerClient.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 + Assert.Equal(expectedException.Message, res.Message); + 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.InternalError, chatSpan.Status); + Assert.True(chatSpan.IsFinished); + 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] + 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); + + _fixture.InnerClient.GetStreamingResponseAsync(Arg.Any>(), Arg.Any(), + Arg.Any()) + .Returns(CreateTestStreamingUpdatesAsync()); + var sentryChatClient = _fixture.GetSut(); + var results = new List(); + + // Act + await foreach (var update in sentryChatClient.GetStreamingResponseAsync([new ChatMessage(ChatRole.User, "hi")])) + { + results.Add(update); + } + + // Assert + _fixture.InnerClient.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); + + 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.True(chatSpan.IsFinished); + 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] + 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 expectedException = new InvalidOperationException("Streaming failed"); + _fixture.InnerClient.GetStreamingResponseAsync(Arg.Any>(), Arg.Any(), + Arg.Any()) + .Returns(CreateFailingStreamingUpdatesAsync(expectedException)); + var sentryChatClient = _fixture.GetSut(); + + // Act + var actualException = await Assert.ThrowsAsync(async () => + { + await foreach (var update in sentryChatClient.GetStreamingResponseAsync([new ChatMessage(ChatRole.User, "hi")])) + { + // Should not reach here due to exception + } + }); + + // Assert + 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.True(chatSpan.IsFinished); + 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) + { + 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(); + 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..f9ca323dd0 --- /dev/null +++ b/test/Sentry.Extensions.AI.Tests/SentryInstrumentedFunctionTests.cs @@ -0,0 +1,240 @@ +#nullable enable +using Microsoft.Extensions.AI; + +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 + var testFunction = AIFunctionFactory.Create(() => "test result", "TestFunction", "Test function description"); + var sentryFunction = new SentryInstrumentedFunction(testFunction, _fixture.Hub); + 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); + _fixture.Hub.Received(1).StartTransaction( + Arg.Any(), + Arg.Any>()); + } + + [Fact] + public async Task InvokeCoreAsync_WithNullResult_ReturnsNull() + { + // Arrange + var testFunction = AIFunctionFactory.Create(object? () => null, "TestFunction", "Test function description"); + var sentryFunction = new SentryInstrumentedFunction(testFunction, _fixture.Hub); + var arguments = new AIFunctionArguments(); + + // Act + var result = await sentryFunction.InvokeAsync(arguments); + + // Assert + if (result is JsonElement jsonElement) + { + Assert.Equal(JsonValueKind.Null, jsonElement.ValueKind); + } + else + { + Assert.Null(result); + } + + _fixture.Hub.Received(1).StartTransaction( + Arg.Any(), + Arg.Any>()); + } + + [Fact] + public async Task InvokeCoreAsync_WithJsonNullResult_ReturnsJsonElement() + { + // Arrange + var jsonNullElement = JsonSerializer.Deserialize("null"); + var testFunction = AIFunctionFactory.Create(() => jsonNullElement, "TestFunction", "Test function description"); + var sentryFunction = new SentryInstrumentedFunction(testFunction, _fixture.Hub); + 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); + _fixture.Hub.Received(1).StartTransaction( + Arg.Any(), + Arg.Any>()); + } + + [Fact] + public async Task InvokeCoreAsync_WithJsonElementResult_CallsToStringForSpanOutput() + { + // Arrange + var jsonElement = JsonSerializer.Deserialize("\"test output\""); + var testFunction = AIFunctionFactory.Create(() => jsonElement, "TestFunction", "Test function description"); + var sentryFunction = new SentryInstrumentedFunction(testFunction, _fixture.Hub); + 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()); + _fixture.Hub.Received(1).StartTransaction( + Arg.Any(), + Arg.Any>()); + } + + [Fact] + public async Task InvokeCoreAsync_WithComplexResult_ReturnsObject() + { + // Arrange + var resultObject = new + { + message = "test", + count = 42 + }; + var testFunction = AIFunctionFactory.Create(() => resultObject, "TestFunction", "Test function description"); + var sentryFunction = new SentryInstrumentedFunction(testFunction, _fixture.Hub); + 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); + } + + _fixture.Hub.Received(1).StartTransaction( + Arg.Any(), + Arg.Any>()); + } + + [Fact] + public async Task InvokeCoreAsync_WhenFunctionThrows_PropagatesException() + { + // Arrange + var expectedException = new InvalidOperationException("Test exception"); + 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 + var actualException = await Assert.ThrowsAsync(async () => + 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 + var testFunction = AIFunctionFactory.Create((CancellationToken cancellationToken) => + { + cancellationToken.ThrowIfCancellationRequested(); + return "result"; + }, "TestFunction", "Test function description"); + + var sentryFunction = new SentryInstrumentedFunction(testFunction, _fixture.Hub); + var arguments = new AIFunctionArguments(); + var cts = new CancellationTokenSource(); + await cts.CancelAsync(); + + // 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 + var receivedArguments = (AIFunctionArguments?)null; + var testFunction = AIFunctionFactory.Create((AIFunctionArguments args) => + { + receivedArguments = args; + return "result"; + }, "TestFunction", "Test function description"); + + var sentryFunction = new SentryInstrumentedFunction(testFunction, _fixture.Hub); + var arguments = new AIFunctionArguments + { + ["param1"] = "value1" + }; + + // Act + await sentryFunction.InvokeAsync(arguments); + + // Assert + Assert.NotNull(receivedArguments); + Assert.Equal("value1", receivedArguments["param1"]); + _fixture.Hub.Received(1).StartTransaction( + Arg.Any(), + Arg.Any>()); + } + + [Fact] + public void Constructor_PreservesInnerFunctionProperties() + { + // Arrange + var testFunction = AIFunctionFactory.Create(() => "test", "TestFunction", "Test function description"); + + // Act + var sentryFunction = new SentryInstrumentedFunction(testFunction, _fixture.Hub); + + // Assert + Assert.Equal("TestFunction", sentryFunction.Name); + Assert.Equal("Test function description", sentryFunction.Description); + } +}