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

[POC] Error reporting via Sentry #8584

Draft
wants to merge 16 commits into
base: master
Choose a base branch
from
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
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
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,7 @@ internal class DefaultErrorReporter(
private val config: Config = EmptyConfig,
private val workContext: CoroutineContext = Dispatchers.IO,
private val logger: Logger = Logger.Noop,
private val sentryConfig: SentryConfig = DefaultSentryConfig,
private val sentryConfig: SentryConfig,
private val environment: String = BuildConfig.BUILD_TYPE,
private val localeCountry: String = Locale.getDefault().country,
private val osVersion: Int = Build.VERSION.SDK_INT
Expand Down

This file was deleted.

Original file line number Diff line number Diff line change
Expand Up @@ -5,9 +5,5 @@ interface SentryConfig {

val key: String

val secret: String

val version: String

fun getTimestamp(): String
}
Original file line number Diff line number Diff line change
Expand Up @@ -97,6 +97,7 @@ class DefaultErrorReporterTest {
get() = fail("Exception while creating tags.")
},
workContext = testDispatcher,
sentryConfig = FakeSentryConfig(),
logger = logger
)

Expand All @@ -110,9 +111,6 @@ class DefaultErrorReporterTest {
private class FakeSentryConfig : SentryConfig {
override val projectId: String = "123"
override val key: String = "abc"
override val secret: String = "def"
override val version: String = "7"

override fun getTimestamp(): String = "1600103536.978037"
}
}
4 changes: 4 additions & 0 deletions financial-connections/build.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -81,6 +81,10 @@ dependencies {
}

android {
defaultConfig {
buildConfigField "String", "FC_SENTRY_KEY", FC_SENTRY_KEY
}

buildFeatures {
compose true
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,9 @@ import android.app.Application
import androidx.lifecycle.SavedStateHandle
import com.stripe.android.core.ApiVersion
import com.stripe.android.core.Logger
import com.stripe.android.core.error.ErrorReporter
import com.stripe.android.core.error.SentryEventReporter
import com.stripe.android.core.error.SentryRequestExecutor
import com.stripe.android.core.frauddetection.FraudDetectionDataRepository
import com.stripe.android.core.networking.ApiRequest
import com.stripe.android.core.networking.StripeNetworkClient
Expand All @@ -16,6 +19,7 @@ import com.stripe.android.financialconnections.domain.IsLinkWithStripe
import com.stripe.android.financialconnections.domain.RealAttachConsumerToLinkAccountSession
import com.stripe.android.financialconnections.domain.RealCreateInstantDebitsResult
import com.stripe.android.financialconnections.domain.RealHandleError
import com.stripe.android.financialconnections.error.FinancialConnectionsSentryConfig
import com.stripe.android.financialconnections.features.networkinglinksignup.LinkSignupHandler
import com.stripe.android.financialconnections.features.networkinglinksignup.LinkSignupHandlerForInstantDebits
import com.stripe.android.financialconnections.features.networkinglinksignup.LinkSignupHandlerForNetworking
Expand Down Expand Up @@ -138,6 +142,19 @@ internal interface FinancialConnectionsSheetNativeModule {
fraudDetectionDataRepository = fraudDetectionDataRepository,
)

@Singleton
@Provides
fun provideErrorReporter(
context: Application,
executor: SentryRequestExecutor,
): ErrorReporter {
return SentryEventReporter(
context = context,
sentryConfig = FinancialConnectionsSentryConfig,
requestExecutor = executor
)
}

@Singleton
@Provides
fun providesFinancialConnectionsAccountsRepository(
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
package com.stripe.android.financialconnections.error

import com.stripe.android.core.error.SentryConfig
import com.stripe.android.financialconnections.BuildConfig

internal object FinancialConnectionsSentryConfig : SentryConfig {
override val projectId: String = "826"
override val key: String = BuildConfig.FC_SENTRY_KEY
override val version: String = "7"
}
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import androidx.lifecycle.viewModelScope
import androidx.lifecycle.viewmodel.initializer
import androidx.lifecycle.viewmodel.viewModelFactory
import com.stripe.android.core.Logger
import com.stripe.android.core.error.ErrorReporter
import com.stripe.android.financialconnections.FinancialConnections
import com.stripe.android.financialconnections.analytics.FinancialConnectionsAnalyticsEvent.ConsentAgree
import com.stripe.android.financialconnections.analytics.FinancialConnectionsAnalyticsEvent.PaneLoaded
Expand Down Expand Up @@ -43,6 +44,7 @@ import java.util.Date
internal class ConsentViewModel @AssistedInject constructor(
@Assisted initialState: ConsentState,
nativeAuthFlowCoordinator: NativeAuthFlowCoordinator,
private val errorReporter: ErrorReporter,
private val acceptConsent: AcceptConsent,
private val getOrFetchSync: GetOrFetchSync,
private val navigationManager: NavigationManager,
Expand Down Expand Up @@ -85,6 +87,7 @@ internal class ConsentViewModel @AssistedInject constructor(
onFail = { logger.error("Error retrieving consent content", it) }
)
onAsync(ConsentState::acceptConsent, onFail = {
errorReporter.reportError(it)
eventTracker.logError(
extraMessage = "Error accepting consent",
error = it,
Expand All @@ -96,6 +99,7 @@ internal class ConsentViewModel @AssistedInject constructor(

fun onContinueClick() {
suspend {
throw IllegalStateException("Envelopes + DSN: Testing error reporting on Android")
eventTracker.track(ConsentAgree)
val updatedManifest: FinancialConnectionsSessionManifest = acceptConsent()
FinancialConnections.emitEvent(Name.CONSENT_ACQUIRED)
Expand Down
1 change: 1 addition & 0 deletions gradle.properties
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ POM_DEVELOPER_ID=stripe
POM_DEVELOPER_NAME=Stripe
[email protected]


android.defaults.buildfeatures.buildconfig=true
android.enableJetifier=false
android.enableR8.fullMode=true
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
package com.stripe.android.core.error

/**
* An interface for sending error reports to Stripe.
*/
fun interface ErrorReporter {
fun reportError(t: Throwable)
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
package com.stripe.android.core.error

interface SentryConfig {
val projectId: String

val key: String

val version: String
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
package com.stripe.android.core.error

import com.stripe.android.core.networking.HTTP_TOO_MANY_REQUESTS
import com.stripe.android.core.networking.StripeRequest
import kotlinx.serialization.Serializable
import java.io.OutputStream

@Serializable
data class SentryEnvelopeRequest(
val envelopeBody: String,
val projectId: String,
override val headers: Map<String, String>,
) : StripeRequest() {

private val postBodyBytes: ByteArray
get() = envelopeBody.toByteArray(Charsets.UTF_8)

override fun writePostBody(outputStream: OutputStream) {
outputStream.write(postBodyBytes)
outputStream.flush()
}

override val method: Method = Method.POST

override val mimeType: MimeType = MimeType.Json

override val url: String = "$HOST/api/$projectId/envelope/"

// TODO@carlosmuvi Sentry responses return a delay period along with rate limit responses; handle it.
override val retryResponseCodes: Iterable<Int> = HTTP_TOO_MANY_REQUESTS..HTTP_TOO_MANY_REQUESTS

companion object {
private const val HOST = "https://errors.stripe.com"
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,164 @@
package com.stripe.android.core.error

import android.content.Context
import android.os.Build
import android.provider.Settings
import androidx.annotation.VisibleForTesting
import com.stripe.android.core.BuildConfig
import com.stripe.android.core.version.StripeSdkVersion.VERSION_NAME
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.GlobalScope
import kotlinx.coroutines.launch
import kotlinx.serialization.encodeToString
import kotlinx.serialization.json.Json
import java.util.Locale
import java.util.UUID.randomUUID
import java.util.concurrent.TimeUnit

/**
* An [ErrorReporter] that reports to Sentry.
*/
class SentryEventReporter(
context: Context,
private val sentryConfig: SentryConfig,
private val requestExecutor: SentryRequestExecutor,
private val environment: String = BuildConfig.BUILD_TYPE,
private val localeCountry: String = Locale.getDefault().country,
) : ErrorReporter {

val deviceId by lazy { Settings.Secure.getString(context.contentResolver, Settings.Secure.ANDROID_ID) }

private val appContext = context.applicationContext

override fun reportError(t: Throwable) {
GlobalScope.launch(Dispatchers.IO) {
val request = SentryEnvelopeRequest(
projectId = sentryConfig.projectId,
envelopeBody = createEnvelopeBody(t),
headers = buildHeaders()
)
requestExecutor.sendErrorRequest(request)
}
}

@VisibleForTesting
internal fun createEnvelopeBody(t: Throwable): String {
val eventId = generateEventId()
val envelopeHeader = SentryEnvelopeHeader(eventId)
val itemHeader = SentryItemHeader("event")
val itemPayload = createEventPayload(t, eventId)

return Json.encodeToString(envelopeHeader) + "\n" +
Json.encodeToString(itemHeader) + "\n" +
Json.encodeToString(itemPayload) + "\n"
}

private fun createEventPayload(
t: Throwable,
eventId: String
): SentryEvent {
return SentryEvent(
eventId = eventId,
timestamp = System.currentTimeMillis() / 1000.0,
platform = "android",
release = VERSION_NAME,
exception = SentryException(
values = listOf(
SentryExceptionValue(
type = t::class.java.canonicalName.orEmpty(),
value = t.message.orEmpty(),
stacktrace = createRequestStacktrace(t)
)
)
),
tags = mapOf(
"locale" to localeCountry,
"environment" to environment,
"android_os_version" to Build.VERSION.SDK_INT.toString()
),
contexts = createRequestContexts(),
user = SentryUser(
id = deviceId
)
)
}

private fun createRequestStacktrace(t: Throwable): SentryStacktrace {
return SentryStacktrace(
frames = t.stackTrace.reversed().map { el ->
SentryFrame(
lineno = el.lineNumber,
filename = el.className,
function = el.methodName
)
}
)
}

private fun buildHeaders(): Map<String, String> = mapOf(
HEADER_CONTENT_TYPE to CONTENT_TYPE,
HEADER_SENTRY_AUTH to createSentryAuthHeader()
)

private fun createRequestContexts(): SentryContexts {
val packageInfo = runCatching {
appContext.packageManager.getPackageInfo(appContext.packageName, 0)
}.getOrNull()

val appName = packageInfo?.applicationInfo?.loadLabel(appContext.packageManager).toString()

return SentryContexts(
app = SentryAppContext(
appIdentifier = appContext.packageName,
appName = appName,
appVersion = packageInfo?.versionName.orEmpty()
),
os = SentryOsContext(
name = "Android",
version = Build.VERSION.RELEASE,
type = Build.TYPE,
build = Build.DISPLAY
),
device = SentryDeviceContext(
modelId = Build.ID,
model = Build.MODEL,
manufacturer = Build.MANUFACTURER,
type = Build.TYPE,
archs = Build.SUPPORTED_ABIS.toList()
)
)
}

@JvmSynthetic
@VisibleForTesting
internal fun createSentryAuthHeader(): String {
return "Sentry " + listOf(
"sentry_key" to sentryConfig.key,
"sentry_version" to sentryConfig.version,
"sentry_client" to USER_AGENT
).joinToString(", ") { (key, value) -> "$key=$value" }
}

private fun generateEventId(): String {
return randomUUID().toString().replace("-", "")
}

/**
* If [System.currentTimeMillis] returns `1600285647423`, this method will return
* `"1600285647.423"`.
*/
fun getTimestamp(): String {
val timestamp = System.currentTimeMillis()
val seconds = TimeUnit.MILLISECONDS.toSeconds(timestamp)
val fraction = timestamp - TimeUnit.SECONDS.toMillis(seconds)
return "$seconds.$fraction"
}

private companion object {
private const val HEADER_CONTENT_TYPE = "Content-Type"
private const val CONTENT_TYPE = "application/x-sentry-envelope"
private const val USER_AGENT = "Stripe/v1 android/$VERSION_NAME"

private const val HEADER_SENTRY_AUTH = "X-Sentry-Auth"
}
}
Loading
Loading