Skip to contents

logging

As code shifts from prototype to production, logging becomes important. Given the ubiquity of the cli and logger packages, it would be helpful to have the option of routing cli output directly into the logs. More generally, any code that generates conditions–messages, warnings, and errors–should be easily loggable as well. To address this concern, metayer provides a with_logger function and uses it to wrap many of the cli functions.

## tl;dr

cli_alert("use cli methods to route output into the logs") %>%
  suppressMessages()
[INFO/global] > use cli methods to route output into the logs 
tryCatch(
  cli_abort("abort: an error"),
  error = function(m) {
    log_error("error: handled")
    invisible(NULL)
  }
)
[ERROR/global] abort: an error 
[ERROR/global] error: handled 
msg <- with_logger({
  message("a logged message")
}) %>%
  catch_cnd()
[INFO/global] a logged message 

with_logger

with_logger provides explicit logging. Internally, this uses withCallingHandlers, so conditions are processed with a log entry before being propagated to the next handler. This results in the visibility of both the log entry as well as the message.

# explicity logging
with_logger({
  message("a logged message")
})
[INFO/global] a logged message 
a logged message

cli wrappers

metayer uses some metadata magic to wrap cli functions. This imbues them with logging functionality.

metayer also utilizes the cli.default_handler option provided by the cli package. This upgrades glue semantics to generate visible NULL values and is useful when debugging.

We consider the available variants of cli_alert_warning, starting with cli’s default behavior.

# default behavior of cli_alert_warning
withr::with_options(
  list(cli.default_handler = NULL), 
  cli::cli_alert_warning("a message without a visible null: '{NULL}'")          
)
! a message without a visible null: ''

cli.default_handler - visible nulls

When using the default metayer handler–cli_nullity_handler–we see that NULL values are handled differently. We can also customize this behavior.

# visible null, default
cli::cli_alert_warning("a message with a default null: '{NULL}'") 
! a message with a default null: '<null>'
# visible null, customized
withr::with_options(
  list(mty.cli_null = "NULL"),
  cli::cli_alert_warning("a message with a custom null: '{NULL}'") 
)
! a message with a custom null: 'NULL'

explicitly logged cli functions

If we were willing to, we could apply wrap_logger to every cli call.

# logging
with_logger(
  cli::cli_alert_warning("a logged message from 'cli::cli_alert_warning'")
)
[INFO/global] ! a logged message from 'cli::cli_alert_warning' 
! a logged message from 'cli::cli_alert_warning'

It’s odd to see a function named cli_alert_warning producing a log with an INFO threshold. This is easily amended. To ensure that the logger level is aligned with the naming implications of the cli function, use the level parameter. Now cli_alert_warning is producing a WARN entry in the logs.

# logging with log level
with_logger(
  cli::cli_alert_warning("a logged warning with 'cli::cli_alert_warning'"),
  level = logger::WARN
)
[WARN/global] ! a logged warning with 'cli::cli_alert_warning' 
! a logged warning with 'cli::cli_alert_warning'

Finally, it’s worth mentioning the double output above. This is because cli::cli_alert_warning bubbles up a message condition, and with_logger processes it along the way. The second output above is that message, delivered to the user. We can deal with this using an even more verbose expression. Now we’ll only see what’s propagated to the logs.

# fix the double messaging
tryCatch(
  {
    with_logger(
      cli::cli_alert_warning("a logged warning with 'cli::cli_alert_warning'"),
      level = logger::WARN
    )
  },
  message = function(msg) invisible(NULL)
)
[WARN/global] ! a logged warning with 'cli::cli_alert_warning' 

wrapped cli functions

metayer provides wrapped versions of cli functions. This recovers the usual verbosity of the original cli package but without the pain previously demonstrated.

# implicit logging and log levels
cli_alert_warning("a logged warning with 'cli_alert_warning'")
[WARN/global] ! a logged warning with 'cli_alert_warning' 
! a logged warning with 'cli_alert_warning'

namespace context

So far, all of the logging has been to the global namespace. In an article context, this is as it should be. However, logs originating elsewhere should reflect the point of origin.

With log_info and friends, one can explicitly specify a namespace.

# explicit custom namespace
log_warn("a warning with 'log_warn' in a custom namespace", namespace = "custom")
[WARN/custom] a warning with 'log_warn' in a custom namespace 

To showcase implicit namespace behavior, we’ll create two functions in the global namespace. We’ll also put copies of these functions in a namespace anchored to metayer. This is intended to mimic the scenario where these functions would have been formally defined in the package.

# two functions in the global namespace
foo <- function() log_info("foo called `log_info`")
bar <- function() cli_alert("bar called `cli_alert`")
# global functions produce 'global' namespace
foo()
[INFO/global] foo called `log_info` 
bar()
[INFO/global] > bar called `cli_alert` 
> bar called `cli_alert`

In the previous output, note that foo–inherently, log_info–doesn’t generate a condition; it shows up only once.

bar, on the other hand, calls cli_alert which generates a condition. Hence that output shows up twice: once as a log entry, processed as it happens, and a second time, now as an unhandled condition that has bubbled up to the top level. This is the same behavior we observed previously.

Next, we’ll put foo and bar into the metayer namespace and observe that they now display a “metayer” namespace.

# two functions "above" the metayer namespace
ns <- new_environment(parent = getNamespace("metayer"))
ns[["foo"]] <- set_env(foo, ns)
ns[["bar"]] <- set_env(bar, ns)
# packaged functions produce 'metayer' namespace
ns$foo()
[INFO/metayer] foo called `log_info` 
ns$bar()
[INFO/metayer] > bar called `cli_alert` 
> bar called `cli_alert`

glue semantics

The wrapped functions are designed to replicate the usual glue semantics found in the cli and logger packages.

# glue semantics for `cli_alert_info` (with visible NULLs)
v1 = "foo"
v2 = NULL
v3 = "bar"

cli_alert_info("{v1} {v2} {v3}")
[INFO/global] i foo <null> bar 
i foo <null> bar
# glue semantics for log_info
log_info("a log: {v1} {v3}")
[INFO/global] a log: foo bar 

However, just like their analogues in the logger package, log_info and friends won’t handle NULL values.

# null values were never allowed here!
logger::log_info("a log: {v1} {v2} {v3}")
# and so we don't expect them to be available here, either.
log_info("a log: {v1} {v2} {v3}")