Skip to content

Commit 3e9fbbc

Browse files
committed
feat(parser): add pipe-separated format and multi-line message support
- Add support for pipe-separated log format (timestamp | level | logger | message) - Add multi-line message support with continuation line detection - Add ISO 8601 UTC timestamp formats with 'Z' suffix - Extend all tests to verify TimeStamp, LogLevel, LoggerName, and Message
1 parent 92403b8 commit 3e9fbbc

2 files changed

Lines changed: 507 additions & 43 deletions

File tree

app/Sentinel.NLogViewer.App/Parsers/PlainTextParser.cs

Lines changed: 168 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -46,45 +46,138 @@ public List<LogEventInfo> Parse(string[] lines, TextFileFormat? format)
4646
public List<LogEventInfo> Parse(string[] lines)
4747
{
4848
var results = new List<LogEventInfo>();
49+
LogEventInfo? currentEvent = null;
4950

5051
foreach (var line in lines)
5152
{
52-
if (string.IsNullOrWhiteSpace(line))
53-
continue;
53+
// Check if this line starts a new log entry (has a timestamp at the beginning)
54+
var isNewEntry = IsNewLogEntry(line);
5455

55-
var logEvent = ParseLine(line);
56-
if (logEvent != null)
56+
if (isNewEntry)
5757
{
58-
results.Add(logEvent);
58+
// Save previous event if exists
59+
if (currentEvent != null)
60+
{
61+
results.Add(currentEvent);
62+
}
63+
64+
// Parse new log entry
65+
currentEvent = ParseLine(line);
66+
}
67+
else if (currentEvent != null)
68+
{
69+
// This is a continuation line - append to current message
70+
if (string.IsNullOrWhiteSpace(line))
71+
{
72+
// Preserve empty lines in multi-line messages (e.g., stack traces)
73+
currentEvent.Message += Environment.NewLine;
74+
}
75+
else
76+
{
77+
currentEvent.Message += Environment.NewLine + line;
78+
}
5979
}
80+
// If no current event and line doesn't start new entry, skip it (orphaned continuation line)
81+
}
82+
83+
// Add the last event if exists
84+
if (currentEvent != null)
85+
{
86+
results.Add(currentEvent);
6087
}
6188

6289
return results;
6390
}
6491

92+
/// <summary>
93+
/// Determines if a line starts a new log entry by checking for timestamp pattern at the beginning
94+
/// </summary>
95+
private bool IsNewLogEntry(string line)
96+
{
97+
if (string.IsNullOrWhiteSpace(line))
98+
return false;
99+
100+
// Check if line starts with a timestamp pattern
101+
var trimmedLine = line.TrimStart();
102+
var timestampMatch = _timestampRegex.Match(trimmedLine);
103+
104+
// Timestamp must be at the start of the line (after trimming whitespace)
105+
return timestampMatch.Success && timestampMatch.Index == 0;
106+
}
107+
65108
/// <summary>
66109
/// Parses lines using the format configuration
67110
/// </summary>
68111
private List<LogEventInfo> ParseWithFormat(string[] lines, TextFileFormat format)
69112
{
70113
var results = new List<LogEventInfo>();
71114
var dataLines = lines.Skip(format.StartLineIndex).ToList();
115+
LogEventInfo? currentEvent = null;
72116

73117
foreach (var line in dataLines)
74118
{
75-
if (string.IsNullOrWhiteSpace(line))
76-
continue;
119+
// Check if this line starts a new log entry
120+
var isNewEntry = IsNewLogEntryWithFormat(line, format);
121+
122+
if (isNewEntry)
123+
{
124+
// Save previous event if exists
125+
if (currentEvent != null)
126+
{
127+
results.Add(currentEvent);
128+
}
77129

78-
var logEvent = ParseLineWithFormat(line, format);
79-
if (logEvent != null)
130+
// Parse new log entry
131+
currentEvent = ParseLineWithFormat(line, format);
132+
}
133+
else if (currentEvent != null)
80134
{
81-
results.Add(logEvent);
135+
// This is a continuation line - append to current message
136+
if (string.IsNullOrWhiteSpace(line))
137+
{
138+
// Preserve empty lines in multi-line messages (e.g., stack traces)
139+
currentEvent.Message += Environment.NewLine;
140+
}
141+
else
142+
{
143+
currentEvent.Message += Environment.NewLine + line;
144+
}
82145
}
146+
// If no current event and line doesn't start new entry, skip it (orphaned continuation line)
147+
}
148+
149+
// Add the last event if exists
150+
if (currentEvent != null)
151+
{
152+
results.Add(currentEvent);
83153
}
84154

85155
return results;
86156
}
87157

158+
/// <summary>
159+
/// Determines if a line starts a new log entry when using format-based parsing
160+
/// </summary>
161+
private bool IsNewLogEntryWithFormat(string line, TextFileFormat format)
162+
{
163+
if (string.IsNullOrWhiteSpace(line))
164+
return false;
165+
166+
// If timestamp column is mapped, check if first column matches timestamp pattern
167+
if (format.ColumnMapping.TimestampColumn >= 0)
168+
{
169+
var parts = SplitLine(line, format.Separator);
170+
if (format.ColumnMapping.TimestampColumn < parts.Length)
171+
{
172+
var timestampStr = parts[format.ColumnMapping.TimestampColumn].Trim();
173+
return TryParseTimestamp(timestampStr, out _);
174+
}
175+
}
176+
177+
// Fallback to general timestamp detection
178+
return IsNewLogEntry(line);
179+
}
180+
88181
/// <summary>
89182
/// Parses a single line using the format configuration
90183
/// </summary>
@@ -166,6 +259,12 @@ private string[] SplitLine(string line, string separator)
166259
{
167260
try
168261
{
262+
// Check if line uses pipe-separated format
263+
if (line.Contains(" | "))
264+
{
265+
return ParsePipeSeparatedLine(line);
266+
}
267+
169268
// Try to extract timestamp
170269
var timestampMatch = _timestampRegex.Match(line);
171270
var timestamp = timestampMatch.Success && TryParseTimestamp(timestampMatch.Value, out var dt)
@@ -200,6 +299,50 @@ private string[] SplitLine(string line, string separator)
200299
}
201300
}
202301

302+
/// <summary>
303+
/// Parses a pipe-separated log line: timestamp | level | logger | message
304+
/// </summary>
305+
private LogEventInfo? ParsePipeSeparatedLine(string line)
306+
{
307+
try
308+
{
309+
var parts = line.Split(new[] { " | " }, StringSplitOptions.None);
310+
311+
if (parts.Length < 4)
312+
{
313+
// Not enough parts for pipe format, fall back to regular parsing
314+
return null;
315+
}
316+
317+
// Extract timestamp (first part)
318+
var timestampStr = parts[0].Trim();
319+
var timestamp = TryParseTimestamp(timestampStr, out var dt) ? dt : DateTime.Now;
320+
321+
// Extract level (second part)
322+
var levelStr = parts[1].Trim();
323+
var level = ParseLogLevel(levelStr);
324+
325+
// Extract logger (third part)
326+
var loggerName = parts[2].Trim();
327+
if (string.IsNullOrWhiteSpace(loggerName))
328+
{
329+
loggerName = "Unknown";
330+
}
331+
332+
// Extract message (fourth part and beyond, in case message contains " | ")
333+
var message = string.Join(" | ", parts.Skip(3)).Trim();
334+
335+
return new LogEventInfo(level, loggerName, message)
336+
{
337+
TimeStamp = timestamp
338+
};
339+
}
340+
catch
341+
{
342+
return null;
343+
}
344+
}
345+
203346
/// <summary>
204347
/// Attempts to parse a timestamp string using various common formats
205348
/// </summary>
@@ -219,6 +362,9 @@ private bool TryParseTimestamp(string timestampStr, out DateTime result)
219362
"yyyy-MM-ddTHH:mm:ss.ffff", // ISO 8601 with microseconds
220363
"yyyy-MM-ddTHH:mm:ss.fff", // ISO 8601 with milliseconds
221364
"yyyy-MM-ddTHH:mm:ss", // ISO 8601
365+
"yyyy-MM-ddTHH:mm:ss.ffffZ", // ISO 8601 with microseconds and UTC (Z)
366+
"yyyy-MM-ddTHH:mm:ss.fffZ", // ISO 8601 with milliseconds and UTC (Z)
367+
"yyyy-MM-ddTHH:mm:ssZ", // ISO 8601 with UTC (Z)
222368
"yyyy-MM-dd HH:mm:ss.ffffK", // With timezone
223369
"yyyy-MM-dd HH:mm:ss.fffK", // With timezone
224370
"yyyy-MM-dd HH:mm:ssK" // With timezone
@@ -227,14 +373,24 @@ private bool TryParseTimestamp(string timestampStr, out DateTime result)
227373
// Try parsing with exact formats first (using InvariantCulture to avoid locale issues)
228374
foreach (var format in formats)
229375
{
230-
if (DateTime.TryParseExact(timestampStr, format, CultureInfo.InvariantCulture, DateTimeStyles.None, out result))
376+
// Use RoundtripKind for formats with 'Z' to preserve UTC
377+
var styles = format.EndsWith("Z")
378+
? DateTimeStyles.RoundtripKind
379+
: DateTimeStyles.None;
380+
381+
if (DateTime.TryParseExact(timestampStr, format, CultureInfo.InvariantCulture, styles, out result))
231382
{
232383
return true;
233384
}
234385
}
235386

236387
// Fallback: Try parsing with InvariantCulture (handles ISO formats better)
237-
if (DateTime.TryParse(timestampStr, CultureInfo.InvariantCulture, DateTimeStyles.None, out result))
388+
// Use RoundtripKind to preserve UTC for timestamps ending with 'Z'
389+
var fallbackStyles = timestampStr.EndsWith("Z", StringComparison.OrdinalIgnoreCase)
390+
? DateTimeStyles.RoundtripKind
391+
: DateTimeStyles.None;
392+
393+
if (DateTime.TryParse(timestampStr, CultureInfo.InvariantCulture, fallbackStyles, out result))
238394
{
239395
return true;
240396
}

0 commit comments

Comments
 (0)