Implementing Remote Logging for Production Debugging in Mobile Apps
A bug reproduces only on a specific user's device—and only in production. Attaching a debugger is impossible. Crashlytics shows the crash, but the stack trace without context doesn't explain how the app reached that state. Remote logging solves exactly this: detailed logs from user devices arrive on the server in real-time or on demand.
Architecture
Remote logging is not "send all logs from every device to the server." That's costly in bandwidth, storage, and impacts performance. The right architecture employs several modes.
Passive mode (default): logs write to a local ring buffer. Nothing goes to the server.
Active mode: enabled by a trigger—crash, specific user ID, flag from Remote Config. Buffer flushes to the server.
Debug session for a specific user: on support request, enable extended logging for a specific userId via Firebase Remote Config or feature flag.
Firebase Remote Config for Dynamic Logging Control
// Android: check logging flags at startup
val remoteConfig = Firebase.remoteConfig
remoteConfig.fetchAndActivate().addOnCompleteListener {
val logLevel = remoteConfig.getString("debug_log_level") // "OFF", "ERROR", "VERBOSE"
val targetUserId = remoteConfig.getString("debug_user_id") // empty = all
RemoteLogger.configure(
level = LogLevel.fromString(logLevel),
targetUserId = targetUserId
)
}
Enable detailed logging for a specific user without releasing: change Remote Config → device pulls new config in 30 minutes → next session writes verbose logs.
Log Transport
Batch Sending
Don't send each log call as a separate HTTP request—accumulate in a queue and send in batches:
class RemoteLogTransport(
private val apiService: LogApiService,
private val batchSize: Int = 100,
private val flushIntervalMs: Long = 30_000
) {
private val pendingLogs = ConcurrentLinkedQueue<LogEntry>()
fun enqueue(entry: LogEntry) {
pendingLogs.add(entry)
if (pendingLogs.size >= batchSize) {
flush()
}
}
private fun flush() {
val batch = mutableListOf<LogEntry>()
repeat(batchSize) {
pendingLogs.poll()?.let { batch.add(it) } ?: return@repeat
}
if (batch.isNotEmpty()) {
scope.launch {
runCatching {
apiService.sendLogs(LogBatch(
sessionId = sessionId,
deviceInfo = deviceInfo,
logs = batch
))
}
}
}
}
}
WorkManager for guaranteed delivery upon network recovery:
val logUploadWork = OneTimeWorkRequestBuilder<LogUploadWorker>()
.setConstraints(Constraints.Builder()
.setRequiredNetworkType(NetworkType.CONNECTED)
.build())
.setBackoffCriteria(BackoffPolicy.EXPONENTIAL, 1, TimeUnit.MINUTES)
.build()
WorkManager.getInstance(context).enqueue(logUploadWork)
iOS—OSLog and Remote Transport Combination
// OSLog for system logging + remote transport
actor RemoteLogger {
private var buffer: [LogEntry] = []
private var isRemoteEnabled = false
private let transport: LogTransport
func log(_ message: String, level: LogLevel) async {
let entry = LogEntry(timestamp: Date(), level: level, message: message)
buffer.append(entry)
if buffer.count > 500 { buffer.removeFirst() }
if isRemoteEnabled {
await transport.enqueue(entry)
}
}
func enableRemote(for userId: String) async {
isRemoteEnabled = true
// Send buffered logs
let bufferedLogs = buffer
await transport.sendBatch(bufferedLogs)
}
}
actor ensures thread safety without explicit locking—the right approach in Swift 5.5+.
Backend Log Storage
Standard solutions:
| Storage | Suited For | Features |
|---|---|---|
| Elasticsearch + Kibana | Full-text log search | Resource-intensive but powerful |
| Loki + Grafana | Structured logs, low resources | Cheaper than Elastic |
| Datadog | SaaS, no infrastructure | Expensive at scale |
| Sentry | Already used for crashes | Breadcrumbs + remote logs in one place |
Sentry Breadcrumbs—often underestimated. Custom breadcrumbs attach to every Event (crash or error) and show what happened before the problem:
SentrySDK.configureScope { scope in
scope.addBreadcrumb(Breadcrumb(
level: .info,
category: "navigation",
message: "User opened PaymentScreen",
data: ["orderId": orderId]
))
}
When a crash occurs, Sentry shows the last 100 breadcrumbs—effectively a ready-made user journey log.
Security and GDPR/CCPA Compliance
Remote logs potentially contain personal data. Mandatory:
- Logs don't contain full names, emails, card numbers—only
userIdfor correlation - Log data stored no longer than 30 days (configurable TTL)
- Users can opt out of diagnostics collection in app settings—flag saved in
UserDefaults/SharedPreferences, checked before each send - Privacy Policy describes diagnostic data collection
Operational Debugging Without Releases
Scenario: production crashes for 0.3% of users on a specific device. Without remote logging:
- Ask user to enable developer mode → unlikely
- Wait for reproduction → unknown when
With remote logging:
- Enable verbose mode via Remote Config for specific userId
- User reproduces the problem in the next session
- In 30 minutes, see detailed session logs in Kibana/Grafana
- Find location → hotfix → disable verbose mode
Timeline Estimates
Basic remote logging system with batch sending, Remote Config management, and Sentry integration—1–2 weeks. Full infrastructure with Elasticsearch, Kibana dashboards, GDPR mechanisms, and iOS+Android—3–4 weeks. Cost calculated individually after infrastructure audit.







