diff --git a/CHANGELOG.md b/CHANGELOG.md index e6b899e623..8c41ce49e9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,10 @@ ## Unreleased +### Features + +- Sentry now includes an EXPERIMENTAL StringStackTraceFactory. This factory isn't as feature rich as the full `SentryStackTraceFactory`. However, it may provide better results if you are compiling your application AOT and not getting useful stack traces from the full stack trace factory. ([#4362](https://github.com/getsentry/sentry-dotnet/pull/4362)) + ### Fixes - Native AOT: don't load SentryNative on unsupported platforms ([#4347](https://github.com/getsentry/sentry-dotnet/pull/4347)) diff --git a/src/Sentry/Extensibility/ISentryStackTraceFactory.cs b/src/Sentry/Extensibility/ISentryStackTraceFactory.cs index db572f60a2..9345032b23 100644 --- a/src/Sentry/Extensibility/ISentryStackTraceFactory.cs +++ b/src/Sentry/Extensibility/ISentryStackTraceFactory.cs @@ -1,7 +1,7 @@ namespace Sentry.Extensibility; /// -/// Factory to from an . +/// Factory to create a from an . /// public interface ISentryStackTraceFactory { diff --git a/src/Sentry/Extensibility/SentryStackTraceFactory.cs b/src/Sentry/Extensibility/SentryStackTraceFactory.cs index 62bdf6f1f7..9f917e956e 100644 --- a/src/Sentry/Extensibility/SentryStackTraceFactory.cs +++ b/src/Sentry/Extensibility/SentryStackTraceFactory.cs @@ -14,11 +14,7 @@ public sealed class SentryStackTraceFactory : ISentryStackTraceFactory /// public SentryStackTraceFactory(SentryOptions options) => _options = options; - /// - /// Creates a from the optional . - /// - /// The exception to create the stacktrace from. - /// A Sentry stack trace. + /// public SentryStackTrace? Create(Exception? exception = null) { if (exception == null && !_options.AttachStacktrace) diff --git a/src/Sentry/Extensibility/StringStackTraceFactory.cs b/src/Sentry/Extensibility/StringStackTraceFactory.cs new file mode 100644 index 0000000000..ac8676b60e --- /dev/null +++ b/src/Sentry/Extensibility/StringStackTraceFactory.cs @@ -0,0 +1,106 @@ +using Sentry.Infrastructure; + +namespace Sentry.Extensibility; + +#if NET8_0_OR_GREATER + +/// +/// A rudimentary implementation of that simply parses the +/// string representation of the stack trace from an exception. This lacks many of the features +/// off the full . However, it may be useful in AOT compiled +/// applications where the full factory is not returning a useful stack trace. +/// +/// +/// This class is currently EXPERIMENTAL +/// +/// +/// This factory is designed for AOT scenarios, so only available for net8.0+ +/// +/// +/// +[Experimental(DiagnosticId.ExperimentalFeature)] +public partial class StringStackTraceFactory : ISentryStackTraceFactory +{ + private readonly SentryOptions _options; + private const string FullStackTraceLinePattern = @"at (?[^\.]+)\.(?.*?) in (?.*?):line (?\d+)"; + private const string StackTraceLinePattern = @"at (.+)\.(.+) \+"; + +#if NET9_0_OR_GREATER + [GeneratedRegex(FullStackTraceLinePattern)] + internal static partial Regex FullStackTraceLine { get; } +#else + internal static readonly Regex FullStackTraceLine = FullStackTraceLineRegex(); + + [GeneratedRegex(FullStackTraceLinePattern)] + private static partial Regex FullStackTraceLineRegex(); +#endif + +#if NET9_0_OR_GREATER + [GeneratedRegex(StackTraceLinePattern)] + private static partial Regex StackTraceLine { get; } +#else + private static readonly Regex StackTraceLine = StackTraceLineRegex(); + + [GeneratedRegex(StackTraceLinePattern)] + private static partial Regex StackTraceLineRegex(); +#endif + + /// + /// Creates a new instance of . + /// + /// The sentry options + public StringStackTraceFactory(SentryOptions options) + { + _options = options; + } + + /// + public SentryStackTrace? Create(Exception? exception = null) + { + _options.LogDebug("Source Stack Trace: {0}", exception?.StackTrace); + + var trace = new SentryStackTrace(); + var frames = new List(); + + var lines = exception?.StackTrace?.Split(Environment.NewLine, StringSplitOptions.RemoveEmptyEntries) ?? []; + foreach (var line in lines) + { + var fullMatch = FullStackTraceLine.Match(line); + if (fullMatch.Success) + { + frames.Add(new SentryStackFrame() + { + Module = fullMatch.Groups[1].Value, + Function = fullMatch.Groups[2].Value, + FileName = fullMatch.Groups[3].Value, + LineNumber = int.Parse(fullMatch.Groups[4].Value), + }); + continue; + } + + _options.LogDebug("Full stack frame match failed for: {0}", line); + var lineMatch = StackTraceLine.Match(line); + if (lineMatch.Success) + { + frames.Add(new SentryStackFrame() + { + Module = lineMatch.Groups[1].Value, + Function = lineMatch.Groups[2].Value + }); + continue; + } + + _options.LogDebug("Stack frame match failed for: {0}", line); + frames.Add(new SentryStackFrame() + { + Function = line + }); + } + + trace.Frames = frames; + _options.LogDebug("Created {0} with {1} frames.", "StringStackTrace", trace.Frames.Count); + return trace.Frames.Count != 0 ? trace : null; + } +} + +#endif diff --git a/src/Sentry/SentryOptions.cs b/src/Sentry/SentryOptions.cs index ceab5113bc..b39fc6df19 100644 --- a/src/Sentry/SentryOptions.cs +++ b/src/Sentry/SentryOptions.cs @@ -1631,7 +1631,16 @@ public IEnumerable GetAllExceptionProcessors() => ExceptionProcessorsProviders.SelectMany(p => p()); /// - /// Use custom . + /// + /// Use a custom . + /// + /// + /// By default, Sentry uses the to create stack traces and this implementation + /// offers the most comprehensive functionality. However, full stack traces are not available in AOT compiled + /// applications. If you are compiling your applications AOT and the stack traces that you see in Sentry are not + /// informative enough, you could consider using the StringStackTraceFactory instead. This is not as functional but + /// is guaranteed to provide at least _something_ useful in AOT compiled applications. + /// /// /// The stack trace factory. public SentryOptions UseStackTraceFactory(ISentryStackTraceFactory sentryStackTraceFactory) diff --git a/test/Directory.Build.props b/test/Directory.Build.props index 11475f416c..5c1a6a9d47 100644 --- a/test/Directory.Build.props +++ b/test/Directory.Build.props @@ -48,6 +48,7 @@ + @@ -59,7 +60,7 @@ - + diff --git a/test/Sentry.Tests/ApiApprovalTests.Run.DotNet8_0.verified.txt b/test/Sentry.Tests/ApiApprovalTests.Run.DotNet8_0.verified.txt index b3e2244f66..71d6bfb677 100644 --- a/test/Sentry.Tests/ApiApprovalTests.Run.DotNet8_0.verified.txt +++ b/test/Sentry.Tests/ApiApprovalTests.Run.DotNet8_0.verified.txt @@ -1516,6 +1516,12 @@ namespace Sentry.Extensibility public SentryStackTraceFactory(Sentry.SentryOptions options) { } public Sentry.SentryStackTrace? Create(System.Exception? exception = null) { } } + [System.Diagnostics.CodeAnalysis.Experimental("SENTRY0001")] + public class StringStackTraceFactory : Sentry.Extensibility.ISentryStackTraceFactory + { + public StringStackTraceFactory(Sentry.SentryOptions options) { } + public Sentry.SentryStackTrace? Create(System.Exception? exception = null) { } + } } namespace Sentry.Http { diff --git a/test/Sentry.Tests/ApiApprovalTests.Run.DotNet9_0.verified.txt b/test/Sentry.Tests/ApiApprovalTests.Run.DotNet9_0.verified.txt index b3e2244f66..71d6bfb677 100644 --- a/test/Sentry.Tests/ApiApprovalTests.Run.DotNet9_0.verified.txt +++ b/test/Sentry.Tests/ApiApprovalTests.Run.DotNet9_0.verified.txt @@ -1516,6 +1516,12 @@ namespace Sentry.Extensibility public SentryStackTraceFactory(Sentry.SentryOptions options) { } public Sentry.SentryStackTrace? Create(System.Exception? exception = null) { } } + [System.Diagnostics.CodeAnalysis.Experimental("SENTRY0001")] + public class StringStackTraceFactory : Sentry.Extensibility.ISentryStackTraceFactory + { + public StringStackTraceFactory(Sentry.SentryOptions options) { } + public Sentry.SentryStackTrace? Create(System.Exception? exception = null) { } + } } namespace Sentry.Http { diff --git a/test/Sentry.Tests/Internals/StringStackTraceFactoryTests.MethodGeneric.verified.txt b/test/Sentry.Tests/Internals/StringStackTraceFactoryTests.MethodGeneric.verified.txt new file mode 100644 index 0000000000..23cb07a00b --- /dev/null +++ b/test/Sentry.Tests/Internals/StringStackTraceFactoryTests.MethodGeneric.verified.txt @@ -0,0 +1,5 @@ +{ + FileName: {ProjectDirectory}Internals/StringStackTraceFactoryTests.cs, + Function: Tests.Internals.StringStackTraceFactoryTests.GenericMethodThatThrows[T](T value), + Module: Other +} \ No newline at end of file diff --git a/test/Sentry.Tests/Internals/StringStackTraceFactoryTests.cs b/test/Sentry.Tests/Internals/StringStackTraceFactoryTests.cs new file mode 100644 index 0000000000..bd2c65e6cf --- /dev/null +++ b/test/Sentry.Tests/Internals/StringStackTraceFactoryTests.cs @@ -0,0 +1,66 @@ +// ReSharper disable once CheckNamespace +// Stack trace filters out Sentry frames by namespace +namespace Other.Tests.Internals; + +#if PLATFORM_NEUTRAL && NET8_0_OR_GREATER + +public class StringStackTraceFactoryTests +{ + [Fact] + public Task MethodGeneric() + { + // Arrange + const int i = 5; + var exception = Record.Exception(() => GenericMethodThatThrows(i)); + + var options = new SentryOptions + { + AttachStacktrace = true + }; + var factory = new StringStackTraceFactory(options); + + // Act + var stackTrace = factory.Create(exception); + + // Assert; + var frame = stackTrace!.Frames.Single(x => x.Function!.Contains("GenericMethodThatThrows")); + return Verify(frame) + .IgnoreMembers( + x => x.Package, + x => x.LineNumber, + x => x.ColumnNumber, + x => x.InstructionAddress, + x => x.FunctionId) + .AddScrubber(x => x.Replace(@"\", @"/")); + } + + [MethodImpl(MethodImplOptions.NoInlining)] + private static void GenericMethodThatThrows(T value) => + throw new Exception(); + + [Theory] + [InlineData("at MyNamespace.MyClass.MyMethod in /path/to/file.cs:line 42", "MyNamespace", "MyClass.MyMethod", "/path/to/file.cs", "42")] + [InlineData("at Foo.Bar.Baz in C:\\code\\foo.cs:line 123", "Foo", "Bar.Baz", "C:\\code\\foo.cs", "123")] + public void FullStackTraceLine_ValidInput_Matches( + string input, string expectedModule, string expectedFunction, string expectedFile, string expectedLine) + { + var match = StringStackTraceFactory.FullStackTraceLine.Match(input); + Assert.True(match.Success); + Assert.Equal(expectedModule, match.Groups["Module"].Value); + Assert.Equal(expectedFunction, match.Groups["Function"].Value); + Assert.Equal(expectedFile, match.Groups["FileName"].Value); + Assert.Equal(expectedLine, match.Groups["LineNo"].Value); + } + + [Theory] + [InlineData("at MyNamespace.MyClass.MyMethod +")] + [InlineData("random text")] + [InlineData("at . in :line ")] + public void FullStackTraceLine_InvalidInput_DoesNotMatch(string input) + { + var match = StringStackTraceFactory.FullStackTraceLine.Match(input); + Assert.False(match.Success); + } +} + +#endif