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