Skip to content

Commit bd18eb5

Browse files
authored
feat: google analytics (APP-105) (#41)
* feat: google analytics * wip: pr * wip: error codes by virtual urls * feat: uncaught excetion handle, logs for analytics errors
1 parent 3c0b16b commit bd18eb5

File tree

14 files changed

+255
-38
lines changed

14 files changed

+255
-38
lines changed

build.gradle

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -29,8 +29,12 @@ buildConfig {
2929
packageName = 'app'
3030
buildConfigField 'int', 'VERSION_CODE', '1'
3131
buildConfigField 'String', 'VERSION', '0.0.1'
32-
buildConfigField "String", "PROFILE_URL", "https://sourcerer.io/"
33-
buildConfigField "String", "API_BASE_URL", "http://localhost:3181"
32+
buildConfigField 'String', 'PROFILE_URL', 'https://sourcerer.io/'
33+
buildConfigField 'String', 'API_BASE_URL', 'http://localhost:3181'
34+
buildConfigField 'String', 'GA_BASE_PATH', 'http://www.google-analytics.com'
35+
buildConfigField 'String', 'GA_TRACKING_ID', 'UA-107129190-2'
36+
buildConfigField 'boolean', 'IS_GA_ENABLED', 'true'
37+
buildConfig
3438
}
3539

3640
junitPlatform {

src/main/kotlin/app/Analytics.kt

Lines changed: 145 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,145 @@
1+
// Copyright 2017 Sourcerer Inc. All Rights Reserved.
2+
// Author: Anatoly Kislov ([email protected])
3+
4+
package app
5+
6+
import com.github.kittinunf.fuel.core.FuelError
7+
import com.github.kittinunf.fuel.core.FuelManager
8+
import com.github.kittinunf.fuel.core.Method
9+
import com.github.kittinunf.fuel.core.Request
10+
import com.google.protobuf.InvalidProtocolBufferException
11+
import java.security.InvalidParameterException
12+
13+
typealias Param = Pair<String, String>
14+
15+
/**
16+
* Google Analytics events tracking.
17+
*/
18+
object Analytics {
19+
private val IS_ENABLED = BuildConfig.IS_GA_ENABLED
20+
private val BASE_PATH = BuildConfig.GA_BASE_PATH
21+
private val BASE_URL = "/virtual/app/"
22+
private val PROTOCOL_VERSION = "1"
23+
private val TRACKING_ID = BuildConfig.GA_TRACKING_ID
24+
private val DATA_SOURCE = "app"
25+
26+
private val HIT_PAGEVIEW = "pageview"
27+
private val HIT_EXCEPTION = "exception"
28+
29+
private val fuelManager = FuelManager()
30+
31+
var uuid: String = "" // Should be set on start of the app.
32+
var username: String = "" // Should be set on successful authorization.
33+
34+
init {
35+
fuelManager.basePath = BASE_PATH
36+
}
37+
38+
private fun post(params: List<Param>): Request {
39+
return fuelManager.request(Method.POST, "/collect", params)
40+
}
41+
42+
/**
43+
* Google Analytics Measurement Protocol is used to track events.
44+
* User iteration data is sent to GA endpoint via POST request.
45+
* Events (or hits) mapped to virtual urls with "Data Source" parameter.
46+
* Used parameters:
47+
* - v: Protocol Version (Required)
48+
* - tid: Tracking ID - used to specify GA account (Required)
49+
* - cid: Client ID - anonymous client id (UUID type 4)
50+
* - uid: User ID - username
51+
* - t: Hit Type - type of event
52+
* - dp: Document Path - virtual url
53+
*/
54+
private fun trackEvent(event: String, params: List<Param> = listOf()) {
55+
if (!IS_ENABLED || (username.isEmpty() && uuid.isEmpty())) {
56+
return
57+
}
58+
59+
val idParams = mutableListOf<Param>()
60+
if (uuid.isNotEmpty()) {
61+
idParams.add("cid" to uuid)
62+
}
63+
if (username.isNotEmpty()) {
64+
idParams.add("uid" to username)
65+
}
66+
67+
val defaultParams = listOf("v" to PROTOCOL_VERSION,
68+
"tid" to TRACKING_ID,
69+
"ds" to DATA_SOURCE,
70+
"t" to HIT_PAGEVIEW,
71+
"dp" to BASE_URL + event)
72+
73+
try {
74+
// Send event to GA with united params.
75+
post(params + defaultParams.filter { !params.contains(it) }
76+
+ idParams).responseString()
77+
} catch (e: Throwable) {
78+
Logger.error("Error while sending error report", e, logOnly = true)
79+
}
80+
}
81+
82+
fun trackStart() {
83+
trackEvent("start")
84+
}
85+
86+
fun trackAuth() {
87+
trackEvent("auth")
88+
}
89+
90+
fun trackConfigSetup() {
91+
trackEvent("config/setup")
92+
}
93+
94+
fun trackConfigChanged() {
95+
trackEvent("config/changed")
96+
}
97+
98+
fun trackHashingRepoSuccess() {
99+
trackEvent("hashing/repo/success")
100+
}
101+
102+
fun trackHashingSuccess() {
103+
trackEvent("hashing/success")
104+
}
105+
106+
fun trackError(e: Throwable? = null, code: String = "") {
107+
val url = if (e != null) getErrorUrl(e) else code
108+
val separator = if (url.isNotEmpty()) "/" else ""
109+
trackEvent("error" + separator + url, listOf("t" to HIT_EXCEPTION))
110+
}
111+
112+
fun trackExit() {
113+
trackEvent("exit")
114+
}
115+
116+
private fun getErrorUrl(e: Throwable): String {
117+
// Mapping for request exceptions.
118+
when (e) {
119+
is FuelError -> return "request"
120+
is InvalidParameterException -> return "request/parsing"
121+
is InvalidProtocolBufferException -> return "request/parsing"
122+
}
123+
124+
// Get concrete class of exception name removing all common parts.
125+
val name = e.javaClass.simpleName.replace("Exception", "")
126+
.replace("Error", "")
127+
.replace("Throwable", "")
128+
129+
if (name.length == 0 || name.length == 1) {
130+
return name
131+
}
132+
133+
// Divide CamelCased words in class name by dashes.
134+
val nameCapitalized = name.toUpperCase()
135+
var url = name[0].toString()
136+
for (i in 1..name.length - 1) {
137+
if (name[i] == nameCapitalized[i]) {
138+
url += "-"
139+
}
140+
url += name[i]
141+
}
142+
143+
return url.toLowerCase()
144+
}
145+
}

src/main/kotlin/app/Logger.kt

Lines changed: 10 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -34,19 +34,19 @@ object Logger {
3434

3535
/**
3636
* Log error message with exception info.
37+
*
38+
* @property message the message for user and logs.
39+
* @property e the exception if presented.
40+
* @property code the code of error if exception is not presented.
3741
*/
38-
fun error(message: String) {
42+
fun error(message: String, e: Throwable? = null, code: String = "",
43+
logOnly: Boolean = false) {
3944
if (LEVEL >= ERROR) {
40-
println("[e] $message.")
45+
println("[e] $message" + if (e != null) ": $e" else "")
4146
}
42-
}
43-
44-
/**
45-
* Log error message with exception info.
46-
*/
47-
fun error(message: String, e: Throwable) {
48-
if (LEVEL >= ERROR) {
49-
println("[e] $message: $e")
47+
if (!logOnly) {
48+
Analytics.trackError(e = e, code = code)
49+
//TODO(anatoly): Add error tracking software.
5050
}
5151
}
5252

src/main/kotlin/app/Main.kt

Lines changed: 22 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,9 @@ import com.beust.jcommander.JCommander
1919
import com.beust.jcommander.MissingCommandException
2020

2121
fun main(argv : Array<String>) {
22+
Thread.setDefaultUncaughtExceptionHandler { _, e: Throwable? ->
23+
Logger.error("Uncaught exception", e)
24+
}
2225
Main(argv)
2326
}
2427

@@ -27,6 +30,9 @@ class Main(argv: Array<String>) {
2730
private val api = ServerApi(configurator)
2831

2932
init {
33+
Analytics.uuid = configurator.getUuidPersistent()
34+
Analytics.trackStart()
35+
3036
val options = Options()
3137
val commandAdd = CommandAdd()
3238
val commandConfig = CommandConfig()
@@ -58,8 +64,13 @@ class Main(argv: Array<String>) {
5864
else -> startUi()
5965
}
6066
} catch (e: MissingCommandException) {
61-
Logger.error("No such command: ${e.unknownCommand}")
67+
Logger.error(
68+
message = "No such command: ${e.unknownCommand}",
69+
code = "no-command"
70+
)
6271
}
72+
73+
Analytics.trackExit()
6374
}
6475

6576
private fun startUi() {
@@ -74,16 +85,20 @@ class Main(argv: Array<String>) {
7485
configurator.addLocalRepoPersistent(localRepo)
7586
configurator.saveToFile()
7687
println("Added git repository at $path.")
88+
89+
Analytics.trackConfigChanged()
7790
} else {
78-
Logger.error("No valid git repository found at $path.")
91+
Logger.error(message = "No valid git repository found at $path.",
92+
code = "repo-invalid")
7993
}
8094
}
8195

8296
private fun doConfig(commandOptions: CommandConfig) {
8397
val (key, value) = commandOptions.pair
8498

8599
if (!arrayListOf("username", "password").contains(key)) {
86-
Logger.error("No such key $key")
100+
Logger.error(message = "No such key $key",
101+
code = "invalid-params")
87102
return
88103
}
89104

@@ -93,6 +108,8 @@ class Main(argv: Array<String>) {
93108
}
94109

95110
configurator.saveToFile()
111+
112+
Analytics.trackConfigChanged()
96113
}
97114

98115
private fun doList() {
@@ -108,6 +125,8 @@ class Main(argv: Array<String>) {
108125
configurator.removeLocalRepoPersistent(LocalRepo(path))
109126
configurator.saveToFile()
110127
println("Repository removed from tracking list.")
128+
129+
Analytics.trackConfigChanged()
111130
} else {
112131
println("Repository not found in tracking list.")
113132
}

src/main/kotlin/app/api/ServerApi.kt

Lines changed: 30 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -13,8 +13,8 @@ import app.model.FactGroup
1313
import app.model.Repo
1414
import app.model.User
1515
import app.utils.RequestException
16-
import com.github.kittinunf.fuel.Fuel
1716
import com.github.kittinunf.fuel.core.FuelManager
17+
import com.github.kittinunf.fuel.core.Method
1818
import com.github.kittinunf.fuel.core.Request
1919
import com.github.kittinunf.fuel.core.Response
2020
import com.google.protobuf.InvalidProtocolBufferException
@@ -30,6 +30,7 @@ class ServerApi (private val configurator: Configurator) : Api {
3030
private val KEY_TOKEN = "Token="
3131
}
3232

33+
private val fuelManager = FuelManager()
3334
private var token = ""
3435

3536
private fun cookieRequestInterceptor() = { req: Request ->
@@ -50,7 +51,6 @@ class ServerApi (private val configurator: Configurator) : Api {
5051
}
5152

5253
init {
53-
val fuelManager = FuelManager.instance
5454
fuelManager.basePath = BuildConfig.API_BASE_URL
5555
fuelManager.addRequestInterceptor { cookieRequestInterceptor() }
5656
fuelManager.addResponseInterceptor { cookieResponseInterceptor() }
@@ -62,38 +62,49 @@ class ServerApi (private val configurator: Configurator) : Api {
6262
private val password
6363
get() = configurator.getPassword()
6464

65+
private fun post(path: String): Request {
66+
return fuelManager.request(Method.POST, path)
67+
}
68+
69+
private fun get(path: String): Request {
70+
return fuelManager.request(Method.GET, path)
71+
}
72+
73+
private fun delete(path: String): Request {
74+
return fuelManager.request(Method.DELETE, path)
75+
}
76+
6577
private fun createRequestGetToken(): Request {
66-
return Fuel.post("/auth").authenticate(username, password)
78+
return post("/auth").authenticate(username, password)
6779
.header(getVersionCodeHeader())
6880
}
6981

7082
private fun createRequestGetUser(): Request {
71-
return Fuel.get("/user")
83+
return get("/user")
7284
}
7385

7486
private fun createRequestGetRepo(repoRehash: String): Request {
75-
return Fuel.get("/repo/$repoRehash")
87+
return get("/repo/$repoRehash")
7688
}
7789

7890
private fun createRequestPostRepo(repo: Repo): Request {
79-
return Fuel.post("/repo").header(getContentTypeHeader())
80-
.body(repo.serialize())
91+
return post("/repo").header(getContentTypeHeader())
92+
.body(repo.serialize())
8193
}
8294

8395
private fun createRequestPostCommits(commits: CommitGroup): Request {
84-
return Fuel.post("/commits").header(getContentTypeHeader())
85-
.body(commits.serialize())
96+
return post("/commits").header(getContentTypeHeader())
97+
.body(commits.serialize())
8698
}
8799

88100
private fun createRequestDeleteCommits(commits: CommitGroup): Request {
89-
return Fuel.delete("/commits").header(getContentTypeHeader())
90-
.body(commits.serialize())
101+
return delete("/commits").header(getContentTypeHeader())
102+
.body(commits.serialize())
91103
}
92104

93-
private fun createRequestPostFacts(facts: FactGroup):
94-
Request {
95-
return Fuel.post("/facts").header(getContentTypeHeader())
96-
.body(facts.serialize())
105+
private fun createRequestPostFacts(facts: FactGroup): Request {
106+
return post("/facts").header(getContentTypeHeader())
107+
.body(facts.serialize())
97108
}
98109

99110
private fun <T> makeRequest(request: Request,
@@ -102,13 +113,13 @@ class ServerApi (private val configurator: Configurator) : Api {
102113
try {
103114
Logger.debug("Request $requestName initialized")
104115
val (_, res, result) = request.responseString()
105-
val (_, error) = result
106-
if (error == null) {
116+
val (_, e) = result
117+
if (e == null) {
107118
Logger.debug("Request $requestName success")
108119
return parser(res.data)
109120
} else {
110-
Logger.error("Request $requestName error", error)
111-
throw RequestException(error)
121+
Logger.error("Request $requestName error", e)
122+
throw RequestException(e)
112123
}
113124
} catch (e: InvalidProtocolBufferException) {
114125
Logger.error("Request $requestName error while parsing", e)

src/main/kotlin/app/config/Config.kt

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ import app.utils.Options
1010
* Config data class.
1111
*/
1212
class Config (
13+
var uuid: String = "",
1314
var username: String = "",
1415
var password: String = "",
1516
var localRepos: MutableSet<LocalRepo> = mutableSetOf()

src/main/kotlin/app/config/Configurator.kt

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ interface Configurator {
1616
fun getRepos(): List<Repo>
1717
fun setUsernameCurrent(username: String)
1818
fun setPasswordCurrent(password: String)
19+
fun getUuidPersistent(): String
1920
fun setUsernamePersistent(username: String)
2021
fun setPasswordPersistent(password: String)
2122
fun addLocalRepoPersistent(localRepo: LocalRepo)

0 commit comments

Comments
 (0)