Structured Logging in ASP.Net with ELK - thinking beyond the basics
✂️
Why are we still talking about it?
This article is on how to go beyond structured logging, especially in the context of Logging in Json when the Json is ingested by Elastic Search via Filebeat (or equivalent)
There are constraints in this case that needs to be taken into account if you want to optimize your logging output
What you are doing
So you are logging for Elastic Search.
Your logs are going to be scrapped from the console and sent to Elastic and you are going to be able to display them nicely in Kibana.
Using Microsoft.Extensions.Logging (ILogger<T>) and JsonConsoleFormatter, you should have code logging like that:
// This is set via injection privatereadonly ILogger<MyClass> _logger;
/*...*/ int countMessages = 5000; _logger.LogInformation("Starting Process"); // This is a scope declared high up in the process usingvar globalLogScope = _logger.BeginScope(new List<KeyValuePair<string, object>> { new("TransactionId", "ABCDE") }); // This is a more local scope applied to only part of the process usingvar logScope = _logger.BeginScope(new List<KeyValuePair<string, object>> { new("RecordId", 1234) }); _logger.LogWarning("Too many messages: {Count}", countMessages);
I have indented those two Jsons, but by default (and this is good for parser), each json will be serialized on a single line. The initial size is 718 bytes
Can we improve ?
Right… There are plenty of issues with this output. Let’s address them :)
This is super verbose and redundant and this is an issue because we are logging to the console. By default, if a message is longer than ~16k, it will be split over multiple lines, and FileBeat will have a hard time parsing it.
Note that the max size of the console line can be different on your system, and you (or your DevOps) can change it but the longer it is, the longer your message can be, and that might be an issue for Filebeat (or the equivalent on your system) in terms of performances, throughput, …
So our objective will be to reduce verbosity and redundancy when possible
There are useless properties
Default values could be skipped, like EventId = 0. I rarely use EventId, but when I use it, it’s nice to be able to filter by this value in Kibana
There are redundant properties:
Message is duplicated in Message and in State.Message, even when there is no placeholder
It is nice to have access to State.{OriginalFormat} because I can easily filter on this type of message in Kibana
As you can see, we have invalid json with a duplicate property: Message. The good news is that the state message is redundant with the field message so we can remove it
Also, in the case of scope messages, it’s a .ToString() of the scope object. In our case, it’s just a List<T>, so it is useless. In the case of Microsoft Scopes (request scopes), the object used is a bit more interesting, it is an internal object ActivityLogScope overloading the ToString() function. However this implementation is just repeating the same exact information that we have as properties later in the message, so we can remove this Message also.
The Category property represents the namespace of the class emitting the log. What is expected is that most of the logs will come from our own namespace (in my case MyApplication.) so we could replace this with something shorter (a -). Also, when there is no state (no placeholder in our message), the state property {OriginalFormat} contains the same information as our Message, so we can drop it.
We are now at 318 bytes, less than half of the initial size
Also, if we log a large message, it won’t be duplicated in the json if it contains no placeholder. So if you are dumping potentially large payload in your message and you make sure you are not using placeholders, then you can cut the size of your logs in two
Is if all great, but we have just created an issue which was not present before:
If two scopes share the same property, they could collide! Same for a scope and a state sharing the same property
Ex:
usingvar globalLogScope = _logger.BeginScope(new List<KeyValuePair<string, object>> { new("Collide", "ABCDE"), }); // This is a more local scope applied to only part of the process usingvar logScope = _logger.BeginScope(new List<KeyValuePair<string, object>> { new("Collide", 1234), }); _logger.LogWarning("Too many messages: {Collide}", countMessages);
Will produce:
{ "LogLevel":"Warning", "Category":"-Controllers.MyController", "Message":"Too many messages: 5000", "Sc":{ "Collide":5000, "{OriginalFormat}":"Too many messages: {Collide}", "Collide":"ABCDE", "Collide":1234 } }
So let’s fix this by using a small HashSet to detect collisions and resolve them with a suffix number