Skip to content

Commit d2f8e54

Browse files
Self-diagnostics - support formatted message for better human readability (#6411)
Co-authored-by: Rajkumar Rangaraj <[email protected]>
1 parent 27234c2 commit d2f8e54

File tree

6 files changed

+161
-17
lines changed

6 files changed

+161
-17
lines changed

src/OpenTelemetry/CHANGELOG.md

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,21 @@ Notes](../../RELEASENOTES.md).
66

77
## Unreleased
88

9+
* Added `FormatMessage` configuration option to self-diagnostics feature. When
10+
set to `true` (default is false), log messages will be formatted by replacing
11+
placeholders with actual parameter values for improved readability.
12+
13+
Example `OTEL_DIAGNOSTICS.json`:
14+
15+
```json
16+
{
17+
"LogDirectory": ".",
18+
"FileSize": 32768,
19+
"LogLevel": "Warning",
20+
"FormatMessage": true
21+
}
22+
```
23+
924
## 1.12.0
1025

1126
Released 2025-Apr-29

src/OpenTelemetry/Internal/SelfDiagnosticsConfigParser.cs

Lines changed: 22 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,9 @@ internal sealed class SelfDiagnosticsConfigParser
2828
private static readonly Regex LogLevelRegex = new(
2929
@"""LogLevel""\s*:\s*""(?<LogLevel>.*?)""", RegexOptions.IgnoreCase | RegexOptions.Compiled);
3030

31+
private static readonly Regex FormatMessageRegex = new(
32+
@"""FormatMessage""\s*:\s*(?:""(?<FormatMessage>.*?)""|(?<FormatMessage>true|false))", RegexOptions.IgnoreCase | RegexOptions.Compiled);
33+
3134
// This class is called in SelfDiagnosticsConfigRefresher.UpdateMemoryMappedFileFromConfiguration
3235
// in both main thread and the worker thread.
3336
// In theory the variable won't be access at the same time because worker thread first Task.Delay for a few seconds.
@@ -36,11 +39,13 @@ internal sealed class SelfDiagnosticsConfigParser
3639
public bool TryGetConfiguration(
3740
[NotNullWhen(true)] out string? logDirectory,
3841
out int fileSizeInKB,
39-
out EventLevel logLevel)
42+
out EventLevel logLevel,
43+
out bool formatMessage)
4044
{
4145
logDirectory = null;
4246
fileSizeInKB = 0;
4347
logLevel = EventLevel.LogAlways;
48+
formatMessage = false;
4449
try
4550
{
4651
var configFilePath = ConfigFileName;
@@ -107,6 +112,9 @@ public bool TryGetConfiguration(
107112
return false;
108113
}
109114

115+
// FormatMessage is optional, defaults to false
116+
_ = TryParseFormatMessage(configJson, out formatMessage);
117+
110118
return Enum.TryParse(logLevelString, out logLevel);
111119
}
112120
catch (Exception)
@@ -141,4 +149,17 @@ internal static bool TryParseLogLevel(
141149
logLevel = logLevelResult.Groups["LogLevel"].Value;
142150
return logLevelResult.Success && !string.IsNullOrWhiteSpace(logLevel);
143151
}
152+
153+
internal static bool TryParseFormatMessage(string configJson, out bool formatMessage)
154+
{
155+
formatMessage = false;
156+
var formatMessageResult = FormatMessageRegex.Match(configJson);
157+
if (formatMessageResult.Success)
158+
{
159+
var formatMessageValue = formatMessageResult.Groups["FormatMessage"].Value;
160+
return bool.TryParse(formatMessageValue, out formatMessage);
161+
}
162+
163+
return true;
164+
}
144165
}

src/OpenTelemetry/Internal/SelfDiagnosticsConfigRefresher.cs

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -43,6 +43,7 @@ internal class SelfDiagnosticsConfigRefresher : IDisposable
4343
private int logFileSize; // Log file size in bytes
4444
private long logFilePosition; // The logger will write into the byte at this position
4545
private EventLevel logEventLevel = (EventLevel)(-1);
46+
private bool formatMessage;
4647

4748
public SelfDiagnosticsConfigRefresher()
4849
{
@@ -138,7 +139,7 @@ private async Task Worker(CancellationToken cancellationToken)
138139

139140
private void UpdateMemoryMappedFileFromConfiguration()
140141
{
141-
if (this.configParser.TryGetConfiguration(out string? newLogDirectory, out int fileSizeInKB, out EventLevel newEventLevel))
142+
if (this.configParser.TryGetConfiguration(out string? newLogDirectory, out int fileSizeInKB, out EventLevel newEventLevel, out bool formatMessage))
142143
{
143144
int newFileSize = fileSizeInKB * 1024;
144145
if (!newLogDirectory.Equals(this.logDirectory, StringComparison.Ordinal) || this.logFileSize != newFileSize)
@@ -147,16 +148,18 @@ private void UpdateMemoryMappedFileFromConfiguration()
147148
this.OpenLogFile(newLogDirectory, newFileSize);
148149
}
149150

150-
if (!newEventLevel.Equals(this.logEventLevel))
151+
if (!newEventLevel.Equals(this.logEventLevel) || this.formatMessage != formatMessage)
151152
{
152153
if (this.eventListener != null)
153154
{
154155
this.eventListener.Dispose();
155156
}
156157

157-
this.eventListener = new SelfDiagnosticsEventListener(newEventLevel, this);
158+
this.eventListener = new SelfDiagnosticsEventListener(newEventLevel, this, formatMessage);
158159
this.logEventLevel = newEventLevel;
159160
}
161+
162+
this.formatMessage = formatMessage;
160163
}
161164
else
162165
{

src/OpenTelemetry/Internal/SelfDiagnosticsEventListener.cs

Lines changed: 24 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -19,17 +19,19 @@ internal sealed class SelfDiagnosticsEventListener : EventListener
1919
private readonly Lock lockObj = new();
2020
private readonly EventLevel logLevel;
2121
private readonly SelfDiagnosticsConfigRefresher configRefresher;
22+
private readonly bool formatMessage;
2223
private readonly ThreadLocal<byte[]?> writeBuffer = new(() => null);
2324
private readonly List<EventSource>? eventSourcesBeforeConstructor = [];
2425

2526
private bool disposedValue;
2627

27-
public SelfDiagnosticsEventListener(EventLevel logLevel, SelfDiagnosticsConfigRefresher configRefresher)
28+
public SelfDiagnosticsEventListener(EventLevel logLevel, SelfDiagnosticsConfigRefresher configRefresher, bool formatMessage = false)
2829
{
2930
Guard.ThrowIfNull(configRefresher);
3031

3132
this.logLevel = logLevel;
3233
this.configRefresher = configRefresher;
34+
this.formatMessage = formatMessage;
3335

3436
List<EventSource> eventSources;
3537
lock (this.lockObj)
@@ -229,20 +231,30 @@ internal void WriteEvent(string? eventMessage, ReadOnlyCollection<object?>? payl
229231

230232
var pos = DateTimeGetBytes(DateTime.UtcNow, buffer, 0);
231233
buffer[pos++] = (byte)':';
232-
pos = EncodeInBuffer(eventMessage, false, buffer, pos);
233-
if (payload != null)
234+
235+
if (this.formatMessage && eventMessage != null && payload != null && payload.Count > 0)
236+
{
237+
// Use string.Format to format the message with parameters
238+
string messageToWrite = string.Format(System.Globalization.CultureInfo.InvariantCulture, eventMessage, payload.ToArray());
239+
pos = EncodeInBuffer(messageToWrite, false, buffer, pos);
240+
}
241+
else
234242
{
235-
// Not using foreach because it can cause allocations
236-
for (int i = 0; i < payload.Count; ++i)
243+
pos = EncodeInBuffer(eventMessage, false, buffer, pos);
244+
if (payload != null)
237245
{
238-
object? obj = payload[i];
239-
if (obj != null)
240-
{
241-
pos = EncodeInBuffer(obj.ToString() ?? "null", true, buffer, pos);
242-
}
243-
else
246+
// Not using foreach because it can cause allocations
247+
for (int i = 0; i < payload.Count; ++i)
244248
{
245-
pos = EncodeInBuffer("null", true, buffer, pos);
249+
object? obj = payload[i];
250+
if (obj != null)
251+
{
252+
pos = EncodeInBuffer(obj.ToString() ?? "null", true, buffer, pos);
253+
}
254+
else
255+
{
256+
pos = EncodeInBuffer("null", true, buffer, pos);
257+
}
246258
}
247259
}
248260
}

src/OpenTelemetry/README.md

Lines changed: 21 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -82,7 +82,8 @@ the following content:
8282
{
8383
"LogDirectory": ".",
8484
"FileSize": 32768,
85-
"LogLevel": "Warning"
85+
"LogLevel": "Warning",
86+
"FormatMessage": "true"
8687
}
8788
```
8889

@@ -117,6 +118,25 @@ You can also find the exact directory by calling these methods from your code.
117118
higher severity levels. For example, `Warning` includes the `Error` and
118119
`Critical` levels.
119120

121+
4. `FormatMessage` is a boolean value that controls whether log messages should
122+
be formatted by replacing placeholders (`{0}`, `{1}`, etc.) with their actual
123+
parameter values. When set to `false` (default), messages are logged with
124+
unformatted placeholders followed by raw parameter values. When set to
125+
`true`, placeholders are replaced with formatted parameter values for
126+
improved readability.
127+
128+
**Example with `FormatMessage: false` (default):**
129+
130+
```txt
131+
2025-07-24T01:45:04.1020880Z:Measurements from Instrument '{0}', Meter '{1}' will be ignored. Reason: '{2}'. Suggested action: '{3}'{dotnet.gc.collections}{System.Runtime}{Instrument belongs to a Meter not subscribed by the provider.}{Use AddMeter to add the Meter to the provider.}
132+
```
133+
134+
**Example with `FormatMessage: true`:**
135+
136+
```txt
137+
2025-07-24T01:44:44.7059260Z:Measurements from Instrument 'dotnet.gc.collections', Meter 'System.Runtime' will be ignored. Reason: 'Instrument belongs to a Meter not subscribed by the provider.'. Suggested action: 'Use AddMeter to add the Meter to the provider.'
138+
```
139+
120140
#### Remarks
121141

122142
A `FileSize`-KiB log file named as `ExecutableName.ProcessId.log` (e.g.

test/OpenTelemetry.Tests/Internal/SelfDiagnosticsConfigParserTests.cs

Lines changed: 73 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -72,4 +72,77 @@ public void SelfDiagnosticsConfigParser_TryParseLogLevel()
7272
Assert.True(SelfDiagnosticsConfigParser.TryParseLogLevel(configJson, out string? logLevelString));
7373
Assert.Equal("Error", logLevelString);
7474
}
75+
76+
[Fact]
77+
public void SelfDiagnosticsConfigParser_TryParseFormatMessage_Success()
78+
{
79+
string configJson = """
80+
{
81+
"LogDirectory": "Diagnostics",
82+
"FileSize": 1024,
83+
"LogLevel": "Error",
84+
"FormatMessage": "true"
85+
}
86+
""";
87+
Assert.True(SelfDiagnosticsConfigParser.TryParseFormatMessage(configJson, out bool formatMessage));
88+
Assert.True(formatMessage);
89+
}
90+
91+
[Fact]
92+
public void SelfDiagnosticsConfigParser_TryParseFormatMessage_CaseInsensitive()
93+
{
94+
string configJson = """
95+
{
96+
"LogDirectory": "Diagnostics",
97+
"fileSize": 1024,
98+
"formatMessage": "FALSE"
99+
}
100+
""";
101+
Assert.True(SelfDiagnosticsConfigParser.TryParseFormatMessage(configJson, out bool formatMessage));
102+
Assert.False(formatMessage);
103+
}
104+
105+
[Fact]
106+
public void SelfDiagnosticsConfigParser_TryParseFormatMessage_MissingField()
107+
{
108+
string configJson = """
109+
{
110+
"LogDirectory": "Diagnostics",
111+
"FileSize": 1024,
112+
"LogLevel": "Error"
113+
}
114+
""";
115+
Assert.True(SelfDiagnosticsConfigParser.TryParseFormatMessage(configJson, out bool formatMessage));
116+
Assert.False(formatMessage); // Should default to false
117+
}
118+
119+
[Fact]
120+
public void SelfDiagnosticsConfigParser_TryParseFormatMessage_InvalidValue()
121+
{
122+
string configJson = """
123+
{
124+
"LogDirectory": "Diagnostics",
125+
"FileSize": 1024,
126+
"LogLevel": "Error",
127+
"FormatMessage": "invalid"
128+
}
129+
""";
130+
Assert.False(SelfDiagnosticsConfigParser.TryParseFormatMessage(configJson, out bool formatMessage));
131+
Assert.False(formatMessage); // Should default to false
132+
}
133+
134+
[Fact]
135+
public void SelfDiagnosticsConfigParser_TryParseFormatMessage_UnquotedBoolean()
136+
{
137+
string configJson = """
138+
{
139+
"LogDirectory": "Diagnostics",
140+
"FileSize": 1024,
141+
"LogLevel": "Error",
142+
"FormatMessage": true
143+
}
144+
""";
145+
Assert.True(SelfDiagnosticsConfigParser.TryParseFormatMessage(configJson, out bool formatMessage));
146+
Assert.True(formatMessage);
147+
}
75148
}

0 commit comments

Comments
 (0)