App Service Logging Strategy (feat. The Logging Wars)

GongBaek·2025년 8월 8일
post-thumbnail

📕 The Night Before Demo Day: The Log Wars at Woowacourse Campus

“We’re drowning in work already, and now they want a logging strategy document…”

The campus buzzed with Demo Day eve chaos — the click-clack of keyboards, sighs in stereo, and the occasional “Oh—!” from someone discovering a last-minute bug.

Bibi walked back in with an empty iced Americano cup, straw still in her mouth.
“If we don’t do it now, the moment the app crashes, no one will know why.
We’ve got Crashlytics and Analytics — but they need to be beautifully written.
This is the MulKkam team’s official logging strategy.”

Tama, hunched over Android Studio, mumbled without looking up.
“Let’s start with the basics: ERROR, WARN, INFO, DEBUG. Easy peasy kimchi squeezy.”

Joy tilted back in her chair, smirking.
“Why don’t we just use println?”

Bibi shot her a look colder than a server room in December.
“println is like Lea showing up to campus, whispering ‘I’m here~’ to a friend…
but never signing the attendance sheet.
When the coach asks, ‘Was Lea here today?’, there’s zero proof.”


0️⃣ Firebase Setup — The Canvas for Our Records

Bibi: “Before we even talk logging, we need the canvas to paint on.”

  1. Create a Firebase projectRegister your Android app (use the exact package name)
  2. Download google-services.json → place it in android/app/
  3. Gradle setup (only what’s necessary):
// build.gradle.kts (Project)
plugins {
    id("com.google.gms.google-services") version "4.4.2" apply false
    id("com.google.firebase.crashlytics") version "3.0.5" apply false
}
// build.gradle.kts (Module: app)
plugins {
    alias(libs.plugins.kotlin.android)
    alias(libs.plugins.google.services)      // ✅ google-services
    alias(libs.plugins.firebase.crashlytics) // ✅ crashlytics
}

dependencies {
    implementation(platform(libs.firebase.bom))
    implementation(libs.firebase.analytics)
    implementation(libs.firebase.crashlytics)
    implementation(libs.timber)
}
// build.gradle.kts (Module: app) — buildTypes example
android {
    buildTypes {
        debug {
            isMinifyEnabled = false
        }
        release {
            isMinifyEnabled = true
            proguardFiles(
                getDefaultProguardFile("proguard-android-optimize.txt"),
                "proguard-rules.pro"
            )
        }
    }
}

MulKkamApp:

class MulKkamApp : Application() {
    override fun onCreate() {
        super.onCreate()
        initLogger()
    }

    private fun initLogger() {
        if (BuildConfig.DEBUG) {
            Timber.plant(LoggingInjection.debugTimberTree)
        } else {
            Timber.plant(LoggingInjection.releaseTimberTree)
        }
    }
}

Bibi clapped like she just passed all unit tests.

Bibi: “Perfect. Now, when we log… it sticks.”

Joy: “Finally, we’ll argue with evidence.”


1️⃣ First PR — Base Setup (Models, Interfaces, Trees, Logger)

Tama: “Let’s define the format first. UTC timestamps, human-readable messages, events, levels, and optional user IDs.”

enum class LogLevel { ERROR, WARN, INFO, DEBUG }
enum class LogEvent { NETWORK, USER_AUTH, USER_ACTION, ERROR }
data class LogEntry(
    val level: LogLevel,
    val event: LogEvent,
    val message: String,
    val userId: String? = null,
    val timestamp: String = DateTimeFormatter.ISO_INSTANT.format(Instant.now()),
)

Joy: “Every layer should just depend on the interface — makes refactoring painless.”

interface Logger {
    fun log(entry: LogEntry)
    fun error(event: LogEvent, message: String = "", userId: String? = null) =
        log(LogEntry(LogLevel.ERROR, event, message, userId))
    fun warn(event: LogEvent, message: String = "", userId: String? = null) =
        log(LogEntry(LogLevel.WARN, event, message, userId))
    fun info(event: LogEvent, message: String = "", userId: String? = null) =
        log(LogEntry(LogLevel.INFO, event, message, userId))
    fun debug(event: LogEvent, message: String = "", userId: String? = null) =
        log(LogEntry(LogLevel.DEBUG, event, message, userId))
}

Bibi: “Separate Trees — Debug goes to Logcat, Release to Crashlytics.”

class DebugLoggingTree(
    private val sanitizer: SensitiveInfoSanitizer,
) : Timber.DebugTree() {
    override fun log(priority: Int, tag: String?, message: String, t: Throwable?) {
        super.log(priority, tag, sanitizer.sanitize(message), t)
    }
}
class ReleaseLoggingTree(
    private val sanitizer: SensitiveInfoSanitizer,
) : Timber.Tree() {
    override fun log(priority: Int, tag: String?, message: String, t: Throwable?) {
        if (priority == Log.VERBOSE || priority == Log.DEBUG) return
        val safe = sanitizer.sanitize(message)
        FirebaseCrashlytics.getInstance().log(safe)
        if (priority == Log.ERROR && t != null) {
            FirebaseCrashlytics.getInstance().recordException(t)
        }
    }
}

2️⃣ The 5,600 Log Explosion — No Build Variant Filtering

Next morning, Joy opened Firebase Analytics and froze.

Joy: “Uh… we have 5,600 events in one day.”

Bibi scrolled through the logs and groaned.

Bibi: “We sent every single log — DEBUG, INFO, everything — straight to Analytics.”

The problem:

  • No build variant check before sending Analytics
  • DEBUG and INFO polluted production data
  • 5,600 logs = “Analytics turned into a packet sniffer”

3️⃣ Build Variant Filtering — Defusing the Bomb

if (!BuildConfig.DEBUG) {
    Firebase.analytics.logEvent(entry.event.name, Bundle().apply {
        putString("level", entry.level.name)
        putString("message", safePayload)
        entry.userId?.let { putString("userId", it) }
    })
}

if (entry.level == LogLevel.ERROR) {
    FirebaseCrashlytics.getInstance().recordException(Exception(safeLine))
}

Debug builds → Logcat only

Release builds → Analytics + Crashlytics

Bibi’s commit message: “Debug build divorces Analytics. Prod data stays clean.”


4️⃣ Token Exposure — The Security Scare

During network log review:

Tama: “Guys… our authorization token is just… sitting there in the logs.”

Joy: “If that leaks, we’re toast.”

Bibi’s monitor showed a DEBUG log with a full JWT token — not just a dev oopsie, but a security incident.

Fix: Mask all sensitive info before logging.

class SensitiveInfoSanitizerImpl(
    private val mask: String = "***",
) : SensitiveInfoSanitizer {
    private val JSON_TOKEN_REGEX = Regex("""(?i)"(token|access_token|refresh_token|deviceid)"\s*:\s*"([^"]+)"""")
    private val KEY_VALUE_REGEX = Regex("""(?i)\b(token|access_token|refresh_token|deviceid)\b\s*[=:]\s*([^\s&"]+)""")
    private val BEARER_REGEX    = Regex("""(?i)\b(authorization)\b\s*:\s*bearer\s+([^\s"]+)""")
    private val JWT_LIKE_REGEX  = Regex("""(?i)\beyJ[\w\-_.]+""")

    override fun sanitize(input: String): String =
        input
            .let { JSON_TOKEN_REGEX.replace(it) { "\"${it.groupValues[1]}\": \"$mask\"" } }
            .let { KEY_VALUE_REGEX.replace(it) { "${it.groupValues[1]}=$mask" } }
            .let { BEARER_REGEX.replace(it)    { "${it.groupValues[1]}: Bearer $mask" } }
            .let { JWT_LIKE_REGEX.replace(it)  { mask } }
}

Tama: “Now tokens are nothing but *** in logs.”

Joy: “Good. I can open logs without feeling like I’m defusing a bomb.”


5️⃣ Network Logging — Full Context

class NetworkLoggingInterceptor(
    private val logger: Logger,
) : Interceptor {
    override fun intercept(chain: Interceptor.Chain): Response {
        val request = chain.request()
        val startNs = System.nanoTime()

        val requestBody = request.body?.let { body ->
            Buffer().apply { body.writeTo(this) }.readString(Charset.forName("UTF-8"))
        } ?: "No Body"

        logger.debug(LogEvent.NETWORK, """
🏹 Request: ${request.method} ${request.url}
Body: $requestBody
""".trimIndent())

        return runCatching {
            val resp = chain.proceed(request)
            val tookMs = TimeUnit.NANOSECONDS.toMillis(System.nanoTime() - startNs)

            val source = resp.body.source().apply { request(Long.MAX_VALUE) }
            val respBody = source.buffer.clone().readString(Charset.forName("UTF-8"))

            logger.debug(LogEvent.NETWORK, """
🛡️ Response: ${resp.code} ${resp.message} (${tookMs}ms)
URL: ${resp.request.url}
Body: $respBody
""".trimIndent())

            resp
        }.onFailure { e ->
            logger.error(LogEvent.NETWORK, "☠️ Network request failed: ${e.message}")
        }.getOrThrow()
    }
}

🏁 Wrap-up

“Everything’s logged now,” Bibi said, a satisfied grin spreading across her face. “And no more five-thousand-six-hundred-log disasters.”

Joy stretched her arms above her head. “Tomorrow, Crashlytics can do the talking for us.”

Tama zipped up his backpack. “Let’s call it a night. Tomorrow, I want to see more INFO than ERROR on the dashboard.”

They shut down their laptops and turned off the Woowacourse campus lights.

On Demo Day eve, MulKkam’s logging was complete, and the app waited quietly… ready to record its next story.


📙 MulKkam Logging Strategy (Team Consensus)

📌 Purpose

  • Stability tracking: Monitor errors and warnings in the app.
  • Root cause analysis: Use Crashlytics and Analytics to trace the source of issues.
  • Service improvement: Analyze usage patterns and user behavior to enhance UX.

📌 Roles

  • Developers: Focus on ERROR, WARN, DEBUG for debugging and technical monitoring.
  • Planners / PMs: Focus on INFO for user actions and business metrics.

📌 Log Levels

LevelDescriptionExample
ERRORCritical failureCrash, NullPointerException
WARNPotential issueAPI instability, slow response
INFOKey user eventLogin success, purchase complete
DEBUGDetailed dev loggingVariable values, execution flow

📌 Build Variants

  • Debug: Logcat output only (no Analytics / Crashlytics).
  • Release:
    - Send INFO, WARN, ERROR to Analytics.
    - Send ERROR stack traces to Crashlytics.

📌 Sensitive Data Masking

  • Mask tokens, deviceId, Authorization headers, and JWT patterns before sending logs anywhere (Logcat, Analytics, Crashlytics).

📌 Network Logging

  • Record request/response method, URL, body (masked), status code, and latency.
  • Log failures at ERROR level with exception details.
  • Apply masking automatically for all network logs.

📌 Retention Policy

  • Crashlytics keeps logs for 90 days (default). No additional long-term storage.
profile
Junior Android Developer

0개의 댓글