diff --git a/CLAUDE.md b/CLAUDE.md index 1df7e91b..546a7896 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -33,7 +33,7 @@ JDK 21. Gradle wrapper 사용(`./gradlew`). CI(`.github/workflows/`)는 `assembl ## 빌드에 필요한 로컬 설정 - `local.properties` 키 → `app/build.gradle.kts` 가 `BuildConfig`·manifestPlaceholder 로 주입한다: - `BASE_URL`(Retrofit base, `@BaseUrl`), `KAKAO_NATIVE_APP_KEY`, `KAKAO_REST_API_KEY`, `KAKAO_REDIRECT_URI`, `GOOGLE_CLIENT_ID`, `GOOGLE_REDIRECT_URI`(스킴 부분이 AppAuth redirect 로 쓰임), `AMPLITUDE_API_KEY`. + `BASE_URL`(Retrofit base, `@BaseUrl`), `KAKAO_NATIVE_APP_KEY`, `KAKAO_REST_API_KEY`, `KAKAO_REDIRECT_URI`, `GOOGLE_CLIENT_ID`, `GOOGLE_REDIRECT_URI`(스킴 부분이 AppAuth redirect 로 쓰임), `AMPLITUDE_API_KEY`, `TTI_SHEET_URL`(TTI 를 받는 구글 시트 Apps Script 웹앱). 값이 비면 빈 문자열로 주입될 뿐 빌드는 통과한다 — 런타임에 조용히 죽으므로 증상이 나오면 여기부터 본다. - `app/google-services.json` 은 커밋하지 않는다. 파일이 **있을 때만** google-services·Crashlytics 플러그인이 적용된다(`app/build.gradle.kts` 상단 조건부 apply). CI 는 시크릿에서 복원한다. - 버전은 전부 `gradle/libs.versions.toml` 버전 카탈로그. 동적 버전(`1.+`)은 쓰지 않는다. diff --git a/OBSERVABILITY.md b/OBSERVABILITY.md index 14e8bac1..d77dc106 100644 --- a/OBSERVABILITY.md +++ b/OBSERVABILITY.md @@ -33,6 +33,10 @@ Amplitude 와 Firebase 는 **병행**한다. 같은 이벤트가 두 곳에 쌓 Amplitude 키(`AMPLITUDE_API_KEY`)가 비면 **Amplitude 출구를 아예 달지 않는다.** Firebase 로는 계속 나간다. 키는 `local.properties` 에서 온다. +TTI 는 위 경로와 별도로 **구글 시트에도 한 행씩** 남는다(`SheetTtiShooter`, #623). 받는 쪽은 시트에 붙은 +Apps Script 웹앱이고, URL(`TTI_SHEET_URL`)이 비면 보내지 않는다. debug·release 모두 보내며 「빌드」 열로 +가른다. 전송 실패는 경고만 남기고 재시도하지 않는다 — 재시도하면 Firebase·Amplitude 에 같은 TTI 가 또 나간다. + ## 2. 모든 이벤트에 붙는 것 - `screen` — 이벤트가 난 시점의 화면 경로. `ScreenTracker` 가 네비게이션마다 갱신하고, 두 diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 362c5a8d..5048bb8c 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -27,6 +27,7 @@ val localProperties = val kakaoNativeAppKey: String = localProperties.getProperty("KAKAO_NATIVE_APP_KEY")?.trim().orEmpty() val baseUrl: String = localProperties.getProperty("BASE_URL")?.trim().orEmpty() val amplitudeApiKey: String = localProperties.getProperty("AMPLITUDE_API_KEY")?.trim().orEmpty() +val ttiSheetUrl: String = localProperties.getProperty("TTI_SHEET_URL")?.trim().orEmpty() val appAuthRedirectScheme: String = localProperties .getProperty("GOOGLE_REDIRECT_URI") @@ -85,6 +86,8 @@ android { buildConfigField("String", "BASE_URL", "\"$baseUrl\"") // Amplitude 수집 키 buildConfigField("String", "AMPLITUDE_API_KEY", "\"$amplitudeApiKey\"") + // TTI 를 받는 구글 시트 Apps Script 웹앱 URL. 비면 보내지 않는다(#623) + buildConfigField("String", "TTI_SHEET_URL", "\"$ttiSheetUrl\"") } signingConfigs { @@ -142,6 +145,7 @@ dependencies { implementation(project(":core:designsystem")) implementation(project(":core:ui")) implementation(project(":core:network")) + implementation(libs.okhttp) implementation(project(":core:datastore")) implementation(project(":observability:data")) implementation(project(":logging:data")) diff --git a/app/src/main/java/com/ruleup/android_ruleup/tti/SheetTtiShooter.kt b/app/src/main/java/com/ruleup/android_ruleup/tti/SheetTtiShooter.kt new file mode 100644 index 00000000..fb72f043 --- /dev/null +++ b/app/src/main/java/com/ruleup/android_ruleup/tti/SheetTtiShooter.kt @@ -0,0 +1,51 @@ +package com.ruleup.android_ruleup.tti + +import com.ruleup.domain.device.DeviceInfoProvider +import com.ruleup.observability.domain.api.Observability +import com.ruleup.observability.domain.api.w +import com.ruleup.tti.domain.TtiRecord +import com.ruleup.tti.domain.TtiShooter +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext +import kotlinx.serialization.json.Json +import okhttp3.MediaType.Companion.toMediaType +import okhttp3.OkHttpClient +import okhttp3.Request +import okhttp3.RequestBody.Companion.toRequestBody + +/** 완성된 TTI 를 구글 시트(Apps Script 웹앱)에 한 행씩 남긴다. URL 이 비어 있으면 보내지 않는다. */ +internal class SheetTtiShooter( + private val url: String, + private val buildType: String, + private val client: OkHttpClient, + private val deviceInfoProvider: DeviceInfoProvider, + private val observability: Observability, +) : TtiShooter { + override suspend fun shoot(records: List) { + if (url.isBlank()) return + val device = deviceInfoProvider.current() + records.mapNotNull { it.toSheetRow(device, buildType) }.forEach { row -> + // 던지면 기록이 지워지지 않아 다음 전송 때 Firebase·Amplitude 에도 같은 TTI 가 또 나간다. + runCatching { post(row) } + .onFailure { observability.w(TAG, it) { "TTI 시트 전송 실패 — ${row.pageName}" } } + } + } + + private suspend fun post(row: TtiSheetRow) = + withContext(Dispatchers.IO) { + val request = + Request + .Builder() + .url(url) + .post(Json.encodeToString(TtiSheetRow.serializer(), row).toRequestBody(JSON)) + .build() + client.newCall(request).execute().use { response -> + check(response.isSuccessful) { "HTTP ${response.code}" } + } + } + + private companion object { + const val TAG = "TtiSheet" + val JSON = "application/json; charset=utf-8".toMediaType() + } +} diff --git a/app/src/main/java/com/ruleup/android_ruleup/tti/TtiAppModule.kt b/app/src/main/java/com/ruleup/android_ruleup/tti/TtiAppModule.kt index d135262e..85fbbde4 100644 --- a/app/src/main/java/com/ruleup/android_ruleup/tti/TtiAppModule.kt +++ b/app/src/main/java/com/ruleup/android_ruleup/tti/TtiAppModule.kt @@ -1,17 +1,39 @@ package com.ruleup.android_ruleup.tti +import com.ruleup.android_ruleup.BuildConfig +import com.ruleup.domain.device.DeviceInfoProvider +import com.ruleup.observability.domain.api.Observability import com.ruleup.tti.domain.TtiShooter -import dagger.Binds import dagger.Module +import dagger.Provides import dagger.hilt.InstallIn import dagger.hilt.components.SingletonComponent +import okhttp3.OkHttpClient import javax.inject.Singleton /** TTI 가 어디로 나갈지 정하는 곳. */ @Module @InstallIn(SingletonComponent::class) -abstract class TtiAppModule { - @Binds +object TtiAppModule { + @Provides @Singleton - abstract fun bindTtiShooter(impl: ObservabilityTtiShooter): TtiShooter + fun provideTtiShooter( + observabilityShooter: ObservabilityTtiShooter, + deviceInfoProvider: DeviceInfoProvider, + observability: Observability, + ): TtiShooter { + // 앱 API 용 클라이언트를 쓰면 인증 토큰이 구글로 따라간다. + val sheetShooter = + SheetTtiShooter( + url = BuildConfig.TTI_SHEET_URL, + buildType = BuildConfig.BUILD_TYPE, + client = OkHttpClient(), + deviceInfoProvider = deviceInfoProvider, + observability = observability, + ) + return TtiShooter { records -> + observabilityShooter.shoot(records) + sheetShooter.shoot(records) + } + } } diff --git a/app/src/main/java/com/ruleup/android_ruleup/tti/TtiSheetRow.kt b/app/src/main/java/com/ruleup/android_ruleup/tti/TtiSheetRow.kt new file mode 100644 index 00000000..96237991 --- /dev/null +++ b/app/src/main/java/com/ruleup/android_ruleup/tti/TtiSheetRow.kt @@ -0,0 +1,49 @@ +package com.ruleup.android_ruleup.tti + +import com.ruleup.domain.device.DeviceInfo +import com.ruleup.domain.time.ServiceDate +import com.ruleup.tti.domain.TtiRecord +import com.ruleup.tti.domain.TtiTimeline +import kotlinx.serialization.Serializable +import java.time.Instant +import java.time.format.DateTimeFormatter + +/** 시트 한 행. 키 이름은 Apps Script doPost 가 읽는 이름과 같아야 한다(#623). */ +@Serializable +internal data class TtiSheetRow( + val recordedAt: String, + val pageName: String, + val viewCreate: Long, + val backend: Long, + val viewBinding: Long, + val bigPartLoading: Long, + val totalMillis: Long, + val appVersion: String, + val buildType: String, + val deviceModel: String, + val os: String, +) + +/** 네 구간이 다 닫히지 않았으면 null — 빈 칸을 0 으로 채우면 빠른 화면처럼 보인다. */ +internal fun TtiRecord.toSheetRow( + device: DeviceInfo, + buildType: String, +): TtiSheetRow? { + val total = totalTimeMillis ?: return null + val millis = TtiTimeline.entries.associateWith { spans[it]?.durationMillis ?: return null } + return TtiSheetRow( + recordedAt = RECORDED_AT_FORMAT.format(Instant.ofEpochMilli(createdAt).atZone(ServiceDate.ZONE)), + pageName = pageName, + viewCreate = millis.getValue(TtiTimeline.VIEW_CREATE), + backend = millis.getValue(TtiTimeline.BACKEND), + viewBinding = millis.getValue(TtiTimeline.VIEW_BINDING), + bigPartLoading = millis.getValue(TtiTimeline.BIG_PART_LOADING), + totalMillis = total, + appVersion = "${device.versionName} (${device.versionCode})", + buildType = buildType, + deviceModel = "${device.manufacturer} ${device.deviceModel}", + os = "Android ${device.osVersion} (SDK ${device.sdkInt})", + ) +} + +private val RECORDED_AT_FORMAT = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss") diff --git a/app/src/test/java/com/ruleup/android_ruleup/tti/SheetTtiShooterTest.kt b/app/src/test/java/com/ruleup/android_ruleup/tti/SheetTtiShooterTest.kt new file mode 100644 index 00000000..3831b093 --- /dev/null +++ b/app/src/test/java/com/ruleup/android_ruleup/tti/SheetTtiShooterTest.kt @@ -0,0 +1,114 @@ +package com.ruleup.android_ruleup.tti + +import com.ruleup.domain.device.DeviceInfo +import com.ruleup.observability.domain.test.testObservability +import com.ruleup.tti.domain.Tti +import com.ruleup.tti.domain.TtiRecord +import com.ruleup.tti.domain.TtiSpan +import com.ruleup.tti.domain.TtiTimeline +import kotlinx.coroutines.test.runTest +import kotlinx.serialization.json.Json +import kotlinx.serialization.json.jsonObject +import kotlinx.serialization.json.jsonPrimitive +import okhttp3.MediaType.Companion.toMediaType +import okhttp3.OkHttpClient +import okhttp3.Protocol +import okhttp3.Request +import okhttp3.Response +import okhttp3.ResponseBody.Companion.toResponseBody +import okio.Buffer +import org.junit.Test +import kotlin.test.assertEquals +import kotlin.test.assertTrue + +/** 시트 전송이 실패해도 TTI 기록 흐름(Firebase·Amplitude)을 막지 않는다. */ +class SheetTtiShooterTest { + private val requests = mutableListOf() + private var status = 200 + + private val client = + OkHttpClient + .Builder() + .addInterceptor { chain -> + requests += chain.request() + Response + .Builder() + .request(chain.request()) + .protocol(Protocol.HTTP_1_1) + .code(status) + .message("") + .body("ok".toResponseBody("text/plain".toMediaType())) + .build() + }.build() + + @Test + fun `URL 이 비어 있으면 아무것도 보내지 않는다`() = + runTest { + shooter(url = "").shoot(listOf(record("home"))) + + assertTrue(requests.isEmpty()) + } + + @Test + fun `완성된 기록마다 Apps Script 가 읽는 키로 한 건씩 보낸다`() = + runTest { + shooter().shoot(listOf(record("home"), record("challenge_detail"))) + + assertEquals(listOf("home", "challenge_detail"), requests.map { it.bodyJson()["pageName"] }) + // 키 이름이 바뀌면 시트에 빈 칸이 쌓인다 + assertEquals("500", requests.first().bodyJson()["backend"]) + } + + @Test + fun `서버가 실패를 돌려줘도 던지지 않는다`() = + runTest { + // 던지면 기록이 남아 다음 전송 때 Firebase·Amplitude 로 같은 TTI 가 또 나간다. + status = 500 + + shooter().shoot(listOf(record("home"))) + + assertEquals(1, requests.size) + } + + private fun shooter(url: String = "https://script.google.com/macros/s/test/exec") = + SheetTtiShooter( + url = url, + buildType = "debug", + client = client, + deviceInfoProvider = { DEVICE }, + observability = testObservability(), + ) + + private fun Request.bodyJson(): Map { + val text = Buffer().also { body!!.writeTo(it) }.readUtf8() + return Json.parseToJsonElement(text).jsonObject.mapValues { it.value.jsonPrimitive.content } + } + + private fun record(page: String) = + TtiRecord( + tti = Tti(page), + pageName = page, + createdAt = 0, + spans = + mapOf( + TtiTimeline.VIEW_CREATE to TtiSpan(0, 100), + TtiTimeline.BACKEND to TtiSpan(100, 600), + TtiTimeline.VIEW_BINDING to TtiSpan(600, 750), + TtiTimeline.BIG_PART_LOADING to TtiSpan(750, 800), + ), + ) + + private companion object { + val DEVICE = + DeviceInfo( + platform = "ANDROID", + osVersion = "15", + sdkInt = 35, + deviceModel = "Pixel 8", + manufacturer = "Google", + lowRam = false, + versionName = "1.0.2", + versionCode = 3, + ) + } +} diff --git a/app/src/test/java/com/ruleup/android_ruleup/tti/TtiSheetRowTest.kt b/app/src/test/java/com/ruleup/android_ruleup/tti/TtiSheetRowTest.kt new file mode 100644 index 00000000..5444a685 --- /dev/null +++ b/app/src/test/java/com/ruleup/android_ruleup/tti/TtiSheetRowTest.kt @@ -0,0 +1,71 @@ +package com.ruleup.android_ruleup.tti + +import com.ruleup.domain.device.DeviceInfo +import com.ruleup.tti.domain.Tti +import com.ruleup.tti.domain.TtiRecord +import com.ruleup.tti.domain.TtiSpan +import com.ruleup.tti.domain.TtiTimeline +import org.junit.Test +import kotlin.test.assertEquals +import kotlin.test.assertNull + +/** 시트 열과 값이 어긋나면 화면별 TTI 를 엉뚱한 구간으로 읽는다. */ +class TtiSheetRowTest { + @Test + fun `완성된 기록은 네 구간과 합계를 각자의 열에 KST 시각으로 싣는다`() { + // 2026-10-20T12:00:00Z = KST 21:00 + val row = record(createdAt = 1_792_497_600_000L).toSheetRow(DEVICE, buildType = "release") + + assertEquals( + TtiSheetRow( + recordedAt = "2026-10-20 21:00:00", + pageName = "home", + viewCreate = 100, + backend = 500, + viewBinding = 150, + bigPartLoading = 50, + totalMillis = 800, + appVersion = "1.0.2 (3)", + buildType = "release", + deviceModel = "Google Pixel 8", + os = "Android 15 (SDK 35)", + ), + row, + ) + } + + @Test + fun `닫히지 않은 구간이 있으면 시트에 남기지 않는다`() { + val open = record().let { it.copy(spans = it.spans + (TtiTimeline.BACKEND to TtiSpan(startedAt = 0))) } + + assertNull(open.toSheetRow(DEVICE, buildType = "debug")) + } + + private fun record(createdAt: Long = 0) = + TtiRecord( + tti = Tti("t1"), + pageName = "home", + createdAt = createdAt, + spans = + mapOf( + TtiTimeline.VIEW_CREATE to TtiSpan(0, 100), + TtiTimeline.BACKEND to TtiSpan(100, 600), + TtiTimeline.VIEW_BINDING to TtiSpan(600, 750), + TtiTimeline.BIG_PART_LOADING to TtiSpan(750, 800), + ), + ) + + private companion object { + val DEVICE = + DeviceInfo( + platform = "ANDROID", + osVersion = "15", + sdkInt = 35, + deviceModel = "Pixel 8", + manufacturer = "Google", + lowRam = false, + versionName = "1.0.2", + versionCode = 3, + ) + } +}