Stack: analyzing parameter values

Published on December 26, 2017

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:

    public void DoWork(string work) {
        DoInnerWork(work, 5);
    }
    public void DoInnerWork(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:

    public void DoWork(string work) {
        try {
            DoInnerWork(work, 5);
        }
        catch (Exception ex) {
            LogifyAlert.Instance.TrackArguments(ex, work);
            throw;
        }
    }
    public void DoInnerWork(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:

    public void TrackArguments(Exception ex, object instance, params object[] 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
    public void DoWork(string work) {
        LogifyAlert.Instance.ResetTrackArguments();
        try {
            DoInnerWork(work, 5);
            LogifyAlert.Instance.ResetTrackArguments();
        }
        catch (Exception ex) {
            LogifyAlert.Instance.TrackArguments(ex, work);
            throw;
        }
    }
    public void DoInnerWork(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;
        }
    }
    void MethodWithHandledException(string work) {
        LogifyAlert.Instance.ResetTrackArguments();
        try {
            DoInnerWork(work, 5);
            LogifyAlert.Instance.ResetTrackArguments();
        }
        catch (Exception ex) {
            HandleException(ex);
            LogifyAlert.Instance.ResetTrackArguments();
        }
    }
    void CurrentDomain_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 shit . The first reaction is to tear down and forget, like a nightmare. It only stops that, whatever one may say, the principle is unchanged, and you still have to collect the parameter values yourself (carefully, 18+, a lot of mat). We will definitely solve the code beauty issues, but only after we get the system up and running.

    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:

    public void TrackArguments(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:

    public class MethodCallInfo {
        public object 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:

    public class MethodParamsInterceptor : IInterceptor {
        public void Intercept(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]
    public class CollectParamsAttribute : OnMethodBoundaryAspect {
        public override bool CompileTimeValidate(MethodBase method) {
            if (method.GetCustomAttribute(typeof(IgnoreCallTrackingAttribute)) != null ||
                method.Name == "Dispose") {
                return false;
            }
            return base.CompileTimeValidate(method);
        }
        public override void OnEntry(MethodExecutionArgs args) {
            base.OnEntry(args);
            LogifyAlert.Instance.ResetTrackArguments();
        }
        public override void OnSuccess(MethodExecutionArgs args) {
            LogifyAlert.Instance.ResetTrackArguments();
            base.OnSuccess(args);
        }
        [MethodImpl(MethodImplOptions.NoInlining)]
        public override void OnException(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:

    void CurrentDomain_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, and here why Luzhkov ? And then, that an exception flies to us from the depths of the application, and along the way the catch, finally and other code blocks are executed in full, the links to local objects are lost, the GC starts to clean them. In general, the probability of Dispose executing during this period is quite high, and to ditch the contents of LogifyAlert.Instance.MethodArgumentsMap “one tablet is enough”.

    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 like new and old, nothing more. Changes to the existing code are minimal. The result is close to perfect! Of the potential downsides is the use of PostSharp, as such, during the build process. Perhaps this will push someone away.

    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.