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-whiteboard.git


The following commit(s) were added to refs/heads/master by this push:
     new 2da7f58b fix(mcp-server-contributions): the log tool now has access to 
logback messages
2da7f58b is described below

commit 2da7f58b8a149ba3ee18068d2d4bbb0f87aeacfa
Author: Robert Munteanu <[email protected]>
AuthorDate: Fri Apr 24 15:41:51 2026 +0200

    fix(mcp-server-contributions): the log tool now has access to logback 
messages
    
    Previously it would only get events from the OSGi LogService, which are a 
small subset of all
    logging events of interest.
---
 mcp-server-contributions/pom.xml                   |  33 +++-
 .../server/impl/contribs/LogToolContribution.java  | 218 +++++----------------
 .../server/impl/contribs/internal/LogSnapshot.java |  42 ++++
 .../contribs/internal/StructuredLogBuffer.java     | 100 ++++++++++
 .../internal/StructuredLogBufferAppender.java      | 173 ++++++++++++++++
 .../internal/StructuredLogBufferAppenderTest.java  |  62 ++++++
 .../contribs/internal/StructuredLogBufferTest.java |  64 ++++++
 7 files changed, 515 insertions(+), 177 deletions(-)

diff --git a/mcp-server-contributions/pom.xml b/mcp-server-contributions/pom.xml
index f63c232c..6299a91b 100644
--- a/mcp-server-contributions/pom.xml
+++ b/mcp-server-contributions/pom.xml
@@ -108,9 +108,21 @@
             <scope>provided</scope>
         </dependency>
         <dependency>
-            <groupId>org.osgi</groupId>
-            <artifactId>org.osgi.service.log</artifactId>
-            <version>1.3.0</version>
+            <groupId>org.slf4j</groupId>
+            <artifactId>slf4j-api</artifactId>
+            <version>1.7.32</version>
+            <scope>provided</scope>
+        </dependency>
+        <dependency>
+            <groupId>ch.qos.logback</groupId>
+            <artifactId>logback-core</artifactId>
+            <version>1.2.13</version>
+            <scope>provided</scope>
+        </dependency>
+        <dependency>
+            <groupId>ch.qos.logback</groupId>
+            <artifactId>logback-classic</artifactId>
+            <version>1.2.13</version>
             <scope>provided</scope>
         </dependency>
         <dependency>
@@ -119,6 +131,21 @@
             <version>0.1.0</version>
             <scope>provided</scope>
         </dependency>
+        <dependency>
+            <groupId>org.junit.jupiter</groupId>
+            <artifactId>junit-jupiter-api</artifactId>
+            <scope>test</scope>
+        </dependency>
+        <dependency>
+            <groupId>org.junit.jupiter</groupId>
+            <artifactId>junit-jupiter-engine</artifactId>
+            <scope>test</scope>
+        </dependency>
+        <dependency>
+            <groupId>org.osgi</groupId>
+            <artifactId>org.osgi.util.converter</artifactId>
+            <scope>test</scope>
+        </dependency>
     </dependencies>
 
     <build>
diff --git 
a/mcp-server-contributions/src/main/java/org/apache/sling/mcp/server/impl/contribs/LogToolContribution.java
 
b/mcp-server-contributions/src/main/java/org/apache/sling/mcp/server/impl/contribs/LogToolContribution.java
index 14d549de..0ec2db36 100644
--- 
a/mcp-server-contributions/src/main/java/org/apache/sling/mcp/server/impl/contribs/LogToolContribution.java
+++ 
b/mcp-server-contributions/src/main/java/org/apache/sling/mcp/server/impl/contribs/LogToolContribution.java
@@ -18,29 +18,23 @@
  */
 package org.apache.sling.mcp.server.impl.contribs;
 
-import java.io.PrintWriter;
-import java.io.StringWriter;
 import java.text.SimpleDateFormat;
-import java.util.ArrayList;
 import java.util.Date;
-import java.util.Enumeration;
 import java.util.List;
+import java.util.Map;
 import java.util.regex.Pattern;
 import java.util.regex.PatternSyntaxException;
 
+import ch.qos.logback.classic.Level;
 import io.modelcontextprotocol.json.McpJsonMapperSupplier;
 import 
io.modelcontextprotocol.server.McpStatelessServerFeatures.SyncToolSpecification;
 import io.modelcontextprotocol.spec.McpSchema.CallToolResult;
 import io.modelcontextprotocol.spec.McpSchema.Tool;
+import org.apache.sling.mcp.server.impl.contribs.internal.LogSnapshot;
+import 
org.apache.sling.mcp.server.impl.contribs.internal.StructuredLogBufferAppender;
 import org.apache.sling.mcp.server.spi.McpServerContribution;
-import org.osgi.framework.Bundle;
-import org.osgi.framework.Constants;
-import org.osgi.framework.ServiceReference;
 import org.osgi.service.component.annotations.Component;
 import org.osgi.service.component.annotations.Reference;
-import org.osgi.service.log.LogEntry;
-import org.osgi.service.log.LogReaderService;
-import org.osgi.service.log.LogService;
 
 /**
  * MCP Tool that provides access to logs with filtering capabilities.
@@ -50,7 +44,7 @@ import org.osgi.service.log.LogService;
 public class LogToolContribution implements McpServerContribution {
 
     @Reference
-    private LogReaderService logReaderService;
+    private StructuredLogBufferAppender structuredLogBufferAppender;
 
     @Reference
     private McpJsonMapperSupplier jsonMapper;
@@ -105,10 +99,10 @@ public class LogToolContribution implements 
McpServerContribution {
                         maxEntries = Math.min(maxEntries, 1000); // Cap at 1000
                     }
 
-                    int minLogLevel = LogService.LOG_ERROR;
+                    Level minLogLevel = Level.ERROR;
                     if (logLevelStr != null && !logLevelStr.isEmpty()) {
                         minLogLevel = parseLogLevel(logLevelStr);
-                        if (minLogLevel == -1) {
+                        if (minLogLevel == null) {
                             return CallToolResult.builder()
                                     .addTextContent("Invalid log level: " + 
logLevelStr
                                             + ". Valid options are: ERROR, 
WARN, INFO, DEBUG, TRACE")
@@ -130,8 +124,8 @@ public class LogToolContribution implements 
McpServerContribution {
                         }
                     }
 
-                    // Collect and filter logs
-                    List<LogEntry> filteredLogs = collectLogs(pattern, 
minLogLevel, maxEntries);
+                    List<LogSnapshot> filteredLogs =
+                            
structuredLogBufferAppender.getBuffer().getRecent(pattern, minLogLevel, 
maxEntries);
 
                     // Format output
                     String result = formatLogs(filteredLogs, regexPattern, 
minLogLevel, maxEntries);
@@ -140,89 +134,18 @@ public class LogToolContribution implements 
McpServerContribution {
                 }));
     }
 
-    private List<LogEntry> collectLogs(Pattern pattern, int minLogLevel, int 
maxEntries) {
-        List<LogEntry> logs = new ArrayList<>();
-
-        @SuppressWarnings("unchecked")
-        Enumeration<LogEntry> logEntries = logReaderService.getLog();
-        while (logEntries.hasMoreElements() && logs.size() < maxEntries) {
-            LogEntry entry = logEntries.nextElement();
-
-            // Filter by log level (lower values = higher severity)
-            if (entry.getLevel() > minLogLevel) {
-                continue;
-            }
-
-            // Filter by regex pattern if provided - search entire log entry
-            if (pattern != null) {
-                String fullLogEntry = buildFullLogEntryText(entry);
-                if (!pattern.matcher(fullLogEntry).find()) {
-                    continue;
-                }
-            }
-
-            logs.add(entry);
-        }
-
-        return logs;
-    }
-
-    private String buildFullLogEntryText(LogEntry entry) {
-        StringBuilder text = new StringBuilder();
-
-        // Add log level
-        text.append(getLogLevelName(entry.getLevel())).append(" ");
-
-        // Add bundle name
-        Bundle bundle = entry.getBundle();
-        if (bundle != null) {
-            text.append(getBundleName(bundle)).append(" ");
-        }
-
-        // Add message
-        String message = entry.getMessage();
-        if (message != null) {
-            text.append(message).append(" ");
-        }
-
-        // Add service reference info
-        ServiceReference<?> serviceRef = entry.getServiceReference();
-        if (serviceRef != null) {
-            String serviceDesc = getServiceDescription(serviceRef);
-            if (serviceDesc != null && !serviceDesc.isEmpty()) {
-                text.append(serviceDesc).append(" ");
-            }
-        }
-
-        // Add exception info
-        Throwable exception = entry.getException();
-        if (exception != null) {
-            text.append(exception.getClass().getName()).append(" ");
-            if (exception.getMessage() != null) {
-                text.append(exception.getMessage()).append(" ");
-            }
-
-            // Add stack trace
-            StringWriter sw = new StringWriter();
-            PrintWriter pw = new PrintWriter(sw);
-            exception.printStackTrace(pw);
-            text.append(sw.toString());
-        }
-
-        return text.toString();
-    }
-
-    private int parseLogLevel(String levelStr) {
+    private Level parseLogLevel(String levelStr) {
         return switch (levelStr.toUpperCase()) {
-            case "ERROR" -> LogService.LOG_ERROR;
-            case "WARN", "WARNING" -> LogService.LOG_WARNING;
-            case "INFO" -> LogService.LOG_INFO;
-            case "DEBUG" -> LogService.LOG_DEBUG;
-            default -> -1;
+            case "ERROR" -> Level.ERROR;
+            case "WARN", "WARNING" -> Level.WARN;
+            case "INFO" -> Level.INFO;
+            case "DEBUG" -> Level.DEBUG;
+            case "TRACE" -> Level.TRACE;
+            default -> null;
         };
     }
 
-    private String formatLogs(List<LogEntry> logs, String regexPattern, int 
minLogLevel, int maxEntries) {
+    private String formatLogs(List<LogSnapshot> logs, String regexPattern, 
Level minLogLevel, int maxEntries) {
         StringBuilder result = new StringBuilder();
 
         result.append("=== Log Entries ===\n\n");
@@ -242,7 +165,7 @@ public class LogToolContribution implements 
McpServerContribution {
         result.append("━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\n\n");
 
         for (int i = 0; i < logs.size(); i++) {
-            LogEntry entry = logs.get(i);
+            LogSnapshot entry = logs.get(i);
             formatLogEntry(entry, i + 1, result);
 
             if (i < logs.size() - 1) {
@@ -253,49 +176,29 @@ public class LogToolContribution implements 
McpServerContribution {
         return result.toString();
     }
 
-    private void formatLogEntry(LogEntry entry, int index, StringBuilder 
result) {
+    private void formatLogEntry(LogSnapshot entry, int index, StringBuilder 
result) {
         result.append("[").append(index).append("] ");
-        result.append(DATE_FORMAT.format(new Date(entry.getTime())));
-        result.append(" 
[").append(getLogLevelName(entry.getLevel())).append("] ");
-
-        // Add bundle information
-        Bundle bundle = entry.getBundle();
-        if (bundle != null) {
-            String bundleName = getBundleName(bundle);
-            result.append("[").append(bundleName).append("] ");
-        }
+        result.append(DATE_FORMAT.format(new Date(entry.timeMillis())));
+        result.append(" [").append(getLogLevelName(entry.level())).append("] 
");
+        result.append("[")
+                .append(entry.loggerName() != null ? entry.loggerName() : 
"(unknown logger)")
+                .append("] ");
 
-        // Add message
-        String message = entry.getMessage();
+        String message = entry.formattedMessage();
         result.append(message != null ? message : "(no message)");
         result.append("\n");
 
-        // Add service reference info if available
-        ServiceReference<?> serviceRef = entry.getServiceReference();
-        if (serviceRef != null) {
-            String serviceDesc = getServiceDescription(serviceRef);
-            if (serviceDesc != null && !serviceDesc.isEmpty()) {
-                result.append("    Service: 
").append(serviceDesc).append("\n");
-            }
+        if (entry.threadName() != null && !entry.threadName().isEmpty()) {
+            result.append("    Thread: 
").append(entry.threadName()).append("\n");
         }
 
-        // Add exception info if available
-        Throwable exception = entry.getException();
-        if (exception != null) {
-            result.append("    Exception: 
").append(exception.getClass().getName());
-            if (exception.getMessage() != null) {
-                result.append(": ").append(exception.getMessage());
-            }
-            result.append("\n");
-
-            // Add stack trace (first few lines)
-            StringWriter sw = new StringWriter();
-            PrintWriter pw = new PrintWriter(sw);
-            exception.printStackTrace(pw);
-            String stackTrace = sw.toString();
+        if (!entry.mdc().isEmpty()) {
+            result.append("    MDC: 
").append(formatMdc(entry.mdc())).append("\n");
+        }
 
-            // Limit stack trace to first 10 lines
-            String[] lines = stackTrace.split("\n");
+        String throwableText = entry.throwableText();
+        if (throwableText != null && !throwableText.isEmpty()) {
+            String[] lines = throwableText.split("\n");
             int maxLines = Math.min(lines.length, 10);
             for (int i = 0; i < maxLines; i++) {
                 result.append("      ").append(lines[i]).append("\n");
@@ -306,53 +209,20 @@ public class LogToolContribution implements 
McpServerContribution {
         }
     }
 
-    private String getBundleName(Bundle bundle) {
-        String name = bundle.getHeaders().get(Constants.BUNDLE_NAME);
-        if (name == null || name.isEmpty()) {
-            name = bundle.getSymbolicName();
-        }
-        if (name == null || name.isEmpty()) {
-            name = "Bundle#" + bundle.getBundleId();
-        }
-        return name;
-    }
-
-    private String getServiceDescription(ServiceReference<?> ref) {
-        if (ref == null) {
-            return null;
-        }
-
-        Object serviceId = ref.getProperty(Constants.SERVICE_ID);
-        Object objectClass = ref.getProperty(Constants.OBJECTCLASS);
-
-        StringBuilder desc = new StringBuilder();
-        if (objectClass instanceof String[]) {
-            String[] classes = (String[]) objectClass;
-            if (classes.length > 0) {
-                desc.append(classes[0]);
-                if (classes.length > 1) {
-                    desc.append(" (").append(classes.length - 1).append(" more 
interfaces)");
-                }
-            }
-        }
-
-        if (serviceId != null) {
-            if (desc.length() > 0) {
-                desc.append(" ");
+    private String formatMdc(Map<String, String> mdc) {
+        StringBuilder result = new StringBuilder();
+        boolean first = true;
+        for (Map.Entry<String, String> entry : mdc.entrySet()) {
+            if (!first) {
+                result.append(", ");
             }
-            desc.append("[id=").append(serviceId).append("]");
+            result.append(entry.getKey()).append('=').append(entry.getValue());
+            first = false;
         }
-
-        return desc.toString();
+        return result.toString();
     }
 
-    private String getLogLevelName(int level) {
-        return switch (level) {
-            case LogService.LOG_ERROR -> "ERROR";
-            case LogService.LOG_WARNING -> "WARN";
-            case LogService.LOG_INFO -> "INFO";
-            case LogService.LOG_DEBUG -> "DEBUG";
-            default -> "LEVEL_" + level;
-        };
+    private String getLogLevelName(Level level) {
+        return level != null ? level.levelStr : "UNKNOWN";
     }
 }
diff --git 
a/mcp-server-contributions/src/main/java/org/apache/sling/mcp/server/impl/contribs/internal/LogSnapshot.java
 
b/mcp-server-contributions/src/main/java/org/apache/sling/mcp/server/impl/contribs/internal/LogSnapshot.java
new file mode 100644
index 00000000..80dce2f5
--- /dev/null
+++ 
b/mcp-server-contributions/src/main/java/org/apache/sling/mcp/server/impl/contribs/internal/LogSnapshot.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.mcp.server.impl.contribs.internal;
+
+import java.util.Collections;
+import java.util.Map;
+
+import ch.qos.logback.classic.Level;
+
+/**
+ * Stores only the lightweight, stable parts of a log event so the in-memory 
buffer
+ * does not retain full {@code ILoggingEvent} object graphs.
+ */
+public record LogSnapshot(
+        long timeMillis,
+        Level level,
+        String loggerName,
+        String threadName,
+        String formattedMessage,
+        String throwableText,
+        Map<String, String> mdc) {
+
+    public LogSnapshot {
+        mdc = mdc == null ? Collections.emptyMap() : 
Collections.unmodifiableMap(mdc);
+    }
+}
diff --git 
a/mcp-server-contributions/src/main/java/org/apache/sling/mcp/server/impl/contribs/internal/StructuredLogBuffer.java
 
b/mcp-server-contributions/src/main/java/org/apache/sling/mcp/server/impl/contribs/internal/StructuredLogBuffer.java
new file mode 100644
index 00000000..80c64fe3
--- /dev/null
+++ 
b/mcp-server-contributions/src/main/java/org/apache/sling/mcp/server/impl/contribs/internal/StructuredLogBuffer.java
@@ -0,0 +1,100 @@
+/*
+ * 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.mcp.server.impl.contribs.internal;
+
+import java.util.ArrayDeque;
+import java.util.ArrayList;
+import java.util.Deque;
+import java.util.List;
+import java.util.regex.Pattern;
+
+import ch.qos.logback.classic.Level;
+
+public class StructuredLogBuffer {
+
+    private final Object lock = new Object();
+    private final Deque<LogSnapshot> entries = new ArrayDeque<>();
+    private int maxEntriesKept;
+
+    public StructuredLogBuffer(int maxEntriesKept) {
+        this.maxEntriesKept = Math.max(1, maxEntriesKept);
+    }
+
+    public void append(LogSnapshot snapshot) {
+        synchronized (lock) {
+            entries.addLast(snapshot);
+            trimToSize();
+        }
+    }
+
+    public List<LogSnapshot> getRecent(Pattern pattern, Level minLevel, int 
maxEntries) {
+        synchronized (lock) {
+            List<LogSnapshot> matches = new ArrayList<>();
+            int remaining = Math.max(1, maxEntries);
+
+            for (var iterator = entries.descendingIterator(); 
iterator.hasNext() && remaining > 0; ) {
+                LogSnapshot snapshot = iterator.next();
+                if (!matches(snapshot, pattern, minLevel)) {
+                    continue;
+                }
+                matches.add(snapshot);
+                remaining--;
+            }
+
+            return matches;
+        }
+    }
+
+    private boolean matches(LogSnapshot snapshot, Pattern pattern, Level 
minLevel) {
+        if (snapshot.level().isGreaterOrEqual(minLevel)) {
+            if (pattern == null) {
+                return true;
+            }
+            return matchesField(pattern, snapshot.level() != null ? 
snapshot.level().levelStr : null)
+                    || 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, LogSnapshot snapshot) {
+        if (snapshot.mdc().isEmpty()) {
+            return false;
+        }
+        for (var 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/mcp-server-contributions/src/main/java/org/apache/sling/mcp/server/impl/contribs/internal/StructuredLogBufferAppender.java
 
b/mcp-server-contributions/src/main/java/org/apache/sling/mcp/server/impl/contribs/internal/StructuredLogBufferAppender.java
new file mode 100644
index 00000000..95a02db0
--- /dev/null
+++ 
b/mcp-server-contributions/src/main/java/org/apache/sling/mcp/server/impl/contribs/internal/StructuredLogBufferAppender.java
@@ -0,0 +1,173 @@
+/*
+ * 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.mcp.server.impl.contribs.internal;
+
+import java.lang.invoke.MethodHandle;
+import java.lang.invoke.MethodHandles;
+import java.lang.invoke.MethodType;
+import java.util.LinkedHashMap;
+import java.util.Map;
+
+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.Appender;
+import ch.qos.logback.core.AppenderBase;
+import org.osgi.service.component.annotations.Activate;
+import org.osgi.service.component.annotations.Component;
+import org.osgi.service.metatype.annotations.AttributeDefinition;
+import org.osgi.service.metatype.annotations.Designate;
+import org.osgi.service.metatype.annotations.ObjectClassDefinition;
+
+@Component(
+        service = {Appender.class, StructuredLogBufferAppender.class},
+        property = {
+            "loggers=ROOT",
+            "service.description=Structured in-memory MCP log appender",
+            "service.vendor=The Apache Software Foundation"
+        })
+@Designate(ocd = StructuredLogBufferAppender.Configuration.class)
+public class StructuredLogBufferAppender extends AppenderBase<ILoggingEvent> {
+
+    // Forward compatibility with logback 1.5+, where IThrowableProxy may 
expose getOverridingMessage().
+    private static final MethodHandle GET_OVERRIDING_MESSAGE = 
findGetOverridingMessage();
+
+    @ObjectClassDefinition(name = "Apache Sling MCP Structured Log Buffer")
+    public @interface Configuration {
+
+        @AttributeDefinition(name = "Max entries")
+        int maxEntries() default 10000;
+    }
+
+    private final StructuredLogBuffer buffer;
+
+    @Activate
+    public StructuredLogBufferAppender(Configuration configuration) {
+        buffer = new StructuredLogBuffer(configuration.maxEntries());
+        setName("mcp-structured-log-buffer");
+    }
+
+    public StructuredLogBuffer getBuffer() {
+        return buffer;
+    }
+
+    @Override
+    protected void append(ILoggingEvent eventObject) {
+        if (eventObject == null) {
+            return;
+        }
+
+        buffer.append(new LogSnapshot(
+                eventObject.getTimeStamp(),
+                eventObject.getLevel(),
+                eventObject.getLoggerName(),
+                eventObject.getThreadName(),
+                eventObject.getFormattedMessage(),
+                getThrowableText(eventObject),
+                copyMdc(eventObject)));
+    }
+
+    private Map<String, String> copyMdc(ILoggingEvent eventObject) {
+        Map<String, String> mdc = eventObject.getMDCPropertyMap();
+        if (mdc == null || mdc.isEmpty()) {
+            return Map.of();
+        }
+        return new LinkedHashMap<>(mdc);
+    }
+
+    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 = getOverridingMessage(throwableProxy);
+        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();
+    }
+
+    private String getOverridingMessage(IThrowableProxy throwableProxy) {
+        if (GET_OVERRIDING_MESSAGE == null) {
+            return null;
+        }
+
+        try {
+            Object overridingMessage = 
GET_OVERRIDING_MESSAGE.invoke(throwableProxy);
+            return overridingMessage instanceof String ? (String) 
overridingMessage : null;
+        } catch (Throwable e) {
+            return null;
+        }
+    }
+
+    private static MethodHandle findGetOverridingMessage() {
+        try {
+            return MethodHandles.publicLookup()
+                    .findVirtual(IThrowableProxy.class, 
"getOverridingMessage", MethodType.methodType(String.class));
+        } catch (NoSuchMethodException | IllegalAccessException e) {
+            return null;
+        }
+    }
+}
diff --git 
a/mcp-server-contributions/src/test/java/org/apache/sling/mcp/server/impl/contribs/internal/StructuredLogBufferAppenderTest.java
 
b/mcp-server-contributions/src/test/java/org/apache/sling/mcp/server/impl/contribs/internal/StructuredLogBufferAppenderTest.java
new file mode 100644
index 00000000..c9064e72
--- /dev/null
+++ 
b/mcp-server-contributions/src/test/java/org/apache/sling/mcp/server/impl/contribs/internal/StructuredLogBufferAppenderTest.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.mcp.server.impl.contribs.internal;
+
+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.junit.jupiter.api.Test;
+import org.osgi.util.converter.Converters;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertNotNull;
+
+class StructuredLogBufferAppenderTest {
+
+    @Test
+    void appenderSnapshotsFormattedMessageAndThrowable() {
+        StructuredLogBufferAppender appender = new 
StructuredLogBufferAppender(configuration(5));
+
+        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(java.util.Map.of());
+        event.setThreadName("worker-1");
+
+        appender.append(event);
+
+        List<LogSnapshot> logs = appender.getBuffer().getRecent(null, 
Level.TRACE, 10);
+        assertEquals(1, logs.size());
+        assertEquals("message", logs.get(0).formattedMessage());
+        assertEquals("worker-1", logs.get(0).threadName());
+        assertNotNull(logs.get(0).throwableText());
+    }
+
+    private StructuredLogBufferAppender.Configuration configuration(int 
maxEntries) {
+        return Converters.standardConverter()
+                .convert(Map.of("maxEntries", maxEntries))
+                .to(StructuredLogBufferAppender.Configuration.class);
+    }
+}
diff --git 
a/mcp-server-contributions/src/test/java/org/apache/sling/mcp/server/impl/contribs/internal/StructuredLogBufferTest.java
 
b/mcp-server-contributions/src/test/java/org/apache/sling/mcp/server/impl/contribs/internal/StructuredLogBufferTest.java
new file mode 100644
index 00000000..d92efced
--- /dev/null
+++ 
b/mcp-server-contributions/src/test/java/org/apache/sling/mcp/server/impl/contribs/internal/StructuredLogBufferTest.java
@@ -0,0 +1,64 @@
+/*
+ * 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.mcp.server.impl.contribs.internal;
+
+import java.util.List;
+import java.util.Map;
+import java.util.regex.Pattern;
+
+import ch.qos.logback.classic.Level;
+import org.junit.jupiter.api.Test;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+
+class StructuredLogBufferTest {
+
+    @Test
+    void keepsOnlyNewestEntriesWithinCapacity() {
+        StructuredLogBuffer buffer = new StructuredLogBuffer(2);
+
+        buffer.append(snapshot(1L, Level.INFO, "first"));
+        buffer.append(snapshot(2L, Level.INFO, "second"));
+        buffer.append(snapshot(3L, Level.INFO, "third"));
+
+        List<LogSnapshot> logs = buffer.getRecent(null, Level.TRACE, 10);
+        assertEquals(
+                List.of("third", "second"),
+                logs.stream().map(LogSnapshot::formattedMessage).toList());
+    }
+
+    @Test
+    void filtersByLevelAndRegex() {
+        StructuredLogBuffer buffer = new StructuredLogBuffer(10);
+
+        buffer.append(snapshot(1L, Level.DEBUG, "debug trace"));
+        buffer.append(snapshot(2L, Level.INFO, "first user ok"));
+        buffer.append(snapshot(3L, Level.ERROR, "first user failure"));
+
+        List<LogSnapshot> logs = buffer.getRecent(Pattern.compile("first", 
Pattern.CASE_INSENSITIVE), Level.INFO, 10);
+
+        assertEquals(
+                List.of("first user failure", "first user ok"),
+                logs.stream().map(LogSnapshot::formattedMessage).toList());
+    }
+
+    private LogSnapshot snapshot(long timeMillis, Level level, String message) 
{
+        return new LogSnapshot(timeMillis, level, "logger", "thread", message, 
null, Map.of());
+    }
+}


Reply via email to