diff --git a/.github/workflows/codeql-analysis.yml b/.github/workflows/codeql-analysis.yml
index b98168faf..e7bbd13eb 100644
--- a/.github/workflows/codeql-analysis.yml
+++ b/.github/workflows/codeql-analysis.yml
@@ -22,6 +22,7 @@ jobs:
- javascript
- python
- ruby
+ - java
steps:
- name: Checkout repository
@@ -34,7 +35,15 @@ jobs:
languages: ${{ matrix.language }}
- name: Autobuild
+ if: matrix.language != 'java'
uses: github/codeql-action/autobuild@v3
+ - name: Build for Java and Kotlin
+ if: matrix.language == 'java'
+ run: |
+ mvn clean install -f packages/java/metrics-core/pom.xml
+ mvn clean install -f packages/java/metrics-spring/pom.xml
+ mvn clean install -f packages/java/metrics-spring2/pom.xml
+
- name: Perform CodeQL Analysis
uses: github/codeql-action/analyze@v3
diff --git a/.github/workflows/java.yaml b/.github/workflows/java.yaml
new file mode 100644
index 000000000..790f2924b
--- /dev/null
+++ b/.github/workflows/java.yaml
@@ -0,0 +1,105 @@
+name: java
+
+on:
+ push:
+ branches:
+ - main
+ paths:
+ - 'packages/java/**'
+
+jobs:
+ build-and-deploy-core:
+ name: Build and Deploy metrics-core
+ runs-on: ubuntu-latest
+
+ steps:
+ - name: Checkout code
+ uses: actions/checkout@v4
+
+ - name: Set up JDK 17
+ uses: actions/setup-java@v3
+ with:
+ distribution: 'temurin'
+ java-version: '17'
+
+ - name: Configure Maven settings.xml
+ run: echo "${{ secrets.MAVEN_SETTINGS }}" > ~/.m2/settings.xml
+
+ - name: Build & Test metrics-core
+ working-directory: packages/java
+ run: mvn clean verify
+
+ - name: Import GPG Key
+ run: |
+ echo "$GPG_PRIVATE_KEY" | base64 --decode | gpg --batch --import
+ env:
+ GPG_PRIVATE_KEY: ${{ secrets.GPG_PRIVATE_KEY }}
+
+ - name: Deploy metrics-core
+ working-directory: packages/java
+ run: mvn deploy -Dgpg.passphrase=${{ secrets.GPG_PASSPHRASE }}
+
+ build-and-deploy-for-spring3:
+ name: Build and Deploy metrics-spring library for Spring Boot 3+
+ needs: build-and-deploy-core
+ runs-on: ubuntu-latest
+
+ steps:
+ - name: Checkout code
+ uses: actions/checkout@v4
+
+ - name: Set up JDK 17
+ uses: actions/setup-java@v3
+ with:
+ distribution: 'temurin'
+ java-version: '17'
+
+ - name: Configure Maven settings.xml
+ run: echo "${{ secrets.MAVEN_SETTINGS }}" > ~/.m2/settings.xml
+
+ - name: Build & Test metrics-spring
+ working-directory: packages/java/metrics-spring
+ run: mvn clean verify
+
+ - name: Import GPG Key
+ run: |
+ echo "$GPG_PRIVATE_KEY" | base64 --decode | gpg --batch --import
+ env:
+ GPG_PRIVATE_KEY: ${{ secrets.GPG_PRIVATE_KEY }}
+
+ - name: Deploy metrics-spring
+ working-directory: packages/java/metrics-spring
+ run: mvn deploy -Dgpg.passphrase=${{ secrets.GPG_PASSPHRASE }}
+
+ build-and-deploy-for-spring2:
+ name: Build and Deploy metrics-spring library for Spring Boot 2+
+ needs: build-and-deploy-core
+ runs-on: ubuntu-latest
+
+ steps:
+ - name: Checkout code
+ uses: actions/checkout@v4
+
+ - name: Set up JDK 17
+ uses: actions/setup-java@v3
+ with:
+ distribution: 'temurin'
+ java-version: '17'
+
+ - name: Configure Maven settings.xml
+ run: echo "${{ secrets.MAVEN_SETTINGS }}" > ~/.m2/settings.xml
+
+ - name: Build & Test metrics-spring2
+ working-directory: packages/java/metrics-spring2
+ run: mvn clean verify
+
+ - name: Import GPG Key
+ run: |
+ echo "$GPG_PRIVATE_KEY" | base64 --decode | gpg --batch --import
+ env:
+ GPG_PRIVATE_KEY: ${{ secrets.GPG_PRIVATE_KEY }}
+
+ - name: Deploy metrics-spring2
+ working-directory: packages/java/metrics-spring2
+ run: mvn deploy -Dgpg.passphrase=${{ secrets.GPG_PASSPHRASE }}
+
diff --git a/.gitignore b/.gitignore
index a6c502872..5e4b20618 100644
--- a/.gitignore
+++ b/.gitignore
@@ -4,3 +4,10 @@ node_modules/
packages/*/.nyc_output/
packages/*/coverage/
packages/*/node_modules/
+
+### IntelliJ IDEA ###
+.idea
+*.iws
+*.iml
+*.ipr
+
diff --git a/packages/java/.gitignore b/packages/java/.gitignore
new file mode 100644
index 000000000..b71abbc04
--- /dev/null
+++ b/packages/java/.gitignore
@@ -0,0 +1,27 @@
+target/
+!.mvn/wrapper/maven-wrapper.jar
+!**/src/main/**/target/
+!**/src/test/**/target/
+../*
+
+### IntelliJ IDEA ###
+.idea/
+*.iws
+*.iml
+*.ipr
+
+### Eclipse ###
+.apt_generated
+.classpath
+.factorypath
+.project
+.settings
+.springBeans
+.sts4-cache
+
+### VS Code ###
+.vscode/
+
+### Mac OS ###
+.DS_Store
+
diff --git a/packages/java/examples/SpringMetricsExample/.gitignore b/packages/java/examples/SpringMetricsExample/.gitignore
new file mode 100644
index 000000000..29a919b24
--- /dev/null
+++ b/packages/java/examples/SpringMetricsExample/.gitignore
@@ -0,0 +1,24 @@
+target/
+!.mvn/wrapper/maven-wrapper.jar
+!**/src/main/**/target/
+!**/src/test/**/target/
+
+### STS ###
+.apt_generated
+.classpath
+.factorypath
+.project
+.settings
+.springBeans
+.sts4-cache
+
+### IntelliJ IDEA ###
+.idea
+*.iws
+*.iml
+*.ipr
+
+### VS Code ###
+.vscode/
+
+.DS_Store
\ No newline at end of file
diff --git a/packages/java/examples/SpringMetricsExample/.mvn/wrapper/maven-wrapper.properties b/packages/java/examples/SpringMetricsExample/.mvn/wrapper/maven-wrapper.properties
new file mode 100644
index 000000000..d58dfb70b
--- /dev/null
+++ b/packages/java/examples/SpringMetricsExample/.mvn/wrapper/maven-wrapper.properties
@@ -0,0 +1,19 @@
+# Licensed to the Apache Software Foundation (ASF) under one
+# or more contributor license agreements. See the NOTICE file
+# distributed with this work for additional information
+# regarding copyright ownership. The ASF licenses this file
+# to you 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
+#
+# http://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.
+wrapperVersion=3.3.2
+distributionType=only-script
+distributionUrl=https://repo.maven.apache.org/maven2/org/apache/maven/apache-maven/3.9.9/apache-maven-3.9.9-bin.zip
diff --git a/packages/java/examples/SpringMetricsExample/README.md b/packages/java/examples/SpringMetricsExample/README.md
new file mode 100644
index 000000000..86a8f15cb
--- /dev/null
+++ b/packages/java/examples/SpringMetricsExample/README.md
@@ -0,0 +1,3 @@
+# SpringMetricsExample
+
+The application is a REST web-service. It is an example of using Java metrics SDK.
diff --git a/packages/java/examples/SpringMetricsExample/pom.xml b/packages/java/examples/SpringMetricsExample/pom.xml
new file mode 100644
index 000000000..5f8f95e44
--- /dev/null
+++ b/packages/java/examples/SpringMetricsExample/pom.xml
@@ -0,0 +1,49 @@
+
+
+ 4.0.0
+
+ org.springframework.boot
+ spring-boot-starter-parent
+ 3.3.4
+
+
+
+ com.readme
+ spring-metrics-example
+ 0.0.1-SNAPSHOT
+ spring-metrics-example
+ Example project to test metrics-sdk
+
+
+ 21
+
+
+
+
+ com.readme
+ metrics-spring
+
+ 0.1.0
+
+
+ org.springframework.boot
+ spring-boot-starter-web
+
+
+ org.springframework.boot
+ spring-boot-starter-test
+ test
+
+
+
+
+
+
+ org.springframework.boot
+ spring-boot-maven-plugin
+
+
+
+
+
diff --git a/packages/java/examples/SpringMetricsExample/src/main/java/com/owl/example/CustomUserDataCollectorConfig.java b/packages/java/examples/SpringMetricsExample/src/main/java/com/owl/example/CustomUserDataCollectorConfig.java
new file mode 100644
index 000000000..9f089400f
--- /dev/null
+++ b/packages/java/examples/SpringMetricsExample/src/main/java/com/owl/example/CustomUserDataCollectorConfig.java
@@ -0,0 +1,49 @@
+package com.owl.example;
+
+import com.readme.core.dataextraction.LogOptions;
+import com.readme.core.dataextraction.payload.user.UserData;
+import com.readme.core.dataextraction.payload.user.UserDataCollector;
+import com.readme.spring.datacollection.ServletDataPayloadAdapter;
+import org.springframework.context.annotation.Bean;
+import org.springframework.context.annotation.Configuration;
+
+import java.util.List;
+
+/**
+ * Configuration class for customizing the strategy to collect user data.
+ *
+ *
This configuration provides a custom implementation of {@link UserDataCollector},
+ * which overrides the default behavior provided by the SDK. It allows developers
+ * to specify their own logic for extracting user-specific information, such as API keys,
+ * email addresses, or labels, from the incoming HTTP requests.
+ *
+ * In this example, the API key is extracted from the HTTP headers using the header
+ * "X-User-Name", while the email and label fields are hardcoded with custom values.
+ * Developers can modify this logic to suit their application's requirements.
+ *
+ * By defining this bean, Spring Boot's auto-configuration will automatically use
+ * this custom implementation instead of the default {@link UserDataCollector}.
+ */
+@Configuration
+public class CustomUserDataCollectorConfig {
+
+ @Bean
+ public UserDataCollector customUserDataCollector() {
+ return payloadAdapter -> {
+ String apiKey = payloadAdapter.getRequestHeaders().get("x-user-name");
+ return UserData.builder()
+ .apiKey(apiKey)
+ .email("owl@owlfactory.abc")
+ .label("owl-label")
+ .build();
+ };
+ }
+
+ @Bean
+ public LogOptions logOptions() {
+ return LogOptions.builder()
+ .baseLogUrl("http://baseurl.abcd")
+ .bufferLength(1)
+ .build();
+ }
+}
diff --git a/packages/java/examples/SpringMetricsExample/src/main/java/com/owl/example/OwlController.java b/packages/java/examples/SpringMetricsExample/src/main/java/com/owl/example/OwlController.java
new file mode 100644
index 000000000..342de8ec0
--- /dev/null
+++ b/packages/java/examples/SpringMetricsExample/src/main/java/com/owl/example/OwlController.java
@@ -0,0 +1,76 @@
+package com.owl.example;
+
+import com.readme.core.datatransfer.har.HttpStatus;
+import org.springframework.http.HttpHeaders;
+import org.springframework.http.MediaType;
+import org.springframework.http.ResponseEntity;
+import org.springframework.web.bind.annotation.*;
+
+import java.util.*;
+
+/**
+ * OwlController is a sample REST controller intended for demonstration and testing purposes.
+ *
+ * It simulates typical HTTP requests and responses to showcase how the ReadMe Metrics SDK
+ * integrates into a Spring Boot application.
+ *
+ * This controller is not intended for production use and serves only as an example endpoint
+ * to test how the SDK logs and processes different request types.
+ */
+@RestController
+public class OwlController {
+
+ private final Map owlStorage = new HashMap<>();
+
+ public OwlController() {
+ owlStorage.put("1", "Default Owl");
+ }
+
+ @GetMapping("/owl/{id}")
+ public String getOwlById(@PathVariable String id) {
+ return "Owl with id " + id;
+ }
+
+ @GetMapping("/owls")
+ public Collection getAllOwl() {
+ return owlStorage.values();
+ }
+
+ @PutMapping("/owl/{owlName}")
+ public ResponseEntity createOwl(@PathVariable String owlName, @RequestBody String body) {
+ UUID birdId = UUID.randomUUID();
+ owlStorage.put(birdId.toString(), owlName);
+
+ String responseBody = "Bird " + owlName + " created a bird with id: " + birdId + "\n" +
+ "Creation request body: \n" + body;
+
+ HttpHeaders headers = new HttpHeaders();
+ headers.add("bird-id", birdId.toString());
+ headers.add("bird-token", Base64.getEncoder()
+ .encodeToString(birdId.toString()
+ .getBytes()));
+
+ return ResponseEntity.status(HttpStatus.CREATED.getCode())
+ .headers(headers)
+ .body(responseBody);
+ }
+
+ @PutMapping(value = "/owl/urlencoded/{owlName}", consumes = MediaType.APPLICATION_FORM_URLENCODED_VALUE)
+ public ResponseEntity createOwlUrlencoded(@RequestParam Map params) {
+ UUID birdId = UUID.randomUUID();
+
+ String responseBody = "Created a bird with id: " + birdId + "\n" +
+ "Creation request urlencoded body: \n" + params;
+
+ HttpHeaders headers = new HttpHeaders();
+ headers.add("bird-id", birdId.toString());
+ headers.add("bird-token", Base64.getEncoder()
+ .encodeToString(birdId.toString()
+ .getBytes()));
+
+ return ResponseEntity.status(HttpStatus.CREATED.getCode())
+ .headers(headers)
+ .body(responseBody);
+ }
+
+}
\ No newline at end of file
diff --git a/packages/java/examples/SpringMetricsExample/src/main/java/com/owl/example/SpringMetricsExample.java b/packages/java/examples/SpringMetricsExample/src/main/java/com/owl/example/SpringMetricsExample.java
new file mode 100644
index 000000000..7a9aea0a2
--- /dev/null
+++ b/packages/java/examples/SpringMetricsExample/src/main/java/com/owl/example/SpringMetricsExample.java
@@ -0,0 +1,13 @@
+package com.owl.example;
+
+import org.springframework.boot.SpringApplication;
+import org.springframework.boot.autoconfigure.SpringBootApplication;
+
+@SpringBootApplication
+public class SpringMetricsExample {
+
+ public static void main(String[] args) {
+ SpringApplication.run(SpringMetricsExample.class, args);
+ }
+
+}
diff --git a/packages/java/examples/SpringMetricsExample/src/main/java/com/owl/example/WebhookController.java b/packages/java/examples/SpringMetricsExample/src/main/java/com/owl/example/WebhookController.java
new file mode 100644
index 000000000..d0f4fae69
--- /dev/null
+++ b/packages/java/examples/SpringMetricsExample/src/main/java/com/owl/example/WebhookController.java
@@ -0,0 +1,28 @@
+package com.owl.example;
+
+import com.readme.core.webhook.WebhookVerifier;
+import lombok.AllArgsConstructor;
+import lombok.Data;
+import org.springframework.http.ResponseEntity;
+import org.springframework.web.bind.annotation.*;
+
+
+@RestController
+@RequestMapping("/webhook")
+public class WebhookController {
+
+ private static final String SECRET = "PASTE_SECRET_HERE";
+
+ @PostMapping(consumes = "application/json", produces = "application/json")
+ public ResponseEntity handleWebhook(@RequestBody String requestBody,
+ @RequestHeader("readme-signature") String signature) {
+ WebhookVerifier.verifyWebhook(requestBody, signature, SECRET);
+ return ResponseEntity.ok(new WebhookResponse("QaOwl"));
+ }
+
+ @Data
+ @AllArgsConstructor
+ public static class WebhookResponse {
+ private String user;
+ }
+}
\ No newline at end of file
diff --git a/packages/java/examples/SpringMetricsExample/src/main/resources/application.properties b/packages/java/examples/SpringMetricsExample/src/main/resources/application.properties
new file mode 100644
index 000000000..b8c00bc18
--- /dev/null
+++ b/packages/java/examples/SpringMetricsExample/src/main/resources/application.properties
@@ -0,0 +1,2 @@
+spring.application.name=example
+logging.level.com.readme=DEBUG
diff --git a/packages/java/examples/SpringMetricsExample/src/main/resources/application.yaml b/packages/java/examples/SpringMetricsExample/src/main/resources/application.yaml
new file mode 100644
index 000000000..4b850490d
--- /dev/null
+++ b/packages/java/examples/SpringMetricsExample/src/main/resources/application.yaml
@@ -0,0 +1,41 @@
+readme:
+ readmeApiKey: ${README_API_KEY}
+# userdata:
+# apiKey:
+# source: header
+# fieldName: X-User-Name
+# email:
+# source: header
+# fieldName: X-User-Email
+# label:
+# source: header
+# fieldName: X-User-Id
+
+#readme:
+# readmeApiKey: ${README_API_KEY}
+# userdata:
+# apiKey:
+# source: jsonBody
+# fieldName: /owl-creator/name
+# email:
+# source: jsonBody
+# fieldName: /owl-creator/contacts/email
+# label:
+# source: jsonBody
+# fieldName: owl-creator/label
+#
+#readme:
+# readmeApiKey: ${README_API_KEY}
+# userdata:
+# apiKey:
+# source: jwt
+# fieldName: name
+# email:
+# source: jwt
+# fieldName: aud
+# label:
+# source: jwt
+# fieldName: user_id
+
+
+
diff --git a/packages/java/examples/kotlin-example/.gitattributes b/packages/java/examples/kotlin-example/.gitattributes
new file mode 100644
index 000000000..8af972cde
--- /dev/null
+++ b/packages/java/examples/kotlin-example/.gitattributes
@@ -0,0 +1,3 @@
+/gradlew text eol=lf
+*.bat text eol=crlf
+*.jar binary
diff --git a/packages/java/examples/kotlin-example/.gitignore b/packages/java/examples/kotlin-example/.gitignore
new file mode 100644
index 000000000..be98258ea
--- /dev/null
+++ b/packages/java/examples/kotlin-example/.gitignore
@@ -0,0 +1,33 @@
+HELP.md
+.gradle
+build/
+!gradle/wrapper/gradle-wrapper.jar
+!**/src/main/**/build/
+!**/src/test/**/build/
+
+### Eclipse ###
+.apt_generated
+.classpath
+.factorypath
+.project
+.settings
+.springBeans
+.sts4-cache
+bin/
+!**/src/main/**/bin/
+!**/src/test/**/bin/
+
+### IntelliJ IDEA ###
+.idea
+*.iws
+*.iml
+*.ipr
+
+### VS Code ###
+.vscode/
+
+### Kotlin ###
+.kotlin
+
+### Mac OS ###
+.DS_Store
\ No newline at end of file
diff --git a/packages/java/examples/kotlin-example/build.gradle.kts b/packages/java/examples/kotlin-example/build.gradle.kts
new file mode 100644
index 000000000..44b1267aa
--- /dev/null
+++ b/packages/java/examples/kotlin-example/build.gradle.kts
@@ -0,0 +1,50 @@
+plugins {
+ kotlin("jvm") version "1.9.25"
+ kotlin("plugin.spring") version "1.9.25"
+ id("org.springframework.boot") version "3.4.3"
+ id("io.spring.dependency-management") version "1.1.7"
+}
+
+group = "com.owl"
+version = "0.0.1-SNAPSHOT"
+
+java {
+ toolchain {
+ languageVersion = JavaLanguageVersion.of(21)
+ }
+}
+
+repositories {
+ mavenLocal()
+ mavenCentral()
+ gradlePluginPortal()
+}
+
+dependencies {
+ implementation("com.readme:metrics-spring:0.1.0")
+ implementation("org.springframework.boot:spring-boot-starter-web")
+ implementation("com.fasterxml.jackson.module:jackson-module-kotlin")
+ implementation("org.jetbrains.kotlin:kotlin-reflect")
+
+ testImplementation("org.springframework.boot:spring-boot-starter-test")
+ testImplementation("org.jetbrains.kotlin:kotlin-test-junit5")
+ testRuntimeOnly("org.junit.platform:junit-platform-launcher")
+}
+
+kotlin {
+ compilerOptions {
+ freeCompilerArgs.addAll("-Xjsr305=strict")
+ }
+}
+
+tasks.withType {
+ mainClass.set("com.owl.kotlin_example.KotlinExampleApplicationKt")
+}
+
+springBoot {
+ mainClass.set("com.owl.kotlin_example.KotlinExampleApplicationKt")
+}
+
+tasks.withType {
+ useJUnitPlatform()
+}
diff --git a/packages/java/examples/kotlin-example/gradle/wrapper/gradle-wrapper.jar b/packages/java/examples/kotlin-example/gradle/wrapper/gradle-wrapper.jar
new file mode 100644
index 000000000..a4b76b953
Binary files /dev/null and b/packages/java/examples/kotlin-example/gradle/wrapper/gradle-wrapper.jar differ
diff --git a/packages/java/examples/kotlin-example/gradle/wrapper/gradle-wrapper.properties b/packages/java/examples/kotlin-example/gradle/wrapper/gradle-wrapper.properties
new file mode 100644
index 000000000..e18bc253b
--- /dev/null
+++ b/packages/java/examples/kotlin-example/gradle/wrapper/gradle-wrapper.properties
@@ -0,0 +1,7 @@
+distributionBase=GRADLE_USER_HOME
+distributionPath=wrapper/dists
+distributionUrl=https\://services.gradle.org/distributions/gradle-8.12.1-bin.zip
+networkTimeout=10000
+validateDistributionUrl=true
+zipStoreBase=GRADLE_USER_HOME
+zipStorePath=wrapper/dists
diff --git a/packages/java/examples/kotlin-example/gradlew b/packages/java/examples/kotlin-example/gradlew
new file mode 100755
index 000000000..f5feea6d6
--- /dev/null
+++ b/packages/java/examples/kotlin-example/gradlew
@@ -0,0 +1,252 @@
+#!/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.
+#
+# SPDX-License-Identifier: Apache-2.0
+#
+
+##############################################################################
+#
+# 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/HEAD/platforms/jvm/plugins-application/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
+
+# This is normally unused
+# shellcheck disable=SC2034
+APP_BASE_NAME=${0##*/}
+# Discard cd standard output in case $CDPATH is set (https://github.com/gradle/gradle/issues/25036)
+APP_HOME=$( cd -P "${APP_HOME:-./}" > /dev/null && printf '%s
+' "$PWD" ) || exit
+
+# 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
+ if ! command -v java >/dev/null 2>&1
+ then
+ 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
+fi
+
+# Increase the maximum file descriptors if we can.
+if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then
+ case $MAX_FD in #(
+ max*)
+ # In POSIX sh, ulimit -H is undefined. That's why the result is checked to see if it worked.
+ # shellcheck disable=SC2039,SC3045
+ MAX_FD=$( ulimit -H -n ) ||
+ warn "Could not query maximum file descriptor limit"
+ esac
+ case $MAX_FD in #(
+ '' | soft) :;; #(
+ *)
+ # In POSIX sh, ulimit -n is undefined. That's why the result is checked to see if it worked.
+ # shellcheck disable=SC2039,SC3045
+ 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
+
+
+# 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"'
+
+# Collect all arguments for the java command:
+# * DEFAULT_JVM_OPTS, JAVA_OPTS, JAVA_OPTS, and optsEnvironmentVar are not allowed to contain shell fragments,
+# and any embedded shellness will be escaped.
+# * For example: A user cannot expect ${Hostname} to be expanded, as it is an environment variable and will be
+# treated as '${Hostname}' itself on the command line.
+
+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/packages/java/examples/kotlin-example/gradlew.bat b/packages/java/examples/kotlin-example/gradlew.bat
new file mode 100644
index 000000000..9d21a2183
--- /dev/null
+++ b/packages/java/examples/kotlin-example/gradlew.bat
@@ -0,0 +1,94 @@
+@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
+@rem SPDX-License-Identifier: Apache-2.0
+@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=.
+@rem This is normally unused
+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. 1>&2
+echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 1>&2
+echo. 1>&2
+echo Please set the JAVA_HOME variable in your environment to match the 1>&2
+echo location of your Java installation. 1>&2
+
+goto fail
+
+:findJavaFromJavaHome
+set JAVA_HOME=%JAVA_HOME:"=%
+set JAVA_EXE=%JAVA_HOME%/bin/java.exe
+
+if exist "%JAVA_EXE%" goto execute
+
+echo. 1>&2
+echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% 1>&2
+echo. 1>&2
+echo Please set the JAVA_HOME variable in your environment to match the 1>&2
+echo location of your Java installation. 1>&2
+
+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/packages/java/examples/kotlin-example/settings.gradle.kts b/packages/java/examples/kotlin-example/settings.gradle.kts
new file mode 100644
index 000000000..41e660bb0
--- /dev/null
+++ b/packages/java/examples/kotlin-example/settings.gradle.kts
@@ -0,0 +1 @@
+rootProject.name = "kotlin-example"
diff --git a/packages/java/examples/kotlin-example/src/main/kotlin/com/owl/kotlin_example/CustomUserDataCollectorConfig.kt b/packages/java/examples/kotlin-example/src/main/kotlin/com/owl/kotlin_example/CustomUserDataCollectorConfig.kt
new file mode 100644
index 000000000..70455a47c
--- /dev/null
+++ b/packages/java/examples/kotlin-example/src/main/kotlin/com/owl/kotlin_example/CustomUserDataCollectorConfig.kt
@@ -0,0 +1,32 @@
+package com.owl.kotlin_example
+
+import com.readme.core.dataextraction.LogOptions
+import com.readme.core.dataextraction.payload.user.UserData
+import com.readme.core.dataextraction.payload.user.UserDataCollector
+import com.readme.spring.datacollection.ServletDataPayloadAdapter
+import org.springframework.context.annotation.Bean
+import org.springframework.context.annotation.Configuration
+
+@Configuration
+class CustomUserDataCollectorConfig {
+
+ @Bean
+ fun customUserDataCollector(): UserDataCollector {
+ return UserDataCollector { payloadAdapter ->
+ val apiKey = payloadAdapter.requestHeaders["x-user-name"]
+ UserData.builder()
+ .apiKey(apiKey)
+ .email("owl@owlfactory.abc")
+ .label("owl-label")
+ .build()
+ }
+ }
+
+ @Bean
+ fun logOptions(): LogOptions {
+ return LogOptions.builder()
+ .baseLogUrl("http://baseurl.abcd")
+ .bufferLength(3)
+ .build()
+ }
+}
\ No newline at end of file
diff --git a/packages/java/examples/kotlin-example/src/main/kotlin/com/owl/kotlin_example/KotlinExampleApplication.kt b/packages/java/examples/kotlin-example/src/main/kotlin/com/owl/kotlin_example/KotlinExampleApplication.kt
new file mode 100644
index 000000000..a5e8364e3
--- /dev/null
+++ b/packages/java/examples/kotlin-example/src/main/kotlin/com/owl/kotlin_example/KotlinExampleApplication.kt
@@ -0,0 +1,11 @@
+package com.owl.kotlin_example
+
+import org.springframework.boot.autoconfigure.SpringBootApplication
+import org.springframework.boot.runApplication
+
+@SpringBootApplication
+class KotlinExampleApplication
+
+fun main(args: Array) {
+ runApplication(*args)
+}
\ No newline at end of file
diff --git a/packages/java/examples/kotlin-example/src/main/kotlin/com/owl/kotlin_example/OwlController.kt b/packages/java/examples/kotlin-example/src/main/kotlin/com/owl/kotlin_example/OwlController.kt
new file mode 100644
index 000000000..33ed4350b
--- /dev/null
+++ b/packages/java/examples/kotlin-example/src/main/kotlin/com/owl/kotlin_example/OwlController.kt
@@ -0,0 +1,66 @@
+package com.owl.kotlin_example
+
+import com.readme.core.datatransfer.har.HttpStatus
+import org.springframework.http.HttpHeaders
+import org.springframework.http.MediaType
+import org.springframework.http.ResponseEntity
+import org.springframework.web.bind.annotation.*
+import java.util.*
+import java.util.Base64
+
+@RestController
+class OwlController {
+
+ private val owlStorage: MutableMap = mutableMapOf("1" to "Default Owl")
+
+ @GetMapping("/owl/{id}")
+ fun getOwlById(@PathVariable id: String): String {
+ return "Owl with id $id"
+ }
+
+ @GetMapping("/owls")
+ fun getAllOwl(): Collection {
+ return owlStorage.values
+ }
+
+ @PutMapping("/owl/{owlName}")
+ fun createOwl(@PathVariable owlName: String, @RequestBody body: String): ResponseEntity {
+ val birdId = UUID.randomUUID()
+ owlStorage[birdId.toString()] = owlName
+
+ val responseBody = buildString {
+ append("Bird $owlName created a bird with id: $birdId\n")
+ append("Creation request body: \n$body")
+ }
+
+ val headers = HttpHeaders().apply {
+ add("bird-id", birdId.toString())
+ add("bird-token", Base64.getEncoder().encodeToString(birdId.toString().toByteArray()))
+ }
+
+ return ResponseEntity
+ .status(HttpStatus.CREATED.code)
+ .headers(headers)
+ .body(responseBody)
+ }
+
+ @PutMapping("/owl/urlencoded/{owlName}", consumes = [MediaType.APPLICATION_FORM_URLENCODED_VALUE])
+ fun createOwlUrlencoded(@RequestParam params: Map): ResponseEntity {
+ val birdId = UUID.randomUUID()
+
+ val responseBody = buildString {
+ append("Created a bird with id: $birdId\n")
+ append("Creation request urlencoded body: \n$params")
+ }
+
+ val headers = HttpHeaders().apply {
+ add("bird-id", birdId.toString())
+ add("bird-token", Base64.getEncoder().encodeToString(birdId.toString().toByteArray()))
+ }
+
+ return ResponseEntity
+ .status(HttpStatus.CREATED.code)
+ .headers(headers)
+ .body(responseBody)
+ }
+}
\ No newline at end of file
diff --git a/packages/java/examples/kotlin-example/src/main/resources/application.properties b/packages/java/examples/kotlin-example/src/main/resources/application.properties
new file mode 100644
index 000000000..08bda9643
--- /dev/null
+++ b/packages/java/examples/kotlin-example/src/main/resources/application.properties
@@ -0,0 +1 @@
+spring.application.name=kotlin-example
diff --git a/packages/java/examples/kotlin-example/src/main/resources/application.yaml b/packages/java/examples/kotlin-example/src/main/resources/application.yaml
new file mode 100644
index 000000000..6341c0b71
--- /dev/null
+++ b/packages/java/examples/kotlin-example/src/main/resources/application.yaml
@@ -0,0 +1,2 @@
+readme:
+ readmeApiKey: ${README_API_KEY}
\ No newline at end of file
diff --git a/packages/java/metrics-core/.gitignore b/packages/java/metrics-core/.gitignore
new file mode 100644
index 000000000..b71abbc04
--- /dev/null
+++ b/packages/java/metrics-core/.gitignore
@@ -0,0 +1,27 @@
+target/
+!.mvn/wrapper/maven-wrapper.jar
+!**/src/main/**/target/
+!**/src/test/**/target/
+../*
+
+### IntelliJ IDEA ###
+.idea/
+*.iws
+*.iml
+*.ipr
+
+### Eclipse ###
+.apt_generated
+.classpath
+.factorypath
+.project
+.settings
+.springBeans
+.sts4-cache
+
+### VS Code ###
+.vscode/
+
+### Mac OS ###
+.DS_Store
+
diff --git a/packages/java/metrics-core/.mvn/wrapper/maven-wrapper.properties b/packages/java/metrics-core/.mvn/wrapper/maven-wrapper.properties
new file mode 100644
index 000000000..d58dfb70b
--- /dev/null
+++ b/packages/java/metrics-core/.mvn/wrapper/maven-wrapper.properties
@@ -0,0 +1,19 @@
+# Licensed to the Apache Software Foundation (ASF) under one
+# or more contributor license agreements. See the NOTICE file
+# distributed with this work for additional information
+# regarding copyright ownership. The ASF licenses this file
+# to you 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
+#
+# http://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.
+wrapperVersion=3.3.2
+distributionType=only-script
+distributionUrl=https://repo.maven.apache.org/maven2/org/apache/maven/apache-maven/3.9.9/apache-maven-3.9.9-bin.zip
diff --git a/packages/java/metrics-core/mvnw b/packages/java/metrics-core/mvnw
new file mode 100755
index 000000000..19529ddf8
--- /dev/null
+++ b/packages/java/metrics-core/mvnw
@@ -0,0 +1,259 @@
+#!/bin/sh
+# ----------------------------------------------------------------------------
+# Licensed to the Apache Software Foundation (ASF) under one
+# or more contributor license agreements. See the NOTICE file
+# distributed with this work for additional information
+# regarding copyright ownership. The ASF licenses this file
+# to you 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
+#
+# http://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.
+# ----------------------------------------------------------------------------
+
+# ----------------------------------------------------------------------------
+# Apache Maven Wrapper startup batch script, version 3.3.2
+#
+# Optional ENV vars
+# -----------------
+# JAVA_HOME - location of a JDK home dir, required when download maven via java source
+# MVNW_REPOURL - repo url base for downloading maven distribution
+# MVNW_USERNAME/MVNW_PASSWORD - user and password for downloading maven
+# MVNW_VERBOSE - true: enable verbose log; debug: trace the mvnw script; others: silence the output
+# ----------------------------------------------------------------------------
+
+set -euf
+[ "${MVNW_VERBOSE-}" != debug ] || set -x
+
+# OS specific support.
+native_path() { printf %s\\n "$1"; }
+case "$(uname)" in
+CYGWIN* | MINGW*)
+ [ -z "${JAVA_HOME-}" ] || JAVA_HOME="$(cygpath --unix "$JAVA_HOME")"
+ native_path() { cygpath --path --windows "$1"; }
+ ;;
+esac
+
+# set JAVACMD and JAVACCMD
+set_java_home() {
+ # For Cygwin and MinGW, ensure paths are in Unix format before anything is touched
+ 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"
+ JAVACCMD="$JAVA_HOME/jre/sh/javac"
+ else
+ JAVACMD="$JAVA_HOME/bin/java"
+ JAVACCMD="$JAVA_HOME/bin/javac"
+
+ if [ ! -x "$JAVACMD" ] || [ ! -x "$JAVACCMD" ]; then
+ echo "The JAVA_HOME environment variable is not defined correctly, so mvnw cannot run." >&2
+ echo "JAVA_HOME is set to \"$JAVA_HOME\", but \"\$JAVA_HOME/bin/java\" or \"\$JAVA_HOME/bin/javac\" does not exist." >&2
+ return 1
+ fi
+ fi
+ else
+ JAVACMD="$(
+ 'set' +e
+ 'unset' -f command 2>/dev/null
+ 'command' -v java
+ )" || :
+ JAVACCMD="$(
+ 'set' +e
+ 'unset' -f command 2>/dev/null
+ 'command' -v javac
+ )" || :
+
+ if [ ! -x "${JAVACMD-}" ] || [ ! -x "${JAVACCMD-}" ]; then
+ echo "The java/javac command does not exist in PATH nor is JAVA_HOME set, so mvnw cannot run." >&2
+ return 1
+ fi
+ fi
+}
+
+# hash string like Java String::hashCode
+hash_string() {
+ str="${1:-}" h=0
+ while [ -n "$str" ]; do
+ char="${str%"${str#?}"}"
+ h=$(((h * 31 + $(LC_CTYPE=C printf %d "'$char")) % 4294967296))
+ str="${str#?}"
+ done
+ printf %x\\n $h
+}
+
+verbose() { :; }
+[ "${MVNW_VERBOSE-}" != true ] || verbose() { printf %s\\n "${1-}"; }
+
+die() {
+ printf %s\\n "$1" >&2
+ exit 1
+}
+
+trim() {
+ # MWRAPPER-139:
+ # Trims trailing and leading whitespace, carriage returns, tabs, and linefeeds.
+ # Needed for removing poorly interpreted newline sequences when running in more
+ # exotic environments such as mingw bash on Windows.
+ printf "%s" "${1}" | tr -d '[:space:]'
+}
+
+# parse distributionUrl and optional distributionSha256Sum, requires .mvn/wrapper/maven-wrapper.properties
+while IFS="=" read -r key value; do
+ case "${key-}" in
+ distributionUrl) distributionUrl=$(trim "${value-}") ;;
+ distributionSha256Sum) distributionSha256Sum=$(trim "${value-}") ;;
+ esac
+done <"${0%/*}/.mvn/wrapper/maven-wrapper.properties"
+[ -n "${distributionUrl-}" ] || die "cannot read distributionUrl property in ${0%/*}/.mvn/wrapper/maven-wrapper.properties"
+
+case "${distributionUrl##*/}" in
+maven-mvnd-*bin.*)
+ MVN_CMD=mvnd.sh _MVNW_REPO_PATTERN=/maven/mvnd/
+ case "${PROCESSOR_ARCHITECTURE-}${PROCESSOR_ARCHITEW6432-}:$(uname -a)" in
+ *AMD64:CYGWIN* | *AMD64:MINGW*) distributionPlatform=windows-amd64 ;;
+ :Darwin*x86_64) distributionPlatform=darwin-amd64 ;;
+ :Darwin*arm64) distributionPlatform=darwin-aarch64 ;;
+ :Linux*x86_64*) distributionPlatform=linux-amd64 ;;
+ *)
+ echo "Cannot detect native platform for mvnd on $(uname)-$(uname -m), use pure java version" >&2
+ distributionPlatform=linux-amd64
+ ;;
+ esac
+ distributionUrl="${distributionUrl%-bin.*}-$distributionPlatform.zip"
+ ;;
+maven-mvnd-*) MVN_CMD=mvnd.sh _MVNW_REPO_PATTERN=/maven/mvnd/ ;;
+*) MVN_CMD="mvn${0##*/mvnw}" _MVNW_REPO_PATTERN=/org/apache/maven/ ;;
+esac
+
+# apply MVNW_REPOURL and calculate MAVEN_HOME
+# maven home pattern: ~/.m2/wrapper/dists/{apache-maven-,maven-mvnd--}/
+[ -z "${MVNW_REPOURL-}" ] || distributionUrl="$MVNW_REPOURL$_MVNW_REPO_PATTERN${distributionUrl#*"$_MVNW_REPO_PATTERN"}"
+distributionUrlName="${distributionUrl##*/}"
+distributionUrlNameMain="${distributionUrlName%.*}"
+distributionUrlNameMain="${distributionUrlNameMain%-bin}"
+MAVEN_USER_HOME="${MAVEN_USER_HOME:-${HOME}/.m2}"
+MAVEN_HOME="${MAVEN_USER_HOME}/wrapper/dists/${distributionUrlNameMain-}/$(hash_string "$distributionUrl")"
+
+exec_maven() {
+ unset MVNW_VERBOSE MVNW_USERNAME MVNW_PASSWORD MVNW_REPOURL || :
+ exec "$MAVEN_HOME/bin/$MVN_CMD" "$@" || die "cannot exec $MAVEN_HOME/bin/$MVN_CMD"
+}
+
+if [ -d "$MAVEN_HOME" ]; then
+ verbose "found existing MAVEN_HOME at $MAVEN_HOME"
+ exec_maven "$@"
+fi
+
+case "${distributionUrl-}" in
+*?-bin.zip | *?maven-mvnd-?*-?*.zip) ;;
+*) die "distributionUrl is not valid, must match *-bin.zip or maven-mvnd-*.zip, but found '${distributionUrl-}'" ;;
+esac
+
+# prepare tmp dir
+if TMP_DOWNLOAD_DIR="$(mktemp -d)" && [ -d "$TMP_DOWNLOAD_DIR" ]; then
+ clean() { rm -rf -- "$TMP_DOWNLOAD_DIR"; }
+ trap clean HUP INT TERM EXIT
+else
+ die "cannot create temp dir"
+fi
+
+mkdir -p -- "${MAVEN_HOME%/*}"
+
+# Download and Install Apache Maven
+verbose "Couldn't find MAVEN_HOME, downloading and installing it ..."
+verbose "Downloading from: $distributionUrl"
+verbose "Downloading to: $TMP_DOWNLOAD_DIR/$distributionUrlName"
+
+# select .zip or .tar.gz
+if ! command -v unzip >/dev/null; then
+ distributionUrl="${distributionUrl%.zip}.tar.gz"
+ distributionUrlName="${distributionUrl##*/}"
+fi
+
+# verbose opt
+__MVNW_QUIET_WGET=--quiet __MVNW_QUIET_CURL=--silent __MVNW_QUIET_UNZIP=-q __MVNW_QUIET_TAR=''
+[ "${MVNW_VERBOSE-}" != true ] || __MVNW_QUIET_WGET='' __MVNW_QUIET_CURL='' __MVNW_QUIET_UNZIP='' __MVNW_QUIET_TAR=v
+
+# normalize http auth
+case "${MVNW_PASSWORD:+has-password}" in
+'') MVNW_USERNAME='' MVNW_PASSWORD='' ;;
+has-password) [ -n "${MVNW_USERNAME-}" ] || MVNW_USERNAME='' MVNW_PASSWORD='' ;;
+esac
+
+if [ -z "${MVNW_USERNAME-}" ] && command -v wget >/dev/null; then
+ verbose "Found wget ... using wget"
+ wget ${__MVNW_QUIET_WGET:+"$__MVNW_QUIET_WGET"} "$distributionUrl" -O "$TMP_DOWNLOAD_DIR/$distributionUrlName" || die "wget: Failed to fetch $distributionUrl"
+elif [ -z "${MVNW_USERNAME-}" ] && command -v curl >/dev/null; then
+ verbose "Found curl ... using curl"
+ curl ${__MVNW_QUIET_CURL:+"$__MVNW_QUIET_CURL"} -f -L -o "$TMP_DOWNLOAD_DIR/$distributionUrlName" "$distributionUrl" || die "curl: Failed to fetch $distributionUrl"
+elif set_java_home; then
+ verbose "Falling back to use Java to download"
+ javaSource="$TMP_DOWNLOAD_DIR/Downloader.java"
+ targetZip="$TMP_DOWNLOAD_DIR/$distributionUrlName"
+ cat >"$javaSource" <<-END
+ public class Downloader extends java.net.Authenticator
+ {
+ protected java.net.PasswordAuthentication getPasswordAuthentication()
+ {
+ return new java.net.PasswordAuthentication( System.getenv( "MVNW_USERNAME" ), System.getenv( "MVNW_PASSWORD" ).toCharArray() );
+ }
+ public static void main( String[] args ) throws Exception
+ {
+ setDefault( new Downloader() );
+ java.nio.file.Files.copy( java.net.URI.create( args[0] ).toURL().openStream(), java.nio.file.Paths.get( args[1] ).toAbsolutePath().normalize() );
+ }
+ }
+ END
+ # For Cygwin/MinGW, switch paths to Windows format before running javac and java
+ verbose " - Compiling Downloader.java ..."
+ "$(native_path "$JAVACCMD")" "$(native_path "$javaSource")" || die "Failed to compile Downloader.java"
+ verbose " - Running Downloader.java ..."
+ "$(native_path "$JAVACMD")" -cp "$(native_path "$TMP_DOWNLOAD_DIR")" Downloader "$distributionUrl" "$(native_path "$targetZip")"
+fi
+
+# If specified, validate the SHA-256 sum of the Maven distribution zip file
+if [ -n "${distributionSha256Sum-}" ]; then
+ distributionSha256Result=false
+ if [ "$MVN_CMD" = mvnd.sh ]; then
+ echo "Checksum validation is not supported for maven-mvnd." >&2
+ echo "Please disable validation by removing 'distributionSha256Sum' from your maven-wrapper.properties." >&2
+ exit 1
+ elif command -v sha256sum >/dev/null; then
+ if echo "$distributionSha256Sum $TMP_DOWNLOAD_DIR/$distributionUrlName" | sha256sum -c >/dev/null 2>&1; then
+ distributionSha256Result=true
+ fi
+ elif command -v shasum >/dev/null; then
+ if echo "$distributionSha256Sum $TMP_DOWNLOAD_DIR/$distributionUrlName" | shasum -a 256 -c >/dev/null 2>&1; then
+ distributionSha256Result=true
+ fi
+ else
+ echo "Checksum validation was requested but neither 'sha256sum' or 'shasum' are available." >&2
+ echo "Please install either command, or disable validation by removing 'distributionSha256Sum' from your maven-wrapper.properties." >&2
+ exit 1
+ fi
+ if [ $distributionSha256Result = false ]; then
+ echo "Error: Failed to validate Maven distribution SHA-256, your Maven distribution might be compromised." >&2
+ echo "If you updated your Maven version, you need to update the specified distributionSha256Sum property." >&2
+ exit 1
+ fi
+fi
+
+# unzip and move
+if command -v unzip >/dev/null; then
+ unzip ${__MVNW_QUIET_UNZIP:+"$__MVNW_QUIET_UNZIP"} "$TMP_DOWNLOAD_DIR/$distributionUrlName" -d "$TMP_DOWNLOAD_DIR" || die "failed to unzip"
+else
+ tar xzf${__MVNW_QUIET_TAR:+"$__MVNW_QUIET_TAR"} "$TMP_DOWNLOAD_DIR/$distributionUrlName" -C "$TMP_DOWNLOAD_DIR" || die "failed to untar"
+fi
+printf %s\\n "$distributionUrl" >"$TMP_DOWNLOAD_DIR/$distributionUrlNameMain/mvnw.url"
+mv -- "$TMP_DOWNLOAD_DIR/$distributionUrlNameMain" "$MAVEN_HOME" || [ -d "$MAVEN_HOME" ] || die "fail to move MAVEN_HOME"
+
+clean || :
+exec_maven "$@"
diff --git a/packages/java/metrics-core/mvnw.cmd b/packages/java/metrics-core/mvnw.cmd
new file mode 100644
index 000000000..b150b91ed
--- /dev/null
+++ b/packages/java/metrics-core/mvnw.cmd
@@ -0,0 +1,149 @@
+<# : batch portion
+@REM ----------------------------------------------------------------------------
+@REM Licensed to the Apache Software Foundation (ASF) under one
+@REM or more contributor license agreements. See the NOTICE file
+@REM distributed with this work for additional information
+@REM regarding copyright ownership. The ASF licenses this file
+@REM to you under the Apache License, Version 2.0 (the
+@REM "License"); you may not use this file except in compliance
+@REM with the License. You may obtain a copy of the License at
+@REM
+@REM http://www.apache.org/licenses/LICENSE-2.0
+@REM
+@REM Unless required by applicable law or agreed to in writing,
+@REM software distributed under the License is distributed on an
+@REM "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+@REM KIND, either express or implied. See the License for the
+@REM specific language governing permissions and limitations
+@REM under the License.
+@REM ----------------------------------------------------------------------------
+
+@REM ----------------------------------------------------------------------------
+@REM Apache Maven Wrapper startup batch script, version 3.3.2
+@REM
+@REM Optional ENV vars
+@REM MVNW_REPOURL - repo url base for downloading maven distribution
+@REM MVNW_USERNAME/MVNW_PASSWORD - user and password for downloading maven
+@REM MVNW_VERBOSE - true: enable verbose log; others: silence the output
+@REM ----------------------------------------------------------------------------
+
+@IF "%__MVNW_ARG0_NAME__%"=="" (SET __MVNW_ARG0_NAME__=%~nx0)
+@SET __MVNW_CMD__=
+@SET __MVNW_ERROR__=
+@SET __MVNW_PSMODULEP_SAVE=%PSModulePath%
+@SET PSModulePath=
+@FOR /F "usebackq tokens=1* delims==" %%A IN (`powershell -noprofile "& {$scriptDir='%~dp0'; $script='%__MVNW_ARG0_NAME__%'; icm -ScriptBlock ([Scriptblock]::Create((Get-Content -Raw '%~f0'))) -NoNewScope}"`) DO @(
+ IF "%%A"=="MVN_CMD" (set __MVNW_CMD__=%%B) ELSE IF "%%B"=="" (echo %%A) ELSE (echo %%A=%%B)
+)
+@SET PSModulePath=%__MVNW_PSMODULEP_SAVE%
+@SET __MVNW_PSMODULEP_SAVE=
+@SET __MVNW_ARG0_NAME__=
+@SET MVNW_USERNAME=
+@SET MVNW_PASSWORD=
+@IF NOT "%__MVNW_CMD__%"=="" (%__MVNW_CMD__% %*)
+@echo Cannot start maven from wrapper >&2 && exit /b 1
+@GOTO :EOF
+: end batch / begin powershell #>
+
+$ErrorActionPreference = "Stop"
+if ($env:MVNW_VERBOSE -eq "true") {
+ $VerbosePreference = "Continue"
+}
+
+# calculate distributionUrl, requires .mvn/wrapper/maven-wrapper.properties
+$distributionUrl = (Get-Content -Raw "$scriptDir/.mvn/wrapper/maven-wrapper.properties" | ConvertFrom-StringData).distributionUrl
+if (!$distributionUrl) {
+ Write-Error "cannot read distributionUrl property in $scriptDir/.mvn/wrapper/maven-wrapper.properties"
+}
+
+switch -wildcard -casesensitive ( $($distributionUrl -replace '^.*/','') ) {
+ "maven-mvnd-*" {
+ $USE_MVND = $true
+ $distributionUrl = $distributionUrl -replace '-bin\.[^.]*$',"-windows-amd64.zip"
+ $MVN_CMD = "mvnd.cmd"
+ break
+ }
+ default {
+ $USE_MVND = $false
+ $MVN_CMD = $script -replace '^mvnw','mvn'
+ break
+ }
+}
+
+# apply MVNW_REPOURL and calculate MAVEN_HOME
+# maven home pattern: ~/.m2/wrapper/dists/{apache-maven-,maven-mvnd--}/
+if ($env:MVNW_REPOURL) {
+ $MVNW_REPO_PATTERN = if ($USE_MVND) { "/org/apache/maven/" } else { "/maven/mvnd/" }
+ $distributionUrl = "$env:MVNW_REPOURL$MVNW_REPO_PATTERN$($distributionUrl -replace '^.*'+$MVNW_REPO_PATTERN,'')"
+}
+$distributionUrlName = $distributionUrl -replace '^.*/',''
+$distributionUrlNameMain = $distributionUrlName -replace '\.[^.]*$','' -replace '-bin$',''
+$MAVEN_HOME_PARENT = "$HOME/.m2/wrapper/dists/$distributionUrlNameMain"
+if ($env:MAVEN_USER_HOME) {
+ $MAVEN_HOME_PARENT = "$env:MAVEN_USER_HOME/wrapper/dists/$distributionUrlNameMain"
+}
+$MAVEN_HOME_NAME = ([System.Security.Cryptography.MD5]::Create().ComputeHash([byte[]][char[]]$distributionUrl) | ForEach-Object {$_.ToString("x2")}) -join ''
+$MAVEN_HOME = "$MAVEN_HOME_PARENT/$MAVEN_HOME_NAME"
+
+if (Test-Path -Path "$MAVEN_HOME" -PathType Container) {
+ Write-Verbose "found existing MAVEN_HOME at $MAVEN_HOME"
+ Write-Output "MVN_CMD=$MAVEN_HOME/bin/$MVN_CMD"
+ exit $?
+}
+
+if (! $distributionUrlNameMain -or ($distributionUrlName -eq $distributionUrlNameMain)) {
+ Write-Error "distributionUrl is not valid, must end with *-bin.zip, but found $distributionUrl"
+}
+
+# prepare tmp dir
+$TMP_DOWNLOAD_DIR_HOLDER = New-TemporaryFile
+$TMP_DOWNLOAD_DIR = New-Item -Itemtype Directory -Path "$TMP_DOWNLOAD_DIR_HOLDER.dir"
+$TMP_DOWNLOAD_DIR_HOLDER.Delete() | Out-Null
+trap {
+ if ($TMP_DOWNLOAD_DIR.Exists) {
+ try { Remove-Item $TMP_DOWNLOAD_DIR -Recurse -Force | Out-Null }
+ catch { Write-Warning "Cannot remove $TMP_DOWNLOAD_DIR" }
+ }
+}
+
+New-Item -Itemtype Directory -Path "$MAVEN_HOME_PARENT" -Force | Out-Null
+
+# Download and Install Apache Maven
+Write-Verbose "Couldn't find MAVEN_HOME, downloading and installing it ..."
+Write-Verbose "Downloading from: $distributionUrl"
+Write-Verbose "Downloading to: $TMP_DOWNLOAD_DIR/$distributionUrlName"
+
+$webclient = New-Object System.Net.WebClient
+if ($env:MVNW_USERNAME -and $env:MVNW_PASSWORD) {
+ $webclient.Credentials = New-Object System.Net.NetworkCredential($env:MVNW_USERNAME, $env:MVNW_PASSWORD)
+}
+[Net.ServicePointManager]::SecurityProtocol = [Net.SecurityProtocolType]::Tls12
+$webclient.DownloadFile($distributionUrl, "$TMP_DOWNLOAD_DIR/$distributionUrlName") | Out-Null
+
+# If specified, validate the SHA-256 sum of the Maven distribution zip file
+$distributionSha256Sum = (Get-Content -Raw "$scriptDir/.mvn/wrapper/maven-wrapper.properties" | ConvertFrom-StringData).distributionSha256Sum
+if ($distributionSha256Sum) {
+ if ($USE_MVND) {
+ Write-Error "Checksum validation is not supported for maven-mvnd. `nPlease disable validation by removing 'distributionSha256Sum' from your maven-wrapper.properties."
+ }
+ Import-Module $PSHOME\Modules\Microsoft.PowerShell.Utility -Function Get-FileHash
+ if ((Get-FileHash "$TMP_DOWNLOAD_DIR/$distributionUrlName" -Algorithm SHA256).Hash.ToLower() -ne $distributionSha256Sum) {
+ Write-Error "Error: Failed to validate Maven distribution SHA-256, your Maven distribution might be compromised. If you updated your Maven version, you need to update the specified distributionSha256Sum property."
+ }
+}
+
+# unzip and move
+Expand-Archive "$TMP_DOWNLOAD_DIR/$distributionUrlName" -DestinationPath "$TMP_DOWNLOAD_DIR" | Out-Null
+Rename-Item -Path "$TMP_DOWNLOAD_DIR/$distributionUrlNameMain" -NewName $MAVEN_HOME_NAME | Out-Null
+try {
+ Move-Item -Path "$TMP_DOWNLOAD_DIR/$MAVEN_HOME_NAME" -Destination $MAVEN_HOME_PARENT | Out-Null
+} catch {
+ if (! (Test-Path -Path "$MAVEN_HOME" -PathType Container)) {
+ Write-Error "fail to move MAVEN_HOME"
+ }
+} finally {
+ try { Remove-Item $TMP_DOWNLOAD_DIR -Recurse -Force | Out-Null }
+ catch { Write-Warning "Cannot remove $TMP_DOWNLOAD_DIR" }
+}
+
+Write-Output "MVN_CMD=$MAVEN_HOME/bin/$MVN_CMD"
diff --git a/packages/java/metrics-core/pom.xml b/packages/java/metrics-core/pom.xml
new file mode 100644
index 000000000..3040546f1
--- /dev/null
+++ b/packages/java/metrics-core/pom.xml
@@ -0,0 +1,181 @@
+
+
+
+
+ MIT License
+ https://opensource.org/licenses/MIT
+
+
+
+
+ readme
+ Readme
+ engineers@readme.io
+
+
+
+ https://github.com/readmeio/metrics-sdks
+ scm:git:git://github.com/readmeio/metrics-sdks.git
+ scm:git:ssh://git@github.com:readmeio/metrics-sdks.git
+ HEAD
+
+ https://github.com/readmeio/metrics-sdks
+
+ 4.0.0
+ com.readme
+ metrics-core
+ 0.1.0
+ Core library for Readme.io JVM related SDKs
+
+
+ 11
+ UTF-8
+ 1.18.34
+ 5.14.2
+ 5.11.4
+ 4.12.0
+
+
+
+
+ org.projectlombok
+ lombok
+ ${lombok.version}
+
+
+ com.squareup.okhttp3
+ okhttp
+ ${okhttp.version}
+
+
+
+ org.slf4j
+ slf4j-api
+ 1.7.36
+
+
+ com.fasterxml.jackson.core
+ jackson-annotations
+ 2.18.2
+
+
+ com.fasterxml.jackson.core
+ jackson-databind
+ 2.18.2
+
+
+
+
+
+ com.github.ben-manes.caffeine
+ caffeine
+ 3.1.8
+
+
+ org.json
+ json
+ 20230618
+
+
+
+
+ org.mockito
+ mockito-core
+ ${mockito-core.version}
+ test
+
+
+ org.mockito
+ mockito-junit-jupiter
+ ${mockito-core.version}
+ test
+
+
+ org.junit.jupiter
+ junit-jupiter-engine
+ ${junit.version}
+ test
+
+
+ com.squareup.okhttp3
+ mockwebserver
+ 4.10.0
+ test
+
+
+
+
+
+
+ org.apache.maven.plugins
+ maven-compiler-plugin
+ 3.13.0
+
+ ${maven.compiler.release}
+
+
+
+ org.apache.maven.plugins
+ maven-surefire-plugin
+ 3.1.2
+
+
+ org.sonatype.central
+ central-publishing-maven-plugin
+ 0.7.0
+ true
+
+ central
+
+
+
+ org.apache.maven.plugins
+ maven-source-plugin
+ 3.3.1
+
+
+ attach-sources
+
+ jar
+
+
+
+
+
+ org.apache.maven.plugins
+ maven-javadoc-plugin
+ 3.11.2
+
+
+ attach-javadocs
+
+ jar
+
+
+
+
+
+ org.apache.maven.plugins
+ maven-gpg-plugin
+ 3.2.7
+
+
+ sign-artifacts
+ deploy
+
+ sign
+
+
+
+
+
+
+
+ src/main/resources
+ true
+
+
+
+
\ No newline at end of file
diff --git a/packages/java/metrics-core/src/main/java/com/readme/core/config/CoreConfig.java b/packages/java/metrics-core/src/main/java/com/readme/core/config/CoreConfig.java
new file mode 100644
index 000000000..65d72c600
--- /dev/null
+++ b/packages/java/metrics-core/src/main/java/com/readme/core/config/CoreConfig.java
@@ -0,0 +1,16 @@
+package com.readme.core.config;
+
+import lombok.Builder;
+import lombok.Value;
+
+@Builder
+@Value
+public class CoreConfig {
+
+ String readmeAPIKey;
+
+ public CoreConfig(String readmeAPIKey) {
+ this.readmeAPIKey = readmeAPIKey;
+ }
+
+}
\ No newline at end of file
diff --git a/packages/java/metrics-core/src/main/java/com/readme/core/config/ReadmeApiConfig.java b/packages/java/metrics-core/src/main/java/com/readme/core/config/ReadmeApiConfig.java
new file mode 100644
index 000000000..5b1082639
--- /dev/null
+++ b/packages/java/metrics-core/src/main/java/com/readme/core/config/ReadmeApiConfig.java
@@ -0,0 +1,8 @@
+package com.readme.core.config;
+
+public class ReadmeApiConfig {
+
+ public static final String README_METRICS_URL = "https://metrics.readme.io/v1/request";
+ public static final String README_API_URL = "https://dash.readme.com/api";
+
+}
diff --git a/packages/java/metrics-core/src/main/java/com/readme/core/dataextraction/ApiKeyMasker.java b/packages/java/metrics-core/src/main/java/com/readme/core/dataextraction/ApiKeyMasker.java
new file mode 100644
index 000000000..06474d383
--- /dev/null
+++ b/packages/java/metrics-core/src/main/java/com/readme/core/dataextraction/ApiKeyMasker.java
@@ -0,0 +1,26 @@
+package com.readme.core.dataextraction;
+
+import java.nio.charset.StandardCharsets;
+import java.security.MessageDigest;
+import java.security.NoSuchAlgorithmException;
+import java.util.Base64;
+
+public class ApiKeyMasker {
+
+ public static String mask(String apiKey) {
+ try {
+ String base64Hash = Base64.getEncoder()
+ .encodeToString(MessageDigest
+ .getInstance("SHA-512")
+ .digest(apiKey.getBytes(StandardCharsets.UTF_8)));
+
+ String last4Digits = apiKey.substring(apiKey.length() - 4);
+ return "sha512-" + base64Hash + "?" + last4Digits;
+ } catch (NoSuchAlgorithmException e) {
+ throw new RuntimeException("SHA-512 algorithm not available", e);
+ } catch (StringIndexOutOfBoundsException e) {
+ throw new IllegalArgumentException("API key must be at least 4 characters long", e);
+ }
+ }
+
+}
\ No newline at end of file
diff --git a/packages/java/metrics-core/src/main/java/com/readme/core/dataextraction/DataPayloadAdapter.java b/packages/java/metrics-core/src/main/java/com/readme/core/dataextraction/DataPayloadAdapter.java
new file mode 100644
index 000000000..c9285a6e6
--- /dev/null
+++ b/packages/java/metrics-core/src/main/java/com/readme/core/dataextraction/DataPayloadAdapter.java
@@ -0,0 +1,33 @@
+package com.readme.core.dataextraction;
+
+import java.util.Map;
+
+/**
+ * Represents a generic payload abstraction that provides methods to interact
+ * with request and response data regardless of the underlying framework or implementation.
+ * This interface allows seamless handling of HTTP-related data, enabling the
+ * extraction of request and response headers, and bodies without tying the logic
+ * to a specific framework or API (e.g., Servlet API, Spring WebFlux, Ktor, etc.).
+ *
+ * Implementations of this interface should adapt their behavior based on the
+ * specific HTTP processing framework they represent, but the consumer of this
+ * interface does not need to be aware of these details.
+ *
+ */
+public interface DataPayloadAdapter {
+
+ String getRequestMethod();
+ String getRequestContentType();
+ Map getRequestHeaders();
+ String getRequestBody();
+ String getAddress();
+ String getProtocol();
+ String getUrl();
+ Map getRequestParameters();
+
+
+ Map getResponseHeaders();
+ String getResponseBody();
+ int getStatusCode();
+ String getStatusMessage();
+}
diff --git a/packages/java/metrics-core/src/main/java/com/readme/core/dataextraction/LogOptions.java b/packages/java/metrics-core/src/main/java/com/readme/core/dataextraction/LogOptions.java
new file mode 100644
index 000000000..0b36d5da1
--- /dev/null
+++ b/packages/java/metrics-core/src/main/java/com/readme/core/dataextraction/LogOptions.java
@@ -0,0 +1,49 @@
+package com.readme.core.dataextraction;
+
+
+import lombok.Builder;
+import lombok.Value;
+
+import java.util.List;
+
+@Value
+@Builder
+public class LogOptions {
+
+ /**
+ * An array of values to include in the incoming and outgoing headers, parameters and body;
+ * everything else will be redacted.
+ *
+ * If set, the denylist will be ignored.
+ */
+
+ List allowlist;
+
+ /**
+ * An array of values to redact from the incoming and outgoing headers, parameters and body.
+ */
+ List denylist;
+
+ /**
+ * If true, the logs will be marked as development logs.
+ */
+ boolean development;
+
+ /**
+ * If true, this will return the log details without waiting for a response from the Metrics
+ * servers.
+ */
+ boolean fireAndForget;
+
+ /**
+ * URL for your documentation site
+ */
+ String baseLogUrl;
+
+ /**
+ * Buffer size
+ */
+ @Builder.Default
+ int bufferLength = 1;
+
+}
\ No newline at end of file
diff --git a/packages/java/metrics-core/src/main/java/com/readme/core/dataextraction/payload/PayloadData.java b/packages/java/metrics-core/src/main/java/com/readme/core/dataextraction/payload/PayloadData.java
new file mode 100644
index 000000000..ff04c06d1
--- /dev/null
+++ b/packages/java/metrics-core/src/main/java/com/readme/core/dataextraction/payload/PayloadData.java
@@ -0,0 +1,38 @@
+package com.readme.core.dataextraction.payload;
+
+import com.readme.core.dataextraction.payload.requestresponse.ApiCallLogData;
+import com.readme.core.dataextraction.payload.user.UserData;
+import lombok.Builder;
+import lombok.Value;
+
+import java.util.Date;
+
+/**
+ * Represents the complete payload for logging an API interaction, combining user-specific data, detailed API request
+ * and response information, and precise timing metrics.
+ */
+@Builder
+@Value
+public class PayloadData {
+
+ /**
+ * Information identifying and describing the user making the API call.
+ */
+ UserData userData;
+
+ /**
+ * Detailed information about the API request and its corresponding response.
+ */
+ ApiCallLogData apiCallLogData;
+
+ /**
+ * Timestamp indicating the exact moment when the API request processing started.
+ */
+ Date requestStartedDateTime;
+
+ /**
+ * Timestamp indicating the exact moment when the API response processing completed.
+ */
+ Date responseEndDateTime;
+
+}
diff --git a/packages/java/metrics-core/src/main/java/com/readme/core/dataextraction/payload/requestresponse/ApiCallLogData.java b/packages/java/metrics-core/src/main/java/com/readme/core/dataextraction/payload/requestresponse/ApiCallLogData.java
new file mode 100644
index 000000000..9524ed675
--- /dev/null
+++ b/packages/java/metrics-core/src/main/java/com/readme/core/dataextraction/payload/requestresponse/ApiCallLogData.java
@@ -0,0 +1,25 @@
+package com.readme.core.dataextraction.payload.requestresponse;
+
+import lombok.Builder;
+import lombok.Value;
+
+/**
+ * Encapsulates the details of an API call for logging and monitoring purposes.
+ * This class serves as a wrapper for both the request and response data,
+ * providing a structured format for capturing and analyzing HTTP interactions.
+ */
+@Value
+@Builder
+public class ApiCallLogData {
+
+ /**
+ * The HTTP request data associated with the API call.
+ */
+ RequestData requestData;
+
+ /**
+ * The HTTP response data associated with the API call.
+ */
+ ResponseData responseData;
+
+}
diff --git a/packages/java/metrics-core/src/main/java/com/readme/core/dataextraction/payload/requestresponse/RequestData.java b/packages/java/metrics-core/src/main/java/com/readme/core/dataextraction/payload/requestresponse/RequestData.java
new file mode 100644
index 000000000..411905214
--- /dev/null
+++ b/packages/java/metrics-core/src/main/java/com/readme/core/dataextraction/payload/requestresponse/RequestData.java
@@ -0,0 +1,60 @@
+package com.readme.core.dataextraction.payload.requestresponse;
+
+import lombok.Builder;
+import lombok.Data;
+import lombok.Setter;
+import lombok.Value;
+
+import java.util.Map;
+
+/**
+ * Represents the details of an HTTP request captured for logging and analysis.
+ * This class encapsulates key request attributes such as headers, query parameters,
+ * request body, and metadata related to the request.
+ */
+@Data
+@Builder
+public class RequestData {
+
+ /**
+ * Request body prepared for logging
+ */
+ private String body;
+
+ /**
+ * The route path associated with the request (e.g., "/api/v1/resource").
+ */
+ private String routePath;
+
+ /**
+ * The remote IP address of the client making the request.
+ */
+ private String remoteAddress;
+
+ /**
+ * The protocol used in the request (e.g., "HTTP/1.1" or "HTTP/2").
+ */
+ private String protocol;
+
+ /**
+ * The full URL of the request, including query parameters.
+ */
+ private String url;
+
+ /**
+ * The HTTP method used in the request (e.g., "GET", "POST", "PUT").
+ */
+ private String method;
+
+ /**
+ * A map of HTTP headers included in the request.
+ */
+ private Map headers;
+
+ /**
+ * A map of query parameters extracted from the request URL.
+ */
+ private Map requestParameters;
+
+
+}
diff --git a/packages/java/metrics-core/src/main/java/com/readme/core/dataextraction/payload/requestresponse/RequestDataCollector.java b/packages/java/metrics-core/src/main/java/com/readme/core/dataextraction/payload/requestresponse/RequestDataCollector.java
new file mode 100644
index 000000000..19a92c17b
--- /dev/null
+++ b/packages/java/metrics-core/src/main/java/com/readme/core/dataextraction/payload/requestresponse/RequestDataCollector.java
@@ -0,0 +1,26 @@
+package com.readme.core.dataextraction.payload.requestresponse;
+
+/**
+ * A generic interface for collecting metrics from various frameworks or APIs.
+ *
+ * The {@link RequestDataCollector} interface is designed to provide a unified way
+ * to handle metric collection regardless of the underlying framework or protocol.
+ * By using generics, this interface can adapt to different data types or request
+ * payloads specific to a given environment.
+ *
+ *
+ * @param the type of the decorator over API or framework data transfer layer
+ * from which metrics will be collected.
+ * This could represent an HTTP request, payload, or any other
+ * structure relevant to the framework being used.
+ */
+public interface RequestDataCollector {
+
+ /**
+ * `collect` method is intended to be implemented to extract and process
+ * metrics data from the given input object. The exact implementation
+ * will vary depending on the framework or API used.
+ */
+ ApiCallLogData collect(T t);
+
+}
diff --git a/packages/java/metrics-core/src/main/java/com/readme/core/dataextraction/payload/requestresponse/ResponseData.java b/packages/java/metrics-core/src/main/java/com/readme/core/dataextraction/payload/requestresponse/ResponseData.java
new file mode 100644
index 000000000..1b476dc26
--- /dev/null
+++ b/packages/java/metrics-core/src/main/java/com/readme/core/dataextraction/payload/requestresponse/ResponseData.java
@@ -0,0 +1,38 @@
+package com.readme.core.dataextraction.payload.requestresponse;
+
+import lombok.Builder;
+import lombok.Value;
+
+import java.util.Map;
+
+/**
+ * Represents the details of an HTTP response captured for logging and analysis.
+ * This class encapsulates key response attributes such as status code, headers,
+ * response body, and status message.
+ */
+@Value
+@Builder
+public class ResponseData {
+
+ /**
+ * The response body sent back to the client.
+ */
+ String body;
+
+ /**
+ * A map of HTTP headers included in the response.
+ */
+ Map headers;
+
+ /**
+ * The HTTP status code of the response (e.g., 200 for OK, 404 for Not Found).
+ */
+ int statusCode;
+
+ /**
+ * The status message associated with the response (e.g., "OK", "Bad Request").
+ */
+ String statusMessage;
+
+
+}
diff --git a/packages/java/metrics-core/src/main/java/com/readme/core/dataextraction/payload/user/UserData.java b/packages/java/metrics-core/src/main/java/com/readme/core/dataextraction/payload/user/UserData.java
new file mode 100644
index 000000000..4684bf28d
--- /dev/null
+++ b/packages/java/metrics-core/src/main/java/com/readme/core/dataextraction/payload/user/UserData.java
@@ -0,0 +1,25 @@
+package com.readme.core.dataextraction.payload.user;
+
+import lombok.Builder;
+import lombok.Value;
+
+@Value
+@Builder
+public class UserData {
+ /**
+ * API Key used to make the request. Note that this is different from the `readmeAPIKey`
+ * and should be a value from your API that is unique to each of your users.
+ */
+ String apiKey;
+
+ /**
+ * Email of the user that is making the call
+ */
+ String email;
+
+ /**
+ * This is the user's display name in the API Metrics Dashboard.
+ */
+ String label;
+
+}
diff --git a/packages/java/metrics-core/src/main/java/com/readme/core/dataextraction/payload/user/UserDataCollector.java b/packages/java/metrics-core/src/main/java/com/readme/core/dataextraction/payload/user/UserDataCollector.java
new file mode 100644
index 000000000..4a3c69df8
--- /dev/null
+++ b/packages/java/metrics-core/src/main/java/com/readme/core/dataextraction/payload/user/UserDataCollector.java
@@ -0,0 +1,28 @@
+package com.readme.core.dataextraction.payload.user;
+
+/**
+ * Interface for extracting user-related data, such as API key, label, and email,
+ * from a given source object. The source object can vary depending on the framework or context,
+ * for example, it may contain {@code HttpServletRequest} and {@code HttpServletResponse}.
+ *
+ * This interface is designed to be flexible and reusable across different environments
+ * where user-related data needs to be collected from request/response objects.
+ *
+ * @param the type of the source object that contains the data to be extracted
+ */
+public interface UserDataCollector {
+
+ /**
+ * Extracts user-related data from the given source object.
+ *
+ * This method processes the provided source object to retrieve the user's API key,
+ * label, and email, encapsulating the results into a {@link UserData} model.
+ * The exact extraction logic depends on the implementation and the source type.
+ *
+ * @param t the payload object containing the sources to get user-related data.
+ *
+ * @return a {@link UserData} object containing the extracted user information
+ */
+ UserData collect(T t);
+
+}
diff --git a/packages/java/metrics-core/src/main/java/com/readme/core/dataextraction/payload/user/UserDataField.java b/packages/java/metrics-core/src/main/java/com/readme/core/dataextraction/payload/user/UserDataField.java
new file mode 100644
index 000000000..6bc361350
--- /dev/null
+++ b/packages/java/metrics-core/src/main/java/com/readme/core/dataextraction/payload/user/UserDataField.java
@@ -0,0 +1,18 @@
+package com.readme.core.dataextraction.payload.user;
+
+import lombok.Getter;
+
+@Getter
+public enum UserDataField {
+
+ API_KEY("api-key"),
+ EMAIL("email"),
+ LABEL("label");
+
+ private final String fieldName;
+
+ UserDataField(String fieldName) {
+ this.fieldName = fieldName;
+ }
+
+}
diff --git a/packages/java/metrics-core/src/main/java/com/readme/core/dataextraction/payload/user/UserDataSource.java b/packages/java/metrics-core/src/main/java/com/readme/core/dataextraction/payload/user/UserDataSource.java
new file mode 100644
index 000000000..f99957d43
--- /dev/null
+++ b/packages/java/metrics-core/src/main/java/com/readme/core/dataextraction/payload/user/UserDataSource.java
@@ -0,0 +1,18 @@
+package com.readme.core.dataextraction.payload.user;
+
+import lombok.Getter;
+
+@Getter
+public enum UserDataSource {
+
+ HEADER("header"),
+ BODY("jsonBody"),
+ JWT("jwt");
+
+ private final String value;
+
+ UserDataSource(String value) {
+ this.value = value;
+ }
+
+}
diff --git a/packages/java/metrics-core/src/main/java/com/readme/core/datatransfer/BaseLogUrlFetcher.java b/packages/java/metrics-core/src/main/java/com/readme/core/datatransfer/BaseLogUrlFetcher.java
new file mode 100644
index 000000000..8623cacfc
--- /dev/null
+++ b/packages/java/metrics-core/src/main/java/com/readme/core/datatransfer/BaseLogUrlFetcher.java
@@ -0,0 +1,70 @@
+package com.readme.core.datatransfer;
+
+import com.github.benmanes.caffeine.cache.Cache;
+import com.github.benmanes.caffeine.cache.Caffeine;
+import lombok.extern.slf4j.Slf4j;
+import java.util.concurrent.TimeUnit;
+import java.util.function.Function;
+
+import okhttp3.OkHttpClient;
+import okhttp3.Request;
+import okhttp3.Response;
+import org.json.JSONObject;
+
+import static com.readme.core.config.ReadmeApiConfig.README_API_URL;
+import static com.readme.core.datatransfer.ReadmeApiKeyEncoder.encode;
+
+@Slf4j
+public class BaseLogUrlFetcher {
+
+ protected static final int CACHE_EXPIRATION_DAYS = 1;
+ protected static final int REQUEST_TIMEOUT_SECONDS = 10;
+
+ private static final Cache baseUrlCache;
+ private static final OkHttpClient httpClient;
+
+ static {
+ baseUrlCache = Caffeine.newBuilder()
+ .expireAfterWrite(CACHE_EXPIRATION_DAYS, TimeUnit.DAYS)
+ .maximumSize(512)
+ .build();
+
+ httpClient = new OkHttpClient.Builder()
+ .callTimeout(REQUEST_TIMEOUT_SECONDS, TimeUnit.SECONDS)
+ .build();
+ }
+
+ public static String fetchBaseLogUrl(String readmeApiKey) {
+ return fetchBaseLogUrl(readmeApiKey, README_API_URL);
+ }
+
+ public static String fetchBaseLogUrl(String readmeApiKey, String apiUrl) {
+ Function fetcher = key -> baseUrlCache.get(key, readmeApiKey1
+ -> fetchBaseUrlFromApi(readmeApiKey1, apiUrl));
+ return fetcher.apply(readmeApiKey);
+ }
+
+ private static String fetchBaseUrlFromApi(String readmeApiKey, String apiUrl) {
+ String encodedApiKey = encode(readmeApiKey);
+
+ Request request = new Request.Builder()
+ .url(apiUrl + "/v1")
+ .header("Authorization", encodedApiKey)
+ .build();
+
+ try (Response response = httpClient.newCall(request).execute()) {
+ if (!response.isSuccessful()) {
+ log.error("Failed to fetch baseUrl: {} ", response.code() + " - " + response.message());
+ return "";
+ }
+
+ String responseBody = response.body() != null ? response.body().string() : "";
+ JSONObject jsonResponse = new JSONObject(responseBody);
+ return jsonResponse.optString("baseUrl", "");
+ } catch (Exception e) {
+ log.error("Error while fetching baseUrl: {}", e.getMessage());
+ return "";
+ }
+ }
+
+}
\ No newline at end of file
diff --git a/packages/java/metrics-core/src/main/java/com/readme/core/datatransfer/DataSender.java b/packages/java/metrics-core/src/main/java/com/readme/core/datatransfer/DataSender.java
new file mode 100644
index 000000000..8429b7e43
--- /dev/null
+++ b/packages/java/metrics-core/src/main/java/com/readme/core/datatransfer/DataSender.java
@@ -0,0 +1,12 @@
+package com.readme.core.datatransfer;
+
+import com.readme.core.dataextraction.LogOptions;
+
+import java.util.List;
+
+public interface DataSender {
+
+
+ boolean send(List payloadData, LogOptions logOptions);
+
+}
diff --git a/packages/java/metrics-core/src/main/java/com/readme/core/datatransfer/HttpDataSender.java b/packages/java/metrics-core/src/main/java/com/readme/core/datatransfer/HttpDataSender.java
new file mode 100644
index 000000000..8ddc6049f
--- /dev/null
+++ b/packages/java/metrics-core/src/main/java/com/readme/core/datatransfer/HttpDataSender.java
@@ -0,0 +1,102 @@
+package com.readme.core.datatransfer;
+
+import com.fasterxml.jackson.core.JsonProcessingException;
+import com.fasterxml.jackson.databind.ObjectMapper;
+import com.readme.core.config.CoreConfig;
+import com.readme.core.dataextraction.LogOptions;
+import lombok.extern.slf4j.Slf4j;
+import okhttp3.*;
+
+import java.io.IOException;
+import java.util.List;
+
+import static com.readme.core.config.ReadmeApiConfig.README_METRICS_URL;
+import static com.readme.core.datatransfer.BaseLogUrlFetcher.fetchBaseLogUrl;
+import static com.readme.core.datatransfer.ReadmeApiKeyEncoder.encode;
+
+/**
+ * Implementation of the {@link DataSender} interface that sends metrics data to a remote server
+ * over HTTP using the OkHttp library. It is responsible for creating and executing HTTP POST requests
+ * to the specified metrics endpoint. It prepares the request payload, adds necessary headers (e.g.,
+ * authentication and content type), and handles the response from the server.
+ *
+ * The default endpoint for sending metrics is {@code https://metrics.readme.io/v1/request}.
+ *
+ */
+
+@Slf4j
+public class HttpDataSender implements DataSender {
+
+ public static final String APPLICATION_JSON_TYPE = "application/json";
+
+ private final OkHttpClient client;
+ private final CoreConfig coreConfig;
+
+ public HttpDataSender(OkHttpClient client, CoreConfig coreConfig) {
+ this.client = client;
+ this.coreConfig = coreConfig;
+ }
+
+ @Override
+ public boolean send(List payloadData, LogOptions logOptions) {
+ if (payloadData != null) {
+ String encodedReadmeApiKey = encode(coreConfig.getReadmeAPIKey());
+ Response response = null;
+ try {
+ Request request = createRequest(payloadData, logOptions, encodedReadmeApiKey);
+ if(logOptions.isFireAndForget()){
+ makeAsyncRequest(request);
+ return true;
+ }
+ response = client.newCall(request).execute();
+ return response.isSuccessful();
+ } catch (JsonProcessingException e) {
+ log.error("Error while building outgoing payload: ", e);
+ } catch (Exception e) {
+ log.error("Error while sending collected data: ", e);
+ } finally {
+ if (response != null) {
+ response.close();
+ }
+ }
+ }
+ return false;
+ }
+
+ private void makeAsyncRequest(Request request) {
+
+ client.newCall(request).enqueue(new Callback() {
+ @Override
+ public void onFailure(Call call, IOException e) {
+ log.error("Error while sending outgoing payload: ", e);
+ }
+
+ @Override
+ public void onResponse(Call call, Response response) throws IOException {
+ response.close();
+ }
+ });
+ }
+
+ private static Request createRequest(List payloadData, LogOptions logOptions, String encodedReadmeApiKey) throws JsonProcessingException {
+ ObjectMapper objectMapper = new ObjectMapper();
+ String outgoingLogBody = objectMapper.writeValueAsString(payloadData);
+ RequestBody body = RequestBody
+ .create(outgoingLogBody, MediaType.get(APPLICATION_JSON_TYPE));
+
+ String baseLogUrl = logOptions.getBaseLogUrl() != null ? logOptions.getBaseLogUrl() : fetchBaseLogUrl(encodedReadmeApiKey);
+
+
+ return new Request.Builder()
+ .url(README_METRICS_URL)
+ .header("Accept", APPLICATION_JSON_TYPE)
+ .header("Content-Type", APPLICATION_JSON_TYPE)
+ .header("Authorization", encodedReadmeApiKey)
+ .header("x-documentation-url", baseLogUrl)
+ .method("POST", body)
+ .build();
+ }
+
+
+
+}
diff --git a/packages/java/metrics-core/src/main/java/com/readme/core/datatransfer/OutgoingLogBody.java b/packages/java/metrics-core/src/main/java/com/readme/core/datatransfer/OutgoingLogBody.java
new file mode 100644
index 000000000..2d4175280
--- /dev/null
+++ b/packages/java/metrics-core/src/main/java/com/readme/core/datatransfer/OutgoingLogBody.java
@@ -0,0 +1,42 @@
+package com.readme.core.datatransfer;
+
+import com.readme.core.datatransfer.har.Group;
+import com.readme.core.datatransfer.har.Har;
+import lombok.Builder;
+import lombok.Value;
+
+import java.util.UUID;
+
+@Value
+@Builder
+public class OutgoingLogBody {
+
+ UUID id;
+ int version;
+ String clientIPAddress;
+ boolean development;
+ Group group;
+
+ /**
+ * This field represents a HAR (HTTP Archive) request structure.
+ *
+ * The HAR model used here, along with all related fields and classes in its hierarchy,
+ * has been custom-implemented. The decision to use custom models instead of relying
+ * on third-party libraries was made due to the following reasons:
+ *
+ * 1. Some existing HAR libraries either do not support quite modern Java versions (e.g., Java 17-)
+ * or lack active maintenance, which introduces compatibility challenges.
+ *
+ * 2. The older versions of those libraries contain security vulnerabilities in their transitive
+ * dependencies, which could pose risks if included in the project.
+ *
+ * 3. Third-party HAR libraries often bring in unnecessary dependencies, increasing the
+ * overall size and complexity of the project and introducing undesired external ties.
+ * However, all we need is to have only models (POJO) classes
+ *
+ * By using custom implementations, this project ensures compatibility, security, and
+ * minimal dependencies while adhering to the HAR specification.
+ */
+ Har request;
+
+}
\ No newline at end of file
diff --git a/packages/java/metrics-core/src/main/java/com/readme/core/datatransfer/OutgoingLogBodyConstructor.java b/packages/java/metrics-core/src/main/java/com/readme/core/datatransfer/OutgoingLogBodyConstructor.java
new file mode 100644
index 000000000..86adc475a
--- /dev/null
+++ b/packages/java/metrics-core/src/main/java/com/readme/core/datatransfer/OutgoingLogBodyConstructor.java
@@ -0,0 +1,291 @@
+package com.readme.core.datatransfer;
+
+import com.fasterxml.jackson.databind.JsonNode;
+import com.fasterxml.jackson.databind.ObjectMapper;
+import com.fasterxml.jackson.databind.node.ObjectNode;
+import com.readme.core.dataextraction.LogOptions;
+import com.readme.core.dataextraction.payload.PayloadData;
+import com.readme.core.dataextraction.payload.requestresponse.RequestData;
+import com.readme.core.dataextraction.payload.requestresponse.ResponseData;
+import com.readme.core.dataextraction.payload.user.UserData;
+import com.readme.core.datatransfer.har.*;
+import lombok.extern.slf4j.Slf4j;
+
+import java.net.URI;
+import java.net.URISyntaxException;
+import java.util.*;
+import java.util.stream.Collectors;
+
+import static com.readme.core.dataextraction.ApiKeyMasker.mask;
+
+@Slf4j
+public class OutgoingLogBodyConstructor {
+
+ public OutgoingLogBody construct(
+ PayloadData payloadData,
+ LogOptions logOptions
+ ) {
+ UserData userData = payloadData.getUserData();
+ RequestData requestData = payloadData.getApiCallLogData().getRequestData();
+ ResponseData responseData = payloadData.getApiCallLogData().getResponseData();
+
+ filterDataByLogOptions(logOptions, requestData);
+
+ HarEntry harEntry = assembleHarEntry(payloadData, logOptions, requestData, responseData);
+ HarLog harLog = assembleHarLog(harEntry);
+ Group group = assembleGroup(userData);
+
+ Har har = new Har(harLog);
+ return OutgoingLogBody.builder()
+ .id(UUID.randomUUID())
+ .version(3)
+ .clientIPAddress(requestData.getRemoteAddress())
+ .development(logOptions.isDevelopment())
+ .group(group)
+ .request(har)
+ .build();
+
+ }
+
+ private static Group assembleGroup(UserData userData) {
+ String maskedApiKey = mask(userData.getApiKey());
+ return Group.builder()
+ .id(maskedApiKey)
+ .label(userData.getLabel())
+ .email(userData.getEmail())
+ .build();
+ }
+
+ private HarEntry assembleHarEntry(PayloadData payloadData, LogOptions logOptions, RequestData requestData, ResponseData responseData) {
+ int serverTime = getServerTime(payloadData);
+
+ return HarEntry.builder()
+ .pageRef(requestData.getRoutePath() != null ? requestData.getRoutePath()
+ : constructUrl(requestData.getUrl(), requestData.getHeaders().get("host"), requestData.getProtocol()))
+ .startedDateTime(payloadData.getRequestStartedDateTime())
+ .time(serverTime)
+ .request(processRequest(requestData, logOptions))
+ .response(processResponse(responseData, logOptions))
+ .cache(HarCache.builder().build())
+ .timings(HarTiming.builder()
+ .waitTime(0)
+ .receive(serverTime)
+ .build())
+ .build();
+ }
+
+ private static HarLog assembleHarLog(HarEntry harEntry) {
+ String systemInformation = new StringBuilder(System.getProperty("os.arch"))
+ .append("-")
+ .append(System.getProperty("os.name"))
+ .append(System.getProperty("os.version"))
+ .append("/")
+ .append(System.getProperty("java.version"))
+ .toString();
+
+
+ HarCreatorBrowser harCreatorBrowser = HarCreatorBrowser.builder()
+ .name("readme-metrics (java)")
+ .version(SdkVersionUtil.getVersion())
+ .comment(systemInformation)
+ .build();
+
+ return HarLog.builder()
+ .version("1.2")
+ .creator(harCreatorBrowser)
+ .entries(Collections.singletonList(harEntry))
+ .build();
+ }
+
+ private HarRequest processRequest(RequestData requestData, LogOptions logOptions) {
+ Map headers = requestData.getHeaders();
+ String requestBody = requestData.getBody();
+ String protocol = requestData.getProtocol();
+
+ String requestParams = getRequestParametersAsString(requestData.getRequestParameters());
+ List harQueryParameterList = getHarQueryParameterList(requestData.getRequestParameters());
+
+ HarRequest.HarRequestBuilder harRequestBuilder = HarRequest.builder()
+ .httpVersion(protocol)
+ .method(HttpMethod.valueOf(requestData.getMethod()))
+ .url(requestData.getUrl() + "?" + requestParams)
+ .queryString(harQueryParameterList)
+ .headers(convertHeaders(headers));
+
+ if(!requestData.getMethod().equals(HttpMethod.GET.name())) {
+ if (requestBody != null) {
+ HarPostData harPostData = assembleHarPostData(requestBody, headers.get("content-type"));
+ harRequestBuilder.postData(harPostData);
+ }
+ }
+
+ return harRequestBuilder.build();
+ }
+
+ private List getHarQueryParameterList(Map requestParameters) {
+ return requestParameters.entrySet().stream()
+ .map(entry -> HarQueryParam.builder()
+ .name(entry.getKey())
+ .value(entry.getValue())
+ .build())
+ .collect(Collectors.toList());
+ }
+
+ private static String getRequestParametersAsString(Map requestParameters) {
+ return requestParameters.entrySet()
+ .stream()
+ .map(entry -> entry.getKey() + "=" + (entry.getValue() != null ? entry.getValue() : ""))
+ .collect(Collectors.joining("&"));
+ }
+
+ private HarPostData assembleHarPostData(String body, String mimeType) {
+ return HarPostData.builder()
+ .mimeType(mimeType)
+ .text(body)
+ .build();
+ }
+
+ private HarResponse processResponse(ResponseData responseData, LogOptions logOptions) {
+ Map headers = responseData.getHeaders();
+ String body = responseData.getBody();
+
+ String contentType = headers.get("content-type");
+ String contentLength = headers.get("content-length");
+ HarContent content = HarContent.builder()
+ .mimeType(contentType)
+ .text(body)
+ .size(contentLength != null ? Long.parseLong(contentLength) : body.length())
+ .build();
+ return HarResponse.builder()
+ .status(responseData.getStatusCode())
+ .statusText(responseData.getStatusMessage())
+ .headers(convertHeaders(headers))
+ .content(content)
+ .build();
+ }
+
+
+ private List convertHeaders(Map headers) {
+ return headers.entrySet().stream()
+ .map(entry ->
+ HarHeader.builder()
+ .name(entry.getKey())
+ .value(entry.getValue()).
+ build())
+ .collect(Collectors.toList());
+ }
+
+ private String constructUrl(String url, String host, String proto) {
+ try {
+ return new URI(proto, host, url, null).toString();
+ } catch (URISyntaxException e) {
+ return "";
+ }
+ }
+
+ private static int getServerTime(PayloadData payloadData) {
+ return (int) (payloadData.getResponseEndDateTime().getTime() - payloadData.getRequestStartedDateTime().getTime());
+ }
+
+
+
+ public void filterDataByLogOptions(LogOptions options, RequestData req) {
+ List denylist = options.getDenylist();
+ List allowlist = options.getAllowlist();
+ ObjectMapper mapper = new ObjectMapper();
+
+ String mimeType = parseContentType(req.getHeaders().get("content-type"));
+ String requestBody = req.getBody();
+
+ if(mimeType.equalsIgnoreCase("application/json")) {
+ try {
+ JsonNode requestBodyNode = mapper.readTree(req.getBody());
+ if (denylist != null && !denylist.isEmpty()) {
+ requestBody = applyJsonBodyDenyList(requestBodyNode, denylist).toString();
+ req.setHeaders(applyHeadersDenyList(req.getHeaders(), denylist));
+ }
+ if (allowlist != null && !allowlist.isEmpty() && denylist == null) {
+ requestBody = applyJsonBodyAllowList(requestBodyNode, allowlist).toString();
+ req.setHeaders(applyHeadersAllowList(req.getHeaders(), allowlist));
+ }
+ } catch (Exception e) {
+ log.error("Error parsing request body", e);
+ }
+ } else if (mimeType.equalsIgnoreCase("application/x-www-form-urlencoded")) {
+ if (denylist != null && !denylist.isEmpty()) {
+ requestBody = applyFormUrlEncodedDenyList(req.getBody(), denylist);
+ req.setHeaders(applyHeadersDenyList(req.getHeaders(), denylist));
+ }
+ if (allowlist != null && !allowlist.isEmpty() && denylist == null) {
+ requestBody = applyFormUrlEncodedAllowList(req.getBody(), allowlist);
+ req.setHeaders(applyHeadersAllowList(req.getHeaders(), allowlist));
+ }
+ }
+ req.setBody(requestBody);
+ }
+
+ private Map applyHeadersDenyList(Map headers, List denylist) {
+ return headers.entrySet().stream()
+ .filter(entry -> !denylist.contains(entry.getKey()))
+ .collect(Collectors.toMap(Map.Entry::getKey, Map.Entry::getValue));
+ }
+
+ private Map applyHeadersAllowList(Map headers, List allowList) {
+ return headers.entrySet().stream()
+ .filter(entry -> {
+ return allowList.contains(entry.getKey()) ||
+ entry.getKey().equalsIgnoreCase("content-type");
+ })
+ .collect(Collectors.toMap(Map.Entry::getKey, Map.Entry::getValue));
+ }
+
+ private String parseContentType(String contentTypeHeader) {
+ if (contentTypeHeader == null || contentTypeHeader.isEmpty()) {
+ return "";
+ }
+ return contentTypeHeader.split(";")[0];
+ }
+
+ private ObjectNode applyJsonBodyDenyList(JsonNode json, List deniedPaths) {
+ ObjectNode newJson = json.deepCopy();
+ deniedPaths.forEach(path -> {
+ if (newJson.has(path)) {
+ newJson.put(path, "[REDACTED]");
+ }
+ });
+ return newJson;
+ }
+
+ private ObjectNode applyJsonBodyAllowList(JsonNode obj, List allowedPaths) {
+ ObjectMapper mapper = new ObjectMapper();
+ ObjectNode newObj = mapper.createObjectNode();
+ allowedPaths.forEach(path -> {
+ if (obj.has(path)) {
+ newObj.set(path, obj.get(path));
+ }
+ });
+ return newObj;
+ }
+
+ private String applyFormUrlEncodedDenyList(String body, List deniedKeys) {
+ return Arrays.stream(body.split("&"))
+ .map(param -> {
+ String[] keyValue = param.split("=");
+ if (keyValue.length == 2 && deniedKeys.contains(keyValue[0])) {
+ return keyValue[0] + "=[REDACTED]";
+ }
+ return param;
+ })
+ .collect(Collectors.joining("&"));
+ }
+
+ private String applyFormUrlEncodedAllowList(String body, List allowedKeys) {
+ return Arrays.stream(body.split("&"))
+ .filter(param -> {
+ String[] keyValue = param.split("=");
+ return keyValue.length == 2 && allowedKeys.contains(keyValue[0]);
+ })
+ .collect(Collectors.joining("&"));
+ }
+
+}
\ No newline at end of file
diff --git a/packages/java/metrics-core/src/main/java/com/readme/core/datatransfer/PayloadDataDispatcher.java b/packages/java/metrics-core/src/main/java/com/readme/core/datatransfer/PayloadDataDispatcher.java
new file mode 100644
index 000000000..c7ed1b832
--- /dev/null
+++ b/packages/java/metrics-core/src/main/java/com/readme/core/datatransfer/PayloadDataDispatcher.java
@@ -0,0 +1,43 @@
+package com.readme.core.datatransfer;
+
+import com.readme.core.dataextraction.LogOptions;
+import com.readme.core.dataextraction.payload.PayloadData;
+import lombok.extern.slf4j.Slf4j;
+
+import java.util.List;
+import java.util.concurrent.BlockingQueue;
+import java.util.concurrent.LinkedBlockingQueue;
+
+@Slf4j
+public class PayloadDataDispatcher {
+
+ private DataSender dataSender;
+
+ private OutgoingLogBodyConstructor payloadConstructor;
+
+ private final BlockingQueue buffer;
+
+ public PayloadDataDispatcher(DataSender dataSender, OutgoingLogBodyConstructor payloadConstructor) {
+ this.buffer = new LinkedBlockingQueue<>();
+ this.dataSender = dataSender;
+ this.payloadConstructor = payloadConstructor;
+ }
+
+ public void dispatch(PayloadData payloadData, LogOptions logOptions) {
+ try {
+ OutgoingLogBody outgoingLogBody = payloadConstructor.construct(payloadData, logOptions);
+ buffer.add(outgoingLogBody);
+ if (buffer.size() >= logOptions.getBufferLength()) {
+ List outgoingLogBodies = List.copyOf(buffer);
+ if (dataSender.send(outgoingLogBodies, logOptions)) {
+ buffer.clear();
+ } else {
+ log.error("Failed to send outgoing log body");
+ }
+ }
+ } catch (Exception e) {
+ log.error("Error occurred on data dispatch phase: {}", e.getMessage());
+ }
+ }
+
+}
diff --git a/packages/java/metrics-core/src/main/java/com/readme/core/datatransfer/ReadmeApiKeyEncoder.java b/packages/java/metrics-core/src/main/java/com/readme/core/datatransfer/ReadmeApiKeyEncoder.java
new file mode 100644
index 000000000..7ca4cb8b9
--- /dev/null
+++ b/packages/java/metrics-core/src/main/java/com/readme/core/datatransfer/ReadmeApiKeyEncoder.java
@@ -0,0 +1,13 @@
+package com.readme.core.datatransfer;
+
+import java.util.Base64;
+
+public class ReadmeApiKeyEncoder {
+
+ public static String encode(String readmeApiKey) {
+ String preparedReadmeAPIKey = readmeApiKey + ":";
+ String encodedReadmeAPIKey = Base64.getEncoder().encodeToString(preparedReadmeAPIKey.getBytes());
+ return "Basic " + encodedReadmeAPIKey;
+ }
+
+}
diff --git a/packages/java/metrics-core/src/main/java/com/readme/core/datatransfer/SdkVersionUtil.java b/packages/java/metrics-core/src/main/java/com/readme/core/datatransfer/SdkVersionUtil.java
new file mode 100644
index 000000000..254bdcd62
--- /dev/null
+++ b/packages/java/metrics-core/src/main/java/com/readme/core/datatransfer/SdkVersionUtil.java
@@ -0,0 +1,20 @@
+package com.readme.core.datatransfer;
+
+import lombok.extern.slf4j.Slf4j;
+
+import java.io.IOException;
+import java.io.InputStream;
+import java.util.Properties;
+
+public class SdkVersionUtil {
+ public static String getVersion() {
+ try (InputStream is = SdkVersionUtil.class.getClassLoader()
+ .getResourceAsStream("version.properties")) {
+ Properties properties = new Properties();
+ properties.load(is);
+ return properties.getProperty("version");
+ } catch (IOException e) {
+ return "UNKNOWN";
+ }
+ }
+}
\ No newline at end of file
diff --git a/packages/java/metrics-core/src/main/java/com/readme/core/datatransfer/har/Group.java b/packages/java/metrics-core/src/main/java/com/readme/core/datatransfer/har/Group.java
new file mode 100644
index 000000000..f97c53217
--- /dev/null
+++ b/packages/java/metrics-core/src/main/java/com/readme/core/datatransfer/har/Group.java
@@ -0,0 +1,12 @@
+package com.readme.core.datatransfer.har;
+
+import lombok.Builder;
+import lombok.Value;
+
+@Value
+@Builder
+public class Group {
+ String id;
+ String label;
+ String email;
+}
\ No newline at end of file
diff --git a/packages/java/metrics-core/src/main/java/com/readme/core/datatransfer/har/Har.java b/packages/java/metrics-core/src/main/java/com/readme/core/datatransfer/har/Har.java
new file mode 100644
index 000000000..313389081
--- /dev/null
+++ b/packages/java/metrics-core/src/main/java/com/readme/core/datatransfer/har/Har.java
@@ -0,0 +1,37 @@
+package com.readme.core.datatransfer.har;
+
+import com.fasterxml.jackson.annotation.JsonIgnoreProperties;
+import com.fasterxml.jackson.annotation.JsonInclude;
+import lombok.AllArgsConstructor;
+import lombok.Value;
+
+
+/**
+ * This class represents a HAR (HTTP Archive) request structure.
+ *
+ * The HAR model used here, along with all related fields and classes in its hierarchy,
+ * has been custom-implemented. The decision to use custom models instead of relying
+ * on third-party libraries was made due to the following reasons:
+ *
+ * 1. Some existing HAR libraries either do not support quite modern Java versions (e.g., Java 17-)
+ * or lack active maintenance, which introduces compatibility challenges.
+ *
+ * 2. The older versions of those libraries contain security vulnerabilities in their transitive
+ * dependencies, which could pose risks if included in the project.
+ *
+ * 3. Third-party HAR libraries often bring in unnecessary dependencies, increasing the
+ * overall size and complexity of the project and introducing undesired external ties.
+ * However, all we need is to have only models (POJO) classes
+ *
+ * By using custom implementations, this project ensures compatibility, security, and
+ * minimal dependencies while adhering to the HAR specification.
+ */
+@JsonInclude(JsonInclude.Include.NON_NULL)
+@JsonIgnoreProperties(ignoreUnknown = true)
+@Value
+@AllArgsConstructor
+public class Har {
+
+ HarLog log;
+
+}
diff --git a/packages/java/metrics-core/src/main/java/com/readme/core/datatransfer/har/HarCache.java b/packages/java/metrics-core/src/main/java/com/readme/core/datatransfer/har/HarCache.java
new file mode 100644
index 000000000..c1876feb9
--- /dev/null
+++ b/packages/java/metrics-core/src/main/java/com/readme/core/datatransfer/har/HarCache.java
@@ -0,0 +1,65 @@
+package com.readme.core.datatransfer.har;
+
+import com.fasterxml.jackson.annotation.JsonAnyGetter;
+import com.fasterxml.jackson.annotation.JsonAnySetter;
+import com.fasterxml.jackson.annotation.JsonIgnoreProperties;
+import com.fasterxml.jackson.annotation.JsonInclude;
+import lombok.Builder;
+import lombok.Data;
+
+import java.util.Date;
+import java.util.HashMap;
+import java.util.Map;
+
+import static com.readme.core.datatransfer.har.MemoryOptimisationConstants.DEFAULT_MAP_INIT_CAPACITY;
+
+
+@JsonInclude(JsonInclude.Include.NON_NULL)
+@JsonIgnoreProperties(ignoreUnknown = true)
+@Data
+@Builder
+public class HarCache {
+
+ private HarCacheInfo beforeRequest;
+ private HarCacheInfo afterRequest;
+ private String comment;
+ private Map additional;
+
+ @JsonAnyGetter
+ public Map getAdditional() {
+ return additional;
+ }
+
+ @JsonAnySetter
+ public void setAdditionalField(String key, Object value) {
+ if (additional == null) {
+ additional = new HashMap<>(DEFAULT_MAP_INIT_CAPACITY);
+ }
+ this.additional.put(key, value);
+ }
+
+ @JsonInclude(JsonInclude.Include.NON_NULL)
+ @JsonIgnoreProperties(ignoreUnknown = true)
+ @Data
+ @Builder
+ public static final class HarCacheInfo {
+
+ private Date expires;
+ private Date lastAccess;
+ private String eTag;
+ private int hitCount;
+ private String comment;
+ private final Map additional = new HashMap<>();
+
+ @JsonAnyGetter
+ public Map getAdditional() {
+ return additional;
+ }
+
+ @JsonAnySetter
+ public void setAdditionalField(String key, Object value) {
+ this.additional.put(key, value);
+ }
+
+ }
+}
diff --git a/packages/java/metrics-core/src/main/java/com/readme/core/datatransfer/har/HarContent.java b/packages/java/metrics-core/src/main/java/com/readme/core/datatransfer/har/HarContent.java
new file mode 100644
index 000000000..60a1ea704
--- /dev/null
+++ b/packages/java/metrics-core/src/main/java/com/readme/core/datatransfer/har/HarContent.java
@@ -0,0 +1,42 @@
+package com.readme.core.datatransfer.har;
+
+import com.fasterxml.jackson.annotation.JsonAnyGetter;
+import com.fasterxml.jackson.annotation.JsonAnySetter;
+import com.fasterxml.jackson.annotation.JsonIgnoreProperties;
+import com.fasterxml.jackson.annotation.JsonInclude;
+import lombok.Builder;
+import lombok.Data;
+
+import java.util.HashMap;
+import java.util.Map;
+
+import static com.readme.core.datatransfer.har.MemoryOptimisationConstants.DEFAULT_MAP_INIT_CAPACITY;
+
+@JsonInclude(JsonInclude.Include.NON_NULL)
+@JsonIgnoreProperties(ignoreUnknown = true)
+@Builder
+@Data
+public class HarContent {
+
+ private long size;
+ private long compression;
+ private String mimeType;
+ private String text;
+ private String encoding;
+ private String comment;
+ private Map additional;
+
+ @JsonAnyGetter
+ public Map getAdditional() {
+ return additional;
+ }
+
+ @JsonAnySetter
+ public void setAdditionalField(String key, Object value) {
+ if (additional == null) {
+ additional = new HashMap<>(DEFAULT_MAP_INIT_CAPACITY);
+ }
+ this.additional.put(key, value);
+ }
+
+}
diff --git a/packages/java/metrics-core/src/main/java/com/readme/core/datatransfer/har/HarCookie.java b/packages/java/metrics-core/src/main/java/com/readme/core/datatransfer/har/HarCookie.java
new file mode 100644
index 000000000..96f78eeac
--- /dev/null
+++ b/packages/java/metrics-core/src/main/java/com/readme/core/datatransfer/har/HarCookie.java
@@ -0,0 +1,45 @@
+package com.readme.core.datatransfer.har;
+
+import com.fasterxml.jackson.annotation.JsonAnyGetter;
+import com.fasterxml.jackson.annotation.JsonAnySetter;
+import com.fasterxml.jackson.annotation.JsonIgnoreProperties;
+import com.fasterxml.jackson.annotation.JsonInclude;
+import lombok.Builder;
+import lombok.Data;
+
+import java.util.Date;
+import java.util.HashMap;
+import java.util.Map;
+
+import static com.readme.core.datatransfer.har.MemoryOptimisationConstants.DEFAULT_MAP_INIT_CAPACITY;
+
+@JsonInclude(JsonInclude.Include.NON_NULL)
+@JsonIgnoreProperties(ignoreUnknown = true)
+@Data
+@Builder
+public class HarCookie {
+
+ private String name;
+ private String value;
+ private String path;
+ private String domain;
+ private Date expires;
+ private Boolean httpOnly;
+ private Boolean secure;
+ private String comment;
+ private Map additional;
+
+ @JsonAnyGetter
+ public Map getAdditional() {
+ return additional;
+ }
+
+ @JsonAnySetter
+ public void setAdditionalField(String key, Object value) {
+ if (additional == null) {
+ additional = new HashMap<>(DEFAULT_MAP_INIT_CAPACITY);
+ }
+ this.additional.put(key, value);
+ }
+
+}
diff --git a/packages/java/metrics-core/src/main/java/com/readme/core/datatransfer/har/HarCreatorBrowser.java b/packages/java/metrics-core/src/main/java/com/readme/core/datatransfer/har/HarCreatorBrowser.java
new file mode 100644
index 000000000..1d33cbb26
--- /dev/null
+++ b/packages/java/metrics-core/src/main/java/com/readme/core/datatransfer/har/HarCreatorBrowser.java
@@ -0,0 +1,40 @@
+package com.readme.core.datatransfer.har;
+
+
+import com.fasterxml.jackson.annotation.JsonAnyGetter;
+import com.fasterxml.jackson.annotation.JsonAnySetter;
+import com.fasterxml.jackson.annotation.JsonIgnoreProperties;
+import com.fasterxml.jackson.annotation.JsonInclude;
+import lombok.Builder;
+import lombok.Data;
+
+import java.util.HashMap;
+import java.util.Map;
+
+import static com.readme.core.datatransfer.har.MemoryOptimisationConstants.DEFAULT_MAP_INIT_CAPACITY;
+
+@JsonInclude(JsonInclude.Include.NON_NULL)
+@JsonIgnoreProperties(ignoreUnknown = true)
+@Data
+@Builder
+public class HarCreatorBrowser {
+
+ private String name;
+ private String version;
+ private String comment;
+ private Map additional;
+
+ @JsonAnyGetter
+ public Map getAdditional() {
+ return additional;
+ }
+
+ @JsonAnySetter
+ public void setAdditionalField(String key, Object value) {
+ if (additional == null) {
+ additional = new HashMap<>(DEFAULT_MAP_INIT_CAPACITY);
+ }
+ this.additional.put(key, value);
+ }
+
+}
diff --git a/packages/java/metrics-core/src/main/java/com/readme/core/datatransfer/har/HarEntry.java b/packages/java/metrics-core/src/main/java/com/readme/core/datatransfer/har/HarEntry.java
new file mode 100644
index 000000000..015d1f795
--- /dev/null
+++ b/packages/java/metrics-core/src/main/java/com/readme/core/datatransfer/har/HarEntry.java
@@ -0,0 +1,50 @@
+package com.readme.core.datatransfer.har;
+
+
+import com.fasterxml.jackson.annotation.JsonAnyGetter;
+import com.fasterxml.jackson.annotation.JsonAnySetter;
+import com.fasterxml.jackson.annotation.JsonIgnoreProperties;
+import com.fasterxml.jackson.annotation.JsonInclude;
+import lombok.Builder;
+import lombok.Data;
+
+import java.util.Date;
+import java.util.HashMap;
+import java.util.Map;
+
+import static com.readme.core.datatransfer.har.MemoryOptimisationConstants.DEFAULT_MAP_INIT_CAPACITY;
+
+
+@JsonInclude(JsonInclude.Include.NON_NULL)
+@JsonIgnoreProperties(ignoreUnknown = true)
+@Data
+@Builder
+public class HarEntry {
+
+ private String pageRef;
+ private Date startedDateTime;
+ private int time;
+ private HarRequest request;
+ private HarResponse response;
+ private HarCache cache;
+ private HarTiming timings;
+ private String serverIPAddress;
+ private String connection;
+ private String comment;
+ private Map additional;
+
+ @JsonAnyGetter
+ public Map getAdditional() {
+ return additional;
+ }
+
+ @JsonAnySetter
+ public void setAdditionalField(String key, Object value) {
+ if (additional == null) {
+ additional = new HashMap<>(DEFAULT_MAP_INIT_CAPACITY);
+ }
+ this.additional.put(key, value);
+ }
+
+
+}
diff --git a/packages/java/metrics-core/src/main/java/com/readme/core/datatransfer/har/HarHeader.java b/packages/java/metrics-core/src/main/java/com/readme/core/datatransfer/har/HarHeader.java
new file mode 100644
index 000000000..c7d6c2f65
--- /dev/null
+++ b/packages/java/metrics-core/src/main/java/com/readme/core/datatransfer/har/HarHeader.java
@@ -0,0 +1,39 @@
+package com.readme.core.datatransfer.har;
+
+import com.fasterxml.jackson.annotation.JsonAnyGetter;
+import com.fasterxml.jackson.annotation.JsonAnySetter;
+import com.fasterxml.jackson.annotation.JsonIgnoreProperties;
+import com.fasterxml.jackson.annotation.JsonInclude;
+import lombok.Builder;
+import lombok.Data;
+
+import java.util.HashMap;
+import java.util.Map;
+
+import static com.readme.core.datatransfer.har.MemoryOptimisationConstants.DEFAULT_MAP_INIT_CAPACITY;
+
+@JsonInclude(JsonInclude.Include.NON_NULL)
+@JsonIgnoreProperties(ignoreUnknown = true)
+@Builder
+@Data
+public class HarHeader {
+
+ private String name;
+ private String value;
+ private String comment;
+ private Map additional;
+
+ @JsonAnyGetter
+ public Map getAdditional() {
+ return additional;
+ }
+
+ @JsonAnySetter
+ public void setAdditionalField(String key, Object value) {
+ if (additional == null) {
+ additional = new HashMap<>(DEFAULT_MAP_INIT_CAPACITY);
+ }
+ this.additional.put(key, value);
+ }
+
+}
diff --git a/packages/java/metrics-core/src/main/java/com/readme/core/datatransfer/har/HarLog.java b/packages/java/metrics-core/src/main/java/com/readme/core/datatransfer/har/HarLog.java
new file mode 100644
index 000000000..b0d40eb27
--- /dev/null
+++ b/packages/java/metrics-core/src/main/java/com/readme/core/datatransfer/har/HarLog.java
@@ -0,0 +1,45 @@
+package com.readme.core.datatransfer.har;
+
+
+import com.fasterxml.jackson.annotation.JsonAnyGetter;
+import com.fasterxml.jackson.annotation.JsonAnySetter;
+import com.fasterxml.jackson.annotation.JsonIgnoreProperties;
+import com.fasterxml.jackson.annotation.JsonInclude;
+import lombok.Builder;
+import lombok.Data;
+
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+
+import static com.readme.core.datatransfer.har.MemoryOptimisationConstants.DEFAULT_MAP_INIT_CAPACITY;
+
+
+@JsonInclude(JsonInclude.Include.NON_NULL)
+@JsonIgnoreProperties(ignoreUnknown = true)
+@Builder
+@Data
+public class HarLog {
+
+ private String version;
+ private HarCreatorBrowser creator;
+ private HarCreatorBrowser browser;
+ private List pages;
+ private List entries;
+ private String comment;
+ private Map additional;
+
+ @JsonAnyGetter
+ public Map getAdditional() {
+ return additional;
+ }
+
+ @JsonAnySetter
+ public void setAdditionalField(String key, Object value) {
+ if (additional == null) {
+ additional = new HashMap<>(DEFAULT_MAP_INIT_CAPACITY);
+ }
+ this.additional.put(key, value);
+ }
+
+}
diff --git a/packages/java/metrics-core/src/main/java/com/readme/core/datatransfer/har/HarPage.java b/packages/java/metrics-core/src/main/java/com/readme/core/datatransfer/har/HarPage.java
new file mode 100644
index 000000000..5a6ba987f
--- /dev/null
+++ b/packages/java/metrics-core/src/main/java/com/readme/core/datatransfer/har/HarPage.java
@@ -0,0 +1,44 @@
+package com.readme.core.datatransfer.har;
+
+
+import com.fasterxml.jackson.annotation.JsonAnyGetter;
+import com.fasterxml.jackson.annotation.JsonAnySetter;
+import com.fasterxml.jackson.annotation.JsonIgnoreProperties;
+import com.fasterxml.jackson.annotation.JsonInclude;
+import lombok.Builder;
+import lombok.Data;
+
+import java.util.Date;
+import java.util.HashMap;
+import java.util.Map;
+
+import static com.readme.core.datatransfer.har.MemoryOptimisationConstants.DEFAULT_MAP_INIT_CAPACITY;
+
+@JsonInclude(JsonInclude.Include.NON_NULL)
+@JsonIgnoreProperties(ignoreUnknown = true)
+@Data
+@Builder
+public class HarPage {
+
+ private Date startedDateTime;
+ private String id;
+ private String title;
+ private HarPageTiming pageTimings;
+ private String comment;
+ private Map additional;
+
+ @JsonAnyGetter
+ public Map getAdditional() {
+ return additional;
+ }
+
+ @JsonAnySetter
+ public void setAdditionalField(String key, Object value) {
+ if (additional == null) {
+ additional = new HashMap<>(DEFAULT_MAP_INIT_CAPACITY);
+ }
+ this.additional.put(key, value);
+ }
+
+
+}
diff --git a/packages/java/metrics-core/src/main/java/com/readme/core/datatransfer/har/HarPageTiming.java b/packages/java/metrics-core/src/main/java/com/readme/core/datatransfer/har/HarPageTiming.java
new file mode 100644
index 000000000..2f5126913
--- /dev/null
+++ b/packages/java/metrics-core/src/main/java/com/readme/core/datatransfer/har/HarPageTiming.java
@@ -0,0 +1,44 @@
+package com.readme.core.datatransfer.har;
+
+import com.fasterxml.jackson.annotation.JsonAnyGetter;
+import com.fasterxml.jackson.annotation.JsonAnySetter;
+import com.fasterxml.jackson.annotation.JsonIgnoreProperties;
+import com.fasterxml.jackson.annotation.JsonInclude;
+import lombok.Builder;
+import lombok.Data;
+
+import java.util.HashMap;
+import java.util.Map;
+
+import static com.readme.core.datatransfer.har.MemoryOptimisationConstants.DEFAULT_MAP_INIT_CAPACITY;
+
+@JsonInclude(JsonInclude.Include.NON_NULL)
+@JsonIgnoreProperties(ignoreUnknown = true)
+@Builder
+@Data
+public class HarPageTiming {
+
+ protected static final int DEFAULT_TIME = -1;
+
+ @Builder.Default
+ private int onContentLoad = DEFAULT_TIME;
+ @Builder.Default
+ private int onLoad = DEFAULT_TIME;
+ private String comment;
+ private Map additional;
+
+ @JsonAnyGetter
+ public Map getAdditional() {
+ return additional;
+ }
+
+ @JsonAnySetter
+ public void setAdditionalField(String key, Object value) {
+ if (additional == null) {
+ additional = new HashMap<>(DEFAULT_MAP_INIT_CAPACITY);
+ }
+ this.additional.put(key, value);
+ }
+
+
+}
diff --git a/packages/java/metrics-core/src/main/java/com/readme/core/datatransfer/har/HarPostData.java b/packages/java/metrics-core/src/main/java/com/readme/core/datatransfer/har/HarPostData.java
new file mode 100644
index 000000000..04c4ec901
--- /dev/null
+++ b/packages/java/metrics-core/src/main/java/com/readme/core/datatransfer/har/HarPostData.java
@@ -0,0 +1,43 @@
+package com.readme.core.datatransfer.har;
+
+import com.fasterxml.jackson.annotation.JsonAnyGetter;
+import com.fasterxml.jackson.annotation.JsonAnySetter;
+import com.fasterxml.jackson.annotation.JsonIgnoreProperties;
+import com.fasterxml.jackson.annotation.JsonInclude;
+import lombok.Builder;
+import lombok.Data;
+
+import java.util.ArrayList;
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+
+import static com.readme.core.datatransfer.har.MemoryOptimisationConstants.DEFAULT_MAP_INIT_CAPACITY;
+
+@JsonInclude(JsonInclude.Include.NON_NULL)
+@JsonIgnoreProperties(ignoreUnknown = true)
+@Builder
+@Data
+public class HarPostData {
+
+ private String mimeType;
+ @Builder.Default
+ private List params = new ArrayList<>(4);
+ private String text;
+ private String comment;
+ private Map additional;
+
+ @JsonAnyGetter
+ public Map getAdditional() {
+ return additional;
+ }
+
+ @JsonAnySetter
+ public void setAdditionalField(String key, Object value) {
+ if (additional == null) {
+ additional = new HashMap<>(DEFAULT_MAP_INIT_CAPACITY);
+ }
+ this.additional.put(key, value);
+ }
+
+}
diff --git a/packages/java/metrics-core/src/main/java/com/readme/core/datatransfer/har/HarPostDataParam.java b/packages/java/metrics-core/src/main/java/com/readme/core/datatransfer/har/HarPostDataParam.java
new file mode 100644
index 000000000..eec8a007a
--- /dev/null
+++ b/packages/java/metrics-core/src/main/java/com/readme/core/datatransfer/har/HarPostDataParam.java
@@ -0,0 +1,41 @@
+package com.readme.core.datatransfer.har;
+
+import com.fasterxml.jackson.annotation.JsonAnyGetter;
+import com.fasterxml.jackson.annotation.JsonAnySetter;
+import com.fasterxml.jackson.annotation.JsonIgnoreProperties;
+import com.fasterxml.jackson.annotation.JsonInclude;
+import lombok.Builder;
+import lombok.Data;
+
+import java.util.HashMap;
+import java.util.Map;
+
+import static com.readme.core.datatransfer.har.MemoryOptimisationConstants.DEFAULT_MAP_INIT_CAPACITY;
+
+@JsonInclude(JsonInclude.Include.NON_NULL)
+@JsonIgnoreProperties(ignoreUnknown = true)
+@Data
+@Builder
+public class HarPostDataParam {
+
+ private String name;
+ private String value;
+ private String fileName;
+ private String contentType;
+ private String comment;
+ private Map additional;
+
+ @JsonAnyGetter
+ public Map getAdditional() {
+ return additional;
+ }
+
+ @JsonAnySetter
+ public void setAdditionalField(String key, Object value) {
+ if (additional == null) {
+ additional = new HashMap<>(DEFAULT_MAP_INIT_CAPACITY);
+ }
+ this.additional.put(key, value);
+ }
+
+}
diff --git a/packages/java/metrics-core/src/main/java/com/readme/core/datatransfer/har/HarQueryParam.java b/packages/java/metrics-core/src/main/java/com/readme/core/datatransfer/har/HarQueryParam.java
new file mode 100644
index 000000000..6ef8b487d
--- /dev/null
+++ b/packages/java/metrics-core/src/main/java/com/readme/core/datatransfer/har/HarQueryParam.java
@@ -0,0 +1,39 @@
+package com.readme.core.datatransfer.har;
+
+import com.fasterxml.jackson.annotation.JsonAnyGetter;
+import com.fasterxml.jackson.annotation.JsonAnySetter;
+import com.fasterxml.jackson.annotation.JsonIgnoreProperties;
+import com.fasterxml.jackson.annotation.JsonInclude;
+import lombok.Builder;
+import lombok.Data;
+
+import java.util.HashMap;
+import java.util.Map;
+
+import static com.readme.core.datatransfer.har.MemoryOptimisationConstants.DEFAULT_MAP_INIT_CAPACITY;
+
+@JsonInclude(JsonInclude.Include.NON_NULL)
+@JsonIgnoreProperties(ignoreUnknown = true)
+@Builder
+@Data
+public class HarQueryParam {
+
+ private String name;
+ private String value;
+ private String comment;
+ private Map additional;
+
+ @JsonAnyGetter
+ public Map getAdditional() {
+ return additional;
+ }
+
+ @JsonAnySetter
+ public void setAdditionalField(String key, Object value) {
+ if (additional == null) {
+ additional = new HashMap<>(DEFAULT_MAP_INIT_CAPACITY);
+ }
+ this.additional.put(key, value);
+ }
+
+}
diff --git a/packages/java/metrics-core/src/main/java/com/readme/core/datatransfer/har/HarRequest.java b/packages/java/metrics-core/src/main/java/com/readme/core/datatransfer/har/HarRequest.java
new file mode 100644
index 000000000..74428e445
--- /dev/null
+++ b/packages/java/metrics-core/src/main/java/com/readme/core/datatransfer/har/HarRequest.java
@@ -0,0 +1,51 @@
+package com.readme.core.datatransfer.har;
+
+import com.fasterxml.jackson.annotation.JsonAnyGetter;
+import com.fasterxml.jackson.annotation.JsonAnySetter;
+import com.fasterxml.jackson.annotation.JsonIgnoreProperties;
+import com.fasterxml.jackson.annotation.JsonInclude;
+import lombok.Builder;
+import lombok.Data;
+
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+
+import static com.readme.core.datatransfer.har.MemoryOptimisationConstants.DEFAULT_MAP_INIT_CAPACITY;
+
+@JsonInclude(JsonInclude.Include.NON_NULL)
+@JsonIgnoreProperties(ignoreUnknown = true)
+@Data
+@Builder
+public class HarRequest {
+
+ protected static final long DEFAULT_SIZE = -1L;
+
+ private HttpMethod method;
+ private String url;
+ private String httpVersion;
+ private List cookies;
+ private List headers;
+ private List queryString;
+ private HarPostData postData;
+ @Builder.Default
+ private long headersSize = DEFAULT_SIZE;
+ @Builder.Default
+ private long bodySize = DEFAULT_SIZE;
+ private String comment;
+ private Map additional;
+
+ @JsonAnyGetter
+ public Map getAdditional() {
+ return additional;
+ }
+
+ @JsonAnySetter
+ public void setAdditionalField(String key, Object value) {
+ if (additional == null) {
+ additional = new HashMap<>(DEFAULT_MAP_INIT_CAPACITY);
+ }
+ this.additional.put(key, value);
+ }
+
+}
diff --git a/packages/java/metrics-core/src/main/java/com/readme/core/datatransfer/har/HarResponse.java b/packages/java/metrics-core/src/main/java/com/readme/core/datatransfer/har/HarResponse.java
new file mode 100644
index 000000000..9e08a1762
--- /dev/null
+++ b/packages/java/metrics-core/src/main/java/com/readme/core/datatransfer/har/HarResponse.java
@@ -0,0 +1,52 @@
+package com.readme.core.datatransfer.har;
+
+import com.fasterxml.jackson.annotation.JsonAnyGetter;
+import com.fasterxml.jackson.annotation.JsonAnySetter;
+import com.fasterxml.jackson.annotation.JsonIgnoreProperties;
+import com.fasterxml.jackson.annotation.JsonInclude;
+import lombok.Builder;
+import lombok.Data;
+
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+
+import static com.readme.core.datatransfer.har.MemoryOptimisationConstants.DEFAULT_MAP_INIT_CAPACITY;
+
+
+@JsonInclude(JsonInclude.Include.NON_NULL)
+@JsonIgnoreProperties(ignoreUnknown = true)
+@Data
+@Builder
+public class HarResponse {
+
+ protected static final long DEFAULT_SIZE = -1L;
+
+ private int status;
+ private String statusText;
+ private String httpVersion;
+ private List cookies;
+ private List headers;
+ private HarContent content;
+ private String redirectURL;
+ @Builder.Default
+ private long headersSize = DEFAULT_SIZE;
+ @Builder.Default
+ private long bodySize = DEFAULT_SIZE;
+ private String comment;
+ private Map additional;
+
+ @JsonAnyGetter
+ public Map getAdditional() {
+ return additional;
+ }
+
+ @JsonAnySetter
+ public void setAdditionalField(String key, Object value) {
+ if (additional == null) {
+ additional = new HashMap<>(DEFAULT_MAP_INIT_CAPACITY);
+ }
+ this.additional.put(key, value);
+ }
+
+}
diff --git a/packages/java/metrics-core/src/main/java/com/readme/core/datatransfer/har/HarTiming.java b/packages/java/metrics-core/src/main/java/com/readme/core/datatransfer/har/HarTiming.java
new file mode 100644
index 000000000..c2437e2f2
--- /dev/null
+++ b/packages/java/metrics-core/src/main/java/com/readme/core/datatransfer/har/HarTiming.java
@@ -0,0 +1,47 @@
+package com.readme.core.datatransfer.har;
+
+import com.fasterxml.jackson.annotation.JsonAnyGetter;
+import com.fasterxml.jackson.annotation.JsonAnySetter;
+import com.fasterxml.jackson.annotation.JsonIgnoreProperties;
+import com.fasterxml.jackson.annotation.JsonInclude;
+import lombok.Builder;
+import lombok.Data;
+
+import java.util.HashMap;
+import java.util.Map;
+
+import static com.readme.core.datatransfer.har.MemoryOptimisationConstants.DEFAULT_MAP_INIT_CAPACITY;
+
+@JsonInclude(JsonInclude.Include.NON_NULL)
+@JsonIgnoreProperties(ignoreUnknown = true)
+@Data
+@Builder
+public class HarTiming {
+
+ protected static final int DEFAULT_TIME = -1;
+
+ private int blocked;
+ private int dns;
+ private int connect;
+ private int send;
+ private int waitTime;
+ private int receive;
+ private int ssl;
+ private String comment;
+ private Map additional;
+
+ @JsonAnyGetter
+ public Map getAdditional() {
+ return additional;
+ }
+
+ @JsonAnySetter
+ public void setAdditionalField(String key, Object value) {
+ if (additional == null) {
+ additional = new HashMap<>(DEFAULT_MAP_INIT_CAPACITY);
+ }
+ this.additional.put(key, value);
+ }
+
+
+}
diff --git a/packages/java/metrics-core/src/main/java/com/readme/core/datatransfer/har/HttpMethod.java b/packages/java/metrics-core/src/main/java/com/readme/core/datatransfer/har/HttpMethod.java
new file mode 100644
index 000000000..8286606af
--- /dev/null
+++ b/packages/java/metrics-core/src/main/java/com/readme/core/datatransfer/har/HttpMethod.java
@@ -0,0 +1,5 @@
+package com.readme.core.datatransfer.har;
+
+public enum HttpMethod {
+ GET, POST, PUT, HEAD, PROPFIND, OPTIONS, REPORT, DELETE, CONNECT, TRACE, CCM_POST, PATCH;
+}
diff --git a/packages/java/metrics-core/src/main/java/com/readme/core/datatransfer/har/HttpStatus.java b/packages/java/metrics-core/src/main/java/com/readme/core/datatransfer/har/HttpStatus.java
new file mode 100644
index 000000000..c4653404f
--- /dev/null
+++ b/packages/java/metrics-core/src/main/java/com/readme/core/datatransfer/har/HttpStatus.java
@@ -0,0 +1,59 @@
+package com.readme.core.datatransfer.har;
+
+import java.util.HashMap;
+import java.util.Map;
+
+public enum HttpStatus {
+
+ UNKNOWN_HTTP_STATUS(0),
+
+ OK(200), CREATED(201), ACCEPTED(202), NO_CONTENT(204), RESET_CONTENT(205),
+ PARTIAL_CONTENT(206), MULTI_STATUS(207), ALREADY_REPORTED(208), IM_USED(226),
+
+ MOVED_PERMANENTLY(301), FOUND(302), SEE_OTHER(303), NOT_MODIFIED(304),
+ USE_PROXY(305), TEMPORARY_REDIRECT(307), PERMANENT_REDIRECT(308),
+
+ BAD_REQUEST(400), UNAUTHORIZED(401), PAYMENT_REQUIRED(402), FORBIDDEN(403),
+ NOT_FOUND(404), METHOD_NOT_ALLOWED(405), NOT_ACCEPTABLE(406),
+ PROXY_AUTHENTICATION_REQUIRED(407), REQUEST_TIMEOUT(408),
+ CONFLICT(409), GONE(410), LENGTH_REQUIRED(411), PRECONDITION_FAILED(412),
+ REQUEST_ENTITY_TOO_LARGE(413), REQUEST_URI_TOO_LONG(414),
+ UNSUPPORTED_MEDIA_TYPE(415), REQUESTED_RANGE_NOT_SATISFIABLE(416),
+ EXPECTATION_FAILED(417), MISDIRECTED_REQUEST(421),
+ UNPROCESSABLE_ENTITY(422), LOCKED(423), FAILED_DEPENDENCY(424),
+ UPGRADE_REQUIRED(426), PRECONDITION_REQUIRED(428), TOO_MANY_REQUESTS(429),
+ REQUEST_HEADER_FIELDS_TOO_LARGE(431), UNAVAILABLE_FOR_LEGAL_REASONS(451),
+
+ INTERNAL_SERVER_ERROR(500), NOT_IMPLEMENTED(501),
+ BAD_GATEWAY(502), SERVICE_UNAVAILABLE(503), GATEWAY_TIMEOUT(504),
+ HTTP_VERSION_NOT_SUPPORTED(505), VARIANT_ALSO_NEGOTIATES(506),
+ INSUFFICIENT_STORAGE(507), LOOP_DETECTED(508),
+ NOT_EXTENDED(510), NETWORK_AUTHENTICATION_REQUIRED(511);
+
+ private static final Map CODE_MAP = new HashMap<>();
+
+ static {
+ for (HttpStatus status : HttpStatus.values()) {
+ CODE_MAP.put(status.getCode(), status);
+ }
+ }
+
+ private int code;
+
+ private HttpStatus(int code) {
+ this.code = code;
+ }
+
+ public int getCode() {
+ return code;
+ }
+
+ public static HttpStatus byCode(int code) {
+ HttpStatus status = CODE_MAP.get(code);
+ if (status == null) {
+ return UNKNOWN_HTTP_STATUS;
+ }
+ return status;
+ }
+
+}
diff --git a/packages/java/metrics-core/src/main/java/com/readme/core/datatransfer/har/MemoryOptimisationConstants.java b/packages/java/metrics-core/src/main/java/com/readme/core/datatransfer/har/MemoryOptimisationConstants.java
new file mode 100644
index 000000000..232455a6e
--- /dev/null
+++ b/packages/java/metrics-core/src/main/java/com/readme/core/datatransfer/har/MemoryOptimisationConstants.java
@@ -0,0 +1,19 @@
+package com.readme.core.datatransfer.har;
+
+/**
+ * Contains constants used for memory and performance optimizations throughout the SDK.
+ *
+ * Adjusting these values helps manage memory consumption efficiently,
+ * especially in high-load scenarios where many small objects (e.g., maps) are frequently created.
+ */
+public class MemoryOptimisationConstants {
+
+ /**
+ * Default initial capacity for maps that are typically small (1-4 entries).
+ *
+ * Reducing the default from 16 to 8 helps minimize memory footprint
+ * when numerous small maps are created, thus reducing overall memory consumption and GC overhead.
+ */
+ public static final int DEFAULT_MAP_INIT_CAPACITY = 8;
+
+}
diff --git a/packages/java/metrics-core/src/main/java/com/readme/core/webhook/WebhookVerifier.java b/packages/java/metrics-core/src/main/java/com/readme/core/webhook/WebhookVerifier.java
new file mode 100644
index 000000000..f00d76de7
--- /dev/null
+++ b/packages/java/metrics-core/src/main/java/com/readme/core/webhook/WebhookVerifier.java
@@ -0,0 +1,82 @@
+package com.readme.core.webhook;
+
+import javax.crypto.Mac;
+import javax.crypto.spec.SecretKeySpec;
+import java.nio.charset.StandardCharsets;
+import java.time.Instant;
+import java.util.HashMap;
+import java.util.Map;
+
+public class WebhookVerifier {
+
+ public static final long SIGNATURE_EXPIRATION_TIME = 30 * 60 * 1000;
+ public static final String SIGNATURE_SCHEME = "v0";
+ public static final String SECRET_KEY_ALGORITHM = "HmacSHA256";
+
+ public static Map verifyWebhook(String body, String signature, String secret) {
+ if (signature == null || signature.isEmpty()) {
+ throw new IllegalArgumentException("Missing Signature");
+ }
+
+ Map parsedSignature = parseSignature(signature);
+ String signatureTime = parsedSignature.get("t");
+ String readmeSignature = parsedSignature.get(SIGNATURE_SCHEME);
+
+ if (signatureTime == null || readmeSignature == null) {
+ throw new IllegalArgumentException("Invalid Signature Format");
+ }
+
+ long timestamp = Long.parseLong(signatureTime);
+ long now = Instant.now().toEpochMilli();
+ if (Math.abs(now - timestamp) > SIGNATURE_EXPIRATION_TIME) {
+ throw new IllegalArgumentException("Expired Signature");
+ }
+
+ String unsigned = timestamp + "." + body;
+ String computedSignature = computeHmacSHA256(unsigned, secret);
+
+ if (!computedSignature.equals(readmeSignature)) {
+ throw new IllegalArgumentException("Invalid Signature");
+ }
+
+ Map result = new HashMap<>();
+ result.put("email", body);
+ return result;
+ }
+
+ private static Map parseSignature(String signature) {
+ Map parsed = new HashMap<>();
+ String[] parts = signature.split(",");
+ for (String part : parts) {
+ String[] kv = part.split("=");
+ if (kv.length == 2) {
+ parsed.put(kv[0].trim(), kv[1].trim());
+ }
+ }
+ return parsed;
+ }
+
+ protected static String computeHmacSHA256(String data, String secret) {
+ try {
+ Mac hmac = Mac.getInstance(SECRET_KEY_ALGORITHM);
+ SecretKeySpec secretKey = new SecretKeySpec(secret.getBytes(StandardCharsets.UTF_8), SECRET_KEY_ALGORITHM);
+ hmac.init(secretKey);
+ byte[] hashBytes = hmac.doFinal(data.getBytes(StandardCharsets.UTF_8));
+ return bytesToHex(hashBytes);
+ } catch (Exception e) {
+ throw new IllegalArgumentException("Invalid Signature");
+ }
+ }
+
+ private static String bytesToHex(byte[] bytes) {
+ StringBuilder hexString = new StringBuilder();
+ for (byte b : bytes) {
+ String hex = Integer.toHexString(0xff & b);
+ if (hex.length() == 1) {
+ hexString.append('0');
+ }
+ hexString.append(hex);
+ }
+ return hexString.toString();
+ }
+}
\ No newline at end of file
diff --git a/packages/java/metrics-core/src/main/resources/version.properties b/packages/java/metrics-core/src/main/resources/version.properties
new file mode 100644
index 000000000..e5683df88
--- /dev/null
+++ b/packages/java/metrics-core/src/main/resources/version.properties
@@ -0,0 +1 @@
+version=${project.version}
\ No newline at end of file
diff --git a/packages/java/metrics-core/src/test/java/com/readme/core/datatransfer/BaseLogUrlFetcherTest.java b/packages/java/metrics-core/src/test/java/com/readme/core/datatransfer/BaseLogUrlFetcherTest.java
new file mode 100644
index 000000000..ec4530d62
--- /dev/null
+++ b/packages/java/metrics-core/src/test/java/com/readme/core/datatransfer/BaseLogUrlFetcherTest.java
@@ -0,0 +1,66 @@
+package com.readme.core.datatransfer;
+
+
+import com.readme.core.datatransfer.BaseLogUrlFetcher;
+import okhttp3.mockwebserver.MockResponse;
+import okhttp3.mockwebserver.MockWebServer;
+import okhttp3.mockwebserver.SocketPolicy;
+import org.json.JSONObject;
+import org.junit.jupiter.api.AfterEach;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+
+import java.io.IOException;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+
+class BaseLogUrlFetcherTest {
+
+ private MockWebServer mockWebServer;
+
+ @BeforeEach
+ void setUp() throws IOException {
+ mockWebServer = new MockWebServer();
+ mockWebServer.start();
+ }
+
+ @AfterEach
+ void tearDown() throws IOException {
+ mockWebServer.shutdown();
+ }
+
+ @Test
+ void fetchBaseLogUrl_ShouldReturnBaseUrl_WhenApiResponseIsSuccessful() {
+ String expectedBaseUrl = "https://example.com/base";
+ JSONObject jsonResponse = new JSONObject().put("baseUrl", expectedBaseUrl);
+ mockWebServer.enqueue(new MockResponse()
+ .setBody(jsonResponse.toString())
+ .setResponseCode(200));
+
+ String mockApiUrl = mockWebServer.url("/v1").toString();
+ String result = BaseLogUrlFetcher.fetchBaseLogUrl("successApiKey", mockApiUrl);
+
+ assertEquals(expectedBaseUrl, result);
+ }
+
+ @Test
+ void fetchBaseLogUrl_ShouldReturnEmptyString_WhenApiResponseIsFailure() {
+ mockWebServer.enqueue(new MockResponse()
+ .setResponseCode(500)
+ .setBody("Internal Server Error"));
+ String mockApiUrl = mockWebServer.url("/v1").toString();
+ String result = BaseLogUrlFetcher.fetchBaseLogUrl("failApiKey", mockApiUrl);
+
+ assertEquals("", result);
+ }
+
+ @Test
+ void fetchBaseLogUrl_ShouldReturnEmptyString_WhenExceptionOccurs() {
+ mockWebServer.enqueue(new MockResponse().setSocketPolicy(SocketPolicy.DISCONNECT_AT_START));
+ String mockApiUrl = mockWebServer.url("/v1").toString();
+ String result = BaseLogUrlFetcher.fetchBaseLogUrl("exceptionApiKey", mockApiUrl);
+
+ assertEquals("", result);
+ }
+
+}
\ No newline at end of file
diff --git a/packages/java/metrics-core/src/test/java/com/readme/core/datatransfer/HttpDataSenderTest.java b/packages/java/metrics-core/src/test/java/com/readme/core/datatransfer/HttpDataSenderTest.java
new file mode 100644
index 000000000..5ee82516a
--- /dev/null
+++ b/packages/java/metrics-core/src/test/java/com/readme/core/datatransfer/HttpDataSenderTest.java
@@ -0,0 +1,115 @@
+package com.readme.core.datatransfer;
+
+import com.readme.core.config.CoreConfig;
+import com.readme.core.dataextraction.LogOptions;
+import com.readme.core.datatransfer.HttpDataSender;
+import com.readme.core.datatransfer.OutgoingLogBody;
+import com.readme.core.datatransfer.har.Group;
+import com.readme.core.datatransfer.har.Har;
+import com.readme.core.datatransfer.har.HarLog;
+import okhttp3.*;
+import org.jetbrains.annotations.NotNull;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+import org.mockito.ArgumentCaptor;
+
+import java.io.IOException;
+import java.util.Base64;
+import java.util.Collections;
+import java.util.List;
+import java.util.UUID;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.mockito.Mockito.*;
+
+public class HttpDataSenderTest {
+
+ private OkHttpClient mockClient;
+ private CoreConfig mockCoreConfig;
+ private HttpDataSender httpDataSender;
+
+ @BeforeEach
+ public void setUp() {
+ mockClient = mock(OkHttpClient.class);
+ mockCoreConfig = mock(CoreConfig.class);
+ when(mockCoreConfig.getReadmeAPIKey()).thenReturn("testApiKey");
+ httpDataSender = new HttpDataSender(mockClient, mockCoreConfig);
+ }
+
+ @Test
+ public void send_ShouldReturnTrue_WhenRequestIsSuccessful() throws IOException {
+ Response mockResponse = mockResponse();
+ Call mockCall = mock(Call.class);
+ when(mockCall.execute()).thenReturn(mockResponse);
+ when(mockClient.newCall(any(Request.class))).thenReturn(mockCall);
+ List payload = Collections.singletonList(createTestOutgoingLogBody());
+
+ boolean result = httpDataSender.send(payload, LogOptions.builder().build());
+
+ assertEquals(true, result);
+ }
+
+ @Test
+ public void send_ShouldReturnFalse_WhenPayloadIsNull() {
+ boolean result = httpDataSender.send(null, LogOptions.builder().build());
+
+ assertEquals(false, result);
+ verifyNoInteractions(mockClient);
+ }
+
+ //TODO fix this test
+ @Test
+ public void send_ShouldReturnFalse_WhenIOExceptionOccurs() throws IOException {
+ Call mockCall = mock(Call.class);
+ when(mockClient.newCall(any(Request.class))).thenReturn(mockCall);
+ doThrow(new IOException("Test exception")).when(mockCall).execute();
+ List payload = Collections.singletonList(createTestOutgoingLogBody());
+
+ boolean result = httpDataSender.send(payload, LogOptions.builder().fireAndForget(false).build());
+ assertEquals(false, result);
+ }
+
+ @Test
+ public void send_ShouldAddAuthorizationHeader() throws IOException {
+ Response mockResponse = mock(Response.class);
+ when(mockResponse.code()).thenReturn(200);
+ Call mockCall = mock(Call.class);
+ when(mockCall.execute()).thenReturn(mockResponse);
+ when(mockClient.newCall(any(Request.class))).thenReturn(mockCall);
+
+ List payload = Collections.singletonList(createTestOutgoingLogBody());
+ ArgumentCaptor requestCaptor = ArgumentCaptor.forClass(Request.class);
+
+ httpDataSender.send(payload, LogOptions.builder().build());
+
+ verify(mockClient).newCall(requestCaptor.capture());
+ Request capturedRequest = requestCaptor.getValue();
+ String authHeader = capturedRequest.header("Authorization");
+ String expectedAuthHeader = "Basic " + Base64.getEncoder().encodeToString("testApiKey:".getBytes());
+ assertEquals(expectedAuthHeader, authHeader);
+ }
+
+ private OutgoingLogBody createTestOutgoingLogBody() {
+ return OutgoingLogBody.builder()
+ .id(UUID.randomUUID())
+ .version(1)
+ .clientIPAddress("127.0.0.1")
+ .development(true)
+ .group(Group.builder().build())
+ .request(new Har(HarLog.builder().build()))
+ .build();
+ }
+
+ @NotNull
+ private static Response mockResponse() {
+ return new Response.Builder()
+ .request(new Request.Builder().url("https://metrics.readme.io/v1/request").build())
+ .code(200)
+ .protocol(Protocol.HTTP_2)
+ .message("OK")
+ .body(ResponseBody.create("body content", MediaType.get("application/json")))
+ .build();
+ }
+
+
+}
diff --git a/packages/java/metrics-core/src/test/java/com/readme/core/datatransfer/OutgoingLogBodyConstructorTest.java b/packages/java/metrics-core/src/test/java/com/readme/core/datatransfer/OutgoingLogBodyConstructorTest.java
new file mode 100644
index 000000000..cb6cfb2a4
--- /dev/null
+++ b/packages/java/metrics-core/src/test/java/com/readme/core/datatransfer/OutgoingLogBodyConstructorTest.java
@@ -0,0 +1,209 @@
+package com.readme.core.datatransfer;
+
+import com.readme.core.dataextraction.LogOptions;
+import com.readme.core.dataextraction.payload.PayloadData;
+import com.readme.core.dataextraction.payload.requestresponse.ApiCallLogData;
+import com.readme.core.dataextraction.payload.requestresponse.RequestData;
+import com.readme.core.dataextraction.payload.requestresponse.ResponseData;
+import com.readme.core.dataextraction.payload.user.UserData;
+import com.readme.core.datatransfer.OutgoingLogBody;
+import com.readme.core.datatransfer.OutgoingLogBodyConstructor;
+import com.readme.core.datatransfer.har.*;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.extension.ExtendWith;
+import org.mockito.InjectMocks;
+import org.mockito.junit.jupiter.MockitoExtension;
+
+import java.util.Date;
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+
+import static com.readme.core.dataextraction.ApiKeyMasker.mask;
+import static org.junit.jupiter.api.Assertions.*;
+
+@ExtendWith(MockitoExtension.class)
+class OutgoingLogBodyConstructorTest {
+
+ @InjectMocks
+ private OutgoingLogBodyConstructor outgoingLogBodyConstructor;
+
+ @Test
+ void construct_ShouldBuildOutgoingLogBody_Success() {
+ PayloadData payloadData = createStubPayloadData();
+ LogOptions logOptions = createStubLogOptions();
+
+ OutgoingLogBody result = outgoingLogBodyConstructor.construct(payloadData, logOptions);
+
+ assertNotNull(result);
+ assertEquals(payloadData.getApiCallLogData().getRequestData().getRemoteAddress(), result.getClientIPAddress());
+ assertEquals(3, result.getVersion());
+ assertTrue(result.isDevelopment());
+
+ Group group = result.getGroup();
+ assertNotNull(group);
+ assertEquals(mask(payloadData.getUserData().getApiKey()), group.getId());
+ assertEquals(payloadData.getUserData().getLabel(), group.getLabel());
+ assertEquals(payloadData.getUserData().getEmail(), group.getEmail());
+
+ Har har = result.getRequest();
+ assertNotNull(har);
+ HarLog harLog = har.getLog();
+ assertNotNull(harLog);
+ assertEquals("1.2", harLog.getVersion());
+ assertEquals(1, harLog.getEntries().size());
+
+ HarEntry harEntry = harLog.getEntries().get(0);
+ assertEquals(payloadData.getApiCallLogData().getRequestData().getRoutePath(), harEntry.getPageRef());
+ assertEquals(payloadData.getApiCallLogData().getResponseData().getStatusCode(), harEntry.getResponse().getStatus());
+ }
+
+ private PayloadData createStubPayloadData() {
+ UserData userData = UserData.builder()
+ .apiKey("owlApiKey")
+ .email("owl@birdfact0ry.abc")
+ .label("Owl Label")
+ .build();
+
+ RequestData requestData = RequestData.builder()
+ .body("{\"key\":\"value\"}")
+ .routePath("/owl/path")
+ .remoteAddress("127.0.0.1")
+ .protocol("HTTP/1.1")
+ .url("http://owl-bowl.abc/api")
+ .method("POST")
+ .headers(new HashMap<>(Map.of("host", "owl-bowl.abc", "content-type", "application/json")))
+ .requestParameters(Map.of("param1", "value1"))
+ .build();
+
+ ResponseData responseData = ResponseData.builder()
+ .body("{\"response\":\"ok\"}")
+ .headers(Map.of("content-type", "application/json", "content-length", "100"))
+ .statusCode(200)
+ .statusMessage("OK")
+ .build();
+
+ ApiCallLogData apiCallLogData = ApiCallLogData.builder()
+ .requestData(requestData)
+ .responseData(responseData)
+ .build();
+
+ return PayloadData.builder()
+ .userData(userData)
+ .apiCallLogData(apiCallLogData)
+ .requestStartedDateTime(new Date(System.currentTimeMillis() - 1000))
+ .responseEndDateTime(new Date())
+ .build();
+ }
+
+ @Test
+ void construct_ShouldApplyLogOptionsDenyList() {
+ PayloadData payloadData = createStubPayloadData();
+ payloadData.getApiCallLogData().getRequestData().setBody("{\"key\":\"value\", \"secret\":\"mySecret\"}");
+ payloadData.getApiCallLogData().getRequestData().getHeaders().put("X-Secret-Header", "SuperSecret");
+
+ LogOptions logOptions = LogOptions.builder()
+ .development(true)
+ .denylist(List.of("secret", "X-Secret-Header"))
+ .build();
+
+ OutgoingLogBody result = outgoingLogBodyConstructor.construct(payloadData, logOptions);
+
+ Har har = result.getRequest();
+ HarEntry entry = har.getLog().getEntries().get(0);
+ HarRequest request = entry.getRequest();
+
+ HarPostData postData = request.getPostData();
+ assertNotNull(postData);
+ assertTrue(postData.getText().contains("\"secret\":\"[REDACTED]\""));
+
+ boolean anySecretHeader = request.getHeaders().stream()
+ .anyMatch(hdr -> hdr.getName().equals("X-Secret-Header"));
+ assertFalse(anySecretHeader);
+ }
+
+ @Test
+ void construct_ShouldApplyLogOptionsAllowList() {
+ PayloadData payloadData = createStubPayloadData();
+ payloadData.getApiCallLogData().getRequestData().setBody("{\"keep\":\"abc\", \"drop\":\"xyz\"}");
+ payloadData.getApiCallLogData().getRequestData().getHeaders().put("Keep-Header", "keepValue");
+ payloadData.getApiCallLogData().getRequestData().getHeaders().put("Drop-Header", "dropValue");
+
+ LogOptions logOptions = LogOptions.builder()
+ .development(false)
+ .allowlist(List.of("keep", "Keep-Header"))
+ .build();
+
+ OutgoingLogBody result = outgoingLogBodyConstructor.construct(payloadData, logOptions);
+
+ Har har = result.getRequest();
+ HarEntry entry = har.getLog().getEntries().get(0);
+ HarRequest request = entry.getRequest();
+ HarPostData postData = request.getPostData();
+
+ assertTrue(postData.getText().contains("\"keep\":\"abc\""));
+ assertFalse(postData.getText().contains("drop"));
+
+ boolean keepHeaderExists = request.getHeaders().stream()
+ .anyMatch(hdr -> hdr.getName().equals("Keep-Header"));
+ assertTrue(keepHeaderExists);
+
+ boolean dropHeaderExists = request.getHeaders().stream()
+ .anyMatch(hdr -> hdr.getName().equals("Drop-Header"));
+ assertFalse(dropHeaderExists );
+ }
+
+ private LogOptions createStubLogOptions() {
+ return LogOptions.builder()
+ .development(true)
+ .build();
+ }
+
+ @Test
+ void construct_ShouldApplyFormUrlEncodedDenyList() {
+ PayloadData payloadData = createStubPayloadData();
+ payloadData.getApiCallLogData().getRequestData().setBody("username=owl&password=superSecret123&token=myToken");
+ payloadData.getApiCallLogData().getRequestData().getHeaders().put("content-type", "application/x-www-form-urlencoded");
+
+ LogOptions logOptions = LogOptions.builder()
+ .development(true)
+ .denylist(List.of("password", "token"))
+ .build();
+
+ OutgoingLogBody result = outgoingLogBodyConstructor.construct(payloadData, logOptions);
+
+ Har har = result.getRequest();
+ HarEntry entry = har.getLog().getEntries().get(0);
+ HarRequest request = entry.getRequest();
+ HarPostData postData = request.getPostData();
+
+ assertNotNull(postData);
+ assertTrue(postData.getText().contains("username=owl"));
+ assertTrue(postData.getText().contains("password=[REDACTED]"));
+ assertTrue(postData.getText().contains("token=[REDACTED]"));
+ }
+
+ @Test
+ void construct_ShouldApplyFormUrlEncodedAllowList() {
+ PayloadData payloadData = createStubPayloadData();
+ payloadData.getApiCallLogData().getRequestData().setBody("username=owl&password=superSecret123&token=myToken");
+ payloadData.getApiCallLogData().getRequestData().getHeaders().put("content-type", "application/x-www-form-urlencoded");
+
+ LogOptions logOptions = LogOptions.builder()
+ .development(false)
+ .allowlist(List.of("username"))
+ .build();
+
+ OutgoingLogBody result = outgoingLogBodyConstructor.construct(payloadData, logOptions);
+
+ Har har = result.getRequest();
+ HarEntry entry = har.getLog().getEntries().get(0);
+ HarRequest request = entry.getRequest();
+ HarPostData postData = request.getPostData();
+
+ assertNotNull(postData);
+ assertTrue(postData.getText().contains("username=owl"));
+ assertFalse(postData.getText().contains("password"));
+ assertFalse(postData.getText().contains("token"));
+ }
+}
\ No newline at end of file
diff --git a/packages/java/metrics-core/src/test/java/com/readme/core/datatransfer/PayloadDataDispatcherTest.java b/packages/java/metrics-core/src/test/java/com/readme/core/datatransfer/PayloadDataDispatcherTest.java
new file mode 100644
index 000000000..382fbcff3
--- /dev/null
+++ b/packages/java/metrics-core/src/test/java/com/readme/core/datatransfer/PayloadDataDispatcherTest.java
@@ -0,0 +1,93 @@
+package com.readme.core.datatransfer;
+
+import com.readme.core.dataextraction.LogOptions;
+import com.readme.core.dataextraction.payload.PayloadData;
+import com.readme.core.dataextraction.payload.user.UserData;
+import com.readme.core.datatransfer.DataSender;
+import com.readme.core.datatransfer.OutgoingLogBody;
+import com.readme.core.datatransfer.OutgoingLogBodyConstructor;
+import com.readme.core.datatransfer.PayloadDataDispatcher;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+import org.mockito.ArgumentCaptor;
+import org.mockito.Captor;
+import org.mockito.InjectMocks;
+import org.mockito.Mock;
+import org.mockito.MockitoAnnotations;
+
+import java.util.List;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+import static org.mockito.Mockito.*;
+
+class PayloadDataDispatcherTest {
+
+ @Mock
+ private DataSender dataSender;
+
+ @Mock
+ private OutgoingLogBodyConstructor payloadConstructor;
+
+ @Captor
+ private ArgumentCaptor> outgoingLogBodiesCaptor;
+
+ @InjectMocks
+ private PayloadDataDispatcher payloadDataDispatcher;
+
+ private PayloadData payloadData;
+ private LogOptions logOptions;
+
+ @BeforeEach
+ void setUp() {
+ MockitoAnnotations.openMocks(this);
+ payloadDataDispatcher = new PayloadDataDispatcher(dataSender, payloadConstructor);
+ payloadData = createStubPayloadData();
+ logOptions = LogOptions.builder()
+ .bufferLength(3)
+ .build();
+ }
+
+ @Test
+ void dispatch_ShouldAddToBufferAndSend_WhenBufferFull() {
+ when(payloadConstructor.construct(payloadData, logOptions))
+ .thenReturn(OutgoingLogBody.builder().build());
+ when(dataSender.send(anyList(), eq(logOptions))).thenReturn(true);
+
+ payloadDataDispatcher.dispatch(payloadData, logOptions);
+ payloadDataDispatcher.dispatch(payloadData, logOptions);
+ payloadDataDispatcher.dispatch(payloadData, logOptions);
+
+ verify(dataSender, times(1))
+ .send(outgoingLogBodiesCaptor.capture(), eq(logOptions));
+ List capturedBodies = outgoingLogBodiesCaptor.getValue();
+
+ assertEquals(3, capturedBodies.size());
+ }
+
+ @Test
+ void dispatch_ShouldNotSend_WhenBufferNotFull() {
+ when(payloadConstructor.construct(payloadData, logOptions))
+ .thenReturn(OutgoingLogBody.builder().build());
+
+ payloadDataDispatcher.dispatch(payloadData, logOptions);
+ payloadDataDispatcher.dispatch(payloadData, logOptions);
+
+ verify(dataSender, never()).send(anyList(), eq(logOptions));
+ }
+
+ @Test
+ void dispatch_ShouldHandleException_DuringDispatchPhase() {
+ when(payloadConstructor.construct(payloadData, logOptions)).thenThrow(new RuntimeException("Construction error"));
+
+ payloadDataDispatcher.dispatch(payloadData, logOptions);
+
+ verifyNoInteractions(dataSender);
+ }
+
+ private PayloadData createStubPayloadData() {
+ return PayloadData.builder()
+ .userData(UserData.builder().apiKey("test-api-key").build())
+ .build();
+ }
+}
\ No newline at end of file
diff --git a/packages/java/metrics-core/src/test/java/com/readme/core/datatransfer/ReadmeApiKeyEncoderTest.java b/packages/java/metrics-core/src/test/java/com/readme/core/datatransfer/ReadmeApiKeyEncoderTest.java
new file mode 100644
index 000000000..ed82c6409
--- /dev/null
+++ b/packages/java/metrics-core/src/test/java/com/readme/core/datatransfer/ReadmeApiKeyEncoderTest.java
@@ -0,0 +1,38 @@
+package com.readme.core.datatransfer;
+
+import com.readme.core.datatransfer.ReadmeApiKeyEncoder;
+import org.junit.jupiter.api.Test;
+
+import java.util.Base64;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+
+class ReadmeApiKeyEncoderTest {
+
+ @Test
+ void encode_ShouldReturnBase64EncodedApiKey_WithBasicPrefix() {
+ String apiKey = "my-test-api-key";
+ String expectedEncodedApiKey = "Basic " + Base64.getEncoder().encodeToString((apiKey + ":").getBytes());
+ String result = ReadmeApiKeyEncoder.encode(apiKey);
+
+ assertEquals(expectedEncodedApiKey, result);
+ }
+
+ @Test
+ void encode_ShouldHandleEmptyApiKey() {
+ String apiKey = "";
+ String expectedEncodedApiKey = "Basic " + Base64.getEncoder().encodeToString(":".getBytes());
+ String result = ReadmeApiKeyEncoder.encode(apiKey);
+
+ assertEquals(expectedEncodedApiKey, result);
+ }
+
+ @Test
+ void encode_ShouldReturnCorrectEncodedValue_ForSpecialCharactersInApiKey() {
+ String apiKey = "api-key!@#$%^&*()";
+ String expectedEncodedApiKey = "Basic " + Base64.getEncoder().encodeToString((apiKey + ":").getBytes());
+ String result = ReadmeApiKeyEncoder.encode(apiKey);
+
+ assertEquals(expectedEncodedApiKey, result);
+ }
+}
\ No newline at end of file
diff --git a/packages/java/metrics-core/src/test/java/com/readme/core/webhook/WebhookVerifierTest.java b/packages/java/metrics-core/src/test/java/com/readme/core/webhook/WebhookVerifierTest.java
new file mode 100644
index 000000000..0498be8e2
--- /dev/null
+++ b/packages/java/metrics-core/src/test/java/com/readme/core/webhook/WebhookVerifierTest.java
@@ -0,0 +1,66 @@
+package com.readme.core.webhook;
+
+import com.readme.core.webhook.WebhookVerifier;
+import org.junit.jupiter.api.Test;
+import java.time.Instant;
+import java.util.Map;
+
+import static org.junit.jupiter.api.Assertions.*;
+
+class WebhookVerifierTest {
+
+ private static final String SECRET = "my-secret-key";
+ private static final String VALID_BODY = "{\"email\":\"test@example.com\"}";
+ private static final long VALID_TIMESTAMP = Instant.now().toEpochMilli();
+
+ @Test
+ void testVerifyWebhook_HappyPath() {
+ String signature = generateSignature(VALID_BODY, VALID_TIMESTAMP, SECRET);
+ Map result = WebhookVerifier.verifyWebhook(VALID_BODY, signature, SECRET);
+
+ assertNotNull(result);
+ assertEquals("{\"email\":\"test@example.com\"}", result.get("email"));
+ }
+
+ @Test
+ void testVerifyWebhook_MissingSignature() {
+ Exception exception = assertThrows(IllegalArgumentException.class, () ->
+ WebhookVerifier.verifyWebhook(VALID_BODY, null, SECRET)
+ );
+ assertEquals("Missing Signature", exception.getMessage());
+ }
+
+ @Test
+ void testVerifyWebhook_InvalidSignatureFormat() {
+ String invalidSignature = "t=invalid,v1=invalid";
+ Exception exception = assertThrows(IllegalArgumentException.class, () ->
+ WebhookVerifier.verifyWebhook(VALID_BODY, invalidSignature, SECRET)
+ );
+ assertEquals("Invalid Signature Format", exception.getMessage());
+ }
+
+ @Test
+ void testVerifyWebhook_ExpiredSignature() {
+ long expiredTimestamp = VALID_TIMESTAMP - (WebhookVerifier.SIGNATURE_EXPIRATION_TIME + 1000);
+ String signature = generateSignature(VALID_BODY, expiredTimestamp, SECRET);
+ Exception exception = assertThrows(IllegalArgumentException.class, () ->
+ WebhookVerifier.verifyWebhook(VALID_BODY, signature, SECRET)
+ );
+ assertEquals("Expired Signature", exception.getMessage());
+ }
+
+ @Test
+ void testVerifyWebhook_InvalidSignature() {
+ String invalidSignature = "t=" + VALID_TIMESTAMP + ",v0=invalid-signature";
+ Exception exception = assertThrows(IllegalArgumentException.class, () ->
+ WebhookVerifier.verifyWebhook(VALID_BODY, invalidSignature, SECRET)
+ );
+ assertEquals("Invalid Signature", exception.getMessage());
+ }
+
+ private String generateSignature(String body, long timestamp, String secret) {
+ String unsigned = timestamp + "." + body;
+ String computedSignature = WebhookVerifier.computeHmacSHA256(unsigned, secret);
+ return "t=" + timestamp + ",v0=" + computedSignature;
+ }
+}
diff --git a/packages/java/metrics-spring/.gitignore b/packages/java/metrics-spring/.gitignore
new file mode 100644
index 000000000..23d280fe4
--- /dev/null
+++ b/packages/java/metrics-spring/.gitignore
@@ -0,0 +1,26 @@
+README.md
+target/
+!.mvn/wrapper/maven-wrapper.jar
+!**/src/main/**/target/
+!**/src/test/**/target/
+
+### Eclipse ###
+.apt_generated
+.classpath
+.factorypath
+.project
+.settings
+.springBeans
+.sts4-cache
+
+### IntelliJ IDEA ###
+.idea
+*.iws
+*.iml
+*.ipr
+
+### VS Code ###
+.vscode/
+
+### MAC ###
+.DS_Store
diff --git a/packages/java/metrics-spring/README.md b/packages/java/metrics-spring/README.md
new file mode 100644
index 000000000..85fb00326
--- /dev/null
+++ b/packages/java/metrics-spring/README.md
@@ -0,0 +1,185 @@
+## Table of Contents
+
+1. [Overview](#overview)
+2. [Readme configuration](#readme-configuration)
+3. [User Data Configuration](#user-data-configuration)
+ - [Custom User Data Collector](#custom-user-data-collector)
+ - [YAML and Properties-based Configuration](#yaml-and-properties-based-configuration)
+4. [Logging Configuration](#logging-configuration)
+5. [Webhook Verification](#webhook-verification)
+
+---
+
+## Overview
+
+This documentation provides a clear and structured guide for integrating ReadMe Metrics SDK into your Spring Boot application. 🚀
+The SDK is designed to collect detailed information from HTTP requests and responses, as well as user-specific data,
+for better observability and insights into application behavior.
+
+### Key Features:
+
+**Request and Response Data Logging**:
+
+- Collects HTTP request and response details, including headers, body content, and HTTP status codes.
+- Ensures minimal impact on the application's core business logic by leveraging efficient wrappers for request and response processing.
+
+**User Data Extraction**:
+
+- Logs information about the user making the request, such as `api-key`, `email`, and `label`.
+- Supports multiple configurable data extraction methods:
+ - **HTTP headers**
+ - **JWT claims**
+ - **JSON body fields**
+
+---
+
+## Readme configuration
+
+Metrics-spring expects you to add `readme.readmeApiKey` property to your application `yaml` or `properties` configuration.
+
+```yaml
+readme:
+ readmeApiKey: ${README_API_KEY}
+```
+
+---
+
+## User Data Configuration
+
+The library provides multiple ways to configure user data extraction. The recommended approach is to define a custom implementation of `UserDataCollector`.
+However, you can also use YAML or properties-based configuration.
+
+### Custom User Data Collector
+
+The preferred method for configuring user data extraction is by defining a custom implementation of `UserDataCollector`.
+This allows for greater flexibility and customization of how user-specific data is collected.
+
+#### Example: Custom Implementation
+
+```java
+@Configuration
+public class CustomUserDataCollectorConfig {
+
+ @Bean
+ public UserDataCollector customUserDataCollector() {
+ return payloadAdapter -> {
+ String apiKey = payloadAdapter.getRequestHeaders().get("x-user-name");
+
+ return UserData.builder()
+ .apiKey(apiKey)
+ .email("owl@birdfact0ry.abc")
+ .label("owl-label")
+ .build();
+ };
+ }
+}
+```
+
+This approach gives you full control over how user data is extracted from requests. Use `payloadAdapter` to get access
+to different parts of an HTTP request (headers, body etc.)
+
+---
+
+### YAML and Properties-based Configuration
+
+As an alternative to implementing `UserDataCollector`, you can configure user data extraction through your application configuration files.
+Using this approach, the library will try to get user data automatically based on provided properties.
+
+Each field (`apiKey`, `email`, `label`) requires two properties:
+
+- **`source`**: Specifies where to extract the data from.
+ - Possible values:
+ - `header`: Extracts data from an HTTP header.
+ - `jwtClaim`: Extracts data from a JWT token claim.
+ - `jsonBody`: Extracts data from the JSON body of a request.
+- **`fieldName`**: The key or field name corresponding to the specified source.
+
+#### Example YAML Configuration
+
+```yaml
+readme:
+ userdata:
+ apiKey:
+ source: header
+ fieldName: X-User-Id
+ email:
+ source: jwtClaim
+ fieldName: aud
+ label:
+ source: jsonBody
+ fieldName: user/name
+```
+
+#### Example Properties Configuration
+
+```properties
+readme.userdata.apikey.source=header
+readme.userdata.apikey.fieldname=X-User-Id
+
+readme.userdata.email.source=jwt
+readme.userdata.email.fieldname=aud
+
+readme.userdata.label.source=jsonBody
+readme.userdata.label.fieldname=user/name
+```
+
+While YAML and properties-based configuration offer a minimal setup, **using a custom implementation provides more flexibility and customization**.
+
+---
+
+## Logging Configuration
+
+By default, logging for the `com.readme` package is **inactive** to prevent unnecessary log clutter.
+
+If you want to enable logging for this library, you can set the logging level in your `application.properties` or `application.yaml` file:
+
+**application.properties:**
+
+```properties
+logging.level.com.readme=DEBUG
+```
+
+**application.yaml:**
+
+```yaml
+logging:
+ level:
+ com.readme: DEBUG
+```
+
+You can replace `DEBUG` with any other log level (`INFO`, `WARN`, `ERROR`, etc.) according to your needs.
+
+---
+
+## Webhook Verification
+
+The library includes a `WebhookVerifier` utility to help verify webhooks received from ReadMe.
+This ensures that webhook payloads are legitimate and have not been tampered with.
+
+### How to Use WebhookVerifier
+
+To verify incoming webhooks, use the `WebhookVerifier` utility within a Spring Boot controller.
+
+#### Example Implementation
+
+```java
+@RestController
+@RequestMapping("/webhook")
+public class WebhookController {
+
+ private static final String SECRET = "my_secret";
+
+ @PostMapping(consumes = "application/json", produces = "application/json")
+ public ResponseEntity handleWebhook(@RequestBody String requestBody,
+ @RequestHeader("readme-signature") String signature) {
+ WebhookVerifier.verifyWebhook(requestBody, signature, SECRET);
+ return ResponseEntity.ok(new WebhookResponse("Owl Bowl"));
+ }
+
+ @Data
+ @AllArgsConstructor
+ public static class WebhookResponse {
+ private String user;
+ }
+}
+```
diff --git a/packages/java/metrics-spring/pom.xml b/packages/java/metrics-spring/pom.xml
new file mode 100644
index 000000000..1a1d3847d
--- /dev/null
+++ b/packages/java/metrics-spring/pom.xml
@@ -0,0 +1,142 @@
+
+
+ 4.0.0
+
+
+
+ MIT License
+ https://opensource.org/licenses/MIT
+
+
+
+
+ readme
+ Readme
+ engineers@readme.io
+
+
+
+ https://github.com/readmeio/metrics-sdks
+ scm:git:git://github.com/readmeio/metrics-sdks.git
+ scm:git:ssh://git@github.com:readmeio/metrics-sdks.git
+ HEAD
+
+ https://github.com/readmeio/metrics-sdks
+
+
+ org.springframework.boot
+ spring-boot-starter-parent
+ 3.3.5
+
+
+
+ com.readme
+ metrics-spring
+ 0.1.0
+ metrics-spring
+ Readme metrics SDK for Spring Boot 3+
+
+
+ 17
+ 0.1.0
+
+
+
+
+ org.springframework.boot
+ spring-boot-starter-web
+
+
+ org.springframework.boot
+ spring-boot-configuration-processor
+ true
+
+
+ com.readme
+ metrics-core
+ ${readme-metrics.version}
+
+
+
+
+ com.auth0
+ java-jwt
+ 4.4.0
+
+
+
+
+ org.springframework.boot
+ spring-boot-starter-test
+ test
+
+
+
+
+
+
+ org.apache.maven.plugins
+ maven-compiler-plugin
+ 3.13.0
+
+ ${maven.compiler.release}
+
+
+
+ org.apache.maven.plugins
+ maven-surefire-plugin
+ 3.1.2
+
+
+ org.sonatype.central
+ central-publishing-maven-plugin
+ 0.7.0
+ true
+
+ central
+
+
+
+ org.apache.maven.plugins
+ maven-source-plugin
+ 3.3.1
+
+
+ attach-sources
+
+ jar
+
+
+
+
+
+ org.apache.maven.plugins
+ maven-javadoc-plugin
+ 3.11.2
+
+
+ attach-javadocs
+
+ jar
+
+
+
+
+
+ org.apache.maven.plugins
+ maven-gpg-plugin
+ 3.2.7
+
+
+ sign-artifacts
+ deploy
+
+ sign
+
+
+
+
+
+
+
diff --git a/packages/java/metrics-spring/src/main/java/com/readme/spring/config/DataCollectionAutoConfiguration.java b/packages/java/metrics-spring/src/main/java/com/readme/spring/config/DataCollectionAutoConfiguration.java
new file mode 100644
index 000000000..747f61917
--- /dev/null
+++ b/packages/java/metrics-spring/src/main/java/com/readme/spring/config/DataCollectionAutoConfiguration.java
@@ -0,0 +1,149 @@
+package com.readme.spring.config;
+
+import com.readme.core.config.CoreConfig;
+
+import com.readme.core.dataextraction.LogOptions;
+import com.readme.core.dataextraction.payload.requestresponse.RequestDataCollector;
+import com.readme.core.dataextraction.payload.user.UserDataCollector;
+import com.readme.core.datatransfer.DataSender;
+import com.readme.core.datatransfer.HttpDataSender;
+import com.readme.core.datatransfer.OutgoingLogBodyConstructor;
+import com.readme.core.datatransfer.PayloadDataDispatcher;
+import com.readme.spring.datacollection.DataCollectionFilter;
+import com.readme.spring.datacollection.ServletDataPayloadAdapter;
+import com.readme.spring.datacollection.userinfo.ServletUserDataCollector;
+import com.readme.spring.datacollection.userinfo.UserDataExtractor;
+import jakarta.annotation.PostConstruct;
+import lombok.AllArgsConstructor;
+import lombok.extern.slf4j.Slf4j;
+import okhttp3.OkHttpClient;
+import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean;
+import org.springframework.boot.logging.LogLevel;
+import org.springframework.boot.logging.LoggingSystem;
+import org.springframework.boot.web.servlet.FilterRegistrationBean;
+import org.springframework.context.annotation.Bean;
+import org.springframework.context.annotation.ComponentScan;
+import org.springframework.context.annotation.Configuration;
+import org.springframework.core.Ordered;
+import org.springframework.core.env.Environment;
+
+/**
+ * Configuration class for registering and initializing the JakartaDataCollectionFilter
+ * along with its dependencies in a Spring Boot application.
+ *
+ * This configuration provides the following:
+ *
+ * - Instantiates the {@link DataCollectionFilter} with required collectors.
+ * - Registers the filter using {@link FilterRegistrationBean} for servlet-based applications.
+ * - Sets up default implementations for collecting request and user data.
+ *
+ */
+@Configuration
+@ComponentScan(basePackages = {"com.readme.spring"})
+@AllArgsConstructor
+@Slf4j
+public class DataCollectionAutoConfiguration {
+
+ private ReadmeConfigurationProperties readmeProperties;
+
+ private final LoggingSystem loggingSystem;
+
+ private final Environment environment;
+
+ /**
+ * Configures logging level for ReadMe SDK based on application properties.
+ */
+ @PostConstruct
+ public void configureLogging() {
+ String logLevel = environment.getProperty("com.readme.logging.level", "OFF");
+ loggingSystem.setLogLevel("com.readme", LogLevel.valueOf(logLevel));
+ }
+
+ /**
+ * Registers the {@link DataCollectionFilter} as a servlet filter to intercept HTTP requests.
+ *
+ * @param requestDataCollector component to extract request details
+ * @param userDataCollector component to extract user-specific data
+ * @param payloadDataDispatcher component responsible for sending collected data
+ * @param logOptions configuration options for logging
+ * @return a configured {@link FilterRegistrationBean} for data collection
+ */
+ @Bean
+ public FilterRegistrationBean metricsFilter(
+ RequestDataCollector requestDataCollector,
+ UserDataCollector userDataCollector,
+ PayloadDataDispatcher payloadDataDispatcher,
+ LogOptions logOptions) {
+ FilterRegistrationBean registrationBean = new FilterRegistrationBean<>();
+ registrationBean.setFilter(new DataCollectionFilter(userDataCollector, requestDataCollector, payloadDataDispatcher, logOptions));
+ registrationBean.setOrder(Ordered.HIGHEST_PRECEDENCE);
+ registrationBean.addUrlPatterns("/*");
+ return registrationBean;
+ }
+
+ /**
+ * Provides a default implementation of {@link UserDataCollector} if none is defined in the context.
+ *
+ * @param userDataProperties configuration properties for user data extraction
+ * @param extractionService service used to extract user info from requests
+ * @return an instance of {@link ServletUserDataCollector}
+ */
+ @Bean
+ @ConditionalOnMissingBean(UserDataCollector.class)
+ public UserDataCollector userDataCollector(UserDataProperties userDataProperties,
+ UserDataExtractor extractionService) {
+ log.info("readme-metrics: Creating of default user data collector");
+ return new ServletUserDataCollector(userDataProperties, extractionService);
+ }
+
+ /**
+ * Creates and configures the component responsible for sending log data to ReadMe API.
+ *
+ * @return an instance of {@link DataSender}.
+ */
+ @Bean
+ public DataSender dataSender() {
+ String readmeApiKey = readmeProperties.getReadmeApiKey();
+ CoreConfig coreConfig = CoreConfig.builder()
+ .readmeAPIKey(readmeApiKey)
+ .build();
+ OkHttpClient okHttpClient = new OkHttpClient();
+
+ return new HttpDataSender(okHttpClient, coreConfig);
+ }
+
+ /**
+ * Provides the component that transforms request/response/user data into HAR format.
+ *
+ * @return an instance of {@link OutgoingLogBodyConstructor}.
+ */
+ @Bean
+ public OutgoingLogBodyConstructor outgoingPayloadConstructor() {
+ return new OutgoingLogBodyConstructor();
+ }
+
+
+ /**
+ * Instantiates the dispatcher responsible for buffering and sending payloads.
+ *
+ * @param dataSender component to send data over HTTP
+ * @param outgoingLogConstructor component to build HAR-like logs from payload data
+ * @return a configured {@link PayloadDataDispatcher}
+ */
+ @Bean
+ public PayloadDataDispatcher payloadDataDispatcher(DataSender dataSender,
+ OutgoingLogBodyConstructor outgoingLogConstructor) {
+ return new PayloadDataDispatcher(dataSender, outgoingLogConstructor);
+ }
+
+ /**
+ * Provides default logging configuration options if none is defined.
+ *
+ * @return an instance of {@link LogOptions}.
+ */
+ @Bean
+ @ConditionalOnMissingBean(LogOptions.class)
+ public LogOptions logOptions() {
+ return LogOptions.builder().build();
+ }
+}
diff --git a/packages/java/metrics-spring/src/main/java/com/readme/spring/config/ReadmeConfigurationProperties.java b/packages/java/metrics-spring/src/main/java/com/readme/spring/config/ReadmeConfigurationProperties.java
new file mode 100644
index 000000000..19b941729
--- /dev/null
+++ b/packages/java/metrics-spring/src/main/java/com/readme/spring/config/ReadmeConfigurationProperties.java
@@ -0,0 +1,29 @@
+package com.readme.spring.config;
+
+import lombok.Data;
+import org.springframework.boot.context.properties.ConfigurationProperties;
+import org.springframework.stereotype.Component;
+
+/**
+ * Configuration properties for the ReadMe Metrics SDK.
+ *
+ * This class binds to properties defined with the prefix readme
+ * in the application's application.yaml or application.properties file.
+ *
+ * Example usage in application.yaml:
+ *
+ * readme:
+ * readmeApiKey: your-api-key-here
+ *
+ */
+@Data
+@Component
+@ConfigurationProperties(prefix = "readme")
+public class ReadmeConfigurationProperties {
+
+ /**
+ * The API key used to authenticate requests to the ReadMe platform.
+ */
+ private String readmeApiKey;
+
+}
diff --git a/packages/java/metrics-spring/src/main/java/com/readme/spring/config/UserDataProperties.java b/packages/java/metrics-spring/src/main/java/com/readme/spring/config/UserDataProperties.java
new file mode 100644
index 000000000..aed7cea57
--- /dev/null
+++ b/packages/java/metrics-spring/src/main/java/com/readme/spring/config/UserDataProperties.java
@@ -0,0 +1,28 @@
+package com.readme.spring.config;
+
+import com.readme.spring.datacollection.userinfo.FieldMapping;
+import lombok.Data;
+import org.springframework.boot.context.properties.ConfigurationProperties;
+import org.springframework.stereotype.Component;
+
+/**
+ * Configuration properties for monitoring library.
+ *
+ * This class allows users to configure sources and field names for extracting
+ * user-related information (e.g., username, email, and label) from HTTP requests.
+ * Each field (username, email, label) can be configured with a specific source
+ * (e.g., header, jwtClaim, or jsonBody) and its corresponding value.
+ *
+ */
+
+@Data
+@Component
+@ConfigurationProperties(prefix = "readme.userdata")
+public class UserDataProperties {
+
+ private FieldMapping apiKey;
+ private FieldMapping email;
+ private FieldMapping label;
+
+}
+
diff --git a/packages/java/metrics-spring/src/main/java/com/readme/spring/datacollection/DataCollectionFilter.java b/packages/java/metrics-spring/src/main/java/com/readme/spring/datacollection/DataCollectionFilter.java
new file mode 100644
index 000000000..68d76e2fb
--- /dev/null
+++ b/packages/java/metrics-spring/src/main/java/com/readme/spring/datacollection/DataCollectionFilter.java
@@ -0,0 +1,95 @@
+package com.readme.spring.datacollection;
+
+import com.readme.core.dataextraction.LogOptions;
+import com.readme.core.dataextraction.payload.PayloadData;
+import com.readme.core.dataextraction.payload.requestresponse.ApiCallLogData;
+import com.readme.core.dataextraction.payload.requestresponse.RequestDataCollector;
+import com.readme.core.dataextraction.payload.user.UserData;
+import com.readme.core.dataextraction.payload.user.UserDataCollector;
+import com.readme.core.datatransfer.PayloadDataDispatcher;
+import jakarta.servlet.*;
+import jakarta.servlet.http.HttpServletRequest;
+import jakarta.servlet.http.HttpServletResponse;
+import lombok.AllArgsConstructor;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.web.util.ContentCachingRequestWrapper;
+import org.springframework.web.util.ContentCachingResponseWrapper;
+import java.io.IOException;
+import java.util.Date;
+import java.util.concurrent.CompletableFuture;
+
+import static com.readme.core.dataextraction.payload.PayloadData.*;
+import static org.springframework.http.HttpMethod.OPTIONS;
+
+
+/**
+ * Servlet filter for collecting HTTP request and response data to be sent to ReadMe Metrics.
+ *
+ * This filter wraps incoming requests and responses to capture relevant metadata, user info,
+ * payload content, and asynchronously dispatches the structured data to the configured destination.
+ */
+@AllArgsConstructor
+@Slf4j
+public class DataCollectionFilter implements Filter {
+
+ private UserDataCollector userDataCollector;
+
+ private RequestDataCollector requestDataCollector;
+
+ private PayloadDataDispatcher payloadDispatcher;
+
+ private LogOptions logOptions;
+
+ /**
+ * Intercepts HTTP requests and responses to extract structured log data for ReadMe metrics.
+ *
+ * For non-OPTIONS requests, this method wraps the request/response, collects user and API call data,
+ * and asynchronously sends it via {@link PayloadDataDispatcher}.
+ *
+ * @param req the incoming {@link ServletRequest}
+ * @param resp the outgoing {@link ServletResponse}
+ * @param chain the {@link FilterChain} to continue request processing
+ * @throws IOException in case of I/O errors
+ * @throws ServletException in case of servlet processing errors
+ */
+ @Override
+ public void doFilter(ServletRequest req, ServletResponse resp, FilterChain chain) throws IOException, ServletException {
+ ContentCachingRequestWrapper request = new ContentCachingRequestWrapper((HttpServletRequest) req);
+ ContentCachingResponseWrapper response = new ContentCachingResponseWrapper((HttpServletResponse) resp);
+
+ PayloadDataBuilder payloadDataBuilder = PayloadData.builder();
+ payloadDataBuilder.requestStartedDateTime(new Date());
+
+ try {
+ if (request.getMethod().equalsIgnoreCase(OPTIONS.name())) {
+ chain.doFilter(req, resp);
+ } else {
+ chain.doFilter(request, response);
+ ServletDataPayloadAdapter payload =
+ new ServletDataPayloadAdapter(request, response);
+
+ UserData userData = userDataCollector.collect(payload);
+ payloadDataBuilder.userData(userData);
+
+ ApiCallLogData apiCallLogData = requestDataCollector.collect(payload);
+ payloadDataBuilder.apiCallLogData(apiCallLogData);
+ payloadDataBuilder.responseEndDateTime(new Date());
+
+ PayloadData payloadData = payloadDataBuilder.build();
+ response.copyBodyToResponse();
+
+ CompletableFuture.runAsync(() -> {
+ try {
+ payloadDispatcher.dispatch(payloadData, logOptions);
+ log.info("Data dispatched successfully");
+ } catch (Exception e) {
+ log.error("Error occurred while sending payload data", e.getMessage());
+ }
+ });
+ }
+ } catch (Exception e){
+ log.error("Error occurred while processing request by readme metrics-sdk: {}", e.getMessage());
+ }
+ }
+
+}
diff --git a/packages/java/metrics-spring/src/main/java/com/readme/spring/datacollection/ServletDataPayloadAdapter.java b/packages/java/metrics-spring/src/main/java/com/readme/spring/datacollection/ServletDataPayloadAdapter.java
new file mode 100644
index 000000000..469ad477b
--- /dev/null
+++ b/packages/java/metrics-spring/src/main/java/com/readme/spring/datacollection/ServletDataPayloadAdapter.java
@@ -0,0 +1,215 @@
+package com.readme.spring.datacollection;
+
+import com.readme.core.dataextraction.DataPayloadAdapter;
+import jakarta.servlet.http.HttpServletResponse;
+import lombok.AllArgsConstructor;
+
+import jakarta.servlet.http.HttpServletRequest;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.http.HttpStatus;
+import org.springframework.web.util.ContentCachingRequestWrapper;
+import org.springframework.web.util.ContentCachingResponseWrapper;
+
+import java.util.*;
+import java.util.stream.Collectors;
+
+/**
+ * An implementation of {@link DataPayloadAdapter} that adapts a servlet-based HTTP request and response.
+ *
+ * This class wraps {@link ContentCachingRequestWrapper} and {@link ContentCachingResponseWrapper}
+ * to allow capturing the full content of HTTP requests and responses for logging and monitoring.
+ */
+@Slf4j
+@AllArgsConstructor
+public class ServletDataPayloadAdapter implements DataPayloadAdapter {
+
+ private ContentCachingRequestWrapper request;
+ private ContentCachingResponseWrapper response;
+
+ /**
+ * Returns the HTTP method of the request (e.g., GET, POST).
+ */
+ @Override
+ public String getRequestMethod() {
+ return request.getMethod();
+ }
+
+
+ /**
+ * Returns the value of the Content-Type header from the request.
+ */
+ @Override
+ public String getRequestContentType() {
+ return request.getContentType();
+ }
+
+ /**
+ * Retrieves the body of the request as a string.
+ *
+ * If an error occurs while reading the request body, an empty string is returned and an error is logged.
+ */
+ @Override
+ public String getRequestBody() {
+ try {
+ return request.getContentAsString();
+ } catch (Exception e) {
+ log.error("Error when trying to get request body: {}", e.getMessage());
+ }
+ return "";
+ }
+
+ /**
+ * Returns the remote address (client IP) of the incoming request.
+ */
+ @Override
+ public String getAddress() {
+ return request.getRemoteAddr();
+ }
+
+
+ /**
+ * Returns the protocol used for the request (e.g., HTTP/1.1).
+ */
+ @Override
+ public String getProtocol() {
+ return request.getProtocol();
+ }
+
+ /**
+ * Returns the full URL that was requested.
+ */
+ @Override
+ public String getUrl() {
+ return request.getRequestURL().toString();
+ }
+
+ /**
+ * Returns a map of request parameters where each parameter name is mapped to its joined value string.
+ *
+ * Multi-valued parameters are joined using an empty string.
+ */
+ @Override
+ public Map getRequestParameters() {
+ return request.getParameterMap()
+ .entrySet()
+ .stream()
+ .collect(Collectors
+ .toMap(Map.Entry::getKey,
+ e -> String.join("", e.getValue())));
+ }
+
+ /**
+ * Retrieves all request headers from the {@link HttpServletRequest} and returns them
+ * as a map where the header names are normalized to lowercase.
+ *
+ * This method ensures consistent header name formatting by converting all
+ * header names to lowercase, which is particularly useful for avoiding case-sensitivity
+ * issues when accessing HTTP headers.
+ *
+Example:
+ * If the request contains headers:
+ *
+ * - Authorization: Bearer token
+ * - X-User-Id: 12345
+ *
+ * The resulting map will look like:
+ *
+ * {
+ * "authorization": "Bearer token",
+ * "x-user-id": "12345"
+ * }
+ *
+ *
+ * @return a map of request header names (lowercased) and their corresponding values.
+ * If no headers are present or provided request is null, returns an empty map.
+ */
+ @Override
+ public Map getRequestHeaders() {
+ if (request != null) {
+ Map headers = new HashMap<>();
+ Enumeration headerNames = request.getHeaderNames();
+
+ while (headerNames.hasMoreElements()) {
+ String headerName = headerNames.nextElement().toLowerCase();
+ headers.put(headerName, request.getHeader(headerName));
+ }
+ return headers;
+ }
+ log.error("The provided request is null");
+ return Collections.emptyMap();
+ }
+
+ /**
+ * Retrieves the response body as a string.
+ *
+ * If an error occurs while reading the response body, an empty string is returned and an error is logged.
+ */
+ @Override
+ public String getResponseBody() {
+ try {
+ byte[] contentAsByteArray = response.getContentAsByteArray();
+ return new String(contentAsByteArray);
+ } catch (Exception e) {
+ log.error("Error when trying to get response body: {}", e.getMessage());
+ }
+ return "";
+ }
+
+ /**
+ * Returns the HTTP status code of the response (e.g., 200, 404).
+ */
+ @Override
+ public int getStatusCode() {
+ return response.getStatus();
+ }
+
+
+ /**
+ * Returns the standard reason phrase for the response status code.
+ *
+ * For example, 200 returns "OK", 404 returns "Not Found".
+ * Returns an empty string if the status code is unrecognized.
+ */
+ @Override
+ public String getStatusMessage() {
+ HttpStatus httpStatus = HttpStatus.resolve(response.getStatus());
+ return httpStatus != null ? httpStatus.getReasonPhrase() : "";
+ }
+
+ /**
+ * Retrieves all response headers from the {@link HttpServletResponse} and returns them
+ * as a map where the header names are preserved in their original case.
+ *
+ *
This method iterates through all header names provided by the {@link HttpServletResponse}
+ * and maps each header name to its corresponding value.
+ *
+ * Example:
+ * If the response contains headers:
+ *
+ * - Content-Type: application/json
+ * - X-Custom-Header: custom-value
+ *
+ * The resulting map will look like:
+ *
+ * {
+ * "Content-Type": "application/json",
+ * "X-Custom-Header": "custom-value"
+ * }
+ *
+ *
+ * @return a map of response header names and their corresponding values.
+ * If no headers are present or provided response is null, returns an empty map.
+ */
+ @Override
+ public Map getResponseHeaders() {
+ if (response != null) {
+ return response.getHeaderNames().stream()
+ .collect(Collectors.toMap(
+ headerName -> headerName,
+ headerName -> response.getHeader(headerName)));
+ }
+ log.error("The provided response is null");
+ return Collections.emptyMap();
+ }
+
+}
diff --git a/packages/java/metrics-spring/src/main/java/com/readme/spring/datacollection/ServletRequestDataCollector.java b/packages/java/metrics-spring/src/main/java/com/readme/spring/datacollection/ServletRequestDataCollector.java
new file mode 100644
index 000000000..e6d3c6871
--- /dev/null
+++ b/packages/java/metrics-spring/src/main/java/com/readme/spring/datacollection/ServletRequestDataCollector.java
@@ -0,0 +1,49 @@
+package com.readme.spring.datacollection;
+
+
+import com.readme.core.dataextraction.payload.requestresponse.ApiCallLogData;
+import com.readme.core.dataextraction.payload.requestresponse.RequestData;
+import com.readme.core.dataextraction.payload.requestresponse.RequestDataCollector;
+import com.readme.core.dataextraction.payload.requestresponse.ResponseData;
+import lombok.AllArgsConstructor;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.stereotype.Component;
+
+
+@Slf4j
+@AllArgsConstructor
+@Component
+public class ServletRequestDataCollector implements RequestDataCollector {
+
+ @Override
+ public ApiCallLogData collect(ServletDataPayloadAdapter dataPayload) {
+ return ApiCallLogData
+ .builder()
+ .requestData(buildRequestData(dataPayload))
+ .responseData(buildResponseData(dataPayload))
+ .build();
+ }
+
+ private RequestData buildRequestData(ServletDataPayloadAdapter dataPayload) {
+ return RequestData.builder()
+ .url(dataPayload.getUrl())
+ .method(dataPayload.getRequestMethod())
+ .protocol(dataPayload.getProtocol())
+ .remoteAddress(dataPayload.getAddress())
+ .headers(dataPayload.getRequestHeaders())
+ .body(dataPayload.getRequestBody())
+ .routePath(dataPayload.getUrl())
+ .requestParameters(dataPayload.getRequestParameters())
+ .build();
+ }
+
+ private ResponseData buildResponseData(ServletDataPayloadAdapter dataPayload) {
+ return ResponseData.builder()
+ .headers(dataPayload.getResponseHeaders())
+ .body(dataPayload.getResponseBody())
+ .statusCode(dataPayload.getStatusCode())
+ .statusMessage(dataPayload.getStatusMessage())
+ .build();
+ }
+
+}
diff --git a/packages/java/metrics-spring/src/main/java/com/readme/spring/datacollection/userinfo/FieldMapping.java b/packages/java/metrics-spring/src/main/java/com/readme/spring/datacollection/userinfo/FieldMapping.java
new file mode 100644
index 000000000..e889b5d16
--- /dev/null
+++ b/packages/java/metrics-spring/src/main/java/com/readme/spring/datacollection/userinfo/FieldMapping.java
@@ -0,0 +1,22 @@
+package com.readme.spring.datacollection.userinfo;
+
+import lombok.AllArgsConstructor;
+import lombok.Data;
+import lombok.NoArgsConstructor;
+
+/**
+ * Represents a mapping source for extracting data from HTTP requests.
+ *
+ * A FieldMapping consists of a source type (e.g., header, jwtClaim, or jsonBody)
+ * and a fieldName that corresponds to the field's name or claim in the HTTP request.
+ *
+ */
+@Data
+@AllArgsConstructor
+@NoArgsConstructor
+public class FieldMapping {
+
+ private String source;
+ private String fieldName;
+
+}
diff --git a/packages/java/metrics-spring/src/main/java/com/readme/spring/datacollection/userinfo/ServletUserDataCollector.java b/packages/java/metrics-spring/src/main/java/com/readme/spring/datacollection/userinfo/ServletUserDataCollector.java
new file mode 100644
index 000000000..39039425a
--- /dev/null
+++ b/packages/java/metrics-spring/src/main/java/com/readme/spring/datacollection/userinfo/ServletUserDataCollector.java
@@ -0,0 +1,112 @@
+package com.readme.spring.datacollection.userinfo;
+
+
+import com.readme.core.dataextraction.payload.user.UserData;
+import com.readme.core.dataextraction.payload.user.UserDataCollector;
+import com.readme.core.dataextraction.payload.user.UserDataSource;
+import com.readme.spring.config.UserDataProperties;
+import com.readme.spring.datacollection.ServletDataPayloadAdapter;
+import lombok.AllArgsConstructor;
+import lombok.extern.slf4j.Slf4j;
+
+
+/**
+ * Responsible for selecting the appropriate {@link UserDataExtractor}
+ * based on the provided configuration in the application settings.
+ *
+ * This class acts as a bridge between YAML/Properties configuration and
+ * the corresponding strategy for extracting user-related data
+ * (e.g., from JSON body, headers, or JWT tokens).
+ *
+ * Ensures flexibility and proper encapsulation of the strategy selection logic.
+ */
+
+@AllArgsConstructor
+@Slf4j
+public class ServletUserDataCollector implements UserDataCollector {
+
+ private UserDataProperties userDataProperties;
+
+ private final UserDataExtractor extractionService;
+
+ @Override
+ public UserData collect(ServletDataPayloadAdapter payload) {
+
+ String apiKey = getApiKey(payload);
+ String email = getEmail(payload);
+ String label = getLabel(payload);
+
+ return UserData.builder()
+ .apiKey(apiKey)
+ .email(email)
+ .label(label)
+ .build();
+
+ }
+
+ private String getApiKey(ServletDataPayloadAdapter payload) {
+ FieldMapping apiKey = userDataProperties.getApiKey();
+ if (apiKey == null) {
+ log.error("api-key extraction is not configured properly");
+ return "";
+ }
+ return extractFieldValue(payload, apiKey);
+ }
+
+ private String getEmail(ServletDataPayloadAdapter payload) {
+ FieldMapping apiKey = userDataProperties.getEmail();
+ if (apiKey == null) {
+ log.error("email extraction is not configured properly");
+ return "";
+ }
+ return extractFieldValue(payload, apiKey);
+ }
+
+ private String getLabel(ServletDataPayloadAdapter payload) {
+ FieldMapping apiKey = userDataProperties.getLabel();
+ if (apiKey == null) {
+ log.error("label extraction is not configured properly");
+ return "";
+ }
+ return extractFieldValue(payload, apiKey);
+ }
+
+ private String extractFieldValue(ServletDataPayloadAdapter payload, FieldMapping fieldMapping) {
+ if (fieldMapping.getSource().equals(UserDataSource.HEADER.getValue())) {
+ String fieldName = fieldMapping.getFieldName().toLowerCase();
+ String fieldValue = extractionService.extractFromHeader(payload, fieldName);
+
+ validate(fieldValue);
+ return fieldValue;
+ }
+
+ if (fieldMapping.getSource().equals(UserDataSource.BODY.getValue())) {
+ String fieldName = fieldMapping.getFieldName().toLowerCase();
+ String fieldValue = extractionService.extractFromBody(payload, fieldName);
+
+ validate(fieldValue);
+ return fieldValue;
+ }
+
+ if (fieldMapping.getSource().equals(UserDataSource.JWT.getValue())) {
+ String fieldName = fieldMapping.getFieldName().toLowerCase();
+ String fieldValue = extractionService.extractFromJwt(payload, fieldName);
+
+ validate(fieldValue);
+ return fieldValue;
+ }
+
+ log.error("unknown field source: {}", fieldMapping.getSource());
+
+ return "";
+ }
+
+ private void validate(String fieldValue) {
+ if (fieldValue == null || fieldValue.isEmpty()) {
+ log.error("The {} extraction is not configured properly. The value is empty", fieldValue);
+ }
+ }
+
+}
+
+
diff --git a/packages/java/metrics-spring/src/main/java/com/readme/spring/datacollection/userinfo/ServletUserDataExtractor.java b/packages/java/metrics-spring/src/main/java/com/readme/spring/datacollection/userinfo/ServletUserDataExtractor.java
new file mode 100644
index 000000000..c777183f8
--- /dev/null
+++ b/packages/java/metrics-spring/src/main/java/com/readme/spring/datacollection/userinfo/ServletUserDataExtractor.java
@@ -0,0 +1,81 @@
+package com.readme.spring.datacollection.userinfo;
+
+import com.auth0.jwt.interfaces.DecodedJWT;
+import com.fasterxml.jackson.databind.JsonNode;
+import com.fasterxml.jackson.databind.ObjectMapper;
+import com.readme.spring.datacollection.ServletDataPayloadAdapter;
+import lombok.AllArgsConstructor;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.http.HttpMethod;
+import org.springframework.stereotype.Component;
+import com.auth0.jwt.JWT;
+
+
+import java.util.Map;
+
+
+@AllArgsConstructor
+@Component
+@Slf4j
+public class ServletUserDataExtractor implements UserDataExtractor {
+
+ private ObjectMapper objectMapper;
+
+ @Override
+ public String extractFromHeader(ServletDataPayloadAdapter payload, String fieldName) {
+ Map requestHeaders = payload.getRequestHeaders();
+ if (requestHeaders.containsKey(fieldName)) {
+ return requestHeaders.get(fieldName);
+ }
+ log.error("The provided header name {} does not exist.", fieldName);
+ return "";
+ }
+
+ @Override
+ public String extractFromBody(ServletDataPayloadAdapter payload, String fieldPath) {
+ if (payload.getRequestMethod().equalsIgnoreCase(HttpMethod.GET.name())) {
+ log.error("The HTTP method {} is not supported to get user data from body.", payload.getRequestMethod());
+ return "";
+ }
+
+ if (!payload.getRequestContentType().equalsIgnoreCase("application/json")) {
+ log.error("The provided body content type {} is not supported to get user data.", payload.getRequestContentType());
+ return "";
+ }
+
+ try {
+ String requestBody = payload.getRequestBody();
+ JsonNode currentNode = objectMapper.readTree(requestBody);
+ if (!fieldPath.startsWith("/")) {
+ fieldPath = "/" + fieldPath;
+ }
+ return currentNode.at(fieldPath).asText();
+ } catch (Exception e) {
+ log.error("Error when reading the user data from JSON body: {}", e.getMessage());
+ }
+ return "";
+ }
+
+ @Override
+ public String extractFromJwt(ServletDataPayloadAdapter payload, String fieldName) {
+ try {
+ Map requestHeaders = payload.getRequestHeaders();
+ String jwtToken = requestHeaders.get("authorization");
+
+ if (jwtToken == null) {
+ log.error("The JWT token is not provided as Authorization header.");
+ return "";
+ }
+ if (jwtToken.startsWith("Bearer ")) {
+ jwtToken = jwtToken.substring(7);
+ }
+
+ DecodedJWT decodedJWT = JWT.decode(jwtToken);
+ return decodedJWT.getClaim(fieldName).asString();
+ } catch (Exception e) {
+ log.error("The Authorization token is invalid. {}", e.getMessage());
+ }
+ return "";
+ }
+
+}
diff --git a/packages/java/metrics-spring/src/main/java/com/readme/spring/datacollection/userinfo/UserDataExtractor.java b/packages/java/metrics-spring/src/main/java/com/readme/spring/datacollection/userinfo/UserDataExtractor.java
new file mode 100644
index 000000000..f5b5815d0
--- /dev/null
+++ b/packages/java/metrics-spring/src/main/java/com/readme/spring/datacollection/userinfo/UserDataExtractor.java
@@ -0,0 +1,45 @@
+package com.readme.spring.datacollection.userinfo;
+
+/**
+ * Defines a contract for extracting user-related data from an incoming request.
+ *
+ * Implementation of this interface is responsible for retrieving specific user data
+ * (e.g., username, email, label) from different sources such as:
+ * - JSON body
+ * - HTTP headers
+ * - JWT tokens
+ *
+ * @param the type of request object from which user data will be extracted.
+ * This can be a framework-specific class (e.g., HttpServletRequest, HttpServletDataPayload).
+ *
+ */
+public interface UserDataExtractor {
+
+ /**
+ * Extracts requested data from request header
+ *
+ * @param payload the type of request object from which user data will be extracted.
+ * @param fieldName is the source field name to extract the data
+ * @return extracted value as a String
+ */
+ String extractFromHeader(T payload, String fieldName);
+
+ /**
+ * Extracts requested data from JSON body
+ *
+ * @param payload the type of request object from which user data will be extracted.
+ * @param fieldPath is the source field name to extract the data
+ * @return extracted value as a String
+ */
+ String extractFromBody(T payload, String fieldPath);
+
+ /**
+ * Extracts requested data from JWT token
+ *
+ * @param payload the type of request object from which user data will be extracted.
+ * @param fieldName is the source field name to extract the data
+ * @return extracted value as a String
+ */
+ String extractFromJwt(T payload, String fieldName);
+
+}
diff --git a/packages/java/metrics-spring/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports b/packages/java/metrics-spring/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports
new file mode 100644
index 000000000..a89b64f5d
--- /dev/null
+++ b/packages/java/metrics-spring/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports
@@ -0,0 +1 @@
+com.readme.spring.config.DataCollectionAutoConfiguration
\ No newline at end of file
diff --git a/packages/java/metrics-spring/src/test/java/com/readme/spring/datacollection/DataCollectionFilterTest.java b/packages/java/metrics-spring/src/test/java/com/readme/spring/datacollection/DataCollectionFilterTest.java
new file mode 100644
index 000000000..04d371612
--- /dev/null
+++ b/packages/java/metrics-spring/src/test/java/com/readme/spring/datacollection/DataCollectionFilterTest.java
@@ -0,0 +1,141 @@
+package com.readme.spring.datacollection;
+
+import com.readme.core.dataextraction.LogOptions;
+import com.readme.core.dataextraction.payload.requestresponse.RequestDataCollector;
+import com.readme.core.dataextraction.payload.user.UserData;
+import com.readme.core.dataextraction.payload.user.UserDataCollector;
+import com.readme.core.datatransfer.PayloadDataDispatcher;
+import jakarta.servlet.FilterChain;
+import jakarta.servlet.ServletException;
+import jakarta.servlet.http.HttpServletRequest;
+import jakarta.servlet.http.HttpServletResponse;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+import org.mockito.*;
+import org.springframework.web.util.ContentCachingRequestWrapper;
+import org.springframework.web.util.ContentCachingResponseWrapper;
+
+import java.io.IOException;
+
+import static org.mockito.Mockito.*;
+
+class DataCollectionFilterTest {
+
+ @Mock
+ private RequestDataCollector requestDataCollector;
+
+ @Mock
+ private UserDataCollector userDataCollector;
+
+ @Mock
+ private HttpServletRequest request;
+
+ @Mock
+ private HttpServletResponse response;
+
+ @Mock
+ private FilterChain chain;
+
+ @Mock
+ private PayloadDataDispatcher payloadDataDispatcher;
+
+ private DataCollectionFilter filter;
+
+ @BeforeEach
+ void setUp() {
+ MockitoAnnotations.openMocks(this);
+ filter = new DataCollectionFilter(userDataCollector, requestDataCollector, payloadDataDispatcher, LogOptions.builder().build());
+ }
+
+ @Test
+ void doFilter_OptionsRequest_ShouldPassThroughWithoutProcessing() throws Exception {
+ when(request.getMethod()).thenReturn("OPTIONS");
+
+ filter.doFilter(request, response, chain);
+
+ verify(chain).doFilter(request, response);
+ verifyNoInteractions(requestDataCollector, userDataCollector);
+ }
+
+
+ @Test
+ void doFilter_GetRequest_ShouldProcessAndCollectData() throws Exception {
+ when(request.getMethod()).thenReturn("GET");
+ testChain();
+ }
+
+ @Test
+ void doFilter_PutRequest_ShouldProcessAndCollectData() throws Exception {
+ when(request.getMethod()).thenReturn("PUT");
+ testChain();
+ }
+
+ @Test
+ void doFilter_PostRequest_ShouldProcessAndCollectData() throws Exception {
+ when(request.getMethod()).thenReturn("POST");
+ testChain();
+ }
+
+ @Test
+ void doFilter_PatchRequest_ShouldProcessAndCollectData() throws Exception {
+ when(request.getMethod()).thenReturn("PATCH");
+ testChain();
+ }
+
+ @Test
+ void doFilter_DeleteRequest_ShouldProcessAndCollectData() throws Exception {
+ when(request.getMethod()).thenReturn("DELETE");
+ testChain();
+ }
+
+
+ private void testChain() throws IOException, ServletException {
+ UserData userData = getMockedUserData();
+ when(userDataCollector.collect(any(ServletDataPayloadAdapter.class))).thenReturn(userData);
+
+ filter.doFilter(request, response, chain);
+
+ verify(chain).doFilter(any(ContentCachingRequestWrapper.class), any(ContentCachingResponseWrapper.class));
+
+ ArgumentCaptor payloadCaptor = ArgumentCaptor.forClass(ServletDataPayloadAdapter.class);
+
+ verify(userDataCollector).collect(payloadCaptor.capture());
+ verify(requestDataCollector).collect(eq(payloadCaptor.getValue()));
+ }
+
+ private static UserData getMockedUserData() {
+ return UserData.builder()
+ .apiKey("Owl")
+ .email("owl@birdfactory.abc")
+ .label("owl-label")
+ .build();
+ }
+
+ @Test
+ void doFilter_UserDataCollectorThrowsException_ShouldHandleExceptionAndContinueFlow() throws Exception {
+ when(request.getMethod()).thenReturn("POST");
+ when(userDataCollector.collect(any(ServletDataPayloadAdapter.class)))
+ .thenThrow(new RuntimeException("Error in UserDataCollector"));
+
+ filter.doFilter(request, response, chain);
+
+ verify(chain).doFilter(any(ContentCachingRequestWrapper.class), any(ContentCachingResponseWrapper.class));
+ verify(requestDataCollector, never()).collect(any());
+ verifyNoMoreInteractions(requestDataCollector);
+ }
+
+ @Test
+ void doFilter_RequestDataCollectorThrowsException_ShouldHandleExceptionAndContinueFlow() throws Exception {
+ when(request.getMethod()).thenReturn("POST");
+ UserData userData = getMockedUserData();
+
+ when(userDataCollector.collect(any(ServletDataPayloadAdapter.class))).thenReturn(userData);
+ doThrow(new RuntimeException("Error in RequestDataCollector"))
+ .when(requestDataCollector).collect(any());
+
+ filter.doFilter(request, response, chain);
+
+ verify(chain).doFilter(any(ContentCachingRequestWrapper.class), any(ContentCachingResponseWrapper.class));
+ verify(userDataCollector).collect(any(ServletDataPayloadAdapter.class));
+ }
+}
\ No newline at end of file
diff --git a/packages/java/metrics-spring/src/test/java/com/readme/spring/datacollection/ServletDataPayloadAdapterTest.java b/packages/java/metrics-spring/src/test/java/com/readme/spring/datacollection/ServletDataPayloadAdapterTest.java
new file mode 100644
index 000000000..46e3d9cf6
--- /dev/null
+++ b/packages/java/metrics-spring/src/test/java/com/readme/spring/datacollection/ServletDataPayloadAdapterTest.java
@@ -0,0 +1,114 @@
+package com.readme.spring.datacollection;
+
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+import org.mockito.Mock;
+import org.mockito.MockitoAnnotations;
+import org.springframework.web.util.ContentCachingRequestWrapper;
+import org.springframework.web.util.ContentCachingResponseWrapper;
+
+import java.io.IOException;
+import java.util.*;
+
+import static org.junit.jupiter.api.Assertions.*;
+import static org.mockito.Mockito.*;
+
+
+class ServletDataPayloadAdapterTest {
+
+ @Mock
+ private ContentCachingRequestWrapper requestMock;
+
+ @Mock
+ private ContentCachingResponseWrapper responseMock;
+
+ private ServletDataPayloadAdapter adapter;
+
+ @BeforeEach
+ void setUp() {
+ MockitoAnnotations.openMocks(this);
+ adapter = new ServletDataPayloadAdapter(requestMock, responseMock);
+ }
+
+
+ // --------------------------- REQUEST --------------------------------
+
+ @Test
+ void getRequestHeaders_HappyPath_ReturnsAllHeaders() {
+ String usernameHeader = "X-User-Name".toLowerCase();
+ String userIdHeader = "X-User-Id".toLowerCase();
+ Enumeration headerNames = Collections.enumeration(List.of(usernameHeader, userIdHeader));
+
+ when(requestMock.getHeaderNames()).thenReturn(headerNames);
+ when(requestMock.getHeader(usernameHeader)).thenReturn("Parrot");
+ when(requestMock.getHeader(userIdHeader)).thenReturn("parrot@birdfact0ry.abc");
+
+ Map headers = adapter.getRequestHeaders();
+
+ assertEquals(2, headers.size());
+ assertEquals("Parrot", headers.get(usernameHeader));
+ assertEquals("parrot@birdfact0ry.abc", headers.get(userIdHeader));
+ }
+
+ @Test
+ void getRequestHeaders_NoHeaders_ReturnsEmptyMap() {
+ when(requestMock.getHeaderNames()).thenReturn(Collections.emptyEnumeration());
+ Map headers = adapter.getRequestHeaders();
+
+ assertTrue(headers.isEmpty());
+ }
+
+ @Test
+ void getRequestMethod_HappyPath_ReturnsCorrectMethod() {
+ when(requestMock.getMethod()).thenReturn("POST");
+ String method = adapter.getRequestMethod();
+
+ assertEquals("POST", method);
+ }
+
+ @Test
+ void getRequestContentType_HappyPath_ReturnsContentType() {
+ when(requestMock.getContentType()).thenReturn("application/json");
+ String contentType = adapter.getRequestContentType();
+
+ assertEquals("application/json", contentType);
+ }
+
+ @Test
+ void getRequestBody_HappyPath_ReturnsRequestBody() throws IOException {
+ String requestBody = "{\"bird\": \"Owl\"}";
+ when(requestMock.getContentAsString()).thenReturn(requestBody);
+ String result = adapter.getRequestBody();
+
+ assertEquals(requestBody, result);
+ }
+
+
+ // --------------------------- RESPONSE --------------------------------
+ @Test
+ void getResponseHeaders_HappyPath_ReturnsAllHeaders() {
+ String usernameHeader = "Response-X-User-Name".toLowerCase();
+ String userIdHeader = "Response-X-User-Id".toLowerCase();
+
+ when(responseMock.getHeaderNames()).thenReturn(List.of(usernameHeader, userIdHeader));
+ when(responseMock.getHeader(usernameHeader)).thenReturn("Parrot");
+ when(responseMock.getHeader(userIdHeader)).thenReturn("parrot@birdfact0ry.abc");
+
+ Map headers = adapter.getResponseHeaders();
+
+ assertEquals(2, headers.size());
+ assertEquals("Parrot", headers.get(usernameHeader));
+ assertEquals("parrot@birdfact0ry.abc", headers.get(userIdHeader));
+ }
+
+
+ @Test
+ void getResponseHeaders_NoHeaders_ReturnsEmptyMap() {
+ when(responseMock.getHeaderNames()).thenReturn(Collections.emptyList());
+ Map headers = adapter.getResponseHeaders();
+
+ assertTrue(headers.isEmpty());
+ }
+
+
+}
\ No newline at end of file
diff --git a/packages/java/metrics-spring/src/test/java/com/readme/spring/datacollection/ServletRequestDataCollectorTest.java b/packages/java/metrics-spring/src/test/java/com/readme/spring/datacollection/ServletRequestDataCollectorTest.java
new file mode 100644
index 000000000..80b23a56e
--- /dev/null
+++ b/packages/java/metrics-spring/src/test/java/com/readme/spring/datacollection/ServletRequestDataCollectorTest.java
@@ -0,0 +1,106 @@
+package com.readme.spring.datacollection;
+
+import com.readme.core.dataextraction.payload.requestresponse.ApiCallLogData;
+import com.readme.core.dataextraction.payload.requestresponse.RequestData;
+import com.readme.core.dataextraction.payload.requestresponse.ResponseData;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.extension.ExtendWith;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.boot.test.mock.mockito.MockBean;
+import org.springframework.test.context.ContextConfiguration;
+import org.springframework.test.context.junit.jupiter.SpringExtension;
+import org.springframework.web.util.ContentCachingRequestWrapper;
+import org.springframework.web.util.ContentCachingResponseWrapper;
+
+import java.util.Collections;
+import java.util.List;
+import java.util.Map;
+import java.util.Set;
+
+import static org.junit.jupiter.api.Assertions.*;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.when;
+
+@ExtendWith(SpringExtension.class)
+@ContextConfiguration(classes = {ServletRequestDataCollector.class})
+class ServletRequestDataCollectorTest {
+
+ @Autowired
+ private ServletRequestDataCollector servletRequestDataCollector;
+
+ @MockBean
+ private ContentCachingRequestWrapper requestMock;
+
+ @MockBean
+ private ContentCachingResponseWrapper responseMock;
+
+ @Test
+ void collect_ShouldReturnApiCallLogData_WithCorrectRequestAndResponseData() {
+ ServletDataPayloadAdapter dataPayload = createStubServletDataPayloadAdapter();
+
+ ApiCallLogData result = servletRequestDataCollector.collect(dataPayload);
+
+ assertNotNull(result);
+ assertNotNull(result.getRequestData());
+ assertNotNull(result.getResponseData());
+
+ RequestData requestData = result.getRequestData();
+ assertEquals("http://owl-bowl.abc", requestData.getUrl());
+ assertEquals("GET", requestData.getMethod());
+ assertEquals("HTTP/1.1", requestData.getProtocol());
+ assertEquals("127.0.0.1", requestData.getRemoteAddress());
+ assertEquals("{}", requestData.getBody());
+ assertEquals("http://owl-bowl.abc", requestData.getRoutePath());
+ assertEquals(Map.of("param1", "value1"), requestData.getRequestParameters());
+ assertEquals(Map.of("authorization", "Bearer token"), requestData.getHeaders());
+
+ ResponseData responseData = result.getResponseData();
+ assertEquals("{\"status\":\"ok\"}", responseData.getBody());
+ assertEquals(200, responseData.getStatusCode());
+ assertEquals("OK", responseData.getStatusMessage());
+ assertEquals(Map.of("content-type", "application/json"), responseData.getHeaders());
+ }
+
+ @Test
+ void collect_ShouldHandleNullHeaders() {
+ when(requestMock.getHeaderNames()).thenReturn(null);
+ when(responseMock.getHeaderNames()).thenReturn(null);
+
+ ServletDataPayloadAdapter dataPayloadAdapter = mock(ServletDataPayloadAdapter.class);
+ ApiCallLogData result = servletRequestDataCollector.collect(dataPayloadAdapter);
+
+ assertNotNull(result);
+ assertTrue(result.getRequestData().getHeaders().isEmpty());
+ assertTrue(result.getResponseData().getHeaders().isEmpty());
+ }
+
+ @Test
+ void collect_ShouldHandleExceptionDuringRequestBodyRead() {
+ ServletDataPayloadAdapter dataPayload = createStubServletDataPayloadAdapter();
+ when(requestMock.getContentAsString()).thenThrow(new RuntimeException("Test exception"));
+
+ ApiCallLogData result = servletRequestDataCollector.collect(dataPayload);
+
+ assertNotNull(result);
+ assertEquals("", result.getRequestData().getBody());
+ }
+
+ private ServletDataPayloadAdapter createStubServletDataPayloadAdapter() {
+ when(requestMock.getMethod()).thenReturn("GET");
+ when(requestMock.getContentType()).thenReturn("application/json");
+ when(requestMock.getContentAsString()).thenReturn("{}");
+ when(requestMock.getRemoteAddr()).thenReturn("127.0.0.1");
+ when(requestMock.getProtocol()).thenReturn("HTTP/1.1");
+ when(requestMock.getRequestURL()).thenReturn(new StringBuffer("http://owl-bowl.abc"));
+ when(requestMock.getParameterMap()).thenReturn(Map.of("param1", new String[]{"value1"}));
+ when(requestMock.getHeaderNames()).thenReturn(Collections.enumeration(List.of("authorization")));
+ when(requestMock.getHeader("authorization")).thenReturn("Bearer token");
+
+ when(responseMock.getContentAsByteArray()).thenReturn("{\"status\":\"ok\"}".getBytes());
+ when(responseMock.getStatus()).thenReturn(200);
+ when(responseMock.getHeaderNames()).thenReturn(Set.of("content-type"));
+ when(responseMock.getHeader("content-type")).thenReturn("application/json");
+
+ return new ServletDataPayloadAdapter(requestMock, responseMock);
+ }
+}
\ No newline at end of file
diff --git a/packages/java/metrics-spring/src/test/java/com/readme/spring/datacollection/userinfo/ServletUserDataCollectorTest.java b/packages/java/metrics-spring/src/test/java/com/readme/spring/datacollection/userinfo/ServletUserDataCollectorTest.java
new file mode 100644
index 000000000..138446238
--- /dev/null
+++ b/packages/java/metrics-spring/src/test/java/com/readme/spring/datacollection/userinfo/ServletUserDataCollectorTest.java
@@ -0,0 +1,99 @@
+package com.readme.spring.datacollection.userinfo;
+
+import com.readme.core.dataextraction.payload.user.UserData;
+import com.readme.core.dataextraction.payload.user.UserDataSource;
+import com.readme.spring.config.UserDataProperties;
+import com.readme.spring.datacollection.ServletDataPayloadAdapter;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+import org.mockito.Mock;
+import org.mockito.MockitoAnnotations;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.mockito.Mockito.*;
+
+class ServletUserDataCollectorTest {
+
+ private ServletUserDataCollector userDataCollector;
+
+ @Mock
+ private UserDataProperties userDataProperties;
+
+ @Mock
+ private UserDataExtractor extractionService;
+
+ @Mock
+ private ServletDataPayloadAdapter payload;
+
+ @BeforeEach
+ void setUp() {
+ MockitoAnnotations.openMocks(this);
+ userDataCollector = new ServletUserDataCollector(userDataProperties, extractionService);
+ }
+
+ @Test
+ void collect_HappyCase() {
+ FieldMapping apiKeyMapping = new FieldMapping(UserDataSource.HEADER.getValue(), "x-api-key");
+ FieldMapping emailMapping = new FieldMapping(UserDataSource.BODY.getValue(), "email");
+ FieldMapping labelMapping = new FieldMapping(UserDataSource.JWT.getValue(), "label");
+
+ when(userDataProperties.getApiKey()).thenReturn(apiKeyMapping);
+ when(userDataProperties.getEmail()).thenReturn(emailMapping);
+ when(userDataProperties.getLabel()).thenReturn(labelMapping);
+
+ when(extractionService.extractFromHeader(payload, "x-api-key")).thenReturn("test-api-key");
+ when(extractionService.extractFromBody(payload, "email")).thenReturn("test@example.com");
+ when(extractionService.extractFromJwt(payload, "label")).thenReturn("user-label");
+
+ UserData result = userDataCollector.collect(payload);
+
+ assertThat(result).isNotNull();
+ assertThat(result.getApiKey()).isEqualTo("test-api-key");
+ assertThat(result.getEmail()).isEqualTo("test@example.com");
+ assertThat(result.getLabel()).isEqualTo("user-label");
+ }
+
+ @Test
+ void collect_MissingApiKeyConfiguration() {
+ when(userDataProperties.getApiKey()).thenReturn(null);
+ UserData result = userDataCollector.collect(payload);
+
+ assertThat(result).isNotNull();
+ assertThat(result.getApiKey()).isEmpty();
+ verify(extractionService, never()).extractFromHeader(any(), anyString());
+ }
+
+ @Test
+ void collect_EmptyHeaderValue() {
+ FieldMapping apiKeyMapping = new FieldMapping(UserDataSource.HEADER.getValue(), "x-api-key");
+ when(userDataProperties.getApiKey()).thenReturn(apiKeyMapping);
+ when(extractionService.extractFromHeader(payload, "x-api-key")).thenReturn("");
+
+ UserData result = userDataCollector.collect(payload);
+
+ assertThat(result).isNotNull();
+ assertThat(result.getApiKey()).isEmpty();
+ verify(extractionService).extractFromHeader(payload, "x-api-key");
+ }
+
+ @Test
+ void collect_UnknownFieldSource() {
+ FieldMapping unknownMapping = new FieldMapping("UNKNOWN", "field");
+ when(userDataProperties.getApiKey()).thenReturn(unknownMapping);
+
+ UserData result = userDataCollector.collect(payload);
+
+ assertThat(result).isNotNull();
+ assertThat(result.getApiKey()).isEmpty();
+ }
+
+ @Test
+ void collect_NullPayload() {
+ UserData result = userDataCollector.collect(null);
+
+ assertThat(result).isNotNull();
+ assertThat(result.getApiKey()).isEmpty();
+ assertThat(result.getEmail()).isEmpty();
+ assertThat(result.getLabel()).isEmpty();
+ }
+}
\ No newline at end of file
diff --git a/packages/java/metrics-spring/src/test/java/com/readme/spring/datacollection/userinfo/ServletUserDataExtractorTest.java b/packages/java/metrics-spring/src/test/java/com/readme/spring/datacollection/userinfo/ServletUserDataExtractorTest.java
new file mode 100644
index 000000000..23485f844
--- /dev/null
+++ b/packages/java/metrics-spring/src/test/java/com/readme/spring/datacollection/userinfo/ServletUserDataExtractorTest.java
@@ -0,0 +1,189 @@
+package com.readme.spring.datacollection.userinfo;
+
+import com.auth0.jwt.JWT;
+import com.auth0.jwt.algorithms.Algorithm;
+import com.fasterxml.jackson.databind.ObjectMapper;
+import com.readme.spring.datacollection.ServletDataPayloadAdapter;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.extension.ExtendWith;
+import org.mockito.Mock;
+import org.mockito.Mockito;
+import org.mockito.junit.jupiter.MockitoExtension;
+
+import java.io.IOException;
+import java.security.KeyPair;
+import java.security.KeyPairGenerator;
+import java.security.NoSuchAlgorithmException;
+import java.security.interfaces.RSAPrivateKey;
+import java.security.interfaces.RSAPublicKey;
+import java.util.Map;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.mockito.Mockito.*;
+
+@ExtendWith(MockitoExtension.class)
+class ServletUserDataExtractorTest {
+
+ private ServletUserDataExtractor extractor;
+
+ @Mock
+ private ServletDataPayloadAdapter payload;
+
+
+ @BeforeEach
+ void setUp() {
+ extractor = new ServletUserDataExtractor(new ObjectMapper());
+ }
+
+ @Test
+ void extractFromHeader_happyPath() {
+ String headerName = "X-User-Name";
+ String expectedValue = "Parrot";
+ Map headers = Map.of(headerName, expectedValue);
+ Mockito.when(payload.getRequestHeaders()).thenReturn(headers);
+ String result = extractor.extractFromHeader(payload, headerName);
+
+ assertEquals(expectedValue, result);
+ }
+
+ @Test
+ void extractFromHeader_headerNotFound() {
+ String headerName = "X-User-Name";
+ String expectedValue = "";
+ Map headers = Map.of();
+ Mockito.when(payload.getRequestHeaders()).thenReturn(headers);
+ String result = extractor.extractFromHeader(payload, headerName);
+
+ assertEquals(expectedValue, result);
+ }
+
+ @Test
+ void extractFromHeader_multipleValuesExtractedCorrectly() {
+ String headerName = "X-User-Name";
+ String expectedValue = "Parrot,Owl,Chicken";
+ Map headers = Map.of(headerName, expectedValue);
+ Mockito.when(payload.getRequestHeaders()).thenReturn(headers);
+ String result = extractor.extractFromHeader(payload, headerName);
+
+ assertEquals(expectedValue, result);
+ }
+
+ @Test
+ void extractFromJwt_happyPath_withBearerPrefix() throws NoSuchAlgorithmException {
+ String claimName = "user_name";
+ String expectedValue = "Parrot";
+
+ Algorithm signingKeyPair = createSigningKeyPair();
+ String jwt = JWT.create()
+ .withClaim(claimName, expectedValue)
+ .sign(signingKeyPair);
+ Map headers = Map.of("authorization", "Bearer " + jwt);
+ Mockito.when(payload.getRequestHeaders()).thenReturn(headers);
+ String result = extractor.extractFromJwt(payload, claimName);
+
+ assertEquals(expectedValue, result);
+ }
+
+ @Test
+ void extractFromJwt_happyPath_NoBearerPrefix() throws NoSuchAlgorithmException {
+ String claimName = "user_name";
+ String expectedValue = "Parrot";
+
+ Algorithm signingKeyPair = createSigningKeyPair();
+ String jwt = JWT.create()
+ .withClaim(claimName, expectedValue)
+ .sign(signingKeyPair);
+ Map headers = Map.of("authorization", jwt);
+ Mockito.when(payload.getRequestHeaders()).thenReturn(headers);
+ String result = extractor.extractFromJwt(payload, claimName);
+
+ assertEquals(expectedValue, result);
+ }
+
+ @Test
+ void extractFromJwt_missingAuthorizationHeader() {
+ String claimName = "user_name";
+ String expectedValue = "";
+
+ Map headers = Map.of();
+ Mockito.when(payload.getRequestHeaders()).thenReturn(headers);
+ String result = extractor.extractFromJwt(payload, claimName);
+
+ assertEquals(expectedValue, result);
+ }
+
+ @Test
+ void extractFromJwt_invalidJwtToken() {
+ String claimName = "user_name";
+ String expectedValue = "";
+
+ Map headers = Map.of("authorization", "Bearer invalidToken");
+ Mockito.when(payload.getRequestHeaders()).thenReturn(headers);
+ String result = extractor.extractFromJwt(payload, claimName);
+
+ assertEquals(expectedValue, result);
+ }
+
+ @Test
+ void extractFromBody_happyPath() throws IOException {
+ String fieldName = "userName";
+ String expectedValue = "Owl";
+
+ String body = "{\"" + fieldName + "\":\"" + expectedValue + "\",\"anotherField\":\"anotherValue\"}";
+ Mockito.when(payload.getRequestBody()).thenReturn(body);
+ Mockito.when(payload.getRequestMethod()).thenReturn("POST");
+ Mockito.when(payload.getRequestContentType()).thenReturn("application/json");
+ String result = extractor.extractFromBody(payload, fieldName);
+
+ assertEquals(expectedValue, result);
+ }
+
+ @Test
+ void extractFromBody_fieldNotFound() {
+ String fieldName = "userName";
+ String expectedValue = "";
+
+ String body = "{\"anotherField\":\"anotherValue\"}";
+ Mockito.when(payload.getRequestBody()).thenReturn(body);
+ Mockito.when(payload.getRequestMethod()).thenReturn("POST");
+ Mockito.when(payload.getRequestContentType()).thenReturn("application/json");
+ String result = extractor.extractFromBody(payload, fieldName);
+
+ assertEquals(expectedValue, result);
+ }
+
+ @Test
+ void extractFromBody_invalidJson() {
+ String body = "invalid-json-body";
+ String expectedValue = "";
+
+ Mockito.when(payload.getRequestBody()).thenReturn(body);
+ Mockito.when(payload.getRequestMethod()).thenReturn("POST");
+ Mockito.when(payload.getRequestContentType()).thenReturn("application/json");
+ String result = extractor.extractFromBody(payload, "fieldName");
+
+ assertEquals(expectedValue, result);
+ }
+
+ @Test
+ void extractFromBody_HttpMethodGet_ReturnsEmptyString() {
+ Mockito.when(payload.getRequestMethod()).thenReturn("GET");
+
+ String result = extractor.extractFromBody(payload, "/fieldName");
+
+ assertEquals("", result);
+ verifyNoMoreInteractions(payload);
+ }
+
+ private Algorithm createSigningKeyPair() throws NoSuchAlgorithmException {
+ KeyPairGenerator keyPairGenerator = KeyPairGenerator.getInstance("RSA");
+ keyPairGenerator.initialize(2048);
+ KeyPair keyPair = keyPairGenerator.generateKeyPair();
+
+ RSAPrivateKey privateKey = (RSAPrivateKey) keyPair.getPrivate();
+ RSAPublicKey publicKey = (RSAPublicKey) keyPair.getPublic();
+
+ return Algorithm.RSA256(publicKey, privateKey);
+ }
+}
\ No newline at end of file
diff --git a/packages/java/metrics-spring2/.gitignore b/packages/java/metrics-spring2/.gitignore
new file mode 100644
index 000000000..23d280fe4
--- /dev/null
+++ b/packages/java/metrics-spring2/.gitignore
@@ -0,0 +1,26 @@
+README.md
+target/
+!.mvn/wrapper/maven-wrapper.jar
+!**/src/main/**/target/
+!**/src/test/**/target/
+
+### Eclipse ###
+.apt_generated
+.classpath
+.factorypath
+.project
+.settings
+.springBeans
+.sts4-cache
+
+### IntelliJ IDEA ###
+.idea
+*.iws
+*.iml
+*.ipr
+
+### VS Code ###
+.vscode/
+
+### MAC ###
+.DS_Store
diff --git a/packages/java/metrics-spring2/pom.xml b/packages/java/metrics-spring2/pom.xml
new file mode 100644
index 000000000..c963cb071
--- /dev/null
+++ b/packages/java/metrics-spring2/pom.xml
@@ -0,0 +1,142 @@
+
+
+ 4.0.0
+
+
+
+ MIT License
+ https://opensource.org/licenses/MIT
+
+
+
+
+ readme
+ Readme
+ engineers@readme.io
+
+
+
+ https://github.com/readmeio/metrics-sdks
+ scm:git:git://github.com/readmeio/metrics-sdks.git
+ scm:git:ssh://git@github.com:readmeio/metrics-sdks.git
+ HEAD
+
+ https://github.com/readmeio/metrics-sdks
+
+
+ org.springframework.boot
+ spring-boot-starter-parent
+ 2.7.18
+
+
+
+ com.readme
+ metrics-spring2
+ 0.1.0
+ metrics-spring2
+ Readme metrics SDK for Spring Boot 2+
+
+
+ 11
+ 0.1.0
+
+
+
+
+ org.springframework.boot
+ spring-boot-starter-web
+
+
+ org.springframework.boot
+ spring-boot-configuration-processor
+ true
+
+
+ com.readme
+ metrics-core
+ ${readme-metrics.version}
+
+
+
+
+ com.auth0
+ java-jwt
+ 4.4.0
+
+
+
+
+ org.springframework.boot
+ spring-boot-starter-test
+ test
+
+
+
+
+
+
+ org.apache.maven.plugins
+ maven-compiler-plugin
+ 3.13.0
+
+ ${maven.compiler.release}
+
+
+
+ org.apache.maven.plugins
+ maven-surefire-plugin
+ 3.1.2
+
+
+ org.sonatype.central
+ central-publishing-maven-plugin
+ 0.7.0
+ true
+
+ central
+
+
+
+ org.apache.maven.plugins
+ maven-source-plugin
+ 3.3.1
+
+
+ attach-sources
+
+ jar
+
+
+
+
+
+ org.apache.maven.plugins
+ maven-javadoc-plugin
+ 3.11.2
+
+
+ attach-javadocs
+
+ jar
+
+
+
+
+
+ org.apache.maven.plugins
+ maven-gpg-plugin
+ 3.2.7
+
+
+ sign-artifacts
+ deploy
+
+ sign
+
+
+
+
+
+
+
diff --git a/packages/java/metrics-spring2/src/main/java/com/readme/spring/config/DataCollectionAutoConfiguration.java b/packages/java/metrics-spring2/src/main/java/com/readme/spring/config/DataCollectionAutoConfiguration.java
new file mode 100644
index 000000000..500f63ec8
--- /dev/null
+++ b/packages/java/metrics-spring2/src/main/java/com/readme/spring/config/DataCollectionAutoConfiguration.java
@@ -0,0 +1,148 @@
+package com.readme.spring.config;
+
+import com.readme.core.config.CoreConfig;
+
+import com.readme.core.dataextraction.LogOptions;
+import com.readme.core.dataextraction.payload.requestresponse.RequestDataCollector;
+import com.readme.core.dataextraction.payload.user.UserDataCollector;
+import com.readme.core.datatransfer.DataSender;
+import com.readme.core.datatransfer.HttpDataSender;
+import com.readme.core.datatransfer.OutgoingLogBodyConstructor;
+import com.readme.core.datatransfer.PayloadDataDispatcher;
+import com.readme.spring.datacollection.DataCollectionFilter;
+import com.readme.spring.datacollection.ServletDataPayloadAdapter;
+import com.readme.spring.datacollection.userinfo.ServletUserDataCollector;
+import com.readme.spring.datacollection.userinfo.UserDataExtractor;
+import javax.annotation.PostConstruct;
+import lombok.AllArgsConstructor;
+import lombok.extern.slf4j.Slf4j;
+import okhttp3.OkHttpClient;
+import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean;
+import org.springframework.boot.logging.LogLevel;
+import org.springframework.boot.logging.LoggingSystem;
+import org.springframework.boot.web.servlet.FilterRegistrationBean;
+import org.springframework.context.annotation.Bean;
+import org.springframework.context.annotation.ComponentScan;
+import org.springframework.context.annotation.Configuration;
+import org.springframework.core.Ordered;
+import org.springframework.core.env.Environment;
+
+/**
+ * Configuration class for registering and initializing the JakartaDataCollectionFilter
+ * along with its dependencies in a Spring Boot application.
+ *
+ * This configuration provides the following:
+ *
+ * - Instantiates the {@link DataCollectionFilter} with required collectors.
+ * - Registers the filter using {@link FilterRegistrationBean} for servlet-based applications.
+ * - Sets up default implementations for collecting request and user data.
+ *
+ */
+@Configuration
+@ComponentScan(basePackages = {"com.readme.spring"})
+@AllArgsConstructor
+@Slf4j
+public class DataCollectionAutoConfiguration {
+
+ private ReadmeConfigurationProperties readmeProperties;
+
+ private final LoggingSystem loggingSystem;
+
+ private final Environment environment;
+
+ /**
+ * Configures logging level for ReadMe SDK based on application properties.
+ */
+ @PostConstruct
+ public void configureLogging() {
+ String logLevel = environment.getProperty("com.readme.logging.level", "OFF");
+ loggingSystem.setLogLevel("com.readme", LogLevel.valueOf(logLevel));
+ }
+
+ /**
+ * Registers the {@link DataCollectionFilter} as a servlet filter to intercept HTTP requests.
+ *
+ * @param requestDataCollector component to extract request details
+ * @param userDataCollector component to extract user-specific data
+ * @param payloadDataDispatcher component responsible for sending collected data
+ * @param logOptions configuration options for logging
+ * @return a configured {@link FilterRegistrationBean} for data collection
+ */
+ @Bean
+ public FilterRegistrationBean metricsFilter(
+ RequestDataCollector requestDataCollector,
+ UserDataCollector userDataCollector,
+ PayloadDataDispatcher payloadDataDispatcher,
+ LogOptions logOptions) {
+ FilterRegistrationBean registrationBean = new FilterRegistrationBean<>();
+ registrationBean.setFilter(new DataCollectionFilter(userDataCollector, requestDataCollector, payloadDataDispatcher, logOptions));
+ registrationBean.setOrder(Ordered.HIGHEST_PRECEDENCE);
+ registrationBean.addUrlPatterns("/*");
+ return registrationBean;
+ }
+
+ /**
+ * Provides a default implementation of {@link UserDataCollector} if none is defined in the context.
+ *
+ * @param userDataProperties configuration properties for user data extraction
+ * @param extractionService service used to extract user info from requests
+ * @return an instance of {@link ServletUserDataCollector}
+ */
+ @Bean
+ @ConditionalOnMissingBean(UserDataCollector.class)
+ public UserDataCollector userDataCollector(UserDataProperties userDataProperties,
+ UserDataExtractor extractionService) {
+ log.info("readme-metrics: Creating of default user data collector");
+ return new ServletUserDataCollector(userDataProperties, extractionService);
+ }
+
+ /**
+ * Creates and configures the component responsible for sending log data to ReadMe API.
+ *
+ * @return an instance of {@link DataSender}.
+ */
+ @Bean
+ public DataSender dataSender() {
+ String readmeApiKey = readmeProperties.getReadmeApiKey();
+ CoreConfig coreConfig = CoreConfig.builder()
+ .readmeAPIKey(readmeApiKey)
+ .build();
+ OkHttpClient okHttpClient = new OkHttpClient();
+
+ return new HttpDataSender(okHttpClient, coreConfig);
+ }
+
+ /**
+ * Provides the component that transforms request/response/user data into HAR format.
+ *
+ * @return an instance of {@link OutgoingLogBodyConstructor}.
+ */
+ @Bean
+ public OutgoingLogBodyConstructor outgoingPayloadConstructor() {
+ return new OutgoingLogBodyConstructor();
+ }
+
+ /**
+ * Instantiates the dispatcher responsible for buffering and sending payloads.
+ *
+ * @param dataSender component to send data over HTTP
+ * @param outgoingLogConstructor component to build HAR-like logs from payload data
+ * @return a configured {@link PayloadDataDispatcher}
+ */
+ @Bean
+ public PayloadDataDispatcher payloadDataDispatcher(DataSender dataSender,
+ OutgoingLogBodyConstructor outgoingLogConstructor) {
+ return new PayloadDataDispatcher(dataSender, outgoingLogConstructor);
+ }
+
+ /**
+ * Provides default logging configuration options if none is defined.
+ *
+ * @return an instance of {@link LogOptions}.
+ */
+ @Bean
+ @ConditionalOnMissingBean(LogOptions.class)
+ public LogOptions logOptions() {
+ return LogOptions.builder().build();
+ }
+}
diff --git a/packages/java/metrics-spring2/src/main/java/com/readme/spring/config/ReadmeConfigurationProperties.java b/packages/java/metrics-spring2/src/main/java/com/readme/spring/config/ReadmeConfigurationProperties.java
new file mode 100644
index 000000000..19b941729
--- /dev/null
+++ b/packages/java/metrics-spring2/src/main/java/com/readme/spring/config/ReadmeConfigurationProperties.java
@@ -0,0 +1,29 @@
+package com.readme.spring.config;
+
+import lombok.Data;
+import org.springframework.boot.context.properties.ConfigurationProperties;
+import org.springframework.stereotype.Component;
+
+/**
+ * Configuration properties for the ReadMe Metrics SDK.
+ *
+ * This class binds to properties defined with the prefix readme
+ * in the application's application.yaml or application.properties file.
+ *
+ * Example usage in application.yaml:
+ *
+ * readme:
+ * readmeApiKey: your-api-key-here
+ *
+ */
+@Data
+@Component
+@ConfigurationProperties(prefix = "readme")
+public class ReadmeConfigurationProperties {
+
+ /**
+ * The API key used to authenticate requests to the ReadMe platform.
+ */
+ private String readmeApiKey;
+
+}
diff --git a/packages/java/metrics-spring2/src/main/java/com/readme/spring/config/UserDataProperties.java b/packages/java/metrics-spring2/src/main/java/com/readme/spring/config/UserDataProperties.java
new file mode 100644
index 000000000..aed7cea57
--- /dev/null
+++ b/packages/java/metrics-spring2/src/main/java/com/readme/spring/config/UserDataProperties.java
@@ -0,0 +1,28 @@
+package com.readme.spring.config;
+
+import com.readme.spring.datacollection.userinfo.FieldMapping;
+import lombok.Data;
+import org.springframework.boot.context.properties.ConfigurationProperties;
+import org.springframework.stereotype.Component;
+
+/**
+ * Configuration properties for monitoring library.
+ *
+ * This class allows users to configure sources and field names for extracting
+ * user-related information (e.g., username, email, and label) from HTTP requests.
+ * Each field (username, email, label) can be configured with a specific source
+ * (e.g., header, jwtClaim, or jsonBody) and its corresponding value.
+ *
+ */
+
+@Data
+@Component
+@ConfigurationProperties(prefix = "readme.userdata")
+public class UserDataProperties {
+
+ private FieldMapping apiKey;
+ private FieldMapping email;
+ private FieldMapping label;
+
+}
+
diff --git a/packages/java/metrics-spring2/src/main/java/com/readme/spring/datacollection/DataCollectionFilter.java b/packages/java/metrics-spring2/src/main/java/com/readme/spring/datacollection/DataCollectionFilter.java
new file mode 100644
index 000000000..60a511882
--- /dev/null
+++ b/packages/java/metrics-spring2/src/main/java/com/readme/spring/datacollection/DataCollectionFilter.java
@@ -0,0 +1,94 @@
+package com.readme.spring.datacollection;
+
+import com.readme.core.dataextraction.LogOptions;
+import com.readme.core.dataextraction.payload.PayloadData;
+import com.readme.core.dataextraction.payload.requestresponse.ApiCallLogData;
+import com.readme.core.dataextraction.payload.requestresponse.RequestDataCollector;
+import com.readme.core.dataextraction.payload.user.UserData;
+import com.readme.core.dataextraction.payload.user.UserDataCollector;
+import com.readme.core.datatransfer.PayloadDataDispatcher;
+import javax.servlet.*;
+import javax.servlet.http.HttpServletRequest;
+import javax.servlet.http.HttpServletResponse;
+import lombok.AllArgsConstructor;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.web.util.ContentCachingRequestWrapper;
+import org.springframework.web.util.ContentCachingResponseWrapper;
+import java.io.IOException;
+import java.util.Date;
+import java.util.concurrent.CompletableFuture;
+
+import static com.readme.core.dataextraction.payload.PayloadData.*;
+import static org.springframework.http.HttpMethod.OPTIONS;
+
+/**
+ * Servlet filter for collecting HTTP request and response data to be sent to ReadMe Metrics.
+ *
+ * This filter wraps incoming requests and responses to capture relevant metadata, user info,
+ * payload content, and asynchronously dispatches the structured data to the configured destination.
+ */
+@AllArgsConstructor
+@Slf4j
+public class DataCollectionFilter implements Filter {
+
+ private UserDataCollector userDataCollector;
+
+ private RequestDataCollector requestDataCollector;
+
+ private PayloadDataDispatcher payloadDispatcher;
+
+ private LogOptions logOptions;
+
+ /**
+ * Intercepts HTTP requests and responses to extract structured log data for ReadMe metrics.
+ *
+ * For non-OPTIONS requests, this method wraps the request/response, collects user and API call data,
+ * and asynchronously sends it via {@link PayloadDataDispatcher}.
+ *
+ * @param req the incoming {@link ServletRequest}
+ * @param resp the outgoing {@link ServletResponse}
+ * @param chain the {@link FilterChain} to continue request processing
+ * @throws IOException in case of I/O errors
+ * @throws ServletException in case of servlet processing errors
+ */
+ @Override
+ public void doFilter(ServletRequest req, ServletResponse resp, FilterChain chain) throws IOException, ServletException {
+ ContentCachingRequestWrapper request = new ContentCachingRequestWrapper((HttpServletRequest) req);
+ ContentCachingResponseWrapper response = new ContentCachingResponseWrapper((HttpServletResponse) resp);
+
+ PayloadDataBuilder payloadDataBuilder = PayloadData.builder();
+ payloadDataBuilder.requestStartedDateTime(new Date());
+
+ try {
+ if (request.getMethod().equalsIgnoreCase(OPTIONS.name())) {
+ chain.doFilter(req, resp);
+ } else {
+ chain.doFilter(request, response);
+ ServletDataPayloadAdapter payload =
+ new ServletDataPayloadAdapter(request, response);
+
+ UserData userData = userDataCollector.collect(payload);
+ payloadDataBuilder.userData(userData);
+
+ ApiCallLogData apiCallLogData = requestDataCollector.collect(payload);
+ payloadDataBuilder.apiCallLogData(apiCallLogData);
+ payloadDataBuilder.responseEndDateTime(new Date());
+
+ PayloadData payloadData = payloadDataBuilder.build();
+ response.copyBodyToResponse();
+
+ CompletableFuture.runAsync(() -> {
+ try {
+ payloadDispatcher.dispatch(payloadData, logOptions);
+ log.info("Data dispatched successfully");
+ } catch (Exception e) {
+ log.error("Error occurred while sending payload data", e.getMessage());
+ }
+ });
+ }
+ } catch (Exception e){
+ log.error("Error occurred while processing request by readme metrics-sdk: {}", e.getMessage());
+ }
+ }
+
+}
diff --git a/packages/java/metrics-spring2/src/main/java/com/readme/spring/datacollection/ServletDataPayloadAdapter.java b/packages/java/metrics-spring2/src/main/java/com/readme/spring/datacollection/ServletDataPayloadAdapter.java
new file mode 100644
index 000000000..7f2867a91
--- /dev/null
+++ b/packages/java/metrics-spring2/src/main/java/com/readme/spring/datacollection/ServletDataPayloadAdapter.java
@@ -0,0 +1,214 @@
+package com.readme.spring.datacollection;
+
+import com.readme.core.dataextraction.DataPayloadAdapter;
+import javax.servlet.http.HttpServletResponse;
+import lombok.AllArgsConstructor;
+
+import javax.servlet.http.HttpServletRequest;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.http.HttpStatus;
+import org.springframework.web.util.ContentCachingRequestWrapper;
+import org.springframework.web.util.ContentCachingResponseWrapper;
+
+import java.util.*;
+import java.util.stream.Collectors;
+
+/**
+ * An implementation of {@link DataPayloadAdapter} that adapts a servlet-based HTTP request and response.
+ *
+ * This class wraps {@link ContentCachingRequestWrapper} and {@link ContentCachingResponseWrapper}
+ * to allow capturing the full content of HTTP requests and responses for logging and monitoring.
+ */
+@Slf4j
+@AllArgsConstructor
+public class ServletDataPayloadAdapter implements DataPayloadAdapter {
+
+ private ContentCachingRequestWrapper request;
+ private ContentCachingResponseWrapper response;
+
+ /**
+ * Returns the HTTP method of the request (e.g., GET, POST).
+ */
+ @Override
+ public String getRequestMethod() {
+ return request.getMethod();
+ }
+
+ /**
+ * Returns the value of the Content-Type header from the request.
+ */
+ @Override
+ public String getRequestContentType() {
+ return request.getContentType();
+ }
+
+ /**
+ * Retrieves the body of the request as a string.
+ *
+ * If an error occurs while reading the request body, an empty string is returned and an error is logged.
+ */
+ @Override
+ public String getRequestBody() {
+ try {
+ return new String(request.getContentAsByteArray());
+ } catch (Exception e) {
+ log.error("Error when trying to get request body: {}", e.getMessage());
+ }
+ return "";
+ }
+
+ /**
+ * Returns the remote address (client IP) of the incoming request.
+ */
+ @Override
+ public String getAddress() {
+ return request.getRemoteAddr();
+ }
+
+ /**
+ * Returns the protocol used for the request (e.g., HTTP/1.1).
+ */
+ @Override
+ public String getProtocol() {
+ return request.getProtocol();
+ }
+
+
+ /**
+ * Returns the full URL that was requested.
+ */
+ @Override
+ public String getUrl() {
+ return request.getRequestURL().toString();
+ }
+
+
+ /**
+ * Returns a map of request parameters where each parameter name is mapped to its joined value string.
+ *
+ * Multi-valued parameters are joined using an empty string.
+ */
+ @Override
+ public Map getRequestParameters() {
+ return request.getParameterMap()
+ .entrySet()
+ .stream()
+ .collect(Collectors
+ .toMap(Map.Entry::getKey,
+ e -> String.join("", e.getValue())));
+ }
+
+ /**
+ * Retrieves all request headers from the {@link HttpServletRequest} and returns them
+ * as a map where the header names are normalized to lowercase.
+ *
+ * This method ensures consistent header name formatting by converting all
+ * header names to lowercase, which is particularly useful for avoiding case-sensitivity
+ * issues when accessing HTTP headers.
+ *
+ * Example:
+ * If the request contains headers:
+ *
+ * - Authorization: Bearer token
+ * - X-User-Id: 12345
+ *
+ * The resulting map will look like:
+ *
+ * {
+ * "authorization": "Bearer token",
+ * "x-user-id": "12345"
+ * }
+ *
+ *
+ * @return a map of request header names (lowercased) and their corresponding values.
+ * If no headers are present or provided request is null, returns an empty map.
+ */
+ @Override
+ public Map getRequestHeaders() {
+ if (request != null) {
+ Map headers = new HashMap<>();
+ Enumeration headerNames = request.getHeaderNames();
+
+ while (headerNames.hasMoreElements()) {
+ String headerName = headerNames.nextElement().toLowerCase();
+ headers.put(headerName, request.getHeader(headerName));
+ }
+ return headers;
+ }
+ log.error("The provided request is null");
+ return Collections.emptyMap();
+ }
+
+ /**
+ * Retrieves the response body as a string.
+ *
+ * If an error occurs while reading the response body, an empty string is returned and an error is logged.
+ */
+ @Override
+ public String getResponseBody() {
+ try {
+ byte[] contentAsByteArray = response.getContentAsByteArray();
+ return new String(contentAsByteArray);
+ } catch (Exception e) {
+ log.error("Error when trying to get response body: {}", e.getMessage());
+ }
+ return "";
+ }
+
+ /**
+ * Returns the HTTP status code of the response (e.g., 200, 404).
+ */
+ @Override
+ public int getStatusCode() {
+ return response.getStatus();
+ }
+
+ /**
+ * Returns the standard reason phrase for the response status code.
+ *
+ * For example, 200 returns "OK", 404 returns "Not Found".
+ * Returns an empty string if the status code is unrecognized.
+ */
+ @Override
+ public String getStatusMessage() {
+ HttpStatus httpStatus = HttpStatus.resolve(response.getStatus());
+ return httpStatus != null ? httpStatus.getReasonPhrase() : "";
+ }
+
+ /**
+ * Retrieves all response headers from the {@link HttpServletResponse} and returns them
+ * as a map where the header names are preserved in their original case.
+ *
+ *
This method iterates through all header names provided by the {@link HttpServletResponse}
+ * and maps each header name to its corresponding value.
+ *
+ * Example:
+ * If the response contains headers:
+ *
+ * - Content-Type: application/json
+ * - X-Custom-Header: custom-value
+ *
+ * The resulting map will look like:
+ *
+ * {
+ * "Content-Type": "application/json",
+ * "X-Custom-Header": "custom-value"
+ * }
+ *
+ *
+ * @return a map of response header names and their corresponding values.
+ * If no headers are present or provided response is null, returns an empty map.
+ */
+ @Override
+ public Map getResponseHeaders() {
+ if (response != null) {
+ return response.getHeaderNames().stream()
+ .collect(Collectors.toMap(
+ headerName -> headerName,
+ headerName -> response.getHeader(headerName)));
+ }
+ log.error("The provided response is null");
+ return Collections.emptyMap();
+ }
+
+}
diff --git a/packages/java/metrics-spring2/src/main/java/com/readme/spring/datacollection/ServletRequestDataCollector.java b/packages/java/metrics-spring2/src/main/java/com/readme/spring/datacollection/ServletRequestDataCollector.java
new file mode 100644
index 000000000..e6d3c6871
--- /dev/null
+++ b/packages/java/metrics-spring2/src/main/java/com/readme/spring/datacollection/ServletRequestDataCollector.java
@@ -0,0 +1,49 @@
+package com.readme.spring.datacollection;
+
+
+import com.readme.core.dataextraction.payload.requestresponse.ApiCallLogData;
+import com.readme.core.dataextraction.payload.requestresponse.RequestData;
+import com.readme.core.dataextraction.payload.requestresponse.RequestDataCollector;
+import com.readme.core.dataextraction.payload.requestresponse.ResponseData;
+import lombok.AllArgsConstructor;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.stereotype.Component;
+
+
+@Slf4j
+@AllArgsConstructor
+@Component
+public class ServletRequestDataCollector implements RequestDataCollector {
+
+ @Override
+ public ApiCallLogData collect(ServletDataPayloadAdapter dataPayload) {
+ return ApiCallLogData
+ .builder()
+ .requestData(buildRequestData(dataPayload))
+ .responseData(buildResponseData(dataPayload))
+ .build();
+ }
+
+ private RequestData buildRequestData(ServletDataPayloadAdapter dataPayload) {
+ return RequestData.builder()
+ .url(dataPayload.getUrl())
+ .method(dataPayload.getRequestMethod())
+ .protocol(dataPayload.getProtocol())
+ .remoteAddress(dataPayload.getAddress())
+ .headers(dataPayload.getRequestHeaders())
+ .body(dataPayload.getRequestBody())
+ .routePath(dataPayload.getUrl())
+ .requestParameters(dataPayload.getRequestParameters())
+ .build();
+ }
+
+ private ResponseData buildResponseData(ServletDataPayloadAdapter dataPayload) {
+ return ResponseData.builder()
+ .headers(dataPayload.getResponseHeaders())
+ .body(dataPayload.getResponseBody())
+ .statusCode(dataPayload.getStatusCode())
+ .statusMessage(dataPayload.getStatusMessage())
+ .build();
+ }
+
+}
diff --git a/packages/java/metrics-spring2/src/main/java/com/readme/spring/datacollection/userinfo/FieldMapping.java b/packages/java/metrics-spring2/src/main/java/com/readme/spring/datacollection/userinfo/FieldMapping.java
new file mode 100644
index 000000000..e889b5d16
--- /dev/null
+++ b/packages/java/metrics-spring2/src/main/java/com/readme/spring/datacollection/userinfo/FieldMapping.java
@@ -0,0 +1,22 @@
+package com.readme.spring.datacollection.userinfo;
+
+import lombok.AllArgsConstructor;
+import lombok.Data;
+import lombok.NoArgsConstructor;
+
+/**
+ * Represents a mapping source for extracting data from HTTP requests.
+ *
+ * A FieldMapping consists of a source type (e.g., header, jwtClaim, or jsonBody)
+ * and a fieldName that corresponds to the field's name or claim in the HTTP request.
+ *
+ */
+@Data
+@AllArgsConstructor
+@NoArgsConstructor
+public class FieldMapping {
+
+ private String source;
+ private String fieldName;
+
+}
diff --git a/packages/java/metrics-spring2/src/main/java/com/readme/spring/datacollection/userinfo/ServletUserDataCollector.java b/packages/java/metrics-spring2/src/main/java/com/readme/spring/datacollection/userinfo/ServletUserDataCollector.java
new file mode 100644
index 000000000..39039425a
--- /dev/null
+++ b/packages/java/metrics-spring2/src/main/java/com/readme/spring/datacollection/userinfo/ServletUserDataCollector.java
@@ -0,0 +1,112 @@
+package com.readme.spring.datacollection.userinfo;
+
+
+import com.readme.core.dataextraction.payload.user.UserData;
+import com.readme.core.dataextraction.payload.user.UserDataCollector;
+import com.readme.core.dataextraction.payload.user.UserDataSource;
+import com.readme.spring.config.UserDataProperties;
+import com.readme.spring.datacollection.ServletDataPayloadAdapter;
+import lombok.AllArgsConstructor;
+import lombok.extern.slf4j.Slf4j;
+
+
+/**
+ * Responsible for selecting the appropriate {@link UserDataExtractor}
+ * based on the provided configuration in the application settings.
+ *
+ * This class acts as a bridge between YAML/Properties configuration and
+ * the corresponding strategy for extracting user-related data
+ * (e.g., from JSON body, headers, or JWT tokens).
+ *
+ * Ensures flexibility and proper encapsulation of the strategy selection logic.
+ */
+
+@AllArgsConstructor
+@Slf4j
+public class ServletUserDataCollector implements UserDataCollector {
+
+ private UserDataProperties userDataProperties;
+
+ private final UserDataExtractor extractionService;
+
+ @Override
+ public UserData collect(ServletDataPayloadAdapter payload) {
+
+ String apiKey = getApiKey(payload);
+ String email = getEmail(payload);
+ String label = getLabel(payload);
+
+ return UserData.builder()
+ .apiKey(apiKey)
+ .email(email)
+ .label(label)
+ .build();
+
+ }
+
+ private String getApiKey(ServletDataPayloadAdapter payload) {
+ FieldMapping apiKey = userDataProperties.getApiKey();
+ if (apiKey == null) {
+ log.error("api-key extraction is not configured properly");
+ return "";
+ }
+ return extractFieldValue(payload, apiKey);
+ }
+
+ private String getEmail(ServletDataPayloadAdapter payload) {
+ FieldMapping apiKey = userDataProperties.getEmail();
+ if (apiKey == null) {
+ log.error("email extraction is not configured properly");
+ return "";
+ }
+ return extractFieldValue(payload, apiKey);
+ }
+
+ private String getLabel(ServletDataPayloadAdapter payload) {
+ FieldMapping apiKey = userDataProperties.getLabel();
+ if (apiKey == null) {
+ log.error("label extraction is not configured properly");
+ return "";
+ }
+ return extractFieldValue(payload, apiKey);
+ }
+
+ private String extractFieldValue(ServletDataPayloadAdapter payload, FieldMapping fieldMapping) {
+ if (fieldMapping.getSource().equals(UserDataSource.HEADER.getValue())) {
+ String fieldName = fieldMapping.getFieldName().toLowerCase();
+ String fieldValue = extractionService.extractFromHeader(payload, fieldName);
+
+ validate(fieldValue);
+ return fieldValue;
+ }
+
+ if (fieldMapping.getSource().equals(UserDataSource.BODY.getValue())) {
+ String fieldName = fieldMapping.getFieldName().toLowerCase();
+ String fieldValue = extractionService.extractFromBody(payload, fieldName);
+
+ validate(fieldValue);
+ return fieldValue;
+ }
+
+ if (fieldMapping.getSource().equals(UserDataSource.JWT.getValue())) {
+ String fieldName = fieldMapping.getFieldName().toLowerCase();
+ String fieldValue = extractionService.extractFromJwt(payload, fieldName);
+
+ validate(fieldValue);
+ return fieldValue;
+ }
+
+ log.error("unknown field source: {}", fieldMapping.getSource());
+
+ return "";
+ }
+
+ private void validate(String fieldValue) {
+ if (fieldValue == null || fieldValue.isEmpty()) {
+ log.error("The {} extraction is not configured properly. The value is empty", fieldValue);
+ }
+ }
+
+}
+
+
diff --git a/packages/java/metrics-spring2/src/main/java/com/readme/spring/datacollection/userinfo/ServletUserDataExtractor.java b/packages/java/metrics-spring2/src/main/java/com/readme/spring/datacollection/userinfo/ServletUserDataExtractor.java
new file mode 100644
index 000000000..9582cb1a3
--- /dev/null
+++ b/packages/java/metrics-spring2/src/main/java/com/readme/spring/datacollection/userinfo/ServletUserDataExtractor.java
@@ -0,0 +1,83 @@
+package com.readme.spring.datacollection.userinfo;
+
+import com.auth0.jwt.interfaces.DecodedJWT;
+import com.fasterxml.jackson.databind.JsonNode;
+import com.fasterxml.jackson.databind.ObjectMapper;
+import com.readme.spring.datacollection.ServletDataPayloadAdapter;
+import lombok.AllArgsConstructor;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.http.HttpMethod;
+import org.springframework.stereotype.Component;
+import com.auth0.jwt.JWT;
+
+import java.util.Map;
+
+/**
+ * Default implementation of {@link UserDataExtractor} for servlet-based applications.
+ *
+ * This class is responsible for extracting user-specific information from incoming HTTP requests.
+ * It supports multiple extraction strategies, including HTTP headers, JSON request bodies,
+ * and JWT tokens (from the Authorization header).
+ */
+@AllArgsConstructor
+@Component
+@Slf4j
+public class ServletUserDataExtractor implements UserDataExtractor {
+
+ private ObjectMapper objectMapper;
+
+ @Override
+ public String extractFromHeader(ServletDataPayloadAdapter payload, String fieldName) {
+ Map requestHeaders = payload.getRequestHeaders();
+ if (requestHeaders.containsKey(fieldName)) {
+ return requestHeaders.get(fieldName);
+ }
+ log.error("The provided header name {} does not exist.", fieldName);
+ return "";
+ }
+
+ @Override
+ public String extractFromBody(ServletDataPayloadAdapter payload, String fieldPath) {
+ if (!payload.getRequestMethod().equalsIgnoreCase(HttpMethod.GET.name())) {
+ if (payload.getRequestContentType().equalsIgnoreCase("application/json")) {
+ String requestBody = payload.getRequestBody();
+ try {
+ JsonNode currentNode = objectMapper.readTree(requestBody);
+ if (!fieldPath.startsWith("/")) {
+ fieldPath = "/" + fieldPath;
+ }
+ return currentNode.at(fieldPath).asText();
+ } catch (Exception e) {
+ log.error("Error when reading the user data from JSON body: {}", e.getMessage());
+ }
+ }
+ log.error("The provided body content type {} is not supported to get user data.", payload.getRequestContentType());
+ return "";
+ }
+ log.error("The HTTP method {} is not supported to get user data from body.", payload.getRequestMethod());
+ return "";
+ }
+
+ @Override
+ public String extractFromJwt(ServletDataPayloadAdapter payload, String fieldName) {
+ try {
+ Map requestHeaders = payload.getRequestHeaders();
+ String jwtToken = requestHeaders.get("authorization");
+
+ if (jwtToken == null) {
+ log.error("The JWT token is not provided as Authorization header.");
+ return "";
+ }
+ if (jwtToken.startsWith("Bearer ")) {
+ jwtToken = jwtToken.substring(7);
+ }
+
+ DecodedJWT decodedJWT = JWT.decode(jwtToken);
+ return decodedJWT.getClaim(fieldName).asString();
+ } catch (Exception e) {
+ log.error("The Authorization token is invalid. {}", e.getMessage());
+ }
+ return "";
+ }
+
+}
diff --git a/packages/java/metrics-spring2/src/main/java/com/readme/spring/datacollection/userinfo/UserDataExtractor.java b/packages/java/metrics-spring2/src/main/java/com/readme/spring/datacollection/userinfo/UserDataExtractor.java
new file mode 100644
index 000000000..f5b5815d0
--- /dev/null
+++ b/packages/java/metrics-spring2/src/main/java/com/readme/spring/datacollection/userinfo/UserDataExtractor.java
@@ -0,0 +1,45 @@
+package com.readme.spring.datacollection.userinfo;
+
+/**
+ * Defines a contract for extracting user-related data from an incoming request.
+ *
+ * Implementation of this interface is responsible for retrieving specific user data
+ * (e.g., username, email, label) from different sources such as:
+ * - JSON body
+ * - HTTP headers
+ * - JWT tokens
+ *
+ * @param the type of request object from which user data will be extracted.
+ * This can be a framework-specific class (e.g., HttpServletRequest, HttpServletDataPayload).
+ *
+ */
+public interface UserDataExtractor {
+
+ /**
+ * Extracts requested data from request header
+ *
+ * @param payload the type of request object from which user data will be extracted.
+ * @param fieldName is the source field name to extract the data
+ * @return extracted value as a String
+ */
+ String extractFromHeader(T payload, String fieldName);
+
+ /**
+ * Extracts requested data from JSON body
+ *
+ * @param payload the type of request object from which user data will be extracted.
+ * @param fieldPath is the source field name to extract the data
+ * @return extracted value as a String
+ */
+ String extractFromBody(T payload, String fieldPath);
+
+ /**
+ * Extracts requested data from JWT token
+ *
+ * @param payload the type of request object from which user data will be extracted.
+ * @param fieldName is the source field name to extract the data
+ * @return extracted value as a String
+ */
+ String extractFromJwt(T payload, String fieldName);
+
+}
diff --git a/packages/java/metrics-spring2/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports b/packages/java/metrics-spring2/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports
new file mode 100644
index 000000000..a89b64f5d
--- /dev/null
+++ b/packages/java/metrics-spring2/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports
@@ -0,0 +1 @@
+com.readme.spring.config.DataCollectionAutoConfiguration
\ No newline at end of file
diff --git a/packages/java/metrics-spring2/src/test/java/com/readme/spring/datacollection/DataCollectionFilterTest.java b/packages/java/metrics-spring2/src/test/java/com/readme/spring/datacollection/DataCollectionFilterTest.java
new file mode 100644
index 000000000..b6924c08c
--- /dev/null
+++ b/packages/java/metrics-spring2/src/test/java/com/readme/spring/datacollection/DataCollectionFilterTest.java
@@ -0,0 +1,141 @@
+package com.readme.spring.datacollection;
+
+import com.readme.core.dataextraction.LogOptions;
+import com.readme.core.dataextraction.payload.requestresponse.RequestDataCollector;
+import com.readme.core.dataextraction.payload.user.UserData;
+import com.readme.core.dataextraction.payload.user.UserDataCollector;
+import com.readme.core.datatransfer.PayloadDataDispatcher;
+import javax.servlet.FilterChain;
+import javax.servlet.ServletException;
+import javax.servlet.http.HttpServletRequest;
+import javax.servlet.http.HttpServletResponse;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+import org.mockito.*;
+import org.springframework.web.util.ContentCachingRequestWrapper;
+import org.springframework.web.util.ContentCachingResponseWrapper;
+
+import java.io.IOException;
+
+import static org.mockito.Mockito.*;
+
+class DataCollectionFilterTest {
+
+ @Mock
+ private RequestDataCollector requestDataCollector;
+
+ @Mock
+ private UserDataCollector userDataCollector;
+
+ @Mock
+ private HttpServletRequest request;
+
+ @Mock
+ private HttpServletResponse response;
+
+ @Mock
+ private FilterChain chain;
+
+ @Mock
+ private PayloadDataDispatcher payloadDataDispatcher;
+
+ private DataCollectionFilter filter;
+
+ @BeforeEach
+ void setUp() {
+ MockitoAnnotations.openMocks(this);
+ filter = new DataCollectionFilter(userDataCollector, requestDataCollector, payloadDataDispatcher, LogOptions.builder().build());
+ }
+
+ @Test
+ void doFilter_OptionsRequest_ShouldPassThroughWithoutProcessing() throws Exception {
+ when(request.getMethod()).thenReturn("OPTIONS");
+
+ filter.doFilter(request, response, chain);
+
+ verify(chain).doFilter(request, response);
+ verifyNoInteractions(requestDataCollector, userDataCollector);
+ }
+
+
+ @Test
+ void doFilter_GetRequest_ShouldProcessAndCollectData() throws Exception {
+ when(request.getMethod()).thenReturn("GET");
+ testChain();
+ }
+
+ @Test
+ void doFilter_PutRequest_ShouldProcessAndCollectData() throws Exception {
+ when(request.getMethod()).thenReturn("PUT");
+ testChain();
+ }
+
+ @Test
+ void doFilter_PostRequest_ShouldProcessAndCollectData() throws Exception {
+ when(request.getMethod()).thenReturn("POST");
+ testChain();
+ }
+
+ @Test
+ void doFilter_PatchRequest_ShouldProcessAndCollectData() throws Exception {
+ when(request.getMethod()).thenReturn("PATCH");
+ testChain();
+ }
+
+ @Test
+ void doFilter_DeleteRequest_ShouldProcessAndCollectData() throws Exception {
+ when(request.getMethod()).thenReturn("DELETE");
+ testChain();
+ }
+
+
+ private void testChain() throws IOException, ServletException {
+ UserData userData = getMockedUserData();
+ when(userDataCollector.collect(any(ServletDataPayloadAdapter.class))).thenReturn(userData);
+
+ filter.doFilter(request, response, chain);
+
+ verify(chain).doFilter(any(ContentCachingRequestWrapper.class), any(ContentCachingResponseWrapper.class));
+
+ ArgumentCaptor payloadCaptor = ArgumentCaptor.forClass(ServletDataPayloadAdapter.class);
+
+ verify(userDataCollector).collect(payloadCaptor.capture());
+ verify(requestDataCollector).collect(eq(payloadCaptor.getValue()));
+ }
+
+ private static UserData getMockedUserData() {
+ return UserData.builder()
+ .apiKey("Owl")
+ .email("owl@birdfactory.abc")
+ .label("owl-label")
+ .build();
+ }
+
+ @Test
+ void doFilter_UserDataCollectorThrowsException_ShouldHandleExceptionAndContinueFlow() throws Exception {
+ when(request.getMethod()).thenReturn("POST");
+ when(userDataCollector.collect(any(ServletDataPayloadAdapter.class)))
+ .thenThrow(new RuntimeException("Error in UserDataCollector"));
+
+ filter.doFilter(request, response, chain);
+
+ verify(chain).doFilter(any(ContentCachingRequestWrapper.class), any(ContentCachingResponseWrapper.class));
+ verify(requestDataCollector, never()).collect(any());
+ verifyNoMoreInteractions(requestDataCollector);
+ }
+
+ @Test
+ void doFilter_RequestDataCollectorThrowsException_ShouldHandleExceptionAndContinueFlow() throws Exception {
+ when(request.getMethod()).thenReturn("POST");
+ UserData userData = getMockedUserData();
+
+ when(userDataCollector.collect(any(ServletDataPayloadAdapter.class))).thenReturn(userData);
+ doThrow(new RuntimeException("Error in RequestDataCollector"))
+ .when(requestDataCollector).collect(any());
+
+ filter.doFilter(request, response, chain);
+
+ verify(chain).doFilter(any(ContentCachingRequestWrapper.class), any(ContentCachingResponseWrapper.class));
+ verify(userDataCollector).collect(any(ServletDataPayloadAdapter.class));
+ }
+}
\ No newline at end of file
diff --git a/packages/java/metrics-spring2/src/test/java/com/readme/spring/datacollection/ServletDataPayloadAdapterTest.java b/packages/java/metrics-spring2/src/test/java/com/readme/spring/datacollection/ServletDataPayloadAdapterTest.java
new file mode 100644
index 000000000..ac1b4ca43
--- /dev/null
+++ b/packages/java/metrics-spring2/src/test/java/com/readme/spring/datacollection/ServletDataPayloadAdapterTest.java
@@ -0,0 +1,114 @@
+package com.readme.spring.datacollection;
+
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+import org.mockito.Mock;
+import org.mockito.MockitoAnnotations;
+import org.springframework.web.util.ContentCachingRequestWrapper;
+import org.springframework.web.util.ContentCachingResponseWrapper;
+
+import java.io.IOException;
+import java.util.*;
+
+import static org.junit.jupiter.api.Assertions.*;
+import static org.mockito.Mockito.*;
+
+
+class ServletDataPayloadAdapterTest {
+
+ @Mock
+ private ContentCachingRequestWrapper requestMock;
+
+ @Mock
+ private ContentCachingResponseWrapper responseMock;
+
+ private ServletDataPayloadAdapter adapter;
+
+ @BeforeEach
+ void setUp() {
+ MockitoAnnotations.openMocks(this);
+ adapter = new ServletDataPayloadAdapter(requestMock, responseMock);
+ }
+
+
+ // --------------------------- REQUEST --------------------------------
+
+ @Test
+ void getRequestHeaders_HappyPath_ReturnsAllHeaders() {
+ String usernameHeader = "X-User-Name".toLowerCase();
+ String userIdHeader = "X-User-Id".toLowerCase();
+ Enumeration headerNames = Collections.enumeration(List.of(usernameHeader, userIdHeader));
+
+ when(requestMock.getHeaderNames()).thenReturn(headerNames);
+ when(requestMock.getHeader(usernameHeader)).thenReturn("Parrot");
+ when(requestMock.getHeader(userIdHeader)).thenReturn("parrot@birdfact0ry.abc");
+
+ Map headers = adapter.getRequestHeaders();
+
+ assertEquals(2, headers.size());
+ assertEquals("Parrot", headers.get(usernameHeader));
+ assertEquals("parrot@birdfact0ry.abc", headers.get(userIdHeader));
+ }
+
+ @Test
+ void getRequestHeaders_NoHeaders_ReturnsEmptyMap() {
+ when(requestMock.getHeaderNames()).thenReturn(Collections.emptyEnumeration());
+ Map headers = adapter.getRequestHeaders();
+
+ assertTrue(headers.isEmpty());
+ }
+
+ @Test
+ void getRequestMethod_HappyPath_ReturnsCorrectMethod() {
+ when(requestMock.getMethod()).thenReturn("POST");
+ String method = adapter.getRequestMethod();
+
+ assertEquals("POST", method);
+ }
+
+ @Test
+ void getRequestContentType_HappyPath_ReturnsContentType() {
+ when(requestMock.getContentType()).thenReturn("application/json");
+ String contentType = adapter.getRequestContentType();
+
+ assertEquals("application/json", contentType);
+ }
+
+ @Test
+ void getRequestBody_HappyPath_ReturnsRequestBody() throws IOException {
+ String requestBody = "{\"bird\": \"Owl\"}";
+ when(requestMock.getContentAsByteArray()).thenReturn(requestBody.getBytes());
+ String result = adapter.getRequestBody();
+
+ assertEquals(requestBody, result);
+ }
+
+
+ // --------------------------- RESPONSE --------------------------------
+ @Test
+ void getResponseHeaders_HappyPath_ReturnsAllHeaders() {
+ String usernameHeader = "Response-X-User-Name".toLowerCase();
+ String userIdHeader = "Response-X-User-Id".toLowerCase();
+
+ when(responseMock.getHeaderNames()).thenReturn(List.of(usernameHeader, userIdHeader));
+ when(responseMock.getHeader(usernameHeader)).thenReturn("Parrot");
+ when(responseMock.getHeader(userIdHeader)).thenReturn("parrot@birdfact0ry.abc");
+
+ Map headers = adapter.getResponseHeaders();
+
+ assertEquals(2, headers.size());
+ assertEquals("Parrot", headers.get(usernameHeader));
+ assertEquals("parrot@birdfact0ry.abc", headers.get(userIdHeader));
+ }
+
+
+ @Test
+ void getResponseHeaders_NoHeaders_ReturnsEmptyMap() {
+ when(responseMock.getHeaderNames()).thenReturn(Collections.emptyList());
+ Map headers = adapter.getResponseHeaders();
+
+ assertTrue(headers.isEmpty());
+ }
+
+
+}
\ No newline at end of file
diff --git a/packages/java/metrics-spring2/src/test/java/com/readme/spring/datacollection/ServletRequestDataCollectorTest.java b/packages/java/metrics-spring2/src/test/java/com/readme/spring/datacollection/ServletRequestDataCollectorTest.java
new file mode 100644
index 000000000..9be60f696
--- /dev/null
+++ b/packages/java/metrics-spring2/src/test/java/com/readme/spring/datacollection/ServletRequestDataCollectorTest.java
@@ -0,0 +1,106 @@
+package com.readme.spring.datacollection;
+
+import com.readme.core.dataextraction.payload.requestresponse.ApiCallLogData;
+import com.readme.core.dataextraction.payload.requestresponse.RequestData;
+import com.readme.core.dataextraction.payload.requestresponse.ResponseData;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.extension.ExtendWith;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.boot.test.mock.mockito.MockBean;
+import org.springframework.test.context.ContextConfiguration;
+import org.springframework.test.context.junit.jupiter.SpringExtension;
+import org.springframework.web.util.ContentCachingRequestWrapper;
+import org.springframework.web.util.ContentCachingResponseWrapper;
+
+import java.util.Collections;
+import java.util.List;
+import java.util.Map;
+import java.util.Set;
+
+import static org.junit.jupiter.api.Assertions.*;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.when;
+
+@ExtendWith(SpringExtension.class)
+@ContextConfiguration(classes = {ServletRequestDataCollector.class})
+class ServletRequestDataCollectorTest {
+
+ @Autowired
+ private ServletRequestDataCollector servletRequestDataCollector;
+
+ @MockBean
+ private ContentCachingRequestWrapper requestMock;
+
+ @MockBean
+ private ContentCachingResponseWrapper responseMock;
+
+ @Test
+ void collect_ShouldReturnApiCallLogData_WithCorrectRequestAndResponseData() {
+ ServletDataPayloadAdapter dataPayload = createStubServletDataPayloadAdapter();
+
+ ApiCallLogData result = servletRequestDataCollector.collect(dataPayload);
+
+ assertNotNull(result);
+ assertNotNull(result.getRequestData());
+ assertNotNull(result.getResponseData());
+
+ RequestData requestData = result.getRequestData();
+ assertEquals("http://owl-bowl.abc", requestData.getUrl());
+ assertEquals("GET", requestData.getMethod());
+ assertEquals("HTTP/1.1", requestData.getProtocol());
+ assertEquals("127.0.0.1", requestData.getRemoteAddress());
+ assertEquals("{}", requestData.getBody());
+ assertEquals("http://owl-bowl.abc", requestData.getRoutePath());
+ assertEquals(Map.of("param1", "value1"), requestData.getRequestParameters());
+ assertEquals(Map.of("authorization", "Bearer token"), requestData.getHeaders());
+
+ ResponseData responseData = result.getResponseData();
+ assertEquals("{\"status\":\"ok\"}", responseData.getBody());
+ assertEquals(200, responseData.getStatusCode());
+ assertEquals("OK", responseData.getStatusMessage());
+ assertEquals(Map.of("content-type", "application/json"), responseData.getHeaders());
+ }
+
+ @Test
+ void collect_ShouldHandleNullHeaders() {
+ when(requestMock.getHeaderNames()).thenReturn(null);
+ when(responseMock.getHeaderNames()).thenReturn(null);
+
+ ServletDataPayloadAdapter dataPayloadAdapter = mock(ServletDataPayloadAdapter.class);
+ ApiCallLogData result = servletRequestDataCollector.collect(dataPayloadAdapter);
+
+ assertNotNull(result);
+ assertTrue(result.getRequestData().getHeaders().isEmpty());
+ assertTrue(result.getResponseData().getHeaders().isEmpty());
+ }
+
+ @Test
+ void collect_ShouldHandleExceptionDuringRequestBodyRead() {
+ ServletDataPayloadAdapter dataPayload = createStubServletDataPayloadAdapter();
+ when(requestMock.getContentAsByteArray()).thenThrow(new RuntimeException("Test exception"));
+
+ ApiCallLogData result = servletRequestDataCollector.collect(dataPayload);
+
+ assertNotNull(result);
+ assertEquals("", result.getRequestData().getBody());
+ }
+
+ private ServletDataPayloadAdapter createStubServletDataPayloadAdapter() {
+ when(requestMock.getMethod()).thenReturn("GET");
+ when(requestMock.getContentType()).thenReturn("application/json");
+ when(requestMock.getContentAsByteArray()).thenReturn("{}".getBytes());
+ when(requestMock.getRemoteAddr()).thenReturn("127.0.0.1");
+ when(requestMock.getProtocol()).thenReturn("HTTP/1.1");
+ when(requestMock.getRequestURL()).thenReturn(new StringBuffer("http://owl-bowl.abc"));
+ when(requestMock.getParameterMap()).thenReturn(Map.of("param1", new String[]{"value1"}));
+ when(requestMock.getHeaderNames()).thenReturn(Collections.enumeration(List.of("authorization")));
+ when(requestMock.getHeader("authorization")).thenReturn("Bearer token");
+
+ when(responseMock.getContentAsByteArray()).thenReturn("{\"status\":\"ok\"}".getBytes());
+ when(responseMock.getStatus()).thenReturn(200);
+ when(responseMock.getHeaderNames()).thenReturn(Set.of("content-type"));
+ when(responseMock.getHeader("content-type")).thenReturn("application/json");
+
+ return new ServletDataPayloadAdapter(requestMock, responseMock);
+ }
+}
\ No newline at end of file
diff --git a/packages/java/metrics-spring2/src/test/java/com/readme/spring/datacollection/userinfo/ServletUserDataCollectorTest.java b/packages/java/metrics-spring2/src/test/java/com/readme/spring/datacollection/userinfo/ServletUserDataCollectorTest.java
new file mode 100644
index 000000000..138446238
--- /dev/null
+++ b/packages/java/metrics-spring2/src/test/java/com/readme/spring/datacollection/userinfo/ServletUserDataCollectorTest.java
@@ -0,0 +1,99 @@
+package com.readme.spring.datacollection.userinfo;
+
+import com.readme.core.dataextraction.payload.user.UserData;
+import com.readme.core.dataextraction.payload.user.UserDataSource;
+import com.readme.spring.config.UserDataProperties;
+import com.readme.spring.datacollection.ServletDataPayloadAdapter;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+import org.mockito.Mock;
+import org.mockito.MockitoAnnotations;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.mockito.Mockito.*;
+
+class ServletUserDataCollectorTest {
+
+ private ServletUserDataCollector userDataCollector;
+
+ @Mock
+ private UserDataProperties userDataProperties;
+
+ @Mock
+ private UserDataExtractor extractionService;
+
+ @Mock
+ private ServletDataPayloadAdapter payload;
+
+ @BeforeEach
+ void setUp() {
+ MockitoAnnotations.openMocks(this);
+ userDataCollector = new ServletUserDataCollector(userDataProperties, extractionService);
+ }
+
+ @Test
+ void collect_HappyCase() {
+ FieldMapping apiKeyMapping = new FieldMapping(UserDataSource.HEADER.getValue(), "x-api-key");
+ FieldMapping emailMapping = new FieldMapping(UserDataSource.BODY.getValue(), "email");
+ FieldMapping labelMapping = new FieldMapping(UserDataSource.JWT.getValue(), "label");
+
+ when(userDataProperties.getApiKey()).thenReturn(apiKeyMapping);
+ when(userDataProperties.getEmail()).thenReturn(emailMapping);
+ when(userDataProperties.getLabel()).thenReturn(labelMapping);
+
+ when(extractionService.extractFromHeader(payload, "x-api-key")).thenReturn("test-api-key");
+ when(extractionService.extractFromBody(payload, "email")).thenReturn("test@example.com");
+ when(extractionService.extractFromJwt(payload, "label")).thenReturn("user-label");
+
+ UserData result = userDataCollector.collect(payload);
+
+ assertThat(result).isNotNull();
+ assertThat(result.getApiKey()).isEqualTo("test-api-key");
+ assertThat(result.getEmail()).isEqualTo("test@example.com");
+ assertThat(result.getLabel()).isEqualTo("user-label");
+ }
+
+ @Test
+ void collect_MissingApiKeyConfiguration() {
+ when(userDataProperties.getApiKey()).thenReturn(null);
+ UserData result = userDataCollector.collect(payload);
+
+ assertThat(result).isNotNull();
+ assertThat(result.getApiKey()).isEmpty();
+ verify(extractionService, never()).extractFromHeader(any(), anyString());
+ }
+
+ @Test
+ void collect_EmptyHeaderValue() {
+ FieldMapping apiKeyMapping = new FieldMapping(UserDataSource.HEADER.getValue(), "x-api-key");
+ when(userDataProperties.getApiKey()).thenReturn(apiKeyMapping);
+ when(extractionService.extractFromHeader(payload, "x-api-key")).thenReturn("");
+
+ UserData result = userDataCollector.collect(payload);
+
+ assertThat(result).isNotNull();
+ assertThat(result.getApiKey()).isEmpty();
+ verify(extractionService).extractFromHeader(payload, "x-api-key");
+ }
+
+ @Test
+ void collect_UnknownFieldSource() {
+ FieldMapping unknownMapping = new FieldMapping("UNKNOWN", "field");
+ when(userDataProperties.getApiKey()).thenReturn(unknownMapping);
+
+ UserData result = userDataCollector.collect(payload);
+
+ assertThat(result).isNotNull();
+ assertThat(result.getApiKey()).isEmpty();
+ }
+
+ @Test
+ void collect_NullPayload() {
+ UserData result = userDataCollector.collect(null);
+
+ assertThat(result).isNotNull();
+ assertThat(result.getApiKey()).isEmpty();
+ assertThat(result.getEmail()).isEmpty();
+ assertThat(result.getLabel()).isEmpty();
+ }
+}
\ No newline at end of file
diff --git a/packages/java/metrics-spring2/src/test/java/com/readme/spring/datacollection/userinfo/ServletUserDataExtractorTest.java b/packages/java/metrics-spring2/src/test/java/com/readme/spring/datacollection/userinfo/ServletUserDataExtractorTest.java
new file mode 100644
index 000000000..23485f844
--- /dev/null
+++ b/packages/java/metrics-spring2/src/test/java/com/readme/spring/datacollection/userinfo/ServletUserDataExtractorTest.java
@@ -0,0 +1,189 @@
+package com.readme.spring.datacollection.userinfo;
+
+import com.auth0.jwt.JWT;
+import com.auth0.jwt.algorithms.Algorithm;
+import com.fasterxml.jackson.databind.ObjectMapper;
+import com.readme.spring.datacollection.ServletDataPayloadAdapter;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.extension.ExtendWith;
+import org.mockito.Mock;
+import org.mockito.Mockito;
+import org.mockito.junit.jupiter.MockitoExtension;
+
+import java.io.IOException;
+import java.security.KeyPair;
+import java.security.KeyPairGenerator;
+import java.security.NoSuchAlgorithmException;
+import java.security.interfaces.RSAPrivateKey;
+import java.security.interfaces.RSAPublicKey;
+import java.util.Map;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.mockito.Mockito.*;
+
+@ExtendWith(MockitoExtension.class)
+class ServletUserDataExtractorTest {
+
+ private ServletUserDataExtractor extractor;
+
+ @Mock
+ private ServletDataPayloadAdapter payload;
+
+
+ @BeforeEach
+ void setUp() {
+ extractor = new ServletUserDataExtractor(new ObjectMapper());
+ }
+
+ @Test
+ void extractFromHeader_happyPath() {
+ String headerName = "X-User-Name";
+ String expectedValue = "Parrot";
+ Map headers = Map.of(headerName, expectedValue);
+ Mockito.when(payload.getRequestHeaders()).thenReturn(headers);
+ String result = extractor.extractFromHeader(payload, headerName);
+
+ assertEquals(expectedValue, result);
+ }
+
+ @Test
+ void extractFromHeader_headerNotFound() {
+ String headerName = "X-User-Name";
+ String expectedValue = "";
+ Map headers = Map.of();
+ Mockito.when(payload.getRequestHeaders()).thenReturn(headers);
+ String result = extractor.extractFromHeader(payload, headerName);
+
+ assertEquals(expectedValue, result);
+ }
+
+ @Test
+ void extractFromHeader_multipleValuesExtractedCorrectly() {
+ String headerName = "X-User-Name";
+ String expectedValue = "Parrot,Owl,Chicken";
+ Map headers = Map.of(headerName, expectedValue);
+ Mockito.when(payload.getRequestHeaders()).thenReturn(headers);
+ String result = extractor.extractFromHeader(payload, headerName);
+
+ assertEquals(expectedValue, result);
+ }
+
+ @Test
+ void extractFromJwt_happyPath_withBearerPrefix() throws NoSuchAlgorithmException {
+ String claimName = "user_name";
+ String expectedValue = "Parrot";
+
+ Algorithm signingKeyPair = createSigningKeyPair();
+ String jwt = JWT.create()
+ .withClaim(claimName, expectedValue)
+ .sign(signingKeyPair);
+ Map headers = Map.of("authorization", "Bearer " + jwt);
+ Mockito.when(payload.getRequestHeaders()).thenReturn(headers);
+ String result = extractor.extractFromJwt(payload, claimName);
+
+ assertEquals(expectedValue, result);
+ }
+
+ @Test
+ void extractFromJwt_happyPath_NoBearerPrefix() throws NoSuchAlgorithmException {
+ String claimName = "user_name";
+ String expectedValue = "Parrot";
+
+ Algorithm signingKeyPair = createSigningKeyPair();
+ String jwt = JWT.create()
+ .withClaim(claimName, expectedValue)
+ .sign(signingKeyPair);
+ Map headers = Map.of("authorization", jwt);
+ Mockito.when(payload.getRequestHeaders()).thenReturn(headers);
+ String result = extractor.extractFromJwt(payload, claimName);
+
+ assertEquals(expectedValue, result);
+ }
+
+ @Test
+ void extractFromJwt_missingAuthorizationHeader() {
+ String claimName = "user_name";
+ String expectedValue = "";
+
+ Map headers = Map.of();
+ Mockito.when(payload.getRequestHeaders()).thenReturn(headers);
+ String result = extractor.extractFromJwt(payload, claimName);
+
+ assertEquals(expectedValue, result);
+ }
+
+ @Test
+ void extractFromJwt_invalidJwtToken() {
+ String claimName = "user_name";
+ String expectedValue = "";
+
+ Map headers = Map.of("authorization", "Bearer invalidToken");
+ Mockito.when(payload.getRequestHeaders()).thenReturn(headers);
+ String result = extractor.extractFromJwt(payload, claimName);
+
+ assertEquals(expectedValue, result);
+ }
+
+ @Test
+ void extractFromBody_happyPath() throws IOException {
+ String fieldName = "userName";
+ String expectedValue = "Owl";
+
+ String body = "{\"" + fieldName + "\":\"" + expectedValue + "\",\"anotherField\":\"anotherValue\"}";
+ Mockito.when(payload.getRequestBody()).thenReturn(body);
+ Mockito.when(payload.getRequestMethod()).thenReturn("POST");
+ Mockito.when(payload.getRequestContentType()).thenReturn("application/json");
+ String result = extractor.extractFromBody(payload, fieldName);
+
+ assertEquals(expectedValue, result);
+ }
+
+ @Test
+ void extractFromBody_fieldNotFound() {
+ String fieldName = "userName";
+ String expectedValue = "";
+
+ String body = "{\"anotherField\":\"anotherValue\"}";
+ Mockito.when(payload.getRequestBody()).thenReturn(body);
+ Mockito.when(payload.getRequestMethod()).thenReturn("POST");
+ Mockito.when(payload.getRequestContentType()).thenReturn("application/json");
+ String result = extractor.extractFromBody(payload, fieldName);
+
+ assertEquals(expectedValue, result);
+ }
+
+ @Test
+ void extractFromBody_invalidJson() {
+ String body = "invalid-json-body";
+ String expectedValue = "";
+
+ Mockito.when(payload.getRequestBody()).thenReturn(body);
+ Mockito.when(payload.getRequestMethod()).thenReturn("POST");
+ Mockito.when(payload.getRequestContentType()).thenReturn("application/json");
+ String result = extractor.extractFromBody(payload, "fieldName");
+
+ assertEquals(expectedValue, result);
+ }
+
+ @Test
+ void extractFromBody_HttpMethodGet_ReturnsEmptyString() {
+ Mockito.when(payload.getRequestMethod()).thenReturn("GET");
+
+ String result = extractor.extractFromBody(payload, "/fieldName");
+
+ assertEquals("", result);
+ verifyNoMoreInteractions(payload);
+ }
+
+ private Algorithm createSigningKeyPair() throws NoSuchAlgorithmException {
+ KeyPairGenerator keyPairGenerator = KeyPairGenerator.getInstance("RSA");
+ keyPairGenerator.initialize(2048);
+ KeyPair keyPair = keyPairGenerator.generateKeyPair();
+
+ RSAPrivateKey privateKey = (RSAPrivateKey) keyPair.getPrivate();
+ RSAPublicKey publicKey = (RSAPublicKey) keyPair.getPublic();
+
+ return Algorithm.RSA256(publicKey, privateKey);
+ }
+}
\ No newline at end of file