sqlinsight-pro/src/SQLInsight.Agent/AgentOptions.cs
Søren Eggert Lundsteen Olsen 66ed4e11b3 Review 1.4: AX 3.0/4.0 local times in the AX time zone, with its DST rules
Axapta 3.0 and AX 4.0 store batch and cleanup times as a local date plus
seconds. They were turned into UTC with the SQL Server's offset at collection
time, so every time from the other side of a DST change was an hour off (a job
planned 01:50 CET on the spring night looked 70 minutes late at 03:00 CEST),
and a SQL Server running UTC behind a CET AOS shifted every time.

AxLocalTime converts with the rules of the AX time zone on that date:
SQLInsight:DynamicsAx:LocalTimeZone (Windows or IANA id), else the agent's own
zone when its offset matches the SQL Server's, else the server's offset (to the
nearest quarter hour) with a Danish data note. The skipped spring hour and the
repeated autumn hour get standard time (deterministic, never throws, never a
false "late"); seconds outside 0..86400 are unknown and 86400 is midnight. The
AX 3.0 cleanup time is local by version, also when LATESTCLEANTIME is missing.
AX 2009+ UTC times are untouched. Also: remaining numbers cannot overflow for
HIGHEST = Int64.MaxValue, and run keys use the invariant culture.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-02 14:19:17 +02:00

209 lines
9.2 KiB
C#

using SQLInsight.Connectivity;
using SQLInsight.Repository;
namespace SQLInsight.Agent;
public sealed class AgentOptions
{
public const string SectionName = "SQLInsight";
/// <summary>How often the scheduler checks whether collectors are due.</summary>
public TimeSpan Tick { get; set; } = TimeSpan.FromSeconds(15);
/// <summary>How many instances are checked at the same time.</summary>
public int MaxParallelInstances { get; set; } = 4;
/// <summary>Overrides of collector intervals by collector name, e.g. <c>"blocking": "00:00:30"</c>.</summary>
public IDictionary<string, TimeSpan> CollectorIntervals { get; } = new Dictionary<string, TimeSpan>(StringComparer.OrdinalIgnoreCase);
public RepositoryOptions Repository { get; set; } = new();
public RetentionOptions Retention { get; set; } = new();
public PrivilegeOptions Privileges { get; set; } = new();
public TopSqlOptions TopSql { get; set; } = new();
public ErrorLogOptions ErrorLog { get; set; } = new();
public ActivityOptions Activity { get; set; } = new();
public TempdbOptions Tempdb { get; set; } = new();
public DynamicsAxOptions DynamicsAx { get; set; } = new();
public IList<MonitoredInstance> Instances { get; } = [];
}
public sealed class RepositoryOptions
{
/// <summary>
/// Connection string to the SQLInsight repository database. Use a SQL Server 2016+ instance other than the
/// ones being monitored.
/// </summary>
public string? ConnectionString { get; set; }
}
public sealed class MonitoredInstance
{
/// <summary>Stable name shown in the UI and used as the key in the repository and SQLInsight Hub.</summary>
public string Name { get; set; } = string.Empty;
public string Server { get; set; } = string.Empty;
public AuthenticationMode Authentication { get; set; } = AuthenticationMode.Windows;
public string? UserName { get; set; }
public string? Password { get; set; }
public ConnectionProvider Provider { get; set; } = ConnectionProvider.Auto;
public EncryptionMode Encryption { get; set; } = EncryptionMode.Mandatory;
public bool TrustServerCertificate { get; set; }
public string OdbcDriver { get; set; } = "ODBC Driver 18 for SQL Server";
/// <summary>Entra tenant id, for EntraServicePrincipal.</summary>
public string? TenantId { get; set; }
public ConnectionTarget ToTarget() => new()
{
Server = Server,
Authentication = Authentication,
UserName = UserName,
Password = Password,
Provider = Provider,
Encryption = Encryption,
TrustServerCertificate = TrustServerCertificate,
OdbcDriver = OdbcDriver,
TenantId = TenantId,
};
}
/// <summary>The privilege audit (sysadmin and the other elevated server rights).</summary>
public sealed class PrivilegeOptions
{
/// <summary>More people and accounts with sysadmin than this is a finding (SQLI-SEC-002).</summary>
public int MaxSysadminAccounts { get; set; } = 3;
/// <summary>Unfold Windows groups with server rights to the people in them, by asking Active Directory (Windows only).</summary>
public bool ExpandWindowsGroups { get; set; } = true;
}
/// <summary>Top SQL (the heaviest statements).</summary>
public sealed class TopSqlOptions
{
/// <summary>
/// A statement whose CPU time is more than this share of the time it has been in the plan cache runs almost
/// constantly (SQLI-PERF-…); 100 means one CPU core all the time.
/// </summary>
public double ActivityThresholdPercent { get; set; } = 50;
}
/// <summary>The SQL Server error log.</summary>
public sealed class ErrorLogOptions
{
/// <summary>Failed logins within one collection (5 minutes) before it is a finding (SQLI-LOG-004).</summary>
public int LoginFailureBurst { get; set; } = 20;
}
/// <summary>Open transactions and long-running statements.</summary>
public sealed class ActivityOptions
{
/// <summary>A transaction open longer than this (minutes) is a finding (SQLI-TRAN-001); six times as long is High.</summary>
public int TransactionMinutes { get; set; } = 10;
/// <summary>A transaction holding more transaction log than this (MB) is a finding (SQLI-TRAN-002).</summary>
public int TransactionLogMegabytes { get; set; } = 1024;
/// <summary>A user statement running longer than this (minutes) is a finding (SQLI-REQ-001).</summary>
public int RequestMinutes { get; set; } = 15;
}
/// <summary>tempdb and the version store.</summary>
public sealed class TempdbOptions
{
/// <summary>The version store above this (MB) is a finding (SQLI-TEMP-001).</summary>
public int VersionStoreMegabytes { get; set; } = 1024;
/// <summary>A session using more tempdb than this (MB) for 15 minutes is a finding (SQLI-TEMP-003).</summary>
public int SessionMegabytes { get; set; } = 1024;
}
/// <summary>
/// The Dynamics AX checks (SQLInsight:DynamicsAx). They only run in databases recognised as AX and chosen for monitoring;
/// the data checks need the optional read access (db/permissions/grant-dynamics-ax-read.sql).
/// </summary>
public sealed class DynamicsAxOptions
{
/// <summary>A waiting batch job this many minutes past its planned start is late (SQLI-AX-003).</summary>
public int BatchLateMinutes { get; set; } = 30;
/// <summary>Late by this many hours: in practice never started (High).</summary>
public int BatchNeverStartedHours { get; set; } = 24;
/// <summary>A batch job executing this many hours is stuck (SQLI-AX-004).</summary>
public int BatchStuckHours { get; set; } = 6;
/// <summary>Running this many times longer than its own 90th percentile is unusually long (SQLI-AX-002).</summary>
public double BatchLongRunningFactor { get; set; } = 2.0;
/// <summary>…but never for runs shorter than this (minutes).</summary>
public int BatchLongRunningMinimumMinutes { get; set; } = 10;
/// <summary>Earlier runs needed before a job is compared with its own history.</summary>
public int BatchHistoryMinimumRuns { get; set; } = 5;
/// <summary>A run that failed within this many hours is reported (SQLI-AX-001).</summary>
public int BatchFailedLookbackHours { get; set; } = 24;
/// <summary>A number sequence used more than this (percent) is a finding (SQLI-AX-005).</summary>
public double NumberSequenceWarnPercent { get; set; } = 80;
/// <summary>…and High from this.</summary>
public double NumberSequenceCriticalPercent { get; set; } = 95;
/// <summary>Forecast to run out within this many days is a finding (number sequences and 64-bit RecId).</summary>
public int ForecastWarnDays { get; set; } = 90;
/// <summary>…and High within this many days.</summary>
public int ForecastCriticalDays { get; set; } = 30;
/// <summary>AX 3.0's 32-bit RecId: a finding from this share of the int range (SQLI-AX-009).</summary>
public double RecId32WarnPercent { get; set; } = 50;
/// <summary>…and Critical from this.</summary>
public double RecId32CriticalPercent { get; set; } = 75;
/// <summary>AX 3.0: forecast to run out within this many days is a finding, Critical within half.</summary>
public int RecId32ForecastDays { get; set; } = 730;
/// <summary>64-bit RecId (or a lowered MAXVAL): a finding from this share.</summary>
public double RecId64WarnPercent { get; set; } = 80;
/// <summary>An AX session blocking others this many seconds is reported with its AX user (SQLI-AX-010).</summary>
public int BlockingSeconds { get; set; } = 30;
/// <summary>How many days back the forecasts look.</summary>
public int ForecastBasisDays { get; set; } = 30;
/// <summary>The AOS's time zone (Windows or IANA id) for Axapta 3.0/4.0's local times; empty: the agent's own zone when it matches the SQL Server.</summary>
public string? LocalTimeZone { get; set; }
public Core.DynamicsAx.AxThresholds ToThresholds() => new()
{
BatchLateMinutes = Math.Max(1, BatchLateMinutes),
BatchNeverStartedHours = Math.Max(1, BatchNeverStartedHours),
BatchStuckHours = Math.Max(1, BatchStuckHours),
BatchLongRunningFactor = Math.Max(1.1, BatchLongRunningFactor),
BatchLongRunningMinimumMinutes = Math.Max(1, BatchLongRunningMinimumMinutes),
BatchHistoryMinimumRuns = Math.Clamp(BatchHistoryMinimumRuns, 1, Core.DynamicsAx.AxBatchHistory.RunsKept),
BatchFailedLookbackHours = Math.Max(1, BatchFailedLookbackHours),
NumberSequenceWarnPercent = Math.Clamp(NumberSequenceWarnPercent, 1, 100),
NumberSequenceCriticalPercent = Math.Clamp(NumberSequenceCriticalPercent, 1, 100),
ForecastWarnDays = Math.Max(1, ForecastWarnDays),
ForecastCriticalDays = Math.Max(1, ForecastCriticalDays),
RecId32WarnPercent = Math.Clamp(RecId32WarnPercent, 1, 100),
RecId32CriticalPercent = Math.Clamp(RecId32CriticalPercent, 1, 100),
RecId32ForecastDays = Math.Max(1, RecId32ForecastDays),
RecId64WarnPercent = Math.Clamp(RecId64WarnPercent, 1, 100),
BlockingSeconds = Math.Max(1, BlockingSeconds),
ForecastBasisDays = Math.Clamp(ForecastBasisDays, 2, 365),
LocalTimeZone = string.IsNullOrWhiteSpace(LocalTimeZone) ? null : LocalTimeZone.Trim(),
};
}