From 64427712702d8c6ab0cc12ad716a4a2d515afe06 Mon Sep 17 00:00:00 2001
From: eikek <eike.kettner@posteo.de>
Date: Sat, 19 Feb 2022 02:18:25 +0100
Subject: [PATCH 01/10] Add a new module to take care of logging

It is based on outr/scribe; mainly providing a json log format and
much easier configuration.
---
 build.sbt                                     |  14 ++
 .../main/scala/docspell/logging/Level.scala   |  57 ++++++++
 .../scala/docspell/logging/LogConfig.scala    |  45 ++++++
 .../scala/docspell/logging/LogEvent.scala     |  43 ++++++
 .../main/scala/docspell/logging/Logger.scala  | 128 ++++++++++++++++++
 .../docspell/logging/impl/JsonWriter.scala    |  26 ++++
 .../docspell/logging/impl/LogfmtWriter.scala  |  32 +++++
 .../docspell/logging/impl/LoggerWrapper.scala |  52 +++++++
 .../scala/docspell/logging/impl/Record.scala  | 120 ++++++++++++++++
 .../logging/impl/ScribeConfigure.scala        |  54 ++++++++
 project/Dependencies.scala                    |  16 +++
 11 files changed, 587 insertions(+)
 create mode 100644 modules/logging/src/main/scala/docspell/logging/Level.scala
 create mode 100644 modules/logging/src/main/scala/docspell/logging/LogConfig.scala
 create mode 100644 modules/logging/src/main/scala/docspell/logging/LogEvent.scala
 create mode 100644 modules/logging/src/main/scala/docspell/logging/Logger.scala
 create mode 100644 modules/logging/src/main/scala/docspell/logging/impl/JsonWriter.scala
 create mode 100644 modules/logging/src/main/scala/docspell/logging/impl/LogfmtWriter.scala
 create mode 100644 modules/logging/src/main/scala/docspell/logging/impl/LoggerWrapper.scala
 create mode 100644 modules/logging/src/main/scala/docspell/logging/impl/Record.scala
 create mode 100644 modules/logging/src/main/scala/docspell/logging/impl/ScribeConfigure.scala

diff --git a/build.sbt b/build.sbt
index baff161a..00505aec 100644
--- a/build.sbt
+++ b/build.sbt
@@ -313,6 +313,19 @@ val common = project
         Dependencies.calevCirce
   )
 
+val logging = project
+  .in(file("modules/logging"))
+  .disablePlugins(RevolverPlugin)
+  .settings(sharedSettings)
+  .settings(testSettingsMUnit)
+  .settings(
+    name := "docspell-logging",
+    libraryDependencies ++=
+      Dependencies.scribe ++
+        Dependencies.catsEffect ++
+        Dependencies.circeCore
+  )
+
 val config = project
   .in(file("modules/config"))
   .disablePlugins(RevolverPlugin)
@@ -869,6 +882,7 @@ val root = project
   )
   .aggregate(
     common,
+    logging,
     config,
     extract,
     convert,
diff --git a/modules/logging/src/main/scala/docspell/logging/Level.scala b/modules/logging/src/main/scala/docspell/logging/Level.scala
new file mode 100644
index 00000000..3600ea93
--- /dev/null
+++ b/modules/logging/src/main/scala/docspell/logging/Level.scala
@@ -0,0 +1,57 @@
+/*
+ * Copyright 2020 Eike K. & Contributors
+ *
+ * SPDX-License-Identifier: AGPL-3.0-or-later
+ */
+
+package docspell.logging
+
+import cats.Order
+import cats.data.NonEmptyList
+
+import io.circe.{Decoder, Encoder}
+
+sealed trait Level { self: Product =>
+  val name: String =
+    productPrefix.toUpperCase
+
+  val value: Double
+}
+
+object Level {
+  case object Fatal extends Level {
+    val value = 600.0
+  }
+  case object Error extends Level {
+    val value = 500.0
+  }
+  case object Warn extends Level {
+    val value = 400.0
+  }
+  case object Info extends Level {
+    val value = 300.0
+  }
+  case object Debug extends Level {
+    val value = 200.0
+  }
+  case object Trace extends Level {
+    val value = 100.0
+  }
+
+  val all: NonEmptyList[Level] =
+    NonEmptyList.of(Fatal, Error, Warn, Info, Debug, Trace)
+
+  def fromString(str: String): Either[String, Level] = {
+    val s = str.toUpperCase
+    all.find(_.name == s).toRight(s"Invalid level name: $str")
+  }
+
+  implicit val order: Order[Level] =
+    Order.by(_.value)
+
+  implicit val jsonEncoder: Encoder[Level] =
+    Encoder.encodeString.contramap(_.name)
+
+  implicit val jsonDecoder: Decoder[Level] =
+    Decoder.decodeString.emap(fromString)
+}
diff --git a/modules/logging/src/main/scala/docspell/logging/LogConfig.scala b/modules/logging/src/main/scala/docspell/logging/LogConfig.scala
new file mode 100644
index 00000000..daaae3e3
--- /dev/null
+++ b/modules/logging/src/main/scala/docspell/logging/LogConfig.scala
@@ -0,0 +1,45 @@
+/*
+ * Copyright 2020 Eike K. & Contributors
+ *
+ * SPDX-License-Identifier: AGPL-3.0-or-later
+ */
+
+package docspell.logging
+
+import cats.data.NonEmptyList
+
+import io.circe.{Decoder, Encoder}
+
+final case class LogConfig(minimumLevel: Level, format: LogConfig.Format) {}
+
+object LogConfig {
+
+  sealed trait Format { self: Product =>
+    def name: String =
+      productPrefix.toLowerCase
+  }
+  object Format {
+    case object Plain extends Format
+    case object Fancy extends Format
+    case object Json extends Format
+    case object Logfmt extends Format
+
+    val all: NonEmptyList[Format] =
+      NonEmptyList.of(Plain, Fancy, Json, Logfmt)
+
+    def fromString(str: String): Either[String, Format] =
+      all.find(_.name.equalsIgnoreCase(str)).toRight(s"Invalid format name: $str")
+
+    implicit val jsonDecoder: Decoder[Format] =
+      Decoder.decodeString.emap(fromString)
+
+    implicit val jsonEncoder: Encoder[Format] =
+      Encoder.encodeString.contramap(_.name)
+  }
+
+  implicit val jsonDecoder: Decoder[LogConfig] =
+    Decoder.forProduct2("minimumLevel", "format")(LogConfig.apply)
+
+  implicit val jsonEncoder: Encoder[LogConfig] =
+    Encoder.forProduct2("minimumLevel", "format")(r => (r.minimumLevel, r.format))
+}
diff --git a/modules/logging/src/main/scala/docspell/logging/LogEvent.scala b/modules/logging/src/main/scala/docspell/logging/LogEvent.scala
new file mode 100644
index 00000000..4a52d57d
--- /dev/null
+++ b/modules/logging/src/main/scala/docspell/logging/LogEvent.scala
@@ -0,0 +1,43 @@
+/*
+ * Copyright 2020 Eike K. & Contributors
+ *
+ * SPDX-License-Identifier: AGPL-3.0-or-later
+ */
+
+package docspell.logging
+
+import io.circe.{Encoder, Json}
+import sourcecode._
+
+final case class LogEvent(
+    level: Level,
+    msg: () => String,
+    additional: List[() => LogEvent.AdditionalMsg],
+    data: Map[String, () => Json],
+    pkg: Pkg,
+    fileName: FileName,
+    name: Name,
+    line: Line
+) {
+
+  def data[A: Encoder](key: String, value: => A): LogEvent =
+    copy(data = data.updated(key, () => Encoder[A].apply(value)))
+
+  def addMessage(msg: => String): LogEvent =
+    copy(additional = (() => Left(msg)) :: additional)
+
+  def addError(ex: Throwable): LogEvent =
+    copy(additional = (() => Right(ex)) :: additional)
+}
+
+object LogEvent {
+
+  type AdditionalMsg = Either[String, Throwable]
+
+  def of(l: Level, m: => String)(implicit
+      pkg: Pkg,
+      fileName: FileName,
+      name: Name,
+      line: Line
+  ): LogEvent = LogEvent(l, () => m, Nil, Map.empty, pkg, fileName, name, line)
+}
diff --git a/modules/logging/src/main/scala/docspell/logging/Logger.scala b/modules/logging/src/main/scala/docspell/logging/Logger.scala
new file mode 100644
index 00000000..e4d7ec47
--- /dev/null
+++ b/modules/logging/src/main/scala/docspell/logging/Logger.scala
@@ -0,0 +1,128 @@
+/*
+ * Copyright 2020 Eike K. & Contributors
+ *
+ * SPDX-License-Identifier: AGPL-3.0-or-later
+ */
+
+package docspell.logging
+
+import cats.Id
+import cats.effect.Sync
+
+import docspell.logging.impl.LoggerWrapper
+
+import sourcecode._
+
+trait Logger[F[_]] {
+
+  def log(ev: LogEvent): F[Unit]
+
+  def asUnsafe: Logger[Id]
+
+  def trace(msg: => String)(implicit
+      pkg: Pkg,
+      fileName: FileName,
+      name: Name,
+      line: Line
+  ): F[Unit] =
+    log(LogEvent.of(Level.Trace, msg))
+
+  def traceWith(msg: => String)(modify: LogEvent => LogEvent)(implicit
+      pkg: Pkg,
+      fileName: FileName,
+      name: Name,
+      line: Line
+  ): F[Unit] =
+    log(modify(LogEvent.of(Level.Trace, msg)))
+
+  def debug(msg: => String)(implicit
+      pkg: Pkg,
+      fileName: FileName,
+      name: Name,
+      line: Line
+  ): F[Unit] =
+    log(LogEvent.of(Level.Debug, msg))
+
+  def debugWith(msg: => String)(modify: LogEvent => LogEvent)(implicit
+      pkg: Pkg,
+      fileName: FileName,
+      name: Name,
+      line: Line
+  ): F[Unit] =
+    log(modify(LogEvent.of(Level.Debug, msg)))
+
+  def info(msg: => String)(implicit
+      pkg: Pkg,
+      fileName: FileName,
+      name: Name,
+      line: Line
+  ): F[Unit] =
+    log(LogEvent.of(Level.Info, msg))
+
+  def infoWith(msg: => String)(modify: LogEvent => LogEvent)(implicit
+      pkg: Pkg,
+      fileName: FileName,
+      name: Name,
+      line: Line
+  ): F[Unit] =
+    log(modify(LogEvent.of(Level.Info, msg)))
+
+  def warn(msg: => String)(implicit
+      pkg: Pkg,
+      fileName: FileName,
+      name: Name,
+      line: Line
+  ): F[Unit] =
+    log(LogEvent.of(Level.Warn, msg))
+
+  def warnWith(msg: => String)(modify: LogEvent => LogEvent)(implicit
+      pkg: Pkg,
+      fileName: FileName,
+      name: Name,
+      line: Line
+  ): F[Unit] =
+    log(modify(LogEvent.of(Level.Warn, msg)))
+
+  def warn(ex: Throwable)(msg: => String)(implicit
+      pkg: Pkg,
+      fileName: FileName,
+      name: Name,
+      line: Line
+  ): F[Unit] =
+    log(LogEvent.of(Level.Warn, msg).addError(ex))
+
+  def error(msg: => String)(implicit
+      pkg: Pkg,
+      fileName: FileName,
+      name: Name,
+      line: Line
+  ): F[Unit] =
+    log(LogEvent.of(Level.Error, msg))
+
+  def errorWith(msg: => String)(modify: LogEvent => LogEvent)(implicit
+      pkg: Pkg,
+      fileName: FileName,
+      name: Name,
+      line: Line
+  ): F[Unit] =
+    log(modify(LogEvent.of(Level.Error, msg)))
+
+  def error(ex: Throwable)(msg: => String)(implicit
+      pkg: Pkg,
+      fileName: FileName,
+      name: Name,
+      line: Line
+  ): F[Unit] =
+    log(LogEvent.of(Level.Error, msg).addError(ex))
+}
+
+object Logger {
+  def unsafe(name: String): Logger[Id] =
+    new LoggerWrapper.ImplUnsafe(scribe.Logger(name))
+
+  def apply[F[_]: Sync](name: String): Logger[F] =
+    new LoggerWrapper.Impl[F](scribe.Logger(name))
+
+  def apply[F[_]: Sync](clazz: Class[_]): Logger[F] =
+    new LoggerWrapper.Impl[F](scribe.Logger(clazz.getName))
+}
diff --git a/modules/logging/src/main/scala/docspell/logging/impl/JsonWriter.scala b/modules/logging/src/main/scala/docspell/logging/impl/JsonWriter.scala
new file mode 100644
index 00000000..28ede7f9
--- /dev/null
+++ b/modules/logging/src/main/scala/docspell/logging/impl/JsonWriter.scala
@@ -0,0 +1,26 @@
+/*
+ * Copyright 2020 Eike K. & Contributors
+ *
+ * SPDX-License-Identifier: AGPL-3.0-or-later
+ */
+
+package docspell.logging.impl
+
+import io.circe.syntax._
+import scribe.LogRecord
+import scribe.output.format.OutputFormat
+import scribe.output.{LogOutput, TextOutput}
+import scribe.writer.Writer
+
+final case class JsonWriter(writer: Writer, compact: Boolean = true) extends Writer {
+  override def write[M](
+      record: LogRecord[M],
+      output: LogOutput,
+      outputFormat: OutputFormat
+  ): Unit = {
+    val r = Record.fromLogRecord(record)
+    val json = r.asJson
+    val jsonString = if (compact) json.noSpaces else json.spaces2
+    writer.write(record, new TextOutput(jsonString), outputFormat)
+  }
+}
diff --git a/modules/logging/src/main/scala/docspell/logging/impl/LogfmtWriter.scala b/modules/logging/src/main/scala/docspell/logging/impl/LogfmtWriter.scala
new file mode 100644
index 00000000..f7f418f0
--- /dev/null
+++ b/modules/logging/src/main/scala/docspell/logging/impl/LogfmtWriter.scala
@@ -0,0 +1,32 @@
+/*
+ * Copyright 2020 Eike K. & Contributors
+ *
+ * SPDX-License-Identifier: AGPL-3.0-or-later
+ */
+
+package docspell.logging.impl
+
+import io.circe.syntax._
+import scribe.LogRecord
+import scribe.output.format.OutputFormat
+import scribe.output.{LogOutput, TextOutput}
+import scribe.writer.Writer
+
+// https://brandur.org/logfmt
+final case class LogfmtWriter(writer: Writer) extends Writer {
+  override def write[M](
+      record: LogRecord[M],
+      output: LogOutput,
+      outputFormat: OutputFormat
+  ): Unit = {
+    val r = Record.fromLogRecord(record)
+    val data = r.data
+      .map { case (k, v) =>
+        s"$k=${v.noSpaces}"
+      }
+      .mkString(" ")
+    val logfmtStr =
+      s"""level=${r.level.asJson.noSpaces} levelValue=${r.levelValue} message=${r.message.asJson.noSpaces} fileName=${r.fileName.asJson.noSpaces} className=${r.className.asJson.noSpaces} methodName=${r.methodName.asJson.noSpaces} line=${r.line.asJson.noSpaces} column=${r.column.asJson.noSpaces} $data timestamp=${r.timeStamp} date=${r.date} time=${r.time}"""
+    writer.write(record, new TextOutput(logfmtStr), outputFormat)
+  }
+}
diff --git a/modules/logging/src/main/scala/docspell/logging/impl/LoggerWrapper.scala b/modules/logging/src/main/scala/docspell/logging/impl/LoggerWrapper.scala
new file mode 100644
index 00000000..19fc0f66
--- /dev/null
+++ b/modules/logging/src/main/scala/docspell/logging/impl/LoggerWrapper.scala
@@ -0,0 +1,52 @@
+/*
+ * Copyright 2020 Eike K. & Contributors
+ *
+ * SPDX-License-Identifier: AGPL-3.0-or-later
+ */
+
+package docspell.logging.impl
+
+import cats.Id
+import cats.effect._
+
+import docspell.logging._
+
+import scribe.LoggerSupport
+import scribe.message.{LoggableMessage, Message}
+
+private[logging] object LoggerWrapper {
+  final class ImplUnsafe(log: scribe.Logger) extends Logger[Id] {
+    override def asUnsafe = this
+
+    override def log(ev: LogEvent): Unit =
+      log.log(convert(ev))
+  }
+  final class Impl[F[_]: Sync](log: scribe.Logger) extends Logger[F] {
+    override def asUnsafe = new ImplUnsafe(log)
+
+    override def log(ev: LogEvent) =
+      Sync[F].delay(log.log(convert(ev)))
+  }
+
+  private[impl] def convertLevel(l: Level): scribe.Level =
+    l match {
+      case Level.Fatal => scribe.Level.Fatal
+      case Level.Error => scribe.Level.Error
+      case Level.Warn  => scribe.Level.Warn
+      case Level.Info  => scribe.Level.Info
+      case Level.Debug => scribe.Level.Debug
+      case Level.Trace => scribe.Level.Trace
+    }
+
+  private[this] def convert(ev: LogEvent) = {
+    val level = convertLevel(ev.level)
+    val additional: List[LoggableMessage] = ev.additional.map { x =>
+      x() match {
+        case Right(ex) => Message.static(ex)
+        case Left(msg) => Message.static(msg)
+      }
+    }
+    LoggerSupport(level, ev.msg(), additional, ev.pkg, ev.fileName, ev.name, ev.line)
+      .copy(data = ev.data)
+  }
+}
diff --git a/modules/logging/src/main/scala/docspell/logging/impl/Record.scala b/modules/logging/src/main/scala/docspell/logging/impl/Record.scala
new file mode 100644
index 00000000..68123a5f
--- /dev/null
+++ b/modules/logging/src/main/scala/docspell/logging/impl/Record.scala
@@ -0,0 +1,120 @@
+/*
+ * Copyright 2020 Eike K. & Contributors
+ *
+ * SPDX-License-Identifier: AGPL-3.0-or-later
+ */
+
+package docspell.logging.impl
+
+import docspell.logging.impl.Record._
+
+import io.circe.syntax._
+import io.circe.{Encoder, Json}
+import perfolation._
+import scribe.LogRecord
+import scribe.data.MDC
+import scribe.message.Message
+
+// From: https://github.com/outr/scribe/blob/8e99521e1ee1f0c421629764dd96e4eb193d84bd/json/shared/src/main/scala/scribe/json/JsonWriter.scala
+// which would introduce jackson and other dependencies. Modified to work with circe.
+// Original licensed under MIT.
+
+private[impl] case class Record(
+    level: String,
+    levelValue: Double,
+    message: String,
+    additionalMessages: List[String],
+    fileName: String,
+    className: String,
+    methodName: Option[String],
+    line: Option[Int],
+    column: Option[Int],
+    data: Map[String, Json],
+    traces: List[Trace],
+    timeStamp: Long,
+    date: String,
+    time: String
+)
+
+private[impl] object Record {
+
+  def fromLogRecord[M](record: LogRecord[M]): Record = {
+    val l = record.timeStamp
+    val traces = record.additionalMessages.collect {
+      case message: Message[_] if message.value.isInstanceOf[Throwable] =>
+        throwable2Trace(message.value.asInstanceOf[Throwable])
+    }
+    val additionalMessages = record.additionalMessages.map(_.logOutput.plainText)
+
+    Record(
+      level = record.level.name,
+      levelValue = record.levelValue,
+      message = record.logOutput.plainText,
+      additionalMessages = additionalMessages,
+      fileName = record.fileName,
+      className = record.className,
+      methodName = record.methodName,
+      line = record.line,
+      column = record.column,
+      data = (record.data ++ MDC.map).map { case (key, value) =>
+        value() match {
+          case value: Json   => key -> value
+          case value: Int    => key -> value.asJson
+          case value: Long   => key -> value.asJson
+          case value: Double => key -> value.asJson
+          case any           => key -> Json.fromString(any.toString)
+        }
+      },
+      traces = traces,
+      timeStamp = l,
+      date = l.t.F,
+      time = s"${l.t.T}.${l.t.L}${l.t.z}"
+    )
+  }
+
+  private def throwable2Trace(throwable: Throwable): Trace = {
+    val elements = throwable.getStackTrace.toList.map { e =>
+      TraceElement(e.getClassName, e.getMethodName, e.getLineNumber)
+    }
+    Trace(
+      throwable.getLocalizedMessage,
+      elements,
+      Option(throwable.getCause).map(throwable2Trace)
+    )
+  }
+
+  implicit val jsonEncoder: Encoder[Record] =
+    Encoder.forProduct14(
+      "level",
+      "levelValue",
+      "message",
+      "additionalMessages",
+      "fileName",
+      "className",
+      "methodName",
+      "line",
+      "column",
+      "data",
+      "traces",
+      "timestamp",
+      "date",
+      "time"
+    )(r => Record.unapply(r).get)
+
+  case class Trace(message: String, elements: List[TraceElement], cause: Option[Trace])
+
+  object Trace {
+    implicit def jsonEncoder: Encoder[Trace] =
+      Encoder.forProduct3("message", "elements", "cause")(r => Trace.unapply(r).get)
+
+    implicit def openEncoder: Encoder[Option[Trace]] =
+      Encoder.instance(opt => opt.map(jsonEncoder.apply).getOrElse(Json.Null))
+  }
+
+  case class TraceElement(`class`: String, method: String, line: Int)
+
+  object TraceElement {
+    implicit val jsonEncoder: Encoder[TraceElement] =
+      Encoder.forProduct3("class", "method", "line")(r => TraceElement.unapply(r).get)
+  }
+}
diff --git a/modules/logging/src/main/scala/docspell/logging/impl/ScribeConfigure.scala b/modules/logging/src/main/scala/docspell/logging/impl/ScribeConfigure.scala
new file mode 100644
index 00000000..8620480e
--- /dev/null
+++ b/modules/logging/src/main/scala/docspell/logging/impl/ScribeConfigure.scala
@@ -0,0 +1,54 @@
+/*
+ * Copyright 2020 Eike K. & Contributors
+ *
+ * SPDX-License-Identifier: AGPL-3.0-or-later
+ */
+
+package docspell.logging.impl
+
+import cats.effect._
+
+import docspell.logging.LogConfig
+import docspell.logging.LogConfig.Format
+
+import scribe.format.Formatter
+import scribe.jul.JULHandler
+import scribe.writer.ConsoleWriter
+
+object ScribeConfigure {
+
+  def configure[F[_]: Sync](cfg: LogConfig): F[Unit] =
+    Sync[F].delay {
+      replaceJUL()
+      unsafeConfigure(scribe.Logger.root, cfg)
+    }
+
+  def unsafeConfigure(logger: scribe.Logger, cfg: LogConfig): Unit = {
+    val mods = List[scribe.Logger => scribe.Logger](
+      _.clearHandlers(),
+      _.withMinimumLevel(LoggerWrapper.convertLevel(cfg.minimumLevel)),
+      l =>
+        cfg.format match {
+          case Format.Fancy =>
+            l.withHandler(formatter = Formatter.enhanced)
+          case Format.Plain =>
+            l.withHandler(formatter = Formatter.classic)
+          case Format.Json =>
+            l.withHandler(writer = JsonWriter(ConsoleWriter))
+          case Format.Logfmt =>
+            l.withHandler(writer = LogfmtWriter(ConsoleWriter))
+        },
+      _.replace()
+    )
+
+    mods.foldLeft(logger)((l, mod) => mod(l))
+    ()
+  }
+
+  def replaceJUL(): Unit = {
+    scribe.Logger.system // just to load effects in Logger singleton
+    val julRoot = java.util.logging.LogManager.getLogManager.getLogger("")
+    julRoot.getHandlers.foreach(julRoot.removeHandler)
+    julRoot.addHandler(JULHandler)
+  }
+}
diff --git a/project/Dependencies.scala b/project/Dependencies.scala
index 24282a77..3633ccbd 100644
--- a/project/Dependencies.scala
+++ b/project/Dependencies.scala
@@ -9,6 +9,8 @@ object Dependencies {
   val BetterMonadicForVersion = "0.3.1"
   val BinnyVersion = "0.3.0"
   val CalevVersion = "0.6.1"
+  val CatsVersion = "2.7.0"
+  val CatsEffectVersion = "3.3.5"
   val CatsParseVersion = "0.3.6"
   val CirceVersion = "0.14.1"
   val ClipboardJsVersion = "2.0.6"
@@ -40,6 +42,7 @@ object Dependencies {
   val PureConfigVersion = "0.17.1"
   val ScalaJavaTimeVersion = "2.3.0"
   val ScodecBitsVersion = "1.1.30"
+  val ScribeVersion = "3.7.0"
   val Slf4jVersion = "1.7.36"
   val StanfordNlpVersion = "4.4.0"
   val TikaVersion = "2.3.0"
@@ -49,6 +52,11 @@ object Dependencies {
   val TwelveMonkeysVersion = "3.8.1"
   val JQueryVersion = "3.5.1"
 
+  val scribe = Seq(
+    "com.outr" %% "scribe" % ScribeVersion,
+    "com.outr" %% "scribe-slf4j" % ScribeVersion
+  )
+
   val jwtScala = Seq(
     "com.github.jwt-scala" %% "jwt-circe" % JwtScalaVersion
   )
@@ -67,6 +75,14 @@ object Dependencies {
     "com.dimafeng" %% "testcontainers-scala-postgresql" % TestContainerVersion
   )
 
+  val cats = Seq(
+    "org.typelevel" %% "cats-core" % CatsVersion
+  )
+
+  val catsEffect = Seq(
+    "org.typelevel" %% "cats-effect" % CatsEffectVersion
+  )
+
   val catsParse = Seq(
     "org.typelevel" %% "cats-parse" % CatsParseVersion
   )

From e483a97de7d67506831d8bc096785d32128895fd Mon Sep 17 00:00:00 2001
From: eikek <eike.kettner@posteo.de>
Date: Sat, 19 Feb 2022 14:00:47 +0100
Subject: [PATCH 02/10] Adopt to new loggin api

---
 build.sbt                                     | 133 ++++++++--------
 .../docspell/analysis/TextAnalyser.scala      |  10 +-
 .../classifier/StanfordTextClassifier.scala   |   1 +
 .../analysis/classifier/TextClassifier.scala  |   2 +-
 .../docspell/analysis/nlp/Annotator.scala     |   1 +
 .../analysis/nlp/BasicCRFAnnotator.scala      |   3 +-
 .../docspell/analysis/nlp/PipelineCache.scala |  14 +-
 .../analysis/nlp/StanfordNerAnnotator.scala   |   3 +-
 .../StanfordTextClassifierSuite.scala         |   2 +-
 .../scala/docspell/backend/auth/Login.scala   |   7 +-
 .../backend/fulltext/CreateIndex.scala        |   1 +
 .../scala/docspell/backend/item/Merge.scala   |   1 +
 .../backend/ops/OClientSettings.scala         |   4 +-
 .../docspell/backend/ops/OCustomFields.scala  |   5 +-
 .../docspell/backend/ops/OFulltext.scala      |  11 +-
 .../scala/docspell/backend/ops/OItem.scala    |   4 +-
 .../scala/docspell/backend/ops/OJob.scala     |   2 +-
 .../scala/docspell/backend/ops/ONode.scala    |  10 +-
 .../docspell/backend/ops/ONotification.scala  |  15 +-
 .../scala/docspell/backend/ops/OShare.scala   |   2 +-
 .../scala/docspell/backend/ops/OTotp.scala    |   7 +-
 .../scala/docspell/backend/ops/OUpload.scala  |  13 +-
 .../docspell/backend/signup/OSignup.scala     |   6 +-
 .../main/scala/docspell/common/LogLevel.scala |  12 ++
 .../main/scala/docspell/common/Logger.scala   | 143 ------------------
 .../scala/docspell/common/SystemCommand.scala |   2 +
 .../docspell/common/syntax/LoggerSyntax.scala |  42 -----
 .../docspell/common/syntax/package.scala      |   7 +-
 .../scala/docspell/config/ConfigFactory.scala |   2 +-
 .../scala/docspell/config/Implicits.scala     |   7 +
 .../scala/docspell/convert/Conversion.scala   |   3 +-
 .../convert/RemovePdfEncryption.scala         |   8 +-
 .../docspell/convert/extern/ExternConv.scala  |   1 +
 .../docspell/convert/extern/OcrMyPdf.scala    |   1 +
 .../docspell/convert/extern/Tesseract.scala   |   1 +
 .../docspell/convert/extern/Unoconv.scala     |   1 +
 .../docspell/convert/extern/WkHtmlPdf.scala   |   1 +
 .../docspell/convert/ConversionTest.scala     |   3 +-
 .../convert/RemovePdfEncryptionTest.scala     |   3 +-
 .../convert/extern/ExternConvTest.scala       |   3 +-
 .../scala/docspell/extract/Extraction.scala   |   1 +
 .../scala/docspell/extract/PdfExtract.scala   |   3 +-
 .../main/scala/docspell/extract/ocr/Ocr.scala |   1 +
 .../docspell/extract/ocr/TextExtract.scala    |   1 +
 .../extract/pdfbox/PdfboxPreview.scala        |   2 +-
 .../extract/ocr/TextExtractionSuite.scala     |   3 +-
 .../scala/docspell/ftsclient/FtsClient.scala  |   5 +-
 .../docspell/ftssolr/SolrFtsClient.scala      |  13 +-
 .../joex/src/main/resources/reference.conf    |  11 ++
 .../src/main/scala/docspell/joex/Config.scala |   2 +
 .../main/scala/docspell/joex/ConfigFile.scala |   3 +-
 .../scala/docspell/joex/JoexAppImpl.scala     |   6 +-
 .../src/main/scala/docspell/joex/Main.scala   |   7 +-
 .../docspell/joex/analysis/RegexNerFile.scala |  15 +-
 .../scala/docspell/joex/fts/FtsContext.scala  |   2 +-
 .../scala/docspell/joex/fts/FtsWork.scala     |   1 +
 .../scala/docspell/joex/fts/Migration.scala   |   1 +
 .../docspell/joex/hk/CheckNodesTask.scala     |   1 +
 .../scala/docspell/joex/learn/Classify.scala  |   1 +
 .../joex/learn/LearnClassifierTask.scala      |   1 +
 .../joex/learn/StoreClassifierModel.scala     |   1 +
 .../scala/docspell/joex/mail/ReadMail.scala   |   1 +
 .../docspell/joex/notify/TaskOperations.scala |   1 +
 .../joex/process/CrossCheckProposals.scala    |   1 +
 .../docspell/joex/process/ReProcessItem.scala |   2 +-
 .../docspell/joex/process/TestTasks.scala     |  45 ------
 .../joex/scanmailbox/ScanMailboxTask.scala    |   1 +
 .../docspell/joex/scheduler/Context.scala     |  13 +-
 .../docspell/joex/scheduler/LogSink.scala     |  18 +--
 .../scheduler/PeriodicSchedulerImpl.scala     |  34 ++---
 .../docspell/joex/scheduler/QueueLogger.scala |  32 ++--
 .../joex/scheduler/SchedulerImpl.scala        |  54 ++++---
 .../scala/docspell/joex/scheduler/Task.scala  |   2 +-
 .../docspell/joexapi/client/JoexClient.scala  |  11 +-
 .../docspell/logging/AndThenLogger.scala      |  39 +++++
 .../main/scala/docspell/logging/Level.scala   |   0
 .../scala/docspell/logging/LogConfig.scala    |   0
 .../scala/docspell/logging/LogEvent.scala     |   8 +
 .../main/scala/docspell/logging/Logger.scala  |  58 +++++--
 .../docspell/logging/LoggerExtension.scala    |  27 ++++
 .../docspell/logging/impl/JsonWriter.scala    |   6 +-
 .../docspell/logging/impl/LogfmtWriter.scala  |   6 +-
 .../scala/docspell/logging/impl/Record.scala  |   0
 .../logging/impl/ScribeConfigure.scala        |   4 +-
 .../logging/impl/ScribeWrapper.scala}         |   6 +-
 .../main/scala/docspell/logging/package.scala |  33 ++++
 .../notification/api/EventExchange.scala      |   8 +-
 .../api/NotificationBackend.scala             |   2 +-
 .../notification/api/NotificationModule.scala |   2 +-
 .../notification/impl/EmailBackend.scala      |   2 +-
 .../impl/EventContextSyntax.scala             |   2 +-
 .../notification/impl/EventNotify.scala       |   8 +-
 .../notification/impl/GotifyBackend.scala     |   2 +-
 .../notification/impl/HttpPostBackend.scala   |   2 +-
 .../docspell/notification/impl/HttpSend.scala |   2 +-
 .../notification/impl/MatrixBackend.scala     |   2 +-
 .../impl/NotificationBackendImpl.scala        |   2 +-
 .../impl/NotificationModuleImpl.scala         |   2 +-
 .../main/scala/docspell/oidc/CodeFlow.scala   |  20 +--
 .../scala/docspell/oidc/CodeFlowRoutes.scala  |   4 +-
 .../main/scala/docspell/oidc/OnUserInfo.scala |  12 +-
 .../scala/docspell/pubsub/api/PubSubT.scala   |   6 +-
 .../docspell/pubsub/naive/NaivePubSub.scala   |   5 +-
 .../docspell/pubsub/naive/Fixtures.scala      |   3 +-
 .../docspell/pubsub/naive/HttpClientOps.scala |   2 +-
 .../pubsub/naive/NaivePubSubTest.scala        |   3 +-
 .../src/main/resources/reference.conf         |  11 ++
 .../scala/docspell/restserver/Config.scala    |   2 +
 .../docspell/restserver/ConfigFile.scala      |   6 +-
 .../main/scala/docspell/restserver/Main.scala |   6 +-
 .../docspell/restserver/RestAppImpl.scala     |   4 +-
 .../docspell/restserver/auth/OpenId.scala     |   9 +-
 .../routes/ClientSettingsRoutes.scala         |   3 +-
 .../restserver/routes/ItemMultiRoutes.scala   |   4 +-
 .../restserver/routes/ItemRoutes.scala        |  13 +-
 .../routes/NotificationRoutes.scala           |   3 +-
 .../restserver/routes/PersonRoutes.scala      |   6 +-
 .../restserver/routes/ShareSearchRoutes.scala |   2 +-
 .../restserver/webapp/TemplateRoutes.scala    |  11 +-
 .../docspell/store/file/BinnyUtils.scala      |   1 +
 .../docspell/store/file/FileRepository.scala  |   3 +-
 .../docspell/store/queries/QAttachment.scala  |  19 +--
 .../scala/docspell/store/queries/QItem.scala  |  27 ++--
 .../scala/docspell/store/queries/QJob.scala   |  31 ++--
 .../scala/docspell/store/queries/QUser.scala  |   2 +-
 .../scala/docspell/store/queue/JobQueue.scala |   4 +-
 .../store/queue/PeriodicTaskStore.scala       |   8 +-
 .../store/records/RNotificationChannel.scala  |   2 +-
 project/Dependencies.scala                    |  12 +-
 project/TestSettings.scala                    |  36 +++++
 130 files changed, 634 insertions(+), 662 deletions(-)
 delete mode 100644 modules/common/src/main/scala/docspell/common/Logger.scala
 delete mode 100644 modules/common/src/main/scala/docspell/common/syntax/LoggerSyntax.scala
 delete mode 100644 modules/joex/src/main/scala/docspell/joex/process/TestTasks.scala
 create mode 100644 modules/logging/api/src/main/scala/docspell/logging/AndThenLogger.scala
 rename modules/logging/{ => api}/src/main/scala/docspell/logging/Level.scala (100%)
 rename modules/logging/{ => api}/src/main/scala/docspell/logging/LogConfig.scala (100%)
 rename modules/logging/{ => api}/src/main/scala/docspell/logging/LogEvent.scala (83%)
 rename modules/logging/{ => api}/src/main/scala/docspell/logging/Logger.scala (63%)
 create mode 100644 modules/logging/api/src/main/scala/docspell/logging/LoggerExtension.scala
 rename modules/logging/{ => scribe}/src/main/scala/docspell/logging/impl/JsonWriter.scala (86%)
 rename modules/logging/{ => scribe}/src/main/scala/docspell/logging/impl/LogfmtWriter.scala (91%)
 rename modules/logging/{ => scribe}/src/main/scala/docspell/logging/impl/Record.scala (100%)
 rename modules/logging/{ => scribe}/src/main/scala/docspell/logging/impl/ScribeConfigure.scala (94%)
 rename modules/logging/{src/main/scala/docspell/logging/impl/LoggerWrapper.scala => scribe/src/main/scala/docspell/logging/impl/ScribeWrapper.scala} (92%)
 create mode 100644 modules/logging/scribe/src/main/scala/docspell/logging/package.scala
 create mode 100644 project/TestSettings.scala

diff --git a/build.sbt b/build.sbt
index 00505aec..bd28e5e8 100644
--- a/build.sbt
+++ b/build.sbt
@@ -9,9 +9,6 @@ val elmCompileMode = settingKey[ElmCompileMode]("How to compile elm sources")
 
 // --- Settings
 
-def inTest(d0: Seq[ModuleID], ds: Seq[ModuleID]*) =
-  ds.fold(d0)(_ ++ _).map(_ % Test)
-
 val scalafixSettings = Seq(
   semanticdbEnabled := true, // enable SemanticDB
   semanticdbVersion := scalafixSemanticdb.revision, // "4.4.0"
@@ -58,14 +55,10 @@ val sharedSettings = Seq(
   libraryDependencySchemes ++= Seq(
     "com.github.eikek" %% "calev-core" % VersionScheme.Always,
     "com.github.eikek" %% "calev-circe" % VersionScheme.Always
-  )
+  ),
+  addCompilerPlugin(Dependencies.kindProjectorPlugin)
 ) ++ scalafixSettings
 
-val testSettingsMUnit = Seq(
-  libraryDependencies ++= inTest(Dependencies.munit, Dependencies.logging),
-  testFrameworks += new TestFramework("munit.Framework")
-)
-
 lazy val noPublish = Seq(
   publish := {},
   publishLocal := {},
@@ -294,6 +287,20 @@ val openapiScalaSettings = Seq(
 
 // --- Modules
 
+val loggingApi = project
+  .in(file("modules/logging/api"))
+  .disablePlugins(RevolverPlugin)
+  .settings(sharedSettings)
+  .withTestSettings
+  .settings(
+    name := "docspell-logging-api",
+    libraryDependencies ++=
+      Dependencies.catsEffect ++
+        Dependencies.circeCore ++
+        Dependencies.fs2Core ++
+        Dependencies.sourcecode
+  )
+
 // Base module, everything depends on this – including restapi and
 // joexapi modules. This should aim to have least possible
 // dependencies
@@ -301,44 +308,44 @@ val common = project
   .in(file("modules/common"))
   .disablePlugins(RevolverPlugin)
   .settings(sharedSettings)
-  .settings(testSettingsMUnit)
+  .withTestSettings
   .settings(
     name := "docspell-common",
-    addCompilerPlugin(Dependencies.kindProjectorPlugin),
     libraryDependencies ++=
       Dependencies.fs2 ++
         Dependencies.circe ++
-        Dependencies.loggingApi ++
         Dependencies.calevCore ++
         Dependencies.calevCirce
   )
-
-val logging = project
-  .in(file("modules/logging"))
-  .disablePlugins(RevolverPlugin)
-  .settings(sharedSettings)
-  .settings(testSettingsMUnit)
-  .settings(
-    name := "docspell-logging",
-    libraryDependencies ++=
-      Dependencies.scribe ++
-        Dependencies.catsEffect ++
-        Dependencies.circeCore
-  )
+  .dependsOn(loggingApi)
 
 val config = project
   .in(file("modules/config"))
   .disablePlugins(RevolverPlugin)
   .settings(sharedSettings)
-  .settings(testSettingsMUnit)
+  .withTestSettings
   .settings(
     name := "docspell-config",
-    addCompilerPlugin(Dependencies.kindProjectorPlugin),
     libraryDependencies ++=
       Dependencies.fs2 ++
         Dependencies.pureconfig
   )
-  .dependsOn(common)
+  .dependsOn(common, loggingApi)
+
+val loggingScribe = project
+  .in(file("modules/logging/scribe"))
+  .disablePlugins(RevolverPlugin)
+  .settings(sharedSettings)
+  .withTestSettings
+  .settings(
+    name := "docspell-logging-scribe",
+    libraryDependencies ++=
+      Dependencies.scribe ++
+        Dependencies.catsEffect ++
+        Dependencies.circeCore ++
+        Dependencies.fs2Core
+  )
+  .dependsOn(loggingApi)
 
 // Some example files for testing
 // https://file-examples.com/index.php/sample-documents-download/sample-doc-download/
@@ -346,7 +353,7 @@ val files = project
   .in(file("modules/files"))
   .disablePlugins(RevolverPlugin)
   .settings(sharedSettings)
-  .settings(testSettingsMUnit)
+  .withTestSettings
   .settings(
     name := "docspell-files",
     libraryDependencies ++=
@@ -385,7 +392,7 @@ val query =
     .in(file("modules/query"))
     .disablePlugins(RevolverPlugin)
     .settings(sharedSettings)
-    .settings(testSettingsMUnit)
+    .withTestSettings
     .settings(
       name := "docspell-query",
       libraryDependencies +=
@@ -405,7 +412,7 @@ val totp = project
   .in(file("modules/totp"))
   .disablePlugins(RevolverPlugin)
   .settings(sharedSettings)
-  .settings(testSettingsMUnit)
+  .withTestSettings
   .settings(
     name := "docspell-totp",
     libraryDependencies ++=
@@ -419,7 +426,7 @@ val jsonminiq = project
   .in(file("modules/jsonminiq"))
   .disablePlugins(RevolverPlugin)
   .settings(sharedSettings)
-  .settings(testSettingsMUnit)
+  .withTestSettings
   .settings(
     name := "docspell-jsonminiq",
     libraryDependencies ++=
@@ -432,25 +439,23 @@ val notificationApi = project
   .in(file("modules/notification/api"))
   .disablePlugins(RevolverPlugin)
   .settings(sharedSettings)
-  .settings(testSettingsMUnit)
+  .withTestSettings
   .settings(
     name := "docspell-notification-api",
-    addCompilerPlugin(Dependencies.kindProjectorPlugin),
     libraryDependencies ++=
       Dependencies.fs2 ++
         Dependencies.emilCommon ++
         Dependencies.circeGenericExtra
   )
-  .dependsOn(common)
+  .dependsOn(common, loggingScribe)
 
 val store = project
   .in(file("modules/store"))
   .disablePlugins(RevolverPlugin)
   .settings(sharedSettings)
-  .settings(testSettingsMUnit)
+  .withTestSettings
   .settings(
     name := "docspell-store",
-    addCompilerPlugin(Dependencies.kindProjectorPlugin),
     libraryDependencies ++=
       Dependencies.doobie ++
         Dependencies.binny ++
@@ -466,16 +471,15 @@ val store = project
     libraryDependencies ++=
       Dependencies.testContainer.map(_ % Test)
   )
-  .dependsOn(common, query.jvm, totp, files, notificationApi, jsonminiq)
+  .dependsOn(common, query.jvm, totp, files, notificationApi, jsonminiq, loggingScribe)
 
 val notificationImpl = project
   .in(file("modules/notification/impl"))
   .disablePlugins(RevolverPlugin)
   .settings(sharedSettings)
-  .settings(testSettingsMUnit)
+  .withTestSettings
   .settings(
     name := "docspell-notification-impl",
-    addCompilerPlugin(Dependencies.kindProjectorPlugin),
     libraryDependencies ++=
       Dependencies.fs2 ++
         Dependencies.emil ++
@@ -492,10 +496,9 @@ val pubsubApi = project
   .in(file("modules/pubsub/api"))
   .disablePlugins(RevolverPlugin)
   .settings(sharedSettings)
-  .settings(testSettingsMUnit)
+  .withTestSettings
   .settings(
     name := "docspell-pubsub-api",
-    addCompilerPlugin(Dependencies.kindProjectorPlugin),
     libraryDependencies ++=
       Dependencies.fs2
   )
@@ -505,10 +508,9 @@ val pubsubNaive = project
   .in(file("modules/pubsub/naive"))
   .disablePlugins(RevolverPlugin)
   .settings(sharedSettings)
-  .settings(testSettingsMUnit)
+  .withTestSettings
   .settings(
     name := "docspell-pubsub-naive",
-    addCompilerPlugin(Dependencies.kindProjectorPlugin),
     libraryDependencies ++=
       Dependencies.fs2 ++
         Dependencies.http4sCirce ++
@@ -522,7 +524,7 @@ val extract = project
   .in(file("modules/extract"))
   .disablePlugins(RevolverPlugin)
   .settings(sharedSettings)
-  .settings(testSettingsMUnit)
+  .withTestSettings
   .settings(
     name := "docspell-extract",
     libraryDependencies ++=
@@ -533,13 +535,13 @@ val extract = project
         Dependencies.commonsIO ++
         Dependencies.julOverSlf4j
   )
-  .dependsOn(common, files % "compile->compile;test->test")
+  .dependsOn(common, loggingScribe, files % "compile->compile;test->test")
 
 val convert = project
   .in(file("modules/convert"))
   .disablePlugins(RevolverPlugin)
   .settings(sharedSettings)
-  .settings(testSettingsMUnit)
+  .withTestSettings
   .settings(
     name := "docspell-convert",
     libraryDependencies ++=
@@ -554,7 +556,7 @@ val analysis = project
   .disablePlugins(RevolverPlugin)
   .enablePlugins(NerModelsPlugin)
   .settings(sharedSettings)
-  .settings(testSettingsMUnit)
+  .withTestSettings
   .settings(NerModelsPlugin.nerClassifierSettings)
   .settings(
     name := "docspell-analysis",
@@ -562,24 +564,24 @@ val analysis = project
       Dependencies.fs2 ++
         Dependencies.stanfordNlpCore
   )
-  .dependsOn(common, files % "test->test")
+  .dependsOn(common, files % "test->test", loggingScribe)
 
 val ftsclient = project
   .in(file("modules/fts-client"))
   .disablePlugins(RevolverPlugin)
   .settings(sharedSettings)
-  .settings(testSettingsMUnit)
+  .withTestSettings
   .settings(
     name := "docspell-fts-client",
     libraryDependencies ++= Seq.empty
   )
-  .dependsOn(common)
+  .dependsOn(common, loggingScribe)
 
 val ftssolr = project
   .in(file("modules/fts-solr"))
   .disablePlugins(RevolverPlugin)
   .settings(sharedSettings)
-  .settings(testSettingsMUnit)
+  .withTestSettings
   .settings(
     name := "docspell-fts-solr",
     libraryDependencies ++=
@@ -595,7 +597,7 @@ val restapi = project
   .disablePlugins(RevolverPlugin)
   .enablePlugins(OpenApiSchema)
   .settings(sharedSettings)
-  .settings(testSettingsMUnit)
+  .withTestSettings
   .settings(openapiScalaSettings)
   .settings(
     name := "docspell-restapi",
@@ -613,7 +615,7 @@ val joexapi = project
   .disablePlugins(RevolverPlugin)
   .enablePlugins(OpenApiSchema)
   .settings(sharedSettings)
-  .settings(testSettingsMUnit)
+  .withTestSettings
   .settings(openapiScalaSettings)
   .settings(
     name := "docspell-joexapi",
@@ -626,13 +628,13 @@ val joexapi = project
     openapiSpec := (Compile / resourceDirectory).value / "joex-openapi.yml",
     openapiStaticGen := OpenApiDocGenerator.Redoc
   )
-  .dependsOn(common)
+  .dependsOn(common, loggingScribe)
 
 val backend = project
   .in(file("modules/backend"))
   .disablePlugins(RevolverPlugin)
   .settings(sharedSettings)
-  .settings(testSettingsMUnit)
+  .withTestSettings
   .settings(
     name := "docspell-backend",
     libraryDependencies ++=
@@ -642,13 +644,13 @@ val backend = project
         Dependencies.http4sClient ++
         Dependencies.emil
   )
-  .dependsOn(store, notificationApi, joexapi, ftsclient, totp, pubsubApi)
+  .dependsOn(store, notificationApi, joexapi, ftsclient, totp, pubsubApi, loggingApi)
 
 val oidc = project
   .in(file("modules/oidc"))
   .disablePlugins(RevolverPlugin)
   .settings(sharedSettings)
-  .settings(testSettingsMUnit)
+  .withTestSettings
   .settings(
     name := "docspell-oidc",
     libraryDependencies ++=
@@ -660,7 +662,7 @@ val oidc = project
         Dependencies.circe ++
         Dependencies.jwtScala
   )
-  .dependsOn(common)
+  .dependsOn(common, loggingScribe)
 
 val webapp = project
   .in(file("modules/webapp"))
@@ -691,7 +693,7 @@ val joex = project
     ClasspathJarPlugin
   )
   .settings(sharedSettings)
-  .settings(testSettingsMUnit)
+  .withTestSettings
   .settings(debianSettings("docspell-joex"))
   .settings(buildInfoSettings)
   .settings(
@@ -714,7 +716,6 @@ val joex = project
         Dependencies.yamusca ++
         Dependencies.loggingApi ++
         Dependencies.logging.map(_ % Runtime),
-    addCompilerPlugin(Dependencies.kindProjectorPlugin),
     addCompilerPlugin(Dependencies.betterMonadicFor),
     buildInfoPackage := "docspell.joex",
     reStart / javaOptions ++= Seq(
@@ -726,6 +727,8 @@ val joex = project
   )
   .dependsOn(
     config,
+    loggingApi,
+    loggingScribe,
     store,
     backend,
     extract,
@@ -748,7 +751,7 @@ val restserver = project
     ClasspathJarPlugin
   )
   .settings(sharedSettings)
-  .settings(testSettingsMUnit)
+  .withTestSettings
   .settings(debianSettings("docspell-server"))
   .settings(buildInfoSettings)
   .settings(
@@ -767,7 +770,6 @@ val restserver = project
         Dependencies.webjars ++
         Dependencies.loggingApi ++
         Dependencies.logging.map(_ % Runtime),
-    addCompilerPlugin(Dependencies.kindProjectorPlugin),
     addCompilerPlugin(Dependencies.betterMonadicFor),
     buildInfoPackage := "docspell.restserver",
     Compile / sourceGenerators += Def.task {
@@ -801,6 +803,8 @@ val restserver = project
   )
   .dependsOn(
     config,
+    loggingApi,
+    loggingScribe,
     restapi,
     joexapi,
     backend,
@@ -882,7 +886,8 @@ val root = project
   )
   .aggregate(
     common,
-    logging,
+    loggingApi,
+    loggingScribe,
     config,
     extract,
     convert,
diff --git a/modules/analysis/src/main/scala/docspell/analysis/TextAnalyser.scala b/modules/analysis/src/main/scala/docspell/analysis/TextAnalyser.scala
index 0158a6af..ca92b377 100644
--- a/modules/analysis/src/main/scala/docspell/analysis/TextAnalyser.scala
+++ b/modules/analysis/src/main/scala/docspell/analysis/TextAnalyser.scala
@@ -15,8 +15,7 @@ import docspell.analysis.contact.Contact
 import docspell.analysis.date.DateFind
 import docspell.analysis.nlp._
 import docspell.common._
-
-import org.log4s.getLogger
+import docspell.logging.Logger
 
 trait TextAnalyser[F[_]] {
 
@@ -30,7 +29,6 @@ trait TextAnalyser[F[_]] {
   def classifier: TextClassifier[F]
 }
 object TextAnalyser {
-  private[this] val logger = getLogger
 
   case class Result(labels: Vector[NerLabel], dates: Vector[NerDateLabel]) {
 
@@ -87,10 +85,11 @@ object TextAnalyser {
   private object Nlp {
     def apply[F[_]: Async](
         cfg: TextAnalysisConfig.NlpConfig
-    ): F[Input[F] => F[Vector[NerLabel]]] =
+    ): F[Input[F] => F[Vector[NerLabel]]] = {
+      val log = docspell.logging.getLogger[F]
       cfg.mode match {
         case NlpMode.Disabled =>
-          Logger.log4s(logger).info("NLP is disabled as defined in config.") *>
+          log.info("NLP is disabled as defined in config.") *>
             Applicative[F].pure(_ => Vector.empty[NerLabel].pure[F])
         case _ =>
           PipelineCache(cfg.clearInterval)(
@@ -99,6 +98,7 @@ object TextAnalyser {
           )
             .map(annotate[F])
       }
+    }
 
     final case class Input[F[_]](
         key: Ident,
diff --git a/modules/analysis/src/main/scala/docspell/analysis/classifier/StanfordTextClassifier.scala b/modules/analysis/src/main/scala/docspell/analysis/classifier/StanfordTextClassifier.scala
index 9fe4f72f..15ac3f24 100644
--- a/modules/analysis/src/main/scala/docspell/analysis/classifier/StanfordTextClassifier.scala
+++ b/modules/analysis/src/main/scala/docspell/analysis/classifier/StanfordTextClassifier.scala
@@ -17,6 +17,7 @@ import docspell.analysis.classifier.TextClassifier._
 import docspell.analysis.nlp.Properties
 import docspell.common._
 import docspell.common.syntax.FileSyntax._
+import docspell.logging.Logger
 
 import edu.stanford.nlp.classify.ColumnDataClassifier
 
diff --git a/modules/analysis/src/main/scala/docspell/analysis/classifier/TextClassifier.scala b/modules/analysis/src/main/scala/docspell/analysis/classifier/TextClassifier.scala
index b0450e92..3fc2662d 100644
--- a/modules/analysis/src/main/scala/docspell/analysis/classifier/TextClassifier.scala
+++ b/modules/analysis/src/main/scala/docspell/analysis/classifier/TextClassifier.scala
@@ -10,7 +10,7 @@ import cats.data.Kleisli
 import fs2.Stream
 
 import docspell.analysis.classifier.TextClassifier.Data
-import docspell.common._
+import docspell.logging.Logger
 
 trait TextClassifier[F[_]] {
 
diff --git a/modules/analysis/src/main/scala/docspell/analysis/nlp/Annotator.scala b/modules/analysis/src/main/scala/docspell/analysis/nlp/Annotator.scala
index b35700ee..48be13fa 100644
--- a/modules/analysis/src/main/scala/docspell/analysis/nlp/Annotator.scala
+++ b/modules/analysis/src/main/scala/docspell/analysis/nlp/Annotator.scala
@@ -12,6 +12,7 @@ import cats.{Applicative, FlatMap}
 
 import docspell.analysis.NlpSettings
 import docspell.common._
+import docspell.logging.Logger
 
 import edu.stanford.nlp.pipeline.StanfordCoreNLP
 
diff --git a/modules/analysis/src/main/scala/docspell/analysis/nlp/BasicCRFAnnotator.scala b/modules/analysis/src/main/scala/docspell/analysis/nlp/BasicCRFAnnotator.scala
index ae580992..63fd79d4 100644
--- a/modules/analysis/src/main/scala/docspell/analysis/nlp/BasicCRFAnnotator.scala
+++ b/modules/analysis/src/main/scala/docspell/analysis/nlp/BasicCRFAnnotator.scala
@@ -19,14 +19,13 @@ import docspell.common._
 import edu.stanford.nlp.ie.AbstractSequenceClassifier
 import edu.stanford.nlp.ie.crf.CRFClassifier
 import edu.stanford.nlp.ling.{CoreAnnotations, CoreLabel}
-import org.log4s.getLogger
 
 /** This is only using the CRFClassifier without building an analysis pipeline. The
   * ner-classifier cannot use results from POS-tagging etc. and is therefore not as good
   * as the [[StanfordNerAnnotator]]. But it uses less memory, while still being not bad.
   */
 object BasicCRFAnnotator {
-  private[this] val logger = getLogger
+  private[this] val logger = docspell.logging.unsafeLogger
 
   // assert correct resource names
   NLPLanguage.all.toList.foreach(classifierResource)
diff --git a/modules/analysis/src/main/scala/docspell/analysis/nlp/PipelineCache.scala b/modules/analysis/src/main/scala/docspell/analysis/nlp/PipelineCache.scala
index caee9e70..f8423492 100644
--- a/modules/analysis/src/main/scala/docspell/analysis/nlp/PipelineCache.scala
+++ b/modules/analysis/src/main/scala/docspell/analysis/nlp/PipelineCache.scala
@@ -15,8 +15,6 @@ import cats.implicits._
 import docspell.analysis.NlpSettings
 import docspell.common._
 
-import org.log4s.getLogger
-
 /** Creating the StanfordCoreNLP pipeline is quite expensive as it involves IO and
   * initializing large objects.
   *
@@ -31,17 +29,19 @@ trait PipelineCache[F[_]] {
 }
 
 object PipelineCache {
-  private[this] val logger = getLogger
+  private[this] val logger = docspell.logging.unsafeLogger
 
   def apply[F[_]: Async](clearInterval: Duration)(
       creator: NlpSettings => Annotator[F],
       release: F[Unit]
-  ): F[PipelineCache[F]] =
+  ): F[PipelineCache[F]] = {
+    val log = docspell.logging.getLogger[F]
     for {
       data <- Ref.of(Map.empty[String, Entry[Annotator[F]]])
       cacheClear <- CacheClearing.create(data, clearInterval, release)
-      _ <- Logger.log4s(logger).info("Creating nlp pipeline cache")
+      _ <- log.info("Creating nlp pipeline cache")
     } yield new Impl[F](data, creator, cacheClear)
+  }
 
   final private class Impl[F[_]: Async](
       data: Ref[F, Map[String, Entry[Annotator[F]]]],
@@ -116,7 +116,7 @@ object PipelineCache {
       for {
         counter <- Ref.of(0L)
         cleaning <- Ref.of(None: Option[Fiber[F, Throwable, Unit]])
-        log = Logger.log4s(logger)
+        log = docspell.logging.getLogger[F]
         result <-
           if (interval.millis <= 0)
             log
@@ -145,7 +145,7 @@ object PipelineCache {
       release: F[Unit]
   )(implicit F: Async[F])
       extends CacheClearing[F] {
-    private[this] val log = Logger.log4s[F](logger)
+    private[this] val log = docspell.logging.getLogger[F]
 
     def withCache: Resource[F, Unit] =
       Resource.make(counter.update(_ + 1) *> cancelClear)(_ =>
diff --git a/modules/analysis/src/main/scala/docspell/analysis/nlp/StanfordNerAnnotator.scala b/modules/analysis/src/main/scala/docspell/analysis/nlp/StanfordNerAnnotator.scala
index 1ed0fb5e..36e40b12 100644
--- a/modules/analysis/src/main/scala/docspell/analysis/nlp/StanfordNerAnnotator.scala
+++ b/modules/analysis/src/main/scala/docspell/analysis/nlp/StanfordNerAnnotator.scala
@@ -14,10 +14,9 @@ import fs2.io.file.Path
 import docspell.common._
 
 import edu.stanford.nlp.pipeline.{CoreDocument, StanfordCoreNLP}
-import org.log4s.getLogger
 
 object StanfordNerAnnotator {
-  private[this] val logger = getLogger
+  private[this] val logger = docspell.logging.unsafeLogger
 
   /** Runs named entity recognition on the given `text`.
     *
diff --git a/modules/analysis/src/test/scala/docspell/analysis/classifier/StanfordTextClassifierSuite.scala b/modules/analysis/src/test/scala/docspell/analysis/classifier/StanfordTextClassifierSuite.scala
index 3dd34255..f795aec3 100644
--- a/modules/analysis/src/test/scala/docspell/analysis/classifier/StanfordTextClassifierSuite.scala
+++ b/modules/analysis/src/test/scala/docspell/analysis/classifier/StanfordTextClassifierSuite.scala
@@ -21,7 +21,7 @@ import docspell.common._
 import munit._
 
 class StanfordTextClassifierSuite extends FunSuite {
-  val logger = Logger.log4s[IO](org.log4s.getLogger)
+  val logger = docspell.logging.getLogger[IO]
 
   test("learn from data") {
     val cfg = TextClassifierConfig(File.path(Paths.get("target")), NonEmptyList.of(Map()))
diff --git a/modules/backend/src/main/scala/docspell/backend/auth/Login.scala b/modules/backend/src/main/scala/docspell/backend/auth/Login.scala
index 12752962..2091e8f3 100644
--- a/modules/backend/src/main/scala/docspell/backend/auth/Login.scala
+++ b/modules/backend/src/main/scala/docspell/backend/auth/Login.scala
@@ -17,7 +17,6 @@ import docspell.store.queries.QLogin
 import docspell.store.records._
 import docspell.totp.{OnetimePassword, Totp}
 
-import org.log4s.getLogger
 import org.mindrot.jbcrypt.BCrypt
 import scodec.bits.ByteVector
 
@@ -41,8 +40,6 @@ trait Login[F[_]] {
 }
 
 object Login {
-  private[this] val logger = getLogger
-
   case class Config(
       serverSecret: ByteVector,
       sessionValid: Duration,
@@ -93,7 +90,7 @@ object Login {
   def apply[F[_]: Async](store: Store[F], totp: Totp): Resource[F, Login[F]] =
     Resource.pure[F, Login[F]](new Login[F] {
 
-      private val logF = Logger.log4s(logger)
+      private val logF = docspell.logging.getLogger[F]
 
       def loginExternal(config: Config)(accountId: AccountId): F[Result] =
         for {
@@ -124,7 +121,7 @@ object Login {
           case Right(acc) =>
             for {
               data <- store.transact(QLogin.findUser(acc))
-              _ <- Sync[F].delay(logger.trace(s"Account lookup: $data"))
+              _ <- logF.trace(s"Account lookup: $data")
               res <-
                 if (data.exists(check(up.pass))) doLogin(config, acc, up.rememberMe)
                 else Result.invalidAuth.pure[F]
diff --git a/modules/backend/src/main/scala/docspell/backend/fulltext/CreateIndex.scala b/modules/backend/src/main/scala/docspell/backend/fulltext/CreateIndex.scala
index 214bef1a..38c1ea67 100644
--- a/modules/backend/src/main/scala/docspell/backend/fulltext/CreateIndex.scala
+++ b/modules/backend/src/main/scala/docspell/backend/fulltext/CreateIndex.scala
@@ -12,6 +12,7 @@ import cats.effect._
 import docspell.common._
 import docspell.ftsclient.FtsClient
 import docspell.ftsclient.TextData
+import docspell.logging.Logger
 import docspell.store.Store
 import docspell.store.queries.QAttachment
 import docspell.store.queries.QItem
diff --git a/modules/backend/src/main/scala/docspell/backend/item/Merge.scala b/modules/backend/src/main/scala/docspell/backend/item/Merge.scala
index 9d9cbb8a..3fdfcce9 100644
--- a/modules/backend/src/main/scala/docspell/backend/item/Merge.scala
+++ b/modules/backend/src/main/scala/docspell/backend/item/Merge.scala
@@ -14,6 +14,7 @@ import cats.implicits._
 import docspell.backend.fulltext.CreateIndex
 import docspell.backend.ops.OItem
 import docspell.common._
+import docspell.logging.Logger
 import docspell.store.Store
 import docspell.store.queries.QCustomField
 import docspell.store.queries.QCustomField.FieldValue
diff --git a/modules/backend/src/main/scala/docspell/backend/ops/OClientSettings.scala b/modules/backend/src/main/scala/docspell/backend/ops/OClientSettings.scala
index 560e4db4..0db6ce2d 100644
--- a/modules/backend/src/main/scala/docspell/backend/ops/OClientSettings.scala
+++ b/modules/backend/src/main/scala/docspell/backend/ops/OClientSettings.scala
@@ -37,11 +37,9 @@ trait OClientSettings[F[_]] {
 }
 
 object OClientSettings {
-  private[this] val logger = org.log4s.getLogger
-
   def apply[F[_]: Async](store: Store[F]): Resource[F, OClientSettings[F]] =
     Resource.pure[F, OClientSettings[F]](new OClientSettings[F] {
-      val log = Logger.log4s[F](logger)
+      val log = docspell.logging.getLogger[F]
 
       private def getUserId(account: AccountId): OptionT[F, Ident] =
         OptionT(store.transact(RUser.findByAccount(account))).map(_.uid)
diff --git a/modules/backend/src/main/scala/docspell/backend/ops/OCustomFields.scala b/modules/backend/src/main/scala/docspell/backend/ops/OCustomFields.scala
index 258930a9..a5416048 100644
--- a/modules/backend/src/main/scala/docspell/backend/ops/OCustomFields.scala
+++ b/modules/backend/src/main/scala/docspell/backend/ops/OCustomFields.scala
@@ -31,7 +31,6 @@ import docspell.store.records.RCustomFieldValue
 import docspell.store.records.RItem
 
 import doobie._
-import org.log4s.getLogger
 
 trait OCustomFields[F[_]] {
 
@@ -153,7 +152,7 @@ object OCustomFields {
   ): Resource[F, OCustomFields[F]] =
     Resource.pure[F, OCustomFields[F]](new OCustomFields[F] {
 
-      private[this] val logger = Logger.log4s[ConnectionIO](getLogger)
+      private[this] val logger = docspell.logging.getLogger[ConnectionIO]
 
       def findAllValues(itemIds: Nel[Ident]): F[List[FieldValue]] =
         store.transact(QCustomField.findAllValues(itemIds))
@@ -224,7 +223,7 @@ object OCustomFields {
               .transact(RItem.existsByIdsAndCollective(items, value.collective))
               .map(flag => if (flag) Right(()) else Left(SetValueResult.itemNotFound))
           )
-          nu <- EitherT.right[SetValueResult](
+          _ <- EitherT.right[SetValueResult](
             items
               .traverse(item => store.transact(RCustomField.setValue(field, item, fval)))
               .map(_.toList.sum)
diff --git a/modules/backend/src/main/scala/docspell/backend/ops/OFulltext.scala b/modules/backend/src/main/scala/docspell/backend/ops/OFulltext.scala
index 4cdcd0dd..9d057f1c 100644
--- a/modules/backend/src/main/scala/docspell/backend/ops/OFulltext.scala
+++ b/modules/backend/src/main/scala/docspell/backend/ops/OFulltext.scala
@@ -14,7 +14,6 @@ import fs2.Stream
 import docspell.backend.JobFactory
 import docspell.backend.ops.OItemSearch._
 import docspell.common._
-import docspell.common.syntax.all._
 import docspell.ftsclient._
 import docspell.query.ItemQuery._
 import docspell.query.ItemQueryDsl._
@@ -23,8 +22,6 @@ import docspell.store.queue.JobQueue
 import docspell.store.records.RJob
 import docspell.store.{Store, qb}
 
-import org.log4s.getLogger
-
 trait OFulltext[F[_]] {
 
   def findItems(maxNoteLen: Int)(
@@ -59,7 +56,6 @@ trait OFulltext[F[_]] {
 }
 
 object OFulltext {
-  private[this] val logger = getLogger
 
   case class FtsInput(
       query: String,
@@ -89,16 +85,17 @@ object OFulltext {
       joex: OJoex[F]
   ): Resource[F, OFulltext[F]] =
     Resource.pure[F, OFulltext[F]](new OFulltext[F] {
+      val logger = docspell.logging.getLogger[F]
       def reindexAll: F[Unit] =
         for {
-          _ <- logger.finfo(s"Re-index all.")
+          _ <- logger.info(s"Re-index all.")
           job <- JobFactory.reIndexAll[F]
           _ <- queue.insertIfNew(job) *> joex.notifyAllNodes
         } yield ()
 
       def reindexCollective(account: AccountId): F[Unit] =
         for {
-          _ <- logger.fdebug(s"Re-index collective: $account")
+          _ <- logger.debug(s"Re-index collective: $account")
           exist <- store.transact(
             RJob.findNonFinalByTracker(DocspellSystem.migrationTaskTracker)
           )
@@ -123,7 +120,7 @@ object OFulltext {
           FtsQuery.HighlightSetting(ftsQ.highlightPre, ftsQ.highlightPost)
         )
         for {
-          _ <- logger.ftrace(s"Find index only: ${ftsQ.query}/$batch")
+          _ <- logger.trace(s"Find index only: ${ftsQ.query}/$batch")
           folders <- store.transact(QFolder.getMemberFolders(account))
           ftsR <- fts.search(fq.withFolders(folders))
           ftsItems = ftsR.results.groupBy(_.itemId)
diff --git a/modules/backend/src/main/scala/docspell/backend/ops/OItem.scala b/modules/backend/src/main/scala/docspell/backend/ops/OItem.scala
index b49d09cc..f625cdfe 100644
--- a/modules/backend/src/main/scala/docspell/backend/ops/OItem.scala
+++ b/modules/backend/src/main/scala/docspell/backend/ops/OItem.scala
@@ -16,6 +16,7 @@ import docspell.backend.fulltext.CreateIndex
 import docspell.backend.item.Merge
 import docspell.common._
 import docspell.ftsclient.FtsClient
+import docspell.logging.Logger
 import docspell.notification.api.Event
 import docspell.store.queries.{QAttachment, QItem, QMoveAttachment}
 import docspell.store.queue.JobQueue
@@ -23,7 +24,6 @@ import docspell.store.records._
 import docspell.store.{AddResult, Store, UpdateResult}
 
 import doobie.implicits._
-import org.log4s.getLogger
 
 trait OItem[F[_]] {
 
@@ -235,7 +235,7 @@ object OItem {
       otag <- OTag(store)
       oorg <- OOrganization(store)
       oequip <- OEquipment(store)
-      logger <- Resource.pure[F, Logger[F]](Logger.log4s(getLogger))
+      logger <- Resource.pure[F, Logger[F]](docspell.logging.getLogger[F])
       oitem <- Resource.pure[F, OItem[F]](new OItem[F] {
 
         def merge(
diff --git a/modules/backend/src/main/scala/docspell/backend/ops/OJob.scala b/modules/backend/src/main/scala/docspell/backend/ops/OJob.scala
index 0c3d92ae..b4b0b7ae 100644
--- a/modules/backend/src/main/scala/docspell/backend/ops/OJob.scala
+++ b/modules/backend/src/main/scala/docspell/backend/ops/OJob.scala
@@ -59,7 +59,7 @@ object OJob {
       pubsub: PubSubT[F]
   ): Resource[F, OJob[F]] =
     Resource.pure[F, OJob[F]](new OJob[F] {
-      private[this] val logger = Logger.log4s(org.log4s.getLogger(OJob.getClass))
+      private[this] val logger = docspell.logging.getLogger[F]
 
       def queueState(collective: Ident, maxResults: Int): F[CollectiveQueueState] =
         store
diff --git a/modules/backend/src/main/scala/docspell/backend/ops/ONode.scala b/modules/backend/src/main/scala/docspell/backend/ops/ONode.scala
index b8e89ee2..8b55ed29 100644
--- a/modules/backend/src/main/scala/docspell/backend/ops/ONode.scala
+++ b/modules/backend/src/main/scala/docspell/backend/ops/ONode.scala
@@ -9,13 +9,10 @@ package docspell.backend.ops
 import cats.effect.{Async, Resource}
 import cats.implicits._
 
-import docspell.common.syntax.all._
 import docspell.common.{Ident, LenientUri, NodeType}
 import docspell.store.Store
 import docspell.store.records.RNode
 
-import org.log4s._
-
 trait ONode[F[_]] {
 
   def register(appId: Ident, nodeType: NodeType, uri: LenientUri): F[Unit]
@@ -24,20 +21,19 @@ trait ONode[F[_]] {
 }
 
 object ONode {
-  private[this] val logger = getLogger
 
   def apply[F[_]: Async](store: Store[F]): Resource[F, ONode[F]] =
     Resource.pure[F, ONode[F]](new ONode[F] {
-
+      val logger = docspell.logging.getLogger[F]
       def register(appId: Ident, nodeType: NodeType, uri: LenientUri): F[Unit] =
         for {
           node <- RNode(appId, nodeType, uri)
-          _ <- logger.finfo(s"Registering node ${node.id.id}")
+          _ <- logger.info(s"Registering node ${node.id.id}")
           _ <- store.transact(RNode.set(node))
         } yield ()
 
       def unregister(appId: Ident): F[Unit] =
-        logger.finfo(s"Unregister app ${appId.id}") *>
+        logger.info(s"Unregister app ${appId.id}") *>
           store.transact(RNode.delete(appId)).map(_ => ())
     })
 
diff --git a/modules/backend/src/main/scala/docspell/backend/ops/ONotification.scala b/modules/backend/src/main/scala/docspell/backend/ops/ONotification.scala
index 1ed08014..05b58275 100644
--- a/modules/backend/src/main/scala/docspell/backend/ops/ONotification.scala
+++ b/modules/backend/src/main/scala/docspell/backend/ops/ONotification.scala
@@ -6,9 +6,6 @@
 
 package docspell.backend.ops
 
-import java.io.PrintWriter
-import java.io.StringWriter
-
 import cats.data.OptionT
 import cats.data.{NonEmptyList => Nel}
 import cats.effect._
@@ -17,6 +14,7 @@ import cats.implicits._
 import docspell.backend.ops.ONotification.Hook
 import docspell.common._
 import docspell.jsonminiq.JsonMiniQuery
+import docspell.logging.{Level, LogEvent, Logger}
 import docspell.notification.api._
 import docspell.store.AddResult
 import docspell.store.Store
@@ -75,14 +73,13 @@ trait ONotification[F[_]] {
 }
 
 object ONotification {
-  private[this] val logger = org.log4s.getLogger
 
   def apply[F[_]: Async](
       store: Store[F],
       notMod: NotificationModule[F]
   ): Resource[F, ONotification[F]] =
     Resource.pure[F, ONotification[F]](new ONotification[F] {
-      val log = Logger.log4s[F](logger)
+      val log = docspell.logging.getLogger[F]
 
       def withUserId[A](
           account: AccountId
@@ -129,9 +126,9 @@ object ONotification {
           .map {
             case Right(res) => res
             case Left(ex) =>
-              val ps = new StringWriter()
-              ex.printStackTrace(new PrintWriter(ps))
-              SendTestResult(false, Vector(s"${ex.getMessage}\n$ps"))
+              val ev =
+                LogEvent.of(Level.Error, "Failed sending sample event").addError(ex)
+              SendTestResult(false, Vector(ev))
           }
 
       def listChannels(account: AccountId): F[Vector[Channel]] =
@@ -316,5 +313,5 @@ object ONotification {
       } yield h
   }
 
-  final case class SendTestResult(success: Boolean, logMessages: Vector[String])
+  final case class SendTestResult(success: Boolean, logEvents: Vector[LogEvent])
 }
diff --git a/modules/backend/src/main/scala/docspell/backend/ops/OShare.scala b/modules/backend/src/main/scala/docspell/backend/ops/OShare.scala
index 75bdc27a..1f393451 100644
--- a/modules/backend/src/main/scala/docspell/backend/ops/OShare.scala
+++ b/modules/backend/src/main/scala/docspell/backend/ops/OShare.scala
@@ -152,7 +152,7 @@ object OShare {
       emil: Emil[F]
   ): OShare[F] =
     new OShare[F] {
-      private[this] val logger = Logger.log4s[F](org.log4s.getLogger)
+      private[this] val logger = docspell.logging.getLogger[F]
 
       def findAll(
           collective: Ident,
diff --git a/modules/backend/src/main/scala/docspell/backend/ops/OTotp.scala b/modules/backend/src/main/scala/docspell/backend/ops/OTotp.scala
index a872fa53..67ffcdf1 100644
--- a/modules/backend/src/main/scala/docspell/backend/ops/OTotp.scala
+++ b/modules/backend/src/main/scala/docspell/backend/ops/OTotp.scala
@@ -5,6 +5,7 @@
  */
 
 package docspell.backend.ops
+
 import cats.effect._
 import cats.implicits._
 
@@ -14,8 +15,6 @@ import docspell.store.records.{RTotp, RUser}
 import docspell.store.{AddResult, Store, UpdateResult}
 import docspell.totp.{Key, OnetimePassword, Totp}
 
-import org.log4s.getLogger
-
 trait OTotp[F[_]] {
 
   /** Return whether TOTP is enabled for this account or not. */
@@ -38,8 +37,6 @@ trait OTotp[F[_]] {
 }
 
 object OTotp {
-  private[this] val logger = getLogger
-
   sealed trait OtpState {
     def isEnabled: Boolean
     def isDisabled = !isEnabled
@@ -86,7 +83,7 @@ object OTotp {
 
   def apply[F[_]: Async](store: Store[F], totp: Totp): Resource[F, OTotp[F]] =
     Resource.pure[F, OTotp[F]](new OTotp[F] {
-      val log = Logger.log4s[F](logger)
+      val log = docspell.logging.getLogger[F]
 
       def initialize(accountId: AccountId): F[InitResult] =
         for {
diff --git a/modules/backend/src/main/scala/docspell/backend/ops/OUpload.scala b/modules/backend/src/main/scala/docspell/backend/ops/OUpload.scala
index ec959f6c..dbda65b2 100644
--- a/modules/backend/src/main/scala/docspell/backend/ops/OUpload.scala
+++ b/modules/backend/src/main/scala/docspell/backend/ops/OUpload.scala
@@ -14,13 +14,10 @@ import fs2.Stream
 
 import docspell.backend.JobFactory
 import docspell.common._
-import docspell.common.syntax.all._
 import docspell.store.Store
 import docspell.store.queue.JobQueue
 import docspell.store.records._
 
-import org.log4s._
-
 trait OUpload[F[_]] {
 
   def submit(
@@ -56,8 +53,6 @@ trait OUpload[F[_]] {
 }
 
 object OUpload {
-  private[this] val logger = getLogger
-
   case class File[F[_]](
       name: Option[String],
       advertisedMime: Option[MimeType],
@@ -117,7 +112,7 @@ object OUpload {
       joex: OJoex[F]
   ): Resource[F, OUpload[F]] =
     Resource.pure[F, OUpload[F]](new OUpload[F] {
-
+      private[this] val logger = docspell.logging.getLogger[F]
       def submit(
           data: OUpload.UploadData[F],
           account: AccountId,
@@ -155,7 +150,7 @@ object OUpload {
             if (data.multiple) files.map(f => ProcessItemArgs(meta, List(f)))
             else Vector(ProcessItemArgs(meta, files.toList))
           jobs <- right(makeJobs(args, account, data.priority, data.tracker))
-          _ <- right(logger.fdebug(s"Storing jobs: $jobs"))
+          _ <- right(logger.debug(s"Storing jobs: $jobs"))
           res <- right(submitJobs(notifyJoex)(jobs))
           _ <- right(
             store.transact(
@@ -194,7 +189,7 @@ object OUpload {
           notifyJoex: Boolean
       )(jobs: Vector[RJob]): F[OUpload.UploadResult] =
         for {
-          _ <- logger.fdebug(s"Storing jobs: $jobs")
+          _ <- logger.debug(s"Storing jobs: $jobs")
           _ <- queue.insertAll(jobs)
           _ <- if (notifyJoex) joex.notifyAllNodes else ().pure[F]
         } yield UploadResult.Success
@@ -203,7 +198,7 @@ object OUpload {
       private def saveFile(
           accountId: AccountId
       )(file: File[F]): F[Option[ProcessItemArgs.File]] =
-        logger.finfo(s"Receiving file $file") *>
+        logger.info(s"Receiving file $file") *>
           file.data
             .through(
               store.fileRepo.save(
diff --git a/modules/backend/src/main/scala/docspell/backend/signup/OSignup.scala b/modules/backend/src/main/scala/docspell/backend/signup/OSignup.scala
index 500469b5..c59bc773 100644
--- a/modules/backend/src/main/scala/docspell/backend/signup/OSignup.scala
+++ b/modules/backend/src/main/scala/docspell/backend/signup/OSignup.scala
@@ -11,12 +11,10 @@ import cats.implicits._
 
 import docspell.backend.PasswordCrypt
 import docspell.common._
-import docspell.common.syntax.all._
 import docspell.store.records.{RCollective, RInvitation, RUser}
 import docspell.store.{AddResult, Store}
 
 import doobie.free.connection.ConnectionIO
-import org.log4s.getLogger
 
 trait OSignup[F[_]] {
 
@@ -29,10 +27,10 @@ trait OSignup[F[_]] {
 }
 
 object OSignup {
-  private[this] val logger = getLogger
 
   def apply[F[_]: Async](store: Store[F]): Resource[F, OSignup[F]] =
     Resource.pure[F, OSignup[F]](new OSignup[F] {
+      private[this] val logger = docspell.logging.getLogger[F]
 
       def newInvite(cfg: Config)(password: Password): F[NewInviteResult] =
         if (cfg.mode == Config.Mode.Invite)
@@ -66,7 +64,7 @@ object OSignup {
                   _ <-
                     if (retryInvite(res))
                       logger
-                        .fdebug(
+                        .debug(
                           s"Adding account failed ($res). Allow retry with invite."
                         ) *> store
                         .transact(
diff --git a/modules/common/src/main/scala/docspell/common/LogLevel.scala b/modules/common/src/main/scala/docspell/common/LogLevel.scala
index efec4d73..201c7a4f 100644
--- a/modules/common/src/main/scala/docspell/common/LogLevel.scala
+++ b/modules/common/src/main/scala/docspell/common/LogLevel.scala
@@ -6,6 +6,8 @@
 
 package docspell.common
 
+import docspell.logging.Level
+
 import io.circe.{Decoder, Encoder}
 
 sealed trait LogLevel { self: Product =>
@@ -40,6 +42,16 @@ object LogLevel {
       case _         => Left(s"Invalid log-level: $str")
     }
 
+  def fromLevel(level: Level): LogLevel =
+    level match {
+      case Level.Fatal => LogLevel.Error
+      case Level.Error => LogLevel.Error
+      case Level.Warn  => LogLevel.Warn
+      case Level.Info  => LogLevel.Info
+      case Level.Debug => LogLevel.Debug
+      case Level.Trace => LogLevel.Debug
+    }
+
   def unsafeString(str: String): LogLevel =
     fromString(str).fold(sys.error, identity)
 
diff --git a/modules/common/src/main/scala/docspell/common/Logger.scala b/modules/common/src/main/scala/docspell/common/Logger.scala
deleted file mode 100644
index 66b583e2..00000000
--- a/modules/common/src/main/scala/docspell/common/Logger.scala
+++ /dev/null
@@ -1,143 +0,0 @@
-/*
- * Copyright 2020 Eike K. & Contributors
- *
- * SPDX-License-Identifier: AGPL-3.0-or-later
- */
-
-package docspell.common
-
-import java.io.{PrintWriter, StringWriter}
-
-import cats.Applicative
-import cats.effect.{Ref, Sync}
-import cats.implicits._
-import fs2.Stream
-
-import docspell.common.syntax.all._
-
-import org.log4s.{Logger => Log4sLogger}
-
-trait Logger[F[_]] { self =>
-
-  def trace(msg: => String): F[Unit]
-  def debug(msg: => String): F[Unit]
-  def info(msg: => String): F[Unit]
-  def warn(msg: => String): F[Unit]
-  def error(ex: Throwable)(msg: => String): F[Unit]
-  def error(msg: => String): F[Unit]
-
-  final def s: Logger[Stream[F, *]] = new Logger[Stream[F, *]] {
-    def trace(msg: => String): Stream[F, Unit] =
-      Stream.eval(self.trace(msg))
-
-    def debug(msg: => String): Stream[F, Unit] =
-      Stream.eval(self.debug(msg))
-
-    def info(msg: => String): Stream[F, Unit] =
-      Stream.eval(self.info(msg))
-
-    def warn(msg: => String): Stream[F, Unit] =
-      Stream.eval(self.warn(msg))
-
-    def error(msg: => String): Stream[F, Unit] =
-      Stream.eval(self.error(msg))
-
-    def error(ex: Throwable)(msg: => String): Stream[F, Unit] =
-      Stream.eval(self.error(ex)(msg))
-  }
-  def andThen(other: Logger[F])(implicit F: Sync[F]): Logger[F] = {
-    val self = this
-    new Logger[F] {
-      def trace(msg: => String) =
-        self.trace(msg) >> other.trace(msg)
-
-      override def debug(msg: => String) =
-        self.debug(msg) >> other.debug(msg)
-
-      override def info(msg: => String) =
-        self.info(msg) >> other.info(msg)
-
-      override def warn(msg: => String) =
-        self.warn(msg) >> other.warn(msg)
-
-      override def error(ex: Throwable)(msg: => String) =
-        self.error(ex)(msg) >> other.error(ex)(msg)
-
-      override def error(msg: => String) =
-        self.error(msg) >> other.error(msg)
-    }
-  }
-}
-
-object Logger {
-
-  def off[F[_]: Applicative]: Logger[F] =
-    new Logger[F] {
-      def trace(msg: => String): F[Unit] =
-        Applicative[F].pure(())
-
-      def debug(msg: => String): F[Unit] =
-        Applicative[F].pure(())
-
-      def info(msg: => String): F[Unit] =
-        Applicative[F].pure(())
-
-      def warn(msg: => String): F[Unit] =
-        Applicative[F].pure(())
-
-      def error(ex: Throwable)(msg: => String): F[Unit] =
-        Applicative[F].pure(())
-
-      def error(msg: => String): F[Unit] =
-        Applicative[F].pure(())
-    }
-
-  def log4s[F[_]: Sync](log: Log4sLogger): Logger[F] =
-    new Logger[F] {
-      def trace(msg: => String): F[Unit] =
-        log.ftrace(msg)
-
-      def debug(msg: => String): F[Unit] =
-        log.fdebug(msg)
-
-      def info(msg: => String): F[Unit] =
-        log.finfo(msg)
-
-      def warn(msg: => String): F[Unit] =
-        log.fwarn(msg)
-
-      def error(ex: Throwable)(msg: => String): F[Unit] =
-        log.ferror(ex)(msg)
-
-      def error(msg: => String): F[Unit] =
-        log.ferror(msg)
-    }
-
-  def buffer[F[_]: Sync](): F[(Ref[F, Vector[String]], Logger[F])] =
-    for {
-      buffer <- Ref.of[F, Vector[String]](Vector.empty[String])
-      logger = new Logger[F] {
-        def trace(msg: => String) =
-          buffer.update(_.appended(s"TRACE $msg"))
-
-        def debug(msg: => String) =
-          buffer.update(_.appended(s"DEBUG $msg"))
-
-        def info(msg: => String) =
-          buffer.update(_.appended(s"INFO $msg"))
-
-        def warn(msg: => String) =
-          buffer.update(_.appended(s"WARN $msg"))
-
-        def error(ex: Throwable)(msg: => String) = {
-          val ps = new StringWriter()
-          ex.printStackTrace(new PrintWriter(ps))
-          buffer.update(_.appended(s"ERROR $msg:\n$ps"))
-        }
-
-        def error(msg: => String) =
-          buffer.update(_.appended(s"ERROR $msg"))
-      }
-    } yield (buffer, logger)
-
-}
diff --git a/modules/common/src/main/scala/docspell/common/SystemCommand.scala b/modules/common/src/main/scala/docspell/common/SystemCommand.scala
index 2382c36e..63f4ca40 100644
--- a/modules/common/src/main/scala/docspell/common/SystemCommand.scala
+++ b/modules/common/src/main/scala/docspell/common/SystemCommand.scala
@@ -17,6 +17,8 @@ import cats.implicits._
 import fs2.io.file.Path
 import fs2.{Stream, io, text}
 
+import docspell.logging.Logger
+
 object SystemCommand {
 
   final case class Config(program: String, args: Seq[String], timeout: Duration) {
diff --git a/modules/common/src/main/scala/docspell/common/syntax/LoggerSyntax.scala b/modules/common/src/main/scala/docspell/common/syntax/LoggerSyntax.scala
deleted file mode 100644
index 54490a85..00000000
--- a/modules/common/src/main/scala/docspell/common/syntax/LoggerSyntax.scala
+++ /dev/null
@@ -1,42 +0,0 @@
-/*
- * Copyright 2020 Eike K. & Contributors
- *
- * SPDX-License-Identifier: AGPL-3.0-or-later
- */
-
-package docspell.common.syntax
-
-import cats.effect.Sync
-import fs2.Stream
-
-import org.log4s.Logger
-
-trait LoggerSyntax {
-
-  implicit final class LoggerOps(logger: Logger) {
-
-    def ftrace[F[_]: Sync](msg: => String): F[Unit] =
-      Sync[F].delay(logger.trace(msg))
-
-    def fdebug[F[_]: Sync](msg: => String): F[Unit] =
-      Sync[F].delay(logger.debug(msg))
-
-    def sdebug[F[_]: Sync](msg: => String): Stream[F, Nothing] =
-      Stream.eval(fdebug(msg)).drain
-
-    def finfo[F[_]: Sync](msg: => String): F[Unit] =
-      Sync[F].delay(logger.info(msg))
-
-    def sinfo[F[_]: Sync](msg: => String): Stream[F, Nothing] =
-      Stream.eval(finfo(msg)).drain
-
-    def fwarn[F[_]: Sync](msg: => String): F[Unit] =
-      Sync[F].delay(logger.warn(msg))
-
-    def ferror[F[_]: Sync](msg: => String): F[Unit] =
-      Sync[F].delay(logger.error(msg))
-
-    def ferror[F[_]: Sync](ex: Throwable)(msg: => String): F[Unit] =
-      Sync[F].delay(logger.error(ex)(msg))
-  }
-}
diff --git a/modules/common/src/main/scala/docspell/common/syntax/package.scala b/modules/common/src/main/scala/docspell/common/syntax/package.scala
index 522feaf0..a295982d 100644
--- a/modules/common/src/main/scala/docspell/common/syntax/package.scala
+++ b/modules/common/src/main/scala/docspell/common/syntax/package.scala
@@ -8,11 +8,6 @@ package docspell.common
 
 package object syntax {
 
-  object all
-      extends EitherSyntax
-      with StreamSyntax
-      with StringSyntax
-      with LoggerSyntax
-      with FileSyntax
+  object all extends EitherSyntax with StreamSyntax with StringSyntax with FileSyntax
 
 }
diff --git a/modules/config/src/main/scala/docspell/config/ConfigFactory.scala b/modules/config/src/main/scala/docspell/config/ConfigFactory.scala
index 47cfac26..3522a48e 100644
--- a/modules/config/src/main/scala/docspell/config/ConfigFactory.scala
+++ b/modules/config/src/main/scala/docspell/config/ConfigFactory.scala
@@ -13,7 +13,7 @@ import cats.effect._
 import cats.implicits._
 import fs2.io.file.{Files, Path}
 
-import docspell.common.Logger
+import docspell.logging.Logger
 
 import pureconfig.{ConfigReader, ConfigSource}
 
diff --git a/modules/config/src/main/scala/docspell/config/Implicits.scala b/modules/config/src/main/scala/docspell/config/Implicits.scala
index 77136cbc..0bc7dda4 100644
--- a/modules/config/src/main/scala/docspell/config/Implicits.scala
+++ b/modules/config/src/main/scala/docspell/config/Implicits.scala
@@ -13,6 +13,7 @@ import scala.reflect.ClassTag
 import fs2.io.file.Path
 
 import docspell.common._
+import docspell.logging.{Level, LogConfig}
 
 import com.github.eikek.calev.CalEvent
 import pureconfig.ConfigReader
@@ -63,6 +64,12 @@ object Implicits {
   implicit val nlpModeReader: ConfigReader[NlpMode] =
     ConfigReader[String].emap(reason(NlpMode.fromString))
 
+  implicit val logFormatReader: ConfigReader[LogConfig.Format] =
+    ConfigReader[String].emap(reason(LogConfig.Format.fromString))
+
+  implicit val logLevelReader: ConfigReader[Level] =
+    ConfigReader[String].emap(reason(Level.fromString))
+
   def reason[A: ClassTag](
       f: String => Either[String, A]
   ): String => Either[FailureReason, A] =
diff --git a/modules/convert/src/main/scala/docspell/convert/Conversion.scala b/modules/convert/src/main/scala/docspell/convert/Conversion.scala
index b1a05aa4..19b1279c 100644
--- a/modules/convert/src/main/scala/docspell/convert/Conversion.scala
+++ b/modules/convert/src/main/scala/docspell/convert/Conversion.scala
@@ -17,6 +17,7 @@ import docspell.convert.ConversionResult.Handler
 import docspell.convert.extern._
 import docspell.convert.flexmark.Markdown
 import docspell.files.{ImageSize, TikaMimetype}
+import docspell.logging.Logger
 
 import scodec.bits.ByteVector
 
@@ -46,7 +47,7 @@ object Conversion {
             val allPass = cfg.decryptPdf.passwords ++ additionalPasswords
             val pdfStream =
               if (cfg.decryptPdf.enabled) {
-                logger.s
+                logger.stream
                   .debug(s"Trying to read the PDF using ${allPass.size} passwords")
                   .drain ++
                   in.through(RemovePdfEncryption(logger, allPass))
diff --git a/modules/convert/src/main/scala/docspell/convert/RemovePdfEncryption.scala b/modules/convert/src/main/scala/docspell/convert/RemovePdfEncryption.scala
index 4d7a469f..0ea9232b 100644
--- a/modules/convert/src/main/scala/docspell/convert/RemovePdfEncryption.scala
+++ b/modules/convert/src/main/scala/docspell/convert/RemovePdfEncryption.scala
@@ -12,6 +12,7 @@ import cats.effect._
 import fs2.{Chunk, Pipe, Stream}
 
 import docspell.common._
+import docspell.logging.Logger
 
 import org.apache.pdfbox.pdmodel.PDDocument
 import org.apache.pdfbox.pdmodel.encryption.InvalidPasswordException
@@ -36,7 +37,7 @@ object RemovePdfEncryption {
         .head
         .flatMap { doc =>
           if (doc.isEncrypted) {
-            logger.s.debug("Removing protection/encryption from PDF").drain ++
+            logger.stream.debug("Removing protection/encryption from PDF").drain ++
               Stream.eval(Sync[F].delay(doc.setAllSecurityToBeRemoved(true))).drain ++
               toStream[F](doc)
           } else {
@@ -44,7 +45,7 @@ object RemovePdfEncryption {
           }
         }
         .ifEmpty(
-          logger.s
+          logger.stream
             .info(
               s"None of the passwords helped to read the given PDF!"
             )
@@ -64,7 +65,8 @@ object RemovePdfEncryption {
 
     val log =
       if (pw.isEmpty) Stream.empty
-      else logger.s.debug(s"Try opening PDF with password: ${pw.pass.take(2)}***").drain
+      else
+        logger.stream.debug(s"Try opening PDF with password: ${pw.pass.take(2)}***").drain
 
     in =>
       Stream
diff --git a/modules/convert/src/main/scala/docspell/convert/extern/ExternConv.scala b/modules/convert/src/main/scala/docspell/convert/extern/ExternConv.scala
index 419c3506..eff5a0fb 100644
--- a/modules/convert/src/main/scala/docspell/convert/extern/ExternConv.scala
+++ b/modules/convert/src/main/scala/docspell/convert/extern/ExternConv.scala
@@ -14,6 +14,7 @@ import fs2.{Pipe, Stream}
 import docspell.common._
 import docspell.convert.ConversionResult
 import docspell.convert.ConversionResult.{Handler, successPdf, successPdfTxt}
+import docspell.logging.Logger
 
 private[extern] object ExternConv {
 
diff --git a/modules/convert/src/main/scala/docspell/convert/extern/OcrMyPdf.scala b/modules/convert/src/main/scala/docspell/convert/extern/OcrMyPdf.scala
index 2b8cc529..d133b4dd 100644
--- a/modules/convert/src/main/scala/docspell/convert/extern/OcrMyPdf.scala
+++ b/modules/convert/src/main/scala/docspell/convert/extern/OcrMyPdf.scala
@@ -13,6 +13,7 @@ import fs2.io.file.Path
 import docspell.common._
 import docspell.convert.ConversionResult
 import docspell.convert.ConversionResult.Handler
+import docspell.logging.Logger
 
 object OcrMyPdf {
 
diff --git a/modules/convert/src/main/scala/docspell/convert/extern/Tesseract.scala b/modules/convert/src/main/scala/docspell/convert/extern/Tesseract.scala
index 7fd89f66..bd2ae95d 100644
--- a/modules/convert/src/main/scala/docspell/convert/extern/Tesseract.scala
+++ b/modules/convert/src/main/scala/docspell/convert/extern/Tesseract.scala
@@ -13,6 +13,7 @@ import fs2.io.file.Path
 import docspell.common._
 import docspell.convert.ConversionResult
 import docspell.convert.ConversionResult.Handler
+import docspell.logging.Logger
 
 object Tesseract {
 
diff --git a/modules/convert/src/main/scala/docspell/convert/extern/Unoconv.scala b/modules/convert/src/main/scala/docspell/convert/extern/Unoconv.scala
index 196f874f..efe0efa7 100644
--- a/modules/convert/src/main/scala/docspell/convert/extern/Unoconv.scala
+++ b/modules/convert/src/main/scala/docspell/convert/extern/Unoconv.scala
@@ -13,6 +13,7 @@ import fs2.io.file.Path
 import docspell.common._
 import docspell.convert.ConversionResult
 import docspell.convert.ConversionResult.Handler
+import docspell.logging.Logger
 
 object Unoconv {
 
diff --git a/modules/convert/src/main/scala/docspell/convert/extern/WkHtmlPdf.scala b/modules/convert/src/main/scala/docspell/convert/extern/WkHtmlPdf.scala
index 437978b7..d3ed16c0 100644
--- a/modules/convert/src/main/scala/docspell/convert/extern/WkHtmlPdf.scala
+++ b/modules/convert/src/main/scala/docspell/convert/extern/WkHtmlPdf.scala
@@ -16,6 +16,7 @@ import fs2.{Chunk, Stream}
 import docspell.common._
 import docspell.convert.ConversionResult.Handler
 import docspell.convert.{ConversionResult, SanitizeHtml}
+import docspell.logging.Logger
 
 object WkHtmlPdf {
 
diff --git a/modules/convert/src/test/scala/docspell/convert/ConversionTest.scala b/modules/convert/src/test/scala/docspell/convert/ConversionTest.scala
index 8f9f191f..5538d19b 100644
--- a/modules/convert/src/test/scala/docspell/convert/ConversionTest.scala
+++ b/modules/convert/src/test/scala/docspell/convert/ConversionTest.scala
@@ -20,12 +20,13 @@ import docspell.convert.extern.OcrMyPdfConfig
 import docspell.convert.extern.{TesseractConfig, UnoconvConfig, WkHtmlPdfConfig}
 import docspell.convert.flexmark.MarkdownConfig
 import docspell.files.ExampleFiles
+import docspell.logging.{Level, Logger}
 
 import munit._
 
 class ConversionTest extends FunSuite with FileChecks {
 
-  val logger = Logger.log4s[IO](org.log4s.getLogger)
+  val logger = Logger.simpleF[IO](System.err, Level.Info)
   val target = File.path(Paths.get("target"))
 
   val convertConfig = ConvertConfig(
diff --git a/modules/convert/src/test/scala/docspell/convert/RemovePdfEncryptionTest.scala b/modules/convert/src/test/scala/docspell/convert/RemovePdfEncryptionTest.scala
index 803f3174..7e386c36 100644
--- a/modules/convert/src/test/scala/docspell/convert/RemovePdfEncryptionTest.scala
+++ b/modules/convert/src/test/scala/docspell/convert/RemovePdfEncryptionTest.scala
@@ -11,11 +11,12 @@ import fs2.Stream
 
 import docspell.common._
 import docspell.files.ExampleFiles
+import docspell.logging.{Level, Logger}
 
 import munit.CatsEffectSuite
 
 class RemovePdfEncryptionTest extends CatsEffectSuite with FileChecks {
-  val logger: Logger[IO] = Logger.log4s(org.log4s.getLogger)
+  val logger: Logger[IO] = Logger.simpleF[IO](System.err, Level.Info)
 
   private val protectedPdf =
     ExampleFiles.secured_protected_test123_pdf.readURL[IO](16 * 1024)
diff --git a/modules/convert/src/test/scala/docspell/convert/extern/ExternConvTest.scala b/modules/convert/src/test/scala/docspell/convert/extern/ExternConvTest.scala
index 3e5d5991..7bf8480b 100644
--- a/modules/convert/src/test/scala/docspell/convert/extern/ExternConvTest.scala
+++ b/modules/convert/src/test/scala/docspell/convert/extern/ExternConvTest.scala
@@ -16,12 +16,13 @@ import fs2.io.file.Path
 import docspell.common._
 import docspell.convert._
 import docspell.files.ExampleFiles
+import docspell.logging.{Level, Logger}
 
 import munit._
 
 class ExternConvTest extends FunSuite with FileChecks {
   val utf8 = StandardCharsets.UTF_8
-  val logger = Logger.log4s[IO](org.log4s.getLogger)
+  val logger = Logger.simpleF[IO](System.err, Level.Info)
   val target = File.path(Paths.get("target"))
 
   test("convert html to pdf") {
diff --git a/modules/extract/src/main/scala/docspell/extract/Extraction.scala b/modules/extract/src/main/scala/docspell/extract/Extraction.scala
index 6bb9b794..c1a27023 100644
--- a/modules/extract/src/main/scala/docspell/extract/Extraction.scala
+++ b/modules/extract/src/main/scala/docspell/extract/Extraction.scala
@@ -18,6 +18,7 @@ import docspell.extract.poi.{PoiExtract, PoiType}
 import docspell.extract.rtf.RtfExtract
 import docspell.files.ImageSize
 import docspell.files.TikaMimetype
+import docspell.logging.Logger
 
 trait Extraction[F[_]] {
 
diff --git a/modules/extract/src/main/scala/docspell/extract/PdfExtract.scala b/modules/extract/src/main/scala/docspell/extract/PdfExtract.scala
index c6672553..a9bf2858 100644
--- a/modules/extract/src/main/scala/docspell/extract/PdfExtract.scala
+++ b/modules/extract/src/main/scala/docspell/extract/PdfExtract.scala
@@ -10,11 +10,12 @@ import cats.effect._
 import cats.implicits._
 import fs2.Stream
 
-import docspell.common.{Language, Logger}
+import docspell.common.Language
 import docspell.extract.internal.Text
 import docspell.extract.ocr.{OcrConfig, TextExtract}
 import docspell.extract.pdfbox.PdfMetaData
 import docspell.extract.pdfbox.PdfboxExtract
+import docspell.logging.Logger
 
 object PdfExtract {
   final case class Result(txt: Text, meta: Option[PdfMetaData])
diff --git a/modules/extract/src/main/scala/docspell/extract/ocr/Ocr.scala b/modules/extract/src/main/scala/docspell/extract/ocr/Ocr.scala
index 9e5d6a92..727666a8 100644
--- a/modules/extract/src/main/scala/docspell/extract/ocr/Ocr.scala
+++ b/modules/extract/src/main/scala/docspell/extract/ocr/Ocr.scala
@@ -11,6 +11,7 @@ import fs2.Stream
 import fs2.io.file.Path
 
 import docspell.common._
+import docspell.logging.Logger
 
 object Ocr {
 
diff --git a/modules/extract/src/main/scala/docspell/extract/ocr/TextExtract.scala b/modules/extract/src/main/scala/docspell/extract/ocr/TextExtract.scala
index a345da13..a08a2cee 100644
--- a/modules/extract/src/main/scala/docspell/extract/ocr/TextExtract.scala
+++ b/modules/extract/src/main/scala/docspell/extract/ocr/TextExtract.scala
@@ -12,6 +12,7 @@ import fs2.Stream
 import docspell.common._
 import docspell.extract.internal.Text
 import docspell.files._
+import docspell.logging.Logger
 
 object TextExtract {
 
diff --git a/modules/extract/src/main/scala/docspell/extract/pdfbox/PdfboxPreview.scala b/modules/extract/src/main/scala/docspell/extract/pdfbox/PdfboxPreview.scala
index b0ddadc7..32481e49 100644
--- a/modules/extract/src/main/scala/docspell/extract/pdfbox/PdfboxPreview.scala
+++ b/modules/extract/src/main/scala/docspell/extract/pdfbox/PdfboxPreview.scala
@@ -32,7 +32,7 @@ trait PdfboxPreview[F[_]] {
 }
 
 object PdfboxPreview {
-  private[this] val logger = org.log4s.getLogger
+  private[this] val logger = docspell.logging.unsafeLogger
 
   def apply[F[_]: Sync](cfg: PreviewConfig): F[PdfboxPreview[F]] =
     Sync[F].pure(new PdfboxPreview[F] {
diff --git a/modules/extract/src/test/scala/docspell/extract/ocr/TextExtractionSuite.scala b/modules/extract/src/test/scala/docspell/extract/ocr/TextExtractionSuite.scala
index d64af3b2..a21c5438 100644
--- a/modules/extract/src/test/scala/docspell/extract/ocr/TextExtractionSuite.scala
+++ b/modules/extract/src/test/scala/docspell/extract/ocr/TextExtractionSuite.scala
@@ -9,7 +9,6 @@ package docspell.extract.ocr
 import cats.effect.IO
 import cats.effect.unsafe.implicits.global
 
-import docspell.common.Logger
 import docspell.files.TestFiles
 
 import munit._
@@ -17,7 +16,7 @@ import munit._
 class TextExtractionSuite extends FunSuite {
   import TestFiles._
 
-  val logger = Logger.log4s[IO](org.log4s.getLogger)
+  val logger = docspell.logging.getLogger[IO]
 
   test("extract english pdf".ignore) {
     val text = TextExtract
diff --git a/modules/fts-client/src/main/scala/docspell/ftsclient/FtsClient.scala b/modules/fts-client/src/main/scala/docspell/ftsclient/FtsClient.scala
index 20311dcb..13ee23c3 100644
--- a/modules/fts-client/src/main/scala/docspell/ftsclient/FtsClient.scala
+++ b/modules/fts-client/src/main/scala/docspell/ftsclient/FtsClient.scala
@@ -11,8 +11,7 @@ import cats.implicits._
 import fs2.Stream
 
 import docspell.common._
-
-import org.log4s.getLogger
+import docspell.logging.Logger
 
 /** The fts client is the interface for docspell to a fulltext search engine.
   *
@@ -127,7 +126,7 @@ object FtsClient {
 
   def none[F[_]: Sync] =
     new FtsClient[F] {
-      private[this] val logger = Logger.log4s[F](getLogger)
+      private[this] val logger = docspell.logging.getLogger[F]
 
       def initialize: F[List[FtsMigration[F]]] =
         Sync[F].pure(Nil)
diff --git a/modules/fts-solr/src/main/scala/docspell/ftssolr/SolrFtsClient.scala b/modules/fts-solr/src/main/scala/docspell/ftssolr/SolrFtsClient.scala
index 75316900..16f7bd13 100644
--- a/modules/fts-solr/src/main/scala/docspell/ftssolr/SolrFtsClient.scala
+++ b/modules/fts-solr/src/main/scala/docspell/ftssolr/SolrFtsClient.scala
@@ -12,10 +12,10 @@ import fs2.Stream
 
 import docspell.common._
 import docspell.ftsclient._
+import docspell.logging.Logger
 
 import org.http4s.client.Client
-import org.http4s.client.middleware.Logger
-import org.log4s.getLogger
+import org.http4s.client.middleware.{Logger => Http4sLogger}
 
 final class SolrFtsClient[F[_]: Async](
     solrUpdate: SolrUpdate[F],
@@ -81,7 +81,6 @@ final class SolrFtsClient[F[_]: Async](
 }
 
 object SolrFtsClient {
-  private[this] val logger = getLogger
 
   def apply[F[_]: Async](
       cfg: SolrConfig,
@@ -100,11 +99,13 @@ object SolrFtsClient {
   private def loggingMiddleware[F[_]: Async](
       cfg: SolrConfig,
       client: Client[F]
-  ): Client[F] =
-    Logger(
+  ): Client[F] = {
+    val delegate = docspell.logging.getLogger[F]
+    Http4sLogger(
       logHeaders = true,
       logBody = cfg.logVerbose,
-      logAction = Some((msg: String) => Sync[F].delay(logger.trace(msg)))
+      logAction = Some((msg: String) => delegate.trace(msg))
     )(client)
+  }
 
 }
diff --git a/modules/joex/src/main/resources/reference.conf b/modules/joex/src/main/resources/reference.conf
index 80348f57..5285c282 100644
--- a/modules/joex/src/main/resources/reference.conf
+++ b/modules/joex/src/main/resources/reference.conf
@@ -18,6 +18,17 @@ docspell.joex {
     port = 7878
   }
 
+  # Configures logging
+  logging {
+    # The format for the log messages. Can be one of:
+    # Json, Logfmt, Fancy or Plain
+    format = "Json"
+
+    # The minimum level to log. From lowest to highest:
+    # Trace, Debug, Info, Warn, Error
+    minimumLevel = "Debug"
+  }
+
   # The database connection.
   #
   # It must be the same connection as the rest server is using.
diff --git a/modules/joex/src/main/scala/docspell/joex/Config.scala b/modules/joex/src/main/scala/docspell/joex/Config.scala
index db9309f0..549b24ca 100644
--- a/modules/joex/src/main/scala/docspell/joex/Config.scala
+++ b/modules/joex/src/main/scala/docspell/joex/Config.scala
@@ -21,12 +21,14 @@ import docspell.joex.hk.HouseKeepingConfig
 import docspell.joex.routes.InternalHeader
 import docspell.joex.scheduler.{PeriodicSchedulerConfig, SchedulerConfig}
 import docspell.joex.updatecheck.UpdateCheckConfig
+import docspell.logging.LogConfig
 import docspell.pubsub.naive.PubSubConfig
 import docspell.store.JdbcConfig
 
 case class Config(
     appId: Ident,
     baseUrl: LenientUri,
+    logging: LogConfig,
     bind: Config.Bind,
     jdbc: JdbcConfig,
     scheduler: SchedulerConfig,
diff --git a/modules/joex/src/main/scala/docspell/joex/ConfigFile.scala b/modules/joex/src/main/scala/docspell/joex/ConfigFile.scala
index 41541480..a3663030 100644
--- a/modules/joex/src/main/scala/docspell/joex/ConfigFile.scala
+++ b/modules/joex/src/main/scala/docspell/joex/ConfigFile.scala
@@ -8,7 +8,6 @@ package docspell.joex
 
 import cats.effect.Async
 
-import docspell.common.Logger
 import docspell.config.Implicits._
 import docspell.config.{ConfigFactory, Validation}
 import docspell.joex.scheduler.CountingScheme
@@ -23,7 +22,7 @@ object ConfigFile {
   import Implicits._
 
   def loadConfig[F[_]: Async](args: List[String]): F[Config] = {
-    val logger = Logger.log4s[F](org.log4s.getLogger)
+    val logger = docspell.logging.getLogger[F]
     ConfigFactory
       .default[F, Config](logger, "docspell.joex")(args, validate)
   }
diff --git a/modules/joex/src/main/scala/docspell/joex/JoexAppImpl.scala b/modules/joex/src/main/scala/docspell/joex/JoexAppImpl.scala
index 5c4a01a1..ce28e8d3 100644
--- a/modules/joex/src/main/scala/docspell/joex/JoexAppImpl.scala
+++ b/modules/joex/src/main/scala/docspell/joex/JoexAppImpl.scala
@@ -132,10 +132,8 @@ object JoexAppImpl extends MailAddressCodec {
   ): Resource[F, JoexApp[F]] =
     for {
       pstore <- PeriodicTaskStore.create(store)
-      pubSubT = PubSubT(
-        pubSub,
-        Logger.log4s(org.log4s.getLogger(s"joex-${cfg.appId.id}"))
-      )
+      joexLogger = docspell.logging.getLogger[F](s"joex-${cfg.appId.id}")
+      pubSubT = PubSubT(pubSub, joexLogger)
       javaEmil =
         JavaMailEmil(Settings.defaultSettings.copy(debug = cfg.mailDebug))
       notificationMod <- Resource.eval(
diff --git a/modules/joex/src/main/scala/docspell/joex/Main.scala b/modules/joex/src/main/scala/docspell/joex/Main.scala
index 6f96e1e0..7866d6d1 100644
--- a/modules/joex/src/main/scala/docspell/joex/Main.scala
+++ b/modules/joex/src/main/scala/docspell/joex/Main.scala
@@ -9,12 +9,12 @@ package docspell.joex
 import cats.effect._
 
 import docspell.common._
-
-import org.log4s.getLogger
+import docspell.logging.Logger
+import docspell.logging.impl.ScribeConfigure
 
 object Main extends IOApp {
 
-  private val logger: Logger[IO] = Logger.log4s[IO](getLogger)
+  private val logger: Logger[IO] = docspell.logging.getLogger[IO]
 
   private val connectEC =
     ThreadFactories.fixed[IO](5, ThreadFactories.ofName("docspell-joex-dbconnect"))
@@ -22,6 +22,7 @@ object Main extends IOApp {
   def run(args: List[String]): IO[ExitCode] =
     for {
       cfg <- ConfigFile.loadConfig[IO](args)
+      _ <- ScribeConfigure.configure[IO](cfg.logging)
       banner = Banner(
         "JOEX",
         BuildInfo.version,
diff --git a/modules/joex/src/main/scala/docspell/joex/analysis/RegexNerFile.scala b/modules/joex/src/main/scala/docspell/joex/analysis/RegexNerFile.scala
index dddabebf..2f46234e 100644
--- a/modules/joex/src/main/scala/docspell/joex/analysis/RegexNerFile.scala
+++ b/modules/joex/src/main/scala/docspell/joex/analysis/RegexNerFile.scala
@@ -12,7 +12,6 @@ import cats.implicits._
 import fs2.io.file.Path
 
 import docspell.common._
-import docspell.common.syntax.all._
 import docspell.store.Store
 import docspell.store.queries.QCollective
 import docspell.store.records.REquipment
@@ -20,7 +19,6 @@ import docspell.store.records.ROrganization
 import docspell.store.records.RPerson
 
 import io.circe.syntax._
-import org.log4s.getLogger
 
 /** Maintains a custom regex-ner file per collective for stanford's regexner annotator. */
 trait RegexNerFile[F[_]] {
@@ -30,7 +28,6 @@ trait RegexNerFile[F[_]] {
 }
 
 object RegexNerFile {
-  private[this] val logger = getLogger
 
   case class Config(maxEntries: Int, directory: Path, minTime: Duration)
 
@@ -49,6 +46,8 @@ object RegexNerFile {
       writer: Semaphore[F] // TODO allow parallelism per collective
   ) extends RegexNerFile[F] {
 
+    private[this] val logger = docspell.logging.getLogger[F]
+
     def makeFile(collective: Ident): F[Option[Path]] =
       if (cfg.maxEntries > 0) doMakeFile(collective)
       else (None: Option[Path]).pure[F]
@@ -61,7 +60,7 @@ object RegexNerFile {
           case Some(nf) =>
             val dur = Duration.between(nf.creation, now)
             if (dur > cfg.minTime)
-              logger.fdebug(
+              logger.debug(
                 s"Cache time elapsed ($dur > ${cfg.minTime}). Check for new state."
               ) *> updateFile(
                 collective,
@@ -89,12 +88,12 @@ object RegexNerFile {
               case Some(cur) =>
                 val nerf =
                   if (cur.updated == lup)
-                    logger.fdebug(s"No state change detected.") *> updateTimestamp(
+                    logger.debug(s"No state change detected.") *> updateTimestamp(
                       cur,
                       now
                     ) *> cur.pure[F]
                   else
-                    logger.fdebug(
+                    logger.debug(
                       s"There have been state changes for collective '${collective.id}'. Reload NER file."
                     ) *> createFile(lup, collective, now)
                 nerf.map(_.nerFilePath(cfg.directory).some)
@@ -126,7 +125,7 @@ object RegexNerFile {
         writer.permit.use(_ =>
           for {
             jsonFile <- Sync[F].pure(nf.jsonFilePath(cfg.directory))
-            _ <- logger.fdebug(
+            _ <- logger.debug(
               s"Writing custom NER file for collective '${collective.id}'"
             )
             _ <- jsonFile.parent match {
@@ -139,7 +138,7 @@ object RegexNerFile {
         )
 
       for {
-        _ <- logger.finfo(s"Generating custom NER file for collective '${collective.id}'")
+        _ <- logger.info(s"Generating custom NER file for collective '${collective.id}'")
         names <- store.transact(QCollective.allNames(collective, cfg.maxEntries))
         nerFile = NerFile(collective, lastUpdate, now)
         _ <- update(nerFile, NerFile.mkNerConfig(names))
diff --git a/modules/joex/src/main/scala/docspell/joex/fts/FtsContext.scala b/modules/joex/src/main/scala/docspell/joex/fts/FtsContext.scala
index e2aca8c0..8e7f133b 100644
--- a/modules/joex/src/main/scala/docspell/joex/fts/FtsContext.scala
+++ b/modules/joex/src/main/scala/docspell/joex/fts/FtsContext.scala
@@ -7,10 +7,10 @@
 package docspell.joex.fts
 
 import docspell.backend.fulltext.CreateIndex
-import docspell.common.Logger
 import docspell.ftsclient.FtsClient
 import docspell.joex.Config
 import docspell.joex.scheduler.Context
+import docspell.logging.Logger
 import docspell.store.Store
 
 case class FtsContext[F[_]](
diff --git a/modules/joex/src/main/scala/docspell/joex/fts/FtsWork.scala b/modules/joex/src/main/scala/docspell/joex/fts/FtsWork.scala
index aeae553c..1184c0a3 100644
--- a/modules/joex/src/main/scala/docspell/joex/fts/FtsWork.scala
+++ b/modules/joex/src/main/scala/docspell/joex/fts/FtsWork.scala
@@ -15,6 +15,7 @@ import docspell.common._
 import docspell.ftsclient._
 import docspell.joex.Config
 import docspell.joex.scheduler.Context
+import docspell.logging.Logger
 
 object FtsWork {
   import syntax._
diff --git a/modules/joex/src/main/scala/docspell/joex/fts/Migration.scala b/modules/joex/src/main/scala/docspell/joex/fts/Migration.scala
index b0b92c35..2788e606 100644
--- a/modules/joex/src/main/scala/docspell/joex/fts/Migration.scala
+++ b/modules/joex/src/main/scala/docspell/joex/fts/Migration.scala
@@ -15,6 +15,7 @@ import docspell.backend.fulltext.CreateIndex
 import docspell.common._
 import docspell.ftsclient._
 import docspell.joex.Config
+import docspell.logging.Logger
 import docspell.store.Store
 
 /** Migrating the index from the previous version to this version.
diff --git a/modules/joex/src/main/scala/docspell/joex/hk/CheckNodesTask.scala b/modules/joex/src/main/scala/docspell/joex/hk/CheckNodesTask.scala
index ecba752b..8e0bdb1c 100644
--- a/modules/joex/src/main/scala/docspell/joex/hk/CheckNodesTask.scala
+++ b/modules/joex/src/main/scala/docspell/joex/hk/CheckNodesTask.scala
@@ -11,6 +11,7 @@ import cats.implicits._
 
 import docspell.common._
 import docspell.joex.scheduler.{Context, Task}
+import docspell.logging.Logger
 import docspell.store.records._
 
 import org.http4s.blaze.client.BlazeClientBuilder
diff --git a/modules/joex/src/main/scala/docspell/joex/learn/Classify.scala b/modules/joex/src/main/scala/docspell/joex/learn/Classify.scala
index e208c79d..1d61f4fd 100644
--- a/modules/joex/src/main/scala/docspell/joex/learn/Classify.scala
+++ b/modules/joex/src/main/scala/docspell/joex/learn/Classify.scala
@@ -14,6 +14,7 @@ import fs2.io.file.Path
 
 import docspell.analysis.classifier.{ClassifierModel, TextClassifier}
 import docspell.common._
+import docspell.logging.Logger
 import docspell.store.Store
 import docspell.store.records.RClassifierModel
 
diff --git a/modules/joex/src/main/scala/docspell/joex/learn/LearnClassifierTask.scala b/modules/joex/src/main/scala/docspell/joex/learn/LearnClassifierTask.scala
index 92fbf401..129afc5a 100644
--- a/modules/joex/src/main/scala/docspell/joex/learn/LearnClassifierTask.scala
+++ b/modules/joex/src/main/scala/docspell/joex/learn/LearnClassifierTask.scala
@@ -15,6 +15,7 @@ import docspell.backend.ops.OCollective
 import docspell.common._
 import docspell.joex.Config
 import docspell.joex.scheduler._
+import docspell.logging.Logger
 import docspell.store.records.{RClassifierModel, RClassifierSetting}
 
 object LearnClassifierTask {
diff --git a/modules/joex/src/main/scala/docspell/joex/learn/StoreClassifierModel.scala b/modules/joex/src/main/scala/docspell/joex/learn/StoreClassifierModel.scala
index af614e8b..e0e7eabc 100644
--- a/modules/joex/src/main/scala/docspell/joex/learn/StoreClassifierModel.scala
+++ b/modules/joex/src/main/scala/docspell/joex/learn/StoreClassifierModel.scala
@@ -13,6 +13,7 @@ import fs2.io.file.Files
 import docspell.analysis.classifier.ClassifierModel
 import docspell.common._
 import docspell.joex.scheduler._
+import docspell.logging.Logger
 import docspell.store.Store
 import docspell.store.records.RClassifierModel
 
diff --git a/modules/joex/src/main/scala/docspell/joex/mail/ReadMail.scala b/modules/joex/src/main/scala/docspell/joex/mail/ReadMail.scala
index 3d9aa623..9b2db148 100644
--- a/modules/joex/src/main/scala/docspell/joex/mail/ReadMail.scala
+++ b/modules/joex/src/main/scala/docspell/joex/mail/ReadMail.scala
@@ -11,6 +11,7 @@ import cats.implicits._
 import fs2.{Pipe, Stream}
 
 import docspell.common._
+import docspell.logging.Logger
 import docspell.store.syntax.MimeTypes._
 
 import emil.javamail.syntax._
diff --git a/modules/joex/src/main/scala/docspell/joex/notify/TaskOperations.scala b/modules/joex/src/main/scala/docspell/joex/notify/TaskOperations.scala
index d57ba05e..da91c374 100644
--- a/modules/joex/src/main/scala/docspell/joex/notify/TaskOperations.scala
+++ b/modules/joex/src/main/scala/docspell/joex/notify/TaskOperations.scala
@@ -12,6 +12,7 @@ import cats.implicits._
 
 import docspell.backend.ops.ONotification
 import docspell.common._
+import docspell.logging.Logger
 import docspell.notification.api.ChannelRef
 import docspell.notification.api.Event
 import docspell.notification.api.EventContext
diff --git a/modules/joex/src/main/scala/docspell/joex/process/CrossCheckProposals.scala b/modules/joex/src/main/scala/docspell/joex/process/CrossCheckProposals.scala
index cccd2b90..3cd9d3ad 100644
--- a/modules/joex/src/main/scala/docspell/joex/process/CrossCheckProposals.scala
+++ b/modules/joex/src/main/scala/docspell/joex/process/CrossCheckProposals.scala
@@ -13,6 +13,7 @@ import cats.implicits._
 
 import docspell.common._
 import docspell.joex.scheduler.Task
+import docspell.logging.Logger
 
 /** After candidates have been determined, the set is reduced by doing some cross checks.
   * For example: if a organization is suggested as correspondent, the correspondent person
diff --git a/modules/joex/src/main/scala/docspell/joex/process/ReProcessItem.scala b/modules/joex/src/main/scala/docspell/joex/process/ReProcessItem.scala
index b35815f8..15aa939d 100644
--- a/modules/joex/src/main/scala/docspell/joex/process/ReProcessItem.scala
+++ b/modules/joex/src/main/scala/docspell/joex/process/ReProcessItem.scala
@@ -49,7 +49,7 @@ object ReProcessItem {
 
   private def contains[F[_]](ctx: Context[F, Args]): RAttachment => Boolean = {
     val selection = ctx.args.attachments.toSet
-    if (selection.isEmpty) (_ => true)
+    if (selection.isEmpty) _ => true
     else ra => selection.contains(ra.id)
   }
 
diff --git a/modules/joex/src/main/scala/docspell/joex/process/TestTasks.scala b/modules/joex/src/main/scala/docspell/joex/process/TestTasks.scala
deleted file mode 100644
index 95eb3423..00000000
--- a/modules/joex/src/main/scala/docspell/joex/process/TestTasks.scala
+++ /dev/null
@@ -1,45 +0,0 @@
-/*
- * Copyright 2020 Eike K. & Contributors
- *
- * SPDX-License-Identifier: AGPL-3.0-or-later
- */
-
-package docspell.joex.process
-
-import cats.effect.Sync
-import cats.implicits._
-
-import docspell.common.ProcessItemArgs
-import docspell.common.syntax.all._
-import docspell.joex.scheduler.Task
-
-import org.log4s._
-
-object TestTasks {
-  private[this] val logger = getLogger
-
-  def success[F[_]]: Task[F, ProcessItemArgs, Unit] =
-    Task(ctx => ctx.logger.info(s"Running task now: ${ctx.args}"))
-
-  def failing[F[_]: Sync]: Task[F, ProcessItemArgs, Unit] =
-    Task { ctx =>
-      ctx.logger
-        .info(s"Failing the task run :(")
-        .map(_ => sys.error("Oh, cannot extract gold from this document"))
-    }
-
-  def longRunning[F[_]: Sync]: Task[F, ProcessItemArgs, Unit] =
-    Task { ctx =>
-      logger.fwarn(s"${Thread.currentThread()} From executing long running task") >>
-        ctx.logger.info(s"${Thread.currentThread()} Running task now: ${ctx.args}") >>
-        sleep(2400) >>
-        ctx.logger.debug("doing things") >>
-        sleep(2400) >>
-        ctx.logger.debug("doing more things") >>
-        sleep(2400) >>
-        ctx.logger.info("doing more things")
-    }
-
-  private def sleep[F[_]: Sync](ms: Long): F[Unit] =
-    Sync[F].delay(Thread.sleep(ms))
-}
diff --git a/modules/joex/src/main/scala/docspell/joex/scanmailbox/ScanMailboxTask.scala b/modules/joex/src/main/scala/docspell/joex/scanmailbox/ScanMailboxTask.scala
index c866490c..869362e3 100644
--- a/modules/joex/src/main/scala/docspell/joex/scanmailbox/ScanMailboxTask.scala
+++ b/modules/joex/src/main/scala/docspell/joex/scanmailbox/ScanMailboxTask.scala
@@ -17,6 +17,7 @@ import docspell.backend.ops.{OJoex, OUpload}
 import docspell.common._
 import docspell.joex.Config
 import docspell.joex.scheduler.{Context, Task}
+import docspell.logging.Logger
 import docspell.store.queries.QOrganization
 import docspell.store.records._
 
diff --git a/modules/joex/src/main/scala/docspell/joex/scheduler/Context.scala b/modules/joex/src/main/scala/docspell/joex/scheduler/Context.scala
index 2989d887..2fb2a529 100644
--- a/modules/joex/src/main/scala/docspell/joex/scheduler/Context.scala
+++ b/modules/joex/src/main/scala/docspell/joex/scheduler/Context.scala
@@ -11,12 +11,10 @@ import cats.implicits._
 import cats.{Applicative, Functor}
 
 import docspell.common._
-import docspell.common.syntax.all._
+import docspell.logging.Logger
 import docspell.store.Store
 import docspell.store.records.RJob
 
-import org.log4s.{Logger => _, _}
-
 trait Context[F[_], A] { self =>
 
   def jobId: Ident
@@ -42,7 +40,6 @@ trait Context[F[_], A] { self =>
 }
 
 object Context {
-  private[this] val log = getLogger
 
   def create[F[_]: Async, A](
       jobId: Ident,
@@ -59,13 +56,15 @@ object Context {
       config: SchedulerConfig,
       logSink: LogSink[F],
       store: Store[F]
-  ): F[Context[F, A]] =
+  ): F[Context[F, A]] = {
+    val log = docspell.logging.getLogger[F]
     for {
-      _ <- log.ftrace("Creating logger for task run")
+      _ <- log.trace("Creating logger for task run")
       logger <- QueueLogger(job.id, job.info, config.logBufferSize, logSink)
-      _ <- log.ftrace("Logger created, instantiating context")
+      _ <- log.trace("Logger created, instantiating context")
       ctx = create[F, A](job.id, arg, config, logger, store)
     } yield ctx
+  }
 
   final private class ContextImpl[F[_]: Functor, A](
       val args: A,
diff --git a/modules/joex/src/main/scala/docspell/joex/scheduler/LogSink.scala b/modules/joex/src/main/scala/docspell/joex/scheduler/LogSink.scala
index 1117e780..ce0d074c 100644
--- a/modules/joex/src/main/scala/docspell/joex/scheduler/LogSink.scala
+++ b/modules/joex/src/main/scala/docspell/joex/scheduler/LogSink.scala
@@ -11,12 +11,9 @@ import cats.implicits._
 import fs2.Pipe
 
 import docspell.common._
-import docspell.common.syntax.all._
 import docspell.store.Store
 import docspell.store.records.RJobLog
 
-import org.log4s.{LogLevel => _, _}
-
 trait LogSink[F[_]] {
 
   def receive: Pipe[F, LogEvent, Unit]
@@ -24,29 +21,30 @@ trait LogSink[F[_]] {
 }
 
 object LogSink {
-  private[this] val logger = getLogger
 
   def apply[F[_]](sink: Pipe[F, LogEvent, Unit]): LogSink[F] =
     new LogSink[F] {
       val receive = sink
     }
 
-  def logInternal[F[_]: Sync](e: LogEvent): F[Unit] =
+  def logInternal[F[_]: Sync](e: LogEvent): F[Unit] = {
+    val logger = docspell.logging.getLogger[F]
     e.level match {
       case LogLevel.Info =>
-        logger.finfo(e.logLine)
+        logger.info(e.logLine)
       case LogLevel.Debug =>
-        logger.fdebug(e.logLine)
+        logger.debug(e.logLine)
       case LogLevel.Warn =>
-        logger.fwarn(e.logLine)
+        logger.warn(e.logLine)
       case LogLevel.Error =>
         e.ex match {
           case Some(exc) =>
-            logger.ferror(exc)(e.logLine)
+            logger.error(exc)(e.logLine)
           case None =>
-            logger.ferror(e.logLine)
+            logger.error(e.logLine)
         }
     }
+  }
 
   def printer[F[_]: Sync]: LogSink[F] =
     LogSink(_.evalMap(e => logInternal(e)))
diff --git a/modules/joex/src/main/scala/docspell/joex/scheduler/PeriodicSchedulerImpl.scala b/modules/joex/src/main/scala/docspell/joex/scheduler/PeriodicSchedulerImpl.scala
index b44ee6c4..39761a74 100644
--- a/modules/joex/src/main/scala/docspell/joex/scheduler/PeriodicSchedulerImpl.scala
+++ b/modules/joex/src/main/scala/docspell/joex/scheduler/PeriodicSchedulerImpl.scala
@@ -13,13 +13,11 @@ import fs2.concurrent.SignallingRef
 
 import docspell.backend.ops.OJoex
 import docspell.common._
-import docspell.common.syntax.all._
 import docspell.joex.scheduler.PeriodicSchedulerImpl.State
 import docspell.store.queue._
 import docspell.store.records.RPeriodicTask
 
 import eu.timepit.fs2cron.calev.CalevScheduler
-import org.log4s.getLogger
 
 final class PeriodicSchedulerImpl[F[_]: Async](
     val config: PeriodicSchedulerConfig,
@@ -30,10 +28,10 @@ final class PeriodicSchedulerImpl[F[_]: Async](
     waiter: SignallingRef[F, Boolean],
     state: SignallingRef[F, State[F]]
 ) extends PeriodicScheduler[F] {
-  private[this] val logger = getLogger
+  private[this] val logger = docspell.logging.getLogger[F]
 
   def start: Stream[F, Nothing] =
-    logger.sinfo("Starting periodic scheduler") ++
+    logger.stream.info("Starting periodic scheduler").drain ++
       mainLoop
 
   def shutdown: F[Unit] =
@@ -43,7 +41,7 @@ final class PeriodicSchedulerImpl[F[_]: Async](
     Async[F].start(
       Stream
         .awakeEvery[F](config.wakeupPeriod.toScala)
-        .evalMap(_ => logger.fdebug("Periodic awake reached") *> notifyChange)
+        .evalMap(_ => logger.debug("Periodic awake reached") *> notifyChange)
         .compile
         .drain
     )
@@ -62,22 +60,22 @@ final class PeriodicSchedulerImpl[F[_]: Async](
   def mainLoop: Stream[F, Nothing] = {
     val body: F[Boolean] =
       for {
-        _ <- logger.fdebug(s"Going into main loop")
+        _ <- logger.debug(s"Going into main loop")
         now <- Timestamp.current[F]
-        _ <- logger.fdebug(s"Looking for next periodic task")
+        _ <- logger.debug(s"Looking for next periodic task")
         go <- logThrow("Error getting next task")(
           store
             .takeNext(config.name, None)
             .use {
               case Marked.Found(pj) =>
                 logger
-                  .fdebug(s"Found periodic task '${pj.subject}/${pj.timer.asString}'") *>
+                  .debug(s"Found periodic task '${pj.subject}/${pj.timer.asString}'") *>
                   (if (isTriggered(pj, now)) submitJob(pj)
                    else scheduleNotify(pj).map(_ => false))
               case Marked.NotFound =>
-                logger.fdebug("No periodic task found") *> false.pure[F]
+                logger.debug("No periodic task found") *> false.pure[F]
               case Marked.NotMarkable =>
-                logger.fdebug("Periodic job cannot be marked. Trying again.") *> true
+                logger.debug("Periodic job cannot be marked. Trying again.") *> true
                   .pure[F]
             }
         )
@@ -86,7 +84,7 @@ final class PeriodicSchedulerImpl[F[_]: Async](
     Stream
       .eval(state.get.map(_.shutdownRequest))
       .evalTap(
-        if (_) logger.finfo[F]("Stopping main loop due to shutdown request.")
+        if (_) logger.info("Stopping main loop due to shutdown request.")
         else ().pure[F]
       )
       .flatMap(if (_) Stream.empty else Stream.eval(cancelNotify *> body))
@@ -94,9 +92,9 @@ final class PeriodicSchedulerImpl[F[_]: Async](
         case true =>
           mainLoop
         case false =>
-          logger.sdebug(s"Waiting for notify") ++
+          logger.stream.debug(s"Waiting for notify").drain ++
             waiter.discrete.take(2).drain ++
-            logger.sdebug(s"Notify signal, going into main loop") ++
+            logger.stream.debug(s"Notify signal, going into main loop").drain ++
             mainLoop
       }
   }
@@ -109,12 +107,12 @@ final class PeriodicSchedulerImpl[F[_]: Async](
       .findNonFinalJob(pj.id)
       .flatMap {
         case Some(job) =>
-          logger.finfo[F](
+          logger.info(
             s"There is already a job with non-final state '${job.state}' in the queue"
           ) *> scheduleNotify(pj) *> false.pure[F]
 
         case None =>
-          logger.finfo[F](s"Submitting job for periodic task '${pj.task.id}'") *>
+          logger.info(s"Submitting job for periodic task '${pj.task.id}'") *>
             pj.toJob.flatMap(queue.insert) *> notifyJoex *> true.pure[F]
       }
 
@@ -125,7 +123,7 @@ final class PeriodicSchedulerImpl[F[_]: Async](
     Timestamp
       .current[F]
       .flatMap(now =>
-        logger.fdebug(
+        logger.debug(
           s"Scheduling next notify for timer ${pj.timer.asString} -> ${pj.timer.nextElapse(now.toUtcDateTime)}"
         )
       ) *>
@@ -153,13 +151,13 @@ final class PeriodicSchedulerImpl[F[_]: Async](
   private def logError(msg: => String)(fa: F[Unit]): F[Unit] =
     fa.attempt.flatMap {
       case Right(_) => ().pure[F]
-      case Left(ex) => logger.ferror(ex)(msg).map(_ => ())
+      case Left(ex) => logger.error(ex)(msg).map(_ => ())
     }
 
   private def logThrow[A](msg: => String)(fa: F[A]): F[A] =
     fa.attempt.flatMap {
       case r @ Right(_) => (r: Either[Throwable, A]).pure[F]
-      case l @ Left(ex) => logger.ferror(ex)(msg).map(_ => (l: Either[Throwable, A]))
+      case l @ Left(ex) => logger.error(ex)(msg).map(_ => (l: Either[Throwable, A]))
     }.rethrow
 }
 
diff --git a/modules/joex/src/main/scala/docspell/joex/scheduler/QueueLogger.scala b/modules/joex/src/main/scala/docspell/joex/scheduler/QueueLogger.scala
index b1d7067b..357a1e83 100644
--- a/modules/joex/src/main/scala/docspell/joex/scheduler/QueueLogger.scala
+++ b/modules/joex/src/main/scala/docspell/joex/scheduler/QueueLogger.scala
@@ -12,6 +12,8 @@ import cats.implicits._
 import fs2.Stream
 
 import docspell.common._
+import docspell.logging
+import docspell.logging.{Level, Logger}
 
 object QueueLogger {
 
@@ -21,26 +23,20 @@ object QueueLogger {
       q: Queue[F, LogEvent]
   ): Logger[F] =
     new Logger[F] {
-      def trace(msg: => String): F[Unit] =
-        LogEvent.create[F](jobId, jobInfo, LogLevel.Debug, msg).flatMap(q.offer)
 
-      def debug(msg: => String): F[Unit] =
-        LogEvent.create[F](jobId, jobInfo, LogLevel.Debug, msg).flatMap(q.offer)
-
-      def info(msg: => String): F[Unit] =
-        LogEvent.create[F](jobId, jobInfo, LogLevel.Info, msg).flatMap(q.offer)
-
-      def warn(msg: => String): F[Unit] =
-        LogEvent.create[F](jobId, jobInfo, LogLevel.Warn, msg).flatMap(q.offer)
-
-      def error(ex: Throwable)(msg: => String): F[Unit] =
+      def log(logEvent: logging.LogEvent) =
         LogEvent
-          .create[F](jobId, jobInfo, LogLevel.Error, msg)
-          .map(le => le.copy(ex = Some(ex)))
-          .flatMap(q.offer)
+          .create[F](jobId, jobInfo, level2Level(logEvent.level), logEvent.msg())
+          .flatMap { ev =>
+            val event =
+              logEvent.findErrors.headOption
+                .map(ex => ev.copy(ex = Some(ex)))
+                .getOrElse(ev)
 
-      def error(msg: => String): F[Unit] =
-        LogEvent.create[F](jobId, jobInfo, LogLevel.Error, msg).flatMap(q.offer)
+            q.offer(event)
+          }
+
+      def asUnsafe = Logger.off
     }
 
   def apply[F[_]: Async](
@@ -57,4 +53,6 @@ object QueueLogger {
       )
     } yield log
 
+  private def level2Level(level: Level): LogLevel =
+    LogLevel.fromLevel(level)
 }
diff --git a/modules/joex/src/main/scala/docspell/joex/scheduler/SchedulerImpl.scala b/modules/joex/src/main/scala/docspell/joex/scheduler/SchedulerImpl.scala
index d30c43eb..be83f9d6 100644
--- a/modules/joex/src/main/scala/docspell/joex/scheduler/SchedulerImpl.scala
+++ b/modules/joex/src/main/scala/docspell/joex/scheduler/SchedulerImpl.scala
@@ -15,7 +15,6 @@ import fs2.concurrent.SignallingRef
 
 import docspell.backend.msg.JobDone
 import docspell.common._
-import docspell.common.syntax.all._
 import docspell.joex.scheduler.SchedulerImpl._
 import docspell.notification.api.Event
 import docspell.notification.api.EventSink
@@ -26,7 +25,6 @@ import docspell.store.queue.JobQueue
 import docspell.store.records.RJob
 
 import io.circe.Json
-import org.log4s.getLogger
 
 final class SchedulerImpl[F[_]: Async](
     val config: SchedulerConfig,
@@ -41,7 +39,7 @@ final class SchedulerImpl[F[_]: Async](
     permits: Semaphore[F]
 ) extends Scheduler[F] {
 
-  private[this] val logger = getLogger
+  private[this] val logger = docspell.logging.getLogger[F]
 
   /** On startup, get all jobs in state running from this scheduler and put them into
     * waiting state, so they get picked up again.
@@ -53,7 +51,7 @@ final class SchedulerImpl[F[_]: Async](
     Async[F].start(
       Stream
         .awakeEvery[F](config.wakeupPeriod.toScala)
-        .evalMap(_ => logger.fdebug("Periodic awake reached") *> notifyChange)
+        .evalMap(_ => logger.debug("Periodic awake reached") *> notifyChange)
         .compile
         .drain
     )
@@ -62,7 +60,7 @@ final class SchedulerImpl[F[_]: Async](
     state.get.flatMap(s => QJob.findAll(s.getRunning, store))
 
   def requestCancel(jobId: Ident): F[Boolean] =
-    logger.finfo(s"Scheduler requested to cancel job: ${jobId.id}") *>
+    logger.info(s"Scheduler requested to cancel job: ${jobId.id}") *>
       state.get.flatMap(_.cancelRequest(jobId) match {
         case Some(ct) => ct.map(_ => true)
         case None =>
@@ -74,7 +72,7 @@ final class SchedulerImpl[F[_]: Async](
             )
           } yield true)
             .getOrElseF(
-              logger.fwarn(s"Job ${jobId.id} not found, cannot cancel.").map(_ => false)
+              logger.warn(s"Job ${jobId.id} not found, cannot cancel.").map(_ => false)
             )
       })
 
@@ -90,16 +88,16 @@ final class SchedulerImpl[F[_]: Async](
 
     val wait = Stream
       .eval(runShutdown)
-      .evalMap(_ => logger.finfo("Scheduler is shutting down now."))
+      .evalMap(_ => logger.info("Scheduler is shutting down now."))
       .flatMap(_ =>
         Stream.eval(state.get) ++ Stream
           .suspend(state.discrete.takeWhile(_.getRunning.nonEmpty))
       )
       .flatMap { state =>
-        if (state.getRunning.isEmpty) Stream.eval(logger.finfo("No jobs running."))
+        if (state.getRunning.isEmpty) Stream.eval(logger.info("No jobs running."))
         else
           Stream.eval(
-            logger.finfo(s"Waiting for ${state.getRunning.size} jobs to finish.")
+            logger.info(s"Waiting for ${state.getRunning.size} jobs to finish.")
           ) ++
             Stream.emit(state)
       }
@@ -108,35 +106,35 @@ final class SchedulerImpl[F[_]: Async](
   }
 
   def start: Stream[F, Nothing] =
-    logger.sinfo("Starting scheduler") ++
+    logger.stream.info("Starting scheduler").drain ++
       mainLoop
 
   def mainLoop: Stream[F, Nothing] = {
     val body: F[Boolean] =
       for {
         _ <- permits.available.flatMap(a =>
-          logger.fdebug(s"Try to acquire permit ($a free)")
+          logger.debug(s"Try to acquire permit ($a free)")
         )
         _ <- permits.acquire
-        _ <- logger.fdebug("New permit acquired")
+        _ <- logger.debug("New permit acquired")
         down <- state.get.map(_.shutdownRequest)
         rjob <-
           if (down)
-            logger.finfo("") *> permits.release *> (None: Option[RJob]).pure[F]
+            logger.info("") *> permits.release *> (None: Option[RJob]).pure[F]
           else
             queue.nextJob(
               group => state.modify(_.nextPrio(group, config.countingScheme)),
               config.name,
               config.retryDelay
             )
-        _ <- logger.fdebug(s"Next job found: ${rjob.map(_.info)}")
+        _ <- logger.debug(s"Next job found: ${rjob.map(_.info)}")
         _ <- rjob.map(execute).getOrElse(permits.release)
       } yield rjob.isDefined
 
     Stream
       .eval(state.get.map(_.shutdownRequest))
       .evalTap(
-        if (_) logger.finfo[F]("Stopping main loop due to shutdown request.")
+        if (_) logger.info("Stopping main loop due to shutdown request.")
         else ().pure[F]
       )
       .flatMap(if (_) Stream.empty else Stream.eval(body))
@@ -144,9 +142,9 @@ final class SchedulerImpl[F[_]: Async](
         case true =>
           mainLoop
         case false =>
-          logger.sdebug(s"Waiting for notify") ++
+          logger.stream.debug(s"Waiting for notify").drain ++
             waiter.discrete.take(2).drain ++
-            logger.sdebug(s"Notify signal, going into main loop") ++
+            logger.stream.debug(s"Notify signal, going into main loop").drain ++
             mainLoop
       }
   }
@@ -161,17 +159,17 @@ final class SchedulerImpl[F[_]: Async](
 
     task match {
       case Left(err) =>
-        logger.ferror(s"Unable to run cancellation task for job ${job.info}: $err")
+        logger.error(s"Unable to run cancellation task for job ${job.info}: $err")
       case Right(t) =>
         for {
           _ <-
-            logger.fdebug(s"Creating context for job ${job.info} to run cancellation $t")
+            logger.debug(s"Creating context for job ${job.info} to run cancellation $t")
           ctx <- Context[F, String](job, job.args, config, logSink, store)
           _ <- t.onCancel.run(ctx)
           _ <- state.modify(_.markCancelled(job))
           _ <- onFinish(job, Json.Null, JobState.Cancelled)
           _ <- ctx.logger.warn("Job has been cancelled.")
-          _ <- logger.fdebug(s"Job ${job.info} has been cancelled.")
+          _ <- logger.debug(s"Job ${job.info} has been cancelled.")
         } yield ()
     }
   }
@@ -186,10 +184,10 @@ final class SchedulerImpl[F[_]: Async](
 
     task match {
       case Left(err) =>
-        logger.ferror(s"Unable to start a task for job ${job.info}: $err")
+        logger.error(s"Unable to start a task for job ${job.info}: $err")
       case Right(t) =>
         for {
-          _ <- logger.fdebug(s"Creating context for job ${job.info} to run $t")
+          _ <- logger.debug(s"Creating context for job ${job.info} to run $t")
           ctx <- Context[F, String](job, job.args, config, logSink, store)
           jot = wrapTask(job, t.task, ctx)
           tok <- forkRun(job, jot.run(ctx), t.onCancel.run(ctx), ctx)
@@ -200,9 +198,9 @@ final class SchedulerImpl[F[_]: Async](
 
   def onFinish(job: RJob, result: Json, finishState: JobState): F[Unit] =
     for {
-      _ <- logger.fdebug(s"Job ${job.info} done $finishState. Releasing resources.")
+      _ <- logger.debug(s"Job ${job.info} done $finishState. Releasing resources.")
       _ <- permits.release *> permits.available.flatMap(a =>
-        logger.fdebug(s"Permit released ($a free)")
+        logger.debug(s"Permit released ($a free)")
       )
       _ <- state.modify(_.removeRunning(job))
       _ <- QJob.setFinalState(job.id, finishState, store)
@@ -241,7 +239,7 @@ final class SchedulerImpl[F[_]: Async](
       ctx: Context[F, String]
   ): Task[F, String, Unit] =
     task
-      .mapF(fa => onStart(job) *> logger.fdebug("Starting task now") *> fa)
+      .mapF(fa => onStart(job) *> logger.debug("Starting task now") *> fa)
       .mapF(_.attempt.flatMap {
         case Right(result) =>
           logger.info(s"Job execution successful: ${job.info}")
@@ -284,11 +282,11 @@ final class SchedulerImpl[F[_]: Async](
       onCancel: F[Unit],
       ctx: Context[F, String]
   ): F[F[Unit]] =
-    logger.fdebug(s"Forking job ${job.info}") *>
+    logger.debug(s"Forking job ${job.info}") *>
       Async[F]
         .start(code)
         .map(fiber =>
-          logger.fdebug(s"Cancelling job ${job.info}") *>
+          logger.debug(s"Cancelling job ${job.info}") *>
             fiber.cancel *>
             onCancel.attempt.map {
               case Right(_) => ()
@@ -299,7 +297,7 @@ final class SchedulerImpl[F[_]: Async](
             state.modify(_.markCancelled(job)) *>
             onFinish(job, Json.Null, JobState.Cancelled) *>
             ctx.logger.warn("Job has been cancelled.") *>
-            logger.fdebug(s"Job ${job.info} has been cancelled.")
+            logger.debug(s"Job ${job.info} has been cancelled.")
         )
 }
 
diff --git a/modules/joex/src/main/scala/docspell/joex/scheduler/Task.scala b/modules/joex/src/main/scala/docspell/joex/scheduler/Task.scala
index 04015b80..d211d5a0 100644
--- a/modules/joex/src/main/scala/docspell/joex/scheduler/Task.scala
+++ b/modules/joex/src/main/scala/docspell/joex/scheduler/Task.scala
@@ -11,7 +11,7 @@ import cats.data.Kleisli
 import cats.effect.Sync
 import cats.implicits._
 
-import docspell.common.Logger
+import docspell.logging.Logger
 
 /** The code that is executed by the scheduler */
 trait Task[F[_], A, B] {
diff --git a/modules/joexapi/src/main/scala/docspell/joexapi/client/JoexClient.scala b/modules/joexapi/src/main/scala/docspell/joexapi/client/JoexClient.scala
index d6a6a5c7..c623eba0 100644
--- a/modules/joexapi/src/main/scala/docspell/joexapi/client/JoexClient.scala
+++ b/modules/joexapi/src/main/scala/docspell/joexapi/client/JoexClient.scala
@@ -9,7 +9,6 @@ package docspell.joexapi.client
 import cats.effect._
 import cats.implicits._
 
-import docspell.common.syntax.all._
 import docspell.common.{Ident, LenientUri}
 import docspell.joexapi.model.BasicResult
 
@@ -17,7 +16,6 @@ import org.http4s.blaze.client.BlazeClientBuilder
 import org.http4s.circe.CirceEntityDecoder
 import org.http4s.client.Client
 import org.http4s.{Method, Request, Uri}
-import org.log4s.getLogger
 
 trait JoexClient[F[_]] {
 
@@ -31,22 +29,21 @@ trait JoexClient[F[_]] {
 
 object JoexClient {
 
-  private[this] val logger = getLogger
-
   def apply[F[_]: Async](client: Client[F]): JoexClient[F] =
     new JoexClient[F] with CirceEntityDecoder {
+      private[this] val logger = docspell.logging.getLogger[F]
 
       def notifyJoex(base: LenientUri): F[BasicResult] = {
         val notifyUrl = base / "api" / "v1" / "notify"
         val req = Request[F](Method.POST, uri(notifyUrl))
-        logger.fdebug(s"Notify joex at ${notifyUrl.asString}") *>
+        logger.debug(s"Notify joex at ${notifyUrl.asString}") *>
           client.expect[BasicResult](req)
       }
 
       def notifyJoexIgnoreErrors(base: LenientUri): F[Unit] =
-        notifyJoex(base).attempt.map {
+        notifyJoex(base).attempt.flatMap {
           case Right(BasicResult(succ, msg)) =>
-            if (succ) ()
+            if (succ) ().pure[F]
             else
               logger.warn(
                 s"Notifying Joex instance '${base.asString}' returned with failure: $msg"
diff --git a/modules/logging/api/src/main/scala/docspell/logging/AndThenLogger.scala b/modules/logging/api/src/main/scala/docspell/logging/AndThenLogger.scala
new file mode 100644
index 00000000..e5109504
--- /dev/null
+++ b/modules/logging/api/src/main/scala/docspell/logging/AndThenLogger.scala
@@ -0,0 +1,39 @@
+/*
+ * Copyright 2020 Eike K. & Contributors
+ *
+ * SPDX-License-Identifier: AGPL-3.0-or-later
+ */
+
+package docspell.logging
+
+import cats.data.NonEmptyList
+import cats.syntax.all._
+import cats.{Applicative, Id}
+
+final private[logging] class AndThenLogger[F[_]: Applicative](
+    val loggers: NonEmptyList[Logger[F]]
+) extends Logger[F] {
+  def log(ev: LogEvent): F[Unit] =
+    loggers.traverse(_.log(ev)).as(())
+
+  def asUnsafe: Logger[Id] =
+    new Logger[Id] { self =>
+      def log(ev: LogEvent): Unit =
+        loggers.toList.foreach(_.asUnsafe.log(ev))
+      def asUnsafe = self
+    }
+}
+
+private[logging] object AndThenLogger {
+  def combine[F[_]: Applicative](a: Logger[F], b: Logger[F]): Logger[F] =
+    (a, b) match {
+      case (aa: AndThenLogger[F], bb: AndThenLogger[F]) =>
+        new AndThenLogger[F](aa.loggers ++ bb.loggers.toList)
+      case (aa: AndThenLogger[F], _) =>
+        new AndThenLogger[F](aa.loggers.prepend(b))
+      case (_, bb: AndThenLogger[F]) =>
+        new AndThenLogger[F](bb.loggers.prepend(a))
+      case _ =>
+        new AndThenLogger[F](NonEmptyList.of(a, b))
+    }
+}
diff --git a/modules/logging/src/main/scala/docspell/logging/Level.scala b/modules/logging/api/src/main/scala/docspell/logging/Level.scala
similarity index 100%
rename from modules/logging/src/main/scala/docspell/logging/Level.scala
rename to modules/logging/api/src/main/scala/docspell/logging/Level.scala
diff --git a/modules/logging/src/main/scala/docspell/logging/LogConfig.scala b/modules/logging/api/src/main/scala/docspell/logging/LogConfig.scala
similarity index 100%
rename from modules/logging/src/main/scala/docspell/logging/LogConfig.scala
rename to modules/logging/api/src/main/scala/docspell/logging/LogConfig.scala
diff --git a/modules/logging/src/main/scala/docspell/logging/LogEvent.scala b/modules/logging/api/src/main/scala/docspell/logging/LogEvent.scala
similarity index 83%
rename from modules/logging/src/main/scala/docspell/logging/LogEvent.scala
rename to modules/logging/api/src/main/scala/docspell/logging/LogEvent.scala
index 4a52d57d..888fe584 100644
--- a/modules/logging/src/main/scala/docspell/logging/LogEvent.scala
+++ b/modules/logging/api/src/main/scala/docspell/logging/LogEvent.scala
@@ -20,6 +20,9 @@ final case class LogEvent(
     line: Line
 ) {
 
+  def asString =
+    s"${level.name} ${name.value}/${fileName}:${line.value} - ${msg()}"
+
   def data[A: Encoder](key: String, value: => A): LogEvent =
     copy(data = data.updated(key, () => Encoder[A].apply(value)))
 
@@ -28,6 +31,11 @@ final case class LogEvent(
 
   def addError(ex: Throwable): LogEvent =
     copy(additional = (() => Right(ex)) :: additional)
+
+  def findErrors: List[Throwable] =
+    additional.map(a => a()).collect { case Right(ex) =>
+      ex
+    }
 }
 
 object LogEvent {
diff --git a/modules/logging/src/main/scala/docspell/logging/Logger.scala b/modules/logging/api/src/main/scala/docspell/logging/Logger.scala
similarity index 63%
rename from modules/logging/src/main/scala/docspell/logging/Logger.scala
rename to modules/logging/api/src/main/scala/docspell/logging/Logger.scala
index e4d7ec47..05db734b 100644
--- a/modules/logging/src/main/scala/docspell/logging/Logger.scala
+++ b/modules/logging/api/src/main/scala/docspell/logging/Logger.scala
@@ -6,14 +6,18 @@
 
 package docspell.logging
 
-import cats.Id
-import cats.effect.Sync
+import java.io.PrintStream
+import java.time.Instant
 
-import docspell.logging.impl.LoggerWrapper
+import cats.effect.{Ref, Sync}
+import cats.syntax.applicative._
+import cats.syntax.functor._
+import cats.syntax.order._
+import cats.{Applicative, Id}
 
 import sourcecode._
 
-trait Logger[F[_]] {
+trait Logger[F[_]] extends LoggerExtension[F] {
 
   def log(ev: LogEvent): F[Unit]
 
@@ -117,12 +121,46 @@ trait Logger[F[_]] {
 }
 
 object Logger {
-  def unsafe(name: String): Logger[Id] =
-    new LoggerWrapper.ImplUnsafe(scribe.Logger(name))
+  def off: Logger[Id] =
+    new Logger[Id] {
+      def log(ev: LogEvent): Unit = ()
+      def asUnsafe = this
+    }
 
-  def apply[F[_]: Sync](name: String): Logger[F] =
-    new LoggerWrapper.Impl[F](scribe.Logger(name))
+  def offF[F[_]: Applicative]: Logger[F] =
+    new Logger[F] {
+      def log(ev: LogEvent) = ().pure[F]
+      def asUnsafe = off
+    }
 
-  def apply[F[_]: Sync](clazz: Class[_]): Logger[F] =
-    new LoggerWrapper.Impl[F](scribe.Logger(clazz.getName))
+  def buffer[F[_]: Sync](): F[(Ref[F, Vector[LogEvent]], Logger[F])] =
+    for {
+      buffer <- Ref.of[F, Vector[LogEvent]](Vector.empty[LogEvent])
+      logger =
+        new Logger[F] {
+          def log(ev: LogEvent) =
+            buffer.update(_.appended(ev))
+          def asUnsafe = off
+        }
+    } yield (buffer, logger)
+
+  /** Just prints to the given print stream. Useful for testing. */
+  def simple(ps: PrintStream, minimumLevel: Level): Logger[Id] =
+    new Logger[Id] {
+      def log(ev: LogEvent): Unit =
+        if (ev.level >= minimumLevel)
+          ps.println(s"${Instant.now()} [${Thread.currentThread()}] ${ev.asString}")
+        else
+          ()
+
+      def asUnsafe = this
+    }
+
+  def simpleF[F[_]: Sync](ps: PrintStream, minimumLevel: Level): Logger[F] =
+    new Logger[F] {
+      def log(ev: LogEvent) =
+        Sync[F].delay(asUnsafe.log(ev))
+
+      val asUnsafe = simple(ps, minimumLevel)
+    }
 }
diff --git a/modules/logging/api/src/main/scala/docspell/logging/LoggerExtension.scala b/modules/logging/api/src/main/scala/docspell/logging/LoggerExtension.scala
new file mode 100644
index 00000000..0e2597af
--- /dev/null
+++ b/modules/logging/api/src/main/scala/docspell/logging/LoggerExtension.scala
@@ -0,0 +1,27 @@
+/*
+ * Copyright 2020 Eike K. & Contributors
+ *
+ * SPDX-License-Identifier: AGPL-3.0-or-later
+ */
+
+package docspell.logging
+
+import cats.Applicative
+import fs2.Stream
+
+trait LoggerExtension[F[_]] { self: Logger[F] =>
+
+  def stream: Logger[Stream[F, *]] =
+    new Logger[Stream[F, *]] {
+      def log(ev: LogEvent) =
+        Stream.eval(self.log(ev))
+
+      def asUnsafe = self.asUnsafe
+    }
+
+  def andThen(other: Logger[F])(implicit F: Applicative[F]): Logger[F] =
+    AndThenLogger.combine(self, other)
+
+  def >>(other: Logger[F])(implicit F: Applicative[F]): Logger[F] =
+    AndThenLogger.combine(self, other)
+}
diff --git a/modules/logging/src/main/scala/docspell/logging/impl/JsonWriter.scala b/modules/logging/scribe/src/main/scala/docspell/logging/impl/JsonWriter.scala
similarity index 86%
rename from modules/logging/src/main/scala/docspell/logging/impl/JsonWriter.scala
rename to modules/logging/scribe/src/main/scala/docspell/logging/impl/JsonWriter.scala
index 28ede7f9..020fc9f9 100644
--- a/modules/logging/src/main/scala/docspell/logging/impl/JsonWriter.scala
+++ b/modules/logging/scribe/src/main/scala/docspell/logging/impl/JsonWriter.scala
@@ -7,10 +7,10 @@
 package docspell.logging.impl
 
 import io.circe.syntax._
-import scribe.LogRecord
+import scribe._
+import scribe.output._
 import scribe.output.format.OutputFormat
-import scribe.output.{LogOutput, TextOutput}
-import scribe.writer.Writer
+import scribe.writer._
 
 final case class JsonWriter(writer: Writer, compact: Boolean = true) extends Writer {
   override def write[M](
diff --git a/modules/logging/src/main/scala/docspell/logging/impl/LogfmtWriter.scala b/modules/logging/scribe/src/main/scala/docspell/logging/impl/LogfmtWriter.scala
similarity index 91%
rename from modules/logging/src/main/scala/docspell/logging/impl/LogfmtWriter.scala
rename to modules/logging/scribe/src/main/scala/docspell/logging/impl/LogfmtWriter.scala
index f7f418f0..f9f4db12 100644
--- a/modules/logging/src/main/scala/docspell/logging/impl/LogfmtWriter.scala
+++ b/modules/logging/scribe/src/main/scala/docspell/logging/impl/LogfmtWriter.scala
@@ -7,10 +7,10 @@
 package docspell.logging.impl
 
 import io.circe.syntax._
-import scribe.LogRecord
+import scribe._
+import scribe.output._
 import scribe.output.format.OutputFormat
-import scribe.output.{LogOutput, TextOutput}
-import scribe.writer.Writer
+import scribe.writer._
 
 // https://brandur.org/logfmt
 final case class LogfmtWriter(writer: Writer) extends Writer {
diff --git a/modules/logging/src/main/scala/docspell/logging/impl/Record.scala b/modules/logging/scribe/src/main/scala/docspell/logging/impl/Record.scala
similarity index 100%
rename from modules/logging/src/main/scala/docspell/logging/impl/Record.scala
rename to modules/logging/scribe/src/main/scala/docspell/logging/impl/Record.scala
diff --git a/modules/logging/src/main/scala/docspell/logging/impl/ScribeConfigure.scala b/modules/logging/scribe/src/main/scala/docspell/logging/impl/ScribeConfigure.scala
similarity index 94%
rename from modules/logging/src/main/scala/docspell/logging/impl/ScribeConfigure.scala
rename to modules/logging/scribe/src/main/scala/docspell/logging/impl/ScribeConfigure.scala
index 8620480e..f975d1c8 100644
--- a/modules/logging/src/main/scala/docspell/logging/impl/ScribeConfigure.scala
+++ b/modules/logging/scribe/src/main/scala/docspell/logging/impl/ScribeConfigure.scala
@@ -6,7 +6,7 @@
 
 package docspell.logging.impl
 
-import cats.effect._
+import cats.effect.Sync
 
 import docspell.logging.LogConfig
 import docspell.logging.LogConfig.Format
@@ -26,7 +26,7 @@ object ScribeConfigure {
   def unsafeConfigure(logger: scribe.Logger, cfg: LogConfig): Unit = {
     val mods = List[scribe.Logger => scribe.Logger](
       _.clearHandlers(),
-      _.withMinimumLevel(LoggerWrapper.convertLevel(cfg.minimumLevel)),
+      _.withMinimumLevel(ScribeWrapper.convertLevel(cfg.minimumLevel)),
       l =>
         cfg.format match {
           case Format.Fancy =>
diff --git a/modules/logging/src/main/scala/docspell/logging/impl/LoggerWrapper.scala b/modules/logging/scribe/src/main/scala/docspell/logging/impl/ScribeWrapper.scala
similarity index 92%
rename from modules/logging/src/main/scala/docspell/logging/impl/LoggerWrapper.scala
rename to modules/logging/scribe/src/main/scala/docspell/logging/impl/ScribeWrapper.scala
index 19fc0f66..1eeadaed 100644
--- a/modules/logging/src/main/scala/docspell/logging/impl/LoggerWrapper.scala
+++ b/modules/logging/scribe/src/main/scala/docspell/logging/impl/ScribeWrapper.scala
@@ -7,14 +7,14 @@
 package docspell.logging.impl
 
 import cats.Id
-import cats.effect._
+import cats.effect.Sync
 
-import docspell.logging._
+import docspell.logging.{Level, LogEvent, Logger}
 
 import scribe.LoggerSupport
 import scribe.message.{LoggableMessage, Message}
 
-private[logging] object LoggerWrapper {
+private[logging] object ScribeWrapper {
   final class ImplUnsafe(log: scribe.Logger) extends Logger[Id] {
     override def asUnsafe = this
 
diff --git a/modules/logging/scribe/src/main/scala/docspell/logging/package.scala b/modules/logging/scribe/src/main/scala/docspell/logging/package.scala
new file mode 100644
index 00000000..4037dccf
--- /dev/null
+++ b/modules/logging/scribe/src/main/scala/docspell/logging/package.scala
@@ -0,0 +1,33 @@
+/*
+ * Copyright 2020 Eike K. & Contributors
+ *
+ * SPDX-License-Identifier: AGPL-3.0-or-later
+ */
+
+package docspell
+
+import cats.Id
+import cats.effect._
+
+import docspell.logging.impl.ScribeWrapper
+
+import sourcecode.Enclosing
+
+package object logging {
+
+  def unsafeLogger(name: String): Logger[Id] =
+    new ScribeWrapper.ImplUnsafe(scribe.Logger(name))
+
+  def unsafeLogger(implicit e: Enclosing): Logger[Id] =
+    unsafeLogger(e.value)
+
+  def getLogger[F[_]: Sync](implicit e: Enclosing): Logger[F] =
+    getLogger(e.value)
+
+  def getLogger[F[_]: Sync](name: String): Logger[F] =
+    new ScribeWrapper.Impl[F](scribe.Logger(name))
+
+  def getLogger[F[_]: Sync](clazz: Class[_]): Logger[F] =
+    new ScribeWrapper.Impl[F](scribe.Logger(clazz.getName))
+
+}
diff --git a/modules/notification/api/src/main/scala/docspell/notification/api/EventExchange.scala b/modules/notification/api/src/main/scala/docspell/notification/api/EventExchange.scala
index 6eb54387..7d3cdad5 100644
--- a/modules/notification/api/src/main/scala/docspell/notification/api/EventExchange.scala
+++ b/modules/notification/api/src/main/scala/docspell/notification/api/EventExchange.scala
@@ -13,7 +13,7 @@ import cats.effect.std.Queue
 import cats.implicits._
 import fs2.Stream
 
-import docspell.common.Logger
+import docspell.logging.Logger
 
 /** Combines a sink and reader to a place where events can be submitted and processed in a
   * producer-consumer manner.
@@ -21,8 +21,6 @@ import docspell.common.Logger
 trait EventExchange[F[_]] extends EventSink[F] with EventReader[F] {}
 
 object EventExchange {
-  private[this] val logger = org.log4s.getLogger
-
   def silent[F[_]: Applicative]: EventExchange[F] =
     new EventExchange[F] {
       def offer(event: Event): F[Unit] =
@@ -36,7 +34,7 @@ object EventExchange {
     Queue.circularBuffer[F, Event](queueSize).map(q => new Impl(q))
 
   final class Impl[F[_]: Async](queue: Queue[F, Event]) extends EventExchange[F] {
-    private[this] val log = Logger.log4s[F](logger)
+    private[this] val log: Logger[F] = docspell.logging.getLogger[F]
 
     def offer(event: Event): F[Unit] =
       log.debug(s"Pushing event to queue: $event") *>
@@ -47,7 +45,7 @@ object EventExchange {
 
     def consume(maxConcurrent: Int)(run: Kleisli[F, Event, Unit]): Stream[F, Nothing] = {
       val stream = Stream.repeatEval(queue.take).evalMap((logEvent >> run).run)
-      log.s.info(s"Starting up $maxConcurrent notification event consumers").drain ++
+      log.stream.info(s"Starting up $maxConcurrent notification event consumers").drain ++
         Stream(stream).repeat.take(maxConcurrent.toLong).parJoin(maxConcurrent).drain
     }
   }
diff --git a/modules/notification/api/src/main/scala/docspell/notification/api/NotificationBackend.scala b/modules/notification/api/src/main/scala/docspell/notification/api/NotificationBackend.scala
index c543b806..5433c34b 100644
--- a/modules/notification/api/src/main/scala/docspell/notification/api/NotificationBackend.scala
+++ b/modules/notification/api/src/main/scala/docspell/notification/api/NotificationBackend.scala
@@ -13,7 +13,7 @@ import cats.implicits._
 import cats.kernel.Monoid
 import fs2.Stream
 
-import docspell.common._
+import docspell.logging.Logger
 
 /** Pushes notification messages/events to an external system */
 trait NotificationBackend[F[_]] {
diff --git a/modules/notification/api/src/main/scala/docspell/notification/api/NotificationModule.scala b/modules/notification/api/src/main/scala/docspell/notification/api/NotificationModule.scala
index a87e819d..c10e24f1 100644
--- a/modules/notification/api/src/main/scala/docspell/notification/api/NotificationModule.scala
+++ b/modules/notification/api/src/main/scala/docspell/notification/api/NotificationModule.scala
@@ -11,7 +11,7 @@ import cats.data.{Kleisli, OptionT}
 import cats.implicits._
 import fs2.Stream
 
-import docspell.common.Logger
+import docspell.logging.Logger
 
 trait NotificationModule[F[_]]
     extends EventSink[F]
diff --git a/modules/notification/impl/src/main/scala/docspell/notification/impl/EmailBackend.scala b/modules/notification/impl/src/main/scala/docspell/notification/impl/EmailBackend.scala
index 81a016c2..bfe944bb 100644
--- a/modules/notification/impl/src/main/scala/docspell/notification/impl/EmailBackend.scala
+++ b/modules/notification/impl/src/main/scala/docspell/notification/impl/EmailBackend.scala
@@ -9,7 +9,7 @@ package docspell.notification.impl
 import cats.effect._
 import cats.implicits._
 
-import docspell.common._
+import docspell.logging.Logger
 import docspell.notification.api._
 
 import emil.Emil
diff --git a/modules/notification/impl/src/main/scala/docspell/notification/impl/EventContextSyntax.scala b/modules/notification/impl/src/main/scala/docspell/notification/impl/EventContextSyntax.scala
index 79fa66a6..1609105c 100644
--- a/modules/notification/impl/src/main/scala/docspell/notification/impl/EventContextSyntax.scala
+++ b/modules/notification/impl/src/main/scala/docspell/notification/impl/EventContextSyntax.scala
@@ -6,7 +6,7 @@
 
 package docspell.notification.impl
 
-import docspell.common.Logger
+import docspell.logging.Logger
 import docspell.notification.api.EventContext
 
 import io.circe.Json
diff --git a/modules/notification/impl/src/main/scala/docspell/notification/impl/EventNotify.scala b/modules/notification/impl/src/main/scala/docspell/notification/impl/EventNotify.scala
index 1b7e95a2..e3fcd63b 100644
--- a/modules/notification/impl/src/main/scala/docspell/notification/impl/EventNotify.scala
+++ b/modules/notification/impl/src/main/scala/docspell/notification/impl/EventNotify.scala
@@ -10,7 +10,6 @@ import cats.data.Kleisli
 import cats.data.OptionT
 import cats.effect._
 
-import docspell.common.Logger
 import docspell.notification.api.Event
 import docspell.notification.api.NotificationBackend
 import docspell.store.Store
@@ -21,13 +20,13 @@ import org.http4s.client.Client
 
 /** Represents the actual work done for each event. */
 object EventNotify {
-  private[this] val log4sLogger = org.log4s.getLogger
 
   def apply[F[_]: Async](
       store: Store[F],
       mailService: Emil[F],
       client: Client[F]
-  ): Kleisli[F, Event, Unit] =
+  ): Kleisli[F, Event, Unit] = {
+    val logger = docspell.logging.getLogger[F]
     Kleisli { event =>
       (for {
         hooks <- OptionT.liftF(store.transact(QNotification.findChannelsForEvent(event)))
@@ -43,10 +42,11 @@ object EventNotify {
             NotificationBackendImpl.forChannelsIgnoreErrors(
               client,
               mailService,
-              Logger.log4s(log4sLogger)
+              logger
             )(channels)
         _ <- OptionT.liftF(backend.send(evctx))
       } yield ()).getOrElse(())
     }
+  }
 
 }
diff --git a/modules/notification/impl/src/main/scala/docspell/notification/impl/GotifyBackend.scala b/modules/notification/impl/src/main/scala/docspell/notification/impl/GotifyBackend.scala
index da81f6a9..7f6b8c8a 100644
--- a/modules/notification/impl/src/main/scala/docspell/notification/impl/GotifyBackend.scala
+++ b/modules/notification/impl/src/main/scala/docspell/notification/impl/GotifyBackend.scala
@@ -9,7 +9,7 @@ package docspell.notification.impl
 import cats.effect._
 import cats.implicits._
 
-import docspell.common.Logger
+import docspell.logging.Logger
 import docspell.notification.api._
 
 import io.circe.Json
diff --git a/modules/notification/impl/src/main/scala/docspell/notification/impl/HttpPostBackend.scala b/modules/notification/impl/src/main/scala/docspell/notification/impl/HttpPostBackend.scala
index 0a5acb21..14078db3 100644
--- a/modules/notification/impl/src/main/scala/docspell/notification/impl/HttpPostBackend.scala
+++ b/modules/notification/impl/src/main/scala/docspell/notification/impl/HttpPostBackend.scala
@@ -9,7 +9,7 @@ package docspell.notification.impl
 import cats.effect._
 import cats.implicits._
 
-import docspell.common.Logger
+import docspell.logging.Logger
 import docspell.notification.api._
 
 import org.http4s.Uri
diff --git a/modules/notification/impl/src/main/scala/docspell/notification/impl/HttpSend.scala b/modules/notification/impl/src/main/scala/docspell/notification/impl/HttpSend.scala
index 38fcd520..276a6bf9 100644
--- a/modules/notification/impl/src/main/scala/docspell/notification/impl/HttpSend.scala
+++ b/modules/notification/impl/src/main/scala/docspell/notification/impl/HttpSend.scala
@@ -9,7 +9,7 @@ package docspell.notification.impl
 import cats.effect._
 import cats.implicits._
 
-import docspell.common._
+import docspell.logging.Logger
 import docspell.notification.api.NotificationChannel
 
 import org.http4s.Request
diff --git a/modules/notification/impl/src/main/scala/docspell/notification/impl/MatrixBackend.scala b/modules/notification/impl/src/main/scala/docspell/notification/impl/MatrixBackend.scala
index 41f0cb9b..8f6d0eea 100644
--- a/modules/notification/impl/src/main/scala/docspell/notification/impl/MatrixBackend.scala
+++ b/modules/notification/impl/src/main/scala/docspell/notification/impl/MatrixBackend.scala
@@ -8,7 +8,7 @@ package docspell.notification.impl
 
 import cats.effect._
 
-import docspell.common.Logger
+import docspell.logging.Logger
 import docspell.notification.api._
 
 import org.http4s.Uri
diff --git a/modules/notification/impl/src/main/scala/docspell/notification/impl/NotificationBackendImpl.scala b/modules/notification/impl/src/main/scala/docspell/notification/impl/NotificationBackendImpl.scala
index 48a3406c..b77f938c 100644
--- a/modules/notification/impl/src/main/scala/docspell/notification/impl/NotificationBackendImpl.scala
+++ b/modules/notification/impl/src/main/scala/docspell/notification/impl/NotificationBackendImpl.scala
@@ -9,7 +9,7 @@ package docspell.notification.impl
 import cats.data.NonEmptyList
 import cats.effect._
 
-import docspell.common.Logger
+import docspell.logging.Logger
 import docspell.notification.api.NotificationBackend.{combineAll, ignoreErrors, silent}
 import docspell.notification.api.{NotificationBackend, NotificationChannel}
 
diff --git a/modules/notification/impl/src/main/scala/docspell/notification/impl/NotificationModuleImpl.scala b/modules/notification/impl/src/main/scala/docspell/notification/impl/NotificationModuleImpl.scala
index 9c49e9da..8ae214da 100644
--- a/modules/notification/impl/src/main/scala/docspell/notification/impl/NotificationModuleImpl.scala
+++ b/modules/notification/impl/src/main/scala/docspell/notification/impl/NotificationModuleImpl.scala
@@ -10,7 +10,7 @@ import cats.data.Kleisli
 import cats.effect.kernel.Async
 import cats.implicits._
 
-import docspell.common._
+import docspell.logging.Logger
 import docspell.notification.api._
 import docspell.store.Store
 
diff --git a/modules/oidc/src/main/scala/docspell/oidc/CodeFlow.scala b/modules/oidc/src/main/scala/docspell/oidc/CodeFlow.scala
index 9a40d9ae..c1a01995 100644
--- a/modules/oidc/src/main/scala/docspell/oidc/CodeFlow.scala
+++ b/modules/oidc/src/main/scala/docspell/oidc/CodeFlow.scala
@@ -22,7 +22,6 @@ import org.http4s.client.middleware.RequestLogger
 import org.http4s.client.middleware.ResponseLogger
 import org.http4s.headers.Accept
 import org.http4s.headers.Authorization
-import org.log4s.getLogger
 
 /** https://openid.net/specs/openid-connect-core-1_0.html (OIDC)
   * https://openid.net/specs/openid-connect-basic-1_0.html#TokenRequest (OIDC)
@@ -30,7 +29,6 @@ import org.log4s.getLogger
   * https://datatracker.ietf.org/doc/html/rfc7519 (JWT)
   */
 object CodeFlow {
-  private[this] val log4sLogger = getLogger
 
   def apply[F[_]: Async, A](
       client: Client[F],
@@ -39,7 +37,7 @@ object CodeFlow {
   )(
       code: String
   ): OptionT[F, Json] = {
-    val logger = Logger.log4s[F](log4sLogger)
+    val logger = docspell.logging.getLogger[F]
     val dsl = new Http4sClientDsl[F] {}
     val c = logRequests[F](logResponses[F](client))
 
@@ -93,7 +91,7 @@ object CodeFlow {
       code: String
   ): OptionT[F, AccessToken] = {
     import dsl._
-    val logger = Logger.log4s[F](log4sLogger)
+    val logger = docspell.logging.getLogger[F]
 
     val req = POST(
       UrlForm(
@@ -133,7 +131,7 @@ object CodeFlow {
       token: AccessToken
   ): OptionT[F, Json] = {
     import dsl._
-    val logger = Logger.log4s[F](log4sLogger)
+    val logger = docspell.logging.getLogger[F]
 
     val req = GET(
       Uri.unsafeFromString(endpointUrl.asString),
@@ -162,18 +160,22 @@ object CodeFlow {
     OptionT(resp)
   }
 
-  private def logRequests[F[_]: Async](c: Client[F]): Client[F] =
+  private def logRequests[F[_]: Async](c: Client[F]): Client[F] = {
+    val logger = docspell.logging.getLogger[F]
     RequestLogger(
       logHeaders = true,
       logBody = true,
-      logAction = Some((msg: String) => Logger.log4s(log4sLogger).trace(msg))
+      logAction = Some((msg: String) => logger.trace(msg))
     )(c)
+  }
 
-  private def logResponses[F[_]: Async](c: Client[F]): Client[F] =
+  private def logResponses[F[_]: Async](c: Client[F]): Client[F] = {
+    val logger = docspell.logging.getLogger[F]
     ResponseLogger(
       logHeaders = true,
       logBody = true,
-      logAction = Some((msg: String) => Logger.log4s(log4sLogger).trace(msg))
+      logAction = Some((msg: String) => logger.trace(msg))
     )(c)
+  }
 
 }
diff --git a/modules/oidc/src/main/scala/docspell/oidc/CodeFlowRoutes.scala b/modules/oidc/src/main/scala/docspell/oidc/CodeFlowRoutes.scala
index eee50d96..531b0103 100644
--- a/modules/oidc/src/main/scala/docspell/oidc/CodeFlowRoutes.scala
+++ b/modules/oidc/src/main/scala/docspell/oidc/CodeFlowRoutes.scala
@@ -17,10 +17,8 @@ import org.http4s._
 import org.http4s.client.Client
 import org.http4s.dsl.Http4sDsl
 import org.http4s.headers.Location
-import org.log4s.getLogger
 
 object CodeFlowRoutes {
-  private[this] val log4sLogger = getLogger
 
   def apply[F[_]: Async](
       enabled: Boolean,
@@ -38,7 +36,7 @@ object CodeFlowRoutes {
   ): HttpRoutes[F] = {
     val dsl: Http4sDsl[F] = new Http4sDsl[F] {}
     import dsl._
-    val logger = Logger.log4s[F](log4sLogger)
+    val logger = docspell.logging.getLogger[F]
     HttpRoutes.of[F] {
       case req @ GET -> Root / Ident(id) =>
         config.findProvider(id) match {
diff --git a/modules/oidc/src/main/scala/docspell/oidc/OnUserInfo.scala b/modules/oidc/src/main/scala/docspell/oidc/OnUserInfo.scala
index 384a9913..5e827356 100644
--- a/modules/oidc/src/main/scala/docspell/oidc/OnUserInfo.scala
+++ b/modules/oidc/src/main/scala/docspell/oidc/OnUserInfo.scala
@@ -10,13 +10,10 @@ import cats.effect._
 import cats.implicits._
 import fs2.Stream
 
-import docspell.common.Logger
-
 import io.circe.Json
 import org.http4s._
 import org.http4s.headers.`Content-Type`
 import org.http4s.implicits._
-import org.log4s.getLogger
 
 /** Once the authentication flow is completed, we get "some" json structure that contains
   * a claim about the user. From here it's to the user of this small library to complete
@@ -44,18 +41,16 @@ trait OnUserInfo[F[_]] {
 }
 
 object OnUserInfo {
-  private[this] val log = getLogger
-
   def apply[F[_]](
       f: (Request[F], ProviderConfig, Option[Json]) => F[Response[F]]
   ): OnUserInfo[F] =
     (req: Request[F], cfg: ProviderConfig, userInfo: Option[Json]) =>
       f(req, cfg, userInfo)
 
-  def logInfo[F[_]: Sync]: OnUserInfo[F] =
+  def logInfo[F[_]: Sync]: OnUserInfo[F] = {
+    val logger = docspell.logging.getLogger[F]
     OnUserInfo((_, _, json) =>
-      Logger
-        .log4s(log)
+      logger
         .info(s"Got data: ${json.map(_.spaces2)}")
         .map(_ =>
           Response[F](Status.Ok)
@@ -65,4 +60,5 @@ object OnUserInfo {
             )
         )
     )
+  }
 }
diff --git a/modules/pubsub/api/src/main/scala/docspell/pubsub/api/PubSubT.scala b/modules/pubsub/api/src/main/scala/docspell/pubsub/api/PubSubT.scala
index 2e51922d..20c964dd 100644
--- a/modules/pubsub/api/src/main/scala/docspell/pubsub/api/PubSubT.scala
+++ b/modules/pubsub/api/src/main/scala/docspell/pubsub/api/PubSubT.scala
@@ -12,7 +12,7 @@ import cats.implicits._
 import fs2.concurrent.SignallingRef
 import fs2.{Pipe, Stream}
 
-import docspell.common.Logger
+import docspell.logging.Logger
 
 trait PubSubT[F[_]] {
 
@@ -33,7 +33,7 @@ trait PubSubT[F[_]] {
 
 object PubSubT {
   def noop[F[_]: Async]: PubSubT[F] =
-    PubSubT(PubSub.noop[F], Logger.off[F])
+    PubSubT(PubSub.noop[F], Logger.offF[F])
 
   def apply[F[_]: Async](pubSub: PubSub[F], logger: Logger[F]): PubSubT[F] =
     new PubSubT[F] {
@@ -57,7 +57,7 @@ object PubSubT {
             m.body.as[A](topic.codec) match {
               case Right(a) => Stream.emit(Message(m.head, a))
               case Left(err) =>
-                logger.s
+                logger.stream
                   .error(err)(
                     s"Could not decode message to topic ${topic.name} to ${topic.msgClass}: ${m.body.noSpaces}"
                   )
diff --git a/modules/pubsub/naive/src/main/scala/docspell/pubsub/naive/NaivePubSub.scala b/modules/pubsub/naive/src/main/scala/docspell/pubsub/naive/NaivePubSub.scala
index 00385ad0..8379e724 100644
--- a/modules/pubsub/naive/src/main/scala/docspell/pubsub/naive/NaivePubSub.scala
+++ b/modules/pubsub/naive/src/main/scala/docspell/pubsub/naive/NaivePubSub.scala
@@ -14,6 +14,7 @@ import fs2.Stream
 import fs2.concurrent.{Topic => Fs2Topic}
 
 import docspell.common._
+import docspell.logging.Logger
 import docspell.pubsub.api._
 import docspell.pubsub.naive.NaivePubSub.State
 import docspell.store.Store
@@ -60,7 +61,7 @@ final class NaivePubSub[F[_]: Async](
     store: Store[F],
     client: Client[F]
 ) extends PubSub[F] {
-  private val logger: Logger[F] = Logger.log4s(org.log4s.getLogger)
+  private val logger: Logger[F] = docspell.logging.getLogger[F]
 
   def withClient(client: Client[F]): NaivePubSub[F] =
     new NaivePubSub[F](cfg, state, store, client)
@@ -85,7 +86,7 @@ final class NaivePubSub[F[_]: Async](
 
   def subscribe(topics: NonEmptyList[Topic]): Stream[F, Message[Json]] =
     (for {
-      _ <- logger.s.info(s"Adding subscriber for topics: $topics")
+      _ <- logger.stream.info(s"Adding subscriber for topics: $topics")
       _ <- Stream.resource[F, Unit](addRemote(topics))
       m <- Stream.eval(addLocal(topics))
     } yield m).flatten
diff --git a/modules/pubsub/naive/src/test/scala/docspell/pubsub/naive/Fixtures.scala b/modules/pubsub/naive/src/test/scala/docspell/pubsub/naive/Fixtures.scala
index 15594d72..078435c7 100644
--- a/modules/pubsub/naive/src/test/scala/docspell/pubsub/naive/Fixtures.scala
+++ b/modules/pubsub/naive/src/test/scala/docspell/pubsub/naive/Fixtures.scala
@@ -9,6 +9,7 @@ package docspell.pubsub.naive
 import cats.effect._
 
 import docspell.common._
+import docspell.logging.Logger
 import docspell.pubsub.api._
 import docspell.store.{Store, StoreFixture}
 
@@ -45,7 +46,7 @@ trait Fixtures extends HttpClientOps { self: CatsEffectSuite =>
 }
 
 object Fixtures {
-  private val loggerIO: Logger[IO] = Logger.log4s(org.log4s.getLogger)
+  private val loggerIO: Logger[IO] = docspell.logging.getLogger[IO]
 
   final case class Env(store: Store[IO], cfg: PubSubConfig) {
     def pubSub: Resource[IO, NaivePubSub[IO]] = {
diff --git a/modules/pubsub/naive/src/test/scala/docspell/pubsub/naive/HttpClientOps.scala b/modules/pubsub/naive/src/test/scala/docspell/pubsub/naive/HttpClientOps.scala
index 3659f88e..4939936f 100644
--- a/modules/pubsub/naive/src/test/scala/docspell/pubsub/naive/HttpClientOps.scala
+++ b/modules/pubsub/naive/src/test/scala/docspell/pubsub/naive/HttpClientOps.scala
@@ -55,5 +55,5 @@ trait HttpClientOps {
 }
 
 object HttpClientOps {
-  private val logger: Logger[IO] = Logger.log4s(org.log4s.getLogger)
+  private val logger = docspell.logging.getLogger[IO]
 }
diff --git a/modules/pubsub/naive/src/test/scala/docspell/pubsub/naive/NaivePubSubTest.scala b/modules/pubsub/naive/src/test/scala/docspell/pubsub/naive/NaivePubSubTest.scala
index 8567e0ec..64922cf3 100644
--- a/modules/pubsub/naive/src/test/scala/docspell/pubsub/naive/NaivePubSubTest.scala
+++ b/modules/pubsub/naive/src/test/scala/docspell/pubsub/naive/NaivePubSubTest.scala
@@ -12,14 +12,13 @@ import cats.effect._
 import cats.implicits._
 import fs2.concurrent.SignallingRef
 
-import docspell.common._
 import docspell.pubsub.api._
 import docspell.pubsub.naive.Topics._
 
 import munit.CatsEffectSuite
 
 class NaivePubSubTest extends CatsEffectSuite with Fixtures {
-  private[this] val logger = Logger.log4s[IO](org.log4s.getLogger)
+  private[this] val logger = docspell.logging.getLogger[IO]
 
   def subscribe[A](ps: PubSubT[IO], topic: TypedTopic[A]) =
     for {
diff --git a/modules/restserver/src/main/resources/reference.conf b/modules/restserver/src/main/resources/reference.conf
index cb60e0dd..b8341816 100644
--- a/modules/restserver/src/main/resources/reference.conf
+++ b/modules/restserver/src/main/resources/reference.conf
@@ -21,6 +21,17 @@ docspell.server {
   # other nodes can reach this server.
   internal-url = "http://localhost:7880"
 
+  # Configures logging
+  logging {
+    # The format for the log messages. Can be one of:
+    # Json, Logfmt, Fancy or Plain
+    format = "Json"
+
+    # The minimum level to log. From lowest to highest:
+    # Trace, Debug, Info, Warn, Error
+    minimumLevel = "Debug"
+  }
+
   # Where the server binds to.
   bind {
     address = "localhost"
diff --git a/modules/restserver/src/main/scala/docspell/restserver/Config.scala b/modules/restserver/src/main/scala/docspell/restserver/Config.scala
index 6d30e1e1..bfce9652 100644
--- a/modules/restserver/src/main/scala/docspell/restserver/Config.scala
+++ b/modules/restserver/src/main/scala/docspell/restserver/Config.scala
@@ -10,6 +10,7 @@ import docspell.backend.auth.Login
 import docspell.backend.{Config => BackendConfig}
 import docspell.common._
 import docspell.ftssolr.SolrConfig
+import docspell.logging.LogConfig
 import docspell.oidc.ProviderConfig
 import docspell.pubsub.naive.PubSubConfig
 import docspell.restserver.Config.OpenIdConfig
@@ -23,6 +24,7 @@ case class Config(
     appId: Ident,
     baseUrl: LenientUri,
     internalUrl: LenientUri,
+    logging: LogConfig,
     bind: Config.Bind,
     backend: BackendConfig,
     auth: Login.Config,
diff --git a/modules/restserver/src/main/scala/docspell/restserver/ConfigFile.scala b/modules/restserver/src/main/scala/docspell/restserver/ConfigFile.scala
index 7a893671..5e72a1d4 100644
--- a/modules/restserver/src/main/scala/docspell/restserver/ConfigFile.scala
+++ b/modules/restserver/src/main/scala/docspell/restserver/ConfigFile.scala
@@ -12,7 +12,6 @@ import cats.Monoid
 import cats.effect.Async
 
 import docspell.backend.signup.{Config => SignupConfig}
-import docspell.common.Logger
 import docspell.config.Implicits._
 import docspell.config.{ConfigFactory, Validation}
 import docspell.oidc.{ProviderConfig, SignatureAlgo}
@@ -23,11 +22,12 @@ import pureconfig.generic.auto._
 import scodec.bits.ByteVector
 
 object ConfigFile {
-  private[this] val unsafeLogger = org.log4s.getLogger
+  private[this] val unsafeLogger = docspell.logging.unsafeLogger
+
   import Implicits._
 
   def loadConfig[F[_]: Async](args: List[String]): F[Config] = {
-    val logger = Logger.log4s(unsafeLogger)
+    val logger = docspell.logging.getLogger[F]
     val validate =
       Validation.of(generateSecretIfEmpty, duplicateOpenIdProvider, signKeyVsUserUrl)
     ConfigFactory
diff --git a/modules/restserver/src/main/scala/docspell/restserver/Main.scala b/modules/restserver/src/main/scala/docspell/restserver/Main.scala
index 61844bdd..c8a6a003 100644
--- a/modules/restserver/src/main/scala/docspell/restserver/Main.scala
+++ b/modules/restserver/src/main/scala/docspell/restserver/Main.scala
@@ -9,17 +9,17 @@ package docspell.restserver
 import cats.effect._
 
 import docspell.common._
-
-import org.log4s.getLogger
+import docspell.logging.impl.ScribeConfigure
 
 object Main extends IOApp {
-  private[this] val logger: Logger[IO] = Logger.log4s(getLogger)
 
   private val connectEC =
     ThreadFactories.fixed[IO](5, ThreadFactories.ofName("docspell-dbconnect"))
 
   def run(args: List[String]) = for {
     cfg <- ConfigFile.loadConfig[IO](args)
+    _ <- ScribeConfigure.configure[IO](cfg.logging)
+    logger = docspell.logging.getLogger[IO]
     banner = Banner(
       "REST Server",
       BuildInfo.version,
diff --git a/modules/restserver/src/main/scala/docspell/restserver/RestAppImpl.scala b/modules/restserver/src/main/scala/docspell/restserver/RestAppImpl.scala
index 902bb8b4..ce14ecd3 100644
--- a/modules/restserver/src/main/scala/docspell/restserver/RestAppImpl.scala
+++ b/modules/restserver/src/main/scala/docspell/restserver/RestAppImpl.scala
@@ -11,7 +11,6 @@ import fs2.Stream
 import fs2.concurrent.Topic
 
 import docspell.backend.BackendApp
-import docspell.common.Logger
 import docspell.ftsclient.FtsClient
 import docspell.ftssolr.SolrFtsClient
 import docspell.notification.api.NotificationModule
@@ -47,7 +46,8 @@ object RestAppImpl {
       pubSub: PubSub[F],
       wsTopic: Topic[F, OutputEvent]
   ): Resource[F, RestApp[F]] = {
-    val logger = Logger.log4s(org.log4s.getLogger(s"restserver-${cfg.appId.id}"))
+    val logger = docspell.logging.getLogger[F](s"restserver-${cfg.appId.id}")
+
     for {
       ftsClient <- createFtsClient(cfg)(httpClient)
       pubSubT = PubSubT(pubSub, logger)
diff --git a/modules/restserver/src/main/scala/docspell/restserver/auth/OpenId.scala b/modules/restserver/src/main/scala/docspell/restserver/auth/OpenId.scala
index 668a405a..9ec8e7ea 100644
--- a/modules/restserver/src/main/scala/docspell/restserver/auth/OpenId.scala
+++ b/modules/restserver/src/main/scala/docspell/restserver/auth/OpenId.scala
@@ -22,10 +22,8 @@ import io.circe.Json
 import org.http4s.dsl.Http4sDsl
 import org.http4s.headers.Location
 import org.http4s.{Response, Uri}
-import org.log4s.getLogger
 
 object OpenId {
-  private[this] val log = getLogger
 
   def codeFlowConfig[F[_]](config: Config): CodeFlowConfig[F] =
     CodeFlowConfig(
@@ -38,9 +36,9 @@ object OpenId {
 
   def handle[F[_]: Async](backend: BackendApp[F], config: Config): OnUserInfo[F] =
     OnUserInfo { (req, provider, userInfo) =>
+      val logger = docspell.logging.getLogger[F]
       val dsl = new Http4sDsl[F] {}
       import dsl._
-      val logger = Logger.log4s(log)
       val baseUrl = ClientRequestInfo.getBaseUrl(config, req)
       val uri = baseUrl.withQuery("openid", "1") / "app" / "login"
       val location = Location(Uri.unsafeFromString(uri.asString))
@@ -101,6 +99,7 @@ object OpenId {
       location: Location,
       baseUrl: LenientUri
   ): F[Response[F]] = {
+    val logger = docspell.logging.getLogger[F]
     val dsl = new Http4sDsl[F] {}
     import dsl._
 
@@ -108,7 +107,6 @@ object OpenId {
       setup <- backend.signup.setupExternal(cfg.backend.signup)(
         ExternalAccount(accountId)
       )
-      logger = Logger.log4s(log)
       res <- setup match {
         case SignupResult.Failure(ex) =>
           logger.error(ex)(s"Error when creating external account!") *>
@@ -141,6 +139,7 @@ object OpenId {
       location: Location,
       baseUrl: LenientUri
   ): F[Response[F]] = {
+    val logger = docspell.logging.getLogger[F]
     val dsl = new Http4sDsl[F] {}
     import dsl._
 
@@ -160,7 +159,7 @@ object OpenId {
             .map(_.addCookie(CookieData(session).asCookie(baseUrl)))
 
         case failed =>
-          Logger.log4s(log).error(s"External login failed: $failed") *>
+          logger.error(s"External login failed: $failed") *>
             SeeOther(location)
       }
     } yield resp
diff --git a/modules/restserver/src/main/scala/docspell/restserver/routes/ClientSettingsRoutes.scala b/modules/restserver/src/main/scala/docspell/restserver/routes/ClientSettingsRoutes.scala
index 96dad4b4..86ea63b4 100644
--- a/modules/restserver/src/main/scala/docspell/restserver/routes/ClientSettingsRoutes.scala
+++ b/modules/restserver/src/main/scala/docspell/restserver/routes/ClientSettingsRoutes.scala
@@ -27,8 +27,7 @@ object ClientSettingsRoutes {
       backend: BackendApp[F],
       token: ShareToken
   ): HttpRoutes[F] = {
-    val logger = Logger.log4s[F](org.log4s.getLogger)
-
+    val logger = docspell.logging.getLogger[F]
     val dsl = new Http4sDsl[F] {}
     import dsl._
 
diff --git a/modules/restserver/src/main/scala/docspell/restserver/routes/ItemMultiRoutes.scala b/modules/restserver/src/main/scala/docspell/restserver/routes/ItemMultiRoutes.scala
index 7c2887fb..373ac8af 100644
--- a/modules/restserver/src/main/scala/docspell/restserver/routes/ItemMultiRoutes.scala
+++ b/modules/restserver/src/main/scala/docspell/restserver/routes/ItemMultiRoutes.scala
@@ -22,16 +22,15 @@ import org.http4s.HttpRoutes
 import org.http4s.circe.CirceEntityDecoder._
 import org.http4s.circe.CirceEntityEncoder._
 import org.http4s.dsl.Http4sDsl
-import org.log4s.getLogger
 
 object ItemMultiRoutes extends NonEmptyListSupport with MultiIdSupport {
-  private[this] val log4sLogger = getLogger
 
   def apply[F[_]: Async](
       cfg: Config,
       backend: BackendApp[F],
       user: AuthToken
   ): HttpRoutes[F] = {
+    val logger = docspell.logging.getLogger[F]
     val dsl = new Http4sDsl[F] {}
     import dsl._
 
@@ -236,7 +235,6 @@ object ItemMultiRoutes extends NonEmptyListSupport with MultiIdSupport {
         for {
           json <- req.as[IdList]
           items <- requireNonEmpty(json.ids)
-          logger = Logger.log4s(log4sLogger)
           res <- backend.item.merge(logger, items, user.account.collective)
           resp <- Ok(Conversions.basicResult(res, "Items merged"))
         } yield resp
diff --git a/modules/restserver/src/main/scala/docspell/restserver/routes/ItemRoutes.scala b/modules/restserver/src/main/scala/docspell/restserver/routes/ItemRoutes.scala
index b4b3c0f6..7d46e83e 100644
--- a/modules/restserver/src/main/scala/docspell/restserver/routes/ItemRoutes.scala
+++ b/modules/restserver/src/main/scala/docspell/restserver/routes/ItemRoutes.scala
@@ -18,7 +18,6 @@ import docspell.backend.ops.OItemSearch.{Batch, Query}
 import docspell.backend.ops.OSimpleSearch
 import docspell.backend.ops.OSimpleSearch.StringSearchResult
 import docspell.common._
-import docspell.common.syntax.all._
 import docspell.query.FulltextExtract.Result.TooMany
 import docspell.query.FulltextExtract.Result.UnsupportedPosition
 import docspell.restapi.model._
@@ -34,16 +33,14 @@ import org.http4s.circe.CirceEntityEncoder._
 import org.http4s.dsl.Http4sDsl
 import org.http4s.headers._
 import org.http4s.{HttpRoutes, Response}
-import org.log4s._
 
 object ItemRoutes {
-  private[this] val logger = getLogger
-
   def apply[F[_]: Async](
       cfg: Config,
       backend: BackendApp[F],
       user: AuthToken
   ): HttpRoutes[F] = {
+    val logger = docspell.logging.getLogger[F]
     val dsl = new Http4sDsl[F] {}
     import dsl._
 
@@ -322,7 +319,7 @@ object ItemRoutes {
       case req @ PUT -> Root / Ident(id) / "duedate" =>
         for {
           date <- req.as[OptionalDate]
-          _ <- logger.fdebug(s"Setting item due date to ${date.date}")
+          _ <- logger.debug(s"Setting item due date to ${date.date}")
           res <- backend.item.setItemDueDate(
             NonEmptyList.of(id),
             date.date,
@@ -334,7 +331,7 @@ object ItemRoutes {
       case req @ PUT -> Root / Ident(id) / "date" =>
         for {
           date <- req.as[OptionalDate]
-          _ <- logger.fdebug(s"Setting item date to ${date.date}")
+          _ <- logger.debug(s"Setting item date to ${date.date}")
           res <- backend.item.setItemDate(
             NonEmptyList.of(id),
             date.date,
@@ -353,7 +350,7 @@ object ItemRoutes {
       case req @ POST -> Root / Ident(id) / "attachment" / "movebefore" =>
         for {
           data <- req.as[MoveAttachment]
-          _ <- logger.fdebug(s"Move item (${id.id}) attachment $data")
+          _ <- logger.debug(s"Move item (${id.id}) attachment $data")
           res <- backend.item.moveAttachmentBefore(id, data.source, data.target)
           resp <- Ok(Conversions.basicResult(res, "Attachment moved."))
         } yield resp
@@ -390,7 +387,7 @@ object ItemRoutes {
       case req @ POST -> Root / Ident(id) / "reprocess" =>
         for {
           data <- req.as[IdList]
-          _ <- logger.fdebug(s"Re-process item ${id.id}")
+          _ <- logger.debug(s"Re-process item ${id.id}")
           res <- backend.item.reprocess(id, data.ids, user.account, true)
           resp <- Ok(Conversions.basicResult(res, "Re-process task submitted."))
         } yield resp
diff --git a/modules/restserver/src/main/scala/docspell/restserver/routes/NotificationRoutes.scala b/modules/restserver/src/main/scala/docspell/restserver/routes/NotificationRoutes.scala
index cdd4370c..fd244fc2 100644
--- a/modules/restserver/src/main/scala/docspell/restserver/routes/NotificationRoutes.scala
+++ b/modules/restserver/src/main/scala/docspell/restserver/routes/NotificationRoutes.scala
@@ -135,7 +135,8 @@ object NotificationRoutes extends NonEmptyListSupport {
             user.account,
             baseUrl.some
           )
-          resp <- Ok(NotificationChannelTestResult(res.success, res.logMessages.toList))
+          messages = res.logEvents.map(_.asString)
+          resp <- Ok(NotificationChannelTestResult(res.success, messages.toList))
         } yield resp
     }
   }
diff --git a/modules/restserver/src/main/scala/docspell/restserver/routes/PersonRoutes.scala b/modules/restserver/src/main/scala/docspell/restserver/routes/PersonRoutes.scala
index ab2046f3..0f893571 100644
--- a/modules/restserver/src/main/scala/docspell/restserver/routes/PersonRoutes.scala
+++ b/modules/restserver/src/main/scala/docspell/restserver/routes/PersonRoutes.scala
@@ -14,7 +14,6 @@ import docspell.backend.BackendApp
 import docspell.backend.auth.AuthToken
 import docspell.backend.ops.OOrganization
 import docspell.common.Ident
-import docspell.common.syntax.all._
 import docspell.restapi.model._
 import docspell.restserver.conv.Conversions._
 import docspell.restserver.http4s.QueryParam
@@ -23,12 +22,11 @@ import org.http4s.HttpRoutes
 import org.http4s.circe.CirceEntityDecoder._
 import org.http4s.circe.CirceEntityEncoder._
 import org.http4s.dsl.Http4sDsl
-import org.log4s._
 
 object PersonRoutes {
-  private[this] val logger = getLogger
 
   def apply[F[_]: Async](backend: BackendApp[F], user: AuthToken): HttpRoutes[F] = {
+    val logger = docspell.logging.getLogger[F]
     val dsl = new Http4sDsl[F] {}
     import dsl._
 
@@ -73,7 +71,7 @@ object PersonRoutes {
 
       case DELETE -> Root / Ident(id) =>
         for {
-          _ <- logger.fdebug(s"Deleting person ${id.id}")
+          _ <- logger.debug(s"Deleting person ${id.id}")
           delOrg <- backend.organization.deletePerson(id, user.account.collective)
           resp <- Ok(basicResult(delOrg, "Person deleted."))
         } yield resp
diff --git a/modules/restserver/src/main/scala/docspell/restserver/routes/ShareSearchRoutes.scala b/modules/restserver/src/main/scala/docspell/restserver/routes/ShareSearchRoutes.scala
index 64e14a5e..806691b1 100644
--- a/modules/restserver/src/main/scala/docspell/restserver/routes/ShareSearchRoutes.scala
+++ b/modules/restserver/src/main/scala/docspell/restserver/routes/ShareSearchRoutes.scala
@@ -33,7 +33,7 @@ object ShareSearchRoutes {
       cfg: Config,
       token: ShareToken
   ): HttpRoutes[F] = {
-    val logger = Logger.log4s[F](org.log4s.getLogger)
+    val logger = docspell.logging.getLogger[F]
 
     val dsl = new Http4sDsl[F] {}
     import dsl._
diff --git a/modules/restserver/src/main/scala/docspell/restserver/webapp/TemplateRoutes.scala b/modules/restserver/src/main/scala/docspell/restserver/webapp/TemplateRoutes.scala
index 43ed9a5b..85a43fda 100644
--- a/modules/restserver/src/main/scala/docspell/restserver/webapp/TemplateRoutes.scala
+++ b/modules/restserver/src/main/scala/docspell/restserver/webapp/TemplateRoutes.scala
@@ -21,12 +21,10 @@ import org.http4s._
 import org.http4s.dsl.Http4sDsl
 import org.http4s.headers._
 import org.http4s.implicits._
-import org.log4s._
 import yamusca.implicits._
 import yamusca.imports._
 
 object TemplateRoutes {
-  private[this] val logger = getLogger
 
   private val textHtml = mediaType"text/html"
   private val appJavascript = mediaType"application/javascript"
@@ -99,11 +97,12 @@ object TemplateRoutes {
   def parseTemplate[F[_]: Sync](str: String): F[Template] =
     Sync[F].pure(mustache.parse(str).leftMap(err => new Exception(err._2))).rethrow
 
-  def loadTemplate[F[_]: Sync](url: URL): F[Template] =
-    loadUrl[F](url).flatMap(parseTemplate[F]).map { t =>
-      logger.info(s"Compiled template $url")
-      t
+  def loadTemplate[F[_]: Sync](url: URL): F[Template] = {
+    val logger = docspell.logging.getLogger[F]
+    loadUrl[F](url).flatMap(parseTemplate[F]).flatMap { t =>
+      logger.info(s"Compiled template $url") *> t.pure[F]
     }
+  }
 
   case class DocData(swaggerRoot: String, openapiSpec: String)
   object DocData {
diff --git a/modules/store/src/main/scala/docspell/store/file/BinnyUtils.scala b/modules/store/src/main/scala/docspell/store/file/BinnyUtils.scala
index 71d426d5..eef07da3 100644
--- a/modules/store/src/main/scala/docspell/store/file/BinnyUtils.scala
+++ b/modules/store/src/main/scala/docspell/store/file/BinnyUtils.scala
@@ -9,6 +9,7 @@ package docspell.store.file
 import docspell.common
 import docspell.common._
 import docspell.files.TikaMimetype
+import docspell.logging.Logger
 
 import binny._
 import scodec.bits.ByteVector
diff --git a/modules/store/src/main/scala/docspell/store/file/FileRepository.scala b/modules/store/src/main/scala/docspell/store/file/FileRepository.scala
index 7eb73f12..b3da6da3 100644
--- a/modules/store/src/main/scala/docspell/store/file/FileRepository.scala
+++ b/modules/store/src/main/scala/docspell/store/file/FileRepository.scala
@@ -32,7 +32,6 @@ trait FileRepository[F[_]] {
 }
 
 object FileRepository {
-  private[this] val logger = org.log4s.getLogger
 
   def genericJDBC[F[_]: Sync](
       xa: Transactor[F],
@@ -41,7 +40,7 @@ object FileRepository {
   ): FileRepository[F] = {
     val attrStore = new AttributeStore[F](xa)
     val cfg = JdbcStoreConfig("filechunk", chunkSize, BinnyUtils.TikaContentTypeDetect)
-    val log = Logger.log4s[F](logger)
+    val log = docspell.logging.getLogger[F]
     val binStore = GenericJdbcStore[F](ds, BinnyUtils.LoggerAdapter(log), cfg, attrStore)
     val keyFun: FileKey => BinaryId = BinnyUtils.fileKeyToBinaryId
 
diff --git a/modules/store/src/main/scala/docspell/store/queries/QAttachment.scala b/modules/store/src/main/scala/docspell/store/queries/QAttachment.scala
index 7768a25c..5cdcafb3 100644
--- a/modules/store/src/main/scala/docspell/store/queries/QAttachment.scala
+++ b/modules/store/src/main/scala/docspell/store/queries/QAttachment.scala
@@ -13,7 +13,6 @@ import cats.implicits._
 import fs2.Stream
 
 import docspell.common._
-import docspell.common.syntax.all._
 import docspell.store.Store
 import docspell.store.qb.DSL._
 import docspell.store.qb._
@@ -22,8 +21,6 @@ import docspell.store.records._
 import doobie._
 
 object QAttachment {
-  private[this] val logger = org.log4s.getLogger
-
   private val a = RAttachment.as("a")
   private val item = RItem.as("i")
   private val am = RAttachmentMeta.as("am")
@@ -79,13 +76,14 @@ object QAttachment {
     * item and should not be used to delete a *single* attachment where the item should
     * stay.
     */
-  private def deleteAttachment[F[_]: Sync](store: Store[F])(ra: RAttachment): F[Int] =
+  private def deleteAttachment[F[_]: Sync](store: Store[F])(ra: RAttachment): F[Int] = {
+    val logger = docspell.logging.getLogger[F]
     for {
-      _ <- logger.fdebug[F](s"Deleting attachment: ${ra.id.id}")
+      _ <- logger.debug(s"Deleting attachment: ${ra.id.id}")
       s <- store.transact(RAttachmentSource.findById(ra.id))
       p <- store.transact(RAttachmentPreview.findById(ra.id))
       n <- store.transact(RAttachment.delete(ra.id))
-      _ <- logger.fdebug[F](
+      _ <- logger.debug(
         s"Deleted $n meta records (source, meta, preview, archive). Deleting binaries now."
       )
       f <-
@@ -96,6 +94,7 @@ object QAttachment {
           .compile
           .foldMonoid
     } yield n + f
+  }
 
   def deleteArchive[F[_]: Sync](store: Store[F])(attachId: Ident): F[Int] =
     (for {
@@ -112,16 +111,18 @@ object QAttachment {
 
   def deleteItemAttachments[F[_]: Sync](
       store: Store[F]
-  )(itemId: Ident, coll: Ident): F[Int] =
+  )(itemId: Ident, coll: Ident): F[Int] = {
+    val logger = docspell.logging.getLogger[F]
     for {
       ras <- store.transact(RAttachment.findByItemAndCollective(itemId, coll))
-      _ <- logger.finfo[F](
+      _ <- logger.info(
         s"Have ${ras.size} attachments to delete. Must first delete archive entries"
       )
       a <- ras.traverse(a => deleteArchive(store)(a.id))
-      _ <- logger.fdebug[F](s"Deleted ${a.sum} archive entries")
+      _ <- logger.debug(s"Deleted ${a.sum} archive entries")
       ns <- ras.traverse(deleteAttachment[F](store))
     } yield ns.sum
+  }
 
   def getMetaProposals(itemId: Ident, coll: Ident): ConnectionIO[MetaProposalList] = {
     val qa = Select(
diff --git a/modules/store/src/main/scala/docspell/store/queries/QItem.scala b/modules/store/src/main/scala/docspell/store/queries/QItem.scala
index 3b5d0c19..66203292 100644
--- a/modules/store/src/main/scala/docspell/store/queries/QItem.scala
+++ b/modules/store/src/main/scala/docspell/store/queries/QItem.scala
@@ -14,7 +14,6 @@ import cats.effect.Sync
 import cats.implicits._
 import fs2.Stream
 
-import docspell.common.syntax.all._
 import docspell.common.{FileKey, IdRef, _}
 import docspell.query.ItemQuery
 import docspell.store.Store
@@ -25,10 +24,9 @@ import docspell.store.records._
 
 import doobie.implicits._
 import doobie.{Query => _, _}
-import org.log4s.getLogger
 
 object QItem {
-  private[this] val logger = getLogger
+  private[this] val logger = docspell.logging.getLogger[ConnectionIO]
 
   private val equip = REquipment.as("e")
   private val org = ROrganization.as("o")
@@ -81,7 +79,7 @@ object QItem {
         )
       ]
       .option
-    logger.trace(s"Find item query: $cq")
+    logger.asUnsafe.trace(s"Find item query: $cq")
     val attachs = RAttachment.findByItemWithMeta(id)
     val sources = RAttachmentSource.findByItemWithMeta(id)
     val archives = RAttachmentArchive.findByItemWithMeta(id)
@@ -181,8 +179,8 @@ object QItem {
       .changeWhere(c => c && queryCondition(today, q.fix.account.collective, q.cond))
       .limit(batch)
       .build
-    logger.trace(s"List $batch items: $sql")
-    sql.query[ListItem].stream
+    logger.stream.trace(s"List $batch items: $sql").drain ++
+      sql.query[ListItem].stream
   }
 
   def searchStats(today: LocalDate)(q: Query): ConnectionIO[SearchSummary] =
@@ -359,8 +357,7 @@ object QItem {
     query.attemptSql.flatMap {
       case Right(res) => res.pure[ConnectionIO]
       case Left(ex) =>
-        Logger
-          .log4s[ConnectionIO](logger)
+        logger
           .error(ex)(
             s"Calculating custom field summary failed. You may have invalid custom field values according to their type."
           ) *>
@@ -405,8 +402,8 @@ object QItem {
         .orderBy(Tids.weight.desc)
         .build
 
-      logger.trace(s"fts query: $from")
-      from.query[ListItem].stream
+      logger.stream.trace(s"fts query: $from").drain ++
+        from.query[ListItem].stream
     }
 
   /** Same as `findItems` but resolves the tags for each item. Note that this is
@@ -515,8 +512,8 @@ object QItem {
       excludeFileMeta: Set[FileKey]
   ): ConnectionIO[Vector[RItem]] = {
     val qq = findByChecksumQuery(checksum, collective, excludeFileMeta).build
-    logger.debug(s"FindByChecksum: $qq")
-    qq.query[RItem].to[Vector]
+    logger.debug(s"FindByChecksum: $qq") *>
+      qq.query[RItem].to[Vector]
   }
 
   def findByChecksumQuery(
@@ -695,7 +692,7 @@ object QItem {
 
   private def contentMax(maxLen: Int): SelectExpr =
     if (maxLen <= 0) {
-      logger.debug("Max text length limit disabled")
+      logger.asUnsafe.debug("Max text length limit disabled")
       m.content.s
     } else substring(m.content.s, 0, maxLen).s
 
@@ -703,11 +700,11 @@ object QItem {
       q: Select
   ): ConnectionIO[TextAndTag] =
     for {
-      _ <- logger.ftrace[ConnectionIO](
+      _ <- logger.trace(
         s"query: $q  (${itemId.id}, ${collective.id})"
       )
       texts <- q.build.query[(String, Option[TextAndTag.TagName])].to[List]
-      _ <- logger.ftrace[ConnectionIO](
+      _ <- logger.trace(
         s"Got ${texts.size} text and tag entries for item ${itemId.id}"
       )
       tag = texts.headOption.flatMap(_._2)
diff --git a/modules/store/src/main/scala/docspell/store/queries/QJob.scala b/modules/store/src/main/scala/docspell/store/queries/QJob.scala
index f005b40c..a172540b 100644
--- a/modules/store/src/main/scala/docspell/store/queries/QJob.scala
+++ b/modules/store/src/main/scala/docspell/store/queries/QJob.scala
@@ -12,7 +12,6 @@ import cats.implicits._
 import fs2.Stream
 
 import docspell.common._
-import docspell.common.syntax.all._
 import docspell.store.Store
 import docspell.store.qb.DSL._
 import docspell.store.qb._
@@ -20,10 +19,9 @@ import docspell.store.records.{RJob, RJobGroupUse, RJobLog}
 
 import doobie._
 import doobie.implicits._
-import org.log4s._
 
 object QJob {
-  private[this] val logger = getLogger
+  private[this] val cioLogger = docspell.logging.getLogger[ConnectionIO]
 
   def takeNextJob[F[_]: Async](
       store: Store[F]
@@ -31,13 +29,14 @@ object QJob {
       priority: Ident => F[Priority],
       worker: Ident,
       retryPause: Duration
-  ): F[Option[RJob]] =
+  ): F[Option[RJob]] = {
+    val logger = docspell.logging.getLogger[F]
     Stream
       .range(0, 10)
       .evalMap(n => takeNextJob1(store)(priority, worker, retryPause, n))
       .evalTap { x =>
         if (x.isLeft)
-          logger.fdebug[F](
+          logger.debug(
             "Cannot mark job, probably due to concurrent updates. Will retry."
           )
         else ().pure[F]
@@ -48,12 +47,13 @@ object QJob {
           Stream.emit(job)
         case Left(_) =>
           Stream
-            .eval(logger.fwarn[F]("Cannot mark job, even after retrying. Give up."))
+            .eval(logger.warn("Cannot mark job, even after retrying. Give up."))
             .map(_ => None)
       }
       .compile
       .last
       .map(_.flatten)
+  }
 
   private def takeNextJob1[F[_]: Async](store: Store[F])(
       priority: Ident => F[Priority],
@@ -61,6 +61,7 @@ object QJob {
       retryPause: Duration,
       currentTry: Int
   ): F[Either[Unit, Option[RJob]]] = {
+    val logger = docspell.logging.getLogger[F]
     // if this fails, we have to restart takeNextJob
     def markJob(job: RJob): F[Either[Unit, RJob]] =
       store.transact(for {
@@ -68,25 +69,25 @@ object QJob {
         _ <-
           if (n == 1) RJobGroupUse.setGroup(RJobGroupUse(job.group, worker))
           else 0.pure[ConnectionIO]
-        _ <- logger.fdebug[ConnectionIO](
+        _ <- cioLogger.debug(
           s"Scheduled job ${job.info} to worker ${worker.id}"
         )
       } yield if (n == 1) Right(job) else Left(()))
 
     for {
-      _ <- logger.ftrace[F](
+      _ <- logger.trace(
         s"About to take next job (worker ${worker.id}), try $currentTry"
       )
       now <- Timestamp.current[F]
       group <- store.transact(selectNextGroup(worker, now, retryPause))
-      _ <- logger.ftrace[F](s"Choose group ${group.map(_.id)}")
+      _ <- logger.trace(s"Choose group ${group.map(_.id)}")
       prio <- group.map(priority).getOrElse((Priority.Low: Priority).pure[F])
-      _ <- logger.ftrace[F](s"Looking for job of prio $prio")
+      _ <- logger.trace(s"Looking for job of prio $prio")
       job <-
         group
           .map(g => store.transact(selectNextJob(g, prio, retryPause, now)))
           .getOrElse((None: Option[RJob]).pure[F])
-      _ <- logger.ftrace[F](s"Found job: ${job.map(_.info)}")
+      _ <- logger.trace(s"Found job: ${job.map(_.info)}")
       res <- job.traverse(j => markJob(j))
     } yield res.map(_.map(_.some)).getOrElse {
       if (group.isDefined)
@@ -138,7 +139,7 @@ object QJob {
         .limit(1)
 
     val frag = groups.build
-    logger.trace(
+    cioLogger.trace(
       s"nextGroupQuery: $frag  (now=${now.toMillis}, pause=${initialPause.millis})"
     )
 
@@ -206,7 +207,8 @@ object QJob {
       _ <- store.transact(RJob.setRunning(id, workerId, now))
     } yield ()
 
-  def setFinalState[F[_]: Async](id: Ident, state: JobState, store: Store[F]): F[Unit] =
+  def setFinalState[F[_]: Async](id: Ident, state: JobState, store: Store[F]): F[Unit] = {
+    val logger = docspell.logging.getLogger[F]
     state match {
       case JobState.Success =>
         setSuccess(id, store)
@@ -217,8 +219,9 @@ object QJob {
       case JobState.Stuck =>
         setStuck(id, store)
       case _ =>
-        logger.ferror[F](s"Invalid final state: $state.")
+        logger.error(s"Invalid final state: $state.")
     }
+  }
 
   def exceedsRetries[F[_]: Async](id: Ident, max: Int, store: Store[F]): F[Boolean] =
     store.transact(RJob.getRetries(id)).map(n => n.forall(_ >= max))
diff --git a/modules/store/src/main/scala/docspell/store/queries/QUser.scala b/modules/store/src/main/scala/docspell/store/queries/QUser.scala
index 6000016a..28c817c4 100644
--- a/modules/store/src/main/scala/docspell/store/queries/QUser.scala
+++ b/modules/store/src/main/scala/docspell/store/queries/QUser.scala
@@ -16,7 +16,7 @@ import docspell.store.records._
 import doobie._
 
 object QUser {
-  private val logger = Logger.log4s[ConnectionIO](org.log4s.getLogger)
+  private[this] val logger = docspell.logging.getLogger[ConnectionIO]
 
   final case class UserData(
       ownedFolders: List[Ident],
diff --git a/modules/store/src/main/scala/docspell/store/queue/JobQueue.scala b/modules/store/src/main/scala/docspell/store/queue/JobQueue.scala
index feb59f60..9b777086 100644
--- a/modules/store/src/main/scala/docspell/store/queue/JobQueue.scala
+++ b/modules/store/src/main/scala/docspell/store/queue/JobQueue.scala
@@ -14,8 +14,6 @@ import docspell.store.Store
 import docspell.store.queries.QJob
 import docspell.store.records.RJob
 
-import org.log4s.getLogger
-
 trait JobQueue[F[_]] {
 
   /** Inserts the job into the queue to get picked up as soon as possible. The job must
@@ -44,7 +42,7 @@ trait JobQueue[F[_]] {
 object JobQueue {
   def apply[F[_]: Async](store: Store[F]): Resource[F, JobQueue[F]] =
     Resource.pure[F, JobQueue[F]](new JobQueue[F] {
-      private[this] val logger = Logger.log4s(getLogger)
+      private[this] val logger = docspell.logging.getLogger[F]
 
       def nextJob(
           prio: Ident => F[Priority],
diff --git a/modules/store/src/main/scala/docspell/store/queue/PeriodicTaskStore.scala b/modules/store/src/main/scala/docspell/store/queue/PeriodicTaskStore.scala
index 8213f319..f1fad91f 100644
--- a/modules/store/src/main/scala/docspell/store/queue/PeriodicTaskStore.scala
+++ b/modules/store/src/main/scala/docspell/store/queue/PeriodicTaskStore.scala
@@ -10,13 +10,10 @@ import cats.effect._
 import cats.implicits._
 
 import docspell.common._
-import docspell.common.syntax.all._
 import docspell.store.queries.QPeriodicTask
 import docspell.store.records._
 import docspell.store.{AddResult, Store}
 
-import org.log4s.getLogger
-
 trait PeriodicTaskStore[F[_]] {
 
   /** Get the free periodic task due next and reserve it to the given worker.
@@ -44,11 +41,10 @@ trait PeriodicTaskStore[F[_]] {
 }
 
 object PeriodicTaskStore {
-  private[this] val logger = getLogger
 
   def create[F[_]: Sync](store: Store[F]): Resource[F, PeriodicTaskStore[F]] =
     Resource.pure[F, PeriodicTaskStore[F]](new PeriodicTaskStore[F] {
-
+      private[this] val logger = docspell.logging.getLogger[F]
       def takeNext(
           worker: Ident,
           excludeId: Option[Ident]
@@ -91,7 +87,7 @@ object PeriodicTaskStore {
         store
           .transact(QPeriodicTask.clearWorkers(name))
           .flatMap { n =>
-            if (n > 0) logger.finfo(s"Clearing $n periodic tasks from worker ${name.id}")
+            if (n > 0) logger.info(s"Clearing $n periodic tasks from worker ${name.id}")
             else ().pure[F]
           }
 
diff --git a/modules/store/src/main/scala/docspell/store/records/RNotificationChannel.scala b/modules/store/src/main/scala/docspell/store/records/RNotificationChannel.scala
index 586bd82a..94aa89c4 100644
--- a/modules/store/src/main/scala/docspell/store/records/RNotificationChannel.scala
+++ b/modules/store/src/main/scala/docspell/store/records/RNotificationChannel.scala
@@ -192,7 +192,7 @@ object RNotificationChannel {
   ): OptionT[ConnectionIO, RNotificationChannel] =
     for {
       time <- OptionT.liftF(Timestamp.current[ConnectionIO])
-      logger = Logger.log4s[ConnectionIO](org.log4s.getLogger)
+      logger = docspell.logging.getLogger[ConnectionIO]
       r <-
         channel match {
           case Channel.Mail(_, name, conn, recipients) =>
diff --git a/project/Dependencies.scala b/project/Dependencies.scala
index 3633ccbd..53980918 100644
--- a/project/Dependencies.scala
+++ b/project/Dependencies.scala
@@ -44,6 +44,7 @@ object Dependencies {
   val ScodecBitsVersion = "1.1.30"
   val ScribeVersion = "3.7.0"
   val Slf4jVersion = "1.7.36"
+  val SourcecodeVersion = "0.2.8"
   val StanfordNlpVersion = "4.4.0"
   val TikaVersion = "2.3.0"
   val YamuscaVersion = "0.8.2"
@@ -57,6 +58,10 @@ object Dependencies {
     "com.outr" %% "scribe-slf4j" % ScribeVersion
   )
 
+  val sourcecode = Seq(
+    "com.lihaoyi" %% "sourcecode" % SourcecodeVersion
+  )
+
   val jwtScala = Seq(
     "com.github.jwt-scala" %% "jwt-circe" % JwtScalaVersion
   )
@@ -226,10 +231,13 @@ object Dependencies {
     "org.mindrot" % "jbcrypt" % BcryptVersion
   )
 
-  val fs2 = Seq(
-    "co.fs2" %% "fs2-core" % Fs2Version,
+  val fs2Core = Seq(
+    "co.fs2" %% "fs2-core" % Fs2Version
+  )
+  val fs2Io = Seq(
     "co.fs2" %% "fs2-io" % Fs2Version
   )
+  val fs2 = fs2Core ++ fs2Io
 
   val http4sClient = Seq(
     "org.http4s" %% "http4s-blaze-client" % Http4sVersion
diff --git a/project/TestSettings.scala b/project/TestSettings.scala
new file mode 100644
index 00000000..c1bace05
--- /dev/null
+++ b/project/TestSettings.scala
@@ -0,0 +1,36 @@
+import sbt._
+import sbt.Keys._
+import docspell.build._
+import sbtcrossproject.CrossProject
+
+object TestSettingsPlugin extends AutoPlugin {
+
+  object autoImport {
+    def inTest(d0: Seq[ModuleID], ds: Seq[ModuleID]*) =
+      ds.fold(d0)(_ ++ _).map(_ % Test)
+
+    implicit class ProjectTestSettingsSyntax(project: Project) {
+      def withTestSettings =
+        project.settings(testSettings)
+
+      def withTestSettingsDependsOn(p: Project, ps: Project*) =
+        (p :: ps.toList).foldLeft(project) { (cur, dep) =>
+          cur.dependsOn(dep % "test->test,compile")
+        }
+    }
+
+    implicit class CrossprojectTestSettingsSyntax(project: CrossProject) {
+      def withTestSettings =
+        project.settings(testSettings)
+    }
+
+  }
+
+  import autoImport._
+
+  val testSettings = Seq(
+    libraryDependencies ++= inTest(Dependencies.munit, Dependencies.logging),
+    testFrameworks += new TestFramework("munit.Framework")
+  )
+
+}

From 8b42708db2efe2d83d7de7863bce470b6bc45fba Mon Sep 17 00:00:00 2001
From: eikek <eike.kettner@posteo.de>
Date: Sat, 19 Feb 2022 22:01:49 +0100
Subject: [PATCH 03/10] Remove old log stuff

---
 build.sbt                                     | 14 +++----------
 .../files/src/test/resources/logback-test.xml | 14 -------------
 modules/joex/src/main/resources/logback.xml   | 14 -------------
 .../joex/src/main/resources/reference.conf    |  4 ++--
 .../restserver/src/main/resources/logback.xml | 16 ---------------
 .../src/main/resources/reference.conf         |  4 ++--
 .../store/src/test/resources/logback-test.xml | 14 -------------
 project/Dependencies.scala                    | 20 +++++++++----------
 project/TestSettings.scala                    |  2 +-
 9 files changed, 18 insertions(+), 84 deletions(-)
 delete mode 100644 modules/files/src/test/resources/logback-test.xml
 delete mode 100644 modules/joex/src/main/resources/logback.xml
 delete mode 100644 modules/restserver/src/main/resources/logback.xml
 delete mode 100644 modules/store/src/test/resources/logback-test.xml

diff --git a/build.sbt b/build.sbt
index bd28e5e8..791938c1 100644
--- a/build.sbt
+++ b/build.sbt
@@ -463,7 +463,6 @@ val store = project
         Dependencies.fs2 ++
         Dependencies.databases ++
         Dependencies.flyway ++
-        Dependencies.loggingApi ++
         Dependencies.emil ++
         Dependencies.emilDoobie ++
         Dependencies.calevCore ++
@@ -532,8 +531,7 @@ val extract = project
         Dependencies.twelvemonkeys ++
         Dependencies.pdfbox ++
         Dependencies.poi ++
-        Dependencies.commonsIO ++
-        Dependencies.julOverSlf4j
+        Dependencies.commonsIO
   )
   .dependsOn(common, loggingScribe, files % "compile->compile;test->test")
 
@@ -638,7 +636,6 @@ val backend = project
   .settings(
     name := "docspell-backend",
     libraryDependencies ++=
-      Dependencies.loggingApi ++
         Dependencies.fs2 ++
         Dependencies.bcrypt ++
         Dependencies.http4sClient ++
@@ -654,7 +651,6 @@ val oidc = project
   .settings(
     name := "docspell-oidc",
     libraryDependencies ++=
-      Dependencies.loggingApi ++
         Dependencies.fs2 ++
         Dependencies.http4sClient ++
         Dependencies.http4sCirce ++
@@ -713,9 +709,7 @@ val joex = project
         Dependencies.emilMarkdown ++
         Dependencies.emilJsoup ++
         Dependencies.jsoup ++
-        Dependencies.yamusca ++
-        Dependencies.loggingApi ++
-        Dependencies.logging.map(_ % Runtime),
+        Dependencies.yamusca,
     addCompilerPlugin(Dependencies.betterMonadicFor),
     buildInfoPackage := "docspell.joex",
     reStart / javaOptions ++= Seq(
@@ -767,9 +761,7 @@ val restserver = project
         Dependencies.pureconfig ++
         Dependencies.yamusca ++
         Dependencies.kittens ++
-        Dependencies.webjars ++
-        Dependencies.loggingApi ++
-        Dependencies.logging.map(_ % Runtime),
+        Dependencies.webjars,
     addCompilerPlugin(Dependencies.betterMonadicFor),
     buildInfoPackage := "docspell.restserver",
     Compile / sourceGenerators += Def.task {
diff --git a/modules/files/src/test/resources/logback-test.xml b/modules/files/src/test/resources/logback-test.xml
deleted file mode 100644
index b1e25720..00000000
--- a/modules/files/src/test/resources/logback-test.xml
+++ /dev/null
@@ -1,14 +0,0 @@
-<configuration>
-  <appender name="STDOUT" class="ch.qos.logback.core.ConsoleAppender">
-    <withJansi>true</withJansi>
-
-    <encoder>
-      <pattern>level=%-5level thread=%thread logger=%logger{15} message="%replace(%msg){'"', '\\"'}"%n</pattern>
-    </encoder>
-  </appender>
-
-  <logger name="docspell" level="debug" />
-  <root level="error">
-    <appender-ref ref="STDOUT" />
-  </root>
-</configuration>
diff --git a/modules/joex/src/main/resources/logback.xml b/modules/joex/src/main/resources/logback.xml
deleted file mode 100644
index df73c8c6..00000000
--- a/modules/joex/src/main/resources/logback.xml
+++ /dev/null
@@ -1,14 +0,0 @@
-<configuration>
-  <appender name="STDOUT" class="ch.qos.logback.core.ConsoleAppender">
-    <withJansi>true</withJansi>
-
-    <encoder>
-      <pattern>level=%-5level thread=%thread logger=%logger{15} message="%replace(%msg){'"', '\\"'}"%n</pattern>
-    </encoder>
-  </appender>
-
-  <logger name="docspell" level="debug" />
-  <root level="INFO">
-    <appender-ref ref="STDOUT" />
-  </root>
-</configuration>
diff --git a/modules/joex/src/main/resources/reference.conf b/modules/joex/src/main/resources/reference.conf
index 5285c282..58347c8c 100644
--- a/modules/joex/src/main/resources/reference.conf
+++ b/modules/joex/src/main/resources/reference.conf
@@ -22,11 +22,11 @@ docspell.joex {
   logging {
     # The format for the log messages. Can be one of:
     # Json, Logfmt, Fancy or Plain
-    format = "Json"
+    format = "Plain"
 
     # The minimum level to log. From lowest to highest:
     # Trace, Debug, Info, Warn, Error
-    minimumLevel = "Debug"
+    minimum-level = "Info"
   }
 
   # The database connection.
diff --git a/modules/restserver/src/main/resources/logback.xml b/modules/restserver/src/main/resources/logback.xml
deleted file mode 100644
index 406afe6e..00000000
--- a/modules/restserver/src/main/resources/logback.xml
+++ /dev/null
@@ -1,16 +0,0 @@
-<configuration>
-  <appender name="STDOUT" class="ch.qos.logback.core.ConsoleAppender">
-    <withJansi>true</withJansi>
-
-    <encoder>
-      <pattern>level=%-5level thread=%thread logger=%logger{15} message="%replace(%msg){'"', '\\"'}"%n</pattern>
-    </encoder>
-  </appender>
-
-  <logger name="docspell" level="debug" />
-  <logger name="emil" level="debug"/>
-  <logger name="org.http4s.server.message-failures" level="debug"/>
-  <root level="INFO">
-    <appender-ref ref="STDOUT" />
-  </root>
-</configuration>
diff --git a/modules/restserver/src/main/resources/reference.conf b/modules/restserver/src/main/resources/reference.conf
index b8341816..afd77ae7 100644
--- a/modules/restserver/src/main/resources/reference.conf
+++ b/modules/restserver/src/main/resources/reference.conf
@@ -25,11 +25,11 @@ docspell.server {
   logging {
     # The format for the log messages. Can be one of:
     # Json, Logfmt, Fancy or Plain
-    format = "Json"
+    format = "Plain"
 
     # The minimum level to log. From lowest to highest:
     # Trace, Debug, Info, Warn, Error
-    minimumLevel = "Debug"
+    minimum-level = "Info"
   }
 
   # Where the server binds to.
diff --git a/modules/store/src/test/resources/logback-test.xml b/modules/store/src/test/resources/logback-test.xml
deleted file mode 100644
index 5a9588a0..00000000
--- a/modules/store/src/test/resources/logback-test.xml
+++ /dev/null
@@ -1,14 +0,0 @@
-<configuration>
-  <appender name="STDOUT" class="ch.qos.logback.core.ConsoleAppender">
-    <withJansi>true</withJansi>
-
-    <encoder>
-      <pattern>level=%-5level thread=%thread logger=%logger{15} message="%replace(%msg){'"', '\\"'}"%n</pattern>
-    </encoder>
-  </appender>
-
-  <logger name="docspell" level="warn" />
-  <root level="error">
-    <appender-ref ref="STDOUT" />
-  </root>
-</configuration>
diff --git a/project/Dependencies.scala b/project/Dependencies.scala
index 53980918..f997362b 100644
--- a/project/Dependencies.scala
+++ b/project/Dependencies.scala
@@ -119,9 +119,9 @@ object Dependencies {
   val jclOverSlf4j = Seq(
     "org.slf4j" % "jcl-over-slf4j" % Slf4jVersion
   )
-  val julOverSlf4j = Seq(
-    "org.slf4j" % "jul-to-slf4j" % Slf4jVersion
-  )
+  // val julOverSlf4j = Seq(
+  //   "org.slf4j" % "jul-to-slf4j" % Slf4jVersion
+  // )
 
   val poi = Seq(
     "org.apache.poi" % "poi" % PoiVersion,
@@ -269,14 +269,14 @@ object Dependencies {
     "io.circe" %% "circe-generic-extras" % CirceVersion
   )
 
-  // https://github.com/Log4s/log4s;ASL 2.0
-  val loggingApi = Seq(
-    "org.log4s" %% "log4s" % Log4sVersion
-  )
+  // // https://github.com/Log4s/log4s;ASL 2.0
+  // val loggingApi = Seq(
+  //   "org.log4s" %% "log4s" % Log4sVersion
+  // )
 
-  val logging = Seq(
-    "ch.qos.logback" % "logback-classic" % LogbackVersion
-  )
+  // val logging = Seq(
+  //   "ch.qos.logback" % "logback-classic" % LogbackVersion
+  // )
 
   // https://github.com/melrief/pureconfig
   // MPL 2.0
diff --git a/project/TestSettings.scala b/project/TestSettings.scala
index c1bace05..cf8909e1 100644
--- a/project/TestSettings.scala
+++ b/project/TestSettings.scala
@@ -29,7 +29,7 @@ object TestSettingsPlugin extends AutoPlugin {
   import autoImport._
 
   val testSettings = Seq(
-    libraryDependencies ++= inTest(Dependencies.munit, Dependencies.logging),
+    libraryDependencies ++= inTest(Dependencies.munit, Dependencies.scribe),
     testFrameworks += new TestFramework("munit.Framework")
   )
 

From 9eb9497675d420165515aa2be2fd935b12c677e0 Mon Sep 17 00:00:00 2001
From: eikek <eike.kettner@posteo.de>
Date: Sat, 19 Feb 2022 23:33:01 +0100
Subject: [PATCH 04/10] Fix logging in tests

---
 build.sbt                                     | 12 +++---
 .../StanfordTextClassifierSuite.scala         |  3 +-
 .../analysis/nlp/BaseCRFAnnotatorSuite.scala  |  3 +-
 .../nlp/StanfordNerAnnotatorSuite.scala       |  3 +-
 .../docspell/convert/ConversionTest.scala     |  6 +--
 .../convert/RemovePdfEncryptionTest.scala     |  9 ++--
 .../convert/extern/ExternConvTest.scala       |  6 +--
 .../extract/ocr/TextExtractionSuite.scala     |  3 +-
 .../docspell/extract/odf/OdfExtractTest.scala |  3 +-
 .../extract/pdfbox/PdfMetaDataTest.scala      |  4 +-
 .../extract/pdfbox/PdfboxExtractTest.scala    |  3 +-
 .../extract/pdfbox/PdfboxPreviewTest.scala    |  3 +-
 .../docspell/extract/poi/PoiExtractTest.scala |  3 +-
 .../docspell/extract/rtf/RtfExtractTest.scala |  3 +-
 .../docspell/joex/scheduler/LogSink.scala     | 14 +++---
 .../main/scala/docspell/logging/Logger.scala  |  3 ++
 .../logging/impl/ScribeConfigure.scala        | 22 ++++++++--
 .../docspell/logging/TestLoggingConfig.scala  | 26 +++++++++++
 .../docspell/pubsub/naive/Fixtures.scala      |  2 +-
 .../docspell/pubsub/naive/HttpClientOps.scala |  3 +-
 .../pubsub/naive/NaivePubSubTest.scala        |  5 ++-
 .../store/migrate/H2MigrateTest.scala         |  3 +-
 .../store/migrate/MariaDbMigrateTest.scala    |  6 ++-
 .../store/migrate/PostgresqlMigrateTest.scala |  6 ++-
 .../docspell/store/qb/QueryBuilderTest.scala  |  3 +-
 .../store/qb/impl/SelectBuilderTest.scala     |  3 +-
 .../docspell/store/queries/QJobTest.scala     |  3 +-
 website/site/content/docs/configure/_index.md | 43 +++++--------------
 28 files changed, 130 insertions(+), 76 deletions(-)
 create mode 100644 modules/logging/scribe/src/test/scala/docspell/logging/TestLoggingConfig.scala

diff --git a/build.sbt b/build.sbt
index 791938c1..4f99fcdf 100644
--- a/build.sbt
+++ b/build.sbt
@@ -453,7 +453,7 @@ val store = project
   .in(file("modules/store"))
   .disablePlugins(RevolverPlugin)
   .settings(sharedSettings)
-  .withTestSettings
+  .withTestSettingsDependsOn(loggingScribe)
   .settings(
     name := "docspell-store",
     libraryDependencies ++=
@@ -523,7 +523,7 @@ val extract = project
   .in(file("modules/extract"))
   .disablePlugins(RevolverPlugin)
   .settings(sharedSettings)
-  .withTestSettings
+  .withTestSettingsDependsOn(loggingScribe)
   .settings(
     name := "docspell-extract",
     libraryDependencies ++=
@@ -539,7 +539,7 @@ val convert = project
   .in(file("modules/convert"))
   .disablePlugins(RevolverPlugin)
   .settings(sharedSettings)
-  .withTestSettings
+  .withTestSettingsDependsOn(loggingScribe)
   .settings(
     name := "docspell-convert",
     libraryDependencies ++=
@@ -554,7 +554,7 @@ val analysis = project
   .disablePlugins(RevolverPlugin)
   .enablePlugins(NerModelsPlugin)
   .settings(sharedSettings)
-  .withTestSettings
+  .withTestSettingsDependsOn(loggingScribe)
   .settings(NerModelsPlugin.nerClassifierSettings)
   .settings(
     name := "docspell-analysis",
@@ -636,7 +636,7 @@ val backend = project
   .settings(
     name := "docspell-backend",
     libraryDependencies ++=
-        Dependencies.fs2 ++
+      Dependencies.fs2 ++
         Dependencies.bcrypt ++
         Dependencies.http4sClient ++
         Dependencies.emil
@@ -651,7 +651,7 @@ val oidc = project
   .settings(
     name := "docspell-oidc",
     libraryDependencies ++=
-        Dependencies.fs2 ++
+      Dependencies.fs2 ++
         Dependencies.http4sClient ++
         Dependencies.http4sCirce ++
         Dependencies.http4sDsl ++
diff --git a/modules/analysis/src/test/scala/docspell/analysis/classifier/StanfordTextClassifierSuite.scala b/modules/analysis/src/test/scala/docspell/analysis/classifier/StanfordTextClassifierSuite.scala
index f795aec3..86804836 100644
--- a/modules/analysis/src/test/scala/docspell/analysis/classifier/StanfordTextClassifierSuite.scala
+++ b/modules/analysis/src/test/scala/docspell/analysis/classifier/StanfordTextClassifierSuite.scala
@@ -17,10 +17,11 @@ import fs2.io.file.Files
 
 import docspell.analysis.classifier.TextClassifier.Data
 import docspell.common._
+import docspell.logging.TestLoggingConfig
 
 import munit._
 
-class StanfordTextClassifierSuite extends FunSuite {
+class StanfordTextClassifierSuite extends FunSuite with TestLoggingConfig {
   val logger = docspell.logging.getLogger[IO]
 
   test("learn from data") {
diff --git a/modules/analysis/src/test/scala/docspell/analysis/nlp/BaseCRFAnnotatorSuite.scala b/modules/analysis/src/test/scala/docspell/analysis/nlp/BaseCRFAnnotatorSuite.scala
index 77f665b9..d5d07a46 100644
--- a/modules/analysis/src/test/scala/docspell/analysis/nlp/BaseCRFAnnotatorSuite.scala
+++ b/modules/analysis/src/test/scala/docspell/analysis/nlp/BaseCRFAnnotatorSuite.scala
@@ -10,10 +10,11 @@ import docspell.analysis.Env
 import docspell.common.Language.NLPLanguage
 import docspell.common._
 import docspell.files.TestFiles
+import docspell.logging.TestLoggingConfig
 
 import munit._
 
-class BaseCRFAnnotatorSuite extends FunSuite {
+class BaseCRFAnnotatorSuite extends FunSuite with TestLoggingConfig {
 
   def annotate(language: NLPLanguage): String => Vector[NerLabel] =
     BasicCRFAnnotator.nerAnnotate(BasicCRFAnnotator.Cache.getAnnotator(language))
diff --git a/modules/analysis/src/test/scala/docspell/analysis/nlp/StanfordNerAnnotatorSuite.scala b/modules/analysis/src/test/scala/docspell/analysis/nlp/StanfordNerAnnotatorSuite.scala
index eee0a9c5..d522ec6c 100644
--- a/modules/analysis/src/test/scala/docspell/analysis/nlp/StanfordNerAnnotatorSuite.scala
+++ b/modules/analysis/src/test/scala/docspell/analysis/nlp/StanfordNerAnnotatorSuite.scala
@@ -14,11 +14,12 @@ import cats.effect.unsafe.implicits.global
 import docspell.analysis.Env
 import docspell.common._
 import docspell.files.TestFiles
+import docspell.logging.TestLoggingConfig
 
 import edu.stanford.nlp.pipeline.StanfordCoreNLP
 import munit._
 
-class StanfordNerAnnotatorSuite extends FunSuite {
+class StanfordNerAnnotatorSuite extends FunSuite with TestLoggingConfig {
   lazy val germanClassifier =
     new StanfordCoreNLP(Properties.nerGerman(None, false))
   lazy val englishClassifier =
diff --git a/modules/convert/src/test/scala/docspell/convert/ConversionTest.scala b/modules/convert/src/test/scala/docspell/convert/ConversionTest.scala
index 5538d19b..25905afe 100644
--- a/modules/convert/src/test/scala/docspell/convert/ConversionTest.scala
+++ b/modules/convert/src/test/scala/docspell/convert/ConversionTest.scala
@@ -20,13 +20,13 @@ import docspell.convert.extern.OcrMyPdfConfig
 import docspell.convert.extern.{TesseractConfig, UnoconvConfig, WkHtmlPdfConfig}
 import docspell.convert.flexmark.MarkdownConfig
 import docspell.files.ExampleFiles
-import docspell.logging.{Level, Logger}
+import docspell.logging.TestLoggingConfig
 
 import munit._
 
-class ConversionTest extends FunSuite with FileChecks {
+class ConversionTest extends FunSuite with FileChecks with TestLoggingConfig {
 
-  val logger = Logger.simpleF[IO](System.err, Level.Info)
+  val logger = docspell.logging.getLogger[IO]
   val target = File.path(Paths.get("target"))
 
   val convertConfig = ConvertConfig(
diff --git a/modules/convert/src/test/scala/docspell/convert/RemovePdfEncryptionTest.scala b/modules/convert/src/test/scala/docspell/convert/RemovePdfEncryptionTest.scala
index 7e386c36..a59465f5 100644
--- a/modules/convert/src/test/scala/docspell/convert/RemovePdfEncryptionTest.scala
+++ b/modules/convert/src/test/scala/docspell/convert/RemovePdfEncryptionTest.scala
@@ -11,12 +11,15 @@ import fs2.Stream
 
 import docspell.common._
 import docspell.files.ExampleFiles
-import docspell.logging.{Level, Logger}
+import docspell.logging.{Logger, TestLoggingConfig}
 
 import munit.CatsEffectSuite
 
-class RemovePdfEncryptionTest extends CatsEffectSuite with FileChecks {
-  val logger: Logger[IO] = Logger.simpleF[IO](System.err, Level.Info)
+class RemovePdfEncryptionTest
+    extends CatsEffectSuite
+    with FileChecks
+    with TestLoggingConfig {
+  val logger: Logger[IO] = docspell.logging.getLogger[IO]
 
   private val protectedPdf =
     ExampleFiles.secured_protected_test123_pdf.readURL[IO](16 * 1024)
diff --git a/modules/convert/src/test/scala/docspell/convert/extern/ExternConvTest.scala b/modules/convert/src/test/scala/docspell/convert/extern/ExternConvTest.scala
index 7bf8480b..6f0ab2ab 100644
--- a/modules/convert/src/test/scala/docspell/convert/extern/ExternConvTest.scala
+++ b/modules/convert/src/test/scala/docspell/convert/extern/ExternConvTest.scala
@@ -16,13 +16,13 @@ import fs2.io.file.Path
 import docspell.common._
 import docspell.convert._
 import docspell.files.ExampleFiles
-import docspell.logging.{Level, Logger}
+import docspell.logging.TestLoggingConfig
 
 import munit._
 
-class ExternConvTest extends FunSuite with FileChecks {
+class ExternConvTest extends FunSuite with FileChecks with TestLoggingConfig {
   val utf8 = StandardCharsets.UTF_8
-  val logger = Logger.simpleF[IO](System.err, Level.Info)
+  val logger = docspell.logging.getLogger[IO]
   val target = File.path(Paths.get("target"))
 
   test("convert html to pdf") {
diff --git a/modules/extract/src/test/scala/docspell/extract/ocr/TextExtractionSuite.scala b/modules/extract/src/test/scala/docspell/extract/ocr/TextExtractionSuite.scala
index a21c5438..71d55ad8 100644
--- a/modules/extract/src/test/scala/docspell/extract/ocr/TextExtractionSuite.scala
+++ b/modules/extract/src/test/scala/docspell/extract/ocr/TextExtractionSuite.scala
@@ -10,10 +10,11 @@ import cats.effect.IO
 import cats.effect.unsafe.implicits.global
 
 import docspell.files.TestFiles
+import docspell.logging.TestLoggingConfig
 
 import munit._
 
-class TextExtractionSuite extends FunSuite {
+class TextExtractionSuite extends FunSuite with TestLoggingConfig {
   import TestFiles._
 
   val logger = docspell.logging.getLogger[IO]
diff --git a/modules/extract/src/test/scala/docspell/extract/odf/OdfExtractTest.scala b/modules/extract/src/test/scala/docspell/extract/odf/OdfExtractTest.scala
index c2dd5089..f6d36e45 100644
--- a/modules/extract/src/test/scala/docspell/extract/odf/OdfExtractTest.scala
+++ b/modules/extract/src/test/scala/docspell/extract/odf/OdfExtractTest.scala
@@ -10,10 +10,11 @@ import cats.effect._
 import cats.effect.unsafe.implicits.global
 
 import docspell.files.ExampleFiles
+import docspell.logging.TestLoggingConfig
 
 import munit._
 
-class OdfExtractTest extends FunSuite {
+class OdfExtractTest extends FunSuite with TestLoggingConfig {
 
   val files = List(
     ExampleFiles.examples_sample_odt -> 6367,
diff --git a/modules/extract/src/test/scala/docspell/extract/pdfbox/PdfMetaDataTest.scala b/modules/extract/src/test/scala/docspell/extract/pdfbox/PdfMetaDataTest.scala
index 4d2748ca..24fff241 100644
--- a/modules/extract/src/test/scala/docspell/extract/pdfbox/PdfMetaDataTest.scala
+++ b/modules/extract/src/test/scala/docspell/extract/pdfbox/PdfMetaDataTest.scala
@@ -6,9 +6,11 @@
 
 package docspell.extract.pdfbox
 
+import docspell.logging.TestLoggingConfig
+
 import munit._
 
-class PdfMetaDataTest extends FunSuite {
+class PdfMetaDataTest extends FunSuite with TestLoggingConfig {
 
   test("split keywords on comma") {
     val md = PdfMetaData.empty.copy(keywords = Some("a,b, c"))
diff --git a/modules/extract/src/test/scala/docspell/extract/pdfbox/PdfboxExtractTest.scala b/modules/extract/src/test/scala/docspell/extract/pdfbox/PdfboxExtractTest.scala
index 1e46bf69..db47476c 100644
--- a/modules/extract/src/test/scala/docspell/extract/pdfbox/PdfboxExtractTest.scala
+++ b/modules/extract/src/test/scala/docspell/extract/pdfbox/PdfboxExtractTest.scala
@@ -10,10 +10,11 @@ import cats.effect._
 import cats.effect.unsafe.implicits.global
 
 import docspell.files.{ExampleFiles, TestFiles}
+import docspell.logging.TestLoggingConfig
 
 import munit._
 
-class PdfboxExtractTest extends FunSuite {
+class PdfboxExtractTest extends FunSuite with TestLoggingConfig {
 
   val textPDFs = List(
     ExampleFiles.letter_de_pdf -> TestFiles.letterDEText,
diff --git a/modules/extract/src/test/scala/docspell/extract/pdfbox/PdfboxPreviewTest.scala b/modules/extract/src/test/scala/docspell/extract/pdfbox/PdfboxPreviewTest.scala
index cae614fb..fa8f916a 100644
--- a/modules/extract/src/test/scala/docspell/extract/pdfbox/PdfboxPreviewTest.scala
+++ b/modules/extract/src/test/scala/docspell/extract/pdfbox/PdfboxPreviewTest.scala
@@ -13,10 +13,11 @@ import fs2.io.file.Files
 import fs2.io.file.Path
 
 import docspell.files.ExampleFiles
+import docspell.logging.TestLoggingConfig
 
 import munit._
 
-class PdfboxPreviewTest extends FunSuite {
+class PdfboxPreviewTest extends FunSuite with TestLoggingConfig {
 
   val testPDFs = List(
     ExampleFiles.letter_de_pdf -> "7d98be75b239816d6c751b3f3c56118ebf1a4632c43baf35a68a662f9d595ab8",
diff --git a/modules/extract/src/test/scala/docspell/extract/poi/PoiExtractTest.scala b/modules/extract/src/test/scala/docspell/extract/poi/PoiExtractTest.scala
index 52ef15e5..d0ff4dcc 100644
--- a/modules/extract/src/test/scala/docspell/extract/poi/PoiExtractTest.scala
+++ b/modules/extract/src/test/scala/docspell/extract/poi/PoiExtractTest.scala
@@ -11,10 +11,11 @@ import cats.effect.unsafe.implicits.global
 
 import docspell.common.MimeTypeHint
 import docspell.files.ExampleFiles
+import docspell.logging.TestLoggingConfig
 
 import munit._
 
-class PoiExtractTest extends FunSuite {
+class PoiExtractTest extends FunSuite with TestLoggingConfig {
 
   val officeFiles = List(
     ExampleFiles.examples_sample_doc -> 6241,
diff --git a/modules/extract/src/test/scala/docspell/extract/rtf/RtfExtractTest.scala b/modules/extract/src/test/scala/docspell/extract/rtf/RtfExtractTest.scala
index b277e29e..0cc12aa1 100644
--- a/modules/extract/src/test/scala/docspell/extract/rtf/RtfExtractTest.scala
+++ b/modules/extract/src/test/scala/docspell/extract/rtf/RtfExtractTest.scala
@@ -7,10 +7,11 @@
 package docspell.extract.rtf
 
 import docspell.files.ExampleFiles
+import docspell.logging.TestLoggingConfig
 
 import munit._
 
-class RtfExtractTest extends FunSuite {
+class RtfExtractTest extends FunSuite with TestLoggingConfig {
 
   test("extract text from rtf using java input-stream") {
     val file = ExampleFiles.examples_sample_rtf
diff --git a/modules/joex/src/main/scala/docspell/joex/scheduler/LogSink.scala b/modules/joex/src/main/scala/docspell/joex/scheduler/LogSink.scala
index ce0d074c..bf01a050 100644
--- a/modules/joex/src/main/scala/docspell/joex/scheduler/LogSink.scala
+++ b/modules/joex/src/main/scala/docspell/joex/scheduler/LogSink.scala
@@ -11,6 +11,7 @@ import cats.implicits._
 import fs2.Pipe
 
 import docspell.common._
+import docspell.logging
 import docspell.store.Store
 import docspell.store.records.RJobLog
 
@@ -29,19 +30,22 @@ object LogSink {
 
   def logInternal[F[_]: Sync](e: LogEvent): F[Unit] = {
     val logger = docspell.logging.getLogger[F]
+    val addData: logging.LogEvent => logging.LogEvent =
+      _.data("jobId", e.jobId).data("jobInfo", e.jobInfo)
+
     e.level match {
       case LogLevel.Info =>
-        logger.info(e.logLine)
+        logger.infoWith(e.logLine)(addData)
       case LogLevel.Debug =>
-        logger.debug(e.logLine)
+        logger.debugWith(e.logLine)(addData)
       case LogLevel.Warn =>
-        logger.warn(e.logLine)
+        logger.warnWith(e.logLine)(addData)
       case LogLevel.Error =>
         e.ex match {
           case Some(exc) =>
-            logger.error(exc)(e.logLine)
+            logger.errorWith(e.logLine)(addData.andThen(_.addError(exc)))
           case None =>
-            logger.error(e.logLine)
+            logger.errorWith(e.logLine)(addData)
         }
     }
   }
diff --git a/modules/logging/api/src/main/scala/docspell/logging/Logger.scala b/modules/logging/api/src/main/scala/docspell/logging/Logger.scala
index 05db734b..18359294 100644
--- a/modules/logging/api/src/main/scala/docspell/logging/Logger.scala
+++ b/modules/logging/api/src/main/scala/docspell/logging/Logger.scala
@@ -163,4 +163,7 @@ object Logger {
 
       val asUnsafe = simple(ps, minimumLevel)
     }
+
+  def simpleDefault[F[_]: Sync](minimumLevel: Level = Level.Info): Logger[F] =
+    simpleF[F](System.err, minimumLevel)
 }
diff --git a/modules/logging/scribe/src/main/scala/docspell/logging/impl/ScribeConfigure.scala b/modules/logging/scribe/src/main/scala/docspell/logging/impl/ScribeConfigure.scala
index f975d1c8..bcaaa4de 100644
--- a/modules/logging/scribe/src/main/scala/docspell/logging/impl/ScribeConfigure.scala
+++ b/modules/logging/scribe/src/main/scala/docspell/logging/impl/ScribeConfigure.scala
@@ -8,21 +8,37 @@ package docspell.logging.impl
 
 import cats.effect.Sync
 
-import docspell.logging.LogConfig
 import docspell.logging.LogConfig.Format
+import docspell.logging.{Level, LogConfig}
 
 import scribe.format.Formatter
 import scribe.jul.JULHandler
 import scribe.writer.ConsoleWriter
 
 object ScribeConfigure {
+  private[this] val docspellRootVerbose = "DOCSPELL_ROOT_LOGGER_LEVEL"
 
   def configure[F[_]: Sync](cfg: LogConfig): F[Unit] =
     Sync[F].delay {
       replaceJUL()
-      unsafeConfigure(scribe.Logger.root, cfg)
+      val docspellLogger = scribe.Logger("docspell")
+      unsafeConfigure(scribe.Logger.root, cfg.copy(minimumLevel = getRootMinimumLevel))
+      unsafeConfigure(docspellLogger, cfg)
     }
 
+  private[this] def getRootMinimumLevel: Level =
+    Option(System.getenv(docspellRootVerbose))
+      .map(Level.fromString)
+      .flatMap {
+        case Right(level) => Some(level)
+        case Left(err) =>
+          scribe.warn(
+            s"Environment variable '$docspellRootVerbose' has invalid value: $err"
+          )
+          None
+      }
+      .getOrElse(Level.Error)
+
   def unsafeConfigure(logger: scribe.Logger, cfg: LogConfig): Unit = {
     val mods = List[scribe.Logger => scribe.Logger](
       _.clearHandlers(),
@@ -45,7 +61,7 @@ object ScribeConfigure {
     ()
   }
 
-  def replaceJUL(): Unit = {
+  private def replaceJUL(): Unit = {
     scribe.Logger.system // just to load effects in Logger singleton
     val julRoot = java.util.logging.LogManager.getLogManager.getLogger("")
     julRoot.getHandlers.foreach(julRoot.removeHandler)
diff --git a/modules/logging/scribe/src/test/scala/docspell/logging/TestLoggingConfig.scala b/modules/logging/scribe/src/test/scala/docspell/logging/TestLoggingConfig.scala
new file mode 100644
index 00000000..5556c040
--- /dev/null
+++ b/modules/logging/scribe/src/test/scala/docspell/logging/TestLoggingConfig.scala
@@ -0,0 +1,26 @@
+/*
+ * Copyright 2020 Eike K. & Contributors
+ *
+ * SPDX-License-Identifier: AGPL-3.0-or-later
+ */
+
+package docspell.logging
+
+import docspell.logging.impl.ScribeConfigure
+
+import munit.Suite
+
+trait TestLoggingConfig extends Suite {
+  def docspellLogConfig: LogConfig = LogConfig(Level.Warn, LogConfig.Format.Fancy)
+  def rootMinimumLevel: Level = Level.Error
+
+  override def beforeAll(): Unit = {
+    super.beforeAll()
+    val docspellLogger = scribe.Logger("docspell")
+    ScribeConfigure.unsafeConfigure(docspellLogger, docspellLogConfig)
+    val rootCfg = docspellLogConfig.copy(minimumLevel = rootMinimumLevel)
+    ScribeConfigure.unsafeConfigure(scribe.Logger.root, rootCfg)
+    ()
+  }
+
+}
diff --git a/modules/pubsub/naive/src/test/scala/docspell/pubsub/naive/Fixtures.scala b/modules/pubsub/naive/src/test/scala/docspell/pubsub/naive/Fixtures.scala
index 078435c7..848fc387 100644
--- a/modules/pubsub/naive/src/test/scala/docspell/pubsub/naive/Fixtures.scala
+++ b/modules/pubsub/naive/src/test/scala/docspell/pubsub/naive/Fixtures.scala
@@ -46,7 +46,7 @@ trait Fixtures extends HttpClientOps { self: CatsEffectSuite =>
 }
 
 object Fixtures {
-  private val loggerIO: Logger[IO] = docspell.logging.getLogger[IO]
+  private val loggerIO: Logger[IO] = Logger.simpleDefault[IO]()
 
   final case class Env(store: Store[IO], cfg: PubSubConfig) {
     def pubSub: Resource[IO, NaivePubSub[IO]] = {
diff --git a/modules/pubsub/naive/src/test/scala/docspell/pubsub/naive/HttpClientOps.scala b/modules/pubsub/naive/src/test/scala/docspell/pubsub/naive/HttpClientOps.scala
index 4939936f..30084d0b 100644
--- a/modules/pubsub/naive/src/test/scala/docspell/pubsub/naive/HttpClientOps.scala
+++ b/modules/pubsub/naive/src/test/scala/docspell/pubsub/naive/HttpClientOps.scala
@@ -9,6 +9,7 @@ package docspell.pubsub.naive
 import cats.effect._
 
 import docspell.common._
+import docspell.logging.Logger
 import docspell.pubsub.api._
 
 import io.circe.Encoder
@@ -55,5 +56,5 @@ trait HttpClientOps {
 }
 
 object HttpClientOps {
-  private val logger = docspell.logging.getLogger[IO]
+  private val logger = Logger.simpleDefault[IO]()
 }
diff --git a/modules/pubsub/naive/src/test/scala/docspell/pubsub/naive/NaivePubSubTest.scala b/modules/pubsub/naive/src/test/scala/docspell/pubsub/naive/NaivePubSubTest.scala
index 64922cf3..bdcb45a9 100644
--- a/modules/pubsub/naive/src/test/scala/docspell/pubsub/naive/NaivePubSubTest.scala
+++ b/modules/pubsub/naive/src/test/scala/docspell/pubsub/naive/NaivePubSubTest.scala
@@ -12,13 +12,14 @@ import cats.effect._
 import cats.implicits._
 import fs2.concurrent.SignallingRef
 
+import docspell.logging.{Logger, TestLoggingConfig}
 import docspell.pubsub.api._
 import docspell.pubsub.naive.Topics._
 
 import munit.CatsEffectSuite
 
-class NaivePubSubTest extends CatsEffectSuite with Fixtures {
-  private[this] val logger = docspell.logging.getLogger[IO]
+class NaivePubSubTest extends CatsEffectSuite with Fixtures with TestLoggingConfig {
+  private[this] val logger = Logger.simpleDefault[IO]()
 
   def subscribe[A](ps: PubSubT[IO], topic: TypedTopic[A]) =
     for {
diff --git a/modules/store/src/test/scala/docspell/store/migrate/H2MigrateTest.scala b/modules/store/src/test/scala/docspell/store/migrate/H2MigrateTest.scala
index 569f6b0f..df03453f 100644
--- a/modules/store/src/test/scala/docspell/store/migrate/H2MigrateTest.scala
+++ b/modules/store/src/test/scala/docspell/store/migrate/H2MigrateTest.scala
@@ -9,11 +9,12 @@ package docspell.store.migrate
 import cats.effect.IO
 import cats.effect.unsafe.implicits._
 
+import docspell.logging.TestLoggingConfig
 import docspell.store.StoreFixture
 
 import munit.FunSuite
 
-class H2MigrateTest extends FunSuite {
+class H2MigrateTest extends FunSuite with TestLoggingConfig {
 
   test("h2 empty schema migration") {
     val jdbc = StoreFixture.memoryDB("h2test")
diff --git a/modules/store/src/test/scala/docspell/store/migrate/MariaDbMigrateTest.scala b/modules/store/src/test/scala/docspell/store/migrate/MariaDbMigrateTest.scala
index 76d443fd..321a1b4d 100644
--- a/modules/store/src/test/scala/docspell/store/migrate/MariaDbMigrateTest.scala
+++ b/modules/store/src/test/scala/docspell/store/migrate/MariaDbMigrateTest.scala
@@ -10,6 +10,7 @@ import cats.effect._
 import cats.effect.unsafe.implicits._
 
 import docspell.common.LenientUri
+import docspell.logging.TestLoggingConfig
 import docspell.store.JdbcConfig
 
 import com.dimafeng.testcontainers.MariaDBContainer
@@ -17,7 +18,10 @@ import com.dimafeng.testcontainers.munit.TestContainerForAll
 import munit._
 import org.testcontainers.utility.DockerImageName
 
-class MariaDbMigrateTest extends FunSuite with TestContainerForAll {
+class MariaDbMigrateTest
+    extends FunSuite
+    with TestContainerForAll
+    with TestLoggingConfig {
   override val containerDef: MariaDBContainer.Def =
     MariaDBContainer.Def(DockerImageName.parse("mariadb:10.5"))
 
diff --git a/modules/store/src/test/scala/docspell/store/migrate/PostgresqlMigrateTest.scala b/modules/store/src/test/scala/docspell/store/migrate/PostgresqlMigrateTest.scala
index 1125f69a..9decab2f 100644
--- a/modules/store/src/test/scala/docspell/store/migrate/PostgresqlMigrateTest.scala
+++ b/modules/store/src/test/scala/docspell/store/migrate/PostgresqlMigrateTest.scala
@@ -10,6 +10,7 @@ import cats.effect._
 import cats.effect.unsafe.implicits._
 
 import docspell.common.LenientUri
+import docspell.logging.TestLoggingConfig
 import docspell.store.JdbcConfig
 
 import com.dimafeng.testcontainers.PostgreSQLContainer
@@ -17,7 +18,10 @@ import com.dimafeng.testcontainers.munit.TestContainerForAll
 import munit._
 import org.testcontainers.utility.DockerImageName
 
-class PostgresqlMigrateTest extends FunSuite with TestContainerForAll {
+class PostgresqlMigrateTest
+    extends FunSuite
+    with TestContainerForAll
+    with TestLoggingConfig {
   override val containerDef: PostgreSQLContainer.Def =
     PostgreSQLContainer.Def(DockerImageName.parse("postgres:13"))
 
diff --git a/modules/store/src/test/scala/docspell/store/qb/QueryBuilderTest.scala b/modules/store/src/test/scala/docspell/store/qb/QueryBuilderTest.scala
index d42d263f..a36afeff 100644
--- a/modules/store/src/test/scala/docspell/store/qb/QueryBuilderTest.scala
+++ b/modules/store/src/test/scala/docspell/store/qb/QueryBuilderTest.scala
@@ -6,12 +6,13 @@
 
 package docspell.store.qb
 
+import docspell.logging.TestLoggingConfig
 import docspell.store.qb.DSL._
 import docspell.store.qb.model._
 
 import munit._
 
-class QueryBuilderTest extends FunSuite {
+class QueryBuilderTest extends FunSuite with TestLoggingConfig {
 
   test("simple") {
     val c = CourseRecord.as("c")
diff --git a/modules/store/src/test/scala/docspell/store/qb/impl/SelectBuilderTest.scala b/modules/store/src/test/scala/docspell/store/qb/impl/SelectBuilderTest.scala
index 55a8f601..0ba3c7a5 100644
--- a/modules/store/src/test/scala/docspell/store/qb/impl/SelectBuilderTest.scala
+++ b/modules/store/src/test/scala/docspell/store/qb/impl/SelectBuilderTest.scala
@@ -6,13 +6,14 @@
 
 package docspell.store.qb.impl
 
+import docspell.logging.TestLoggingConfig
 import docspell.store.qb.DSL._
 import docspell.store.qb._
 import docspell.store.qb.model._
 
 import munit._
 
-class SelectBuilderTest extends FunSuite {
+class SelectBuilderTest extends FunSuite with TestLoggingConfig {
 
   test("basic fragment") {
     val c = CourseRecord.as("c")
diff --git a/modules/store/src/test/scala/docspell/store/queries/QJobTest.scala b/modules/store/src/test/scala/docspell/store/queries/QJobTest.scala
index cd439777..8c60f240 100644
--- a/modules/store/src/test/scala/docspell/store/queries/QJobTest.scala
+++ b/modules/store/src/test/scala/docspell/store/queries/QJobTest.scala
@@ -12,6 +12,7 @@ import java.util.concurrent.atomic.AtomicLong
 import cats.implicits._
 
 import docspell.common._
+import docspell.logging.TestLoggingConfig
 import docspell.store.StoreFixture
 import docspell.store.records.RJob
 import docspell.store.records.RJobGroupUse
@@ -19,7 +20,7 @@ import docspell.store.records.RJobGroupUse
 import doobie.implicits._
 import munit._
 
-class QJobTest extends CatsEffectSuite with StoreFixture {
+class QJobTest extends CatsEffectSuite with StoreFixture with TestLoggingConfig {
   private[this] val c = new AtomicLong(0)
 
   private val worker = Ident.unsafe("joex1")
diff --git a/website/site/content/docs/configure/_index.md b/website/site/content/docs/configure/_index.md
index 932b39e7..b57cf1ea 100644
--- a/website/site/content/docs/configure/_index.md
+++ b/website/site/content/docs/configure/_index.md
@@ -604,41 +604,18 @@ Please have a look at the corresponding [section](@/docs/configure/_index.md#mem
 # Logging
 
 By default, docspell logs to stdout. This works well, when managed by
-systemd or other inits. Logging is done by
-[logback](https://logback.qos.ch/). Please refer to its documentation
-for how to configure logging.
+systemd or other inits. Logging can be configured in the configuration
+file or via environment variables. There are only two settings:
 
-If you created your logback config file, it can be added as argument
-to the executable using this syntax:
-
-``` bash
-/path/to/docspell -Dlogback.configurationFile=/path/to/your/logging-config-file
-```
-
-To get started, the default config looks like this:
-
-``` xml
-<configuration>
-  <appender name="STDOUT" class="ch.qos.logback.core.ConsoleAppender">
-    <withJansi>true</withJansi>
-
-    <encoder>
-      <pattern>[%thread] %highlight(%-5level) %cyan(%logger{15}) - %msg %n</pattern>
-    </encoder>
-  </appender>
-
-  <logger name="docspell" level="debug" />
-  <root level="INFO">
-    <appender-ref ref="STDOUT" />
-  </root>
-</configuration>
-```
-
-The `<root level="INFO">` means, that only log statements with level
-"INFO" will be printed. But the `<logger name="docspell"
-level="debug">` above says, that for loggers with name "docspell"
-statements with level "DEBUG" will be printed, too.
+- `minimum-level` specifies the log level to control the verbosity.
+  Levels are ordered from: *Trace*, *Debug*, *Info*, *Warn* and
+  *Error*
+- `format` this defines how the logs are formatted. There are two
+  formats for humans: *Plain* and *Fancy*. And two more suited for
+  machine consumption: *Json* and *Logfmt*. The *Json* format contains
+  all details, while the others may omit some for readability
 
+These settings are the same for joex and the restserver component.
 
 # Default Config
 ## Rest Server

From 773b4181d6180c1ec74ebce905588677aedbdfd1 Mon Sep 17 00:00:00 2001
From: eikek <eike.kettner@posteo.de>
Date: Sat, 19 Feb 2022 23:37:45 +0100
Subject: [PATCH 05/10] Add new config to nix modules

---
 nix/module-joex.nix   | 24 ++++++++++++++++++++++++
 nix/module-server.nix | 23 +++++++++++++++++++++++
 2 files changed, 47 insertions(+)

diff --git a/nix/module-joex.nix b/nix/module-joex.nix
index d9285bee..99387dd1 100644
--- a/nix/module-joex.nix
+++ b/nix/module-joex.nix
@@ -16,6 +16,10 @@ let
       address = "localhost";
       port = 7878;
     };
+    logging = {
+      minimum-level = "Info";
+      format = "Plain";
+    };
     mail-debug = false;
     jdbc = {
       url = "jdbc:h2:///tmp/docspell-demo.db;MODE=PostgreSQL;DATABASE_TO_LOWER=TRUE;AUTO_SERVER=TRUE";
@@ -286,6 +290,26 @@ in {
         default = defaults.bind;
         description = "Address and port bind the rest server.";
       };
+
+      logging = mkOption {
+        type = types.submodule({
+          options = {
+            minimum-level = mkOption {
+              type = types.str;
+              default = defaults.logging.minimum-level;
+              description = "The minimum level for logging to control verbosity.";
+            };
+            format = mkOption {
+              type = types.str;
+              default = defaults.logging.format;
+              description = "The log format. One of: Fancy, Plain, Json or Logfmt";
+            };
+          };
+        });
+        default = defaults.logging;
+        description = "Settings for logging";
+      };
+
       mail-debug = mkOption {
         type = types.bool;
         default = defaults.mail-debug;
diff --git a/nix/module-server.nix b/nix/module-server.nix
index a8a886f8..f0413a4f 100644
--- a/nix/module-server.nix
+++ b/nix/module-server.nix
@@ -21,6 +21,10 @@ let
       address = "localhost";
       port = 7880;
     };
+    logging = {
+      minimum-level = "Info";
+      format = "Plain";
+    };
     integration-endpoint = {
       enabled = false;
       priority = "low";
@@ -210,6 +214,25 @@ in {
         description = "Address and port bind the rest server.";
       };
 
+      logging = mkOption {
+        type = types.submodule({
+          options = {
+            minimum-level = mkOption {
+              type = types.str;
+              default = defaults.logging.minimum-level;
+              description = "The minimum level for logging to control verbosity.";
+            };
+            format = mkOption {
+              type = types.str;
+              default = defaults.logging.format;
+              description = "The log format. One of: Fancy, Plain, Json or Logfmt";
+            };
+          };
+        });
+        default = defaults.logging;
+        description = "Settings for logging";
+      };
+
       auth = mkOption {
         type = types.submodule({
           options = {

From 99329805ad7ee44399b11a2e0615ad3243c527e5 Mon Sep 17 00:00:00 2001
From: eikek <eike.kettner@posteo.de>
Date: Sun, 20 Feb 2022 00:14:17 +0100
Subject: [PATCH 06/10] Always log to stdout

---
 .../logging/impl/ScribeConfigure.scala        |  9 ++-
 .../docspell/logging/impl/StdoutWriter.scala  | 56 +++++++++++++++++++
 2 files changed, 60 insertions(+), 5 deletions(-)
 create mode 100644 modules/logging/scribe/src/main/scala/docspell/logging/impl/StdoutWriter.scala

diff --git a/modules/logging/scribe/src/main/scala/docspell/logging/impl/ScribeConfigure.scala b/modules/logging/scribe/src/main/scala/docspell/logging/impl/ScribeConfigure.scala
index bcaaa4de..1b8b6c79 100644
--- a/modules/logging/scribe/src/main/scala/docspell/logging/impl/ScribeConfigure.scala
+++ b/modules/logging/scribe/src/main/scala/docspell/logging/impl/ScribeConfigure.scala
@@ -13,7 +13,6 @@ import docspell.logging.{Level, LogConfig}
 
 import scribe.format.Formatter
 import scribe.jul.JULHandler
-import scribe.writer.ConsoleWriter
 
 object ScribeConfigure {
   private[this] val docspellRootVerbose = "DOCSPELL_ROOT_LOGGER_LEVEL"
@@ -46,13 +45,13 @@ object ScribeConfigure {
       l =>
         cfg.format match {
           case Format.Fancy =>
-            l.withHandler(formatter = Formatter.enhanced)
+            l.withHandler(formatter = Formatter.enhanced, writer = StdoutWriter)
           case Format.Plain =>
-            l.withHandler(formatter = Formatter.classic)
+            l.withHandler(formatter = Formatter.classic, writer = StdoutWriter)
           case Format.Json =>
-            l.withHandler(writer = JsonWriter(ConsoleWriter))
+            l.withHandler(writer = JsonWriter(StdoutWriter))
           case Format.Logfmt =>
-            l.withHandler(writer = LogfmtWriter(ConsoleWriter))
+            l.withHandler(writer = LogfmtWriter(StdoutWriter))
         },
       _.replace()
     )
diff --git a/modules/logging/scribe/src/main/scala/docspell/logging/impl/StdoutWriter.scala b/modules/logging/scribe/src/main/scala/docspell/logging/impl/StdoutWriter.scala
new file mode 100644
index 00000000..ba79d892
--- /dev/null
+++ b/modules/logging/scribe/src/main/scala/docspell/logging/impl/StdoutWriter.scala
@@ -0,0 +1,56 @@
+/*
+ * Copyright 2020 Eike K. & Contributors
+ *
+ * SPDX-License-Identifier: AGPL-3.0-or-later
+ */
+
+package docspell.logging.impl
+
+import scribe._
+import scribe.output.LogOutput
+import scribe.output.format.OutputFormat
+import scribe.writer.Writer
+
+// From: https://github.com/outr/scribe/blob/8e99521e1ee1f0c421629764dd96e4eb193d84bd/core/shared/src/main/scala/scribe/writer/SystemOutputWriter.scala
+// Modified to always log to stdout. The original code was logging to stdout and stderr
+// depending on the log level.
+// Original code licensed under MIT
+
+private[impl] object StdoutWriter extends Writer {
+
+  /** If true, will always synchronize writing to the console to avoid interleaved text.
+    * Most native consoles will handle this automatically, but IntelliJ and Eclipse are
+    * notorious about not properly handling this. Defaults to true.
+    */
+  val synchronizeWriting: Boolean = true
+
+  /** Workaround for some consoles that don't play nicely with asynchronous calls */
+  val alwaysFlush: Boolean = false
+
+  private val stringBuilders = new ThreadLocal[StringBuilder] {
+    override def initialValue(): StringBuilder = new StringBuilder(512)
+  }
+
+  @annotation.nowarn
+  override def write[M](
+      record: LogRecord[M],
+      output: LogOutput,
+      outputFormat: OutputFormat
+  ): Unit = {
+    val stream = Logger.system.out
+    val sb = stringBuilders.get()
+    outputFormat.begin(sb.append(_))
+    outputFormat(output, s => sb.append(s))
+    outputFormat.end(sb.append(_))
+    if (synchronizeWriting) {
+      synchronized {
+        stream.println(sb.toString())
+        if (alwaysFlush) stream.flush()
+      }
+    } else {
+      stream.println(sb.toString())
+      if (alwaysFlush) stream.flush()
+    }
+    sb.clear()
+  }
+}

From a98991f85f74ffb417143bf8b18f39ba448a0bf1 Mon Sep 17 00:00:00 2001
From: Scala Steward <me@scala-steward.org>
Date: Mon, 21 Feb 2022 20:21:04 +0100
Subject: [PATCH 07/10] Update sbt-native-packager to 1.9.9

---
 project/plugins.sbt | 2 +-
 1 file changed, 1 insertion(+), 1 deletion(-)

diff --git a/project/plugins.sbt b/project/plugins.sbt
index b0c4ba13..834d5717 100644
--- a/project/plugins.sbt
+++ b/project/plugins.sbt
@@ -4,7 +4,7 @@ addSbtPlugin("com.github.eikek" % "sbt-openapi-schema" % "0.9.0")
 addSbtPlugin("com.github.sbt" % "sbt-pgp" % "2.1.2")
 addSbtPlugin("com.github.sbt" % "sbt-release" % "1.1.0")
 addSbtPlugin("com.typesafe.sbt" % "sbt-git" % "1.0.2")
-addSbtPlugin("com.github.sbt" % "sbt-native-packager" % "1.9.8")
+addSbtPlugin("com.github.sbt" % "sbt-native-packager" % "1.9.9")
 addSbtPlugin("de.heikoseeberger" % "sbt-header" % "5.6.5")
 addSbtPlugin("io.kevinlee" % "sbt-github-pages" % "0.8.1")
 addSbtPlugin("io.spray" % "sbt-revolver" % "0.9.1")

From 1b1dd0687aeec15afff07e2b3768536cab04e0a4 Mon Sep 17 00:00:00 2001
From: Scala Steward <me@scala-steward.org>
Date: Mon, 21 Feb 2022 20:21:12 +0100
Subject: [PATCH 08/10] Update scribe, scribe-slf4j to 3.7.1

---
 project/Dependencies.scala | 2 +-
 1 file changed, 1 insertion(+), 1 deletion(-)

diff --git a/project/Dependencies.scala b/project/Dependencies.scala
index fb67e109..28bf6aeb 100644
--- a/project/Dependencies.scala
+++ b/project/Dependencies.scala
@@ -42,7 +42,7 @@ object Dependencies {
   val PureConfigVersion = "0.17.1"
   val ScalaJavaTimeVersion = "2.3.0"
   val ScodecBitsVersion = "1.1.30"
-  val ScribeVersion = "3.7.0"
+  val ScribeVersion = "3.7.1"
   val Slf4jVersion = "1.7.36"
   val SourcecodeVersion = "0.2.8"
   val StanfordNlpVersion = "4.4.0"

From c80ae8366432a1528b40d0e3f01d2c98f82d715d Mon Sep 17 00:00:00 2001
From: eikek <eike.kettner@posteo.de>
Date: Mon, 21 Feb 2022 20:55:53 +0100
Subject: [PATCH 09/10] Compare zip file exstension case insensitive

Some other filetypes, like office documents, are also zip file. To
distinguish these without unpacking them, the file extensions is
checked.

Fixes: #1365
---
 .../src/main/scala/docspell/joex/process/ExtractArchive.scala   | 2 +-
 1 file changed, 1 insertion(+), 1 deletion(-)

diff --git a/modules/joex/src/main/scala/docspell/joex/process/ExtractArchive.scala b/modules/joex/src/main/scala/docspell/joex/process/ExtractArchive.scala
index ef98b43d..17f90b59 100644
--- a/modules/joex/src/main/scala/docspell/joex/process/ExtractArchive.scala
+++ b/modules/joex/src/main/scala/docspell/joex/process/ExtractArchive.scala
@@ -93,7 +93,7 @@ object ExtractArchive {
       archive: Option[RAttachmentArchive]
   )(ra: RAttachment, pos: Int, mime: MimeType): F[Extracted] =
     mime match {
-      case MimeType.ZipMatch(_) if ra.name.exists(_.endsWith(".zip")) =>
+      case MimeType.ZipMatch(_) if ra.name.exists(_.toLowerCase.endsWith(".zip")) =>
         ctx.logger.info(s"Extracting zip archive ${ra.name.getOrElse("<noname>")}.") *>
           extractZip(ctx, archive)(ra, pos)
             .flatMap(cleanupParents(ctx, ra, archive))

From 79d29229ae9f8aa2f122b8dd2896763780fc3c9c Mon Sep 17 00:00:00 2001
From: eikek <eike.kettner@posteo.de>
Date: Mon, 21 Feb 2022 22:52:39 +0100
Subject: [PATCH 10/10] Add more breakpoints and increase card column count

Refs: #1401
---
 modules/webapp/src/main/elm/Comp/ItemCardList.elm |  4 +++-
 modules/webapp/tailwind.config.js                 | 11 +++++++++++
 2 files changed, 14 insertions(+), 1 deletion(-)

diff --git a/modules/webapp/src/main/elm/Comp/ItemCardList.elm b/modules/webapp/src/main/elm/Comp/ItemCardList.elm
index 7899ac7c..f9f2967b 100644
--- a/modules/webapp/src/main/elm/Comp/ItemCardList.elm
+++ b/modules/webapp/src/main/elm/Comp/ItemCardList.elm
@@ -208,7 +208,9 @@ itemContainerCss : ViewConfig -> String
 itemContainerCss cfg =
     case cfg.arrange of
         Data.ItemArrange.Cards ->
-            "grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 2xl:grid-cols-4 gap-2"
+            "grid grid-cols-1 sm:grid-cols-2 md:grid-cols-2 lg:grid-cols-3 "
+                ++ "xl:grid-cols-3 2xl:grid-cols-4 3xl:grid-cols-5 4xl:grid-cols-6 "
+                ++ "5xl:grid-cols-8 6xl:grid-cols-10 gap-2"
 
         Data.ItemArrange.List ->
             "flex flex-col divide-y"
diff --git a/modules/webapp/tailwind.config.js b/modules/webapp/tailwind.config.js
index 5b8acf26..3e25d8b8 100644
--- a/modules/webapp/tailwind.config.js
+++ b/modules/webapp/tailwind.config.js
@@ -8,6 +8,17 @@ module.exports = {
                "./src/main/styles/keep.txt",
                "../restserver/src/main/templates/*.html"
              ],
+    theme: {
+        extend: {
+            screens: {
+                '3xl': '1792px',
+                '4xl': '2048px',
+                '5xl': '2560px',
+                '6xl': '3072px',
+                '7xl': '3584px'
+            }
+        }
+    },
     variants: {
         extend: {
             backgroundOpacity: ['dark']