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.

Liked this? There's more.

Every week: one practical technique, explained simply, with code you can use immediately.