Skip to content

Commit

Permalink
Use Plausible for app analytics (#929)
Browse files Browse the repository at this point in the history
* Remove datadog

* Update CI

* update Makefile

* update Makefile

* remove datadog directory

* Use Sentry for crash reporting (#927)

* update Makefile

* Add back Sentry for crash reporting

* remove datadog from gradle config

* Add plausible

* integrate plausible analytics

* integrate plausible analytics

* track when ads are shown

* track when ads are shown

* add Plausible class

* track when Replica content is viewed

* track searches and uploads

* Formatting

* Track when ads fail to load as well
  • Loading branch information
atavism authored Oct 24, 2023
1 parent ef229ac commit 78da9aa
Show file tree
Hide file tree
Showing 20 changed files with 484 additions and 4 deletions.
2 changes: 0 additions & 2 deletions android/app/build.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -428,8 +428,6 @@ dependencies {

implementation 'com.stripe:stripe-android:20.17.0'

implementation 'com.datadoghq:dd-sdk-android:1.19.3'

annotationProcessor "org.androidannotations:androidannotations:$androidAnnotationsVersion"
implementation("org.androidannotations:androidannotations-api:$androidAnnotationsVersion")
kapt "org.androidannotations:androidannotations:$androidAnnotationsVersion"
Expand Down
10 changes: 10 additions & 0 deletions android/app/src/main/AndroidManifest.xml
Original file line number Diff line number Diff line change
Expand Up @@ -105,6 +105,16 @@
android:resource="@xml/file_paths" />
</provider>

<provider
android:name="androidx.startup.InitializationProvider"
android:authorities="${applicationId}.androidx-startup"
android:exported="false"
tools:node="merge">
<meta-data
android:name="org.getlantern.lantern.plausible.PlausibleInitializer"
android:value="androidx.startup" />
</provider>

<service android:name="org.getlantern.lantern.vpn.LanternVpnService"
android:exported="false"
android:permission="android.permission.BIND_VPN_SERVICE">
Expand Down
5 changes: 5 additions & 0 deletions android/app/src/main/kotlin/io/lantern/model/SessionModel.kt
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@ import org.getlantern.lantern.model.LanternHttpClient.ProUserCallback
import org.getlantern.lantern.model.ProError
import org.getlantern.lantern.model.ProUser
import org.getlantern.lantern.model.Utils
import org.getlantern.lantern.plausible.Plausible
import org.getlantern.lantern.util.AutoUpdater
import org.getlantern.lantern.util.PaymentsUtil
import org.getlantern.lantern.util.PermissionUtil
Expand Down Expand Up @@ -181,6 +182,10 @@ class SessionModel(
}
}

"trackUserAction" -> {
Plausible.event(call.argument("message")!!)
}

"acceptTerms" -> {
LanternApp.getSession().acceptTerms()
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,7 @@ import org.getlantern.lantern.model.Utils
import org.getlantern.lantern.model.VpnState
import org.getlantern.lantern.notification.NotificationHelper
import org.getlantern.lantern.notification.NotificationReceiver
import org.getlantern.lantern.plausible.Plausible
import org.getlantern.lantern.service.LanternService_
import org.getlantern.lantern.util.PermissionUtil
import org.getlantern.lantern.util.PlansUtil
Expand Down Expand Up @@ -90,6 +91,7 @@ class MainActivity :
eventManager = object : EventManager("lantern_event_channel", flutterEngine) {
override fun onListen(event: Event) {
if (LanternApp.getSession().lanternDidStart()) {
Plausible.enable(true)
fetchLoConf()
Logger.debug(
TAG,
Expand Down Expand Up @@ -129,7 +131,6 @@ class MainActivity :
override fun onCreate(savedInstanceState: Bundle?) {
val start = System.currentTimeMillis()
super.onCreate(savedInstanceState)

Logger.debug(TAG, "Default Locale is %1\$s", Locale.getDefault())
if (!EventBus.getDefault().isRegistered(this)) {
EventBus.getDefault().register(this)
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
package org.getlantern.lantern.plausible

import org.getlantern.lantern.util.Json

internal data class Event(
val domain: String,
val name: String,
val url: String,
val referrer: String,
val screenWidth: Int,
val props: Map<String, String>?
) {
companion object {
fun fromJson(json: String): Event? = try {
Json.gson.fromJson(json, Event::class.java)
} catch (ignored: Exception) {
null
}
}
}

internal fun Event.toJson(): String = Json.gson.toJson(this)
Original file line number Diff line number Diff line change
@@ -0,0 +1,107 @@
package org.getlantern.lantern.plausible

import android.content.Context
import java.util.concurrent.atomic.AtomicReference
import org.getlantern.mobilesdk.Logger

// Singleton for sending events to Plausible.
object Plausible {
private val client: AtomicReference<PlausibleClient?> = AtomicReference(null)
private val config: AtomicReference<PlausibleConfig?> = AtomicReference(null)

fun init(context: Context) {
val config = AndroidResourcePlausibleConfig(context)
val client = NetworkFirstPlausibleClient(config)
init(client, config)
}

internal fun init(client: PlausibleClient, config: PlausibleConfig) {
this.client.set(client)
this.config.set(config)
}

// Enable or disable event sending
@Suppress("unused")
fun enable(enable: Boolean) {
config.get()
?.let {
it.enable = enable
}
?: Logger.d("Plausible", "Ignoring call to enable(). Did you forget to call Plausible.init()?")
}

/**
* The raw value of User-Agent is used to calculate the user_id which identifies a unique
* visitor in Plausible.
* User-Agent is also used to populate the Devices report in your
* Plausible dashboard. The device data is derived from the open source database
* device-detector. If your User-Agent is not showing up in your dashboard, it's probably
* because it is not recognized as one in the device-detector database.
*/
@Suppress("unused")
fun setUserAgent(userAgent: String) {
config.get()
?.let {
it.userAgent = userAgent
}
?: Logger.d("Plausible", "Ignoring call to setUserAgent(). Did you forget to call Plausible.init()?")
}

/**
* Send a `pageview` event.
*
* @param url URL of the page where the event was triggered. If the URL contains UTM parameters,
* they will be extracted and stored.
* The URL parameter will feel strange in a mobile app but you can manufacture something that looks
* like a web URL. If you name your mobile app screens like page URLs, Plausible will know how to
* handle it. So for example, on your login screen you could send something like
* `app://localhost/login`. The pathname (/login) is what will be shown as the page value in the
* Plausible dashboard.
* @param referrer Referrer for this event.
* Plausible uses the open source referer-parser database to parse referrers and assign these
*/
fun pageView(
url: String,
referrer: String = "",
props: Map<String, Any?>? = null
) = event(
name = "pageview",
url = url,
referrer = referrer,
props = props
)

/**
* Send a custom event. To send a `pageview` event, consider using [pageView] instead.
*
* @param name Name of the event. Can specify `pageview` which is a special type of event in
* Plausible. All other names will be treated as custom events.
* @param url URL of the page where the event was triggered. If the URL contains UTM parameters,
* they will be extracted and stored.
* The URL parameter will feel strange in a mobile app but you can manufacture something that looks
* like a web URL. If you name your mobile app screens like page URLs, Plausible will know how to
* handle it. So for example, on your login screen you could send something like
* `app://localhost/login`. The pathname (/login) is what will be shown as the page value in the
* Plausible dashboard.
* @param referrer Referrer for this event.
* Plausible uses the open source referer-parser database to parse referrers and assign these
* source categories.
*/
@Suppress("MemberVisibilityCanBePrivate")
fun event(
name: String,
url: String = "",
referrer: String = "",
props: Map<String, Any?>? = null
) {
client.get()
?.let { client ->
config.get()
?.let { config ->
client.event(config.domain, name, url, referrer, config.screenWidth, props)
}
?: Logger.d("Plausible", "Ignoring call to event(). Did you forget to call Plausible.init()?")
}
?: Logger.d("Plausible", "Ignoring call to event(). Did you forget to call Plausible.init()?")
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,179 @@
package org.getlantern.lantern.plausible

import android.net.Uri
import androidx.annotation.VisibleForTesting
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
import kotlinx.coroutines.suspendCancellableCoroutine
import okhttp3.Call
import okhttp3.Callback
import okhttp3.HttpUrl.Companion.toHttpUrl
import okhttp3.MediaType.Companion.toMediaType
import okhttp3.OkHttpClient
import okhttp3.Request
import okhttp3.RequestBody.Companion.toRequestBody
import okhttp3.Response
import java.io.File
import java.io.IOException
import java.net.InetSocketAddress
import java.net.Proxy
import java.net.URI
import kotlin.coroutines.CoroutineContext
import kotlin.coroutines.resume
import kotlin.coroutines.resumeWithException
import org.getlantern.lantern.LanternApp
import org.getlantern.mobilesdk.Logger

internal interface PlausibleClient {

// See [Plausible.event] for details on parameters.
// @return true if the event was successfully processed and false if not
fun event(
domain: String,
name: String,
url: String,
referrer: String,
screenWidth: Int,
props: Map<String, Any?>? = null
) {
var correctedUrl = Uri.parse(url)
if (correctedUrl.scheme.isNullOrBlank()) {
correctedUrl = correctedUrl.buildUpon().scheme("app").build()
}
if (correctedUrl.authority.isNullOrBlank()) {
correctedUrl = correctedUrl.buildUpon().authority("localhost").build()
}
return event(Event(
domain,
name,
correctedUrl.toString(),
referrer,
screenWidth,
props?.mapValues { (_, v) -> v.toString() }
))
}

fun event(event: Event)
}

// The primary client for sending events to Plausible. It will attempt to send events immediately,
// caching them to disk to send later upon failure.
internal class NetworkFirstPlausibleClient(
private val config: PlausibleConfig,
coroutineContext: CoroutineContext = Dispatchers.IO
) : PlausibleClient {
private val coroutineScope = CoroutineScope(coroutineContext)

init {
coroutineScope.launch {
config.eventDir.mkdirs()
config.eventDir.listFiles()?.forEach {
val event = Event.fromJson(it.readText())
if (event == null) {
Logger.e("Plausible", "Failed to decode event JSON, discarding")
it.delete()
return@forEach
}
try {
postEvent(event)
} catch (e: IOException) {
return@forEach
}
it.delete()
}
}
}

override fun event(event: Event) {
coroutineScope.launch {
suspendEvent(event)
}
}

@VisibleForTesting
internal suspend fun suspendEvent(event: Event) {
try {
postEvent(event)
} catch (e: IOException) {
if (!config.retryOnFailure) return
val file = File(config.eventDir, "event_${System.currentTimeMillis()}.json")
file.writeText(event.toJson())
var retryAttempts = 0
var retryDelay = 1000L
while (retryAttempts < 5) {
delay(retryDelay)
retryDelay = when (retryDelay) {
1000L -> 60_000L
60_000L -> 360_000L
360_000L -> 600_000L
else -> break
}
try {
postEvent(event)
file.delete()
break
} catch (e: IOException) {
retryAttempts++
}
}
}
}

private suspend fun postEvent(event: Event) {
if (!config.enable) {
Logger.e("Plausible", "Plausible disabled, not sending event: $event")
return
}
val body = event.toJson().toRequestBody("application/json".toMediaType())
val url = config.host
.toHttpUrl()
.newBuilder()
.addPathSegments("api/event")
.build()
val request = Request.Builder()
.url(url)
.addHeader("User-Agent", config.userAgent)
.post(body)
.build()
suspendCancellableCoroutine { continuation ->
val call = okHttpClient.newCall(request)
continuation.invokeOnCancellation {
call.cancel()
}

call.enqueue(object : Callback {
override fun onFailure(call: Call, e: IOException) {
Logger.e("Plausible", "Failed to send event to backend")
continuation.resumeWithException(e)
}

override fun onResponse(call: Call, response: Response) {
response.use { res ->
if (res.isSuccessful) {
continuation.resume(Unit)
} else {
val e = IOException(
"Received unexpected response: ${res.code} ${res.body?.string()}"
)
onFailure(call, e)
}
}
}
})
}
}

val okHttpClient: OkHttpClient by lazy {
val session = LanternApp.getSession()
val hTTPAddr = session.hTTPAddr
val uri = URI("http://" + hTTPAddr)
val proxy = Proxy(Proxy.Type.HTTP, InetSocketAddress(
"127.0.0.1",
uri.getPort(),
),
)
OkHttpClient.Builder().proxy(proxy).build()
}
}
Loading

0 comments on commit 78da9aa

Please sign in to comment.