Skip to content

Commit 167f839

Browse files
committed
Add CCM (CMTrace) log file format option
Assisted-by: Claude:claude-4.8-opus Signed-off-by: Tom Plant <tom.plant@devicie.com>
1 parent b53a84f commit 167f839

8 files changed

Lines changed: 201 additions & 1 deletion

File tree

doc/Settings.md

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -326,6 +326,16 @@ Sets the default strategy for naming log files for installers that support it. `
326326
},
327327
```
328328

329+
### format
330+
331+
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`.
332+
333+
```json
334+
"logging": {
335+
"format": "winget" | "ccm"
336+
},
337+
```
338+
329339
### file
330340

331341
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.

schemas/JSON/settings/settings.schema.0.2.json

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -91,6 +91,14 @@
9191
"shortguid"
9292
]
9393
},
94+
"format": {
95+
"description": "The format used when writing log entries to a file",
96+
"type": "string",
97+
"enum": [
98+
"winget",
99+
"ccm"
100+
]
101+
},
94102
"file": {
95103
"description": "The file settings control the log files generated by winget during operation.",
96104
"type": "object",

src/AppInstallerCLITests/FileLogger.cpp

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,8 +2,13 @@
22
// Licensed under the MIT License.
33
#include "pch.h"
44
#include "TestCommon.h"
5+
#include "TestSettings.h"
6+
#include "TestHooks.h"
57
#include <AppInstallerFileLogger.h>
68
#include <AppInstallerStrings.h>
9+
#include <winget/Settings.h>
10+
11+
#include <regex>
712

813
using namespace AppInstaller::Logging;
914
using namespace AppInstaller::Utility;
@@ -206,6 +211,44 @@ TEST_CASE("FileLogger_MaximumSize", "[logging]")
206211
FileLogger_MaximumSize_Test(tagState, sizeState);
207212
}
208213

214+
TEST_CASE("FileLogger_CCMFormat", "[logging]")
215+
{
216+
// The CCM/CMTrace log format is opt-in via the "logging.format" user setting; override it for this test.
217+
auto settingsGuard = DeleteUserSettingsFiles();
218+
SetSetting(AppInstaller::Settings::Stream::PrimaryUserSettings, R"({ "logging": { "format": "ccm" } })");
219+
UserSettingsTest userSettings;
220+
TestHook::SetUserSettings_Override userSettingsOverride{ userSettings };
221+
222+
// CCM type: 1=Info/Verbose, 2=Warning, 3=Error/Critical.
223+
Level level = Level::Info;
224+
int expectedType = 1;
225+
SECTION("Verbose maps to type 1") { level = Level::Verbose; expectedType = 1; }
226+
SECTION("Info maps to type 1") { level = Level::Info; expectedType = 1; }
227+
SECTION("Warning maps to type 2") { level = Level::Warning; expectedType = 2; }
228+
SECTION("Error maps to type 3") { level = Level::Error; expectedType = 3; }
229+
SECTION("Crit maps to type 3") { level = Level::Crit; expectedType = 3; }
230+
231+
const std::string message = "CCM format test message";
232+
233+
TempFile tempFile{ "FileLogger_CCM", ".log" };
234+
INFO("File: " << tempFile.GetPath().u8string());
235+
{
236+
FileLogger logger{ tempFile };
237+
logger.Write(DefaultChannel, level, message);
238+
}
239+
240+
std::ifstream fileStream{ tempFile.GetPath(), std::ios::binary };
241+
auto fileContents = ReadEntireStream(fileStream);
242+
INFO("File contents: " << fileContents);
243+
244+
// Expected: <![LOG[<message>]LOG]!><time="HH:MM:SS.mmm+<bias>" date="MM-DD-YYYY" component="<channel>" context="" type="<N>" thread="<id>" file="">
245+
std::regex ccmPattern{
246+
R"(^<!\[LOG\[CCM format test message\]LOG\]!><time="\d{2}:\d{2}:\d{2}\.\d{3}\+-?\d+" date="\d{2}-\d{2}-\d{4}" component="[^"]*" context="" type=")"
247+
+ std::to_string(expectedType)
248+
+ R"(" thread="\d+" file="">)" };
249+
REQUIRE(std::regex_search(fileContents, ccmPattern));
250+
}
251+
209252
TEST_CASE("FileLogger_MaximumSize_ManyWraps", "[logging]")
210253
{
211254
TempFile tempFile{ "FileLogger_ManyWraps", ".log" };

src/AppInstallerCLITests/UserSettings.cpp

Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -386,6 +386,64 @@ TEST_CASE("SettingLoggingFileNameStrategy", "[settings]") {
386386
}
387387
}
388388

389+
TEST_CASE("SettingLoggingFormat", "[settings]")
390+
{
391+
auto again = DeleteUserSettingsFiles();
392+
393+
SECTION("Default value")
394+
{
395+
UserSettingsTest userSettingTest;
396+
397+
REQUIRE(userSettingTest.Get<Setting::LoggingFormat>() == LogFileFormat::WinGet);
398+
REQUIRE(userSettingTest.GetWarnings().size() == 0);
399+
}
400+
SECTION("WinGet")
401+
{
402+
std::string_view json = R"({ "logging": { "format": "winget" } })";
403+
SetSetting(Stream::PrimaryUserSettings, json);
404+
UserSettingsTest userSettingTest;
405+
406+
REQUIRE(userSettingTest.Get<Setting::LoggingFormat>() == LogFileFormat::WinGet);
407+
REQUIRE(userSettingTest.GetWarnings().size() == 0);
408+
}
409+
SECTION("CCM")
410+
{
411+
std::string_view json = R"({ "logging": { "format": "ccm" } })";
412+
SetSetting(Stream::PrimaryUserSettings, json);
413+
UserSettingsTest userSettingTest;
414+
415+
REQUIRE(userSettingTest.Get<Setting::LoggingFormat>() == LogFileFormat::CCM);
416+
REQUIRE(userSettingTest.GetWarnings().size() == 0);
417+
}
418+
SECTION("Case insensitive CCM")
419+
{
420+
std::string_view json = R"({ "logging": { "format": "CCM" } })";
421+
SetSetting(Stream::PrimaryUserSettings, json);
422+
UserSettingsTest userSettingTest;
423+
424+
REQUIRE(userSettingTest.Get<Setting::LoggingFormat>() == LogFileFormat::CCM);
425+
REQUIRE(userSettingTest.GetWarnings().size() == 0);
426+
}
427+
SECTION("Bad value")
428+
{
429+
std::string_view json = R"({ "logging": { "format": "cmtrace" } })";
430+
SetSetting(Stream::PrimaryUserSettings, json);
431+
UserSettingsTest userSettingTest;
432+
433+
REQUIRE(userSettingTest.Get<Setting::LoggingFormat>() == LogFileFormat::WinGet);
434+
REQUIRE(userSettingTest.GetWarnings().size() == 1);
435+
}
436+
SECTION("Bad value type")
437+
{
438+
std::string_view json = R"({ "logging": { "format": true } })";
439+
SetSetting(Stream::PrimaryUserSettings, json);
440+
UserSettingsTest userSettingTest;
441+
442+
REQUIRE(userSettingTest.Get<Setting::LoggingFormat>() == LogFileFormat::WinGet);
443+
REQUIRE(userSettingTest.GetWarnings().size() == 1);
444+
}
445+
}
446+
389447
TEST_CASE("SettingAutoUpdateIntervalInMinutes", "[settings]")
390448
{
391449
auto again = DeleteUserSettingsFiles();

src/AppInstallerCommonCore/FileLogger.cpp

Lines changed: 55 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,52 @@ namespace AppInstaller::Logging
2929
return std::move(strstr).str();
3030
}
3131

32+
// Formats a log line in CCM (CMTrace-compatible) format.
33+
std::string ToCCMLogLine(Channel channel, Level level, std::string_view message)
34+
{
35+
auto now = std::chrono::system_clock::now();
36+
auto tt = std::chrono::system_clock::to_time_t(now);
37+
tm localTime{};
38+
_localtime64_s(&localTime, &tt);
39+
40+
auto sinceEpoch = now.time_since_epoch();
41+
auto leftoverMillis = std::chrono::duration_cast<std::chrono::milliseconds>(sinceEpoch) - std::chrono::duration_cast<std::chrono::seconds>(sinceEpoch);
42+
43+
// Get UTC bias in minutes (positive means west of UTC, CMTrace uses positive for west)
44+
long timezoneBiasSeconds = 0;
45+
_get_timezone(&timezoneBiasSeconds);
46+
long biasMins = timezoneBiasSeconds / 60;
47+
48+
// CCM type: 1=Info/Verbose, 2=Warning, 3=Error/Critical
49+
int type;
50+
switch (level)
51+
{
52+
case Level::Warning: type = 2; break;
53+
case Level::Error:
54+
case Level::Crit: type = 3; break;
55+
default: type = 1; break;
56+
}
57+
58+
std::stringstream strstr;
59+
strstr << "<![LOG[" << message << "]LOG]!>"
60+
<< "<time=\""
61+
<< std::setw(2) << std::setfill('0') << localTime.tm_hour << ":"
62+
<< std::setw(2) << std::setfill('0') << localTime.tm_min << ":"
63+
<< std::setw(2) << std::setfill('0') << localTime.tm_sec << "."
64+
<< std::setw(3) << std::setfill('0') << leftoverMillis.count()
65+
<< "+" << biasMins << "\""
66+
<< " date=\""
67+
<< std::setw(2) << std::setfill('0') << (1 + localTime.tm_mon) << "-"
68+
<< std::setw(2) << std::setfill('0') << localTime.tm_mday << "-"
69+
<< (1900 + localTime.tm_year) << "\""
70+
<< " component=\"" << GetChannelName(channel) << "\""
71+
<< " context=\"\""
72+
<< " type=\"" << type << "\""
73+
<< " thread=\"" << GetCurrentThreadId() << "\""
74+
<< " file=\"\">";
75+
return std::move(strstr).str();
76+
}
77+
3278
// Determines the difference between the given position and the maximum as an offset.
3379
std::ofstream::off_type CalculateDiff(const std::ofstream::pos_type& position, std::ofstream::off_type maximum)
3480
{
@@ -94,7 +140,15 @@ namespace AppInstaller::Logging
94140

95141
void FileLogger::Write(Channel channel, Level level, std::string_view message) noexcept try
96142
{
97-
std::string log = ToLogLine(channel, level, message);
143+
std::string log;
144+
if (Settings::User().Get<Settings::Setting::LoggingFormat>() == LogFileFormat::CCM)
145+
{
146+
log = ToCCMLogLine(channel, level, message);
147+
}
148+
else
149+
{
150+
log = ToLogLine(channel, level, message);
151+
}
98152
WriteDirect(channel, level, log);
99153
}
100154
catch (...) {}

src/AppInstallerCommonCore/Public/winget/UserSettings.h

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -133,6 +133,7 @@ namespace AppInstaller::Settings
133133
LoggingFileTotalSizeLimitInMB,
134134
LoggingFileIndividualSizeLimitInMB,
135135
LoggingFileCountLimit,
136+
LoggingFormat,
136137
// Uninstall behavior
137138
UninstallPurgePortablePackage,
138139
// Download behavior
@@ -237,6 +238,7 @@ namespace AppInstaller::Settings
237238
SETTINGMAPPING_SPECIALIZATION(Setting::LoggingFileTotalSizeLimitInMB, uint32_t, uint32_t, 128, ".logging.file.totalSizeLimitInMB"sv);
238239
SETTINGMAPPING_SPECIALIZATION(Setting::LoggingFileIndividualSizeLimitInMB, uint32_t, uint32_t, 16, ".logging.file.individualSizeLimitInMB"sv);
239240
SETTINGMAPPING_SPECIALIZATION(Setting::LoggingFileCountLimit, uint32_t, uint32_t, 0, ".logging.file.countLimit"sv);
241+
SETTINGMAPPING_SPECIALIZATION(Setting::LoggingFormat, std::string, Logging::LogFileFormat, Logging::LogFileFormat::WinGet, ".logging.format"sv);
240242
// Interactivity
241243
SETTINGMAPPING_SPECIALIZATION(Setting::InteractivityDisable, bool, bool, false, ".interactivity.disable"sv);
242244
// Output behavior

src/AppInstallerCommonCore/UserSettings.cpp

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -530,6 +530,22 @@ namespace AppInstaller::Settings
530530
return value * 24h;
531531
}
532532

533+
WINGET_VALIDATE_SIGNATURE(LoggingFormat)
534+
{
535+
static constexpr std::string_view s_format_winget = "winget";
536+
static constexpr std::string_view s_format_ccm = "ccm";
537+
538+
if (Utility::CaseInsensitiveEquals(value, s_format_winget))
539+
{
540+
return LogFileFormat::WinGet;
541+
}
542+
else if (Utility::CaseInsensitiveEquals(value, s_format_ccm))
543+
{
544+
return LogFileFormat::CCM;
545+
}
546+
return {};
547+
}
548+
533549
WINGET_VALIDATE_SIGNATURE(OutputSortOrder)
534550
{
535551
std::vector<SortField> fields;

src/AppInstallerSharedLib/Public/AppInstallerLogging.h

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -98,6 +98,15 @@ namespace AppInstaller::Logging
9898
ShortGuid,
9999
};
100100

101+
// The format used when writing log entries to a file.
102+
enum class LogFileFormat
103+
{
104+
// Default WinGet format: "<timestamp> <level> [channel] message"
105+
WinGet,
106+
// CCM/CMTrace-compatible format: "<![LOG[message]LOG]!><time="<time>" date="<date>" component="<channel>" context="" type="N" thread="<id>" file="">"
107+
CCM,
108+
};
109+
101110
// Indicates a location of significance in the logging stream.
102111
enum class Tag
103112
{

0 commit comments

Comments
 (0)