You are viewing a plain text version of this content. The canonical link for it is here.
Posted to commits@sling.apache.org by ro...@apache.org on 2026/05/19 15:42:32 UTC
(sling-org-apache-sling-commons-log) 01/01: SLING-13188 - Provide an optional in-memory store of recent log events
This is an automated email from the ASF dual-hosted git repository.
rombert pushed a commit to branch issue/SLING-13188
in repository https://gitbox.apache.org/repos/asf/sling-org-apache-sling-commons-log.git
commit 298f115dfe7a9ee2add7057decd9e396054004e1
Author: Robert Munteanu <ro...@apache.org>
AuthorDate: Tue May 19 17:40:20 2026 +0200
SLING-13188 - Provide an optional in-memory store of recent log events
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 | 121 +++++++++++++++++++
.../logback/internal/store/LogStoreRegistrar.java | 126 +++++++++++++++++++
.../sling/commons/log/logback/store/LogEntry.java | 116 ++++++++++++++++++
.../sling/commons/log/logback/store/LogLevel.java | 30 +++++
.../sling/commons/log/logback/store/LogStore.java | 33 +++++
.../commons/log/logback/store/package-info.java | 31 +++++
.../integration/ITLogStoreRegistrarLifecycle.java | 85 +++++++++++++
.../internal/store/LogStoreAppenderTest.java | 62 ++++++++++
.../logback/internal/store/LogStoreImplTest.java | 91 ++++++++++++++
11 files changed, 835 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..998c27e 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
@@ -63,6 +63,7 @@ import ch.qos.logback.core.status.StatusListener;
import ch.qos.logback.core.status.StatusListenerAsList;
import ch.qos.logback.core.status.StatusUtil;
import org.apache.sling.commons.log.logback.internal.AppenderTracker.AppenderInfo;
+import org.apache.sling.commons.log.logback.internal.store.LogStoreRegistrar;
import org.apache.sling.commons.log.logback.internal.config.ConfigAdminSupport;
import org.apache.sling.commons.log.logback.internal.config.ConfigurationException;
import org.apache.sling.commons.log.logback.internal.joran.JoranConfiguratorWrapper;
@@ -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..99493c7
--- /dev/null
+++ b/src/main/java/org/apache/sling/commons/log/logback/internal/store/LogStoreImpl.java
@@ -0,0 +1,121 @@
+/*
+ * 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() {
+ this(DEFAULT_MAX_ENTRIES);
+ }
+
+ 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..570c8f5
--- /dev/null
+++ b/src/main/java/org/apache/sling/commons/log/logback/internal/store/LogStoreRegistrar.java
@@ -0,0 +1,126 @@
+/*
+ * 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;
+
+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;
+ }
+
+ if (store == null) {
+ activate();
+ }
+
+ store.setMaxEntries(getMaxEntries(properties));
+ }
+
+ private void activate() {
+ if (bundleContext == null || store != null) {
+ return;
+ }
+
+ store = new LogStoreImpl();
+ 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;
+ }
+
+ private int getMaxEntries(Dictionary<String, ?> properties) {
+ Object value = properties.get(PROP_MAX_ENTRIES);
+ if (value instanceof Number) {
+ return ((Number) value).intValue();
+ }
+ if (value instanceof String) {
+ try {
+ return Integer.parseInt((String) value);
+ } catch (NumberFormatException e) {
+ return LogStoreImpl.DEFAULT_MAX_ENTRIES;
+ }
+ }
+ return LogStoreImpl.DEFAULT_MAX_ENTRIES;
+ }
+}
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..7b50769
--- /dev/null
+++ b/src/main/java/org/apache/sling/commons/log/logback/store/LogEntry.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.store;
+
+import java.util.Collections;
+import java.util.HashMap;
+import java.util.Map;
+import java.util.Objects;
+
+/**
+ * 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 final class LogEntry {
+
+ private final long timeMillis;
+ private final LogLevel level;
+ private final String loggerName;
+ private final String threadName;
+ private final String formattedMessage;
+ private final String throwableText;
+ private final Map<String, String> mdc;
+
+ public LogEntry(
+ long timeMillis,
+ LogLevel level,
+ String loggerName,
+ String threadName,
+ String formattedMessage,
+ String throwableText,
+ Map<String, String> mdc) {
+ this.timeMillis = timeMillis;
+ this.level = level;
+ this.loggerName = loggerName;
+ this.threadName = threadName;
+ this.formattedMessage = formattedMessage;
+ this.throwableText = throwableText;
+ this.mdc = mdc.isEmpty() ? Collections.emptyMap() : Collections.unmodifiableMap(new HashMap<>(mdc));
+ }
+
+ public long timeMillis() {
+ return timeMillis;
+ }
+
+ public LogLevel level() {
+ return level;
+ }
+
+ public String loggerName() {
+ return loggerName;
+ }
+
+ public String threadName() {
+ return threadName;
+ }
+
+ public String formattedMessage() {
+ return formattedMessage;
+ }
+
+ public String throwableText() {
+ return throwableText;
+ }
+
+ public Map<String, String> mdc() {
+ return mdc;
+ }
+
+ @Override
+ public boolean equals(Object other) {
+ if (this == other) {
+ return true;
+ }
+ if (!(other instanceof LogEntry)) {
+ return false;
+ }
+ LogEntry that = (LogEntry) other;
+ return timeMillis == that.timeMillis
+ && Objects.equals(level, that.level)
+ && Objects.equals(loggerName, that.loggerName)
+ && Objects.equals(threadName, that.threadName)
+ && Objects.equals(formattedMessage, that.formattedMessage)
+ && Objects.equals(throwableText, that.throwableText)
+ && Objects.equals(mdc, that.mdc);
+ }
+
+ @Override
+ public int hashCode() {
+ return Objects.hash(timeMillis, level, loggerName, threadName, formattedMessage, throwableText, mdc);
+ }
+
+ @Override
+ public String toString() {
+ return "LogEntry{" + "timeMillis=" + timeMillis + ", level='" + level + '\'' + ", loggerName='"
+ + loggerName + '\'' + ", threadName='" + threadName + '\'' + ", formattedMessage='"
+ + formattedMessage + '\'' + ", throwableText='" + throwableText + '\'' + ", mdc=" + 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..8c6b99a
--- /dev/null
+++ b/src/main/java/org/apache/sling/commons/log/logback/store/LogStore.java
@@ -0,0 +1,33 @@
+/*
+ * 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 {
+
+ 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..a37710d
--- /dev/null
+++ b/src/main/java/org/apache/sling/commons/log/logback/store/package-info.java
@@ -0,0 +1,31 @@
+/*
+ * 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 an in-memory log store for 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
+ */
+@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..13b09a0
--- /dev/null
+++ b/src/test/java/org/apache/sling/commons/log/logback/integration/ITLogStoreRegistrarLifecycle.java
@@ -0,0 +1,85 @@
+/*
+ * 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..10ba978
--- /dev/null
+++ b/src/test/java/org/apache/sling/commons/log/logback/internal/store/LogStoreAppenderTest.java
@@ -0,0 +1,62 @@
+/*
+ * 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 static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertNotNull;
+
+import java.util.List;
+import java.util.Map;
+
+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 ch.qos.logback.classic.Level;
+import ch.qos.logback.classic.Logger;
+import ch.qos.logback.classic.LoggerContext;
+import ch.qos.logback.classic.spi.LoggingEvent;
+
+class LogStoreAppenderTest {
+
+ @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());
+ }
+
+}
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..86db938
--- /dev/null
+++ b/src/test/java/org/apache/sling/commons/log/logback/internal/store/LogStoreImplTest.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.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());
+ }
+}