diff --git a/.gitignore b/.gitignore index 508df78..378d2c9 100644 --- a/.gitignore +++ b/.gitignore @@ -1,2 +1,4 @@ .Rproj.user .Rhistory +.google-sheets-credentials +.secret diff --git a/.google-sheets-credentials.example b/.google-sheets-credentials.example index cbf29c4..6c0fc4e 100644 --- a/.google-sheets-credentials.example +++ b/.google-sheets-credentials.example @@ -1,2 +1,2 @@ -GOOGLE_SHEET_ID = "sheetid" -GOOGLE_SHEET_USER = "me@gmail.com" +GOOGLE_SHEET_ID=sheetid +GOOGLE_SHEET_USER=me@gmail.com diff --git a/DESCRIPTION b/DESCRIPTION index 58c8c44..b6c2333 100644 --- a/DESCRIPTION +++ b/DESCRIPTION @@ -1,6 +1,6 @@ Package: shinyusertracking Title: Add user tracking to Shiny apps -Version: 1.0.1.000 +Version: 1.0.2.000 Authors@R: person( "Mark", "McPherson", , @@ -13,6 +13,14 @@ Encoding: UTF-8 Roxygen: list(markdown = TRUE) RoxygenNote: 7.3.1 Config/testthat/edition: 3 +VignetteBuilder: knitr Imports: googlesheets4, - lubridate + lubridate, + pkgload, + rstudioapi, + shiny, + usethis +Suggests: + knitr, + rmarkdown diff --git a/NAMESPACE b/NAMESPACE index b2483aa..8c994be 100644 --- a/NAMESPACE +++ b/NAMESPACE @@ -1,3 +1,6 @@ # Generated by roxygen2: do not edit by hand -export(set_user_tracking) +export(add_credentials) +export(set_credentials) +export(setup_sheet) +export(use_logging) diff --git a/R/usertracking.R b/R/usertracking.R index 0ea8b6e..3ace463 100644 --- a/R/usertracking.R +++ b/R/usertracking.R @@ -1,31 +1,96 @@ -#' Add user tracking +#' Securely ask for Google sheet ID and username #' -#' Log session ID, username (only for Private apps), session start, end and -#' duration to a Google sheet. +#' Uses Rstudio secret functionality together with the keyring package to securely +#' store the input values. If keyring is not installed and up to date, the option to +#' do so will be given. Once keyring is present, tick the box to save the secrets. +#' The next time this is run, the secrets will be pre-filled. #' -#' @param columns Which columns to record, from id, username, login, logout and -#' duration. By default all will be recorded. -#' @param session Shiny session object. +#' @param file File used to store credentials. Defaults to `.google-sheets-credentials`. +#' @param overwrite Whether to overwrite file if it already exists. Default is FALSE. +#' +#' @return Nothing, used for side-effects only #' -#' @return Nothing; used for side effect. #' @export #' #' @examples #' \dontrun{ -#' library(shiny) -#' -#' ui <- fluidPage() -#' server <- function(input, output, session) { -#' shinyusertracking::set_user_tracking( -#' c("login", "logout", "duration"), -#' session -#' ) +#' add_credentials(overwrite = TRUE) #' } +add_credentials <- function(file = ".google-sheets-credentials", overwrite = FALSE) { + if (!overwrite && file.exists(file)) { + stop( + "Credentials file ", file, " already exists; set overwrite = TRUE if you are sure." + ) + } + + writeLines( + c( + paste0( + "GOOGLE_SHEET_ID=", + tryCatch( + rstudioapi::askForSecret("GOOGLE_SHEET_ID"), + error = \(e) stop("Aborting...") + ) + ), + paste0( + "GOOGLE_SHEET_USER=", + tryCatch( + rstudioapi::askForSecret("GOOGLE_SHEET_USER"), + error = \(e) stop("Aborting...") + ) + ) + ), + file + ) + + usethis::use_git_ignore(file) +} + + +#' Set environment variables for the Google sheet ID and username #' -#' shinyApp(ui, server) +#' @param file File used to store credentials. Defaults to `.google-sheets-credentials`. +#' +#' @return Nothing, used for side-effects only +#' +#' @export +#' +#' @examples +#' \dontrun{ +#' set_credentials() #' } +set_credentials <- function(file = ".google-sheets-credentials") { + if (file.exists(file)) { + tryCatch( + lines <- readLines(file), + error = \(e) stop("Error reading lines from ", file) + ) + } else { + stop("File ", file, " does not exist") + } + + id <- "GOOGLE_SHEET_ID" + id_line <- lines[startsWith(lines, id)] + if (!length(id_line)) stop("No value for ", id, " in ", file) + + user <- "GOOGLE_SHEET_USER" + user_line <- lines[startsWith(lines, user)] + if (!length(user_line)) stop("No value for ", user, " in ", file) + + Sys.setenv(GOOGLE_SHEET_ID = gsub("^[^\\=]*\\=(.*)$", "\\1", id_line)) + Sys.setenv(GOOGLE_SHEET_USER = gsub("^[^\\=]*\\=(.*)$", "\\1", user_line)) +} + + +#' Check that only known columns are provided +#' +#' @param columns Either NULL or vector of column names. +#' +#' @return The provided columns, if all are known; all known columns if NULL input; +#' or error if some provided columns are not known. #' -set_user_tracking <- function(columns = NULL, session) { +#' @noRd +check_cols <- function(columns) { known_cols <- c( "id", "username", @@ -35,35 +100,102 @@ set_user_tracking <- function(columns = NULL, session) { ) if (is.null(columns)) { - columns <- known_cols + return(known_cols) } else { - stopifnot({ - columns %in% known_cols - }) + stopifnot( + "Columns not in: id, username, login, logout, duration" = columns %in% known_cols + ) } - eval_lines(".google-sheets-credentials") + columns +} - google_email <- NULL - sheet_id <- NULL - try({ - google_email <- get("GOOGLE_SHEET_USER") - }) - try({ - sheet_id <- get("GOOGLE_SHEET_ID") - }) +#' Add a new sheet for tracking to the Google sheets +#' +#' @param sheet_name Name for the sheet. Default is to use current package name. +#' @param columns Which columns to log, from id, username, login, logout and +#' duration. By default login, logout and duration will be logged. +#' @param creds File used to store credentials. Defaults to `.google-sheets-credentials`. +#' +#' @return Nothing, used for side-effects only +#' +#' @export +#' +#' @examples +#' \dontrun{ +#' setup_sheet("A new Shiny app", c("login", "logout", "duration")) +#' } +setup_sheet <- function(sheet_name = pkgload::pkg_name(), + columns = c("login", "logout", "duration"), + creds = ".google-sheets-credentials") { + columns <- check_cols(columns) - if (is.null(google_email) || is.null(sheet_id)) { - warning( - "Credentials missing for shinyusertracking::set_user_tracking", - call. = FALSE + set_credentials(creds) + + googlesheets4::gs4_auth( + email = Sys.getenv("GOOGLE_SHEET_USER"), + cache = ".secret/" + ) + + usethis::use_git_ignore(".secret") + + googlesheets4::sheet_add(Sys.getenv("GOOGLE_SHEET_ID"), sheet_name) + googlesheets4::sheet_append( + Sys.getenv("GOOGLE_SHEET_ID"), + data.frame(matrix(columns, nrow = 1)), + sheet_name + ) +} + + +#' Add visit tracking to Shiny app +#' +#' Log session ID, username (only for Private apps), session start, end and +#' duration to a Google sheet. +#' +#' @param sheet_name Name for the sheet. Default is to use current package name. +#' @param columns Which columns to log, from id, username, login, logout and +#' duration. By default login, logout and duration will be logged. +#' @param creds File used to store credentials. Defaults to `.google-sheets-credentials`. +#' +#' @return Nothing, used for side-effects only +#' +#' @export +#' +#' @examples +#' \dontrun{ +#' library(shiny) +#' +#' ui <- fluidPage() +#' server <- function(input, output, session) { +#' shinyusertracking::set_user_tracking( +#' c("login", "logout", "duration"), +#' session +#' ) +#' } +#' +#' shinyApp(ui, server) +#' } +use_logging <- function(sheet_name = pkgload::pkg_name(), + columns = c("login", "logout", "duration"), + creds = ".google-sheets-credentials") { + columns <- check_cols(columns) + + stopifnot( + "set_user_tracking can run only in a Shiny app" = shiny::isRunning(), + "set_user_tracking requires a Shiny session object to run" = exists( + "session", + parent.frame() ) - return() - } + ) + + session <- get("session", parent.frame()) + + set_credentials(creds) googlesheets4::gs4_auth( - email = google_email, + email = Sys.getenv("GOOGLE_SHEET_USER"), cache = ".secret/" ) @@ -94,40 +226,9 @@ set_user_tracking <- function(columns = NULL, session) { session$userData$tracking$duration <- as.character(duration) googlesheets4::sheet_append( - sheet_id, - subset(session$userData$tracking, select = columns) + Sys.getenv("GOOGLE_SHEET_ID"), + subset(session$userData$tracking, select = columns), + sheet_name ) }) } - - -#' Evaluate each line of plain text file -#' -#' Reads a plain text file line by line, evaluating each line. Useful for -#' creating variables dynamically, e.g. reading in parameters. -#' -#' @param filepath Filepath as a String. -#' @param envir Environment to evaluate in. Default is calling environment. -#' -#' @return Nothing -#' -#' @examples -#' \dontrun{ -#' filepath <- tempfile() -#' writeLines( -#' text = "LEFT = \"right\"", -#' con = filepath -#' ) -#' eval_lines(filepath) -#' print(LEFT) -#' unlink(filepath) # delete temporary file -#' rm(left) # remove example variable -#' } -eval_lines <- function(filepath, envir = parent.frame()) { - con <- file(filepath, open = "r") - on.exit(close(con)) - - while (length(line <- readLines(con, n = 1, warn = FALSE)) > 0) { - eval(parse(text = line), envir = envir) - } -} diff --git a/README.md b/README.md index d850864..7057548 100644 --- a/README.md +++ b/README.md @@ -9,15 +9,14 @@ ## Installation -You can install `shinyusertracking` from [GitHub](https://github.com/) with: +To install this package from GitHub, use the below code. Note that you must explicitly ask for vignettes to be built when installing from GitHub. -``` r -# install.packages("devtools") -devtools::install_github("nhsbsa-data-analytics/shinyusertracking") -``` +`remotes::install_github("nhsbsa-data-analytics/shinyusertracking", build_vignettes = TRUE)` ## Usage +Fields available for logging are: + Column|Description :---:|:---: id|The Shiny session ID @@ -26,39 +25,24 @@ login|Timestamp of session start logout|Timestamp of session end duration|Duration of session in `hh:mm:ss` format -1. Create a new Google sheet, with column headers corresponding to the columns you will be recording (they can be named differently to the columns given if you want). -2. Copy the contents of the file `.google-sheets-credentials.example` in this repo to a file named `.google-sheets-credentials` in the root directory of your app. -3. IMPORTANT: You must tell git to ignore this! `usethis::use_git_ignore(".google-sheets-credentials")`. -4. IMPORTANT: While you are at it, also `usethis::use_git_ignore(".secret/")`. This is the directory in which the Google authorisation secret will be stored. -5. Replace the example for `GOOGLE_SHEET_ID` with the ID of the Google sheet. You can find this in the URL. For example, if the URL is `https://docs.google.com/spreadsheets/d/1vwrKiwX4T_-A2IldWnjcd1PHlCDsGAq9U-yTtQ6tgzk/edit?gid=0#gid=0`, the ID is `1vwrKiwX4T_-A2IldWnjcd1PHlCDsGAq9U-yTtQ6tgzk`. -6. Replace the example for `GOOGLE_SHEET_USER` with the Google account username. See the page _Coding and Dashboards/ R code/Shiny app visit tracking_ in the DALL Wiki for details of a Data Science team Google account to use. -7. Add the code at the top of your `server` function. -8. The first time it is run, you will be asked to authenticate the Google account access. Once this is done, an authorisation token will be stored in the `.secrets` directory. This can be reused when adding visit tracking for another app. So alternatively, you could copy an existing `.secret` directory, with the token inside, and paste into the root directory of your app. Note that you will not be able to confirm the access on AVDs, as the Google pages to do so are not whitelisted. See the page _Coding and Dashboards/ R code/Shiny app visit tracking_ in the DALL Wiki for details of where to find an existing token. -8. Ensure you bundle both the `.google-sheets-credentials` file and the `.secret` directory when your app is deployed. +By default, `login`, `logout` and `duration` will be logged. Although `sessionid` and `username` are also available, these have potential to be treated as PII, so please ensure you meet any legal obligations if logging these. -Note that 'visits' when running it locally in development will also track. So you might want to introduce some configuration to only run in production. Alternatively, try to remember to delete any entries logged during development from the Google sheet. +For instructions on using check out the vignette by running `vignette("adding-logging-to-a-shiny-app", "shinyusertracking")`. + +Note that 'visits' when running it locally in development will also be logged. So you might want to introduce some configuration to only run in production. Alternatively, try to remember to delete any entries logged during development from the Google sheet. ## Example +Once the necessary credentials are in place, and a Google sheet is ready to be logged to, just place the a call to `use_logging()` at the top of your server function. + ``` r library(shiny) ui <- fluidPage() server <- function(input, output, session) { - shinyusertracking::set_user_tracking( - session - ) + shinyusertracking::use_logging() } shinyApp(ui, server) ``` - -Optionally, you can choose to log specific columns only. - -``` r -shinyusertracking::set_user_tracking( - columns = c("login", "logout", "duration"), - session -) -``` diff --git a/man/add_credentials.Rd b/man/add_credentials.Rd new file mode 100644 index 0000000..a98fead --- /dev/null +++ b/man/add_credentials.Rd @@ -0,0 +1,27 @@ +% Generated by roxygen2: do not edit by hand +% Please edit documentation in R/usertracking.R +\name{add_credentials} +\alias{add_credentials} +\title{Securely ask for Google sheet ID and username} +\usage{ +add_credentials(file = ".google-sheets-credentials", overwrite = FALSE) +} +\arguments{ +\item{file}{File used to store credentials. Defaults to \code{.google-sheets-credentials}.} + +\item{overwrite}{Whether to overwrite file if it already exists. Default is FALSE.} +} +\value{ +Nothing, used for side-effects only +} +\description{ +Uses Rstudio secret functionality together with the keyring package to securely +store the input values. If keyring is not installed and up to date, the option to +do so will be given. Once keyring is present, tick the box to save the secrets. +The next time this is run, the secrets will be pre-filled. +} +\examples{ +\dontrun{ +add_credentials(overwrite = TRUE) +} +} diff --git a/man/eval_lines.Rd b/man/eval_lines.Rd deleted file mode 100644 index 337e68e..0000000 --- a/man/eval_lines.Rd +++ /dev/null @@ -1,33 +0,0 @@ -% Generated by roxygen2: do not edit by hand -% Please edit documentation in R/usertracking.R -\name{eval_lines} -\alias{eval_lines} -\title{Evaluate each line of plain text file} -\usage{ -eval_lines(filepath, envir = parent.frame()) -} -\arguments{ -\item{filepath}{Filepath as a String.} - -\item{envir}{Environment to evaluate in. Default is calling environment.} -} -\value{ -Nothing -} -\description{ -Reads a plain text file line by line, evaluating each line. Useful for -creating variables dynamically, e.g. reading in parameters. -} -\examples{ -\dontrun{ -filepath <- tempfile() -writeLines( - text = "LEFT = \"right\"", - con = filepath -) -eval_lines(filepath) -print(LEFT) -unlink(filepath) # delete temporary file -rm(left) # remove example variable -} -} diff --git a/man/set_credentials.Rd b/man/set_credentials.Rd new file mode 100644 index 0000000..dc3c94a --- /dev/null +++ b/man/set_credentials.Rd @@ -0,0 +1,22 @@ +% Generated by roxygen2: do not edit by hand +% Please edit documentation in R/usertracking.R +\name{set_credentials} +\alias{set_credentials} +\title{Set environment variables for the Google sheet ID and username} +\usage{ +set_credentials(file = ".google-sheets-credentials") +} +\arguments{ +\item{file}{File used to store credentials. Defaults to \code{.google-sheets-credentials}.} +} +\value{ +Nothing, used for side-effects only +} +\description{ +Set environment variables for the Google sheet ID and username +} +\examples{ +\dontrun{ +set_credentials() +} +} diff --git a/man/set_user_tracking.Rd b/man/set_user_tracking.Rd deleted file mode 100644 index 54d017d..0000000 --- a/man/set_user_tracking.Rd +++ /dev/null @@ -1,37 +0,0 @@ -% Generated by roxygen2: do not edit by hand -% Please edit documentation in R/usertracking.R -\name{set_user_tracking} -\alias{set_user_tracking} -\title{Add user tracking} -\usage{ -set_user_tracking(columns = NULL, session) -} -\arguments{ -\item{columns}{Which columns to record, from id, username, login, logout and -duration. By default all will be recorded.} - -\item{session}{Shiny session object.} -} -\value{ -Nothing; used for side effect. -} -\description{ -Log session ID, username (only for Private apps), session start, end and -duration to a Google sheet. -} -\examples{ -\dontrun{ -library(shiny) - -ui <- fluidPage() -server <- function(input, output, session) { - shinyusertracking::set_user_tracking( - c("login", "logout", "duration"), - session - ) -} - -shinyApp(ui, server) -} - -} diff --git a/man/setup_sheet.Rd b/man/setup_sheet.Rd new file mode 100644 index 0000000..2978534 --- /dev/null +++ b/man/setup_sheet.Rd @@ -0,0 +1,31 @@ +% Generated by roxygen2: do not edit by hand +% Please edit documentation in R/usertracking.R +\name{setup_sheet} +\alias{setup_sheet} +\title{Add a new sheet for tracking to the Google sheets} +\usage{ +setup_sheet( + sheet_name = pkgload::pkg_name(), + columns = c("login", "logout", "duration"), + creds = ".google-sheets-credentials" +) +} +\arguments{ +\item{sheet_name}{Name for the sheet. Default is to use current package name.} + +\item{columns}{Which columns to log, from id, username, login, logout and +duration. By default login, logout and duration will be logged.} + +\item{creds}{File used to store credentials. Defaults to \code{.google-sheets-credentials}.} +} +\value{ +Nothing, used for side-effects only +} +\description{ +Add a new sheet for tracking to the Google sheets +} +\examples{ +\dontrun{ +setup_sheet("A new Shiny app", c("login", "logout", "duration")) +} +} diff --git a/man/use_logging.Rd b/man/use_logging.Rd new file mode 100644 index 0000000..9ae4f86 --- /dev/null +++ b/man/use_logging.Rd @@ -0,0 +1,42 @@ +% Generated by roxygen2: do not edit by hand +% Please edit documentation in R/usertracking.R +\name{use_logging} +\alias{use_logging} +\title{Add visit tracking to Shiny app} +\usage{ +use_logging( + sheet_name = pkgload::pkg_name(), + columns = c("login", "logout", "duration"), + creds = ".google-sheets-credentials" +) +} +\arguments{ +\item{sheet_name}{Name for the sheet. Default is to use current package name.} + +\item{columns}{Which columns to log, from id, username, login, logout and +duration. By default login, logout and duration will be logged.} + +\item{creds}{File used to store credentials. Defaults to \code{.google-sheets-credentials}.} +} +\value{ +Nothing, used for side-effects only +} +\description{ +Log session ID, username (only for Private apps), session start, end and +duration to a Google sheet. +} +\examples{ +\dontrun{ +library(shiny) + +ui <- fluidPage() +server <- function(input, output, session) { + shinyusertracking::set_user_tracking( + c("login", "logout", "duration"), + session + ) +} + +shinyApp(ui, server) +} +} diff --git a/vignettes/.gitignore b/vignettes/.gitignore new file mode 100644 index 0000000..097b241 --- /dev/null +++ b/vignettes/.gitignore @@ -0,0 +1,2 @@ +*.html +*.R diff --git a/vignettes/adding-logging-to-a-shiny-app.Rmd b/vignettes/adding-logging-to-a-shiny-app.Rmd new file mode 100644 index 0000000..3fa0e71 --- /dev/null +++ b/vignettes/adding-logging-to-a-shiny-app.Rmd @@ -0,0 +1,73 @@ +--- +title: "Adding logging to a shiny app" +output: rmarkdown::html_vignette +vignette: > + %\VignetteIndexEntry{Adding logging to a shiny app} + %\VignetteEngine{knitr::rmarkdown} + %\VignetteEncoding{UTF-8} +--- + +```{r, include = FALSE} +knitr::opts_chunk$set( + collapse = TRUE, + comment = "#>" +) +``` + +## Information needed + +### ID of Google sheet to use + +To keep all logging sheets together, it is advised to create an initial Google sheet and then add new sheets as you add logging to apps. You will need the ID of the initial sheet. This can be found from the URL. + +#### Example + +A sheet with URL + +``` +https://docs.google.com/spreadsheets/d/1PZJ_xCbZPSlMzfmjtrM0ePB6115Qqp5fYmLxTNTa5ms/edit?gid=0#gid=0 +``` + +has ID `1PZJ_xCbZPSlMzfmjtrM0ePB6115Qqp5fYmLxTNTa5ms` + +### Google account username + +You will need the username of the Google account. Typically this will be an email. + +## Add credentials + +Since the shiny app will be deployed, you need to provide it with a 'hard copy' of the credentials. To add these, use `add_credentials()`. Ensure you are in the root folder of the R project holding the app in Rstudio. Two pop-ups will appear, one for sheet ID and one for username. + +These pop-ups will allow the credentials to be cached in your OS secrets store securely. This makes them permanently available, ready for the next time you need them. If necessary, the option to install and update the `keyring` package will be shown. + +The file in which credentials are saved will be added to the `.gitignore` file automatically. + +## Set up logging sheet + +Once the credentials are added you can add a new sheet for the app. To do this, again ensuring you are in the root directory of the R project containing the app, run `setup_sheet()`. This will append a new sheet to the sheet with ID provided earlier. By default, `login`, `logout` and `duration` will be logged. Although `sessionid` and `username` are also available, these have potential to be treated as PII. + +Before the sheet can be added, authorisation must take place. You have two options. + +Option 1. Place an existing authorisation file in a folder `.secret`, within the root folder of the project. + +Option 2. Let a browser window open, and confirm authorisation. This will save an authorisation file in `.secret`. + +The `.secret` folder, whether pasted in or created anew, will be added to the `.gitignore` file automatically. + +## Add code to server function + +Add the logging code to the shiny server function. This should be placed at the top, before any other code. + +``` r +server <- function(input, output, session) { + shinyusertracking::use_logging() + + ... # Existing code below + ... + ... +} +``` + +## App deployment + +When deploying the shiny app, ensure that both the credentials file (`.google-sheets-credentials` by default) and `.secret` folder are bundled with the usual files.