diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml
index d20889fdca1..a0fc1ce4fec 100644
--- a/.github/workflows/build.yml
+++ b/.github/workflows/build.yml
@@ -61,6 +61,17 @@ jobs:
- name: Build
# executing ITs requires installing artifacts to the local repository
run: mvn -B ${{ env.MVN_GOAL }} ${{ env.MVN_ADDITIONAL_OPTS }} -Pcoverage,integrationTesting,javadoc -Dnsfixtures=SEGMENT_TAR,DOCUMENT_NS
+ - name: Test SLF4J 2.0 compatibility
+ # Reuse the SLF4J 1.7-built library classes; do not recompile against the modern API.
+ run: >-
+ mvn -B initialize surefire:test -Pslf4j-2.0
+ -Dtest=SystemPropertySupplierTest,LogCustomizerTest,TreeTest,LoggingDocumentStoreWrapperTest,LoggingHookTest,AzureHttpRequestLoggingPolicyTest,AzureHttpRequestLoggingPolicyV12Test,LuceneDocumentMakerLargeStringPropertiesLogTest,ElasticDocumentMakerLargeStringPropertiesLogTest
+ -Dsurefire.failIfNoSpecifiedTests=false
+ - name: Test embedded logging with SLF4J 1.7
+ run: >-
+ mvn -B verify -pl oak-it-osgi -PintegrationTesting
+ -Dit.test=OSGiIT#slf4jImportRanges+cloudSlf4jImports+embeddedSlf4jLogging
+ -Dslf4j.api.version=1.7.36 -Dlogback.version=1.2.13
- name: Upload build result
uses: actions/upload-artifact@v4
with:
diff --git a/oak-benchmarks-elastic/pom.xml b/oak-benchmarks-elastic/pom.xml
index f96101a235e..886795afa71 100644
--- a/oak-benchmarks-elastic/pom.xml
+++ b/oak-benchmarks-elastic/pom.xml
@@ -28,6 +28,8 @@
oak-benchmarks-elastic
+ ${slf4j.version}
+ ${logback.runtime.version}
true
diff --git a/oak-benchmarks-lucene/pom.xml b/oak-benchmarks-lucene/pom.xml
index d46cfce278b..d6d76fec276 100644
--- a/oak-benchmarks-lucene/pom.xml
+++ b/oak-benchmarks-lucene/pom.xml
@@ -29,6 +29,8 @@
oak-benchmarks-lucene
+ ${slf4j.version}
+ ${logback.runtime.version}
true
diff --git a/oak-benchmarks/pom.xml b/oak-benchmarks/pom.xml
index 324abf02ff2..8ea32b2001c 100644
--- a/oak-benchmarks/pom.xml
+++ b/oak-benchmarks/pom.xml
@@ -31,6 +31,8 @@
Oak Benchmarks Runner
+ ${slf4j.version}
+ ${logback.runtime.version}
true
diff --git a/oak-blob-cloud/pom.xml b/oak-blob-cloud/pom.xml
index 474fb5d1f98..71e8d3e6c48 100644
--- a/oak-blob-cloud/pom.xml
+++ b/oak-blob-cloud/pom.xml
@@ -58,6 +58,8 @@
org.slf4j;version="[1.7,2)",
+
+ org.slf4j.event;version="[1.7,3)",
software.amazon.awssdk.crt;resolution:=optional,
diff --git a/oak-examples/standalone/pom.xml b/oak-examples/standalone/pom.xml
index b279f7cc6d0..3c178a943a0 100644
--- a/oak-examples/standalone/pom.xml
+++ b/oak-examples/standalone/pom.xml
@@ -35,6 +35,8 @@
Standalone application based on Spring Boot then embeds a Jackrabbit Oak content repository
+ ${slf4j.version}
+ ${logback.runtime.version}
true
2.7.18
diff --git a/oak-examples/webapp/pom.xml b/oak-examples/webapp/pom.xml
index 987c50bda9f..92c9acd5dcf 100644
--- a/oak-examples/webapp/pom.xml
+++ b/oak-examples/webapp/pom.xml
@@ -35,6 +35,8 @@
Web application that hosts and serves a Jackrabbit Oak content repository
+ ${slf4j.version}
+ ${logback.runtime.version}
9.0.118
true
diff --git a/oak-it-osgi/pom.xml b/oak-it-osgi/pom.xml
index c561fac8fbe..dccb61f7b6a 100644
--- a/oak-it-osgi/pom.xml
+++ b/oak-it-osgi/pom.xml
@@ -31,6 +31,8 @@
Oak Integration Tests for OSGi deployments
+ ${slf4j.version}
+ ${logback.runtime.version}
true
diff --git a/oak-it-osgi/src/test/java/org/apache/jackrabbit/oak/osgi/OSGiIT.java b/oak-it-osgi/src/test/java/org/apache/jackrabbit/oak/osgi/OSGiIT.java
index 64c9e808ff9..9f696b0d6dc 100644
--- a/oak-it-osgi/src/test/java/org/apache/jackrabbit/oak/osgi/OSGiIT.java
+++ b/oak-it-osgi/src/test/java/org/apache/jackrabbit/oak/osgi/OSGiIT.java
@@ -27,19 +27,41 @@
import java.io.File;
import java.io.IOException;
+import java.io.InputStream;
+import java.lang.reflect.Constructor;
import java.net.URI;
import java.net.MalformedURLException;
import java.net.URISyntaxException;
+import java.net.URL;
+import java.net.URLClassLoader;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.nio.file.StandardCopyOption;
+import java.util.Arrays;
+import java.util.List;
+import java.util.Locale;
+import java.util.Map;
+import java.util.function.Supplier;
+import java.util.jar.JarEntry;
+import java.util.jar.JarFile;
import javax.inject.Inject;
import javax.jcr.Repository;
import javax.jcr.RepositoryException;
+import ch.qos.logback.classic.Level;
+import ch.qos.logback.classic.Logger;
+import ch.qos.logback.classic.LoggerContext;
+import ch.qos.logback.classic.spi.ILoggingEvent;
+import ch.qos.logback.classic.spi.ThrowableProxy;
+import ch.qos.logback.core.read.ListAppender;
import org.apache.jackrabbit.oak.plugins.document.spi.lease.LeaseFailureHandler;
import org.apache.jackrabbit.oak.spi.state.NodeStore;
import org.junit.Test;
+import org.junit.Assert;
import org.junit.runner.RunWith;
import org.ops4j.pax.exam.Configuration;
+import org.ops4j.pax.exam.Constants;
import org.ops4j.pax.exam.CoreOptions;
import org.ops4j.pax.exam.Option;
import org.ops4j.pax.exam.junit.PaxExam;
@@ -49,9 +71,18 @@
import org.ops4j.pax.exam.spi.reactors.PerClass;
import org.osgi.framework.Bundle;
import org.osgi.framework.BundleContext;
+import org.osgi.framework.BundleException;
+import org.osgi.framework.Filter;
+import org.osgi.framework.FrameworkUtil;
import org.osgi.framework.InvalidSyntaxException;
import org.osgi.framework.ServiceReference;
import org.osgi.framework.Version;
+import org.osgi.framework.namespace.PackageNamespace;
+import org.osgi.framework.wiring.BundleCapability;
+import org.osgi.framework.wiring.BundleRequirement;
+import org.osgi.framework.wiring.BundleRevision;
+import org.osgi.framework.wiring.BundleWire;
+import org.osgi.framework.wiring.BundleWiring;
@RunWith(PaxExam.class)
@ExamReactorStrategy(PerClass.class)
@@ -86,6 +117,8 @@ public Option[] configuration() throws IOException, URISyntaxException {
// required for slf4j 2.0.x
spyflyOptions(),
+ // Keep provider discovery inside OSGi rather than Maven's test classpath.
+ frameworkProperty("felix.bootdelegation.implicit").value("false"),
frameworkProperty("repository.home").value("target"),
systemProperties(new SystemPropertyOption("felix.fileinstall.dir").value(getConfigDir())),
jarBundles(),
@@ -106,6 +139,7 @@ static Option spyflyOptions() {
mavenBundle("org.ow2.asm", "asm-commons", "9.7.1"),
mavenBundle("org.ow2.asm", "asm-util", "9.7.1"),
mavenBundle("org.apache.aries.spifly", "org.apache.aries.spifly.dynamic.bundle", "1.3.7")
+ .startLevel(1)
);
}
@@ -136,9 +170,12 @@ static String getConfigDir(){
static Option jarBundles() throws MalformedURLException {
DefaultCompositeOption composite = new DefaultCompositeOption();
+ List loggingBundles = List.of("slf4j-api.jar", "logback-core.jar", "logback-classic.jar");
for (File bundle : new File("target", "test-bundles").listFiles()) {
if (bundle.getName().endsWith(".jar") && bundle.isFile()) {
- composite.add(bundle(bundle.toURI().toURL().toString()));
+ // Make the provider available before framework services initialize SLF4J.
+ composite.add(bundle(bundle.toURI().toURL().toString()).startLevel(
+ loggingBundles.contains(bundle.getName()) ? 1 : Constants.START_LEVEL_DEFAULT_PROVISION));
}
}
return composite;
@@ -156,6 +193,206 @@ public void bundleStates() {
}
}
+ /**
+ * Checks library logging contracts and the embedded Elastic client's shared SLF4J SPI.
+ */
+ @Test
+ public void slf4jImportRanges() throws InvalidSyntaxException, ClassNotFoundException {
+ int imports = 0;
+ for (Bundle bundle : context.getBundles()) {
+ if (!bundle.getSymbolicName().startsWith("org.apache.jackrabbit.oak")) {
+ continue;
+ }
+ BundleWiring wiring = bundle.adapt(BundleWiring.class);
+ Assert.assertNotNull(bundle.toString(), wiring);
+ boolean elastic = "org.apache.jackrabbit.oak-search-elastic".equals(bundle.getSymbolicName());
+ if (elastic) {
+ Class.forName("co.elastic.clients.elasticsearch._helpers.bulk.BulkIngester",
+ true, wiring.getClassLoader());
+ }
+ for (BundleWire wire : wiring.getRequiredWires(PackageNamespace.PACKAGE_NAMESPACE)) {
+ String packageName = (String) wire.getCapability().getAttributes()
+ .get(PackageNamespace.PACKAGE_NAMESPACE);
+ if (!packageName.equals("org.slf4j") && !packageName.startsWith("org.slf4j.")) {
+ continue;
+ }
+ Filter filter = FrameworkUtil.createFilter(wire.getRequirement().getDirectives()
+ .get(PackageNamespace.REQUIREMENT_FILTER_DIRECTIVE));
+ String message = bundle + ": " + filter;
+ Assert.assertTrue(message, filter.matches(Map.of(
+ PackageNamespace.PACKAGE_NAMESPACE, packageName,
+ PackageNamespace.CAPABILITY_VERSION_ATTRIBUTE, new Version("1.7.36"))));
+ Assert.assertEquals(message, elastic && packageName.equals("org.slf4j.spi"), filter.matches(Map.of(
+ PackageNamespace.PACKAGE_NAMESPACE, packageName,
+ PackageNamespace.CAPABILITY_VERSION_ATTRIBUTE, new Version("2.0.20"))));
+ Assert.assertFalse(message, filter.matches(Map.of(
+ PackageNamespace.PACKAGE_NAMESPACE, packageName,
+ PackageNamespace.CAPABILITY_VERSION_ATTRIBUTE, new Version("3.0.0"))));
+ imports++;
+ }
+ }
+ Assert.assertTrue("No Oak SLF4J imports were checked", imports > 0);
+ }
+
+ /**
+ * Checks the embedded AWS SDK's logging requirements against SLF4J alone, without Pax Logging exports.
+ */
+ @Test
+ public void cloudSlf4jImports() throws BundleException, InvalidSyntaxException {
+ Bundle provider = Arrays.stream(context.getBundles())
+ .filter(bundle -> "slf4j.api".equals(bundle.getSymbolicName()))
+ .findFirst().orElseThrow(() -> new IllegalStateException("SLF4J API bundle is missing"));
+ List exports = provider.adapt(BundleRevision.class)
+ .getDeclaredCapabilities(PackageNamespace.PACKAGE_NAMESPACE);
+ Bundle cloud = context.installBundle(
+ new File("target/test-bundles/cloud/oak-blob-cloud.jar").toURI().toString());
+ try {
+ boolean eventChecked = false;
+ for (BundleRequirement requirement : cloud.adapt(BundleRevision.class)
+ .getDeclaredRequirements(PackageNamespace.PACKAGE_NAMESPACE)) {
+ String filterText = requirement.getDirectives()
+ .get(PackageNamespace.REQUIREMENT_FILTER_DIRECTIVE);
+ if (!filterText.contains("(osgi.wiring.package=org.slf4j)")
+ && !filterText.contains("(osgi.wiring.package=org.slf4j.")) {
+ continue;
+ }
+ boolean optional = PackageNamespace.RESOLUTION_OPTIONAL.equals(requirement.getDirectives()
+ .get(PackageNamespace.REQUIREMENT_RESOLUTION_DIRECTIVE));
+ String message = cloud + ": " + filterText + " against " + provider;
+ if (filterText.contains("(osgi.wiring.package=org.slf4j.event)")) {
+ Assert.assertFalse(message, optional);
+ Filter filter = FrameworkUtil.createFilter(filterText);
+ Assert.assertFalse(message, filter.matches(Map.of(
+ PackageNamespace.PACKAGE_NAMESPACE, "org.slf4j.event",
+ PackageNamespace.CAPABILITY_VERSION_ATTRIBUTE, new Version("3.0.0"))));
+ eventChecked = true;
+ }
+ if (!optional) {
+ Assert.assertTrue(message, exports.stream().anyMatch(requirement::matches));
+ }
+ }
+ Assert.assertTrue("No AWS SDK SLF4J event import was checked", eventChecked);
+ } finally {
+ cloud.uninstall();
+ }
+ }
+
+ /**
+ * Verifies the embedded Commons Logging bridge and AWS logging against the selected runtime.
+ */
+ @Test
+ public void embeddedSlf4jLogging() throws Exception {
+ Bundle elastic = Arrays.stream(context.getBundles())
+ .filter(bundle -> "org.apache.jackrabbit.oak-search-elastic".equals(bundle.getSymbolicName()))
+ .findFirst().orElseThrow(() -> new IllegalStateException("Elastic bundle is missing"));
+ Bundle api = Arrays.stream(context.getBundles())
+ .filter(bundle -> "slf4j.api".equals(bundle.getSymbolicName()))
+ .findFirst().orElseThrow(() -> new IllegalStateException("SLF4J API bundle is missing"));
+ String loggerName = OSGiIT.class.getName() + ".embedded";
+ LoggerContext loggerContext = (LoggerContext) api.loadClass("org.slf4j.LoggerFactory")
+ .getMethod("getILoggerFactory").invoke(null);
+ Logger logger = loggerContext.getLogger(loggerName);
+ Path sdkJar = Files.createTempFile("oak-slf4j-sdk-", ".jar");
+ Level previousLevel = logger.getLevel();
+ boolean previousAdditive = logger.isAdditive();
+ ListAppender appender = new ListAppender<>() {
+ @Override
+ protected void append(ILoggingEvent event) {
+ // Capture caller data while the logging call is still on the stack.
+ event.getCallerData();
+ super.append(event);
+ }
+ };
+ appender.start();
+ logger.addAppender(appender);
+ logger.setAdditive(false);
+ logger.setLevel(Level.TRACE);
+ try (JarFile cloud = new JarFile("target/test-bundles/cloud/oak-blob-cloud.jar")) {
+ JarEntry utils = cloud.stream()
+ .filter(entry -> entry.getName().startsWith("utils-")
+ && !entry.getName().startsWith("utils-lite-") && entry.getName().endsWith(".jar"))
+ .findFirst().orElseThrow(() -> new IllegalStateException("Embedded AWS SDK utils JAR is missing"));
+ try (InputStream input = cloud.getInputStream(utils)) {
+ Files.copy(input, sdkJar, StandardCopyOption.REPLACE_EXISTING);
+ }
+ Assert.assertSame(api.loadClass("org.slf4j.LoggerFactory"),
+ elastic.loadClass("org.slf4j.LoggerFactory"));
+ Class> logFactoryClass = elastic.loadClass("org.apache.commons.logging.LogFactory");
+ Object commonsFactory = logFactoryClass.getMethod("getFactory").invoke(null);
+ Assert.assertEquals("org.apache.commons.logging.impl.Slf4jLogFactory",
+ commonsFactory.getClass().getName());
+ Object commonsLog = logFactoryClass.getMethod("getLog", String.class).invoke(null, loggerName);
+ Class> logClass = elastic.loadClass("org.apache.commons.logging.Log");
+ Class> levelClass = api.loadClass("org.slf4j.event.Level");
+ ClassLoader loggingLoader = new ClassLoader(null) {
+ @Override
+ protected Class> findClass(String name) throws ClassNotFoundException {
+ if (name.startsWith("org.slf4j.")) {
+ return api.loadClass(name);
+ }
+ throw new ClassNotFoundException(name);
+ }
+ };
+ try (URLClassLoader sdkLoader = new URLClassLoader(new URL[] {sdkJar.toUri().toURL()}, loggingLoader)) {
+ Class> sdkClass = sdkLoader.loadClass("software.amazon.awssdk.utils.Logger");
+ Assert.assertSame(sdkLoader, sdkClass.getClassLoader());
+ Constructor> sdkConstructor = sdkClass.getDeclaredConstructor(api.loadClass("org.slf4j.Logger"));
+ sdkConstructor.setAccessible(true);
+ Object sdkLog = sdkConstructor.newInstance(logger);
+ Assert.assertSame(logger, sdkClass.getMethod("logger").invoke(sdkLog));
+ for (String level : List.of("TRACE", "DEBUG", "INFO", "WARN", "ERROR")) {
+ String method = level.toLowerCase(Locale.ROOT);
+ String enabledMethod = "is" + level.charAt(0) + method.substring(1) + "Enabled";
+ appender.list.clear();
+ Assert.assertEquals(true, logClass.getMethod(enabledMethod).invoke(commonsLog));
+ logClass.getMethod(method, Object.class)
+ .invoke(commonsLog, "commons-" + level);
+ Object sdkLevel = levelClass.getField(level).get(null);
+ Assert.assertEquals(true, sdkClass.getMethod("isLoggingLevelEnabled", levelClass)
+ .invoke(sdkLog, sdkLevel));
+ Supplier message = () -> "aws-" + level;
+ sdkClass.getMethod("log", levelClass, Supplier.class).invoke(sdkLog, sdkLevel, message);
+ Assert.assertEquals(2, appender.list.size());
+ Assert.assertEquals("commons-" + level, appender.list.get(0).getFormattedMessage());
+ Assert.assertEquals("aws-" + level, appender.list.get(1).getFormattedMessage());
+ for (ILoggingEvent event : appender.list) {
+ Assert.assertEquals(Level.toLevel(level), event.getLevel());
+ }
+ StackTraceElement caller = Arrays.stream(appender.list.get(0).getCallerData())
+ .filter(frame -> !frame.getClassName().startsWith("java.lang.reflect.")
+ && !frame.getClassName().startsWith("jdk.internal.reflect."))
+ .findFirst().orElseThrow(() -> new AssertionError("Missing bridge caller data"));
+ Assert.assertEquals(OSGiIT.class.getName(), caller.getClassName());
+ Assert.assertEquals("embeddedSlf4jLogging", caller.getMethodName());
+
+ Throwable failure = new IllegalStateException("commons-failure-" + level);
+ logClass.getMethod(method, Object.class, Throwable.class)
+ .invoke(commonsLog, "commons-failure-" + level, failure);
+ Assert.assertEquals(3, appender.list.size());
+ ILoggingEvent event = appender.list.get(2);
+ Assert.assertEquals("commons-failure-" + level, event.getFormattedMessage());
+ Assert.assertEquals(Level.toLevel(level), event.getLevel());
+ Assert.assertSame(failure, ((ThrowableProxy) event.getThrowableProxy()).getThrowable());
+
+ logger.setLevel(Level.OFF);
+ appender.list.clear();
+ Assert.assertEquals(false, logClass.getMethod(enabledMethod).invoke(commonsLog));
+ logClass.getMethod(method, Object.class).invoke(commonsLog, "suppressed");
+ logClass.getMethod(method, Object.class, Throwable.class)
+ .invoke(commonsLog, "suppressed", failure);
+ Assert.assertTrue(appender.list.isEmpty());
+ logger.setLevel(Level.TRACE);
+ }
+ }
+ } finally {
+ logger.detachAppender(appender);
+ logger.setLevel(previousLevel);
+ logger.setAdditive(previousAdditive);
+ appender.stop();
+ Files.delete(sdkJar);
+ }
+ }
+
@Test
public void listBundles() {
for (Bundle bundle : context.getBundles()) {
diff --git a/oak-it-osgi/test-bundles.xml b/oak-it-osgi/test-bundles.xml
index 39a3aa85675..b008a27ead1 100644
--- a/oak-it-osgi/test-bundles.xml
+++ b/oak-it-osgi/test-bundles.xml
@@ -23,6 +23,15 @@
false
+
+ cloud
+ ${artifact.artifactId}.jar
+ test
+
+ org.apache.jackrabbit:oak-blob-cloud
+
+ false
+
${artifact.artifactId}.jar
diff --git a/oak-parent/pom.xml b/oak-parent/pom.xml
index 2aa8cdd4a07..fc4f3e79a2a 100644
--- a/oak-parent/pom.xml
+++ b/oak-parent/pom.xml
@@ -58,9 +58,10 @@
SegmentMK
4.7.2
5.5.2
- 2.0.20
- 2.0.20
- 1.6.4
+ 1.7.36
+ 2.0.20
+ 1.2.13
+ 1.6.4
2.1.214
1.28.5
10.16.1.1
@@ -599,22 +600,22 @@
org.slf4j
slf4j-simple
- ${slf4j.version}
+ ${slf4j.api.version}
org.slf4j
log4j-over-slf4j
- ${slf4j.version}
+ ${slf4j.api.version}
org.slf4j
jcl-over-slf4j
- ${slf4j.version}
+ ${slf4j.api.version}
org.slf4j
jul-to-slf4j
- ${slf4j.version}
+ ${slf4j.api.version}
ch.qos.logback
@@ -947,6 +948,14 @@
+
+
+ slf4j-2.0
+
+ ${slf4j.version}
+ ${logback.runtime.version}
+
+
unittesting
diff --git a/oak-run-commons/pom.xml b/oak-run-commons/pom.xml
index 3f1faa6a86d..9214a9cb1c5 100644
--- a/oak-run-commons/pom.xml
+++ b/oak-run-commons/pom.xml
@@ -30,6 +30,11 @@
oak-run-commons
Oak Commons for Runnable modules
+
+ ${slf4j.version}
+ ${logback.runtime.version}
+
+
diff --git a/oak-run-elastic/pom.xml b/oak-run-elastic/pom.xml
index 349860d05b5..acf2754572c 100644
--- a/oak-run-elastic/pom.xml
+++ b/oak-run-elastic/pom.xml
@@ -31,6 +31,8 @@
Oak Elastic Runnable Jar
+ ${slf4j.version}
+ ${logback.runtime.version}
8.2.0.v20160908
+ org.slf4j.spi;version="[1.7,3)",
!com.ibm.*,
!org.apache.derby.*,
!io.opentelemetry.sdk.autoconfigure.*,
diff --git a/oak-store-composite/src/test/java/org/apache/jackrabbit/oak/composite/it/CompositeTestSupport.java b/oak-store-composite/src/test/java/org/apache/jackrabbit/oak/composite/it/CompositeTestSupport.java
index 6727a89758c..c7743f9a88c 100644
--- a/oak-store-composite/src/test/java/org/apache/jackrabbit/oak/composite/it/CompositeTestSupport.java
+++ b/oak-store-composite/src/test/java/org/apache/jackrabbit/oak/composite/it/CompositeTestSupport.java
@@ -108,8 +108,6 @@ protected static Option logging(String level) {
return composite(
mavenBundle("org.ops4j.pax.logging", "pax-logging-api", "1.11.13"),
SlingOptions.spyfly(),
- mavenBundle("org.slf4j", "slf4j-api", "2.0.12"),
- mavenBundle("org.slf4j", "slf4j-simple", "2.0.12"),
systemProperty("org.ops4j.pax.logging.DefaultServiceLog.level").value(level),
SlingOptions.logback()
);
diff --git a/oak-upgrade/pom.xml b/oak-upgrade/pom.xml
index ee4b7a0518f..1939b38976a 100644
--- a/oak-upgrade/pom.xml
+++ b/oak-upgrade/pom.xml
@@ -31,6 +31,11 @@
Oak upgrade
Tooling for upgrading Jackrabbit repositories to Oak
+
+ ${slf4j.version}
+ ${logback.runtime.version}
+
+