DotnetLog.Cli 0.1.2

dotnet tool install --global DotnetLog.Cli --version 0.1.2
                    
This package contains a .NET tool you can call from the shell/command line.
dotnet new tool-manifest
                    
if you are setting up this repo
dotnet tool install --local DotnetLog.Cli --version 0.1.2
                    
This package contains a .NET tool you can call from the shell/command line.
#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.jsonl while C:\logs does 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 stackalloc are skipped. IL forbids localloc inside 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 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. 
Compatible target framework(s)
Included target framework(s) (in package)
Learn more about Target Frameworks and .NET Standard.

This package has no dependencies.

Version Downloads Last Updated
0.1.2 45 8/28/2026
0.1.1 36 8/28/2026
0.1.0 43 8/28/2026