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
Library Authors
Provide an integration through a separate module, for example logs4cats-otel4s.
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:
Build a LogRecordBuilder
Create a new empty log record builder.
Copy message and attributes
Transfer all relevant information from the source log event.
Attach current context
Ensure trace and span IDs propagate automatically.
Set severity and timestamp
Configure the log level and time information.
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.
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>
Add directives to the *.scala file://> using dep "org.typelevel::otel4s-oteljava:0.15.0" // <1>
//> using dep "io.opentelemetry:opentelemetry-exporter-otlp:1.59.0" // <2>
//> using dep "io.opentelemetry:opentelemetry-sdk-extension-autoconfigure:1.59.0" // <3>
//> using javaOpt "-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.