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 <[email protected]>
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());
+    }
+}


Reply via email to