diff --git a/doc/Settings.md b/doc/Settings.md index 0dfe03b3e4..c1cedc26c2 100644 --- a/doc/Settings.md +++ b/doc/Settings.md @@ -326,6 +326,16 @@ Sets the default strategy for naming log files for installers that support it. ` }, ``` +### format + +Sets the format used when writing log entries to a file. `winget` is the default and uses the standard WinGet log format. `ccm` writes entries in a [CMTrace](https://learn.microsoft.com/mem/configmgr/core/support/cmtrace)-compatible format, which is useful when collecting winget logs alongside Configuration Manager (CCM) logs. Invalid values will revert to `winget`. + +```json + "logging": { + "format": "winget" | "ccm" + }, +``` + ### file The `file` settings control the log files generated by winget during operation. These settings apply to the automatic cleanup that happens whenever a Windows Package Manager process is run. diff --git a/schemas/JSON/settings/settings.schema.0.2.json b/schemas/JSON/settings/settings.schema.0.2.json index 06d6314d91..9ff14bb4c2 100644 --- a/schemas/JSON/settings/settings.schema.0.2.json +++ b/schemas/JSON/settings/settings.schema.0.2.json @@ -91,6 +91,14 @@ "shortguid" ] }, + "format": { + "description": "The format used when writing log entries to a file", + "type": "string", + "enum": [ + "winget", + "ccm" + ] + }, "file": { "description": "The file settings control the log files generated by winget during operation.", "type": "object", diff --git a/src/AppInstallerCLITests/FileLogger.cpp b/src/AppInstallerCLITests/FileLogger.cpp index 8d82b15093..a591467ed3 100644 --- a/src/AppInstallerCLITests/FileLogger.cpp +++ b/src/AppInstallerCLITests/FileLogger.cpp @@ -2,8 +2,13 @@ // Licensed under the MIT License. #include "pch.h" #include "TestCommon.h" +#include "TestSettings.h" +#include "TestHooks.h" #include #include +#include + +#include using namespace AppInstaller::Logging; using namespace AppInstaller::Utility; @@ -206,6 +211,44 @@ TEST_CASE("FileLogger_MaximumSize", "[logging]") FileLogger_MaximumSize_Test(tagState, sizeState); } +TEST_CASE("FileLogger_CCMFormat", "[logging]") +{ + // The CCM/CMTrace log format is opt-in via the "logging.format" user setting; override it for this test. + auto settingsGuard = DeleteUserSettingsFiles(); + SetSetting(AppInstaller::Settings::Stream::PrimaryUserSettings, R"({ "logging": { "format": "ccm" } })"); + UserSettingsTest userSettings; + TestHook::SetUserSettings_Override userSettingsOverride{ userSettings }; + + // CCM type: 1=Info/Verbose, 2=Warning, 3=Error/Critical. + Level level = Level::Info; + int expectedType = 1; + SECTION("Verbose maps to type 1") { level = Level::Verbose; expectedType = 1; } + SECTION("Info maps to type 1") { level = Level::Info; expectedType = 1; } + SECTION("Warning maps to type 2") { level = Level::Warning; expectedType = 2; } + SECTION("Error maps to type 3") { level = Level::Error; expectedType = 3; } + SECTION("Crit maps to type 3") { level = Level::Crit; expectedType = 3; } + + const std::string message = "CCM format test message"; + + TempFile tempFile{ "FileLogger_CCM", ".log" }; + INFO("File: " << tempFile.GetPath().u8string()); + { + FileLogger logger{ tempFile }; + logger.Write(DefaultChannel, level, message); + } + + std::ifstream fileStream{ tempFile.GetPath(), std::ios::binary }; + auto fileContents = ReadEntireStream(fileStream); + INFO("File contents: " << fileContents); + + // Expected: ]LOG]!>" date="MM-DD-YYYY" component="" context="" type="" thread="" file=""> + std::regex ccmPattern{ + R"(^)" }; + REQUIRE(std::regex_search(fileContents, ccmPattern)); +} + TEST_CASE("FileLogger_MaximumSize_ManyWraps", "[logging]") { TempFile tempFile{ "FileLogger_ManyWraps", ".log" }; diff --git a/src/AppInstallerCLITests/UserSettings.cpp b/src/AppInstallerCLITests/UserSettings.cpp index c4d63e0905..47634aed95 100644 --- a/src/AppInstallerCLITests/UserSettings.cpp +++ b/src/AppInstallerCLITests/UserSettings.cpp @@ -386,6 +386,64 @@ TEST_CASE("SettingLoggingFileNameStrategy", "[settings]") { } } +TEST_CASE("SettingLoggingFormat", "[settings]") +{ + auto again = DeleteUserSettingsFiles(); + + SECTION("Default value") + { + UserSettingsTest userSettingTest; + + REQUIRE(userSettingTest.Get() == LogFileFormat::WinGet); + REQUIRE(userSettingTest.GetWarnings().size() == 0); + } + SECTION("WinGet") + { + std::string_view json = R"({ "logging": { "format": "winget" } })"; + SetSetting(Stream::PrimaryUserSettings, json); + UserSettingsTest userSettingTest; + + REQUIRE(userSettingTest.Get() == LogFileFormat::WinGet); + REQUIRE(userSettingTest.GetWarnings().size() == 0); + } + SECTION("CCM") + { + std::string_view json = R"({ "logging": { "format": "ccm" } })"; + SetSetting(Stream::PrimaryUserSettings, json); + UserSettingsTest userSettingTest; + + REQUIRE(userSettingTest.Get() == LogFileFormat::CCM); + REQUIRE(userSettingTest.GetWarnings().size() == 0); + } + SECTION("Case insensitive CCM") + { + std::string_view json = R"({ "logging": { "format": "CCM" } })"; + SetSetting(Stream::PrimaryUserSettings, json); + UserSettingsTest userSettingTest; + + REQUIRE(userSettingTest.Get() == LogFileFormat::CCM); + REQUIRE(userSettingTest.GetWarnings().size() == 0); + } + SECTION("Bad value") + { + std::string_view json = R"({ "logging": { "format": "cmtrace" } })"; + SetSetting(Stream::PrimaryUserSettings, json); + UserSettingsTest userSettingTest; + + REQUIRE(userSettingTest.Get() == LogFileFormat::WinGet); + REQUIRE(userSettingTest.GetWarnings().size() == 1); + } + SECTION("Bad value type") + { + std::string_view json = R"({ "logging": { "format": true } })"; + SetSetting(Stream::PrimaryUserSettings, json); + UserSettingsTest userSettingTest; + + REQUIRE(userSettingTest.Get() == LogFileFormat::WinGet); + REQUIRE(userSettingTest.GetWarnings().size() == 1); + } +} + TEST_CASE("SettingAutoUpdateIntervalInMinutes", "[settings]") { auto again = DeleteUserSettingsFiles(); diff --git a/src/AppInstallerCommonCore/FileLogger.cpp b/src/AppInstallerCommonCore/FileLogger.cpp index e97d797d10..18e63da259 100644 --- a/src/AppInstallerCommonCore/FileLogger.cpp +++ b/src/AppInstallerCommonCore/FileLogger.cpp @@ -29,6 +29,52 @@ namespace AppInstaller::Logging return std::move(strstr).str(); } + // Formats a log line in CCM (CMTrace-compatible) format. + std::string ToCCMLogLine(Channel channel, Level level, std::string_view message) + { + auto now = std::chrono::system_clock::now(); + auto tt = std::chrono::system_clock::to_time_t(now); + tm localTime{}; + _localtime64_s(&localTime, &tt); + + auto sinceEpoch = now.time_since_epoch(); + auto leftoverMillis = std::chrono::duration_cast(sinceEpoch) - std::chrono::duration_cast(sinceEpoch); + + // Get UTC bias in minutes (positive means west of UTC, CMTrace uses positive for west) + long timezoneBiasSeconds = 0; + _get_timezone(&timezoneBiasSeconds); + long biasMins = timezoneBiasSeconds / 60; + + // CCM type: 1=Info/Verbose, 2=Warning, 3=Error/Critical + int type; + switch (level) + { + case Level::Warning: type = 2; break; + case Level::Error: + case Level::Crit: type = 3; break; + default: type = 1; break; + } + + std::stringstream strstr; + strstr << "" + << ""; + return std::move(strstr).str(); + } + // Determines the difference between the given position and the maximum as an offset. std::ofstream::off_type CalculateDiff(const std::ofstream::pos_type& position, std::ofstream::off_type maximum) { @@ -94,7 +140,15 @@ namespace AppInstaller::Logging void FileLogger::Write(Channel channel, Level level, std::string_view message) noexcept try { - std::string log = ToLogLine(channel, level, message); + std::string log; + if (Settings::User().Get() == LogFileFormat::CCM) + { + log = ToCCMLogLine(channel, level, message); + } + else + { + log = ToLogLine(channel, level, message); + } WriteDirect(channel, level, log); } catch (...) {} diff --git a/src/AppInstallerCommonCore/Public/winget/UserSettings.h b/src/AppInstallerCommonCore/Public/winget/UserSettings.h index 0b194d3ce9..4c433f63b8 100644 --- a/src/AppInstallerCommonCore/Public/winget/UserSettings.h +++ b/src/AppInstallerCommonCore/Public/winget/UserSettings.h @@ -133,6 +133,7 @@ namespace AppInstaller::Settings LoggingFileTotalSizeLimitInMB, LoggingFileIndividualSizeLimitInMB, LoggingFileCountLimit, + LoggingFormat, // Uninstall behavior UninstallPurgePortablePackage, // Download behavior @@ -237,6 +238,7 @@ namespace AppInstaller::Settings SETTINGMAPPING_SPECIALIZATION(Setting::LoggingFileTotalSizeLimitInMB, uint32_t, uint32_t, 128, ".logging.file.totalSizeLimitInMB"sv); SETTINGMAPPING_SPECIALIZATION(Setting::LoggingFileIndividualSizeLimitInMB, uint32_t, uint32_t, 16, ".logging.file.individualSizeLimitInMB"sv); SETTINGMAPPING_SPECIALIZATION(Setting::LoggingFileCountLimit, uint32_t, uint32_t, 0, ".logging.file.countLimit"sv); + SETTINGMAPPING_SPECIALIZATION(Setting::LoggingFormat, std::string, Logging::LogFileFormat, Logging::LogFileFormat::WinGet, ".logging.format"sv); // Interactivity SETTINGMAPPING_SPECIALIZATION(Setting::InteractivityDisable, bool, bool, false, ".interactivity.disable"sv); // Output behavior diff --git a/src/AppInstallerCommonCore/UserSettings.cpp b/src/AppInstallerCommonCore/UserSettings.cpp index a7f9bb3b93..8f298f6140 100644 --- a/src/AppInstallerCommonCore/UserSettings.cpp +++ b/src/AppInstallerCommonCore/UserSettings.cpp @@ -530,6 +530,22 @@ namespace AppInstaller::Settings return value * 24h; } + WINGET_VALIDATE_SIGNATURE(LoggingFormat) + { + static constexpr std::string_view s_format_winget = "winget"; + static constexpr std::string_view s_format_ccm = "ccm"; + + if (Utility::CaseInsensitiveEquals(value, s_format_winget)) + { + return LogFileFormat::WinGet; + } + else if (Utility::CaseInsensitiveEquals(value, s_format_ccm)) + { + return LogFileFormat::CCM; + } + return {}; + } + WINGET_VALIDATE_SIGNATURE(OutputSortOrder) { std::vector fields; diff --git a/src/AppInstallerSharedLib/Public/AppInstallerLogging.h b/src/AppInstallerSharedLib/Public/AppInstallerLogging.h index 6b95a002e2..986e900a7b 100644 --- a/src/AppInstallerSharedLib/Public/AppInstallerLogging.h +++ b/src/AppInstallerSharedLib/Public/AppInstallerLogging.h @@ -98,6 +98,15 @@ namespace AppInstaller::Logging ShortGuid, }; + // The format used when writing log entries to a file. + enum class LogFileFormat + { + // Default WinGet format: " [channel] message" + WinGet, + // CCM/CMTrace-compatible format: "" date="" component="" context="" type="N" thread="" file="">" + CCM, + }; + // Indicates a location of significance in the logging stream. enum class Tag {