---
title: "Logging a data.table pipeline"
output: rmarkdown::html_vignette
vignette: >
  %\VignetteIndexEntry{Logging a data.table pipeline}
  %\VignetteEngine{knitr::rmarkdown}
  %\VignetteEncoding{UTF-8}
---

```{r setup, include = FALSE}
knitr::opts_chunk$set(
  collapse = TRUE,
  comment = "#>"
)
```

`data.table` says very little about what it did. `DT[i, j, by]` returns a
table, and whether the filter dropped two rows or two thousand, whether the
join matched anything, whether `:=` overwrote a column you meant to keep, is
something you have to check yourself, one `nrow()` at a time.

`dtlog` prints it instead. It redefines `[.data.table` and the `data.table`
functions around it so that every operation reports what it did, and leaves the
operations themselves untouched -- same return value, same visibility, same
modification by reference. It is the idea behind
[tidylog](https://github.com/elbersb/tidylog), applied to `data.table`.

This vignette follows one pipeline from a CSV file to a summary table, reading
the log as it goes, and then writes the whole session to a transcript file.
The README covers the reference material: the full list of what is logged, the
comparison with tidylog, and the timings.

## Loading

`dtlog` masks functions that `data.table` exports, so it has to come after
`data.table` on the search path. Load it last, or there will be no output.

```{r load, message = FALSE}
library(data.table)
library(dtlog)
```

```{r vignette-option, include = FALSE}
options(dtlog.log_from_packages = TRUE)
```

By default `dtlog` only reports calls made from the global environment, so
`data.table` code inside other packages stays silent. Code in a vignette is not
run from the global environment either, so this vignette sets
`options(dtlog.log_from_packages = TRUE)` once, up front. In an ordinary
session you do not need it.

## The data

Two tables: repeated measurements, and one row per patient.

```{r data}
set.seed(42)

visits <- data.table(
  id    = rep(1:20, each = 3),
  visit = rep(1:3, times = 20),
  date  = as.Date("2026-01-01") + sample(0:180, 60, replace = TRUE),
  sbp   = round(rnorm(60, mean = 132, sd = 16)),
  crp   = round(rexp(60, rate = 1 / 8), 1)
)
visits[sample(60, 6), sbp := NA_real_]

patients <- data.table(
  id  = 1:20,
  sex = sample(c("F", "M"), 20, replace = TRUE),
  age = sample(45:85, 20, replace = TRUE),
  arm = rep(c("control", "treatment"), each = 10)
)
patients <- patients[id != 7]   # one patient never made it into the registry
```

`data.table()` itself is not wrapped, so building the two tables is silent; the
`:=` that writes the missing values and the filter that drops a patient are
not. Writing the patient table out and reading it back shows the file end of
things.

```{r io}
path <- tempfile(fileext = ".csv")
fwrite(patients, path)
patients <- fread(path)
```

`fread()` reports what it read and from where, `fwrite()` what it wrote. Both
messages name the file, which is the part that is easy to get wrong in a script
that builds paths.

## Cleaning

Everything below is ordinary `data.table` code. The `#>` lines are `dtlog`.

```{r clean}
visits <- na.omit(visits, cols = "sbp")

visits[, high := sbp >= 140]

visits[crp > 25, crp := NA_real_]
```

Three messages worth reading closely:

* `drop_na` gives the rows removed and the rows left, so a `cols =` argument
  that matches nothing is visible immediately.
* the first `:=` is a new column: name, type, how many distinct values, how
  much of it is `NA`.
* the second `:=` writes into a column that already exists, so the message is
  about change -- how many values moved, and how many `NA`s that created. A
  recode that silently hits far more rows than expected shows up here.

Dropping a column and renaming one are logged the same way:

```{r rename}
visits[, high := NULL]

setnames(visits, "sbp", "sbp_mmhg")
```

`setnames()` prints both names, `sbp -> sbp_mmhg`, so the log stays readable
when several renames happen in a row.

## Joining

```{r join}
merged <- merge(visits, patients, by = "id", all.x = TRUE)
```

The join message has two halves: the columns that arrived, and the matching
counts. The counts are the useful half here -- the `rows only in visits` line
is the patient who never made it into the registry, three visits with no `arm`
and no `age`. A left join keeps them, the row count does not change, and
without the log nothing about the result says anything happened.

Now that the log has pointed at them, they can go:

```{r drop-unmatched}
merged <- merged[!is.na(arm)]
```

The same join written as a `data.table` subset logs the columns and the change
in rows:

```{r join-i}
setkey(patients, id)
joined <- visits[patients, on = "id"]
```

`setkey()` reports the key it set and how many rows it sorted; the subset
reports the join.

## Reshaping and aggregating

```{r reshape}
wide <- dcast(merged, id + arm ~ visit, value.var = "sbp_mmhg")
```

`dcast()` reports the shape before and after, which is where a reshape usually
goes wrong: an unexpected row count means the left hand side of the formula
does not identify a row.

Aggregation is logged in two lines, the grouping and the result:

```{r summarise}
by_arm <- merged[, .(n        = .N,
                     patients = uniqueN(id),
                     mean_sbp = mean(sbp_mmhg),
                     mean_crp = mean(crp, na.rm = TRUE)),
                 by = arm]
by_arm
```

`dtlog` distinguishes the rows `i` selects from the rows `j` produces. When `j`
aggregates, the row count is not blamed on the filter:

```{r i-vs-j}
merged[age >= 65, .(mean_sbp = mean(sbp_mmhg)), by = arm]
```

The same holds in a pipe. `x |> f(y)` reaches `dtlog` as an ordinary call to
`f(x, y)`, so a piped chain logs exactly like a nested one:

```{r pipe, eval = getRversion() >= "4.3.0"}
merged[arm == "treatment"] |>
  _[, .(mean_crp = mean(crp, na.rm = TRUE)), by = sex]
```

## Writing the session to a file

`dt_log()` opens a transcript. From that point on every operation is appended
to a text file together with the call that produced it, and `dt_log_end()`
closes it. This is the part that is hard to reproduce by hand: a record of what
a script actually did to the data, next to the code that did it.

```{r transcript}
log_path <- tempfile(fileext = ".txt")
dt_log(log_path)

final <- merged[!is.na(crp)]
final[, crp_log := log(crp)]
summary_tbl <- final[, .(n = .N, mean_crp_log = mean(crp_log)), by = .(arm, sex)]

dt_log_end()
```

The file holds the calls as R deparses them, with their messages underneath:

```{r transcript-show, comment = ""}
cat(readLines(log_path), sep = "\n")
```

`dt_log(append = TRUE)` adds to an existing file, `code = FALSE` writes the
messages without the calls, and `echo = FALSE` writes only to the file and
leaves the console quiet. `dt_log_file()` returns the path of the open
transcript, or `NULL`:

```{r transcript-file}
dt_log_file()
```

The file is flushed after every operation, so it is readable while a long
script is still running, and a session that ends without `dt_log_end()` still
leaves a complete file -- only the closing line is missing.

## Turning the volume down

A long pipeline in a loop does not need a message per iteration.
`dtlog_pause()` and `dtlog_resume()` bracket a block of code:

```{r pause}
dtlog_pause()
for (i in 1:3) merged[, tmp := i]
dtlog_resume()

merged[, tmp := NULL]
```

`options(dtlog.detail = "compact")` keeps the messages but drops the
value-level detail -- types, unique values, share of `NA`, number of values
changed. It is also the setting that never copies data, so a `:=` on a large
table costs nothing:

```{r compact}
options(dtlog.detail = "compact")
merged[, crp_high := crp > 10]
options(dtlog.detail = "full")
```

`options(dtlog.display = ...)` decides where the output goes. The default is
`message()`; a list of functions sends each message to all of them, and an
empty list turns logging off without unloading the package:

```{r display}
options(dtlog.display = list(function(x) cat("LOG |", x, "\n")))
elderly <- merged[age >= 70]

options(dtlog.display = NULL)  # back to message()
```

Finally, `dtlog_summary()` describes a table and returns it unchanged, so it
can sit in the middle of a chain:

```{r summary}
dtlog_summary(merged)[1:2, .(id, arm)]
```

## What does not change

`dtlog` re-evaluates the call you wrote, unchanged, in the frame you wrote it
in. Return values and visibility are the same, `:=` and the `set*()` functions
still modify by reference, non-standard evaluation still works, and `setDT()`
still converts a variable in the caller. The package's parity tests run more
than a hundred `data.table` idioms twice -- once through `dtlog`, once through
`data.table` -- and compare the value, its visibility and the state of the
inputs.

```{r cleanup, include = FALSE}
unlink(c(path, log_path))
```
