sprints work
This commit is contained in:
165
src/Signals/StellaOps.Signals.RuntimeAgent/AgentRegistration.cs
Normal file
165
src/Signals/StellaOps.Signals.RuntimeAgent/AgentRegistration.cs
Normal file
@@ -0,0 +1,165 @@
|
||||
// <copyright file="AgentRegistration.cs" company="StellaOps">
|
||||
// Copyright (c) StellaOps. Licensed under the AGPL-3.0-or-later.
|
||||
// </copyright>
|
||||
|
||||
using System.Collections.Immutable;
|
||||
|
||||
namespace StellaOps.Signals.RuntimeAgent;
|
||||
|
||||
/// <summary>
|
||||
/// Represents a registered runtime agent.
|
||||
/// Sprint: SPRINT_20260109_009_004 Task: Implement AgentRegistrationService
|
||||
/// </summary>
|
||||
public sealed record AgentRegistration
|
||||
{
|
||||
/// <summary>Unique agent identifier.</summary>
|
||||
public required string AgentId { get; init; }
|
||||
|
||||
/// <summary>Target platform.</summary>
|
||||
public required RuntimePlatform Platform { get; init; }
|
||||
|
||||
/// <summary>Hostname where agent is running.</summary>
|
||||
public required string Hostname { get; init; }
|
||||
|
||||
/// <summary>Container ID if running in container.</summary>
|
||||
public string? ContainerId { get; init; }
|
||||
|
||||
/// <summary>Kubernetes namespace if running in K8s.</summary>
|
||||
public string? KubernetesNamespace { get; init; }
|
||||
|
||||
/// <summary>Kubernetes pod name if running in K8s.</summary>
|
||||
public string? KubernetesPodName { get; init; }
|
||||
|
||||
/// <summary>Target application name.</summary>
|
||||
public string? ApplicationName { get; init; }
|
||||
|
||||
/// <summary>Target process ID.</summary>
|
||||
public int? ProcessId { get; init; }
|
||||
|
||||
/// <summary>Agent version.</summary>
|
||||
public required string AgentVersion { get; init; }
|
||||
|
||||
/// <summary>Registration timestamp.</summary>
|
||||
public required DateTimeOffset RegisteredAt { get; init; }
|
||||
|
||||
/// <summary>Last heartbeat timestamp.</summary>
|
||||
public DateTimeOffset LastHeartbeat { get; init; }
|
||||
|
||||
/// <summary>Current agent state.</summary>
|
||||
public AgentState State { get; init; } = AgentState.Stopped;
|
||||
|
||||
/// <summary>Current posture.</summary>
|
||||
public RuntimePosture Posture { get; init; } = RuntimePosture.Sampled;
|
||||
|
||||
/// <summary>Tags for grouping/filtering.</summary>
|
||||
public ImmutableDictionary<string, string> Tags { get; init; } =
|
||||
ImmutableDictionary<string, string>.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// Checks if the agent is considered healthy (recent heartbeat).
|
||||
/// </summary>
|
||||
public bool IsHealthy(DateTimeOffset now, TimeSpan heartbeatTimeout)
|
||||
{
|
||||
return now - LastHeartbeat < heartbeatTimeout;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Agent registration request.
|
||||
/// </summary>
|
||||
public sealed record AgentRegistrationRequest
|
||||
{
|
||||
/// <summary>Unique agent identifier (generated by agent).</summary>
|
||||
public required string AgentId { get; init; }
|
||||
|
||||
/// <summary>Target platform.</summary>
|
||||
public required RuntimePlatform Platform { get; init; }
|
||||
|
||||
/// <summary>Hostname where agent is running.</summary>
|
||||
public required string Hostname { get; init; }
|
||||
|
||||
/// <summary>Container ID if running in container.</summary>
|
||||
public string? ContainerId { get; init; }
|
||||
|
||||
/// <summary>Kubernetes namespace if running in K8s.</summary>
|
||||
public string? KubernetesNamespace { get; init; }
|
||||
|
||||
/// <summary>Kubernetes pod name if running in K8s.</summary>
|
||||
public string? KubernetesPodName { get; init; }
|
||||
|
||||
/// <summary>Target application name.</summary>
|
||||
public string? ApplicationName { get; init; }
|
||||
|
||||
/// <summary>Target process ID.</summary>
|
||||
public int? ProcessId { get; init; }
|
||||
|
||||
/// <summary>Agent version.</summary>
|
||||
public required string AgentVersion { get; init; }
|
||||
|
||||
/// <summary>Initial posture.</summary>
|
||||
public RuntimePosture InitialPosture { get; init; } = RuntimePosture.Sampled;
|
||||
|
||||
/// <summary>Tags for grouping/filtering.</summary>
|
||||
public ImmutableDictionary<string, string> Tags { get; init; } =
|
||||
ImmutableDictionary<string, string>.Empty;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Agent heartbeat request.
|
||||
/// </summary>
|
||||
public sealed record AgentHeartbeatRequest
|
||||
{
|
||||
/// <summary>Agent identifier.</summary>
|
||||
public required string AgentId { get; init; }
|
||||
|
||||
/// <summary>Current agent state.</summary>
|
||||
public required AgentState State { get; init; }
|
||||
|
||||
/// <summary>Current posture.</summary>
|
||||
public required RuntimePosture Posture { get; init; }
|
||||
|
||||
/// <summary>Statistics snapshot.</summary>
|
||||
public AgentStatistics? Statistics { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Agent heartbeat response.
|
||||
/// </summary>
|
||||
public sealed record AgentHeartbeatResponse
|
||||
{
|
||||
/// <summary>Whether the agent should continue.</summary>
|
||||
public bool Continue { get; init; } = true;
|
||||
|
||||
/// <summary>New posture if changed.</summary>
|
||||
public RuntimePosture? NewPosture { get; init; }
|
||||
|
||||
/// <summary>Command to execute.</summary>
|
||||
public AgentCommand? Command { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Commands that can be sent to agents.
|
||||
/// </summary>
|
||||
public enum AgentCommand
|
||||
{
|
||||
/// <summary>No command.</summary>
|
||||
None = 0,
|
||||
|
||||
/// <summary>Start collection.</summary>
|
||||
Start = 1,
|
||||
|
||||
/// <summary>Stop collection.</summary>
|
||||
Stop = 2,
|
||||
|
||||
/// <summary>Pause collection.</summary>
|
||||
Pause = 3,
|
||||
|
||||
/// <summary>Resume collection.</summary>
|
||||
Resume = 4,
|
||||
|
||||
/// <summary>Update configuration.</summary>
|
||||
UpdateConfig = 5,
|
||||
|
||||
/// <summary>Terminate agent.</summary>
|
||||
Terminate = 6
|
||||
}
|
||||
@@ -0,0 +1,264 @@
|
||||
// <copyright file="AgentRegistrationService.cs" company="StellaOps">
|
||||
// Copyright (c) StellaOps. Licensed under the AGPL-3.0-or-later.
|
||||
// </copyright>
|
||||
|
||||
using System.Collections.Concurrent;
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
namespace StellaOps.Signals.RuntimeAgent;
|
||||
|
||||
/// <summary>
|
||||
/// In-memory implementation of agent registration service.
|
||||
/// Sprint: SPRINT_20260109_009_004 Task: Implement AgentRegistrationService
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// This implementation uses in-memory storage. For production use with persistence,
|
||||
/// implement a database-backed version using the same interface.
|
||||
/// </remarks>
|
||||
public sealed class AgentRegistrationService : IAgentRegistrationService
|
||||
{
|
||||
private readonly TimeProvider _timeProvider;
|
||||
private readonly ILogger<AgentRegistrationService> _logger;
|
||||
private readonly ConcurrentDictionary<string, AgentRegistration> _registrations = new();
|
||||
private readonly ConcurrentDictionary<string, AgentCommand> _pendingCommands = new();
|
||||
private readonly ConcurrentDictionary<string, RuntimePosture> _pendingPostureChanges = new();
|
||||
|
||||
/// <summary>
|
||||
/// Heartbeat timeout for considering agents unhealthy.
|
||||
/// </summary>
|
||||
public TimeSpan HeartbeatTimeout { get; set; } = TimeSpan.FromMinutes(2);
|
||||
|
||||
public AgentRegistrationService(TimeProvider timeProvider, ILogger<AgentRegistrationService> logger)
|
||||
{
|
||||
_timeProvider = timeProvider;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
public Task<AgentRegistration> RegisterAsync(AgentRegistrationRequest request, CancellationToken ct = default)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(request.AgentId);
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(request.Hostname);
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(request.AgentVersion);
|
||||
|
||||
var now = _timeProvider.GetUtcNow();
|
||||
|
||||
var registration = new AgentRegistration
|
||||
{
|
||||
AgentId = request.AgentId,
|
||||
Platform = request.Platform,
|
||||
Hostname = request.Hostname,
|
||||
ContainerId = request.ContainerId,
|
||||
KubernetesNamespace = request.KubernetesNamespace,
|
||||
KubernetesPodName = request.KubernetesPodName,
|
||||
ApplicationName = request.ApplicationName,
|
||||
ProcessId = request.ProcessId,
|
||||
AgentVersion = request.AgentVersion,
|
||||
RegisteredAt = now,
|
||||
LastHeartbeat = now,
|
||||
State = AgentState.Stopped,
|
||||
Posture = request.InitialPosture,
|
||||
Tags = request.Tags
|
||||
};
|
||||
|
||||
_registrations.AddOrUpdate(
|
||||
request.AgentId,
|
||||
registration,
|
||||
(_, existing) =>
|
||||
{
|
||||
_logger.LogWarning(
|
||||
"Agent {AgentId} re-registered (previous: {PreviousRegistration})",
|
||||
request.AgentId,
|
||||
existing.RegisteredAt);
|
||||
return registration;
|
||||
});
|
||||
|
||||
_logger.LogInformation(
|
||||
"Agent {AgentId} registered: Platform={Platform}, Host={Hostname}, App={Application}",
|
||||
request.AgentId,
|
||||
request.Platform,
|
||||
request.Hostname,
|
||||
request.ApplicationName ?? "N/A");
|
||||
|
||||
return Task.FromResult(registration);
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
public Task<AgentHeartbeatResponse> HeartbeatAsync(AgentHeartbeatRequest request, CancellationToken ct = default)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(request.AgentId);
|
||||
|
||||
var now = _timeProvider.GetUtcNow();
|
||||
|
||||
if (!_registrations.TryGetValue(request.AgentId, out var existing))
|
||||
{
|
||||
_logger.LogWarning("Heartbeat from unknown agent {AgentId}", request.AgentId);
|
||||
return Task.FromResult(new AgentHeartbeatResponse { Continue = false });
|
||||
}
|
||||
|
||||
// Update registration with heartbeat info
|
||||
var updated = existing with
|
||||
{
|
||||
LastHeartbeat = now,
|
||||
State = request.State,
|
||||
Posture = request.Posture
|
||||
};
|
||||
|
||||
_registrations.TryUpdate(request.AgentId, updated, existing);
|
||||
|
||||
// Check for pending commands
|
||||
_pendingCommands.TryRemove(request.AgentId, out var pendingCommand);
|
||||
_pendingPostureChanges.TryRemove(request.AgentId, out var pendingPosture);
|
||||
|
||||
var response = new AgentHeartbeatResponse
|
||||
{
|
||||
Continue = true,
|
||||
Command = pendingCommand != AgentCommand.None ? pendingCommand : null,
|
||||
NewPosture = pendingPosture != default ? pendingPosture : null
|
||||
};
|
||||
|
||||
_logger.LogDebug(
|
||||
"Heartbeat from {AgentId}: State={State}, Posture={Posture}, Events={Events}",
|
||||
request.AgentId,
|
||||
request.State,
|
||||
request.Posture,
|
||||
request.Statistics?.TotalEventsCollected ?? 0);
|
||||
|
||||
return Task.FromResult(response);
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
public Task UnregisterAsync(string agentId, CancellationToken ct = default)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(agentId);
|
||||
|
||||
if (_registrations.TryRemove(agentId, out var removed))
|
||||
{
|
||||
_pendingCommands.TryRemove(agentId, out _);
|
||||
_pendingPostureChanges.TryRemove(agentId, out _);
|
||||
|
||||
_logger.LogInformation(
|
||||
"Agent {AgentId} unregistered (was registered since {RegisteredAt})",
|
||||
agentId,
|
||||
removed.RegisteredAt);
|
||||
}
|
||||
else
|
||||
{
|
||||
_logger.LogDebug("Attempted to unregister unknown agent {AgentId}", agentId);
|
||||
}
|
||||
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
public Task<AgentRegistration?> GetAsync(string agentId, CancellationToken ct = default)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(agentId);
|
||||
|
||||
_registrations.TryGetValue(agentId, out var registration);
|
||||
return Task.FromResult(registration);
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
public Task<IReadOnlyList<AgentRegistration>> ListAsync(CancellationToken ct = default)
|
||||
{
|
||||
var result = _registrations.Values.ToList();
|
||||
return Task.FromResult<IReadOnlyList<AgentRegistration>>(result);
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
public Task<IReadOnlyList<AgentRegistration>> ListByPlatformAsync(RuntimePlatform platform, CancellationToken ct = default)
|
||||
{
|
||||
var result = _registrations.Values
|
||||
.Where(r => r.Platform == platform)
|
||||
.ToList();
|
||||
return Task.FromResult<IReadOnlyList<AgentRegistration>>(result);
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
public Task<IReadOnlyList<AgentRegistration>> ListHealthyAsync(CancellationToken ct = default)
|
||||
{
|
||||
var now = _timeProvider.GetUtcNow();
|
||||
var result = _registrations.Values
|
||||
.Where(r => r.IsHealthy(now, HeartbeatTimeout))
|
||||
.ToList();
|
||||
return Task.FromResult<IReadOnlyList<AgentRegistration>>(result);
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
public Task SendCommandAsync(string agentId, AgentCommand command, CancellationToken ct = default)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(agentId);
|
||||
|
||||
if (!_registrations.ContainsKey(agentId))
|
||||
{
|
||||
_logger.LogWarning("Cannot send command to unknown agent {AgentId}", agentId);
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
_pendingCommands[agentId] = command;
|
||||
|
||||
_logger.LogInformation(
|
||||
"Queued command {Command} for agent {AgentId}",
|
||||
command,
|
||||
agentId);
|
||||
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
public Task UpdatePostureAsync(string agentId, RuntimePosture posture, CancellationToken ct = default)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(agentId);
|
||||
|
||||
if (!_registrations.ContainsKey(agentId))
|
||||
{
|
||||
_logger.LogWarning("Cannot update posture for unknown agent {AgentId}", agentId);
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
_pendingPostureChanges[agentId] = posture;
|
||||
|
||||
_logger.LogInformation(
|
||||
"Queued posture change to {Posture} for agent {AgentId}",
|
||||
posture,
|
||||
agentId);
|
||||
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Prune stale registrations (no heartbeat within timeout).
|
||||
/// </summary>
|
||||
/// <returns>Number of pruned registrations.</returns>
|
||||
public int PruneStale()
|
||||
{
|
||||
var now = _timeProvider.GetUtcNow();
|
||||
var pruned = 0;
|
||||
|
||||
foreach (var (agentId, registration) in _registrations)
|
||||
{
|
||||
if (!registration.IsHealthy(now, HeartbeatTimeout))
|
||||
{
|
||||
if (_registrations.TryRemove(agentId, out _))
|
||||
{
|
||||
_pendingCommands.TryRemove(agentId, out _);
|
||||
_pendingPostureChanges.TryRemove(agentId, out _);
|
||||
pruned++;
|
||||
|
||||
_logger.LogWarning(
|
||||
"Pruned stale agent {AgentId} (last heartbeat: {LastHeartbeat})",
|
||||
agentId,
|
||||
registration.LastHeartbeat);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return pruned;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets count of registered agents.
|
||||
/// </summary>
|
||||
public int Count => _registrations.Count;
|
||||
}
|
||||
294
src/Signals/StellaOps.Signals.RuntimeAgent/ClrMethodResolver.cs
Normal file
294
src/Signals/StellaOps.Signals.RuntimeAgent/ClrMethodResolver.cs
Normal file
@@ -0,0 +1,294 @@
|
||||
// <copyright file="ClrMethodResolver.cs" company="StellaOps">
|
||||
// Copyright (c) StellaOps. Licensed under the AGPL-3.0-or-later.
|
||||
// </copyright>
|
||||
|
||||
using System.Collections.Concurrent;
|
||||
using System.Collections.Immutable;
|
||||
using System.Globalization;
|
||||
using System.Text.RegularExpressions;
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
namespace StellaOps.Signals.RuntimeAgent;
|
||||
|
||||
/// <summary>
|
||||
/// Resolves CLR method IDs from ETW/EventPipe events to readable method names.
|
||||
/// Sprint: SPRINT_20260109_009_004 Task: Implement ClrMethodResolver
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// CLR runtime events use method IDs (MethodID/FunctionID) that need to be resolved
|
||||
/// to human-readable names. This class maintains caches from MethodLoad/MethodILToNativeMap
|
||||
/// events and provides resolution services.
|
||||
/// </remarks>
|
||||
public sealed partial class ClrMethodResolver
|
||||
{
|
||||
private readonly ILogger<ClrMethodResolver> _logger;
|
||||
private readonly TimeProvider _timeProvider;
|
||||
|
||||
// Method ID to name cache (from MethodLoad events)
|
||||
private readonly ConcurrentDictionary<ulong, ResolvedMethod> _methodIdCache = new();
|
||||
|
||||
// Module ID to name cache (from ModuleLoad events)
|
||||
private readonly ConcurrentDictionary<ulong, ModuleInfo> _moduleIdCache = new();
|
||||
|
||||
// Assembly ID to name cache (from AssemblyLoad events)
|
||||
private readonly ConcurrentDictionary<ulong, string> _assemblyIdCache = new();
|
||||
|
||||
public ClrMethodResolver(TimeProvider timeProvider, ILogger<ClrMethodResolver> logger)
|
||||
{
|
||||
_timeProvider = timeProvider;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets the number of resolved methods in cache.
|
||||
/// </summary>
|
||||
public int CachedMethodCount => _methodIdCache.Count;
|
||||
|
||||
/// <summary>
|
||||
/// Gets the number of resolved modules in cache.
|
||||
/// </summary>
|
||||
public int CachedModuleCount => _moduleIdCache.Count;
|
||||
|
||||
/// <summary>
|
||||
/// Registers a module from ModuleLoad event.
|
||||
/// </summary>
|
||||
public void RegisterModule(ulong moduleId, ulong assemblyId, string modulePath, string simpleName)
|
||||
{
|
||||
var info = new ModuleInfo(moduleId, assemblyId, modulePath, simpleName);
|
||||
_moduleIdCache[moduleId] = info;
|
||||
|
||||
_logger.LogDebug("Registered module {ModuleId}: {SimpleName}", moduleId, simpleName);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Registers an assembly from AssemblyLoad event.
|
||||
/// </summary>
|
||||
public void RegisterAssembly(ulong assemblyId, string assemblyName)
|
||||
{
|
||||
_assemblyIdCache[assemblyId] = assemblyName;
|
||||
|
||||
_logger.LogDebug("Registered assembly {AssemblyId}: {AssemblyName}", assemblyId, assemblyName);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Registers a method from MethodLoad event.
|
||||
/// </summary>
|
||||
public void RegisterMethod(
|
||||
ulong methodId,
|
||||
ulong moduleId,
|
||||
string methodNamespace,
|
||||
string methodName,
|
||||
string methodSignature)
|
||||
{
|
||||
var resolved = new ResolvedMethod(
|
||||
MethodId: methodId,
|
||||
ModuleId: moduleId,
|
||||
Namespace: methodNamespace,
|
||||
TypeName: ExtractTypeName(methodNamespace),
|
||||
MethodName: methodName,
|
||||
Signature: methodSignature,
|
||||
ResolvedAt: _timeProvider.GetUtcNow());
|
||||
|
||||
_methodIdCache[methodId] = resolved;
|
||||
|
||||
_logger.LogDebug(
|
||||
"Registered method {MethodId}: {Namespace}.{Method}",
|
||||
methodId, methodNamespace, methodName);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Resolves a method ID to its full name.
|
||||
/// </summary>
|
||||
/// <returns>Resolved method info or null if not found.</returns>
|
||||
public ResolvedMethod? ResolveMethod(ulong methodId)
|
||||
{
|
||||
return _methodIdCache.TryGetValue(methodId, out var resolved) ? resolved : null;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Resolves a method ID to a RuntimeMethodEvent.
|
||||
/// </summary>
|
||||
public RuntimeMethodEvent? ResolveToEvent(
|
||||
ulong methodId,
|
||||
RuntimeEventKind eventKind,
|
||||
string eventId,
|
||||
DateTimeOffset timestamp,
|
||||
int? processId = null,
|
||||
string? threadId = null)
|
||||
{
|
||||
if (!_methodIdCache.TryGetValue(methodId, out var resolved))
|
||||
{
|
||||
_logger.LogDebug("Method {MethodId} not found in cache", methodId);
|
||||
return null;
|
||||
}
|
||||
|
||||
var assemblyName = GetAssemblyForModule(resolved.ModuleId);
|
||||
|
||||
return new RuntimeMethodEvent
|
||||
{
|
||||
EventId = eventId,
|
||||
SymbolId = FormatSymbolId(methodId),
|
||||
MethodName = resolved.MethodName,
|
||||
TypeName = resolved.TypeName,
|
||||
AssemblyOrModule = assemblyName ?? "Unknown",
|
||||
Timestamp = timestamp,
|
||||
Kind = eventKind,
|
||||
Platform = RuntimePlatform.DotNet,
|
||||
ProcessId = processId,
|
||||
ThreadId = threadId,
|
||||
Context = new Dictionary<string, string>
|
||||
{
|
||||
["MethodId"] = methodId.ToString("X16", CultureInfo.InvariantCulture),
|
||||
["Namespace"] = resolved.Namespace,
|
||||
["Signature"] = resolved.Signature
|
||||
}.ToImmutableDictionary()
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Tries to parse an ETW-style method reference like "MethodID=0x06000123".
|
||||
/// </summary>
|
||||
public bool TryParseEtwMethodId(string etwString, out ulong methodId)
|
||||
{
|
||||
methodId = 0;
|
||||
|
||||
var match = EtwMethodIdRegex().Match(etwString);
|
||||
if (!match.Success)
|
||||
return false;
|
||||
|
||||
var hexValue = match.Groups["id"].Value;
|
||||
return ulong.TryParse(
|
||||
hexValue,
|
||||
NumberStyles.HexNumber,
|
||||
CultureInfo.InvariantCulture,
|
||||
out methodId);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Parses a full ETW method string with module info.
|
||||
/// Example: "MethodID=0x06000123 ModuleID=0x00007FF8ABC12340"
|
||||
/// </summary>
|
||||
public (ulong MethodId, ulong ModuleId)? ParseEtwMethodWithModule(string etwString)
|
||||
{
|
||||
var methodMatch = EtwMethodIdRegex().Match(etwString);
|
||||
var moduleMatch = EtwModuleIdRegex().Match(etwString);
|
||||
|
||||
if (!methodMatch.Success)
|
||||
return null;
|
||||
|
||||
var methodHex = methodMatch.Groups["id"].Value;
|
||||
if (!ulong.TryParse(methodHex, NumberStyles.HexNumber, CultureInfo.InvariantCulture, out var methodId))
|
||||
return null;
|
||||
|
||||
ulong moduleId = 0;
|
||||
if (moduleMatch.Success)
|
||||
{
|
||||
var moduleHex = moduleMatch.Groups["id"].Value;
|
||||
ulong.TryParse(moduleHex, NumberStyles.HexNumber, CultureInfo.InvariantCulture, out moduleId);
|
||||
}
|
||||
|
||||
return (methodId, moduleId);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Formats a method ID as a symbol ID string.
|
||||
/// </summary>
|
||||
public static string FormatSymbolId(ulong methodId)
|
||||
{
|
||||
return $"clr:method:{methodId:X16}";
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Clears all caches.
|
||||
/// </summary>
|
||||
public void Clear()
|
||||
{
|
||||
_methodIdCache.Clear();
|
||||
_moduleIdCache.Clear();
|
||||
_assemblyIdCache.Clear();
|
||||
|
||||
_logger.LogDebug("Cleared all method resolution caches");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets statistics about the resolver.
|
||||
/// </summary>
|
||||
public ClrMethodResolverStats GetStatistics()
|
||||
{
|
||||
return new ClrMethodResolverStats(
|
||||
CachedMethods: _methodIdCache.Count,
|
||||
CachedModules: _moduleIdCache.Count,
|
||||
CachedAssemblies: _assemblyIdCache.Count);
|
||||
}
|
||||
|
||||
private string? GetAssemblyForModule(ulong moduleId)
|
||||
{
|
||||
if (!_moduleIdCache.TryGetValue(moduleId, out var moduleInfo))
|
||||
return null;
|
||||
|
||||
if (_assemblyIdCache.TryGetValue(moduleInfo.AssemblyId, out var assemblyName))
|
||||
return assemblyName;
|
||||
|
||||
return moduleInfo.SimpleName;
|
||||
}
|
||||
|
||||
private static string ExtractTypeName(string fullNamespace)
|
||||
{
|
||||
if (string.IsNullOrEmpty(fullNamespace))
|
||||
return "_";
|
||||
|
||||
// Take the last part after the dot
|
||||
var lastDot = fullNamespace.LastIndexOf('.');
|
||||
return lastDot >= 0 && lastDot < fullNamespace.Length - 1
|
||||
? fullNamespace[(lastDot + 1)..]
|
||||
: fullNamespace;
|
||||
}
|
||||
|
||||
[GeneratedRegex(@"MethodID=0x(?<id>[0-9A-Fa-f]+)", RegexOptions.Compiled)]
|
||||
private static partial Regex EtwMethodIdRegex();
|
||||
|
||||
[GeneratedRegex(@"ModuleID=0x(?<id>[0-9A-Fa-f]+)", RegexOptions.Compiled)]
|
||||
private static partial Regex EtwModuleIdRegex();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Resolved method information.
|
||||
/// </summary>
|
||||
public sealed record ResolvedMethod(
|
||||
ulong MethodId,
|
||||
ulong ModuleId,
|
||||
string Namespace,
|
||||
string TypeName,
|
||||
string MethodName,
|
||||
string Signature,
|
||||
DateTimeOffset ResolvedAt)
|
||||
{
|
||||
/// <summary>
|
||||
/// Gets the fully qualified name.
|
||||
/// </summary>
|
||||
public string FullyQualifiedName => string.IsNullOrEmpty(Namespace)
|
||||
? MethodName
|
||||
: $"{Namespace}.{MethodName}";
|
||||
|
||||
/// <summary>
|
||||
/// Gets the display name with signature.
|
||||
/// </summary>
|
||||
public string DisplayName => $"{FullyQualifiedName}{Signature}";
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Module information.
|
||||
/// </summary>
|
||||
public sealed record ModuleInfo(
|
||||
ulong ModuleId,
|
||||
ulong AssemblyId,
|
||||
string ModulePath,
|
||||
string SimpleName);
|
||||
|
||||
/// <summary>
|
||||
/// Statistics about the method resolver.
|
||||
/// </summary>
|
||||
public sealed record ClrMethodResolverStats(
|
||||
int CachedMethods,
|
||||
int CachedModules,
|
||||
int CachedAssemblies);
|
||||
@@ -0,0 +1,81 @@
|
||||
// <copyright file="IAgentRegistrationService.cs" company="StellaOps">
|
||||
// Copyright (c) StellaOps. Licensed under the AGPL-3.0-or-later.
|
||||
// </copyright>
|
||||
|
||||
namespace StellaOps.Signals.RuntimeAgent;
|
||||
|
||||
/// <summary>
|
||||
/// Service for managing runtime agent registrations.
|
||||
/// Sprint: SPRINT_20260109_009_004 Task: Implement AgentRegistrationService
|
||||
/// </summary>
|
||||
public interface IAgentRegistrationService
|
||||
{
|
||||
/// <summary>
|
||||
/// Register a new agent.
|
||||
/// </summary>
|
||||
/// <param name="request">Registration request.</param>
|
||||
/// <param name="ct">Cancellation token.</param>
|
||||
/// <returns>The registration record.</returns>
|
||||
Task<AgentRegistration> RegisterAsync(AgentRegistrationRequest request, CancellationToken ct = default);
|
||||
|
||||
/// <summary>
|
||||
/// Process agent heartbeat.
|
||||
/// </summary>
|
||||
/// <param name="request">Heartbeat request.</param>
|
||||
/// <param name="ct">Cancellation token.</param>
|
||||
/// <returns>Response with commands.</returns>
|
||||
Task<AgentHeartbeatResponse> HeartbeatAsync(AgentHeartbeatRequest request, CancellationToken ct = default);
|
||||
|
||||
/// <summary>
|
||||
/// Unregister an agent.
|
||||
/// </summary>
|
||||
/// <param name="agentId">Agent identifier.</param>
|
||||
/// <param name="ct">Cancellation token.</param>
|
||||
Task UnregisterAsync(string agentId, CancellationToken ct = default);
|
||||
|
||||
/// <summary>
|
||||
/// Get registration by agent ID.
|
||||
/// </summary>
|
||||
/// <param name="agentId">Agent identifier.</param>
|
||||
/// <param name="ct">Cancellation token.</param>
|
||||
/// <returns>Registration or null if not found.</returns>
|
||||
Task<AgentRegistration?> GetAsync(string agentId, CancellationToken ct = default);
|
||||
|
||||
/// <summary>
|
||||
/// List all registered agents.
|
||||
/// </summary>
|
||||
/// <param name="ct">Cancellation token.</param>
|
||||
/// <returns>All registrations.</returns>
|
||||
Task<IReadOnlyList<AgentRegistration>> ListAsync(CancellationToken ct = default);
|
||||
|
||||
/// <summary>
|
||||
/// List agents by platform.
|
||||
/// </summary>
|
||||
/// <param name="platform">Platform filter.</param>
|
||||
/// <param name="ct">Cancellation token.</param>
|
||||
/// <returns>Matching registrations.</returns>
|
||||
Task<IReadOnlyList<AgentRegistration>> ListByPlatformAsync(RuntimePlatform platform, CancellationToken ct = default);
|
||||
|
||||
/// <summary>
|
||||
/// List healthy agents (recent heartbeat).
|
||||
/// </summary>
|
||||
/// <param name="ct">Cancellation token.</param>
|
||||
/// <returns>Healthy registrations.</returns>
|
||||
Task<IReadOnlyList<AgentRegistration>> ListHealthyAsync(CancellationToken ct = default);
|
||||
|
||||
/// <summary>
|
||||
/// Send command to an agent.
|
||||
/// </summary>
|
||||
/// <param name="agentId">Agent identifier.</param>
|
||||
/// <param name="command">Command to send.</param>
|
||||
/// <param name="ct">Cancellation token.</param>
|
||||
Task SendCommandAsync(string agentId, AgentCommand command, CancellationToken ct = default);
|
||||
|
||||
/// <summary>
|
||||
/// Update agent posture.
|
||||
/// </summary>
|
||||
/// <param name="agentId">Agent identifier.</param>
|
||||
/// <param name="posture">New posture.</param>
|
||||
/// <param name="ct">Cancellation token.</param>
|
||||
Task UpdatePostureAsync(string agentId, RuntimePosture posture, CancellationToken ct = default);
|
||||
}
|
||||
@@ -44,42 +44,3 @@ public interface IRuntimeFactsIngest
|
||||
/// <param name="ct">Cancellation token.</param>
|
||||
Task UnregisterAgentAsync(string agentId, CancellationToken ct);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Agent registration information.
|
||||
/// </summary>
|
||||
public sealed record AgentRegistration
|
||||
{
|
||||
/// <summary>Unique agent ID.</summary>
|
||||
public required string AgentId { get; init; }
|
||||
|
||||
/// <summary>Target platform.</summary>
|
||||
public required RuntimePlatform Platform { get; init; }
|
||||
|
||||
/// <summary>Agent version.</summary>
|
||||
public required string AgentVersion { get; init; }
|
||||
|
||||
/// <summary>Hostname.</summary>
|
||||
public required string Hostname { get; init; }
|
||||
|
||||
/// <summary>Container ID if applicable.</summary>
|
||||
public string? ContainerId { get; init; }
|
||||
|
||||
/// <summary>Kubernetes pod name if applicable.</summary>
|
||||
public string? PodName { get; init; }
|
||||
|
||||
/// <summary>Kubernetes namespace if applicable.</summary>
|
||||
public string? Namespace { get; init; }
|
||||
|
||||
/// <summary>Target process name.</summary>
|
||||
public string? ProcessName { get; init; }
|
||||
|
||||
/// <summary>Target process ID.</summary>
|
||||
public int? ProcessId { get; init; }
|
||||
|
||||
/// <summary>Registration timestamp.</summary>
|
||||
public required DateTimeOffset RegisteredAt { get; init; }
|
||||
|
||||
/// <summary>Initial posture.</summary>
|
||||
public RuntimePosture Posture { get; init; } = RuntimePosture.Sampled;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,303 @@
|
||||
// <copyright file="RuntimeFactsIngestService.cs" company="StellaOps">
|
||||
// Copyright (c) StellaOps. Licensed under the AGPL-3.0-or-later.
|
||||
// </copyright>
|
||||
|
||||
using System.Collections.Concurrent;
|
||||
using System.Collections.Immutable;
|
||||
using System.Threading.Channels;
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
namespace StellaOps.Signals.RuntimeAgent;
|
||||
|
||||
/// <summary>
|
||||
/// Service for ingesting and processing runtime facts from agents.
|
||||
/// Sprint: SPRINT_20260109_009_004 Task: Implement RuntimeFactsIngestService
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// This implementation buffers events in memory and aggregates them by symbol.
|
||||
/// For production use, integrate with persistence and the Signals module.
|
||||
/// </remarks>
|
||||
public sealed class RuntimeFactsIngestService : IRuntimeFactsIngest, IAsyncDisposable
|
||||
{
|
||||
private readonly TimeProvider _timeProvider;
|
||||
private readonly ILogger<RuntimeFactsIngestService> _logger;
|
||||
private readonly IAgentRegistrationService _registrationService;
|
||||
|
||||
// Event buffer channel for async processing
|
||||
private readonly Channel<IngestBatch> _ingestChannel;
|
||||
private readonly Task _processingTask;
|
||||
private readonly CancellationTokenSource _cts = new();
|
||||
|
||||
// Symbol observation tracking
|
||||
private readonly ConcurrentDictionary<string, SymbolObservation> _observations = new();
|
||||
|
||||
// Statistics
|
||||
private long _totalEventsIngested;
|
||||
private long _totalBatchesProcessed;
|
||||
|
||||
public RuntimeFactsIngestService(
|
||||
TimeProvider timeProvider,
|
||||
IAgentRegistrationService registrationService,
|
||||
ILogger<RuntimeFactsIngestService> logger)
|
||||
{
|
||||
_timeProvider = timeProvider;
|
||||
_registrationService = registrationService;
|
||||
_logger = logger;
|
||||
|
||||
// Create bounded channel to prevent memory issues
|
||||
_ingestChannel = Channel.CreateBounded<IngestBatch>(new BoundedChannelOptions(1000)
|
||||
{
|
||||
FullMode = BoundedChannelFullMode.Wait,
|
||||
SingleReader = true,
|
||||
SingleWriter = false
|
||||
});
|
||||
|
||||
// Start background processing
|
||||
_processingTask = ProcessIngestChannelAsync(_cts.Token);
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
public async Task<int> IngestAsync(
|
||||
string agentId,
|
||||
IReadOnlyList<RuntimeMethodEvent> events,
|
||||
CancellationToken ct)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(agentId);
|
||||
|
||||
if (events.Count == 0)
|
||||
return 0;
|
||||
|
||||
var batch = new IngestBatch(agentId, events, _timeProvider.GetUtcNow());
|
||||
|
||||
await _ingestChannel.Writer.WriteAsync(batch, ct).ConfigureAwait(false);
|
||||
|
||||
_logger.LogDebug(
|
||||
"Queued {Count} events from agent {AgentId}",
|
||||
events.Count,
|
||||
agentId);
|
||||
|
||||
return events.Count;
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
public Task RegisterAgentAsync(AgentRegistration registration, CancellationToken ct)
|
||||
{
|
||||
_logger.LogInformation(
|
||||
"Agent {AgentId} registered for fact ingestion",
|
||||
registration.AgentId);
|
||||
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
public Task HeartbeatAsync(string agentId, AgentStatistics statistics, CancellationToken ct)
|
||||
{
|
||||
_logger.LogDebug(
|
||||
"Heartbeat from {AgentId}: {TotalEvents} events collected",
|
||||
agentId,
|
||||
statistics.TotalEventsCollected);
|
||||
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
public Task UnregisterAgentAsync(string agentId, CancellationToken ct)
|
||||
{
|
||||
_logger.LogInformation(
|
||||
"Agent {AgentId} unregistered from fact ingestion",
|
||||
agentId);
|
||||
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets the observation for a symbol.
|
||||
/// </summary>
|
||||
public SymbolObservation? GetObservation(string symbolId)
|
||||
{
|
||||
_observations.TryGetValue(symbolId, out var observation);
|
||||
return observation;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets all symbols observed since a given time.
|
||||
/// </summary>
|
||||
public IReadOnlyList<SymbolObservation> GetObservationsSince(DateTimeOffset since)
|
||||
{
|
||||
return _observations.Values
|
||||
.Where(o => o.LastObserved >= since)
|
||||
.ToList();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets all unique symbols observed.
|
||||
/// </summary>
|
||||
public IReadOnlyList<string> GetObservedSymbols()
|
||||
{
|
||||
return _observations.Keys.ToList();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets ingest statistics.
|
||||
/// </summary>
|
||||
public RuntimeFactsIngestStats GetStatistics()
|
||||
{
|
||||
return new RuntimeFactsIngestStats(
|
||||
TotalEventsIngested: Interlocked.Read(ref _totalEventsIngested),
|
||||
TotalBatchesProcessed: Interlocked.Read(ref _totalBatchesProcessed),
|
||||
UniqueSymbolsObserved: _observations.Count,
|
||||
PendingBatches: _ingestChannel.Reader.Count);
|
||||
}
|
||||
|
||||
private async Task ProcessIngestChannelAsync(CancellationToken ct)
|
||||
{
|
||||
try
|
||||
{
|
||||
await foreach (var batch in _ingestChannel.Reader.ReadAllAsync(ct).ConfigureAwait(false))
|
||||
{
|
||||
try
|
||||
{
|
||||
ProcessBatch(batch);
|
||||
Interlocked.Increment(ref _totalBatchesProcessed);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Error processing batch from agent {AgentId}", batch.AgentId);
|
||||
}
|
||||
}
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
// Expected during shutdown
|
||||
}
|
||||
}
|
||||
|
||||
private void ProcessBatch(IngestBatch batch)
|
||||
{
|
||||
foreach (var @event in batch.Events)
|
||||
{
|
||||
var symbolId = @event.SymbolId;
|
||||
|
||||
_observations.AddOrUpdate(
|
||||
symbolId,
|
||||
_ => CreateObservation(@event, batch),
|
||||
(_, existing) => UpdateObservation(existing, @event, batch));
|
||||
|
||||
Interlocked.Increment(ref _totalEventsIngested);
|
||||
}
|
||||
|
||||
_logger.LogDebug(
|
||||
"Processed batch of {Count} events from agent {AgentId}",
|
||||
batch.Events.Count,
|
||||
batch.AgentId);
|
||||
}
|
||||
|
||||
private SymbolObservation CreateObservation(RuntimeMethodEvent @event, IngestBatch batch)
|
||||
{
|
||||
return new SymbolObservation
|
||||
{
|
||||
SymbolId = @event.SymbolId,
|
||||
MethodName = @event.MethodName,
|
||||
TypeName = @event.TypeName,
|
||||
AssemblyOrModule = @event.AssemblyOrModule,
|
||||
Platform = @event.Platform,
|
||||
FirstObserved = @event.Timestamp,
|
||||
LastObserved = @event.Timestamp,
|
||||
ObservationCount = 1,
|
||||
AgentIds = [batch.AgentId],
|
||||
EventKinds = [@event.Kind]
|
||||
};
|
||||
}
|
||||
|
||||
private static SymbolObservation UpdateObservation(
|
||||
SymbolObservation existing,
|
||||
RuntimeMethodEvent @event,
|
||||
IngestBatch batch)
|
||||
{
|
||||
var agentIds = existing.AgentIds.Contains(batch.AgentId)
|
||||
? existing.AgentIds
|
||||
: existing.AgentIds.Add(batch.AgentId);
|
||||
|
||||
var eventKinds = existing.EventKinds.Contains(@event.Kind)
|
||||
? existing.EventKinds
|
||||
: existing.EventKinds.Add(@event.Kind);
|
||||
|
||||
return existing with
|
||||
{
|
||||
LastObserved = @event.Timestamp > existing.LastObserved
|
||||
? @event.Timestamp
|
||||
: existing.LastObserved,
|
||||
ObservationCount = existing.ObservationCount + 1,
|
||||
AgentIds = agentIds,
|
||||
EventKinds = eventKinds
|
||||
};
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
public async ValueTask DisposeAsync()
|
||||
{
|
||||
_cts.Cancel();
|
||||
_ingestChannel.Writer.Complete();
|
||||
|
||||
try
|
||||
{
|
||||
await _processingTask.ConfigureAwait(false);
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
// Expected
|
||||
}
|
||||
|
||||
_cts.Dispose();
|
||||
}
|
||||
|
||||
private sealed record IngestBatch(
|
||||
string AgentId,
|
||||
IReadOnlyList<RuntimeMethodEvent> Events,
|
||||
DateTimeOffset ReceivedAt);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Aggregated observation for a symbol.
|
||||
/// </summary>
|
||||
public sealed record SymbolObservation
|
||||
{
|
||||
/// <summary>Symbol identifier.</summary>
|
||||
public required string SymbolId { get; init; }
|
||||
|
||||
/// <summary>Method name.</summary>
|
||||
public required string MethodName { get; init; }
|
||||
|
||||
/// <summary>Type/class name.</summary>
|
||||
public required string TypeName { get; init; }
|
||||
|
||||
/// <summary>Assembly/module.</summary>
|
||||
public required string AssemblyOrModule { get; init; }
|
||||
|
||||
/// <summary>Platform.</summary>
|
||||
public required RuntimePlatform Platform { get; init; }
|
||||
|
||||
/// <summary>First observation timestamp.</summary>
|
||||
public required DateTimeOffset FirstObserved { get; init; }
|
||||
|
||||
/// <summary>Most recent observation timestamp.</summary>
|
||||
public required DateTimeOffset LastObserved { get; init; }
|
||||
|
||||
/// <summary>Total observation count.</summary>
|
||||
public required long ObservationCount { get; init; }
|
||||
|
||||
/// <summary>Agents that observed this symbol.</summary>
|
||||
public required ImmutableHashSet<string> AgentIds { get; init; }
|
||||
|
||||
/// <summary>Event kinds observed.</summary>
|
||||
public required ImmutableHashSet<RuntimeEventKind> EventKinds { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Statistics for the ingest service.
|
||||
/// </summary>
|
||||
public sealed record RuntimeFactsIngestStats(
|
||||
long TotalEventsIngested,
|
||||
long TotalBatchesProcessed,
|
||||
int UniqueSymbolsObserved,
|
||||
int PendingBatches);
|
||||
@@ -0,0 +1,272 @@
|
||||
// <copyright file="AgentRegistrationServiceTests.cs" company="StellaOps">
|
||||
// Copyright (c) StellaOps. Licensed under the AGPL-3.0-or-later.
|
||||
// </copyright>
|
||||
|
||||
using FluentAssertions;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using Microsoft.Extensions.Time.Testing;
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.Signals.RuntimeAgent.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Tests for <see cref="AgentRegistrationService"/>.
|
||||
/// </summary>
|
||||
[Trait("Category", "Unit")]
|
||||
public class AgentRegistrationServiceTests
|
||||
{
|
||||
private readonly FakeTimeProvider _timeProvider = new();
|
||||
private readonly AgentRegistrationService _service;
|
||||
|
||||
public AgentRegistrationServiceTests()
|
||||
{
|
||||
_service = new AgentRegistrationService(
|
||||
_timeProvider,
|
||||
NullLogger<AgentRegistrationService>.Instance);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task RegisterAsync_ValidRequest_ReturnsRegistration()
|
||||
{
|
||||
var request = CreateRegistrationRequest("agent-1");
|
||||
|
||||
var result = await _service.RegisterAsync(request);
|
||||
|
||||
result.Should().NotBeNull();
|
||||
result.AgentId.Should().Be("agent-1");
|
||||
result.Platform.Should().Be(RuntimePlatform.DotNet);
|
||||
result.Hostname.Should().Be("host1");
|
||||
result.State.Should().Be(AgentState.Stopped);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task RegisterAsync_DuplicateAgent_UpdatesRegistration()
|
||||
{
|
||||
var request1 = CreateRegistrationRequest("agent-1");
|
||||
var request2 = CreateRegistrationRequest("agent-1") with { ApplicationName = "App2" };
|
||||
|
||||
await _service.RegisterAsync(request1);
|
||||
_timeProvider.Advance(TimeSpan.FromMinutes(1));
|
||||
var result = await _service.RegisterAsync(request2);
|
||||
|
||||
result.ApplicationName.Should().Be("App2");
|
||||
_service.Count.Should().Be(1);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task HeartbeatAsync_RegisteredAgent_UpdatesState()
|
||||
{
|
||||
var request = CreateRegistrationRequest("agent-1");
|
||||
await _service.RegisterAsync(request);
|
||||
|
||||
_timeProvider.Advance(TimeSpan.FromSeconds(30));
|
||||
var heartbeat = new AgentHeartbeatRequest
|
||||
{
|
||||
AgentId = "agent-1",
|
||||
State = AgentState.Running,
|
||||
Posture = RuntimePosture.Full,
|
||||
Statistics = CreateStatistics("agent-1", 1000)
|
||||
};
|
||||
|
||||
var response = await _service.HeartbeatAsync(heartbeat);
|
||||
|
||||
response.Continue.Should().BeTrue();
|
||||
var registration = await _service.GetAsync("agent-1");
|
||||
registration!.State.Should().Be(AgentState.Running);
|
||||
registration.Posture.Should().Be(RuntimePosture.Full);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task HeartbeatAsync_UnknownAgent_ReturnsContinueFalse()
|
||||
{
|
||||
var heartbeat = new AgentHeartbeatRequest
|
||||
{
|
||||
AgentId = "unknown-agent",
|
||||
State = AgentState.Running,
|
||||
Posture = RuntimePosture.Sampled
|
||||
};
|
||||
|
||||
var response = await _service.HeartbeatAsync(heartbeat);
|
||||
|
||||
response.Continue.Should().BeFalse();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task HeartbeatAsync_WithPendingCommand_ReturnsCommand()
|
||||
{
|
||||
var request = CreateRegistrationRequest("agent-1");
|
||||
await _service.RegisterAsync(request);
|
||||
await _service.SendCommandAsync("agent-1", AgentCommand.Start);
|
||||
|
||||
var heartbeat = new AgentHeartbeatRequest
|
||||
{
|
||||
AgentId = "agent-1",
|
||||
State = AgentState.Stopped,
|
||||
Posture = RuntimePosture.Sampled
|
||||
};
|
||||
var response = await _service.HeartbeatAsync(heartbeat);
|
||||
|
||||
response.Command.Should().Be(AgentCommand.Start);
|
||||
|
||||
// Second heartbeat should not have command
|
||||
var response2 = await _service.HeartbeatAsync(heartbeat);
|
||||
response2.Command.Should().BeNull();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task HeartbeatAsync_WithPendingPosture_ReturnsNewPosture()
|
||||
{
|
||||
var request = CreateRegistrationRequest("agent-1");
|
||||
await _service.RegisterAsync(request);
|
||||
await _service.UpdatePostureAsync("agent-1", RuntimePosture.Full);
|
||||
|
||||
var heartbeat = new AgentHeartbeatRequest
|
||||
{
|
||||
AgentId = "agent-1",
|
||||
State = AgentState.Running,
|
||||
Posture = RuntimePosture.Sampled
|
||||
};
|
||||
var response = await _service.HeartbeatAsync(heartbeat);
|
||||
|
||||
response.NewPosture.Should().Be(RuntimePosture.Full);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task UnregisterAsync_RegisteredAgent_RemovesFromList()
|
||||
{
|
||||
var request = CreateRegistrationRequest("agent-1");
|
||||
await _service.RegisterAsync(request);
|
||||
|
||||
await _service.UnregisterAsync("agent-1");
|
||||
|
||||
var result = await _service.GetAsync("agent-1");
|
||||
result.Should().BeNull();
|
||||
_service.Count.Should().Be(0);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ListAsync_ReturnsAllRegistrations()
|
||||
{
|
||||
await _service.RegisterAsync(CreateRegistrationRequest("agent-1"));
|
||||
await _service.RegisterAsync(CreateRegistrationRequest("agent-2"));
|
||||
await _service.RegisterAsync(CreateRegistrationRequest("agent-3"));
|
||||
|
||||
var result = await _service.ListAsync();
|
||||
|
||||
result.Should().HaveCount(3);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ListByPlatformAsync_FiltersCorrectly()
|
||||
{
|
||||
await _service.RegisterAsync(CreateRegistrationRequest("agent-1") with
|
||||
{
|
||||
Platform = RuntimePlatform.DotNet
|
||||
});
|
||||
await _service.RegisterAsync(CreateRegistrationRequest("agent-2") with
|
||||
{
|
||||
Platform = RuntimePlatform.Java
|
||||
});
|
||||
await _service.RegisterAsync(CreateRegistrationRequest("agent-3") with
|
||||
{
|
||||
Platform = RuntimePlatform.DotNet
|
||||
});
|
||||
|
||||
var result = await _service.ListByPlatformAsync(RuntimePlatform.DotNet);
|
||||
|
||||
result.Should().HaveCount(2);
|
||||
result.Should().AllSatisfy(r => r.Platform.Should().Be(RuntimePlatform.DotNet));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ListHealthyAsync_FiltersStaleAgents()
|
||||
{
|
||||
_service.HeartbeatTimeout = TimeSpan.FromMinutes(2);
|
||||
|
||||
await _service.RegisterAsync(CreateRegistrationRequest("agent-1"));
|
||||
await _service.RegisterAsync(CreateRegistrationRequest("agent-2"));
|
||||
|
||||
// Advance time and only heartbeat agent-1
|
||||
_timeProvider.Advance(TimeSpan.FromMinutes(1));
|
||||
await _service.HeartbeatAsync(new AgentHeartbeatRequest
|
||||
{
|
||||
AgentId = "agent-1",
|
||||
State = AgentState.Running,
|
||||
Posture = RuntimePosture.Sampled
|
||||
});
|
||||
|
||||
// Advance 30 seconds - agent-1 should still be healthy (1.5 min since heartbeat)
|
||||
// but agent-2 is unhealthy (2.5 min since registration/initial heartbeat)
|
||||
_timeProvider.Advance(TimeSpan.FromMinutes(1.5));
|
||||
|
||||
var healthy = await _service.ListHealthyAsync();
|
||||
|
||||
healthy.Should().HaveCount(1);
|
||||
healthy[0].AgentId.Should().Be("agent-1");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task PruneStale_RemovesExpiredRegistrations()
|
||||
{
|
||||
_service.HeartbeatTimeout = TimeSpan.FromMinutes(2);
|
||||
|
||||
await _service.RegisterAsync(CreateRegistrationRequest("agent-1"));
|
||||
await _service.RegisterAsync(CreateRegistrationRequest("agent-2"));
|
||||
|
||||
_timeProvider.Advance(TimeSpan.FromMinutes(3));
|
||||
|
||||
var pruned = _service.PruneStale();
|
||||
|
||||
pruned.Should().Be(2);
|
||||
_service.Count.Should().Be(0);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SendCommandAsync_UnknownAgent_DoesNotThrow()
|
||||
{
|
||||
await _service.SendCommandAsync("unknown", AgentCommand.Start);
|
||||
|
||||
// Should not throw, just log warning
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task UpdatePostureAsync_UnknownAgent_DoesNotThrow()
|
||||
{
|
||||
await _service.UpdatePostureAsync("unknown", RuntimePosture.Full);
|
||||
|
||||
// Should not throw, just log warning
|
||||
}
|
||||
|
||||
private static AgentRegistrationRequest CreateRegistrationRequest(string agentId)
|
||||
{
|
||||
return new AgentRegistrationRequest
|
||||
{
|
||||
AgentId = agentId,
|
||||
Platform = RuntimePlatform.DotNet,
|
||||
Hostname = "host1",
|
||||
AgentVersion = "1.0.0",
|
||||
ApplicationName = "TestApp",
|
||||
InitialPosture = RuntimePosture.Sampled
|
||||
};
|
||||
}
|
||||
|
||||
private AgentStatistics CreateStatistics(string agentId, long eventsCollected)
|
||||
{
|
||||
return new AgentStatistics
|
||||
{
|
||||
AgentId = agentId,
|
||||
Timestamp = _timeProvider.GetUtcNow(),
|
||||
State = AgentState.Running,
|
||||
Uptime = TimeSpan.FromMinutes(5),
|
||||
TotalEventsCollected = eventsCollected,
|
||||
EventsLastMinute = Math.Min(eventsCollected, 1000),
|
||||
EventsDropped = 0,
|
||||
UniqueMethodsObserved = (int)(eventsCollected / 10),
|
||||
UniqueTypesObserved = (int)(eventsCollected / 100),
|
||||
UniqueAssembliesObserved = 5,
|
||||
BufferUtilizationPercent = 25.0,
|
||||
EstimatedCpuOverheadPercent = 1.5,
|
||||
MemoryUsageBytes = 50_000_000
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,257 @@
|
||||
// <copyright file="ClrMethodResolverTests.cs" company="StellaOps">
|
||||
// Copyright (c) StellaOps. Licensed under the AGPL-3.0-or-later.
|
||||
// </copyright>
|
||||
|
||||
using FluentAssertions;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using Microsoft.Extensions.Time.Testing;
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.Signals.RuntimeAgent.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Tests for <see cref="ClrMethodResolver"/>.
|
||||
/// </summary>
|
||||
[Trait("Category", "Unit")]
|
||||
public class ClrMethodResolverTests
|
||||
{
|
||||
private readonly FakeTimeProvider _timeProvider = new();
|
||||
private readonly ClrMethodResolver _resolver;
|
||||
|
||||
public ClrMethodResolverTests()
|
||||
{
|
||||
_resolver = new ClrMethodResolver(
|
||||
_timeProvider,
|
||||
NullLogger<ClrMethodResolver>.Instance);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void RegisterMethod_IncrementsCacheCount()
|
||||
{
|
||||
_resolver.RegisterMethod(
|
||||
methodId: 0x06000001,
|
||||
moduleId: 0x00007FF8ABC12340,
|
||||
methodNamespace: "MyApp.Services",
|
||||
methodName: "ProcessData",
|
||||
methodSignature: "(System.String)");
|
||||
|
||||
_resolver.CachedMethodCount.Should().Be(1);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void RegisterModule_IncrementsCacheCount()
|
||||
{
|
||||
_resolver.RegisterModule(
|
||||
moduleId: 0x00007FF8ABC12340,
|
||||
assemblyId: 0x00007FF8DEF00000,
|
||||
modulePath: @"C:\app\MyApp.dll",
|
||||
simpleName: "MyApp");
|
||||
|
||||
_resolver.CachedModuleCount.Should().Be(1);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ResolveMethod_RegisteredMethod_ReturnsResolved()
|
||||
{
|
||||
const ulong methodId = 0x06000001;
|
||||
_resolver.RegisterMethod(
|
||||
methodId: methodId,
|
||||
moduleId: 0x00007FF8ABC12340,
|
||||
methodNamespace: "MyApp.Services.DataService",
|
||||
methodName: "ProcessData",
|
||||
methodSignature: "(System.String)");
|
||||
|
||||
var resolved = _resolver.ResolveMethod(methodId);
|
||||
|
||||
resolved.Should().NotBeNull();
|
||||
resolved!.MethodName.Should().Be("ProcessData");
|
||||
resolved.Namespace.Should().Be("MyApp.Services.DataService");
|
||||
resolved.TypeName.Should().Be("DataService");
|
||||
resolved.Signature.Should().Be("(System.String)");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ResolveMethod_UnknownMethod_ReturnsNull()
|
||||
{
|
||||
var resolved = _resolver.ResolveMethod(0xDEADBEEF);
|
||||
|
||||
resolved.Should().BeNull();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ResolveToEvent_RegisteredMethod_ReturnsEvent()
|
||||
{
|
||||
const ulong methodId = 0x06000001;
|
||||
const ulong moduleId = 0x00007FF8ABC12340;
|
||||
|
||||
_resolver.RegisterModule(moduleId, 0, @"C:\app\MyApp.dll", "MyApp");
|
||||
_resolver.RegisterMethod(
|
||||
methodId: methodId,
|
||||
moduleId: moduleId,
|
||||
methodNamespace: "MyApp.Services.DataService",
|
||||
methodName: "ProcessData",
|
||||
methodSignature: "(System.String)");
|
||||
|
||||
var @event = _resolver.ResolveToEvent(
|
||||
methodId,
|
||||
RuntimeEventKind.MethodEnter,
|
||||
eventId: "test-event-1",
|
||||
timestamp: _timeProvider.GetUtcNow(),
|
||||
processId: 1234);
|
||||
|
||||
@event.Should().NotBeNull();
|
||||
@event!.MethodName.Should().Be("ProcessData");
|
||||
@event.TypeName.Should().Be("DataService");
|
||||
@event.AssemblyOrModule.Should().Be("MyApp");
|
||||
@event.Kind.Should().Be(RuntimeEventKind.MethodEnter);
|
||||
@event.Platform.Should().Be(RuntimePlatform.DotNet);
|
||||
@event.ProcessId.Should().Be(1234);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ResolveToEvent_UnknownMethod_ReturnsNull()
|
||||
{
|
||||
var @event = _resolver.ResolveToEvent(
|
||||
0xDEADBEEF,
|
||||
RuntimeEventKind.MethodEnter,
|
||||
eventId: "test-event-1",
|
||||
timestamp: _timeProvider.GetUtcNow());
|
||||
|
||||
@event.Should().BeNull();
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("MethodID=0x06000123", 0x06000123UL)]
|
||||
[InlineData("MethodID=0x00000001", 0x00000001UL)]
|
||||
[InlineData("MethodID=0xDEADBEEF", 0xDEADBEEFUL)]
|
||||
public void TryParseEtwMethodId_ValidInput_ReturnsMethodId(string input, ulong expected)
|
||||
{
|
||||
var success = _resolver.TryParseEtwMethodId(input, out var methodId);
|
||||
|
||||
success.Should().BeTrue();
|
||||
methodId.Should().Be(expected);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("")]
|
||||
[InlineData("invalid")]
|
||||
[InlineData("MethodID=invalid")]
|
||||
[InlineData("ModuleID=0x123")]
|
||||
public void TryParseEtwMethodId_InvalidInput_ReturnsFalse(string input)
|
||||
{
|
||||
var success = _resolver.TryParseEtwMethodId(input, out _);
|
||||
|
||||
success.Should().BeFalse();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ParseEtwMethodWithModule_ValidInput_ReturnsBoth()
|
||||
{
|
||||
var result = _resolver.ParseEtwMethodWithModule(
|
||||
"MethodID=0x06000123 ModuleID=0x00007FF8ABC12340");
|
||||
|
||||
result.Should().NotBeNull();
|
||||
result!.Value.MethodId.Should().Be(0x06000123UL);
|
||||
result.Value.ModuleId.Should().Be(0x00007FF8ABC12340UL);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ParseEtwMethodWithModule_MethodOnly_ReturnsZeroModule()
|
||||
{
|
||||
var result = _resolver.ParseEtwMethodWithModule("MethodID=0x06000123");
|
||||
|
||||
result.Should().NotBeNull();
|
||||
result!.Value.MethodId.Should().Be(0x06000123UL);
|
||||
result.Value.ModuleId.Should().Be(0UL);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void FormatSymbolId_ReturnsCorrectFormat()
|
||||
{
|
||||
var symbolId = ClrMethodResolver.FormatSymbolId(0x06000123);
|
||||
|
||||
symbolId.Should().Be("clr:method:0000000006000123");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Clear_RemovesAllCaches()
|
||||
{
|
||||
_resolver.RegisterMethod(0x1, 0x2, "ns", "method", "()");
|
||||
_resolver.RegisterModule(0x2, 0x3, "path", "name");
|
||||
_resolver.RegisterAssembly(0x3, "assembly");
|
||||
|
||||
_resolver.Clear();
|
||||
|
||||
_resolver.CachedMethodCount.Should().Be(0);
|
||||
_resolver.CachedModuleCount.Should().Be(0);
|
||||
_resolver.GetStatistics().CachedAssemblies.Should().Be(0);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetStatistics_ReturnsCorrectCounts()
|
||||
{
|
||||
_resolver.RegisterMethod(0x1, 0x2, "ns1", "method1", "()");
|
||||
_resolver.RegisterMethod(0x2, 0x2, "ns2", "method2", "()");
|
||||
_resolver.RegisterModule(0x10, 0x20, "path1", "name1");
|
||||
_resolver.RegisterAssembly(0x20, "assembly1");
|
||||
|
||||
var stats = _resolver.GetStatistics();
|
||||
|
||||
stats.CachedMethods.Should().Be(2);
|
||||
stats.CachedModules.Should().Be(1);
|
||||
stats.CachedAssemblies.Should().Be(1);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ResolvedMethod_FullyQualifiedName_CombinesCorrectly()
|
||||
{
|
||||
const ulong methodId = 0x06000001;
|
||||
_resolver.RegisterMethod(
|
||||
methodId: methodId,
|
||||
moduleId: 0,
|
||||
methodNamespace: "MyApp.Services.DataService",
|
||||
methodName: "ProcessData",
|
||||
methodSignature: "()");
|
||||
|
||||
var resolved = _resolver.ResolveMethod(methodId);
|
||||
|
||||
resolved!.FullyQualifiedName.Should().Be("MyApp.Services.DataService.ProcessData");
|
||||
resolved.DisplayName.Should().Be("MyApp.Services.DataService.ProcessData()");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ResolvedMethod_EmptyNamespace_UsesMethodNameOnly()
|
||||
{
|
||||
const ulong methodId = 0x06000001;
|
||||
_resolver.RegisterMethod(
|
||||
methodId: methodId,
|
||||
moduleId: 0,
|
||||
methodNamespace: "",
|
||||
methodName: "Main",
|
||||
methodSignature: "()");
|
||||
|
||||
var resolved = _resolver.ResolveMethod(methodId);
|
||||
|
||||
resolved!.FullyQualifiedName.Should().Be("Main");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void RegisterAssembly_ResolvesInEvent()
|
||||
{
|
||||
const ulong methodId = 0x06000001;
|
||||
const ulong moduleId = 0x00007FF8ABC12340;
|
||||
const ulong assemblyId = 0x00007FF8DEF00000;
|
||||
|
||||
_resolver.RegisterAssembly(assemblyId, "MyApp.Core, Version=1.0.0.0");
|
||||
_resolver.RegisterModule(moduleId, assemblyId, @"C:\app\MyApp.Core.dll", "MyApp.Core");
|
||||
_resolver.RegisterMethod(methodId, moduleId, "MyApp.Core.Data", "Load", "()");
|
||||
|
||||
var @event = _resolver.ResolveToEvent(
|
||||
methodId,
|
||||
RuntimeEventKind.MethodEnter,
|
||||
eventId: "e1",
|
||||
timestamp: _timeProvider.GetUtcNow());
|
||||
|
||||
@event!.AssemblyOrModule.Should().Be("MyApp.Core, Version=1.0.0.0");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,248 @@
|
||||
// <copyright file="RuntimeFactsIngestServiceTests.cs" company="StellaOps">
|
||||
// Copyright (c) StellaOps. Licensed under the AGPL-3.0-or-later.
|
||||
// </copyright>
|
||||
|
||||
using FluentAssertions;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using Microsoft.Extensions.Time.Testing;
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.Signals.RuntimeAgent.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Tests for <see cref="RuntimeFactsIngestService"/>.
|
||||
/// </summary>
|
||||
[Trait("Category", "Unit")]
|
||||
public class RuntimeFactsIngestServiceTests : IAsyncLifetime
|
||||
{
|
||||
private readonly FakeTimeProvider _timeProvider = new();
|
||||
private readonly AgentRegistrationService _registrationService;
|
||||
private readonly RuntimeFactsIngestService _service;
|
||||
|
||||
public RuntimeFactsIngestServiceTests()
|
||||
{
|
||||
_registrationService = new AgentRegistrationService(
|
||||
_timeProvider,
|
||||
NullLogger<AgentRegistrationService>.Instance);
|
||||
|
||||
_service = new RuntimeFactsIngestService(
|
||||
_timeProvider,
|
||||
_registrationService,
|
||||
NullLogger<RuntimeFactsIngestService>.Instance);
|
||||
}
|
||||
|
||||
public ValueTask InitializeAsync() => ValueTask.CompletedTask;
|
||||
|
||||
public async ValueTask DisposeAsync()
|
||||
{
|
||||
await _service.DisposeAsync();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task IngestAsync_EmptyEvents_ReturnsZero()
|
||||
{
|
||||
var count = await _service.IngestAsync("agent-1", [], CancellationToken.None);
|
||||
|
||||
count.Should().Be(0);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task IngestAsync_ValidEvents_ReturnsCount()
|
||||
{
|
||||
var events = CreateEvents(5);
|
||||
|
||||
var count = await _service.IngestAsync("agent-1", events, CancellationToken.None);
|
||||
|
||||
count.Should().Be(5);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task IngestAsync_ProcessesEventsThroughChannel()
|
||||
{
|
||||
var events = CreateEvents(10);
|
||||
|
||||
await _service.IngestAsync("agent-1", events, CancellationToken.None);
|
||||
|
||||
// Allow time for background processing
|
||||
await Task.Delay(100);
|
||||
|
||||
var stats = _service.GetStatistics();
|
||||
stats.TotalEventsIngested.Should().Be(10);
|
||||
stats.TotalBatchesProcessed.Should().Be(1);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task IngestAsync_AggregatesSymbolObservations()
|
||||
{
|
||||
var now = _timeProvider.GetUtcNow();
|
||||
var events = new List<RuntimeMethodEvent>
|
||||
{
|
||||
CreateEvent("symbol-1", "Method1", now),
|
||||
CreateEvent("symbol-1", "Method1", now.AddSeconds(1)),
|
||||
CreateEvent("symbol-1", "Method1", now.AddSeconds(2)),
|
||||
CreateEvent("symbol-2", "Method2", now)
|
||||
};
|
||||
|
||||
await _service.IngestAsync("agent-1", events, CancellationToken.None);
|
||||
await Task.Delay(100);
|
||||
|
||||
var observation = _service.GetObservation("symbol-1");
|
||||
observation.Should().NotBeNull();
|
||||
observation!.ObservationCount.Should().Be(3);
|
||||
observation.MethodName.Should().Be("Method1");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task IngestAsync_TracksMultipleAgents()
|
||||
{
|
||||
var now = _timeProvider.GetUtcNow();
|
||||
var event1 = CreateEvent("symbol-1", "Method1", now);
|
||||
var event2 = CreateEvent("symbol-1", "Method1", now.AddSeconds(1));
|
||||
|
||||
await _service.IngestAsync("agent-1", [event1], CancellationToken.None);
|
||||
await _service.IngestAsync("agent-2", [event2], CancellationToken.None);
|
||||
await Task.Delay(100);
|
||||
|
||||
var observation = _service.GetObservation("symbol-1");
|
||||
observation!.AgentIds.Should().Contain("agent-1");
|
||||
observation.AgentIds.Should().Contain("agent-2");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetObservedSymbols_ReturnsAllUniqueSymbols()
|
||||
{
|
||||
var now = _timeProvider.GetUtcNow();
|
||||
var events = new List<RuntimeMethodEvent>
|
||||
{
|
||||
CreateEvent("symbol-1", "Method1", now),
|
||||
CreateEvent("symbol-2", "Method2", now),
|
||||
CreateEvent("symbol-3", "Method3", now),
|
||||
CreateEvent("symbol-1", "Method1", now) // Duplicate
|
||||
};
|
||||
|
||||
await _service.IngestAsync("agent-1", events, CancellationToken.None);
|
||||
await Task.Delay(100);
|
||||
|
||||
var symbols = _service.GetObservedSymbols();
|
||||
symbols.Should().HaveCount(3);
|
||||
symbols.Should().Contain("symbol-1");
|
||||
symbols.Should().Contain("symbol-2");
|
||||
symbols.Should().Contain("symbol-3");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetObservationsSince_FiltersCorrectly()
|
||||
{
|
||||
var baseTime = _timeProvider.GetUtcNow();
|
||||
var events1 = new List<RuntimeMethodEvent>
|
||||
{
|
||||
CreateEvent("symbol-1", "Method1", baseTime)
|
||||
};
|
||||
|
||||
await _service.IngestAsync("agent-1", events1, CancellationToken.None);
|
||||
await Task.Delay(100);
|
||||
|
||||
_timeProvider.Advance(TimeSpan.FromHours(1));
|
||||
var laterTime = _timeProvider.GetUtcNow();
|
||||
|
||||
var events2 = new List<RuntimeMethodEvent>
|
||||
{
|
||||
CreateEvent("symbol-2", "Method2", laterTime)
|
||||
};
|
||||
|
||||
await _service.IngestAsync("agent-1", events2, CancellationToken.None);
|
||||
await Task.Delay(100);
|
||||
|
||||
var recentObservations = _service.GetObservationsSince(baseTime.AddMinutes(30));
|
||||
recentObservations.Should().HaveCount(1);
|
||||
recentObservations[0].SymbolId.Should().Be("symbol-2");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetStatistics_ReturnsCorrectCounts()
|
||||
{
|
||||
var events1 = CreateEvents(5);
|
||||
var events2 = CreateEvents(3);
|
||||
|
||||
await _service.IngestAsync("agent-1", events1, CancellationToken.None);
|
||||
await _service.IngestAsync("agent-1", events2, CancellationToken.None);
|
||||
await Task.Delay(100);
|
||||
|
||||
var stats = _service.GetStatistics();
|
||||
stats.TotalEventsIngested.Should().Be(8);
|
||||
stats.TotalBatchesProcessed.Should().Be(2);
|
||||
stats.UniqueSymbolsObserved.Should().BeGreaterThan(0);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task RegisterAgentAsync_DoesNotThrow()
|
||||
{
|
||||
var registration = new AgentRegistration
|
||||
{
|
||||
AgentId = "agent-1",
|
||||
Platform = RuntimePlatform.DotNet,
|
||||
Hostname = "host1",
|
||||
AgentVersion = "1.0.0",
|
||||
RegisteredAt = _timeProvider.GetUtcNow(),
|
||||
LastHeartbeat = _timeProvider.GetUtcNow()
|
||||
};
|
||||
|
||||
await _service.RegisterAgentAsync(registration, CancellationToken.None);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task HeartbeatAsync_DoesNotThrow()
|
||||
{
|
||||
var stats = CreateStatistics("agent-1", 100);
|
||||
await _service.HeartbeatAsync("agent-1", stats, CancellationToken.None);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task UnregisterAgentAsync_DoesNotThrow()
|
||||
{
|
||||
await _service.UnregisterAgentAsync("agent-1", CancellationToken.None);
|
||||
}
|
||||
|
||||
private List<RuntimeMethodEvent> CreateEvents(int count)
|
||||
{
|
||||
var now = _timeProvider.GetUtcNow();
|
||||
return Enumerable.Range(0, count)
|
||||
.Select(i => CreateEvent($"symbol-{i}", $"Method{i}", now.AddMilliseconds(i)))
|
||||
.ToList();
|
||||
}
|
||||
|
||||
private AgentStatistics CreateStatistics(string agentId, long eventsCollected)
|
||||
{
|
||||
return new AgentStatistics
|
||||
{
|
||||
AgentId = agentId,
|
||||
Timestamp = _timeProvider.GetUtcNow(),
|
||||
State = AgentState.Running,
|
||||
Uptime = TimeSpan.FromMinutes(5),
|
||||
TotalEventsCollected = eventsCollected,
|
||||
EventsLastMinute = Math.Min(eventsCollected, 1000),
|
||||
EventsDropped = 0,
|
||||
UniqueMethodsObserved = (int)(eventsCollected / 10),
|
||||
UniqueTypesObserved = (int)(eventsCollected / 100),
|
||||
UniqueAssembliesObserved = 5,
|
||||
BufferUtilizationPercent = 25.0,
|
||||
EstimatedCpuOverheadPercent = 1.5,
|
||||
MemoryUsageBytes = 50_000_000
|
||||
};
|
||||
}
|
||||
|
||||
private static RuntimeMethodEvent CreateEvent(string symbolId, string methodName, DateTimeOffset timestamp)
|
||||
{
|
||||
return new RuntimeMethodEvent
|
||||
{
|
||||
EventId = Guid.NewGuid().ToString("N"),
|
||||
SymbolId = symbolId,
|
||||
MethodName = methodName,
|
||||
TypeName = "TestType",
|
||||
AssemblyOrModule = "TestAssembly",
|
||||
Timestamp = timestamp,
|
||||
Kind = RuntimeEventKind.MethodEnter,
|
||||
Platform = RuntimePlatform.DotNet
|
||||
};
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user