Skip to content

Commit

Permalink
Merge pull request #176 from Appsilon/feature/#174-add-mongodb-support
Browse files Browse the repository at this point in the history
Feature/#174 add mongodb support
  • Loading branch information
averissimo authored Jun 17, 2024
2 parents 57205d3 + e2b5b5b commit a7674af
Show file tree
Hide file tree
Showing 15 changed files with 641 additions and 1 deletion.
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

0 comments on commit a7674af

Please sign in to comment.