diff --git a/analytics/jvm/.gitignore b/analytics/jvm/.gitignore new file mode 100644 index 00000000..93bb3958 --- /dev/null +++ b/analytics/jvm/.gitignore @@ -0,0 +1,144 @@ +# Override for main repo's gitignore +!main* + +### Java template +# Compiled class file +*.class + +# Log file +*.log + +# BlueJ files +*.ctxt + +# Mobile Tools for Java (J2ME) +.mtj.tmp/ + +# Package Files # +*.jar +*.war +*.nar +*.ear +*.zip +*.tar.gz +*.rar + +# virtual machine crash logs, see http://www.java.com/en/download/help/error_hotspot.xml +hs_err_pid* +replay_pid* + +### JetBrains template +# This section has been modified specifically for this project. +.idea/ + +# Gradle and Maven with auto-import +# When using Gradle or Maven with auto-import, you should exclude module files, +# since they will be recreated, and may cause churn. Uncomment if using +# auto-import. +# .idea/artifacts +# .idea/compiler.xml +# .idea/jarRepositories.xml +# .idea/modules.xml +# .idea/*.iml +# .idea/modules +# *.iml +# *.ipr + +# CMake +cmake-build-*/ + +# Mongo Explorer plugin +.idea/**/mongoSettings.xml + +# File-based project format +*.iws + +# IntelliJ +out/ + +# mpeltonen/sbt-idea plugin +.idea_modules/ + +# JIRA plugin +atlassian-ide-plugin.xml + +# Cursive Clojure plugin +.idea/replstate.xml + +# SonarLint plugin +.idea/sonarlint/ + +# Crashlytics plugin (for Android Studio and IntelliJ) +com_crashlytics_export_strings.xml +crashlytics.properties +crashlytics-build.properties +fabric.properties + +# Editor-based Rest Client +.idea/httpRequests + +# Android studio 3.1+ serialized cache file +.idea/caches/build_file_checksums.ser + +### Gradle template +.gradle +**/build/ +!src/**/build/ + +# Ignore Gradle GUI config +gradle-app.setting + +# Avoid ignoring Gradle wrapper jar file (.jar files are usually ignored) +!gradle-wrapper.jar + +# Avoid ignore Gradle wrappper properties +!gradle-wrapper.properties + +# Cache of project +.gradletasknamecache + +# Eclipse Gradle plugin generated files +# Eclipse Core +.project +# JDT-specific (Eclipse Java Development Tools) +.classpath + +### Windows template +# Windows thumbnail cache files +Thumbs.db +Thumbs.db:encryptable +ehthumbs.db +ehthumbs_vista.db + +# Dump file +*.stackdump + +# Folder config file +[Dd]esktop.ini + +# Recycle Bin used on file shares +$RECYCLE.BIN/ + +# Windows Installer files +*.cab +*.msi +*.msix +*.msm +*.msp + +# Windows shortcuts +*.lnk + +### Kotlin template +# Compiled class file + +# Log file + +# BlueJ files + +# Mobile Tools for Java (J2ME) + +# Package Files # + +# virtual machine crash logs, see http://www.java.com/en/download/help/error_hotspot.xml + diff --git a/analytics/jvm/README.md b/analytics/jvm/README.md new file mode 100644 index 00000000..cdc27172 --- /dev/null +++ b/analytics/jvm/README.md @@ -0,0 +1,120 @@ +# API Analytics + +A free and lightweight API analytics solution, complete with a dashboard. + +## Getting Started + +### 1. Generate an API key + +Head to [apianalytics.dev/generate](https://apianalytics.dev/generate) to generate your unique API key with a single click. This key is used to monitor your specific API and should be stored privately. It's also required in order to access your API analytics dashboard and data. + +### 2. Add middleware to your API + +Add our lightweight middleware to your API. Almost all processing is handled by our servers so there is minimal impact on the performance of your API. + +#### Ktor + +First, import it using Gradle; + +```kotlin +repositories { + mavenCentral() // This package and all it's dependencies are in Maven Central +} + +dependencies { + implementation("dev.tomdraper.apianalytics:ktor:1.0.0") +} +``` + +Lastly, install it. The middleware usually creates it's own `HttpClient`, but overwriting it is highly recommended in case you have specific settings you use that the default doesnt have.\ +Though it should be noted that the example doesnt do anything, as the default one has the exact same settings. + +```kotlin +// Imports simplified due to space constraints +import io.ktor.client.* +import io.ktor.server.* +import io.ktor.http.* +import io.ktor.client.plugins.contentnegotiation.ContentNegotiation as ClientContentNegotiation +import io.ktor.server.plugins.contentnegotiation.ContentNegotiation as ServerContentNegotiation + +private val myHttpClient: HttpClient = HttpClient(engineFactory = OkHttp) { + install(ClientContentNegotiation) { jackson { } } +} + +embeddedServer(factory = Netty) { + install(ServerContentNegotiation) { jackson { } } + install(AnalyticsPlugin) { + apiKey = "[YOUR API KEY HERE]" + httpClient = myHttpClient + } +} +``` + +#### Spring + +First is import it; + +From Maven: + +```xml + + dev.tomdraper.apianalytics + spring + 1.0.0 + +``` + +Or from Gradle: + +```kotlin +// While this is the kotlin specific format, it will still work (at least in modern Gradle) +implementation("dev.tomdraper.apianalytics:spring:1.0.0") +``` + +Next configure the plugin, either in; + +`application.properties`: +```properties +apianalytics.apiKey = [YOUR API KEY HERE] +# Spring-only configs: +apianalytics.sendUserId = false # If a numerical user-id is found in the header name below, it is sent as well +apianalytics.userHeader = "" # The request header checked for numerical IDs +``` + +or `application.yml`: +```yml +apianalytics: + apiKey = "[YOUR API KEY HERE]" + sendUserId = false + userHeader = "" +``` + +Then you're done, as the `@Component` annotation on the middleware will register it automatically. + +#### PlayFramework + +First import it in SBT: + +```sbt +libraryDependencies += "dev.tomdraper.apianalytics" % "play" % "1.0.0" +``` + +Next, set up the `application.conf` file: + +```conf +apianalytics { + apiKey = [YOUR API KEY HERE] +} +``` + +Then add it to your project: + +```scala +import dev.tomdraper.apianalytics.play.PlayAnalyticsFilter +import play.api.http.HttpFilters +import javax.inject.Inject + +class MyFilters @Inject()(playAnalyticsFilter: PlayAnalyticsFilter) extends HttpFilters { + def filters = Seq(playAnalyticsFilter) +} +``` diff --git a/analytics/jvm/build.gradle.kts b/analytics/jvm/build.gradle.kts new file mode 100644 index 00000000..186d1652 --- /dev/null +++ b/analytics/jvm/build.gradle.kts @@ -0,0 +1,7 @@ +plugins { + alias(libs.plugins.kotlin) apply false + alias(libs.plugins.ktor) apply false + alias(libs.plugins.spring.kotlin) apply false + alias(libs.plugins.spring.boot) apply false + alias(libs.plugins.play) apply false +} diff --git a/analytics/jvm/common/build.gradle.kts b/analytics/jvm/common/build.gradle.kts new file mode 100644 index 00000000..2c3a3fa7 --- /dev/null +++ b/analytics/jvm/common/build.gradle.kts @@ -0,0 +1,15 @@ +plugins { + alias(libs.plugins.kotlin) +} + +group = "org.example" +version = "unspecified" + +repositories { + mavenCentral() +} + +dependencies { + implementation(libs.jackson.databind) + implementation(libs.jackson.kotlin) +} diff --git a/analytics/jvm/common/src/main/java/dev/tomdraper/apianalytics/AbstractAnalyticsHandler.kt b/analytics/jvm/common/src/main/java/dev/tomdraper/apianalytics/AbstractAnalyticsHandler.kt new file mode 100644 index 00000000..be5bf9d2 --- /dev/null +++ b/analytics/jvm/common/src/main/java/dev/tomdraper/apianalytics/AbstractAnalyticsHandler.kt @@ -0,0 +1,53 @@ +package dev.tomdraper.apianalytics + +import com.fasterxml.jackson.databind.ObjectMapper + +abstract class AbstractAnalyticsHandler( + open val apiKey: String?, + open val client: C, + open val loggingTimeout: Long, + open val serverUrl: String, + private val frameworkName: String, + open val privacyLevel: Int, +) { + // + private val requests: MutableList = emptyList().toMutableList() + // + private var lastPosted: Long = System.currentTimeMillis() + /** Universal [ObjectMapper] for implementations to use instead of creating their own. */ + protected val objectMapper = ObjectMapper() + + // + abstract fun send(payload: PayloadHandler.AnalyticsPayload, endpoint: String): R + + // + fun logRequest(requestData: PayloadHandler.RequestData): R? { + this.requests += requestData + + return if ((System.currentTimeMillis() - this.lastPosted) > this.loggingTimeout) forceSend() + else null + } + + /** Force-sends a request to the API, ignoring any usage limits. + * Usually only called during shutdown or panic, but also used in [logRequest]. + * This function should be used *very* carefully. */ + fun forceSend(): R? { + if (this.apiKey == null) return null + + val endpoint = getServerEndpoint() + val payload = PayloadHandler.AnalyticsPayload( + apiKey!!, + requests, + frameworkName, + privacyLevel + ) + println(objectMapper.writeValueAsString(payload)) + + return send(payload, endpoint) + } + + // + private fun getServerEndpoint(): String = + if (serverUrl.endsWith("/")) "${serverUrl}api/log-request" + else "$serverUrl/api/log-request" +} diff --git a/analytics/jvm/common/src/main/java/dev/tomdraper/apianalytics/PayloadHandler.kt b/analytics/jvm/common/src/main/java/dev/tomdraper/apianalytics/PayloadHandler.kt new file mode 100644 index 00000000..8b289edd --- /dev/null +++ b/analytics/jvm/common/src/main/java/dev/tomdraper/apianalytics/PayloadHandler.kt @@ -0,0 +1,56 @@ +package dev.tomdraper.apianalytics + +import com.fasterxml.jackson.annotation.JsonProperty +import java.text.SimpleDateFormat +import java.util.* + +/** Universal payload handling, containing classes to be serialized and sent to the API. */ +object PayloadHandler { + + /** Default server for the api endpoint(s). */ + const val DEFAULT_ENDPOINT = "https://www.apianalytics-server.com/" + + /** Universal helper function for calculating the right "created at" time. + * + * Getting [java.time.LocalDate] to parse as an ISO date was unnecessarily difficult, + * so I wrote new logic that's platform agnostic and works with the API correctly. */ + @JvmStatic + fun createdAt(): String { + val tz = TimeZone.getTimeZone("UTC") + val df = SimpleDateFormat("yyyy-MM-dd'T'HH:mm'Z'") + df.timeZone = tz + return df.format(Date()) + } + + data class RequestData( + // + val hostname: String, + // + @get:JsonProperty("ip_address") val ipAddress: String, + // + @get:JsonProperty("user_agent") val userAgent: String?, + // + val path: String, + // + @get:JsonProperty("status") val statusCode: Int?, + // + val method: String, + // + @get:JsonProperty("response_time") val responseTime: Long, + // + @get:JsonProperty("user_id") val userId: Int?, + // + @get:JsonProperty("created_at") val createdAt: String? + ) + + data class AnalyticsPayload( + // + @get:JsonProperty("api_key") val apiKey: String, + // + val requests: List, + // + val framework: String, + // + @get:JsonProperty("privacy_level") val privacyLevel: Int, + ) +} \ No newline at end of file diff --git a/analytics/jvm/common/src/main/java/dev/tomdraper/apianalytics/UniversalAnalyticsConfig.java b/analytics/jvm/common/src/main/java/dev/tomdraper/apianalytics/UniversalAnalyticsConfig.java new file mode 100644 index 00000000..45611e4e --- /dev/null +++ b/analytics/jvm/common/src/main/java/dev/tomdraper/apianalytics/UniversalAnalyticsConfig.java @@ -0,0 +1,26 @@ +package dev.tomdraper.apianalytics; + +import org.jetbrains.annotations.NotNull; +import org.jetbrains.annotations.Nullable; + +import static dev.tomdraper.apianalytics.PayloadHandler.DEFAULT_ENDPOINT; + +/** A universal config class that all JVM implementations of ApiAnalytics rely on.
+ *
+ * Note that the PlayFramework and Spring subprojects do not rely on this as a superclass, + * and instead uses a config file or their own config class respectively. Changes must be propagated manually. + * @see #apiKey + * @see #timeout + * @see #privacyLevel + * @see #serverUrl + * */ +public class UniversalAnalyticsConfig { + /** The API key for */ + public @Nullable String apiKey; + /** Changes the time between requests to the service in milliseconds. Changing this value is not recommended. */ + public @NotNull Long timeout = 60000L; + // + public @NotNull Integer privacyLevel = 0; + /** Changes where the plugin sends requests to. Don't change this unless you don't use the main server (i.e. self-hosting or secondary server) */ + public @NotNull String serverUrl = DEFAULT_ENDPOINT; +} diff --git a/analytics/jvm/gradle.properties b/analytics/jvm/gradle.properties new file mode 100644 index 00000000..7fc6f1ff --- /dev/null +++ b/analytics/jvm/gradle.properties @@ -0,0 +1 @@ +kotlin.code.style=official diff --git a/analytics/jvm/gradle/libs.versions.toml b/analytics/jvm/gradle/libs.versions.toml new file mode 100644 index 00000000..d344ff4f --- /dev/null +++ b/analytics/jvm/gradle/libs.versions.toml @@ -0,0 +1,55 @@ +[versions] +# Languages +kotlin-version = "1.9.25" +scala-version = "2.13.12" + +# Common Impl +jackson-version = "2.18.2" +slf4j-version = "2.0.16" +lombok-version = "1.18.36" +# Common Testing +logback-version = "1.3.0" +junit-version = "5.10.0" + +# Frameworks +ktor-version = "3.0.0" +spring-version = "3.2.6" +play-version = "2.9.0" + +[libraries] +# Common Impl +jackson-databind = { module = "com.fasterxml.jackson.core:jackson-databind", version.ref = "jackson-version" } +jackson-core = { module = "com.fasterxml.jackson.core:jackson-core", version.ref = "jackson-version" } +jackson-kotlin = { module = "com.fasterxml.jackson.module:jackson-module-kotlin", version.ref = "jackson-version" } +jackson-scala = { module = "com.fasterxml.jackson.module:jackson-module-scala_2.13", version.ref = "jackson-version" } +logging-slf4j = { module = "org.slf4j:slf4j-api", version.ref = "slf4j-version" } +lombok = { module = "org.projectlombok:lombok", version.ref = "lombok-version" } +# Common Testing (testing deps from common are not transitive! copy-paste the lines to the right into new subprojects) +logging-logback = { module = "ch.qos.logback:logback-classic", version.ref = "logback-version" } # testImplementation(libs.logging.logback) +kotlin-test = { module = "org.jetbrains.kotlin:kotlin-test", version.ref = "kotlin-version" } +junit-kotlin = { module = "org.jetbrains.kotlin:kotlin-test-junit", version.ref = "kotlin-version" } # testImplementation(libs.junit.kotlin) +junit-api = { module = "org.junit.jupiter:junit-jupiter-api", version.ref = "junit-version" } # testImplementation(libs.junit.api) +junit-engine = { module = "org.junit.jupiter:junit-jupiter-engine", version.ref = "junit-version" } # testRuntimeOnly(libs.junit.engine) + +# Ktor Impl +ktor-client-core = { module = "io.ktor:ktor-client-core", version.ref = "ktor-version" } +ktor-client-content = { module = "io.ktor:ktor-client-content-negotiation", version.ref = "ktor-version" } +ktor-serialization-jackson = { module = "io.ktor:ktor-serialization-jackson", version.ref = "ktor-version" } +ktor-server-core = { module = "io.ktor:ktor-server-core", version.ref = "ktor-version" } +# Ktor Testing (use JVM specific impls) +ktor-client-okhttp = { module = "io.ktor:ktor-client-okhttp-jvm", version.ref = "ktor-version" } +ktor-server-test = { module = "io.ktor:ktor-server-test-host", version.ref = "ktor-version" } +ktor-server-netty = { module = "io.ktor:ktor-server-netty-jvm", version.ref = "ktor-version" } +ktor-server-content = { module = "io.ktor:ktor-server-content-negotiation-jvm", version.ref = "ktor-version" } + +# Play Impl +scala-stdlib = { module = "org.scala-lang:scala-library", version.ref = "scala-version" } +play-core = { module = "com.typesafe.play:play_2.13", version.ref = "play-version" } +play-webservice = { module = "com.typesafe.play:play-ws_2.13", version.ref = "play-version" } + +[plugins] +kotlin = { id = "org.jetbrains.kotlin.jvm", version.ref = "kotlin-version" } +ktor = { id = "io.ktor.plugin", version.ref = "ktor-version" } +spring-kotlin = { id = "org.jetbrains.kotlin.plugin.spring", version.ref = "kotlin-version" } +spring-boot = { id = "org.springframework.boot", version.ref = "spring-version" } +play = { id = "org.gradle.playframework", version = "0.14" } diff --git a/analytics/jvm/gradle/wrapper/gradle-wrapper.jar b/analytics/jvm/gradle/wrapper/gradle-wrapper.jar new file mode 100644 index 00000000..249e5832 Binary files /dev/null and b/analytics/jvm/gradle/wrapper/gradle-wrapper.jar differ diff --git a/analytics/jvm/gradle/wrapper/gradle-wrapper.properties b/analytics/jvm/gradle/wrapper/gradle-wrapper.properties new file mode 100644 index 00000000..17655d0e --- /dev/null +++ b/analytics/jvm/gradle/wrapper/gradle-wrapper.properties @@ -0,0 +1,5 @@ +distributionBase=GRADLE_USER_HOME +distributionPath=wrapper/dists +distributionUrl=https\://services.gradle.org/distributions/gradle-8.6-bin.zip +zipStoreBase=GRADLE_USER_HOME +zipStorePath=wrapper/dists diff --git a/analytics/jvm/gradlew b/analytics/jvm/gradlew new file mode 100644 index 00000000..a69d9cb6 --- /dev/null +++ b/analytics/jvm/gradlew @@ -0,0 +1,240 @@ +#!/bin/sh + +# +# Copyright © 2015-2021 the original authors. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# https://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# + +############################################################################## +# +# Gradle start up script for POSIX generated by Gradle. +# +# Important for running: +# +# (1) You need a POSIX-compliant shell to run this script. If your /bin/sh is +# noncompliant, but you have some other compliant shell such as ksh or +# bash, then to run this script, type that shell name before the whole +# command line, like: +# +# ksh Gradle +# +# Busybox and similar reduced shells will NOT work, because this script +# requires all of these POSIX shell features: +# * functions; +# * expansions «$var», «${var}», «${var:-default}», «${var+SET}», +# «${var#prefix}», «${var%suffix}», and «$( cmd )»; +# * compound commands having a testable exit status, especially «case»; +# * various built-in commands including «command», «set», and «ulimit». +# +# Important for patching: +# +# (2) This script targets any POSIX shell, so it avoids extensions provided +# by Bash, Ksh, etc; in particular arrays are avoided. +# +# The "traditional" practice of packing multiple parameters into a +# space-separated string is a well documented source of bugs and security +# problems, so this is (mostly) avoided, by progressively accumulating +# options in "$@", and eventually passing that to Java. +# +# Where the inherited environment variables (DEFAULT_JVM_OPTS, JAVA_OPTS, +# and GRADLE_OPTS) rely on word-splitting, this is performed explicitly; +# see the in-line comments for details. +# +# There are tweaks for specific operating systems such as AIX, CygWin, +# Darwin, MinGW, and NonStop. +# +# (3) This script is generated from the Groovy template +# https://github.com/gradle/gradle/blob/master/subprojects/plugins/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt +# within the Gradle project. +# +# You can find Gradle at https://github.com/gradle/gradle/. +# +############################################################################## + +# Attempt to set APP_HOME + +# Resolve links: $0 may be a link +app_path=$0 + +# Need this for daisy-chained symlinks. +while + APP_HOME=${app_path%"${app_path##*/}"} # leaves a trailing /; empty if no leading path + [ -h "$app_path" ] +do + ls=$( ls -ld "$app_path" ) + link=${ls#*' -> '} + case $link in #( + /*) app_path=$link ;; #( + *) app_path=$APP_HOME$link ;; + esac +done + +APP_HOME=$( cd "${APP_HOME:-./}" && pwd -P ) || exit + +APP_NAME="Gradle" +APP_BASE_NAME=${0##*/} + +# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"' + +# Use the maximum available, or set MAX_FD != -1 to use that value. +MAX_FD=maximum + +warn () { + echo "$*" +} >&2 + +die () { + echo + echo "$*" + echo + exit 1 +} >&2 + +# OS specific support (must be 'true' or 'false'). +cygwin=false +msys=false +darwin=false +nonstop=false +case "$( uname )" in #( + CYGWIN* ) cygwin=true ;; #( + Darwin* ) darwin=true ;; #( + MSYS* | MINGW* ) msys=true ;; #( + NONSTOP* ) nonstop=true ;; +esac + +CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar + + +# Determine the Java command to use to start the JVM. +if [ -n "$JAVA_HOME" ] ; then + if [ -x "$JAVA_HOME/jre/sh/java" ] ; then + # IBM's JDK on AIX uses strange locations for the executables + JAVACMD=$JAVA_HOME/jre/sh/java + else + JAVACMD=$JAVA_HOME/bin/java + fi + if [ ! -x "$JAVACMD" ] ; then + die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME + +Please set the JAVA_HOME variable in your environment to match the +location of your Java installation." + fi +else + JAVACMD=java + which java >/dev/null 2>&1 || die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. + +Please set the JAVA_HOME variable in your environment to match the +location of your Java installation." +fi + +# Increase the maximum file descriptors if we can. +if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then + case $MAX_FD in #( + max*) + MAX_FD=$( ulimit -H -n ) || + warn "Could not query maximum file descriptor limit" + esac + case $MAX_FD in #( + '' | soft) :;; #( + *) + ulimit -n "$MAX_FD" || + warn "Could not set maximum file descriptor limit to $MAX_FD" + esac +fi + +# Collect all arguments for the java command, stacking in reverse order: +# * args from the command line +# * the main class name +# * -classpath +# * -D...appname settings +# * --module-path (only if needed) +# * DEFAULT_JVM_OPTS, JAVA_OPTS, and GRADLE_OPTS environment variables. + +# For Cygwin or MSYS, switch paths to Windows format before running java +if "$cygwin" || "$msys" ; then + APP_HOME=$( cygpath --path --mixed "$APP_HOME" ) + CLASSPATH=$( cygpath --path --mixed "$CLASSPATH" ) + + JAVACMD=$( cygpath --unix "$JAVACMD" ) + + # Now convert the arguments - kludge to limit ourselves to /bin/sh + for arg do + if + case $arg in #( + -*) false ;; # don't mess with options #( + /?*) t=${arg#/} t=/${t%%/*} # looks like a POSIX filepath + [ -e "$t" ] ;; #( + *) false ;; + esac + then + arg=$( cygpath --path --ignore --mixed "$arg" ) + fi + # Roll the args list around exactly as many times as the number of + # args, so each arg winds up back in the position where it started, but + # possibly modified. + # + # NB: a `for` loop captures its iteration list before it begins, so + # changing the positional parameters here affects neither the number of + # iterations, nor the values presented in `arg`. + shift # remove old arg + set -- "$@" "$arg" # push replacement arg + done +fi + +# Collect all arguments for the java command; +# * $DEFAULT_JVM_OPTS, $JAVA_OPTS, and $GRADLE_OPTS can contain fragments of +# shell script including quotes and variable substitutions, so put them in +# double quotes to make sure that they get re-expanded; and +# * put everything else in single quotes, so that it's not re-expanded. + +set -- \ + "-Dorg.gradle.appname=$APP_BASE_NAME" \ + -classpath "$CLASSPATH" \ + org.gradle.wrapper.GradleWrapperMain \ + "$@" + +# Stop when "xargs" is not available. +if ! command -v xargs >/dev/null 2>&1 +then + die "xargs is not available" +fi + +# Use "xargs" to parse quoted args. +# +# With -n1 it outputs one arg per line, with the quotes and backslashes removed. +# +# In Bash we could simply go: +# +# readarray ARGS < <( xargs -n1 <<<"$var" ) && +# set -- "${ARGS[@]}" "$@" +# +# but POSIX shell has neither arrays nor command substitution, so instead we +# post-process each arg (as a line of input to sed) to backslash-escape any +# character that might be a shell metacharacter, then use eval to reverse +# that process (while maintaining the separation between arguments), and wrap +# the whole thing up as a single "set" statement. +# +# This will of course break if any of these variables contains a newline or +# an unmatched quote. +# + +eval "set -- $( + printf '%s\n' "$DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS" | + xargs -n1 | + sed ' s~[^-[:alnum:]+,./:=@_]~\\&~g; ' | + tr '\n' ' ' + )" '"$@"' + +exec "$JAVACMD" "$@" diff --git a/analytics/jvm/gradlew.bat b/analytics/jvm/gradlew.bat new file mode 100644 index 00000000..f127cfd4 --- /dev/null +++ b/analytics/jvm/gradlew.bat @@ -0,0 +1,91 @@ +@rem +@rem Copyright 2015 the original author or authors. +@rem +@rem Licensed under the Apache License, Version 2.0 (the "License"); +@rem you may not use this file except in compliance with the License. +@rem You may obtain a copy of the License at +@rem +@rem https://www.apache.org/licenses/LICENSE-2.0 +@rem +@rem Unless required by applicable law or agreed to in writing, software +@rem distributed under the License is distributed on an "AS IS" BASIS, +@rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +@rem See the License for the specific language governing permissions and +@rem limitations under the License. +@rem + +@if "%DEBUG%"=="" @echo off +@rem ########################################################################## +@rem +@rem Gradle startup script for Windows +@rem +@rem ########################################################################## + +@rem Set local scope for the variables with windows NT shell +if "%OS%"=="Windows_NT" setlocal + +set DIRNAME=%~dp0 +if "%DIRNAME%"=="" set DIRNAME=. +set APP_BASE_NAME=%~n0 +set APP_HOME=%DIRNAME% + +@rem Resolve any "." and ".." in APP_HOME to make it shorter. +for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi + +@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m" + +@rem Find java.exe +if defined JAVA_HOME goto findJavaFromJavaHome + +set JAVA_EXE=java.exe +%JAVA_EXE% -version >NUL 2>&1 +if %ERRORLEVEL% equ 0 goto execute + +echo. +echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. +echo. +echo Please set the JAVA_HOME variable in your environment to match the +echo location of your Java installation. + +goto fail + +:findJavaFromJavaHome +set JAVA_HOME=%JAVA_HOME:"=% +set JAVA_EXE=%JAVA_HOME%/bin/java.exe + +if exist "%JAVA_EXE%" goto execute + +echo. +echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% +echo. +echo Please set the JAVA_HOME variable in your environment to match the +echo location of your Java installation. + +goto fail + +:execute +@rem Setup the command line + +set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar + + +@rem Execute Gradle +"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %* + +:end +@rem End local scope for the variables with windows NT shell +if %ERRORLEVEL% equ 0 goto mainEnd + +:fail +rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of +rem the _cmd.exe /c_ return code! +set EXIT_CODE=%ERRORLEVEL% +if %EXIT_CODE% equ 0 set EXIT_CODE=1 +if not ""=="%GRADLE_EXIT_CONSOLE%" exit %EXIT_CODE% +exit /b %EXIT_CODE% + +:mainEnd +if "%OS%"=="Windows_NT" endlocal + +:omega diff --git a/analytics/jvm/ktor/build.gradle.kts b/analytics/jvm/ktor/build.gradle.kts new file mode 100644 index 00000000..f3beb970 --- /dev/null +++ b/analytics/jvm/ktor/build.gradle.kts @@ -0,0 +1,36 @@ +plugins { + alias(libs.plugins.kotlin) + alias(libs.plugins.ktor) +} + +group = "dev.tomdraper.apianalytics" +version = "1.0.0" + +repositories { + mavenCentral() +} + +dependencies { + implementation(project(":common")) + implementation(libs.ktor.server.core) + implementation(libs.ktor.client.core) + implementation(libs.ktor.client.content) + implementation(libs.ktor.serialization.jackson) + + testImplementation(libs.ktor.server.test) + testImplementation(libs.ktor.server.content) + testImplementation(libs.ktor.server.netty) + testImplementation(libs.ktor.client.okhttp) + testImplementation(libs.logging.logback) + testImplementation(libs.junit.kotlin) + testImplementation(libs.junit.api) + testRuntimeOnly(libs.junit.engine) +} + +application { + mainClass.set("dev.tomdraper.apianalytics.ktor.DummyKt") +} + +tasks.named("test") { + useJUnitPlatform() +} diff --git a/analytics/jvm/ktor/src/main/java/dev/tomdraper/apianalytics/ktor/Dummy.kt b/analytics/jvm/ktor/src/main/java/dev/tomdraper/apianalytics/ktor/Dummy.kt new file mode 100644 index 00000000..e5fbed35 --- /dev/null +++ b/analytics/jvm/ktor/src/main/java/dev/tomdraper/apianalytics/ktor/Dummy.kt @@ -0,0 +1,7 @@ +package dev.tomdraper.apianalytics.ktor + +class Dummy { + fun main(args: Array) { + /* no-op, gradle dislikes having no entrypoint. */ + } +} \ No newline at end of file diff --git a/analytics/jvm/ktor/src/main/java/dev/tomdraper/apianalytics/ktor/KtorAnalyticsHandler.kt b/analytics/jvm/ktor/src/main/java/dev/tomdraper/apianalytics/ktor/KtorAnalyticsHandler.kt new file mode 100644 index 00000000..73568001 --- /dev/null +++ b/analytics/jvm/ktor/src/main/java/dev/tomdraper/apianalytics/ktor/KtorAnalyticsHandler.kt @@ -0,0 +1,31 @@ +package dev.tomdraper.apianalytics.ktor + +import dev.tomdraper.apianalytics.AbstractAnalyticsHandler +import dev.tomdraper.apianalytics.PayloadHandler +import io.ktor.client.* +import io.ktor.client.request.* +import io.ktor.client.statement.* +import io.ktor.http.* +import kotlinx.coroutines.runBlocking + +class KtorAnalyticsHandler( + override val apiKey: String?, + override val client: HttpClient, + override val loggingTimeout: Long, + override val serverUrl: String, + override val privacyLevel: Int +) : AbstractAnalyticsHandler( + apiKey, + client, + loggingTimeout, + serverUrl, + "Express",//"Ktor", // Spoofing Express temporarily until the backend can catch up + privacyLevel +) { + override fun send(payload: PayloadHandler.AnalyticsPayload, endpoint: String): HttpResponse = runBlocking { + return@runBlocking client.post(endpoint) { + contentType(ContentType.Application.Json) + setBody(payload) + } + } +} \ No newline at end of file diff --git a/analytics/jvm/ktor/src/main/java/dev/tomdraper/apianalytics/ktor/KtorAnalyticsPlugin.kt b/analytics/jvm/ktor/src/main/java/dev/tomdraper/apianalytics/ktor/KtorAnalyticsPlugin.kt new file mode 100644 index 00000000..ed138bbc --- /dev/null +++ b/analytics/jvm/ktor/src/main/java/dev/tomdraper/apianalytics/ktor/KtorAnalyticsPlugin.kt @@ -0,0 +1,74 @@ +package dev.tomdraper.apianalytics.ktor + +import dev.tomdraper.apianalytics.PayloadHandler +import dev.tomdraper.apianalytics.UniversalAnalyticsConfig +import io.ktor.client.* +import io.ktor.client.plugins.contentnegotiation.* +import io.ktor.client.statement.* +import io.ktor.serialization.jackson.* +import io.ktor.server.application.* +import io.ktor.server.application.hooks.* +import io.ktor.server.plugins.* +import io.ktor.server.request.* +import io.ktor.util.* +import io.ktor.util.logging.* + +@Suppress("unused") +object KtorAnalyticsPlugin { + private val LOG: Logger = KtorSimpleLogger("AnalyticsPlugin") + + val AnalyticsPlugin = createApplicationPlugin( + name = "AnalyticsPlugin", + createConfiguration = ::KtorAnalyticsConfig + ) { + val handler = KtorAnalyticsHandler( + this.pluginConfig.apiKey, + this.pluginConfig.httpClient, + this.pluginConfig.timeout, + this.pluginConfig.serverUrl, + this.pluginConfig.privacyLevel, + ) + val start = AttributeKey("AnalyticsPluginStartTime") + + onCallReceive { call, _ -> + call.attributes.put(start, System.currentTimeMillis()) + } + onCallRespond { call, _ -> + var responseTime: Long = -1 + try { + responseTime = System.currentTimeMillis() - call.attributes[start] + } catch (resTimeErr: IllegalStateException) { + LOG.error("Issue grabbing response time!") + } + + // Short name so string templating in the debug log isn't a complete mess. + val hr = handler.logRequest( + PayloadHandler.RequestData( + call.request.host(), + call.request.origin.remoteAddress, + call.request.userAgent(), + call.request.path(), + call.response.status()?.value, + call.request.httpMethod.value, + responseTime, + null, + PayloadHandler.createdAt() + ) + ) + + if (hr != null) + LOG.debug("""Request to ApiAnalytics (specifically ${hr.request.url.host}) + |ran with status ${hr.status.value} / ${hr.status.description}""".trimMargin()) + } + on(MonitoringEvent(ApplicationStopping)) {app -> + println(handler.forceSend()) + app.monitor.unsubscribe(ApplicationStopping) { /* no-op, release resources */ } + } + } + + class KtorAnalyticsConfig : UniversalAnalyticsConfig() { + /** ApiAnalytics by default uses its own [HttpClient] and Jackson for serde. + * Overriding this with your preferred Ktor HttpClient is recommended, but completely optional. */ + var httpClient: HttpClient = HttpClient { install(ContentNegotiation) { jackson { } } } + } +} \ No newline at end of file diff --git a/analytics/jvm/ktor/src/test/java/dev/tomdraper/apianalytics/ktor/KtorTest.kt b/analytics/jvm/ktor/src/test/java/dev/tomdraper/apianalytics/ktor/KtorTest.kt new file mode 100644 index 00000000..18008a1f --- /dev/null +++ b/analytics/jvm/ktor/src/test/java/dev/tomdraper/apianalytics/ktor/KtorTest.kt @@ -0,0 +1,49 @@ +package dev.tomdraper.apianalytics.ktor + +import dev.tomdraper.apianalytics.ktor.KtorAnalyticsPlugin.AnalyticsPlugin +import io.ktor.client.call.* +import io.ktor.client.request.* +import io.ktor.http.* +import io.ktor.serialization.jackson.* +import io.ktor.server.application.* +import io.ktor.server.response.* +import io.ktor.server.routing.* +import io.ktor.server.testing.* +import org.junit.jupiter.api.Test +import org.slf4j.Logger +import org.slf4j.LoggerFactory +import io.ktor.client.plugins.contentnegotiation.ContentNegotiation as ClientContentNegotiation +import io.ktor.server.plugins.contentnegotiation.ContentNegotiation as ServerContentNegotiation + +object KtorTest { + + private val logger: Logger = LoggerFactory.getLogger(KtorTest::class.java) + + @Test + fun spamEndpoint() = testApplication { + + application { + install(ServerContentNegotiation) { jackson { } } + install(AnalyticsPlugin) { + apiKey = "f9b678de-ddc7-48eb-91fc-d97e8e52c0a1" + timeout = 10000 + } + + routing { + get("/") { + call.respond(HttpStatusCode.OK, mapOf("hello" to "world")) + } + } + } + + val client = createClient { + install(ClientContentNegotiation) { jackson { } } + } + + repeat(50) {iteration -> + logger.info("$iteration") + val res = client.get("/") + logger.info("${res.status.value} ${res.status.description} ${res.body()}") + } + } +} \ No newline at end of file diff --git a/analytics/jvm/play/build.gradle.kts b/analytics/jvm/play/build.gradle.kts new file mode 100644 index 00000000..5d5968d5 --- /dev/null +++ b/analytics/jvm/play/build.gradle.kts @@ -0,0 +1,33 @@ +plugins { + alias(libs.plugins.kotlin) + alias(libs.plugins.play) + scala +} + +group = "dev.tomdraper.apianalytics" +version = "1.0.0" + +repositories { + mavenCentral() +} + +dependencies { + implementation(project(":common")) + implementation(libs.jackson.scala) + implementation(libs.scala.stdlib) + implementation(libs.play.core) + implementation(libs.play.webservice) + + testImplementation("org.scalactic:scalactic_3:3.2.19") + testImplementation("org.scalatest:scalatest_3:3.2.19") + testImplementation("org.scalatestplus.play:scalatestplus-play_2.13:5.1.0") +} +/* +play { + platform { + playVersion.set("2.8.20") + scalaVersion.set("2.13") + javaVersion.set(JavaVersion.VERSION_17) + } +} +*/ diff --git a/analytics/jvm/play/src/main/java/dev/tomdraper/apianalytics/play/PlayAnalyticsFilter.scala b/analytics/jvm/play/src/main/java/dev/tomdraper/apianalytics/play/PlayAnalyticsFilter.scala new file mode 100644 index 00000000..a7e6ee01 --- /dev/null +++ b/analytics/jvm/play/src/main/java/dev/tomdraper/apianalytics/play/PlayAnalyticsFilter.scala @@ -0,0 +1,54 @@ +package dev.tomdraper.apianalytics.play + +import dev.tomdraper.apianalytics.PayloadHandler.{DEFAULT_ENDPOINT, RequestData, createdAt} +import play.api.ConfigLoader.stringLoader +import play.api.Configuration +import play.api.libs.ws.WSClient +import play.api.mvc._ + +import javax.inject.Inject +import scala.annotation.unused +import scala.concurrent.ExecutionContext + +@unused +class PlayAnalyticsFilter @Inject()( + wsClient: WSClient, + config: Configuration +)(implicit val ec: ExecutionContext) extends EssentialFilter { + + private val apiKey: String = config.get[String]("apianalytics.api-key") + private val timeout: Long = config.getOptional[Long]("apianalytics.timeout").getOrElse(60000L) + private val privacyLevel: Int = config.getOptional[Int]("apianalytics.privacy-level").getOrElse(0) + private val serverUrl: String = config.getOptional[String]("apianalytics.server-url").getOrElse(DEFAULT_ENDPOINT) + + private val apiHandler = new PlayAnalyticsHandler( + apiKey, + wsClient, + timeout, + serverUrl, + privacyLevel + ) + + override def apply(next: EssentialAction): EssentialAction = EssentialAction { request => + val startTime = System.currentTimeMillis() + + next(request).map { result => + val endTime = System.currentTimeMillis() + val elapsedTime = endTime - startTime + + apiHandler.logRequest(new RequestData( + request.host, + request.connection.remoteAddress.getHostAddress, + request.headers.get("user-Agent").orNull, + request.uri, + result.header.status, + request.method, + elapsedTime, + null, + createdAt() + )) + + result + } + } +} diff --git a/analytics/jvm/play/src/main/java/dev/tomdraper/apianalytics/play/PlayAnalyticsHandler.scala b/analytics/jvm/play/src/main/java/dev/tomdraper/apianalytics/play/PlayAnalyticsHandler.scala new file mode 100644 index 00000000..4d8315d0 --- /dev/null +++ b/analytics/jvm/play/src/main/java/dev/tomdraper/apianalytics/play/PlayAnalyticsHandler.scala @@ -0,0 +1,29 @@ +package dev.tomdraper.apianalytics.play + +import dev.tomdraper.apianalytics.{AbstractAnalyticsHandler, PayloadHandler} +import play.api.libs.ws.{WSClient, WSResponse} + +import scala.annotation.unused +import scala.concurrent.Future + +@unused +class PlayAnalyticsHandler( + apiKey: String, + httpClient: WSClient, + timeout: Long, + serverUrl: String, + privacyLevel: Int +) extends AbstractAnalyticsHandler[Future[WSResponse], WSClient]( + apiKey, + httpClient, + timeout, + serverUrl, + "Play", + privacyLevel +) { + + override def send(payload: PayloadHandler.AnalyticsPayload, endpoint: String): Future[WSResponse] = + httpClient.url(endpoint) + .withHttpHeaders("Content-Type" -> "application/json") + .post(getObjectMapper.writeValueAsString(payload)) +} \ No newline at end of file diff --git a/analytics/jvm/settings.gradle b/analytics/jvm/settings.gradle new file mode 100644 index 00000000..746680e0 --- /dev/null +++ b/analytics/jvm/settings.gradle @@ -0,0 +1,3 @@ +rootProject.name = "apianalytics_jvm" + +include(':common', ':ktor', ':play', ':spring') diff --git a/analytics/jvm/spring/build.gradle.kts b/analytics/jvm/spring/build.gradle.kts new file mode 100644 index 00000000..0176b8c8 --- /dev/null +++ b/analytics/jvm/spring/build.gradle.kts @@ -0,0 +1,52 @@ +plugins { + alias(libs.plugins.kotlin) + alias(libs.plugins.spring.kotlin) + alias(libs.plugins.spring.boot) + id("io.spring.dependency-management") + application +} + +group = "dev.tomdraper.apianalytics" +version = "1.0.0" + +java { + toolchain { + languageVersion = JavaLanguageVersion.of(17) + } +} + +application { + mainClass.set("dev.tomdraper.apianalytics.spring.DummyEntrypoint") +} + +repositories { + mavenCentral() +} + +dependencies { + implementation(project(":common")) + implementation("org.springframework.boot:spring-boot-starter-web") + implementation("org.jetbrains.kotlin:kotlin-reflect") + compileOnly(libs.lombok) + annotationProcessor(libs.lombok) + + testImplementation("org.springframework.boot:spring-boot-starter-test") + testImplementation(libs.logging.logback) + testImplementation(libs.jackson.core) + testImplementation(libs.junit.kotlin) + testImplementation(libs.junit.api) + testRuntimeOnly(libs.junit.engine) +} +kotlin { + compilerOptions { + freeCompilerArgs.addAll("-Xjsr305=strict") + } +} + +tasks.withType { + useJUnitPlatform() +} + +tasks.withType { + duplicatesStrategy = DuplicatesStrategy.EXCLUDE +} diff --git a/analytics/jvm/spring/src/main/java/dev/tomdraper/apianalytics/spring/AnalyticsFilter.java b/analytics/jvm/spring/src/main/java/dev/tomdraper/apianalytics/spring/AnalyticsFilter.java new file mode 100644 index 00000000..0e5d2a97 --- /dev/null +++ b/analytics/jvm/spring/src/main/java/dev/tomdraper/apianalytics/spring/AnalyticsFilter.java @@ -0,0 +1,81 @@ +package dev.tomdraper.apianalytics.spring; + +import dev.tomdraper.apianalytics.PayloadHandler; +import jakarta.annotation.PreDestroy; +import jakarta.servlet.FilterChain; +import jakarta.servlet.ServletException; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import org.jetbrains.annotations.NotNull; +import org.jetbrains.annotations.Nullable; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.stereotype.Component; +import org.springframework.web.filter.OncePerRequestFilter; + +import java.io.IOException; + +@Component +public class AnalyticsFilter extends OncePerRequestFilter { + + private static final Logger logger = LoggerFactory.getLogger(AnalyticsFilter.class); + private final SpringAnalyticsConfig config; + public static SpringAnalyticsHandler handler; + + public AnalyticsFilter(SpringAnalyticsConfig config) { + this.config = config; + handler = new SpringAnalyticsHandler( + config.apiKey, + config.timeout, + config.serverUrl, + config.privacyLevel + ); + } + + @Override + protected void doFilterInternal( + @NotNull HttpServletRequest request, @NotNull HttpServletResponse response, @NotNull FilterChain filterChain + ) throws ServletException, IOException { + long startTime = System.currentTimeMillis(); + + filterChain.doFilter(request, response); + + long endTime = System.currentTimeMillis(); + long elapsedTime = endTime - startTime; + + String ipAddress = request.getHeader("X-Forwarded-For"); + if (ipAddress == null || ipAddress.isEmpty()) { + ipAddress = request.getRemoteAddr(); + } + + String userIdString = request.getHeader(config.getUserHeader()); + @Nullable Integer userId = null; + try { + if (config.getSendUserId() && userIdString != null) { + userId = Integer.parseInt(userIdString); + } + } catch (NumberFormatException e) { + logger.error("Invalid User ID! Defaulting to sending null", e); + } + + PayloadHandler.RequestData reqData = new PayloadHandler.RequestData( + request.getServerName(), + ipAddress, + request.getHeader("User-Agent"), + request.getContextPath(), + response.getStatus(), + request.getMethod(), + elapsedTime, + userId, + PayloadHandler.createdAt() + ); + + handler.logRequest(reqData); + } + + @PreDestroy + public void destroy() { + handler.forceSend(); + } +} + diff --git a/analytics/jvm/spring/src/main/java/dev/tomdraper/apianalytics/spring/AnalyticsFilterBeanConfiguration.java b/analytics/jvm/spring/src/main/java/dev/tomdraper/apianalytics/spring/AnalyticsFilterBeanConfiguration.java new file mode 100644 index 00000000..91f2e686 --- /dev/null +++ b/analytics/jvm/spring/src/main/java/dev/tomdraper/apianalytics/spring/AnalyticsFilterBeanConfiguration.java @@ -0,0 +1,19 @@ +package dev.tomdraper.apianalytics.spring; + +import org.springframework.boot.context.properties.EnableConfigurationProperties; +import org.springframework.boot.web.servlet.FilterRegistrationBean; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; + +@SuppressWarnings("unused") +@Configuration +@EnableConfigurationProperties({SpringAnalyticsConfig.class}) +public class AnalyticsFilterBeanConfiguration { + @Bean + public FilterRegistrationBean analyticsFilterRegistration(SpringAnalyticsConfig config) { + FilterRegistrationBean registrationBean = new FilterRegistrationBean<>(); + registrationBean.setFilter(new AnalyticsFilter(config)); + registrationBean.addUrlPatterns("/*"); + return registrationBean; + } +} diff --git a/analytics/jvm/spring/src/main/java/dev/tomdraper/apianalytics/spring/DummyEntrypoint.kt b/analytics/jvm/spring/src/main/java/dev/tomdraper/apianalytics/spring/DummyEntrypoint.kt new file mode 100644 index 00000000..e5454c22 --- /dev/null +++ b/analytics/jvm/spring/src/main/java/dev/tomdraper/apianalytics/spring/DummyEntrypoint.kt @@ -0,0 +1,12 @@ +package dev.tomdraper.apianalytics.spring + +import org.springframework.boot.autoconfigure.SpringBootApplication +import org.springframework.boot.runApplication + +@Suppress("unused") +@SpringBootApplication +internal class DummyEntrypoint { + fun main(args: Array) { + runApplication(*args) + } +} \ No newline at end of file diff --git a/analytics/jvm/spring/src/main/java/dev/tomdraper/apianalytics/spring/DummyRestController.kt b/analytics/jvm/spring/src/main/java/dev/tomdraper/apianalytics/spring/DummyRestController.kt new file mode 100644 index 00000000..21f2879b --- /dev/null +++ b/analytics/jvm/spring/src/main/java/dev/tomdraper/apianalytics/spring/DummyRestController.kt @@ -0,0 +1,15 @@ +package dev.tomdraper.apianalytics.spring + +import org.springframework.web.bind.annotation.GetMapping +import org.springframework.web.bind.annotation.RequestMapping +import org.springframework.web.bind.annotation.RestController + +@Suppress("unused") +@RestController +@RequestMapping("/spring") +internal class DummyRestController { + + @GetMapping + fun helloWorld(): Map = mapOf("hello" to "world") + +} \ No newline at end of file diff --git a/analytics/jvm/spring/src/main/java/dev/tomdraper/apianalytics/spring/SpringAnalyticsConfig.java b/analytics/jvm/spring/src/main/java/dev/tomdraper/apianalytics/spring/SpringAnalyticsConfig.java new file mode 100644 index 00000000..d8ac38fd --- /dev/null +++ b/analytics/jvm/spring/src/main/java/dev/tomdraper/apianalytics/spring/SpringAnalyticsConfig.java @@ -0,0 +1,27 @@ +package dev.tomdraper.apianalytics.spring; + +import lombok.Getter; +import lombok.Setter; +import org.jetbrains.annotations.NotNull; +import org.jetbrains.annotations.Nullable; +import org.springframework.boot.context.properties.ConfigurationProperties; + +import static dev.tomdraper.apianalytics.PayloadHandler.DEFAULT_ENDPOINT; + +@SuppressWarnings("unused") +@ConfigurationProperties(prefix = "apianalytics") +@Getter @Setter +public class SpringAnalyticsConfig { + // + private @NotNull Boolean sendUserId = false; + // + private @Nullable String userHeader; + // + public @Nullable String apiKey; + /** Changes the time between requests to the service in milliseconds. Changing this value is not recommended. */ + public @NotNull Long timeout = 60000L; + // + public @NotNull Integer privacyLevel = 0; + /** Changes where the plugin sends requests to. Don't change this unless you don't use the main server (i.e. self-hosting or secondary server) */ + public @NotNull String serverUrl = DEFAULT_ENDPOINT; +} diff --git a/analytics/jvm/spring/src/main/java/dev/tomdraper/apianalytics/spring/SpringAnalyticsHandler.kt b/analytics/jvm/spring/src/main/java/dev/tomdraper/apianalytics/spring/SpringAnalyticsHandler.kt new file mode 100644 index 00000000..d04f09ae --- /dev/null +++ b/analytics/jvm/spring/src/main/java/dev/tomdraper/apianalytics/spring/SpringAnalyticsHandler.kt @@ -0,0 +1,35 @@ +package dev.tomdraper.apianalytics.spring + +import dev.tomdraper.apianalytics.AbstractAnalyticsHandler +import dev.tomdraper.apianalytics.PayloadHandler.AnalyticsPayload +import org.slf4j.Logger +import org.slf4j.LoggerFactory +import org.springframework.http.HttpEntity +import org.springframework.http.HttpHeaders +import org.springframework.http.MediaType +import org.springframework.web.client.RestTemplate + +class SpringAnalyticsHandler( + override val apiKey: String?, + override val loggingTimeout: Long, + override val serverUrl: String, + override val privacyLevel: Int +) : AbstractAnalyticsHandler( + apiKey, + RestTemplate(), + loggingTimeout, + serverUrl, + "Express",//"Spring", // Spoofing Express temporarily until the backend can catch up + privacyLevel +) { + private val logger: Logger = LoggerFactory.getLogger(SpringAnalyticsHandler::class.java) + + override fun send(payload: AnalyticsPayload, endpoint: String) { + logger.debug("Sending payload to analytics API...") + val body = objectMapper.writeValueAsString(payload) + val headers = HttpHeaders() + headers.contentType = MediaType.APPLICATION_JSON + val request = HttpEntity(body, headers) + client.postForEntity(endpoint, request, String::class.java) + } +} \ No newline at end of file diff --git a/analytics/jvm/spring/src/main/resources/application.properties b/analytics/jvm/spring/src/main/resources/application.properties new file mode 100644 index 00000000..a18ca622 --- /dev/null +++ b/analytics/jvm/spring/src/main/resources/application.properties @@ -0,0 +1,10 @@ +spring.application.name = apianalytics + +#apianalytics.apiKey = f9b678de-ddc7-48eb-91fc-d97e8e52c0a1 +#apianalytics.timeout = 100 + +# Spring-only configs: +# If a numerical user-id is found in the header name below, it is sent as well +apianalytics.sendUserId = false +# The request header checked for numerical IDs +apianalytics.userHeader = "" \ No newline at end of file diff --git a/analytics/jvm/spring/src/test/java/dev/tomdraper/apianalytics/spring/PingServerTest.kt b/analytics/jvm/spring/src/test/java/dev/tomdraper/apianalytics/spring/PingServerTest.kt new file mode 100644 index 00000000..962862f1 --- /dev/null +++ b/analytics/jvm/spring/src/test/java/dev/tomdraper/apianalytics/spring/PingServerTest.kt @@ -0,0 +1,42 @@ +package dev.tomdraper.apianalytics.spring + +import org.junit.jupiter.api.Test +import org.slf4j.Logger +import org.slf4j.LoggerFactory +import org.springframework.boot.test.context.SpringBootTest +import org.springframework.boot.test.web.server.LocalServerPort +import org.springframework.web.client.ResourceAccessException +import org.springframework.web.client.RestTemplate +import java.lang.Thread.sleep + +/** Warning: this test says that it passed even though it could have failed during shutdown. + * Please check this test manually until a proper fix can be found. */ +@SpringBootTest( + webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT, + classes = [DummyEntrypoint::class], + properties = ["apianalytics.apiKey=f9b678de-ddc7-48eb-91fc-d97e8e52c0a1"] +) +class PingServerTest { + + val logger: Logger = LoggerFactory.getLogger(PingServerTest::class.java) + + /** Primitives like [Int] cant be `lateinit`, use [Integer] instead. */ + @Suppress("PLATFORM_CLASS_MAPPED_TO_KOTLIN") + @LocalServerPort + lateinit var port: Integer + + var restTemp: RestTemplate = RestTemplate() + + @Test + fun spamPing() { + repeat(50) { index -> + try { + val res = restTemp.getForEntity("http://localhost:$port/spring", String::class.java) + logger.info("Ping $index successful: ${res.body}") + } catch (rae: ResourceAccessException) { + logger.error(rae.message) + } + } + sleep(5000) + } +} \ No newline at end of file