The otel4s logs module enables library authors to bridge logs from existing logging frameworks into OpenTelemetry. It is designed for integration, not as a replacement for application logging libraries.
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.
Overview
The API mirrors the OpenTelemetry Logs specification and focuses on structured, context-aware log records that flow into the same OpenTelemetry pipeline as traces and metrics.
When to Use This Module
Use the logs module when:
- Your library relies on a logging facade or implementation
- You want log events to flow into the OpenTelemetry pipeline
- You need to correlate logs with traces and metrics
- You want to export logs through OTLP
What This Module Does NOT Do
The logs module does not:
- Format console output
- Rotate log files
- Manage log levels of specific loggers
- Manage appenders
- Replace your logging framework
Core Concepts
There are three main interfaces:
LoggerProvider
Factory for creating Logger instances. Usually created during startup by the backend (otel4s-oteljava or otel4s-sdk).
trait LoggerProvider[F[_], Ctx] {
def get(name: String): LoggerBuilder[F, Ctx]
def logger(name: String): LoggerBuilder[F, Ctx]
}
Logger
Provides logRecordBuilder to create new log records:
trait Logger[F[_], Ctx] {
def meta: InstrumentMeta[F, Ctx]
def logRecordBuilder: LogRecordBuilder[F, Ctx]
}
LogRecordBuilder
Builder for setting timestamps, severity, attributes, body, and context, then emitting the record:
trait LogRecordBuilder[F[_], Ctx] {
def withTimestamp(timestamp: FiniteDuration): LogRecordBuilder[F, Ctx]
def withObservedTimestamp(timestamp: FiniteDuration): LogRecordBuilder[F, Ctx]
def withSeverity(severity: Severity): LogRecordBuilder[F, Ctx]
def withSeverityText(text: String): LogRecordBuilder[F, Ctx]
def withBody(body: AnyValue): LogRecordBuilder[F, Ctx]
def addAttribute[A](attribute: Attribute[A]): LogRecordBuilder[F, Ctx]
def addAttributes(attributes: Attribute[_]*): LogRecordBuilder[F, Ctx]
def withContext(context: Ctx): LogRecordBuilder[F, Ctx]
def emit: F[Unit]
}
Installation
For Library Authors
Use the otel4s-core-logs module without depending on a specific backend:
libraryDependencies += "org.typelevel" %% "otel4s-core-logs" % "0.15.0"
//> using dep "org.typelevel::otel4s-core-logs:0.15.0"
This allows users to choose their preferred otel4s backend.
For Application Developers
Use the full backend with exporters:
libraryDependencies ++= Seq(
"org.typelevel" %% "otel4s-oteljava" % "0.15.0",
"io.opentelemetry" % "opentelemetry-exporter-otlp" % "1.59.0" % Runtime,
"io.opentelemetry" % "opentelemetry-sdk-extension-autoconfigure" % "1.59.0" % Runtime
)
javaOptions += "-Dotel.java.global-autoconfigure.enabled=true"
//> using dep "org.typelevel::otel4s-oteljava:0.15.0"
//> using dep "io.opentelemetry:opentelemetry-exporter-otlp:1.59.0"
//> using dep "io.opentelemetry:opentelemetry-sdk-extension-autoconfigure:1.59.0"
//> using javaOpt "-Dotel.java.global-autoconfigure.enabled=true"
Getting a LoggerProvider
Create a LoggerProvider from the backend:
import cats.effect.{IO, IOApp}
import org.typelevel.otel4s.oteljava.OtelJava
import org.typelevel.otel4s.oteljava.context.Context
import org.typelevel.otel4s.logs.LoggerProvider
object TelemetryApp extends IOApp.Simple {
def run: IO[Unit] =
OtelJava
.autoConfigured[IO]()
.use { sdk =>
program(sdk.loggerProvider)
}
def program(
loggerProvider: LoggerProvider[IO, Context]
): IO[Unit] = ???
}
Building a Bridge
Here’s how to bridge logs from an existing framework into OpenTelemetry:
Basic Structure
- Get the current context
- Build a log record with severity, timestamp, body, and attributes
- Attach context so trace and span IDs propagate
- Emit the record
Example: Bridging Scribe
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.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)
logger <- provider.logger(r.className).withVersion("0.0.1").get
ctx <- local.ask
isEnabled <- logger.meta.isEnabled(ctx, toSeverity(r.level), None)
_ <- 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
.pipe { l =>
toSeverity(record.level).fold(l)(l.withSeverity)
}
.withSeverityText(record.level.name)
.withTimestamp(record.timeStamp.millis)
.withBody(AnyValue.string(record.logOutput.plainText))
.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))
}
)
}
.pipe { builder => builder.addAttributes(codePathAttributes(record)) }
.pipe { builder =>
record.messages
.collect { case scribe.throwable.TraceLoggableMessage(throwable) => throwable }
.foldLeft(builder)((b, t) => b.addAttributes(exceptionAttributes(t)))
}
.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: Long => builder += Attribute(key, v)
case v: Double => builder += Attribute(key, v)
case _ => // ignore or stringify
}
}
builder.result()
}
}
Using the Bridge
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")
)
}
}
}
The log record is automatically correlated with the current tracing context, allowing you to see logs alongside traces in your observability platform.
Best Practices for Library Authors
Timestamps
Use withObservedTimestamp for when your adapter observed the event. If your source event carries the original creation time, also set withTimestamp to that origin time.
logger.logRecordBuilder
.withTimestamp(originalEventTime)
.withObservedTimestamp(observedTime)
Attributes
Use semantic conventions when applicable:
import org.typelevel.otel4s.semconv.attributes._
// Code location
CodeAttributes.CodeFilePath("MyClass.scala")
CodeAttributes.CodeLineNumber(42L)
CodeAttributes.CodeFunctionName("processRequest")
// Exceptions
ExceptionAttributes.ExceptionType("java.lang.RuntimeException")
ExceptionAttributes.ExceptionMessage("Something failed")
ExceptionAttributes.ExceptionStacktrace(stackTrace)
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 automatically.
You can also explicitly inject context:
logger.logRecordBuilder
.withContext(specificContext)
.withBody(AnyValue.string("Log message"))
.emit
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.
Performance tips:
- Avoid heavy string concatenation on the hot path
- Prefer structured attributes over preformatted strings
- Keep callback computations lightweight
- Use lazy message evaluation when supported
Checking if Logging is Enabled
The logger.meta.isEnabled method checks whether the logging pipeline is active:
for {
ctx <- local.ask
enabled <- logger.meta.isEnabled(ctx, Severity.info, None)
_ <- if (enabled) buildAndEmitLogRecord() else Monad[F].unit
} yield ()
isEnabled checks if the OpenTelemetry logging pipeline is active, not whether a specific logger is enabled. If the pipeline is active, it always returns true. If the implementation is no-op, it always returns false.
Severity Levels
OpenTelemetry defines standard severity levels:
object Severity {
val trace: Severity
val debug: Severity
val info: Severity
val warn: Severity
val error: Severity
val fatal: Severity
}
Map your logging framework’s levels to these standard severities.
Type Signatures
trait LoggerProvider[F[_], Ctx] {
def get(name: String): LoggerBuilder[F, Ctx]
def logger(name: String): LoggerBuilder[F, Ctx]
}
trait Logger[F[_], Ctx] {
def meta: InstrumentMeta[F, Ctx]
def logRecordBuilder: LogRecordBuilder[F, Ctx]
}
trait LogRecordBuilder[F[_], Ctx] {
def withTimestamp(timestamp: FiniteDuration): LogRecordBuilder[F, Ctx]
def withObservedTimestamp(timestamp: FiniteDuration): LogRecordBuilder[F, Ctx]
def withSeverity(severity: Severity): LogRecordBuilder[F, Ctx]
def withSeverityText(text: String): LogRecordBuilder[F, Ctx]
def withBody(body: AnyValue): LogRecordBuilder[F, Ctx]
def addAttribute[A](attribute: Attribute[A]): LogRecordBuilder[F, Ctx]
def addAttributes(attributes: Attribute[_]*): LogRecordBuilder[F, Ctx]
def withContext(context: Ctx): LogRecordBuilder[F, Ctx]
def emit: F[Unit]
}