diff --git a/build.gradle b/build.gradle index 865c0621..ba01cd11 100644 --- a/build.gradle +++ b/build.gradle @@ -29,8 +29,12 @@ buildConfig { packageName = 'app' buildConfigField 'int', 'VERSION_CODE', '1' buildConfigField 'String', 'VERSION', '0.0.1' - buildConfigField "String", "PROFILE_URL", "https://sourcerer.io/" - buildConfigField "String", "API_BASE_URL", "http://localhost:3181" + buildConfigField 'String', 'PROFILE_URL', 'https://sourcerer.io/' + buildConfigField 'String', 'API_BASE_URL', 'http://localhost:3181' + buildConfigField 'String', 'GA_BASE_PATH', 'http://www.google-analytics.com' + buildConfigField 'String', 'GA_TRACKING_ID', 'UA-107129190-2' + buildConfigField 'boolean', 'IS_GA_ENABLED', 'true' + buildConfig } junitPlatform { diff --git a/src/main/kotlin/app/Analytics.kt b/src/main/kotlin/app/Analytics.kt new file mode 100644 index 00000000..ecbe740e --- /dev/null +++ b/src/main/kotlin/app/Analytics.kt @@ -0,0 +1,145 @@ +// Copyright 2017 Sourcerer Inc. All Rights Reserved. +// Author: Anatoly Kislov (anatoly@sourcerer.io) + +package app + +import com.github.kittinunf.fuel.core.FuelError +import com.github.kittinunf.fuel.core.FuelManager +import com.github.kittinunf.fuel.core.Method +import com.github.kittinunf.fuel.core.Request +import com.google.protobuf.InvalidProtocolBufferException +import java.security.InvalidParameterException + +typealias Param = Pair + +/** + * Google Analytics events tracking. + */ +object Analytics { + private val IS_ENABLED = BuildConfig.IS_GA_ENABLED + private val BASE_PATH = BuildConfig.GA_BASE_PATH + private val BASE_URL = "/virtual/app/" + private val PROTOCOL_VERSION = "1" + private val TRACKING_ID = BuildConfig.GA_TRACKING_ID + private val DATA_SOURCE = "app" + + private val HIT_PAGEVIEW = "pageview" + private val HIT_EXCEPTION = "exception" + + private val fuelManager = FuelManager() + + var uuid: String = "" // Should be set on start of the app. + var username: String = "" // Should be set on successful authorization. + + init { + fuelManager.basePath = BASE_PATH + } + + private fun post(params: List): Request { + return fuelManager.request(Method.POST, "/collect", params) + } + + /** + * Google Analytics Measurement Protocol is used to track events. + * User iteration data is sent to GA endpoint via POST request. + * Events (or hits) mapped to virtual urls with "Data Source" parameter. + * Used parameters: + * - v: Protocol Version (Required) + * - tid: Tracking ID - used to specify GA account (Required) + * - cid: Client ID - anonymous client id (UUID type 4) + * - uid: User ID - username + * - t: Hit Type - type of event + * - dp: Document Path - virtual url + */ + private fun trackEvent(event: String, params: List = listOf()) { + if (!IS_ENABLED || (username.isEmpty() && uuid.isEmpty())) { + return + } + + val idParams = mutableListOf() + if (uuid.isNotEmpty()) { + idParams.add("cid" to uuid) + } + if (username.isNotEmpty()) { + idParams.add("uid" to username) + } + + val defaultParams = listOf("v" to PROTOCOL_VERSION, + "tid" to TRACKING_ID, + "ds" to DATA_SOURCE, + "t" to HIT_PAGEVIEW, + "dp" to BASE_URL + event) + + try { + // Send event to GA with united params. + post(params + defaultParams.filter { !params.contains(it) } + + idParams).responseString() + } catch (e: Throwable) { + Logger.error("Error while sending error report", e, logOnly = true) + } + } + + fun trackStart() { + trackEvent("start") + } + + fun trackAuth() { + trackEvent("auth") + } + + fun trackConfigSetup() { + trackEvent("config/setup") + } + + fun trackConfigChanged() { + trackEvent("config/changed") + } + + fun trackHashingRepoSuccess() { + trackEvent("hashing/repo/success") + } + + fun trackHashingSuccess() { + trackEvent("hashing/success") + } + + fun trackError(e: Throwable? = null, code: String = "") { + val url = if (e != null) getErrorUrl(e) else code + val separator = if (url.isNotEmpty()) "/" else "" + trackEvent("error" + separator + url, listOf("t" to HIT_EXCEPTION)) + } + + fun trackExit() { + trackEvent("exit") + } + + private fun getErrorUrl(e: Throwable): String { + // Mapping for request exceptions. + when (e) { + is FuelError -> return "request" + is InvalidParameterException -> return "request/parsing" + is InvalidProtocolBufferException -> return "request/parsing" + } + + // Get concrete class of exception name removing all common parts. + val name = e.javaClass.simpleName.replace("Exception", "") + .replace("Error", "") + .replace("Throwable", "") + + if (name.length == 0 || name.length == 1) { + return name + } + + // Divide CamelCased words in class name by dashes. + val nameCapitalized = name.toUpperCase() + var url = name[0].toString() + for (i in 1..name.length - 1) { + if (name[i] == nameCapitalized[i]) { + url += "-" + } + url += name[i] + } + + return url.toLowerCase() + } +} diff --git a/src/main/kotlin/app/Logger.kt b/src/main/kotlin/app/Logger.kt index a8bf6cd8..0b2d913f 100644 --- a/src/main/kotlin/app/Logger.kt +++ b/src/main/kotlin/app/Logger.kt @@ -34,19 +34,19 @@ object Logger { /** * Log error message with exception info. + * + * @property message the message for user and logs. + * @property e the exception if presented. + * @property code the code of error if exception is not presented. */ - fun error(message: String) { + fun error(message: String, e: Throwable? = null, code: String = "", + logOnly: Boolean = false) { if (LEVEL >= ERROR) { - println("[e] $message.") + println("[e] $message" + if (e != null) ": $e" else "") } - } - - /** - * Log error message with exception info. - */ - fun error(message: String, e: Throwable) { - if (LEVEL >= ERROR) { - println("[e] $message: $e") + if (!logOnly) { + Analytics.trackError(e = e, code = code) + //TODO(anatoly): Add error tracking software. } } diff --git a/src/main/kotlin/app/Main.kt b/src/main/kotlin/app/Main.kt index 88122558..72be7309 100644 --- a/src/main/kotlin/app/Main.kt +++ b/src/main/kotlin/app/Main.kt @@ -19,6 +19,9 @@ import com.beust.jcommander.JCommander import com.beust.jcommander.MissingCommandException fun main(argv : Array) { + Thread.setDefaultUncaughtExceptionHandler { _, e: Throwable? -> + Logger.error("Uncaught exception", e) + } Main(argv) } @@ -27,6 +30,9 @@ class Main(argv: Array) { private val api = ServerApi(configurator) init { + Analytics.uuid = configurator.getUuidPersistent() + Analytics.trackStart() + val options = Options() val commandAdd = CommandAdd() val commandConfig = CommandConfig() @@ -58,8 +64,13 @@ class Main(argv: Array) { else -> startUi() } } catch (e: MissingCommandException) { - Logger.error("No such command: ${e.unknownCommand}") + Logger.error( + message = "No such command: ${e.unknownCommand}", + code = "no-command" + ) } + + Analytics.trackExit() } private fun startUi() { @@ -74,8 +85,11 @@ class Main(argv: Array) { configurator.addLocalRepoPersistent(localRepo) configurator.saveToFile() println("Added git repository at $path.") + + Analytics.trackConfigChanged() } else { - Logger.error("No valid git repository found at $path.") + Logger.error(message = "No valid git repository found at $path.", + code = "repo-invalid") } } @@ -83,7 +97,8 @@ class Main(argv: Array) { val (key, value) = commandOptions.pair if (!arrayListOf("username", "password").contains(key)) { - Logger.error("No such key $key") + Logger.error(message = "No such key $key", + code = "invalid-params") return } @@ -93,6 +108,8 @@ class Main(argv: Array) { } configurator.saveToFile() + + Analytics.trackConfigChanged() } private fun doList() { @@ -108,6 +125,8 @@ class Main(argv: Array) { configurator.removeLocalRepoPersistent(LocalRepo(path)) configurator.saveToFile() println("Repository removed from tracking list.") + + Analytics.trackConfigChanged() } else { println("Repository not found in tracking list.") } diff --git a/src/main/kotlin/app/api/ServerApi.kt b/src/main/kotlin/app/api/ServerApi.kt index a375b046..58312f1d 100644 --- a/src/main/kotlin/app/api/ServerApi.kt +++ b/src/main/kotlin/app/api/ServerApi.kt @@ -13,8 +13,8 @@ import app.model.FactGroup import app.model.Repo import app.model.User import app.utils.RequestException -import com.github.kittinunf.fuel.Fuel import com.github.kittinunf.fuel.core.FuelManager +import com.github.kittinunf.fuel.core.Method import com.github.kittinunf.fuel.core.Request import com.github.kittinunf.fuel.core.Response import com.google.protobuf.InvalidProtocolBufferException @@ -30,6 +30,7 @@ class ServerApi (private val configurator: Configurator) : Api { private val KEY_TOKEN = "Token=" } + private val fuelManager = FuelManager() private var token = "" private fun cookieRequestInterceptor() = { req: Request -> @@ -50,7 +51,6 @@ class ServerApi (private val configurator: Configurator) : Api { } init { - val fuelManager = FuelManager.instance fuelManager.basePath = BuildConfig.API_BASE_URL fuelManager.addRequestInterceptor { cookieRequestInterceptor() } fuelManager.addResponseInterceptor { cookieResponseInterceptor() } @@ -62,38 +62,49 @@ class ServerApi (private val configurator: Configurator) : Api { private val password get() = configurator.getPassword() + private fun post(path: String): Request { + return fuelManager.request(Method.POST, path) + } + + private fun get(path: String): Request { + return fuelManager.request(Method.GET, path) + } + + private fun delete(path: String): Request { + return fuelManager.request(Method.DELETE, path) + } + private fun createRequestGetToken(): Request { - return Fuel.post("/auth").authenticate(username, password) + return post("/auth").authenticate(username, password) .header(getVersionCodeHeader()) } private fun createRequestGetUser(): Request { - return Fuel.get("/user") + return get("/user") } private fun createRequestGetRepo(repoRehash: String): Request { - return Fuel.get("/repo/$repoRehash") + return get("/repo/$repoRehash") } private fun createRequestPostRepo(repo: Repo): Request { - return Fuel.post("/repo").header(getContentTypeHeader()) - .body(repo.serialize()) + return post("/repo").header(getContentTypeHeader()) + .body(repo.serialize()) } private fun createRequestPostCommits(commits: CommitGroup): Request { - return Fuel.post("/commits").header(getContentTypeHeader()) - .body(commits.serialize()) + return post("/commits").header(getContentTypeHeader()) + .body(commits.serialize()) } private fun createRequestDeleteCommits(commits: CommitGroup): Request { - return Fuel.delete("/commits").header(getContentTypeHeader()) - .body(commits.serialize()) + return delete("/commits").header(getContentTypeHeader()) + .body(commits.serialize()) } - private fun createRequestPostFacts(facts: FactGroup): - Request { - return Fuel.post("/facts").header(getContentTypeHeader()) - .body(facts.serialize()) + private fun createRequestPostFacts(facts: FactGroup): Request { + return post("/facts").header(getContentTypeHeader()) + .body(facts.serialize()) } private fun makeRequest(request: Request, @@ -102,13 +113,13 @@ class ServerApi (private val configurator: Configurator) : Api { try { Logger.debug("Request $requestName initialized") val (_, res, result) = request.responseString() - val (_, error) = result - if (error == null) { + val (_, e) = result + if (e == null) { Logger.debug("Request $requestName success") return parser(res.data) } else { - Logger.error("Request $requestName error", error) - throw RequestException(error) + Logger.error("Request $requestName error", e) + throw RequestException(e) } } catch (e: InvalidProtocolBufferException) { Logger.error("Request $requestName error while parsing", e) diff --git a/src/main/kotlin/app/config/Config.kt b/src/main/kotlin/app/config/Config.kt index cf1cabca..98b4fa51 100644 --- a/src/main/kotlin/app/config/Config.kt +++ b/src/main/kotlin/app/config/Config.kt @@ -10,6 +10,7 @@ import app.utils.Options * Config data class. */ class Config ( + var uuid: String = "", var username: String = "", var password: String = "", var localRepos: MutableSet = mutableSetOf() diff --git a/src/main/kotlin/app/config/Configurator.kt b/src/main/kotlin/app/config/Configurator.kt index a54033ac..0cab1036 100644 --- a/src/main/kotlin/app/config/Configurator.kt +++ b/src/main/kotlin/app/config/Configurator.kt @@ -16,6 +16,7 @@ interface Configurator { fun getRepos(): List fun setUsernameCurrent(username: String) fun setPasswordCurrent(password: String) + fun getUuidPersistent(): String fun setUsernamePersistent(username: String) fun setPasswordPersistent(password: String) fun addLocalRepoPersistent(localRepo: LocalRepo) diff --git a/src/main/kotlin/app/config/FileConfigurator.kt b/src/main/kotlin/app/config/FileConfigurator.kt index 684873d6..117e9b22 100644 --- a/src/main/kotlin/app/config/FileConfigurator.kt +++ b/src/main/kotlin/app/config/FileConfigurator.kt @@ -21,6 +21,7 @@ import java.nio.file.Files import java.nio.file.InvalidPathException import java.nio.file.NoSuchFileException import java.nio.file.Paths +import java.util.UUID /** * Singleton class that manage configs and CLI options. @@ -85,6 +86,17 @@ class FileConfigurator : Configurator { */ init { loadFromFile() + assignUuidIfMissing() + } + + /** + * Generates UUID for analytics on install. + */ + private fun assignUuidIfMissing() { + if (persistent.uuid.isNotEmpty()) { + return + } + persistent.uuid = UUID.randomUUID().toString() } /** @@ -152,6 +164,13 @@ class FileConfigurator : Configurator { current.password = PasswordHelper.hashPassword(password) } + /** + * Gets UUID. + */ + override fun getUuidPersistent(): String { + return persistent.uuid + } + /** * Sets username to persistent config. Use [saveToFile] to save. */ diff --git a/src/main/kotlin/app/config/MockConfigurator.kt b/src/main/kotlin/app/config/MockConfigurator.kt index 521401bb..1b482c23 100644 --- a/src/main/kotlin/app/config/MockConfigurator.kt +++ b/src/main/kotlin/app/config/MockConfigurator.kt @@ -13,7 +13,8 @@ class MockConfigurator(var mockUsername: String = "", var mockIsFirstLaunch: Boolean = true, var mockRepos: MutableList = mutableListOf(), var mockLocalRepos: MutableList = - mutableListOf()) : Configurator { + mutableListOf(), + var uuid: String = "") : Configurator { var mockCurrent: Config = Config() var mockPersistent: Config = Config() var mockOptions: Options = Options() @@ -50,6 +51,10 @@ class MockConfigurator(var mockUsername: String = "", mockCurrent.password = password } + override fun getUuidPersistent(): String { + return uuid + } + override fun setUsernamePersistent(username: String) { mockPersistent.username = username } diff --git a/src/main/kotlin/app/hashers/RepoHasher.kt b/src/main/kotlin/app/hashers/RepoHasher.kt index 240f1cf0..7004013c 100644 --- a/src/main/kotlin/app/hashers/RepoHasher.kt +++ b/src/main/kotlin/app/hashers/RepoHasher.kt @@ -3,6 +3,7 @@ package app.hashers +import app.Analytics import app.Logger import app.api.Api import app.config.Configurator @@ -53,7 +54,7 @@ class RepoHasher(private val localRepo: LocalRepo, private val api: Api, val errors = mutableListOf() val onError: (Throwable) -> Unit = { e -> errors.add(e) - Logger.error("Error while hashing:", e) + Logger.error("Hashing error", e) } // Hash by all plugins. @@ -82,6 +83,7 @@ class RepoHasher(private val localRepo: LocalRepo, private val api: Api, } println("Hashing $localRepo successfully finished.") + Analytics.trackHashingRepoSuccess() } finally { closeGit(git) diff --git a/src/main/kotlin/app/ui/AddRepoState.kt b/src/main/kotlin/app/ui/AddRepoState.kt index bd89671f..e36156db 100644 --- a/src/main/kotlin/app/ui/AddRepoState.kt +++ b/src/main/kotlin/app/ui/AddRepoState.kt @@ -3,6 +3,7 @@ package app.ui +import app.Analytics import app.api.Api import app.config.Configurator import app.model.LocalRepo @@ -44,6 +45,8 @@ class AddRepoState constructor(private val context: Context, } } } + + Analytics.trackConfigSetup() } override fun next() { diff --git a/src/main/kotlin/app/ui/AuthState.kt b/src/main/kotlin/app/ui/AuthState.kt index a68f2ccf..627b63e1 100644 --- a/src/main/kotlin/app/ui/AuthState.kt +++ b/src/main/kotlin/app/ui/AuthState.kt @@ -3,6 +3,7 @@ package app.ui +import app.Analytics import app.BuildConfig import app.api.Api import app.config.Configurator @@ -74,6 +75,10 @@ class AuthState constructor(private val context: Context, println("You are successfully authenticated. Your profile page is " + BuildConfig.PROFILE_URL + configurator.getUsername()) saveCredentialsIfChanged() + + Analytics.username = configurator.getUsername() + Analytics.trackAuth() + return true } catch (e: RequestException) { if (e.isAuthError) { diff --git a/src/main/kotlin/app/ui/UpdateRepoState.kt b/src/main/kotlin/app/ui/UpdateRepoState.kt index 159da0c1..925a4c23 100644 --- a/src/main/kotlin/app/ui/UpdateRepoState.kt +++ b/src/main/kotlin/app/ui/UpdateRepoState.kt @@ -3,6 +3,7 @@ package app.ui +import app.Analytics import app.hashers.RepoHasher import app.Logger import app.api.Api @@ -33,6 +34,8 @@ class UpdateRepoState constructor(private val context: Context, } println("The repositories have been hashed. See result online on your " + "Sourcerer profile.") + + Analytics.trackHashingSuccess() } override fun next() { diff --git a/src/main/kotlin/app/utils/RepoHelper.kt b/src/main/kotlin/app/utils/RepoHelper.kt index ff87f00b..2cb5f3de 100644 --- a/src/main/kotlin/app/utils/RepoHelper.kt +++ b/src/main/kotlin/app/utils/RepoHelper.kt @@ -32,7 +32,7 @@ object RepoHelper { repository = git.repository commitId = repository.resolve(MASTER_BRANCH) } catch (e: Exception) { - Logger.error("Cannot access repository at path $path", e) + Logger.error("Cannot access repository at path $path", e) return false } finally { repository?.close() @@ -42,7 +42,6 @@ object RepoHelper { if (commitId != null) { return true } - Logger.error("Repository at path $path is empty") return false }