This motoko library provides a module to help create an append-only logger actor.
You can use this library with the vessel package manager. A sample usage of creating a logger actor (or canister) from the Logger module can be found in the example sub-directory. You'll need both dfx and vessel in PATH before trying it out:
cd example
dfx deploy
It creates a text-based logger and gives the controller a few privileged methods to use it. Its actor interface is given in candid:
type View =
record {
messages: vec text;
start_index: nat;
};
type Stats =
record {
bucket_sizes: vec nat;
start_index: nat;
};
service : {
allow: (vec principal) -> () oneway; // Allow some canisters to call the `append` method.
append: (vec text) -> () oneway; // Append a set of new log entries.
pop_buckets: (nat) -> () oneway; // Remove the given number of oldest buckets.
stats: () -> (Stats) query; // Get the latest logger stats.
view: (nat, nat) -> (View) query; // View logs in the given index interval (inclusive).
}
- The logger actor provides a single
append
method for other actors to call. - The logger will keep all past logs in the order as they were received, and every logline has an index or line number.
These logs are stored in "buckets", and when a bucket is full, the next bucket is created.
Buckets are also numbered, and the capacity of a bucket is configurable when initializing the
Logger
class. - The controller can perform some adminstrative duties, such as changing which canisters are allowed to call
append
, or remove old buckets to save some space. - When an old bucket is removed, it does not change the index of log lines. This means querying logs using
view(..)
should given consistent results (unless they are removed).
Something I find very useful when doing logging is to keep track of "sessions". Due to the nature of async programming, calls may be intertwined, so are the log messages they produce. This makes it hard to figure out what is going on in different threads (or "sessions") of execution.
Here is a helper function I use to solve this problem:
func logger(name: Text) : Text -> async () {
let prefix = "[" # Int.toText(Time.now()) # "/";
func(s: Text) : async () {
Logger.append([prefix # Int.toText(Time.now() / 1_000_000_000) # "] " # name # ": " # s])
}
};
Calling logger("some name")
will return a function that can be used to do the actual logging.
The interesting bit is that we use the timestamp at the creation of this logger as a prefix to help distinguish "sessions" that span across await
statements.
Here are some example log entries from tipjar:
"[1642195271679220110/1642195271] heartbeat: BeforeCheck {canister = rkp4c-7iaaa-aaaaa-aaaca-cai}"
"[1642195271679220110/1642195271] heartbeat: AfterCheck {cycle = 90_000_000_000_000}"
"[1642195271062468878/1642195272] heartbeat: AfterCheck {cycle = 100_000_000_000_000}"
"[1642198872792672023/1642198872] heartbeat: BeforeCheck {canister = rno2w-sqaaa-aaaaa-aaacq-cai}"
"[1642198873407808427/1642198873] heartbeat: BeforeCheck {canister = rkp4c-7iaaa-aaaaa-aaaca-cai}"
"[1642198873407808427/1642198873] heartbeat: AfterCheck {cycle = 90_000_000_000_000}"
"[1642198872792672023/1642198874] heartbeat: AfterCheck {cycle = 100_000_000_000_000}"
It has a heartbeat
method that logs BeforeCheck
and AfterCheck
.
These entries are ordered by when the Logger receives the append
call.
We can see that 1642198872792672023
has two entries that do not appear next to each other.
And yet we can still tell they are from the same "session" because of the common prefix.
The code is documented inline and unit tests are provided. If you have installed a nix environment, you can run the tests like this:
nix-shell
cd test
make