Using the DiagnosticSource in .NET Core: Theory
DiagnosticSource is a simple but very useful set of APIs (available in the System.Diagnostics.DiagnosticSource NuGet package ), which, on the one hand, allows different libraries to send named events about their work, and on the other hand, allows applications to subscribe to these events and process their.
Each such event contains additional information (payload), and since event processing occurs in the same process as sending, this information can contain almost any objects without the need for serialization / deserialization.
DiagnosticSource already used in AspNetCore, EntityFrameworkCore, HttpClient, and the SqlClient, that actually gives developers the ability to intercept incoming / outgoing http requests, database queries, access to objects such as HttpContext
, DbConnection
, DbCommand
, HttpRequestMessage
and many others, and even modify these objects if necessary.
I decided to divide my story about DiagnosticSource into two articles. In this article, we will use a simple example to analyze the principle of operation of the mechanism, and in the next I will talk about events that exist in .NET that can be processed with its help and show several examples of its use in OZON.ru.
Example
To better understand how DiagnosticSource works, consider a small example c intercepting database requests. Imagine that we have a simple console application that makes a request to the database and outputs the result to the console.
publicstaticclassProgram
{
publicconststring ConnectionString =
@"Data Source=localhost;Initial Catalog=master;User ID=sa;Password=Password12!;";
publicstaticasync Task Main()
{
var answer = await GetAnswerAsync();
Console.WriteLine(answer);
}
publicstaticasync Task<int> GetAnswerAsync()
{
using (var connection = new SqlConnection(ConnectionString))
{
// using Dapperreturnawait connection.QuerySingleAsync<int>("SELECT 42;");
}
}
}
For simplicity, I picked up SQL Server in a docker container.
docker run --rm --detach --name mssql-server \
--publish 1433:1433 \
--env ACCEPT_EULA=Y \
--env SA_PASSWORD=Password12! \
mcr.microsoft.com/mssql/server:2017-latest
Now let's imagine that we have a task: we need to measure the execution time of all queries to the database using Stopwatch
and output the "Request" - "Runtime" pairs to the console.
Of course, you can simply wrap the call with a QuerySingleAsync
code that will create and start an instance Stopwatch
, stop it after the query has been executed, and display the result, but then several difficulties arise at once:
- What if the application has more than one request, and much more?
- What if the code that executes the request is already compiled, connected to the application as a NuGet package, and we do not have the ability to change it?
- What if the database request is not done via Dapper, but through EntityFramework, for example, and we do not have access to either the object
DbCommand
or the generated text of the request that will actually be executed?
Let's try to solve this problem using DiagnosticSource.
Using the System.Diagnostics.DiagnosticSource NuGet Package
The first thing to do after connecting the System.Diagnostics.DiagnosticSource package NuGet is to create a class that will handle the events of interest to us:
publicsealedclassExampleDiagnosticObserver
{ }
In order to start processing events, you need to create an instance of this class and register it with an observer in a static object DiagnosticListener.AllListeners
(located in the namespace System.Diagnostics
). Let's do this at the very beginning of the function Main
:
publicstaticasync Task Main()
{
var observer = new ExampleDiagnosticObserver();
IDisposable subscription = DiagnosticListener.AllListeners.Subscribe(observer);
var answer = await GetAnswerAsync();
Console.WriteLine(answer);
}
In this case, the compiler will fairly tell us that the class ExampleDiagnosticObserver
should implement the interface IObserver<DiagnosticListener>
. Let's implement it:
publicsealedclassExampleDiagnosticObserver : IObserver<DiagnosticListener>
{
void IObserver<DiagnosticListener>.OnNext(DiagnosticListener diagnosticListener)
{
Console.WriteLine(diagnosticListener.Name);
}
void IObserver<DiagnosticListener>.OnError(Exception error)
{ }
void IObserver<DiagnosticListener>.OnCompleted()
{ }
}
If we run this code now, we will see that the following will be displayed in the console:
SqlClientDiagnosticListener
SqlClientDiagnosticListener
42
This means that somewhere in .NET there are two registered objects of the type DiagnosticListener
with the name "SqlClientDiagnosticListener"
that worked when executing this code.
На самом деле их три, но поскольку мы не использовали SqlTransaction
, сработало только два:
- https://github.com/dotnet/corefx/blob/v2.2.1/src/System.Data.SqlClient/src/System/Data/SqlClient/SqlConnection.cs#L50
- https://github.com/dotnet/corefx/blob/v2.2.1/src/System.Data.SqlClient/src/System/Data/SqlClient/SqlCommand.cs#L31
- https://github.com/dotnet/corefx/blob/v2.2.1/src/System.Data.SqlClient/src/System/Data/SqlClient/SqlTransaction.cs#L16
The method IObserver<DiagnosticListener>.OnNext
will be called once at the first use for each instance DiagnosticListener
that is created in the application (usually they are created as static properties). Now we just brought the name of the instances to the console DiagnosticListener
, but in practice this method needs to check this name and, if we are interested in processing events from this instance, subscribe to it using the method Subscribe
.
I also want to note that when DiagnosticListener.AllListeners.Subscribe
we call, we will get an object subscription
that implements the interface as a result IDisposable
. Calling a method Dispose
on this object will result in a reply that needs to be implemented in the method IObserver<DiagnosticListener>.OnCompleted
.
Let's implement IObserver<DiagnosticListener>
again:
publicsealedclassExampleDiagnosticObserver : IObserver<DiagnosticListener>
{
privatereadonly List<IDisposable> _subscriptions = new List<IDisposable>();
void IObserver<DiagnosticListener>.OnNext(DiagnosticListener diagnosticListener)
{
if (diagnosticListener.Name == "SqlClientDiagnosticListener")
{
var subscription = diagnosticListener.Subscribe(this);
_subscriptions.Add(subscription);
}
}
void IObserver<DiagnosticListener>.OnError(Exception error)
{ }
void IObserver<DiagnosticListener>.OnCompleted()
{
_subscriptions.ForEach(x => x.Dispose());
_subscriptions.Clear();
}
}
Now the compiler will tell us that our class ExampleDiagnosticObserver
should also implement the interface IObserver<KeyValuePair<string, object>>
. Here we need to implement a method IObserver<KeyValuePair<string, object>>.OnNext
that takes as a parameter KeyValuePair<string, object>
, where the key is the name of the event, and the value is an anonymous object (usually) with arbitrary parameters that we can use at our discretion. Let's add the implementation of this interfex:
publicsealedclassExampleDiagnosticObserver :
IObserver<DiagnosticListener>,
IObserver<KeyValuePair<string, object>>
{
// IObserver<DiagnosticListener> implementation// ...void IObserver<KeyValuePair<string, object>>.OnNext(KeyValuePair<string, object> pair)
{
Write(pair.Key, pair.Value);
}
void IObserver<KeyValuePair<string, object>>.OnError(Exception error)
{ }
void IObserver<KeyValuePair<string, object>>.OnCompleted()
{ }
privatevoidWrite(string name, objectvalue)
{
Console.WriteLine(name);
Console.WriteLine(value);
Console.WriteLine();
}
}
If we now run the resulting code, then the following will be displayed in the console:
System.Data.SqlClient.WriteConnectionOpenBefore
{ OperationId = 3da1b5d4-9ce1-4f28-b1ff-6a5bfc9d64b8, Operation = OpenAsync, Connection = System.Data.SqlClient.SqlConnection, Timestamp = 26978341062 }
System.Data.SqlClient.WriteConnectionOpenAfter
{ OperationId = 3da1b5d4-9ce1-4f28-b1ff-6a5bfc9d64b8, Operation = OpenAsync, ConnectionId = 84bd0095-9831-456b-8ebc-cb9dc2017368, Connection = System.Data.SqlClient.SqlConnection,
Statistics = System.Data.SqlClient.SqlStatistics+StatisticsDictionary, Timestamp = 26978631500 }
System.Data.SqlClient.WriteCommandBefore
{ OperationId = 5c6d300c-bc49-4f80-9211-693fa1e2497c, Operation = ExecuteReaderAsync, ConnectionId = 84bd0095-9831-456b-8ebc-cb9dc2017368, Command = System.Data.SqlClient.SqlComman
d }
System.Data.SqlClient.WriteCommandAfter
{ OperationId = 5c6d300c-bc49-4f80-9211-693fa1e2497c, Operation = ExecuteReaderAsync, ConnectionId = 84bd0095-9831-456b-8ebc-cb9dc2017368, Command = System.Data.SqlClient.SqlComman
d, Statistics = System.Data.SqlClient.SqlStatistics+StatisticsDictionary, Timestamp = 26978709490 }
System.Data.SqlClient.WriteConnectionCloseBefore
{ OperationId = 3f6bfd8f-e5f6-48b7-82c7-41aeab881142, Operation = Close, ConnectionId = 84bd0095-9831-456b-8ebc-cb9dc2017368, Connection = System.Data.SqlClient.SqlConnection, Stat
istics = System.Data.SqlClient.SqlStatistics+StatisticsDictionary, Timestamp = 26978760625 }
System.Data.SqlClient.WriteConnectionCloseAfter
{ OperationId = 3f6bfd8f-e5f6-48b7-82c7-41aeab881142, Operation = Close, ConnectionId = 84bd0095-9831-456b-8ebc-cb9dc2017368, Connection = System.Data.SqlClient.SqlConnection, Stat
istics = System.Data.SqlClient.SqlStatistics+StatisticsDictionary, Timestamp = 26978772888 }
42
In total we will see six events. Two of them are performed before and after opening a connection to the database, two before and after the command is executed, and two more before and after closing the connection to the database.
Each event includes a set of parameters such as OperationId
, ConnectionId
, Connection
, Command
, which are typically transmitted as an anonymous object properties. You can get the typed values of these properties, for example, by using reflection. (In practice, using reflection may not be very desirable. We use DynamicMethod to get event parameters.)
Now everything is ready for us to solve the original problem - measure the execution time of all queries in the database and output it to the console along with the original query.
Change the implementation of the method Write
as follows:
publicsealedclassExampleDiagnosticObserver :
IObserver<DiagnosticListener>,
IObserver<KeyValuePair<string, object>>
{
// IObserver<DiagnosticListener> implementation// ...// IObserver<KeyValuePair<string, object>> implementation// ...privatereadonly AsyncLocal<Stopwatch> _stopwatch = new AsyncLocal<Stopwatch>();
privatevoidWrite(string name, objectvalue)
{
switch (name)
{
case"System.Data.SqlClient.WriteCommandBefore":
{
// Этот код будет выполняться до выполнения запроса в базу данных
_stopwatch.Value = Stopwatch.StartNew();
break;
}
case"System.Data.SqlClient.WriteCommandAfter":
{
// Этот код будет выполняться после выполнения запроса в базу данныхvar stopwatch = _stopwatch.Value;
stopwatch.Stop();
var command = GetProperty<SqlCommand>(value, "Command");
Console.WriteLine($"CommandText: {command.CommandText}");
Console.WriteLine($"Elapsed: {stopwatch.Elapsed}");
Console.WriteLine();
break;
}
}
}
privatestatic T GetProperty<T>(objectvalue, string name)
{
return (T) value.GetType()
.GetProperty(name)
.GetValue(value);
}
}
Here we intercept the start and end events of the query in the database. Before executing the request, we create and start a stopwatch, storing it in a type variable AsyncLocal<Stopwatch>
, to get it back later. After the request is completed, we get the previously started stopwatch, stop it, get the executed command from the parameter value
through reflection and output the result to the console.
If we now run the resulting code, then the following will be displayed in the console:
CommandText: SELECT 42;
Elapsed: 00:00:00.0341357
42
It would seem that we have already solved our problem, but one small detail remains. The fact is that when we subscribe to events DiagnosticListener
we start to receive from it even those events that are not interesting to us, and since sending each event creates an anonymous object with parameters, this can create an extra load on the GC.
To avoid this situation and to inform which events from DiagnosticListener
we are going to handle, we can specify a special delegate of the type Predicate<string>
that subscribes to the name of the event and returns true
if this event should be processed.
Let's slightly change the method IObserver<DiagnosticListener>.OnNext
in our class:
void IObserver<DiagnosticListener>.OnNext(DiagnosticListener diagnosticListener)
{
if (diagnosticListener.Name == "SqlClientDiagnosticListener")
{
var subscription = diagnosticListener.Subscribe(this, IsEnabled);
_subscriptions.Add(subscription);
}
}
privateboolIsEnabled(string name)
{
return name == "System.Data.SqlClient.WriteCommandBefore"
|| name == "System.Data.SqlClient.WriteCommandAfter";
}
Now our method Write
will be called only for events "System.Data.SqlClient.WriteCommandBefore"
and "System.Data.SqlClient.WriteCommandAfter"
.
Using NuGet Microsoft.Extensions.DiagnosticAdapter
Since the event parameters that we receive from DiagnosticListener
are usually passed as an anonymous object, it can be too expensive to retrieve them through reflection. Fortunately, there is a NuGet Microsoft.Extensions.DiagnosticAdapter package that can do this for us, using runtime code generation from the namespace System.Reflection.Emit
.
In order to use this package when subscribing to events from an instance DiagnosticListener
, Subscribe
you must use an extension method instead of a method SubscribeWithAdapter
. Implementing an interface IObserver<KeyValuePair<string, object>>
in this case is no longer required. Instead, for each event that we want to handle, we need to declare a separate method, marking it with an attribute DiagnosticNameAttribute
(from the namespace Microsoft.Extensions.DiagnosticAdapter
). The parameters of these methods are the parameters of the event being processed.
If we rewrite our class ExampleDiagnosticObserver
using this NuGet package, we get the following code:
publicsealedclassExampleDiagnosticObserver : IObserver<DiagnosticListener>
{
privatereadonly List<IDisposable> _subscriptions = new List<IDisposable>();
void IObserver<DiagnosticListener>.OnNext(DiagnosticListener diagnosticListener)
{
if (diagnosticListener.Name == "SqlClientDiagnosticListener")
{
var subscription = diagnosticListener.SubscribeWithAdapter(this);
_subscriptions.Add(subscription);
}
}
void IObserver<DiagnosticListener>.OnError(Exception error)
{ }
void IObserver<DiagnosticListener>.OnCompleted()
{
_subscriptions.ForEach(x => x.Dispose());
_subscriptions.Clear();
}
privatereadonly AsyncLocal<Stopwatch> _stopwatch = new AsyncLocal<Stopwatch>();
[DiagnosticName("System.Data.SqlClient.WriteCommandBefore")]
publicvoidOnCommandBefore()
{
_stopwatch.Value = Stopwatch.StartNew();
}
[DiagnosticName("System.Data.SqlClient.WriteCommandAfter")]
publicvoidOnCommandAfter(DbCommand command)
{
var stopwatch = _stopwatch.Value;
stopwatch.Stop();
Console.WriteLine($"CommandText: {command.CommandText}");
Console.WriteLine($"Elapsed: {stopwatch.Elapsed}");
Console.WriteLine();
}
}
Thus, we can now measure the execution time of all queries to the database from our application, with virtually no change to the code of the application itself.
Creating Your Own Instances of a DiagnosticListener
When you use the DiagnosticSource in practice, in most cases you will subscribe to already existing events. You DiagnosticListener
probably don’t have to create your own instances and send your own events (only if you don’t develop any library), so I won’t dwell on this section for long.
To create your own instance, DiagnosticListener
you will need to declare it as a static variable somewhere in the code:
privatestaticreadonly DiagnosticSource _myDiagnosticSource =
new DiagnosticListener("MyLibraty");
After that, to send an event, you can use the following construction:
if (_myDiagnosticSource.IsEnabled("MyEvent"))
_myDiagnosticSource.Write("MyEvent", new { /* parameters */ });
More information about creating your own instances DiagnosticListener
can be found in the DiagnosticSource User's Guide , which describes in detail Best Practices for using the DiagnosticSource.
Conclusion
The example that we considered is definitely very abstract and is hardly useful in a real project. But it perfectly shows how this mechanism can be used to monitor and diagnose your applications.
In the next article, I will provide a list of events known to me that can be processed through the DiagnosticSource, and I will show several practical examples of its use.