[libraries/qxmpp] /: Logger: Optionally elide very long messages
Linus Jahn <[email protected]>
| Newsgroups | gmane.comp.kde.cvs |
|---|---|
| Message-ID | <[email protected]> |
Git commit b9c26d0939f79962530125300603920f5bc44f90 by Linus Jahn. Committed on 05/08/2026 at 23:19. Pushed by lnj into branch 'master'. Logger: Optionally elide very long messages Huge stanzas (base64 blobs, MAM pages, large roster pushes) flood the log and hide the surrounding traffic. QXmppLogger can now shorten them, always eliding in the middle so beginning and end stay visible. Two independent, optional limits: - setElideXmlTextAbove(): maximum length of an XML text node. Elides only the oversized content while keeping the XML structure readable. Applies to pretty-printed Sent/Received stanzas. - setElideLogMessagesAbove(): maximum length of a whole logged message. Also covers stanzas that are large because of the number of elements they contain and payloads that are not valid XML. Both are disabled by default. enableEliding() turns them on with reasonable defaults, and enablePrettyXml() now does that too: its output is meant to be read by humans on a terminal, where full base64 blobs are never what you want. Log output that is written to a file or parsed is unaffected as long as pretty printing stays off, and enableEliding(false) brings the full stanzas back. The XML text limit is implemented in formatXmlForDebug(), which gained a private overload taking an options struct. The public function is unchanged. Co-Authored-By: Claude Opus 5 <[email protected]> M +100 -7 src/base/QXmppLogger.cpp M +23 -0 src/base/QXmppLogger.h M +73 -4 src/base/QXmppXmlFormatter.cpp A +30 -0 src/base/QXmppXmlFormatter_p.h [License: LGPL(v2.1+)] M +114 -0 tests/tst_QXmppLogger.cpp M +49 -1 tests/tst_QXmppXmlFormatter.cpp https://invent.kde.org/libraries/qxmpp/-/commit/b9c26d0939f79962530125300603920f5bc44f90 diff --git a/src/base/QXmppLogger.cpp b/src/base/QXmppLogger.cpp index 3029102f..a823a973 100644 --- a/src/base/QXmppLogger.cpp +++ b/src/base/QXmppLogger.cpp @@ -1,12 +1,13 @@ // SPDX-FileCopyrightText: 2009 Manjeet Dahiya <[email protected]> // SPDX-FileCopyrightText: 2010 Jeremy Lainé <[email protected]> +// SPDX-FileCopyrightText: 2026 Linus Jahn <[email protected]> // // SPDX-License-Identifier: LGPL-2.1-or-later #include "QXmppLogger.h" #include "QXmppConstants_p.h" -#include "QXmppXmlFormatter.h" +#include "QXmppXmlFormatter_p.h" #include "StringLiterals.h" @@ -179,6 +180,8 @@ public: bool prettyXml = false; QXmppLogger::ColorMode colorMode = QXmppLogger::ColorAuto; bool filterStreamManagementAcks = true; + std::optional<qsizetype> elideXmlTextAbove; + std::optional<qsizetype> elideLogMessagesAbove; }; QXmppLoggerPrivate::QXmppLoggerPrivate() @@ -298,8 +301,13 @@ void QXmppLogger::log(QXmppLogger::MessageType type, const QString &text) } QString payload = text; - if (d->prettyXml && (type == SentMessage || type == ReceivedMessage) && !text.isEmpty()) { - payload = QXmpp::formatXmlForDebug(text, true, 2, colorize); + if ((type == SentMessage || type == ReceivedMessage) && !text.isEmpty()) { + if (d->prettyXml) { + payload = QXmpp::Private::formatXmlForDebug(text, { true, 2, colorize, d->elideXmlTextAbove }); + } + if (d->elideLogMessagesAbove) { + payload = QXmpp::Private::elideMiddle(payload, *d->elideLogMessagesAbove, colorize, true); + } } switch (d->loggingType) { @@ -394,11 +402,95 @@ void QXmppLogger::setFilterStreamManagementAcks(bool enable) } /*! - Enables (\a enable) pretty-printing of Sent/Received XML stanzas and sets ColorAuto so - ANSI escapes appear on a TTY. + Returns the maximum length of an XML text node in logged stanzas, or std::nullopt if + text nodes are never elided. + + \sa setElideXmlTextAbove() + \since QXmpp 1.17 +*/ +std::optional<qsizetype> QXmppLogger::elideXmlTextAbove() const +{ + return d->elideXmlTextAbove; +} + +/*! + Sets the maximum \a length of an XML text node in logged stanzas. + + Text nodes longer than \a length are elided in the middle, so the beginning and the end of + the content stay visible while the XML structure around them is preserved. This is useful + for large base64 payloads (avatars, file previews, encrypted content). + + Only applies to Sent/Received messages with prettyXml() enabled. Pass std::nullopt to never + elide text nodes; that is the default unless eliding was enabled via enableEliding() or + enablePrettyXml(). + + \sa setElideLogMessagesAbove(), enableEliding() + \since QXmpp 1.17 +*/ +void QXmppLogger::setElideXmlTextAbove(std::optional<qsizetype> length) +{ + d->elideXmlTextAbove = length; +} + +/*! + Returns the maximum length of a logged message, or std::nullopt if messages are never + elided. + + \sa setElideLogMessagesAbove() + \since QXmpp 1.17 +*/ +std::optional<qsizetype> QXmppLogger::elideLogMessagesAbove() const +{ + return d->elideLogMessagesAbove; +} + +/*! + Sets the maximum \a length of a logged message. + + Messages longer than \a length are elided in the middle, so the beginning and the end stay + visible. Unlike setElideXmlTextAbove() this also shortens stanzas that are large because of + the sheer number of elements they contain, as well as payloads that are not valid XML. + + Only applies to Sent/Received messages. Pass std::nullopt to never elide messages; that is + the default unless eliding was enabled via enableEliding() or enablePrettyXml(). + + \sa setElideXmlTextAbove(), enableEliding() + \since QXmpp 1.17 +*/ +void QXmppLogger::setElideLogMessagesAbove(std::optional<qsizetype> length) +{ + d->elideLogMessagesAbove = length; +} + +/*! + Enables (\a enable) eliding of overly long Sent/Received messages using default limits. + + Equivalent to calling setElideXmlTextAbove(DefaultElideXmlTextAbove) and + setElideLogMessagesAbove(DefaultElideLogMessagesAbove), or, if disabling, setting both to + std::nullopt. Use those setters directly to pick your own limits. + + enablePrettyXml() already turns this on. + + \sa setElideXmlTextAbove(), setElideLogMessagesAbove() + \since QXmpp 1.17 +*/ +void QXmppLogger::enableEliding(bool enable) +{ + if (enable) { + setElideXmlTextAbove(DefaultElideXmlTextAbove); + setElideLogMessagesAbove(DefaultElideLogMessagesAbove); + } else { + setElideXmlTextAbove(std::nullopt); + setElideLogMessagesAbove(std::nullopt); + } +} + +/*! + Enables (\a enable) pretty-printing of Sent/Received XML stanzas, sets ColorAuto so ANSI + escapes appear on a TTY and elides overly long messages. - Equivalent to calling setPrettyXml(enable) and, if enabling, - setColorMode(ColorAuto). + Equivalent to calling setPrettyXml(enable) and, if enabling, setColorMode(ColorAuto) and + enableEliding(). Call enableEliding(false) afterwards to keep full stanzas. \since QXmpp 1.16 */ @@ -407,6 +499,7 @@ void QXmppLogger::enablePrettyXml(bool enable) setPrettyXml(enable); if (enable) { setColorMode(ColorAuto); + enableEliding(); } } diff --git a/src/base/QXmppLogger.h b/src/base/QXmppLogger.h index 094a4343..ec0cf801 100644 --- a/src/base/QXmppLogger.h +++ b/src/base/QXmppLogger.h @@ -1,5 +1,6 @@ // SPDX-FileCopyrightText: 2009 Manjeet Dahiya <[email protected]> // SPDX-FileCopyrightText: 2010 Jeremy Lainé <[email protected]> +// SPDX-FileCopyrightText: 2026 Linus Jahn <[email protected]> // // SPDX-License-Identifier: LGPL-2.1-or-later @@ -9,6 +10,7 @@ #include "QXmppGlobal.h" #include <memory> +#include <optional> #include <QObject> @@ -115,6 +117,19 @@ public: }; Q_ENUM(ColorMode) + /*! + Default maximum length of an XML text node used by enableEliding(). + + \since QXmpp 1.17 + */ + static constexpr qsizetype DefaultElideXmlTextAbove = 512; + /*! + Default maximum length of a logged message used by enableEliding(). + + \since QXmpp 1.17 + */ + static constexpr qsizetype DefaultElideLogMessagesAbove = 8192; + QXmppLogger(QObject *parent = nullptr); ~QXmppLogger() override; @@ -143,6 +158,14 @@ public: bool filterStreamManagementAcks() const; void setFilterStreamManagementAcks(bool enable); + std::optional<qsizetype> elideXmlTextAbove() const; + void setElideXmlTextAbove(std::optional<qsizetype> length); + + std::optional<qsizetype> elideLogMessagesAbove() const; + void setElideLogMessagesAbove(std::optional<qsizetype> length); + + void enableEliding(bool enable = true); + void enablePrettyXml(bool enable = true); Q_SLOT virtual void setGauge(const QString &gauge, double value); diff --git a/src/base/QXmppXmlFormatter.cpp b/src/base/QXmppXmlFormatter.cpp index 1984a78d..cf2108db 100644 --- a/src/base/QXmppXmlFormatter.cpp +++ b/src/base/QXmppXmlFormatter.cpp @@ -2,10 +2,12 @@ // // SPDX-License-Identifier: LGPL-2.1-or-later -#include "QXmppXmlFormatter.h" +#include "QXmppXmlFormatter_p.h" #include "StringLiterals.h" +#include <algorithm> + #include <QStringBuilder> #include <QXmlStreamReader> @@ -248,12 +250,65 @@ std::optional<QString> tryFormatSoloEndTag(QStringView trimmed, bool colorize) return out; } +// Longest ANSI escape sequence emitted by this file ("\x1b[90m" and friends). +constexpr qsizetype MaxEscapeLength = 8; + +// Moves a cut position back so that it does not fall inside an ANSI escape +// sequence (ESC '[' … 'm'), which would leave a stray "[90m" in the output. +qsizetype ansiSafeCut(QStringView text, qsizetype pos) +{ + auto begin = std::max(qsizetype(0), pos - MaxEscapeLength); + for (auto i = pos - 1; i >= begin; --i) { + auto c = text.at(i); + if (c == u'm') { + break; + } + if (c == u'\x1b') { + return i; + } + } + return pos; +} + } // namespace -namespace QXmpp { +namespace QXmpp::Private { -QString formatXmlForDebug(QStringView raw, bool indent, int indentWidth, bool colorize) +QString elideMiddle(QStringView text, qsizetype maxLength, bool colorize, bool ownLine) +{ + if (maxLength <= 0 || text.size() <= maxLength) { + return text.toString(); + } + + auto leftEnd = maxLength / 2; + auto rightBegin = text.size() - (maxLength - leftEnd); + if (colorize) { + leftEnd = ansiSafeCut(text, leftEnd); + rightBegin = ansiSafeCut(text, rightBegin); + } + + QString marker = u"…["_s + QString::number(rightBegin - leftEnd) + u" characters elided]…"_s; + if (colorize) { + // terminate a color span the cut may have left open, then dim the marker + marker = CReset.toString() + CComment.toString() + marker + CReset.toString(); + } + if (ownLine) { + marker = u'\n' + marker + u'\n'; + } + + // only elide if that actually shortens the output + if (leftEnd + marker.size() + (text.size() - rightBegin) >= text.size()) { + return text.toString(); + } + return text.left(leftEnd).toString() + marker + text.sliced(rightBegin).toString(); +} + +QString formatXmlForDebug(QStringView raw, const XmlFormatOptions &options) { + const bool indent = options.indent; + const int indentWidth = options.indentWidth; + const bool colorize = options.colorize; + if (raw.isEmpty()) { return raw.toString(); } @@ -401,7 +456,12 @@ QString formatXmlForDebug(QStringView raw, bool indent, int indentWidth, bool co emitOpenClose(out, colorize); stack.last().tagOpen = false; } - out += escapeText(text); + if (options.elideTextAbove) { + // elide before escaping, so the reported count refers to the original text + out += escapeText(elideMiddle(text, *options.elideTextAbove, colorize)); + } else { + out += escapeText(text); + } if (!whitespaceOnly) { stack.last().hadText = true; } @@ -459,4 +519,13 @@ QString formatXmlForDebug(QStringView raw, bool indent, int indentWidth, bool co return out; } +} // namespace QXmpp::Private + +namespace QXmpp { + +QString formatXmlForDebug(QStringView raw, bool indent, int indentWidth, bool colorize) +{ + return Private::formatXmlForDebug(raw, { indent, indentWidth, colorize, {} }); +} + } // namespace QXmpp diff --git a/src/base/QXmppXmlFormatter_p.h b/src/base/QXmppXmlFormatter_p.h new file mode 100644 index 00000000..2f8a594f --- /dev/null +++ b/src/base/QXmppXmlFormatter_p.h @@ -0,0 +1,30 @@ +// SPDX-FileCopyrightText: 2026 Linus Jahn <[email protected]> +// +// SPDX-License-Identifier: LGPL-2.1-or-later + +#ifndef QXMPPXMLFORMATTER_P_H +#define QXMPPXMLFORMATTER_P_H + +#include "QXmppXmlFormatter.h" + +#include <optional> + +namespace QXmpp::Private { + +struct XmlFormatOptions { + bool indent = true; + int indentWidth = 2; + bool colorize = false; + // maximum length of an XML text node; longer text is elided in the middle + std::optional<qsizetype> elideTextAbove; +}; + +QString formatXmlForDebug(QStringView raw, const XmlFormatOptions &options); + +// Cuts the middle out of text longer than maxLength, leaving head and tail visible. +// If ownLine is true, the elision marker is placed on a line of its own. +QString elideMiddle(QStringView text, qsizetype maxLength, bool colorize = false, bool ownLine = false); + +} // namespace QXmpp::Private + +#endif // QXMPPXMLFORMATTER_P_H diff --git a/tests/tst_QXmppLogger.cpp b/tests/tst_QXmppLogger.cpp index 00ed0c2b..8ed7d129 100644 --- a/tests/tst_QXmppLogger.cpp +++ b/tests/tst_QXmppLogger.cpp @@ -16,6 +16,11 @@ private: Q_SLOT void testFilterStreamManagementAcks(); Q_SLOT void testStreamManagementAcksNotFilteredWhenDisabled(); Q_SLOT void testNonAcksAlwaysLogged(); + Q_SLOT void testElidingDisabledByDefault(); + Q_SLOT void testElideLogMessagesAbove(); + Q_SLOT void testElideXmlTextAboveNeedsPrettyXml(); + Q_SLOT void testEnableEliding(); + Q_SLOT void testPrettyXmlEnablesEliding(); }; void tst_QXmppLogger::testFilterStreamManagementAcks() @@ -73,5 +78,114 @@ void tst_QXmppLogger::testNonAcksAlwaysLogged() QCOMPARE(spy.size(), 6); } +static QString hugeStanza(qsizetype dataSize = 100000) +{ + return u"<iq type=\"result\"><data>"_s + QString(dataSize, u'A') + u"</data></iq>"_s; +} + +void tst_QXmppLogger::testElidingDisabledByDefault() +{ + QXmppLogger logger; + logger.setLoggingType(QXmppLogger::SignalLogging); + QVERIFY(!logger.elideXmlTextAbove()); + QVERIFY(!logger.elideLogMessagesAbove()); + + QSignalSpy spy(&logger, &QXmppLogger::message); + + auto stanza = hugeStanza(); + logger.log(QXmppLogger::SentMessage, stanza); + + QCOMPARE(spy.size(), 1); + QCOMPARE(spy.at(0).at(1).toString(), stanza); +} + +void tst_QXmppLogger::testElideLogMessagesAbove() +{ + QXmppLogger logger; + logger.setLoggingType(QXmppLogger::SignalLogging); + logger.setElideLogMessagesAbove(1000); + QCOMPARE(logger.elideLogMessagesAbove().value(), qsizetype(1000)); + + QSignalSpy spy(&logger, &QXmppLogger::message); + + auto stanza = hugeStanza(); + logger.log(QXmppLogger::SentMessage, stanza); + // long messages of other types are never elided + logger.log(QXmppLogger::DebugMessage, stanza); + // short messages stay untouched + logger.log(QXmppLogger::ReceivedMessage, u"<iq type=\"get\"/>"_s); + + QCOMPARE(spy.size(), 3); + + auto elided = spy.at(0).at(1).toString(); + QVERIFY(elided.size() < 1500); + QVERIFY(elided.startsWith(u"<iq type=\"result\"><data>AAA")); + QVERIFY(elided.endsWith(u"AAA</data></iq>")); + QVERIFY(elided.contains(u"characters elided")); + + QCOMPARE(spy.at(1).at(1).toString(), stanza); + QCOMPARE(spy.at(2).at(1).toString(), u"<iq type=\"get\"/>"_s); +} + +void tst_QXmppLogger::testElideXmlTextAboveNeedsPrettyXml() +{ + auto stanza = hugeStanza(); + + // without pretty printing the XML text limit has no effect + QXmppLogger plain; + plain.setLoggingType(QXmppLogger::SignalLogging); + plain.setElideXmlTextAbove(100); + QCOMPARE(plain.elideXmlTextAbove().value(), qsizetype(100)); + + QSignalSpy plainSpy(&plain, &QXmppLogger::message); + plain.log(QXmppLogger::SentMessage, stanza); + QCOMPARE(plainSpy.size(), 1); + QCOMPARE(plainSpy.at(0).at(1).toString(), stanza); + + // with pretty printing the text node is elided, the XML structure is kept + QXmppLogger pretty; + pretty.setLoggingType(QXmppLogger::SignalLogging); + pretty.setPrettyXml(true); + pretty.setColorMode(QXmppLogger::ColorOff); + pretty.setElideXmlTextAbove(100); + + QSignalSpy prettySpy(&pretty, &QXmppLogger::message); + pretty.log(QXmppLogger::SentMessage, stanza); + QCOMPARE(prettySpy.size(), 1); + + auto payload = prettySpy.at(0).at(1).toString(); + QVERIFY(payload.size() < 1000); + QVERIFY(payload.startsWith(u"<iq type=\"result\">\n <data>AAA")); + QVERIFY(payload.endsWith(u"AAA</data>\n</iq>")); + QVERIFY(payload.contains(u"…[99900 characters elided]…")); +} + +void tst_QXmppLogger::testEnableEliding() +{ + QXmppLogger logger; + logger.enableEliding(); + QCOMPARE(logger.elideXmlTextAbove().value(), QXmppLogger::DefaultElideXmlTextAbove); + QCOMPARE(logger.elideLogMessagesAbove().value(), QXmppLogger::DefaultElideLogMessagesAbove); + + logger.enableEliding(false); + QVERIFY(!logger.elideXmlTextAbove()); + QVERIFY(!logger.elideLogMessagesAbove()); +} + +void tst_QXmppLogger::testPrettyXmlEnablesEliding() +{ + QXmppLogger logger; + logger.enablePrettyXml(); + QVERIFY(logger.prettyXml()); + QCOMPARE(logger.elideXmlTextAbove().value(), QXmppLogger::DefaultElideXmlTextAbove); + QCOMPARE(logger.elideLogMessagesAbove().value(), QXmppLogger::DefaultElideLogMessagesAbove); + + // pretty printing can be kept while logging full stanzas + logger.enableEliding(false); + QVERIFY(logger.prettyXml()); + QVERIFY(!logger.elideXmlTextAbove()); + QVERIFY(!logger.elideLogMessagesAbove()); +} + QTEST_MAIN(tst_QXmppLogger) #include "tst_QXmppLogger.moc" diff --git a/tests/tst_QXmppXmlFormatter.cpp b/tests/tst_QXmppXmlFormatter.cpp index 6dfbf04e..107cfab8 100644 --- a/tests/tst_QXmppXmlFormatter.cpp +++ b/tests/tst_QXmppXmlFormatter.cpp @@ -3,7 +3,7 @@ // SPDX-License-Identifier: LGPL-2.1-or-later #include "QXmppLogger.h" -#include "QXmppXmlFormatter.h" +#include "QXmppXmlFormatter_p.h" #include "util.h" @@ -30,6 +30,9 @@ private: Q_SLOT void streamOpenFragment(); Q_SLOT void streamCloseFragment(); Q_SLOT void streamOpenWithXmlDecl(); + Q_SLOT void elideLongTextNode(); + Q_SLOT void elideKeepsShortTextAndAttributes(); + Q_SLOT void elideColorized(); }; void tst_QXmppXmlFormatter::roundTripIq() @@ -170,5 +173,50 @@ void tst_QXmppXmlFormatter::streamOpenWithXmlDecl() u"<stream:stream xmlns=\"jabber:client\" xmlns:stream=\"http://etherx.jabber.org/streams\" from=\"x@y\">"_s); } +void tst_QXmppXmlFormatter::elideLongTextNode() +{ + using namespace QXmpp::Private; + + QString in = u"<message><data>"_s + QString(1000, u'A') + u"</data><thread>x</thread></message>"_s; + auto out = formatXmlForDebug(in, { true, 2, false, 100 }); + + QVERIFY(out.startsWith(u"<message>\n <data>")); + QVERIFY(out.endsWith(u"</data>\n <thread>x</thread>\n</message>")); + QVERIFY(out.contains(QString(50, u'A') + u"…[900 characters elided]…"_s + QString(50, u'A'))); + QVERIFY(out.size() < in.size()); + + QCOMPARE(formatXmlForDebug(in, { true, 2, false, {} }), QXmpp::formatXmlForDebug(in)); +} + +void tst_QXmppXmlFormatter::elideKeepsShortTextAndAttributes() +{ + using namespace QXmpp::Private; + + // text shorter than the limit is left alone + auto out = formatXmlForDebug(u"<body>hello world</body>"_s, { true, 2, false, 100 }); + QCOMPARE(out, u"<body>hello world</body>"_s); + + // attribute values are not elided + auto value = QString(1000, u'A'); + QString withAttr = u"<data value=\""_s + value + u"\"/>"_s; + QVERIFY(formatXmlForDebug(withAttr, { true, 2, false, 100 }).contains(value)); +} + +void tst_QXmppXmlFormatter::elideColorized() +{ + using namespace QXmpp::Private; + + QString in = u"<data>"_s + QString(1000, u'A') + u"</data>"_s; + auto out = formatXmlForDebug(in, { true, 2, true, 100 }); + + QVERIFY(out.contains(u"characters elided")); + // no escape sequence was cut in half + static const QRegularExpression ansiRe(u"\x1b\\[[0-9;]*m"_s); + auto stripped = out; + stripped.remove(ansiRe); + QVERIFY(!stripped.contains(QChar(0x1b))); + QVERIFY(stripped.contains(u"…[900 characters elided]…")); +} + QTEST_MAIN(tst_QXmppXmlFormatter) #include "tst_QXmppXmlFormatter.moc"