Scala - Logging Best Practices
• Structured logging with context propagation beats string concatenation—use SLF4J with Logback and MDC for production-grade systems that need traceability across distributed services
Key Insights
• Structured logging with context propagation beats string concatenation—use SLF4J with Logback and MDC for production-grade systems that need traceability across distributed services • Lazy evaluation and by-name parameters prevent expensive log statement computation when log levels are disabled, eliminating performance overhead in hot paths • Type-safe logging with macros and compile-time verification catches errors early while maintaining zero runtime cost, making logs reliable diagnostic tools rather than sources of production issues
Choose SLF4J with Logback as Your Foundation
SLF4J provides a clean abstraction over logging implementations while Logback offers superior performance and configuration flexibility. Avoid println statements and scala.util.logging—they lack the control needed for production systems.
// build.sbt
libraryDependencies ++= Seq(
"ch.qos.logback" % "logback-classic" % "1.4.11",
"com.typesafe.scala-logging" %% "scala-logging" % "3.9.5"
)
The scala-logging wrapper provides idiomatic Scala interfaces with macro-based lazy evaluation:
import com.typesafe.scalalogging.LazyLogging
class UserService extends LazyLogging {
def createUser(email: String): Either[Error, User] = {
logger.debug(s"Creating user with email: $email")
val result = performCreation(email)
result match {
case Right(user) =>
logger.info(s"User created successfully: ${user.id}")
Right(user)
case Left(error) =>
logger.error(s"Failed to create user: ${error.message}", error.cause)
Left(error)
}
}
}
Implement Structured Logging with Context
String interpolation creates unstructured logs that are difficult to parse and query. Use structured logging with key-value pairs:
import net.logstash.logback.argument.StructuredArguments._
import com.typesafe.scalalogging.Logger
class OrderProcessor(logger: Logger) {
def processOrder(orderId: String, userId: String, amount: BigDecimal): Unit = {
logger.info("Processing order",
keyValue("orderId", orderId),
keyValue("userId", userId),
keyValue("amount", amount),
keyValue("currency", "USD")
)
// Add logstash-logback-encoder dependency
// "net.logstash.logback" % "logstash-logback-encoder" % "7.4"
}
}
This produces JSON output that log aggregation systems can index and search:
{
"message": "Processing order",
"orderId": "ORD-12345",
"userId": "USR-789",
"amount": 99.99,
"currency": "USD",
"timestamp": "2024-01-15T10:30:45.123Z"
}
Leverage MDC for Request Tracing
Mapped Diagnostic Context (MDC) propagates contextual information across your call stack without passing parameters explicitly:
import org.slf4j.MDC
import scala.concurrent.{ExecutionContext, Future}
import scala.util.Try
object MDCPropagation {
// Capture current MDC context
def mdcContext: Map[String, String] = {
Option(MDC.getCopyOfContextMap)
.map(_.asScala.toMap)
.getOrElse(Map.empty)
}
// Execute with preserved MDC
def withMDC[T](context: Map[String, String])(block: => T): T = {
val backup = mdcContext
try {
context.foreach { case (k, v) => MDC.put(k, v) }
block
} finally {
MDC.clear()
backup.foreach { case (k, v) => MDC.put(k, v) }
}
}
}
class RequestHandler extends LazyLogging {
def handleRequest(requestId: String, userId: String)
(implicit ec: ExecutionContext): Future[Response] = {
MDC.put("requestId", requestId)
MDC.put("userId", userId)
val context = MDCPropagation.mdcContext
Future {
MDCPropagation.withMDC(context) {
logger.info("Processing request") // Includes requestId and userId
performBusinessLogic()
}
}
}
}
Configure Logback to include MDC values:
<!-- logback.xml -->
<configuration>
<appender name="STDOUT" class="ch.qos.logback.core.ConsoleAppender">
<encoder>
<pattern>%d{HH:mm:ss.SSS} [%thread] %-5level %logger{36} [%X{requestId}] [%X{userId}] - %msg%n</pattern>
</encoder>
</appender>
<root level="INFO">
<appender-ref ref="STDOUT" />
</root>
</configuration>
Use By-Name Parameters for Performance
Avoid computing expensive log messages when logging is disabled:
// Bad: String concatenation happens regardless of log level
logger.debug("User data: " + expensiveUserDataSerialization(user))
// Good: scala-logging uses macros for lazy evaluation
logger.debug(s"User data: ${expensiveUserDataSerialization(user)}")
// Manual implementation for custom loggers
class CustomLogger(underlying: org.slf4j.Logger) {
def debug(msg: => String): Unit = {
if (underlying.isDebugEnabled) {
underlying.debug(msg)
}
}
def debugWithContext(msg: => String, ctx: => Map[String, Any]): Unit = {
if (underlying.isDebugEnabled) {
val context = ctx.map { case (k, v) => keyValue(k, v) }.toArray
underlying.debug(msg, context: _*)
}
}
}
Create Type-Safe Logging Events
Define log events as case classes for compile-time safety and consistency:
sealed trait LogEvent {
def message: String
def context: Map[String, Any]
}
case class UserCreated(userId: String, email: String) extends LogEvent {
val message = "User created"
val context = Map("userId" -> userId, "email" -> email)
}
case class PaymentProcessed(
orderId: String,
amount: BigDecimal,
currency: String
) extends LogEvent {
val message = "Payment processed"
val context = Map(
"orderId" -> orderId,
"amount" -> amount,
"currency" -> currency
)
}
class EventLogger(logger: Logger) {
def log(event: LogEvent, level: Level = Level.INFO): Unit = {
val args = event.context.map {
case (k, v) => keyValue(k, v)
}.toArray
level match {
case Level.DEBUG => logger.debug(event.message, args: _*)
case Level.INFO => logger.info(event.message, args: _*)
case Level.WARN => logger.warn(event.message, args: _*)
case Level.ERROR => logger.error(event.message, args: _*)
}
}
}
// Usage
val eventLogger = new EventLogger(logger)
eventLogger.log(UserCreated("usr-123", "user@example.com"))
eventLogger.log(PaymentProcessed("ord-456", 99.99, "USD"))
Handle Exceptions Properly
Always include exception stack traces and relevant context:
class DataProcessor extends LazyLogging {
def processData(data: Data): Try[Result] = {
Try {
validateData(data)
transformData(data)
}.recoverWith {
case e: ValidationException =>
logger.warn(
"Data validation failed",
keyValue("dataId", data.id),
keyValue("validationErrors", e.errors),
e
)
Failure(e)
case e: TransformationException =>
logger.error(
"Data transformation failed",
keyValue("dataId", data.id),
keyValue("step", e.failedStep),
e
)
Failure(e)
case e: Exception =>
logger.error(
"Unexpected error processing data",
keyValue("dataId", data.id),
keyValue("dataType", data.getClass.getSimpleName),
e
)
Failure(e)
}
}
}
Configure Log Levels Per Environment
Use Logback’s configuration to control verbosity:
<!-- logback.xml -->
<configuration>
<property name="LOG_LEVEL" value="${LOG_LEVEL:-INFO}" />
<appender name="JSON" class="ch.qos.logback.core.ConsoleAppender">
<encoder class="net.logstash.logback.encoder.LogstashEncoder">
<includeContext>true</includeContext>
<includeMdc>true</includeMdc>
</encoder>
</appender>
<!-- Application logs -->
<logger name="com.yourcompany" level="${LOG_LEVEL}" />
<!-- Reduce third-party noise -->
<logger name="org.apache.kafka" level="WARN" />
<logger name="akka.actor" level="WARN" />
<root level="INFO">
<appender-ref ref="JSON" />
</root>
</configuration>
Environment-specific overrides:
// application.conf
akka.loglevel = "INFO"
akka.loglevel = ${?AKKA_LOG_LEVEL}
// Start application with custom level
// java -DLOG_LEVEL=DEBUG -DAKKA_LOG_LEVEL=DEBUG -jar app.jar
Implement Sampling for High-Volume Logs
Rate-limit verbose logs in hot paths:
import java.util.concurrent.atomic.AtomicLong
class SampledLogger(logger: Logger, sampleRate: Int = 100) {
private val counter = new AtomicLong(0)
def debugSampled(msg: => String, args: => Array[StructuredArgument]): Unit = {
if (counter.incrementAndGet() % sampleRate == 0) {
logger.debug(msg, args: _*)
}
}
}
class HighThroughputProcessor extends LazyLogging {
private val sampledLogger = new SampledLogger(logger, sampleRate = 1000)
def processMessage(msg: Message): Unit = {
// Only log every 1000th message
sampledLogger.debugSampled(
"Processing message",
Array(keyValue("messageId", msg.id))
)
// Always log errors
if (msg.isInvalid) {
logger.error("Invalid message", keyValue("messageId", msg.id))
}
}
}
These patterns create maintainable logging infrastructure that scales from development to production, providing the observability needed for debugging distributed systems without sacrificing performance.