name structured-logging description Emit JSON-structured logs on mobile with correlation ids, consistent field names, and redaction. Use when setting up logging in Kotlin, Swift, Dart, or TypeScript mobile code.
Structured Logging on Mobile
Instructions
Unstructured Log.d("user=${email}") logs are a debugging trap and a privacy landmine. Emit a small, typed, JSON schema everywhere and redact at the emit site.
1. The Minimum Log Schema
Every log line, on device or shipped, must conform to this schema:
{
"ts" : "2026-04-19T14:03:11.201Z" ,
"level" : "info" ,
"logger" : "checkout.payment" ,
"msg" : "payment_submitted" ,
"trace_id" : "4bf92f3577b34da6a3ce929d0e0e4736" ,
"span_id" : "00f067aa0ba902b7" ,
"session_id" : "s_8a92" ,
"app_version" : "4.12.0+4120" ,
"platform" : "android" ,
"attrs" : { "amount_cents" : 1299 , "currency" : "USD" }
}
Rules:
msg is a stable event name (snake_case), not a free-text sentence. Free-text goes in attrs.detail.
trace_id / span_id come from the active OpenTelemetry context (see trace-propagation).
attrs is a flat map of primitives; nested objects are serialized into it via dot keys (attrs.user.bucket = "a").
2. Android (Kotlin)
Use a thin facade over Timber so every call serializes through one formatter:
object Log {
private val json = Json { encodeDefaults = true }
fun event (logger: String , msg: String , level: Level = Level.INFO, attrs: Map <String , Any?> = emptyMap() ) {
val span = Span.current().spanContext
val entry = LogEntry(
ts = Instant.now().toString(),
level = level.name.lowercase(),
logger = logger,
msg = msg,
traceId = span.traceId.takeIf { it != TraceId.getInvalid() },
spanId = span.spanId.takeIf { it != SpanId.getInvalid() },
sessionId = Session.current.id,
appVersion = BuildConfig.VERSION_NAME + "+" + BuildConfig.VERSION_CODE,
platform = "android" ,
attrs = Redactor.scrub(attrs)
)
Timber.tag(logger).log(level.android, json.encodeToString(entry))
}
}
3. iOS (Swift)
Use os.Logger for device logs and a second sink for remote:
import OSLog
struct AppLog {
static func event (logger : String , msg : String , level : OSLogType = .info,
attrs : [String : Any ] = [:]) {
let scrubbed = Redactor .scrub(attrs)
let entry = LogEntry (ts: .now, level: level.name, logger: logger,
msg: msg, traceId: currentTraceId(),
spanId: currentSpanId(), sessionId: Session .current.id,
appVersion: Bundle .main.version,
platform: "ios" , attrs: scrubbed)
Logger (subsystem: Bundle .main.bundleIdentifier! , category: logger)
.log(level: level, "\(entry.asJSONString, privacy: .public) " )
RemoteLogSink .shared.enqueue(entry)
}
}
privacy: .public is only safe because the payload has already been redacted . Never pass a raw \(user.email).
4. Flutter (Dart)
class AppLog {
static void event(String logger, String msg,
{Level level = Level.info, Map<String, Object?> attrs = const {}}) {
final ctx = Sentry.getSpan()?.context;
final entry = LogEntry(
ts: DateTime.now().toUtc(),
level: level.name,
logger: logger,
msg: msg,
traceId: ctx?.traceId.toString(),
spanId: ctx?.spanId.toString(),
sessionId: Session.current.id,
appVersion: '${packageInfo.version}+${packageInfo.buildNumber}',
platform: Platform.operatingSystem,
attrs: Redactor.scrub(attrs),
);
developer.log(jsonEncode(entry), name: logger, level: level.value);
RemoteLogSink.instance.enqueue(entry);
}
}
5. React Native (TypeScript)
export function logEvent (
logger : string ,
msg : string ,
level : LogLevel = 'info' ,
attrs : Record <string , unknown > = {},
): void {
const span = api.trace .getActiveSpan ()?.spanContext ();
const entry : LogEntry = {
ts : new Date ().toISOString (),
level,
logger,
msg,
trace_id : span?.traceId ,
span_id : span?.spanId ,
session_id : Session .current .id ,
app_version : `${pkg.version} +${buildNumber} ` ,
platform : Platform .OS ,
attrs : redact (attrs),
};
if (__DEV__) console .log (JSON .stringify (entry));
remoteLogSink.enqueue (entry);
}
6. Correlation Ids
trace_id / span_id: taken from the active OTel context so logs and traces line up.
session_id: generated once per app cold start, persisted in memory only, rotated on sign-out.
request_id: set by the HTTP interceptor on outbound requests and attached as attrs.request_id.
user_id_hash: a SHA-256 of the authenticated user id with an app salt, attached only after sign-in.
7. Redaction
Put a single Redactor.scrub function in front of every emit. It should:
Drop any key in the deny-list (email, phone, token, password, pan, cvv, ssn, address, lat, lng).
Drop any value matching a regex for emails, credit-card numbers, JWTs, or e.g. ^\+?\d{10,}$ phone patterns.
Truncate free-text fields to 256 chars.
Replace removed values with the string "[redacted]" so reviewers can see the shape.
Test the redactor in unit tests -- it's the single most important piece of the logger.
8. Logger Names
Use dotted hierarchical names: checkout.payment, auth.oauth, home.feed.
Filter by logger name in the remote pipeline to downsample chatty loggers before ingestion.
Document reserved prefixes in Agent.md so feature teams do not collide.
9. Dashboards
A "log volume per logger per release" panel catches a regression where someone added Log.event inside a render loop.
An "error log rate vs crash-free sessions" correlation chart validates that error logs line up with actual crashes.
Checklist
Single facade (Log.event / AppLog.event) is the only way to emit logs in the codebase.
Every log conforms to the JSON schema with ts, level, logger, msg, trace_id, session_id, app_version, platform, attrs.
msg is a stable snake_case event name, not free text.
Redactor unit tests cover email, phone, token, credit card, and arbitrary denylisted keys.
OTel trace/span ids are attached automatically.
Session id rotates on sign-out and is never persisted to disk.
Log volume dashboards are in place to catch regressions.