diff --git a/.github/workflows/check_with_databases.yml b/.github/workflows/check_with_databases.yml index 74b49572..f7efbf2a 100644 --- a/.github/workflows/check_with_databases.yml +++ b/.github/workflows/check_with_databases.yml @@ -50,6 +50,13 @@ jobs: TEST_MSSQLSERVER_HOSTNAME: '127.0.0.1' TEST_MSSQLSERVER_TRUST_SERVER_CERTIFICATE: 'YES' TEST_MSSQLSERVER_DRIVER: 'ODBC Driver 18 for SQL Server' + ## MongoDb + TEST_MONGODB_USER: 'mongodb' + TEST_MONGODB_PASSWORD: 'mysecretpassword' + TEST_MONGODB_HOST: '127.0.0.1' + TEST_MONGODB_PORT: 27017 + TEST_MONGODB_DBNAME: 'shiny_telemetry' + TEST_MONGODB_COLLECTION: 'event_log' ########################################### # Services container to run with main job # @@ -79,6 +86,13 @@ jobs: env: ACCEPT_EULA: Y MSSQL_SA_PASSWORD: 'my-Secr3t_Password' + mongodb: + image: mongo + env: + MONGO_INITDB_ROOT_USERNAME: mongodb + MONGO_INITDB_ROOT_PASSWORD: mysecretpassword + ports: + - 27017:27017 steps: ################## diff --git a/DESCRIPTION b/DESCRIPTION index 3fcd2004..fb38479c 100644 --- a/DESCRIPTION +++ b/DESCRIPTION @@ -1,13 +1,14 @@ Type: Package Package: shiny.telemetry Title: 'Shiny' App Usage Telemetry -Version: 0.2.0.9012 +Version: 0.2.0.9013 Authors@R: c( person("André", "Veríssimo", , "opensource+andre@appsilon.com", role = c("aut", "cre")), person("Kamil", "Żyła", , "kamil@appsilon.com", role = "aut"), person("Krystian", "Igras", , "krystian8207@gmail.com", role = "aut"), person("Recle", "Vibal", , "recle.vibal@appsilon.com", role = "aut"), person("Arun", "Kodati", , "arun.kodati@appsilon.com", role = "aut"), + person("Wahaduzzaman", "Khan", , "wahaduzzaman@appsilon.com", role = "aut"), person("Appsilon Sp. z o.o.", , , "opensource@appsilon.com", role = "cph") ) Description: Enables instrumentation of 'Shiny' apps for tracking user @@ -48,6 +49,7 @@ Suggests: RMariaDB, RPostgreSQL, RPostgres, + mongolite, scales, semantic.dashboard (>= 0.1.1), shiny.semantic (>= 0.2.0), diff --git a/NAMESPACE b/NAMESPACE index 09fa4b0d..65da73a5 100644 --- a/NAMESPACE +++ b/NAMESPACE @@ -3,6 +3,7 @@ export(DataStorageLogFile) export(DataStorageMSSQLServer) export(DataStorageMariaDB) +export(DataStorageMongoDB) export(DataStoragePlumber) export(DataStoragePostgreSQL) export(DataStorageSQLite) diff --git a/NEWS.md b/NEWS.md index 78082b73..de205270 100644 --- a/NEWS.md +++ b/NEWS.md @@ -6,6 +6,7 @@ - Added flexibility to select between [`RPostgreSQL`, `RPostgres`] drivers (#147). - Improved input tracking by implementing inclusion and exclusion logic (#30). - Added tracking for returning anonymous users (#142). +- Added support for MongoDB (see `DataStorageMongoDB` class) (#174). ### Miscellaneous diff --git a/R/auxiliary.R b/R/auxiliary.R index 0c7c6908..b0815e6a 100644 --- a/R/auxiliary.R +++ b/R/auxiliary.R @@ -71,6 +71,91 @@ build_query_sql <- function( trimws(do.call(glue::glue_sql, c(query, .con = .con))) } +#' Build query to read from collection in DataStorageMongoDB provider +#' +#' @param date_from date representing the starting day of results. Can be NULL. +#' @param date_to date representing the last day of results. Can be NULL. +#' +#' @return A string or a JSON object that can be used as the query argument of +#' the `find()` method of a [mongolite::mongo()] class. +#' +#' @noRd +#' @examples +#' con <- mongolite::mongo() +#' con$find(query = build_query_mongodb()) +#' con$find(query = build_query_mongodb(Sys.Date() - 365)) +#' con$find(query = build_query_mongodb(date_to = Sys.Date() + 365)) +#' con$find(query = build_query_mongodb( +#' date_from = Sys.Date() - 365, date_to = Sys.Date() + 365) +#' ) +#' con$find(query = build_query_mongodb( +#' date_from = as.Date("2023-04-13"), date_to = as.Date("2000-01-01") +#' )) +build_query_mongodb <- function(date_from, date_to) { + if (is.null(date_from) && is.null(date_to)) { + query <- "{}" + return(query) + } else { + query <- list(time = list()) + } + + if (!is.null(date_from)) { + if (inherits(date_from, "Date")) { + date_from <- paste0(as.character(date_from), " 00:00:00 UTC") + } + query$time["$gte"] <- as.integer(lubridate::as_datetime(date_from)) * 1000 + } + + if (!is.null(date_to)) { + if (inherits(date_to, "Date")) { + date_to <- paste0(as.character(date_to), " 23:59:59 UTC") + } + query$time["$lte"] <- as.integer(lubridate::as_datetime(date_to)) * 1000 + } + + jsonlite::toJSON(query, auto_unbox = TRUE) +} + +#' Create the connection string for mongodb +#' +#' @noRd +#' @keywords internal +#' @examples +#' build_mongo_connection_string( +#' "localhost", +#' 31, +#' "user", +#' "pass", +#' "authdb", +#' list("option1" = "value1", "option2" = "value2") +#' ) +build_mongo_connection_string <- function( + hostname, port, username, password, authdb, options) { + checkmate::assert_string(hostname) + checkmate::assert_int(port) + checkmate::assert_string(username, null.ok = TRUE) + checkmate::assert_string(password, null.ok = TRUE) + checkmate::assert_string(authdb, null.ok = TRUE) + checkmate::assert_list(options, null.ok = TRUE) + + paste0( + "mongodb://", + sprintf("%s:%s@", username, password), + hostname, + ":", + port, + sprintf("/%s", authdb %||% ""), + ifelse( + isFALSE(is.null(options)), + sprintf( + "?%s", + paste(names(options), "=", options, collapse = "&", sep = "") + ), + "" + ) + ) +} + #' Process a row's detail (from DB) in JSON format to a data.frame #' #' @param details_json string containing details a valid JSON, NULL or NA diff --git a/R/data-storage-mongodb.R b/R/data-storage-mongodb.R new file mode 100644 index 00000000..7ae0699f --- /dev/null +++ b/R/data-storage-mongodb.R @@ -0,0 +1,168 @@ +#' Data storage class with MongoDB provider +#' +#' @description +#' Implementation of the [`DataStorage`] R6 class to MongoDB backend using a +#' unified API for read/write operations +#' +#' @export +#' +#' @examples +#' \dontrun{ +#' data_storage <- DataStorageMongoDB$new( +#' host = "localhost", +#' db = "test", +#' ssl_options = mongolite::ssl_options() +#' ) +#' data_storage$insert("example", "test_event", "session1") +#' data_storage$insert("example", "input", "s1", list(id = "id1")) +#' data_storage$insert("example", "input", "s1", list(id = "id2", value = 32)) +#' +#' data_storage$insert( +#' "example", "test_event_3_days_ago", "session1", +#' time = lubridate::as_datetime(lubridate::today() - 3) +#' ) +#' +#' data_storage$read_event_data() +#' data_storage$read_event_data(Sys.Date() - 1, Sys.Date() + 1) +#' data_storage$close() +#' } +DataStorageMongoDB <- R6::R6Class( # nolint object_name. + classname = "DataStorageMongoDB", + inherit = DataStorage, + # + # Public + public = list( + + #' @description + #' Initialize the data storage class + #' @param hostname the hostname or IP address of the MongoDB server. + #' @param port the port number of the MongoDB server (default is 27017). + #' @param username the username for authentication (optional). + #' @param password the password for authentication (optional). + #' @param authdb the default authentication database (optional). + #' @param dbname name of database (default is "shiny_telemetry"). + #' @param options Additional connection options in a named list format + #' (e.g., list(ssl = "true", replicaSet = "myreplicaset")) (optional). + #' @param ssl_options additional connection options such as SSL keys/certs + #' (default is [`mongolite::ssl_options()`]). + + initialize = function( + hostname = "localhost", + port = 27017, + username = NULL, + password = NULL, + authdb = NULL, + dbname = "shiny_telemetry", + options = NULL, + ssl_options = mongolite::ssl_options() + ) { + # create the connection string for mongodb + checkmate::assert_string(hostname) + checkmate::assert_int(port) + checkmate::assert_string(username, null.ok = TRUE) + checkmate::assert_string(password, null.ok = TRUE) + checkmate::assert_string(authdb, null.ok = TRUE) + checkmate::assert_string(dbname) + checkmate::assert_list(options, null.ok = TRUE) + checkmate::assert_list(ssl_options, null.ok = TRUE) + + password_debug <- if (is.null(password)) { + "(empty)" + } else { + digest::digest(password, algo = "sha256") + } + + logger::log_debug( + "Parameters for MongoDB:\n", + " * username: {username %||% \"(empty)\"}\n", + " * password (sha256): {password_debug}\n", + " * hostname:port: {hostname}:{port}\n", + " * db name: {dbname}\n", + " * authdb: {authdb %||% \"(empty)\"}\n", + " * options: {jsonlite::toJSON(options, auto_unbox = TRUE)}\n", + " * ssl_options: ", + "{jsonlite::toJSON(unclass(mongolite::ssl_options()), auto_unbox = TRUE)}\n", + namespace = "shiny.telemetry" + ) + + private$connect( + url = build_mongo_connection_string( + hostname = hostname, + port = port, + username = username, + password = password, + authdb = authdb, + options = options + ), + dbname, + options = ssl_options + ) + } + ), + # + # Private + private = list( + # Private Fields + db_con = NULL, + + # Private methods + connect = function(url, dbname, options) { + # Initialize connection with database + private$db_con <- mongolite::mongo( + url = url, + db = dbname, + collection = self$event_bucket, + options = options + ) + }, + + close_connection = function() { + private$db_con$disconnect() + }, + + write = function(values, bucket) { + checkmate::assert_choice(bucket, choices = c(self$event_bucket)) + checkmate::assert_list(values) + + if (!is.null(values$details)) { + values$details <- jsonlite::fromJSON(values$details) + } + + private$db_con$insert(values, auto_unbox = TRUE, POSIXt = "epoch") + }, + + read_data = function(date_from, date_to, bucket) { + checkmate::assert_choice(bucket, c(self$event_bucket)) + + event_data <- private$db_con$find( + query = build_query_mongodb(date_from, date_to), + fields = '{"_id": false}' + ) + + if (nrow(event_data) > 0) { + result <- event_data %>% + dplyr::tibble() %>% + tidyr::unnest(cols = "details") %>% + dplyr::mutate(time = lubridate::as_datetime(as.integer(time / 1000))) + + # Force value column to be a character data type + if ("value" %in% colnames(result)) { + dplyr::mutate(result, value = format(value)) + } else { + # If there is no column, then it should still be a character data type + dplyr::mutate(result, value = NA_character_) + } + } else { + dplyr::tibble( + app_name = character(), + type = character(), + session = character(), + username = character(), + id = character(), + value = character(), + time = lubridate::as_datetime(NULL, tz = "UTC") + ) + } + } + ) +) diff --git a/inst/WORDLIST b/inst/WORDLIST index c9120c57..3f6c27ce 100644 --- a/inst/WORDLIST +++ b/inst/WORDLIST @@ -7,7 +7,15 @@ CMD customizable filesystem hostname +INITDB Javascript +mongo +mongodb +myreplicaset +replicaSet Rhinoverse RStudio +SSL +ssl UI +URI diff --git a/inst/examples/mongodb/README.md b/inst/examples/mongodb/README.md new file mode 100644 index 00000000..e76699b5 --- /dev/null +++ b/inst/examples/mongodb/README.md @@ -0,0 +1,6 @@ +# Instrumented app with MongoDB backend + +This example application uses MongoDB as a provider for data storage. + +It is bundled with an example docker container provided by `docker-compose.yml`. +The MongoDB instance has to be running for the application and analytics dashboard to work. diff --git a/inst/examples/mongodb/docker-compose.yml b/inst/examples/mongodb/docker-compose.yml new file mode 100644 index 00000000..1deed2c9 --- /dev/null +++ b/inst/examples/mongodb/docker-compose.yml @@ -0,0 +1,13 @@ +# Use root/example as user/password credentials +version: '3.1' + +services: + + mongo: + image: mongo + restart: always + environment: + MONGO_INITDB_ROOT_USERNAME: root + MONGO_INITDB_ROOT_PASSWORD: example + ports: + - 27017:27017 diff --git a/inst/examples/mongodb/mongodb_analytics.R b/inst/examples/mongodb/mongodb_analytics.R new file mode 100644 index 00000000..1597c648 --- /dev/null +++ b/inst/examples/mongodb/mongodb_analytics.R @@ -0,0 +1,25 @@ +library(shiny) +library(shiny.semantic) +library(semantic.dashboard) +library(shinyjs) +library(tidyr) +library(dplyr) +library(purrr) +library(plotly) +library(timevis) +library(ggplot2) +library(mgcv) +library(config) +library(DT) + +# Please install shiny.telemetry with all dependencies +library(shiny.telemetry) + +# Default storage backend using MariaDB +data_storage <- DataStorageMongoDB$new( + username = "root", password = "example" +) + +analytics_app(data_storage = data_storage) + +# shiny::shinyAppFile(system.file("examples", "mariadb", "mariadb_analytics.R", package = "shiny.telemetry")) # nolint: commented_code, line_length. diff --git a/inst/examples/mongodb/mongodb_app.R b/inst/examples/mongodb/mongodb_app.R new file mode 100644 index 00000000..7c3bc63a --- /dev/null +++ b/inst/examples/mongodb/mongodb_app.R @@ -0,0 +1,118 @@ +library(shiny) +library(semantic.dashboard) +library(shiny.semantic) +library(shiny.telemetry) +library(dplyr) +library(config) + +counter_ui <- function(id, label = "Counter") { + ns <- NS(id) + div( + h2(class = "ui header primary", "Widgets tab content", style = "margin: 2rem"), + box( + title = label, + action_button(ns("button"), "Click me!", class = "red"), + verbatimTextOutput(ns("out")), + width = 4, color = "teal" + ) + ) +} + +ui <- dashboardPage( + dashboardHeader(title = "Basic dashboard"), + dashboardSidebar( + sidebarMenu( + menuItem(tabName = "dashboard", text = "Home", icon = icon("home")), + menuItem(tabName = "widgets", text = "Another Tab", icon = icon("heart")), + menuItem(tabName = "another-widgets", text = "Yet Another Tab", icon = icon("heart")), + id = "uisidebar" + ) + ), + dashboardBody( + use_telemetry(), + tabItems( + # First tab content + tabItem( + tabName = "dashboard", + box( + title = "Controls", + sliderInput("bins", "Number of observations:", 1, 50, 30), + action_button("apply_slider", "Apply", class = "green"), + width = 4, color = "teal" + ), + box( + title = "Old Faithful Geyser Histogram", + plotOutput("plot1", height = 400), + width = 11, color = "blue" + ), + segment( + class = "basic", + h3("Sample application instrumented by Shiny.telemetry"), + p(glue::glue("Note: using MariaDB as data backend.")), + p("Information logged:"), + tags$ul( + tags$li("Start of session"), + tags$li("Every time slider changes"), + tags$li("Click of 'Apply' button"), + tags$li("Tab navigation when clicking on the links in the left sidebar") + ) + ) + ), + + # Second tab content + tabItem( + tabName = "widgets", + counter_ui("widgets", "Counter 1") + ), + + # Third tab content + tabItem( + tabName = "another-widgets", + counter_ui("another-widgets", "Counter 2") + ) + ) + ) +) + +# Default Telemetry with data storage backend using MariaDB +telemetry <- Telemetry$new( + app_name = "demo", + data_storage = DataStorageMongoDB$new( + username = "root", password = "example" + ) +) + +# Define the server logic for a module +counter_server <- function(id) { + moduleServer( + id, + function(input, output, session) { + count <- reactiveVal(0) + observeEvent(input$button, { + count(count() + 1) + }) + output$out <- renderText(count()) + count + } + ) +} + +shinyApp(ui = ui, server = function(input, output, session) { + telemetry$start_session( + track_values = TRUE, + navigation_input_id = "uisidebar" + ) + + # server code + output$plot1 <- renderPlot({ + input$apply_slider + x <- faithful[, 2] + bins <- seq(min(x), max(x), length.out = isolate(input$bins) + 1) + hist(x, breaks = bins, col = "#0099F9", border = "white") + }) + + counter_server("widgets") + counter_server("another-widgets") +}) + +# shiny::shinyAppFile(system.file("examples", "mariadb", "mariadb_app.R", package = "shiny.telemetry")) # nolint: commented_code, line_length. diff --git a/man/DataStorageMongoDB.Rd b/man/DataStorageMongoDB.Rd new file mode 100644 index 00000000..65e4b8e2 --- /dev/null +++ b/man/DataStorageMongoDB.Rd @@ -0,0 +1,109 @@ +% Generated by roxygen2: do not edit by hand +% Please edit documentation in R/data-storage-mongodb.R +\name{DataStorageMongoDB} +\alias{DataStorageMongoDB} +\title{Data storage class with MongoDB provider} +\description{ +Implementation of the \code{\link{DataStorage}} R6 class to MongoDB backend using a +unified API for read/write operations +} +\examples{ +\dontrun{ +data_storage <- DataStorageMongoDB$new( + host = "localhost", + db = "test", + ssl_options = mongolite::ssl_options() +) +data_storage$insert("example", "test_event", "session1") +data_storage$insert("example", "input", "s1", list(id = "id1")) +data_storage$insert("example", "input", "s1", list(id = "id2", value = 32)) + +data_storage$insert( + "example", "test_event_3_days_ago", "session1", + time = lubridate::as_datetime(lubridate::today() - 3) +) + +data_storage$read_event_data() +data_storage$read_event_data(Sys.Date() - 1, Sys.Date() + 1) +data_storage$close() +} +} +\section{Super class}{ +\code{\link[shiny.telemetry:DataStorage]{shiny.telemetry::DataStorage}} -> \code{DataStorageMongoDB} +} +\section{Methods}{ +\subsection{Public methods}{ +\itemize{ +\item \href{#method-DataStorageMongoDB-new}{\code{DataStorageMongoDB$new()}} +\item \href{#method-DataStorageMongoDB-clone}{\code{DataStorageMongoDB$clone()}} +} +} +\if{html}{\out{ + +}} +\if{html}{\out{