diff --git a/.github/workflows/android-client.yml b/.github/workflows/android-client.yml new file mode 100644 index 0000000..b9a1b83 --- /dev/null +++ b/.github/workflows/android-client.yml @@ -0,0 +1,43 @@ +# Builds and tests the Android SDK (clients/android) when it changes. +name: Android client + +on: + pull_request: + paths: + - 'clients/android/**' + - '.github/workflows/android-client.yml' + push: + branches: [main, sudoquery-monorepo-impl] + paths: + - 'clients/android/**' + - '.github/workflows/android-client.yml' + workflow_dispatch: + +permissions: + contents: read + +concurrency: + group: android-client-${{ github.ref }} + cancel-in-progress: true + +jobs: + check: + runs-on: ubuntu-latest + defaults: + run: + working-directory: clients/android + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Setup Java + uses: actions/setup-java@v4 + with: + distribution: temurin + java-version: '17' + + - name: Setup Gradle + uses: gradle/actions/setup-gradle@v4 + + - name: Test, lint, build the minified sample and publish locally + run: ./gradlew check :sample:assembleRelease :sudoquery:publishToMavenLocal diff --git a/.github/workflows/release-android.yml b/.github/workflows/release-android.yml new file mode 100644 index 0000000..210c296 --- /dev/null +++ b/.github/workflows/release-android.yml @@ -0,0 +1,50 @@ +# Publishes the Android SDK (clients/android) to Maven Central when a tag android-v is pushed. +# The npm release (release.yml) only triggers on tags starting with "v". +name: Release Android SDK to Maven Central + +on: + push: + tags: + - 'android-v*' + +jobs: + release: + runs-on: ubuntu-latest + environment: production + permissions: + contents: read + defaults: + run: + working-directory: clients/android + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Setup Java + uses: actions/setup-java@v4 + with: + distribution: temurin + java-version: '17' + + - name: Setup Gradle + uses: gradle/actions/setup-gradle@v4 + + - name: Check the tag matches VERSION_NAME + run: | + version="$(sed -n 's/^VERSION_NAME=//p' gradle.properties)" + if [ "$GITHUB_REF_NAME" != "android-v$version" ]; then + echo "::error::Tag $GITHUB_REF_NAME does not match VERSION_NAME=$version in clients/android/gradle.properties" + exit 1 + fi + + - name: Test + run: ./gradlew check + + # Uploads and validates the deployment; release it with "Publish" on central.sonatype.com. + - name: Publish to Maven Central + run: ./gradlew :sudoquery:publishToMavenCentral + env: + ORG_GRADLE_PROJECT_mavenCentralUsername: ${{ secrets.MAVEN_CENTRAL_USERNAME }} + ORG_GRADLE_PROJECT_mavenCentralPassword: ${{ secrets.MAVEN_CENTRAL_PASSWORD }} + ORG_GRADLE_PROJECT_signingInMemoryKey: ${{ secrets.SIGNING_KEY }} + ORG_GRADLE_PROJECT_signingInMemoryKeyPassword: ${{ secrets.SIGNING_KEY_PASSWORD }} diff --git a/clients/android/.gitignore b/clients/android/.gitignore new file mode 100644 index 0000000..726fca6 --- /dev/null +++ b/clients/android/.gitignore @@ -0,0 +1,8 @@ +.gradle/ +.kotlin/ +build/ +captures/ +.cxx/ +.idea/ +*.iml +local.properties diff --git a/clients/android/README.md b/clients/android/README.md new file mode 100644 index 0000000..2faa786 --- /dev/null +++ b/clients/android/README.md @@ -0,0 +1,226 @@ +# SudoQuery Android SDK + +The Android client for the SudoQuery events collector: the Kotlin counterpart of +[`clients/typescript`](../typescript). Track events from anywhere in your app; the SDK batches +them, keeps them on disk until they are delivered, and posts them to the collector's +`POST /v1/events/batch` route. + +- **Durable:** events are written to disk as they are tracked and survive app restarts and crashes. +- **Batched:** one request per batch, sent when a batch fills up, on a timer, and when the app goes to the background. +- **Safe to call anywhere:** every method is thread-safe, returns at once and never throws. Disk and network work runs on background threads. +- **Small:** Kotlin, coroutines, kotlinx.serialization and `lifecycle-process`. No HTTP library is added to your app. + +## Installation + +```kotlin +dependencies { + implementation("io.juspay:sudoquery-android:0.1.0") +} +``` + +Requirements: minSdk 21, compileSdk 34 or newer, and Kotlin 2.0 or newer (Java apps work too). +The SDK's manifest adds the `INTERNET` permission. + +## Quick start + +Initialize once, in `Application.onCreate`: + +```kotlin +class App : Application() { + override fun onCreate() { + super.onCreate() + SudoQuery.init( + this, + SudoQueryConfig.Builder( + orgId = "acme-store-k3x9qa", + projectId = "acme-store-app-9t2r4m", + endpoint = "https://collector.example.com/v1/events/batch", + ).build(), + ) + } +} +``` + +Then track events from anywhere: + +```kotlin +SudoQuery.track("button_click", mapOf("button_id" to "submit", "page" to "/checkout")) + +SudoQuery.setUser("user_123") // sent as actor_id from now on, and after restarts +SudoQuery.setSuperProperty("plan", "pro") // added to every event +SudoQuery.reset() // on logout: new anonymous id and session, no user +``` + +From Java the same methods are static: + +```java +SudoQuery.init(this, new SudoQueryConfig.Builder(orgId, projectId, endpoint).build()); +SudoQuery.track("button_click", Collections.singletonMap("button_id", "submit")); +``` + +`orgId` and `projectId` are the server-generated slugs shown in the dashboard's project settings +(the collector reads them from `x-org-id` / `x-project-id` and from every event). + +## Configuration + +`SudoQueryConfig.Builder(orgId, projectId, endpoint)` takes: + +| Option | Default | Description | +|---|---|---| +| `endpoint` | required | Full URL of the batch route, e.g. `https://collector.example.com/v1/events/batch`. | +| `token(String?)` | none | Sent as `Authorization: Bearer `, for a proxy or gateway in front of the collector. | +| `headers(Map)` | none | Extra headers. They can't replace `x-org-id`, `x-project-id` or the bearer token. | +| `source(String?)` | `"android"` | The `source` of every event. | +| `sessionId(String?)` | managed | Use a fixed session id instead of SDK-managed sessions. | +| `batchSize(Int)` | `50` | Events per batch and request (1 to 500). | +| `flushIntervalMillis(Long)` | `30000` | Send pending events at least this often; `0` turns the timer off. | +| `sessionTimeoutMillis(Long)` | 30 min | A new session starts after this long without events. | +| `debugLogging(Boolean)` | `false` | Debug logs under the `SudoQuery` logcat tag. Warnings and errors are always logged. | + +`build()` throws `IllegalArgumentException` for a blank id, a non-http(s) endpoint or an +out-of-range value. Ids that don't look like slugs only log a warning. + +## API + +| Method | Description | +|---|---| +| `init(context, config)` | Starts the SDK. The first call wins; later calls are ignored. Does nothing outside the main process. | +| `track(name, properties)` | Records an event. Properties are a `Map` or an `org.json.JSONObject`; nested maps, lists and arrays are supported. NaN and infinite numbers become `null`. | +| `setUser(id)` / `removeUser()` / `getUser()` | The user id sent as `actor_id`. It is kept across app restarts. | +| `setSuperProperty(key, value)` / `getSuperProperties()` / `clearSuperProperties()` | Properties added to every event (the event's own keys win). In memory only; usable before `init`. | +| `flush()` | Sends the events tracked so far now instead of waiting for a full batch. | +| `reset()` | For logout: later events get a new anonymous id and session, with no user id and no super properties. | +| `isInitialized` | True once `init` has started the SDK. | + +Events tracked before `init` are dropped with a warning. + +## How events are delivered + +1. `track()` copies the properties and timestamps the event on the calling thread, then hands it + to a background writer. The writer adds the ids and session, serializes the event once and + appends it to the open batch file. +2. A batch is closed when it reaches `batchSize` events or 512 KiB, every `flushIntervalMillis`, + when the app goes to the background, and on `flush()`. Closed batches wait in an on-disk queue. +3. The uploader posts queued batches oldest first, one per request: + - **Any 2xx:** the batch is delivered and deleted. + - **400, 413, 422:** the collector will never accept the batch. It is dropped and the collector's error is logged. + - **Anything else** (no network, timeouts, 5xx, 429, redirects, 401/403/404): the batch is kept. Uploads stop and retry with exponential backoff from 5 s up to 5 minutes, honouring `Retry-After`. Coming back to the foreground retries at once. + +Delivery is **at least once**. A resend is byte-for-byte the same request with the same event ids, +so the collector's downstream stores can deduplicate it. + +| Limit | Value | +|---|---| +| Single event | 64 KiB serialized; larger events are dropped with a warning | +| Queue on disk | 10 MiB; the oldest batches are dropped beyond it | +| Age | Batches older than 7 days are dropped | +| Events waiting for the writer | 1,000 in memory; more are dropped with a warning | + +Everything is stored under the app's `noBackupFilesDir/sudoquery/`. The OS never purges it, and +Auto Backup never restores it, so the anonymous id isn't copied to another device. Clearing app +data starts over with a new anonymous id. The SDK runs only in the app's main process. + +**Sessions:** a session continues while events keep coming, and a new one starts after +`sessionTimeoutMillis` without events. This holds across app restarts. + +## Wire format and changing the event type + +All wire types live in one file, +[`sudoquery/src/main/kotlin/io/juspay/sudoquery/Event.kt`](sudoquery/src/main/kotlin/io/juspay/sudoquery/Event.kt). +`EventV1` mirrors `CollectorEvent` in `crates/events-collector/src/collector_event.rs` and the TS +client's `Event`, including the `envelop_version` and `occured_at` spellings. Every key is written +out, nulls included: + +```json +{"events":[{"envelop_version":"1.0","id":"3b241101-e2bb-4255-8caf-4136c566a962","name":"button_click", +"org_id":"acme-k3x9qa","project_id":"android-demo1","session_id":"9f1c2d3e-…","anon_id":"6e8bc430-…", +"actor_id":null,"source":"android","occured_at":"2026-10-14T09:30:12.345Z","properties":{"page":"/checkout"}, +"correlation_id":null,"trace_id":null,"system_properties":null}], +"system_properties":{"geo":null,"timezone":"Asia/Kolkata"}} +``` + +The class the SDK sends is chosen by one line: + +```kotlin +internal typealias CanonicalEvent = EventV1 +``` + +To switch envelopes: +1. Add the new `@Serializable` class to `Event.kt`, with a `constructor(input: EventInput)` that fills it from what `track()` captured. +2. Point the alias at it. + +Batching, storage and upload only handle serialized events, so nothing else changes. Events already +queued by an older app version are sent exactly as they were written. If the new class needs data +`EventInput` doesn't carry, add it to `EventInput` and to where `EventWriter` fills it. + +## Differences from the TypeScript client + +- **`endpoint` is required:** there is no default. Point it at `/v1/events/batch`. +- **Events are persisted** and retried by status code, instead of held in memory and retried forever. +- **The user id persists across restarts**, and `reset()` is added for logout. +- **Sessions end after inactivity** instead of on every page load. +- **Calls before `init` are dropped** with a warning instead of throwing. +- **Properties are always a JSON object** (a map or `JSONObject`). +- **`source` defaults to `"android"`.** + +## Privacy + +For the Play Console's Data safety form: +- The SDK stores a random anonymous id (`anon_id`) and, after `setUser`, your user id (`actor_id`). +- It sends both with every event, together with the properties you track and the device's time zone. +- The collector derives the client IP address and country from each request. + +Nothing else is collected automatically. + +## Old devices + +Android 7.0 (API 24) and older don't trust some newer certificate authorities, including Let's +Encrypt's root, so serve the collector with a certificate those devices accept if you support them. + +## Development + +The build needs JDK 17 and the Android SDK. AGP is kept at 9.1 so Android Studio Panda 3 can open the project. + +```sh +cd clients/android +./gradlew check # unit tests and lint +./gradlew :sample:assembleRelease # R8-minified sample app +./gradlew :sudoquery:publishToMavenLocal +``` + +To run against a local collector from the repository root: + +```sh +cargo xtask setup redpanda +docker compose exec redpanda rpk topic create events.acme-k3x9qa.android-demo1 +COLLECTOR_CONFIG=crates/events-collector/cac.toml SERVER_ADDR=0.0.0.0:3001 cargo run -p event-collector +``` + +- **Through the real pipeline, from the JVM:** + ```sh + (cd clients/android && ./gradlew :sudoquery:testDebugUnitTest --tests '*LiveCollectorTest*' \ + -Psudoquery.liveCollectorUrl=http://localhost:3001/v1/events/batch) + ``` +- **The sample app on an emulator** (10.0.2.2 is the host machine): + ```sh + (cd clients/android && ./gradlew :sample:installRelease -Psudoquery.endpoint=http://10.0.2.2:3001/v1/events/batch) + ``` +- **Read what arrived:** + ```sh + docker compose exec redpanda rpk topic consume events.acme-k3x9qa.android-demo1 -n 10 + ``` + +## Releasing + +Releases go to Maven Central from `.github/workflows/release-android.yml`: +1. Set `VERSION_NAME` in `gradle.properties`. +2. Push the tag `android-v`. + +The workflow checks the tag, runs the tests, uploads and validates the release. You then publish +it with the "Publish" button on [central.sonatype.com](https://central.sonatype.com). + +One-time setup: +- Verify the `io.juspay` namespace on Central (a DNS TXT record on juspay.io). +- Create a Central user token. +- Create a GPG signing key and upload the public key to keys.openpgp.org. +- Add the repository secrets `MAVEN_CENTRAL_USERNAME`, `MAVEN_CENTRAL_PASSWORD`, `SIGNING_KEY` (the ASCII-armored private key) and `SIGNING_KEY_PASSWORD`. diff --git a/clients/android/build.gradle.kts b/clients/android/build.gradle.kts new file mode 100644 index 0000000..414124d --- /dev/null +++ b/clients/android/build.gradle.kts @@ -0,0 +1,17 @@ +buildscript { + repositories { + mavenCentral() + } + dependencies { + // AGP 9's built-in Kotlin compiles with the Kotlin Gradle plugin on this classpath. + // Pinning it keeps it in step with the serialization compiler plugin below. + classpath(libs.kotlin.gradle.plugin) + } +} + +plugins { + alias(libs.plugins.android.application) apply false + alias(libs.plugins.android.library) apply false + alias(libs.plugins.kotlin.serialization) apply false + alias(libs.plugins.vanniktech.publish) apply false +} diff --git a/clients/android/gradle.properties b/clients/android/gradle.properties new file mode 100644 index 0000000..bd463a1 --- /dev/null +++ b/clients/android/gradle.properties @@ -0,0 +1,24 @@ +org.gradle.jvmargs=-Xmx3g -Dfile.encoding=UTF-8 +org.gradle.caching=true +org.gradle.parallel=true +android.useAndroidX=true +kotlin.code.style=official + +# Published coordinates: io.juspay:sudoquery-android:. +# Releases are cut by pushing the tag android-v. +GROUP=io.juspay +VERSION_NAME=0.1.0 + +POM_NAME=SudoQuery Android SDK +POM_DESCRIPTION=Android client for the SudoQuery events collector: batches events, persists them on disk and delivers them to /v1/events/batch. +POM_INCEPTION_YEAR=2026 +POM_URL=https://github.com/juspay/sudoquery +POM_LICENSE_NAME=ISC License +POM_LICENSE_URL=https://opensource.org/license/isc-license-txt +POM_LICENSE_DIST=repo +POM_SCM_URL=https://github.com/juspay/sudoquery +POM_SCM_CONNECTION=scm:git:git://github.com/juspay/sudoquery.git +POM_SCM_DEV_CONNECTION=scm:git:ssh://git@github.com/juspay/sudoquery.git +POM_DEVELOPER_ID=juspay +POM_DEVELOPER_NAME=Juspay +POM_DEVELOPER_URL=https://github.com/juspay diff --git a/clients/android/gradle/libs.versions.toml b/clients/android/gradle/libs.versions.toml new file mode 100644 index 0000000..3155f5a --- /dev/null +++ b/clients/android/gradle/libs.versions.toml @@ -0,0 +1,36 @@ +[versions] +# AGP 9.1 is the newest that Android Studio Panda 3 (2025.3.3) can sync. +agp = "9.1.1" +# AGP 9 compiles Kotlin itself (built-in Kotlin) with the KGP pinned in the root build file; +# the serialization compiler plugin must use the same version. +kotlin = "2.2.21" +# Runtime dependencies are kept at versions whose kotlin-stdlib is 2.1.x, so apps on Kotlin 2.0+ can consume the SDK. +coroutines = "1.10.2" +serialization = "1.8.1" +# Newest lifecycle-process that still supports minSdk 21. +lifecycle = "2.9.4" +vanniktech-publish = "0.37.0" +junit = "4.13.2" +okhttp = "5.3.2" +robolectric = "4.16.1" +androidx-test-core = "1.6.1" +org-json = "20260814" + +[libraries] +kotlin-gradle-plugin = { module = "org.jetbrains.kotlin:kotlin-gradle-plugin", version.ref = "kotlin" } +kotlinx-coroutines-core = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-core", version.ref = "coroutines" } +kotlinx-coroutines-test = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-test", version.ref = "coroutines" } +kotlinx-serialization-json = { module = "org.jetbrains.kotlinx:kotlinx-serialization-json", version.ref = "serialization" } +androidx-lifecycle-process = { module = "androidx.lifecycle:lifecycle-process", version.ref = "lifecycle" } +junit = { module = "junit:junit", version.ref = "junit" } +okhttp-mockwebserver3 = { module = "com.squareup.okhttp3:mockwebserver3", version.ref = "okhttp" } +robolectric = { module = "org.robolectric:robolectric", version.ref = "robolectric" } +androidx-test-core = { module = "androidx.test:core", version.ref = "androidx-test-core" } +# Android's org.json is a stub in plain JVM unit tests; this is the real implementation. +org-json = { module = "org.json:json", version.ref = "org-json" } + +[plugins] +android-application = { id = "com.android.application", version.ref = "agp" } +android-library = { id = "com.android.library", version.ref = "agp" } +kotlin-serialization = { id = "org.jetbrains.kotlin.plugin.serialization", version.ref = "kotlin" } +vanniktech-publish = { id = "com.vanniktech.maven.publish", version.ref = "vanniktech-publish" } diff --git a/clients/android/gradle/wrapper/gradle-wrapper.jar b/clients/android/gradle/wrapper/gradle-wrapper.jar new file mode 100644 index 0000000..b1b8ef5 Binary files /dev/null and b/clients/android/gradle/wrapper/gradle-wrapper.jar differ diff --git a/clients/android/gradle/wrapper/gradle-wrapper.properties b/clients/android/gradle/wrapper/gradle-wrapper.properties new file mode 100644 index 0000000..9354476 --- /dev/null +++ b/clients/android/gradle/wrapper/gradle-wrapper.properties @@ -0,0 +1,9 @@ +distributionBase=GRADLE_USER_HOME +distributionPath=wrapper/dists +distributionUrl=https\://services.gradle.org/distributions/gradle-9.3.1-bin.zip +networkTimeout=10000 +retries=0 +retryBackOffMs=500 +validateDistributionUrl=true +zipStoreBase=GRADLE_USER_HOME +zipStorePath=wrapper/dists diff --git a/clients/android/gradlew b/clients/android/gradlew new file mode 100755 index 0000000..b9bb139 --- /dev/null +++ b/clients/android/gradlew @@ -0,0 +1,248 @@ +#!/bin/sh + +# +# Copyright © 2015 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/3d91ce3b8caaf77ad09f381f43615b715b53f72c/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\n' "$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 + + + +# 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" ) + + 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, 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" \ + -jar "$APP_HOME/gradle/wrapper/gradle-wrapper.jar" \ + "$@" + +# 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/clients/android/gradlew.bat b/clients/android/gradlew.bat new file mode 100644 index 0000000..aa5f10b --- /dev/null +++ b/clients/android/gradlew.bat @@ -0,0 +1,82 @@ +@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, and ensure extensions are enabled +setlocal EnableExtensions + +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 + +"%COMSPEC%" /c exit 1 + +: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 + +"%COMSPEC%" /c exit 1 + +:execute +@rem Setup the command line + + + +@rem Execute Gradle +@rem endlocal doesn't take effect until after the line is parsed and variables are expanded +@rem which allows us to clear the local environment before executing the java command +endlocal & "%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -jar "%APP_HOME%\gradle\wrapper\gradle-wrapper.jar" %* & call :exitWithErrorLevel + +:exitWithErrorLevel +@rem Use "%COMSPEC%" /c exit to allow operators to work properly in scripts +"%COMSPEC%" /c exit %ERRORLEVEL% diff --git a/clients/android/sample/build.gradle.kts b/clients/android/sample/build.gradle.kts new file mode 100644 index 0000000..83aa375 --- /dev/null +++ b/clients/android/sample/build.gradle.kts @@ -0,0 +1,46 @@ +plugins { + alias(libs.plugins.android.application) +} + +// Point the sample at a collector with -Psudoquery.endpoint=... (10.0.2.2 is the host machine from the emulator). +val endpoint = providers.gradleProperty("sudoquery.endpoint").getOrElse("http://10.0.2.2:3000/v1/events/batch") +val orgId = providers.gradleProperty("sudoquery.orgId").getOrElse("acme-k3x9qa") +val projectId = providers.gradleProperty("sudoquery.projectId").getOrElse("android-demo1") + +android { + namespace = "io.juspay.sudoquery.sample" + compileSdk = 36 + + defaultConfig { + applicationId = "io.juspay.sudoquery.sample" + minSdk = 21 + targetSdk = 36 + versionCode = 1 + versionName = "1.0" + buildConfigField("String", "SUDOQUERY_ENDPOINT", "\"$endpoint\"") + buildConfigField("String", "SUDOQUERY_ORG_ID", "\"$orgId\"") + buildConfigField("String", "SUDOQUERY_PROJECT_ID", "\"$projectId\"") + } + + buildFeatures { + buildConfig = true + } + + buildTypes { + // Minified like a production app, to check the SDK works under R8. + release { + isMinifyEnabled = true + proguardFiles(getDefaultProguardFile("proguard-android-optimize.txt")) + signingConfig = signingConfigs.getByName("debug") + } + } + + compileOptions { + sourceCompatibility = JavaVersion.VERSION_11 + targetCompatibility = JavaVersion.VERSION_11 + } +} + +dependencies { + implementation(project(":sudoquery")) +} diff --git a/clients/android/sample/src/main/AndroidManifest.xml b/clients/android/sample/src/main/AndroidManifest.xml new file mode 100644 index 0000000..737b1c9 --- /dev/null +++ b/clients/android/sample/src/main/AndroidManifest.xml @@ -0,0 +1,20 @@ + + + + + + + + + + + + + + diff --git a/clients/android/sample/src/main/kotlin/io/juspay/sudoquery/sample/MainActivity.kt b/clients/android/sample/src/main/kotlin/io/juspay/sudoquery/sample/MainActivity.kt new file mode 100644 index 0000000..7037c2b --- /dev/null +++ b/clients/android/sample/src/main/kotlin/io/juspay/sudoquery/sample/MainActivity.kt @@ -0,0 +1,62 @@ +package io.juspay.sudoquery.sample + +import android.app.Activity +import android.os.Bundle +import android.widget.Button +import android.widget.LinearLayout +import android.widget.ScrollView +import android.widget.TextView +import io.juspay.sudoquery.SudoQuery + +class MainActivity : Activity() { + private var clicks = 0 + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + val status = TextView(this) + val padding = (16 * resources.displayMetrics.density).toInt() + val layout = LinearLayout(this).apply { + orientation = LinearLayout.VERTICAL + setPadding(padding, padding, padding, padding) + } + + fun addButton(label: String, action: () -> String) { + layout.addView(Button(this).apply { + text = label + setOnClickListener { status.text = action() } + }) + } + + addButton("Track button_click") { + clicks++ + SudoQuery.track("button_click", mapOf("button_id" to "sample", "clicks" to clicks)) + "Tracked button_click #$clicks" + } + addButton("Track 100 events") { + repeat(100) { SudoQuery.track("bulk_event", mapOf("index" to it)) } + "Tracked 100 events" + } + addButton("Set user user_123") { + SudoQuery.setUser("user_123") + "User: ${SudoQuery.getUser()}" + } + addButton("Remove user") { + SudoQuery.removeUser() + "User: ${SudoQuery.getUser()}" + } + addButton("Reset (logout)") { + SudoQuery.reset() + "Reset: new anonymous id and session" + } + addButton("Flush") { + SudoQuery.flush() + "Flush requested" + } + layout.addView(status) + + setContentView(ScrollView(this).apply { + fitsSystemWindows = true + addView(layout) + }) + } +} diff --git a/clients/android/sample/src/main/kotlin/io/juspay/sudoquery/sample/SampleApp.kt b/clients/android/sample/src/main/kotlin/io/juspay/sudoquery/sample/SampleApp.kt new file mode 100644 index 0000000..ade5390 --- /dev/null +++ b/clients/android/sample/src/main/kotlin/io/juspay/sudoquery/sample/SampleApp.kt @@ -0,0 +1,22 @@ +package io.juspay.sudoquery.sample + +import android.app.Application +import io.juspay.sudoquery.SudoQuery +import io.juspay.sudoquery.SudoQueryConfig + +class SampleApp : Application() { + override fun onCreate() { + super.onCreate() + val config = SudoQueryConfig.Builder( + orgId = BuildConfig.SUDOQUERY_ORG_ID, + projectId = BuildConfig.SUDOQUERY_PROJECT_ID, + endpoint = BuildConfig.SUDOQUERY_ENDPOINT, + ) + .batchSize(20) + .flushIntervalMillis(10_000) + .debugLogging(true) + .build() + SudoQuery.init(this, config) + SudoQuery.setSuperProperty("app_version", BuildConfig.VERSION_NAME) + } +} diff --git a/clients/android/sample/src/main/res/xml/network_security_config.xml b/clients/android/sample/src/main/res/xml/network_security_config.xml new file mode 100644 index 0000000..0a3a05b --- /dev/null +++ b/clients/android/sample/src/main/res/xml/network_security_config.xml @@ -0,0 +1,8 @@ + + + + + 10.0.2.2 + localhost + + diff --git a/clients/android/settings.gradle.kts b/clients/android/settings.gradle.kts new file mode 100644 index 0000000..33773ed --- /dev/null +++ b/clients/android/settings.gradle.kts @@ -0,0 +1,31 @@ +pluginManagement { + repositories { + google { + content { + includeGroupByRegex("com\\.android.*") + includeGroupByRegex("com\\.google.*") + includeGroupByRegex("androidx.*") + } + } + mavenCentral() + gradlePluginPortal() + } +} + +dependencyResolutionManagement { + repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS) + repositories { + google { + content { + includeGroupByRegex("com\\.android.*") + includeGroupByRegex("com\\.google.*") + includeGroupByRegex("androidx.*") + } + } + mavenCentral() + } +} + +rootProject.name = "sudoquery-android" + +include(":sudoquery", ":sample") diff --git a/clients/android/sudoquery/build.gradle.kts b/clients/android/sudoquery/build.gradle.kts new file mode 100644 index 0000000..930edd5 --- /dev/null +++ b/clients/android/sudoquery/build.gradle.kts @@ -0,0 +1,90 @@ +import com.vanniktech.maven.publish.AndroidSingleVariantLibrary +import com.vanniktech.maven.publish.JavadocJar +import com.vanniktech.maven.publish.SourcesJar +import org.jetbrains.kotlin.gradle.dsl.KotlinVersion + +plugins { + alias(libs.plugins.android.library) + alias(libs.plugins.kotlin.serialization) + alias(libs.plugins.vanniktech.publish) +} + +val sdkVersion: String = providers.gradleProperty("VERSION_NAME").get() + +android { + namespace = "io.juspay.sudoquery" + compileSdk = 36 + + defaultConfig { + minSdk = 21 + consumerProguardFiles("consumer-rules.pro") + buildConfigField("String", "SDK_VERSION", "\"$sdkVersion\"") + // Apps only need compileSdk 34 to consume the SDK, not this module's compileSdk. + aarMetadata { + minCompileSdk = 34 + } + } + + buildFeatures { + buildConfig = true + } + + compileOptions { + sourceCompatibility = JavaVersion.VERSION_11 + targetCompatibility = JavaVersion.VERSION_11 + } + + testOptions { + unitTests.isIncludeAndroidResources = true + } + + lint { + // Versions are pinned on purpose (see gradle/libs.versions.toml): newer ones would raise + // the Kotlin, minSdk or Android Studio versions the SDK and this build require. + disable += setOf("AndroidGradlePluginVersion", "GradleDependency", "NewerVersionAvailable") + } +} + +kotlin { + explicitApi() + // Compile against the Kotlin 2.1 stdlib and language so apps on Kotlin 2.0+ can consume the AAR. + coreLibrariesVersion = "2.1.21" + compilerOptions { + languageVersion = KotlinVersion.KOTLIN_2_1 + apiVersion = KotlinVersion.KOTLIN_2_1 + } +} + +dependencies { + implementation(libs.kotlinx.coroutines.core) + implementation(libs.kotlinx.serialization.json) + implementation(libs.androidx.lifecycle.process) + + testImplementation(libs.junit) + testImplementation(libs.kotlinx.coroutines.test) + testImplementation(libs.okhttp.mockwebserver3) + testImplementation(libs.robolectric) + testImplementation(libs.androidx.test.core) + testImplementation(libs.org.json) +} + +tasks.withType().configureEach { + // Opt-in end-to-end test against a running collector: + // ./gradlew :sudoquery:testDebugUnitTest -Psudoquery.liveCollectorUrl=http://localhost:3001/v1/events/batch + systemProperty( + "sudoquery.liveCollectorUrl", + providers.gradleProperty("sudoquery.liveCollectorUrl").getOrElse(""), + ) +} + +mavenPublishing { + // Maven Central requires a javadoc jar; an empty one is accepted. + configure(AndroidSingleVariantLibrary(javadocJar = JavadocJar.Empty(), sourcesJar = SourcesJar.Sources(), variant = "release")) + coordinates(artifactId = "sudoquery-android") + publishToMavenCentral() + // CI passes the key as ORG_GRADLE_PROJECT_signingInMemoryKey; without it (local builds, + // publishToMavenLocal) publications are left unsigned. + if (providers.gradleProperty("signingInMemoryKey").isPresent) { + signAllPublications() + } +} diff --git a/clients/android/sudoquery/consumer-rules.pro b/clients/android/sudoquery/consumer-rules.pro new file mode 100644 index 0000000..7cb3559 --- /dev/null +++ b/clients/android/sudoquery/consumer-rules.pro @@ -0,0 +1,2 @@ +# The SDK needs no keep rules: it uses no reflection, and its serializers are referenced +# directly (kotlinx.serialization ships the rules for its own runtime). diff --git a/clients/android/sudoquery/src/main/AndroidManifest.xml b/clients/android/sudoquery/src/main/AndroidManifest.xml new file mode 100644 index 0000000..5e7684f --- /dev/null +++ b/clients/android/sudoquery/src/main/AndroidManifest.xml @@ -0,0 +1,6 @@ + + + + + + diff --git a/clients/android/sudoquery/src/main/kotlin/io/juspay/sudoquery/Event.kt b/clients/android/sudoquery/src/main/kotlin/io/juspay/sudoquery/Event.kt new file mode 100644 index 0000000..7c54500 --- /dev/null +++ b/clients/android/sudoquery/src/main/kotlin/io/juspay/sudoquery/Event.kt @@ -0,0 +1,93 @@ +package io.juspay.sudoquery + +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable +import kotlinx.serialization.json.Json +import kotlinx.serialization.json.JsonElement +import kotlinx.serialization.json.JsonObject + +/** + * The event class the SDK sends. To move to a new envelope, add its class to this file (with a + * `constructor(input: EventInput)`) and point this alias at it; nothing else needs to change. + * Batching, storage and upload only ever see serialized events. + */ +internal typealias CanonicalEvent = EventV1 + +/** + * Everything the SDK knows about one tracked event, independent of the envelope version. + * Each event class picks the fields it needs in its `constructor(input: EventInput)`. + */ +internal class EventInput( + val id: String, + val name: String, + val orgId: String, + val projectId: String, + val sessionId: String?, + val anonId: String, + val actorId: String?, + val source: String?, + /** RFC 3339 in UTC with milliseconds, e.g. `2026-10-14T09:30:12.345Z`. */ + val occurredAt: String, + val properties: JsonObject, +) + +/** + * Envelope "1.0". Mirrors `CollectorEvent` in crates/events-collector/src/collector_event.rs and + * `Event` in clients/typescript/src/types.ts, field for field and in the same order, including + * the `envelop_version` and `occured_at` spellings. + */ +@Serializable +internal data class EventV1( + @SerialName("envelop_version") val envelopVersion: String = "1.0", + @SerialName("id") val id: String, + @SerialName("name") val name: String, + @SerialName("org_id") val orgId: String, + @SerialName("project_id") val projectId: String, + @SerialName("session_id") val sessionId: String? = null, + @SerialName("anon_id") val anonId: String, + @SerialName("actor_id") val actorId: String? = null, + @SerialName("source") val source: String? = null, + @SerialName("occured_at") val occuredAt: String, + @SerialName("properties") val properties: JsonObject? = null, + @SerialName("correlation_id") val correlationId: String? = null, + @SerialName("trace_id") val traceId: String? = null, + @SerialName("system_properties") val systemProperties: SystemProperties? = null, +) { + constructor(input: EventInput) : this( + id = input.id, + name = input.name, + orgId = input.orgId, + projectId = input.projectId, + sessionId = input.sessionId, + anonId = input.anonId, + actorId = input.actorId, + source = input.source, + occuredAt = input.occurredAt, + properties = input.properties, + ) +} + +/** System properties understood by the collector. */ +@Serializable +internal data class SystemProperties( + @SerialName("geo") val geo: Geo? = null, + @SerialName("timezone") val timezone: String? = null, +) + +@Serializable +internal data class Geo( + @SerialName("country") val country: String? = null, +) + +/** + * Body of `POST /v1/events/batch`. Events stay opaque JSON, so stored events of any envelope + * version are sent exactly as they were written. + */ +@Serializable +internal data class BatchPayload( + @SerialName("events") val events: List, + @SerialName("system_properties") val systemProperties: SystemProperties?, +) + +/** JSON settings for everything on the wire: like `JSON.stringify` in the TS client, nulls and defaults are written out. */ +internal val WireJson: Json = Json { encodeDefaults = true } diff --git a/clients/android/sudoquery/src/main/kotlin/io/juspay/sudoquery/SudoQuery.kt b/clients/android/sudoquery/src/main/kotlin/io/juspay/sudoquery/SudoQuery.kt new file mode 100644 index 0000000..4b00014 --- /dev/null +++ b/clients/android/sudoquery/src/main/kotlin/io/juspay/sudoquery/SudoQuery.kt @@ -0,0 +1,197 @@ +package io.juspay.sudoquery + +import android.content.Context +import io.juspay.sudoquery.internal.AndroidClock +import io.juspay.sudoquery.internal.AndroidLogger +import io.juspay.sudoquery.internal.Clock +import io.juspay.sudoquery.internal.JsonValues +import io.juspay.sudoquery.internal.Logger +import io.juspay.sudoquery.internal.SudoQueryClient +import io.juspay.sudoquery.internal.SuperProperties +import io.juspay.sudoquery.internal.isMainProcess +import kotlinx.serialization.json.JsonObject +import org.json.JSONObject +import java.util.concurrent.atomic.AtomicBoolean + +/** + * The SudoQuery Android SDK. Call [init] once, typically in `Application.onCreate`, then [track] + * events from anywhere. + * + * Every method is thread-safe, returns quickly and never throws: events are written to disk and + * sent to the collector on background threads. Events survive app restarts and are retried until + * the collector accepts them (at-least-once delivery). + */ +public object SudoQuery { + private val lock = Any() + private val superProperties = SuperProperties() + private val warnedNotInitialized = AtomicBoolean(false) + + @Volatile + private var client: SudoQueryClient? = null + + @Volatile + private var logger: Logger = AndroidLogger(debugEnabled = false) + + // Seams for tests. + @Volatile + internal var processCheck: (Context) -> Boolean = ::isMainProcess + + @Volatile + internal var clock: Clock = AndroidClock + + /** + * Starts the SDK. Only the first call has an effect; later calls are ignored, as in the TS + * client. Does nothing outside the app's main process. + */ + @JvmStatic + public fun init(context: Context, config: SudoQueryConfig) { + guarded("init") { + synchronized(lock) { + if (client != null) { + logger.debug("init called again; keeping the first configuration") + return + } + val app = context.applicationContext ?: context + val configLogger = AndroidLogger(config.debugLogging) + if (!processCheck(app)) { + configLogger.warn("Not starting: SudoQuery only runs in the app's main process") + return + } + warnIfNotSlug("orgId", config.orgId, configLogger) + warnIfNotSlug("projectId", config.projectId, configLogger) + logger = configLogger + client = SudoQueryClient.start(app, config, configLogger, clock) + } + } + } + + /** True once [init] has started the SDK. */ + @JvmStatic + public val isInitialized: Boolean + get() = client != null + + /** + * Records an event. [properties] are copied and sent as a JSON object, merged over the super + * properties (keys given here win). Nested maps, lists and arrays are supported; NaN and + * infinite numbers become null. Events tracked before [init] are dropped with a warning. + */ + @JvmStatic + @JvmOverloads + public fun track(name: String, properties: Map = emptyMap()) { + val timestamp = clock.nowMillis() + guarded("track") { + val active = clientOrWarn("track") ?: return + active.track(name, timestamp, merged(JsonValues.toJsonObject(properties, logger))) + } + } + + /** Records an event with properties from an `org.json` object. See [track]. */ + @JvmStatic + public fun track(name: String, properties: JSONObject) { + val timestamp = clock.nowMillis() + guarded("track") { + val active = clientOrWarn("track") ?: return + active.track(name, timestamp, merged(JsonValues.toJsonObject(properties, logger))) + } + } + + /** Sets the user id sent as `actor_id` with every later event. It is kept across app restarts. */ + @JvmStatic + public fun setUser(userId: String) { + guarded("setUser") { clientOrWarn("setUser")?.setUser(userId) } + } + + /** Stops sending a user id. The anonymous id and session are kept; see [reset] for a full logout. */ + @JvmStatic + public fun removeUser() { + guarded("removeUser") { clientOrWarn("removeUser")?.setUser(null) } + } + + /** The current user id, or null. */ + @JvmStatic + public fun getUser(): String? = client?.currentUser() + + /** Adds a property to every later event. Kept in memory only, and usable before [init]. */ + @JvmStatic + public fun setSuperProperty(key: String, value: Any?) { + guarded("setSuperProperty") { superProperties.set(key, JsonValues.toJsonElement(value, logger)) } + } + + /** A copy of the super properties; JSON objects come back as maps and arrays as lists. */ + @JvmStatic + public fun getSuperProperties(): Map = + superProperties.snapshot().mapValues { JsonValues.toPlain(it.value) } + + @JvmStatic + public fun clearSuperProperties() { + superProperties.clear() + } + + /** + * Commits the events tracked so far into a batch and starts sending it. Returns at once; + * if uploads are failing, the batch waits for the next retry. + */ + @JvmStatic + public fun flush() { + guarded("flush") { clientOrWarn("flush")?.flush() } + } + + /** + * For logout: later events get a new anonymous id and session, and no user id. Super + * properties are cleared. Events already tracked are still sent unchanged. + */ + @JvmStatic + public fun reset() { + guarded("reset") { + superProperties.clear() + clientOrWarn("reset")?.reset() + } + } + + internal val clientForTests: SudoQueryClient? + get() = client + + /** Stops the SDK and forgets its state, so a test can call [init] again. */ + internal fun resetForTests() { + synchronized(lock) { + client?.shutdown() + client = null + superProperties.clear() + warnedNotInitialized.set(false) + processCheck = ::isMainProcess + clock = AndroidClock + } + } + + private fun merged(properties: JsonObject): JsonObject = superProperties.mergeUnder(properties) + + private fun clientOrWarn(operation: String): SudoQueryClient? { + val current = client + if (current == null && warnedNotInitialized.compareAndSet(false, true)) { + logger.warn("SudoQuery.$operation was called before SudoQuery.init; such calls are ignored") + } + return current + } + + private inline fun guarded(operation: String, block: () -> Unit) { + try { + block() + } catch (e: Exception) { + logger.error("SudoQuery.$operation failed", e) + } + } + + private val SLUG = Regex("^[a-z][a-z0-9-]{4,28}[a-z0-9]$") + + /** + * Org and project ids are server-generated slugs (e.g. "acme-store-k3x9qa"). Warn, never throw, + * when one deviates, so the misconfiguration is visible without breaking the app. + */ + private fun warnIfNotSlug(field: String, value: String, logger: Logger) { + if (SLUG.matches(value)) return + logger.warn( + "config option \"$field\" value \"$value\" does not match the expected server-generated slug shape " + + "/^[a-z][a-z0-9-]{4,28}[a-z0-9]$/ (e.g. \"acme-store-k3x9qa\"); the collector may reject events carrying it.", + ) + } +} diff --git a/clients/android/sudoquery/src/main/kotlin/io/juspay/sudoquery/SudoQueryConfig.kt b/clients/android/sudoquery/src/main/kotlin/io/juspay/sudoquery/SudoQueryConfig.kt new file mode 100644 index 0000000..a673a0e --- /dev/null +++ b/clients/android/sudoquery/src/main/kotlin/io/juspay/sudoquery/SudoQueryConfig.kt @@ -0,0 +1,118 @@ +package io.juspay.sudoquery + +import java.net.MalformedURLException +import java.net.URL + +/** + * Configuration for [SudoQuery.init]. Create one with [Builder]: + * + * ``` + * val config = SudoQueryConfig.Builder( + * orgId = "acme-store-k3x9qa", + * projectId = "acme-store-app-9t2r4m", + * endpoint = "https://collector.example.com/v1/events/batch", + * ).build() + * ``` + */ +public class SudoQueryConfig private constructor( + internal val orgId: String, + internal val projectId: String, + internal val endpoint: String, + internal val token: String?, + internal val headers: Map, + internal val source: String?, + internal val sessionId: String?, + internal val batchSize: Int, + internal val flushIntervalMillis: Long, + internal val sessionTimeoutMillis: Long, + internal val debugLogging: Boolean, +) { + + /** + * @param orgId the organization id (a server-generated slug such as `acme-store-k3x9qa`), + * sent as `x-org-id` and in every event. + * @param projectId the project id (a server-generated slug), sent as `x-project-id` and in + * every event. + * @param endpoint the full URL of the collector's batch route, e.g. + * `https://collector.example.com/v1/events/batch`. + */ + public class Builder( + private val orgId: String, + private val projectId: String, + private val endpoint: String, + ) { + private var token: String? = null + private var headers: Map = emptyMap() + private var source: String? = DEFAULT_SOURCE + private var sessionId: String? = null + private var batchSize: Int = DEFAULT_BATCH_SIZE + private var flushIntervalMillis: Long = DEFAULT_FLUSH_INTERVAL_MILLIS + private var sessionTimeoutMillis: Long = DEFAULT_SESSION_TIMEOUT_MILLIS + private var debugLogging: Boolean = false + + /** Sent as `Authorization: Bearer `, for a proxy or gateway in front of the collector. */ + public fun token(token: String?): Builder = apply { this.token = token } + + /** + * Extra headers for every request. They can replace `Content-Type` and `User-Agent`, + * but not `x-org-id`, `x-project-id` or the bearer [token]. + */ + public fun headers(headers: Map): Builder = apply { this.headers = headers.toMap() } + + /** The `source` of every event. Defaults to `"android"`. */ + public fun source(source: String?): Builder = apply { this.source = source } + + /** Use this session id for every event instead of SDK-managed sessions. */ + public fun sessionId(sessionId: String?): Builder = apply { this.sessionId = sessionId } + + /** Events per batch file and per request, 1 to 500. Defaults to 50. */ + public fun batchSize(batchSize: Int): Builder = apply { this.batchSize = batchSize } + + /** How often pending events are sent even if a batch isn't full. Defaults to 30 s; 0 turns the timer off. */ + public fun flushIntervalMillis(millis: Long): Builder = apply { this.flushIntervalMillis = millis } + + /** A new session starts when no event was tracked for this long. Defaults to 30 minutes. */ + public fun sessionTimeoutMillis(millis: Long): Builder = apply { this.sessionTimeoutMillis = millis } + + /** Log debug details to logcat under the `SudoQuery` tag. Warnings and errors are always logged. */ + public fun debugLogging(enabled: Boolean): Builder = apply { this.debugLogging = enabled } + + /** @throws IllegalArgumentException if an id is blank, the endpoint isn't an http(s) URL, or a value is out of range. */ + public fun build(): SudoQueryConfig { + require(orgId.isNotBlank()) { "orgId must not be blank" } + require(projectId.isNotBlank()) { "projectId must not be blank" } + val url = try { + URL(endpoint) + } catch (e: MalformedURLException) { + throw IllegalArgumentException("endpoint is not a valid URL: $endpoint", e) + } + require(url.protocol == "https" || url.protocol == "http") { + "endpoint must be an http(s) URL: $endpoint" + } + require(batchSize in 1..MAX_BATCH_SIZE) { "batchSize must be between 1 and $MAX_BATCH_SIZE, was $batchSize" } + require(flushIntervalMillis >= 0) { "flushIntervalMillis must not be negative, was $flushIntervalMillis" } + require(sessionTimeoutMillis > 0) { "sessionTimeoutMillis must be positive, was $sessionTimeoutMillis" } + return SudoQueryConfig( + orgId = orgId, + projectId = projectId, + endpoint = endpoint, + token = token, + headers = headers, + source = source, + sessionId = sessionId, + batchSize = batchSize, + flushIntervalMillis = flushIntervalMillis, + sessionTimeoutMillis = sessionTimeoutMillis, + debugLogging = debugLogging, + ) + } + } + + internal companion object { + const val DEFAULT_SOURCE: String = "android" + const val DEFAULT_BATCH_SIZE: Int = 50 + const val MAX_BATCH_SIZE: Int = 500 + const val DEFAULT_FLUSH_INTERVAL_MILLIS: Long = 30_000 + const val DEFAULT_SESSION_TIMEOUT_MILLIS: Long = 30 * 60 * 1000 + } +} diff --git a/clients/android/sudoquery/src/main/kotlin/io/juspay/sudoquery/internal/EventWriter.kt b/clients/android/sudoquery/src/main/kotlin/io/juspay/sudoquery/internal/EventWriter.kt new file mode 100644 index 0000000..34e2927 --- /dev/null +++ b/clients/android/sudoquery/src/main/kotlin/io/juspay/sudoquery/internal/EventWriter.kt @@ -0,0 +1,154 @@ +package io.juspay.sudoquery.internal + +import io.juspay.sudoquery.EventInput +import io.juspay.sudoquery.WireJson +import kotlinx.coroutines.CompletableDeferred +import kotlinx.serialization.KSerializer +import kotlinx.serialization.json.JsonObject +import java.io.IOException +import java.util.UUID + +/** Work for the event writer, handled strictly in the order the app made the calls. */ +internal sealed interface Command { + class Track(val name: String, val timestampMillis: Long, val properties: JsonObject) : Command + + class SetUser(val userId: String?) : Command + + object Reset : Command + + object Flush : Command + + object Tick : Command + + object Background : Command + + object Foreground : Command + + /** Completes once every earlier command has been handled. */ + class Barrier(val done: CompletableDeferred) : Command +} + +internal class WriterSettings( + val orgId: String, + val projectId: String, + val source: String?, + val fixedSessionId: String?, + val sessionTimeoutMillis: Long, +) + +/** + * Turns commands into stored events. It runs on a single coroutine, so `setUser` → `track` → + * `removeUser` keeps its meaning, and identity is resolved here (after it was loaded from disk) + * rather than on the app's thread. + * + * Generic over the event class and its serializer; the SDK wires it with `CanonicalEvent`. Each + * event is serialized exactly once, so a resend carries the same `id` and timestamp. + */ +internal class EventWriter( + private val serializer: KSerializer, + private val buildEvent: (EventInput) -> E, + private val settings: WriterSettings, + private val store: QueueStore, + private val identityStore: IdentityStore, + private val uploads: UploadControl, + private val logger: Logger, + private val newId: () -> String = { UUID.randomUUID().toString() }, +) { + private lateinit var identity: IdentityState + private var identityDirty = false + + /** Loads the identity and recovers batches left by the previous run. Returns the stored user id. */ + fun start(): String? { + identity = identityStore.load() ?: IdentityState(anonId = newId()).also { identityStore.save(it) } + try { + store.recover() + store.enforceLimits() + } catch (e: Exception) { + logger.error("Could not recover the event queue", e) + } + uploads.requestUpload() + return identity.actorId + } + + fun handle(command: Command) { + when (command) { + is Command.Track -> track(command) + is Command.SetUser -> updateIdentity(identity.copy(actorId = command.userId)) + Command.Reset -> updateIdentity(IdentityState(anonId = newId())) + Command.Flush -> commitAndUpload() + Command.Tick -> { + // While uploads back off (e.g. offline), let the open batch fill up instead of + // committing many small ones. + if (!uploads.isBackingOff) commitAndUpload() + saveIdentityIfDirty() + } + Command.Background -> { + commitAndUpload() + saveIdentityIfDirty() + } + Command.Foreground -> { + uploads.resetBackoff() + uploads.requestUpload() + } + is Command.Barrier -> command.done.complete(Unit) + } + } + + private fun track(command: Command.Track) { + val previousSession = identity.sessionId + identity = identity.touch(command.timestampMillis, settings.sessionTimeoutMillis, settings.fixedSessionId, newId) + if (identity.sessionId != previousSession) updateIdentity(identity) else identityDirty = true + + val input = EventInput( + id = newId(), + name = command.name, + orgId = settings.orgId, + projectId = settings.projectId, + sessionId = identity.sessionId, + anonId = identity.anonId, + actorId = identity.actorId, + source = settings.source, + occurredAt = formatIsoUtcMillis(command.timestampMillis), + properties = command.properties, + ) + val line = WireJson.encodeToString(serializer, buildEvent(input)).toByteArray(Charsets.UTF_8) + if (line.size > MAX_EVENT_BYTES) { + logger.warn("Dropped event \"${command.name}\": it is ${line.size} bytes, over the $MAX_EVENT_BYTES-byte limit") + return + } + try { + if (store.append(line)) afterCommit() + } catch (e: IOException) { + logger.warn("Could not store event \"${command.name}\"; it was dropped", e) + } + } + + private fun commitAndUpload() { + try { + if (store.commit()) store.enforceLimits() + } catch (e: IOException) { + logger.warn("Could not commit the open batch", e) + } + uploads.requestUpload() + } + + private fun afterCommit() { + store.enforceLimits() + uploads.requestUpload() + } + + private fun updateIdentity(state: IdentityState) { + identity = state + identityStore.save(state) + identityDirty = false + } + + private fun saveIdentityIfDirty() { + if (identityDirty) updateIdentity(identity) + } + + internal companion object { + /** Larger events are dropped: they would crowd out whole batches. */ + const val MAX_EVENT_BYTES: Int = 64 * 1024 + } +} diff --git a/clients/android/sudoquery/src/main/kotlin/io/juspay/sudoquery/internal/HttpTransport.kt b/clients/android/sudoquery/src/main/kotlin/io/juspay/sudoquery/internal/HttpTransport.kt new file mode 100644 index 0000000..afcd0fa --- /dev/null +++ b/clients/android/sudoquery/src/main/kotlin/io/juspay/sudoquery/internal/HttpTransport.kt @@ -0,0 +1,98 @@ +package io.juspay.sudoquery.internal + +import java.io.ByteArrayOutputStream +import java.io.IOException +import java.io.InputStream +import java.net.HttpURLConnection +import java.net.URL + +internal class HttpResult( + val code: Int, + /** `Retry-After` in seconds, when the server sent one. */ + val retryAfterSeconds: Long?, + /** The start of the response body, for logging. */ + val body: String?, +) + +internal fun interface Transport { + /** Posts one batch. Blocking; throws [IOException] when no HTTP response arrives. */ + fun post(body: ByteArray): HttpResult +} + +/** + * Request headers, with the TS client's precedence: custom headers can replace `Content-Type` and + * `User-Agent`, but `x-org-id`, `x-project-id` and the bearer token always win. Names are + * compared case-insensitively. + */ +internal fun requestHeaders( + orgId: String, + projectId: String, + token: String?, + custom: Map, + userAgent: String, +): Map { + val headers = LinkedHashMap() + fun put(name: String, value: String) { + headers.keys.removeAll { it.equals(name, ignoreCase = true) } + headers[name] = value + } + put("Content-Type", "application/json") + put("User-Agent", userAgent) + for ((name, value) in custom) put(name, value) + put("x-org-id", orgId) + put("x-project-id", projectId) + if (!token.isNullOrEmpty()) put("Authorization", "Bearer $token") + return headers +} + +/** [Transport] over `HttpURLConnection`, so the SDK adds no HTTP library to the app. */ +internal class HttpUrlConnectionTransport( + private val endpoint: URL, + private val headers: Map, + private val connectTimeoutMillis: Int = 15_000, + private val readTimeoutMillis: Int = 30_000, +) : Transport { + + override fun post(body: ByteArray): HttpResult { + val connection = endpoint.openConnection() as HttpURLConnection + try { + connection.requestMethod = "POST" + connection.doOutput = true + connection.useCaches = false + // Following a redirect would mean sending the batch again; treat it as a misconfigured endpoint. + connection.instanceFollowRedirects = false + connection.connectTimeout = connectTimeoutMillis + connection.readTimeout = readTimeoutMillis + connection.setFixedLengthStreamingMode(body.size) + for ((name, value) in headers) connection.setRequestProperty(name, value) + + connection.outputStream.use { it.write(body) } + + val code = connection.responseCode + val retryAfter = connection.getHeaderField("Retry-After")?.trim()?.toLongOrNull() + val stream = if (code >= 400) connection.errorStream else connection.inputStream + // Reading the body to the end lets the connection be reused for the next batch. + val text = stream?.use { readStart(it) } + return HttpResult(code, retryAfter, text) + } catch (e: IOException) { + connection.disconnect() + throw e + } + } + + private fun readStart(input: InputStream): String { + val start = ByteArrayOutputStream() + val chunk = ByteArray(4096) + while (true) { + val read = input.read(chunk) + if (read < 0) break + val room = MAX_LOGGED_BODY_BYTES - start.size() + if (room > 0) start.write(chunk, 0, minOf(read, room)) + } + return start.toString("UTF-8") + } + + private companion object { + const val MAX_LOGGED_BODY_BYTES = 2048 + } +} diff --git a/clients/android/sudoquery/src/main/kotlin/io/juspay/sudoquery/internal/Identity.kt b/clients/android/sudoquery/src/main/kotlin/io/juspay/sudoquery/internal/Identity.kt new file mode 100644 index 0000000..bfb0831 --- /dev/null +++ b/clients/android/sudoquery/src/main/kotlin/io/juspay/sudoquery/internal/Identity.kt @@ -0,0 +1,68 @@ +package io.juspay.sudoquery.internal + +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable +import kotlinx.serialization.json.Json +import java.io.File +import java.io.FileOutputStream +import java.io.IOException + +/** Who events belong to. Persisted so the anonymous id, user and session survive app restarts. */ +@Serializable +internal data class IdentityState( + @SerialName("anon_id") val anonId: String, + @SerialName("actor_id") val actorId: String? = null, + @SerialName("session_id") val sessionId: String? = null, + @SerialName("last_activity_ms") val lastActivityMillis: Long = 0, +) + +/** + * Applies the session rules for an event at [nowMillis]: a session ends after [timeoutMillis] + * without events. If the clock moved backwards the session continues, timed from the new clock. + * A [fixedSessionId] from the config is used as is. + */ +internal fun IdentityState.touch( + nowMillis: Long, + timeoutMillis: Long, + fixedSessionId: String?, + newId: () -> String, +): IdentityState { + val session = when { + fixedSessionId != null -> fixedSessionId + sessionId == null || nowMillis - lastActivityMillis > timeoutMillis -> newId() + else -> sessionId + } + return copy(sessionId = session, lastActivityMillis = nowMillis) +} + +/** Keeps [IdentityState] in one small JSON file, replaced atomically so a crash can't leave it half-written. */ +internal class IdentityStore(private val file: File, private val logger: Logger) { + + fun load(): IdentityState? { + if (!file.exists()) return null + return try { + json.decodeFromString(IdentityState.serializer(), file.readText(Charsets.UTF_8)) + } catch (e: Exception) { + logger.warn("Ignoring unreadable ${file.name}; starting with a new anonymous id", e) + null + } + } + + fun save(state: IdentityState) { + val temp = File(file.path + ".tmp") + try { + file.parentFile?.mkdirs() + FileOutputStream(temp).use { out -> + out.write(json.encodeToString(IdentityState.serializer(), state).toByteArray(Charsets.UTF_8)) + out.fd.sync() + } + if (!temp.renameTo(file)) throw IOException("could not rename ${temp.name} to ${file.name}") + } catch (e: IOException) { + logger.warn("Could not save the SDK identity", e) + } + } + + private companion object { + val json = Json { ignoreUnknownKeys = true } + } +} diff --git a/clients/android/sudoquery/src/main/kotlin/io/juspay/sudoquery/internal/IsoTime.kt b/clients/android/sudoquery/src/main/kotlin/io/juspay/sudoquery/internal/IsoTime.kt new file mode 100644 index 0000000..1704075 --- /dev/null +++ b/clients/android/sudoquery/src/main/kotlin/io/juspay/sudoquery/internal/IsoTime.kt @@ -0,0 +1,48 @@ +package io.juspay.sudoquery.internal + +private const val MILLIS_PER_DAY = 86_400_000L + +/** + * Formats epoch milliseconds as RFC 3339 in UTC with milliseconds, e.g. `2026-10-14T09:30:12.345Z`, + * the same as `Date.toISOString()` in the TS client. + * + * Plain arithmetic: `java.time` needs API 26, and `SimpleDateFormat` is slow and not thread-safe. + * The date conversion is Howard Hinnant's `civil_from_days`. + */ +internal fun formatIsoUtcMillis(epochMillis: Long): String { + val days = epochMillis.floorDiv(MILLIS_PER_DAY) + val millisOfDay = epochMillis.mod(MILLIS_PER_DAY) + + val z = days + 719_468 + val era = z.floorDiv(146_097L) + val dayOfEra = z - era * 146_097 + val yearOfEra = (dayOfEra - dayOfEra / 1_460 + dayOfEra / 36_524 - dayOfEra / 146_096) / 365 + val dayOfYear = dayOfEra - (365 * yearOfEra + yearOfEra / 4 - yearOfEra / 100) + val shiftedMonth = (5 * dayOfYear + 2) / 153 + val day = dayOfYear - (153 * shiftedMonth + 2) / 5 + 1 + val month = if (shiftedMonth < 10) shiftedMonth + 3 else shiftedMonth - 9 + val year = yearOfEra + era * 400 + if (month <= 2) 1 else 0 + + return buildString(24) { + appendPadded(year, 4) + append('-') + appendPadded(month, 2) + append('-') + appendPadded(day, 2) + append('T') + appendPadded(millisOfDay / 3_600_000, 2) + append(':') + appendPadded(millisOfDay / 60_000 % 60, 2) + append(':') + appendPadded(millisOfDay / 1_000 % 60, 2) + append('.') + appendPadded(millisOfDay % 1_000, 3) + append('Z') + } +} + +private fun StringBuilder.appendPadded(value: Long, width: Int) { + val digits = value.toString() + repeat(width - digits.length) { append('0') } + append(digits) +} diff --git a/clients/android/sudoquery/src/main/kotlin/io/juspay/sudoquery/internal/JsonValues.kt b/clients/android/sudoquery/src/main/kotlin/io/juspay/sudoquery/internal/JsonValues.kt new file mode 100644 index 0000000..6dedb75 --- /dev/null +++ b/clients/android/sudoquery/src/main/kotlin/io/juspay/sudoquery/internal/JsonValues.kt @@ -0,0 +1,89 @@ +package io.juspay.sudoquery.internal + +import kotlinx.serialization.json.JsonArray +import kotlinx.serialization.json.JsonElement +import kotlinx.serialization.json.JsonNull +import kotlinx.serialization.json.JsonObject +import kotlinx.serialization.json.JsonPrimitive +import kotlinx.serialization.json.booleanOrNull +import kotlinx.serialization.json.doubleOrNull +import kotlinx.serialization.json.longOrNull +import org.json.JSONArray +import org.json.JSONObject + +/** + * Converts values passed to `track()` and `setSuperProperty()` into JSON. The result is a copy, + * so a caller changing its map afterwards can't alter an event that is already queued. + */ +internal object JsonValues { + /** Deeper values become null; this also stops a map that contains itself. */ + private const val MAX_DEPTH = 32 + + fun toJsonObject(map: Map<*, *>, logger: Logger): JsonObject = convertMap(map, 0, logger) + + fun toJsonObject(json: JSONObject, logger: Logger): JsonObject = convertJSONObject(json, 0, logger) + + fun toJsonElement(value: Any?, logger: Logger): JsonElement = convert(value, 0, logger) + + /** The reverse, for handing super properties back to the app: objects become maps, arrays lists. */ + fun toPlain(element: JsonElement): Any? = when (element) { + JsonNull -> null + is JsonPrimitive -> when { + element.isString -> element.content + else -> element.booleanOrNull ?: element.longOrNull ?: element.doubleOrNull ?: element.content + } + is JsonObject -> element.mapValues { toPlain(it.value) } + is JsonArray -> element.map { toPlain(it) } + } + + private fun convert(value: Any?, depth: Int, logger: Logger): JsonElement { + if (value == null || value === JSONObject.NULL) return JsonNull + if (depth > MAX_DEPTH) { + logger.warn("A property nested deeper than $MAX_DEPTH levels was replaced with null") + return JsonNull + } + return when (value) { + is JsonElement -> value + is String -> JsonPrimitive(value) + is Boolean -> JsonPrimitive(value) + is Double -> finite(value.isFinite(), value, logger) + is Float -> finite(value.isFinite(), value, logger) + is Number -> JsonPrimitive(value) + is Char -> JsonPrimitive(value.toString()) + is Enum<*> -> JsonPrimitive(value.name) + is Map<*, *> -> convertMap(value, depth, logger) + is JSONObject -> convertJSONObject(value, depth, logger) + is JSONArray -> JsonArray(List(value.length()) { convert(value.opt(it), depth + 1, logger) }) + is Iterable<*> -> JsonArray(value.map { convert(it, depth + 1, logger) }) + is Array<*> -> JsonArray(value.map { convert(it, depth + 1, logger) }) + is BooleanArray -> JsonArray(value.map { JsonPrimitive(it) }) + is ByteArray -> JsonArray(value.map { JsonPrimitive(it) }) + is ShortArray -> JsonArray(value.map { JsonPrimitive(it) }) + is IntArray -> JsonArray(value.map { JsonPrimitive(it) }) + is LongArray -> JsonArray(value.map { JsonPrimitive(it) }) + is FloatArray -> JsonArray(value.map { finite(it.isFinite(), it, logger) }) + is DoubleArray -> JsonArray(value.map { finite(it.isFinite(), it, logger) }) + is CharArray -> JsonArray(value.map { JsonPrimitive(it.toString()) }) + else -> JsonPrimitive(value.toString()) + } + } + + private fun convertMap(map: Map<*, *>, depth: Int, logger: Logger): JsonObject { + val content = LinkedHashMap(map.size) + for ((key, value) in map) content[key.toString()] = convert(value, depth + 1, logger) + return JsonObject(content) + } + + private fun convertJSONObject(json: JSONObject, depth: Int, logger: Logger): JsonObject { + val content = LinkedHashMap(json.length()) + for (key in json.keys()) content[key] = convert(json.opt(key), depth + 1, logger) + return JsonObject(content) + } + + /** JSON has no NaN or Infinity; sending one would make the collector reject the whole batch. */ + private fun finite(isFinite: Boolean, value: Number, logger: Logger): JsonElement { + if (isFinite) return JsonPrimitive(value) + logger.warn("A property value of $value can't be sent as JSON and was replaced with null") + return JsonNull + } +} diff --git a/clients/android/sudoquery/src/main/kotlin/io/juspay/sudoquery/internal/Platform.kt b/clients/android/sudoquery/src/main/kotlin/io/juspay/sudoquery/internal/Platform.kt new file mode 100644 index 0000000..a26740e --- /dev/null +++ b/clients/android/sudoquery/src/main/kotlin/io/juspay/sudoquery/internal/Platform.kt @@ -0,0 +1,92 @@ +package io.juspay.sudoquery.internal + +import android.app.ActivityManager +import android.app.Application +import android.content.Context +import android.os.Build +import android.os.Handler +import android.os.Looper +import android.os.Process +import android.util.Log +import androidx.lifecycle.DefaultLifecycleObserver +import androidx.lifecycle.LifecycleOwner +import androidx.lifecycle.ProcessLifecycleOwner + +internal interface Clock { + /** Wall-clock time, for event timestamps, file ages and sessions. */ + fun nowMillis(): Long + + /** Monotonic time that ignores clock changes, for retry backoff. */ + fun elapsedMillis(): Long +} + +internal object AndroidClock : Clock { + override fun nowMillis(): Long = System.currentTimeMillis() + + override fun elapsedMillis(): Long = android.os.SystemClock.elapsedRealtime() +} + +internal interface Logger { + fun debug(message: String) + + fun warn(message: String, error: Throwable? = null) + + fun error(message: String, error: Throwable? = null) +} + +internal class AndroidLogger(private val debugEnabled: Boolean) : Logger { + override fun debug(message: String) { + if (debugEnabled) Log.d(TAG, message) + } + + override fun warn(message: String, error: Throwable?) { + Log.w(TAG, message, error) + } + + override fun error(message: String, error: Throwable?) { + Log.e(TAG, message, error) + } + + private companion object { + const val TAG = "SudoQuery" + } +} + +/** + * True in the app's main process. The SDK runs only there: a second process writing the same + * queue directory would interleave and re-upload batches. + */ +internal fun isMainProcess(context: Context): Boolean { + val mainProcess = context.applicationInfo.processName ?: context.packageName + val current = currentProcessName(context) ?: return true + return current == mainProcess +} + +private fun currentProcessName(context: Context): String? { + if (Build.VERSION.SDK_INT >= 28) return Application.getProcessName() + val activityManager = context.getSystemService(Context.ACTIVITY_SERVICE) as? ActivityManager ?: return null + val pid = Process.myPid() + return activityManager.runningAppProcesses?.firstOrNull { it.pid == pid }?.processName +} + +/** Reports the app moving between foreground and background, via [ProcessLifecycleOwner]. */ +internal class AppLifecycle( + private val onForeground: () -> Unit, + private val onBackground: () -> Unit, +) : DefaultLifecycleObserver { + + override fun onStart(owner: LifecycleOwner) = onForeground() + + override fun onStop(owner: LifecycleOwner) = onBackground() + + /** Observers must be added on the main thread. Without lifecycle events the flush timer still runs. */ + fun register(logger: Logger) { + Handler(Looper.getMainLooper()).post { + try { + ProcessLifecycleOwner.get().lifecycle.addObserver(this) + } catch (e: Throwable) { + logger.warn("App lifecycle unavailable; events are sent by the flush timer only", e) + } + } + } +} diff --git a/clients/android/sudoquery/src/main/kotlin/io/juspay/sudoquery/internal/QueueStore.kt b/clients/android/sudoquery/src/main/kotlin/io/juspay/sudoquery/internal/QueueStore.kt new file mode 100644 index 0000000..64bf619 --- /dev/null +++ b/clients/android/sudoquery/src/main/kotlin/io/juspay/sudoquery/internal/QueueStore.kt @@ -0,0 +1,206 @@ +package io.juspay.sudoquery.internal + +import kotlinx.serialization.SerializationException +import kotlinx.serialization.json.Json +import kotlinx.serialization.json.JsonElement +import java.io.File +import java.io.FileOutputStream +import java.io.IOException +import java.util.Locale + +/** + * The on-disk event queue, ported from the file design of hyper-sdk-android's + * `LogSessioniserExp` / `LogPusherExp`. + * + * Serialized events are appended as NDJSON lines to one open batch file in `staging/`. When the + * batch is full (or flushed) it is committed: closed and renamed into `ready/`, after which it is + * never modified. The uploader sends `ready/` files oldest first and deletes each once delivered. + * + * Files are named `-.ndjson`. Order comes from the sequence number, so a + * wall-clock change can't reorder the queue; the creation time is only used for expiry. + * + * [recover], [append], [commit] and [enforceLimits] belong to the writer coroutine. + * [readyBatches], [read] and [delete] are also called by the uploader: committed files never + * change and deleting one twice is harmless. + */ +internal class QueueStore( + root: File, + private val maxEventsPerBatch: Int, + private val clock: Clock, + private val logger: Logger, + private val maxBytesPerBatch: Long = MAX_BATCH_BYTES, + private val maxQueueBytes: Long = MAX_QUEUE_BYTES, + private val maxAgeMillis: Long = MAX_AGE_MILLIS, +) { + private val stagingDir = File(root, "staging") + private val readyDir = File(root, "ready") + private var nextSeq = 1L + private var open: OpenBatch? = null + + private class OpenBatch(val file: File, val stream: FileOutputStream) { + var events = 0 + var bytes = 0L + } + + /** Commits any batch a previous process left open, and picks the next sequence number. Call once, first. */ + fun recover() { + stagingDir.mkdirs() + readyDir.mkdirs() + var recovered = 0 + for (file in stagingDir.listFiles().orEmpty()) { + if (parseName(file.name) == null || file.length() == 0L) { + file.delete() + } else { + moveToReady(file) + recovered++ + } + } + if (recovered > 0) logger.debug("Recovered $recovered unsent batch(es) from the previous run") + nextSeq = (readyDir.listFiles().orEmpty().mapNotNull { parseName(it.name)?.seq }.maxOrNull() ?: 0) + 1 + } + + /** + * Appends one serialized event (without a trailing newline) with a single unbuffered write, so it + * reaches the file system even if the process dies right after. Returns true if a batch was + * committed, i.e. there is something new to upload. + */ + fun append(event: ByteArray): Boolean { + var committed = false + val current = open + if (current != null && current.bytes + event.size + 1 > maxBytesPerBatch) committed = commit() + + val batch = open ?: openBatch() + val record = event.copyOf(event.size + 1) + record[event.size] = '\n'.code.toByte() + try { + batch.stream.write(record) + } catch (e: IOException) { + // Keep the events already written; the reader skips the partial line. + commit() + throw e + } + batch.events++ + batch.bytes += record.size + + if (batch.events >= maxEventsPerBatch) committed = commit() || committed + return committed + } + + /** Closes the open batch and moves it to `ready/`. Returns true if it held any events. */ + fun commit(): Boolean { + val batch = open ?: return false + open = null + try { + batch.stream.close() + } catch (e: IOException) { + logger.warn("Could not close ${batch.file.name}", e) + } + if (batch.events == 0) { + batch.file.delete() + return false + } + moveToReady(batch.file) + return true + } + + /** Committed batches, oldest first. */ + fun readyBatches(): List = + readyDir.listFiles().orEmpty() + .mapNotNull { file -> parseName(file.name)?.let { file to it.seq } } + .sortedBy { it.second } + .map { it.first } + + /** + * The events of a committed batch, or null if the file is gone. Lines that aren't valid JSON, + * such as a write cut short by a crash, are skipped. + */ + fun read(file: File): List? { + val text = try { + file.readText(Charsets.UTF_8) + } catch (e: IOException) { + if (file.exists()) logger.warn("Could not read ${file.name}", e) + return null + } + val events = ArrayList() + var skipped = 0 + for (line in text.lineSequence()) { + if (line.isBlank()) continue + try { + events += Json.parseToJsonElement(line) + } catch (e: SerializationException) { + skipped++ + } + } + if (skipped > 0) logger.warn("Skipped $skipped unreadable event(s) in ${file.name}") + return events + } + + fun delete(file: File) { + if (!file.delete() && file.exists()) logger.warn("Could not delete ${file.name}") + } + + /** + * Drops batches older than the maximum age, then the oldest batches while the queue is over its + * size limit, down to 80 % of it. Unlike hyper-sdk's `checkFolderLimit`, a file's size is taken + * before it is deleted. + */ + fun enforceLimits() { + val now = clock.nowMillis() + var expired = 0 + val kept = ArrayList>() + for (file in readyBatches()) { + val created = parseName(file.name)?.createdMillis ?: continue + // A batch dated in the future (the clock moved back) counts as fresh. + if (now - created > maxAgeMillis) { + if (file.delete()) expired++ + } else { + kept += file to file.length() + } + } + + var total = kept.sumOf { it.second } + var trimmed = 0 + if (total > maxQueueBytes) { + val target = maxQueueBytes / 10 * 8 + for ((file, size) in kept) { + if (total <= target) break + if (file.delete()) { + total -= size + trimmed++ + } + } + } + + if (expired > 0) logger.warn("Dropped $expired unsent batch(es) older than ${maxAgeMillis / MILLIS_PER_HOUR} h") + if (trimmed > 0) logger.warn("Dropped the $trimmed oldest unsent batch(es) to keep the queue under $maxQueueBytes bytes") + } + + private fun openBatch(): OpenBatch { + stagingDir.mkdirs() + val file = File(stagingDir, fileName(nextSeq++, maxOf(0, clock.nowMillis()))) + return OpenBatch(file, FileOutputStream(file, true)).also { open = it } + } + + private fun moveToReady(file: File) { + readyDir.mkdirs() + if (!file.renameTo(File(readyDir, file.name))) logger.warn("Could not move ${file.name} to the upload queue") + } + + internal class BatchName(val seq: Long, val createdMillis: Long) + + internal companion object { + private const val MILLIS_PER_HOUR = 60L * 60 * 1000 + const val MAX_BATCH_BYTES: Long = 512L * 1024 + const val MAX_QUEUE_BYTES: Long = 10L * 1024 * 1024 + const val MAX_AGE_MILLIS: Long = 7L * 24 * MILLIS_PER_HOUR + private val NAME = Regex("""(\d{1,18})-(\d{1,18})\.ndjson""") + + fun fileName(seq: Long, createdMillis: Long): String = + String.format(Locale.ROOT, "%010d-%d.ndjson", seq, createdMillis) + + fun parseName(name: String): BatchName? { + val match = NAME.matchEntire(name) ?: return null + return BatchName(match.groupValues[1].toLong(), match.groupValues[2].toLong()) + } + } +} diff --git a/clients/android/sudoquery/src/main/kotlin/io/juspay/sudoquery/internal/SudoQueryClient.kt b/clients/android/sudoquery/src/main/kotlin/io/juspay/sudoquery/internal/SudoQueryClient.kt new file mode 100644 index 0000000..60f3159 --- /dev/null +++ b/clients/android/sudoquery/src/main/kotlin/io/juspay/sudoquery/internal/SudoQueryClient.kt @@ -0,0 +1,175 @@ +package io.juspay.sudoquery.internal + +import android.content.Context +import android.os.Build +import io.juspay.sudoquery.BuildConfig +import io.juspay.sudoquery.CanonicalEvent +import io.juspay.sudoquery.SudoQueryConfig +import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.CompletableDeferred +import kotlinx.coroutines.CoroutineExceptionHandler +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.cancel +import kotlinx.coroutines.channels.Channel +import kotlinx.coroutines.delay +import kotlinx.coroutines.launch +import kotlinx.serialization.json.JsonObject +import kotlinx.serialization.serializer +import java.io.File +import java.net.URL +import java.util.concurrent.atomic.AtomicBoolean +import java.util.concurrent.atomic.AtomicInteger + +/** + * One initialized SDK. App threads only enqueue [Command]s; a single writer coroutine handles them + * in order and an uploader coroutine does the network IO, so a slow network never blocks writes. + */ +internal class SudoQueryClient private constructor( + private val scope: CoroutineScope, + private val commands: Channel, + private val logger: Logger, +) { + private val queuedTracks = AtomicInteger() + private val overflowWarned = AtomicBoolean(false) + private val user = UserMirror() + + fun track(name: String, timestampMillis: Long, properties: JsonObject) { + // Only events are capped: control commands such as setUser must never be dropped. + if (queuedTracks.incrementAndGet() > MAX_QUEUED_TRACKS) { + queuedTracks.decrementAndGet() + if (overflowWarned.compareAndSet(false, true)) { + logger.warn("More than $MAX_QUEUED_TRACKS events are waiting to be stored; dropping new ones until they are") + } + return + } + commands.trySend(Command.Track(name, timestampMillis, properties)) + } + + fun setUser(userId: String?) { + user.set(userId) + commands.trySend(Command.SetUser(userId)) + } + + fun currentUser(): String? = user.get() + + fun reset() { + user.set(null) + commands.trySend(Command.Reset) + } + + fun flush() { + commands.trySend(Command.Flush) + } + + fun onForeground() { + commands.trySend(Command.Foreground) + } + + fun onBackground() { + commands.trySend(Command.Background) + } + + /** Suspends until every command sent before this call has been handled. */ + suspend fun awaitIdle() { + val done = CompletableDeferred() + commands.trySend(Command.Barrier(done)) + done.await() + } + + /** Stops the SDK's coroutines. Only for tests: the SDK otherwise lives as long as the process. */ + fun shutdown() { + commands.close() + scope.cancel() + } + + private fun handle(writer: EventWriter<*>, command: Command) { + if (command is Command.Track && queuedTracks.decrementAndGet() < MAX_QUEUED_TRACKS / 2) { + overflowWarned.set(false) + } + try { + writer.handle(command) + } catch (e: CancellationException) { + throw e + } catch (e: Exception) { + logger.error("Could not handle ${command.javaClass.simpleName}", e) + } + } + + /** `getUser()` must reflect `setUser()` at once, before the writer has loaded the stored user. */ + private class UserMirror { + private val lock = Any() + private var value: String? = null + private var setByApp = false + + fun set(userId: String?) = synchronized(lock) { + value = userId + setByApp = true + } + + fun loaded(userId: String?) = synchronized(lock) { + if (!setByApp) value = userId + } + + fun get(): String? = synchronized(lock) { value } + } + + internal companion object { + const val MAX_QUEUED_TRACKS: Int = 1_000 + + fun start(context: Context, config: SudoQueryConfig, logger: Logger, clock: Clock): SudoQueryClient { + val scope = CoroutineScope( + SupervisorJob() + Dispatchers.IO + CoroutineExceptionHandler { _, e -> logger.error("Unexpected SDK error", e) }, + ) + val commands = Channel(Channel.UNLIMITED) + val client = SudoQueryClient(scope, commands, logger) + + scope.launch { + // noBackupFilesDir touches the disk, so storage is set up here, not on the app's thread. + // It is never purged by the OS, and Auto Backup never copies it to another device. + val root = File(context.noBackupFilesDir, "sudoquery/v1") + val store = QueueStore(root, config.batchSize, clock, logger) + val headers = requestHeaders( + orgId = config.orgId, + projectId = config.projectId, + token = config.token, + custom = config.headers, + userAgent = "sudoquery-android/${BuildConfig.SDK_VERSION} (Android ${Build.VERSION.RELEASE})", + ) + val uploader = Uploader(store, HttpUrlConnectionTransport(URL(config.endpoint), headers), clock, logger) + val writer = EventWriter( + // The only place that names the event class; see CanonicalEvent in Event.kt. + serializer = serializer(), + buildEvent = { CanonicalEvent(it) }, + settings = WriterSettings( + orgId = config.orgId, + projectId = config.projectId, + source = config.source, + fixedSessionId = config.sessionId, + sessionTimeoutMillis = config.sessionTimeoutMillis, + ), + store = store, + identityStore = IdentityStore(File(root, "identity.json"), logger), + uploads = uploader, + logger = logger, + ) + launch { uploader.run() } + client.user.loaded(writer.start()) + for (command in commands) client.handle(writer, command) + } + + if (config.flushIntervalMillis > 0) { + scope.launch { + while (true) { + delay(config.flushIntervalMillis) + commands.trySend(Command.Tick) + } + } + } + + AppLifecycle(onForeground = client::onForeground, onBackground = client::onBackground).register(logger) + return client + } + } +} diff --git a/clients/android/sudoquery/src/main/kotlin/io/juspay/sudoquery/internal/SuperProperties.kt b/clients/android/sudoquery/src/main/kotlin/io/juspay/sudoquery/internal/SuperProperties.kt new file mode 100644 index 0000000..7a1b1ff --- /dev/null +++ b/clients/android/sudoquery/src/main/kotlin/io/juspay/sudoquery/internal/SuperProperties.kt @@ -0,0 +1,29 @@ +package io.juspay.sudoquery.internal + +import kotlinx.serialization.json.JsonElement +import kotlinx.serialization.json.JsonObject + +/** Properties added to every event. In memory only, as in the TS client. */ +internal class SuperProperties { + private val lock = Any() + + @Volatile + private var values: Map = emptyMap() + + fun set(key: String, value: JsonElement) { + synchronized(lock) { values = LinkedHashMap(values).apply { put(key, value) } } + } + + fun clear() { + synchronized(lock) { values = emptyMap() } + } + + fun snapshot(): Map = values + + /** The event's own properties win, like `{...superProperties, ...properties}` in the TS client. */ + fun mergeUnder(properties: JsonObject): JsonObject { + val current = values + if (current.isEmpty()) return properties + return JsonObject(LinkedHashMap(current).apply { putAll(properties) }) + } +} diff --git a/clients/android/sudoquery/src/main/kotlin/io/juspay/sudoquery/internal/Uploader.kt b/clients/android/sudoquery/src/main/kotlin/io/juspay/sudoquery/internal/Uploader.kt new file mode 100644 index 0000000..f67ddc2 --- /dev/null +++ b/clients/android/sudoquery/src/main/kotlin/io/juspay/sudoquery/internal/Uploader.kt @@ -0,0 +1,195 @@ +package io.juspay.sudoquery.internal + +import io.juspay.sudoquery.BatchPayload +import io.juspay.sudoquery.SystemProperties +import io.juspay.sudoquery.WireJson +import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.channels.Channel +import kotlinx.coroutines.withTimeoutOrNull +import kotlinx.serialization.json.Json +import kotlinx.serialization.json.JsonObject +import kotlinx.serialization.json.contentOrNull +import kotlinx.serialization.json.jsonPrimitive +import java.io.File +import java.io.IOException +import java.util.TimeZone +import kotlin.random.Random + +/** What the event writer needs from the uploader. */ +internal interface UploadControl { + /** Asks for queued batches to be sent. Cheap; requests made while one is pending collapse into it. */ + fun requestUpload() + + /** Forgets the retry backoff, e.g. when the app comes back to the foreground. */ + fun resetBackoff() + + /** True while uploads are failing and waiting to retry. */ + val isBackingOff: Boolean +} + +/** How the SDK treats a collector response. */ +internal enum class Delivery { + /** Delivered: delete the batch. */ + SENT, + + /** The collector will never accept this batch: delete it and move on. */ + REJECTED, + + /** Keep the batch and try again later. */ + RETRY, +} + +/** + * 400 (bad headers or body), 413 (too large) and 422 can't succeed on a resend. Everything else + * (redirects, 401/403/404 configuration problems, 408, 429, 5xx) may, so the batch is kept. + */ +internal fun classify(code: Int): Delivery = when (code) { + in 200..299 -> Delivery.SENT + 400, 413, 422 -> Delivery.REJECTED + else -> Delivery.RETRY +} + +/** + * Sends committed batches to the collector, one file per request, oldest first. Ported from + * hyper-sdk-android's `LogPusherExp`, but any 2xx counts as delivered, rejected batches are + * dropped instead of blocking the queue, and failures back off exponentially with jitter instead + * of retrying forever at a fixed interval. + * + * Only one upload runs at a time: [run] is the single consumer of upload requests. + */ +internal class Uploader( + private val store: QueueStore, + private val transport: Transport, + private val clock: Clock, + private val logger: Logger, + private val timezone: () -> String? = { TimeZone.getDefault().id }, + private val random: Random = Random.Default, +) : UploadControl { + private val requests = Channel(Channel.CONFLATED) + + @Volatile + private var retryAtMillis = 0L + + @Volatile + private var failures = 0 + + override fun requestUpload() { + requests.trySend(Unit) + } + + override fun resetBackoff() { + failures = 0 + retryAtMillis = 0 + } + + override val isBackingOff: Boolean + get() = clock.elapsedMillis() < retryAtMillis + + /** Runs for the life of the SDK: waits for a request, or for a backoff to end, then drains the queue. */ + suspend fun run() { + while (true) { + val wait = retryAtMillis - clock.elapsedMillis() + if (wait > 0) { + // Sleep out the backoff. A request can wake us early, which only matters after resetBackoff(). + withTimeoutOrNull(wait) { requests.receive() } + if (isBackingOff) continue + } else { + requests.receive() + } + try { + drain() + } catch (e: CancellationException) { + throw e + } catch (e: Exception) { + logger.error("Upload failed unexpectedly", e) + } + } + } + + /** Sends ready batches until the queue is empty or a batch has to be retried later. */ + internal fun drain() { + val attempted = HashSet() + while (true) { + val batches = store.readyBatches().filter { it.name !in attempted } + if (batches.isEmpty()) return + for (file in batches) { + attempted += file.name + // Stop at the first batch to retry, so the queue stays in order. + if (!send(file)) return + } + } + } + + /** Returns false if the batch has to be retried later. */ + private fun send(file: File): Boolean { + val events = store.read(file) ?: return true + if (events.isEmpty()) { + store.delete(file) + return true + } + val payload = BatchPayload(events, SystemProperties(geo = null, timezone = timezone())) + val body = WireJson.encodeToString(BatchPayload.serializer(), payload).toByteArray(Charsets.UTF_8) + + val response = try { + transport.post(body) + } catch (e: IOException) { + return retryLater("Could not reach the collector (${e.javaClass.simpleName}: ${e.message})", null) + } catch (e: RuntimeException) { + // E.g. a custom header value HttpURLConnection refuses. + logger.error("Could not send events", e) + return retryLater("Could not send events", null) + } + + return when (classify(response.code)) { + Delivery.SENT -> { + store.delete(file) + failures = 0 + logger.debug("Sent ${events.size} event(s)") + true + } + Delivery.REJECTED -> { + store.delete(file) + logger.error( + "The collector rejected ${events.size} event(s) with HTTP ${response.code}, dropping them: " + + response.errorText(), + ) + true + } + Delivery.RETRY -> retryLater("The collector answered HTTP ${response.code}", response.retryAfterSeconds) + } + } + + private fun retryLater(reason: String, retryAfterSeconds: Long?): Boolean { + failures++ + val delay = backoffMillis(failures, retryAfterSeconds) + retryAtMillis = clock.elapsedMillis() + delay + val message = "$reason; events are kept and retried in ${(delay + 999) / 1000} s" + if (failures == 1) logger.warn(message) else logger.debug(message) + return false + } + + /** 5 s doubling to 5 min, with half of it randomized so many devices don't retry in step. */ + private fun backoffMillis(failures: Int, retryAfterSeconds: Long?): Long { + val exponential = (BASE_BACKOFF_MILLIS shl (failures - 1).coerceAtMost(16)).coerceAtMost(MAX_BACKOFF_MILLIS) + val jittered = exponential / 2 + random.nextLong(exponential / 2 + 1) + val requested = (retryAfterSeconds ?: 0).coerceIn(0, MAX_RETRY_AFTER_SECONDS) * 1000 + return maxOf(jittered, requested) + } + + /** The collector's `{"error": "..."}` message, or the raw body (e.g. a 413 is plain text). */ + private fun HttpResult.errorText(): String { + val text = body?.trim().orEmpty() + val message = try { + (Json.parseToJsonElement(text) as? JsonObject)?.get("error")?.jsonPrimitive?.contentOrNull + } catch (e: Exception) { + null + } + return (message ?: text).take(300) + } + + internal companion object { + const val BASE_BACKOFF_MILLIS: Long = 5_000 + const val MAX_BACKOFF_MILLIS: Long = 5 * 60 * 1000 + const val MAX_RETRY_AFTER_SECONDS: Long = 60 * 60 + } +} diff --git a/clients/android/sudoquery/src/test/kotlin/io/juspay/sudoquery/EventWireFormatTest.kt b/clients/android/sudoquery/src/test/kotlin/io/juspay/sudoquery/EventWireFormatTest.kt new file mode 100644 index 0000000..824a2cd --- /dev/null +++ b/clients/android/sudoquery/src/test/kotlin/io/juspay/sudoquery/EventWireFormatTest.kt @@ -0,0 +1,106 @@ +package io.juspay.sudoquery + +import io.juspay.sudoquery.internal.EventWriter +import io.juspay.sudoquery.internal.FakeClock +import io.juspay.sudoquery.internal.FakeTransport +import io.juspay.sudoquery.internal.FakeUploads +import io.juspay.sudoquery.internal.IdentityStore +import io.juspay.sudoquery.internal.QueueStore +import io.juspay.sudoquery.internal.RecordingLogger +import io.juspay.sudoquery.internal.Uploader +import io.juspay.sudoquery.internal.WriterSettings +import io.juspay.sudoquery.internal.Command +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable +import kotlinx.serialization.json.Json +import kotlinx.serialization.json.JsonObject +import kotlinx.serialization.json.JsonPrimitive +import kotlinx.serialization.json.jsonPrimitive +import kotlinx.serialization.serializer +import org.junit.Assert.assertEquals +import org.junit.Rule +import org.junit.Test +import org.junit.rules.TemporaryFolder + +class EventWireFormatTest { + @get:Rule + val temp = TemporaryFolder() + + private val input = EventInput( + id = "3b241101-e2bb-4255-8caf-4136c566a962", + name = "button_click", + orgId = "acme-k3x9qa", + projectId = "android-demo1", + sessionId = "9f1c2d3e-4b5a-4c6d-8e7f-0a1b2c3d4e5f", + anonId = "6e8bc430-9c3a-4d6b-9f3e-2a1b0c9d8e7f", + actorId = null, + source = "android", + occurredAt = "2026-10-14T09:30:12.345Z", + properties = JsonObject( + linkedMapOf( + "app_version" to JsonPrimitive("1.0.0"), + "button_id" to JsonPrimitive("submit"), + "page" to JsonPrimitive("/checkout"), + ), + ), + ) + + @Test + fun `an EventV1 serializes exactly like the TS client's event`() { + // All 14 keys, in the TS order, with explicit nulls. + assertEquals(golden("event.json"), WireJson.encodeToString(EventV1.serializer(), EventV1(input))) + } + + @Test + fun `a batch body matches the collector's Batch shape`() { + val event = Json.parseToJsonElement(WireJson.encodeToString(EventV1.serializer(), EventV1(input))) + val batch = BatchPayload(listOf(event), SystemProperties(geo = null, timezone = "Asia/Kolkata")) + assertEquals(golden("batch.json"), WireJson.encodeToString(BatchPayload.serializer(), batch)) + } + + @Serializable + private data class TestEventV2( + @SerialName("envelop_version") val envelopVersion: String = "2.0", + @SerialName("event_id") val eventId: String, + @SerialName("event_name") val eventName: String, + @SerialName("anon_id") val anonId: String, + ) { + constructor(input: EventInput) : this(eventId = input.id, eventName = input.name, anonId = input.anonId) + } + + @Test + fun `the pipeline sends whatever event class it is given`() { + // What changing the CanonicalEvent alias does: the same writer, store and uploader, another event class. + val clock = FakeClock() + val logger = RecordingLogger() + val store = QueueStore(temp.newFolder("queue"), maxEventsPerBatch = 10, clock = clock, logger = logger) + val writer = EventWriter( + serializer = serializer(), + buildEvent = { TestEventV2(it) }, + settings = WriterSettings("acme-k3x9qa", "android-demo1", "android", null, 30 * 60 * 1000L), + store = store, + identityStore = IdentityStore(temp.root.resolve("identity.json"), logger), + uploads = FakeUploads(), + logger = logger, + ) + val transport = FakeTransport() + val uploader = Uploader(store, transport, clock, logger, timezone = { "UTC" }) + + writer.start() + writer.handle(Command.Track("signup", T_EVENT, JsonObject(emptyMap()))) + writer.handle(Command.Flush) + uploader.drain() + + val event = transport.events(0).single() + assertEquals(listOf("envelop_version", "event_id", "event_name", "anon_id"), event.keys.toList()) + assertEquals("2.0", event.getValue("envelop_version").jsonPrimitive.content) + assertEquals("signup", event.getValue("event_name").jsonPrimitive.content) + } + + private fun golden(name: String): String = + checkNotNull(javaClass.classLoader?.getResource("golden/$name")) { "missing golden/$name" }.readText().trim() + + private companion object { + const val T_EVENT = 1_760_000_000_000L + } +} diff --git a/clients/android/sudoquery/src/test/kotlin/io/juspay/sudoquery/SudoQueryFacadeTest.kt b/clients/android/sudoquery/src/test/kotlin/io/juspay/sudoquery/SudoQueryFacadeTest.kt new file mode 100644 index 0000000..d138bcc --- /dev/null +++ b/clients/android/sudoquery/src/test/kotlin/io/juspay/sudoquery/SudoQueryFacadeTest.kt @@ -0,0 +1,143 @@ +package io.juspay.sudoquery + +import android.content.Context +import androidx.test.core.app.ApplicationProvider +import kotlinx.coroutines.runBlocking +import kotlinx.coroutines.withTimeout +import kotlinx.serialization.json.Json +import kotlinx.serialization.json.JsonNull +import kotlinx.serialization.json.JsonObject +import kotlinx.serialization.json.jsonArray +import kotlinx.serialization.json.jsonObject +import kotlinx.serialization.json.jsonPrimitive +import mockwebserver3.MockResponse +import mockwebserver3.MockWebServer +import org.junit.After +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import java.io.File +import java.util.concurrent.TimeUnit + +@RunWith(RobolectricTestRunner::class) +class SudoQueryFacadeTest { + private val context: Context = ApplicationProvider.getApplicationContext() + private val server = MockWebServer() + + @Before + fun setUp() { + server.start() + SudoQuery.resetForTests() + } + + @After + fun tearDown() { + SudoQuery.resetForTests() + server.close() + File(context.noBackupFilesDir, "sudoquery").deleteRecursively() + } + + private fun config(batchSize: Int = 2, endpoint: String = server.url("/v1/events/batch").toString()) = + SudoQueryConfig.Builder("acme-k3x9qa", "android-demo1", endpoint) + .batchSize(batchSize) + .flushIntervalMillis(0) + .build() + + @Test + fun `calls before init are ignored without throwing`() { + SudoQuery.track("early", mapOf("k" to 1)) + SudoQuery.setUser("u1") + SudoQuery.removeUser() + SudoQuery.flush() + SudoQuery.reset() + assertFalse(SudoQuery.isInitialized) + assertNull(SudoQuery.getUser()) + } + + @Test + fun `super properties work before init and are copies`() { + SudoQuery.setSuperProperty("plan", "pro") + SudoQuery.setSuperProperty("tags", listOf("a", "b")) + assertEquals(mapOf("plan" to "pro", "tags" to listOf("a", "b")), SudoQuery.getSuperProperties()) + SudoQuery.clearSuperProperties() + assertEquals(emptyMap(), SudoQuery.getSuperProperties()) + } + + @Test + fun `the first init wins`() { + SudoQuery.init(context, config()) + val first = SudoQuery.clientForTests + SudoQuery.init(context, config(endpoint = "https://other.example.com/v1/events/batch")) + assertTrue(SudoQuery.isInitialized) + assertTrue(first === SudoQuery.clientForTests) + } + + @Test + fun `init does nothing outside the main process`() { + SudoQuery.processCheck = { false } + SudoQuery.init(context, config()) + assertFalse(SudoQuery.isInitialized) + } + + @Test + fun `invalid config is rejected at build time`() { + assertThrows { SudoQueryConfig.Builder("", "android-demo1", "https://c.example.com/v1/events/batch").build() } + assertThrows { SudoQueryConfig.Builder("acme-k3x9qa", "android-demo1", "collector/v1/events/batch").build() } + assertThrows { SudoQueryConfig.Builder("acme-k3x9qa", "android-demo1", "ftp://c.example.com").build() } + assertThrows { SudoQueryConfig.Builder("acme-k3x9qa", "android-demo1", "https://c.example.com").batchSize(0).build() } + } + + @Test + fun `tracked events reach the collector with identity and super properties`() { + server.enqueue(MockResponse(code = 200, body = """{"filtered":0,"collected":2,"total":2}""")) + SudoQuery.init(context, config(batchSize = 2)) + SudoQuery.setSuperProperty("app_version", "1.0.0") + SudoQuery.setUser("u1") + assertEquals("u1", SudoQuery.getUser()) + + SudoQuery.track("checkout_started", mapOf("cart_value" to 499.5, "app_version" to "override")) + SudoQuery.track("checkout_completed") + + val request = checkNotNull(server.takeRequest(10, TimeUnit.SECONDS)) { "no request reached the server" } + assertEquals("acme-k3x9qa", request.headers["x-org-id"]) + assertEquals("android-demo1", request.headers["x-project-id"]) + assertTrue(request.headers["User-Agent"]!!.startsWith("sudoquery-android/")) + + val body = Json.parseToJsonElement(request.body!!.utf8()).jsonObject + val events = body.getValue("events").jsonArray.map { it.jsonObject } + assertEquals(listOf("checkout_started", "checkout_completed"), events.map { it.text("name") }) + assertEquals(listOf("u1", "u1"), events.map { it.text("actor_id") }) + assertEquals(events[0].text("anon_id"), events[1].text("anon_id")) + assertEquals(events[0].text("session_id"), events[1].text("session_id")) + assertEquals("""{"app_version":"override","cart_value":499.5}""", events[0].getValue("properties").toString()) + assertEquals("""{"app_version":"1.0.0"}""", events[1].getValue("properties").toString()) + assertTrue(body.getValue("system_properties").jsonObject.containsKey("timezone")) + } + + @Test + fun `reset clears the user and super properties`() { + SudoQuery.init(context, config()) + SudoQuery.setUser("u1") + SudoQuery.setSuperProperty("plan", "pro") + SudoQuery.reset() + assertNull(SudoQuery.getUser()) + assertEquals(emptyMap(), SudoQuery.getSuperProperties()) + runBlocking { withTimeout(10_000) { SudoQuery.clientForTests!!.awaitIdle() } } + } + + private fun JsonObject.text(key: String): String? = getValue(key).let { if (it is JsonNull) null else it.jsonPrimitive.content } + + private fun assertThrows(block: () -> Unit) { + try { + block() + } catch (expected: IllegalArgumentException) { + return + } + throw AssertionError("expected IllegalArgumentException") + } +} diff --git a/clients/android/sudoquery/src/test/kotlin/io/juspay/sudoquery/internal/EventWriterTest.kt b/clients/android/sudoquery/src/test/kotlin/io/juspay/sudoquery/internal/EventWriterTest.kt new file mode 100644 index 0000000..b176b57 --- /dev/null +++ b/clients/android/sudoquery/src/test/kotlin/io/juspay/sudoquery/internal/EventWriterTest.kt @@ -0,0 +1,193 @@ +package io.juspay.sudoquery.internal + +import io.juspay.sudoquery.CanonicalEvent +import kotlinx.coroutines.CompletableDeferred +import kotlinx.serialization.json.JsonNull +import kotlinx.serialization.json.JsonObject +import kotlinx.serialization.json.JsonPrimitive +import kotlinx.serialization.json.jsonPrimitive +import kotlinx.serialization.serializer +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNotEquals +import org.junit.Assert.assertTrue +import org.junit.Rule +import org.junit.Test +import org.junit.rules.TemporaryFolder +import java.io.File + +class EventWriterTest { + @get:Rule + val temp = TemporaryFolder() + + private val clock = FakeClock() + private val logger = RecordingLogger() + private val uploads = FakeUploads() + private val root by lazy { temp.newFolder("queue") } + private lateinit var store: QueueStore + + private fun writer(batchSize: Int = 50, fixedSessionId: String? = null): EventWriter { + store = QueueStore(root, batchSize, clock, logger) + return EventWriter( + serializer = serializer(), + buildEvent = { CanonicalEvent(it) }, + settings = WriterSettings("acme-k3x9qa", "android-demo1", "android", fixedSessionId, TIMEOUT), + store = store, + identityStore = IdentityStore(File(root, "identity.json"), logger), + uploads = uploads, + logger = logger, + ).also { it.start() } + } + + private fun track(name: String, at: Long = clock.now, properties: Map = emptyMap()) = + Command.Track(name, at, JsonObject(properties.mapValues { JsonPrimitive(it.value) })) + + private fun sentEvents(): List = store.readyBatches().flatMap { it.ndjsonLines() } + + private fun JsonObject.text(key: String): String? = getValue(key).let { if (it is JsonNull) null else it.jsonPrimitive.content } + + @Test + fun `a tracked event carries the identity, config and timestamp`() { + val writer = writer() + writer.handle(track("button_click", at = T0, properties = mapOf("page" to "/home"))) + writer.handle(Command.Flush) + + val event = sentEvents().single() + assertEquals("1.0", event.text("envelop_version")) + assertEquals("button_click", event.text("name")) + assertEquals("acme-k3x9qa", event.text("org_id")) + assertEquals("android-demo1", event.text("project_id")) + assertEquals("android", event.text("source")) + assertEquals("2025-10-09T08:53:20.000Z", event.text("occured_at")) + assertEquals("""{"page":"/home"}""", event.getValue("properties").toString()) + assertEquals(null, event.text("actor_id")) + assertTrue(event.text("id")!!.matches(UUID_V4)) + assertTrue(event.text("anon_id")!!.matches(UUID_V4)) + assertTrue(event.text("session_id")!!.matches(UUID_V4)) + } + + @Test + fun `commands are applied in the order they were made`() { + val writer = writer() + writer.handle(Command.SetUser("u1")) + writer.handle(track("a")) + writer.handle(Command.SetUser(null)) + writer.handle(track("b")) + writer.handle(Command.Flush) + + assertEquals(listOf("u1", null), sentEvents().map { it.text("actor_id") }) + } + + @Test + fun `reset gives later events a new anonymous id and session`() { + val writer = writer() + writer.handle(Command.SetUser("u1")) + writer.handle(track("a")) + writer.handle(Command.Reset) + writer.handle(track("b")) + writer.handle(Command.Flush) + + val (a, b) = sentEvents() + assertNotEquals(a.text("anon_id"), b.text("anon_id")) + assertNotEquals(a.text("session_id"), b.text("session_id")) + assertEquals(null, b.text("actor_id")) + } + + @Test + fun `identity and session survive a restart`() { + val first = writer() + first.handle(Command.SetUser("u1")) + first.handle(track("a")) + first.handle(Command.Background) + + clock.now += 60_000 + val second = EventWriter( + serializer(), { CanonicalEvent(it) }, + WriterSettings("acme-k3x9qa", "android-demo1", "android", null, TIMEOUT), + store, IdentityStore(File(root, "identity.json"), logger), uploads, logger, + ) + assertEquals("u1", second.start()) + second.handle(track("b")) + second.handle(Command.Flush) + + val (a, b) = sentEvents() + assertEquals(a.text("anon_id"), b.text("anon_id")) + assertEquals(a.text("session_id"), b.text("session_id")) + assertEquals("u1", b.text("actor_id")) + } + + @Test + fun `a session ends after the timeout without events`() { + val writer = writer() + writer.handle(track("a", at = T0)) + writer.handle(track("b", at = T0 + TIMEOUT)) + writer.handle(track("c", at = T0 + 2 * TIMEOUT + 1)) + writer.handle(Command.Flush) + + val sessions = sentEvents().map { it.text("session_id") } + assertEquals(sessions[0], sessions[1]) + assertNotEquals(sessions[1], sessions[2]) + } + + @Test + fun `a configured session id is used for every event`() { + val writer = writer(fixedSessionId = "checkout-42") + writer.handle(track("a", at = T0)) + writer.handle(track("b", at = T0 + 3 * TIMEOUT)) + writer.handle(Command.Flush) + assertEquals(listOf("checkout-42", "checkout-42"), sentEvents().map { it.text("session_id") }) + } + + @Test + fun `a full batch is committed and an upload requested`() { + val writer = writer(batchSize = 2) + val before = uploads.requests + writer.handle(track("a")) + writer.handle(track("b")) + assertEquals(1, store.readyBatches().size) + assertEquals(before + 1, uploads.requests) + } + + @Test + fun `an oversized event is dropped`() { + val writer = writer() + writer.handle(track("huge", properties = mapOf("blob" to "x".repeat(EventWriter.MAX_EVENT_BYTES)))) + writer.handle(Command.Flush) + assertEquals(emptyList(), sentEvents()) + assertTrue(logger.warnings.single().contains("huge")) + } + + @Test + fun `ticks leave the open batch alone while uploads back off`() { + val writer = writer() + writer.handle(track("a")) + uploads.isBackingOff = true + writer.handle(Command.Tick) + assertEquals(0, store.readyBatches().size) + + uploads.isBackingOff = false + writer.handle(Command.Tick) + assertEquals(1, store.readyBatches().size) + } + + @Test + fun `coming to the foreground resets the backoff and asks for an upload`() { + val writer = writer() + val before = uploads.requests + writer.handle(Command.Foreground) + assertEquals(1, uploads.resets) + assertEquals(before + 1, uploads.requests) + } + + @Test + fun `a barrier completes in order`() { + val writer = writer() + val done = CompletableDeferred() + writer.handle(Command.Barrier(done)) + assertTrue(done.isCompleted) + } + + private companion object { + const val TIMEOUT = 30 * 60 * 1000L + val UUID_V4 = Regex("[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}") + } +} diff --git a/clients/android/sudoquery/src/test/kotlin/io/juspay/sudoquery/internal/HttpTransportTest.kt b/clients/android/sudoquery/src/test/kotlin/io/juspay/sudoquery/internal/HttpTransportTest.kt new file mode 100644 index 0000000..e22e895 --- /dev/null +++ b/clients/android/sudoquery/src/test/kotlin/io/juspay/sudoquery/internal/HttpTransportTest.kt @@ -0,0 +1,110 @@ +package io.juspay.sudoquery.internal + +import mockwebserver3.MockResponse +import mockwebserver3.MockWebServer +import okhttp3.Headers.Companion.headersOf +import org.junit.After +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNull +import org.junit.Assert.fail +import org.junit.Before +import org.junit.Test +import java.io.IOException +import java.util.concurrent.TimeUnit + +class HttpTransportTest { + private val server = MockWebServer() + + @Before + fun start() = server.start() + + @After + fun stop() = server.close() + + private fun transport(headers: Map = mapOf("Content-Type" to "application/json")) = + HttpUrlConnectionTransport(server.url("/v1/events/batch").toUrl(), headers) + + @Test + fun `posts the body with the given headers`() { + server.enqueue(MockResponse(code = 200, body = """{"filtered":0,"collected":1,"total":1}""")) + val headers = requestHeaders("acme-k3x9qa", "android-demo1", "secret", mapOf("X-Api-Key" to "k"), "sudoquery-android/0.1.0") + + val result = transport(headers).post("""{"events":[]}""".toByteArray()) + + assertEquals(200, result.code) + assertEquals("""{"filtered":0,"collected":1,"total":1}""", result.body) + val request = server.takeRequest(5, TimeUnit.SECONDS)!! + assertEquals("POST", request.method) + assertEquals("/v1/events/batch", request.url.encodedPath) + assertEquals("""{"events":[]}""", request.body!!.utf8()) + assertEquals("application/json", request.headers["Content-Type"]) + assertEquals("acme-k3x9qa", request.headers["x-org-id"]) + assertEquals("android-demo1", request.headers["x-project-id"]) + assertEquals("Bearer secret", request.headers["Authorization"]) + assertEquals("k", request.headers["X-Api-Key"]) + assertEquals("sudoquery-android/0.1.0", request.headers["User-Agent"]) + } + + @Test + fun `returns error responses with their body and Retry-After`() { + server.enqueue(MockResponse(code = 400, body = """{"error":"bad"}""")) + server.enqueue(MockResponse(code = 429, headers = headersOf("Retry-After", "30"))) + + val rejected = transport().post(ByteArray(0)) + assertEquals(400, rejected.code) + assertEquals("""{"error":"bad"}""", rejected.body) + assertNull(rejected.retryAfterSeconds) + + assertEquals(30L, transport().post(ByteArray(0)).retryAfterSeconds) + } + + @Test + fun `does not follow redirects`() { + server.enqueue(MockResponse(code = 307, headers = headersOf("Location", server.url("/elsewhere").toString()))) + assertEquals(307, transport().post(ByteArray(1)).code) + assertEquals(1, server.requestCount) + } + + @Test + fun `throws IOException when the collector is unreachable`() { + val unreachable = transport() + server.close() + try { + unreachable.post(ByteArray(1)) + fail("expected an IOException") + } catch (expected: IOException) { + } + } + + @Test + fun `custom headers can't replace the ids or the token`() { + val headers = requestHeaders( + orgId = "acme-k3x9qa", + projectId = "android-demo1", + token = "secret", + custom = mapOf( + "content-type" to "application/x-ndjson", + "X-Org-Id" to "other-org", + "authorization" to "Basic abc", + "user-agent" to "custom", + ), + userAgent = "sudoquery-android/0.1.0", + ) + assertEquals( + mapOf( + "content-type" to "application/x-ndjson", + "user-agent" to "custom", + "x-org-id" to "acme-k3x9qa", + "x-project-id" to "android-demo1", + "Authorization" to "Bearer secret", + ), + headers, + ) + } + + @Test + fun `an empty token is not sent`() { + val headers = requestHeaders("acme-k3x9qa", "android-demo1", "", emptyMap(), "ua") + assertEquals(listOf("Content-Type", "User-Agent", "x-org-id", "x-project-id"), headers.keys.toList()) + } +} diff --git a/clients/android/sudoquery/src/test/kotlin/io/juspay/sudoquery/internal/IdentityTest.kt b/clients/android/sudoquery/src/test/kotlin/io/juspay/sudoquery/internal/IdentityTest.kt new file mode 100644 index 0000000..3d934ee --- /dev/null +++ b/clients/android/sudoquery/src/test/kotlin/io/juspay/sudoquery/internal/IdentityTest.kt @@ -0,0 +1,72 @@ +package io.juspay.sudoquery.internal + +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertNull +import org.junit.Rule +import org.junit.Test +import org.junit.rules.TemporaryFolder + +class IdentityTest { + @get:Rule + val temp = TemporaryFolder() + + private val timeout = 30 * 60 * 1000L + private var ids = 0 + private val newId = { "id-${++ids}" } + + @Test + fun `the first event starts a session`() { + val state = IdentityState(anonId = "anon").touch(T0, timeout, null, newId) + assertEquals("id-1", state.sessionId) + assertEquals(T0, state.lastActivityMillis) + } + + @Test + fun `a session continues within the timeout and ends after it`() { + val started = IdentityState(anonId = "anon").touch(T0, timeout, null, newId) + val continued = started.touch(T0 + timeout, timeout, null, newId) + assertEquals(started.sessionId, continued.sessionId) + + val renewed = continued.touch(T0 + 2 * timeout + 1, timeout, null, newId) + assertEquals("id-2", renewed.sessionId) + } + + @Test + fun `a clock moving backwards keeps the session and times it from the new clock`() { + val started = IdentityState(anonId = "anon").touch(T0, timeout, null, newId) + val back = started.touch(T0 - 3_600_000, timeout, null, newId) + assertEquals(started.sessionId, back.sessionId) + assertEquals(T0 - 3_600_000, back.lastActivityMillis) + } + + @Test + fun `a configured session id is always used`() { + val state = IdentityState(anonId = "anon", sessionId = "old", lastActivityMillis = 0).touch(T0, timeout, "fixed", newId) + assertEquals("fixed", state.sessionId) + assertEquals(0, ids) + } + + @Test + fun `the store round-trips and replaces the file atomically`() { + val file = temp.root.resolve("v1/identity.json") + val store = IdentityStore(file, RecordingLogger()) + assertNull(store.load()) + + val state = IdentityState(anonId = "anon", actorId = "user", sessionId = "session", lastActivityMillis = T0) + store.save(state) + assertEquals(state, store.load()) + assertFalse(temp.root.resolve("v1/identity.json.tmp").exists()) + + store.save(state.copy(actorId = null)) + assertEquals(state.copy(actorId = null), IdentityStore(file, RecordingLogger()).load()) + } + + @Test + fun `an unreadable file is ignored with a warning`() { + val file = temp.newFile("identity.json").apply { writeText("{not json") } + val logger = RecordingLogger() + assertNull(IdentityStore(file, logger).load()) + assertEquals(1, logger.warnings.size) + } +} diff --git a/clients/android/sudoquery/src/test/kotlin/io/juspay/sudoquery/internal/IsoTimeTest.kt b/clients/android/sudoquery/src/test/kotlin/io/juspay/sudoquery/internal/IsoTimeTest.kt new file mode 100644 index 0000000..e83d5d3 --- /dev/null +++ b/clients/android/sudoquery/src/test/kotlin/io/juspay/sudoquery/internal/IsoTimeTest.kt @@ -0,0 +1,42 @@ +package io.juspay.sudoquery.internal + +import org.junit.Assert.assertEquals +import org.junit.Test +import java.time.Instant +import java.time.ZoneOffset +import java.time.format.DateTimeFormatter +import kotlin.random.Random + +class IsoTimeTest { + private val reference = DateTimeFormatter.ofPattern("uuuu-MM-dd'T'HH:mm:ss.SSS'Z'").withZone(ZoneOffset.UTC) + + private fun expected(millis: Long) = reference.format(Instant.ofEpochMilli(millis)) + + @Test + fun `formats like Date toISOString`() { + assertEquals("2026-10-14T09:30:12.345Z", formatIsoUtcMillis(Instant.parse("2026-10-14T09:30:12.345Z").toEpochMilli())) + assertEquals("1970-01-01T00:00:00.000Z", formatIsoUtcMillis(0)) + assertEquals("1969-12-31T23:59:59.999Z", formatIsoUtcMillis(-1)) + } + + @Test + fun `handles leap days and year ends`() { + listOf( + "2000-02-29T12:00:00.001Z", + "2024-02-29T23:59:59.999Z", + "2100-03-01T00:00:00.000Z", + "2026-12-31T23:59:59.999Z", + "2027-01-01T00:00:00.000Z", + ).forEach { iso -> assertEquals(iso, formatIsoUtcMillis(Instant.parse(iso).toEpochMilli())) } + } + + @Test + fun `matches java time across two centuries`() { + val random = Random(42) + val end = Instant.parse("2170-01-01T00:00:00Z").toEpochMilli() + repeat(20_000) { + val millis = random.nextLong(0, end) + assertEquals(expected(millis), formatIsoUtcMillis(millis)) + } + } +} diff --git a/clients/android/sudoquery/src/test/kotlin/io/juspay/sudoquery/internal/JsonValuesTest.kt b/clients/android/sudoquery/src/test/kotlin/io/juspay/sudoquery/internal/JsonValuesTest.kt new file mode 100644 index 0000000..cc1fc47 --- /dev/null +++ b/clients/android/sudoquery/src/test/kotlin/io/juspay/sudoquery/internal/JsonValuesTest.kt @@ -0,0 +1,110 @@ +package io.juspay.sudoquery.internal + +import kotlinx.serialization.json.JsonArray +import kotlinx.serialization.json.JsonNull +import kotlinx.serialization.json.JsonObject +import kotlinx.serialization.json.JsonPrimitive +import kotlinx.serialization.json.jsonObject +import org.json.JSONArray +import org.json.JSONObject +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Test +import java.math.BigDecimal + +class JsonValuesTest { + private val logger = RecordingLogger() + + private enum class Plan { PRO } + + @Test + fun `converts nested maps, lists and arrays in order`() { + val json = JsonValues.toJsonObject( + linkedMapOf( + "string" to "a", + "int" to 1, + "long" to 2L, + "double" to 1.5, + "decimal" to BigDecimal("10.25"), + "bool" to true, + "null" to null, + "char" to 'x', + "enum" to Plan.PRO, + "list" to listOf(1, "two"), + "array" to arrayOf("a", "b"), + "ints" to intArrayOf(1, 2), + "nested" to mapOf("inner" to mapOf("deep" to 1)), + ), + logger, + ) + assertEquals( + """{"string":"a","int":1,"long":2,"double":1.5,"decimal":10.25,"bool":true,"null":null,"char":"x",""" + + """"enum":"PRO","list":[1,"two"],"array":["a","b"],"ints":[1,2],"nested":{"inner":{"deep":1}}}""", + json.toString(), + ) + } + + @Test + fun `non-finite numbers become null with a warning`() { + val json = JsonValues.toJsonObject( + mapOf("nan" to Double.NaN, "inf" to Float.POSITIVE_INFINITY, "list" to doubleArrayOf(1.0, Double.NEGATIVE_INFINITY)), + logger, + ) + assertEquals("""{"nan":null,"inf":null,"list":[1.0,null]}""", json.toString()) + assertEquals(3, logger.warnings.size) + } + + @Test + fun `converts org json values`() { + val source = JSONObject() + .put("a", 1) + .put("none", JSONObject.NULL) + .put("list", JSONArray().put("x").put(JSONObject().put("y", false))) + assertEquals("""{"a":1,"none":null,"list":["x",{"y":false}]}""", JsonValues.toJsonObject(source, logger).toString()) + } + + @Test + fun `a map containing itself stops at the depth limit`() { + val loop = HashMap() + loop["self"] = loop + val json = JsonValues.toJsonObject(loop, logger) + var depth = 0 + var current: Any = json + while (current is JsonObject) { + current = current.getValue("self") + depth++ + } + assertEquals(JsonNull, current) + assertEquals(33, depth) + assertTrue(logger.warnings.single().contains("nested deeper")) + } + + @Test + fun `the result is a copy`() { + val properties = mutableMapOf("items" to mutableListOf("a")) + val json = JsonValues.toJsonObject(properties, logger) + @Suppress("UNCHECKED_CAST") + (properties["items"] as MutableList).add("b") + properties["late"] = 1 + assertEquals("""{"items":["a"]}""", json.toString()) + } + + @Test + fun `toPlain turns JSON back into maps, lists and primitives`() { + val element = JsonObject( + mapOf( + "s" to JsonPrimitive("x"), + "n" to JsonPrimitive(3), + "d" to JsonPrimitive(2.5), + "b" to JsonPrimitive(true), + "null" to JsonNull, + "obj" to JsonObject(mapOf("k" to JsonArray(listOf(JsonPrimitive(1))))), + ), + ) + assertEquals( + mapOf("s" to "x", "n" to 3L, "d" to 2.5, "b" to true, "null" to null, "obj" to mapOf("k" to listOf(1L))), + JsonValues.toPlain(element), + ) + assertEquals("x", JsonValues.toPlain(JsonValues.toJsonObject(mapOf("s" to "x"), logger).jsonObject.getValue("s"))) + } +} diff --git a/clients/android/sudoquery/src/test/kotlin/io/juspay/sudoquery/internal/LiveCollectorTest.kt b/clients/android/sudoquery/src/test/kotlin/io/juspay/sudoquery/internal/LiveCollectorTest.kt new file mode 100644 index 0000000..f127ce9 --- /dev/null +++ b/clients/android/sudoquery/src/test/kotlin/io/juspay/sudoquery/internal/LiveCollectorTest.kt @@ -0,0 +1,73 @@ +package io.juspay.sudoquery.internal + +import io.juspay.sudoquery.CanonicalEvent +import kotlinx.serialization.json.JsonObject +import kotlinx.serialization.json.JsonPrimitive +import kotlinx.serialization.serializer +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assume.assumeTrue +import org.junit.Rule +import org.junit.Test +import org.junit.rules.TemporaryFolder +import java.io.File +import java.net.URL + +/** + * Sends events through the real writer, store, uploader and HTTP transport to a running collector. + * Skipped unless a collector URL is given: + * + * ``` + * ./gradlew :sudoquery:testDebugUnitTest --tests '*LiveCollectorTest*' \ + * -Psudoquery.liveCollectorUrl=http://localhost:3001/v1/events/batch + * ``` + * + * The collector must be able to publish to the `events.acme-k3x9qa.android-demo1` topic. + */ +class LiveCollectorTest { + @get:Rule + val temp = TemporaryFolder() + + @Test + fun `events are accepted by a real collector`() { + val url = System.getProperty("sudoquery.liveCollectorUrl").orEmpty() + assumeTrue("set -Psudoquery.liveCollectorUrl to run", url.isNotEmpty()) + + val clock = JvmClock + val logger = RecordingLogger() + val store = QueueStore(temp.newFolder("queue"), maxEventsPerBatch = 2, clock = clock, logger = logger) + val headers = requestHeaders(ORG_ID, PROJECT_ID, token = null, custom = emptyMap(), userAgent = "sudoquery-android/live-test") + val uploader = Uploader(store, HttpUrlConnectionTransport(URL(url), headers), clock, logger) + val writer = EventWriter( + serializer = serializer(), + buildEvent = { CanonicalEvent(it) }, + settings = WriterSettings(ORG_ID, PROJECT_ID, "android", null, 30 * 60 * 1000L), + store = store, + identityStore = IdentityStore(File(temp.root, "identity.json"), logger), + uploads = uploader, + logger = logger, + ) + + writer.start() + writer.handle(Command.SetUser("live-test-user")) + repeat(3) { writer.handle(Command.Track("live_test_event", clock.nowMillis(), JsonObject(mapOf("index" to JsonPrimitive(it))))) } + writer.handle(Command.Flush) + uploader.drain() + + assertEquals("collector errors: ${logger.errors}", emptyList(), logger.errors) + assertEquals("warnings: ${logger.warnings}", emptyList(), logger.warnings) + assertEquals(0, store.readyBatches().size) + assertFalse(uploader.isBackingOff) + } + + private object JvmClock : Clock { + override fun nowMillis(): Long = System.currentTimeMillis() + + override fun elapsedMillis(): Long = System.nanoTime() / 1_000_000 + } + + private companion object { + const val ORG_ID = "acme-k3x9qa" + const val PROJECT_ID = "android-demo1" + } +} diff --git a/clients/android/sudoquery/src/test/kotlin/io/juspay/sudoquery/internal/QueueStoreTest.kt b/clients/android/sudoquery/src/test/kotlin/io/juspay/sudoquery/internal/QueueStoreTest.kt new file mode 100644 index 0000000..b92420e --- /dev/null +++ b/clients/android/sudoquery/src/test/kotlin/io/juspay/sudoquery/internal/QueueStoreTest.kt @@ -0,0 +1,133 @@ +package io.juspay.sudoquery.internal + +import kotlinx.serialization.json.jsonObject +import kotlinx.serialization.json.jsonPrimitive +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Rule +import org.junit.Test +import org.junit.rules.TemporaryFolder +import java.io.File + +class QueueStoreTest { + @get:Rule + val temp = TemporaryFolder() + + private val clock = FakeClock() + private val logger = RecordingLogger() + private val root by lazy { temp.newFolder("queue") } + + private fun store( + maxEvents: Int = 3, + maxBytes: Long = QueueStore.MAX_BATCH_BYTES, + maxQueueBytes: Long = QueueStore.MAX_QUEUE_BYTES, + ) = QueueStore(root, maxEvents, clock, logger, maxBytes, maxQueueBytes).also { it.recover() } + + private fun event(n: Int) = """{"n":$n}""".toByteArray() + + private fun QueueStore.numbers(file: File) = read(file)!!.map { it.jsonObject.getValue("n").jsonPrimitive.content.toInt() } + + @Test + fun `a batch is committed when it reaches the event limit`() { + val store = store(maxEvents = 3) + assertFalse(store.append(event(1))) + assertFalse(store.append(event(2))) + assertTrue(store.append(event(3))) + assertFalse(store.append(event(4))) + + val ready = store.readyBatches() + assertEquals(1, ready.size) + assertEquals(listOf(1, 2, 3), store.numbers(ready.single())) + assertEquals(1, File(root, "staging").listFiles()!!.size) + } + + @Test + fun `a batch is committed before it would exceed the byte limit`() { + val store = store(maxEvents = 100, maxBytes = 20) + store.append(event(1)) // 8 bytes with the newline + store.append(event(2)) // 16 + assertTrue(store.append(event(3))) // would be 24: commits the first two, starts a new batch + store.commit() + assertEquals(listOf(listOf(1, 2), listOf(3)), store.readyBatches().map { store.numbers(it) }) + } + + @Test + fun `commit returns false when nothing was appended`() { + val store = store() + assertFalse(store.commit()) + store.append(event(1)) + assertTrue(store.commit()) + assertFalse(store.commit()) + } + + @Test + fun `recover commits batches a previous process left open and keeps the sequence going`() { + val first = store(maxEvents = 10) + first.append(event(1)) + first.append(event(2)) + // The process dies here: the batch is still in staging/. Add an empty and a foreign file too. + File(root, "staging/0000000099-1.ndjson").createNewFile() + File(root, "staging/notes.txt").writeText("x") + + val second = store(maxEvents = 10) + val ready = second.readyBatches() + assertEquals(listOf(listOf(1, 2)), ready.map { second.numbers(it) }) + assertEquals(0, File(root, "staging").listFiles()!!.size) + + second.append(event(3)) + second.commit() + assertEquals(listOf(listOf(1, 2), listOf(3)), second.readyBatches().map { second.numbers(it) }) + } + + @Test + fun `lines cut short by a crash are skipped`() { + val store = store() + val file = File(root, "ready/${QueueStore.fileName(1, T0)}") + file.writeText("{\"n\":1}\n{\"n\":2}\n{\"n\":3, \"cut") + assertEquals(listOf(1, 2), store.numbers(file)) + assertTrue(logger.warnings.single().contains("Skipped 1")) + } + + @Test + fun `read returns null for a batch that is gone`() { + assertNull(store().read(File(root, "ready/missing.ndjson"))) + } + + @Test + fun `batches are ordered by sequence, not by creation time`() { + val store = store(maxEvents = 1) + clock.now = T0 + 10_000 + store.append(event(1)) + clock.now = T0 // the clock moved back + store.append(event(2)) + assertEquals(listOf(listOf(1), listOf(2)), store.readyBatches().map { store.numbers(it) }) + } + + @Test + fun `expired batches are dropped and future-dated ones kept`() { + val store = store() + val ready = File(root, "ready") + File(ready, QueueStore.fileName(1, T0 - QueueStore.MAX_AGE_MILLIS - 1)).writeText("{\"n\":1}\n") + File(ready, QueueStore.fileName(2, T0 - 1000)).writeText("{\"n\":2}\n") + File(ready, QueueStore.fileName(3, T0 + 86_400_000)).writeText("{\"n\":3}\n") + + store.enforceLimits() + + assertEquals(listOf(listOf(2), listOf(3)), store.readyBatches().map { store.numbers(it) }) + assertTrue(logger.warnings.single().contains("older than")) + } + + @Test + fun `an oversized queue drops its oldest batches down to 80 percent`() { + val store = store(maxEvents = 1, maxQueueBytes = 100) + repeat(10) { store.append("""{"pad":"${"x".repeat(9)}"}""".toByteArray()) } // 10 batches of 20 bytes = 200 + + store.enforceLimits() + + val remaining = store.readyBatches() + assertEquals(4, remaining.size) // 80 bytes <= 80 % of 100 + assertEquals(listOf(7L, 8L, 9L, 10L), remaining.map { QueueStore.parseName(it.name)!!.seq }) + } +} diff --git a/clients/android/sudoquery/src/test/kotlin/io/juspay/sudoquery/internal/TestSupport.kt b/clients/android/sudoquery/src/test/kotlin/io/juspay/sudoquery/internal/TestSupport.kt new file mode 100644 index 0000000..fc9ecea --- /dev/null +++ b/clients/android/sudoquery/src/test/kotlin/io/juspay/sudoquery/internal/TestSupport.kt @@ -0,0 +1,79 @@ +package io.juspay.sudoquery.internal + +import kotlinx.serialization.json.Json +import kotlinx.serialization.json.JsonArray +import kotlinx.serialization.json.JsonObject +import kotlinx.serialization.json.jsonArray +import kotlinx.serialization.json.jsonObject +import java.io.File +import java.io.IOException + +/** 2025-10-09T08:53:20Z, a fixed "now" for tests. */ +internal const val T0: Long = 1_760_000_000_000L + +internal class FakeClock(var now: Long = T0, var elapsed: Long = 0L) : Clock { + override fun nowMillis(): Long = now + + override fun elapsedMillis(): Long = elapsed +} + +internal class RecordingLogger : Logger { + val warnings: MutableList = mutableListOf() + val errors: MutableList = mutableListOf() + + override fun debug(message: String) = Unit + + @Synchronized + override fun warn(message: String, error: Throwable?) { + warnings += message + } + + @Synchronized + override fun error(message: String, error: Throwable?) { + errors += message + } +} + +internal class FakeUploads : UploadControl { + var requests = 0 + var resets = 0 + override var isBackingOff = false + + override fun requestUpload() { + requests++ + } + + override fun resetBackoff() { + resets++ + isBackingOff = false + } +} + +/** Replies with the queued outcomes in order, then with 200. Records every body it was given. */ +internal class FakeTransport : Transport { + val bodies: MutableList = mutableListOf() + private val outcomes = ArrayDeque<() -> HttpResult>() + + fun reply(code: Int, body: String = "{}", retryAfterSeconds: Long? = null) { + outcomes.addLast { HttpResult(code, retryAfterSeconds, body) } + } + + fun failWith(error: IOException) { + outcomes.addLast { throw error } + } + + override fun post(body: ByteArray): HttpResult { + bodies += body.decodeToString() + val next = outcomes.removeFirstOrNull() ?: return HttpResult(200, null, """{"filtered":0,"collected":1,"total":1}""") + return next() + } + + /** The events of the [index]th request. */ + fun events(index: Int): List = + Json.parseToJsonElement(bodies[index]).jsonObject.getValue("events").jsonArray.map { it.jsonObject } +} + +internal fun File.ndjsonLines(): List = + readLines().filter { it.isNotBlank() }.map { Json.parseToJsonElement(it).jsonObject } + +internal fun JsonArray.objects(): List = map { it.jsonObject } diff --git a/clients/android/sudoquery/src/test/kotlin/io/juspay/sudoquery/internal/UploaderTest.kt b/clients/android/sudoquery/src/test/kotlin/io/juspay/sudoquery/internal/UploaderTest.kt new file mode 100644 index 0000000..3527591 --- /dev/null +++ b/clients/android/sudoquery/src/test/kotlin/io/juspay/sudoquery/internal/UploaderTest.kt @@ -0,0 +1,214 @@ +package io.juspay.sudoquery.internal + +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.launch +import kotlinx.coroutines.test.advanceTimeBy +import kotlinx.coroutines.test.runCurrent +import kotlinx.coroutines.test.runTest +import kotlinx.serialization.json.Json +import kotlinx.serialization.json.jsonObject +import kotlinx.serialization.json.jsonPrimitive +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Rule +import org.junit.Test +import org.junit.rules.TemporaryFolder +import java.io.IOException +import java.net.ConnectException +import kotlin.random.Random + +class UploaderTest { + @get:Rule + val temp = TemporaryFolder() + + private val clock = FakeClock() + private val logger = RecordingLogger() + private val transport = FakeTransport() + private val store by lazy { + QueueStore(temp.newFolder("queue"), maxEventsPerBatch = 2, clock = clock, logger = logger).also { it.recover() } + } + private val uploader by lazy { Uploader(store, transport, clock, logger, timezone = { "Asia/Kolkata" }, random = Random(1)) } + + /** Commits [count] batches of two events each, numbered from 1. */ + private fun queueBatches(count: Int) { + repeat(count * 2) { store.append("""{"n":${it + 1}}""".toByteArray()) } + } + + private fun sentNumbers(request: Int) = transport.events(request).map { it.getValue("n").jsonPrimitive.content.toInt() } + + @Test + fun `sends batches oldest first and deletes them`() { + queueBatches(2) + uploader.drain() + + assertEquals(listOf(1, 2), sentNumbers(0)) + assertEquals(listOf(3, 4), sentNumbers(1)) + assertEquals(0, store.readyBatches().size) + assertFalse(uploader.isBackingOff) + } + + @Test + fun `the batch carries the timezone at batch level`() { + queueBatches(1) + uploader.drain() + val body = Json.parseToJsonElement(transport.bodies.single()).jsonObject + assertEquals("""{"geo":null,"timezone":"Asia/Kolkata"}""", body.getValue("system_properties").toString()) + } + + @Test + fun `any 2xx counts as delivered`() { + queueBatches(2) + transport.reply(202) + transport.reply(204, body = "") + uploader.drain() + assertEquals(0, store.readyBatches().size) + } + + @Test + fun `a rejected batch is dropped and the queue moves on`() { + for (code in listOf(400, 413, 422)) { + queueBatches(2) + transport.reply(code, body = """{"error":"missing field `anon_id`"}""") + uploader.drain() + assertEquals("HTTP $code", 0, store.readyBatches().size) + } + assertEquals(6, transport.bodies.size) + assertTrue(logger.errors.first().contains("missing field `anon_id`")) + assertFalse(uploader.isBackingOff) + } + + @Test + fun `other failures keep the batch, stop the queue and back off`() { + for (code in listOf(301, 401, 403, 404, 408, 429, 500, 502, 503)) { + uploader.resetBackoff() + queueBatches(1) + val before = transport.bodies.size + transport.reply(code) + uploader.drain() + + assertEquals("HTTP $code", before + 1, transport.bodies.size) + assertTrue("HTTP $code", uploader.isBackingOff) + uploader.resetBackoff() + uploader.drain() // delivered on the next attempt + assertEquals("HTTP $code", 0, store.readyBatches().size) + } + } + + @Test + fun `a network error keeps the batch and stops the queue`() { + queueBatches(2) + transport.failWith(ConnectException("Connection refused")) + uploader.drain() + + assertEquals(1, transport.bodies.size) + assertEquals(2, store.readyBatches().size) + assertTrue(uploader.isBackingOff) + assertTrue(logger.warnings.single().contains("Could not reach the collector")) + } + + @Test + fun `a resend is byte-for-byte the same request`() { + queueBatches(1) + transport.reply(502) + uploader.drain() + uploader.resetBackoff() + uploader.drain() + assertEquals(2, transport.bodies.size) + assertEquals(transport.bodies[0], transport.bodies[1]) + } + + @Test + fun `backoff doubles from 5 s up to 5 min, half of it randomized`() { + queueBatches(1) + val delays = (1..10).map { + transport.failWith(IOException("offline")) + clock.elapsed = 0 + uploader.drain() + waitTime() + } + val ceilings = listOf(5_000L, 10_000, 20_000, 40_000, 80_000, 160_000, 300_000, 300_000, 300_000, 300_000) + delays.zip(ceilings).forEach { (delay, ceiling) -> assertTrue("$delay vs $ceiling", delay in ceiling / 2..ceiling) } + } + + @Test + fun `Retry-After is honoured`() { + queueBatches(1) + transport.reply(429, retryAfterSeconds = 120) + uploader.drain() + assertTrue(waitTime() >= 120_000) + } + + @Test + fun `an empty batch file is deleted without a request`() { + store.append("""{"n":1}""".toByteArray()) + store.commit() + val file = store.readyBatches().single() + file.writeText("") + uploader.drain() + assertEquals(0, transport.bodies.size) + assertEquals(0, store.readyBatches().size) + } + + @OptIn(ExperimentalCoroutinesApi::class) + @Test + fun `run retries by itself once the backoff ends`() = runTest { + val virtualClock = object : Clock { + override fun nowMillis() = T0 + override fun elapsedMillis() = testScheduler.currentTime + } + val uploader = Uploader(store, transport, virtualClock, logger, timezone = { "UTC" }, random = Random(1)) + backgroundScope.launch { uploader.run() } + + queueBatches(1) + transport.reply(503) + uploader.requestUpload() + runCurrent() + assertEquals(1, transport.bodies.size) + assertEquals(1, store.readyBatches().size) + + uploader.requestUpload() // ignored while backing off + runCurrent() + assertEquals(1, transport.bodies.size) + + advanceTimeBy(5_001) + runCurrent() + assertEquals(2, transport.bodies.size) + assertEquals(0, store.readyBatches().size) + } + + @OptIn(ExperimentalCoroutinesApi::class) + @Test + fun `resetBackoff lets a request through at once`() = runTest { + val virtualClock = object : Clock { + override fun nowMillis() = T0 + override fun elapsedMillis() = testScheduler.currentTime + } + val uploader = Uploader(store, transport, virtualClock, logger, timezone = { "UTC" }, random = Random(1)) + backgroundScope.launch { uploader.run() } + + queueBatches(1) + transport.reply(503) + uploader.requestUpload() + runCurrent() + uploader.resetBackoff() + uploader.requestUpload() + runCurrent() + assertEquals(2, transport.bodies.size) + assertEquals(0, store.readyBatches().size) + } + + /** How long the uploader will wait before retrying, read through its public backoff check. */ + private fun waitTime(): Long { + val start = clock.elapsed + var low = 0L + var high = 2 * 60 * 60 * 1000L + while (low < high) { + val mid = (low + high) / 2 + clock.elapsed = start + mid + if (uploader.isBackingOff) low = mid + 1 else high = mid + } + clock.elapsed = start + return low + } +} diff --git a/clients/android/sudoquery/src/test/resources/golden/batch.json b/clients/android/sudoquery/src/test/resources/golden/batch.json new file mode 100644 index 0000000..8298ebd --- /dev/null +++ b/clients/android/sudoquery/src/test/resources/golden/batch.json @@ -0,0 +1 @@ +{"events":[{"envelop_version":"1.0","id":"3b241101-e2bb-4255-8caf-4136c566a962","name":"button_click","org_id":"acme-k3x9qa","project_id":"android-demo1","session_id":"9f1c2d3e-4b5a-4c6d-8e7f-0a1b2c3d4e5f","anon_id":"6e8bc430-9c3a-4d6b-9f3e-2a1b0c9d8e7f","actor_id":null,"source":"android","occured_at":"2026-10-14T09:30:12.345Z","properties":{"app_version":"1.0.0","button_id":"submit","page":"/checkout"},"correlation_id":null,"trace_id":null,"system_properties":null}],"system_properties":{"geo":null,"timezone":"Asia/Kolkata"}} diff --git a/clients/android/sudoquery/src/test/resources/golden/event.json b/clients/android/sudoquery/src/test/resources/golden/event.json new file mode 100644 index 0000000..85b6aae --- /dev/null +++ b/clients/android/sudoquery/src/test/resources/golden/event.json @@ -0,0 +1 @@ +{"envelop_version":"1.0","id":"3b241101-e2bb-4255-8caf-4136c566a962","name":"button_click","org_id":"acme-k3x9qa","project_id":"android-demo1","session_id":"9f1c2d3e-4b5a-4c6d-8e7f-0a1b2c3d4e5f","anon_id":"6e8bc430-9c3a-4d6b-9f3e-2a1b0c9d8e7f","actor_id":null,"source":"android","occured_at":"2026-10-14T09:30:12.345Z","properties":{"app_version":"1.0.0","button_id":"submit","page":"/checkout"},"correlation_id":null,"trace_id":null,"system_properties":null} diff --git a/clients/android/sudoquery/src/test/resources/robolectric.properties b/clients/android/sudoquery/src/test/resources/robolectric.properties new file mode 100644 index 0000000..2c8ddd1 --- /dev/null +++ b/clients/android/sudoquery/src/test/resources/robolectric.properties @@ -0,0 +1,2 @@ +# Robolectric's SDK 36 runtime needs JDK 21; the build runs on JDK 17. +sdk=34