Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Feature/#174 add mongodb support #176

Merged
merged 16 commits into from
Jun 17, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 14 additions & 0 deletions .github/workflows/check_with_databases.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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 #
Expand Down Expand Up @@ -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:
##################
Expand Down
4 changes: 3 additions & 1 deletion DESCRIPTION
Original file line number Diff line number Diff line change
@@ -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", , "[email protected]", role = c("aut", "cre")),
person("Kamil", "Żyła", , "[email protected]", role = "aut"),
person("Krystian", "Igras", , "[email protected]", role = "aut"),
person("Recle", "Vibal", , "[email protected]", role = "aut"),
person("Arun", "Kodati", , "[email protected]", role = "aut"),
person("Wahaduzzaman", "Khan", , "[email protected]", role = "aut"),
person("Appsilon Sp. z o.o.", , , "[email protected]", role = "cph")
)
Description: Enables instrumentation of 'Shiny' apps for tracking user
Expand Down Expand Up @@ -48,6 +49,7 @@ Suggests:
RMariaDB,
RPostgreSQL,
RPostgres,
mongolite,
scales,
semantic.dashboard (>= 0.1.1),
shiny.semantic (>= 0.2.0),
Expand Down
1 change: 1 addition & 0 deletions NAMESPACE
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
export(DataStorageLogFile)
export(DataStorageMSSQLServer)
export(DataStorageMariaDB)
export(DataStorageMongoDB)
export(DataStoragePlumber)
export(DataStoragePostgreSQL)
export(DataStorageSQLite)
Expand Down
1 change: 1 addition & 0 deletions NEWS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
85 changes: 85 additions & 0 deletions R/auxiliary.R
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
168 changes: 168 additions & 0 deletions R/data-storage-mongodb.R
Original file line number Diff line number Diff line change
@@ -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")
)
}
}
)
)
8 changes: 8 additions & 0 deletions inst/WORDLIST
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,15 @@ CMD
customizable
filesystem
hostname
INITDB
Javascript
mongo
mongodb
myreplicaset
replicaSet
Rhinoverse
RStudio
SSL
ssl
UI
URI
6 changes: 6 additions & 0 deletions inst/examples/mongodb/README.md
Original file line number Diff line number Diff line change
@@ -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.
13 changes: 13 additions & 0 deletions inst/examples/mongodb/docker-compose.yml
Original file line number Diff line number Diff line change
@@ -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
25 changes: 25 additions & 0 deletions inst/examples/mongodb/mongodb_analytics.R
Original file line number Diff line number Diff line change
@@ -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.
Loading
Loading