Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Use Plausible for app analytics #929

Merged
merged 25 commits into from
Oct 24, 2023
Merged
Show file tree
Hide file tree
Changes from 24 commits
Commits
Show all changes
25 commits
Select commit Hold shift + click to select a range
05e9628
Remove datadog
atavism Oct 5, 2023
1cac832
Update CI
atavism Oct 5, 2023
609dce9
update Makefile
atavism Oct 5, 2023
01b6a92
update Makefile
atavism Oct 5, 2023
509a31e
remove datadog directory
atavism Oct 9, 2023
10e40fb
Merge remote-tracking branch 'origin/main' into atavism/remove-datadog
atavism Oct 9, 2023
5fce666
Use Sentry for crash reporting (#927)
atavism Oct 10, 2023
86c68e1
remove datadog from gradle config
atavism Oct 10, 2023
099f829
Merge branch 'atavism/remove-datadog' of github.com:getlantern/androi…
atavism Oct 10, 2023
771140d
Add plausible
atavism Oct 10, 2023
e177ec1
Merge branch 'atavism/remove-datadog' into atavism/plausible
atavism Oct 10, 2023
27e8f37
integrate plausible analytics
atavism Oct 10, 2023
91400cf
integrate plausible analytics
atavism Oct 10, 2023
bc2e97e
merge latest
atavism Oct 10, 2023
7d8a005
track when ads are shown
atavism Oct 10, 2023
62591e3
track when ads are shown
atavism Oct 10, 2023
304a2a9
add Plausible class
atavism Oct 10, 2023
3420730
Merge remote-tracking branch 'origin/main' into atavism/plausible
atavism Oct 10, 2023
58b5687
Merge remote-tracking branch 'origin/main' into atavism/plausible
atavism Oct 10, 2023
55fa800
track when Replica content is viewed
atavism Oct 16, 2023
aba062a
track searches and uploads
atavism Oct 16, 2023
21c7e5b
Formatting
atavism Oct 16, 2023
8cbed26
Merge remote-tracking branch 'origin/main' into atavism/plausible
atavism Oct 23, 2023
e166efd
Merge remote-tracking branch 'origin/main' into atavism/plausible
atavism Oct 23, 2023
d63603f
Track when ads fail to load as well
atavism Oct 24, 2023
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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
Loading