mirror of
https://github.com/okieselbach/AutopilotMonitor.git
synced 2026-10-24 04:32:32 +00:00
Logparser pattern testing with the agent's exact .NET engine (test_log_pattern) (#125)
* updated commands * feat(backend): logparser pattern test endpoint with agent-exact semantics POST /api/rules/gather/test-pattern evaluates a logparser gather-rule regex against pasted sample lines exactly the way the agent would on the device. Motivated by a field incident: a customer verified a pattern with a PHP regex tester and the .NET engine matched differently. - CmTraceLogParser moves Agent -> AutopilotMonitor.Shared.Logging so agent (IME tracker, logparser collector, stall probe, IME matching mode) and backend run the SAME parsing implementation. Pure mechanical move, agent behavior unchanged (V2.Core suite 2147/2147 green). - TestLogPatternFunction mirrors LogParserCollector verbatim: Regex(Compiled, 1s timeout, NO IgnoreCase -> case-sensitive), first match per line, group "0" excluded / unsuccessful groups omitted, format cmtrace (default, match against parsed message) vs text (raw line), per-line outcomes incl. parse_failed/regex_timeout, and the agent's "set parameter format=text" hint verbatim. Pure compute, no tenant data. Caps: 200 lines, 8KB/line. - Policy: TenantAdminOrGlobalReader (same tier as the analyze dry-run). - Tests pin the parity showcases: case-sensitivity, envelope invisibility in cmtrace mode, optional-group omission, format hint, request validation. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * feat(mcp): test_log_pattern tool + logparser lint and guide hardening - test_log_pattern: wraps POST /api/rules/gather/test-pattern — tests a logparser regex against pasted sample lines with the agent-exact .NET semantics (case-sensitive, cmtrace/text mode). Description steers clients away from JS/PHP/Python regex testing. - validate_rule: logparser parameter lint (missing/non-compiling pattern, unknown format value = silently cmtrace on the agent) + info pointing to test_log_pattern; nextStep for valid logparser drafts routes there too. - rule_authoring_guide: logparser section now states the matching semantics (case-SENSITIVE, .NET, first match per line, cmtrace default) and two new pitfalls (engine mismatch, plain-text log under cmtrace default). - Catalog snapshot + handler/validator tests extended (MCP suite 532 green). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * docs(okf): test-pattern endpoint + shared CmTraceLogParser in rule authoring surface Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> --------- Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
committed by
GitHub
parent
acc95471a6
commit
75197a4184
+1
-1
Submodule .claude/commands updated: 5c66c265a8...a7e749f215
@@ -1,5 +1,9 @@
|
||||
# Log
|
||||
|
||||
## 2026-08-05 (later)
|
||||
|
||||
* **Update**: `rules/rule-authoring-surface.md` — new section `POST /api/rules/gather/test-pattern`: the logparser pattern tester (agent-exact .NET matching: no IgnoreCase, first-match-per-line, cmtrace vs text mode, verbatim format=text hint), motivated by a field incident where a PHP-verified regex behaved differently under .NET. `CmTraceLogParser` moved Agent → `AutopilotMonitor.Shared.Logging` so agent and backend share the ONE parsing implementation (timestamp caveat: AssumeLocal = server TZ on backend, endpoint reports no timestamps). MCP: new `test_log_pattern` tool, logparser lint in `validate_rule`, guide gains the case-sensitivity/engine-mismatch pitfalls.
|
||||
|
||||
## 2026-08-05
|
||||
|
||||
* **Creation**: Added `mcp/tool-telemetry.md` — the three MCP tool-telemetry layers (App Insights `McpToolName`, per-user usage table, `MCP_TOOL_LOGGING` stderr lines) and the new quality signals: soft-error capture (`toolError` returns instead of throwing, so backend telemetry counted those calls as successes), `resultChars`/`overCap` vs. the inline-size hint, bounded args summaries, caller scope, `search_zero_hit` on the three search tools (unmet-demand signal), and `tool_call_rejected` for JSON-RPC-level Zod/unknown-tool rejections sniffed at the `/mcp` route (previously invisible everywhere). Bicep default for `toolLoggingEnabled` flipped to `true` (documented desired state; live flag set via `az containerapp update`).
|
||||
|
||||
@@ -100,15 +100,50 @@ Invariants, pinned by `RuleEngineDryRunTests`:
|
||||
explanation/remediation preview via the existing `interpolate-rule-template.ts`
|
||||
against the returned `matchedConditions`.
|
||||
|
||||
## POST /api/rules/gather/test-pattern (backend)
|
||||
|
||||
The logparser counterpart to the analyze dry-run, added after a field incident:
|
||||
a customer verified a logparser regex with a PHP tester and the agent's .NET
|
||||
engine matched differently. `TestLogPatternFunction` evaluates a pattern against
|
||||
pasted sample lines with the AGENT's exact matching semantics:
|
||||
|
||||
* Regex construction identical to `LogParserCollector`: `Compiled`, 1 s timeout,
|
||||
**no IgnoreCase** — logparser matching is case-sensitive (unlike analyze-rule
|
||||
regex conditions, which run IgnoreCase).
|
||||
* `format` behaves exactly like the rule parameter: `cmtrace` (default) parses
|
||||
each line via the shared `CmTraceLogParser` and matches against the parsed
|
||||
MESSAGE only; `text` matches the raw line. First match per line
|
||||
(`Regex.Match`), group `"0"` excluded, unsuccessful groups omitted — the
|
||||
returned `groups` are exactly the event-data fields the device would emit.
|
||||
* Mirrors the agent's diagnostics: per-line outcomes
|
||||
(`matched | no_match | parse_failed | regex_timeout`), the all-lines-parse-fail
|
||||
hint ("set parameter format=text") verbatim, and a case-sensitivity note on
|
||||
zero matches.
|
||||
* Pure compute on the request body — no tenant data touched. Same policy tier
|
||||
as the dry-run (`TenantAdminOrGlobalReader`); caps: 200 lines, 8 KB/line,
|
||||
1 MB body.
|
||||
|
||||
Parity is structural, not duplicated: `CmTraceLogParser` moved from the agent
|
||||
(`Monitoring/Enrollment/Ime`) to **Shared** (`AutopilotMonitor.Shared.Logging`)
|
||||
so agent and backend run the SAME parsing implementation. Timestamp caveat:
|
||||
bias-less CMTrace lines parse with `AssumeLocal`, which on the backend is the
|
||||
server's timezone — the endpoint therefore reports match/parse results, never
|
||||
timestamps. `test_log_pattern` (MCP) wraps the endpoint; `validate_rule`
|
||||
additionally lints logparser parameters (missing/non-compiling pattern, unknown
|
||||
format value) and its `nextStep` routes logparser drafts to `test_log_pattern`.
|
||||
|
||||
## What is deliberately absent
|
||||
|
||||
* **No gather-rule remote execution** — gather rules run as SYSTEM on devices;
|
||||
the dry-run surface is analyze-only. Gather authors get guardrail pre-flight
|
||||
plus the on-device debug log ([gather-rule-debug-log](../agent/gather-rule-debug-log.md)).
|
||||
session dry-run is analyze-only. Logparser authors get the pattern tester
|
||||
above; all gather authors get guardrail pre-flight plus the on-device debug
|
||||
log ([gather-rule-debug-log](../agent/gather-rule-debug-log.md)).
|
||||
* **No rule write/deploy via MCP** — creating rules stays in the portal.
|
||||
|
||||
# Citations
|
||||
|
||||
* `src/Backend/AutopilotMonitor.Functions/Functions/Rules/TestLogPatternFunction.cs` — logparser pattern tester
|
||||
* `src/Shared/AutopilotMonitor.Shared/Logging/CmTraceLogParser.cs` — shared CMTrace parser (agent + backend)
|
||||
* `src/Backend/AutopilotMonitor.Functions/Services/RuleEngine.DryRun.cs` — trace twin + DTOs
|
||||
* `src/Backend/AutopilotMonitor.Functions/Functions/Rules/DryRunAnalyzeRuleFunction.cs` — endpoint + draft validation
|
||||
* `src/McpServer/autopilot-monitor-mcp/src/rule-validation.ts` — validator
|
||||
|
||||
+1
-1
@@ -1,5 +1,5 @@
|
||||
using System;
|
||||
using AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.Ime;
|
||||
using AutopilotMonitor.Shared.Logging;
|
||||
using Xunit;
|
||||
|
||||
namespace AutopilotMonitor.Agent.V2.Core.Tests.Monitoring.Ime
|
||||
|
||||
+1
@@ -7,6 +7,7 @@ using System.Text.RegularExpressions;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using AutopilotMonitor.Agent.V2.Core.Logging;
|
||||
using AutopilotMonitor.Shared.Logging;
|
||||
using AutopilotMonitor.Shared.Models;
|
||||
|
||||
namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.Ime
|
||||
|
||||
+1
@@ -10,6 +10,7 @@ using AutopilotMonitor.Agent.V2.Core.Logging;
|
||||
using AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.Ime;
|
||||
using AutopilotMonitor.Agent.V2.Core.Orchestration;
|
||||
using AutopilotMonitor.Shared;
|
||||
using AutopilotMonitor.Shared.Logging;
|
||||
using AutopilotMonitor.Shared.Models;
|
||||
using Microsoft.Win32;
|
||||
|
||||
|
||||
+1
@@ -10,6 +10,7 @@ using AutopilotMonitor.Agent.V2.Core.Monitoring.Runtime;
|
||||
using AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment;
|
||||
using AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.Ime;
|
||||
using AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.SystemSignals;
|
||||
using AutopilotMonitor.Shared.Logging;
|
||||
|
||||
namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Telemetry.Gather.Collectors
|
||||
{
|
||||
|
||||
@@ -6,6 +6,7 @@ using System.Net.Http;
|
||||
using System.Text;
|
||||
using System.Text.RegularExpressions;
|
||||
using AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.Ime;
|
||||
using AutopilotMonitor.Shared.Logging;
|
||||
using AutopilotMonitor.Shared.Models;
|
||||
using Newtonsoft.Json;
|
||||
|
||||
|
||||
@@ -0,0 +1,145 @@
|
||||
using System.Text.RegularExpressions;
|
||||
using AutopilotMonitor.Functions.Functions.Rules;
|
||||
|
||||
namespace AutopilotMonitor.Functions.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Pins the agent-parity semantics of the logparser pattern test endpoint: case-sensitive
|
||||
/// matching (the field incident that motivated the endpoint: a pattern verified with a PHP
|
||||
/// regex tester behaved differently under .NET), first-match-per-line, group extraction
|
||||
/// identical to what LogParserCollector puts into the emitted event data, CMTrace parsing
|
||||
/// via the SHARED CmTraceLogParser, and the format=text hint.
|
||||
/// </summary>
|
||||
public class TestLogPatternFunctionTests
|
||||
{
|
||||
private static Regex AgentRegex(string pattern)
|
||||
=> new(pattern, RegexOptions.Compiled, TimeSpan.FromSeconds(1));
|
||||
|
||||
private const string CmTraceLine =
|
||||
"<![LOG[Installation failed for app Ivanti Secure Access Client with error 0x87D1041C]LOG]!>" +
|
||||
"<time=\"07:48:35.1783405\" date=\"8-4-2026\" component=\"AppWorkload\" context=\"\" type=\"3\" thread=\"42\" file=\"\">";
|
||||
|
||||
// ===== text mode =====
|
||||
|
||||
[Fact]
|
||||
public void TextMode_NamedGroups_LandInGroups()
|
||||
{
|
||||
var result = TestLogPatternFunction.EvaluatePattern(
|
||||
AgentRegex(@"(?<level>ERROR|FATAL)\s+(?<component>\w+):\s+(?<message>.+)"),
|
||||
isTextMode: true,
|
||||
new[] { "ERROR Installer: disk full", "INFO Installer: all good" });
|
||||
|
||||
Assert.Equal(1, result.MatchCount);
|
||||
Assert.Equal("matched", result.Lines[0].Outcome);
|
||||
Assert.Equal("ERROR", result.Lines[0].Groups!["level"]);
|
||||
Assert.Equal("Installer", result.Lines[0].Groups!["component"]);
|
||||
Assert.Equal("disk full", result.Lines[0].Groups!["message"]);
|
||||
Assert.Equal("no_match", result.Lines[1].Outcome);
|
||||
}
|
||||
|
||||
/// <summary>The motivating field case: logparser matching has NO IgnoreCase — a pattern
|
||||
/// that matched in a case-insensitive tester silently never fires on the device.</summary>
|
||||
[Fact]
|
||||
public void TextMode_IsCaseSensitive_AndSaysSoInTheNote()
|
||||
{
|
||||
var result = TestLogPatternFunction.EvaluatePattern(
|
||||
AgentRegex("ERROR"), isTextMode: true, new[] { "error: something broke" });
|
||||
|
||||
Assert.Equal(0, result.MatchCount);
|
||||
Assert.Contains(result.Notes, n => n.Contains("case-SENSITIVE"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void TextMode_UnsuccessfulOptionalGroup_IsOmitted()
|
||||
{
|
||||
var result = TestLogPatternFunction.EvaluatePattern(
|
||||
AgentRegex(@"ERROR(?<code>\s0x[0-9A-F]+)?"), isTextMode: true, new[] { "ERROR without code" });
|
||||
|
||||
Assert.Equal(1, result.MatchCount);
|
||||
Assert.False(result.Lines[0].Groups!.ContainsKey("code"));
|
||||
}
|
||||
|
||||
// ===== cmtrace mode =====
|
||||
|
||||
[Fact]
|
||||
public void CmTraceMode_MatchesAgainstParsedMessage_AndReportsComponentAndType()
|
||||
{
|
||||
var result = TestLogPatternFunction.EvaluatePattern(
|
||||
AgentRegex(@"error (?<errorCode>0x[0-9A-Fa-f]+)"), isTextMode: false, new[] { CmTraceLine });
|
||||
|
||||
Assert.Equal(1, result.MatchCount);
|
||||
var row = result.Lines[0];
|
||||
Assert.Equal("matched", row.Outcome);
|
||||
Assert.Equal("0x87D1041C", row.Groups!["errorCode"]);
|
||||
Assert.Equal("AppWorkload", row.Component);
|
||||
Assert.Equal(3, row.CmTraceType);
|
||||
Assert.Contains("Installation failed", row.Message);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CmTraceMode_RegexDoesNotSeeTheEnvelope()
|
||||
{
|
||||
// "component=" only exists in the CMTrace envelope, never in the message the
|
||||
// agent matches against — a pattern targeting it must NOT match.
|
||||
var result = TestLogPatternFunction.EvaluatePattern(
|
||||
AgentRegex("component="), isTextMode: false, new[] { CmTraceLine });
|
||||
|
||||
Assert.Equal(0, result.MatchCount);
|
||||
Assert.Equal("no_match", result.Lines[0].Outcome);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CmTraceMode_PlainTextLines_ParseFail_WithFormatHint()
|
||||
{
|
||||
var result = TestLogPatternFunction.EvaluatePattern(
|
||||
AgentRegex("ERROR"), isTextMode: false, new[] { "ERROR plain line", "another plain line" });
|
||||
|
||||
Assert.Equal(2, result.ParseFailureCount);
|
||||
Assert.All(result.Lines, l => Assert.Equal("parse_failed", l.Outcome));
|
||||
Assert.Contains(result.Notes, n => n.Contains("format=text"));
|
||||
}
|
||||
|
||||
// ===== request validation =====
|
||||
|
||||
[Fact]
|
||||
public void Validate_RejectsMissingPatternAndLines()
|
||||
{
|
||||
Assert.Contains("pattern", TestLogPatternFunction.ValidateRequest(
|
||||
new TestLogPatternFunction.TestLogPatternRequest { SampleLines = new List<string> { "x" } })!);
|
||||
Assert.Contains("sampleLines", TestLogPatternFunction.ValidateRequest(
|
||||
new TestLogPatternFunction.TestLogPatternRequest { Pattern = "a" })!);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Validate_RejectsTooManyLines_AndBadFormat()
|
||||
{
|
||||
var tooMany = new TestLogPatternFunction.TestLogPatternRequest
|
||||
{
|
||||
Pattern = "a",
|
||||
SampleLines = Enumerable.Repeat("line", TestLogPatternFunction.MaxSampleLines + 1).ToList(),
|
||||
};
|
||||
Assert.Contains("at most", TestLogPatternFunction.ValidateRequest(tooMany)!);
|
||||
|
||||
var badFormat = new TestLogPatternFunction.TestLogPatternRequest
|
||||
{
|
||||
Pattern = "a",
|
||||
Format = "json",
|
||||
SampleLines = new List<string> { "x" },
|
||||
};
|
||||
Assert.Contains("format", TestLogPatternFunction.ValidateRequest(badFormat)!);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Validate_AcceptsBothFormatsAndDefault()
|
||||
{
|
||||
foreach (var format in new[] { null, "cmtrace", "text", "TEXT" })
|
||||
{
|
||||
Assert.Null(TestLogPatternFunction.ValidateRequest(new TestLogPatternFunction.TestLogPatternRequest
|
||||
{
|
||||
Pattern = "a",
|
||||
Format = format,
|
||||
SampleLines = new List<string> { "x" },
|
||||
}));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,244 @@
|
||||
using System.Net;
|
||||
using System.Text.RegularExpressions;
|
||||
using AutopilotMonitor.Shared.Logging;
|
||||
using Microsoft.Azure.Functions.Worker;
|
||||
using Microsoft.Azure.Functions.Worker.Http;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Newtonsoft.Json;
|
||||
|
||||
namespace AutopilotMonitor.Functions.Functions.Rules
|
||||
{
|
||||
/// <summary>
|
||||
/// Tests a logparser gather-rule regex against pasted sample log lines with the AGENT's
|
||||
/// exact matching semantics — the ".NET dry-run" for logparser rules, which cannot be
|
||||
/// dry-run against a session (they execute on devices).
|
||||
///
|
||||
/// Parity is the entire point: authors test regexes in whatever engine their AI client
|
||||
/// has (JS, PHP, Python) and .NET behaves subtly differently (this endpoint exists
|
||||
/// because exactly that happened in the field). Mirrored semantics, keep in lock-step
|
||||
/// with LogParserCollector (agent):
|
||||
/// - Regex options: Compiled, 1s timeout, NO IgnoreCase — logparser matching is
|
||||
/// case-SENSITIVE (unlike analyze-rule regex conditions).
|
||||
/// - First match per line (Regex.Match, not Matches).
|
||||
/// - format "cmtrace" (default): CmTraceLogParser.TryParseLine first (shared code, the
|
||||
/// same implementation the agent runs), regex against the parsed MESSAGE only.
|
||||
/// - format "text": regex against the raw line.
|
||||
/// - Captured data: every named/numbered group except "0", unsuccessful groups omitted.
|
||||
///
|
||||
/// No tenant data is touched; the endpoint is pure compute on the request body.
|
||||
/// </summary>
|
||||
public class TestLogPatternFunction
|
||||
{
|
||||
private readonly ILogger<TestLogPatternFunction> _logger;
|
||||
|
||||
public TestLogPatternFunction(ILogger<TestLogPatternFunction> logger)
|
||||
{
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
public sealed class TestLogPatternRequest
|
||||
{
|
||||
public string? Pattern { get; set; }
|
||||
public string? Format { get; set; }
|
||||
public List<string>? SampleLines { get; set; }
|
||||
}
|
||||
|
||||
internal const int MaxSampleLines = 200;
|
||||
internal const int MaxLineLength = 8192;
|
||||
|
||||
[Function("TestLogPattern")]
|
||||
public async Task<HttpResponseData> Run(
|
||||
[HttpTrigger(AuthorizationLevel.Anonymous, "post", Route = "rules/gather/test-pattern")] HttpRequestData req)
|
||||
{
|
||||
// Authentication + TenantAdminOrGlobalReader authorization enforced by PolicyEnforcementMiddleware.
|
||||
|
||||
if (req.Headers.TryGetValues("Content-Length", out var clValues)
|
||||
&& long.TryParse(clValues.FirstOrDefault(), out var contentLength)
|
||||
&& contentLength > 1_048_576) // 1 MB limit
|
||||
{
|
||||
return await BadRequestAsync(req, "Request body too large");
|
||||
}
|
||||
|
||||
var body = await new StreamReader(req.Body).ReadToEndAsync();
|
||||
TestLogPatternRequest? request;
|
||||
try
|
||||
{
|
||||
request = JsonConvert.DeserializeObject<TestLogPatternRequest>(body);
|
||||
}
|
||||
catch (JsonException ex)
|
||||
{
|
||||
return await BadRequestAsync(req, $"Request body is not valid JSON: {ex.Message}");
|
||||
}
|
||||
|
||||
var validationError = ValidateRequest(request);
|
||||
if (validationError != null)
|
||||
{
|
||||
return await BadRequestAsync(req, validationError);
|
||||
}
|
||||
|
||||
var isTextMode = string.Equals(request!.Format, "text", StringComparison.OrdinalIgnoreCase);
|
||||
|
||||
Regex pattern;
|
||||
try
|
||||
{
|
||||
// EXACT agent construction (LogParserCollector): Compiled + 1s timeout,
|
||||
// no IgnoreCase — logparser matching is case-sensitive.
|
||||
pattern = new Regex(request.Pattern!, RegexOptions.Compiled, TimeSpan.FromSeconds(1));
|
||||
}
|
||||
catch (ArgumentException ex)
|
||||
{
|
||||
return await BadRequestAsync(req, $"Invalid regex pattern (this is the agent's .NET engine speaking): {ex.Message}");
|
||||
}
|
||||
|
||||
var result = EvaluatePattern(pattern, isTextMode, request.SampleLines!);
|
||||
|
||||
_logger.LogInformation(
|
||||
"Log-pattern test: {LineCount} lines, {MatchCount} matched, {ParseFailures} parse failures, mode={Mode}",
|
||||
request.SampleLines!.Count, result.MatchCount, result.ParseFailureCount, isTextMode ? "text" : "cmtrace");
|
||||
|
||||
var response = req.CreateResponse(HttpStatusCode.OK);
|
||||
await response.WriteAsJsonAsync(new { success = true, format = isTextMode ? "text" : "cmtrace", result });
|
||||
return response;
|
||||
}
|
||||
|
||||
internal static string? ValidateRequest(TestLogPatternRequest? request)
|
||||
{
|
||||
if (request == null || string.IsNullOrEmpty(request.Pattern))
|
||||
return "pattern is required";
|
||||
if (request.SampleLines == null || request.SampleLines.Count == 0)
|
||||
return "sampleLines is required — paste raw lines from the log file";
|
||||
if (request.SampleLines.Count > MaxSampleLines)
|
||||
return $"too many sampleLines ({request.SampleLines.Count}) — send at most {MaxSampleLines} representative lines";
|
||||
if (request.SampleLines.Any(l => l != null && l.Length > MaxLineLength))
|
||||
return $"a sample line exceeds {MaxLineLength} characters";
|
||||
if (!string.IsNullOrEmpty(request.Format)
|
||||
&& !string.Equals(request.Format, "cmtrace", StringComparison.OrdinalIgnoreCase)
|
||||
&& !string.Equals(request.Format, "text", StringComparison.OrdinalIgnoreCase))
|
||||
return "format must be \"cmtrace\" (default) or \"text\"";
|
||||
return null;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Per-line evaluation mirroring LogParserCollector's inner loop. Returns one row per
|
||||
/// sample line so the author sees exactly which lines matched, which failed CMTrace
|
||||
/// parsing, and which data fields the emitted event would carry.
|
||||
/// </summary>
|
||||
internal static LogPatternTestResult EvaluatePattern(Regex pattern, bool isTextMode, IReadOnlyList<string> lines)
|
||||
{
|
||||
var result = new LogPatternTestResult();
|
||||
|
||||
for (var i = 0; i < lines.Count; i++)
|
||||
{
|
||||
var line = lines[i] ?? string.Empty;
|
||||
var row = new LogPatternLineResult { LineNumber = i + 1 };
|
||||
result.Lines.Add(row);
|
||||
|
||||
string textToMatch;
|
||||
if (isTextMode)
|
||||
{
|
||||
textToMatch = line;
|
||||
}
|
||||
else
|
||||
{
|
||||
if (!CmTraceLogParser.TryParseLine(line, out var entry))
|
||||
{
|
||||
row.Outcome = "parse_failed";
|
||||
result.ParseFailureCount++;
|
||||
continue;
|
||||
}
|
||||
row.Component = entry.Component;
|
||||
row.CmTraceType = entry.Type;
|
||||
row.Message = Truncate(entry.Message, 500);
|
||||
textToMatch = entry.Message;
|
||||
}
|
||||
|
||||
Match match;
|
||||
try
|
||||
{
|
||||
match = pattern.Match(textToMatch);
|
||||
}
|
||||
catch (RegexMatchTimeoutException)
|
||||
{
|
||||
row.Outcome = "regex_timeout";
|
||||
result.TimeoutCount++;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!match.Success)
|
||||
{
|
||||
row.Outcome = "no_match";
|
||||
continue;
|
||||
}
|
||||
|
||||
row.Outcome = "matched";
|
||||
result.MatchCount++;
|
||||
row.MatchedText = Truncate(match.Value, 500);
|
||||
var groups = new Dictionary<string, string>();
|
||||
foreach (var groupName in pattern.GetGroupNames())
|
||||
{
|
||||
if (groupName == "0") continue;
|
||||
var group = match.Groups[groupName];
|
||||
if (group.Success)
|
||||
groups[groupName] = Truncate(group.Value, 500);
|
||||
}
|
||||
row.Groups = groups;
|
||||
}
|
||||
|
||||
// Mirror the agent's debug-log hint verbatim — the single most common logparser
|
||||
// authoring mistake is running a plain-text log through the cmtrace default.
|
||||
if (!isTextMode && result.Lines.Count > 0 && result.ParseFailureCount == result.Lines.Count)
|
||||
{
|
||||
result.Notes.Add("every line failed CMTrace parsing — if this is a plain-text log, set parameter format=text");
|
||||
}
|
||||
if (result.MatchCount == 0 && result.ParseFailureCount < result.Lines.Count)
|
||||
{
|
||||
result.Notes.Add("no line matched — remember logparser patterns are case-SENSITIVE (unlike analyze-rule regex conditions)");
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
private static string Truncate(string value, int maxLength)
|
||||
{
|
||||
if (string.IsNullOrEmpty(value) || value.Length <= maxLength)
|
||||
return value;
|
||||
return value.Substring(0, maxLength) + "...";
|
||||
}
|
||||
|
||||
private static async Task<HttpResponseData> BadRequestAsync(HttpRequestData req, string message)
|
||||
{
|
||||
var badRequest = req.CreateResponse(HttpStatusCode.BadRequest);
|
||||
await badRequest.WriteAsJsonAsync(new { success = false, message });
|
||||
return badRequest;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Aggregate outcome of one pattern test. Serialized camelCase to clients.</summary>
|
||||
public sealed class LogPatternTestResult
|
||||
{
|
||||
public int MatchCount { get; set; }
|
||||
public int ParseFailureCount { get; set; }
|
||||
public int TimeoutCount { get; set; }
|
||||
public List<LogPatternLineResult> Lines { get; } = new();
|
||||
public List<string> Notes { get; } = new();
|
||||
}
|
||||
|
||||
public sealed class LogPatternLineResult
|
||||
{
|
||||
public int LineNumber { get; set; }
|
||||
|
||||
/// <summary>matched | no_match | parse_failed | regex_timeout</summary>
|
||||
public string Outcome { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>The named/numbered capture groups exactly as they would land in the
|
||||
/// emitted event's data (group "0" excluded, unsuccessful groups omitted).</summary>
|
||||
public Dictionary<string, string>? Groups { get; set; }
|
||||
|
||||
public string? MatchedText { get; set; }
|
||||
|
||||
/// <summary>cmtrace mode only: the parsed component/type/message the regex ran against.</summary>
|
||||
public string? Component { get; set; }
|
||||
public int? CmTraceType { get; set; }
|
||||
public string? Message { get; set; }
|
||||
}
|
||||
}
|
||||
@@ -269,6 +269,9 @@ public static class EndpointAccessPolicyCatalog
|
||||
// tier so the read-only Global Reader can help debug rules while tenant Operators/Viewers
|
||||
// stay excluded from the authoring surface.
|
||||
new("POST", "rules/analyze/dryrun", EndpointPolicy.TenantAdminOrGlobalReader),
|
||||
// Same shape: POST body in, pure compute out (regex vs pasted sample lines with the
|
||||
// agent's exact .NET matching semantics) — touches no tenant data at all.
|
||||
new("POST", "rules/gather/test-pattern", EndpointPolicy.TenantAdminOrGlobalReader),
|
||||
// Member tier so Operators/Viewers can see their tenant's configuration read-only in the
|
||||
// Settings UI. Safe because the handler redacts by default: only a Global Admin or the
|
||||
// tenant's OWN admin gets the unredacted secrets — every other admitted caller (Operator,
|
||||
|
||||
@@ -162,6 +162,36 @@ describe('gather guardrails (agent matching semantics)', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('logparser parameter lint', () => {
|
||||
function logparserRule(parameters: Record<string, unknown>): Record<string, unknown> {
|
||||
const r = validGather();
|
||||
r.collectorType = 'logparser';
|
||||
r.target = 'C:\\Windows\\Logs\\CustomApp\\install.log';
|
||||
r.parameters = parameters;
|
||||
return r;
|
||||
}
|
||||
|
||||
it('missing pattern is an error', () => {
|
||||
expect(errors(validateRuleDraft(logparserRule({})).findings).some((m) => m.includes('parameters.pattern'))).toBe(true);
|
||||
});
|
||||
|
||||
it('non-compiling pattern is an error', () => {
|
||||
expect(errors(validateRuleDraft(logparserRule({ pattern: '([unclosed' })).findings)
|
||||
.some((m) => m.includes('does not compile'))).toBe(true);
|
||||
});
|
||||
|
||||
it('valid pattern yields the .NET/case-sensitivity info pointing to test_log_pattern', () => {
|
||||
const r = validateRuleDraft(logparserRule({ pattern: '(?<level>ERROR)', format: 'text' }));
|
||||
expect(r.valid).toBe(true);
|
||||
expect(r.findings.some((f) => f.level === 'info' && f.message.includes('test_log_pattern'))).toBe(true);
|
||||
});
|
||||
|
||||
it('unknown format value warns (agent silently treats it as cmtrace)', () => {
|
||||
expect(warnings(validateRuleDraft(logparserRule({ pattern: 'x', format: 'json' })).findings)
|
||||
.some((m) => m.includes('CMTrace mode'))).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('gather semantic lint', () => {
|
||||
it('interval without intervalSeconds is an error; on_event without triggerEventType is an error', () => {
|
||||
const a = validGather();
|
||||
|
||||
@@ -150,10 +150,12 @@ describe('role catalog snapshot — privilege-leak guard', () => {
|
||||
'search_sessions',
|
||||
'search_sessions_by_cve',
|
||||
'search_sessions_by_event',
|
||||
// Rule-authoring pair, available to every role: validate_rule is MCP-local
|
||||
// (no backend call at all) and test_analyze_rule's dryrun endpoint enforces
|
||||
// TenantAdminOrGlobalReader server-side (a Viewer's call 403s there).
|
||||
// Rule-authoring trio, available to every role: validate_rule is MCP-local
|
||||
// (no backend call at all); test_analyze_rule's dryrun endpoint and
|
||||
// test_log_pattern's test-pattern endpoint enforce TenantAdminOrGlobalReader
|
||||
// server-side (a Viewer's call 403s there).
|
||||
'test_analyze_rule',
|
||||
'test_log_pattern',
|
||||
'update_tenant_config',
|
||||
'validate_rule',
|
||||
];
|
||||
|
||||
@@ -114,4 +114,67 @@ describe('validate_rule', () => {
|
||||
expect(payload.valid).toBe(true);
|
||||
expect(payload.nextStep).toContain('test_analyze_rule');
|
||||
});
|
||||
|
||||
it('valid logparser draft points to test_log_pattern as the next step', async () => {
|
||||
const handlers = captureToolHandlers(false);
|
||||
const res = await handlers.validate_rule({
|
||||
rule: {
|
||||
ruleId: 'GATHER-APPS-101',
|
||||
title: 'CustomApp installer errors',
|
||||
collectorType: 'logparser',
|
||||
target: 'C:\\Windows\\Logs\\CustomApp\\install*.log',
|
||||
parameters: { pattern: '(?<level>ERROR|FATAL)', format: 'text' },
|
||||
trigger: 'startup',
|
||||
outputEventType: 'gather_customapp_errors',
|
||||
},
|
||||
});
|
||||
const payload = JSON.parse(res.content[0].text);
|
||||
expect(payload.valid).toBe(true);
|
||||
expect(payload.nextStep).toContain('test_log_pattern');
|
||||
});
|
||||
});
|
||||
|
||||
describe('test_log_pattern', () => {
|
||||
it('POSTs pattern + sampleLines + format to the test-pattern endpoint', async () => {
|
||||
apiFetchMock.mockResolvedValue({
|
||||
success: true,
|
||||
format: 'text',
|
||||
result: { matchCount: 1, parseFailureCount: 0, timeoutCount: 0, lines: [], notes: [] },
|
||||
});
|
||||
|
||||
const handlers = captureToolHandlers(false);
|
||||
const res = await handlers.test_log_pattern({
|
||||
pattern: '(?<level>ERROR)',
|
||||
sampleLines: ['ERROR one', 'INFO two'],
|
||||
format: 'text',
|
||||
});
|
||||
|
||||
expect(apiFetchMock).toHaveBeenCalledTimes(1);
|
||||
const [path, init] = apiFetchMock.mock.calls[0] as [string, RequestInit];
|
||||
expect(path).toBe('/api/rules/gather/test-pattern');
|
||||
expect(init.method).toBe('POST');
|
||||
const body = JSON.parse(String(init.body));
|
||||
expect(body.pattern).toBe('(?<level>ERROR)');
|
||||
expect(body.sampleLines).toEqual(['ERROR one', 'INFO two']);
|
||||
expect(body.format).toBe('text');
|
||||
|
||||
expect(res.isError).toBeFalsy();
|
||||
const payload = JSON.parse(res.content[0].text);
|
||||
expect(payload.result.matchCount).toBe(1);
|
||||
});
|
||||
|
||||
it('omits format when not given (backend defaults to cmtrace like the agent)', async () => {
|
||||
apiFetchMock.mockResolvedValue({ success: true, format: 'cmtrace', result: {} });
|
||||
const handlers = captureToolHandlers(false);
|
||||
await handlers.test_log_pattern({ pattern: 'x', sampleLines: ['line'] });
|
||||
const body = JSON.parse(String((apiFetchMock.mock.calls[0] as [string, RequestInit])[1].body));
|
||||
expect(body.format).toBeUndefined();
|
||||
});
|
||||
|
||||
it('surfaces backend errors via toolError (no throw)', async () => {
|
||||
apiFetchMock.mockRejectedValue(new Error('boom'));
|
||||
const handlers = captureToolHandlers(false);
|
||||
const res = await handlers.test_log_pattern({ pattern: 'x', sampleLines: ['line'] });
|
||||
expect(res.isError).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -34,9 +34,10 @@ export const RULE_AUTHORING_GUIDE = {
|
||||
'session from your tenant (ideally one where the rule SHOULD fire and one where it should ' +
|
||||
'NOT). The dry-run returns a per-condition trace with evidence — iterate until the verdict ' +
|
||||
'matches your expectation. Nothing is persisted by a dry-run.',
|
||||
'5. Gather rules cannot be dry-run server-side (they execute on devices). Validate them, then ' +
|
||||
'deploy to a test device/VM; the agent debug log setting for gather rules shows per-rule ' +
|
||||
'execution detail on-device.',
|
||||
'5. Gather rules cannot be dry-run against a session (they execute on devices). For logparser ' +
|
||||
'rules, test the regex with test_log_pattern against sample lines pasted from the real log ' +
|
||||
'file — it uses the agent\'s exact .NET matching semantics. Then deploy to a test device/VM; ' +
|
||||
'the agent debug log setting for gather rules shows per-rule execution detail on-device.',
|
||||
'6. Create the finished rule in the portal (Settings → Gather Rules / Analyze Rules). Custom ' +
|
||||
'rules are tenant-scoped. Well-tested rules can be proposed as community rules via GitHub.',
|
||||
],
|
||||
@@ -70,8 +71,13 @@ export const RULE_AUTHORING_GUIDE = {
|
||||
'Matching is EXACT (trimmed, case-insensitive) — no arguments can be added or removed.',
|
||||
logparser:
|
||||
'Parse log files with a regex. target = file/glob under an allow-listed prefix. parameters: ' +
|
||||
'pattern (regex, named groups become event data), format ("cmtrace" for IME-style logs), ' +
|
||||
'maxLines, trackPosition ("true" = only new lines on re-runs).',
|
||||
'pattern (regex, named groups become event data fields on the emitted timeline event), ' +
|
||||
'format ("cmtrace" = default: each line is parsed as CMTrace/IME format first and the regex ' +
|
||||
'runs against the parsed MESSAGE only; "text" = regex against the raw line), maxLines, ' +
|
||||
'trackPosition ("true" = only new lines on re-runs). Matching semantics: .NET regex engine, ' +
|
||||
'case-SENSITIVE (unlike analyze-rule regex conditions), first match per line. ALWAYS verify ' +
|
||||
'the pattern with test_log_pattern against real lines from the log file — engines like ' +
|
||||
'JS/PHP/Python behave subtly differently from the agent\'s .NET engine.',
|
||||
},
|
||||
triggers: {
|
||||
startup: 'Once when the agent starts.',
|
||||
@@ -248,6 +254,8 @@ export const RULE_AUTHORING_GUIDE = {
|
||||
'Unanchored allow-list regex with not_regex — anchors (^, \\b) are mandatory or impostor values pass.',
|
||||
'A confidenceFactor condition like "count > 3" or "Count >= 3" — only the exact shapes "exists", "count >= N", "phase_duration > N" evaluate.',
|
||||
'Gather targets outside the guardrail allowlists — the agent blocks them at run time; validate_rule checks this up front.',
|
||||
'Testing a logparser regex in a JS/PHP/Python tester and assuming it behaves the same on the device — the agent uses the .NET engine and matches case-SENSITIVELY. Use test_log_pattern, which runs the exact agent semantics.',
|
||||
'Running a plain-text log through the default cmtrace format — every line fails CMTrace parsing and nothing ever matches. Set parameters.format="text" (test_log_pattern reports this explicitly).',
|
||||
'Duplicate condition signals — evidence is keyed by signal, the second overwrites the first.',
|
||||
'Expecting gather events in sessions that ran BEFORE the gather rule was deployed — dry-run against a recent session.',
|
||||
],
|
||||
|
||||
@@ -361,6 +361,49 @@ function lintGatherRule(rule: Record<string, unknown>): ValidationFinding[] {
|
||||
findings.push(...checkGatherTarget(rule.collectorType, rule.target));
|
||||
}
|
||||
|
||||
if (rule.collectorType === 'logparser') {
|
||||
findings.push(...lintLogparserParameters(rule));
|
||||
}
|
||||
|
||||
return findings;
|
||||
}
|
||||
|
||||
/** logparser-specific parameter lint. Matching runs on the DEVICE with .NET regex —
|
||||
* this only pre-checks what would make the rule dead; real pattern testing belongs in
|
||||
* test_log_pattern (agent-exact engine). */
|
||||
function lintLogparserParameters(rule: Record<string, unknown>): ValidationFinding[] {
|
||||
const findings: ValidationFinding[] = [];
|
||||
const parameters = (rule.parameters ?? {}) as Record<string, unknown>;
|
||||
const pattern = parameters.pattern;
|
||||
|
||||
if (typeof pattern !== 'string' || pattern.length === 0) {
|
||||
findings.push({ level: 'error', message: 'logparser requires parameters.pattern — without it the rule can never match.' });
|
||||
return findings;
|
||||
}
|
||||
|
||||
try {
|
||||
// JS compile is only an approximation of the agent's .NET engine — good enough to
|
||||
// catch syntax errors, not equivalence.
|
||||
new RegExp(pattern);
|
||||
} catch (e) {
|
||||
findings.push({ level: 'error', message: `parameters.pattern does not compile (${(e as Error).message}). Note the agent uses .NET regex syntax.` });
|
||||
}
|
||||
|
||||
findings.push({
|
||||
level: 'info',
|
||||
message:
|
||||
'logparser matching is case-SENSITIVE and runs on the agent\'s .NET regex engine — a pattern verified in a JS/PHP/Python tester can behave differently. ' +
|
||||
'Test it with test_log_pattern(pattern, sampleLines) against real lines from the log file before deploying.',
|
||||
});
|
||||
|
||||
const format = parameters.format;
|
||||
if (typeof format === 'string' && format.toLowerCase() !== 'text' && format.toLowerCase() !== 'cmtrace') {
|
||||
findings.push({
|
||||
level: 'warning',
|
||||
message: `parameters.format "${format}" is not a known format — the agent treats anything other than "text" as CMTrace mode.`,
|
||||
});
|
||||
}
|
||||
|
||||
return findings;
|
||||
}
|
||||
|
||||
|
||||
@@ -46,7 +46,9 @@ export function registerRuleTools(server: McpServer, ga: boolean): void {
|
||||
nextStep: result.valid
|
||||
? (result.ruleType === 'analyze'
|
||||
? 'No errors. Dry-run it now: test_analyze_rule(sessionId=<recent session>, rule=<this draft>) — once against a session where it SHOULD fire, once where it should not.'
|
||||
: 'No errors. Gather rules run on-device and cannot be dry-run server-side — deploy to a test device and check the emitted events (the gather debug log setting shows per-rule detail).')
|
||||
: (args.rule as Record<string, unknown>).collectorType === 'logparser'
|
||||
? 'No errors. Now test the regex with the agent\'s exact .NET engine: test_log_pattern(pattern=<parameters.pattern>, sampleLines=<raw lines from the log file>, format=<parameters.format>) — then deploy to a test device.'
|
||||
: 'No errors. Gather rules run on-device and cannot be dry-run server-side — deploy to a test device and check the emitted events (the gather debug log setting shows per-rule detail).')
|
||||
: 'Fix the error-level findings and validate again.',
|
||||
},
|
||||
MAX_RESULT_SIZE_CHARS.small,
|
||||
@@ -57,6 +59,44 @@ export function registerRuleTools(server: McpServer, ga: boolean): void {
|
||||
})
|
||||
);
|
||||
|
||||
// Tool: test_log_pattern (backend .NET-regex test for logparser gather rules)
|
||||
server.registerTool(
|
||||
'test_log_pattern',
|
||||
{
|
||||
title: 'Test Logparser Pattern Against Sample Lines',
|
||||
description:
|
||||
'Tests a logparser gather-rule regex against pasted sample log lines using the AGENT\'s ' +
|
||||
'exact .NET matching semantics — the dry-run for logparser rules (which run on devices ' +
|
||||
'and cannot be tested against a session). Use this INSTEAD of testing the regex in ' +
|
||||
'JS/PHP/Python: .NET behaves subtly differently, and logparser matching is ' +
|
||||
'case-SENSITIVE (unlike analyze-rule regex conditions). Paste 10-50 representative raw ' +
|
||||
'lines from the customer\'s log file (include lines that must match AND lines that must ' +
|
||||
'not). format="cmtrace" (default) parses each line as CMTrace/IME format first and runs ' +
|
||||
'the regex against the parsed message; format="text" matches the raw line. Returns ' +
|
||||
'per-line outcomes with the exact capture groups that would land in the emitted ' +
|
||||
'timeline event\'s data. Nothing is stored.',
|
||||
inputSchema: {
|
||||
pattern: z.string().min(1).max(2000).describe('The regex (named groups become event data fields), .NET syntax'),
|
||||
sampleLines: z.array(z.string().max(8192)).min(1).max(200)
|
||||
.describe('Raw lines pasted from the log file (max 200)'),
|
||||
format: z.enum(['cmtrace', 'text']).optional()
|
||||
.describe('Log format, exactly like the rule\'s parameters.format: "cmtrace" (default, IME/SCCM style) or "text" (plain lines)'),
|
||||
},
|
||||
annotations: READ_ONLY,
|
||||
},
|
||||
async (args) => withToolTelemetry('test_log_pattern', args, async () => {
|
||||
try {
|
||||
const data = await apiFetch('/api/rules/gather/test-pattern', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ pattern: args.pattern, format: args.format, sampleLines: args.sampleLines }),
|
||||
});
|
||||
return toolResultText(data, MAX_RESULT_SIZE_CHARS.small);
|
||||
} catch (error: unknown) {
|
||||
return toolError('test_log_pattern', args, error);
|
||||
}
|
||||
})
|
||||
);
|
||||
|
||||
// Tool: test_analyze_rule (backend dry-run, side-effect free)
|
||||
server.registerTool(
|
||||
'test_analyze_rule',
|
||||
|
||||
+8
-1
@@ -2,7 +2,7 @@ using System;
|
||||
using System.Globalization;
|
||||
using System.Text.RegularExpressions;
|
||||
|
||||
namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.Ime
|
||||
namespace AutopilotMonitor.Shared.Logging
|
||||
{
|
||||
/// <summary>
|
||||
/// Represents a single parsed line from a CMTrace-format log file
|
||||
@@ -19,6 +19,13 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.Ime
|
||||
/// <summary>
|
||||
/// Parses CMTrace/SCCM log format used by IME and other Microsoft components.
|
||||
/// Format: <![LOG[{message}]LOG]!><time="{time}" date="{date}" component="{comp}" context="" type="{type}" thread="{thread}" file="">
|
||||
///
|
||||
/// Lives in Shared because it is the SINGLE parsing implementation for both sides:
|
||||
/// the agent's IME tracker / logparser gather collector (on-device) and the backend's
|
||||
/// rules/gather/test-pattern endpoint, which must reproduce the agent's matching
|
||||
/// semantics exactly so authors can test patterns without a device. Timestamp note:
|
||||
/// bias-less lines parse with DateTimeStyles.AssumeLocal — on the backend that is the
|
||||
/// SERVER's timezone, so the test endpoint reports match/parse results, not timestamps.
|
||||
/// </summary>
|
||||
public static class CmTraceLogParser
|
||||
{
|
||||
Reference in New Issue
Block a user