.NET core startup hooks is a feature I really like, and I had a lot of fun with it in the past. Still, I had yet to find a legitimate use for them, and the opportunity finally came a few days ago.
What are startup hooks?
Let’s start by a quick catch-up, for those who don’t know what startup hooks are. The feature was introduced with .net core 2.2, and allows to execute any arbitrary code in a .net process before the Main entry point has a chance to run. This is done by declaring a
DOTNET_STARTUP_HOOKS environment variable, pointing to the assembly you want to inject. The target assembly must declare a
StartupHook class outside of any namespace, with a static
Initialize method. That method is the entry point of the hook.
Back to the story. If you follow me on social medias, you might know that I joined Datadog a few weeks ago. I’m working on improving the performance of the .net tracer. As with any performance work, one of the first steps is to setup tests to measure the impact of the optimizations. Datadog already has a reliability environment, where the product is tested against popular applications, and key indicators are measured such as response time, CPU usage, or memory consumption. This was a very good start, but I also wanted to get stats about GC, and more precisely the number of garbage collections.
How to measure this? From inside of the process, it’s just a matter of calling
GC.CollectionCount. From outside of the process it gets a bit trickier, as performance counters are not available for .net core applications. You can instead use ETW or event-pipes, as my former coworker Christophe Nasarre wrote back in the days. But this is quite a bit of work, and I was looking for a quick win. I needed an easy and unobtrusive way to inject my code inside of the applications we test. That’s when I remembered of startup hooks.
Using a startup hook to monitor GC collection count
The Datadog agent exposes a StatsD interface that can be used to push any arbitrary metric. My plan was to inject a thread in the target applications that would poll the number of collections and push it to the agent. Once you know about startup hooks, this is surprisingly straightforward to implement:
The code makes use of the DogStatsD-CSharp-Client nuget package. From there, it was just a matter of adding a
DOTNET_STARTUP_HOOKS environment variable, pointing to the hook, to start monitoring any .net core application. Or so I thought.
Loading an arbitrary assembly intro a process that has no prior knowledge of it comes with (at least) one tricky part: handling references. My startup hook depended on the
DogStatsD-CSharp-Client library, which itself had its own references, and all of those weren’t known to the target application at compilation time. This brought its fair share of dependency errors at runtime. Rather than trying to reconcile the errors on a case-per-case basis, I needed a way to isolate my dependencies from those of the target applications. .NET core does not support
AppDomain, but brings a worthy successor:
To take advantage of it, I separated my project into two assemblies:
GCCollector, that starts the background thread and pushes the metrics, and
GCStartupHook, which is the entry point of the startup hook. Inside, instead of directly referencing
GCCollector, I load it through a dedicated
AssemblyLoadContext, so that all of its dependencies are isolated:
In the implementation of the
AssemblyLoadContext, I needed to load all required dependencies. Rather than re-implementing the assembly resolve logic, I took advantage of new gem brought by .net core 3.0:
The way it works is very straightforward. The resolver is given the path to an assembly, in this case
ResolveAssemblyToPath is called, it’s going to use the associated
deps.json file in order to resolve dependencies just like if that assembly was a standalone application. Incredibly convenient for plugins… or for startup hooks.
With that, the hook is complete. All is left is publishing it, setting the
DOTNET_STARTUP_HOOKS environment, and the GC metrics are pushed to the agent!