Skip to content
Merged
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
2 changes: 1 addition & 1 deletion CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.+`)은 쓰지 않는다.
Expand Down
4 changes: 4 additions & 0 deletions OBSERVABILITY.md
Original file line number Diff line number Diff line change
Expand Up @@ -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` 가 네비게이션마다 갱신하고, 두
Expand Down
4 changes: 4 additions & 0 deletions app/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -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"))
Expand Down
51 changes: 51 additions & 0 deletions app/src/main/java/com/ruleup/android_ruleup/tti/SheetTtiShooter.kt
Original file line number Diff line number Diff line change
@@ -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<TtiRecord>) {
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()
}
}
30 changes: 26 additions & 4 deletions app/src/main/java/com/ruleup/android_ruleup/tti/TtiAppModule.kt
Original file line number Diff line number Diff line change
@@ -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)
}
}
}
49 changes: 49 additions & 0 deletions app/src/main/java/com/ruleup/android_ruleup/tti/TtiSheetRow.kt
Original file line number Diff line number Diff line change
@@ -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")
114 changes: 114 additions & 0 deletions app/src/test/java/com/ruleup/android_ruleup/tti/SheetTtiShooterTest.kt
Original file line number Diff line number Diff line change
@@ -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<Request>()
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<String, String> {
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,
)
}
}
71 changes: 71 additions & 0 deletions app/src/test/java/com/ruleup/android_ruleup/tti/TtiSheetRowTest.kt
Original file line number Diff line number Diff line change
@@ -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,
)
}
}
Loading