diff --git a/src/PostHog/ErrorTracking/ExceptionPropertiesBuilder.cs b/src/PostHog/ErrorTracking/ExceptionPropertiesBuilder.cs index c1cc453a..6b432f7b 100644 --- a/src/PostHog/ErrorTracking/ExceptionPropertiesBuilder.cs +++ b/src/PostHog/ErrorTracking/ExceptionPropertiesBuilder.cs @@ -103,6 +103,7 @@ private static List> BuildStackFrameList(Exception ex var method = frame.GetMethod(); var fileName = frame.GetFileName(); + var moduleName = method?.DeclaringType?.FullName ?? ""; var lineNumber = frame.GetFileLineNumber(); var columnNumber = frame.GetFileColumnNumber(); @@ -112,8 +113,8 @@ private static List> BuildStackFrameList(Exception ex ["lang"] = "dotnet", ["filename"] = Path.GetFileName(fileName) ?? "", ["abs_path"] = fileName ?? "", - ["function"] = method?.Name ?? "", - ["module"] = method?.DeclaringType?.FullName ?? "", + ["function"] = BuildFunctionName(method?.Name, moduleName, fileName), + ["module"] = moduleName, ["lineno"] = lineNumber, ["colno"] = columnNumber }; @@ -137,6 +138,21 @@ private static List> BuildStackFrameList(Exception ex return stackFrames; } + private static string BuildFunctionName(string? methodName, string moduleName, string? fileName) + { + if (string.IsNullOrEmpty(methodName)) + { + return ""; + } + + if (!string.IsNullOrEmpty(fileName) || string.IsNullOrEmpty(moduleName)) + { + return methodName; + } + + return $"{moduleName}.{methodName}"; + } + private static SourceCodeContext BuildSourceCodeContext( string absolutePath, int lineNumber, diff --git a/tests/UnitTests/PostHogClientTests.cs b/tests/UnitTests/PostHogClientTests.cs index 4009ae0a..b2203134 100644 --- a/tests/UnitTests/PostHogClientTests.cs +++ b/tests/UnitTests/PostHogClientTests.cs @@ -1278,6 +1278,71 @@ public static void Boom() // This test is pretty expensive because it dynamically compiles and loads an assembly. // Consider alternatives. + [Fact] + public async Task CaptureExceptionWithoutSourceInfoQualifiesFunctionName() + { + var (_, requestHandler, client) = CreateClient(); + + var code = + """ + using System; + namespace SourceLessFrames; + + public static class Thrower + { + public static void Boom() + { + throw new InvalidOperationException("No source info"); + } + } + """; + + var tree = CSharpSyntaxTree.ParseText(SourceText.From(code, Encoding.UTF8)); + var trustedPlatformAssemblies = ((string)AppContext.GetData("TRUSTED_PLATFORM_ASSEMBLIES")!).Split(Path.PathSeparator); + + var refs = trustedPlatformAssemblies + .Where(p => p.EndsWith(".dll", StringComparison.OrdinalIgnoreCase)) + .GroupBy(Path.GetFileName, StringComparer.OrdinalIgnoreCase) + .Select(g => MetadataReference.CreateFromFile(g.First())); + + var comp = CSharpCompilation.Create( + $"ThrowerAsm{Guid.NewGuid():N}", + [tree], + refs, + new CSharpCompilationOptions(OutputKind.DynamicallyLinkedLibrary, optimizationLevel: OptimizationLevel.Release)); + + using var pe = new MemoryStream(); + var emit = comp.Emit(pe); + Assert.True(emit.Success, string.Join(Environment.NewLine, emit.Diagnostics)); + + pe.Position = 0; + + var assemblyLoadContext = new AssemblyLoadContext($"ThrowerCtx{Guid.NewGuid():N}", isCollectible: true); + var assembly = assemblyLoadContext.LoadFromStream(pe); + var boom = assembly.GetType("SourceLessFrames.Thrower")!.GetMethod("Boom", BindingFlags.Public | BindingFlags.Static)!; + + try + { + boom.Invoke(null, null); + } + catch (TargetInvocationException tie) when (tie.InnerException is InvalidOperationException ex) + { + client.CaptureException(ex, "some-distinct-id"); + await client.FlushAsync(); + + var (_, batchItem, props) = ParseSingleEvent(requestHandler.GetReceivedRequestBody(indented: true)); + var invalidOperationException = GetExceptionOfType(props, "System.InvalidOperationException"); + var frames = GetStackFrames(invalidOperationException); + + Assert.Equal("$exception", batchItem.GetProperty("event").GetString()); + Assert.Equal("", frames[0].GetProperty("filename").GetString()); + Assert.Equal("", frames[0].GetProperty("abs_path").GetString()); + Assert.Equal("SourceLessFrames.Thrower", frames[0].GetProperty("module").GetString()); + Assert.Equal("SourceLessFrames.Thrower.Boom", frames[0].GetProperty("function").GetString()); + Assert.Equal(0, frames[0].GetProperty("lineno").GetInt32()); + } + } + [Fact] public async Task CaptureExceptionWithInvalidFilePathInStackFrame() { @@ -1338,10 +1403,13 @@ public static void Boom() var divideByZeroException = GetExceptionOfType(props, "System.DivideByZeroException"); var frames = GetStackFrames(divideByZeroException); + var sourceFrame = frames.FirstOrDefault(f => + f.TryGetProperty("filename", out var filename) && + string.Equals(filename.GetString(), fakePath, StringComparison.Ordinal)); Assert.Equal("$exception", batchItem.GetProperty("event").GetString()); - Assert.Equal(fakePath, frames[0].GetProperty("filename").GetString()); - AssertContextEmpty(frames[0]); + Assert.NotEqual(JsonValueKind.Undefined, sourceFrame.ValueKind); + AssertContextEmpty(sourceFrame); } } @@ -2009,4 +2077,4 @@ await Assert.ThrowsAsync(() => Assert.DoesNotContain(errorLogs, log => log.Message?.Contains("Failed to load feature flags", StringComparison.Ordinal) == true); } -} \ No newline at end of file +}