chore(backend): remove dead /raw/events/search endpoint + orphaned repo methods

After the MCP search tools moved to the paginated /api/raw/events fan-out
(prev commit), /api/raw/events/search had zero callers anywhere in the repo
(no MCP tool, no web). Remove the now-dead surface end to end:

- Delete SearchEventsFunction (raw/events/search + global/raw/events/search).
- Drop both routes from EndpointAccessPolicyCatalog and the no-store cache
  middleware allowlist (+ its test InlineData). EndpointPolicyCatalog
  completeness test stays green (route and entry removed together).
- Remove the now-orphaned repo method SearchEventsByTypesAsync (its only
  caller was the deleted endpoint) from ISessionRepository + TableSessionRepository
  delegation + TableStorageService impl.
- Remove the non-paginated SearchSessionsByEventAsync: its only runtime caller
  was SearchEventsByTypesAsync, and it is strictly redundant with the resumable
  SearchSessionsByEventPageAsync (used by /api/raw/events + search/sessions-by-event).
  Reword the surviving paged method's xmldoc/comment that referenced it.

No capability lost: multi-type cross-session event search still runs via the
MCP fan-out over the paginated single-type endpoint; single-type primitives
remain exposed via query_raw_events + search_sessions_by_event. Full solution
builds clean (0 errors); catalog-completeness + cache-middleware tests 77/77.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Oliver Kieselbach
2026-05-29 15:24:21 +02:00
parent f5cec705af
commit d72a79b156
8 changed files with 7 additions and 268 deletions
@@ -27,7 +27,6 @@ public class NoStoreCacheMiddlewareTests
// GA cross-tenant raw + global PII surfaces
[InlineData("/api/raw/sessions")]
[InlineData("/api/raw/events")]
[InlineData("/api/raw/events/search")]
[InlineData("/api/global/sessions")]
[InlineData("/api/global/raw/sessions")]
[InlineData("/api/global/raw/events")]
@@ -153,16 +153,6 @@ namespace AutopilotMonitor.Functions.DataAccess.TableStorage
int pageSize, string? continuation)
=> _storage.SearchSessionsByEventPageAsync(tenantId, eventType, source, severity, phase, pageSize, continuation);
public Task<List<SessionSummary>> SearchSessionsByEventAsync(
string? tenantId, string eventType, string? source, string? severity,
string? phase, int limit = 50)
=> _storage.SearchSessionsByEventAsync(tenantId, eventType, source, severity, phase, limit);
public Task<List<EnrollmentEvent>> SearchEventsByTypesAsync(
string? tenantId, IEnumerable<string> eventTypes, string? source, string? severity,
int sessionLimit = 10, int eventLimit = 50)
=> _storage.SearchEventsByTypesAsync(tenantId, eventTypes, source, severity, sessionLimit, eventLimit);
public Task<List<SessionSummary>> SearchSessionsByCveAsync(
string? tenantId, string cveId, double? minCvssScore, string? overallRisk, int limit = 50)
=> _storage.SearchSessionsByCveAsync(tenantId, cveId, minCvssScore, overallRisk, limit);
@@ -1,104 +0,0 @@
using System.Net;
using AutopilotMonitor.Functions.Helpers;
using AutopilotMonitor.Functions.Services;
using AutopilotMonitor.Shared.DataAccess;
using AutopilotMonitor.Shared.Models;
using Microsoft.Azure.Functions.Worker;
using Microsoft.Azure.Functions.Worker.Http;
using Microsoft.Extensions.Logging;
namespace AutopilotMonitor.Functions.Functions.Raw
{
/// <summary>
/// Index-based event search across sessions.
/// Uses EventTypeIndex for pre-filtering (severity, source), then fetches only matching
/// event types per session via server-side OData filter — eliminates the N+1 pattern.
/// Designed for MCP search tools (search_events_semantic, deep_search_events).
/// </summary>
public class SearchEventsFunction
{
private readonly ILogger<SearchEventsFunction> _logger;
private readonly ISessionRepository _sessionRepo;
public SearchEventsFunction(ILogger<SearchEventsFunction> logger, ISessionRepository sessionRepo)
{
_logger = logger;
_sessionRepo = sessionRepo;
}
/// <summary>
/// GET /api/raw/events/search — Tenant-scoped index-based event search
/// </summary>
[Function("SearchEvents")]
public async Task<HttpResponseData> Run(
[HttpTrigger(AuthorizationLevel.Anonymous, "get", Route = "raw/events/search")] HttpRequestData req)
{
try
{
var tenantId = TenantHelper.GetTenantId(req);
return await SearchEvents(req, tenantId);
}
catch (UnauthorizedAccessException)
{
var err = req.CreateResponse(HttpStatusCode.Unauthorized);
await err.WriteAsJsonAsync(new { error = "Unauthorized" });
return err;
}
catch (Exception ex)
{
return await req.InternalServerErrorAsync(_logger, ex, "Search events");
}
}
/// <summary>
/// GET /api/global/raw/events/search — Cross-tenant index-based event search (GlobalAdminOnly)
/// </summary>
[Function("SearchEventsGlobal")]
public async Task<HttpResponseData> RunGlobal(
[HttpTrigger(AuthorizationLevel.Anonymous, "get", Route = "global/raw/events/search")] HttpRequestData req)
{
try
{
var tenantId = req.Query["tenantId"];
return await SearchEvents(req, string.IsNullOrEmpty(tenantId) ? null : tenantId);
}
catch (Exception ex)
{
return await req.InternalServerErrorAsync(_logger, ex, "Search events global");
}
}
private async Task<HttpResponseData> SearchEvents(HttpRequestData req, string? tenantId)
{
var eventTypesStr = req.Query["eventTypes"];
var severity = req.Query["severity"];
var source = req.Query["source"];
var sessionLimitStr = req.Query["sessionLimit"];
var limitStr = req.Query["limit"];
if (string.IsNullOrEmpty(eventTypesStr))
{
var bad = req.CreateResponse(HttpStatusCode.BadRequest);
await bad.WriteAsJsonAsync(new { error = "eventTypes parameter is required (comma-separated list)" });
return bad;
}
var eventTypes = eventTypesStr.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries);
var sessionLimit = int.TryParse(sessionLimitStr, out var sl) ? Math.Clamp(sl, 1, 50) : 10;
var limit = int.TryParse(limitStr, out var l) ? Math.Clamp(l, 1, 500) : 50;
var events = await _sessionRepo.SearchEventsByTypesAsync(tenantId, eventTypes, source, severity, sessionLimit, limit);
ErrorCodeEnricher.EnrichEvents(events);
var response = req.CreateResponse(HttpStatusCode.OK);
await response.WriteAsJsonAsync(new
{
tenantId,
eventTypes,
count = events.Count,
events
});
return response;
}
}
}
@@ -37,7 +37,6 @@ public class NoStoreCacheMiddleware : IFunctionsWorkerMiddleware
// GA cross-tenant raw + global PII surfaces
"/api/raw/sessions",
"/api/raw/events",
"/api/raw/events/search",
"/api/global/sessions",
"/api/global/distress-reports",
"/api/global/session-reports", // covers list; /download-url + /{id}/note via prefix
@@ -130,7 +130,6 @@ public static class EndpointAccessPolicyCatalog
// ── MemberRead (Admin + Operator, later + Viewer) ───────────────
new("GET", "raw/sessions", EndpointPolicy.MemberRead),
new("GET", "raw/events", EndpointPolicy.MemberRead),
new("GET", "raw/events/search", EndpointPolicy.MemberRead),
new("GET", "search/quick", EndpointPolicy.MemberRead),
new("GET", "search/sessions", EndpointPolicy.MemberRead),
new("GET", "search/sessions-by-event", EndpointPolicy.MemberRead),
@@ -238,7 +237,6 @@ public static class EndpointAccessPolicyCatalog
new("GET", "global/session-deletions", EndpointPolicy.GlobalAdminOnly),
new("GET", "global/raw/sessions", EndpointPolicy.GlobalAdminOnly),
new("GET", "global/raw/events", EndpointPolicy.GlobalAdminOnly),
new("GET", "global/raw/events/search", EndpointPolicy.GlobalAdminOnly),
new("GET", "global/raw/tables", EndpointPolicy.GlobalAdminOnly),
new("GET", "global/raw/tables/{tableName}", EndpointPolicy.GlobalAdminOnly),
new("POST", "global/raw/logs", EndpointPolicy.GlobalAdminOnly),
@@ -886,72 +886,7 @@ namespace AutopilotMonitor.Functions.Services
}
/// <summary>
/// Searches sessions that contain a specific event type using the EventTypeIndex.
/// Supports server-side severity prefilter via MaxSeverity (enriched index rows).
/// Source filtering is applied client-side on the Sources field.
/// </summary>
public async Task<List<SessionSummary>> SearchSessionsByEventAsync(
string? tenantId, string eventType, string? source, string? severity, string? phase, int limit = 50)
{
var tableClient = _tableServiceClient.GetTableClient(Constants.TableNames.EventTypeIndex);
// Build OData filter: PartitionKey + optional severity prefilter
int? minSeverity = null;
if (!string.IsNullOrEmpty(severity) &&
Enum.TryParse<AutopilotMonitor.Shared.Models.EventSeverity>(severity, ignoreCase: true, out var parsedSeverity))
{
minSeverity = (int)parsedSeverity;
}
// Tenant-scoped: partition-targeted (cheap, exact-match on PK).
// Cross-tenant: server-side filter on the EventType column. The
// PartitionKey shape is `{tenantId}_{eventType}` and tenantIds may
// contain dashes but never underscores, while eventType itself may
// contain underscores (e.g. `app_install_failed`) — so a PK-suffix
// split is brittle. Filtering on the explicit EventType column
// avoids that ambiguity entirely (regression: previously used
// `LastIndexOf('_')` and silently dropped every multi-underscore
// event type).
string? oDataFilter;
if (!string.IsNullOrEmpty(tenantId))
{
oDataFilter = $"PartitionKey eq '{ODataSanitizer.EscapeValue(tenantId)}_{ODataSanitizer.EscapeValue(eventType)}'";
if (minSeverity.HasValue)
oDataFilter += $" and MaxSeverity ge {minSeverity.Value}";
}
else
{
oDataFilter = $"EventType eq '{ODataSanitizer.EscapeValue(eventType)}'";
if (minSeverity.HasValue)
oDataFilter += $" and MaxSeverity ge {minSeverity.Value}";
}
var sessionIds = new List<string>();
await foreach (var entity in tableClient.QueryAsync<TableEntity>(filter: oDataFilter))
{
// Client-side source filter on the Sources field
if (!string.IsNullOrEmpty(source))
{
var sources = entity.GetString("Sources") ?? "";
if (!sources.Contains(source, StringComparison.OrdinalIgnoreCase)) continue;
}
var sessionId = entity.GetString("SessionId");
if (!string.IsNullOrEmpty(sessionId) && !sessionIds.Contains(sessionId))
sessionIds.Add(sessionId);
if (sessionIds.Count >= limit * 2) break;
}
if (sessionIds.Count == 0) return new List<SessionSummary>();
var sessions = await BatchGetSessionsAsync(tenantId, sessionIds);
return sessions.Take(limit).ToList();
}
/// <summary>
/// Paged variant of <see cref="SearchSessionsByEventAsync"/> — drives
/// Paged EventTypeIndex walk that drives
/// <c>QueryRawEventsFunction</c>'s cross-session walk. Returns up to
/// <paramref name="pageSize"/> distinct sessions plus the underlying
/// Azure-Tables continuation; caller follows the wire <c>nextLink</c>
@@ -965,8 +900,8 @@ namespace AutopilotMonitor.Functions.Services
var tableClient = _tableServiceClient.GetTableClient(Constants.TableNames.EventTypeIndex);
// Build OData filter — same as legacy SearchSessionsByEventAsync but
// without the limit*2 over-fetch, since pagination handles depth.
// Build OData filter (PartitionKey eq for tenant-scoped, EventType eq
// for cross-tenant) — no limit*2 over-fetch; pagination handles depth.
int? minSeverity = null;
if (!string.IsNullOrEmpty(severity) &&
Enum.TryParse<AutopilotMonitor.Shared.Models.EventSeverity>(severity, ignoreCase: true, out var parsedSeverity))
@@ -1029,77 +964,6 @@ namespace AutopilotMonitor.Functions.Services
return new RawPage<SessionSummary>(sessions, nextRawToken);
}
/// <summary>
/// Searches events across sessions for multiple event types using the EventTypeIndex for pre-filtering,
/// then fetches only matching events per session via server-side OData filter on the Events table.
/// Designed for MCP search tools to replace the N+1 fetchSessionEvents pattern.
/// </summary>
public async Task<List<EnrollmentEvent>> SearchEventsByTypesAsync(
string? tenantId, IEnumerable<string> eventTypes, string? source, string? severity,
int sessionLimit = 10, int eventLimit = 50)
{
var typeList = eventTypes.Where(t => !string.IsNullOrEmpty(t)).Distinct(StringComparer.OrdinalIgnoreCase).ToList();
if (typeList.Count == 0) return new List<EnrollmentEvent>();
// Step 1: Query EventTypeIndex for each event type to find candidate sessions
var candidateSessionIds = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase); // sessionId → tenantId
foreach (var eventType in typeList)
{
var sessions = await SearchSessionsByEventAsync(tenantId, eventType, source, severity, null, limit: sessionLimit);
foreach (var session in sessions)
{
candidateSessionIds.TryAdd(session.SessionId, session.TenantId);
}
if (candidateSessionIds.Count >= sessionLimit) break;
}
if (candidateSessionIds.Count == 0) return new List<EnrollmentEvent>();
// Step 2: For each candidate session, fetch only matching event types via filtered query
var allEvents = new List<EnrollmentEvent>();
var sem = new SemaphoreSlim(10, 10);
var tasks = candidateSessionIds.Take(sessionLimit).Select(async kvp =>
{
await sem.WaitAsync();
try
{
var sessionEvents = new List<EnrollmentEvent>();
foreach (var eventType in typeList)
{
var events = await GetSessionEventsByTypeAsync(kvp.Value, kvp.Key, eventType, eventLimit);
sessionEvents.AddRange(events);
}
// Apply remaining client-side filters (severity, source)
return sessionEvents.Where(e =>
(string.IsNullOrEmpty(severity) || e.Severity.ToString().Equals(severity, StringComparison.OrdinalIgnoreCase)) &&
(string.IsNullOrEmpty(source) || (e.Source ?? "").Contains(source, StringComparison.OrdinalIgnoreCase))
).ToList();
}
catch (Exception ex)
{
_logger.LogWarning(ex, "Failed to fetch events for session {SessionId}", kvp.Key);
return new List<EnrollmentEvent>();
}
finally
{
sem.Release();
}
});
var results = await Task.WhenAll(tasks);
foreach (var batch in results)
allEvents.AddRange(batch);
return allEvents
.OrderByDescending(e => e.Timestamp)
.Take(eventLimit)
.ToList();
}
/// <summary>
/// Searches sessions affected by a specific CVE using the CveIndex.
/// </summary>
@@ -43,9 +43,9 @@ function extractEventTypeCandidates(keywords: string[]): string[] {
//
// The cross-session search walks the already-paginated /api/raw/events
// endpoint once per candidate event type, following nextLink to a bounded
// budget and merging client-side. This replaces the legacy capped
// /api/raw/events/search call (fixed sessionLimit:10 + limit:200, no cursor,
// no nextLink) whose result silently truncated without telling the caller.
// budget and merging client-side. This replaces a former capped multi-type
// search call (fixed sessionLimit:10 + limit:200, no cursor, no nextLink)
// whose result silently truncated without telling the caller.
// Because each walk filters by a single event type and Azure-Tables
// continuation pages never overlap, the same event can never appear in two
// walks — only sessions recur across type-walks, which we union into a Set.
@@ -152,12 +152,8 @@ namespace AutopilotMonitor.Shared.DataAccess
/// </summary>
Task<RawPage<SessionSummary>> SearchSessionsPageAsync(
string? tenantId, SessionSearchFilter filter, int pageSize, string? continuation);
Task<List<SessionSummary>> SearchSessionsByEventAsync(
string? tenantId, string eventType, string? source, string? severity,
string? phase, int limit = 50);
/// <summary>
/// Paged variant of <see cref="SearchSessionsByEventAsync"/>. Walks the
/// Paged walk of the
/// <c>EventTypeIndex</c> table page-by-page so callers can drain large
/// result sets via <c>nextLink</c>. Replaces the legacy hard-coded
/// <c>limit:20</c> session-index lookup that silently dropped recall on
@@ -166,9 +162,6 @@ namespace AutopilotMonitor.Shared.DataAccess
Task<RawPage<SessionSummary>> SearchSessionsByEventPageAsync(
string? tenantId, string eventType, string? source, string? severity, string? phase,
int pageSize, string? continuation);
Task<List<EnrollmentEvent>> SearchEventsByTypesAsync(
string? tenantId, IEnumerable<string> eventTypes, string? source, string? severity,
int sessionLimit = 10, int eventLimit = 50);
Task<List<SessionSummary>> SearchSessionsByCveAsync(
string? tenantId, string cveId, double? minCvssScore, string? overallRisk, int limit = 50);