Compare commits

..

18 commits

Author SHA1 Message Date
28bd148691 Version 1.2.0
Some checks failed
deploy community / deploy (push) Successful in 16s
deploy hub / deploy (push) Successful in 23s
CI / Build og unit tests (push) Successful in 6s
CI / MSI-installer (push) Successful in 15s
CI / Integration (mcr.microsoft.com/mssql/server:2017-latest) (push) Has been cancelled
CI / Integration (mcr.microsoft.com/mssql/server:2019-latest) (push) Has been cancelled
CI / Integration (mcr.microsoft.com/mssql/server:2022-latest) (push) Has been cancelled
CI / Integration (mcr.microsoft.com/mssql/server:2025-latest) (push) Has been cancelled
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-02 11:47:20 +02:00
d9e3b4ee6d Review 1.2: read only the default trace's current file between rollovers
With ALTER TRACE the error log collector read every default trace file
(up to 5 x 20 MB) every 5 minutes. While the current file is the one read
last time, everything since then is in it, so only that file is read;
after a rollover (and the first time) all files are read once as before.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-02 11:38:34 +02:00
8e551bcaa6 Review 1.2: document the new collection limits
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-02 11:35:54 +02:00
3f2e959f18 Review 1.2: list the new API v1 endpoints in the integration docs
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-02 11:35:41 +02:00
d070cd872b Review 1.2: scan the version store for its generators at most hourly
sys.dm_tran_top_version_generators reads the whole version store. It ran
every 5 minutes while the version store was over half the threshold,
i.e. exactly when it is large and expensive to scan, and a timeout failed
the whole tempdb collection. It now runs at most once per hour (the
previous answer is kept in between, with its time) and a failed scan
keeps the previous answer.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-02 11:35:00 +02:00
4ceb64f2d5 Review 1.2: index usage after a counter reset without a restart
sys.dm_db_index_usage_stats also starts over when a database is taken
offline, closed by AUTO_CLOSE or (2012/2014) an index is rebuilt. The
delta was then clamped to 0, so the usage since the reset was lost from
the daily history and the tracked totals. A lower counter now counts as
new usage. IndexInfo.Key also used the literal text "001f" as its
separator instead of the unit separator character.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-02 11:34:17 +02:00
a01ec374a4 Review 1.2: keep SQL Server's error message out of the replication reason
When the distribution database could not be read, SQL Server's message
(which names the agent's login) was appended to Unavailable. That text is
the rules' "not measurable" reason and is returned unredacted by
/rule-states and /replication. It is now UnavailableDetail: shown on the
local instance page and [udeladt] in the API unless
ApiIncludesSensitiveEvidence. The agent list is also capped (TOP 500).

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-02 11:33:25 +02:00
fcf2466738 Review 1.2: bound the activity collector and avoid duplicate rows
The open transactions and long requests had no TOP, so a busy server could
return (and store in the snapshot every minute) thousands of rows with
statement texts. Now at most 100 transactions (large log first, then the
oldest) and the 50 longest requests.

The transactions were joined to sys.dm_exec_requests, which has one row
per request: a MARS session with an open transaction gave duplicate rows,
and the MERGE into insight.LongTransactions then failed on the primary
key every minute. The first request is now taken with OUTER APPLY TOP 1,
and the save keeps one row per transaction.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-02 11:32:40 +02:00
a256e4c8cc Review 1.2: say correctly what each error log source shows
The error log page called the default trace "the whole error log" and
said it has every line, and the ring buffer source told SQLI-LOG-002/005
to grant ALTER TRACE, which does not make 833 or stack dumps visible
(they are only in the error log itself). The rules now say that it takes
securityadmin, why SQLInsight does not grant it, and the exact statement.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-02 11:31:53 +02:00
70934e7562 Review 1.2: error log entries are no longer counted again
The default trace was filtered on whole seconds only, so the newest entry
read (ReadUntil) came back at every collection until a newer one was
written: one 824 became a new event every 5 minutes. Entries at or before
ReadUntil are now left out, as for xp_readerrorlog.

The ring buffer's times were computed to whole seconds from the clock at
each read and moved by up to a second between reads, so the newest entry
could be counted twice. They are now computed to the millisecond with a
small tolerance.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-02 11:31:00 +02:00
1bbe5eb8eb Plan analysis test: start from an empty plan cache
With the new collectors the full suite fills the plan cache with heavier
statements, so the light probe statements could fall outside the 50
heaviest. The test clears the cache first (test only; SQLInsight never does).

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-02 11:14:54 +02:00
d27323a1a4 Availability groups: cluster node or witness down (D)
The availability group collector also reads sys.dm_hadr_cluster_members
(empty or unavailable without a WSFC), and SQLI-AG-003 (High) reports a
node or witness that is not up, with how many members are still up.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-02 10:56:43 +02:00
6cc65e3c75 Index usage over time and never-updated statistics (8g, F)
The index collector now sums index reads and writes between collections
across restarts of SQL Server (and of the agent, by seeding from the
stored snapshot) and keeps them per day in insight.IndexUsageDaily
(migration 0015, HourlyDays). SQLI-IDX-002 judges an index once it has
been followed for 7 days instead of needing 7 days of uptime.
GET /api/v1/instances/{name}/index-usage lists usage per index.

SQLI-STAT-001 also reports statistics that were never updated on tables
with rows; statistic rows now come from the table's heap or clustered
index, as a never-updated statistic has no row count of its own.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-02 10:55:22 +02:00
473a7d0be3 Replication from the distributor (8f)
On a distributor, a 15-minute collector reads each distribution agent's
latest run (status, latency, message) and replication errors the last
day from the distribution database. That needs SELECT on
MSdistribution_agents, MSdistribution_history and MSrepl_errors, which
the agent does not have by default (never the replicated commands);
without it SQLI-REPL-001 (agent failed/retrying, High) and SQLI-REPL-002
(latency > 5 min, Medium) are "not measurable" with the exact grant, and
"not relevant" elsewhere. Shown on the instance page and in
GET /api/v1/instances/{name}/replication. Tested against a real
distributor set up in the SQL 2022 container.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-02 10:48:46 +02:00
66129d99fc Stopped automatic SQL Server services (8e)
SQLI-SVC-001 (High) reports a service set to start automatically that is
not running (SQL Server Agent, Full-text launcher, Launchpad …), from the
sys.dm_server_services data the configuration collector already reads
(2008 R2 SP1+; not relevant before). The service account is sensitive.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-02 10:43:26 +02:00
4d18d20ab0 tempdb and version store (8c)
A five-minute collector reads tempdb space (free, version store, user and
internal objects, whether files can grow), the 'Version Store Size (KB)'
counter, the sessions using most tempdb, the oldest snapshot transaction
and, only when the version store is large, the databases generating
versions. Trend and heavy sessions survive restarts; used space and the
version store become metrics with explanation and assessment.

Rules SQLI-TEMP-001 (version store > 1 GB or steadily growing),
SQLI-TEMP-002 (nearly full and cannot grow) and SQLI-TEMP-003 (session
above 1 GB for 15 minutes), configurable under SQLInsight:Tempdb.
"tempdb lige nu" on the instance page; GET /api/v1/instances/{name}/tempdb.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-02 10:42:38 +02:00
6f7839d011 Long and large transactions and long-running statements (8b, 8d)
A one-minute collector reads open user transactions (age, log bytes used
and reserved, database, session, login, program, statement) and user
requests running longer than the threshold, leaving out the agent's own
and system sessions, BACKUP/RESTORE/DBCC (long operations) and requests
waiting on purpose. Transactions open a minute or holding 100 MB are kept
in insight.LongTransactions (migration 0014, RawDays).

Rules SQLI-TRAN-001 (open > 10 min, High at 6x), SQLI-TRAN-002 (> 1 GB
log) and SQLI-REQ-001 (statement > 15 min), configurable under
SQLInsight:Activity. Shown "right now" on the instance page and in the
time window analysis and PDF; GET /api/v1/instances/{name}/activity and
the analysis JSON leave logins and statements out.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-02 10:36:41 +02:00
e6840e40d7 SQL Server error log without elevated rights (8a)
A five-minute collector reads important error log entries from the best
source the agent's rights allow: the error log itself (xp_readerrorlog,
only with securityadmin), the default trace's ErrorLog and Audit Login
Failed events (optional ALTER TRACE), or the exception and connectivity
ring buffers (VIEW SERVER STATE: numbers and severity, failed logins).
Repetitions are summed per error; the watermark is the newest entry
read, because the default trace is buffered. Stored in
insight.ErrorLogEvents (migration 0013, EventDays), resumed after restart.

Rules SQLI-LOG-001..006: corruption 823/824/825, I/O over 15 s (833),
severe errors (17+), login failure bursts (configurable), stack dumps and
AG errors; rules needing the log text are "not measurable" with the
right to grant. Page /instanser/{navn}/fejllog (menu Overvågning) and
GET /api/v1/instances/{name}/error-log with messages redacted.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-02 10:30:41 +02:00
67 changed files with 3210 additions and 31 deletions

View file

@ -14,7 +14,7 @@
<Product>SQLInsight Pro</Product> <Product>SQLInsight Pro</Product>
<Copyright>Copyright (c) SEOSoft</Copyright> <Copyright>Copyright (c) SEOSoft</Copyright>
<!-- Release version; set with -p:Version=… when publishing. Update checks compare against it. --> <!-- Release version; set with -p:Version=… when publishing. Update checks compare against it. -->
<Version>1.1.0</Version> <Version>1.2.0</Version>
</PropertyGroup> </PropertyGroup>
<PropertyGroup Condition="$(MSBuildProjectName.EndsWith('Tests'))"> <PropertyGroup Condition="$(MSBuildProjectName.EndsWith('Tests'))">

View file

@ -4,7 +4,7 @@
SQLInsight Pro er et overvågningsværktøj til Microsoft SQL Server, skrevet i C# på .NET. Det kører hos kunden og forlader ikke netværket, medmindre kunden selv vælger det. Værktøjet nøjes ikke med at vise tal. Det forklarer, hvad der er galt, hvorfor det er et problem, og hvordan det løses. Rådene har samme niveau som dem, man får fra en erfaren DBA. SQLInsight Pro er et overvågningsværktøj til Microsoft SQL Server, skrevet i C# på .NET. Det kører hos kunden og forlader ikke netværket, medmindre kunden selv vælger det. Værktøjet nøjes ikke med at vise tal. Det forklarer, hvad der er galt, hvorfor det er et problem, og hvordan det løses. Rådene har samme niveau som dem, man får fra en erfaren DBA.
> **Status:** Fase 1–4 er implementeret: agent, 66 indbyggede regler plus community-regler, notifikationer, lokalt repository og web-UI, licenser, signerede opdateringer, SQLInsight Hub med portal, erfaring på tværs af kunder, PDF-rapporter og integration med Prometheus, Grafana og SCOM, plus MSI-installer, community-websted og Discourse-forum. Se [installation](docs/installation.md), [opdateringer](docs/opdatering.md), [integration](docs/integration.md), [rapporter](docs/rapporter.md), [egne regler](docs/regler.md), [databehandling](docs/databehandling.md), [community](docs/community.md), [Roadmap](#roadmap) og [CONTRIBUTING.md](CONTRIBUTING.md). > **Status:** Fase 1–4 er implementeret: agent, 82 indbyggede regler plus community-regler, notifikationer, lokalt repository og web-UI, licenser, signerede opdateringer, SQLInsight Hub med portal, erfaring på tværs af kunder, PDF-rapporter og integration med Prometheus, Grafana og SCOM, plus MSI-installer, community-websted og Discourse-forum. Se [installation](docs/installation.md), [opdateringer](docs/opdatering.md), [integration](docs/integration.md), [rapporter](docs/rapporter.md), [egne regler](docs/regler.md), [databehandling](docs/databehandling.md), [community](docs/community.md), [Roadmap](#roadmap) og [CONTRIBUTING.md](CONTRIBUTING.md).
--- ---

View file

@ -107,7 +107,8 @@ EXEC (@Sql);
/* /*
Valgfrit: hvem og hvornår ved ændringer af serverroller. Valgfrit: hvem og hvornår ved ændringer af serverroller.
Rettighedsrevisionen (sysadmin m.m.) virker uden. Med ALTER TRACE kan agenten desuden læse default trace og vise, Rettighedsrevisionen (sysadmin m.m.) og fejlloggen virker uden. Med ALTER TRACE kan agenten desuden læse default trace og vise
fejltekster og mislykkede logins fra fejlloggen (docs/fejllog.md), og
HVEM der gav en ny rettighed og HVORNÅR. ALTER TRACE giver også adgang til at oprette traces, så det er et bevidst HVEM der gav en ny rettighed og HVORNÅR. ALTER TRACE giver også adgang til at oprette traces, så det er et bevidst
valg; fjern kommentaren for at give den: valg; fjern kommentaren for at give den:

View file

@ -105,3 +105,39 @@ Agenten husker varigheden af de seneste 10 kørsler af samme kommando på samme
Kolonnen **Aktivitet** under tungeste forespørgsler er sætningens CPU-tid som andel af tidsrummet: 100 % betyder, at den har holdt én CPU-kerne beskæftiget hele tiden (over 100 % er parallelt). Kolonnen **Aktivitet** under tungeste forespørgsler er sætningens CPU-tid som andel af tidsrummet: 100 % betyder, at den har holdt én CPU-kerne beskæftiget hele tiden (over 100 % er parallelt).
Reglen **SQLI-CPU-002** (Middel) ser på sætninger, der har ligget i plan cache i mindst en time og stadig kører: er deres samlede CPU-tid over 50 % af tiden i cache, bruger de i praksis en CPU-kerne konstant. Grænsen sættes med `SQLInsight:TopSql:ActivityThresholdPercent` (standard 50). SQLInsight tømmer aldrig plan cache eller ændrer noget; fundet peger kun på sætningen. Reglen **SQLI-CPU-002** (Middel) ser på sætninger, der har ligget i plan cache i mindst en time og stadig kører: er deres samlede CPU-tid over 50 % af tiden i cache, bruger de i praksis en CPU-kerne konstant. Grænsen sættes med `SQLInsight:TopSql:ActivityThresholdPercent` (standard 50). SQLInsight tømmer aldrig plan cache eller ændrer noget; fundet peger kun på sætningen.
## Åbne transaktioner og lange sætninger
Hvert minut ser agenten på brugertransaktioner (`sys.dm_tran_*`) og kørende sætninger (`sys.dm_exec_requests`). Agentens egne sessioner og systemsessioner tages ikke med.
- **Lige nu** på instanssiden viser åbne transaktioner (højst 100: dem med mest log først, ellers de ældste; alder, database, log brugt og reserveret, session, ventetype og sætning) og sætninger, der har kørt over grænsen (højst 50, de længste).
- **Lange og store transaktioner** i analysen af et tidsrum (og PDF'en) viser transaktioner, der var åbne mindst et minut eller holdt over 100 MB log. De gemmes i `RawDays`.
- Login, vært, program og sætning er kundens data: de vises lokalt og er `[udeladt]` i API'et (`GET /api/v1/instances/{navn}/activity` og analysen).
| Regel | Alvor | Hvornår |
|---|---|---|
| SQLI-TRAN-001 | Middel, Høj ved 6 × grænsen | En transaktion har været åben over `SQLInsight:Activity:TransactionMinutes` (standard 10 minutter) |
| SQLI-TRAN-002 | Middel | En transaktion holder over `TransactionLogMegabytes` (standard 1.024 MB) log |
| SQLI-REQ-001 | Middel | En brugersætning har kørt over `RequestMinutes` (standard 15 minutter). BACKUP, RESTORE og DBCC (langvarige operationer) og sætninger, der venter med vilje (WAITFOR, Service Broker), tælles ikke |
## tempdb og version store
Hvert 5. minut læser agenten tempdb's plads (`tempdb.sys.dm_db_file_space_usage`: fri plads, version store, brugerobjekter og interne objekter), om datafilerne kan vokse, tælleren `Version Store Size (KB)`, de sessioner der bruger mest tempdb (`sys.dm_db_session_space_usage` og `sys.dm_db_task_space_usage`), den ældste snapshot-transaktion (`sys.dm_tran_active_snapshot_database_transactions`) og – kun når version store er over halvdelen af grænsen, fordi det kræver en gennemgang af version store – hvilke databaser der laver versionerne (`sys.dm_tran_top_version_generators`, højst én gang i timen).
- **tempdb lige nu** på instanssiden, og graferne "tempdb i brug" og "Version store" med forklaring og vurdering (også i analysen af et tidsrum).
- API: `GET /api/v1/instances/{navn}/tempdb` (login og program er `[udeladt]`).
| Regel | Alvor | Hvornår |
|---|---|---|
| SQLI-TEMP-001 | Middel | Version store over `SQLInsight:Tempdb:VersionStoreMegabytes` (standard 1.024 MB), eller vokset i hver af de seneste 6 målinger med mindst 256 MB i alt |
| SQLI-TEMP-002 | Høj | Under 10 % fri plads, og ingen datafil kan vokse |
| SQLI-TEMP-003 | Middel | En session har brugt over `SessionMegabytes` (standard 1.024 MB) tempdb i mindst 15 minutter |
## Indexbrug over tid
SQL Server nulstiller sine tal for indexbrug (`sys.dm_db_index_usage_stats`) ved hver genstart. SQLInsight lægger derfor brugen mellem målingerne sammen hen over genstarter (og agentens egne genstarter) og gemmer læsninger og skrivninger pr. index pr. dag i `insight.IndexUsageDaily` (i `HourlyDays`, standard 400 dage).
- **SQLI-IDX-002** (ubrugt index) vurderer et index, når det har været fulgt i mindst 7 dage – også hvis SQL Server er genstartet for nylig – og først når det ikke er læst i hele perioden.
- API: `GET /api/v1/instances/{navn}/index-usage?days=30` giver læsninger, skrivninger og antal dage med læsninger pr. index, mindst læste først.
**SQLI-STAT-001** melder nu også statistikker, der **aldrig** er opdateret (`STATS_DATE` er tom) på tabeller med rækker, fx statistikker oprettet med NORECOMPUTE på en tom tabel. De står øverst blandt fundene.

30
docs/fejllog.md Normal file
View file

@ -0,0 +1,30 @@
# SQL Servers fejllog
SQLInsight Pro læser vigtige hændelser fra SQL Servers fejllog hvert 5. minut og samler gentagelser: samme fejl flere gange bliver én linje med antal, første og seneste tidspunkt og et eksempel. Siden **Fejllog** (menuen Overvågning, eller knappen på instansens side) viser hændelserne for 1–90 dage, og de findes i API'et: `GET /api/v1/instances/{navn}/error-log?days=7` (eksemplet er `[udeladt]` uden `ApiIncludesSensitiveEvidence`).
## Hvad ses, med hvilke rettigheder
Agenten har ikke adgang til selve logfilen. Den bruger den bedste kilde, rettighederne giver:
| Kilde | Rettighed | Hvad ses |
|---|---|---|
| Fejlloggen (`xp_readerrorlog`) | `securityadmin` (gives **ikke** som standard) | Alt, også langsom I/O (833), læsegentagelser (825) og stack dumps |
| Default trace (hændelserne ErrorLog og Audit Login Failed) | `ALTER TRACE` (valgfri i `grant-least-privilege.sql`) | Fejl med severity 20 og derover med tekst, og mislykkede logins med login og klientadresse |
| Ring buffers (`sys.dm_os_ring_buffers`) | `VIEW SERVER STATE` (har agenten altid) | Fejlnumre og severity fra motorens exceptions (fx 823/824, 701) og mislykkede logins (2008+) – uden tekst |
Kan en regel ikke vurderes med den kilde, agenten har, er den "ikke målbar" med årsagen: 833 og stack dumps kræver selve fejlloggen.
## Regler
| Regel | Alvor | Hvornår |
|---|---|---|
| SQLI-LOG-001 | Kritisk (823/824), Høj (825) | Beskadigede sider eller læsninger, der måtte gentages |
| SQLI-LOG-002 | Høj | I/O over 15 sekunder (833) |
| SQLI-LOG-003 | Middel (severity 17–19), Høj (20+) | Andre alvorlige fejl |
| SQLI-LOG-004 | Middel | Mindst 20 mislykkede logins inden for en måling (5 minutter); `SQLInsight:ErrorLog:LoginFailureBurst` |
| SQLI-LOG-005 | Høj | Stack dumps |
| SQLI-LOG-006 | Høj | Fejl i Always On availability groups |
Fund er åbne i et døgn efter seneste forekomst. Fejlmeddelelser kan indeholde loginnavne og klientadresser: eksemplet er en følsom måling, som ikke sendes til SQLInsight Hub. Hændelserne gemmes lokalt i `EventDays` (standard 90 dage).
SQL Server skriver default trace i buffere, så en hændelse kan først blive læsbar et par minutter efter; agenten fortsætter fra den nyeste hændelse, den har læst, så intet springes over.

View file

@ -73,13 +73,18 @@ Alt, hvad webbrugerfladen viser, kan hentes som JSON under `/api/v1/` med de sam
| `GET /api/v1/reports/driftsrapport?days=30&format=json\|pdf` | Driftsrapporten: nøgletal, sammenfatning, kontroller pr. instans, instanser, aktive fund, konfigurationsændringer | | `GET /api/v1/reports/driftsrapport?days=30&format=json\|pdf` | Driftsrapporten: nøgletal, sammenfatning, kontroller pr. instans, instanser, aktive fund, konfigurationsændringer |
| `GET /api/v1/reports/analyse?instance=…&from=…&to=…&sort=cpu&format=json\|pdf` | Analyse af et tidsrum (som siden og PDF'en) | | `GET /api/v1/reports/analyse?instance=…&from=…&to=…&sort=cpu&format=json\|pdf` | Analyse af et tidsrum (som siden og PDF'en) |
| `GET /api/v1/reports/rettigheder?days=90&instance=…&format=json\|pdf` | Rettighedsrevisionen for én eller alle instanser | | `GET /api/v1/reports/rettigheder?days=90&instance=…&format=json\|pdf` | Rettighedsrevisionen for én eller alle instanser |
| `GET /api/v1/instances/{navn}/analysis?from=&to=&sort=` | Analyse af tidsrum: målinger med vurdering, ventetider, tungeste sætninger med aktivitet, diske, blocking, deadlocks, langvarige operationer, fund | | `GET /api/v1/instances/{navn}/analysis?from=&to=&sort=` | Analyse af tidsrum: målinger med vurdering, ventetider, tungeste sætninger med aktivitet, diske, blocking, deadlocks, langvarige operationer, lange og store transaktioner, fund |
| `GET /api/v1/instances/{navn}/top-sql?from=&to=&sort=cpu\|varighed\|laesninger\|koersler&limit=` | Tungeste sætninger i et tidsrum (standard seneste døgn) | | `GET /api/v1/instances/{navn}/top-sql?from=&to=&sort=cpu\|varighed\|laesninger\|koersler&limit=` | Tungeste sætninger i et tidsrum (standard seneste døgn) |
| `GET /api/v1/instances/{navn}/disks?from=&to=` | Diskhastighed pr. drev med vurdering og tidsserier | | `GET /api/v1/instances/{navn}/disks?from=&to=` | Diskhastighed pr. drev med vurdering og tidsserier |
| `GET /api/v1/instances/{navn}/configuration?days=90` | Opsætning, tjenester, tempdb, sp_configure, trace flags og ændringslog | | `GET /api/v1/instances/{navn}/configuration?days=90` | Opsætning, tjenester, tempdb, sp_configure, trace flags og ændringslog |
| `GET /api/v1/instances/{navn}/privileges?days=90` | Rettigheder med udfoldede Windows-grupper, sa, adgangskodepolitik, default trace og ændringer | | `GET /api/v1/instances/{navn}/privileges?days=90` | Rettigheder med udfoldede Windows-grupper, sa, adgangskodepolitik, default trace og ændringer |
| `GET /api/v1/privileges?days=90` | Det samme for alle instanser | | `GET /api/v1/privileges?days=90` | Det samme for alle instanser |
| `GET /api/v1/instances/{navn}/wait-types/{ventetype}?from=&to=` | Forklaring, udvikling og de sætninger, der ventede | | `GET /api/v1/instances/{navn}/wait-types/{ventetype}?from=&to=` | Forklaring, udvikling og de sætninger, der ventede |
| `GET /api/v1/instances/{navn}/activity` | Åbne brugertransaktioner (alder, log, database) og sætninger, der har kørt over grænsen, lige nu. Login, vært, program og sætning er `[udeladt]` |
| `GET /api/v1/instances/{navn}/tempdb` | tempdb's størrelse og fri plads, version store med udvikling og de databaser, der laver versioner, ældste snapshot-transaktion og største forbrugere |
| `GET /api/v1/instances/{navn}/error-log?days=7` | Vigtige hændelser i fejlloggen (1–90 dage), kilde og dækning. Eksemplet er `[udeladt]`; se [fejllog.md](fejllog.md) |
| `GET /api/v1/instances/{navn}/replication` | Replikering set fra distributøren: hver distributionsagents status, seneste kørsel og forsinkelse, og fejl det seneste døgn |
| `GET /api/v1/instances/{navn}/index-usage?days=30` | Læsninger og skrivninger pr. index summeret over dagene (1–400), på tværs af genstarter, mindst læste først |
| `POST /api/v1/support-cases`, `GET /api/v1/support-cases[/{id}]`, `POST /api/v1/support-cases/{id}/close` | Supportsager: se [support.md](support.md) (kræver scope `support:write` for at skrive) | | `POST /api/v1/support-cases`, `GET /api/v1/support-cases[/{id}]`, `POST /api/v1/support-cases/{id}/close` | Supportsager: se [support.md](support.md) (kræver scope `support:write` for at skrive) |
| `GET /metrics` | Prometheus-format | | `GET /metrics` | Prometheus-format |

View file

@ -19,3 +19,32 @@ Lock Pages in Memory og Instant File Initialization kan kun aflæses fra SQL Ser
Agenten læser konfigurationen hvert 15. minut (collector `instance-config`) og sammenligner med sidste gang, også på tværs af en genstart af agenten. Ændringer af sp_configure, trace flags og tjenestekonti giver fundet **SQLI-CFG-013 Konfiguration ændret** (information, i et døgn), og alle ændringer, også version og antal tempdb-filer, står i ændringsloggen og i driftsrapporten. Den første måling efter installationen er udgangspunktet. Agenten læser konfigurationen hvert 15. minut (collector `instance-config`) og sammenligner med sidste gang, også på tværs af en genstart af agenten. Ændringer af sp_configure, trace flags og tjenestekonti giver fundet **SQLI-CFG-013 Konfiguration ændret** (information, i et døgn), og alle ændringer, også version og antal tempdb-filer, står i ændringsloggen og i driftsrapporten. Den første måling efter installationen er udgangspunktet.
Tidspunktet er, hvornår ændringen blev opdaget, ikke hvornår den blev lavet. Ændringsloggen gemmes lige så længe som løste fund (`SQLInsight:Retention:ResolvedFindingDays`, standard 365 dage). Tidspunktet er, hvornår ændringen blev opdaget, ikke hvornår den blev lavet. Ændringsloggen gemmes lige så længe som løste fund (`SQLInsight:Retention:ResolvedFindingDays`, standard 365 dage).
## Tjenester, der ikke kører
Reglen **SQLI-SVC-001** (Høj) melder, når en SQL Server-tjeneste, der er sat til at starte automatisk, ikke kører: typisk SQL Server Agent (så kører hverken backup eller vedligeholdelse), Full-text Filter Daemon Launcher eller Launchpad. Tjenesterne læses fra `sys.dm_server_services` sammen med konfigurationen hvert 15. minut og kræver SQL Server 2008 R2 SP1 eller nyere; på ældre versioner er reglen ikke relevant.
## Replikering
Er instansen distributør for replikering (har en distributionsdatabase), læser agenten hvert 15. minut hver distributionsagents seneste kørsel (status, forsinkelse, meddelelse) og antallet af replikeringsfejl det seneste døgn, og viser dem under **Replikering** på instansens side og i `GET /api/v1/instances/{navn}/replication`.
Det kræver læseadgang til tre tabeller i distributionsdatabasen, som agenten **ikke** har som standard (de replikerede data i `MSrepl_commands` læses ikke):
```sql
USE distribution;
CREATE USER [DOMÆNE\svc-sqlinsight$] FOR LOGIN [DOMÆNE\svc-sqlinsight$];
GRANT SELECT ON dbo.MSdistribution_agents TO [DOMÆNE\svc-sqlinsight$];
GRANT SELECT ON dbo.MSdistribution_history TO [DOMÆNE\svc-sqlinsight$];
GRANT SELECT ON dbo.MSrepl_errors TO [DOMÆNE\svc-sqlinsight$];
```
Uden den er reglerne "ikke målbar" med denne vejledning. På andre instanser end distributøren er de "ikke relevant".
| Regel | Alvor | Hvornår |
|---|---|---|
| SQLI-REPL-001 | Høj | En distributionsagent fejlede eller prøver igen |
| SQLI-REPL-002 | Middel | Ændringer er over 5 minutter om at nå abonnenten |
## Availability groups og clusteret
Ud over replikaernes og databasernes tilstand (SQLI-AG-001/002) læser agenten Windows-clusterets noder og vidne (`sys.dm_hadr_cluster_members`). **SQLI-AG-003** (Høj) melder, når en node eller vidnet ikke er oppe: clusteret har så færre stemmer, og mistes en til, går quorum og alle availability groups offline. Uden et Windows-cluster (CLUSTER_TYPE NONE eller EXTERNAL) er listen tom.

View file

@ -126,18 +126,18 @@ Kører web i IIS, angives `"AppOffline": true` i stedet for `Service`. Så lægg
dotnet run --project tools/SQLInsight.LicenseTool -- rulepack --version 2026.10.14 --out rulepack-2026.10.14.json dotnet run --project tools/SQLInsight.LicenseTool -- rulepack --version 2026.10.14 --out rulepack-2026.10.14.json
# MSI til installationer lavet med MSI'en (bygges på Linux) # MSI til installationer lavet med MSI'en (bygges på Linux)
installer/build-msi.sh 1.1.0 installer/build-msi.sh 1.2.0
# Programpakker: én zip pr. platform med mapperne Agent/ og Web/ # Programpakker: én zip pr. platform med mapperne Agent/ og Web/
# Samme opsætning som MSI'en (indbygget .NET), så en opdatering ikke kræver en runtime på serveren # Samme opsætning som MSI'en (indbygget .NET), så en opdatering ikke kræver en runtime på serveren
dotnet publish src/SQLInsight.Agent -c Release -r win-x64 --self-contained true -p:Version=1.1.0 -o pkg/win-x64/Agent dotnet publish src/SQLInsight.Agent -c Release -r win-x64 --self-contained true -p:Version=1.2.0 -o pkg/win-x64/Agent
dotnet publish src/SQLInsight.Web -c Release -r win-x64 --self-contained true -p:Version=1.1.0 -o pkg/win-x64/Web dotnet publish src/SQLInsight.Web -c Release -r win-x64 --self-contained true -p:Version=1.2.0 -o pkg/win-x64/Web
# Signér kanalens manifest (det, der ikke angives, overtages fra det eksisterende manifest) # Signér kanalens manifest (det, der ikke angives, overtages fra det eksisterende manifest)
dotnet run --project tools/SQLInsight.LicenseTool -- publish --key sqlinsight-k20260929.private.pem --kid k20260929 \ dotnet run --project tools/SQLInsight.LicenseTool -- publish --key sqlinsight-k20260929.private.pem --kid k20260929 \
--feed feed --channel stable --rulepack rulepack-2026.10.14.json \ --feed feed --channel stable --rulepack rulepack-2026.10.14.json \
--release 1.1.0 --asset win-x64=sqlinsight-1.1.0-win-x64.zip --asset win-x64-msi=dist/SQLInsight-Pro-1.1.0-x64.msi \ --release 1.2.0 --asset win-x64=sqlinsight-1.2.0-win-x64.zip --asset win-x64-msi=dist/SQLInsight-Pro-1.2.0-x64.msi \
--asset linux-x64=sqlinsight-1.1.0-linux-x64.zip \ --asset linux-x64=sqlinsight-1.2.0-linux-x64.zip \
--notes https://git.seosoft.dk/seo/sqlinsight-pro/releases --notes https://git.seosoft.dk/seo/sqlinsight-pro/releases
``` ```

View file

@ -24,6 +24,12 @@ public sealed class AgentOptions
public TopSqlOptions TopSql { 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 IList<MonitoredInstance> Instances { get; } = []; public IList<MonitoredInstance> Instances { get; } = [];
} }
@ -86,3 +92,33 @@ public sealed class TopSqlOptions
/// </summary> /// </summary>
public double ActivityThresholdPercent { get; set; } = 50; 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;
}

View file

@ -246,6 +246,24 @@ public sealed partial class InstanceMonitor(
return await repository.GetSnapshotAsync<Core.Data.LongOperationSnapshot>(instanceId, collector.Name, cancellationToken).ConfigureAwait(false); return await repository.GetSnapshotAsync<Core.Data.LongOperationSnapshot>(instanceId, collector.Name, cancellationToken).ConfigureAwait(false);
} }
// The error log continues where it stopped, so nothing written while the agent was down is missed.
if (collector is Collectors.Instance.ErrorLogCollector)
{
return await repository.GetSnapshotAsync<Core.Data.ErrorLogSnapshot>(instanceId, collector.Name, cancellationToken).ConfigureAwait(false);
}
// The version store trend and heavy sessions continue after a restart.
if (collector is Collectors.Instance.TempdbCollector)
{
return await repository.GetSnapshotAsync<Core.Data.TempdbSnapshot>(instanceId, collector.Name, cancellationToken).ConfigureAwait(false);
}
// Index usage is summed across restarts of SQL Server and of the agent.
if (collector is Collectors.Databases.IndexCollector)
{
return await repository.GetSnapshotAsync<Core.Data.IndexSnapshot>(instanceId, collector.Name, cancellationToken).ConfigureAwait(false);
}
if (collector is not Collectors.Concurrency.DeadlockCollector) if (collector is not Collectors.Concurrency.DeadlockCollector)
{ {
return null; return null;

View file

@ -32,7 +32,9 @@ public sealed partial class MonitoringService(
var intervals = new Dictionary<string, TimeSpan>(settings.CollectorIntervals, StringComparer.OrdinalIgnoreCase); var intervals = new Dictionary<string, TimeSpan>(settings.CollectorIntervals, StringComparer.OrdinalIgnoreCase);
var monitors = settings.Instances var monitors = settings.Instances
.Select(i => new InstanceMonitor(i, BuiltInCollectors.Create(customRules, new CollectorSettings(groupResolver, settings.Privileges.MaxSysadminAccounts, settings.TopSql.ActivityThresholdPercent)), intervals, connectionFactory, ruleEngine, repository, .Select(i => new InstanceMonitor(i, BuiltInCollectors.Create(customRules, new CollectorSettings(groupResolver, settings.Privileges.MaxSysadminAccounts, settings.TopSql.ActivityThresholdPercent, settings.ErrorLog.LoginFailureBurst,
settings.Activity.TransactionMinutes, settings.Activity.TransactionLogMegabytes, settings.Activity.RequestMinutes,
settings.Tempdb.VersionStoreMegabytes, settings.Tempdb.SessionMegabytes)), intervals, connectionFactory, ruleEngine, repository,
loggerFactory.CreateLogger($"SQLInsight.Instance.{i.Name}"), notifications, notificationOptions.Value.WebUrl, hubOutbox)) loggerFactory.CreateLogger($"SQLInsight.Instance.{i.Name}"), notifications, notificationOptions.Value.WebUrl, hubOutbox))
.ToList(); .ToList();
if (monitors.Count == 0) if (monitors.Count == 0)

View file

@ -27,9 +27,13 @@ public static class BuiltInCollectors
new QueryStatsCollector(settings.ActivityThresholdPercent), new QueryStatsCollector(settings.ActivityThresholdPercent),
new WaitSamplingCollector(), new WaitSamplingCollector(),
new LongOperationCollector(), new LongOperationCollector(),
new ErrorLogCollector(settings.LoginFailureBurst),
new TempdbCollector(settings.VersionStoreMegabytes, settings.TempdbSessionMegabytes),
new ReplicationCollector(),
new PerformanceCounterCollector(), new PerformanceCounterCollector(),
new CpuCollector(), new CpuCollector(),
new BlockingCollector(), new BlockingCollector(),
new ActivityCollector(settings.TransactionMinutes, settings.TransactionLogMegabytes, settings.RequestMinutes),
new DeadlockCollector(), new DeadlockCollector(),
new QueryStoreCollector(), new QueryStoreCollector(),
new PlanAnalysisCollector(), new PlanAnalysisCollector(),
@ -45,7 +49,19 @@ public static class BuiltInCollectors
/// <param name="Groups">Unfolds Windows groups with server rights to the people in them (Active Directory).</param> /// <param name="Groups">Unfolds Windows groups with server rights to the people in them (Active Directory).</param>
/// <param name="MaxSysadminAccounts">More people and accounts with sysadmin than this is a finding.</param> /// <param name="MaxSysadminAccounts">More people and accounts with sysadmin than this is a finding.</param>
/// <param name="ActivityThresholdPercent">A statement using more CPU time than this share of its lifetime is a finding.</param> /// <param name="ActivityThresholdPercent">A statement using more CPU time than this share of its lifetime is a finding.</param>
/// <param name="LoginFailureBurst">Failed logins within one collection (5 minutes) before it is a finding.</param>
/// <param name="TransactionMinutes">An open transaction older than this is a finding.</param>
/// <param name="TransactionLogMegabytes">A transaction holding more log than this is a finding.</param>
/// <param name="RequestMinutes">A statement running longer than this is a finding.</param>
/// <param name="VersionStoreMegabytes">The version store above this is a finding.</param>
/// <param name="TempdbSessionMegabytes">A session using more tempdb than this for 15 minutes is a finding.</param>
public sealed record CollectorSettings( public sealed record CollectorSettings(
Core.Data.IGroupMemberResolver? Groups = null, Core.Data.IGroupMemberResolver? Groups = null,
int MaxSysadminAccounts = 3, int MaxSysadminAccounts = 3,
double ActivityThresholdPercent = 50); double ActivityThresholdPercent = 50,
int LoginFailureBurst = 20,
int TransactionMinutes = 10,
int TransactionLogMegabytes = 1024,
int RequestMinutes = 15,
int VersionStoreMegabytes = 1024,
int TempdbSessionMegabytes = 1024);

View file

@ -0,0 +1,87 @@
using System.Globalization;
using SQLInsight.Core.Data;
using SQLInsight.Core.Instances;
namespace SQLInsight.Collectors.Concurrency;
/// <summary>
/// Open user transactions (age, transaction log held, session, statement) and user requests that have run longer
/// than the threshold, every minute. The agent's own sessions and system sessions are left out. VIEW SERVER STATE;
/// SQL Server 2005+.
/// </summary>
public sealed class ActivityCollector(int transactionMinutes = 10, int transactionLogMegabytes = 1024, int requestMinutes = 15) : Collector<ActivitySnapshot>
{
private const string NotAgent = "s.is_user_process = 1 AND s.session_id <> @@SPID AND (s.program_name IS NULL OR s.program_name NOT LIKE N'SQLInsight Pro%')";
/// <summary>
/// One row per open transaction of a user session, at most 100: those holding more log than the threshold first, then
/// the oldest. The statement is the running one (the first request of a MARS session), or else the session's last.
/// Times are converted to UTC on the server.
/// </summary>
internal const string TransactionsSql = """
SELECT TOP 100 t.session_id, t.transaction_id, t.name, DATEADD(minute, DATEDIFF(minute, GETDATE(), GETUTCDATE()), t.begin_time),
t.used, t.reserved, t.db, t.dbs, s.login_name, s.host_name, s.program_name, s.status, r.wait_type,
CAST(CASE WHEN r.sql_handle IS NOT NULL
THEN SUBSTRING(q.text, r.statement_start_offset / 2 + 1,
CASE WHEN r.statement_end_offset = -1 THEN 1000 ELSE (r.statement_end_offset - r.statement_start_offset) / 2 + 1 END)
ELSE LEFT(q.text, 1000) END AS nvarchar(1000))
FROM (SELECT st.session_id, at.transaction_id, MAX(at.name) AS name, MIN(at.transaction_begin_time) AS begin_time,
ISNULL(SUM(dt.database_transaction_log_bytes_used), 0) AS used, ISNULL(SUM(dt.database_transaction_log_bytes_reserved), 0) AS reserved,
MIN(DB_NAME(dt.database_id)) AS db, COUNT(DISTINCT dt.database_id) AS dbs
FROM sys.dm_tran_session_transactions st
JOIN sys.dm_tran_active_transactions at ON at.transaction_id = st.transaction_id
LEFT JOIN sys.dm_tran_database_transactions dt ON dt.transaction_id = st.transaction_id AND dt.database_id <> 32767
GROUP BY st.session_id, at.transaction_id) t
JOIN sys.dm_exec_sessions s ON s.session_id = t.session_id
OUTER APPLY (SELECT TOP 1 x.sql_handle, x.statement_start_offset, x.statement_end_offset, x.wait_type FROM sys.dm_exec_requests x
WHERE x.session_id = t.session_id ORDER BY x.request_id) r
OUTER APPLY (SELECT TOP 1 c.most_recent_sql_handle FROM sys.dm_exec_connections c WHERE c.session_id = t.session_id) c
OUTER APPLY sys.dm_exec_sql_text(COALESCE(r.sql_handle, c.most_recent_sql_handle)) q
WHERE {notAgent} AND t.begin_time IS NOT NULL
ORDER BY CASE WHEN t.used + t.reserved >= {logBytes} THEN 0 ELSE 1 END, t.begin_time
""";
internal const string LongRequestsSql = """
SELECT TOP 50 r.session_id, DATEADD(minute, DATEDIFF(minute, GETDATE(), GETUTCDATE()), r.start_time), r.total_elapsed_time, r.command, r.status,
r.wait_type, r.wait_time, DB_NAME(r.database_id), s.login_name, s.host_name, s.program_name, r.blocking_session_id,
CAST(SUBSTRING(q.text, r.statement_start_offset / 2 + 1,
CASE WHEN r.statement_end_offset = -1 THEN 1000 ELSE (r.statement_end_offset - r.statement_start_offset) / 2 + 1 END) AS nvarchar(1000))
FROM sys.dm_exec_requests r
JOIN sys.dm_exec_sessions s ON s.session_id = r.session_id
OUTER APPLY sys.dm_exec_sql_text(r.sql_handle) q
WHERE {notAgent} AND r.total_elapsed_time > {thresholdMs} AND r.percent_complete = 0
ORDER BY r.total_elapsed_time DESC
""";
public override string Name => "activity";
public override TimeSpan DefaultInterval => TimeSpan.FromMinutes(1);
public override IReadOnlyCollection<Capability> RequiredCapabilities => [Capability.DynamicManagementViews];
protected override async Task<ActivitySnapshot> CollectAsync(CollectorContext context, ActivitySnapshot? previous)
{
var ct = context.CancellationToken;
var logBytes = (long)transactionLogMegabytes * 1024 * 1024;
var transactionsSql = TransactionsSql.Replace("{notAgent}", NotAgent, StringComparison.Ordinal)
.Replace("{logBytes}", logBytes.ToString(CultureInfo.InvariantCulture), StringComparison.Ordinal);
var transactions = await Db.QueryAsync(context.Connection, transactionsSql, r => new OpenTransaction(
r.Int(0), r.Long(1), r.NullableString(2), Utc(r.DateTime(3)), r.Long(4), r.Long(5), r.NullableString(6), r.Int(7),
r.NullableString(8), r.NullableString(9), r.NullableString(10), r.NullableString(11), r.NullableString(12), r.NullableString(13)), ct).ConfigureAwait(false);
var thresholdMs = (long)requestMinutes * 60_000;
var sql = LongRequestsSql.Replace("{notAgent}", NotAgent, StringComparison.Ordinal)
.Replace("{thresholdMs}", thresholdMs.ToString(CultureInfo.InvariantCulture), StringComparison.Ordinal);
var requests = await Db.QueryAsync(context.Connection, sql, r => new LongRunningRequest(
r.Int(0), Utc(r.DateTime(1)), r.Long(2), r.String(3).Trim(), r.NullableString(4), r.NullableString(5), r.Long(6), r.NullableString(7),
r.NullableString(8), r.NullableString(9), r.NullableString(10), r.NullableInt(11) ?? 0, r.NullableString(12)), ct).ConfigureAwait(false);
return new ActivitySnapshot
{
CapturedAt = context.Now,
Transactions = transactions,
LongRequests = requests.Where(ActivityAnalysis.IsStatement).ToList(),
TransactionMinutes = transactionMinutes,
TransactionLogMegabytes = transactionLogMegabytes,
RequestMinutes = requestMinutes,
};
}
private static DateTimeOffset Utc(DateTime value) => new(DateTime.SpecifyKind(value, DateTimeKind.Utc));
}

View file

@ -56,7 +56,7 @@ public sealed class IndexCollector : Collector<IndexSnapshot>
}); });
} }
return new IndexSnapshot { CapturedAt = context.Now, SqlServerStartTime = startTime, Databases = databases }; return IndexUsageTracking.Advance(previous, new IndexSnapshot { CapturedAt = context.Now, SqlServerStartTime = startTime, Databases = databases });
} }
private static async Task<IndexDatabase> ReadAsync(DbConnection connection, string database, bool filtered, CancellationToken ct) private static async Task<IndexDatabase> ReadAsync(DbConnection connection, string database, bool filtered, CancellationToken ct)
@ -141,12 +141,14 @@ public sealed class IndexCollector : Collector<IndexSnapshot>
"""; """;
} }
// sys.sysindexes.rowmodctr needs only metadata visibility, unlike sys.dm_db_stats_properties. // sys.sysindexes.rowmodctr needs only metadata visibility, unlike sys.dm_db_stats_properties. Rows are the table's
// (heap or clustered index), so a statistic that was never updated (STATS_DATE NULL) still shows the rows it covers.
internal static string BuildStatisticsSql(string database) internal static string BuildStatisticsSql(string database)
{ {
var db = Db.Identifier(database); var db = Db.Identifier(database);
return $""" return $"""
SELECT s.name, o.name, si.name, STATS_DATE(si.id, si.indid), si.rowcnt, si.rowmodctr SELECT s.name, o.name, si.name, STATS_DATE(si.id, si.indid),
COALESCE((SELECT TOP 1 t.rowcnt FROM {db}.sys.sysindexes t WHERE t.id = si.id AND t.indid IN (0, 1)), si.rowcnt), si.rowmodctr
FROM {db}.sys.sysindexes si FROM {db}.sys.sysindexes si
JOIN {db}.sys.objects o ON o.object_id = si.id AND o.type = 'U' AND o.is_ms_shipped = 0 JOIN {db}.sys.objects o ON o.object_id = si.id AND o.type = 'U' AND o.is_ms_shipped = 0
JOIN {db}.sys.schemas s ON s.schema_id = o.schema_id JOIN {db}.sys.schemas s ON s.schema_id = o.schema_id

View file

@ -40,6 +40,16 @@ internal static class Db
/// <summary>A [bracketed] identifier with brackets escaped.</summary> /// <summary>A [bracketed] identifier with brackets escaped.</summary>
public static string Identifier(string name) => "[" + name.Replace("]", "]]", StringComparison.Ordinal) + "]"; public static string Identifier(string name) => "[" + name.Replace("]", "]]", StringComparison.Ordinal) + "]";
/// <summary>Runs a statement without results (USE …).</summary>
public static async Task ExecuteAsync(DbConnection connection, string sql, CancellationToken cancellationToken)
{
ArgumentNullException.ThrowIfNull(connection);
await using var command = connection.CreateCommand();
command.CommandText = sql;
command.CommandTimeout = CommandTimeoutSeconds;
await command.ExecuteNonQueryAsync(cancellationToken).ConfigureAwait(false);
}
/// <summary> /// <summary>
/// The SQL expression for when the instance started. Cumulative DMVs reset at that moment. /// The SQL expression for when the instance started. Cumulative DMVs reset at that moment.
/// </summary> /// </summary>

View file

@ -24,6 +24,11 @@ public sealed class AvailabilityGroupCollector : Collector<AvailabilityGroupSnap
LEFT JOIN sys.dm_hadr_availability_replica_states ars ON ars.replica_id = drs.replica_id LEFT JOIN sys.dm_hadr_availability_replica_states ars ON ars.replica_id = drs.replica_id
"""; """;
// Without a Windows Server Failover Cluster (CLUSTER_TYPE = NONE/EXTERNAL) the view is empty or unavailable.
internal const string ClusterMembersSql = """
SELECT member_name, member_type_desc, member_state, member_state_desc, number_of_quorum_votes FROM sys.dm_hadr_cluster_members
""";
public override string Name => "availability-groups"; public override string Name => "availability-groups";
public override TimeSpan DefaultInterval => TimeSpan.FromMinutes(1); public override TimeSpan DefaultInterval => TimeSpan.FromMinutes(1);
public override IReadOnlyCollection<Capability> RequiredCapabilities => [Capability.HadrEnabled]; public override IReadOnlyCollection<Capability> RequiredCapabilities => [Capability.HadrEnabled];
@ -61,6 +66,17 @@ public sealed class AvailabilityGroupCollector : Collector<AvailabilityGroupSnap
LastCommitTime = r.NullableDateTime(13), LastCommitTime = r.NullableDateTime(13),
}, ct).ConfigureAwait(false); }, ct).ConfigureAwait(false);
return new AvailabilityGroupSnapshot { CapturedAt = context.Now, Replicas = replicas, Databases = databases }; IReadOnlyList<ClusterMember> members;
try
{
members = await Db.QueryAsync(context.Connection, ClusterMembersSql, r => new ClusterMember(
r.String(0), r.NullableString(1) ?? "?", r.NullableInt(2) ?? 0, r.NullableString(3) ?? "?", r.NullableInt(4)), ct).ConfigureAwait(false);
}
catch (System.Data.Common.DbException)
{
members = [];
}
return new AvailabilityGroupSnapshot { CapturedAt = context.Now, Replicas = replicas, Databases = databases, ClusterMembers = members };
} }
} }

View file

@ -0,0 +1,186 @@
using System.Data.Common;
using System.Globalization;
using SQLInsight.Core.Data;
using SQLInsight.Core.Instances;
namespace SQLInsight.Collectors.Instance;
/// <summary>
/// The SQL Server error log since the previous collection, from the best source the agent's rights allow:
/// the error log itself (securityadmin), the default trace's ErrorLog and Audit Login Failed events (ALTER TRACE, an
/// optional grant), or the exception ring buffer (VIEW SERVER STATE: error numbers and severity, no text).
/// Repeated errors are summed. Read only; SQL Server 2005+.
/// </summary>
public sealed class ErrorLogCollector(int loginFailureBurst = 20) : Collector<ErrorLogSnapshot>
{
/// <summary>On the first collection, this far back.</summary>
public static readonly TimeSpan FirstLookBack = TimeSpan.FromHours(1);
/// <summary>How much the ring buffer's computed times can move between two reads.</summary>
internal static readonly TimeSpan RingBufferClockTolerance = TimeSpan.FromMilliseconds(50);
internal const string CanReadErrorLogSql = """
SELECT CASE WHEN IS_SRVROLEMEMBER(N'securityadmin') = 1 OR IS_SRVROLEMEMBER(N'sysadmin') = 1 THEN 1 ELSE 0 END,
DATEDIFF(minute, GETUTCDATE(), GETDATE()), GETUTCDATE()
""";
// The exception ring buffer: every exception raised, with number and severity. ms_ticks is the clock it counts in.
// The time is computed to the millisecond: rounded to whole seconds it moved by up to a second between collections,
// so the newest entry could be read (and counted) again.
internal const string RingBufferSql = """
SELECT DATEADD(millisecond, -CAST((i.ms_ticks - rb.timestamp) % 1000 AS int), DATEADD(second, -CAST((i.ms_ticks - rb.timestamp) / 1000 AS int), GETUTCDATE())),
rb.x.value('(//Exception/Error)[1]', 'int'), rb.x.value('(//Exception/Severity)[1]', 'int')
FROM (SELECT timestamp, CAST(record AS xml) AS x FROM sys.dm_os_ring_buffers WHERE ring_buffer_type = N'RING_BUFFER_EXCEPTION') rb
CROSS JOIN sys.dm_os_sys_info i
""";
// Failed logins in the connectivity ring buffer (SQL Server 2008+): error 18456 with its state and client address.
internal const string ConnectivitySql = """
SELECT DATEADD(millisecond, -CAST((i.ms_ticks - rb.timestamp) % 1000 AS int), DATEADD(second, -CAST((i.ms_ticks - rb.timestamp) / 1000 AS int), GETUTCDATE())),
rb.x.value('(//SniConsumerError)[1]', 'int'), rb.x.value('(//State)[1]', 'int'), rb.x.value('(//RemoteHost)[1]', 'nvarchar(128)')
FROM (SELECT timestamp, CAST(record AS xml) AS x FROM sys.dm_os_ring_buffers
WHERE ring_buffer_type = N'RING_BUFFER_CONNECTIVITY' AND record LIKE N'%<SniConsumerError>18456</SniConsumerError>%') rb
CROSS JOIN sys.dm_os_sys_info i
""";
public const string DefaultTraceCoverage =
"Fejl med severity 20 og derover og mislykkede logins fra SQL Servers default trace. Langsom I/O (833), læsegentagelser (825) og stack dumps "
+ "står kun i selve fejlloggen, som kræver rollen securityadmin (xp_readerrorlog); SQLInsight giver ikke agenten den som standard.";
public const string RingBufferCoverage =
"Kun fejlnumre og alvor (uden tekst) fra SQL Servers exception ring buffer og mislykkede logins fra connectivity ring buffer; langsom I/O (833), "
+ "læsegentagelser (825) og stack dumps ses ikke. "
+ "Giv agenten ALTER TRACE (valgfrit i grant-least-privilege.sql) for fejltekster og alvorlige fejl via default trace.";
public override string Name => "error-log";
public override TimeSpan DefaultInterval => TimeSpan.FromMinutes(5);
public override IReadOnlyCollection<Capability> RequiredCapabilities => [Capability.DynamicManagementViews];
protected override async Task<ErrorLogSnapshot> CollectAsync(CollectorContext context, ErrorLogSnapshot? previous)
{
var ct = context.CancellationToken;
var connection = context.Connection;
var (errorLog, offsetMinutes, serverUtc) = (await Db.QueryAsync(connection, CanReadErrorLogSql, r => (r.Int(0) == 1, r.Int(1), r.DateTime(2)), ct)
.ConfigureAwait(false))[0];
var now = new DateTimeOffset(DateTime.SpecifyKind(serverUtc, DateTimeKind.Utc));
var since = previous?.ReadUntil ?? now - FirstLookBack;
var offset = TimeSpan.FromMinutes(offsetMinutes);
List<ErrorLogEntry> entries;
ErrorLogSource source;
string? coverage = null;
string? traceFile = null;
if (errorLog && await ReadErrorLogAsync(connection, since, now, offset, ct).ConfigureAwait(false) is { } fromLog)
{
(entries, source) = (fromLog, ErrorLogSource.ErrorLog);
}
else if (await ReadDefaultTraceAsync(connection, since, offset, previous?.TraceFile, ct).ConfigureAwait(false) is { } fromTrace)
{
(entries, source, coverage, traceFile) = (fromTrace.Entries, ErrorLogSource.DefaultTrace, DefaultTraceCoverage, fromTrace.File);
}
else
{
// The ring buffer's times are derived from the server's clock at each read and can differ by a few
// milliseconds between reads (datetime precision), so the newest entry read last time must not be read again.
var after = since + RingBufferClockTolerance;
entries = (await Db.QueryAsync(connection, RingBufferSql, r => new ErrorLogEntry(Utc(r.DateTime(0)), r.NullableInt(1), r.NullableInt(2), null), ct)
.ConfigureAwait(false)).Where(e => e.At > after).ToList();
if (context.Instance.Version.Major >= 10)
{
entries.AddRange((await Db.QueryAsync(connection, ConnectivitySql, r => new ErrorLogEntry(Utc(r.DateTime(0)), 18456, 14,
$"Login failed (state {r.NullableInt(2)}) [CLIENT: {r.NullableString(3)}]"), ct).ConfigureAwait(false)).Where(e => e.At > after));
}
(source, coverage) = (ErrorLogSource.RingBuffer, RingBufferCoverage);
}
var groups = ErrorLogAnalysis.Group(entries);
return new ErrorLogSnapshot
{
CapturedAt = context.Now,
Source = source,
// The newest entry read, not the clock: the default trace is written in buffers, and entries can become readable after later collections.
ReadUntil = entries.Count == 0 ? since : entries.Max(e => e.At),
Groups = groups,
Recent = ErrorLogAnalysis.Recent(previous?.Recent ?? [], groups, context.Now),
Coverage = coverage,
TraceFile = traceFile,
LoginFailureBurst = loginFailureBurst,
};
}
/// <summary>The current error log (xp_readerrorlog), or null when it cannot be read.</summary>
private static async Task<List<ErrorLogEntry>?> ReadErrorLogAsync(DbConnection connection, DateTimeOffset since, DateTimeOffset until, TimeSpan offset,
CancellationToken cancellationToken)
{
try
{
var from = Local(since, offset);
var to = Local(until.AddMinutes(1), offset);
var lines = await Db.QueryAsync(connection, $"EXEC master.dbo.xp_readerrorlog 0, 1, NULL, NULL, '{from}', '{to}'",
r => (Utc(r.DateTime(0)) - offset, r.NullableString(2) ?? string.Empty), cancellationToken).ConfigureAwait(false);
return [.. ErrorLogAnalysis.ParseLines(lines.Where(l => l.Item1 > since).Select(l => (l.Item1, l.Item2)))];
}
catch (DbException)
{
return null;
}
}
/// <summary>ErrorLog (22) and Audit Login Failed (20) events from the default trace, or null without ALTER TRACE.</summary>
private static async Task<(List<ErrorLogEntry> Entries, string File)?> ReadDefaultTraceAsync(DbConnection connection, DateTimeOffset since, TimeSpan offset,
string? previousFile, CancellationToken cancellationToken)
{
try
{
var path = (await Db.QueryAsync(connection, PrivilegeCollector.DefaultTracePathSql, r => r.NullableString(0), cancellationToken).ConfigureAwait(false))
.FirstOrDefault();
if (path is null)
{
return null;
}
// All rollover files (up to 100 MB) every 5 minutes is not cheap. While the current file is the one read last
// time, everything since then is in it; after a rollover (and the first time) all files are read once.
var files = string.Equals(path, previousFile, StringComparison.OrdinalIgnoreCase)
? Db.Literal(path) + ", 1"
: Db.Literal(PrivilegeAnalysis.TraceFileBase(path)) + ", DEFAULT";
var sql = $"""
SELECT StartTime, EventClass, Error, Severity, CAST(TextData AS nvarchar(2000))
FROM sys.fn_trace_gettable({files})
WHERE EventClass IN (20, 22) AND StartTime > '{Local(since, offset)}'
""";
var rows = await Db.QueryAsync(connection, sql, r => (At: Utc(r.DateTime(0)) - offset, Class: r.Int(1), Error: r.NullableInt(2), Severity: r.NullableInt(3),
Text: r.NullableString(4)), cancellationToken).ConfigureAwait(false);
var entries = new List<ErrorLogEntry>();
foreach (var row in rows)
{
if (row.Class == 20)
{
entries.Add(new ErrorLogEntry(row.At, 18456, row.Severity ?? 14, row.Text));
}
else if (row.Error is > 0)
{
entries.Add(new ErrorLogEntry(row.At, row.Error, row.Severity, row.Text));
}
else
{
entries.AddRange(ErrorLogAnalysis.ParseLines([(row.At, row.Text ?? string.Empty)]));
}
}
// StartTime is compared to whole seconds above, so the entries of that second that were read last time
// (the newest of them is ReadUntil) come back; leave them out, or they are counted again every collection.
return ([.. entries.Where(e => e.At > since)], path);
}
catch (DbException)
{
return null;
}
}
private static string Local(DateTimeOffset utc, TimeSpan offset) =>
(utc.UtcDateTime + offset).ToString("yyyy-MM-ddTHH:mm:ss", CultureInfo.InvariantCulture);
private static DateTimeOffset Utc(DateTime value) => new(DateTime.SpecifyKind(value, DateTimeKind.Utc));
}

View file

@ -0,0 +1,71 @@
using System.Data.Common;
using SQLInsight.Core.Data;
using SQLInsight.Core.Instances;
namespace SQLInsight.Collectors.Instance;
/// <summary>
/// On a distributor: each distribution agent's latest run (status, latency, message) and replication errors the last
/// day, as SELECTs in the distribution database. Reading it needs SELECT on those three tables (not on the replicated
/// commands); without it the snapshot says why. SQL Server 2005+.
/// </summary>
public sealed class ReplicationCollector : Collector<ReplicationSnapshot>
{
internal const string DistributionDatabaseSql = "SELECT TOP 1 name FROM sys.databases WHERE is_distributor = 1 AND state = 0";
internal const string AgentsSql = """
SELECT TOP 500 a.name, a.publisher_db, h.runstatus, DATEADD(minute, DATEDIFF(minute, GETDATE(), GETUTCDATE()), h.time), h.delivery_latency,
CAST(h.comments AS nvarchar(500))
FROM dbo.MSdistribution_agents a
OUTER APPLY (SELECT TOP 1 x.runstatus, x.time, x.delivery_latency, x.comments FROM dbo.MSdistribution_history x
WHERE x.agent_id = a.id ORDER BY x.timestamp DESC) h
""";
internal const string ErrorsSql = "SELECT COUNT(*) FROM dbo.MSrepl_errors WHERE time > DATEADD(hour, -24, GETDATE())";
public const string NoRightsText =
"Distributionsdatabasen kan ikke læses med agentens rettigheder. Giv agenten læseadgang til netop agenternes status (ikke de replikerede data): "
+ "USE distribution; CREATE USER [agentens login]; GRANT SELECT ON dbo.MSdistribution_agents TO [agentens login]; "
+ "GRANT SELECT ON dbo.MSdistribution_history TO [agentens login]; GRANT SELECT ON dbo.MSrepl_errors TO [agentens login].";
public override string Name => "replication";
public override TimeSpan DefaultInterval => TimeSpan.FromMinutes(15);
public override IReadOnlyCollection<Capability> RequiredCapabilities => [Capability.DynamicManagementViews];
protected override async Task<ReplicationSnapshot> CollectAsync(CollectorContext context, ReplicationSnapshot? previous)
{
var ct = context.CancellationToken;
var connection = context.Connection;
var distribution = (await Db.QueryAsync(connection, DistributionDatabaseSql, r => r.String(0), ct).ConfigureAwait(false)).FirstOrDefault();
if (distribution is null)
{
return new ReplicationSnapshot { CapturedAt = context.Now };
}
var original = (await Db.QueryAsync(connection, "SELECT DB_NAME()", r => r.String(0), ct).ConfigureAwait(false))[0];
try
{
var use = "USE " + Db.Identifier(distribution) + ";\n";
var agents = await Db.QueryAsync(connection, use + AgentsSql, r => new DistributionAgent(
r.String(0), r.NullableString(1), r.NullableInt(2),
r.NullableDateTime(3) is { } at ? new DateTimeOffset(DateTime.SpecifyKind(at, DateTimeKind.Utc)) : null,
r.NullableLong(4), r.NullableString(5)), ct).ConfigureAwait(false);
var errors = (await Db.QueryAsync(connection, use + ErrorsSql, r => r.Int(0), ct).ConfigureAwait(false))[0];
return new ReplicationSnapshot { CapturedAt = context.Now, IsDistributor = true, DistributionDatabase = distribution, Agents = agents, ErrorsLastDay = errors };
}
catch (DbException ex)
{
return new ReplicationSnapshot
{
CapturedAt = context.Now, IsDistributor = true, DistributionDatabase = distribution,
// The error message names the agent's login: kept apart from the reason, which rules and the API show as is.
Unavailable = NoRightsText,
UnavailableDetail = ex.Message.Split('\n')[0].Trim(),
};
}
finally
{
await Db.ExecuteAsync(connection, "USE " + Db.Identifier(original), ct).ConfigureAwait(false);
}
}
}

View file

@ -0,0 +1,116 @@
using SQLInsight.Core.Data;
using SQLInsight.Core.Instances;
namespace SQLInsight.Collectors.Instance;
/// <summary>
/// tempdb space (free, version store, user and internal objects), the sessions using most of it, the oldest snapshot
/// transaction and which databases generate row versions. VIEW SERVER STATE; SQL Server 2005+.
/// </summary>
public sealed class TempdbCollector(int versionStoreMegabytes = 1024, int sessionMegabytes = 1024) : Collector<TempdbSnapshot>
{
internal const string SpaceSql = """
SELECT SUM(CAST(u.unallocated_extent_page_count AS bigint)), SUM(CAST(u.version_store_reserved_page_count AS bigint)),
SUM(CAST(u.user_object_reserved_page_count AS bigint)), SUM(CAST(u.internal_object_reserved_page_count AS bigint)),
(SELECT SUM(CAST(f.size AS bigint)) FROM tempdb.sys.database_files f WHERE f.type = 0),
(SELECT MAX(CASE WHEN f.growth > 0 AND (f.max_size = -1 OR f.size < f.max_size) THEN 1 ELSE 0 END) FROM tempdb.sys.database_files f WHERE f.type = 0),
(SELECT MAX(CAST(p.cntr_value AS bigint)) FROM sys.dm_os_performance_counters p
WHERE p.counter_name = N'Version Store Size (KB)' AND p.object_name LIKE N'%Transactions%')
FROM tempdb.sys.dm_db_file_space_usage u
""";
internal const string SessionsSql = """
SELECT TOP 5 u.session_id, DATEADD(minute, DATEDIFF(minute, GETDATE(), GETUTCDATE()), s.login_time), u.pages, s.login_name, s.program_name, s.host_name, s.status
FROM (SELECT a.session_id, SUM(a.uo_alloc - a.uo_dealloc + a.io_alloc - a.io_dealloc) AS pages
FROM (SELECT session_id, CAST(user_objects_alloc_page_count AS bigint) AS uo_alloc, CAST(user_objects_dealloc_page_count AS bigint) AS uo_dealloc,
CAST(internal_objects_alloc_page_count AS bigint) AS io_alloc, CAST(internal_objects_dealloc_page_count AS bigint) AS io_dealloc
FROM sys.dm_db_session_space_usage
UNION ALL
SELECT session_id, CAST(user_objects_alloc_page_count AS bigint), CAST(user_objects_dealloc_page_count AS bigint),
CAST(internal_objects_alloc_page_count AS bigint), CAST(internal_objects_dealloc_page_count AS bigint)
FROM sys.dm_db_task_space_usage) a
GROUP BY a.session_id) u
JOIN sys.dm_exec_sessions s ON s.session_id = u.session_id
WHERE s.is_user_process = 1 AND u.pages > 0
ORDER BY u.pages DESC
""";
internal const string OldestSnapshotSql = """
SELECT TOP 1 t.session_id, t.elapsed_time_seconds, s.login_name, s.program_name
FROM sys.dm_tran_active_snapshot_database_transactions t
LEFT JOIN sys.dm_exec_sessions s ON s.session_id = t.session_id
ORDER BY t.elapsed_time_seconds DESC
""";
/// <summary>How often the version store is scanned for the databases generating it, while it is large.</summary>
public static readonly TimeSpan GeneratorsInterval = TimeSpan.FromHours(1);
// Scans the version store: only run when it is large enough to need explaining.
internal const string GeneratorsSql = """
SELECT TOP 10 DB_NAME(database_id), SUM(CAST(aggregated_record_length_in_bytes AS bigint))
FROM sys.dm_tran_top_version_generators
GROUP BY database_id
ORDER BY SUM(CAST(aggregated_record_length_in_bytes AS bigint)) DESC
""";
public override string Name => "tempdb";
public override TimeSpan DefaultInterval => TimeSpan.FromMinutes(5);
public override IReadOnlyCollection<Capability> RequiredCapabilities => [Capability.DynamicManagementViews];
protected override async Task<TempdbSnapshot> CollectAsync(CollectorContext context, TempdbSnapshot? previous)
{
var ct = context.CancellationToken;
var connection = context.Connection;
const double PageMb = 8.0 / 1024;
var space = (await Db.QueryAsync(connection, SpaceSql, r => (
Free: r.NullableLong(0) ?? 0, Version: r.NullableLong(1) ?? 0, User: r.NullableLong(2) ?? 0, Internal: r.NullableLong(3) ?? 0,
Size: r.NullableLong(4) ?? 0, CanGrow: (r.NullableInt(5) ?? 1) == 1, VersionKb: r.NullableLong(6)), ct).ConfigureAwait(false))[0];
var sessions = await Db.QueryAsync(connection, SessionsSql, r => new TempdbSession(
r.Int(0), new DateTimeOffset(DateTime.SpecifyKind(r.DateTime(1), DateTimeKind.Utc)), r.Long(2) * PageMb, r.NullableString(3), r.NullableString(4),
r.NullableString(5), r.NullableString(6)), ct).ConfigureAwait(false);
var oldest = (await Db.QueryAsync(connection, OldestSnapshotSql, r => new SnapshotTransaction(r.Int(0), r.NullableLong(1) ?? 0, r.NullableString(2), r.NullableString(3)), ct)
.ConfigureAwait(false)).FirstOrDefault();
var versionMb = space.VersionKb is { } kb ? kb / 1024.0 : space.Version * PageMb;
IReadOnlyList<VersionGenerator> generators = [];
DateTimeOffset? generatorsAt = null;
if (versionMb >= versionStoreMegabytes / 2.0)
{
// Scanning a large version store every 5 minutes is not cheap: at most once per GeneratorsInterval, and a
// scan that fails (times out) keeps the previous answer instead of failing the whole collection.
if (previous is { VersionGeneratorsAt: { } at } && context.Now - at < GeneratorsInterval)
{
(generators, generatorsAt) = (previous.VersionGenerators, at);
}
else
{
try
{
generators = await Db.QueryAsync(connection, GeneratorsSql, r => new VersionGenerator(r.NullableString(0) ?? "?", (r.NullableLong(1) ?? 0) / 1024.0 / 1024.0), ct)
.ConfigureAwait(false);
generatorsAt = context.Now;
}
catch (System.Data.Common.DbException)
{
(generators, generatorsAt) = (previous?.VersionGenerators ?? [], previous?.VersionGeneratorsAt);
}
}
}
return TempdbAnalysis.Advance(previous, new TempdbSnapshot
{
CapturedAt = context.Now,
SizeMegabytes = space.Size * PageMb,
FreeMegabytes = space.Free * PageMb,
VersionStoreMegabytes = versionMb,
UserObjectsMegabytes = space.User * PageMb,
InternalObjectsMegabytes = space.Internal * PageMb,
CanGrow = space.CanGrow,
TopSessions = sessions,
OldestSnapshotTransaction = oldest,
VersionGenerators = generators,
VersionGeneratorsAt = generatorsAt,
VersionStoreThresholdMegabytes = versionStoreMegabytes,
SessionThresholdMegabytes = sessionMegabytes,
});
}
}

View file

@ -0,0 +1,86 @@
namespace SQLInsight.Core.Data;
/// <summary>
/// An open user transaction: how long it has been open and how much transaction log it holds. Login, host, program
/// and statement are sensitive. Times are UTC.
/// </summary>
public sealed record OpenTransaction(
int SessionId,
long TransactionId,
string? Name,
DateTimeOffset BeganAt,
long LogBytesUsed,
long LogBytesReserved,
string? DatabaseName,
int Databases,
string? LoginName,
string? HostName,
string? ProgramName,
string? SessionStatus,
string? WaitType,
string? Statement)
{
public TimeSpan Age(DateTimeOffset now) => now - BeganAt;
public double LogMegabytes => (LogBytesUsed + LogBytesReserved) / 1024.0 / 1024.0;
/// <summary>Identifies the transaction across samples.</summary>
public string Key => $"{SessionId}|{TransactionId}|{BeganAt.UtcTicks}";
}
/// <summary>A user request that has been running for a long time right now. Login, host, program and statement are sensitive.</summary>
public sealed record LongRunningRequest(
int SessionId,
DateTimeOffset StartedAt,
long ElapsedMs,
string Command,
string? Status,
string? WaitType,
long WaitTimeMs,
string? DatabaseName,
string? LoginName,
string? HostName,
string? ProgramName,
int BlockingSessionId,
string? Statement);
/// <summary>Open transactions and long-running requests, every minute.</summary>
public sealed record ActivitySnapshot
{
public required DateTimeOffset CapturedAt { get; init; }
public IReadOnlyList<OpenTransaction> Transactions { get; init; } = [];
public IReadOnlyList<LongRunningRequest> LongRequests { get; init; } = [];
/// <summary>A transaction open longer than this is a finding (SQLInsight:Activity:TransactionMinutes).</summary>
public int TransactionMinutes { get; init; } = 10;
/// <summary>A transaction holding more log than this is a finding (SQLInsight:Activity:TransactionLogMegabytes).</summary>
public int TransactionLogMegabytes { get; init; } = 1024;
/// <summary>A statement running longer than this is a finding (SQLInsight:Activity:RequestMinutes).</summary>
public int RequestMinutes { get; init; } = 15;
}
public static class ActivityAnalysis
{
/// <summary>Transactions worth keeping in the history: open a minute or more, or holding 100 MB of log.</summary>
public static bool IsWorthKeeping(OpenTransaction transaction, DateTimeOffset now)
{
ArgumentNullException.ThrowIfNull(transaction);
return transaction.Age(now) >= TimeSpan.FromMinutes(1) || transaction.LogMegabytes >= 100;
}
/// <summary>
/// Requests that wait on purpose (WAITFOR, Service Broker queues) or are maintenance with its own progress
/// (BACKUP, RESTORE, DBCC: see long operations) are not long-running statements.
/// </summary>
public static bool IsStatement(LongRunningRequest request)
{
ArgumentNullException.ThrowIfNull(request);
var command = request.Command.Trim();
return !command.StartsWith("BACKUP", StringComparison.OrdinalIgnoreCase)
&& !command.StartsWith("RESTORE", StringComparison.OrdinalIgnoreCase)
&& !command.StartsWith("DBCC", StringComparison.OrdinalIgnoreCase)
&& !(request.WaitType is { } wait && WaitCatalog.IsBenign(wait));
}
}

View file

@ -0,0 +1,181 @@
using System.Globalization;
using System.Text.RegularExpressions;
namespace SQLInsight.Core.Data;
/// <summary>Where the error log entries came from, best first. Each needs less than the one before, and sees less.</summary>
public enum ErrorLogSource
{
/// <summary>The error log itself (xp_readerrorlog): everything. Needs securityadmin, which the agent normally does not have.</summary>
ErrorLog,
/// <summary>The default trace's ErrorLog and Audit Login Failed events: severe errors with text and failed logins, not 833 or stack dumps. Needs ALTER TRACE (optional).</summary>
DefaultTrace,
/// <summary>The exception ring buffer: error numbers and severity, no text. Needs only VIEW SERVER STATE.</summary>
RingBuffer,
}
/// <summary>What kind of error log entry this is, for the rules.</summary>
public enum ErrorLogCategory
{
Other,
/// <summary>823, 824, 825: SQL Server read a damaged page or had to retry a read.</summary>
Corruption,
/// <summary>833: I/O requests taking longer than 15 seconds.</summary>
SlowIo,
/// <summary>18456: a login failed.</summary>
LoginFailed,
/// <summary>A stack dump (an internal SQL Server failure).</summary>
StackDump,
/// <summary>Always On availability group errors.</summary>
AvailabilityGroup,
/// <summary>Other errors of severity 17 and above: resources, software or hardware.</summary>
Severe,
}
/// <param name="Message">The text, when the source has it. Can name logins and client addresses: sensitive.</param>
public sealed record ErrorLogEntry(DateTimeOffset At, int? Error, int? Severity, string? Message);
/// <summary>The same error repeated, summed: one line per error number (and category) in a collection.</summary>
public sealed record ErrorLogGroup(ErrorLogCategory Category, int? Error, int? Severity, int Count, DateTimeOffset FirstAt, DateTimeOffset LastAt, string? Sample);
public sealed record ErrorLogSnapshot
{
public required DateTimeOffset CapturedAt { get; init; }
public required ErrorLogSource Source { get; init; }
/// <summary>The newest entry read; the next collection continues after it.</summary>
public required DateTimeOffset ReadUntil { get; init; }
/// <summary>The default trace's current file when it was read; while it stays the same, only that file is read next time.</summary>
public string? TraceFile { get; init; }
/// <summary>What was found since the previous collection, grouped.</summary>
public IReadOnlyList<ErrorLogGroup> Groups { get; init; } = [];
/// <summary>The groups of the last <see cref="ErrorLogAnalysis.RecentWindow"/>: they keep findings open.</summary>
public IReadOnlyList<ErrorLogGroup> Recent { get; init; } = [];
/// <summary>Why less is seen than the error log has (e.g. no ALTER TRACE); null with the full error log.</summary>
public string? Coverage { get; init; }
/// <summary>Login failures in the collection interval before a burst is a finding (SQLInsight:ErrorLog:LoginFailureBurst).</summary>
public int LoginFailureBurst { get; init; } = 20;
}
public static partial class ErrorLogAnalysis
{
/// <summary>How long an error keeps its finding open after it was last seen.</summary>
public static readonly TimeSpan RecentWindow = TimeSpan.FromHours(24);
/// <summary>
/// Error log lines as the default trace or xp_readerrorlog give them: "Error: 824, Severity: 24, State: 2." is its own
/// line, followed by the message. The number line is joined to the message that follows.
/// </summary>
public static IReadOnlyList<ErrorLogEntry> ParseLines(IEnumerable<(DateTimeOffset At, string Text)> lines)
{
ArgumentNullException.ThrowIfNull(lines);
var entries = new List<ErrorLogEntry>();
(DateTimeOffset At, int Error, int Severity)? pending = null;
foreach (var (at, raw) in lines)
{
var text = raw.Trim();
if (text.Length == 0)
{
continue;
}
if (ErrorLine().Match(text) is { Success: true } m)
{
if (pending is { } p)
{
entries.Add(new ErrorLogEntry(p.At, p.Error, p.Severity, null));
}
pending = (at, int.Parse(m.Groups[1].Value, CultureInfo.InvariantCulture), int.Parse(m.Groups[2].Value, CultureInfo.InvariantCulture));
continue;
}
if (pending is { } q)
{
entries.Add(new ErrorLogEntry(q.At, q.Error, q.Severity, text));
pending = null;
continue;
}
entries.Add(new ErrorLogEntry(at, ErrorIn(text), null, text));
}
if (pending is { } last)
{
entries.Add(new ErrorLogEntry(last.At, last.Error, last.Severity, null));
}
return entries;
}
/// <summary>Messages without a number line that still say which error they are (833, stack dumps, login failures).</summary>
private static int? ErrorIn(string text) =>
text.Contains("I/O requests taking longer than 15 seconds", StringComparison.OrdinalIgnoreCase) ? 833
: text.StartsWith("Login failed", StringComparison.OrdinalIgnoreCase) ? 18456
: null;
public static ErrorLogCategory Categorize(ErrorLogEntry entry)
{
ArgumentNullException.ThrowIfNull(entry);
var text = entry.Message ?? string.Empty;
return entry.Error switch
{
823 or 824 or 825 => ErrorLogCategory.Corruption,
833 => ErrorLogCategory.SlowIo,
18456 => ErrorLogCategory.LoginFailed,
17065 or 17066 or 17067 or 17310 => ErrorLogCategory.StackDump,
1480 or 19406 or 41142 or 41143 or 41144 or 41131 => ErrorLogCategory.AvailabilityGroup,
>= 35201 and <= 35299 => ErrorLogCategory.AvailabilityGroup,
_ when text.Contains("Stack Signature", StringComparison.OrdinalIgnoreCase) || text.Contains("SqlDumpExceptionHandler", StringComparison.OrdinalIgnoreCase)
|| text.Contains("Stack Dump", StringComparison.OrdinalIgnoreCase) => ErrorLogCategory.StackDump,
_ when text.Contains("availability group", StringComparison.OrdinalIgnoreCase) && (entry.Severity >= 16 || text.Contains("failed", StringComparison.OrdinalIgnoreCase))
=> ErrorLogCategory.AvailabilityGroup,
_ when entry.Severity >= 17 => ErrorLogCategory.Severe,
_ => ErrorLogCategory.Other,
};
}
/// <summary>Sums repeated errors: one group per category and error number, with the first message as a sample.</summary>
public static IReadOnlyList<ErrorLogGroup> Group(IEnumerable<ErrorLogEntry> entries)
{
ArgumentNullException.ThrowIfNull(entries);
return entries
.Select(e => (Entry: e, Category: Categorize(e)))
.Where(x => x.Category != ErrorLogCategory.Other)
.GroupBy(x => (x.Category, x.Entry.Error))
.Select(g => new ErrorLogGroup(g.Key.Category, g.Key.Error, g.Max(x => x.Entry.Severity), g.Count(),
g.Min(x => x.Entry.At), g.Max(x => x.Entry.At), Cut(g.Select(x => x.Entry.Message).FirstOrDefault(m => m is not null))))
.OrderByDescending(g => g.Category).ThenByDescending(g => g.Count)
.ToList();
}
/// <summary>The groups of the last day: this collection's plus the earlier ones still in the window, merged.</summary>
public static IReadOnlyList<ErrorLogGroup> Recent(IReadOnlyList<ErrorLogGroup> previous, IReadOnlyList<ErrorLogGroup> current, DateTimeOffset now)
{
ArgumentNullException.ThrowIfNull(previous);
ArgumentNullException.ThrowIfNull(current);
return previous.Where(g => now - g.LastAt < RecentWindow).Concat(current)
.GroupBy(g => (g.Category, g.Error))
.Select(g => new ErrorLogGroup(g.Key.Category, g.Key.Error, g.Max(x => x.Severity), g.Sum(x => x.Count), g.Min(x => x.FirstAt), g.Max(x => x.LastAt),
g.OrderByDescending(x => x.LastAt).Select(x => x.Sample).FirstOrDefault(s => s is not null)))
.ToList();
}
private static string? Cut(string? text) => text is { Length: > 500 } ? text[..500] : text;
[GeneratedRegex(@"^Error:\s*(\d+),\s*Severity:\s*(\d+),\s*State:\s*\d+", RegexOptions.IgnoreCase)]
private static partial Regex ErrorLine();
}

View file

@ -8,6 +8,9 @@ public sealed record AvailabilityGroupSnapshot
public required DateTimeOffset CapturedAt { get; init; } public required DateTimeOffset CapturedAt { get; init; }
public required IReadOnlyList<ReplicaState> Replicas { get; init; } public required IReadOnlyList<ReplicaState> Replicas { get; init; }
public required IReadOnlyList<DatabaseReplicaState> Databases { get; init; } public required IReadOnlyList<DatabaseReplicaState> Databases { get; init; }
/// <summary>The Windows cluster's nodes and witness (sys.dm_hadr_cluster_members); empty without a WSFC.</summary>
public IReadOnlyList<ClusterMember> ClusterMembers { get; init; } = [];
} }
public sealed record ReplicaState public sealed record ReplicaState
@ -83,3 +86,10 @@ public sealed record AgentJob
public bool LastRunFailed => LastRunStatus == 0; public bool LastRunFailed => LastRunStatus == 0;
} }
/// <param name="Type">CLUSTER_NODE, DISK_WITNESS, FILE_SHARE_WITNESS, CLOUD_WITNESS.</param>
/// <param name="State">0 offline, 1 up.</param>
public sealed record ClusterMember(string Name, string Type, int State, string StateText, int? QuorumVotes)
{
public bool IsUp => State == 1;
}

View file

@ -11,6 +11,16 @@ public sealed record IndexSnapshot
public required IReadOnlyList<IndexDatabase> Databases { get; init; } public required IReadOnlyList<IndexDatabase> Databases { get; init; }
public TimeSpan Uptime => CapturedAt.UtcDateTime - SqlServerStartTime; public TimeSpan Uptime => CapturedAt.UtcDateTime - SqlServerStartTime;
/// <summary>Reads and writes per index since the previous collection, for the daily history.</summary>
public IReadOnlyList<IndexUsageDelta> UsageDeltas { get; init; } = [];
/// <summary>How long the usage of an index has been followed: across restarts, unlike <see cref="Uptime"/>.</summary>
public TimeSpan TrackedFor(IndexInfo index)
{
ArgumentNullException.ThrowIfNull(index);
return index.TrackedSince is { } since ? CapturedAt - since : Uptime;
}
} }
public sealed record IndexDatabase public sealed record IndexDatabase
@ -51,6 +61,18 @@ public sealed record IndexInfo
/// <summary>Heaps only: rows read through a forwarding pointer.</summary> /// <summary>Heaps only: rows read through a forwarding pointer.</summary>
public long ForwardedFetches { get; init; } public long ForwardedFetches { get; init; }
/// <summary>Reads summed across restarts of SQL Server since <see cref="TrackedSince"/> (index usage is reset at each restart).</summary>
public long TrackedReads { get; init; }
/// <summary>Writes summed the same way.</summary>
public long TrackedWrites { get; init; }
/// <summary>Since when SQLInsight has followed the usage (the restart before the first collection).</summary>
public DateTimeOffset? TrackedSince { get; init; }
/// <summary>Identifies the index within its database across collections (unit separator between the parts).</summary>
public string Key => $"{Schema}\u001f{Table}\u001f{IndexId}";
public long Reads => UserSeeks + UserScans + UserLookups; public long Reads => UserSeeks + UserScans + UserLookups;
public double SizeMb => UsedPages * 8.0 / 1024; public double SizeMb => UsedPages * 8.0 / 1024;
public string QualifiedTable => $"{Schema}.{Table}"; public string QualifiedTable => $"{Schema}.{Table}";
@ -88,3 +110,48 @@ public sealed record StatisticInfo
/// <summary>Row modifications since the last update (sysindexes.rowmodctr; approximate).</summary> /// <summary>Row modifications since the last update (sysindexes.rowmodctr; approximate).</summary>
public long Modifications { get; init; } public long Modifications { get; init; }
} }
/// <summary>An index's reads and writes since the previous collection (after a restart: since the restart).</summary>
public sealed record IndexUsageDelta(string Database, string Schema, string Table, string? Index, long Reads, long Writes);
public static class IndexUsageTracking
{
/// <summary>
/// Adds this collection's usage to the totals carried over from the previous one. After a restart the counters
/// started from zero again, so all of the current usage is new.
/// </summary>
public static IndexSnapshot Advance(IndexSnapshot? previous, IndexSnapshot current)
{
ArgumentNullException.ThrowIfNull(current);
var restarted = previous is null || previous.SqlServerStartTime != current.SqlServerStartTime;
var start = new DateTimeOffset(DateTime.SpecifyKind(current.SqlServerStartTime, DateTimeKind.Utc));
var before = (previous?.Databases ?? [])
.SelectMany(d => d.Indexes.Select(i => (Key: d.Name + "\u001f" + i.Key, Index: i)))
.GroupBy(x => x.Key, StringComparer.OrdinalIgnoreCase)
.ToDictionary(g => g.Key, g => g.First().Index, StringComparer.OrdinalIgnoreCase);
var deltas = new List<IndexUsageDelta>();
var databases = current.Databases.Select(d => d with
{
Indexes = d.Indexes.Select(i =>
{
before.TryGetValue(d.Name + "\u001f" + i.Key, out var was);
// A counter lower than last time was reset without a restart (database taken offline or closed by
// AUTO_CLOSE, index rebuilt on 2012/2014): what it holds now is all new.
var reads = restarted || was is null || i.Reads < was.Reads ? i.Reads : i.Reads - was.Reads;
var writes = restarted || was is null || i.UserUpdates < was.UserUpdates ? i.UserUpdates : i.UserUpdates - was.UserUpdates;
if (reads > 0 || writes > 0)
{
deltas.Add(new IndexUsageDelta(d.Name, i.Schema, i.Table, i.Name, reads, writes));
}
return i with
{
TrackedReads = (was?.TrackedReads ?? 0) + reads,
TrackedWrites = (was?.TrackedWrites ?? 0) + writes,
TrackedSince = was?.TrackedSince ?? start,
};
}).ToList(),
}).ToList();
return current with { Databases = databases, UsageDeltas = deltas };
}
}

View file

@ -0,0 +1,45 @@
namespace SQLInsight.Core.Data;
/// <summary>A distribution agent's latest run, from MSdistribution_history.</summary>
/// <param name="RunStatus">1 start, 2 succeed, 3 in progress, 4 idle, 5 retry, 6 fail.</param>
/// <param name="Comment">The agent's message; can name servers and objects.</param>
public sealed record DistributionAgent(string Name, string? PublisherDatabase, int? RunStatus, DateTimeOffset? LastRunAt, long? DeliveryLatencyMs, string? Comment)
{
public bool Failed => RunStatus is 5 or 6;
public string StatusText => RunStatus switch
{
1 => "startet",
2 => "lykkedes",
3 => "i gang",
4 => "venter (intet at levere)",
5 => "prøver igen",
6 => "fejlede",
_ => "ukendt",
};
}
/// <summary>Replication as seen from the distributor, every 15 minutes.</summary>
public sealed record ReplicationSnapshot
{
public required DateTimeOffset CapturedAt { get; init; }
/// <summary>The instance is a distributor (has a distribution database).</summary>
public bool IsDistributor { get; init; }
public string? DistributionDatabase { get; init; }
/// <summary>Why the distribution database could not be read (typically no SELECT rights); null when it could.</summary>
public string? Unavailable { get; init; }
/// <summary>SQL Server's error message when it could not be read. Names the agent's login: sensitive (local UI only).</summary>
public string? UnavailableDetail { get; init; }
public IReadOnlyList<DistributionAgent> Agents { get; init; } = [];
/// <summary>Rows in MSrepl_errors the last 24 hours.</summary>
public int ErrorsLastDay { get; init; }
/// <summary>Delivery latency above this (ms) is a finding.</summary>
public long LatencyThresholdMs { get; init; } = 300_000;
}

View file

@ -0,0 +1,84 @@
namespace SQLInsight.Core.Data;
/// <summary>A session using tempdb (user objects plus internal objects: sorts, hash tables, spools). Login and program are sensitive.</summary>
/// <param name="Since">When the session logged in; with <see cref="SessionId"/> it identifies the session across samples.</param>
public sealed record TempdbSession(int SessionId, DateTimeOffset Since, double UsedMegabytes, string? LoginName, string? ProgramName, string? HostName, string? Status)
{
public string Key => $"{SessionId}|{Since.UtcTicks}";
}
/// <summary>The oldest open snapshot transaction: it keeps every row version made since it began.</summary>
public sealed record SnapshotTransaction(int SessionId, long ElapsedSeconds, string? LoginName, string? ProgramName);
/// <summary>Version store use per database (from sys.dm_tran_top_version_generators).</summary>
public sealed record VersionGenerator(string DatabaseName, double Megabytes);
/// <summary>tempdb space and the version store, every 5 minutes.</summary>
public sealed record TempdbSnapshot
{
public required DateTimeOffset CapturedAt { get; init; }
/// <summary>The size of tempdb's data files now.</summary>
public double SizeMegabytes { get; init; }
public double FreeMegabytes { get; init; }
public double VersionStoreMegabytes { get; init; }
public double UserObjectsMegabytes { get; init; }
public double InternalObjectsMegabytes { get; init; }
/// <summary>Whether a data file can still grow (autogrowth on and not at its maximum size).</summary>
public bool CanGrow { get; init; } = true;
public IReadOnlyList<TempdbSession> TopSessions { get; init; } = [];
public SnapshotTransaction? OldestSnapshotTransaction { get; init; }
public IReadOnlyList<VersionGenerator> VersionGenerators { get; init; } = [];
/// <summary>When <see cref="VersionGenerators"/> was read (the scan is expensive and is repeated at most hourly).</summary>
public DateTimeOffset? VersionGeneratorsAt { get; init; }
/// <summary>Version store sizes of the latest samples (MB), oldest first: the trend.</summary>
public IReadOnlyList<double> VersionStoreHistory { get; init; } = [];
/// <summary>Since when each session has been above <see cref="SessionThresholdMegabytes"/>, by <see cref="TempdbSession.Key"/>.</summary>
public IReadOnlyDictionary<string, DateTimeOffset> HeavySessionsSince { get; init; } = new Dictionary<string, DateTimeOffset>();
/// <summary>The version store above this is a finding (SQLInsight:Tempdb:VersionStoreMegabytes).</summary>
public int VersionStoreThresholdMegabytes { get; init; } = 1024;
/// <summary>A session using more tempdb than this for <see cref="TempdbAnalysis.HeavySessionMinutes"/> is a finding.</summary>
public int SessionThresholdMegabytes { get; init; } = 1024;
public double FreePercent => SizeMegabytes <= 0 ? 100 : 100 * FreeMegabytes / SizeMegabytes;
}
public static class TempdbAnalysis
{
public const int HistoryLength = 12;
public const int HeavySessionMinutes = 15;
/// <summary>The version store has grown in each of the last six samples, by a quarter GB in all.</summary>
public static bool IsGrowing(IReadOnlyList<double> history)
{
ArgumentNullException.ThrowIfNull(history);
if (history.Count < 6)
{
return false;
}
var last = history.Skip(history.Count - 6).ToList();
return last.Zip(last.Skip(1)).All(p => p.Second > p.First) && last[^1] - last[0] >= 256;
}
/// <summary>Carries the trend and the heavy sessions over from the previous sample.</summary>
public static TempdbSnapshot Advance(TempdbSnapshot? previous, TempdbSnapshot current)
{
ArgumentNullException.ThrowIfNull(current);
var history = (previous?.VersionStoreHistory ?? []).Append(current.VersionStoreMegabytes).TakeLast(HistoryLength).ToList();
var heavy = new Dictionary<string, DateTimeOffset>(StringComparer.Ordinal);
foreach (var s in current.TopSessions.Where(s => s.UsedMegabytes >= current.SessionThresholdMegabytes))
{
heavy[s.Key] = previous?.HeavySessionsSince.TryGetValue(s.Key, out var since) == true ? since : current.CapturedAt;
}
return current with { VersionStoreHistory = history, HeavySessionsSince = heavy };
}
}

View file

@ -97,6 +97,12 @@ public static class MetricAssessment
+ "vedvarende over 0 betyder, at hukommelsen er en flaskehals lige nu (ventetypen RESOURCE_SEMAPHORE).", + "vedvarende over 0 betyder, at hukommelsen er en flaskehals lige nu (ventetypen RESOURCE_SEMAPHORE).",
Metrics.ProcessesBlocked => Metrics.ProcessesBlocked =>
"Sessioner, der lige nu venter på en lås, som en anden session holder. Kortvarig blokering er normal; vedvarende blokering betyder, at brugere venter.", "Sessioner, der lige nu venter på en lås, som en anden session holder. Kortvarig blokering er normal; vedvarende blokering betyder, at brugere venter.",
Metrics.TempdbUsedMegabytes =>
"Hvor meget af tempdb der er i brug: temp-tabeller, sorteringer og hash-tabeller, der ikke er plads til i hukommelsen, og version store. "
+ "Pludselige toppe skyldes typisk en enkelt tung forespørgsel eller et indexvedligehold; se Lige nu og version store.",
Metrics.VersionStoreMegabytes =>
"Gamle udgaver af rækker, som snapshot isolation og READ_COMMITTED_SNAPSHOT (AX 2009/2012) holder i tempdb. Den bør gå op og ned; "
+ "vokser den hele tiden, holder en lang transaktion de gamle versioner fast.",
"disk.read" => "disk.read" =>
"Gennemsnitlig tid pr. læsning fra datafilerne på drevet. Under 10 ms er godt for de fleste systemer, over 20 ms mærkes af brugerne.", "Gennemsnitlig tid pr. læsning fra datafilerne på drevet. Under 10 ms er godt for de fleste systemer, over 20 ms mærkes af brugerne.",
"disk.write.log" => "disk.write.log" =>
@ -135,6 +141,8 @@ public static class MetricAssessment
Metrics.BatchRequestsPerSecond => new(AssessmentLevel.Ok, Metrics.BatchRequestsPerSecond => new(AssessmentLevel.Ok,
$"Gennemsnitligt {N(Average(points), 1)} pr. sekund, højst {N(Peak(points), 1)}. Et mål for travlhed; sammenlign med normalen i analysen."), $"Gennemsnitligt {N(Average(points), 1)} pr. sekund, højst {N(Peak(points), 1)}. Et mål for travlhed; sammenlign med normalen i analysen."),
Metrics.UserConnections => Connections(points), Metrics.UserConnections => Connections(points),
Metrics.VersionStoreMegabytes => VersionStore(points),
Metrics.TempdbUsedMegabytes => new(AssessmentLevel.Ok, $"I gennemsnit {N(Average(points), 0)} MB i brug, højst {N(Peak(points), 0)} MB."),
_ => new(AssessmentLevel.Unknown, "Ingen vurdering."), _ => new(AssessmentLevel.Unknown, "Ingen vurdering."),
}; };
} }
@ -286,6 +294,18 @@ public static class MetricAssessment
: new(AssessmentLevel.Note, summary + " Kortvarigt.", action, link); : new(AssessmentLevel.Note, summary + " Kortvarigt.", action, link);
} }
private static Assessment VersionStore(IReadOnlyList<MetricPoint> points)
{
var summary = $"I gennemsnit {N(Average(points), 0)} MB, højst {N(Peak(points), 0)} MB.";
var growing = TempdbAnalysis.IsGrowing([.. points.OrderBy(p => p.At).Select(p => p.Value)]);
return Peak(points) >= 1024 || growing
? new(AssessmentLevel.Act, summary + (growing ? " Den vokser støt." : " Over 1 GB."),
"Find den ældste snapshot-transaktion og lange transaktioner (Lige nu på instansens side) og få dem afsluttet.", "#aktivitet")
: Peak(points) >= 512
? new(AssessmentLevel.Note, summary, "Hold øje med, om den falder igen.")
: new(AssessmentLevel.Ok, summary);
}
private static Assessment Connections(IReadOnlyList<MetricPoint> points) private static Assessment Connections(IReadOnlyList<MetricPoint> points)
{ {
var avg = Average(points); var avg = Average(points);

View file

@ -26,6 +26,9 @@ public sealed record TimeWindowAnalysis
/// <summary>Backup, restore, DBCC and other operations with progress that ran in the window.</summary> /// <summary>Backup, restore, DBCC and other operations with progress that ran in the window.</summary>
public IReadOnlyList<StoredLongOperation> LongOperations { get; init; } = []; public IReadOnlyList<StoredLongOperation> LongOperations { get; init; } = [];
/// <summary>User transactions open a minute or more (or holding much log) in the window. Statements and logins are customer data.</summary>
public IReadOnlyList<StoredTransaction> LongTransactions { get; init; } = [];
/// <summary> /// <summary>
/// A statement's CPU time as a share of the window, in percent of one CPU core: 100 means one core all the time. /// A statement's CPU time as a share of the window, in percent of one CPU core: 100 means one core all the time.
/// </summary> /// </summary>
@ -55,7 +58,7 @@ public sealed record TimeWindowAnalysis
metrics.FirstOrDefault(m => m.Metric == metric)?.Points ?? []; metrics.FirstOrDefault(m => m.Metric == metric)?.Points ?? [];
public bool HasData => Metrics.Any(m => m.Points.Count > 0) || Waits.Count > 0 || Files.Count > 0 || Disks.Count > 0 || Blocking.Count > 0 public bool HasData => Metrics.Any(m => m.Points.Count > 0) || Waits.Count > 0 || Files.Count > 0 || Disks.Count > 0 || Blocking.Count > 0
|| Deadlocks.Count > 0 || TopQueries.Count > 0 || LongOperations.Count > 0; || Deadlocks.Count > 0 || TopQueries.Count > 0 || LongOperations.Count > 0 || LongTransactions.Count > 0;
/// <summary>The metrics shown, in order: key, label, unit, decimals, lower-is-worse, fixed maximum.</summary> /// <summary>The metrics shown, in order: key, label, unit, decimals, lower-is-worse, fixed maximum.</summary>
public static readonly IReadOnlyList<(string Metric, string Label, string Unit, int Decimals, bool LowerIsWorse, double? Max)> MetricDefinitions = public static readonly IReadOnlyList<(string Metric, string Label, string Unit, int Decimals, bool LowerIsWorse, double? Max)> MetricDefinitions =
@ -68,6 +71,8 @@ public sealed record TimeWindowAnalysis
(Repository.Metrics.UserConnections, "Brugerforbindelser", "", 0, false, null), (Repository.Metrics.UserConnections, "Brugerforbindelser", "", 0, false, null),
(Repository.Metrics.MemoryGrantsPending, "Memory grants pending", "", 0, false, null), (Repository.Metrics.MemoryGrantsPending, "Memory grants pending", "", 0, false, null),
(Repository.Metrics.ProcessesBlocked, "Blokerede processer", "", 0, false, null), (Repository.Metrics.ProcessesBlocked, "Blokerede processer", "", 0, false, null),
(Repository.Metrics.TempdbUsedMegabytes, "tempdb i brug (MB)", "", 0, false, null),
(Repository.Metrics.VersionStoreMegabytes, "Version store (MB)", "", 0, false, null),
]; ];
public static async Task<TimeWindowAnalysis> LoadAsync( public static async Task<TimeWindowAnalysis> LoadAsync(
@ -139,6 +144,7 @@ public sealed record TimeWindowAnalysis
Blocking = await repository.GetBlockingAsync(id, from, to, cancellationToken).ConfigureAwait(false), Blocking = await repository.GetBlockingAsync(id, from, to, cancellationToken).ConfigureAwait(false),
Deadlocks = await repository.GetDeadlocksAsync(id, from, to, cancellationToken).ConfigureAwait(false), Deadlocks = await repository.GetDeadlocksAsync(id, from, to, cancellationToken).ConfigureAwait(false),
LongOperations = await repository.GetLongOperationsAsync(id, from, to, 100, cancellationToken).ConfigureAwait(false), LongOperations = await repository.GetLongOperationsAsync(id, from, to, 100, cancellationToken).ConfigureAwait(false),
LongTransactions = await repository.GetLongTransactionsAsync(id, from, to, 100, cancellationToken).ConfigureAwait(false),
Findings = await repository.GetFindingsActiveDuringAsync(id, from, to, cancellationToken).ConfigureAwait(false), Findings = await repository.GetFindingsActiveDuringAsync(id, from, to, cancellationToken).ConfigureAwait(false),
QueryOrder = order, QueryOrder = order,
TopQueries = await repository.GetTopQueriesAsync(id, from, to, order, 25, cancellationToken).ConfigureAwait(false), TopQueries = await repository.GetTopQueriesAsync(id, from, to, order, 25, cancellationToken).ConfigureAwait(false),

View file

@ -68,6 +68,7 @@ public static class TimeWindowReport
Disks(section, analysis); Disks(section, analysis);
Files(section, analysis); Files(section, analysis);
LongOperations(section, analysis, zone); LongOperations(section, analysis, zone);
LongTransactions(section, analysis, zone);
Blocking(section, analysis, zone); Blocking(section, analysis, zone);
Deadlocks(section, analysis, zone); Deadlocks(section, analysis, zone);
Findings(section, analysis); Findings(section, analysis);
@ -221,6 +222,23 @@ public static class TimeWindowReport
} }
} }
private static void LongTransactions(Section section, TimeWindowAnalysis analysis, TimeZoneInfo zone)
{
if (analysis.LongTransactions.Count == 0)
{
return;
}
section.AddParagraph("Lange og store transaktioner", StyleNames.Heading1);
var table = Table(section, [3.4, 2.6, 4.8, 2.6, 2.4]);
Header(table, "Åben siden", "Varighed", "Database", "Log", "Session");
foreach (var t in analysis.LongTransactions.Take(20))
{
Row(table, false, Time(new DateTimeOffset(t.BeganAt, TimeSpan.Zero), zone), Number(t.Duration.TotalMinutes, 0) + " min", t.DatabaseName ?? "–",
Number(t.MaxLogBytes / 1024.0 / 1024.0, 0) + " MB", t.SessionId.ToString(Danish));
}
}
private static void Blocking(Section section, TimeWindowAnalysis analysis, TimeZoneInfo zone) private static void Blocking(Section section, TimeWindowAnalysis analysis, TimeZoneInfo zone)
{ {
section.AddParagraph("Blocking", StyleNames.Heading1); section.AddParagraph("Blocking", StyleNames.Heading1);

View file

@ -0,0 +1,68 @@
using System.Globalization;
using System.Text.Json;
using Dapper;
using Microsoft.Data.SqlClient;
using SQLInsight.Core.Data;
namespace SQLInsight.Repository;
/// <summary>A long or large transaction as stored. Login, host, program and statement are sensitive (local UI only).</summary>
public sealed record StoredTransaction(int SessionId, long TransactionId, DateTime BeganAt, string? DatabaseName, string? LoginName, string? HostName,
string? ProgramName, long MaxLogBytes, string? Statement, DateTime LastSeenAt)
{
public TimeSpan Duration => LastSeenAt - BeganAt;
}
public sealed partial class InsightRepository
{
private static async Task SaveActivityAsync(SqlConnection connection, int instanceId, ActivitySnapshot snapshot)
{
// One row per transaction: a duplicate in the source would make the MERGE insert the same key twice.
var kept = snapshot.Transactions.Where(t => ActivityAnalysis.IsWorthKeeping(t, snapshot.CapturedAt)).DistinctBy(t => (t.SessionId, t.TransactionId)).ToList();
if (kept.Count == 0)
{
return;
}
await connection.ExecuteAsync("""
MERGE insight.LongTransactions AS t
USING (SELECT * FROM OPENJSON(@json) WITH (SessionId int, TransactionId bigint, BeganAt datetime2(0), DatabaseName nvarchar(128),
LoginName nvarchar(128), HostName nvarchar(128), ProgramName nvarchar(128), LogBytes bigint, Statement nvarchar(1000))) AS s
ON t.InstanceId = @instanceId AND t.BeganAt = s.BeganAt AND t.SessionId = s.SessionId AND t.TransactionId = s.TransactionId
WHEN MATCHED THEN UPDATE SET LastSeenAt = @at, MaxLogBytes = CASE WHEN s.LogBytes > t.MaxLogBytes THEN s.LogBytes ELSE t.MaxLogBytes END,
Statement = COALESCE(s.Statement, t.Statement)
WHEN NOT MATCHED THEN INSERT (InstanceId, SessionId, TransactionId, BeganAt, DatabaseName, LoginName, HostName, ProgramName, MaxLogBytes, Statement, LastSeenAt)
VALUES (@instanceId, s.SessionId, s.TransactionId, s.BeganAt, s.DatabaseName, s.LoginName, s.HostName, s.ProgramName, s.LogBytes, s.Statement, @at);
""", new
{
instanceId,
at = Utc(snapshot.CapturedAt),
json = JsonSerializer.Serialize(kept.Select(t => new
{
t.SessionId,
t.TransactionId,
BeganAt = Utc(t.BeganAt).ToString("yyyy-MM-ddTHH:mm:ss", CultureInfo.InvariantCulture),
t.DatabaseName,
t.LoginName,
t.HostName,
t.ProgramName,
LogBytes = t.LogBytesUsed + t.LogBytesReserved,
t.Statement,
}), Json),
}).ConfigureAwait(false);
}
/// <summary>Long or large transactions that were open at some point in [from, to], longest first.</summary>
public async Task<IReadOnlyList<StoredTransaction>> GetLongTransactionsAsync(int instanceId, DateTimeOffset from, DateTimeOffset to, int top = 100,
CancellationToken cancellationToken = default)
{
await using var connection = await OpenAsync(cancellationToken).ConfigureAwait(false);
var rows = await connection.QueryAsync<StoredTransaction>("""
SELECT TOP (@top) SessionId, TransactionId, BeganAt, DatabaseName, LoginName, HostName, ProgramName, MaxLogBytes, Statement, LastSeenAt
FROM insight.LongTransactions
WHERE InstanceId = @instanceId AND BeganAt <= @to AND LastSeenAt >= @from
ORDER BY DATEDIFF(second, BeganAt, LastSeenAt) DESC;
""", new { instanceId, from = from.UtcDateTime, to = to.UtcDateTime, top }).ConfigureAwait(false);
return rows.ToList();
}
}

View file

@ -0,0 +1,58 @@
using System.Globalization;
using System.Text.Json;
using Dapper;
using Microsoft.Data.SqlClient;
using SQLInsight.Core.Data;
namespace SQLInsight.Repository;
/// <summary>An error log group as stored. <see cref="Sample"/> is sensitive (local UI only).</summary>
public sealed record StoredErrorLogEvent(long EventId, DateTime CollectedAt, string Category, int? Error, int? Severity, int Count, DateTime FirstAt, DateTime LastAt,
string? Sample, string Source);
public sealed partial class InsightRepository
{
private static async Task SaveErrorLogAsync(SqlConnection connection, int instanceId, ErrorLogSnapshot snapshot)
{
if (snapshot.Groups.Count == 0)
{
return;
}
await connection.ExecuteAsync("""
INSERT insight.ErrorLogEvents (InstanceId, CollectedAt, Category, Error, Severity, [Count], FirstAt, LastAt, Sample, Source)
SELECT @instanceId, @at, Category, Error, Severity, [Count], FirstAt, LastAt, Sample, @source
FROM OPENJSON(@json) WITH (Category varchar(32), Error int, Severity int, [Count] int, FirstAt datetime2(0), LastAt datetime2(0), Sample nvarchar(500));
""", new
{
instanceId,
at = Utc(snapshot.CapturedAt),
source = snapshot.Source.ToString(),
json = JsonSerializer.Serialize(snapshot.Groups.Select(g => new
{
Category = g.Category.ToString(),
g.Error,
g.Severity,
g.Count,
FirstAt = Iso(g.FirstAt),
LastAt = Iso(g.LastAt),
Sample = g.Sample is { Length: > 500 } s ? s[..500] : g.Sample,
}), Json),
}).ConfigureAwait(false);
static string Iso(DateTimeOffset value) => Utc(value).ToString("yyyy-MM-ddTHH:mm:ss", CultureInfo.InvariantCulture);
}
/// <summary>Error log groups last seen in [from, to], newest first.</summary>
public async Task<IReadOnlyList<StoredErrorLogEvent>> GetErrorLogEventsAsync(int instanceId, DateTimeOffset from, DateTimeOffset to, int top = 500,
CancellationToken cancellationToken = default)
{
await using var connection = await OpenAsync(cancellationToken).ConfigureAwait(false);
var rows = await connection.QueryAsync<StoredErrorLogEvent>("""
SELECT TOP (@top) EventId, CollectedAt, Category, Error, Severity, [Count], FirstAt, LastAt, Sample, Source
FROM insight.ErrorLogEvents WHERE InstanceId = @instanceId AND LastAt >= @from AND FirstAt <= @to
ORDER BY LastAt DESC, EventId DESC;
""", new { instanceId, from = from.UtcDateTime, to = to.UtcDateTime, top }).ConfigureAwait(false);
return rows.ToList();
}
}

View file

@ -0,0 +1,54 @@
using System.Text.Json;
using Dapper;
using Microsoft.Data.SqlClient;
using SQLInsight.Core.Data;
namespace SQLInsight.Repository;
/// <summary>An index's reads and writes summed over a period, and on how many days it was used at all.</summary>
public sealed record IndexUsageTotal(string DatabaseName, string SchemaName, string TableName, string IndexName, long Reads, long Writes, int Days, DateTime FirstDay, DateTime LastDay);
public sealed partial class InsightRepository
{
private static async Task SaveIndexUsageAsync(SqlConnection connection, int instanceId, IndexSnapshot snapshot)
{
if (snapshot.UsageDeltas.Count == 0)
{
return;
}
await connection.ExecuteAsync("""
MERGE insight.IndexUsageDaily AS t
USING (SELECT DatabaseName, SchemaName, TableName, IndexName, SUM(Reads) AS Reads, SUM(Writes) AS Writes
FROM OPENJSON(@json) WITH (DatabaseName nvarchar(128) '$.Database', SchemaName nvarchar(128) '$.Schema', TableName nvarchar(128) '$.Table',
IndexName nvarchar(128) '$.Index', Reads bigint, Writes bigint)
GROUP BY DatabaseName, SchemaName, TableName, IndexName) AS s
ON t.InstanceId = @instanceId AND t.[Day] = @day AND t.DatabaseName = s.DatabaseName AND t.SchemaName = s.SchemaName
AND t.TableName = s.TableName AND t.IndexName = s.IndexName
WHEN MATCHED THEN UPDATE SET Reads = t.Reads + s.Reads, Writes = t.Writes + s.Writes
WHEN NOT MATCHED THEN INSERT (InstanceId, [Day], DatabaseName, SchemaName, TableName, IndexName, Reads, Writes)
VALUES (@instanceId, @day, s.DatabaseName, s.SchemaName, s.TableName, s.IndexName, s.Reads, s.Writes);
""", new
{
instanceId,
day = snapshot.CapturedAt.UtcDateTime.Date,
json = JsonSerializer.Serialize(snapshot.UsageDeltas.Select(d => d with { Index = d.Index ?? "(heap)" }), Json),
}).ConfigureAwait(false);
}
/// <summary>Index usage summed over the days in [from, to], least read first.</summary>
public async Task<IReadOnlyList<IndexUsageTotal>> GetIndexUsageAsync(int instanceId, DateTimeOffset from, DateTimeOffset to, int top = 500,
CancellationToken cancellationToken = default)
{
await using var connection = await OpenAsync(cancellationToken).ConfigureAwait(false);
var rows = await connection.QueryAsync<IndexUsageTotal>("""
SELECT TOP (@top) DatabaseName, SchemaName, TableName, IndexName, SUM(Reads) AS Reads, SUM(Writes) AS Writes,
SUM(CASE WHEN Reads > 0 THEN 1 ELSE 0 END) AS Days, MIN([Day]) AS FirstDay, MAX([Day]) AS LastDay
FROM insight.IndexUsageDaily
WHERE InstanceId = @instanceId AND [Day] >= @from AND [Day] <= @to
GROUP BY DatabaseName, SchemaName, TableName, IndexName
ORDER BY SUM(Reads), SUM(Writes) DESC;
""", new { instanceId, from = from.UtcDateTime.Date, to = to.UtcDateTime.Date, top }).ConfigureAwait(false);
return rows.ToList();
}
}

View file

@ -84,12 +84,15 @@ public sealed partial class InsightRepository
await DeleteInBatchesAsync(connection, "insight.QueryStatSamples", "SampledAt < @cutoff", raw).ConfigureAwait(false); await DeleteInBatchesAsync(connection, "insight.QueryStatSamples", "SampledAt < @cutoff", raw).ConfigureAwait(false);
await DeleteInBatchesAsync(connection, "insight.ActiveWaitSamples", "SampledAt < @cutoff", raw).ConfigureAwait(false); await DeleteInBatchesAsync(connection, "insight.ActiveWaitSamples", "SampledAt < @cutoff", raw).ConfigureAwait(false);
await DeleteInBatchesAsync(connection, "insight.LongOperations", "LastSeenAt < @cutoff", raw).ConfigureAwait(false); await DeleteInBatchesAsync(connection, "insight.LongOperations", "LastSeenAt < @cutoff", raw).ConfigureAwait(false);
await DeleteInBatchesAsync(connection, "insight.LongTransactions", "LastSeenAt < @cutoff", raw).ConfigureAwait(false);
await DeleteInBatchesAsync(connection, "insight.QueryTexts", "LastSeenAt < @cutoff", raw).ConfigureAwait(false); await DeleteInBatchesAsync(connection, "insight.QueryTexts", "LastSeenAt < @cutoff", raw).ConfigureAwait(false);
await DeleteInBatchesAsync(connection, "insight.MetricHourly", "[Hour] < @cutoff", hourly).ConfigureAwait(false); await DeleteInBatchesAsync(connection, "insight.MetricHourly", "[Hour] < @cutoff", hourly).ConfigureAwait(false);
await DeleteInBatchesAsync(connection, "insight.WaitHourly", "[Hour] < @cutoff", hourly).ConfigureAwait(false); await DeleteInBatchesAsync(connection, "insight.WaitHourly", "[Hour] < @cutoff", hourly).ConfigureAwait(false);
await DeleteInBatchesAsync(connection, "insight.DiskHourly", "[Hour] < @cutoff", hourly).ConfigureAwait(false); await DeleteInBatchesAsync(connection, "insight.DiskHourly", "[Hour] < @cutoff", hourly).ConfigureAwait(false);
await DeleteInBatchesAsync(connection, "insight.IndexUsageDaily", "[Day] < @cutoff", hourly).ConfigureAwait(false);
await DeleteInBatchesAsync(connection, "insight.BlockingEvents", "SampledAt < @cutoff", events).ConfigureAwait(false); await DeleteInBatchesAsync(connection, "insight.BlockingEvents", "SampledAt < @cutoff", events).ConfigureAwait(false);
await DeleteInBatchesAsync(connection, "insight.Deadlocks", "OccurredAt < @cutoff", events).ConfigureAwait(false); await DeleteInBatchesAsync(connection, "insight.Deadlocks", "OccurredAt < @cutoff", events).ConfigureAwait(false);
await DeleteInBatchesAsync(connection, "insight.ErrorLogEvents", "LastAt < @cutoff", events).ConfigureAwait(false);
await DeleteInBatchesAsync(connection, "insight.Findings", "ResolvedAt < @cutoff", resolved).ConfigureAwait(false); await DeleteInBatchesAsync(connection, "insight.Findings", "ResolvedAt < @cutoff", resolved).ConfigureAwait(false);
await DeleteInBatchesAsync(connection, "insight.ConfigurationChanges", "DetectedAt < @cutoff", resolved).ConfigureAwait(false); await DeleteInBatchesAsync(connection, "insight.ConfigurationChanges", "DetectedAt < @cutoff", resolved).ConfigureAwait(false);
await DeleteInBatchesAsync(connection, "insight.PrivilegeChanges", "DetectedAt < @cutoff", resolved).ConfigureAwait(false); await DeleteInBatchesAsync(connection, "insight.PrivilegeChanges", "DetectedAt < @cutoff", resolved).ConfigureAwait(false);

View file

@ -264,6 +264,26 @@ public sealed partial class InsightRepository(string connectionString)
await SaveLongOperationsAsync(connection, instanceId, operations).ConfigureAwait(false); await SaveLongOperationsAsync(connection, instanceId, operations).ConfigureAwait(false);
await SaveSnapshotJsonAsync(connection, instanceId, kind, data).ConfigureAwait(false); await SaveSnapshotJsonAsync(connection, instanceId, kind, data).ConfigureAwait(false);
return; return;
case ErrorLogSnapshot errorLog:
await SaveErrorLogAsync(connection, instanceId, errorLog).ConfigureAwait(false);
await SaveSnapshotJsonAsync(connection, instanceId, kind, data).ConfigureAwait(false);
return;
case ActivitySnapshot activity:
await SaveActivityAsync(connection, instanceId, activity).ConfigureAwait(false);
await SaveSnapshotJsonAsync(connection, instanceId, kind, data).ConfigureAwait(false);
return;
case TempdbSnapshot tempdb:
await InsertMetricsAsync(connection, instanceId,
[
(Metrics.TempdbUsedMegabytes, tempdb.CapturedAt, tempdb.SizeMegabytes - tempdb.FreeMegabytes),
(Metrics.VersionStoreMegabytes, tempdb.CapturedAt, tempdb.VersionStoreMegabytes),
]).ConfigureAwait(false);
await SaveSnapshotJsonAsync(connection, instanceId, kind, data).ConfigureAwait(false);
return;
case IndexSnapshot indexes:
await SaveIndexUsageAsync(connection, instanceId, indexes).ConfigureAwait(false);
await SaveSnapshotJsonAsync(connection, instanceId, kind, data).ConfigureAwait(false);
return;
case PrivilegeSnapshot privileges: case PrivilegeSnapshot privileges:
await SavePrivilegeChangesAsync(connection, instanceId, privileges.Changes).ConfigureAwait(false); await SavePrivilegeChangesAsync(connection, instanceId, privileges.Changes).ConfigureAwait(false);
await SaveLatePrivilegeAuthorsAsync(connection, instanceId, privileges.RecentChanges).ConfigureAwait(false); await SaveLatePrivilegeAuthorsAsync(connection, instanceId, privileges.RecentChanges).ConfigureAwait(false);
@ -484,4 +504,6 @@ public static class Metrics
public const string ProcessesBlocked = "blocking.processes"; public const string ProcessesBlocked = "blocking.processes";
public const string BatchRequestsPerSecond = "workload.batch_requests"; public const string BatchRequestsPerSecond = "workload.batch_requests";
public const string CompilationsPerSecond = "workload.compilations"; public const string CompilationsPerSecond = "workload.compilations";
public const string TempdbUsedMegabytes = "tempdb.used_mb";
public const string VersionStoreMegabytes = "tempdb.version_store_mb";
} }

View file

@ -0,0 +1,18 @@
/* Important SQL Server error log entries, summed per collection and error (corruption, slow I/O, severe errors, login
failures, stack dumps, AG). Sample is a message and can name logins and addresses: local only. Kept EventDays. */
CREATE TABLE insight.ErrorLogEvents
(
EventId bigint IDENTITY(1, 1) NOT NULL CONSTRAINT PK_ErrorLogEvents PRIMARY KEY NONCLUSTERED,
InstanceId int NOT NULL,
CollectedAt datetime2(0) NOT NULL,
Category varchar(32) NOT NULL,
Error int NULL,
Severity int NULL,
[Count] int NOT NULL,
FirstAt datetime2(0) NOT NULL,
LastAt datetime2(0) NOT NULL,
Sample nvarchar(500) NULL,
Source varchar(16) NOT NULL
);
CREATE CLUSTERED INDEX CX_ErrorLogEvents ON insight.ErrorLogEvents (InstanceId, LastAt);

View file

@ -0,0 +1,17 @@
/* User transactions open a minute or more (or holding 100 MB of log): one row per transaction, updated while it is
open. Login, host, program and statement are sensitive (local only). Kept as long as the other raw samples. */
CREATE TABLE insight.LongTransactions
(
InstanceId int NOT NULL,
SessionId int NOT NULL,
TransactionId bigint NOT NULL,
BeganAt datetime2(0) NOT NULL,
DatabaseName nvarchar(128) NULL,
LoginName nvarchar(128) NULL,
HostName nvarchar(128) NULL,
ProgramName nvarchar(128) NULL,
MaxLogBytes bigint NOT NULL,
Statement nvarchar(1000) NULL,
LastSeenAt datetime2(0) NOT NULL,
CONSTRAINT PK_LongTransactions PRIMARY KEY (InstanceId, BeganAt, SessionId, TransactionId)
);

View file

@ -0,0 +1,14 @@
/* Index reads and writes per day (deltas of sys.dm_db_index_usage_stats between collections), so unused indexes can be
judged over time, across restarts of SQL Server. Kept as long as the hourly history (HourlyDays). */
CREATE TABLE insight.IndexUsageDaily
(
InstanceId int NOT NULL,
[Day] date NOT NULL,
DatabaseName nvarchar(128) NOT NULL,
SchemaName nvarchar(128) NOT NULL,
TableName nvarchar(128) NOT NULL,
IndexName nvarchar(128) NOT NULL,
Reads bigint NOT NULL,
Writes bigint NOT NULL,
CONSTRAINT PK_IndexUsageDaily PRIMARY KEY (InstanceId, [Day], DatabaseName, SchemaName, TableName, IndexName)
);

View file

@ -175,3 +175,35 @@ public sealed class AgentStoppedRule : Rule
references: Docs.Learn("SQL Server Agent", "ssms/agent/sql-server-agent"))); references: Docs.Learn("SQL Server Agent", "ssms/agent/sql-server-agent")));
} }
} }
/// <summary>
/// A node or the witness of the Windows cluster under the availability groups is down: the cluster has fewer votes,
/// and losing one more can take quorum, and with it every availability group, offline.
/// </summary>
public sealed class ClusterMemberDownRule : Rule
{
public override string Id => "SQLI-AG-003";
public override string Title => "En node eller et vidne i clusteret er nede";
public override IReadOnlyCollection<Capability> RequiredCapabilities => [Capability.HadrEnabled];
public override RuleEvaluation Evaluate(RuleContext context)
{
ArgumentNullException.ThrowIfNull(context);
var snapshot = context.Data.Get<AvailabilityGroupSnapshot>();
return Result(snapshot.ClusterMembers.Where(m => !m.IsUp).Select(m => CreateFinding(
Severity.High,
$"{(m.Type == "CLUSTER_NODE" ? "Clusternoden" : "Vidnet")} {m.Name} er {m.StateText.ToLowerInvariant()}",
"Windows-clusteret under availability groups har mistet en node eller sit vidne. Clusteret kører videre, så længe der er quorum, "
+ "men der er færre stemmer tilbage: mistes en til, går clusteret og dermed alle availability groups offline.",
"Få noden eller vidnet op igen (Failover Cluster Manager, netværk, fildelingen eller Azure-vidnet), og tjek clusterets hændelseslog. "
+ "Overvej dynamisk quorum og vidnets placering, så en enkelt fejl ikke truer quorum.",
[
new Evidence("Medlem", m.Name),
new Evidence("Type", m.Type),
new Evidence("Tilstand", m.StateText),
new Evidence("Stemmer", m.QuorumVotes?.ToString(System.Globalization.CultureInfo.InvariantCulture) ?? "–"),
new Evidence("Medlemmer oppe", $"{snapshot.ClusterMembers.Count(x => x.IsUp)} af {snapshot.ClusterMembers.Count}"),
],
subject: $"cluster member: {m.Name}")));
}
}

View file

@ -0,0 +1,77 @@
using System.Globalization;
using SQLInsight.Core.Collection;
using SQLInsight.Core.Data;
using SQLInsight.Core.Findings;
using SQLInsight.Core.Instances;
using SQLInsight.Rules.Engine;
namespace SQLInsight.Rules.Availability;
internal static class ReplicationData
{
/// <summary>The snapshot when the instance is a distributor that could be read; otherwise not relevant or not measurable.</summary>
public static ReplicationSnapshot Readable(RuleContext context)
{
var snapshot = context.Data.Get<ReplicationSnapshot>();
if (!snapshot.IsDistributor)
{
throw new DataUnavailableException("Ikke relevant: instansen er ikke distributør for replikering.") { NotApplicable = true };
}
return snapshot.Unavailable is { } why ? throw new DataUnavailableException(why) : snapshot;
}
}
/// <summary>A distribution agent that failed or keeps retrying, or replication errors in the last day.</summary>
public sealed class ReplicationAgentFailedRule : Rule
{
public override string Id => "SQLI-REPL-001";
public override string Title => "Replikering fejler";
public override IReadOnlyCollection<Capability> RequiredCapabilities => [Capability.DynamicManagementViews];
public override RuleEvaluation Evaluate(RuleContext context)
{
ArgumentNullException.ThrowIfNull(context);
var snapshot = ReplicationData.Readable(context);
return Result(snapshot.Agents.Where(a => a.Failed).Select(a => CreateFinding(
Severity.High,
$"Distributionsagenten for {a.PublisherDatabase ?? a.Name} {a.StatusText}",
"En distributionsagent leverer ikke ændringer til abonnenten: data hos abonnenten bliver forældet, og distributionsdatabasen og "
+ "publicistens log vokser, så længe ændringerne venter.",
"Se agentens historik i Replication Monitor og meddelelsen i målingerne. Typiske årsager: abonnenten kan ikke nås, rettigheder, "
+ "eller en række, der ikke findes hos abonnenten. Start agenten igen, når årsagen er rettet.",
[
new Evidence("Agent", a.Name),
new Evidence("Status", a.StatusText),
new Evidence("Seneste kørsel", a.LastRunAt?.UtcDateTime.ToString("yyyy-MM-dd HH:mm 'UTC'", CultureInfo.InvariantCulture) ?? "–"),
new Evidence("Fejl det seneste døgn", snapshot.ErrorsLastDay.ToString(CultureInfo.InvariantCulture)),
new Evidence("Meddelelse", a.Comment ?? "–", Sensitive: true),
],
subject: $"replication: {a.Name}")));
}
}
/// <summary>Changes take long to reach a subscriber.</summary>
public sealed class ReplicationLatencyRule : Rule
{
public override string Id => "SQLI-REPL-002";
public override string Title => "Replikering er forsinket";
public override IReadOnlyCollection<Capability> RequiredCapabilities => [Capability.DynamicManagementViews];
public override RuleEvaluation Evaluate(RuleContext context)
{
ArgumentNullException.ThrowIfNull(context);
var snapshot = ReplicationData.Readable(context);
return Result(snapshot.Agents.Where(a => !a.Failed && a.DeliveryLatencyMs >= snapshot.LatencyThresholdMs).Select(a => CreateFinding(
Severity.Medium,
$"Ændringer til {a.PublisherDatabase ?? a.Name} er {Format.Number(a.DeliveryLatencyMs!.Value / 60000.0, 1)} minutter forsinket",
"Distributionsagenten leverer, men det tager lang tid, før ændringer når abonnenten. Rapporter og systemer, der læser hos abonnenten, ser gamle data.",
"Se om abonnenten eller netværket er belastet, om der er store transaktioner på publicisten, og om distributionsdatabasen er stor (oprydningsjob).",
[
new Evidence("Agent", a.Name),
new Evidence("Forsinkelse", $"{Format.Number(a.DeliveryLatencyMs.Value / 1000.0)} s"),
new Evidence("Seneste kørsel", a.LastRunAt?.UtcDateTime.ToString("yyyy-MM-dd HH:mm 'UTC'", CultureInfo.InvariantCulture) ?? "–"),
],
subject: $"replication latency: {a.Name}")));
}
}

View file

@ -4,6 +4,7 @@ using SQLInsight.Rules.Configuration;
using SQLInsight.Rules.Databases; using SQLInsight.Rules.Databases;
using SQLInsight.Rules.Engine; using SQLInsight.Rules.Engine;
using SQLInsight.Rules.Indexes; using SQLInsight.Rules.Indexes;
using SQLInsight.Rules.Instance;
using SQLInsight.Rules.Lifecycle; using SQLInsight.Rules.Lifecycle;
using SQLInsight.Rules.Performance; using SQLInsight.Rules.Performance;
using SQLInsight.Rules.Security; using SQLInsight.Rules.Security;
@ -56,6 +57,22 @@ public static class BuiltInRules
new CpuRule(), new CpuRule(),
new ConstantActivityRule(), new ConstantActivityRule(),
new SlowBackupRule(), new SlowBackupRule(),
new CorruptionErrorRule(),
new SlowIoErrorRule(),
new SevereErrorRule(),
new LoginFailureBurstRule(),
new StackDumpRule(),
new AvailabilityGroupErrorRule(),
new LongTransactionRule(),
new LargeTransactionRule(),
new LongRunningStatementRule(),
new VersionStoreRule(),
new TempdbFullRule(),
new TempdbSessionRule(),
new StoppedServiceRule(),
new ReplicationAgentFailedRule(),
new ReplicationLatencyRule(),
new ClusterMemberDownRule(),
new MemoryGrantsPendingRule(), new MemoryGrantsPendingRule(),
new AnomalyRule(), new AnomalyRule(),
new PlanRegressionRule(), new PlanRegressionRule(),

View file

@ -0,0 +1,127 @@
using System.Globalization;
using SQLInsight.Core.Data;
using SQLInsight.Core.Findings;
using SQLInsight.Core.Instances;
using SQLInsight.Rules.Engine;
namespace SQLInsight.Rules.Concurrency;
internal static class ActivityEvidence
{
public static string Utc(DateTimeOffset value) => value.UtcDateTime.ToString("yyyy-MM-dd HH:mm 'UTC'", CultureInfo.InvariantCulture);
public static string Minutes(TimeSpan value) => value.TotalHours >= 2
? $"{Format.Number(value.TotalHours, 1)} timer"
: $"{Format.Number(value.TotalMinutes)} minutter";
public static IEnumerable<Evidence> Session(int sessionId, string? login, string? host, string? program, string? statement) =>
[
new Evidence("Session", sessionId.ToString(CultureInfo.InvariantCulture)),
new Evidence("Login", login ?? "–", Sensitive: true),
new Evidence("Vært", host ?? "–", Sensitive: true),
new Evidence("Program", program ?? "–", Sensitive: true),
new Evidence("Sætning", statement is { Length: > 500 } s ? s[..500] + " …" : statement ?? "–", Sensitive: true),
];
}
/// <summary>
/// A user transaction that has been open a long time: it holds its locks and keeps the log (and the version store)
/// from being truncated. Often a user who left a dialog open, or a program that forgot to commit.
/// </summary>
public sealed class LongTransactionRule : Rule
{
public override string Id => "SQLI-TRAN-001";
public override string Title => "Transaktion har været åben længe";
public override IReadOnlyCollection<Capability> RequiredCapabilities => [Capability.DynamicManagementViews];
public override RuleEvaluation Evaluate(RuleContext context)
{
ArgumentNullException.ThrowIfNull(context);
var snapshot = context.Data.Get<ActivitySnapshot>();
var limit = TimeSpan.FromMinutes(snapshot.TransactionMinutes);
return Result(snapshot.Transactions
.Where(t => t.Age(snapshot.CapturedAt) >= limit)
.OrderByDescending(t => t.Age(snapshot.CapturedAt))
.Take(10)
.Select(t => CreateFinding(
t.Age(snapshot.CapturedAt) >= limit * 6 ? Severity.High : Severity.Medium,
$"Transaktion i {t.DatabaseName ?? "en database"} har været åben i {ActivityEvidence.Minutes(t.Age(snapshot.CapturedAt))}",
"En åben transaktion holder sine låse, så andre kan blive blokeret, og den forhindrer, at transaktionsloggen (og tempdb's version store) "
+ "kan genbruges, så de vokser. Det skyldes typisk en bruger, der har en dialog åben midt i en ændring, eller et program, der har glemt COMMIT.",
"Find sessionen (sp_who2 eller Aktivitetsmonitor) og programmet bag. Afslut transaktionen i programmet, eller luk sessionen med KILL, "
+ "hvis ejeren er enig (det ruller ændringerne tilbage, hvilket kan tage lige så lang tid). Ret programmet, så transaktioner holdes korte.",
[
new Evidence("Åben siden", ActivityEvidence.Utc(t.BeganAt)),
new Evidence("Database", t.DatabaseName ?? "–"),
new Evidence("Log brugt og reserveret", $"{Format.Number(t.LogMegabytes)} MB"),
new Evidence("Status", t.SessionStatus ?? "–"),
.. ActivityEvidence.Session(t.SessionId, t.LoginName, t.HostName, t.ProgramName, t.Statement),
],
subject: $"transaction: {t.SessionId}/{t.BeganAt.UtcDateTime:yyyyMMddHHmmss}")));
}
}
/// <summary>A transaction holding a lot of transaction log: it cannot be truncated until the transaction ends.</summary>
public sealed class LargeTransactionRule : Rule
{
public override string Id => "SQLI-TRAN-002";
public override string Title => "Transaktion bruger meget transaktionslog";
public override IReadOnlyCollection<Capability> RequiredCapabilities => [Capability.DynamicManagementViews];
public override RuleEvaluation Evaluate(RuleContext context)
{
ArgumentNullException.ThrowIfNull(context);
var snapshot = context.Data.Get<ActivitySnapshot>();
return Result(snapshot.Transactions
.Where(t => t.LogMegabytes >= snapshot.TransactionLogMegabytes)
.OrderByDescending(t => t.LogMegabytes)
.Take(10)
.Select(t => CreateFinding(
Severity.Medium,
$"Transaktion i {t.DatabaseName ?? "en database"} bruger {Format.Number(t.LogMegabytes / 1024, 1)} GB transaktionslog",
"Al log, en transaktion har skrevet, skal blive i loggen, til den er afsluttet (og en eventuel rollback vil tage tilsvarende lang tid). "
+ "Store transaktioner får loggen til at vokse og kan fylde disken.",
"Find hvad sessionen gør. Store opdateringer og sletninger bør deles op i mindre batches med COMMIT imellem, "
+ "og indexvedligehold bør ikke køre som én transaktion over mange tabeller.",
[
new Evidence("Åben siden", ActivityEvidence.Utc(t.BeganAt)),
new Evidence("Database", t.DatabaseName ?? "–"),
new Evidence("Log brugt og reserveret", $"{Format.Number(t.LogMegabytes)} MB"),
.. ActivityEvidence.Session(t.SessionId, t.LoginName, t.HostName, t.ProgramName, t.Statement),
],
subject: $"transaction log: {t.SessionId}/{t.BeganAt.UtcDateTime:yyyyMMddHHmmss}")));
}
}
/// <summary>A user statement that is still running after a long time (BACKUP, RESTORE and DBCC are long operations instead).</summary>
public sealed class LongRunningStatementRule : Rule
{
public override string Id => "SQLI-REQ-001";
public override string Title => "Sætning har kørt længe";
public override IReadOnlyCollection<Capability> RequiredCapabilities => [Capability.DynamicManagementViews];
public override RuleEvaluation Evaluate(RuleContext context)
{
ArgumentNullException.ThrowIfNull(context);
var snapshot = context.Data.Get<ActivitySnapshot>();
return Result(snapshot.LongRequests
.OrderByDescending(r => r.ElapsedMs)
.Take(10)
.Select(r => CreateFinding(
Severity.Medium,
$"Sætning i {r.DatabaseName ?? "en database"} har kørt i {ActivityEvidence.Minutes(TimeSpan.FromMilliseconds(r.ElapsedMs))}",
$"En brugersætning har kørt i over {snapshot.RequestMinutes} minutter. Den bruger ressourcer og holder måske låse, som andre venter på. "
+ "Ventetypen fortæller, hvad den venter på lige nu.",
"Se sætningen og dens plan (sys.dm_exec_query_plan for sessionen). Er den blokeret (Blokeret af), så find head blocker. "
+ "Er det en rapport eller et job, der plejer at tage så lang tid, så kan grænsen hæves med SQLInsight:Activity:RequestMinutes.",
[
new Evidence("Startet", ActivityEvidence.Utc(r.StartedAt)),
new Evidence("Kommando", r.Command),
new Evidence("Database", r.DatabaseName ?? "–"),
new Evidence("Ventetype", r.WaitType ?? "kører (ingen ventetid)"),
new Evidence("Blokeret af", r.BlockingSessionId > 0 ? r.BlockingSessionId.ToString(CultureInfo.InvariantCulture) : "–"),
.. ActivityEvidence.Session(r.SessionId, r.LoginName, r.HostName, r.ProgramName, r.Statement),
],
subject: $"request: {r.SessionId}/{r.StartedAt.UtcDateTime:yyyyMMddHHmmss}")));
}
}

View file

@ -173,25 +173,33 @@ public sealed class UnusedIndexRule : Rule
public override RuleEvaluation Evaluate(RuleContext context) public override RuleEvaluation Evaluate(RuleContext context)
{ {
ArgumentNullException.ThrowIfNull(context); ArgumentNullException.ThrowIfNull(context);
var snapshot = IndexText.RequireUptime(context); // Usage is followed across restarts, so an index can be judged once it has been followed for a week.
var snapshot = IndexText.Snapshot(context);
var followed = snapshot.Databases.SelectMany(db => db.Indexes).Any(i => snapshot.TrackedFor(i) >= IndexText.MinUptimeForUsage);
if (!followed)
{
IndexText.RequireUptime(context);
}
return Result(snapshot.Databases return Result(snapshot.Databases
.SelectMany(db => db.Indexes.Select(i => (Db: db.Name, Index: i))) .SelectMany(db => db.Indexes.Select(i => (Db: db.Name, Index: i)))
.Where(x => x.Index.Type == "NONCLUSTERED" && !x.Index.IsPrimaryKey && !x.Index.IsUniqueConstraint && !x.Index.IsUnique .Where(x => x.Index.Type == "NONCLUSTERED" && !x.Index.IsPrimaryKey && !x.Index.IsUniqueConstraint && !x.Index.IsUnique
&& !x.Index.IsDisabled && x.Index.Reads == 0 && (x.Index.UserUpdates >= 1000 || x.Index.SizeMb >= 100)) && !x.Index.IsDisabled && snapshot.TrackedFor(x.Index) >= IndexText.MinUptimeForUsage
.OrderByDescending(x => x.Index.UserUpdates + x.Index.UsedPages) && x.Index.Reads == 0 && x.Index.TrackedReads == 0 && (Math.Max(x.Index.UserUpdates, x.Index.TrackedWrites) >= 1000 || x.Index.SizeMb >= 100))
.OrderByDescending(x => Math.Max(x.Index.UserUpdates, x.Index.TrackedWrites) + x.Index.UsedPages)
.Take(IndexText.MaxFindingsPerRule) .Take(IndexText.MaxFindingsPerRule)
.Select(x => CreateFinding( .Select(x => CreateFinding(
x.Index.SizeMb >= 1024 ? Severity.Medium : Severity.Low, x.Index.SizeMb >= 1024 ? Severity.Medium : Severity.Low,
$"{x.Db}.{x.Index.QualifiedTable}: index {x.Index.Name} er ikke læst i {Format.Age(snapshot.Uptime)}", $"{x.Db}.{x.Index.QualifiedTable}: index {x.Index.Name} er ikke læst i {Format.Age(snapshot.TrackedFor(x.Index))}",
"Indexet opdateres ved hver INSERT, UPDATE og DELETE på tabellen, men ingen forespørgsel har læst det siden seneste " "Indexet opdateres ved hver INSERT, UPDATE og DELETE på tabellen, men ingen forespørgsel har læst det, så længe SQLInsight har fulgt det "
+ "genstart. Det koster skrivninger, transaktionslog, backup-størrelse og vedligeholdelsestid.", + "(også hen over genstarter af SQL Server). Det koster skrivninger, transaktionslog, backup-størrelse og vedligeholdelsestid.",
"Deaktivér indexet (ALTER INDEX … DISABLE) i stedet for at slette det, så definitionen bevares, og vent en hel " "Deaktivér indexet (ALTER INDEX … DISABLE) i stedet for at slette det, så definitionen bevares, og vent en hel "
+ "forretningscyklus (måneds- og kvartalsafslutning, rapporter). Er det stadig ubrugt, kan det slettes. Genopret med ALTER INDEX … REBUILD.", + "forretningscyklus (måneds- og kvartalsafslutning, rapporter). Er det stadig ubrugt, kan det slettes. Genopret med ALTER INDEX … REBUILD.",
[ [
new Evidence("Index", $"{x.Index.Name} ({IndexText.Columns(x.Index.KeyColumns)})"), new Evidence("Index", $"{x.Index.Name} ({IndexText.Columns(x.Index.KeyColumns)})"),
new Evidence("Skrivninger / læsninger", $"{Format.Number(x.Index.UserUpdates)} / 0"), new Evidence("Skrivninger / læsninger", $"{Format.Number(Math.Max(x.Index.UserUpdates, x.Index.TrackedWrites))} / 0"),
new Evidence("Størrelse", $"{Format.Number(x.Index.SizeMb)} MB"), new Evidence("Størrelse", $"{Format.Number(x.Index.SizeMb)} MB"),
new Evidence("Periode", $"siden genstart for {Format.Age(snapshot.Uptime)} siden"), new Evidence("Periode", $"fulgt i {Format.Age(snapshot.TrackedFor(x.Index))} (seneste genstart for {Format.Age(snapshot.Uptime)} siden)"),
], ],
subject: $"index: {x.Db}.{x.Index.QualifiedTable}.{x.Index.Name}", subject: $"index: {x.Db}.{x.Index.QualifiedTable}.{x.Index.Name}",
fixScript: $"{IndexText.Use(x.Db)}\nALTER INDEX {IndexText.Quote(x.Index.Name!)} ON {IndexText.Table(x.Index)} DISABLE;", fixScript: $"{IndexText.Use(x.Db)}\nALTER INDEX {IndexText.Quote(x.Index.Name!)} ON {IndexText.Table(x.Index)} DISABLE;",
@ -330,12 +338,15 @@ public sealed class StaleStatisticsRule : Rule
{ {
ArgumentNullException.ThrowIfNull(context); ArgumentNullException.ThrowIfNull(context);
return Result(IndexText.Snapshot(context).Databases return Result(IndexText.Snapshot(context).Databases
.SelectMany(db => db.Statistics.Where(s => s.Rows >= MinRows && s.Modifications >= s.Rows * MinModifiedShare).Select(s => (Db: db.Name, Stat: s))) .SelectMany(db => db.Statistics.Where(s => s.LastUpdated is null && s.Rows > 0 || s.Rows >= MinRows && s.Modifications >= s.Rows * MinModifiedShare)
.OrderByDescending(x => x.Stat.Modifications) .Select(s => (Db: db.Name, Stat: s)))
.OrderByDescending(x => x.Stat.LastUpdated is null).ThenByDescending(x => x.Stat.Modifications)
.Take(IndexText.MaxFindingsPerRule) .Take(IndexText.MaxFindingsPerRule)
.Select(x => CreateFinding( .Select(x => CreateFinding(
Severity.Low, Severity.Low,
$"{x.Db}.{x.Stat.Schema}.{x.Stat.Table}: statistikken {x.Stat.Name} er {Format.Number(100.0 * x.Stat.Modifications / x.Stat.Rows)} % ændret", x.Stat.LastUpdated is null
? $"{x.Db}.{x.Stat.Schema}.{x.Stat.Table}: statistikken {x.Stat.Name} er aldrig opdateret ({Format.Number(x.Stat.Rows)} rækker)"
: $"{x.Db}.{x.Stat.Schema}.{x.Stat.Table}: statistikken {x.Stat.Name} er {Format.Number(100.0 * x.Stat.Modifications / x.Stat.Rows)} % ændret",
"Optimizeren vælger planer ud fra statistikkernes billede af data. Når en stor del af rækkerne er ændret siden seneste " "Optimizeren vælger planer ud fra statistikkernes billede af data. Når en stor del af rækkerne er ændret siden seneste "
+ "opdatering, rammer estimaterne ved siden af: for små memory grants, forkerte join-typer og spild til tempdb.", + "opdatering, rammer estimaterne ved siden af: for små memory grants, forkerte join-typer og spild til tempdb.",
"Opdatér statistikkerne som en del af det faste vedligehold (fx Ola Hallengrens IndexOptimize med @UpdateStatistics = 'ALL' og " "Opdatér statistikkerne som en del af det faste vedligehold (fx Ola Hallengrens IndexOptimize med @UpdateStatistics = 'ALL' og "

View file

@ -0,0 +1,148 @@
using System.Globalization;
using SQLInsight.Core.Collection;
using SQLInsight.Core.Data;
using SQLInsight.Core.Findings;
using SQLInsight.Core.Instances;
using SQLInsight.Rules.Engine;
namespace SQLInsight.Rules.Instance;
/// <summary>
/// Important entries in the SQL Server error log, summed per error number and kept open for a day after they were
/// last seen. Messages can name logins and client addresses: they are sensitive evidence.
/// </summary>
public abstract class ErrorLogRule(ErrorLogCategory category, bool needsText) : Rule
{
public override IReadOnlyCollection<Capability> RequiredCapabilities => [Capability.DynamicManagementViews];
protected abstract Severity SeverityOf(ErrorLogGroup group);
protected abstract string TitleOf(ErrorLogGroup group);
protected abstract string Explanation { get; }
protected abstract string Recommendation { get; }
protected virtual IEnumerable<ErrorLogGroup> Relevant(ErrorLogSnapshot snapshot) => snapshot.Recent.Where(g => g.Category == category);
public override RuleEvaluation Evaluate(RuleContext context)
{
ArgumentNullException.ThrowIfNull(context);
var snapshot = context.Data.Get<ErrorLogSnapshot>();
if (needsText && snapshot.Source != ErrorLogSource.ErrorLog)
{
// Not the snapshot's coverage text: that recommends ALTER TRACE, which does not make 833 or stack dumps visible.
throw new DataUnavailableException(ErrorLogCollectorText.NeedsErrorLog);
}
return Result(Relevant(snapshot).Select(g => CreateFinding(
SeverityOf(g),
TitleOf(g),
Explanation,
Recommendation,
[
new Evidence("Fejl", g.Error?.ToString(CultureInfo.InvariantCulture) ?? "–"),
new Evidence("Alvor (severity)", g.Severity?.ToString(CultureInfo.InvariantCulture) ?? "–"),
new Evidence("Antal", g.Count.ToString(CultureInfo.InvariantCulture)),
new Evidence("Første", Utc(g.FirstAt)),
new Evidence("Seneste", Utc(g.LastAt)),
new Evidence("Eksempel", g.Sample ?? "(kilden har ikke teksten)", Sensitive: true),
new Evidence("Kilde", snapshot.Source switch { ErrorLogSource.ErrorLog => "fejlloggen", ErrorLogSource.DefaultTrace => "default trace", _ => "exception ring buffer" }),
],
subject: $"errorlog: {category}/{g.Error}")));
}
private static string Utc(DateTimeOffset value) => value.UtcDateTime.ToString("yyyy-MM-dd HH:mm 'UTC'", CultureInfo.InvariantCulture);
}
internal static class ErrorLogCollectorText
{
public const string NeedsErrorLog =
"Langsom I/O (833) og stack dumps står kun i selve fejlloggen (xp_readerrorlog), som kræver serverrollen securityadmin; ALTER TRACE er ikke nok. "
+ "SQLInsight giver ikke agenten securityadmin som standard, fordi rollen kan tildele rettigheder. Ønskes det alligevel: "
+ "ALTER SERVER ROLE securityadmin ADD MEMBER [agentens login]; (SQL Server 2005-2008 R2: EXEC sp_addsrvrolemember N'agentens login', N'securityadmin';)";
}
public sealed class CorruptionErrorRule() : ErrorLogRule(ErrorLogCategory.Corruption, needsText: false)
{
public override string Id => "SQLI-LOG-001";
public override string Title => "SQL Server har læst beskadigede sider (fejl 823/824/825)";
protected override Severity SeverityOf(ErrorLogGroup group) => group.Error == 825 ? Severity.High : Severity.Critical;
protected override string TitleOf(ErrorLogGroup group) => group.Error == 825
? $"SQL Server måtte læse sider igen for at få dem (fejl 825, {group.Count} gange)"
: $"SQL Server har læst beskadigede sider (fejl {group.Error}, {group.Count} gange)";
protected override string Explanation =>
"823 og 824 betyder, at en side i en database var beskadiget eller ikke kunne læses: data kan være tabt. 825 betyder, at læsningen først lykkedes "
+ "efter flere forsøg, et tidligt varsel om fejlende storage. Begge peger næsten altid på disk, controller, driver eller SAN.";
protected override string Recommendation =>
"Kør DBCC CHECKDB på databasen nu, og sørg for, at der findes en god backup og log-backups. Tjek Windows' systemlog og storage for fejl. "
+ "Se msdb.dbo.suspect_pages for de berørte sider. Ret ikke med REPAIR_ALLOW_DATA_LOSS uden at have talt med en DBA.";
}
public sealed class SlowIoErrorRule() : ErrorLogRule(ErrorLogCategory.SlowIo, needsText: true)
{
public override string Id => "SQLI-LOG-002";
public override string Title => "I/O-forespørgsler, der tager over 15 sekunder (fejl 833)";
protected override Severity SeverityOf(ErrorLogGroup group) => Severity.High;
protected override string TitleOf(ErrorLogGroup group) => $"Storage har brugt over 15 sekunder på I/O ({group.Count} gange)";
protected override string Explanation =>
"SQL Server skriver fejl 833, når en læsning eller skrivning har ventet over 15 sekunder. Brugerne oplever det som hængende forespørgsler, "
+ "og det skyldes næsten altid storage: overbelastede diske, SAN, multipathing, en virtuel disk på en travl host eller en backup der kører samtidig.";
protected override string Recommendation =>
"Se hvilken fil og hvilket drev meddelelsen nævner (Diskhastighed pr. drev), og tjek storage og hosten på tidspunktet. Fejlen skal ikke ignoreres.";
}
public sealed class SevereErrorRule() : ErrorLogRule(ErrorLogCategory.Severe, needsText: false)
{
public override string Id => "SQLI-LOG-003";
public override string Title => "Alvorlige fejl i SQL Servers fejllog (severity 17 og derover)";
protected override Severity SeverityOf(ErrorLogGroup group) => group.Severity >= 20 ? Severity.High : Severity.Medium;
protected override string TitleOf(ErrorLogGroup group) => $"Fejl {group.Error} med severity {group.Severity} ({group.Count} gange)";
protected override string Explanation =>
"Severity 17–19 er fejl, brugeren ikke selv kan rette (mangel på ressourcer som hukommelse, låse eller plads). Severity 20 og derover er fatale: "
+ "forbindelsen afbrydes, og ved 22–25 kan databasen eller instansen være berørt.";
protected override string Recommendation =>
"Slå fejlnummeret op (Microsofts liste over databasemotorfejl) og se meddelelsen i fejlloggen. Gentagne fejl af samme slags skal undersøges.";
}
public sealed class LoginFailureBurstRule() : ErrorLogRule(ErrorLogCategory.LoginFailed, needsText: false)
{
public override string Id => "SQLI-LOG-004";
public override string Title => "Mange mislykkede logins";
protected override Severity SeverityOf(ErrorLogGroup group) => Severity.Medium;
protected override string TitleOf(ErrorLogGroup group) => $"{group.Count} mislykkede logins på kort tid";
protected override string Explanation =>
"Mange mislykkede logins (fejl 18456) på kort tid er enten en applikation eller tjeneste med et forkert eller udløbet kodeord, eller et forsøg på at gætte kodeord.";
protected override string Recommendation =>
"Se fejlloggen for login og klientadresse (State fortæller hvorfor: 8 = forkert kodeord, 5 = ukendt login, 38 = database findes ikke). "
+ "Ret applikationens forbindelse, eller blokér adressen, hvis det er et angreb. Overvej Windows-godkendelse og at deaktivere sa.";
/// <summary>Only a burst in the latest interval, not the sum of a whole day.</summary>
protected override IEnumerable<ErrorLogGroup> Relevant(ErrorLogSnapshot snapshot) =>
snapshot.Groups.Where(g => g.Category == ErrorLogCategory.LoginFailed && g.Count >= snapshot.LoginFailureBurst);
}
public sealed class StackDumpRule() : ErrorLogRule(ErrorLogCategory.StackDump, needsText: true)
{
public override string Id => "SQLI-LOG-005";
public override string Title => "SQL Server har lavet et stack dump";
protected override Severity SeverityOf(ErrorLogGroup group) => Severity.High;
protected override string TitleOf(ErrorLogGroup group) => $"SQL Server har lavet stack dump ({group.Count} gange)";
protected override string Explanation =>
"Et stack dump betyder en intern fejl i SQL Server (access violation, assertion, en tråd der ikke svarer). Det kan skyldes en kendt fejl "
+ "rettet i en senere CU, tredjepartssoftware i processen eller hardware.";
protected override string Recommendation =>
"Find SQLDump*.mdmp/.txt i SQL Servers logmappe og meddelelsen i fejlloggen. Installér seneste CU (se versionsfund), og kontakt Microsoft eller en DBA, hvis det gentager sig.";
}
public sealed class AvailabilityGroupErrorRule() : ErrorLogRule(ErrorLogCategory.AvailabilityGroup, needsText: false)
{
public override string Id => "SQLI-LOG-006";
public override string Title => "Fejl i Always On availability groups";
protected override Severity SeverityOf(ErrorLogGroup group) => Severity.High;
protected override string TitleOf(ErrorLogGroup group) => $"Availability group-fejl {group.Error} ({group.Count} gange)";
protected override string Explanation =>
"Fejlloggen viser fejl fra Always On: failover, tabt forbindelse til en replika, lease-timeout eller en database, der ikke synkroniserer.";
protected override string Recommendation =>
"Se AG-fundene og AG-dashboardet i SSMS, Windows-clusterets log og netværket mellem replikaerne på tidspunktet.";
}

View file

@ -0,0 +1,51 @@
using SQLInsight.Core.Collection;
using SQLInsight.Core.Data;
using SQLInsight.Core.Findings;
using SQLInsight.Core.Instances;
using SQLInsight.Rules.Engine;
namespace SQLInsight.Rules.Instance;
/// <summary>
/// A SQL Server service set to start automatically is not running: SQL Server Agent (no backups or maintenance jobs),
/// the Full-text launcher (no full-text search) or Launchpad (no R/Python). From sys.dm_server_services, SQL Server
/// 2008 R2 SP1+.
/// </summary>
public sealed class StoppedServiceRule : Rule
{
public override string Id => "SQLI-SVC-001";
public override string Title => "En SQL Server-tjeneste, der skal starte automatisk, kører ikke";
public override IReadOnlyCollection<Capability> RequiredCapabilities => [Capability.VolumeStats];
public override RuleEvaluation Evaluate(RuleContext context)
{
ArgumentNullException.ThrowIfNull(context);
var setup = context.Data.Get<InstanceConfigurationSnapshot>();
if (!setup.ServicesAvailable)
{
throw new DataUnavailableException("Tjenesterne kunne ikke læses (sys.dm_server_services).");
}
return Result(setup.Services
.Where(s => string.Equals(s.StartupType, "Automatic", StringComparison.OrdinalIgnoreCase)
&& !string.Equals(s.Status, "Running", StringComparison.OrdinalIgnoreCase))
.Select(s => CreateFinding(
Severity.High,
$"Tjenesten {s.Name} kører ikke",
$"{s.Name} er sat til at starte automatisk, men står som \"{s.Status}\". "
+ (s.Name.Contains("Agent", StringComparison.OrdinalIgnoreCase)
? "Uden SQL Server Agent kører hverken backup, integritetstjek, indexvedligehold eller andre planlagte job."
: s.Name.Contains("Full-text", StringComparison.OrdinalIgnoreCase) || s.Name.Contains("Launcher", StringComparison.OrdinalIgnoreCase)
? "Uden den virker fulltext-søgning og -opdatering ikke."
: "Funktionerne, der bruger tjenesten, virker ikke."),
"Start tjenesten i SQL Server Configuration Manager, og find i Windows' hændelseslog (System) eller tjenestens egen log, hvorfor den stoppede. "
+ "Tjek også, at tjenestekontoens kodeord ikke er udløbet.",
[
new Evidence("Tjeneste", s.Name),
new Evidence("Status", s.Status ?? "ukendt"),
new Evidence("Start", s.StartupType ?? "–"),
new Evidence("Konto", s.Account ?? "–", Sensitive: true),
],
subject: $"service: {s.Name}")));
}
}

View file

@ -0,0 +1,123 @@
using System.Globalization;
using SQLInsight.Core.Data;
using SQLInsight.Core.Findings;
using SQLInsight.Core.Instances;
using SQLInsight.Rules.Engine;
namespace SQLInsight.Rules.Instance;
/// <summary>
/// The version store in tempdb (row versions for snapshot isolation, READ_COMMITTED_SNAPSHOT, triggers, online index
/// operations) is large or keeps growing: something keeps old versions alive, usually a long-open transaction.
/// </summary>
public sealed class VersionStoreRule : Rule
{
public override string Id => "SQLI-TEMP-001";
public override string Title => "Version store i tempdb er stor eller vokser";
public override IReadOnlyCollection<Capability> RequiredCapabilities => [Capability.DynamicManagementViews];
public override RuleEvaluation Evaluate(RuleContext context)
{
ArgumentNullException.ThrowIfNull(context);
var t = context.Data.Get<TempdbSnapshot>();
var large = t.VersionStoreMegabytes >= t.VersionStoreThresholdMegabytes;
var growing = TempdbAnalysis.IsGrowing(t.VersionStoreHistory);
if (!large && !growing)
{
return RuleEvaluation.Passed();
}
var evidence = new List<Evidence>
{
new("Version store", $"{Format.Number(t.VersionStoreMegabytes)} MB"),
new("Grænse", $"{t.VersionStoreThresholdMegabytes.ToString(CultureInfo.InvariantCulture)} MB"),
new("Udvikling", string.Join(" → ", t.VersionStoreHistory.TakeLast(6).Select(v => Format.Number(v)))),
};
if (t.VersionGenerators.Count > 0)
{
evidence.Add(new("Databaser, der laver versioner", string.Join(", ", t.VersionGenerators.Take(5).Select(g => $"{g.DatabaseName} {Format.Number(g.Megabytes)} MB"))));
}
if (t.OldestSnapshotTransaction is { } oldest)
{
evidence.Add(new("Ældste snapshot-transaktion", $"session {oldest.SessionId}, {Format.Age(TimeSpan.FromSeconds(oldest.ElapsedSeconds))}"));
evidence.Add(new("Dens login og program", $"{oldest.LoginName} {oldest.ProgramName}".Trim(), Sensitive: true));
}
return RuleEvaluation.Found(CreateFinding(
Severity.Medium,
large ? $"Version store i tempdb fylder {Format.Number(t.VersionStoreMegabytes)} MB" : "Version store i tempdb vokser støt",
"Version store holder gamle udgaver af rækker til snapshot isolation og READ_COMMITTED_SNAPSHOT (AX 2009/2012 bruger det), triggere og online "
+ "indexvedligehold. Versionerne kan først fjernes, når den ældste transaktion, der kan have brug for dem, er afsluttet. Vokser den hele tiden, "
+ "er det næsten altid en transaktion, der har stået åben længe.",
"Find den ældste snapshot-transaktion (se målingerne) eller lange transaktioner under Lige nu på instansens side, og få den afsluttet. "
+ "Se hvilke databaser, der laver flest versioner. Sørg for, at tempdb har plads nok, så den ikke løber fuld imens.",
evidence,
subject: "tempdb: version store"));
}
}
/// <summary>tempdb is nearly full and cannot grow: queries needing it will fail.</summary>
public sealed class TempdbFullRule : Rule
{
public const double FreePercent = 10;
public override string Id => "SQLI-TEMP-002";
public override string Title => "tempdb er næsten fuld";
public override IReadOnlyCollection<Capability> RequiredCapabilities => [Capability.DynamicManagementViews];
public override RuleEvaluation Evaluate(RuleContext context)
{
ArgumentNullException.ThrowIfNull(context);
var t = context.Data.Get<TempdbSnapshot>();
if (t.CanGrow || t.FreePercent >= FreePercent)
{
return RuleEvaluation.Passed();
}
return RuleEvaluation.Found(CreateFinding(
Severity.High,
$"tempdb har kun {Format.Number(t.FreePercent)} % fri plads og kan ikke vokse",
"Når tempdb er fuld, fejler sorteringer, temp-tabeller, version store og indexvedligehold (fejl 1105/9002), og SQL Server kan blive ustabil.",
"Find hvad der fylder (version store, brugerobjekter eller interne objekter, og de sessioner, der bruger mest). Giv tempdb mere plads "
+ "eller autogrowth, og løs årsagen (ofte en lang transaktion eller en forespørgsel, der sorterer enorme mængder).",
[
new Evidence("Størrelse", $"{Format.Number(t.SizeMegabytes)} MB"),
new Evidence("Fri", $"{Format.Number(t.FreeMegabytes)} MB"),
new Evidence("Version store", $"{Format.Number(t.VersionStoreMegabytes)} MB"),
new Evidence("Brugerobjekter", $"{Format.Number(t.UserObjectsMegabytes)} MB"),
new Evidence("Interne objekter", $"{Format.Number(t.InternalObjectsMegabytes)} MB"),
],
subject: "tempdb: full"));
}
}
/// <summary>A session that has used a lot of tempdb for a long time.</summary>
public sealed class TempdbSessionRule : Rule
{
public override string Id => "SQLI-TEMP-003";
public override string Title => "Session bruger meget tempdb i lang tid";
public override IReadOnlyCollection<Capability> RequiredCapabilities => [Capability.DynamicManagementViews];
public override RuleEvaluation Evaluate(RuleContext context)
{
ArgumentNullException.ThrowIfNull(context);
var t = context.Data.Get<TempdbSnapshot>();
return Result(t.TopSessions
.Where(s => t.HeavySessionsSince.TryGetValue(s.Key, out var since) && t.CapturedAt - since >= TimeSpan.FromMinutes(TempdbAnalysis.HeavySessionMinutes))
.Select(s => CreateFinding(
Severity.Medium,
$"Session {s.SessionId} har brugt over {Format.Number(t.SessionThresholdMegabytes)} MB tempdb i over {TempdbAnalysis.HeavySessionMinutes} minutter",
"En session holder store temp-tabeller, tabelvariabler eller interne objekter (sorteringer, hash-tabeller, spools) i tempdb. "
+ "Det fylder tempdb for alle andre og tyder på en tung forespørgsel eller temp-tabeller, der ikke ryddes op.",
"Se hvad sessionen kører (Lige nu på instansens side). Ryd temp-tabeller op, når de ikke bruges, og se på forespørgsler, der spilder til tempdb.",
[
new Evidence("Brugt", $"{Format.Number(s.UsedMegabytes)} MB"),
new Evidence("Over grænsen siden", t.HeavySessionsSince[s.Key].UtcDateTime.ToString("yyyy-MM-dd HH:mm 'UTC'", CultureInfo.InvariantCulture)),
new Evidence("Session", s.SessionId.ToString(CultureInfo.InvariantCulture)),
new Evidence("Login", s.LoginName ?? "–", Sensitive: true),
new Evidence("Program", s.ProgramName ?? "–", Sensitive: true),
],
subject: $"tempdb session: {s.SessionId}/{s.Since.UtcDateTime:yyyyMMddHHmmss}")));
}
}

View file

@ -46,6 +46,11 @@ public static class ApiV1Endpoints
api.MapGet("/instances/{name}/configuration", ConfigurationAsync).WithSummary("Konfiguration og ændringslog / Configuration and change log"); api.MapGet("/instances/{name}/configuration", ConfigurationAsync).WithSummary("Konfiguration og ændringslog / Configuration and change log");
api.MapGet("/instances/{name}/privileges", InstancePrivilegesAsync).WithSummary("Rettigheder og ændringer på en instans / Privileges on an instance"); api.MapGet("/instances/{name}/privileges", InstancePrivilegesAsync).WithSummary("Rettigheder og ændringer på en instans / Privileges on an instance");
api.MapGet("/privileges", AllPrivilegesAsync).WithSummary("Rettigheder og ændringer på alle instanser / Privileges on all instances"); api.MapGet("/privileges", AllPrivilegesAsync).WithSummary("Rettigheder og ændringer på alle instanser / Privileges on all instances");
api.MapGet("/instances/{name}/activity", ActivityAsync).WithSummary("Åbne transaktioner og lange sætninger lige nu / Open transactions and long-running statements");
api.MapGet("/instances/{name}/tempdb", TempdbAsync).WithSummary("tempdb og version store lige nu / tempdb and the version store");
api.MapGet("/instances/{name}/replication", ReplicationAsync).WithSummary("Replikering set fra distributøren / Replication from the distributor");
api.MapGet("/instances/{name}/index-usage", IndexUsageAsync).WithSummary("Indexbrug over tid, på tværs af genstarter / Index usage over time (days)");
api.MapGet("/instances/{name}/error-log", ErrorLogAsync).WithSummary("Vigtige hændelser i fejlloggen / Important error log events (days)");
api.MapGet("/instances/{name}/wait-types/{waitType}", WaitTypeAsync).WithSummary("En ventetype: forklaring, udvikling og sætninger / One wait type"); api.MapGet("/instances/{name}/wait-types/{waitType}", WaitTypeAsync).WithSummary("En ventetype: forklaring, udvikling og sætninger / One wait type");
} }
@ -325,6 +330,11 @@ public static class ApiV1Endpoints
}), }),
Deadlocks = a.Deadlocks.Select(d => new { d.DeadlockId, d.OccurredAt, Processes = d.Processes.Count, Graph = $"/deadlocks/{d.DeadlockId}.xdl" }), Deadlocks = a.Deadlocks.Select(d => new { d.DeadlockId, d.OccurredAt, Processes = d.Processes.Count, Graph = $"/deadlocks/{d.DeadlockId}.xdl" }),
LongOperations = a.LongOperations.Select(o => new { o.Command, o.DatabaseName, LoginName = redact.Value(o.LoginName), o.StartedAt, o.LastSeenAt, o.PercentComplete, o.EstimatedEnd }), LongOperations = a.LongOperations.Select(o => new { o.Command, o.DatabaseName, LoginName = redact.Value(o.LoginName), o.StartedAt, o.LastSeenAt, o.PercentComplete, o.EstimatedEnd }),
LongTransactions = a.LongTransactions.Select(t => new
{
t.SessionId, t.BeganAt, t.LastSeenAt, t.DatabaseName, LogMegabytes = t.MaxLogBytes / 1024.0 / 1024.0,
LoginName = redact.Value(t.LoginName), HostName = redact.Value(t.HostName), ProgramName = redact.Value(t.ProgramName), Statement = redact.Value(t.Statement),
}),
Findings = a.Findings.Select(f => FindingSummary(f, redact)), Findings = a.Findings.Select(f => FindingSummary(f, redact)),
}; };
@ -493,6 +503,115 @@ public static class ApiV1Endpoints
return Results.Ok(AuditJson(audit, RedactorFor(options))); return Results.Ok(AuditJson(audit, RedactorFor(options)));
} }
private static async Task<IResult> ActivityAsync(string name, InsightRepository repository, IOptions<WebOptions> options, CancellationToken ct)
{
if (await repository.GetInstanceAsync(name, ct).ConfigureAwait(false) is not { } instance)
{
return Results.NotFound();
}
var redact = RedactorFor(options);
var a = await repository.GetSnapshotAsync<ActivitySnapshot>(instance.InstanceId, "activity", ct).ConfigureAwait(false);
return Results.Ok(new
{
Instance = instance.Name,
MeasuredAt = a?.CapturedAt,
Thresholds = a is null ? null : new { a.TransactionMinutes, a.TransactionLogMegabytes, a.RequestMinutes },
Transactions = (a?.Transactions ?? []).Select(t => new
{
t.SessionId, t.BeganAt, AgeMinutes = t.Age(a!.CapturedAt).TotalMinutes, t.DatabaseName, t.Databases, t.LogMegabytes, t.SessionStatus, t.WaitType,
LoginName = redact.Value(t.LoginName), HostName = redact.Value(t.HostName), ProgramName = redact.Value(t.ProgramName), Statement = redact.Value(t.Statement),
}),
LongRequests = (a?.LongRequests ?? []).Select(r => new
{
r.SessionId, r.StartedAt, ElapsedMinutes = r.ElapsedMs / 60000.0, r.Command, r.Status, r.WaitType, r.DatabaseName, r.BlockingSessionId,
LoginName = redact.Value(r.LoginName), HostName = redact.Value(r.HostName), ProgramName = redact.Value(r.ProgramName), Statement = redact.Value(r.Statement),
}),
});
}
private static async Task<IResult> TempdbAsync(string name, InsightRepository repository, IOptions<WebOptions> options, CancellationToken ct)
{
if (await repository.GetInstanceAsync(name, ct).ConfigureAwait(false) is not { } instance)
{
return Results.NotFound();
}
var redact = RedactorFor(options);
var t = await repository.GetSnapshotAsync<TempdbSnapshot>(instance.InstanceId, "tempdb", ct).ConfigureAwait(false);
return Results.Ok(t is null ? new { Instance = instance.Name } : (object)new
{
Instance = instance.Name,
MeasuredAt = t.CapturedAt,
t.SizeMegabytes, t.FreeMegabytes, t.FreePercent, t.CanGrow, t.VersionStoreMegabytes, t.UserObjectsMegabytes, t.InternalObjectsMegabytes,
t.VersionStoreThresholdMegabytes, t.VersionStoreHistory, t.VersionGenerators,
OldestSnapshotTransaction = t.OldestSnapshotTransaction is { } o
? new { o.SessionId, o.ElapsedSeconds, LoginName = redact.Value(o.LoginName), ProgramName = redact.Value(o.ProgramName) }
: null,
TopSessions = t.TopSessions.Select(s => new
{
s.SessionId, s.Since, s.UsedMegabytes, s.Status, LoginName = redact.Value(s.LoginName), ProgramName = redact.Value(s.ProgramName), HostName = redact.Value(s.HostName),
}),
Explanation = new { VersionStore = MetricAssessment.Explanation(Metrics.VersionStoreMegabytes), Used = MetricAssessment.Explanation(Metrics.TempdbUsedMegabytes) },
});
}
private static async Task<IResult> ReplicationAsync(string name, InsightRepository repository, IOptions<WebOptions> options, CancellationToken ct)
{
if (await repository.GetInstanceAsync(name, ct).ConfigureAwait(false) is not { } instance)
{
return Results.NotFound();
}
var redact = RedactorFor(options);
var r = await repository.GetSnapshotAsync<ReplicationSnapshot>(instance.InstanceId, "replication", ct).ConfigureAwait(false);
return Results.Ok(new
{
Instance = instance.Name,
MeasuredAt = r?.CapturedAt,
IsDistributor = r?.IsDistributor,
r?.DistributionDatabase,
r?.Unavailable,
UnavailableDetail = redact.Value(r?.UnavailableDetail),
r?.ErrorsLastDay,
Agents = (r?.Agents ?? []).Select(a => new { a.Name, a.PublisherDatabase, a.RunStatus, a.StatusText, a.LastRunAt, a.DeliveryLatencyMs, Comment = redact.Value(a.Comment) }),
});
}
private static async Task<IResult> IndexUsageAsync(string name, InsightRepository repository, CancellationToken ct, int? days = null)
{
if (await repository.GetInstanceAsync(name, ct).ConfigureAwait(false) is not { } instance)
{
return Results.NotFound();
}
var span = Math.Clamp(days ?? 30, 1, 400);
var now = DateTimeOffset.UtcNow;
var usage = await repository.GetIndexUsageAsync(instance.InstanceId, now.AddDays(-span), now, 1000, ct).ConfigureAwait(false);
return Results.Ok(new { Instance = instance.Name, Days = span, Indexes = usage });
}
private static async Task<IResult> ErrorLogAsync(string name, InsightRepository repository, IOptions<WebOptions> options, CancellationToken ct, int? days = null)
{
if (await repository.GetInstanceAsync(name, ct).ConfigureAwait(false) is not { } instance)
{
return Results.NotFound();
}
var redact = RedactorFor(options);
var now = DateTimeOffset.UtcNow;
var snapshot = await repository.GetSnapshotAsync<ErrorLogSnapshot>(instance.InstanceId, "error-log", ct).ConfigureAwait(false);
var events = await repository.GetErrorLogEventsAsync(instance.InstanceId, now.AddDays(-Math.Clamp(days ?? 7, 1, 90)), now, 500, ct).ConfigureAwait(false);
return Results.Ok(new
{
Instance = instance.Name,
Source = snapshot?.Source.ToString(),
snapshot?.Coverage,
ReadAt = snapshot?.CapturedAt,
Events = events.Select(e => new { e.Category, e.Error, e.Severity, e.Count, e.FirstAt, e.LastAt, Sample = redact.Value(e.Sample), e.Source }),
});
}
private static async Task<IResult> WaitTypeAsync(string name, string waitType, InsightRepository repository, IOptions<WebOptions> options, CancellationToken ct, private static async Task<IResult> WaitTypeAsync(string name, string waitType, InsightRepository repository, IOptions<WebOptions> options, CancellationToken ct,
DateTimeOffset? from = null, DateTimeOffset? to = null) DateTimeOffset? from = null, DateTimeOffset? to = null)
{ {

View file

@ -42,6 +42,7 @@
[ [
new("Overblik", "/"), new("Overblik", "/"),
new("Alle fund", "/fund", "/fund/"), new("Alle fund", "/fund", "/fund/"),
new("Fejllog", "/vaelg/fejllog", "/fejllog"),
]), ]),
new("analyse", "Analyse", "chart", new("analyse", "Analyse", "chart",
[ [

View file

@ -155,6 +155,11 @@
<LongOperationTable Operations="_analysis.LongOperations" Empty="Ingen operationer med fremskridt i tidsrummet." /> <LongOperationTable Operations="_analysis.LongOperations" Empty="Ingen operationer med fremskridt i tidsrummet." />
</section> </section>
<section class="card">
<div class="card-head"><h2>Lange og store transaktioner</h2><span class="muted">åbne mindst et minut eller med over 100 MB log</span></div>
<TransactionTable Transactions="_analysis.LongTransactions" Empty="Ingen lange eller store transaktioner i tidsrummet." />
</section>
<div class="grid2"> <div class="grid2">
<section class="card"> <section class="card">
<div class="card-head" id="blocking"><h2>Blocking</h2></div> <div class="card-head" id="blocking"><h2>Blocking</h2></div>

View file

@ -43,6 +43,7 @@ else
["ventetyper"] = ("Ventetyper", "#ventetider"), ["ventetyper"] = ("Ventetyper", "#ventetider"),
["disk"] = ("Diskhastighed", "#disk"), ["disk"] = ("Diskhastighed", "#disk"),
["konfiguration"] = ("Konfiguration", "/konfiguration"), ["konfiguration"] = ("Konfiguration", "/konfiguration"),
["fejllog"] = ("Fejllog", "/fejllog"),
}; };
[Parameter] public string Side { get; set; } = string.Empty; [Parameter] public string Side { get; set; } = string.Empty;

View file

@ -0,0 +1,105 @@
@page "/instanser/{Name}/fejllog"
@inject InsightRepository Repository
@inject Display Display
@if (_instance is null)
{
<PageTitle>Ukendt instans · SQLInsight Pro</PageTitle>
<h1>Ukendt instans</h1>
<p>Der findes ingen instans med navnet <strong>@Name</strong>.</p>
return;
}
<PageTitle>Fejllog · @_instance.Name · SQLInsight Pro</PageTitle>
<p class="crumbs"><a href="/">Instanser</a> / <a href="/instanser/@Uri.EscapeDataString(_instance.Name)">@_instance.Name</a> / Fejllog</p>
<h1>Fejllog</h1>
<p class="lead">Vigtige hændelser i SQL Servers fejllog: beskadigede sider, langsom I/O, alvorlige fejl, mislykkede logins, stack dumps og availability group-fejl. Gentagelser er lagt sammen. Læses hvert 5. minut.</p>
@if (_snapshot is { } s)
{
<p class="muted">Kilde: @SourceText(s.Source), senest læst @Display.Ago(s.CapturedAt.UtcDateTime).</p>
@if (s.Coverage is { } coverage)
{
<div class="alert warn">@coverage</div>
}
}
else
{
<p class="muted">Fejlloggen er endnu ikke læst. Den læses første gang inden for 5 minutter efter opgradering.</p>
}
<section class="card">
<div class="card-head"><h2>Hændelser</h2><span class="muted">seneste @Dage dage</span></div>
<PeriodLinks Days="Dage!.Value" />
@if (_events.Count == 0)
{
<p class="muted">Ingen vigtige hændelser i perioden.</p>
}
else
{
<table>
<thead><tr><th>Seneste</th><th>Type</th><th class="num">Fejl</th><th class="num">Severity</th><th class="num">Antal</th><th>Eksempel</th></tr></thead>
<tbody>
@foreach (var e in _events)
{
<tr class="@(e.Category is "Corruption" or "StackDump" ? "bad" : "")">
<td class="nowrap">@Display.Time(e.LastAt)</td>
<td>@CategoryText(e.Category)</td>
<td class="num">@(e.Error?.ToString() ?? "–")</td>
<td class="num">@(e.Severity?.ToString() ?? "–")</td>
<td class="num">@e.Count</td>
<td class="mono small">@(e.Sample ?? "–")</td>
</tr>
}
</tbody>
</table>
}
</section>
<section class="card">
<details>
<summary>Hvad betyder det?</summary>
<p>SQL Servers fejllog er instansens dagbog. SQLInsight Pro bruger den bedste kilde, agentens rettigheder giver: selve fejlloggen kun med serverrollen securityadmin (gives ikke som standard), med rettigheden ALTER TRACE (valgfri i grant-least-privilege.sql) default trace med alvorlige fejl og mislykkede logins, og ellers kun fejlnumre fra SQL Servers exception ring buffer. Langsom I/O (833) og stack dumps ses kun i selve fejlloggen. Beskadigede sider (823/824/825) og I/O over 15 sekunder (833) peger næsten altid på storage og skal undersøges med det samme.</p>
</details>
</section>
@code {
[Parameter] public string Name { get; set; } = string.Empty;
[SupplyParameterFromQuery] public int? Dage { get; set; }
private InstanceOverview? _instance;
private ErrorLogSnapshot? _snapshot;
private IReadOnlyList<StoredErrorLogEvent> _events = [];
protected override async Task OnParametersSetAsync()
{
Dage = Math.Clamp(Dage ?? 7, 1, 90);
_instance = await Repository.GetInstanceAsync(Name);
if (_instance is null)
{
return;
}
_snapshot = await Repository.GetSnapshotAsync<ErrorLogSnapshot>(_instance.InstanceId, "error-log");
var now = DateTimeOffset.UtcNow;
_events = await Repository.GetErrorLogEventsAsync(_instance.InstanceId, now.AddDays(-Dage.Value), now);
}
public static string SourceText(ErrorLogSource source) => source switch
{
ErrorLogSource.ErrorLog => "fejlloggen",
ErrorLogSource.DefaultTrace => "default trace (alvorlige fejl og mislykkede logins)",
_ => "exception ring buffer (kun fejlnumre)",
};
public static string CategoryText(string category) => category switch
{
"Corruption" => "Beskadigede sider",
"SlowIo" => "I/O over 15 s",
"LoginFailed" => "Mislykkede logins",
"StackDump" => "Stack dump",
"AvailabilityGroup" => "Availability group",
"Severe" => "Alvorlig fejl",
_ => category,
};
}

View file

@ -21,6 +21,7 @@
<p class="report-links"> <p class="report-links">
<a class="button" href="/instanser/@Uri.EscapeDataString(_instance.Name)/konfiguration">Konfiguration</a> <a class="button" href="/instanser/@Uri.EscapeDataString(_instance.Name)/konfiguration">Konfiguration</a>
<a class="button" href="/instanser/@Uri.EscapeDataString(_instance.Name)/rettigheder">Rettigheder</a> <a class="button" href="/instanser/@Uri.EscapeDataString(_instance.Name)/rettigheder">Rettigheder</a>
<a class="button" href="/instanser/@Uri.EscapeDataString(_instance.Name)/fejllog">Fejllog</a>
</p> </p>
<PrivilegeAlert Findings="_findings" /> <PrivilegeAlert Findings="_findings" />
@ -135,6 +136,75 @@ else
<LongOperationTable Operations="_operations" Empty="Ingen backup, restore, DBCC eller lignende med fremskridt de seneste 24 timer." /> <LongOperationTable Operations="_operations" Empty="Ingen backup, restore, DBCC eller lignende med fremskridt de seneste 24 timer." />
</section> </section>
<section class="card" id="tempdb">
<div class="card-head"><h2>tempdb lige nu</h2><span class="muted">@(_tempdb is null ? "endnu ikke målt" : "målt " + Display.Ago(_tempdb.CapturedAt.UtcDateTime))</span></div>
@if (_tempdb is { } td)
{
<table class="kv">
<tbody>
<tr><th>Størrelse / fri</th><td class="@(!td.CanGrow && td.FreePercent < 10 ? "bad" : "")">@Display.Number(td.SizeMegabytes) MB / @Display.Number(td.FreeMegabytes) MB (@Display.Number(td.FreePercent) %)@(td.CanGrow ? "" : ", kan ikke vokse")</td></tr>
<tr><th>Version store</th><td class="@(td.VersionStoreMegabytes >= td.VersionStoreThresholdMegabytes ? "bad" : "")">@Display.Number(td.VersionStoreMegabytes) MB@(td.VersionGenerators.Count > 0 ? " · " + string.Join(", ", td.VersionGenerators.Take(3).Select(g => $"{g.DatabaseName} {Display.Number(g.Megabytes)} MB")) : "")</td></tr>
<tr><th>Brugerobjekter / interne</th><td>@Display.Number(td.UserObjectsMegabytes) MB / @Display.Number(td.InternalObjectsMegabytes) MB</td></tr>
<tr><th>Ældste snapshot-transaktion</th><td>@(td.OldestSnapshotTransaction is { } o ? $"session {o.SessionId}, {TransactionTable.Duration(TimeSpan.FromSeconds(o.ElapsedSeconds))}" : "ingen")</td></tr>
<tr><th>Største forbrugere</th><td>@(td.TopSessions.Count == 0 ? "–" : string.Join(", ", td.TopSessions.Take(5).Select(s => $"session {s.SessionId} {Display.Number(s.UsedMegabytes)} MB")))</td></tr>
</tbody>
</table>
}
</section>
@if (_replication is { IsDistributor: true } repl)
{
<section class="card" id="replikering">
<div class="card-head"><h2>Replikering</h2><span class="muted">distributionsdatabase @repl.DistributionDatabase</span></div>
@if (repl.Unavailable is { } why)
{
<p class="muted">@why@(repl.UnavailableDetail is { } detail ? " (" + detail + ")" : "")</p>
}
else if (repl.Agents.Count == 0)
{
<p class="muted">Ingen distributionsagenter.</p>
}
else
{
<table>
<thead><tr><th>Agent</th><th>Database</th><th>Status</th><th>Seneste kørsel</th><th class="num">Forsinkelse</th></tr></thead>
<tbody>
@foreach (var a in repl.Agents)
{
<tr class="@(a.Failed ? "bad" : "")"><td class="mono">@a.Name</td><td>@a.PublisherDatabase</td><td>@a.StatusText</td><td class="nowrap">@Display.Time(a.LastRunAt?.UtcDateTime)</td><td class="num">@(a.DeliveryLatencyMs is { } ms ? Display.Number(ms / 1000.0) + " s" : "–")</td></tr>
}
</tbody>
</table>
<p class="muted">Replikeringsfejl det seneste døgn: @repl.ErrorsLastDay</p>
}
</section>
}
<section class="card" id="aktivitet">
<div class="card-head"><h2>Lige nu: åbne transaktioner og lange sætninger</h2><span class="muted">@(_activity is null ? "endnu ikke målt" : "målt " + Display.Ago(_activity.CapturedAt.UtcDateTime))</span></div>
@if (_activity is not { } activity || activity.Transactions.Count == 0 && activity.LongRequests.Count == 0)
{
<p class="muted">Ingen åbne brugertransaktioner eller sætninger over grænsen.</p>
}
else
{
<table class="queries">
<thead><tr><th>Type</th><th class="num">Varighed</th><th>Database</th><th class="num">Log</th><th>Session</th><th>Ventetype</th><th>Sætning</th></tr></thead>
<tbody>
@foreach (var t in activity.Transactions.OrderByDescending(t => t.Age(activity.CapturedAt)).Take(20))
{
<tr class="@(t.Age(activity.CapturedAt).TotalMinutes >= activity.TransactionMinutes ? "bad" : "")"><td>Transaktion</td><td class="num nowrap">@TransactionTable.Duration(t.Age(activity.CapturedAt))</td><td>@(t.DatabaseName ?? "–")</td><td class="num nowrap">@Display.Number(t.LogMegabytes) MB</td><td class="small">@t.SessionId <span class="muted">@t.LoginName @t.ProgramName</span></td><td>@(t.WaitType ?? "–")</td><td><pre class="sql">@(t.Statement ?? "–")</pre></td></tr>
}
@foreach (var r in activity.LongRequests)
{
<tr class="bad"><td>Sætning</td><td class="num nowrap">@TransactionTable.Duration(TimeSpan.FromMilliseconds(r.ElapsedMs))</td><td>@(r.DatabaseName ?? "–")</td><td class="num">–</td><td class="small">@r.SessionId <span class="muted">@r.LoginName @r.ProgramName</span></td><td>@(r.WaitType ?? "kører")</td><td><pre class="sql">@(r.Statement ?? "–")</pre></td></tr>
}
</tbody>
</table>
}
<details><summary>Hvad betyder det?</summary><p>En åben transaktion holder sine låse og forhindrer, at transaktionsloggen og tempdb's version store kan genbruges. Over @(_activity?.TransactionMinutes ?? 10) minutter er det et fund, og over @(_activity?.TransactionLogMegabytes ?? 1024) MB log også. Sætninger, der har kørt over @(_activity?.RequestMinutes ?? 15) minutter (undtagen backup, restore og DBCC, som står under langvarige operationer), er også et fund. Grænserne sættes under SQLInsight:Activity.</p></details>
</section>
<div class="grid2"> <div class="grid2">
<section class="card"> <section class="card">
<div class="card-head"><h2>Blocking, seneste 24 timer</h2></div> <div class="card-head"><h2>Blocking, seneste 24 timer</h2></div>
@ -276,6 +346,9 @@ else
private IReadOnlyList<DiskSeries> _diskSeries = []; private IReadOnlyList<DiskSeries> _diskSeries = [];
private IReadOnlyList<StoredBlocking> _blocking = []; private IReadOnlyList<StoredBlocking> _blocking = [];
private IReadOnlyList<StoredLongOperation> _operations = []; private IReadOnlyList<StoredLongOperation> _operations = [];
private ActivitySnapshot? _activity;
private TempdbSnapshot? _tempdb;
private ReplicationSnapshot? _replication;
private IReadOnlyList<StoredDeadlock> _deadlocks = []; private IReadOnlyList<StoredDeadlock> _deadlocks = [];
protected override async Task OnInitializedAsync() protected override async Task OnInitializedAsync()
@ -309,6 +382,9 @@ else
_diskSeries = await Repository.GetDiskSeriesAsync(id, day); _diskSeries = await Repository.GetDiskSeriesAsync(id, day);
_blocking = await Repository.GetBlockingAsync(id, day); _blocking = await Repository.GetBlockingAsync(id, day);
_operations = await Repository.GetLongOperationsAsync(id, day, DateTimeOffset.UtcNow, 50); _operations = await Repository.GetLongOperationsAsync(id, day, DateTimeOffset.UtcNow, 50);
_activity = await Repository.GetSnapshotAsync<ActivitySnapshot>(id, "activity");
_tempdb = await Repository.GetSnapshotAsync<TempdbSnapshot>(id, "tempdb");
_replication = await Repository.GetSnapshotAsync<ReplicationSnapshot>(id, "replication");
_deadlocks = await Repository.GetDeadlocksAsync(id, DateTimeOffset.UtcNow.AddDays(-7)); _deadlocks = await Repository.GetDeadlocksAsync(id, DateTimeOffset.UtcNow.AddDays(-7));
} }

View file

@ -0,0 +1,33 @@
@inject Display Display
@* Long or large user transactions. Logins and statements are customer data: shown locally only. *@
@if (Transactions.Count == 0)
{
<p class="muted">@Empty</p>
}
else
{
<table class="queries">
<thead><tr><th>Åben siden</th><th class="num">Varighed</th><th>Database</th><th class="num">Log</th><th>Session</th><th>Sætning</th></tr></thead>
<tbody>
@foreach (var t in Transactions)
{
<tr>
<td class="nowrap">@Display.Time(t.BeganAt)</td>
<td class="num nowrap">@Duration(t.Duration)</td>
<td>@(t.DatabaseName ?? "–")</td>
<td class="num nowrap">@Display.Number(t.MaxLogBytes / 1024.0 / 1024.0) MB</td>
<td class="small">@t.SessionId <span class="muted">@t.LoginName @t.ProgramName</span></td>
<td><pre class="sql">@(t.Statement ?? "–")</pre></td>
</tr>
}
</tbody>
</table>
}
@code {
[Parameter, EditorRequired] public IReadOnlyList<StoredTransaction> Transactions { get; set; } = [];
[Parameter] public string Empty { get; set; } = "Ingen lange transaktioner.";
public static string Duration(TimeSpan value) => value.TotalHours >= 1 ? $"{(int)value.TotalHours} t {value.Minutes} min" : $"{Math.Max(0, (int)value.TotalMinutes)} min";
}

View file

@ -0,0 +1,54 @@
using SQLInsight.Core.Data;
namespace SQLInsight.Core.Tests;
public class ErrorLogTests
{
private static readonly DateTimeOffset T = new(2026, 10, 2, 8, 0, 0, TimeSpan.Zero);
[Fact]
public void Number_lines_are_joined_to_the_message_that_follows()
{
var entries = ErrorLogAnalysis.ParseLines(
[
(T, "Error: 824, Severity: 24, State: 2."),
(T, "SQL Server detected a logical consistency-based I/O error: incorrect checksum."),
(T.AddSeconds(1), "SQL Server has encountered 3 occurrence(s) of I/O requests taking longer than 15 seconds to complete on file [D:\\Data\\AX.mdf]"),
(T.AddSeconds(2), "Login failed for user 'sa'. Reason: Password did not match. [CLIENT: 10.0.0.9]"),
(T.AddSeconds(3), "Error: 17310, Severity: 20, State: 1."),
]);
Assert.Equal(4, entries.Count);
Assert.Equal((824, 24), (entries[0].Error, entries[0].Severity));
Assert.Contains("checksum", entries[0].Message, StringComparison.Ordinal);
Assert.Equal(833, entries[1].Error);
Assert.Equal(18456, entries[2].Error);
Assert.Equal(17310, entries[3].Error);
}
[Theory]
[InlineData(825, 10, null, ErrorLogCategory.Corruption)]
[InlineData(833, 10, null, ErrorLogCategory.SlowIo)]
[InlineData(18456, 14, null, ErrorLogCategory.LoginFailed)]
[InlineData(35206, 16, null, ErrorLogCategory.AvailabilityGroup)]
[InlineData(null, null, "***Stack Dump being sent to C:\\…\\SQLDump0001.txt", ErrorLogCategory.StackDump)]
[InlineData(701, 17, null, ErrorLogCategory.Severe)]
[InlineData(5701, 10, "Changed database context to 'master'.", ErrorLogCategory.Other)]
public void Entries_are_categorized(int? error, int? severity, string? message, ErrorLogCategory expected) =>
Assert.Equal(expected, ErrorLogAnalysis.Categorize(new ErrorLogEntry(T, error, severity, message)));
[Fact]
public void Repetitions_are_summed_and_kept_for_a_day()
{
var first = ErrorLogAnalysis.Group(Enumerable.Range(0, 30).Select(i => new ErrorLogEntry(T.AddSeconds(i), 18456, 14, "Login failed for user 'x'.")));
var later = ErrorLogAnalysis.Group([new ErrorLogEntry(T.AddHours(2), 18456, 14, "Login failed for user 'y'.")]);
var login = Assert.Single(first);
Assert.Equal((30, T, T.AddSeconds(29)), (login.Count, login.FirstAt, login.LastAt));
var recent = Assert.Single(ErrorLogAnalysis.Recent(first, later, T.AddHours(2)));
Assert.Equal(31, recent.Count);
Assert.Contains("'y'", recent.Sample, StringComparison.Ordinal);
Assert.Single(ErrorLogAnalysis.Recent(first, later, T.AddHours(25))); // the first batch has expired
Assert.Equal(1, ErrorLogAnalysis.Recent(first, later, T.AddHours(25))[0].Count);
}
}

View file

@ -0,0 +1,45 @@
using SQLInsight.Core.Data;
namespace SQLInsight.Core.Tests;
public class IndexUsageTests
{
private static readonly DateTime Start = new(2026, 9, 1, 6, 0, 0, DateTimeKind.Utc);
private static IndexSnapshot Snapshot(DateTime start, DateTimeOffset at, long reads, long writes) => new()
{
CapturedAt = at,
SqlServerStartTime = start,
Databases = [new IndexDatabase { Name = "AX", Indexes = [new IndexInfo { Schema = "dbo", Table = "INVENTTRANS", IndexId = 5, Name = "I_177ITEMIDX", Type = "NONCLUSTERED", UserSeeks = reads, UserUpdates = writes }] }],
};
[Fact]
public void Usage_is_summed_across_restarts_and_tracked_from_the_first_restart_seen()
{
var first = IndexUsageTracking.Advance(null, Snapshot(Start, new DateTimeOffset(Start.AddDays(1)), 10, 100));
var second = IndexUsageTracking.Advance(first, Snapshot(Start, new DateTimeOffset(Start.AddDays(2)), 15, 300));
var restart = Start.AddDays(3);
var afterRestart = IndexUsageTracking.Advance(second, Snapshot(restart, new DateTimeOffset(restart.AddHours(6)), 2, 50));
var index = afterRestart.Databases[0].Indexes[0];
Assert.Equal((17L, 350L), (index.TrackedReads, index.TrackedWrites));
Assert.Equal(new DateTimeOffset(Start), index.TrackedSince);
Assert.Equal(TimeSpan.FromDays(3.25), afterRestart.TrackedFor(index));
Assert.Equal(TimeSpan.FromHours(6), afterRestart.Uptime);
var delta = Assert.Single(second.UsageDeltas);
Assert.Equal((5L, 200L), (delta.Reads, delta.Writes));
Assert.Equal((2L, 50L), (afterRestart.UsageDeltas[0].Reads, afterRestart.UsageDeltas[0].Writes)); // counters started over
}
[Fact]
public void A_counter_reset_without_a_restart_counts_what_the_counter_holds_now()
{
// The database was taken offline (or closed by AUTO_CLOSE) between the collections: same start time, lower counters.
var first = IndexUsageTracking.Advance(null, Snapshot(Start, new DateTimeOffset(Start.AddDays(1)), 100, 1000));
var second = IndexUsageTracking.Advance(first, Snapshot(Start, new DateTimeOffset(Start.AddDays(2)), 7, 40));
var delta = Assert.Single(second.UsageDeltas);
Assert.Equal((7L, 40L), (delta.Reads, delta.Writes));
Assert.Equal((107L, 1040L), (second.Databases[0].Indexes[0].TrackedReads, second.Databases[0].Indexes[0].TrackedWrites));
}
}

View file

@ -0,0 +1,113 @@
using Microsoft.AspNetCore.Hosting;
using Microsoft.AspNetCore.Mvc.Testing;
using Microsoft.Data.SqlClient;
using SQLInsight.Collectors.Concurrency;
using SQLInsight.Core.Data;
using SQLInsight.Core.Instances;
using SQLInsight.Repository;
namespace SQLInsight.IntegrationTests;
/// <summary>Open transactions and long-running statements, as the least-privilege agent sees them.</summary>
[Collection(SqlServerTestGroup.Name)]
[Trait("Category", "Integration")]
public class ActivityTests(SqlServerFixture sql)
{
private SqlConnection Open(string application) => new(new SqlConnectionStringBuilder(sql.DatabaseConnectionString(SqlServerFixture.TestDatabase))
{
ApplicationName = application, CommandTimeout = 120,
}.ConnectionString);
[Fact]
public async Task An_open_transaction_and_a_statement_blocked_by_it_are_seen_but_not_the_agent()
{
var table = "Activity_" + Guid.NewGuid().ToString("N")[..6];
await sql.ExecuteAsSaAsync($"USE [{SqlServerFixture.TestDatabase}]; CREATE TABLE dbo.{table} (Id int PRIMARY KEY, Name nvarchar(20)); INSERT dbo.{table} VALUES (1, N'a');");
await using var holder = Open("Lager-job");
await holder.OpenAsync();
await using var transaction = holder.BeginTransaction();
await using (var update = new SqlCommand($"UPDATE dbo.{table} SET Name = N'b' WHERE Id = 1;", holder, transaction))
{
await update.ExecuteNonQueryAsync();
}
await using var reader = Open("Rapport");
await reader.OpenAsync();
var blocked = new SqlCommand($"SELECT Name FROM dbo.{table} WHERE Id = 1;", reader).ExecuteScalarAsync();
await using var agentLike = Open("SQLInsight Pro (test)");
await agentLike.OpenAsync();
await using var agentTransaction = agentLike.BeginTransaction();
await using (var touch = new SqlCommand("SELECT 1;", agentLike, agentTransaction))
{
await touch.ExecuteScalarAsync();
}
try
{
await Task.Delay(1500);
await using var session = await AgentSession.OpenAsync(sql);
var snapshot = await session.CollectAsync<ActivitySnapshot>(new ActivityCollector(transactionMinutes: 0, requestMinutes: 0));
var open = Assert.Single(snapshot.Transactions, t => t.ProgramName == "Lager-job");
Assert.Equal(SqlServerFixture.TestDatabase, open.DatabaseName);
Assert.True(open.LogBytesUsed > 0);
Assert.Contains(table, open.Statement, StringComparison.Ordinal);
Assert.DoesNotContain(snapshot.Transactions, t => t.ProgramName?.StartsWith("SQLInsight Pro", StringComparison.Ordinal) == true);
var waiting = Assert.Single(snapshot.LongRequests, r => r.ProgramName == "Rapport");
Assert.Equal("LCK_M_S", waiting.WaitType);
Assert.Equal(open.SessionId, waiting.BlockingSessionId);
}
finally
{
await transaction.RollbackAsync();
await agentTransaction.RollbackAsync();
await blocked;
await sql.ExecuteAsSaAsync($"USE [{SqlServerFixture.TestDatabase}]; DROP TABLE dbo.{table};");
}
}
[Fact]
public async Task Page_analysis_and_api_show_transactions_with_sensitive_values_left_out()
{
var database = "SQLInsightAct_" + Guid.NewGuid().ToString("N")[..8];
await sql.ExecuteAsSaAsync($"CREATE DATABASE [{database}];");
try
{
var repository = new InsightRepository(sql.DatabaseConnectionString(database));
await repository.MigrateAsync();
var now = DateTimeOffset.UtcNow;
var id = await repository.UpsertInstanceAsync("prod-sql01", new InstanceInfo
{
ServerName = "SQL01", Version = SqlServerVersion.Parse("16.0.4135.4"), ProductLevel = "RTM", Edition = "Standard Edition", EngineEdition = EngineEdition.Standard,
}, "SqlClient", false, now);
await repository.SaveCollectedAsync(id, "activity", new ActivitySnapshot
{
CapturedAt = now,
Transactions = [new OpenTransaction(61, 4711, "user_transaction", now.AddMinutes(-25), 300L * 1024 * 1024, 0, "AXDB", 1, @"KUNDE\anne", "PC01", "Ax32.exe", "sleeping", null, "UPDATE INVENTSUM SET POSTEDQTY = 0")],
});
await using var factory = new WebApplicationFactory<Program>().WithWebHostBuilder(b =>
{
b.UseEnvironment("Development");
b.UseSetting("SQLInsight:Repository:ConnectionString", sql.DatabaseConnectionString(database));
b.UseSetting("SQLInsight:Web:Authentication", "None");
});
var client = factory.CreateClient();
var page = await client.GetStringAsync("/instanser/prod-sql01");
var api = await client.GetStringAsync("/api/v1/instances/prod-sql01/activity");
var analysis = await client.GetStringAsync("/api/v1/instances/prod-sql01/analysis?from=" + Uri.EscapeDataString(now.AddHours(-1).ToString("o")));
Assert.Contains("UPDATE INVENTSUM", page, StringComparison.Ordinal);
Assert.Contains("AXDB", api, StringComparison.Ordinal);
Assert.DoesNotContain("INVENTSUM", api, StringComparison.Ordinal);
Assert.DoesNotContain("anne", api, StringComparison.Ordinal);
Assert.Contains("\"longTransactions\":[{", analysis, StringComparison.Ordinal);
Assert.DoesNotContain("INVENTSUM", analysis, StringComparison.Ordinal);
Assert.Single(await repository.GetLongTransactionsAsync(id, now.AddHours(-1), now.AddMinutes(1)));
}
finally
{
await sql.ExecuteAsSaAsync($"ALTER DATABASE [{database}] SET SINGLE_USER WITH ROLLBACK IMMEDIATE; DROP DATABASE [{database}];");
}
}
}

View file

@ -267,6 +267,10 @@ public class CollectorTests(SqlServerFixture sql)
END; END;
EXEC (N'CREATE OR ALTER FUNCTION dbo.fn_PlanProbePrice (@p int) RETURNS int AS BEGIN RETURN @p * DATEPART(ms, GETDATE()); END;'); EXEC (N'CREATE OR ALTER FUNCTION dbo.fn_PlanProbePrice (@p int) RETURNS int AS BEGIN RETURN @p * DATEPART(ms, GETDATE()); END;');
"""); """);
// The rest of the suite fills the plan cache with heavier statements; start from an empty cache (test only:
// SQLInsight itself never clears it).
await sql.ExecuteAsSaAsync("DBCC FREEPROCCACHE WITH NO_INFOMSGS;");
// Run in the database (not after USE in a master batch) so the plans belong to it, and put the tags inside the // Run in the database (not after USE in a master batch) so the plans belong to it, and put the tags inside the
// statements: the statement text ends at the statement, not at a trailing comment. // statements: the statement text ends at the statement, not at a trailing comment.
await using (var connection = new Microsoft.Data.SqlClient.SqlConnection(sql.DatabaseConnectionString(SqlServerFixture.TestDatabase))) await using (var connection = new Microsoft.Data.SqlClient.SqlConnection(sql.DatabaseConnectionString(SqlServerFixture.TestDatabase)))

View file

@ -0,0 +1,129 @@
using Microsoft.AspNetCore.Hosting;
using Microsoft.AspNetCore.Mvc.Testing;
using Microsoft.Data.SqlClient;
using SQLInsight.Collectors.Instance;
using SQLInsight.Core.Data;
using SQLInsight.Core.Instances;
using SQLInsight.Repository;
namespace SQLInsight.IntegrationTests;
/// <summary>The error log as the least-privilege agent sees it: ring buffer without ALTER TRACE, default trace with it.</summary>
[Collection(SqlServerTestGroup.Name)]
[Trait("Category", "Integration")]
public class ErrorLogTests(SqlServerFixture sql)
{
private static async Task<ErrorLogSnapshot> UntilAsync(AgentSession session, ErrorLogCollector collector, ErrorLogSnapshot previous, Func<ErrorLogSnapshot, bool> done,
int seconds = 30)
{
var latest = previous;
// SQL Server writes the default trace in buffers: events can take a while to become readable.
for (var i = 0; i < seconds; i++)
{
await Task.Delay(1000);
latest = await session.CollectAsync(collector, latest with { Recent = latest.Recent });
if (done(latest))
{
break;
}
}
return latest;
}
[Fact]
public async Task Without_alter_trace_failed_logins_come_from_the_ring_buffer_with_the_reason()
{
await using var session = await AgentSession.OpenAsync(sql);
var collector = new ErrorLogCollector();
var first = await session.CollectAsync<ErrorLogSnapshot>(collector);
for (var i = 0; i < 3; i++)
{
var wrong = new SqlConnectionStringBuilder(sql.AgentConnectionString) { Password = "forkert-" + i, Pooling = false };
await using var connection = new SqlConnection(wrong.ConnectionString);
await Assert.ThrowsAsync<SqlException>(() => connection.OpenAsync());
}
var latest = await UntilAsync(session, collector, first, s => s.Recent.Any(g => g.Category == ErrorLogCategory.LoginFailed && g.Count >= 3));
Assert.Equal(ErrorLogSource.RingBuffer, first.Source);
Assert.Contains("ALTER TRACE", first.Coverage, StringComparison.Ordinal);
var logins = Assert.Single(latest.Recent, g => g.Category == ErrorLogCategory.LoginFailed);
Assert.True(logins.Count >= 3);
Assert.Contains("state 8", logins.Sample, StringComparison.Ordinal);
}
[Fact]
public async Task With_alter_trace_the_error_log_and_failed_logins_are_read_from_the_default_trace()
{
await sql.ExecuteAsSaAsync($"GRANT ALTER TRACE TO [{SqlServerFixture.AgentLogin}];");
try
{
await using var session = await AgentSession.OpenAsync(sql);
var collector = new ErrorLogCollector(loginFailureBurst: 3);
var first = await session.CollectAsync<ErrorLogSnapshot>(collector);
await Assert.ThrowsAsync<SqlException>(() => sql.ExecuteAsSaAsync("RAISERROR (N'SQLInsight test written to the error log', 20, 1) WITH LOG;"));
for (var i = 0; i < 4; i++)
{
var wrong = new SqlConnectionStringBuilder(sql.AgentConnectionString) { Password = "forkert-" + i, Pooling = false };
await using var connection = new SqlConnection(wrong.ConnectionString);
await Assert.ThrowsAsync<SqlException>(() => connection.OpenAsync());
}
var latest = await UntilAsync(session, collector, first,
s => s.Recent.Any(g => g.Error == 50000) && s.Recent.Any(g => g.Category == ErrorLogCategory.LoginFailed && g.Count >= 4), seconds: 120);
Assert.Equal(ErrorLogSource.DefaultTrace, latest.Source);
Assert.Contains("securityadmin", latest.Coverage, StringComparison.Ordinal);
var logged = Assert.Single(latest.Recent, g => g.Error == 50000);
Assert.Contains("SQLInsight test written to the error log", logged.Sample, StringComparison.Ordinal);
Assert.True(latest.Recent.Single(g => g.Category == ErrorLogCategory.LoginFailed).Count >= 4);
}
finally
{
await sql.ExecuteAsSaAsync($"REVOKE ALTER TRACE FROM [{SqlServerFixture.AgentLogin}];");
}
}
[Fact]
public async Task Page_and_api_show_events_with_the_sample_left_out_of_the_api()
{
var database = "SQLInsightLog_" + Guid.NewGuid().ToString("N")[..8];
await sql.ExecuteAsSaAsync($"CREATE DATABASE [{database}];");
try
{
var repository = new InsightRepository(sql.DatabaseConnectionString(database));
await repository.MigrateAsync();
var now = DateTimeOffset.UtcNow;
var id = await repository.UpsertInstanceAsync("prod-sql01", new InstanceInfo
{
ServerName = "SQL01", Version = SqlServerVersion.Parse("16.0.4135.4"), ProductLevel = "RTM", Edition = "Standard Edition", EngineEdition = EngineEdition.Standard,
}, "SqlClient", false, now);
var groups = new[] { new ErrorLogGroup(ErrorLogCategory.Corruption, 824, 24, 2, now.AddMinutes(-4), now.AddMinutes(-1), "incorrect checksum in AX.mdf") };
await repository.SaveCollectedAsync(id, "error-log", new ErrorLogSnapshot
{
CapturedAt = now, Source = ErrorLogSource.DefaultTrace, ReadUntil = now, Groups = groups, Recent = groups,
});
await using var factory = new WebApplicationFactory<Program>().WithWebHostBuilder(b =>
{
b.UseEnvironment("Development");
b.UseSetting("SQLInsight:Repository:ConnectionString", sql.DatabaseConnectionString(database));
b.UseSetting("SQLInsight:Web:Authentication", "None");
});
var client = factory.CreateClient();
var page = await client.GetStringAsync("/instanser/prod-sql01/fejllog");
var api = await client.GetStringAsync("/api/v1/instances/prod-sql01/error-log");
Assert.Contains("Beskadigede sider", page, StringComparison.Ordinal);
Assert.Contains("incorrect checksum", page, StringComparison.Ordinal);
Assert.Contains("824", api, StringComparison.Ordinal);
Assert.Contains("[udeladt]", api, StringComparison.Ordinal);
Assert.DoesNotContain("incorrect checksum", api, StringComparison.Ordinal);
}
finally
{
await sql.ExecuteAsSaAsync($"ALTER DATABASE [{database}] SET SINGLE_USER WITH ROLLBACK IMMEDIATE; DROP DATABASE [{database}];");
}
}
}

View file

@ -0,0 +1,66 @@
using Microsoft.AspNetCore.Hosting;
using Microsoft.AspNetCore.Mvc.Testing;
using SQLInsight.Collectors.Databases;
using SQLInsight.Core.Data;
using SQLInsight.Core.Instances;
using SQLInsight.Repository;
namespace SQLInsight.IntegrationTests;
/// <summary>Index usage followed over time, and statistics that were never updated.</summary>
[Collection(SqlServerTestGroup.Name)]
[Trait("Category", "Integration")]
public class IndexUsageTests(SqlServerFixture sql)
{
[Fact]
public async Task Usage_between_collections_is_stored_per_day_and_never_updated_statistics_are_seen()
{
var table = "Usage_" + Guid.NewGuid().ToString("N")[..6];
await sql.ExecuteAsSaAsync($"""
USE [{SqlServerFixture.TestDatabase}];
CREATE TABLE dbo.{table} (Id int IDENTITY PRIMARY KEY, Code int NOT NULL, Name nvarchar(20));
CREATE INDEX IX_{table}_Code ON dbo.{table} (Code);
CREATE STATISTICS ST_{table}_Name ON dbo.{table} (Name) WITH NORECOMPUTE;
INSERT dbo.{table} (Code, Name) SELECT TOP (500) object_id % 100, N'x' FROM sys.all_objects;
""");
var database = "SQLInsightIdx_" + Guid.NewGuid().ToString("N")[..8];
await sql.ExecuteAsSaAsync($"CREATE DATABASE [{database}];");
try
{
await using var session = await AgentSession.OpenAsync(sql);
var first = await session.CollectAsync<IndexSnapshot>(new IndexCollector());
await sql.ExecuteAsSaAsync($"USE [{SqlServerFixture.TestDatabase}]; SELECT COUNT(*) FROM dbo.{table} WITH (INDEX(IX_{table}_Code)) WHERE Code = 7;");
var second = await session.CollectAsync(new IndexCollector(), first);
var index = second.Databases.Single(d => d.Name == SqlServerFixture.TestDatabase).Indexes.Single(i => i.Name == $"IX_{table}_Code");
Assert.True(index.TrackedReads >= 1);
Assert.NotNull(index.TrackedSince);
var delta = Assert.Single(second.UsageDeltas, d => d.Index == $"IX_{table}_Code");
Assert.True(delta.Reads >= 1);
var never = second.Databases.Single(d => d.Name == SqlServerFixture.TestDatabase).Statistics.Single(s => s.Name == $"ST_{table}_Name");
Assert.Null(never.LastUpdated);
Assert.Equal(500, never.Rows); // the table's rows, not the statistic's
var repository = new InsightRepository(sql.DatabaseConnectionString(database));
await repository.MigrateAsync();
var id = await repository.UpsertInstanceAsync("prod-sql01", session.Instance, "SqlClient", false, DateTimeOffset.UtcNow);
await repository.SaveCollectedAsync(id, "indexes", second);
var usage = await repository.GetIndexUsageAsync(id, DateTimeOffset.UtcNow.AddDays(-1), DateTimeOffset.UtcNow);
Assert.Contains(usage, u => u.IndexName == $"IX_{table}_Code" && u.Reads >= 1 && u.Days == 1);
await using var factory = new WebApplicationFactory<Program>().WithWebHostBuilder(b =>
{
b.UseEnvironment("Development");
b.UseSetting("SQLInsight:Repository:ConnectionString", sql.DatabaseConnectionString(database));
b.UseSetting("SQLInsight:Web:Authentication", "None");
});
var api = await factory.CreateClient().GetStringAsync("/api/v1/instances/prod-sql01/index-usage?days=7");
Assert.Contains($"IX_{table}_Code", api, StringComparison.Ordinal);
}
finally
{
await sql.ExecuteAsSaAsync($"ALTER DATABASE [{database}] SET SINGLE_USER WITH ROLLBACK IMMEDIATE; DROP DATABASE [{database}];");
await sql.ExecuteAsSaAsync($"USE [{SqlServerFixture.TestDatabase}]; DROP TABLE dbo.{table};");
}
}
}

View file

@ -0,0 +1,38 @@
using SQLInsight.Collectors.Instance;
using SQLInsight.Core.Data;
namespace SQLInsight.IntegrationTests;
/// <summary>Replication on a distributor, as the least-privilege agent: not readable without rights, readable with SELECT on the agent tables.</summary>
[Collection(SqlServerTestGroup.Name)]
[Trait("Category", "Integration")]
public class ReplicationTests(SqlServerFixture sql)
{
[Fact]
public async Task A_distributor_is_unreadable_without_rights_and_readable_with_select_on_the_agent_tables()
{
await using var session = await AgentSession.OpenAsync(sql);
var before = await session.CollectAsync<ReplicationSnapshot>(new ReplicationCollector());
Assert.False(before.IsDistributor);
await sql.ExecuteAsSaAsync("DECLARE @s sysname; SET @s = @@SERVERNAME; EXEC sp_adddistributor @distributor = @s, @password = N'Distr-Passw0rd!';");
await sql.ExecuteAsSaAsync("EXEC sp_adddistributiondb @database = N'distribution';");
try
{
var denied = await session.CollectAsync<ReplicationSnapshot>(new ReplicationCollector());
await sql.ExecuteAsSaAsync($"USE distribution; IF USER_ID(N'{SqlServerFixture.AgentLogin}') IS NULL CREATE USER [{SqlServerFixture.AgentLogin}] FOR LOGIN [{SqlServerFixture.AgentLogin}]; GRANT SELECT ON dbo.MSdistribution_agents TO [{SqlServerFixture.AgentLogin}]; GRANT SELECT ON dbo.MSdistribution_history TO [{SqlServerFixture.AgentLogin}]; GRANT SELECT ON dbo.MSrepl_errors TO [{SqlServerFixture.AgentLogin}];");
var readable = await session.CollectAsync<ReplicationSnapshot>(new ReplicationCollector());
Assert.True(denied.IsDistributor);
Assert.Contains("GRANT SELECT ON dbo.MSdistribution_agents", denied.Unavailable, StringComparison.Ordinal);
Assert.True(readable.Unavailable is null, readable.Unavailable);
Assert.Empty(readable.Agents);
Assert.Equal(0, readable.ErrorsLastDay);
}
finally
{
await sql.ExecuteAsSaAsync("EXEC sp_dropdistributiondb @database = N'distribution';");
await sql.ExecuteAsSaAsync("EXEC sp_dropdistributor @no_checks = 1, @ignore_distributor = 1;");
}
}
}

View file

@ -0,0 +1,82 @@
using Microsoft.AspNetCore.Hosting;
using Microsoft.AspNetCore.Mvc.Testing;
using Microsoft.Data.SqlClient;
using SQLInsight.Collectors.Instance;
using SQLInsight.Core.Data;
using SQLInsight.Core.Instances;
using SQLInsight.Repository;
namespace SQLInsight.IntegrationTests;
/// <summary>tempdb and the version store as the least-privilege agent reads them.</summary>
[Collection(SqlServerTestGroup.Name)]
[Trait("Category", "Integration")]
public class TempdbTests(SqlServerFixture sql)
{
[Fact]
public async Task Space_version_store_and_the_session_filling_tempdb_are_read()
{
await using var user = new SqlConnection(new SqlConnectionStringBuilder(sql.SaConnectionString) { ApplicationName = "Rapport" }.ConnectionString);
await user.OpenAsync();
await using (var fill = new SqlCommand("CREATE TABLE #big (Id int, Pad char(4000)); INSERT #big SELECT TOP (3000) 1, 'x' FROM sys.all_objects a CROSS JOIN sys.all_objects b;", user))
{
await fill.ExecuteNonQueryAsync();
}
await using var session = await AgentSession.OpenAsync(sql);
var first = await session.CollectAsync<TempdbSnapshot>(new TempdbCollector(sessionMegabytes: 1));
var second = await session.CollectAsync(new TempdbCollector(sessionMegabytes: 1), first);
Assert.True(first.SizeMegabytes > 0);
Assert.InRange(first.FreeMegabytes, 0, first.SizeMegabytes);
Assert.True(first.VersionStoreMegabytes >= 0);
var heavy = Assert.Single(first.TopSessions, s => s.ProgramName == "Rapport");
Assert.True(heavy.UsedMegabytes >= 10);
Assert.Equal(2, second.VersionStoreHistory.Count);
Assert.Equal(first.HeavySessionsSince[heavy.Key], second.HeavySessionsSince[heavy.Key]); // the start is kept
}
[Fact]
public async Task Instance_page_and_api_show_tempdb_with_logins_left_out()
{
var database = "SQLInsightTmp_" + Guid.NewGuid().ToString("N")[..8];
await sql.ExecuteAsSaAsync($"CREATE DATABASE [{database}];");
try
{
var repository = new InsightRepository(sql.DatabaseConnectionString(database));
await repository.MigrateAsync();
var now = DateTimeOffset.UtcNow;
var id = await repository.UpsertInstanceAsync("prod-sql01", new InstanceInfo
{
ServerName = "SQL01", Version = SqlServerVersion.Parse("16.0.4135.4"), ProductLevel = "RTM", Edition = "Standard Edition", EngineEdition = EngineEdition.Standard,
}, "SqlClient", false, now);
await repository.SaveCollectedAsync(id, "tempdb", new TempdbSnapshot
{
CapturedAt = now, SizeMegabytes = 8192, FreeMegabytes = 1024, VersionStoreMegabytes = 2048,
TopSessions = [new TempdbSession(70, now.AddHours(-1), 3000, @"KUNDE\anne", "SSMS", "PC01", "running")],
VersionGenerators = [new VersionGenerator("AXDB", 1800)],
});
await using var factory = new WebApplicationFactory<Program>().WithWebHostBuilder(b =>
{
b.UseEnvironment("Development");
b.UseSetting("SQLInsight:Repository:ConnectionString", sql.DatabaseConnectionString(database));
b.UseSetting("SQLInsight:Web:Authentication", "None");
});
var client = factory.CreateClient();
var page = await client.GetStringAsync("/instanser/prod-sql01");
var api = await client.GetStringAsync("/api/v1/instances/prod-sql01/tempdb");
Assert.Contains("tempdb lige nu", page, StringComparison.Ordinal);
Assert.Contains("AXDB", page, StringComparison.Ordinal);
Assert.Contains("Version store (MB)", page, StringComparison.Ordinal); // the graph with its explanation
Assert.Contains("\"versionStoreMegabytes\":2048", api, StringComparison.Ordinal);
Assert.DoesNotContain("anne", api, StringComparison.Ordinal);
Assert.Single(await repository.GetMetricSamplesAsync(id, Metrics.VersionStoreMegabytes, now.AddMinutes(-5), now.AddMinutes(5)));
}
finally
{
await sql.ExecuteAsSaAsync($"ALTER DATABASE [{database}] SET SINGLE_USER WITH ROLLBACK IMMEDIATE; DROP DATABASE [{database}];");
}
}
}

View file

@ -0,0 +1,54 @@
using SQLInsight.Core.Data;
using SQLInsight.Core.Findings;
using SQLInsight.Rules.Concurrency;
using SQLInsight.Rules.Engine;
using static SQLInsight.Rules.Tests.TestInstances;
namespace SQLInsight.Rules.Tests;
public class ActivityRuleTests
{
private static RuleResult Run(IRule rule, RuleContext context) => Assert.Single(new RuleEngine([rule]).Evaluate(context));
private static OpenTransaction Tran(double minutes, long logMb = 1) =>
new(61, 4711, "user_transaction", Now.AddMinutes(-minutes), logMb * 1024 * 1024, 0, "AX", 1, @"KUNDE\anne", "PC01", "Microsoft Dynamics AX", "sleeping", null, "UPDATE INVENTSUM SET …");
private static ActivitySnapshot Snapshot(IReadOnlyList<OpenTransaction>? transactions = null, IReadOnlyList<LongRunningRequest>? requests = null) =>
new() { CapturedAt = Now, Transactions = transactions ?? [], LongRequests = requests ?? [] };
[Theory]
[InlineData(9, null)]
[InlineData(12, Severity.Medium)]
[InlineData(61, Severity.High)]
public void Open_transactions_by_age(double minutes, Severity? expected)
{
var result = Run(new LongTransactionRule(), With(Snapshot([Tran(minutes)])));
Assert.Equal(expected, result.Findings.SingleOrDefault()?.Severity);
if (expected is not null)
{
var finding = result.Findings.Single();
Assert.True(finding.Evidence.Single(e => e.Name == "Login").Sensitive);
Assert.True(finding.Evidence.Single(e => e.Name == "Sætning").Sensitive);
Assert.DoesNotContain("anne", finding.Title + finding.Subject, StringComparison.Ordinal);
}
}
[Theory]
[InlineData(500, RuleOutcome.Passed)]
[InlineData(2048, RuleOutcome.Findings)]
public void Transactions_holding_much_log(long megabytes, RuleOutcome expected) =>
Assert.Equal(expected, Run(new LargeTransactionRule(), With(Snapshot([Tran(1, megabytes)]))).Outcome);
[Fact]
public void Long_running_statements_are_found_but_not_backups_or_waitfor()
{
var select = new LongRunningRequest(70, Now.AddMinutes(-20), 20 * 60_000, "SELECT", "suspended", "LCK_M_S", 1000, "AX", "anne", "PC01", "AX", 61, "SELECT …");
Assert.True(ActivityAnalysis.IsStatement(select));
Assert.False(ActivityAnalysis.IsStatement(select with { Command = "BACKUP DATABASE" }));
Assert.False(ActivityAnalysis.IsStatement(select with { WaitType = "WAITFOR" }));
var finding = Assert.Single(Run(new LongRunningStatementRule(), With(Snapshot(requests: [select]))).Findings);
Assert.Equal("61", finding.Evidence.Single(e => e.Name == "Blokeret af").Value);
}
}

View file

@ -19,6 +19,27 @@ public class AvailabilityRuleTests
return context; return context;
} }
[Fact]
public void A_cluster_node_or_witness_that_is_not_up_is_high()
{
var snapshot = new AvailabilityGroupSnapshot
{
CapturedAt = Now, Replicas = [], Databases = [],
ClusterMembers =
[
new ClusterMember("SQL01", "CLUSTER_NODE", 1, "UP", 1),
new ClusterMember("SQL02", "CLUSTER_NODE", 0, "DOWN", 1),
new ClusterMember("Cloud Witness", "CLOUD_WITNESS", 1, "UP", 1),
],
};
var finding = Assert.Single(Run(new ClusterMemberDownRule(), Hadr(snapshot)).Findings);
Assert.Equal(Severity.High, finding.Severity);
Assert.Equal("cluster member: SQL02", finding.Subject);
Assert.Equal("2 af 3", finding.Evidence.Single(e => e.Name == "Medlemmer oppe").Value);
}
private static DatabaseReplicaState Db(string replica, bool primary, DateTime? commit = null, long redoKb = 0, long redoRate = 1000, bool suspended = false, string health = "HEALTHY") => new() private static DatabaseReplicaState Db(string replica, bool primary, DateTime? commit = null, long redoKb = 0, long redoRate = 1000, bool suspended = false, string health = "HEALTHY") => new()
{ {
Group = "ag1", Database = "Sales", Replica = replica, IsPrimaryReplica = primary, LastCommitTime = commit, Group = "ag1", Database = "Sales", Replica = replica, IsPrimaryReplica = primary, LastCommitTime = commit,

View file

@ -0,0 +1,56 @@
using SQLInsight.Core.Data;
using SQLInsight.Core.Findings;
using SQLInsight.Rules.Engine;
using SQLInsight.Rules.Instance;
using static SQLInsight.Rules.Tests.TestInstances;
namespace SQLInsight.Rules.Tests;
public class ErrorLogRuleTests
{
private static RuleResult Run(IRule rule, RuleContext context) => Assert.Single(new RuleEngine([rule]).Evaluate(context));
private static ErrorLogSnapshot Log(ErrorLogSource source, params ErrorLogGroup[] groups) => new()
{
CapturedAt = Now, Source = source, ReadUntil = Now, Groups = groups, Recent = groups, LoginFailureBurst = 20,
Coverage = source == ErrorLogSource.ErrorLog ? null : "Giv agenten ALTER TRACE eller securityadmin",
};
private static ErrorLogGroup G(ErrorLogCategory category, int error, int severity, int count = 1) =>
new(category, error, severity, count, Now.AddMinutes(-3), Now.AddMinutes(-1), "Error text with login 'anne'");
[Theory]
[InlineData(824, Severity.Critical)]
[InlineData(825, Severity.High)]
public void Corruption_is_critical_and_read_retries_high(int error, Severity expected)
{
var finding = Assert.Single(Run(new CorruptionErrorRule(), With(Log(ErrorLogSource.DefaultTrace, G(ErrorLogCategory.Corruption, error, 24)))).Findings);
Assert.Equal(expected, finding.Severity);
Assert.True(finding.Evidence.Single(e => e.Name == "Eksempel").Sensitive);
}
[Fact]
public void Slow_io_and_stack_dumps_need_the_text_of_the_log()
{
var result = Run(new SlowIoErrorRule(), With(Log(ErrorLogSource.RingBuffer)));
Assert.Equal(RuleOutcome.NotMeasurable, result.Outcome);
Assert.Contains("securityadmin ADD MEMBER", result.Reason, StringComparison.Ordinal);
Assert.Equal(RuleOutcome.NotMeasurable, Run(new SlowIoErrorRule(), With(Log(ErrorLogSource.DefaultTrace))).Outcome);
Assert.Equal(RuleOutcome.Findings, Run(new SlowIoErrorRule(), With(Log(ErrorLogSource.ErrorLog, G(ErrorLogCategory.SlowIo, 833, 10)))).Outcome);
Assert.Equal(RuleOutcome.NotMeasurable, Run(new StackDumpRule(), With(Log(ErrorLogSource.RingBuffer))).Outcome);
}
[Theory]
[InlineData(19, RuleOutcome.Passed)]
[InlineData(20, RuleOutcome.Findings)]
public void Login_failures_are_a_finding_from_the_burst_size(int count, RuleOutcome expected) =>
Assert.Equal(expected, Run(new LoginFailureBurstRule(), With(Log(ErrorLogSource.RingBuffer, G(ErrorLogCategory.LoginFailed, 18456, 14, count)))).Outcome);
[Theory]
[InlineData(17, Severity.Medium)]
[InlineData(21, Severity.High)]
public void Severe_errors_by_severity(int severity, Severity expected) =>
Assert.Equal(expected, Assert.Single(Run(new SevereErrorRule(), With(Log(ErrorLogSource.RingBuffer, G(ErrorLogCategory.Severe, 701, severity)))).Findings).Severity);
}

View file

@ -173,9 +173,10 @@ public class IndexRuleTests
Name = "Sales", Name = "Sales",
Statistics = Statistics =
[ [
new StatisticInfo { Schema = "dbo", Table = "Orders", Name = "IX_Orders_Date", Rows = 1_000_000, Modifications = 400_000 }, new StatisticInfo { Schema = "dbo", Table = "Orders", Name = "IX_Orders_Date", Rows = 1_000_000, Modifications = 400_000, LastUpdated = Now.UtcDateTime.AddDays(-9) },
new StatisticInfo { Schema = "dbo", Table = "Small", Name = "PK_Small", Rows = 500, Modifications = 500 }, new StatisticInfo { Schema = "dbo", Table = "Small", Name = "PK_Small", Rows = 500, Modifications = 500, LastUpdated = Now.UtcDateTime.AddDays(-9) },
new StatisticInfo { Schema = "dbo", Table = "Stable", Name = "PK_Stable", Rows = 1_000_000, Modifications = 1_000 }, new StatisticInfo { Schema = "dbo", Table = "Stable", Name = "PK_Stable", Rows = 1_000_000, Modifications = 1_000, LastUpdated = Now.UtcDateTime.AddDays(-9) },
new StatisticInfo { Schema = "dbo", Table = "Empty", Name = "_WA_Sys_Empty", Rows = 0, Modifications = 0 },
], ],
}; };
@ -184,6 +185,40 @@ public class IndexRuleTests
Assert.Equal("statistic: Sales.dbo.Orders.IX_Orders_Date", finding.Subject); Assert.Equal("statistic: Sales.dbo.Orders.IX_Orders_Date", finding.Subject);
} }
[Fact]
public void Statistics_never_updated_on_a_table_with_rows_are_found_first()
{
var db = new IndexDatabase
{
Name = "AX",
Statistics =
[
new StatisticInfo { Schema = "dbo", Table = "Orders", Name = "IX_Orders_Date", Rows = 1_000_000, Modifications = 400_000, LastUpdated = Now.UtcDateTime.AddDays(-9) },
new StatisticInfo { Schema = "dbo", Table = "CUSTTRANS", Name = "_WA_Sys_00000003", Rows = 120 },
],
};
var findings = Run(new StaleStatisticsRule(), Indexes(Month, db)).Findings;
Assert.Equal(2, findings.Count);
Assert.Contains("aldrig opdateret", findings[0].Title, StringComparison.Ordinal);
}
[Fact]
public void Unused_index_is_judged_on_usage_followed_across_restarts()
{
var index = Index(2, "IX_Unused", "NONCLUSTERED", ["Status"], reads: 0, updates: 50_000) with
{
TrackedReads = 0, TrackedWrites = 900_000, TrackedSince = Now.AddDays(-40),
};
var db = new IndexDatabase { Name = "Sales", Indexes = [index] };
var finding = Assert.Single(Run(new UnusedIndexRule(), Indexes(TimeSpan.FromDays(1), db)).Findings); // restarted yesterday
Assert.Contains("40 dage", finding.Title, StringComparison.Ordinal);
Assert.Equal(RuleOutcome.Passed, Run(new UnusedIndexRule(), Indexes(TimeSpan.FromDays(1), db with { Indexes = [index with { TrackedReads = 3 }] })).Outcome);
}
[Fact] [Fact]
public void Auto_statistics_off() public void Auto_statistics_off()
{ {

View file

@ -0,0 +1,40 @@
using SQLInsight.Core.Data;
using SQLInsight.Core.Findings;
using SQLInsight.Rules.Availability;
using SQLInsight.Rules.Engine;
using static SQLInsight.Rules.Tests.TestInstances;
namespace SQLInsight.Rules.Tests;
public class ReplicationRuleTests
{
private static RuleResult Run(IRule rule, RuleContext context) => Assert.Single(new RuleEngine([rule]).Evaluate(context));
private static ReplicationSnapshot Distributor(params DistributionAgent[] agents) =>
new() { CapturedAt = Now, IsDistributor = true, DistributionDatabase = "distribution", Agents = agents };
[Fact]
public void Not_a_distributor_is_not_relevant_and_without_rights_not_measurable()
{
Assert.Equal(RuleOutcome.NotApplicable, Run(new ReplicationAgentFailedRule(), With(new ReplicationSnapshot { CapturedAt = Now })).Outcome);
var denied = Run(new ReplicationLatencyRule(), With(Distributor() with { Unavailable = "Giv agenten SELECT på MSdistribution_agents" }));
Assert.Equal(RuleOutcome.NotMeasurable, denied.Outcome);
Assert.Contains("MSdistribution_agents", denied.Reason, StringComparison.Ordinal);
}
[Fact]
public void Failed_agents_are_high_and_slow_ones_medium()
{
var failed = new DistributionAgent("SQL01-AX-Pub-SQL02-1", "AX", 6, Now, null, "The process could not connect to Subscriber 'SQL02'.");
var slow = new DistributionAgent("SQL01-AX-Pub-SQL03-2", "AX", 3, Now, 600_000, null);
var fine = new DistributionAgent("SQL01-AX-Pub-SQL04-3", "AX", 4, Now, 2_000, null);
var errors = Run(new ReplicationAgentFailedRule(), With(Distributor(failed, slow, fine)));
var latency = Run(new ReplicationLatencyRule(), With(Distributor(failed, slow, fine)));
var finding = Assert.Single(errors.Findings);
Assert.Equal(Severity.High, finding.Severity);
Assert.True(finding.Evidence.Single(e => e.Name == "Meddelelse").Sensitive);
Assert.Contains("SQL03", Assert.Single(latency.Findings).Evidence.Single(e => e.Name == "Agent").Value, StringComparison.Ordinal);
}
}

View file

@ -18,6 +18,6 @@ public class RuleCatalogTests
[Fact] [Fact]
public void Rule_count() public void Rule_count()
{ {
Assert.Equal(66, BuiltInRules.Create().Count); Assert.Equal(82, BuiltInRules.Create().Count);
} }
} }

View file

@ -0,0 +1,33 @@
using SQLInsight.Core.Data;
using SQLInsight.Core.Findings;
using SQLInsight.Rules.Engine;
using SQLInsight.Rules.Instance;
using static SQLInsight.Rules.Tests.TestInstances;
namespace SQLInsight.Rules.Tests;
public class ServiceRuleTests
{
private static RuleResult Run(IRule rule, RuleContext context) => Assert.Single(new RuleEngine([rule]).Evaluate(context));
private static InstanceConfigurationSnapshot Setup(params ServiceInfo[] services) =>
new() { CapturedAt = Now, ServicesAvailable = true, Services = services };
[Fact]
public void A_stopped_automatic_service_is_high_and_manual_ones_are_ignored()
{
var result = Run(new StoppedServiceRule(), With(Setup(
new ServiceInfo("SQL Server (MSSQLSERVER)", @"NT Service\MSSQLSERVER", "Automatic", "Running", true),
new ServiceInfo("SQL Server Agent (MSSQLSERVER)", @"KUNDE\svc-agent", "Automatic", "Stopped", null),
new ServiceInfo("SQL Server Launchpad (MSSQLSERVER)", null, "Manual", "Stopped", null))));
var finding = Assert.Single(result.Findings);
Assert.Equal(Severity.High, finding.Severity);
Assert.Contains("backup", finding.Explanation, StringComparison.Ordinal);
Assert.True(finding.Evidence.Single(e => e.Name == "Konto").Sensitive);
}
[Fact]
public void Before_2008_r2_sp1_it_is_not_relevant() =>
Assert.Equal(RuleOutcome.NotApplicable, Run(new StoppedServiceRule(), WithVersion("10.50.1600.1", Setup())).Outcome);
}

View file

@ -0,0 +1,51 @@
using SQLInsight.Core.Data;
using SQLInsight.Core.Findings;
using SQLInsight.Rules.Engine;
using SQLInsight.Rules.Instance;
using static SQLInsight.Rules.Tests.TestInstances;
namespace SQLInsight.Rules.Tests;
public class TempdbRuleTests
{
private static RuleResult Run(IRule rule, RuleContext context) => Assert.Single(new RuleEngine([rule]).Evaluate(context));
private static TempdbSnapshot Tempdb(double versionStore = 100, double free = 5000, bool canGrow = true, IReadOnlyList<double>? history = null) => new()
{
CapturedAt = Now, SizeMegabytes = 10000, FreeMegabytes = free, VersionStoreMegabytes = versionStore, CanGrow = canGrow,
VersionStoreHistory = history ?? [versionStore],
OldestSnapshotTransaction = new SnapshotTransaction(61, 7200, @"KUNDE\anne", "Ax32.exe"),
VersionGenerators = [new VersionGenerator("AXDB", 900)],
};
[Fact]
public void A_large_or_steadily_growing_version_store_is_found()
{
Assert.Equal(RuleOutcome.Passed, Run(new VersionStoreRule(), With(Tempdb(500))).Outcome);
var large = Assert.Single(Run(new VersionStoreRule(), With(Tempdb(2000))).Findings);
Assert.Contains(large.Evidence, e => e.Name == "Databaser, der laver versioner" && e.Value.Contains("AXDB", StringComparison.Ordinal));
Assert.True(large.Evidence.Single(e => e.Name == "Dens login og program").Sensitive);
Assert.Equal(RuleOutcome.Findings, Run(new VersionStoreRule(), With(Tempdb(600, history: [100, 200, 300, 400, 500, 600]))).Outcome);
Assert.False(TempdbAnalysis.IsGrowing([100, 200, 150, 400, 500, 600]));
}
[Theory]
[InlineData(500, false, RuleOutcome.Findings)]
[InlineData(500, true, RuleOutcome.Passed)] // it can still grow
[InlineData(2000, false, RuleOutcome.Passed)] // 20 % free
public void Tempdb_nearly_full_only_when_it_cannot_grow(double free, bool canGrow, RuleOutcome expected) =>
Assert.Equal(expected, Run(new TempdbFullRule(), With(Tempdb(free: free, canGrow: canGrow))).Outcome);
[Fact]
public void A_session_heavy_for_fifteen_minutes_is_found()
{
var session = new TempdbSession(70, Now.AddHours(-1), 3000, "anne", "SSMS", "PC01", "running");
var first = TempdbAnalysis.Advance(null, Tempdb() with { CapturedAt = Now.AddMinutes(-20), TopSessions = [session] });
var now = TempdbAnalysis.Advance(first, Tempdb() with { TopSessions = [session] });
Assert.Equal(RuleOutcome.Passed, Run(new TempdbSessionRule(), With(first)).Outcome);
var finding = Assert.Single(Run(new TempdbSessionRule(), With(now)).Findings);
Assert.Equal(Severity.Medium, finding.Severity);
Assert.True(finding.Evidence.Single(e => e.Name == "Login").Sensitive);
}
}