feat(logs): parse log4j xml events in logs

Signed-off-by: Rachel Powers <508861+Ryex@users.noreply.github.com>
This commit is contained in:
Rachel Powers 2025-04-16 00:37:35 -07:00
parent 15a981b3f8
commit 47295da390
No known key found for this signature in database
GPG Key ID: E10E321EB160949B
7 changed files with 461 additions and 1 deletions

View File

@ -175,6 +175,8 @@ set(LAUNCH_SOURCES
launch/LogModel.h
launch/TaskStepWrapper.cpp
launch/TaskStepWrapper.h
logs/LogParser.cpp
logs/LogParser.h
)
# Old update system

View File

@ -4,6 +4,8 @@ MessageLevel::Enum MessageLevel::getLevel(const QString& levelName)
{
if (levelName == "Launcher")
return MessageLevel::Launcher;
else if (levelName == "Trace")
return MessageLevel::Trace;
else if (levelName == "Debug")
return MessageLevel::Debug;
else if (levelName == "Info")

View File

@ -12,6 +12,7 @@ enum Enum {
StdOut, /**< Undetermined stderr messages */
StdErr, /**< Undetermined stdout messages */
Launcher, /**< Launcher Messages */
Trace, /**< Trace Messages */
Debug, /**< Debug Messages */
Info, /**< Info Messages */
Message, /**< Standard Messages */

View File

@ -37,11 +37,14 @@
#include "launch/LaunchTask.h"
#include <assert.h>
#include <qlogging.h>
#include <QAnyStringView>
#include <QCoreApplication>
#include <QDebug>
#include <QDir>
#include <QRegularExpression>
#include <QStandardPaths>
#include <variant>
#include "MessageLevel.h"
#include "tasks/Task.h"
@ -213,6 +216,52 @@ shared_qobject_ptr<LogModel> LaunchTask::getLogModel()
return m_logModel;
}
bool LaunchTask::parseXmlLogs(QString const& line, MessageLevel::Enum level)
{
LogParser* parser;
switch (level) {
case MessageLevel::StdErr:
parser = &m_stderrParser;
break;
case MessageLevel::StdOut:
parser = &m_stdoutParser;
break;
default:
return false;
}
parser->appendLine(line);
auto items = parser->parseAvailable();
if (auto err = parser->getError(); err.has_value()) {
auto& model = *getLogModel();
model.append(MessageLevel::Error, tr("[Log4j Parse Error] Failed to parse log4j log event: %1").arg(err.value().errMessage));
return false;
} else {
if (items.has_value()) {
auto& model = *getLogModel();
for (auto const& item : items.value()) {
if (std::holds_alternative<LogParser::LogEntry>(item)) {
auto entry = std::get<LogParser::LogEntry>(item);
auto msg = QString("[%1] [%2/%3] [%4]: %5")
.arg(entry.timestamp.toString("HH:mm:ss"))
.arg(entry.thread)
.arg(entry.levelText)
.arg(entry.logger)
.arg(entry.message);
msg = censorPrivateInfo(msg);
model.append(entry.level, msg);
} else if (std::holds_alternative<LogParser::PlainText>(item)) {
auto msg = std::get<LogParser::PlainText>(item).message;
level = m_instance->guessLevel(msg, level);
msg = censorPrivateInfo(msg);
model.append(level, msg);
}
}
}
}
return true;
}
void LaunchTask::onLogLines(const QStringList& lines, MessageLevel::Enum defaultLevel)
{
for (auto& line : lines) {
@ -222,6 +271,10 @@ void LaunchTask::onLogLines(const QStringList& lines, MessageLevel::Enum default
void LaunchTask::onLogLine(QString line, MessageLevel::Enum level)
{
if (parseXmlLogs(line, level)) {
return;
}
// if the launcher part set a log level, use it
auto innerLevel = MessageLevel::fromLine(line);
if (innerLevel != MessageLevel::Unknown) {
@ -229,7 +282,7 @@ void LaunchTask::onLogLine(QString line, MessageLevel::Enum level)
}
// If the level is still undetermined, guess level
if (level == MessageLevel::StdErr || level == MessageLevel::StdOut || level == MessageLevel::Unknown) {
if (level == MessageLevel::Unknown) {
level = m_instance->guessLevel(line, level);
}

View File

@ -43,6 +43,7 @@
#include "LaunchStep.h"
#include "LogModel.h"
#include "MessageLevel.h"
#include "logs/LogParser.h"
class LaunchTask : public Task {
Q_OBJECT
@ -114,6 +115,9 @@ class LaunchTask : public Task {
private: /*methods */
void finalizeSteps(bool successful, const QString& error);
protected:
bool parseXmlLogs(QString const& line, MessageLevel::Enum level);
protected: /* data */
MinecraftInstancePtr m_instance;
shared_qobject_ptr<LogModel> m_logModel;
@ -122,4 +126,6 @@ class LaunchTask : public Task {
int currentStep = -1;
State state = NotStarted;
qint64 m_pid = -1;
LogParser m_stdoutParser;
LogParser m_stderrParser;
};

322
launcher/logs/LogParser.cpp Normal file
View File

@ -0,0 +1,322 @@
// SPDX-License-Identifier: GPL-3.0-only
/*
* Prism Launcher - Minecraft Launcher
* Copyright (C) 2025 Rachel Powers <508861+Ryex@users.noreply.github.com>
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, version 3.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*
*/
#include "LogParser.h"
void LogParser::appendLine(QAnyStringView data)
{
if (!m_partialData.isEmpty()) {
m_buffer = QString(m_partialData);
m_buffer.append("\n");
m_buffer.append(data.toString());
m_partialData.clear();
} else {
m_buffer.append(data.toString());
}
}
std::optional<LogParser::Error> LogParser::getError()
{
return m_error;
}
MessageLevel::Enum LogParser::parseLogLevel(const QString& level)
{
auto test = level.trimmed().toUpper();
if (test == "TRACE") {
return MessageLevel::Trace;
} else if (test == "DEBUG") {
return MessageLevel::Debug;
} else if (test == "INFO") {
return MessageLevel::Info;
} else if (test == "WARN") {
return MessageLevel::Warning;
} else if (test == "ERROR") {
return MessageLevel::Error;
} else if (test == "FATAL") {
return MessageLevel::Fatal;
} else {
return MessageLevel::Unknown;
}
}
std::optional<LogParser::LogEntry> LogParser::parseAttributes()
{
LogParser::LogEntry entry{
"",
MessageLevel::Info,
};
auto attributes = m_parser.attributes();
for (const auto& attr : attributes) {
auto name = attr.name();
auto value = attr.value();
if (name == "logger") {
entry.logger = value.trimmed().toString();
} else if (name == "timestamp") {
if (value.trimmed().isEmpty()) {
m_parser.raiseError("log4j:Event Missing required attribute: timestamp");
return {};
}
entry.timestamp = QDateTime::fromSecsSinceEpoch(value.trimmed().toLongLong());
} else if (name == "level") {
entry.levelText = value.trimmed().toString();
entry.level = parseLogLevel(entry.levelText);
} else if (name == "thread") {
entry.thread = value.trimmed().toString();
}
}
if (entry.logger.isEmpty()) {
m_parser.raiseError("log4j:Event Missing required attribute: logger");
return {};
}
return entry;
}
void LogParser::setError()
{
m_error = {
m_parser.errorString(),
m_parser.error(),
};
}
void LogParser::clearError()
{
m_error = {}; // clear previous error
}
bool isPotentialLog4JStart(QStringView buffer)
{
static QString target = QStringLiteral("<log4j:event");
if (buffer.isEmpty() || buffer[0] != '<') {
return false;
}
auto bufLower = buffer.toString().toLower();
return target.startsWith(bufLower) || bufLower.startsWith(target);
}
std::optional<LogParser::ParsedItem> LogParser::parseNext()
{
clearError();
if (m_buffer.isEmpty()) {
return {};
}
if (m_buffer.trimmed().isEmpty()) {
m_buffer.clear();
return {};
}
// check if we have a full xml log4j event
bool isCompleteLog4j = false;
m_parser.clear();
m_parser.setNamespaceProcessing(false);
m_parser.addData(m_buffer);
if (m_parser.readNextStartElement()) {
if (m_parser.qualifiedName() == "log4j:Event") {
int depth = 1;
bool eod = false;
while (depth > 0 && !eod) {
auto tok = m_parser.readNext();
switch (tok) {
case QXmlStreamReader::TokenType::StartElement: {
depth += 1;
} break;
case QXmlStreamReader::TokenType::EndElement: {
depth -= 1;
} break;
case QXmlStreamReader::TokenType::EndDocument: {
eod = true; // break outer while loop
} break;
default: {
// no op
}
}
if (m_parser.hasError()) {
break;
}
}
isCompleteLog4j = depth == 0;
}
}
if (isCompleteLog4j) {
return parseLog4J();
} else {
if (isPotentialLog4JStart(m_buffer)) {
m_partialData = QString(m_buffer);
return LogParser::Partial{ QString(m_buffer) };
}
int start = 0;
auto bufView = QStringView(m_buffer);
while (start < bufView.length()) {
if (qsizetype pos = bufView.right(bufView.length() - start).indexOf('<'); pos != -1) {
auto slicestart = start + pos;
auto slice = bufView.right(bufView.length() - slicestart);
if (isPotentialLog4JStart(slice)) {
if (slicestart > 0) {
auto text = m_buffer.left(slicestart);
m_buffer = m_buffer.right(m_buffer.length() - slicestart);
if (!text.trimmed().isEmpty()) {
return LogParser::PlainText{ text };
}
}
m_partialData = QString(m_buffer);
return LogParser::Partial{ QString(m_buffer) };
}
start = slicestart + 1;
} else {
break;
}
}
// no log4j found, all plain text
auto text = QString(m_buffer);
m_buffer.clear();
if (text.trimmed().isEmpty()) {
return {};
} else {
return LogParser::PlainText{ text };
}
}
}
std::optional<QList<LogParser::ParsedItem>> LogParser::parseAvailable()
{
QList<LogParser::ParsedItem> items;
bool doNext = true;
while (doNext) {
auto item_ = parseNext();
if (m_error.has_value()) {
return {};
}
if (item_.has_value()) {
auto item = item_.value();
if (std::holds_alternative<LogParser::Partial>(item)) {
break;
} else {
items.push_back(item);
}
} else {
doNext = false;
}
}
return items;
}
std::optional<LogParser::ParsedItem> LogParser::parseLog4J()
{
m_parser.clear();
m_parser.setNamespaceProcessing(false);
m_parser.addData(m_buffer);
m_parser.readNextStartElement();
if (m_parser.qualifiedName() == "log4j:Event") {
auto entry_ = parseAttributes();
if (!entry_.has_value()) {
setError();
return {};
}
auto entry = entry_.value();
bool foundMessage = false;
int depth = 1;
while (!m_parser.atEnd()) {
auto tok = m_parser.readNext();
switch (tok) {
case QXmlStreamReader::TokenType::StartElement: {
depth += 1;
if (m_parser.qualifiedName() == "log4j:Message") {
QString message;
bool messageComplete = false;
while (!messageComplete) {
auto tok = m_parser.readNext();
switch (tok) {
case QXmlStreamReader::TokenType::Characters: {
message.append(m_parser.text());
} break;
case QXmlStreamReader::TokenType::EndElement: {
if (m_parser.qualifiedName() == "log4j:Message") {
messageComplete = true;
}
} break;
case QXmlStreamReader::TokenType::EndDocument: {
return {}; // parse fail
} break;
default: {
// no op
}
}
if (m_parser.hasError()) {
return {};
}
}
entry.message = message;
foundMessage = true;
depth -= 1;
}
break;
case QXmlStreamReader::TokenType::EndElement: {
depth -= 1;
if (depth == 0 && m_parser.qualifiedName() == "log4j:Event") {
if (foundMessage) {
auto consumed = m_parser.characterOffset();
if (consumed > 0 && consumed <= m_buffer.length()) {
m_buffer = m_buffer.right(m_buffer.length() - consumed);
if (!m_buffer.isEmpty() && m_buffer.trimmed().isEmpty()) {
// only whitespace, dump it
m_buffer.clear();
}
}
clearError();
return entry;
}
m_parser.raiseError("log4j:Event Missing required attribute: message");
setError();
return {};
}
} break;
case QXmlStreamReader::TokenType::EndDocument: {
return {};
} break;
default: {
// no op
}
}
}
if (m_parser.hasError()) {
return {};
}
}
}
throw std::runtime_error("unreachable: already verified this was a complete log4j:Event");
}

74
launcher/logs/LogParser.h Normal file
View File

@ -0,0 +1,74 @@
// SPDX-License-Identifier: GPL-3.0-only
/*
* Prism Launcher - Minecraft Launcher
* Copyright (C) 2025 Rachel Powers <508861+Ryex@users.noreply.github.com>
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, version 3.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*
*/
#include <QAnyStringView>
#include <QDateTime>
#include <QList>
#include <QString>
#include <QStringView>
#include <QXmlStreamReader>
#include <optional>
#include <variant>
#include "MessageLevel.h"
class LogParser {
public:
struct LogEntry {
QString logger;
MessageLevel::Enum level;
QString levelText;
QDateTime timestamp;
QString thread;
QString message;
};
struct Partial {
QString data;
};
struct PlainText {
QString message;
};
struct Error {
QString errMessage;
QXmlStreamReader::Error error;
};
using ParsedItem = std::variant<LogEntry, PlainText, Partial>;
public:
LogParser() = default;
void appendLine(QAnyStringView data);
std::optional<ParsedItem> parseNext();
std::optional<QList<ParsedItem>> parseAvailable();
std::optional<Error> getError();
protected:
MessageLevel::Enum parseLogLevel(const QString& level);
std::optional<LogEntry> parseAttributes();
void setError();
void clearError();
std::optional<ParsedItem> parseLog4J();
private:
QString m_buffer;
QString m_partialData;
QXmlStreamReader m_parser;
std::optional<Error> m_error;
};