chore: Integrate MQTT logging with Kermit and enhance PII sanitization (#5338)

This commit is contained in:
James Rich
2026-05-02 13:53:19 -05:00
committed by GitHub
parent 61af98e966
commit 1c984d54f4
4 changed files with 108 additions and 19 deletions

View File

@@ -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
}
}

View File

@@ -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)
}

View File

@@ -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 <https://www.gnu.org/licenses/>.
*/
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
}
}
}

View File

@@ -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<MqttClientProxyMessage>.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<MqttJsonPayload>(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"
}
}
}
}
}
}