Skip to main content
The logging module is meant for library authors who want to bridge logs from an existing logging API into OpenTelemetry, not for application developers to replace their logging library.It is not a replacement for a full logging API.
The API mirrors the OpenTelemetry Logs spec and focuses on structured, context-aware log records.

When to Use This Module

Use it when your library already relies on a logging facade or implementation, and you want those log events to flow into the same OpenTelemetry pipeline as traces and metrics. The module does not:
  • Format console output
  • Rotate files
  • Manage log levels of a specific logger
  • Manage appenders
It only allows turning log events into OpenTelemetry LogRecords and exporting them through OTLP.

Target Audience

1

Library Authors

Provide an integration through a separate module, for example logs4cats-otel4s.
2

Application Developers

Keep their preferred logger and level management, for example logback or slf4j.

Core Concepts

You should bridge logs from your logging framework into OpenTelemetry as close to the source as possible. That means you implement a small adapter that, for each log event, does the following:
1

Build a LogRecordBuilder

Create a new empty log record builder.
2

Copy message and attributes

Transfer all relevant information from the source log event.
3

Attach current context

Ensure trace and span IDs propagate automatically.
4

Set severity and timestamp

Configure the log level and time information.
5

Emit the record

Send the log record to the OpenTelemetry pipeline.

Main Interfaces

There are three main interfaces:
  • LoggerProvider[F, Ctx] - factory for Logger. Usually created during startup by the backend, either otel4s-oteljava or otel4s-sdk.
  • Logger[F, Ctx] - use logRecordBuilder to create a new empty log record.
  • LogRecordBuilder[F, Ctx] - set timestamps, severity, attributes, body, and context, then call emit.
The Ctx parameter refers to the backend-specific context type. In most cases, you don’t need to use it directly.

Tips for Library Authors

Use otel4s-core-logs module, you don’t need to use a specific otel4s backend: otel4s-oteljava or otel4s-sdk. That way, users can choose their preferred otel4s backend.
Keep in mind that the logs module doesn’t manage log files, appenders, or log levels. It’s the responsibility of a logging library to decide whether the given logger is enabled.
However, you can use the logger.meta.isEnabled to check whether the logging pipeline is active:
  • If the pipeline is active, isEnabled will always return true regardless of the specific logger
  • If the implementation is no-op, isEnabled will always return false

Timestamps

Use withObservedTimestamp for the time your adapter observed the event. If your source event carries the original creation time, set withTimestamp to that origin time too.

Attributes

Use semantic conventions when applicable, for example:
  • code.filepath, code.lineno, code.function when provided by your logging framework
  • exception.type, exception.message, exception.stacktrace for failures
Prefer stable names and values that are easy to aggregate.

Context Propagation

Logs become far more valuable when they carry trace and span IDs. By default, otel4s uses the current context to propagate trace and span IDs. However, you can also use the withContext to inject a specific context into the log record.

Backpressure and Performance

The emit call sends the record into the processing pipeline. By default, the backend configures a batch processor, so the effect is usually non-blocking.
Avoid heavy string concatenation and unnecessary exception stack traces on the hot path. Prefer structured attributes over preformatted strings. If your logging facade supports lazy messages, keep that behavior and only build a log record when severity passes the library-level filter.

Integration Example with Scribe

Scribe is a fast, flexible, and asynchronous Scala logging library that provides rich features like log levels, structured logging, and customizable handlers. It is also available for all platforms: JVM, Scala.js, and Scala Native. Here is an example of how to forward scribe log events into OpenTelemetry using otel4s-core-logs:
import cats.Monad
import cats.mtl.Local
import cats.syntax.all._
import org.typelevel.otel4s.{AnyValue, Attribute, Attributes}
import org.typelevel.otel4s.logs.{LogRecordBuilder, LoggerProvider, Severity}
import org.typelevel.otel4s.logs.{Logger => OtelLogger}
import org.typelevel.otel4s.semconv.attributes.{CodeAttributes, ExceptionAttributes}

import scribe._

import java.io.{PrintWriter, StringWriter}

import scala.concurrent.duration._
import scala.util.chaining._

final class ScriberLoggerSupport[F[_]: Monad, Ctx](
  provider: LoggerProvider[F, Ctx],
  local: Local[F, Ctx]
) extends LoggerSupport[F[Unit]] {
  
  def log(record: => LogRecord): F[Unit] =
    for {
      r <- Monad[F].pure(record)
      // use the library version here
      logger <- provider.logger(r.className).withVersion("0.0.1").get

      // retrieve the current context
      ctx <- local.ask
      
      // Check if logging instrumentation is enabled for the current context.
      // NOTE: this does not check whether an individual logger is enabled.
      // If the OpenTelemetry logging pipeline (backed by OTLP) is active, 
      // `isEnabled` will always return true regardless of the specific logger 
      isEnabled <- logger.meta.isEnabled(ctx, toSeverity(r.level), None)
      
      // if enabled, build and emit the log record
      _ <- if (isEnabled) buildLogRecord(logger, r).emit else Monad[F].unit
    } yield ()

  private def buildLogRecord(
      logger: OtelLogger[F, Ctx], 
      record: LogRecord
  ): LogRecordBuilder[F, Ctx] =
    logger.logRecordBuilder
      // severity
      .pipe { l =>
        toSeverity(record.level).fold(l)(l.withSeverity)
      }
      .withSeverityText(record.level.name)
      // timestmap
      .withTimestamp(record.timeStamp.millis)
      // log message
      .withBody(AnyValue.string(record.logOutput.plainText))
      // thread info
      .pipe { builder =>
        builder.addAttributes(
          if (record.thread.getId != -1) {
            Attributes(
              Attribute("thread.id", record.thread.getId),
              Attribute("thread.name", record.thread.getName),
            )
          } else {
            Attributes(
              Attribute("thread.name", record.thread.getName),
            )
          }
        )
      }
      // code path info
      .pipe { builder =>
        builder.addAttributes(codePathAttributes(record))
      }
      // exception info
      .pipe { builder =>
        record.messages
          .collect  {
            case scribe.throwable.TraceLoggableMessage(throwable) => throwable
          }
          .foldLeft(builder)((b, t) => b.addAttributes(exceptionAttributes(t)))
      }
      // context
      // MDC
      .pipe { builder =>
        if (record.data.nonEmpty) builder.addAttributes(dataAttributes(record.data)) 
        else builder
      }

  private def toSeverity(level: Level): Option[Severity] =
    level match {
      case Level("TRACE", _) => Some(Severity.trace)
      case Level("DEBUG", _) => Some(Severity.debug)
      case Level("INFO", _)  => Some(Severity.info)
      case Level("WARN", _)  => Some(Severity.warn)
      case Level("ERROR", _) => Some(Severity.error)
      case Level("FATAL", _) => Some(Severity.fatal)
      case _                 => None
    }

  private def codePathAttributes(record: LogRecord): Attributes = {
    val builder = Attributes.newBuilder

    builder += Attribute("code.namespace", record.className)
    builder += CodeAttributes.CodeFilePath(record.fileName)
    builder ++= record.line.map(line => CodeAttributes.CodeLineNumber(line.toLong))
    builder ++= record.column.map(col => CodeAttributes.CodeColumnNumber(col.toLong))
    builder ++= record.methodName.map(name => CodeAttributes.CodeFunctionName(name))
    
    builder.result()
  }

  private def exceptionAttributes(exception: Throwable): Attributes = {
    val builder = Attributes.newBuilder

    builder += ExceptionAttributes.ExceptionType(exception.getClass.getName)

    val message = exception.getMessage
    if (message != null) {
      builder += ExceptionAttributes.ExceptionMessage(message)

    }
    
    if (exception.getStackTrace.nonEmpty) {
      val stringWriter = new StringWriter()
      val printWriter = new PrintWriter(stringWriter)

      exception.printStackTrace(printWriter)
      builder += ExceptionAttributes.ExceptionStacktrace(stringWriter.toString)
    }
    
    builder.result()
  }
  
  private def dataAttributes(data: Map[String, () => Any]): Attributes = {
    val builder = Attributes.newBuilder
    data.foreach { case (key, getValue) =>
      getValue() match {
        case v: String  => builder += Attribute(key, v)
        case v: Boolean => builder += Attribute(key, v)
        case v: Byte    => builder += Attribute(key, v.toLong)
        case v: Short   => builder += Attribute(key, v.toLong)
        case v: Int     => builder += Attribute(key, v.toLong)
        case v: Long    => builder += Attribute(key, v)
        case v: Double  => builder += Attribute(key, v)
        case v: Float   => builder += Attribute(key, v.toDouble)
        case _          => 
          // ignore the rest. 
          // alternatively, you can stringify the value:
          // builder += Attribute(key, v.toString)
      }
    }
    builder.result()
  }
}

Using the Integration

Then, you can use it in your application:
import org.typelevel.otel4s.Otel4s

def program[F[_]: Monad](otel4s: Otel4s[F]): F[Unit] = {
  val logger = new ScriberLoggerSupport(otel4s.loggerProvider, otel4s.localContext)

  otel4s.tracerProvider.get("tracer").flatMap { tracer =>
    tracer.spanBuilder("test-span").build.surround {
      logger.error(
        "something went wrong", 
        new RuntimeException("Oops, something went wrong")
      )
    }
  }
}
As you can see, the log record is automatically correlated with the current tracing context.

Getting a LoggerProvider

You can acquire a provider from either backend. You typically rely on autoconfiguration or a manual SDK builder that includes the OTLP exporter. The otel4s-oteljava uses the OpenTelemetry Java SDK under the hood.
Add settings to the build.sbt:
libraryDependencies ++= Seq(
  "org.typelevel" %% "otel4s-oteljava" % "0.15.0", // <1>
  "io.opentelemetry" % "opentelemetry-exporter-otlp" % "1.59.0" % Runtime, // <2>
  "io.opentelemetry" % "opentelemetry-sdk-extension-autoconfigure" % "1.59.0" % Runtime // <3>
)
javaOptions += "-Dotel.java.global-autoconfigure.enabled=true" // <4>
Then use OtelJava.autoConfigured to autoconfigure the SDK:
import cats.effect.{IO, IOApp}
import org.typelevel.otel4s.oteljava.context.Context
import org.typelevel.otel4s.oteljava.OtelJava
import org.typelevel.otel4s.metrics.MeterProvider
import org.typelevel.otel4s.trace.TracerProvider
import org.typelevel.otel4s.logs.LoggerProvider

object TelemetryApp extends IOApp.Simple {

  def run: IO[Unit] =
    OtelJava
      .autoConfigured[IO]()
      .use { sdk =>
        program(sdk.meterProvider, sdk.tracerProvider, sdk.loggerProvider)
      }

  def program(
      meterProvider: MeterProvider[IO], 
      tracerProvider: TracerProvider[IO],
      loggerProvider: LoggerProvider[IO, Context],
  ): IO[Unit] =
    ???
}
The .autoConfigured(...) relies on the environment variables and system properties to configure the SDK. For example, use export OTEL_SERVICE_NAME=auth-service to configure the name of the service. See the full set of the supported configuration options.

Build docs developers (and LLMs) love