Stack: analyzing parameter values

Very often, looking at the fall stack, I want to see with what parameter values the calls were made. Under the debugger in VisualStudio, we can see these values. But what if the program is launched without a debugger and handles exceptions on its own? For answers, welcome to cat.
The question of parameter values is not idle for us. Almost the first question that developers ask when they try our crash reporter : “Can you see the parameter values?”
Well, let's examine the problem in more detail.
Regardless of whether an exception is handled by us or not, initially we have the Exception object itself (and its InnerException chain).
The fall stack is obtained from the Exception.StackTrace property , or you can get it in a little more detailed form by creating an object of type System.Diagnostics.StackTrace . And if the frames contained in StackTrace can determine which methods were called and what signatures they had, then the parameter values and object references (this) cannot be determined.
What to do? Since the runtime does not give out the information we need, we will try to collect it ourselves.
Take the simplest code:
publicvoidDoWork(string work) {
DoInnerWork(work, 5);
}
publicvoidDoInnerWork(string work, int times) {
object o = null;
o.ToString();
}
Let's wrap the contents of the try / catch methods. We will register each caught exception together with the values of the method parameters and send it further:
publicvoidDoWork(string work) {
try {
DoInnerWork(work, 5);
}
catch (Exception ex) {
LogifyAlert.Instance.TrackArguments(ex, work);
throw;
}
}
publicvoidDoInnerWork(string innerWork, this, int times) {
try {
object o = null;
o.ToString();
}
catch (Exception ex) {
LogifyAlert.Instance.TrackArguments(ex, this, innerWork, times);
throw;
}
}
The Track method will have a signature:
publicvoidTrackArguments(Exception ex, object instance, paramsobject[] args)
and it will add the values of the arguments to itself in an internal list or in a dictionary so that they can be bound to the corresponding lines from Exception.StackTrace . It is also important to clear the list at the right moments, otherwise its contents will become irrelevant for the second thrown exception. What are these moments? Entrance to the method and successful (without throwing an exception) exit from it, as well as entrance to the global exception handler. Something like this:
Warning, govnokod
publicvoidDoWork(string work) {
LogifyAlert.Instance.ResetTrackArguments();
try {
DoInnerWork(work, 5);
LogifyAlert.Instance.ResetTrackArguments();
}
catch (Exception ex) {
LogifyAlert.Instance.TrackArguments(ex, work);
throw;
}
}
publicvoidDoInnerWork(string innerWork, this, int times) {
LogifyAlert.Instance.ResetTrackArguments();
try {
object o = null;
o.ToString();
LogifyAlert.Instance.ResetTrackArguments();
}
catch (Exception ex) {
LogifyAlert.Instance.TrackArguments(ex, this, innerWork, times);
throw;
}
}
voidMethodWithHandledException(string work) {
LogifyAlert.Instance.ResetTrackArguments();
try {
DoInnerWork(work, 5);
LogifyAlert.Instance.ResetTrackArguments();
}
catch (Exception ex) {
HandleException(ex);
LogifyAlert.Instance.ResetTrackArguments();
}
}
voidCurrentDomain_UnhandledException(object sender, UnhandledExceptionEventArgs e) {
var map = LogifyAlert.Instance.MethodArgumentsMap;
ExceptionTracker.Reset();
// handle exception below
}
It looks enchanting, the code finally turned into something unreadable
How to bind parameter values to stack lines? By the serial number of the frame on the stack, of course! The moment we create System.Diagnostics.StackTrace, the current frame always has index 0, and the number of frames can be different. When an exception is thrown for the first time, the number of frames (stack depth) is maximum; in all subsequent rethrow of the same exception, the stack depth will be only less. Thus, the line number on the stack (for a specific exception) is the difference between the maximum and current stack depth. In the form of code:
publicvoidTrackArguments(Exception ex, MethodCallInfo call) {
StackTrace trace = new StackTrace(0, false);
int frameCount = trace.FrameCount;
MethodCallStackArgumentMap map;
if (!MethodArgumentsMap.TryGetValue(ex, out map)) {
map = new MethodCallStackArgumentMap();
map.FirstChanceFrameCount = frameCount;
MethodArgumentsMap[ex] = map;
}
int lineIndex = map.FirstChanceFrameCount - frameCount;
map[lineIndex] = call;
}
Where MethodCallInfo looks like this:
publicclassMethodCallInfo {
publicobject Instance { get; set; }
public MethodBase Method { get; set; }
public IList<object> Arguments { get; set; }
}
Binding done. We write it in the crash report, send it to the server along with Exception.StackTrace , and there we will figure out the display. We get something similar to: The

principle operability of the approach has been proved, now we need to make sure that the code does not become scary like a nuclear war, but ideally, that no code should be written at all.
We recall about such a useful thing in the household as AOP .
We try, for example, Castle.DynamicProxy , create an interceptor:
publicclassMethodParamsInterceptor : IInterceptor {
publicvoidIntercept(IInvocation invocation) {
try {
LogifyAlert.Instance.ResetTrackArguments();
invocation.Proceed();
LogifyAlert.Instance.ResetTrackArguments();
}
catch (Exception ex) {
LogifyAlert.Instance.TrackArguments(
ex,
CreateMethodCallInfo(invocation)
);
throw;
}
}
MethodCallInfo CreateMethodCallInfo(IInvocation invocation) {
MethodCallInfo result = new MethodCallInfo();
result.Method = invocation.Method;
result.Arguments = invocation.Arguments;
result.Instance = invocation.Proxy;
return result;
}
}
Connect the crash reporter:
var client = LogifyAlert.Instance;
client.ApiKey = "<my-api-key>";
client.StartExceptionsHandling();
Create a test class using an interceptor:
var proxy = generator.CreateClassProxy<ThrowTestExceptionHelper>(
new MethodParamsInterceptor()
);
proxy.DoWork("work");
We carry out and look at the result:

Everything worked well, but there are as many as a few BUT:
- The stack is now very cluttered with Castle frames.
- Interception only works with virtual methods and interfaces.
- Creating objects has become quite cumbersome.
The last point is the most critical - we will have to significantly rewrite the whole project only for the sake of parameter values on the stack. A game of sheepskin is hardly worth it.
Or maybe "there is the same, but with mother-of-pearl buttons"? And yet there is, PostSharp . We realize the aspect:
[AttributeUsage(AttributeTargets.Method |
AttributeTargets.Class |
AttributeTargets.Assembly |
AttributeTargets.Module)]
[Serializable]
publicclassCollectParamsAttribute : OnMethodBoundaryAspect {
publicoverrideboolCompileTimeValidate(MethodBase method) {
if (method.GetCustomAttribute(typeof(IgnoreCallTrackingAttribute)) != null ||
method.Name == "Dispose") {
returnfalse;
}
returnbase.CompileTimeValidate(method);
}
publicoverridevoidOnEntry(MethodExecutionArgs args) {
base.OnEntry(args);
LogifyAlert.Instance.ResetTrackArguments();
}
publicoverridevoidOnSuccess(MethodExecutionArgs args) {
LogifyAlert.Instance.ResetTrackArguments();
base.OnSuccess(args);
}
[MethodImpl(MethodImplOptions.NoInlining)]
publicoverridevoidOnException(MethodExecutionArgs args) {
if (args.Exception == null)
return;
if (args.Method != null && args.Arguments != null && args.Instance != this)
LogifyAlert.Instance.TrackArguments(args.Exception,
CreateMethodCallInfo(args));
base.OnException(args);
}
MethodCallInfo CreateMethodCallInfo(MethodExecutionArgs args) {
MethodCallInfo result = new MethodCallInfo();
result.Method = args.Method;
result.Arguments = args.Arguments;
result.Instance = args.Instance;
return result;
}
}
There are several nuances in the code. First: we forbid PostSharp to instrument the methods marked with the IgnoreCallTrackingAttribute attribute. For what? We recall this code:
voidCurrentDomain_UnhandledException(object sender, UnhandledExceptionEventArgs e) {
var map = LogifyAlert.Instance.MethodArgumentsMap;
ExceptionTracker.Reset();
// handle exception below
}
What happens when you call it if PostSharp rewrites it? The OnEntry method of the aspect is called, which first of all will clean up the call parameters that we collected with such difficulty. Epic Fail. Therefore, all methods where we need to access the MethodCallArgumentsTracker should be marked with the IgnoreCallTrackingAttribute attribute.
Second: we prohibit rewriting Dispose. It would seem,
The third nuance in the strange test:
if (args.Method != null && args.Arguments != null && args.Instance != this)
LogifyAlert.Instance.TrackArguments(
args.Exception,
CreateMethodCallInfo(args)
);
The fact is that PostSharp aggressively optimizes the code that it embeds in methods. And if we do not explicitly turn to the MethodExecutionArgs fields, then we get a completely kosher null in the values of these fields, which, of course, will make us all further logic meaningless.
So, with a flick of the wrist we apply the aspect to the entire assembly:
[assembly: CollectParams]
We execute and watch the crash report:

The stack looks
What other options are there besides PostSharp and the like?
First of all, this is writing a profiler and using the methods ICorProfilerInfo :: GetILFunctionBody and ICorProfilerInfo :: SetILFunctionBody in order to modify the bodies of methods directly during program execution. You can read a good series of articles on how to do this here . A good selection of links on the topic here.
pros
- using regular CLR capabilities;
- no modification of the source code is required at all;
- everything will work in runtime.
Minuses
- will work in runtime, which means a bit to slow down the program.
- profiler cannot be written in managed code.
- profiler assembly must be registered in the system
- Before starting the application, you need to properly configure the environment .
More are hacking methods, only hardcore worthy of Chuck Norris , who is known:

That there is described the approach of the fact that if you can correctly identify the addresses of some non-public functions and JIT-realization, you can try to gently use them to replace the IL- code methods immediately before compiling them into native code. The disadvantages are that defining function addresses correctly is not easy, and that they can change regularly with updates. So, the example from the article by the author simply did not work, because The required addresses could not be determined. Another minus - the approach will not work if the assembly was processed by NGen .
Another chic descriptionThe original method of intercepting methods was published by ForwardAA comrade , here on the hub It is possible that, with proper file refinement, his approach can be adapted to the task of collecting call argument values. From the pluses - it is likely that the approach will work even after processing the assembly with NGen .
Conclusion
The most reliable way to collect call argument values at the time the exception is thrown is using Postsharp. The Logify client can bind collected values to the stack that was written when an exception occurred. The resulting crash report due to this, in some cases, may turn out to be significantly more informative than containing only the stack.