diff --git a/app/src/fdroid/kotlin/org/meshtastic/app/analytics/FdroidPlatformAnalytics.kt b/app/src/fdroid/kotlin/org/meshtastic/app/analytics/FdroidPlatformAnalytics.kt index 0432560e5..5faa8e0e8 100644 --- a/app/src/fdroid/kotlin/org/meshtastic/app/analytics/FdroidPlatformAnalytics.kt +++ b/app/src/fdroid/kotlin/org/meshtastic/app/analytics/FdroidPlatformAnalytics.kt @@ -31,26 +31,21 @@ import org.meshtastic.core.repository.PlatformAnalytics class FdroidPlatformAnalytics : PlatformAnalytics { init { // For F-Droid builds we don't initialize external analytics services. - // In debug builds we attach a DebugTree for convenient local logging, but - // release builds rely on system logging only. - if (BuildConfig.DEBUG) { - Logger.setMinSeverity(Severity.Debug) - Logger.i { "F-Droid platform no-op analytics initialized (Debug mode)." } - } else { - Logger.setMinSeverity(Severity.Info) - Logger.i { "F-Droid platform no-op analytics initialized." } + // Configure Kermit logging: Debug level for debug builds, Info level for release builds. + Logger.setMinSeverity(if (BuildConfig.DEBUG) Severity.Debug else Severity.Info) + Logger.i { + "F-Droid platform no-op analytics initialized (${if (BuildConfig.DEBUG) "Debug" else "Info"} logging)." } } override fun setDeviceAttributes(firmwareVersion: String, model: String) { - // No-op for F-Droid - Logger.d { "Set device attributes called: firmwareVersion=$firmwareVersion, deviceHardware=$model" } + if (BuildConfig.DEBUG) Logger.d { "setDeviceAttributes: firmwareVersion=$firmwareVersion, model=$model" } } override val isPlatformServicesAvailable: Boolean get() = false override fun track(event: String, vararg properties: DataPair) { - Logger.d { "Track called: event=$event, properties=${properties.toList()}" } + // No-op for F-Droid } } diff --git a/app/src/google/kotlin/org/meshtastic/app/analytics/GooglePlatformAnalytics.kt b/app/src/google/kotlin/org/meshtastic/app/analytics/GooglePlatformAnalytics.kt index 0deea7aeb..26107cb95 100644 --- a/app/src/google/kotlin/org/meshtastic/app/analytics/GooglePlatformAnalytics.kt +++ b/app/src/google/kotlin/org/meshtastic/app/analytics/GooglePlatformAnalytics.kt @@ -343,7 +343,13 @@ class GooglePlatformAnalytics(private val context: Context, private val analytic // Explicitly handle String else -> bundle.putString(it.name, value.toString()) // Fallback for other types } - KermitLogger.withTag(TAG).d { "Analytics: track $event (${it.name} : $value)" } + KermitLogger.withTag(TAG).d { + if (BuildConfig.DEBUG) { + "Analytics: track $event (${it.name} : $value)" + } else { + "Analytics: track $event (${it.name})" + } + } } Firebase.analytics.logEvent(event, bundle) } diff --git a/core/network/src/commonMain/kotlin/org/meshtastic/core/network/repository/KermitMqttLogger.kt b/core/network/src/commonMain/kotlin/org/meshtastic/core/network/repository/KermitMqttLogger.kt new file mode 100644 index 000000000..8bd2cbda5 --- /dev/null +++ b/core/network/src/commonMain/kotlin/org/meshtastic/core/network/repository/KermitMqttLogger.kt @@ -0,0 +1,48 @@ +/* + * Copyright (c) 2026 Meshtastic LLC + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ +package org.meshtastic.core.network.repository + +import co.touchlab.kermit.Logger +import org.meshtastic.mqtt.MqttLogLevel +import org.meshtastic.mqtt.MqttLogger + +/** + * Adapter that implements [MqttLogger] to send MQTT client logs to Kermit's [Logger]. + * + * This allows the MQTTastic library logging to integrate with the app's logging infrastructure, including any + * configured log sinks (Crashlytics, Datadog, etc.). + * + * The library's [tag] (e.g. "MqttClient", "MqttConnection") is forwarded as a structured Kermit tag so that Datadog + * receives it as an indexed attribute rather than freetext in the message body, enabling per-component filtering in log + * queries. + * + * Note: The production log level should be set to [MqttLogLevel.WARN] (not INFO) to prevent the library's own + * INFO-level messages (which include endpoint addresses and topic strings) from reaching remote analytics sinks. + */ +class KermitMqttLogger : MqttLogger { + override fun log(level: MqttLogLevel, tag: String, message: String, throwable: Throwable?) { + val logger = Logger.withTag(tag) + when (level) { + MqttLogLevel.TRACE -> logger.v(throwable) { message } + MqttLogLevel.DEBUG -> logger.d(throwable) { message } + MqttLogLevel.INFO -> logger.i(throwable) { message } + MqttLogLevel.WARN -> logger.w(throwable) { message } + MqttLogLevel.ERROR -> logger.e(throwable) { message } + MqttLogLevel.NONE -> return + } + } +} diff --git a/core/network/src/commonMain/kotlin/org/meshtastic/core/network/repository/MQTTRepositoryImpl.kt b/core/network/src/commonMain/kotlin/org/meshtastic/core/network/repository/MQTTRepositoryImpl.kt index 5ea482624..9838af9a7 100644 --- a/core/network/src/commonMain/kotlin/org/meshtastic/core/network/repository/MQTTRepositoryImpl.kt +++ b/core/network/src/commonMain/kotlin/org/meshtastic/core/network/repository/MQTTRepositoryImpl.kt @@ -47,6 +47,7 @@ import org.meshtastic.mqtt.ConnectionState import org.meshtastic.mqtt.MqttClient import org.meshtastic.mqtt.MqttEndpoint import org.meshtastic.mqtt.MqttException +import org.meshtastic.mqtt.MqttLogLevel import org.meshtastic.mqtt.MqttMessage import org.meshtastic.mqtt.QoS import org.meshtastic.mqtt.packet.Subscription @@ -57,6 +58,7 @@ import kotlin.concurrent.Volatile class MQTTRepositoryImpl( private val radioConfigRepository: RadioConfigRepository, private val nodeRepository: NodeRepository, + private val buildConfigProvider: org.meshtastic.core.common.BuildConfigProvider, dispatchers: CoroutineDispatchers, ) : MQTTRepository { @@ -109,6 +111,11 @@ class MQTTRepositoryImpl( autoReconnect = true username = mqttConfig?.username mqttConfig?.password?.let { password(it) } + logger = KermitMqttLogger() + // WARN for production: the library emits endpoint addresses and topic strings at + // INFO level. WARN messages (reconnect, timeout, retry) contain no PII and are + // exactly the signals needed for production diagnostics. + logLevel = if (buildConfigProvider.isDebug) MqttLogLevel.DEBUG else MqttLogLevel.WARN } client = newClient @@ -139,7 +146,27 @@ class MQTTRepositoryImpl( launch { newClient.messages.collect { msg -> processMessage(msg) } } // Forward the client's connection state to the repo-level StateFlow for UI observation. - launch { newClient.connectionState.collect { _connectionState.value = it } } + // Also emit structured log messages on transitions so reconnect attempt counts and + // disconnect reason codes are visible in Crashlytics/Datadog without any PII. + launch { + newClient.connectionState.collect { state -> + _connectionState.value = state + when (state) { + ConnectionState.Connecting -> Logger.i { "MQTT connecting" } + + ConnectionState.Connected -> Logger.i { "MQTT connected" } + + is ConnectionState.Reconnecting -> { + val errorDetail = state.lastError?.message?.let { ": $it" } ?: "" + Logger.w { "MQTT reconnecting (attempt ${state.attempt}$errorDetail)" } + } + + is ConnectionState.Disconnected -> { + state.reason?.let { Logger.w { "MQTT disconnected: ${it.message}" } } + } + } + } + } // Retry the initial connect with exponential backoff. Once established, // autoReconnect handles subsequent drops and re-subscribes internally. @@ -147,7 +174,9 @@ class MQTTRepositoryImpl( var reconnectDelay = INITIAL_RECONNECT_DELAY_MS while (true) { val result = safeCatching { - Logger.i { "MQTT Connecting to $endpoint" } + Logger.i { + if (buildConfigProvider.isDebug) "MQTT Connecting to $endpoint" else "MQTT Connecting..." + } newClient.connect(endpoint) if (subscriptions.isNotEmpty()) { Logger.d { "MQTT subscribing to ${subscriptions.size} topics" } @@ -181,13 +210,11 @@ class MQTTRepositoryImpl( private fun ProducerScope.processMessage(msg: MqttMessage) { val topic = msg.topic val payload = msg.payload.toByteArray() - Logger.d { "MQTT received message on topic $topic (size: ${payload.size} bytes)" } if (topic.contains("/json/")) { try { val jsonStr = payload.decodeToString() json.decodeFromString(jsonStr) - Logger.d { "MQTT parsed JSON payload successfully" } trySend(MqttClientProxyMessage(topic = topic, text = jsonStr, retained = msg.retain)) } catch (e: JsonDecodingException) { Logger.e(e) { "Failed to parse MQTT JSON: ${e.shortMessage} (path: ${e.path})" } @@ -204,10 +231,15 @@ class MQTTRepositoryImpl( override fun publish(topic: String, data: ByteArray, retained: Boolean) { val currentClient = client if (currentClient == null) { - Logger.w { "MQTT publish to $topic dropped: client not connected" } + Logger.w { + if (buildConfigProvider.isDebug) { + "MQTT publish to $topic dropped: client not connected" + } else { + "MQTT publish dropped: client not connected" + } + } return } - Logger.d { "MQTT publishing message to topic $topic (size: ${data.size} bytes, retained: $retained)" } scope.launch { publishSemaphore.withPermit { safeCatching { @@ -215,7 +247,15 @@ class MQTTRepositoryImpl( MqttMessage(topic = topic, payload = data, qos = QoS.AT_LEAST_ONCE, retain = retained), ) } - .onFailure { e -> Logger.w(e) { "MQTT publish to $topic failed" } } + .onFailure { e -> + Logger.w(e) { + if (buildConfigProvider.isDebug) { + "MQTT publish to $topic failed" + } else { + "MQTT publish (${data.size} bytes) failed" + } + } + } } } }