Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions .changeset/async-stack-traces.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
---
"PostHog": patch
"PostHog.AspNetCore": patch
---

Fix async exception stack trace frames.
91 changes: 89 additions & 2 deletions src/PostHog/ErrorTracking/ExceptionPropertiesBuilder.cs
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
using PostHog.Library;
using PostHog.Versioning;
using System.Reflection;
using System.Runtime.CompilerServices;
using System.Runtime.InteropServices;
using System.Text;

Expand Down Expand Up @@ -102,6 +104,17 @@ private static List<Dictionary<string, object>> BuildStackFrameList(Exception ex
}

var method = frame.GetMethod();
if (IsStackTraceHidden(method))
{
continue;
}

var displayMethod = GetDisplayMethod(method);
if (displayMethod != method && IsStackTraceHidden(displayMethod))
{
continue;
}

var fileName = frame.GetFileName();
var lineNumber = frame.GetFileLineNumber();
var columnNumber = frame.GetFileColumnNumber();
Expand All @@ -112,8 +125,8 @@ private static List<Dictionary<string, object>> BuildStackFrameList(Exception ex
["lang"] = "dotnet",
["filename"] = Path.GetFileName(fileName) ?? "",
["abs_path"] = fileName ?? "",
["function"] = method?.Name ?? "",
["module"] = method?.DeclaringType?.FullName ?? "",
["function"] = displayMethod?.Name ?? "",
["module"] = displayMethod?.DeclaringType?.FullName ?? "",
["lineno"] = lineNumber,
["colno"] = columnNumber
};
Expand All @@ -137,6 +150,80 @@ private static List<Dictionary<string, object>> BuildStackFrameList(Exception ex
return stackFrames;
}

private static MethodBase? GetDisplayMethod(MethodBase? method)
{
if (method?.Name != nameof(IAsyncStateMachine.MoveNext))
{
return method;
}

var stateMachineType = method?.DeclaringType;
if (stateMachineType is null ||
!stateMachineType.IsDefined(typeof(CompilerGeneratedAttribute), inherit: false))
{
return method;
}

var isAsyncStateMachine = typeof(IAsyncStateMachine).IsAssignableFrom(stateMachineType);
var declaringType = stateMachineType.DeclaringType;
if (declaringType is null)
{
return method;
}

foreach (var sourceMethod in declaringType.GetMethods(
BindingFlags.Instance | BindingFlags.Static | BindingFlags.Public |
BindingFlags.NonPublic | BindingFlags.DeclaredOnly))
{
var asyncStateMachine = sourceMethod.GetCustomAttribute<AsyncStateMachineAttribute>();
if (isAsyncStateMachine && IsStateMachineType(asyncStateMachine?.StateMachineType, stateMachineType))
Comment thread
marandaneto marked this conversation as resolved.
{
return sourceMethod;
}

if (HasAsyncIteratorStateMachineAttribute(sourceMethod, stateMachineType))
{
return sourceMethod;
}

var iteratorStateMachine = sourceMethod.GetCustomAttribute<IteratorStateMachineAttribute>();
if (IsStateMachineType(iteratorStateMachine?.StateMachineType, stateMachineType))
{
return sourceMethod;
}
}

return method;
}

private static bool HasAsyncIteratorStateMachineAttribute(MethodInfo sourceMethod, Type stateMachineType)
{
foreach (var attribute in sourceMethod.GetCustomAttributesData())
{
if (attribute.AttributeType.FullName == "System.Runtime.CompilerServices.AsyncIteratorStateMachineAttribute" &&
attribute.ConstructorArguments.FirstOrDefault().Value is Type attributedType &&
IsStateMachineType(attributedType, stateMachineType))
{
return true;
}
}

return false;
}

private static bool IsStateMachineType(Type? attributedType, Type stateMachineType)
=> attributedType == stateMachineType ||
(attributedType?.IsGenericType == true && stateMachineType.IsGenericType &&
attributedType.GetGenericTypeDefinition() == stateMachineType.GetGenericTypeDefinition());

private static bool IsStackTraceHidden(MethodBase? method)
=> method is not null &&
(HasStackTraceHiddenAttribute(method) || HasStackTraceHiddenAttribute(method.DeclaringType));

private static bool HasStackTraceHiddenAttribute(MemberInfo? member)
=> member?.GetCustomAttributesData().Any(attribute =>
attribute.AttributeType.FullName == "System.Diagnostics.StackTraceHiddenAttribute") == true;

private static SourceCodeContext BuildSourceCodeContext(
string absolutePath,
int lineNumber,
Expand Down
115 changes: 114 additions & 1 deletion tests/UnitTests/PostHogClientTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -1313,6 +1313,60 @@ public async Task CaptureExceptionWithDivideByZeroException() // based on PostHo
}
}

[Fact]
public async Task CaptureExceptionUsesLogicalAsyncMethodName()
{
var (_, requestHandler, client) = CreateClient();
var exception = await CreateExceptionAfterAwaitAsync();

client.CaptureException(exception, "some-distinct-id");
await client.FlushAsync();

var (_, _, properties) = ParseSingleEvent(requestHandler.GetReceivedRequestBody(indented: false));
var frames = GetStackFrames(GetFirstException(properties));

Assert.Contains(frames, frame =>
frame.GetProperty("function").GetString() == nameof(CreateExceptionAfterAwaitAsync) &&
frame.GetProperty("module").GetString() == typeof(TheCaptureExceptionMethod).FullName);
Assert.DoesNotContain(frames, frame => frame.GetProperty("function").GetString() == "MoveNext");
}

[Fact]
public async Task CaptureExceptionUsesLogicalAsyncIteratorMethodName()
{
var (_, requestHandler, client) = CreateClient();
var exception = await CreateExceptionFromAsyncIteratorAsync();

client.CaptureException(exception, "some-distinct-id");
await client.FlushAsync();

var (_, _, properties) = ParseSingleEvent(requestHandler.GetReceivedRequestBody(indented: false));
var frames = GetStackFrames(GetFirstException(properties));

Assert.Contains(frames, frame =>
frame.GetProperty("function").GetString() == nameof(ThrowFromAsyncIteratorAfterAwait) &&
frame.GetProperty("module").GetString() == typeof(TheCaptureExceptionMethod).FullName);
Assert.DoesNotContain(frames, frame => frame.GetProperty("function").GetString() == "MoveNext");
}

#if NET8_0_OR_GREATER
[Fact]
public async Task CaptureExceptionOmitsStackTraceHiddenFrames()
{
var (_, requestHandler, client) = CreateClient();
var exception = CreateExceptionThroughHiddenMethod();

client.CaptureException(exception, "some-distinct-id");
await client.FlushAsync();

var (_, _, properties) = ParseSingleEvent(requestHandler.GetReceivedRequestBody(indented: false));
var frames = GetStackFrames(GetFirstException(properties));

Assert.DoesNotContain(frames, frame =>
frame.GetProperty("function").GetString() == nameof(ThrowFromHiddenMethod));
}
#endif

[Fact]
public async Task CaptureExceptionWithAggregateException()
{
Expand Down Expand Up @@ -1571,6 +1625,65 @@ public static void Boom()
}
}

private static async Task<InvalidOperationException> CreateExceptionAfterAwaitAsync()
{
try
{
await Task.Yield();
throw new InvalidOperationException("Async exception");
}
catch (InvalidOperationException exception)
{
return exception;
}
}

private static async Task<InvalidOperationException> CreateExceptionFromAsyncIteratorAsync()
{
try
{
await foreach (var _ in ThrowFromAsyncIteratorAfterAwait())
{
}

throw new InvalidOperationException("Unreachable");
}
catch (InvalidOperationException exception)
{
return exception;
}
}

private static async IAsyncEnumerable<int> ThrowFromAsyncIteratorAfterAwait()
{
await Task.Yield();
if (DateTime.UtcNow.Year == 1)
{
yield return 0;
}

throw new InvalidOperationException("Async iterator exception");
}

#if NET8_0_OR_GREATER
private static InvalidOperationException CreateExceptionThroughHiddenMethod()
{
try
{
ThrowFromHiddenMethod();
throw new InvalidOperationException("Unreachable");
}
catch (InvalidOperationException exception)
{
return exception;
}
}

[System.Diagnostics.StackTraceHidden]
private static void ThrowFromHiddenMethod()
=> throw new InvalidOperationException("Hidden exception");
#endif

// This test is pretty expensive because it dynamically compiles and loads an assembly.
// Consider alternatives.
[Fact]
Expand Down Expand Up @@ -2322,4 +2435,4 @@ await Assert.ThrowsAsync<OperationCanceledException>(() =>
Assert.DoesNotContain(errorLogs, log =>
log.Message?.Contains("Failed to load feature flags", StringComparison.Ordinal) == true);
}
}
}
Loading