diff --git a/.vscode/settings.json b/.vscode/settings.json index 47a8ff1f7f..1425c69a44 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -1,3 +1,4 @@ { - "omnisharp.useModernNet": true + "omnisharp.useModernNet": true, + "dotnet.defaultSolution": "Sentry.sln" } diff --git a/samples/Sentry.Samples.Console.Basic/Sentry.Samples.Console.Basic.csproj b/samples/Sentry.Samples.Console.Basic/Sentry.Samples.Console.Basic.csproj index fd327d2f31..ba11fdf51b 100644 --- a/samples/Sentry.Samples.Console.Basic/Sentry.Samples.Console.Basic.csproj +++ b/samples/Sentry.Samples.Console.Basic/Sentry.Samples.Console.Basic.csproj @@ -2,9 +2,10 @@ Exe - net6.0 + net7.0 enable enable + true diff --git a/src/Sentry/AttributeReader.cs b/src/Sentry/AttributeReader.cs index 1fa69e220b..2ad9fc9e0a 100644 --- a/src/Sentry/AttributeReader.cs +++ b/src/Sentry/AttributeReader.cs @@ -2,11 +2,6 @@ internal static class AttributeReader { - public static bool TryGetProjectDirectory(Assembly assembly, out string? projectDirectory) - { - projectDirectory = assembly.GetCustomAttributes() - .FirstOrDefault(x => x.Key == "Sentry.ProjectDirectory") - ?.Value; - return projectDirectory != null; - } + public static string? TryGetProjectDirectory(Assembly assembly) => + assembly.GetCustomAttributes().FirstOrDefault(x => x.Key == "Sentry.ProjectDirectory")?.Value; } diff --git a/src/Sentry/Internal/DebugStackTrace.cs b/src/Sentry/Internal/DebugStackTrace.cs index 1d92182b50..95c4e2c1ec 100644 --- a/src/Sentry/Internal/DebugStackTrace.cs +++ b/src/Sentry/Internal/DebugStackTrace.cs @@ -31,6 +31,9 @@ internal class DebugStackTrace : SentryStackTrace private static readonly Regex RegexAsyncReturn = new(@"^(.+`[0-9]+)\[\[", RegexOptions.Compiled | RegexOptions.CultureInvariant); + private static readonly Regex RegexNativeAOTInfo = new(@"^(.+)\.([^.]+\(.*\)) ?\+ ?0x", + RegexOptions.Compiled | RegexOptions.CultureInvariant); + internal DebugStackTrace(SentryOptions options) { _options = options; @@ -123,11 +126,14 @@ private IEnumerable CreateFrames(StackTrace stackTrace, bool i { var frames = _options.StackTraceMode switch { - StackTraceMode.Enhanced => EnhancedStackTrace.GetFrames(stackTrace).Select(p => p as StackFrame), + StackTraceMode.Enhanced => EnhancedStackTrace.GetFrames(stackTrace).Select(p => new RealStackFrame(p)), _ => stackTrace.GetFrames() // error CS8619: Nullability of reference types in value of type 'StackFrame?[]' doesn't match target type 'IEnumerable'. #if NETCOREAPP3_0 .Where(f => f is not null) + .Select(p => new RealStackFrame(p!)) +#else + .Select(p => new RealStackFrame(p)) #endif }; @@ -153,102 +159,149 @@ private IEnumerable CreateFrames(StackTrace stackTrace, bool i #endif // Remove the frames until the call for capture with the SDK - if (firstFrame - && isCurrentStackTrace - && stackFrame.GetMethod() is { } method - && method.DeclaringType?.AssemblyQualifiedName?.StartsWith("Sentry") == true) + if (firstFrame && isCurrentStackTrace) { - _options.LogDebug("Skipping initial stack frame '{0}'", method.Name); - continue; + string? frameInfo = null; + if (stackFrame.GetMethod() is { } method) + { + frameInfo = method.DeclaringType?.AssemblyQualifiedName; + } + + // Native AOT currently only exposes some method info at runtime via ToString(). + // See https://github.com/dotnet/runtime/issues/92869 + if (frameInfo is null && stackFrame.HasNativeImage()) + { + frameInfo = stackFrame.ToString(); + } + + if (frameInfo?.StartsWith("Sentry") is true) + { + _options.LogDebug("Skipping initial stack frame '{0}'", frameInfo); + continue; + } } firstFrame = false; - yield return CreateFrame(stackFrame); + if (CreateFrame(stackFrame) is { } frame) + { + yield return frame; + } + else + { + _options.LogDebug("Could not resolve stack frame '{0}'", stackFrame.ToString()); + } } } /// - /// Create a from a . + /// Native AOT implementation of CreateFrame. + /// Native frames have only limited method information at runtime (and even that can be disabled). + /// We try to parse that and also add addresses for server-side symbolication. /// - internal SentryStackFrame CreateFrame(StackFrame stackFrame) => InternalCreateFrame(stackFrame, true); + private SentryStackFrame? TryCreateNativeAOTFrame(IStackFrame stackFrame) + { + if (!stackFrame.HasNativeImage()) + { + return null; + } + + var frame = ParseNativeAOTToString(stackFrame.ToString()); + frame.ImageAddress = stackFrame.GetNativeImageBase(); + frame.InstructionAddress = stackFrame.GetNativeIP(); + return frame; + } + + // Method info is currently only exposed by ToString(), see https://github.com/dotnet/runtime/issues/92869 + // We only care about the case where the method is available (`StackTraceSupport` property is the default `true`): + // https://github.com/dotnet/runtime/blob/254230253da143a082f47cfaf8711627c0bf2faf/src/coreclr/nativeaot/System.Private.CoreLib/src/Internal/DeveloperExperience/DeveloperExperience.cs#L42 + internal static SentryStackFrame ParseNativeAOTToString(string info) + { + var frame = new SentryStackFrame(); + var match = RegexNativeAOTInfo.Match(info); + if (match is { Success: true, Groups.Count: 3 }) + { + frame.Module = match.Groups[1].Value; + frame.Function = match.Groups[2].Value; + } + return frame; + } /// /// Default the implementation of CreateFrame. /// - private SentryStackFrame InternalCreateFrame(StackFrame stackFrame, bool demangle) + private SentryStackFrame? TryCreateManagedFrame(IStackFrame stackFrame) { + if (stackFrame.GetMethod() is not { } method) + { + return null; + } + const string unknownRequiredField = "(unknown)"; - string? projectPath = null; - var frame = new SentryStackFrame(); - if (stackFrame.GetMethod() is { } method) + var frame = new SentryStackFrame { - frame.Module = method.DeclaringType?.FullName ?? unknownRequiredField; - frame.Package = method.DeclaringType?.Assembly.FullName; + Module = method.DeclaringType?.FullName ?? unknownRequiredField, + Package = method.DeclaringType?.Assembly.FullName + }; - if (_options.StackTraceMode == StackTraceMode.Enhanced && - stackFrame is EnhancedStackFrame enhancedStackFrame) - { - var stringBuilder = new StringBuilder(); - frame.Function = enhancedStackFrame.MethodInfo.Append(stringBuilder, false).ToString(); + if (stackFrame.Frame is EnhancedStackFrame enhancedStackFrame) + { + var stringBuilder = new StringBuilder(); + frame.Function = enhancedStackFrame.MethodInfo.Append(stringBuilder, false).ToString(); - if (enhancedStackFrame.MethodInfo.DeclaringType is { } declaringType) - { - stringBuilder.Clear(); - stringBuilder.AppendTypeDisplayName(declaringType); - - // Ben.Demystifier doesn't always include the namespace, even when fullName==true. - // It's important that the module name always be fully qualified, so that in-app frame - // detection works correctly. - var module = stringBuilder.ToString(); - frame.Module = declaringType.Namespace is { } ns && !module.StartsWith(ns) - ? $"{ns}.{module}" - : module; - } - } - else + if (enhancedStackFrame.MethodInfo.DeclaringType is { } declaringType) { - frame.Function = method.Name; + stringBuilder.Clear(); + stringBuilder.AppendTypeDisplayName(declaringType); + + // Ben.Demystifier doesn't always include the namespace, even when fullName==true. + // It's important that the module name always be fully qualified, so that in-app frame + // detection works correctly. + var module = stringBuilder.ToString(); + frame.Module = declaringType.Namespace is { } ns && !module.StartsWith(ns) + ? $"{ns}.{module}" + : module; } + } + else + { + frame.Function = method.Name; + } - // Originally we didn't skip methods from dynamic assemblies, so not to break compatibility: - if (_options.StackTraceMode != StackTraceMode.Original && method.Module.Assembly.IsDynamic) - { - frame.InApp = false; - } + // Originally we didn't skip methods from dynamic assemblies, so not to break compatibility: + if (_options.StackTraceMode != StackTraceMode.Original && method.Module.Assembly.IsDynamic) + { + frame.InApp = false; + } - AttributeReader.TryGetProjectDirectory(method.Module.Assembly, out projectPath); + if (AddDebugImage(method.Module) is { } moduleIdx && moduleIdx != DebugImageMissing) + { + frame.AddressMode = GetRelativeAddressMode(moduleIdx); - if (AddDebugImage(method.Module) is { } moduleIdx && moduleIdx != DebugImageMissing) + try { - frame.AddressMode = GetRelativeAddressMode(moduleIdx); - - try - { - var token = method.MetadataToken; - // The top byte is the token type, the lower three bytes are the record id. - // See: https://docs.microsoft.com/en-us/previous-versions/dotnet/netframework-4.0/ms404456(v=vs.100)#metadata-token-structure - var tokenType = token & 0xff000000; - // See https://docs.microsoft.com/en-us/dotnet/framework/unmanaged-api/metadata/cortokentype-enumeration - if (tokenType == 0x06000000) // CorTokenType.mdtMethodDef - { - frame.FunctionId = token & 0x00ffffff; - } - } - catch (InvalidOperationException) + var token = method.MetadataToken; + // The top byte is the token type, the lower three bytes are the record id. + // See: https://docs.microsoft.com/en-us/previous-versions/dotnet/netframework-4.0/ms404456(v=vs.100)#metadata-token-structure + var tokenType = token & 0xff000000; + // See https://docs.microsoft.com/en-us/dotnet/framework/unmanaged-api/metadata/cortokentype-enumeration + if (tokenType == 0x06000000) // CorTokenType.mdtMethodDef { - // method.MetadataToken may throw - // see https://learn.microsoft.com/en-us/dotnet/api/system.reflection.memberinfo.metadatatoken?view=net-6.0 - _options.LogDebug("Could not get MetadataToken for stack frame {0} from {1}", frame.Function, method.Module.Name); + frame.FunctionId = token & 0x00ffffff; } } + catch (InvalidOperationException) + { + // method.MetadataToken may throw + // see https://learn.microsoft.com/en-us/dotnet/api/system.reflection.memberinfo.metadatatoken?view=net-6.0 + _options.LogDebug("Could not get MetadataToken for stack frame {0} from {1}", frame.Function, method.Module.Name); + } } - frame.ConfigureAppFrame(_options); - if (stackFrame.GetFileName() is { } frameFileName) { - if (projectPath != null && frameFileName.StartsWith(projectPath, StringComparison.OrdinalIgnoreCase)) + if (AttributeReader.TryGetProjectDirectory(method.Module.Assembly) is { } projectPath + && frameFileName.StartsWith(projectPath, StringComparison.OrdinalIgnoreCase)) { frame.AbsolutePath = frameFileName; frameFileName = frameFileName[projectPath.Length..]; @@ -256,6 +309,23 @@ private SentryStackFrame InternalCreateFrame(StackFrame stackFrame, bool demangl frame.FileName = frameFileName; } + return frame; + } + + /// + /// Create a from a . + /// + internal SentryStackFrame? CreateFrame(IStackFrame stackFrame) + { + var frame = TryCreateManagedFrame(stackFrame); + frame ??= TryCreateNativeAOTFrame(stackFrame); + if (frame is null) + { + return null; + } + + frame.ConfigureAppFrame(_options); + // stackFrame.HasILOffset() throws NotImplemented on Mono 5.12 var ilOffset = stackFrame.GetILOffset(); if (ilOffset != StackFrame.OFFSET_UNKNOWN) @@ -270,19 +340,19 @@ private SentryStackFrame InternalCreateFrame(StackFrame stackFrame, bool demangl } var colNo = stackFrame.GetFileColumnNumber(); - if (lineNo > 0) + if (colNo > 0) { frame.ColumnNumber = colNo; } - if (demangle && _options.StackTraceMode != StackTraceMode.Enhanced) + if (stackFrame.Frame is not EnhancedStackFrame) { DemangleAsyncFunctionName(frame); DemangleAnonymousFunction(frame); DemangleLambdaReturnType(frame); } - if (_options.StackTraceMode == StackTraceMode.Enhanced) + if (stackFrame.Frame is EnhancedStackFrame) { // In Enhanced mode, Module (which in this case is the Namespace) // is already prepended to the function, after return type. diff --git a/src/Sentry/Internal/StackFrame.cs b/src/Sentry/Internal/StackFrame.cs new file mode 100644 index 0000000000..bb4980eb43 --- /dev/null +++ b/src/Sentry/Internal/StackFrame.cs @@ -0,0 +1,131 @@ +using Sentry.Internal.Extensions; +using Sentry.Extensibility; + +namespace Sentry.Internal; + +/// +/// Mockable variant of the Diagnostics.StackFrame. +/// This is necessary to test NativeAOT code that relies on extensions from StackFrameExtensions. +/// +internal interface IStackFrame +{ + StackFrame? Frame { get; } + + /// + /// Returns a pointer to the base address of the native image that this stack frame is executing. + /// + /// + /// A pointer to the base address of the native image or System.IntPtr.Zero if you're targeting the .NET Framework. + /// + public nint GetNativeImageBase(); + + /// + /// Gets an interface pointer to the start of the native code for the method that is being executed. + /// + /// + /// An interface pointer to the start of the native code for the method that is being + /// executed or System.IntPtr.Zero if you're targeting the .NET Framework. + /// + public nint GetNativeIP(); + + /// + /// Indicates whether the native image is available for the specified stack frame. + /// + /// + /// true if a native image is available for this stack frame; otherwise, false. + /// + public bool HasNativeImage(); + + /// + /// Gets the column number in the file that contains the code that is executing. + /// This information is typically extracted from the debugging symbols for the executable. + /// + /// + /// The file column number, or 0 (zero) if the file column number cannot be determined. + /// + public int GetFileColumnNumber(); + + /// + /// Gets the line number in the file that contains the code that is executing. This + /// information is typically extracted from the debugging symbols for the executable. + /// + /// + /// The file line number, or 0 (zero) if the file line number cannot be determined. + /// + public int GetFileLineNumber(); + + /// + /// Gets the file name that contains the code that is executing. This information + /// is typically extracted from the debugging symbols for the executable. + /// + /// + /// The file name, or null if the file name cannot be determined. + /// + public string? GetFileName(); + + /// + /// Gets the offset from the start of the Microsoft intermediate language (MSIL) + /// code for the method that is executing. This offset might be an approximation + /// depending on whether or not the just-in-time (JIT) compiler is generating debugging + /// code. The generation of this debugging information is controlled by the System.Diagnostics.DebuggableAttribute. + /// + /// + /// The offset from the start of the MSIL code for the method that is executing. + /// + public int GetILOffset(); + + /// + /// Gets the method in which the frame is executing. + /// + /// + /// The method in which the frame is executing. + /// + + public MethodBase? GetMethod(); + + /// + /// Builds a readable representation of the stack trace. + /// + /// + /// A readable representation of the stack trace. + /// + public string ToString(); +} + +internal class RealStackFrame : IStackFrame +{ + private readonly StackFrame _frame; + + public RealStackFrame(StackFrame frame) + { + _frame = frame; + } + + public StackFrame? Frame => _frame; + + public override string ToString() => _frame.ToString(); + + public int GetFileColumnNumber() => _frame.GetFileColumnNumber(); + + public int GetFileLineNumber() => _frame.GetFileLineNumber(); + + public string? GetFileName() => _frame.GetFileName(); + + public int GetILOffset() => _frame.GetILOffset(); + + public MethodBase? GetMethod() => _frame.GetMethod(); + +#if NET5_0_OR_GREATER + public nint GetNativeImageBase() => _frame.GetNativeImageBase(); + + public nint GetNativeIP() => _frame.GetNativeIP(); + + public bool HasNativeImage() => _frame.HasNativeImage(); +#else + public nint GetNativeImageBase() => default; + + public nint GetNativeIP() => default; + + public bool HasNativeImage() => false; +#endif +} diff --git a/test/Sentry.Tests/AttributeReaderTests.cs b/test/Sentry.Tests/AttributeReaderTests.cs index c1d9be89bd..25a5574e75 100644 --- a/test/Sentry.Tests/AttributeReaderTests.cs +++ b/test/Sentry.Tests/AttributeReaderTests.cs @@ -6,7 +6,6 @@ public class AttributeReaderTests public void Simple() { var assembly = typeof(AttributeReaderTests).Assembly; - Assert.True(AttributeReader.TryGetProjectDirectory(assembly, out var projectDirectory)); - Assert.NotNull(projectDirectory); + Assert.NotNull(AttributeReader.TryGetProjectDirectory(assembly)); } } diff --git a/test/Sentry.Tests/Internals/DebugStackTraceTests.CreateFrame_ForNativeAOT.verified.txt b/test/Sentry.Tests/Internals/DebugStackTraceTests.CreateFrame_ForNativeAOT.verified.txt new file mode 100644 index 0000000000..d6da3fed6b --- /dev/null +++ b/test/Sentry.Tests/Internals/DebugStackTraceTests.CreateFrame_ForNativeAOT.verified.txt @@ -0,0 +1,7 @@ +{ + function: DoSomething(int, long), + module: Foo.Bar, + in_app: true, + image_addr: 0x1, + instruction_addr: 0x2 +} \ No newline at end of file diff --git a/test/Sentry.Tests/Internals/DebugStackTraceTests.cs b/test/Sentry.Tests/Internals/DebugStackTraceTests.verify.cs similarity index 68% rename from test/Sentry.Tests/Internals/DebugStackTraceTests.cs rename to test/Sentry.Tests/Internals/DebugStackTraceTests.verify.cs index 62e68c4524..36d7b62162 100644 --- a/test/Sentry.Tests/Internals/DebugStackTraceTests.cs +++ b/test/Sentry.Tests/Internals/DebugStackTraceTests.verify.cs @@ -1,7 +1,11 @@ +#nullable enable // ReSharper disable once CheckNamespace // Stack trace filters out Sentry frames by namespace + namespace Other.Tests.Internals; +[UsesVerify] + public class DebugStackTraceTests { private class Fixture @@ -11,7 +15,7 @@ private class Fixture } private readonly Fixture _fixture = new(); - private static readonly string ThisNamespace = typeof(SentryStackTraceFactoryTests).Namespace; + private static readonly string ThisNamespace = typeof(SentryStackTraceFactoryTests).Namespace!; [Fact] public void CreateSentryStackFrame_AppNamespace_InAppFrame() @@ -19,9 +23,9 @@ public void CreateSentryStackFrame_AppNamespace_InAppFrame() var frame = new StackFrame(); var sut = _fixture.GetSut(); - var actual = sut.CreateFrame(frame); + var actual = sut.CreateFrame(new RealStackFrame(frame)); - Assert.True(actual.InApp); + Assert.True(actual?.InApp); } [Fact] @@ -31,9 +35,9 @@ public void CreateSentryStackFrame_AppNamespaceExcluded_NotInAppFrame() var sut = _fixture.GetSut(); var frame = new StackFrame(); - var actual = sut.CreateFrame(frame); + var actual = sut.CreateFrame(new RealStackFrame(frame)); - Assert.False(actual.InApp); + Assert.False(actual?.InApp); } [Theory] @@ -52,10 +56,10 @@ public void CreateSentryStackFrame_SystemType_NotInAppFrame(bool useEnhancedStac Assert.Equal(typeof(Convert), frame.GetMethod()?.DeclaringType); // Act - var actual = sut.CreateFrame(frame); + var actual = sut.CreateFrame(new RealStackFrame(frame)); // Assert - Assert.False(actual.InApp); + Assert.False(actual?.InApp); } [Fact] @@ -66,9 +70,9 @@ public void CreateSentryStackFrame_NamespaceIncludedAndExcluded_IncludesTakesPre var sut = _fixture.GetSut(); var frame = new StackFrame(); - var actual = sut.CreateFrame(frame); + var actual = sut.CreateFrame(new RealStackFrame(frame)); - Assert.True(actual.InApp); + Assert.True(actual?.InApp); } // https://github.com/getsentry/sentry-dotnet/issues/64 @@ -180,6 +184,43 @@ void CheckStackTraceIsUnchanged(SentryStackTrace stackTrace) } } + [Fact] + public void ParseNativeAOTToString() + { + var frame = DebugStackTrace.ParseNativeAOTToString( + "System.Runtime.CompilerServices.TaskAwaiter.HandleNonSuccessAndDebuggerNotification(Task) + 0x42 at offset 66 in file:line:column :0:0"); + Assert.Equal("System.Runtime.CompilerServices.TaskAwaiter", frame.Module); + Assert.Equal("HandleNonSuccessAndDebuggerNotification(Task)", frame.Function); + Assert.Null(frame.Package); + + frame = DebugStackTrace.ParseNativeAOTToString( + "Program.<
$>d__0.MoveNext() + 0xdd at offset 221 in file:line:column :0:0"); + Assert.Equal("Program.<
$>d__0", frame.Module); + Assert.Equal("MoveNext()", frame.Function); + Assert.Null(frame.Package); + + frame = DebugStackTrace.ParseNativeAOTToString( + "Sentry.Samples.Console.Basic!+0x4abb3b at offset 283 in file:line:column :0:0"); + Assert.Null(frame.Module); + Assert.Null(frame.Function); + Assert.Null(frame.Package); + } + + [Fact] + public Task CreateFrame_ForNativeAOT() + { + var sut = _fixture.GetSut(); + var frame = sut.CreateFrame(new StubNativeAOTStackFrame() + { + Function = "DoSomething(int, long)", + Module = "Foo.Bar", + ImageBase = 1, + IP = 2, + }); + + return VerifyJson(frame.ToJsonString()); + } + private class InjectableDebugStackTrace : DebugStackTrace { public InjectableDebugStackTrace(SentryOptions options) : base(options) { } @@ -193,4 +234,41 @@ public void Inject(int identifier) }); } } + internal class StubNativeAOTStackFrame : IStackFrame + { + internal string? Function; + internal string? Module; + internal nint ImageBase; + internal nint IP; + + public StackFrame? Frame => null; + + public int GetFileColumnNumber() => 0; + + public int GetFileLineNumber() => 0; + + public string? GetFileName() => null; + + public int GetILOffset() => StackFrame.OFFSET_UNKNOWN; + + public MethodBase? GetMethod() => null; + + public nint GetNativeImageBase() => ImageBase; + + public nint GetNativeIP() => IP; + + public bool HasNativeImage() => true; + + public override string ToString() + { + if (Function is not null && Module is not null) + { + return $"{Module}.{Function} + 0x{ImageBase:x} at offset 0x{IP - ImageBase:x} in file:line:column :0:0"; + } + else + { + return ""; + } + } + } }