Skip to content

Commit

Permalink
Rules usage report (#146)
Browse files Browse the repository at this point in the history
* Update to Angular 13 and Node 16
* Adding a report to show the usage of rules as imported from web analytics

---------

Co-authored-by: mkr <[email protected]>
  • Loading branch information
pbartusch and mkr authored Apr 14, 2024
1 parent c341912 commit 4827262
Show file tree
Hide file tree
Showing 25 changed files with 452 additions and 50 deletions.
3 changes: 3 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -31,3 +31,6 @@ hs_err_pid*

# SBT related stuff
.bsp

# Ignore the Angular Cache
frontend/.angular/cache
5 changes: 3 additions & 2 deletions Dockerfile
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
# syntax = docker/dockerfile:1.0-experimental
FROM openjdk:11-buster as builder

ARG NODE_VERSION=10
ARG NODE_VERSION=16

RUN echo "deb https://repo.scala-sbt.org/scalasbt/debian /" | tee -a /etc/apt/sources.list.d/sbt.list \
&& curl -sL "https://keyserver.ubuntu.com/pks/lookup?op=get&search=0x2EE0EA64E40A89B84B2DF73499E82A75642AC823" | apt-key add \
Expand All @@ -14,7 +14,8 @@ RUN apt-get install -y lsb-release \
&& echo "deb-src https://deb.nodesource.com/node_$NODE_VERSION.x $DISTRO main" >> /etc/apt/sources.list.d/nodesource.list \
&& curl -sSL https://deb.nodesource.com/gpgkey/nodesource.gpg.key | apt-key add - \
&& apt-get update \
&& apt-get install -y nodejs
&& apt-get install -y nodejs \
&& apt-get install -y g++ make

COPY . /smui
WORKDIR /smui
Expand Down
16 changes: 14 additions & 2 deletions app/controllers/ApiController.scala
Original file line number Diff line number Diff line change
Expand Up @@ -21,13 +21,14 @@ import models.config.SmuiVersion
import models.config.TargetEnvironment._
import models.input.{InputTagId, InputValidator, ListItem, SearchInputId, SearchInputWithRules}
import models.querqy.QuerqyRulesTxtGenerator
import models.reports.RulesUsageReport
import models.rules.{DeleteRule, FilterRule, RedirectRule, SynonymRule, UpDownRule}
import models.spellings.{CanonicalSpellingId, CanonicalSpellingValidator, CanonicalSpellingWithAlternatives}
import org.pac4j.core.profile.{ProfileManager, UserProfile}
import org.pac4j.play.PlayWebContext
import org.pac4j.play.scala.{Security, SecurityComponents}
import play.api.libs.Files
import services.{RulesTxtDeploymentService, RulesTxtImportService}
import services.{RulesTxtDeploymentService, RulesTxtImportService, RulesUsageService}


// TODO Make ApiController pure REST- / JSON-Controller to ensure all implicit Framework responses (e.g. 400, 500) conformity
Expand All @@ -37,7 +38,8 @@ class ApiController @Inject()(val controllerComponents: SecurityComponents,
querqyRulesTxtGenerator: QuerqyRulesTxtGenerator,
rulesTxtDeploymentService: RulesTxtDeploymentService,
rulesTxtImportService: RulesTxtImportService,
targetEnvironmentConfigService: TargetEnvironmentConfigService)
targetEnvironmentConfigService: TargetEnvironmentConfigService,
rulesUsageService: RulesUsageService)
(implicit executionContext: ExecutionContext)
extends Security[UserProfile] with play.api.i18n.I18nSupport with Logging {

Expand Down Expand Up @@ -667,6 +669,16 @@ class ApiController @Inject()(val controllerComponents: SecurityComponents,
}
}

def getRulesUsageReport(solrIndexId: String): Action[AnyContent] = Action {
rulesUsageService.getRulesUsageStatistics.map { ruleUsageStatistics =>
val allSearchInputs = searchManagementRepository.listAllSearchInputsInclDirectedSynonyms(SolrIndexId(solrIndexId))
val report = RulesUsageReport.create(allSearchInputs, ruleUsageStatistics)
Ok(Json.toJson(report))
}.getOrElse(
NoContent
)
}

private def lookupUserInfo(request: Request[AnyContent]) = {
val maybeUserId = getProfiles(request).headOption.map(_.getId)
logger.debug(s"Current user: $maybeUserId")
Expand Down
9 changes: 8 additions & 1 deletion app/models/FeatureToggleModel.scala
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import play.twirl.api.utils.StringEscapeUtils

import scala.util.Try
import models.rules.UpDownRule
import services.RulesUsageService

// TODO refactor FeatureToggleModel (and FeatureToggleService) to config package (for being in sync with Spec structure)
package object FeatureToggleModel extends Logging {
Expand Down Expand Up @@ -65,6 +66,7 @@ package object FeatureToggleModel extends Logging {
private val FEATURE_CUSTOM_UP_DOWN_MAPPINGS = "toggle.ui-concept.custom.up-down-dropdown-mappings"
private val SMUI_DEPLOYMENT_GIT_REPO_URL = "smui.deployment.git.repo-url"
private val SMUI_DEPLOYMENT_GIT_FN_COMMON_RULES_TXT = "smui2solr.deployment.git.filename.common-rules-txt"
private val FEATURE_TOGGLE_REPORT_RULE_USAGE_STATISTICS = "toggle.report.rule-usage-statistics"

/**
* helper for custom UP/DOWN mappings
Expand Down Expand Up @@ -166,7 +168,8 @@ package object FeatureToggleModel extends Logging {
JsFeatureToggle(FEATURE_TOGGLE_DEPLOYMENT_LABEL, new JsStringFeatureToggleValue(
appConfig.getOptional[String](FEATURE_TOGGLE_DEPLOYMENT_LABEL).getOrElse("LIVE"))),
JsFeatureToggle(FEATURE_TOGGLE_DEPLOYMENT_PRELIVE_LABEL, new JsStringFeatureToggleValue(
appConfig.getOptional[String](FEATURE_TOGGLE_DEPLOYMENT_PRELIVE_LABEL).getOrElse("PRELIVE")))
appConfig.getOptional[String](FEATURE_TOGGLE_DEPLOYMENT_PRELIVE_LABEL).getOrElse("PRELIVE"))),
jsBoolFeatureToggle(FEATURE_TOGGLE_REPORT_RULE_USAGE_STATISTICS, hasRulesUsageStatisticsLocationConfigured)
)
}

Expand Down Expand Up @@ -230,6 +233,10 @@ package object FeatureToggleModel extends Logging {
appConfig.getOptional[String](FEATURE_TOGGLE_DEPLOYMENT_PRELIVE_LABEL).getOrElse("PRELIVE")
}

def hasRulesUsageStatisticsLocationConfigured: Boolean = {
appConfig.getOptional[String](RulesUsageService.ConfigKeyRuleUsageStatistics).nonEmpty
}

}

}
56 changes: 56 additions & 0 deletions app/models/reports/RulesUsageReport.scala
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
package models.reports

import models.input.{SearchInput, SearchInputId, SearchInputWithRules}
import play.api.Logging
import play.api.libs.json.{Json, OFormat}
import services.RulesUsage

case class RulesUsageReportEntry(
// the search input ID from the report
searchInputId: String,
// the stored search input term if it could be found in the DB
searchInputTerm: Option[String],
// the user keywords/query that the search input was triggered on
keywords: String,
// the frequency that rule was triggered for the particular keywords
frequency: Int
)

case class RulesUsageReport(items: Seq[RulesUsageReportEntry])

object RulesUsageReport extends Logging {

implicit val jsonFormatRulesUsageReportEntry: OFormat[RulesUsageReportEntry] = Json.format[RulesUsageReportEntry]
implicit val jsonFormatRulesUsageReport: OFormat[RulesUsageReport] = Json.format[RulesUsageReport]

def create(searchInputs: Seq[SearchInputWithRules], rulesUsageStatistics: Seq[RulesUsage]): RulesUsageReport = {
// perform a "full outer join" of the rules usage with the existing search inputs
val searchInputsById = searchInputs.map(searchInput => searchInput.id.id -> searchInput).toMap
val searchInputIdsFromAnalytics = rulesUsageStatistics.map(_.inputId.id).toSet
val searchInputIdsNotFound = searchInputIdsFromAnalytics -- searchInputsById.keySet
val searchInputIdsNotUsed = searchInputsById.keySet -- searchInputIdsFromAnalytics
logger.info(s"Creating report from ${searchInputIdsFromAnalytics.size} used search inputs" +
s" and ${searchInputsById.size} search inputs currently configured" +
s" with ${searchInputIdsNotFound.size} search inputs not found" +
s" and ${searchInputIdsNotUsed.size} search inputs not used")

val reportEntriesUsedSearchInputs = rulesUsageStatistics.map { rulesUsage: RulesUsage =>
RulesUsageReportEntry(
rulesUsage.inputId.id,
searchInputsById.get(rulesUsage.inputId.id).map(_.term),
rulesUsage.keywords,
rulesUsage.frequency
)
}
val reportEntriesUnusedSearchInputs = searchInputIdsNotUsed.map { searchInputId =>
RulesUsageReportEntry(
searchInputId,
searchInputsById.get(searchInputId).map(_.term),
"",
0
)
}
RulesUsageReport(reportEntriesUsedSearchInputs ++ reportEntriesUnusedSearchInputs)
}

}
50 changes: 50 additions & 0 deletions app/services/ReaderProvider.scala
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
package services

import com.google.cloud.storage.{BlobId, StorageOptions}

import java.io.{InputStreamReader, Reader}
import java.net.URI
import java.nio.channels.Channels
import java.nio.charset.StandardCharsets
import java.nio.file.{Files, Paths}

trait ReaderProvider {
def openReader(url: String): Reader
}

class FileSystemReaderProvider extends ReaderProvider {
override def openReader(url: String): Reader = {
val uri = if (url.startsWith("file://")) url else "file://" + url
val path = Paths.get(new URI(uri))
Files.newBufferedReader(path)
}
}

class GcsReaderProvider extends ReaderProvider {

override def openReader(url: String): Reader = {
val storage = StorageOptions.getDefaultInstance.getService
val blob = storage.get(BlobId.fromGsUtilUri(url))
val readChannel = blob.reader()
new InputStreamReader(Channels.newInputStream(readChannel), StandardCharsets.UTF_8)
}
}

class ReaderProviderDispatcher extends ReaderProvider {

private val fileSystemReaderProvider = new FileSystemReaderProvider()
private lazy val gcsReaderProvider: GcsReaderProvider = {
new GcsReaderProvider()
}

override def openReader(url: String): Reader = {
val readerProvider = url.toLowerCase.trim match {
case url if url.startsWith("gs://") => gcsReaderProvider
case url if url.startsWith("file://") => fileSystemReaderProvider
case url if Files.exists(Paths.get(url)) => fileSystemReaderProvider
case _ => throw new IllegalArgumentException(s"Unsupported URL scheme or file not found: ${url}")
}
readerProvider.openReader(url)
}

}
49 changes: 49 additions & 0 deletions app/services/RulesUsageService.scala
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
package services

import models.input.SearchInputId
import org.apache.commons.csv.CSVFormat
import play.api.{Configuration, Logging}

import javax.inject.Inject
import scala.collection.JavaConverters.iterableAsScalaIterableConverter

case class RulesUsage(inputId: SearchInputId,
keywords: String,
frequency: Int)

class RulesUsageService @Inject()(configuration: Configuration,
readerProvider: ReaderProviderDispatcher) extends Logging {

private val CsvFormat = CSVFormat.DEFAULT.builder().setHeader().setSkipHeaderRecord(true).build()

def getRulesUsageStatistics: Option[Seq[RulesUsage]] = {
configuration.getOptional[String](RulesUsageService.ConfigKeyRuleUsageStatistics)
.map { location =>
logger.info(s"Loading rule usage statistics from ${location}")
try {
val reader = readerProvider.openReader(location)
try {
CsvFormat.parse(reader).asScala.map { record =>
RulesUsage(
SearchInputId(record.get("SMUI_GUID")),
record.get("USER_QUERY"),
record.get("FREQUENCY").toInt)
}.toSeq
} finally {
reader.close()
}
} catch {
case e: Exception =>
logger.error("Could not load rule usage statistics", e)
Seq.empty
}
}
}

}

object RulesUsageService {

val ConfigKeyRuleUsageStatistics = "smui.rule-usage-statistics.location"

}
4 changes: 3 additions & 1 deletion build.sbt
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import com.typesafe.sbt.GitBranchPrompt

name := "search-management-ui"
version := "4.0.11"
version := "4.1.0"
maintainer := "Contact productful.io <[email protected]>"

scalaVersion := "2.12.17"
Expand Down Expand Up @@ -67,10 +67,12 @@ libraryDependencies ++= {
"com.typesafe.play" %% "play-json" % "2.9.3",
"com.pauldijou" %% "jwt-play" % "4.1.0",
"com.fasterxml.jackson.module" %% "jackson-module-scala" % JacksonVersion,
"org.apache.commons" % "commons-csv" % "1.10.0",
"org.apache.shiro" % "shiro-core" % "1.12.0",
"org.pac4j" % "pac4j-http" % Pac4jVersion excludeAll (JacksonCoreExclusion, BcProv15Exclusion, SpringJclBridgeExclusion),
"org.pac4j" % "pac4j-saml" % Pac4jVersion excludeAll (JacksonCoreExclusion, BcProv15Exclusion, SpringJclBridgeExclusion),
"org.pac4j" %% "play-pac4j" % "11.1.0-PLAY2.8",
"com.google.cloud" % "google-cloud-storage" % "2.33.0",
"org.scalatestplus.play" %% "scalatestplus-play" % "3.1.0" % Test,
"org.mockito" % "mockito-all" % "1.10.19" % Test,
"com.pauldijou" %% "jwt-play" % "4.1.0",
Expand Down
4 changes: 4 additions & 0 deletions conf/application.conf
Original file line number Diff line number Diff line change
Expand Up @@ -168,6 +168,10 @@ database.dispatcher {
}
}

smui.rule-usage-statistics {
# Set a file:// or gs:// URL to a CSV file to load rule usage statistics for the rule usage report
location = ${?SMUI_RULE_USAGE_STATISTICS_LOCATION}
}

# Enable security module/filter
play.modules.enabled += "modules.SecurityModule"
Expand Down
1 change: 1 addition & 0 deletions conf/routes
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,7 @@ GET /api/v1/report/activity-report/:solrIndexId controllers.ApiC
GET /api/v1/version/latest-info controllers.ApiController.getLatestVersionInfo()
GET /api/v2/log/deployment-info controllers.ApiController.getLatestDeploymentResult(solrIndexId: String)
GET /api/v1/config/target-environment controllers.ApiController.getTargetEnvironment()
GET /api/v1/report/rules-usage-report/:solrIndexId controllers.ApiController.getRulesUsageReport(solrIndexId: String)

# Map static resources from the /public folder to the /assets URL path
GET /*file controllers.FrontendController.assetOrDefault(file)
10 changes: 7 additions & 3 deletions frontend/angular.json
Original file line number Diff line number Diff line change
Expand Up @@ -28,9 +28,13 @@
],
"styles": [
"node_modules/bootstrap/dist/css/bootstrap.min.css",
"node_modules/datatables.net-dt/css/dataTables.dataTables.css",
"src/styles.css"
],
"scripts": []
"scripts": [
"node_modules/jquery/dist/jquery.js",
"node_modules/datatables.net/js/dataTables.js"
]
},
"configurations": {
"production": {
Expand All @@ -50,8 +54,8 @@
"budgets": [
{
"type": "initial",
"maximumWarning": "500kb",
"maximumError": "1mb"
"maximumWarning": "1mb",
"maximumError": "2mb"
},
{
"type": "anyComponentStyle",
Expand Down
46 changes: 26 additions & 20 deletions frontend/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -13,35 +13,41 @@
},
"private": true,
"dependencies": {
"@angular/animations": "~11.0.1",
"@angular/common": "~11.0.1",
"@angular/compiler": "~11.0.1",
"@angular/core": "~11.0.1",
"@angular/forms": "~11.0.1",
"@angular/localize": "~11.0.1",
"@angular/platform-browser": "~11.0.1",
"@angular/platform-browser-dynamic": "~11.0.1",
"@angular/router": "~11.0.1",
"@angular/animations": "~13.4.0",
"@angular/common": "~13.4.0",
"@angular/compiler": "~13.4.0",
"@angular/core": "~13.4.0",
"@angular/forms": "~13.4.0",
"@angular/localize": "~13.4.0",
"@angular/platform-browser": "~13.4.0",
"@angular/platform-browser-dynamic": "~13.4.0",
"@angular/router": "~13.4.0",
"@fortawesome/fontawesome-free": "^5.15.1",
"@ng-bootstrap/ng-bootstrap": "^8.0.0",
"angular2-multiselect-dropdown": "^4.6.6",
"angular-datatables": "^13.1.0",
"angular2-multiselect-dropdown": "^5.0.4",
"angular2-toaster": "^11.0.1",
"bootstrap": "^4.5.0",
"datatables.net": "~2.0.3",
"datatables.net-dt": "~2.0.3",
"jquery": "~3.7.1",
"rxjs": "~6.6.0",
"tslib": "^2.0.0",
"tslib": "^2.6.2",
"zone.js": "~0.10.2"
},
"devDependencies": {
"@angular-devkit/build-angular": "~0.1100.2",
"@angular-eslint/builder": "1.1.0",
"@angular-eslint/eslint-plugin": "1.1.0",
"@angular-eslint/eslint-plugin-template": "1.1.0",
"@angular-eslint/schematics": "1.1.0",
"@angular-eslint/template-parser": "1.1.0",
"@angular/cli": "~11.0.2",
"@angular/compiler-cli": "~11.0.1",
"@angular-devkit/build-angular": "13.3.11",
"@angular-eslint/builder": "13.4.0",
"@angular-eslint/eslint-plugin": "13.4.0",
"@angular-eslint/eslint-plugin-template": "13.4.0",
"@angular-eslint/schematics": "13.4.0",
"@angular-eslint/template-parser": "13.4.0",
"@angular/cli": "13.3.11",
"@angular/compiler-cli": "13.3.11",
"@types/datatables.net": "1.10.21",
"@types/jasmine": "~3.6.0",
"@types/jasminewd2": "~2.0.8",
"@types/jquery": "~3.5.29",
"@types/node": "^12.11.1",
"@typescript-eslint/eslint-plugin": "4.3.0",
"@typescript-eslint/parser": "4.3.0",
Expand All @@ -59,6 +65,6 @@
"karma-jasmine": "~4.0.0",
"karma-jasmine-html-reporter": "^1.5.0",
"ts-node": "~8.3.0",
"typescript": "~4.0.2"
"typescript": "~4.6.4"
}
}
Loading

0 comments on commit 4827262

Please sign in to comment.