This is an automated email from the ASF dual-hosted git repository.
rombert pushed a commit to branch master
in repository
https://gitbox.apache.org/repos/asf/sling-org-apache-sling-commons-log.git
The following commit(s) were added to refs/heads/master by this push:
new b3f2ff9 SLING-13188 - Provide an optional in-memory store of recent
log events (#31)
b3f2ff9 is described below
commit b3f2ff92a7a565565b13b863c2fc58e263683074
Author: Robert Munteanu <[email protected]>
AuthorDate: Fri May 22 15:43:42 2026 +0200
SLING-13188 - Provide an optional in-memory store of recent log events (#31)
Expose a simple APIs to retrieve logging events that are stored in memory.
The needed components
are disabled by default unless configured.
---
.../log/logback/internal/LogConfigManager.java | 6 +
.../logback/internal/store/LogStoreAppender.java | 134 +++++++++++++++++++++
.../log/logback/internal/store/LogStoreImpl.java | 116 ++++++++++++++++++
.../logback/internal/store/LogStoreRegistrar.java | 117 ++++++++++++++++++
.../sling/commons/log/logback/store/LogEntry.java | 43 +++++++
.../sling/commons/log/logback/store/LogLevel.java | 30 +++++
.../sling/commons/log/logback/store/LogStore.java | 42 +++++++
.../commons/log/logback/store/package-info.java | 29 +++++
.../integration/ITLogStoreRegistrarLifecycle.java | 91 ++++++++++++++
.../internal/store/LogStoreAppenderTest.java | 115 ++++++++++++++++++
.../logback/internal/store/LogStoreImplTest.java | 93 ++++++++++++++
11 files changed, 816 insertions(+)
diff --git
a/src/main/java/org/apache/sling/commons/log/logback/internal/LogConfigManager.java
b/src/main/java/org/apache/sling/commons/log/logback/internal/LogConfigManager.java
index 5131513..303485a 100644
---
a/src/main/java/org/apache/sling/commons/log/logback/internal/LogConfigManager.java
+++
b/src/main/java/org/apache/sling/commons/log/logback/internal/LogConfigManager.java
@@ -68,6 +68,7 @@ import
org.apache.sling.commons.log.logback.internal.config.ConfigurationExcepti
import
org.apache.sling.commons.log.logback.internal.joran.JoranConfiguratorWrapper;
import
org.apache.sling.commons.log.logback.internal.stacktrace.OSGiAwareExceptionHandling;
import
org.apache.sling.commons.log.logback.internal.stacktrace.PackageInfoCollector;
+import org.apache.sling.commons.log.logback.internal.store.LogStoreRegistrar;
import
org.apache.sling.commons.log.logback.internal.util.LoggerSpecificEncoder;
import
org.apache.sling.commons.log.logback.internal.util.SlingRollingFileAppender;
import org.apache.sling.commons.log.logback.internal.util.SlingStatusPrinter;
@@ -145,6 +146,8 @@ public class LogConfigManager extends LoggerContextAwareBase
*/
private final ConfigAdminSupport configAdminSupport;
+ private final LogStoreRegistrar logStoreRegistrar;
+
/**
* The logger for this class
*/
@@ -306,6 +309,7 @@ public class LogConfigManager extends LoggerContextAwareBase
this.osgiIntegrationListener = new OsgiIntegrationListener(this);
this.configAdminSupport = new ConfigAdminSupport();
+ this.logStoreRegistrar = new LogStoreRegistrar();
}
/**
@@ -319,6 +323,7 @@ public class LogConfigManager extends LoggerContextAwareBase
bridgeHandlerInstalled = maybeInstallSlf4jBridgeHandler(bundleContext);
configAdminSupport.start(bundleContext, this);
+ logStoreRegistrar.start(bundleContext);
// enable the LevelChangePropagator during any reset
//
http://logback.qos.ch/manual/configuration.html#LevelChangePropagator
@@ -386,6 +391,7 @@ public class LogConfigManager extends LoggerContextAwareBase
loggerContext.removeListener(osgiIntegrationListener);
configAdminSupport.stop();
+ logStoreRegistrar.stop();
for (ServiceTracker<?, ?> tracker : serviceTrackers) {
tracker.close();
diff --git
a/src/main/java/org/apache/sling/commons/log/logback/internal/store/LogStoreAppender.java
b/src/main/java/org/apache/sling/commons/log/logback/internal/store/LogStoreAppender.java
new file mode 100644
index 0000000..c22f5d4
--- /dev/null
+++
b/src/main/java/org/apache/sling/commons/log/logback/internal/store/LogStoreAppender.java
@@ -0,0 +1,134 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied. See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+package org.apache.sling.commons.log.logback.internal.store;
+
+import ch.qos.logback.classic.spi.ILoggingEvent;
+import ch.qos.logback.classic.spi.IThrowableProxy;
+import ch.qos.logback.classic.spi.StackTraceElementProxy;
+import ch.qos.logback.core.AppenderBase;
+import org.apache.sling.commons.log.logback.store.LogEntry;
+import org.apache.sling.commons.log.logback.store.LogLevel;
+
+public class LogStoreAppender extends AppenderBase<ILoggingEvent> {
+
+ public static final String APPENDER_NAME = "structured-log-store";
+
+ private final LogStoreImpl store;
+
+ public LogStoreAppender(LogStoreImpl store) {
+ this.store = store;
+ setName(APPENDER_NAME);
+ }
+
+ @Override
+ protected void append(ILoggingEvent eventObject) {
+ if (eventObject == null) {
+ return;
+ }
+
+ LogLevel logLevel = getLogLevel(eventObject);
+ if (logLevel == null) {
+ return;
+ }
+
+ store.append(new LogEntry(
+ eventObject.getTimeStamp(),
+ logLevel,
+ eventObject.getLoggerName(),
+ eventObject.getThreadName(),
+ eventObject.getFormattedMessage(),
+ getThrowableText(eventObject),
+ eventObject.getMDCPropertyMap()));
+ }
+
+ private LogLevel getLogLevel(ILoggingEvent eventObject) {
+ switch (eventObject.getLevel().levelInt) {
+ case ch.qos.logback.classic.Level.TRACE_INT:
+ return LogLevel.TRACE;
+ case ch.qos.logback.classic.Level.DEBUG_INT:
+ return LogLevel.DEBUG;
+ case ch.qos.logback.classic.Level.INFO_INT:
+ return LogLevel.INFO;
+ case ch.qos.logback.classic.Level.WARN_INT:
+ return LogLevel.WARN;
+ case ch.qos.logback.classic.Level.ERROR_INT:
+ return LogLevel.ERROR;
+ default:
+ return null;
+ }
+ }
+
+ private String getThrowableText(ILoggingEvent eventObject) {
+ IThrowableProxy throwableProxy = eventObject.getThrowableProxy();
+ if (throwableProxy == null) {
+ return null;
+ }
+
+ StringBuilder text = new StringBuilder();
+ appendThrowable(text, throwableProxy, null);
+ return text.toString();
+ }
+
+ private void appendThrowable(StringBuilder text, IThrowableProxy
throwableProxy, String prefix) {
+ if (prefix != null) {
+ text.append(prefix);
+ }
+ text.append(getThrowableHeader(throwableProxy)).append('\n');
+
+ StackTraceElementProxy[] stackTrace =
throwableProxy.getStackTraceElementProxyArray();
+ if (stackTrace != null) {
+ int framesToRender = Math.max(0, stackTrace.length - Math.max(0,
throwableProxy.getCommonFrames()));
+ for (int i = 0; i < framesToRender; i++) {
+ text.append('\t').append(stackTrace[i]).append('\n');
+ }
+ if (throwableProxy.getCommonFrames() > 0) {
+ text.append("\t... ")
+ .append(throwableProxy.getCommonFrames())
+ .append(" common frames omitted")
+ .append('\n');
+ }
+ }
+
+ IThrowableProxy[] suppressed = throwableProxy.getSuppressed();
+ if (suppressed != null) {
+ for (IThrowableProxy suppressedThrowable : suppressed) {
+ appendThrowable(text, suppressedThrowable, "Suppressed: ");
+ }
+ }
+
+ IThrowableProxy cause = throwableProxy.getCause();
+ if (cause != null) {
+ appendThrowable(text, cause, "Caused by: ");
+ }
+ }
+
+ private String getThrowableHeader(IThrowableProxy throwableProxy) {
+ String overridingMessage = throwableProxy.getOverridingMessage();
+ if (overridingMessage != null && !overridingMessage.isEmpty()) {
+ return overridingMessage;
+ }
+
+ StringBuilder header = new
StringBuilder(throwableProxy.getClassName());
+ String message = throwableProxy.getMessage();
+ if (message != null && !message.isEmpty()) {
+ header.append(": ").append(message);
+ }
+ return header.toString();
+ }
+}
diff --git
a/src/main/java/org/apache/sling/commons/log/logback/internal/store/LogStoreImpl.java
b/src/main/java/org/apache/sling/commons/log/logback/internal/store/LogStoreImpl.java
new file mode 100644
index 0000000..d62f066
--- /dev/null
+++
b/src/main/java/org/apache/sling/commons/log/logback/internal/store/LogStoreImpl.java
@@ -0,0 +1,116 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied. See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+package org.apache.sling.commons.log.logback.internal.store;
+
+import java.util.ArrayDeque;
+import java.util.ArrayList;
+import java.util.Deque;
+import java.util.Iterator;
+import java.util.List;
+import java.util.Map;
+import java.util.regex.Pattern;
+
+import org.apache.sling.commons.log.logback.store.LogEntry;
+import org.apache.sling.commons.log.logback.store.LogLevel;
+import org.apache.sling.commons.log.logback.store.LogStore;
+
+public class LogStoreImpl implements LogStore {
+
+ static final int DEFAULT_MAX_ENTRIES = 10000;
+
+ private final Object lock = new Object();
+ private final Deque<LogEntry> entries = new ArrayDeque<>();
+ private int maxEntriesKept;
+
+ public LogStoreImpl(int maxEntriesKept) {
+ this.maxEntriesKept = Math.max(1, maxEntriesKept);
+ }
+
+ public void append(LogEntry snapshot) {
+ synchronized (lock) {
+ entries.addLast(snapshot);
+ trimToSize();
+ }
+ }
+
+ @Override
+ public List<LogEntry> getRecent(Pattern pattern, LogLevel minLevel, int
maxEntries) {
+ LogLevel effectiveMinLevel = minLevel == null ? LogLevel.TRACE :
minLevel;
+
+ synchronized (lock) {
+ List<LogEntry> matches = new ArrayList<>();
+ int remaining = Math.max(1, maxEntries);
+
+ for (Iterator<LogEntry> iterator = entries.descendingIterator();
iterator.hasNext() && remaining > 0; ) {
+ LogEntry snapshot = iterator.next();
+ if (!matches(snapshot, pattern, effectiveMinLevel)) {
+ continue;
+ }
+ matches.add(snapshot);
+ remaining--;
+ }
+
+ return matches;
+ }
+ }
+
+ public void setMaxEntries(int maxEntriesKept) {
+ synchronized (lock) {
+ this.maxEntriesKept = Math.max(1, maxEntriesKept);
+ trimToSize();
+ }
+ }
+
+ private boolean matches(LogEntry snapshot, Pattern pattern, LogLevel
minLevel) {
+ if (snapshot.level().ordinal() >= minLevel.ordinal()) {
+ if (pattern == null) {
+ return true;
+ }
+ return matchesField(pattern, snapshot.level().name())
+ || matchesField(pattern, snapshot.loggerName())
+ || matchesField(pattern, snapshot.threadName())
+ || matchesField(pattern, snapshot.formattedMessage())
+ || matchesField(pattern, snapshot.throwableText())
+ || matchesMdc(pattern, snapshot);
+ }
+ return false;
+ }
+
+ private boolean matchesMdc(Pattern pattern, LogEntry snapshot) {
+ if (snapshot.mdc().isEmpty()) {
+ return false;
+ }
+ for (Map.Entry<String, String> entry : snapshot.mdc().entrySet()) {
+ if (matchesField(pattern, entry.getKey()) || matchesField(pattern,
entry.getValue())) {
+ return true;
+ }
+ }
+ return false;
+ }
+
+ private boolean matchesField(Pattern pattern, String value) {
+ return value != null && !value.isEmpty() &&
pattern.matcher(value).find();
+ }
+
+ private void trimToSize() {
+ while (entries.size() > maxEntriesKept) {
+ entries.removeFirst();
+ }
+ }
+}
diff --git
a/src/main/java/org/apache/sling/commons/log/logback/internal/store/LogStoreRegistrar.java
b/src/main/java/org/apache/sling/commons/log/logback/internal/store/LogStoreRegistrar.java
new file mode 100644
index 0000000..eb87229
--- /dev/null
+++
b/src/main/java/org/apache/sling/commons/log/logback/internal/store/LogStoreRegistrar.java
@@ -0,0 +1,117 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied. See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+package org.apache.sling.commons.log.logback.internal.store;
+
+import java.util.Dictionary;
+import java.util.Hashtable;
+
+import ch.qos.logback.core.Appender;
+import org.apache.sling.commons.log.logback.internal.LogConstants;
+import org.apache.sling.commons.log.logback.store.LogStore;
+import org.jetbrains.annotations.Nullable;
+import org.osgi.framework.BundleContext;
+import org.osgi.framework.Constants;
+import org.osgi.framework.ServiceRegistration;
+import org.osgi.service.cm.ManagedService;
+import org.osgi.util.converter.Converters;
+
+public class LogStoreRegistrar {
+
+ static final String PID = "org.apache.sling.commons.log.LogStore";
+ static final String PROP_MAX_ENTRIES = "maxEntries";
+
+ private ServiceRegistration<LogStore> storeRegistration;
+ private ServiceRegistration<Appender> appenderRegistration;
+ private ServiceRegistration<ManagedService> configRegistration;
+ private BundleContext bundleContext;
+ private LogStoreImpl store;
+ private LogStoreAppender appender;
+
+ public void start(BundleContext context) {
+ this.bundleContext = context;
+
+ Dictionary<String, Object> configProps = new Hashtable<>();
+ configProps.put(Constants.SERVICE_VENDOR,
LogConstants.ASF_SERVICE_VENDOR);
+ configProps.put(Constants.SERVICE_DESCRIPTION, "Log Store
Configurator");
+ configProps.put(Constants.SERVICE_PID, PID);
+ configRegistration = context.registerService(ManagedService.class,
this::updated, configProps);
+ }
+
+ public void stop() {
+ deactivate();
+
+ if (configRegistration != null) {
+ configRegistration.unregister();
+ configRegistration = null;
+ }
+ bundleContext = null;
+ }
+
+ void updated(@Nullable Dictionary<String, ?> properties) {
+ if (properties == null) {
+ deactivate();
+ return;
+ }
+
+ int maxEntries = Converters.standardConverter()
+ .convert(properties.get(PROP_MAX_ENTRIES))
+ .defaultValue(LogStoreImpl.DEFAULT_MAX_ENTRIES)
+ .to(Integer.class);
+
+ if (store == null) {
+ activate(maxEntries);
+ } else {
+ store.setMaxEntries(maxEntries);
+ }
+ }
+
+ private void activate(int maxEntries) {
+ if (bundleContext == null || store != null) {
+ return;
+ }
+
+ store = new LogStoreImpl(maxEntries);
+ appender = new LogStoreAppender(store);
+
+ Dictionary<String, Object> serviceProps = new Hashtable<>();
+ serviceProps.put(Constants.SERVICE_VENDOR,
LogConstants.ASF_SERVICE_VENDOR);
+ serviceProps.put(Constants.SERVICE_DESCRIPTION, "Log Store");
+ storeRegistration = bundleContext.registerService(LogStore.class,
store, serviceProps);
+
+ Dictionary<String, Object> appenderProps = new Hashtable<>();
+ appenderProps.put(Constants.SERVICE_VENDOR,
LogConstants.ASF_SERVICE_VENDOR);
+ appenderProps.put(Constants.SERVICE_DESCRIPTION, "Log Store Appender");
+ appenderProps.put("loggers", "ROOT");
+ appenderRegistration = bundleContext.registerService(Appender.class,
appender, appenderProps);
+ }
+
+ private void deactivate() {
+ if (appenderRegistration != null) {
+ appenderRegistration.unregister();
+ appenderRegistration = null;
+ }
+ if (storeRegistration != null) {
+ storeRegistration.unregister();
+ storeRegistration = null;
+ }
+
+ appender = null;
+ store = null;
+ }
+}
diff --git
a/src/main/java/org/apache/sling/commons/log/logback/store/LogEntry.java
b/src/main/java/org/apache/sling/commons/log/logback/store/LogEntry.java
new file mode 100644
index 0000000..d7cc252
--- /dev/null
+++ b/src/main/java/org/apache/sling/commons/log/logback/store/LogEntry.java
@@ -0,0 +1,43 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied. See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+package org.apache.sling.commons.log.logback.store;
+
+import java.util.Collections;
+import java.util.HashMap;
+import java.util.Map;
+
+/**
+ * Snapshot of a log entry.
+ *
+ * <p>Stores only the lightweight, stable parts of a log event so the log store
+ * does not retain full logging event object graphs.</p>
+ */
+public record LogEntry(
+ long timeMillis,
+ LogLevel level,
+ String loggerName,
+ String threadName,
+ String formattedMessage,
+ String throwableText,
+ Map<String, String> mdc) {
+
+ public LogEntry {
+ mdc = mdc.isEmpty() ? Collections.emptyMap() :
Collections.unmodifiableMap(new HashMap<>(mdc));
+ }
+}
diff --git
a/src/main/java/org/apache/sling/commons/log/logback/store/LogLevel.java
b/src/main/java/org/apache/sling/commons/log/logback/store/LogLevel.java
new file mode 100644
index 0000000..d4590c5
--- /dev/null
+++ b/src/main/java/org/apache/sling/commons/log/logback/store/LogLevel.java
@@ -0,0 +1,30 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied. See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+package org.apache.sling.commons.log.logback.store;
+
+/**
+ * Severity levels supported by the log store.
+ */
+public enum LogLevel {
+ TRACE,
+ DEBUG,
+ INFO,
+ WARN,
+ ERROR;
+}
diff --git
a/src/main/java/org/apache/sling/commons/log/logback/store/LogStore.java
b/src/main/java/org/apache/sling/commons/log/logback/store/LogStore.java
new file mode 100644
index 0000000..4e11225
--- /dev/null
+++ b/src/main/java/org/apache/sling/commons/log/logback/store/LogStore.java
@@ -0,0 +1,42 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied. See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+package org.apache.sling.commons.log.logback.store;
+
+import java.util.List;
+import java.util.regex.Pattern;
+
+import org.osgi.annotation.versioning.ProviderType;
+
+/**
+ * Queriable store of structured log entries.
+ */
+@ProviderType
+public interface LogStore {
+
+ /**
+ * Returns a list of <code>LogEntrie</code>s matching the specified
parameters
+ *
+ * @param pattern the pattern to match against all the text-based fields
of the log entry. Ignored if <code>null</code>.
+ * @param minLevel the minimum level of the log entries. Defaults to
{@link LogLevel#TRACE} if <code>null</code>.
+ * @param maxEntries the maximum entries to return. Clamped to 1 if needed.
+ *
+ * @return a list of entries matching the parameters. May be empty but not
<code>null</code>
+ */
+ List<LogEntry> getRecent(Pattern pattern, LogLevel minLevel, int
maxEntries);
+}
diff --git
a/src/main/java/org/apache/sling/commons/log/logback/store/package-info.java
b/src/main/java/org/apache/sling/commons/log/logback/store/package-info.java
new file mode 100644
index 0000000..76ac0cf
--- /dev/null
+++ b/src/main/java/org/apache/sling/commons/log/logback/store/package-info.java
@@ -0,0 +1,29 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied. See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+
+/**
+ * Provides programatic access to logging events
+ *
+ * <p>The API is intentionally decoupled from slf4j and logback to prevent
binding to those APIs
+ * directly, which hinders evolution and is problematic when upgrading
dependency versions.</p>
+ */
+@Version("1.0.0")
+package org.apache.sling.commons.log.logback.store;
+
+import org.osgi.annotation.versioning.Version;
diff --git
a/src/test/java/org/apache/sling/commons/log/logback/integration/ITLogStoreRegistrarLifecycle.java
b/src/test/java/org/apache/sling/commons/log/logback/integration/ITLogStoreRegistrarLifecycle.java
new file mode 100644
index 0000000..2e1a123
--- /dev/null
+++
b/src/test/java/org/apache/sling/commons/log/logback/integration/ITLogStoreRegistrarLifecycle.java
@@ -0,0 +1,91 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied. See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+package org.apache.sling.commons.log.logback.integration;
+
+import javax.inject.Inject;
+
+import java.util.Dictionary;
+import java.util.Hashtable;
+
+import ch.qos.logback.core.Appender;
+import org.apache.sling.commons.log.logback.store.LogStore;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.ops4j.pax.exam.Option;
+import org.ops4j.pax.exam.junit.PaxExam;
+import org.ops4j.pax.exam.spi.reactors.ExamReactorStrategy;
+import org.ops4j.pax.exam.spi.reactors.PerClass;
+import org.osgi.service.cm.Configuration;
+import org.osgi.service.cm.ConfigurationAdmin;
+
+import static org.junit.Assert.assertEquals;
+import static org.ops4j.pax.exam.CoreOptions.composite;
+import static org.ops4j.pax.exam.CoreOptions.mavenBundle;
+
+@RunWith(PaxExam.class)
+@ExamReactorStrategy(PerClass.class)
+public class ITLogStoreRegistrarLifecycle extends LogTestBase {
+
+ private static final String LOG_STORE_PID =
"org.apache.sling.commons.log.LogStore";
+ private static final String MAX_ENTRIES = "maxEntries";
+
+ @Inject
+ private ConfigurationAdmin ca;
+
+ @Override
+ protected Option addExtraOptions() {
+ return composite(configAdmin(), mavenBundle("commons-io",
"commons-io").versionAsInProject());
+ }
+
+ @Test
+ public void testLifecycle() throws Exception {
+ Configuration config = ca.getConfiguration(LOG_STORE_PID, null);
+ try {
+ assertEquals(
+ 0, bundleContext.getServiceReferences(LogStore.class,
null).size());
+ assertEquals(
+ 0, bundleContext.getServiceReferences(Appender.class,
null).size());
+
+ Dictionary<String, Object> properties = new Hashtable<String,
Object>();
+ properties.put(MAX_ENTRIES, 5);
+ config.update(properties);
+ delay();
+
+ assertEquals(
+ 1, bundleContext.getServiceReferences(LogStore.class,
null).size());
+ assertEquals(
+ 1, bundleContext.getServiceReferences(Appender.class,
null).size());
+
+ properties.put(MAX_ENTRIES, 7);
+ config.update(properties);
+ delay();
+
+ assertEquals(
+ 1, bundleContext.getServiceReferences(LogStore.class,
null).size());
+ assertEquals(
+ 1, bundleContext.getServiceReferences(Appender.class,
null).size());
+ } finally {
+ config.delete();
+ delay();
+ }
+
+ assertEquals(0, bundleContext.getServiceReferences(LogStore.class,
null).size());
+ assertEquals(0, bundleContext.getServiceReferences(Appender.class,
null).size());
+ }
+}
diff --git
a/src/test/java/org/apache/sling/commons/log/logback/internal/store/LogStoreAppenderTest.java
b/src/test/java/org/apache/sling/commons/log/logback/internal/store/LogStoreAppenderTest.java
new file mode 100644
index 0000000..7259351
--- /dev/null
+++
b/src/test/java/org/apache/sling/commons/log/logback/internal/store/LogStoreAppenderTest.java
@@ -0,0 +1,115 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied. See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+package org.apache.sling.commons.log.logback.internal.store;
+
+import java.util.List;
+import java.util.Map;
+
+import ch.qos.logback.classic.Level;
+import ch.qos.logback.classic.Logger;
+import ch.qos.logback.classic.LoggerContext;
+import ch.qos.logback.classic.spi.LoggingEvent;
+import org.apache.sling.commons.log.logback.store.LogEntry;
+import org.apache.sling.commons.log.logback.store.LogLevel;
+import org.junit.jupiter.api.Test;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertNotNull;
+
+class LogStoreAppenderTest {
+
+ private static final StackTraceElement SHARED_FRAME_ONE =
+ new StackTraceElement("example.Shared", "sharedOne",
"Shared.java", 10);
+ private static final StackTraceElement SHARED_FRAME_TWO =
+ new StackTraceElement("example.Shared", "sharedTwo",
"Shared.java", 20);
+
+ @Test
+ void appenderSnapshotsFormattedMessageAndThrowable() {
+ LogStoreImpl store = new LogStoreImpl(5);
+ LogStoreAppender appender = new LogStoreAppender(store);
+
+ LoggerContext context = new LoggerContext();
+ appender.setContext(context);
+ Logger logger = context.getLogger("test.logger");
+ RuntimeException failure = new RuntimeException("error");
+ LoggingEvent event = new LoggingEvent(getClass().getName(), logger,
Level.ERROR, "message", failure, null);
+ event.setMDCPropertyMap(Map.of("requestId", "123"));
+ event.setThreadName("worker-1");
+
+ appender.append(event);
+
+ List<LogEntry> logs = store.getRecent(null, LogLevel.TRACE, 10);
+ assertEquals(1, logs.size());
+ assertEquals("message", logs.get(0).formattedMessage());
+ assertEquals("worker-1", logs.get(0).threadName());
+ assertEquals(LogLevel.ERROR, logs.get(0).level());
+ assertEquals(Map.of("requestId", "123"), logs.get(0).mdc());
+ assertNotNull(logs.get(0).throwableText());
+ }
+
+ @Test
+ void appenderFormatsSuppressedCauseAndCommonFrames() {
+ LogStoreImpl store = new LogStoreImpl(5);
+ LogStoreAppender appender = new LogStoreAppender(store);
+ String expectedThrowableText = """
+ java.lang.RuntimeException: top level
+ at example.Top.run(Top.java:50)
+ at example.Shared.sharedOne(Shared.java:10)
+ at example.Shared.sharedTwo(Shared.java:20)
+ Suppressed: java.lang.IllegalStateException: suppressed
+ at example.Suppressed.hide(Suppressed.java:40)
+ ... 2 common frames omitted
+ Caused by: java.lang.IllegalArgumentException: root cause
+ at example.Cause.explode(Cause.java:30)
+ ... 2 common frames omitted
+ """;
+
+ LoggerContext context = new LoggerContext();
+ appender.setContext(context);
+ Logger logger = context.getLogger("test.logger");
+
+ IllegalArgumentException cause = exceptionWithSharedFrames(
+ new IllegalArgumentException("root cause"), "example.Cause",
"explode", "Cause.java", 30);
+
+ IllegalStateException suppressed = exceptionWithSharedFrames(
+ new IllegalStateException("suppressed"), "example.Suppressed",
"hide", "Suppressed.java", 40);
+
+ RuntimeException failure = new RuntimeException("top level", cause);
+ exceptionWithSharedFrames(failure, "example.Top", "run", "Top.java",
50);
+ failure.addSuppressed(suppressed);
+
+ LoggingEvent event = new LoggingEvent(getClass().getName(), logger,
Level.ERROR, "message", failure, null);
+ event.setMDCPropertyMap(Map.of());
+ event.setThreadName("worker-1");
+
+ appender.append(event);
+
+ List<LogEntry> logs = store.getRecent(null, LogLevel.TRACE, 10);
+ assertEquals(1, logs.size());
+ assertEquals(expectedThrowableText, logs.get(0).throwableText());
+ }
+
+ private <T extends Throwable> T exceptionWithSharedFrames(
+ T throwable, String className, String methodName, String fileName,
int lineNumber) {
+ throwable.setStackTrace(new StackTraceElement[] {
+ new StackTraceElement(className, methodName, fileName,
lineNumber), SHARED_FRAME_ONE, SHARED_FRAME_TWO
+ });
+ return throwable;
+ }
+}
diff --git
a/src/test/java/org/apache/sling/commons/log/logback/internal/store/LogStoreImplTest.java
b/src/test/java/org/apache/sling/commons/log/logback/internal/store/LogStoreImplTest.java
new file mode 100644
index 0000000..2d056bc
--- /dev/null
+++
b/src/test/java/org/apache/sling/commons/log/logback/internal/store/LogStoreImplTest.java
@@ -0,0 +1,93 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied. See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+package org.apache.sling.commons.log.logback.internal.store;
+
+import java.util.List;
+import java.util.Map;
+import java.util.regex.Pattern;
+import java.util.stream.Collectors;
+
+import org.apache.sling.commons.log.logback.store.LogEntry;
+import org.apache.sling.commons.log.logback.store.LogLevel;
+import org.junit.jupiter.api.Test;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+
+class LogStoreImplTest {
+
+ @Test
+ void keepsOnlyNewestEntriesWithinCapacity() {
+ LogStoreImpl store = new LogStoreImpl(2);
+
+ store.append(logEntry(1L, LogLevel.INFO, "first"));
+ store.append(logEntry(2L, LogLevel.INFO, "second"));
+ store.append(logEntry(3L, LogLevel.INFO, "third"));
+
+ List<LogEntry> logs = store.getRecent(null, LogLevel.TRACE, 10);
+ assertEquals(
+ List.of("third", "second"),
+
logs.stream().map(LogEntry::formattedMessage).collect(Collectors.toList()));
+ }
+
+ @Test
+ void filtersByLevelAndRegex() {
+ LogStoreImpl store = new LogStoreImpl(10);
+
+ store.append(logEntry(1L, LogLevel.DEBUG, "debug trace"));
+ store.append(logEntry(2L, LogLevel.INFO, "first user ok"));
+ store.append(logEntry(3L, LogLevel.ERROR, "first user failure"));
+
+ List<LogEntry> logs = store.getRecent(Pattern.compile("first",
Pattern.CASE_INSENSITIVE), LogLevel.INFO, 10);
+
+ assertEquals(
+ List.of("first user failure", "first user ok"),
+
logs.stream().map(LogEntry::formattedMessage).collect(Collectors.toList()));
+ }
+
+ @Test
+ void defaultsToTraceWhenMinLevelIsNull() {
+ LogStoreImpl store = new LogStoreImpl(10);
+ store.append(logEntry(1L, LogLevel.DEBUG, "debug trace"));
+
+ List<LogEntry> logs = store.getRecent(null, null, 10);
+
+ assertEquals(
+ List.of("debug trace"),
+
logs.stream().map(LogEntry::formattedMessage).collect(Collectors.toList()));
+ }
+
+ @Test
+ void shrinksStoreWhenMaxEntriesIsLowered() {
+ LogStoreImpl store = new LogStoreImpl(3);
+
+ store.append(logEntry(1L, LogLevel.INFO, "first"));
+ store.append(logEntry(2L, LogLevel.INFO, "second"));
+ store.append(logEntry(3L, LogLevel.INFO, "third"));
+ store.setMaxEntries(2);
+
+ List<LogEntry> logs = store.getRecent(null, LogLevel.TRACE, 10);
+ assertEquals(
+ List.of("third", "second"),
+
logs.stream().map(LogEntry::formattedMessage).collect(Collectors.toList()));
+ }
+
+ private LogEntry logEntry(long timeMillis, LogLevel level, String message)
{
+ return new LogEntry(timeMillis, level, "logger", "thread", message,
null, Map.of());
+ }
+}