DotnetLog.Cli
0.1.2
dotnet tool install --global DotnetLog.Cli --version 0.1.2
dotnet new tool-manifest
dotnet tool install --local DotnetLog.Cli --version 0.1.2
#tool dotnet:?package=DotnetLog.Cli&version=0.1.2
nuke :add-package DotnetLog.Cli --version 0.1.2
dotnet-log
A command-line tool that adds call logging to a .NET application you do not have the sources of.
It takes the compiled .dll files, writes a call to the logger into the beginning and the
end of every selected method, drops the logger library next to the application and registers
it in the application's assembly list. After that the application runs as before, but writes
a log: who called whom, with which arguments, what came back, how long it took and what
threw.
The log is a JSONL file. To read it from C# — call tree, argument values, search, printing — use the companion package DotnetLog.Reader.
Installing and running
Run it without installing anything (.NET 10 and above):
dnx DotnetLog.Cli -- "MyCompany.*" --dir C:\app
Or install it as a global tool and call it by name:
dotnet tool install -g DotnetLog.Cli
dotnet-log "MyCompany.*" --dir C:\app
Then start the application as usual — it writes the log while it runs.
How a run looks
dotnet-log <masks> [options]
The masks come first — they say which assembly files to open at all. Everything that does not match a mask is left untouched, so the framework and other people's packages stay out of the log unless you ask for them.
A mask is not a regular expression: * means "any number of any characters", ? means
"exactly one character", everything else is compared literally, dots included. So
Sberbank.Db does not match SberbankXDb.
dotnet-log Sberbank.A* -o log.jsonl
dotnet-log Sberbank.* --include-only Sberbank.App.Payments.* --dry-run
dotnet-log --restore
There are two levels of selection, and mixing them up is the usual mistake:
| Level | What it picks | Which option |
|---|---|---|
| assemblies | which .dll files to open |
the masks themselves, plus --exclude |
| types | which types inside an opened assembly to touch | --include-only |
Options
| Option | Default | What it does |
|---|---|---|
-o, --out <FILE> |
dotnet-log.jsonl |
where to write the log |
--dir <FOLDER> |
. |
where to look for the assemblies |
--flush-ms <N> |
10 |
how often to flush to disk, milliseconds |
--capacity <N> |
100000 |
queue size in records |
--max-depth <N> |
3 |
how deep to walk nested objects |
--max-items <N> |
50 |
how many collection elements to write |
--max-string <N> |
512 |
where to cut long strings |
--no-this |
off | do not log object state, arguments only — noticeably cheaper |
--async-chain |
off | keep the call chain across await (costs more) |
--include-accessors |
off | also log property getters and setters |
--include-generated |
off | also log lambdas and the async/yield state machines |
--exclude <MASK> |
— | skip these assemblies even if they matched |
--include-only <MASKS> |
— | rewrite only these types (comma-separated, or @file) |
--dry-run |
off | show what would be processed and exit, touching nothing |
--show-refs <FILE> |
— | show which assemblies the given dll references |
--restore |
off | put the original assemblies back |
dotnet-log --help always prints the current list — the help text is generated from the
options themselves, so it cannot fall behind the code.
Undoing it
The tool overwrites someone else's files, so a way back has to exist. Before the first edit
each original is copied into a .dotnet-log-backup subfolder next to the application, and
--restore puts the copies back:
dotnet-log --restore --dir C:\app
The copies stay in place afterwards, so you can restore again later. Running the tool twice does not destroy the backup: it only saves an original if there is no copy yet.
Where the log ends up
If you pass no -o, the file is called dotnet-log.jsonl.
Now the part that is easy to get wrong. A relative path is resolved neither against the
application folder nor against the folder you ran dotnet-log in. The file is opened by
the logger, and the logger runs later, inside the other application, in a different process —
possibly on a different machine. A relative path there is resolved against the current
working directory of that process.
cd C:\work
dotnet C:\app\AcceptanceApp.dll
The log appears in C:\work\dotnet-log.jsonl, not in C:\app. For a Windows service or a
container the working directory can be somewhere you would never guess — so if the log seems
"missing", look at the process working directory, not at the application folder.
To stop guessing, give a full path. There are two ways:
| How | Example | When it fits |
|---|---|---|
| the tool option | dotnet-log App* -o C:\logs\app.jsonl |
the path is known while you are rewriting the assemblies |
| an environment variable | DOTNET_LOG_PATH=/app/dotnet-log.jsonl |
the path changes from run to run — no need to rewrite the assemblies again |
The environment variable wins over the option, because it is set later. That is how the path was given in the container during acceptance testing.
Two more things about the file itself:
- it is opened in append mode, not truncated. A second run of the application appends to
the same file. Call numbers (
id) are unique only within one run, so one file holding two runs contains two sets of calls with repeating numbers — split them by time (ts) when you analyse such a file; - the folder is not created for you. Pass
-o C:\logs\app.jsonlwhileC:\logsdoes not exist and the logger cannot open the file. It is not allowed to crash the host application, so it stays silent and writes nothing — which looks exactly like "logging does not work".
All environment variables
They override the options that were baked in when the assemblies were rewritten, so you can change behaviour without touching the application again:
DOTNET_LOG_PATH=/tmp/log.jsonl DOTNET_LOG_FLUSH_MS=50
DOTNET_LOG_CAPACITY=100000 DOTNET_LOG_MAX_DEPTH=3
DOTNET_LOG_MAX_ITEMS=50 DOTNET_LOG_MAX_STRING=512
DOTNET_LOG_INCLUDE_THIS=false DOTNET_LOG_ASYNC_CHAIN=true
Yes/no values are understood both as words (true, false) and as digits (1, 0).
A broken value — letters where a number is expected, a missing file, invalid JSON in the
settings file — is ignored silently and the previous value stays. The logger is never allowed
to bring the host application down.
Settings are layered from weakest to strongest: built-in defaults, then the
dotnet-log.settings.json file the tool writes next to the application, then environment
variables. The layering is per value: a path in the file and a queue size in a variable
both survive.
The log format
JSONL: one JSON object per line, no line breaks inside. Such a file is read line by line and
filtered with tools like jq without being loaded into memory.
A method call is two lines: one when it is entered, one when it is left. They are tied
together by id.
{"ts":"2026-08-27T10:00:00.1200000Z","ev":"enter","type":"SampleApp.Account","sig":"Decimal Deposit(Decimal)","args":{"amount":50},"this":{"Owner":"Иванов","Balance":100},"id":1,"pid":0,"root":1,"d":0,"th":12}
{"ts":"2026-08-27T10:00:00.1250000Z","ev":"exit","ms":5,"ret":150,"id":1}
Every field
| Field | Meaning | Example |
|---|---|---|
| ts | when the event was written, UTC | "2026-08-27T10:00:00.1200000Z" |
| ev | kind of event | "enter", "exit", "async-exit", "error", "dropped" |
| type | full class name | "Humanizer.LocaliserRegistry\1"| |sig| method signature |"Decimal Deposit(Decimal)"| |args| arguments by parameter name |{"amount":50}| |this| state of the object the method was called on |{"Owner":"Иванов","Balance":100}| |ret| returned value |150, null, "forty-two"| |out| values ofoutparameters, by name |{"priorPolicyKey":null}| |ex| the exception:type, msg, stack| see below | |ms| how long the call ran, milliseconds |16.895| |id| call number — this is what pairs enter with exit |245| |pid| number of the caller;0means nobody called us |4| |root| number of the topmost call of the chain |1| |d| nesting depth,0at the top |4| |th| thread number |11| |n| how many records were dropped (only on"dropped"lines) |137` |
Which fields appear on which line — this is the part that surprises people:
ev |
Fields present | Notes |
|---|---|---|
enter |
ts, ev, type, sig, args, this, id, pid, root, d, th |
args is absent when the method has no parameters; this is absent for a static method or under --no-this |
exit |
ts, ev, ms, ret, out, id |
out is absent when there are no out parameters |
async-exit |
same as exit |
written when the async method's task really completed |
error |
ts, ev, ms, ex, id, pid |
pid is here on purpose: it shows where the exception flies next |
dropped |
ev, n |
no ts: the background thread writes it, and the exact moment is lost anyway |
exit, async-exit and error carry no type and no sig. They are not duplicated —
that would double the file size. Take the class and the method from the paired enter line
with the same id.
Field order is fixed and follows one rule: the reason you opened the log comes first.
When (ts), then what happened (ev), then where (type, sig), then the values, and only
then the bookkeeping numbers. A machine does not care, but the line is long and does not fit
into a narrow terminal — put the numbers first and the method name scrolls off the screen.
More real lines
An instance method of a generic class, with a delegate argument. Note <max-depth>: the
serializer stopped at the depth limit instead of walking further:
{"ts":"2026-08-27T18:49:03.8089799Z","ev":"enter","type":"Humanizer.LocaliserRegistry`1","sig":"Void Register(String, Func<CultureInfo, TLocaliser>)","args":{"localeCode":"en","localiser":{"_target":{},"_methodBase":null,"_methodPtr":{"_value":{"_value":"<max-depth>"}}}},"this":{},"id":6,"pid":4,"root":1,"d":4,"th":11}
A void method with two out parameters — ret is null, the values are in out:
{"ts":"2026-08-27T18:49:03.8235049Z","ev":"exit","ms":0.488,"ret":null,"out":{"priorPolicyWrapKey":null,"priorPolicyKey":null},"id":241}
An async method that really finished 16.9 ms later:
{"ts":"2026-08-27T18:49:03.8411133Z","ev":"async-exit","ms":16.895,"ret":84,"id":245}
A method that threw:
{"ts":"2026-08-27T10:00:00.1550000Z","ev":"error","ms":5,"ex":{"type":"System.InvalidOperationException","msg":"Счёт закрыт","stack":"at App.Money.Count"},"id":4,"pid":1}
The queue overflowed and records were thrown away — the application was not slowed down, but the tree now has holes:
{"ev":"dropped","n":137}
A constructor: on the way in the object does not exist yet, so on the way out the finished
object is written into this instead of a returned value.
Placeholders instead of values
When a value cannot be written in full, a marker string appears in its place. Do not mistake one for a real string:
| Placeholder | What happened |
|---|---|
"<max-depth>" |
the nesting limit --max-depth was reached |
"<cycle>" |
the object refers back to itself |
"<lazy: IEnumerable<Int32>>" |
a lazy sequence — walking it would run the application's own code, which the logger must not do |
"<unsupported>" |
the value cannot be put into the log at all, e.g. a Span<T> |
"<error: …>" |
reading the value failed |
Long strings are cut to --max-string characters, collections to --max-items elements.
What never gets into the log
The logger reads fields only and never calls anything of the application's own. A property is a method, and inside it there can be anything at all — during acceptance testing walking properties of a live web server crashed the application in about half of the runs.
Ordinary properties (public string Owner { get; }) are still in the log: they have a hidden
backing field, and it is written under the readable property name. Only computed
properties disappear. Same rule as with lazy sequences: an observer may look, never touch.
How the application may and may not be built
The tool works on compiled managed assemblies — the .dll files — and on the
*.deps.json list the .NET loader reads. Everything below follows from that.
Works
| Publish mode | Command | Evidence |
|---|---|---|
| Framework-dependent, no runtime identifier | dotnet publish -c Release |
acceptance run: ASP.NET Core app with Humanizer and Polly, 2 871 methods rewritten, application answers as before |
| Framework-dependent for a specific platform | dotnet publish -c Release -r linux-x64 --self-contained false |
same app in a Podman container on mcr.microsoft.com/dotnet/aspnet:10.0: 564 log lines, no errors |
| Plain build output | dotnet build |
the same .dll files, nothing special about them |
Self-contained publishes (--self-contained true) are the same case: the managed assemblies
are still separate files next to the application.
Rewrite after publishing, not before. Publishing copies the assemblies from bin into
the output folder, so anything you rewrite before publishing gets overwritten by the clean
originals.
Does not work
| Publish mode | What happens | Why |
|---|---|---|
-p:PublishSingleFile=true |
the tool prints Под маски не подошла ни одна сборка and changes nothing |
the output holds no .dll files at all — every managed assembly is packed inside the .exe, and there is no deps.json to register the logger in |
-p:PublishReadyToRun=true |
the tool reports success, and then the application will not start: FileLoadException … A dynamic link library (DLL) initialization routine failed. (0x8007045A) |
a ReadyToRun assembly carries precompiled native code alongside the IL. Rewriting the IL leaves that native code describing a method body that no longer exists, and the loader rejects the image |
-p:PublishAot=true (native AOT) |
nothing to rewrite | the application is compiled to a native executable ahead of time; there are no managed assemblies in the output, same as with a single file. (Reasoned, not measured — this machine has no C++ linker to build a native AOT app.) |
The ReadyToRun case is the dangerous one, because the tool does not warn you. Both the
single-file and the ReadyToRun checks above were run on the acceptance application; in the
ReadyToRun case dotnet-log --restore put the originals back and the application started
and answered normally again, which is what proves the rewriting was the cause.
If your publish pipeline uses ReadyToRun or single-file, publish a second time without those switches for the run you want to log:
dotnet publish MyApp -c Release -o out-for-logging
dotnet-log MyApp* --dir out-for-logging
Other limitations worth knowing
- Trimming (
-p:PublishTrimmed=true) removes members nobody appears to use. The logger reads fields by reflection, so trimming can quietly take away values you expected to see. Not tested here. - Methods with
stackallocare skipped. IL forbidslocallocinside a protected block, and the tool wraps whole method bodies in one. - Full coverage costs time and disk. On OrchardCore — 194 assemblies, 189 of them rewritten — a single HTTP request produced a 742 MB log of 1 330 175 lines, and the first request went from 32 to 183 seconds. The masks are a working tool, not decoration: select the assemblies and types you actually care about.
Reading the log afterwards
#:package DotnetLog.Reader@1.0.1
using DotnetLog.Reader.BuildingCallTree;
var log = CallLog.LoadFromFile("dotnet-log.jsonl");
foreach (var failed in log.Calls.Where(call => call.ThrownException is not null))
Console.WriteLine($"{failed.FullMethodName}: {failed.ThrownException!.Message}");
The full library documentation is in DotnetLog.Reader.
| Product | Versions Compatible and additional computed target framework versions. |
|---|---|
| .NET | net10.0 is compatible. net10.0-android was computed. net10.0-browser was computed. net10.0-ios was computed. net10.0-maccatalyst was computed. net10.0-macos was computed. net10.0-tvos was computed. net10.0-windows was computed. |
This package has no dependencies.