Powered by AppSignal & Oban Pro

Reproducible R Analysis With renv

renv_process_backend_smoke.livemd

Reproducible R Analysis With renv

Mix.install([
  {:rx, "~> 0.1"}
])

Shared Analysis Scenario

This notebook models a common workflow: an Elixir Livebook opens an R analysis project with a pinned renv.lock, starts Rx inside that project, reads project configuration from R, and runs a small analysis that depends on the packages in the project library.

The demo uses datasets::airquality and jsonlite so it can run without downloading a large package set. On a real team project, replace the temporary demo path with the project directory that contains your committed renv.lock.

For an already-restored project, call :ok = Rx.renv_init("/path/to/your/project", restore: false).

To populate the project library from the lockfile first, call :ok = Rx.renv_init("/path/to/your/project", restore: true).

Prepare A Demo Project

The helper below builds a disposable project that behaves like a checked-out R project: it has a project file, an renv.lock, a project-local library, and a small JSON configuration file. It reports %{status: "skipped"} when local R prerequisites are missing instead of writing to a user library.

defmodule RenvAnalysisExample do
  @moduledoc false

  def prepare do
    with {:ok, rscript} <- find_rscript(),
         :ok <- require_r_package(rscript, "renv"),
         :ok <- require_r_package(rscript, "jsonlite") do
      prepare_project(rscript)
    end
  end

  def run_analysis(%{status: "ready"} = context) do
    {result, _globals} =
      Rx.eval(
        ~S"""
        project <- Sys.getenv("RX_RENV_PROJECT")
        config <- jsonlite::fromJSON(file.path(project, "analysis_config.json"))

        observations <- stats::na.omit(
          datasets::airquality[, c("Ozone", "Solar.R", "Wind", "Temp")]
        )

        fit <- lm(Ozone ~ Solar.R + Wind + Temp, data = observations)

        prediction_days <- as.data.frame(config$prediction_days)
        prediction_days$predicted_ozone <- as.numeric(predict(fit, newdata = prediction_days))

        coefficient_matrix <- coef(summary(fit))
        coefficients <- data.frame(
          term = rownames(coefficient_matrix),
          estimate = unname(coefficient_matrix[, "Estimate"]),
          std_error = unname(coefficient_matrix[, "Std. Error"]),
          p_value = unname(coefficient_matrix[, "Pr(>|t|)"]),
          row.names = NULL
        )

        report <- list(
          status = "completed",
          project = config$project,
          rx_project = Sys.getenv("RX_RENV_PROJECT"),
          lockfile = Sys.getenv("RX_RENV_LOCKFILE"),
          lib_paths = .libPaths(),
          jsonlite_version = as.character(utils::packageVersion("jsonlite")),
          r_version = as.character(getRversion()),
          rows_used = nrow(observations),
          model_formula = deparse(formula(fit)),
          coefficients = coefficients,
          predictions = prediction_days,
          model_summary = paste(capture.output(summary(fit)), collapse = "\n"),
          session = paste(capture.output(sessionInfo()), collapse = "\n")
        )

        as.character(
          jsonlite::toJSON(report, dataframe = "rows", auto_unbox = TRUE, digits = 5, na = "null")
        )
        """,
        %{}
      )

    result
    |> Rx.decode()
    |> Jason.decode!()
    |> Map.put("project_directory", context.project)
  end

  def run_analysis(skipped), do: skipped

  defp find_rscript do
    case System.find_executable("Rscript") do
      nil -> {:skip, "Rscript is not on PATH"}
      path -> {:ok, path}
    end
  end

  defp require_r_package(rscript, package) do
    source = ~s|cat(requireNamespace("#{package}", quietly = TRUE))|

    case System.cmd(rscript, ["--vanilla", "-e", source], stderr_to_stdout: true) do
      {"TRUE", 0} -> :ok
      {output, _status} -> {:skip, "R package #{package} is unavailable: #{String.trim(output)}"}
    end
  end

  defp prepare_project(rscript) do
    root = Path.join(System.tmp_dir!(), "rx-renv-analysis-#{System.unique_integer([:positive])}")
    project = Path.join(root, "airquality-project")
    renv_root = Path.join(root, "renv-root")
    renv_library = Path.join(project, "renv-library")
    config_path = Path.join(project, "analysis_config.json")

    File.mkdir_p!(project)

    config = %{
      "project" => "airquality-ozone-forecast",
      "prediction_days" => [
        %{"Solar.R" => 190, "Wind" => 7.4, "Temp" => 87},
        %{"Solar.R" => 250, "Wind" => 9.2, "Temp" => 92}
      ]
    }

    File.write!(config_path, Jason.encode!(config))

    env = [
      {"RENV_PATHS_ROOT", renv_root},
      {"RENV_PATHS_LIBRARY", renv_library},
      {"RENV_CONFIG_SANDBOX_ENABLED", "FALSE"}
    ]

    source = ~S"""
    args <- commandArgs(trailingOnly = TRUE)
    if (length(args) > 0L && identical(args[[1]], "--args")) args <- args[-1L]
    project <- normalizePath(args[[1]], mustWork = TRUE)

    options(renv.consent = TRUE)

    if (!requireNamespace("renv", quietly = TRUE)) {
      cat("renv unavailable\n", file = stderr())
      quit(save = "no", status = 10)
    }

    if (!requireNamespace("jsonlite", quietly = TRUE)) {
      cat("jsonlite unavailable\n", file = stderr())
      quit(save = "no", status = 11)
    }

    lockfile <- file.path(project, "renv.lock")
    writeLines(c(
      "{",
      sprintf('  "R": { "Version": "%s" },', as.character(getRversion())),
      '  "Packages": {}',
      "}"
    ), lockfile)

    renv::init(project = project, bare = TRUE, restart = FALSE, load = FALSE)

    library_path <- renv::paths$library(project = project)
    dir.create(library_path, recursive = TRUE, showWarnings = FALSE)

    source <- find.package("jsonlite")
    target <- file.path(library_path, "jsonlite")
    if (dir.exists(target)) unlink(target, recursive = TRUE)

    if (!file.copy(source, library_path, recursive = TRUE)) {
      cat("failed to copy jsonlite into isolated renv library\n", file = stderr())
      quit(save = "no", status = 12)
    }

    lock <- renv::lockfile_read(lockfile, project = project)
    lock$Packages$jsonlite <- list(
      Package = "jsonlite",
      Version = as.character(utils::packageVersion("jsonlite")),
      Source = "Repository",
      Repository = "CRAN"
    )
    renv::lockfile_write(lock, lockfile, project = project)
    """

    case System.cmd(rscript, ["--vanilla", "-e", source, "--args", project],
           env: env,
           stderr_to_stdout: true
         ) do
      {_output, 0} ->
        {:ok,
         %{
           status: "prepared",
           project: Path.expand(project),
           lockfile: Path.expand(Path.join(project, "renv.lock")),
           config_path: Path.expand(config_path),
           renv_root: Path.expand(renv_root),
           renv_library: Path.expand(renv_library),
           renv_env: env
         }}

      {output, status} ->
        {:skip, "could not prepare isolated renv project, status #{status}: #{String.trim(output)}"}
    end
  end
end

Start Rx In That Project

Rx.renv_init/2 validates the lockfile, checks that renv and jsonlite are available to the selected project, and starts the process backend with RX_RENV_PROJECT and RX_RENV_LOCKFILE set for the R process.

example =
  case RenvAnalysisExample.prepare() do
    {:ok, context} ->
      project = context.project

      :ok =
        Rx.renv_init(project,
          restore: false,
          renv_env: context.renv_env
        )

      context
      |> Map.put(:status, "ready")
      |> Map.put(:backend, Rx.backend())

    {:skip, reason} ->
      %{status: "skipped", reason: reason}
  end

Map.drop(example, [:renv_env])

Inspect The R Environment

This is the quick sanity check you would usually run before a notebook analysis: confirm the project path, lockfile path, project library, and package version R actually sees.

renv_environment =
  case example do
    %{status: "ready"} ->
      {result, _globals} =
        Rx.eval(
          ~S"""
          list(
            project = Sys.getenv("RX_RENV_PROJECT"),
            lockfile = Sys.getenv("RX_RENV_LOCKFILE"),
            lib_paths = .libPaths(),
            jsonlite_version = as.character(utils::packageVersion("jsonlite"))
          )
          """,
          %{}
        )

      Rx.decode(result)

    skipped ->
      skipped
  end

renv_environment

Run The Analysis

The R code reads the project configuration with jsonlite::fromJSON(), fits a small ozone model against datasets::airquality, predicts for the configured days, and returns a JSON report to Elixir.

analysis_report = RenvAnalysisExample.run_analysis(example)

case analysis_report do
  %{"status" => "completed"} = report ->
    %{
      project: report["project"],
      rows_used: report["rows_used"],
      formula: report["model_formula"],
      jsonlite_version: report["jsonlite_version"],
      predictions: report["predictions"],
      coefficient_terms: Enum.map(report["coefficients"], &amp; &amp;1["term"])
    }

  skipped ->
    skipped
end

Keep The Full Report

The previous cell keeps the display compact. The full report still includes the project paths, .libPaths(), model summary, and sessionInfo() output so the notebook result can be audited later.

analysis_report

What Changes Between Projects

Rx treats the selected project, lockfile path, lockfile content, resolved library paths, and selected renv environment as part of the backend identity. If any of those change, call Rx.renv_init/2 again and recreate R object handles in the new session before passing them back to R.

For example, repoint the process backend at another checked-out project with :ok = Rx.renv_init("/path/to/another/project", restore: false).