Compare commits

...

18 commits

Author SHA1 Message Date
90d37be8a5 Version 1.4.0
Some checks failed
deploy community / deploy (push) Successful in 18s
deploy hub / deploy (push) Successful in 19s
CI / Build og unit tests (push) Successful in 8s
CI / MSI-installer (push) Successful in 15s
CI / Integration (mcr.microsoft.com/mssql/server:2025-latest) (push) Has been cancelled
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
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-02 14:45:31 +02:00
31fa89b385 Review 1.4: AX detection survives passing errors; revoke only what the grant gave; smaller fixes
- A lock timeout or deadlock while detecting (e.g. during an AX table
  synchronisation) dropped the database from AX monitoring for an hour, so
  its findings resolved and reopened. The earlier detection is kept and
  retried within 5 minutes. A cancelled collection is no longer reported as
  "læsningen fejlede".
- A lock timeout kept the earlier batch picture forever; now at most 15
  minutes (MeasuredAt), so a job that has started is not reported late from
  an old picture.
- revoke-dynamics-ax-read.sql revoked SELECT on same-named tables in every
  schema, also a DENY set by a DBA. It now finds the AX schema like the grant
  script and revokes only granted SELECT there.
- SQLI-AX-006 skips blocked sequences (as -005/-008 do); forecasts under a
  day say "inden for et døgn" instead of "om ca. 0 dage".
- Integration tests: the SQL Scripts run as-is in main's format, and the
  shared connection has the agent's session settings after the AX reads.
- docs/dynamics-ax.md: history bounds, lock-timeout window, session settings,
  /detection-problems, API redaction.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-02 14:45:31 +02:00
13f22c7541 Review 1.4: AX batch rules: one finding per job, stuck jobs only once, no crash without a start
- Axapta 3.0/4.0 keep a BATCH row per recurrence, so a failed run and the
  next waiting run share a job key and gave two SQLI-AX-001 findings with the
  same subject (the repository kept the last, the hub the first). The batch
  rules now keep one finding per subject, the most severe.
- SQLI-AX-004 treated a single earlier 10-hour run as "normally that long"
  and stayed silent; it now needs BatchHistoryMinimumRuns runs, like
  SQLI-AX-002. A stuck job is SQLI-AX-004's finding only, not also -002's.
- Running(now)!.Value threw for an executing job with neither a start time
  nor a first-seen time (and a negative duration meant clock skew); such jobs
  are skipped.
- SQLI-AX-010 no longer calls every session in an AX database "from Dynamics
  AX", and shows the raw CONTEXT_INFO as sensitive evidence.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-02 14:29:35 +02:00
8365ba5357 Review 1.4: AX pages and API leave out removed instances; bounded, complete reads
- DynamicsAxView (pages and API v1) listed instances removed from monitoring;
  /batch-runs without ?instance= returned runs of every instance. Both now
  only cover the instances the overview shows.
- AX blocking read the instance's newest 200 blocking events and filtered
  for AX afterwards, so other blocking could push AX blocking out entirely.
  GetBlockingInDatabasesAsync filters on the AX databases in SQL (OPENJSON)
  with its own TOP (1000).
- /sequence-samples loaded every sample and cut at 10,000 in memory; the limit
  is in SQL now, with X-Truncated when there are more.
- Two new runs with the same run key in one snapshot would break the
  primary key of insight.AxBatchRuns; they are deduplicated first.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-02 14:29:34 +02:00
f4d2603181 Review 1.4: AX texts in API v1 and the hub without logins or host names; API has what the pages show
- "Ikke målbar" texts, notes and detection problems carry the GRANT text with
  the agent's login and database user. API v1 (batch-jobs, number-sequences,
  recids, databases, rule-states) returned them as they were, although logins
  are redacted everywhere else. AxReadAccess.WithoutPrincipals replaces them
  with the placeholder unless sensitive values are allowed; the grant script
  also hides a database user that differs from the login.
- SQLI-AX-010 put the host and program name (in 2-tier AX 3.0 the user's own
  PC) in the finding's explanation, which reaches the hub unredacted for an
  opted-in database. They are only in the sensitive evidence now, with the raw
  CONTEXT_INFO; the CONTEXT_INFO parsing says it is not verified against
  customer data.
- A database named e.g. "AX · test" was read from a subject as "AX", whose
  hub opt-in then applied. Middle dots in the database are doubled in AX
  subjects, and the AX community rules' subjects are built the same way.
- API v1 now also has the detection problems (/detection-problems), what each
  raw batch STATUS means, and the thresholds behind the number sequence and
  RecId pages; the blocking explanation is no longer redacted (it holds
  nothing sensitive now).

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-02 14:29:34 +02:00
66ed4e11b3 Review 1.4: AX 3.0/4.0 local times in the AX time zone, with its DST rules
Axapta 3.0 and AX 4.0 store batch and cleanup times as a local date plus
seconds. They were turned into UTC with the SQL Server's offset at collection
time, so every time from the other side of a DST change was an hour off (a job
planned 01:50 CET on the spring night looked 70 minutes late at 03:00 CEST),
and a SQL Server running UTC behind a CET AOS shifted every time.

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

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-02 14:19:17 +02:00
fdaa7e76bf Review 1.4: the AX batch history watermark no longer gets stuck
After the first read, BATCHJOBHISTORY was read from the watermark minus an
overlap of 2000 RecIds with TOP (1000), oldest first. From AX 4.0 RecIds are
handed out per table and are dense, so once 1000 rows existed in the overlap
every read returned the same old rows: the watermark never moved and new runs
were never read again (every AX 2009/2012 installation with more than ~1000
history rows). The read after the watermark now takes the overlap plus 1000
rows (TOP (3000)), so at least 1000 new runs are read each time. Test with
20,000 dense RecIds.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-02 14:15:49 +02:00
be039a6f73 Review 1.4: AX reads put the agent's session settings back
AxSql.Reset ran SET LOCK_TIMEOUT -1 and READ COMMITTED after every AX read
(and after the AX detection in discovery). The collectors of an instance
share one connection, so every collector after the AX ones waited on locks
without limit instead of the agent's 5 s, and no longer read uncommitted.
Reset now restores the agent's own settings, from one place
(Core.Instances.AgentSession) that SqlConnectionFactory.SessionSettings also
uses. The AX reads themselves are unchanged: READ COMMITTED (or SNAPSHOT)
with a 2 s lock timeout inside sp_executesql, never NOLOCK.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-02 14:14:24 +02:00
3a0bbe22f1 Review 1.4: SQL Scripts for Dynamics AX in the 1.3 structure
The AX scripts written in 1.4 (own header style and a generator) now follow
main's "SQL Scripts" structure: the review header, "-- Kilde:" blocks
registered in SqlScriptSources and checked word for word against the code.

- SqlScriptSources: the shared AX detection (DatabasesSql, LoginSql,
  BuildDetectionSql), companies, batch jobs, batch history (first read and
  from the watermark) and number sequences / RecId for Axapta 3.0, AX 4.0,
  AX 2009 and AX 2012; BlockingCollector split 2008 / 2005, as CONTEXT_INFO
  comes from sys.dm_exec_sessions from 2008 and from sysprocesses on 2005.
- SQLI-AX-001..010 and _collectors/Ax*.sql in main's format; COM-AX-001..007
  with the 1.4 queries, the AX detection and USE [AXDB]; BLK-001,
  BlockingCollector and DatabaseDiscovery updated (CONTEXT_INFO,
  sp_executesql).
- Scripts_are_read_only stays strict (no INTO #temp anywhere) and now also
  checks the batch inside EXEC [db].sys.sp_executesql N'...'.
- SQLINSIGHT_UPDATE_SQL_SCRIPTS=1 (from 1.4) rewrites out-of-date blocks from
  the code; the 1.4 generator test now checks that every AX script shows its
  sources for every AX version and says what is unverified.
- README: SQLI-AX-001..010, the AX collector scripts, 108 rules.
- AxDatabaseDetector: LoginSql and BuildDetectionSql as members, so the
  registry sees them.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-02 14:11:45 +02:00
08cd741b52 Dynamics AX: notice a GRANT within minutes, one AX lookup per custom-rule run
The detector looks again every 5 minutes at an AX database where the agent
lacks part of the read access, so a GRANT takes effect quickly. The AX SQL
test checks that only standard AX tables are read. The hub end-to-end test
expects no AX rule outcomes without the opt-in. Docs: what 1.5's synthetic
AX checks can reuse.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-02 13:54:36 +02:00
cd72f14883 Docs: Dynamics AX (1.4)
docs/dynamics-ax.md: detection, the optional read access per version, how
AX is read (SELECT only, TOP, 2 s lock timeout or snapshot, never NOLOCK),
the rules SQLI-AX-001..010 with thresholds, batch times and the unverified
AX 3.0 STATUS mapping, forecasts from SQLInsight's own samples, AX users
behind blocking (CONTEXT_INFO, honest for 3.0), the hub opt-in, the API and
how to drop in a real 2009/2012 schema. AX 2009/2012/F&O are marked as based
on Microsoft's documented schema, not verified against customer data.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-02 13:54:36 +02:00
d574057114 Integration tests: fake Axapta 3.0 (bmssa) and AX 2012 R2 databases
Built from the schema files in the SQL Server 2022 container and read as the
least-privilege agent login: detection and discovery, 'ikke målbar' with the
exact GRANT and measuring after it (no customer table readable, no data
changed), the shipped grant/revoke scripts, AX 2012 history by RECID read
once, a locked AX table skipped within seconds, the SQL Scripts run as-is,
repository retention and read-back, and the web pages and API v1.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-02 13:54:23 +02:00
e2284657ae Web and API v1: Dynamics AX pages and endpoints
Dynamics AX in the menu (shown when AX databases are recognised): AX-databaser
(version, schema, companies, read access and the exact GRANT per database),
Batchjob, Nummerserier, RecId and AX-brugere bag blokering. The same data in
API v1 under /api/v1/dynamics-ax/ (databases, grant-script, batch-jobs,
batch-runs, number-sequences, recids, sequence-samples, blocking), documented
in OpenAPI, with AX users, hosts, statements and the agent's login redacted
unless ApiIncludesSensitiveEvidence is on.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-02 13:54:23 +02:00
3e1cc74f0e Dynamics AX rules SQLI-AX-001..010 and their SQL Scripts
Batch jobs: failed (001), longer than their own history (002), late or never
started (003), stuck in Executing (004). Number sequences: running out with a
forecast from SQLInsight's own samples (005), format shorter than HIGHEST
(006), blocked but still referenced (007), continuous and not cleaned up
(008). RecId running out, flagged far earlier for AX 3.0's 32-bit RecId
(009). AX user behind blocking from CONTEXT_INFO, honest where AX 3.0 cannot
tell (010). Subjects carry the database, so findings follow the hub opt-in.

SQL Scripts/<rule>.sql (and _collectors/) for every AX rule added or changed
in 1.4, incl. COM-AX-001..007: a Danish header plus the exact SQL SQLInsight
sends, generated from the code and checked by SqlScriptTests (rewrite with
SQLINSIGHT_UPDATE_SQL_SCRIPTS=1).

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-02 13:54:23 +02:00
36ad4c0656 Repository and agent: Dynamics AX history (migration 0017), seeding, hub opt-in
0017_dynamics_ax adds insight.AxBatchRuns (completed batch runs, EventDays
retention) and insight.AxSequenceSamples (one NEXTREC/NEXTVAL sample per
sequence per day, HourlyDays retention). The agent reads them back after a
restart so job comparisons and forecasts continue, takes its thresholds from
SQLInsight:DynamicsAx, and only sends findings of the built-in SQLI-AX rules
to the hub for databases with the AX opt-in ticked; AX rule reasons (which
can name tables, logins and GRANT text) are replaced by a fixed text.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-02 13:53:34 +02:00
6b945295d0 Dynamics AX collectors: shared detector, batch jobs, number sequences and RecId
dynamics-ax (hourly) lists the monitored AX databases with version, schema,
companies and read access; discovery and the AX community rules (now marked
dynamicsAx) use the same detection. dynamics-ax-batch (5 min) reads BATCH
(3.0/4.0) or BATCHJOB/BATCHJOBHISTORY (2009+), keeps each job's own run
history; dynamics-ax-sequences (hourly) reads NUMBERSEQUENCETABLE and
SYSTEMSEQUENCES and forecasts from SQLInsight's own daily samples. All reads
run through [db].sys.sp_executesql under read committed with a 2 s lock
timeout (snapshot when allowed), bounded with TOP, never NOLOCK, never
written. Blocking now captures CONTEXT_INFO for the AX user mapping.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-02 13:53:34 +02:00
4ebae4efe6 Dynamics AX core: central detector, per-version schema adapters, read-access GRANT
One place recognises AX (version 3.0/4.0/2009/2012/2012 R2/F&O, schema owner,
model store) from metadata only. Per-version adapters hold all table/column
knowledge, check every column in sys.columns before use and degrade to
'ikke målbar' with the exact column or the exact GRANT text. Only AX 3.0 is
verified against a real customer schema; 2009/2012 are written from
Microsoft's documented schema (docs/dynamics-ax/*.documented.tsv, replaced
by a real export when one is dropped in). grant-dynamics-ax-read.sql is
version- and schema-aware (bmssa) and covers exactly the adapters' tables.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-02 13:52:58 +02:00
66aa47ecb3 Deadlocks: read system_health files without a temp table
The 2012+ query used SELECT ... INTO #system_health, a write to tempdb.
One SELECT with ROW_NUMBER() now returns the deadlock rows and the newest
row (the read position) in the same pass. SQL Scripts and the read-only
test no longer allow any exception.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-02 13:47:31 +02:00
107 changed files with 9088 additions and 235 deletions

View file

@ -14,7 +14,7 @@
<Product>SQLInsight Pro</Product>
<Copyright>Copyright (c) SEOSoft</Copyright>
<!-- Release version; set with -p:Version=… when publishing. Update checks compare against it. -->
<Version>1.3.0</Version>
<Version>1.4.0</Version>
</PropertyGroup>
<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.
> **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).
> **Status:** Fase 1–4 er implementeret: agent, 92 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), [Dynamics AX](docs/dynamics-ax.md), [databehandling](docs/databehandling.md), [community](docs/community.md), [Roadmap](#roadmap) og [CONTRIBUTING.md](CONTRIBUTING.md).
---

View file

@ -5,6 +5,9 @@
-- Titel: Dynamics AX 3.0 er uden support fra Microsoft
-- Alvor: Medium
-- Versioner: SQL Server 2005 og nyere. Kræver ingen særlige capabilities (DeclarativeRule).
-- Kører kun i Dynamics AX-databaser (den fælles AX-genkendelse). Axapta 3.0: verificeret mod en kundes
-- skema; AX 4.0, AX 2009, AX 2012 og F&O: baseret på Microsofts dokumenterede skema - ikke
-- verificeret mod kundedata.
--
-- Hvad tjekkes, og hvorfor:
-- Dynamics AX 3.0 er uden support fra Microsoft. Microsoft Dynamics AX 3.0 er for længst ude af Microsofts support,
@ -14,8 +17,10 @@
--
-- Vurdering (sker i C# ud fra de indsamlede data, ikke i SQL'en, medmindre andet er nævnt):
-- Ingen C#-logik ud over at læse resultatet: hver række, forespørgslen returnerer, er et fund (højst 100 rækker pr.
-- regel). Forespørgslen køres i hver online brugerdatabase, agenten kan åbne (CustomQueryCollector.DatabasesSql),
-- og kolonnen database tilføjes hver række. Kører instansen en ældre version end minimumVersion, er reglen "ikke
-- regel). Reglen er markeret "dynamicsAx" (fra 1.4): forespørgslen køres kun i de overvågede
-- databaser (CustomQueryCollector.DatabasesSql), som AX-genkendelsen (AxDatabaseDetector) genkender som Dynamics AX;
-- er der ingen, er reglen "ikke relevant". Fund går kun til SQLInsight Hub for databaser med AX-tilvalget.
-- Kolonnen database tilføjes hver række. Kører instansen en ældre version end minimumVersion, er reglen "ikke
-- relevant". Fejler forespørgslen pga. manglende rettigheder, er reglen "ikke målbar". Fundets titel: "{database}
-- er en Dynamics AX 3.0-database, og AX 3.0 er uden support" ({kolonne} udfyldes fra rækken).
--
@ -34,15 +39,20 @@
-- CustomQueryCollector.BuildRuleSql(database, query)
-- Interval: 15 min som standard (SQLInsight:CollectorIntervals:custom-rules)
-- Delt script: SQL Scripts/_collectors/CustomQueryCollector.sql
-- AxDatabaseDetector (den fælles AX-genkendelse)
-- Fil: src/SQLInsight.Collectors/DynamicsAx/AxDatabaseDetector.cs
-- Medlemmer: AxDatabaseDetector.DatabasesSql, AxDatabaseDetector.BuildDetectionSql(database)
-- Interval: hver time pr. database (hvert 5. minut, mens AX-læseadgangen mangler), delt af alle AX-collectors
-- Delt script: SQL Scripts/_collectors/AxDatabaseDetector.sql
-- Regelkode: src/SQLInsight.Rules/Custom/DeclarativeRule.cs : DeclarativeRule.Evaluate; reglen selv:
-- rules/community/dynamics-ax.json (id COM-AX-001, indlejret i regelpakken)
--
-- Read-only: Ja: kun SELECT samt USE (skifter sessionens database), SET-sessionsindstillinger. Intet ændrer data,
-- indstillinger eller objekter.
-- Read-only: Ja: kun SELECT samt USE (skifter sessionens database), SET-sessionsindstillinger og AX-genkendelsen
-- via [db].sys.sp_executesql (kun metadata, ingen AX-tabeldata). Intet ændrer data, indstillinger eller
-- objekter.
-- Kørsel: Kør i SSMS som agentens login (eller et login med samme rettigheder). Hver variant er sin egen batch
-- (GO); en variant for en anden SQL Server-version end din kan fejle - det er forventet.
-- Per-database-forespørgsler er vist for databasen msdb, som findes overalt: ret navnet til den
-- database, du vil se.
-- Forespørgslerne i databasen er vist for AXDB: ret [AXDB] til din Dynamics AX-database.
-- ===================================================================================================================
-- -------------------------------------------------------------------------------------------------------------------
@ -65,10 +75,42 @@ GO
SELECT name FROM sys.databases WHERE database_id > 4 AND state = 0 AND source_database_id IS NULL AND HAS_DBACCESS(name) = 1 ORDER BY name
GO
-- -------------------------------------------------------------------------------------------------------------------
-- AxDatabaseDetector: Hvilke af databaserne er Dynamics AX (kun metadata)
-- -------------------------------------------------------------------------------------------------------------------
-- Variant: Alle versioner fra SQL Server 2005: online brugerdatabaser og deres snapshot-indstillinger
-- Kilde: AxDatabaseDetector.DatabasesSql
-- Kode: src/SQLInsight.Collectors/DynamicsAx/AxDatabaseDetector.cs : AxDatabaseDetector.DatabasesSql
SELECT name, snapshot_isolation_state, is_read_committed_snapshot_on
FROM sys.databases
WHERE database_id > 4 AND state = 0 AND source_database_id IS NULL AND HAS_DBACCESS(name) = 1
ORDER BY name
GO
-- Variant: SQL Server 2005 og nyere: AX-genkendelsen i hver overvåget database (kun metadata; vist for AXDB)
-- Kilde: AxDatabaseDetector.BuildDetectionSql
-- Kode: src/SQLInsight.Collectors/DynamicsAx/AxDatabaseDetector.cs : AxDatabaseDetector.BuildDetectionSql(database) = AxSql.InDatabase(database, AxDetection.MetadataSql)
-- Agenten kører dette i hver overvåget database fra listen ovenfor (ikke *_model). Ret [AXDB] til den database, du vil tjekke.
EXEC [AXDB].sys.sp_executesql N'SET NOCOUNT ON; SET LOCK_TIMEOUT 2000; SET TRANSACTION ISOLATION LEVEL READ COMMITTED; SELECT s.name, UPPER(t.name), UPPER(c.name), TYPE_NAME(c.system_type_id), c.precision, c.max_length,
ISNULL(HAS_PERMS_BY_NAME(QUOTENAME(s.name) + N''.'' + QUOTENAME(t.name), N''OBJECT'', N''SELECT''), 0),
(SELECT TOP 1 p.name FROM sys.database_principals p WHERE p.sid = SUSER_SID())
FROM sys.tables t
JOIN sys.schemas s ON s.schema_id = t.schema_id
JOIN sys.columns c ON c.object_id = t.object_id
WHERE UPPER(t.name) IN (N''SQLDICTIONARY'', N''DATAAREA'', N''SYSCONFIG'', N''BATCH'', N''BATCHJOB'', N''BATCHJOBHISTORY'', N''BATCHGROUP'', N''NUMBERSEQUENCETABLE'', N''NUMBERSEQUENCESCOPE'', N''NUMBERSEQUENCEREFERENCE'', N''SYSTEMSEQUENCES'', N''MODELELEMENT'')
ORDER BY s.name, t.name, c.column_id';
SET LOCK_TIMEOUT 5000; SET TRANSACTION ISOLATION LEVEL READ UNCOMMITTED;
GO
-- -------------------------------------------------------------------------------------------------------------------
-- CustomQueryCollector: reglens forespørgsel i hver genkendt Dynamics AX-database
-- -------------------------------------------------------------------------------------------------------------------
-- Variant: SQL Server 2005 og nyere
-- Kilde: CustomQueryCollector.BuildRuleSql
-- Kode: src/SQLInsight.Collectors/Custom/CustomQueryCollector.cs : CustomQueryCollector.BuildRuleSql(database, query)
-- Agenten kører dette i hver brugerdatabase fra listen ovenfor. Ret [msdb] til den database, du vil tjekke.
USE [msdb];
SELECT CAST(SERVERPROPERTY('ProductVersion') AS nvarchar(128)) AS [sql_version] WHERE EXISTS (SELECT 1 FROM sys.tables WHERE name = N'SQLDICTIONARY') AND EXISTS (SELECT 1 FROM sys.tables WHERE name = N'DATAAREA') AND EXISTS (SELECT 1 FROM sys.tables WHERE name = N'SYSCONFIG') AND COLUMNPROPERTY((SELECT TOP 1 object_id FROM sys.tables WHERE name = N'SQLDICTIONARY'), N'RECID', 'Precision') = 10
-- Agenten kører dette i hver overvåget database, som AX-genkendelsen herover har genkendt. Ret [AXDB] til din AX-database.
USE [AXDB];
SELECT CAST(SERVERPROPERTY('ProductVersion') AS nvarchar(128)) AS [sql_version] WHERE COLUMNPROPERTY((SELECT TOP 1 object_id FROM sys.tables WHERE name = N'SQLDICTIONARY'), N'RECID', 'Precision') = 10
GO

View file

@ -5,6 +5,9 @@
-- Titel: Dynamics AX 3.0 kører på en SQL Server-version, der ikke var understøttet til AX 3.0
-- Alvor: Medium
-- Versioner: SQL Server 2005 og nyere. Kræver ingen særlige capabilities (DeclarativeRule).
-- Kører kun i Dynamics AX-databaser (den fælles AX-genkendelse). Axapta 3.0: verificeret mod en kundes
-- skema; AX 4.0, AX 2009, AX 2012 og F&O: baseret på Microsofts dokumenterede skema - ikke
-- verificeret mod kundedata.
--
-- Hvad tjekkes, og hvorfor:
-- Dynamics AX 3.0 kører på en SQL Server-version, der ikke var understøttet til AX 3.0. AX 3.0 blev kun frigivet
@ -15,8 +18,10 @@
--
-- Vurdering (sker i C# ud fra de indsamlede data, ikke i SQL'en, medmindre andet er nævnt):
-- Ingen C#-logik ud over at læse resultatet: hver række, forespørgslen returnerer, er et fund (højst 100 rækker pr.
-- regel). Forespørgslen køres i hver online brugerdatabase, agenten kan åbne (CustomQueryCollector.DatabasesSql),
-- og kolonnen database tilføjes hver række. Kører instansen en ældre version end minimumVersion, er reglen "ikke
-- regel). Reglen er markeret "dynamicsAx" (fra 1.4): forespørgslen køres kun i de overvågede
-- databaser (CustomQueryCollector.DatabasesSql), som AX-genkendelsen (AxDatabaseDetector) genkender som Dynamics AX;
-- er der ingen, er reglen "ikke relevant". Fund går kun til SQLInsight Hub for databaser med AX-tilvalget.
-- Kolonnen database tilføjes hver række. Kører instansen en ældre version end minimumVersion, er reglen "ikke
-- relevant". Fejler forespørgslen pga. manglende rettigheder, er reglen "ikke målbar". Fundets titel: "AX
-- 3.0-databasen {database} ligger på SQL Server {sql_version}" ({kolonne} udfyldes fra rækken).
--
@ -35,15 +40,20 @@
-- CustomQueryCollector.BuildRuleSql(database, query)
-- Interval: 15 min som standard (SQLInsight:CollectorIntervals:custom-rules)
-- Delt script: SQL Scripts/_collectors/CustomQueryCollector.sql
-- AxDatabaseDetector (den fælles AX-genkendelse)
-- Fil: src/SQLInsight.Collectors/DynamicsAx/AxDatabaseDetector.cs
-- Medlemmer: AxDatabaseDetector.DatabasesSql, AxDatabaseDetector.BuildDetectionSql(database)
-- Interval: hver time pr. database (hvert 5. minut, mens AX-læseadgangen mangler), delt af alle AX-collectors
-- Delt script: SQL Scripts/_collectors/AxDatabaseDetector.sql
-- Regelkode: src/SQLInsight.Rules/Custom/DeclarativeRule.cs : DeclarativeRule.Evaluate; reglen selv:
-- rules/community/dynamics-ax.json (id COM-AX-002, indlejret i regelpakken)
--
-- Read-only: Ja: kun SELECT samt USE (skifter sessionens database), SET-sessionsindstillinger. Intet ændrer data,
-- indstillinger eller objekter.
-- Read-only: Ja: kun SELECT samt USE (skifter sessionens database), SET-sessionsindstillinger og AX-genkendelsen
-- via [db].sys.sp_executesql (kun metadata, ingen AX-tabeldata). Intet ændrer data, indstillinger eller
-- objekter.
-- Kørsel: Kør i SSMS som agentens login (eller et login med samme rettigheder). Hver variant er sin egen batch
-- (GO); en variant for en anden SQL Server-version end din kan fejle - det er forventet.
-- Per-database-forespørgsler er vist for databasen msdb, som findes overalt: ret navnet til den
-- database, du vil se.
-- Forespørgslerne i databasen er vist for AXDB: ret [AXDB] til din Dynamics AX-database.
-- ===================================================================================================================
-- -------------------------------------------------------------------------------------------------------------------
@ -66,10 +76,42 @@ GO
SELECT name FROM sys.databases WHERE database_id > 4 AND state = 0 AND source_database_id IS NULL AND HAS_DBACCESS(name) = 1 ORDER BY name
GO
-- -------------------------------------------------------------------------------------------------------------------
-- AxDatabaseDetector: Hvilke af databaserne er Dynamics AX (kun metadata)
-- -------------------------------------------------------------------------------------------------------------------
-- Variant: Alle versioner fra SQL Server 2005: online brugerdatabaser og deres snapshot-indstillinger
-- Kilde: AxDatabaseDetector.DatabasesSql
-- Kode: src/SQLInsight.Collectors/DynamicsAx/AxDatabaseDetector.cs : AxDatabaseDetector.DatabasesSql
SELECT name, snapshot_isolation_state, is_read_committed_snapshot_on
FROM sys.databases
WHERE database_id > 4 AND state = 0 AND source_database_id IS NULL AND HAS_DBACCESS(name) = 1
ORDER BY name
GO
-- Variant: SQL Server 2005 og nyere: AX-genkendelsen i hver overvåget database (kun metadata; vist for AXDB)
-- Kilde: AxDatabaseDetector.BuildDetectionSql
-- Kode: src/SQLInsight.Collectors/DynamicsAx/AxDatabaseDetector.cs : AxDatabaseDetector.BuildDetectionSql(database) = AxSql.InDatabase(database, AxDetection.MetadataSql)
-- Agenten kører dette i hver overvåget database fra listen ovenfor (ikke *_model). Ret [AXDB] til den database, du vil tjekke.
EXEC [AXDB].sys.sp_executesql N'SET NOCOUNT ON; SET LOCK_TIMEOUT 2000; SET TRANSACTION ISOLATION LEVEL READ COMMITTED; SELECT s.name, UPPER(t.name), UPPER(c.name), TYPE_NAME(c.system_type_id), c.precision, c.max_length,
ISNULL(HAS_PERMS_BY_NAME(QUOTENAME(s.name) + N''.'' + QUOTENAME(t.name), N''OBJECT'', N''SELECT''), 0),
(SELECT TOP 1 p.name FROM sys.database_principals p WHERE p.sid = SUSER_SID())
FROM sys.tables t
JOIN sys.schemas s ON s.schema_id = t.schema_id
JOIN sys.columns c ON c.object_id = t.object_id
WHERE UPPER(t.name) IN (N''SQLDICTIONARY'', N''DATAAREA'', N''SYSCONFIG'', N''BATCH'', N''BATCHJOB'', N''BATCHJOBHISTORY'', N''BATCHGROUP'', N''NUMBERSEQUENCETABLE'', N''NUMBERSEQUENCESCOPE'', N''NUMBERSEQUENCEREFERENCE'', N''SYSTEMSEQUENCES'', N''MODELELEMENT'')
ORDER BY s.name, t.name, c.column_id';
SET LOCK_TIMEOUT 5000; SET TRANSACTION ISOLATION LEVEL READ UNCOMMITTED;
GO
-- -------------------------------------------------------------------------------------------------------------------
-- CustomQueryCollector: reglens forespørgsel i hver genkendt Dynamics AX-database
-- -------------------------------------------------------------------------------------------------------------------
-- Variant: SQL Server 2005 og nyere
-- Kilde: CustomQueryCollector.BuildRuleSql
-- Kode: src/SQLInsight.Collectors/Custom/CustomQueryCollector.cs : CustomQueryCollector.BuildRuleSql(database, query)
-- Agenten kører dette i hver brugerdatabase fra listen ovenfor. Ret [msdb] til den database, du vil tjekke.
USE [msdb];
SELECT CAST(SERVERPROPERTY('ProductVersion') AS nvarchar(128)) AS [sql_version], d.compatibility_level AS [compatibility_level] FROM sys.databases d WHERE d.database_id = DB_ID() AND CAST(PARSENAME(CAST(SERVERPROPERTY('ProductVersion') AS nvarchar(128)), 4) AS int) > 9 AND EXISTS (SELECT 1 FROM sys.tables WHERE name = N'SQLDICTIONARY') AND EXISTS (SELECT 1 FROM sys.tables WHERE name = N'DATAAREA') AND EXISTS (SELECT 1 FROM sys.tables WHERE name = N'SYSCONFIG') AND COLUMNPROPERTY((SELECT TOP 1 object_id FROM sys.tables WHERE name = N'SQLDICTIONARY'), N'RECID', 'Precision') = 10
-- Agenten kører dette i hver overvåget database, som AX-genkendelsen herover har genkendt. Ret [AXDB] til din AX-database.
USE [AXDB];
SELECT CAST(SERVERPROPERTY('ProductVersion') AS nvarchar(128)) AS [sql_version], d.compatibility_level AS [compatibility_level] FROM sys.databases d WHERE d.database_id = DB_ID() AND CAST(PARSENAME(CAST(SERVERPROPERTY('ProductVersion') AS nvarchar(128)), 4) AS int) > 9 AND COLUMNPROPERTY((SELECT TOP 1 object_id FROM sys.tables WHERE name = N'SQLDICTIONARY'), N'RECID', 'Precision') = 10
GO

View file

@ -5,6 +5,9 @@
-- Titel: Dynamics AX-database på en instans, hvor MAXDOP ikke er 1 (SQL Server 2005-2014)
-- Alvor: Medium
-- Versioner: SQL Server 2005 og nyere. Kræver ingen særlige capabilities (DeclarativeRule).
-- Kører kun i Dynamics AX-databaser (den fælles AX-genkendelse). Axapta 3.0: verificeret mod en kundes
-- skema; AX 4.0, AX 2009, AX 2012 og F&O: baseret på Microsofts dokumenterede skema - ikke
-- verificeret mod kundedata.
--
-- Hvad tjekkes, og hvorfor:
-- Dynamics AX-database på en instans, hvor MAXDOP ikke er 1 (SQL Server 2005-2014). Dynamics AX sender mange små,
@ -14,8 +17,10 @@
--
-- Vurdering (sker i C# ud fra de indsamlede data, ikke i SQL'en, medmindre andet er nævnt):
-- Ingen C#-logik ud over at læse resultatet: hver række, forespørgslen returnerer, er et fund (højst 100 rækker pr.
-- regel). Forespørgslen køres i hver online brugerdatabase, agenten kan åbne (CustomQueryCollector.DatabasesSql),
-- og kolonnen database tilføjes hver række. Kører instansen en ældre version end minimumVersion, er reglen "ikke
-- regel). Reglen er markeret "dynamicsAx" (fra 1.4): forespørgslen køres kun i de overvågede
-- databaser (CustomQueryCollector.DatabasesSql), som AX-genkendelsen (AxDatabaseDetector) genkender som Dynamics AX;
-- er der ingen, er reglen "ikke relevant". Fund går kun til SQLInsight Hub for databaser med AX-tilvalget.
-- Kolonnen database tilføjes hver række. Kører instansen en ældre version end minimumVersion, er reglen "ikke
-- relevant". Fejler forespørgslen pga. manglende rettigheder, er reglen "ikke målbar". Fundets titel: "MAXDOP er
-- {maxdop} på instansen med Dynamics AX-databasen {database}" ({kolonne} udfyldes fra rækken).
--
@ -34,15 +39,20 @@
-- CustomQueryCollector.BuildRuleSql(database, query)
-- Interval: 15 min som standard (SQLInsight:CollectorIntervals:custom-rules)
-- Delt script: SQL Scripts/_collectors/CustomQueryCollector.sql
-- AxDatabaseDetector (den fælles AX-genkendelse)
-- Fil: src/SQLInsight.Collectors/DynamicsAx/AxDatabaseDetector.cs
-- Medlemmer: AxDatabaseDetector.DatabasesSql, AxDatabaseDetector.BuildDetectionSql(database)
-- Interval: hver time pr. database (hvert 5. minut, mens AX-læseadgangen mangler), delt af alle AX-collectors
-- Delt script: SQL Scripts/_collectors/AxDatabaseDetector.sql
-- Regelkode: src/SQLInsight.Rules/Custom/DeclarativeRule.cs : DeclarativeRule.Evaluate; reglen selv:
-- rules/community/dynamics-ax.json (id COM-AX-003, indlejret i regelpakken)
--
-- Read-only: Ja: kun SELECT samt USE (skifter sessionens database), SET-sessionsindstillinger. Intet ændrer data,
-- indstillinger eller objekter.
-- Read-only: Ja: kun SELECT samt USE (skifter sessionens database), SET-sessionsindstillinger og AX-genkendelsen
-- via [db].sys.sp_executesql (kun metadata, ingen AX-tabeldata). Intet ændrer data, indstillinger eller
-- objekter.
-- Kørsel: Kør i SSMS som agentens login (eller et login med samme rettigheder). Hver variant er sin egen batch
-- (GO); en variant for en anden SQL Server-version end din kan fejle - det er forventet.
-- Per-database-forespørgsler er vist for databasen msdb, som findes overalt: ret navnet til den
-- database, du vil se.
-- Forespørgslerne i databasen er vist for AXDB: ret [AXDB] til din Dynamics AX-database.
-- ===================================================================================================================
-- -------------------------------------------------------------------------------------------------------------------
@ -65,10 +75,42 @@ GO
SELECT name FROM sys.databases WHERE database_id > 4 AND state = 0 AND source_database_id IS NULL AND HAS_DBACCESS(name) = 1 ORDER BY name
GO
-- -------------------------------------------------------------------------------------------------------------------
-- AxDatabaseDetector: Hvilke af databaserne er Dynamics AX (kun metadata)
-- -------------------------------------------------------------------------------------------------------------------
-- Variant: Alle versioner fra SQL Server 2005: online brugerdatabaser og deres snapshot-indstillinger
-- Kilde: AxDatabaseDetector.DatabasesSql
-- Kode: src/SQLInsight.Collectors/DynamicsAx/AxDatabaseDetector.cs : AxDatabaseDetector.DatabasesSql
SELECT name, snapshot_isolation_state, is_read_committed_snapshot_on
FROM sys.databases
WHERE database_id > 4 AND state = 0 AND source_database_id IS NULL AND HAS_DBACCESS(name) = 1
ORDER BY name
GO
-- Variant: SQL Server 2005 og nyere: AX-genkendelsen i hver overvåget database (kun metadata; vist for AXDB)
-- Kilde: AxDatabaseDetector.BuildDetectionSql
-- Kode: src/SQLInsight.Collectors/DynamicsAx/AxDatabaseDetector.cs : AxDatabaseDetector.BuildDetectionSql(database) = AxSql.InDatabase(database, AxDetection.MetadataSql)
-- Agenten kører dette i hver overvåget database fra listen ovenfor (ikke *_model). Ret [AXDB] til den database, du vil tjekke.
EXEC [AXDB].sys.sp_executesql N'SET NOCOUNT ON; SET LOCK_TIMEOUT 2000; SET TRANSACTION ISOLATION LEVEL READ COMMITTED; SELECT s.name, UPPER(t.name), UPPER(c.name), TYPE_NAME(c.system_type_id), c.precision, c.max_length,
ISNULL(HAS_PERMS_BY_NAME(QUOTENAME(s.name) + N''.'' + QUOTENAME(t.name), N''OBJECT'', N''SELECT''), 0),
(SELECT TOP 1 p.name FROM sys.database_principals p WHERE p.sid = SUSER_SID())
FROM sys.tables t
JOIN sys.schemas s ON s.schema_id = t.schema_id
JOIN sys.columns c ON c.object_id = t.object_id
WHERE UPPER(t.name) IN (N''SQLDICTIONARY'', N''DATAAREA'', N''SYSCONFIG'', N''BATCH'', N''BATCHJOB'', N''BATCHJOBHISTORY'', N''BATCHGROUP'', N''NUMBERSEQUENCETABLE'', N''NUMBERSEQUENCESCOPE'', N''NUMBERSEQUENCEREFERENCE'', N''SYSTEMSEQUENCES'', N''MODELELEMENT'')
ORDER BY s.name, t.name, c.column_id';
SET LOCK_TIMEOUT 5000; SET TRANSACTION ISOLATION LEVEL READ UNCOMMITTED;
GO
-- -------------------------------------------------------------------------------------------------------------------
-- CustomQueryCollector: reglens forespørgsel i hver genkendt Dynamics AX-database
-- -------------------------------------------------------------------------------------------------------------------
-- Variant: SQL Server 2005 og nyere
-- Kilde: CustomQueryCollector.BuildRuleSql
-- Kode: src/SQLInsight.Collectors/Custom/CustomQueryCollector.cs : CustomQueryCollector.BuildRuleSql(database, query)
-- Agenten kører dette i hver brugerdatabase fra listen ovenfor. Ret [msdb] til den database, du vil tjekke.
USE [msdb];
SELECT CAST(c.value_in_use AS int) AS [maxdop] FROM sys.configurations c WHERE c.name = 'max degree of parallelism' AND CAST(c.value_in_use AS int) <> 1 AND CAST(PARSENAME(CAST(SERVERPROPERTY('ProductVersion') AS nvarchar(128)), 4) AS int) < 13 AND EXISTS (SELECT 1 FROM sys.tables WHERE name = N'SQLDICTIONARY') AND EXISTS (SELECT 1 FROM sys.tables WHERE name = N'DATAAREA') AND EXISTS (SELECT 1 FROM sys.tables WHERE name = N'SYSCONFIG')
-- Agenten kører dette i hver overvåget database, som AX-genkendelsen herover har genkendt. Ret [AXDB] til din AX-database.
USE [AXDB];
SELECT CAST(c.value_in_use AS int) AS [maxdop] FROM sys.configurations c WHERE c.name = 'max degree of parallelism' AND CAST(c.value_in_use AS int) <> 1 AND CAST(PARSENAME(CAST(SERVERPROPERTY('ProductVersion') AS nvarchar(128)), 4) AS int) < 13
GO

View file

@ -5,6 +5,9 @@
-- Titel: Dynamics AX-database, hvor MAXDOP ikke er 1 (SQL Server 2016 og nyere)
-- Alvor: Medium
-- Versioner: SQL Server 2016 og nyere. Kræver ingen særlige capabilities (DeclarativeRule).
-- Kører kun i Dynamics AX-databaser (den fælles AX-genkendelse). Axapta 3.0: verificeret mod en kundes
-- skema; AX 4.0, AX 2009, AX 2012 og F&O: baseret på Microsofts dokumenterede skema - ikke
-- verificeret mod kundedata.
--
-- Hvad tjekkes, og hvorfor:
-- Dynamics AX-database, hvor MAXDOP ikke er 1 (SQL Server 2016 og nyere). Dynamics AX sender mange små, hyppige
@ -14,8 +17,10 @@
--
-- Vurdering (sker i C# ud fra de indsamlede data, ikke i SQL'en, medmindre andet er nævnt):
-- Ingen C#-logik ud over at læse resultatet: hver række, forespørgslen returnerer, er et fund (højst 100 rækker pr.
-- regel). Forespørgslen køres i hver online brugerdatabase, agenten kan åbne (CustomQueryCollector.DatabasesSql),
-- og kolonnen database tilføjes hver række. Kører instansen en ældre version end minimumVersion, er reglen "ikke
-- regel). Reglen er markeret "dynamicsAx" (fra 1.4): forespørgslen køres kun i de overvågede
-- databaser (CustomQueryCollector.DatabasesSql), som AX-genkendelsen (AxDatabaseDetector) genkender som Dynamics AX;
-- er der ingen, er reglen "ikke relevant". Fund går kun til SQLInsight Hub for databaser med AX-tilvalget.
-- Kolonnen database tilføjes hver række. Kører instansen en ældre version end minimumVersion, er reglen "ikke
-- relevant". Fejler forespørgslen pga. manglende rettigheder, er reglen "ikke målbar". Fundets titel: "Dynamics
-- AX-databasen {database} kører med MAXDOP større end 1" ({kolonne} udfyldes fra rækken).
--
@ -34,15 +39,20 @@
-- CustomQueryCollector.BuildRuleSql(database, query)
-- Interval: 15 min som standard (SQLInsight:CollectorIntervals:custom-rules)
-- Delt script: SQL Scripts/_collectors/CustomQueryCollector.sql
-- AxDatabaseDetector (den fælles AX-genkendelse)
-- Fil: src/SQLInsight.Collectors/DynamicsAx/AxDatabaseDetector.cs
-- Medlemmer: AxDatabaseDetector.DatabasesSql, AxDatabaseDetector.BuildDetectionSql(database)
-- Interval: hver time pr. database (hvert 5. minut, mens AX-læseadgangen mangler), delt af alle AX-collectors
-- Delt script: SQL Scripts/_collectors/AxDatabaseDetector.sql
-- Regelkode: src/SQLInsight.Rules/Custom/DeclarativeRule.cs : DeclarativeRule.Evaluate; reglen selv:
-- rules/community/dynamics-ax.json (id COM-AX-004, indlejret i regelpakken)
--
-- Read-only: Ja: kun SELECT samt USE (skifter sessionens database), SET-sessionsindstillinger. Intet ændrer data,
-- indstillinger eller objekter.
-- Read-only: Ja: kun SELECT samt USE (skifter sessionens database), SET-sessionsindstillinger og AX-genkendelsen
-- via [db].sys.sp_executesql (kun metadata, ingen AX-tabeldata). Intet ændrer data, indstillinger eller
-- objekter.
-- Kørsel: Kør i SSMS som agentens login (eller et login med samme rettigheder). Hver variant er sin egen batch
-- (GO); en variant for en anden SQL Server-version end din kan fejle - det er forventet.
-- Per-database-forespørgsler er vist for databasen msdb, som findes overalt: ret navnet til den
-- database, du vil se.
-- Forespørgslerne i databasen er vist for AXDB: ret [AXDB] til din Dynamics AX-database.
-- ===================================================================================================================
-- -------------------------------------------------------------------------------------------------------------------
@ -65,10 +75,42 @@ GO
SELECT name FROM sys.databases WHERE database_id > 4 AND state = 0 AND source_database_id IS NULL AND HAS_DBACCESS(name) = 1 ORDER BY name
GO
-- -------------------------------------------------------------------------------------------------------------------
-- AxDatabaseDetector: Hvilke af databaserne er Dynamics AX (kun metadata)
-- -------------------------------------------------------------------------------------------------------------------
-- Variant: Alle versioner fra SQL Server 2005: online brugerdatabaser og deres snapshot-indstillinger
-- Kilde: AxDatabaseDetector.DatabasesSql
-- Kode: src/SQLInsight.Collectors/DynamicsAx/AxDatabaseDetector.cs : AxDatabaseDetector.DatabasesSql
SELECT name, snapshot_isolation_state, is_read_committed_snapshot_on
FROM sys.databases
WHERE database_id > 4 AND state = 0 AND source_database_id IS NULL AND HAS_DBACCESS(name) = 1
ORDER BY name
GO
-- Variant: SQL Server 2005 og nyere: AX-genkendelsen i hver overvåget database (kun metadata; vist for AXDB)
-- Kilde: AxDatabaseDetector.BuildDetectionSql
-- Kode: src/SQLInsight.Collectors/DynamicsAx/AxDatabaseDetector.cs : AxDatabaseDetector.BuildDetectionSql(database) = AxSql.InDatabase(database, AxDetection.MetadataSql)
-- Agenten kører dette i hver overvåget database fra listen ovenfor (ikke *_model). Ret [AXDB] til den database, du vil tjekke.
EXEC [AXDB].sys.sp_executesql N'SET NOCOUNT ON; SET LOCK_TIMEOUT 2000; SET TRANSACTION ISOLATION LEVEL READ COMMITTED; SELECT s.name, UPPER(t.name), UPPER(c.name), TYPE_NAME(c.system_type_id), c.precision, c.max_length,
ISNULL(HAS_PERMS_BY_NAME(QUOTENAME(s.name) + N''.'' + QUOTENAME(t.name), N''OBJECT'', N''SELECT''), 0),
(SELECT TOP 1 p.name FROM sys.database_principals p WHERE p.sid = SUSER_SID())
FROM sys.tables t
JOIN sys.schemas s ON s.schema_id = t.schema_id
JOIN sys.columns c ON c.object_id = t.object_id
WHERE UPPER(t.name) IN (N''SQLDICTIONARY'', N''DATAAREA'', N''SYSCONFIG'', N''BATCH'', N''BATCHJOB'', N''BATCHJOBHISTORY'', N''BATCHGROUP'', N''NUMBERSEQUENCETABLE'', N''NUMBERSEQUENCESCOPE'', N''NUMBERSEQUENCEREFERENCE'', N''SYSTEMSEQUENCES'', N''MODELELEMENT'')
ORDER BY s.name, t.name, c.column_id';
SET LOCK_TIMEOUT 5000; SET TRANSACTION ISOLATION LEVEL READ UNCOMMITTED;
GO
-- -------------------------------------------------------------------------------------------------------------------
-- CustomQueryCollector: reglens forespørgsel i hver genkendt Dynamics AX-database
-- -------------------------------------------------------------------------------------------------------------------
-- Variant: SQL Server 2016 og nyere
-- Kilde: CustomQueryCollector.BuildRuleSql
-- Kode: src/SQLInsight.Collectors/Custom/CustomQueryCollector.cs : CustomQueryCollector.BuildRuleSql(database, query)
-- Agenten kører dette i hver brugerdatabase fra listen ovenfor. Ret [msdb] til den database, du vil tjekke.
USE [msdb];
SELECT CAST(c.value_in_use AS int) AS [server_maxdop], CAST(s.value AS int) AS [database_maxdop] FROM sys.configurations c CROSS JOIN sys.database_scoped_configurations s WHERE c.name = 'max degree of parallelism' AND s.name = 'MAXDOP' AND (CASE WHEN CAST(s.value AS int) = 0 THEN CAST(c.value_in_use AS int) ELSE CAST(s.value AS int) END) <> 1 AND EXISTS (SELECT 1 FROM sys.tables WHERE name = N'SQLDICTIONARY') AND EXISTS (SELECT 1 FROM sys.tables WHERE name = N'DATAAREA') AND EXISTS (SELECT 1 FROM sys.tables WHERE name = N'SYSCONFIG')
-- Agenten kører dette i hver overvåget database, som AX-genkendelsen herover har genkendt. Ret [AXDB] til din AX-database.
USE [AXDB];
SELECT CAST(c.value_in_use AS int) AS [server_maxdop], CAST(s.value AS int) AS [database_maxdop] FROM sys.configurations c CROSS JOIN sys.database_scoped_configurations s WHERE c.name = 'max degree of parallelism' AND s.name = 'MAXDOP' AND (CASE WHEN CAST(s.value AS int) = 0 THEN CAST(c.value_in_use AS int) ELSE CAST(s.value AS int) END) <> 1
GO

View file

@ -5,6 +5,9 @@
-- Titel: Dynamics AX-tabeller uden unikt RecId-indeks
-- Alvor: Medium
-- Versioner: SQL Server 2005 og nyere. Kræver ingen særlige capabilities (DeclarativeRule).
-- Kører kun i Dynamics AX-databaser (den fælles AX-genkendelse). Axapta 3.0: verificeret mod en kundes
-- skema; AX 4.0, AX 2009, AX 2012 og F&O: baseret på Microsofts dokumenterede skema - ikke
-- verificeret mod kundedata.
--
-- Hvad tjekkes, og hvorfor:
-- Dynamics AX-tabeller uden unikt RecId-indeks. AX identificerer hver post med RecId og opretter normalt et unikt
@ -14,8 +17,10 @@
--
-- Vurdering (sker i C# ud fra de indsamlede data, ikke i SQL'en, medmindre andet er nævnt):
-- Ingen C#-logik ud over at læse resultatet: hver række, forespørgslen returnerer, er et fund (højst 100 rækker pr.
-- regel). Forespørgslen køres i hver online brugerdatabase, agenten kan åbne (CustomQueryCollector.DatabasesSql),
-- og kolonnen database tilføjes hver række. Kører instansen en ældre version end minimumVersion, er reglen "ikke
-- regel). Reglen er markeret "dynamicsAx" (fra 1.4): forespørgslen køres kun i de overvågede
-- databaser (CustomQueryCollector.DatabasesSql), som AX-genkendelsen (AxDatabaseDetector) genkender som Dynamics AX;
-- er der ingen, er reglen "ikke relevant". Fund går kun til SQLInsight Hub for databaser med AX-tilvalget.
-- Kolonnen database tilføjes hver række. Kører instansen en ældre version end minimumVersion, er reglen "ikke
-- relevant". Fejler forespørgslen pga. manglende rettigheder, er reglen "ikke målbar". Fundets titel: "{tables}
-- AX-tabeller i {database} har intet unikt indeks på RECID" ({kolonne} udfyldes fra rækken).
--
@ -34,15 +39,20 @@
-- CustomQueryCollector.BuildRuleSql(database, query)
-- Interval: 15 min som standard (SQLInsight:CollectorIntervals:custom-rules)
-- Delt script: SQL Scripts/_collectors/CustomQueryCollector.sql
-- AxDatabaseDetector (den fælles AX-genkendelse)
-- Fil: src/SQLInsight.Collectors/DynamicsAx/AxDatabaseDetector.cs
-- Medlemmer: AxDatabaseDetector.DatabasesSql, AxDatabaseDetector.BuildDetectionSql(database)
-- Interval: hver time pr. database (hvert 5. minut, mens AX-læseadgangen mangler), delt af alle AX-collectors
-- Delt script: SQL Scripts/_collectors/AxDatabaseDetector.sql
-- Regelkode: src/SQLInsight.Rules/Custom/DeclarativeRule.cs : DeclarativeRule.Evaluate; reglen selv:
-- rules/community/dynamics-ax.json (id COM-AX-005, indlejret i regelpakken)
--
-- Read-only: Ja: kun SELECT samt USE (skifter sessionens database), SET-sessionsindstillinger. Intet ændrer data,
-- indstillinger eller objekter.
-- Read-only: Ja: kun SELECT samt USE (skifter sessionens database), SET-sessionsindstillinger og AX-genkendelsen
-- via [db].sys.sp_executesql (kun metadata, ingen AX-tabeldata). Intet ændrer data, indstillinger eller
-- objekter.
-- Kørsel: Kør i SSMS som agentens login (eller et login med samme rettigheder). Hver variant er sin egen batch
-- (GO); en variant for en anden SQL Server-version end din kan fejle - det er forventet.
-- Per-database-forespørgsler er vist for databasen msdb, som findes overalt: ret navnet til den
-- database, du vil se.
-- Forespørgslerne i databasen er vist for AXDB: ret [AXDB] til din Dynamics AX-database.
-- ===================================================================================================================
-- -------------------------------------------------------------------------------------------------------------------
@ -65,10 +75,42 @@ GO
SELECT name FROM sys.databases WHERE database_id > 4 AND state = 0 AND source_database_id IS NULL AND HAS_DBACCESS(name) = 1 ORDER BY name
GO
-- -------------------------------------------------------------------------------------------------------------------
-- AxDatabaseDetector: Hvilke af databaserne er Dynamics AX (kun metadata)
-- -------------------------------------------------------------------------------------------------------------------
-- Variant: Alle versioner fra SQL Server 2005: online brugerdatabaser og deres snapshot-indstillinger
-- Kilde: AxDatabaseDetector.DatabasesSql
-- Kode: src/SQLInsight.Collectors/DynamicsAx/AxDatabaseDetector.cs : AxDatabaseDetector.DatabasesSql
SELECT name, snapshot_isolation_state, is_read_committed_snapshot_on
FROM sys.databases
WHERE database_id > 4 AND state = 0 AND source_database_id IS NULL AND HAS_DBACCESS(name) = 1
ORDER BY name
GO
-- Variant: SQL Server 2005 og nyere: AX-genkendelsen i hver overvåget database (kun metadata; vist for AXDB)
-- Kilde: AxDatabaseDetector.BuildDetectionSql
-- Kode: src/SQLInsight.Collectors/DynamicsAx/AxDatabaseDetector.cs : AxDatabaseDetector.BuildDetectionSql(database) = AxSql.InDatabase(database, AxDetection.MetadataSql)
-- Agenten kører dette i hver overvåget database fra listen ovenfor (ikke *_model). Ret [AXDB] til den database, du vil tjekke.
EXEC [AXDB].sys.sp_executesql N'SET NOCOUNT ON; SET LOCK_TIMEOUT 2000; SET TRANSACTION ISOLATION LEVEL READ COMMITTED; SELECT s.name, UPPER(t.name), UPPER(c.name), TYPE_NAME(c.system_type_id), c.precision, c.max_length,
ISNULL(HAS_PERMS_BY_NAME(QUOTENAME(s.name) + N''.'' + QUOTENAME(t.name), N''OBJECT'', N''SELECT''), 0),
(SELECT TOP 1 p.name FROM sys.database_principals p WHERE p.sid = SUSER_SID())
FROM sys.tables t
JOIN sys.schemas s ON s.schema_id = t.schema_id
JOIN sys.columns c ON c.object_id = t.object_id
WHERE UPPER(t.name) IN (N''SQLDICTIONARY'', N''DATAAREA'', N''SYSCONFIG'', N''BATCH'', N''BATCHJOB'', N''BATCHJOBHISTORY'', N''BATCHGROUP'', N''NUMBERSEQUENCETABLE'', N''NUMBERSEQUENCESCOPE'', N''NUMBERSEQUENCEREFERENCE'', N''SYSTEMSEQUENCES'', N''MODELELEMENT'')
ORDER BY s.name, t.name, c.column_id';
SET LOCK_TIMEOUT 5000; SET TRANSACTION ISOLATION LEVEL READ UNCOMMITTED;
GO
-- -------------------------------------------------------------------------------------------------------------------
-- CustomQueryCollector: reglens forespørgsel i hver genkendt Dynamics AX-database
-- -------------------------------------------------------------------------------------------------------------------
-- Variant: SQL Server 2005 og nyere
-- Kilde: CustomQueryCollector.BuildRuleSql
-- Kode: src/SQLInsight.Collectors/Custom/CustomQueryCollector.cs : CustomQueryCollector.BuildRuleSql(database, query)
-- Agenten kører dette i hver brugerdatabase fra listen ovenfor. Ret [msdb] til den database, du vil tjekke.
USE [msdb];
SELECT COUNT(*) AS [tables], LEFT(STUFF((SELECT TOP 10 N', ' + t2.name FROM sys.tables t2 WHERE COLUMNPROPERTY(t2.object_id, N'RECID', 'ColumnId') IS NOT NULL AND NOT EXISTS (SELECT 1 FROM sys.indexes i JOIN sys.index_columns ic ON ic.object_id = i.object_id AND ic.index_id = i.index_id AND ic.is_included_column = 0 JOIN sys.columns c ON c.object_id = ic.object_id AND c.column_id = ic.column_id WHERE i.object_id = t2.object_id AND i.is_unique = 1 AND c.name = N'RECID') ORDER BY t2.name FOR XML PATH('')), 1, 2, ''), 400) AS [examples] FROM sys.tables t WHERE COLUMNPROPERTY(t.object_id, N'RECID', 'ColumnId') IS NOT NULL AND NOT EXISTS (SELECT 1 FROM sys.indexes i JOIN sys.index_columns ic ON ic.object_id = i.object_id AND ic.index_id = i.index_id AND ic.is_included_column = 0 JOIN sys.columns c ON c.object_id = ic.object_id AND c.column_id = ic.column_id WHERE i.object_id = t.object_id AND i.is_unique = 1 AND c.name = N'RECID') AND EXISTS (SELECT 1 FROM sys.tables WHERE name = N'SQLDICTIONARY') AND EXISTS (SELECT 1 FROM sys.tables WHERE name = N'DATAAREA') AND EXISTS (SELECT 1 FROM sys.tables WHERE name = N'SYSCONFIG') HAVING COUNT(*) > 0
-- Agenten kører dette i hver overvåget database, som AX-genkendelsen herover har genkendt. Ret [AXDB] til din AX-database.
USE [AXDB];
SELECT COUNT(*) AS [tables], LEFT(STUFF((SELECT TOP 10 N', ' + t2.name FROM sys.tables t2 WHERE COLUMNPROPERTY(t2.object_id, N'RECID', 'ColumnId') IS NOT NULL AND NOT EXISTS (SELECT 1 FROM sys.indexes i JOIN sys.index_columns ic ON ic.object_id = i.object_id AND ic.index_id = i.index_id AND ic.is_included_column = 0 JOIN sys.columns c ON c.object_id = ic.object_id AND c.column_id = ic.column_id WHERE i.object_id = t2.object_id AND i.is_unique = 1 AND c.name = N'RECID') ORDER BY t2.name FOR XML PATH('')), 1, 2, ''), 400) AS [examples] FROM sys.tables t WHERE COLUMNPROPERTY(t.object_id, N'RECID', 'ColumnId') IS NOT NULL AND NOT EXISTS (SELECT 1 FROM sys.indexes i JOIN sys.index_columns ic ON ic.object_id = i.object_id AND ic.index_id = i.index_id AND ic.is_included_column = 0 JOIN sys.columns c ON c.object_id = ic.object_id AND c.column_id = ic.column_id WHERE i.object_id = t.object_id AND i.is_unique = 1 AND c.name = N'RECID') HAVING COUNT(*) > 0
GO

View file

@ -5,6 +5,9 @@
-- Titel: Store Dynamics AX-tabeller uden clustered index (heaps)
-- Alvor: Medium
-- Versioner: SQL Server 2005 og nyere. Kræver ingen særlige capabilities (DeclarativeRule).
-- Kører kun i Dynamics AX-databaser (den fælles AX-genkendelse). Axapta 3.0: verificeret mod en kundes
-- skema; AX 4.0, AX 2009, AX 2012 og F&O: baseret på Microsofts dokumenterede skema - ikke
-- verificeret mod kundedata.
--
-- Hvad tjekkes, og hvorfor:
-- Store Dynamics AX-tabeller uden clustered index (heaps). En tabel uden clustered index (en heap) får spredte data
@ -14,8 +17,10 @@
--
-- Vurdering (sker i C# ud fra de indsamlede data, ikke i SQL'en, medmindre andet er nævnt):
-- Ingen C#-logik ud over at læse resultatet: hver række, forespørgslen returnerer, er et fund (højst 100 rækker pr.
-- regel). Forespørgslen køres i hver online brugerdatabase, agenten kan åbne (CustomQueryCollector.DatabasesSql),
-- og kolonnen database tilføjes hver række. Kører instansen en ældre version end minimumVersion, er reglen "ikke
-- regel). Reglen er markeret "dynamicsAx" (fra 1.4): forespørgslen køres kun i de overvågede
-- databaser (CustomQueryCollector.DatabasesSql), som AX-genkendelsen (AxDatabaseDetector) genkender som Dynamics AX;
-- er der ingen, er reglen "ikke relevant". Fund går kun til SQLInsight Hub for databaser med AX-tilvalget.
-- Kolonnen database tilføjes hver række. Kører instansen en ældre version end minimumVersion, er reglen "ikke
-- relevant". Fejler forespørgslen pga. manglende rettigheder, er reglen "ikke målbar". Fundets titel: "{tables}
-- store AX-tabeller i {database} har intet clustered index" ({kolonne} udfyldes fra rækken).
--
@ -34,15 +39,20 @@
-- CustomQueryCollector.BuildRuleSql(database, query)
-- Interval: 15 min som standard (SQLInsight:CollectorIntervals:custom-rules)
-- Delt script: SQL Scripts/_collectors/CustomQueryCollector.sql
-- AxDatabaseDetector (den fælles AX-genkendelse)
-- Fil: src/SQLInsight.Collectors/DynamicsAx/AxDatabaseDetector.cs
-- Medlemmer: AxDatabaseDetector.DatabasesSql, AxDatabaseDetector.BuildDetectionSql(database)
-- Interval: hver time pr. database (hvert 5. minut, mens AX-læseadgangen mangler), delt af alle AX-collectors
-- Delt script: SQL Scripts/_collectors/AxDatabaseDetector.sql
-- Regelkode: src/SQLInsight.Rules/Custom/DeclarativeRule.cs : DeclarativeRule.Evaluate; reglen selv:
-- rules/community/dynamics-ax.json (id COM-AX-006, indlejret i regelpakken)
--
-- Read-only: Ja: kun SELECT samt USE (skifter sessionens database), SET-sessionsindstillinger. Intet ændrer data,
-- indstillinger eller objekter.
-- Read-only: Ja: kun SELECT samt USE (skifter sessionens database), SET-sessionsindstillinger og AX-genkendelsen
-- via [db].sys.sp_executesql (kun metadata, ingen AX-tabeldata). Intet ændrer data, indstillinger eller
-- objekter.
-- Kørsel: Kør i SSMS som agentens login (eller et login med samme rettigheder). Hver variant er sin egen batch
-- (GO); en variant for en anden SQL Server-version end din kan fejle - det er forventet.
-- Per-database-forespørgsler er vist for databasen msdb, som findes overalt: ret navnet til den
-- database, du vil se.
-- Forespørgslerne i databasen er vist for AXDB: ret [AXDB] til din Dynamics AX-database.
-- ===================================================================================================================
-- -------------------------------------------------------------------------------------------------------------------
@ -65,10 +75,42 @@ GO
SELECT name FROM sys.databases WHERE database_id > 4 AND state = 0 AND source_database_id IS NULL AND HAS_DBACCESS(name) = 1 ORDER BY name
GO
-- -------------------------------------------------------------------------------------------------------------------
-- AxDatabaseDetector: Hvilke af databaserne er Dynamics AX (kun metadata)
-- -------------------------------------------------------------------------------------------------------------------
-- Variant: Alle versioner fra SQL Server 2005: online brugerdatabaser og deres snapshot-indstillinger
-- Kilde: AxDatabaseDetector.DatabasesSql
-- Kode: src/SQLInsight.Collectors/DynamicsAx/AxDatabaseDetector.cs : AxDatabaseDetector.DatabasesSql
SELECT name, snapshot_isolation_state, is_read_committed_snapshot_on
FROM sys.databases
WHERE database_id > 4 AND state = 0 AND source_database_id IS NULL AND HAS_DBACCESS(name) = 1
ORDER BY name
GO
-- Variant: SQL Server 2005 og nyere: AX-genkendelsen i hver overvåget database (kun metadata; vist for AXDB)
-- Kilde: AxDatabaseDetector.BuildDetectionSql
-- Kode: src/SQLInsight.Collectors/DynamicsAx/AxDatabaseDetector.cs : AxDatabaseDetector.BuildDetectionSql(database) = AxSql.InDatabase(database, AxDetection.MetadataSql)
-- Agenten kører dette i hver overvåget database fra listen ovenfor (ikke *_model). Ret [AXDB] til den database, du vil tjekke.
EXEC [AXDB].sys.sp_executesql N'SET NOCOUNT ON; SET LOCK_TIMEOUT 2000; SET TRANSACTION ISOLATION LEVEL READ COMMITTED; SELECT s.name, UPPER(t.name), UPPER(c.name), TYPE_NAME(c.system_type_id), c.precision, c.max_length,
ISNULL(HAS_PERMS_BY_NAME(QUOTENAME(s.name) + N''.'' + QUOTENAME(t.name), N''OBJECT'', N''SELECT''), 0),
(SELECT TOP 1 p.name FROM sys.database_principals p WHERE p.sid = SUSER_SID())
FROM sys.tables t
JOIN sys.schemas s ON s.schema_id = t.schema_id
JOIN sys.columns c ON c.object_id = t.object_id
WHERE UPPER(t.name) IN (N''SQLDICTIONARY'', N''DATAAREA'', N''SYSCONFIG'', N''BATCH'', N''BATCHJOB'', N''BATCHJOBHISTORY'', N''BATCHGROUP'', N''NUMBERSEQUENCETABLE'', N''NUMBERSEQUENCESCOPE'', N''NUMBERSEQUENCEREFERENCE'', N''SYSTEMSEQUENCES'', N''MODELELEMENT'')
ORDER BY s.name, t.name, c.column_id';
SET LOCK_TIMEOUT 5000; SET TRANSACTION ISOLATION LEVEL READ UNCOMMITTED;
GO
-- -------------------------------------------------------------------------------------------------------------------
-- CustomQueryCollector: reglens forespørgsel i hver genkendt Dynamics AX-database
-- -------------------------------------------------------------------------------------------------------------------
-- Variant: SQL Server 2005 og nyere
-- Kilde: CustomQueryCollector.BuildRuleSql
-- Kode: src/SQLInsight.Collectors/Custom/CustomQueryCollector.cs : CustomQueryCollector.BuildRuleSql(database, query)
-- Agenten kører dette i hver brugerdatabase fra listen ovenfor. Ret [msdb] til den database, du vil tjekke.
USE [msdb];
SELECT COUNT(*) AS [tables], SUM(h.row_count) AS [rows], LEFT(STUFF((SELECT TOP 10 N', ' + OBJECT_NAME(h2.object_id) + N' (' + CAST(h2.row_count AS nvarchar(20)) + N')' FROM (SELECT p.object_id, SUM(p.rows) AS row_count FROM sys.partitions p JOIN sys.tables t ON t.object_id = p.object_id WHERE p.index_id = 0 GROUP BY p.object_id) h2 WHERE h2.row_count >= 100000 ORDER BY h2.row_count DESC FOR XML PATH('')), 1, 2, ''), 400) AS [largest] FROM (SELECT p.object_id, SUM(p.rows) AS row_count FROM sys.partitions p JOIN sys.tables t ON t.object_id = p.object_id WHERE p.index_id = 0 GROUP BY p.object_id) h WHERE h.row_count >= 100000 AND EXISTS (SELECT 1 FROM sys.tables WHERE name = N'SQLDICTIONARY') AND EXISTS (SELECT 1 FROM sys.tables WHERE name = N'DATAAREA') AND EXISTS (SELECT 1 FROM sys.tables WHERE name = N'SYSCONFIG') HAVING COUNT(*) > 0
-- Agenten kører dette i hver overvåget database, som AX-genkendelsen herover har genkendt. Ret [AXDB] til din AX-database.
USE [AXDB];
SELECT COUNT(*) AS [tables], SUM(h.row_count) AS [rows], LEFT(STUFF((SELECT TOP 10 N', ' + OBJECT_NAME(h2.object_id) + N' (' + CAST(h2.row_count AS nvarchar(20)) + N')' FROM (SELECT p.object_id, SUM(p.rows) AS row_count FROM sys.partitions p JOIN sys.tables t ON t.object_id = p.object_id WHERE p.index_id = 0 GROUP BY p.object_id) h2 WHERE h2.row_count >= 100000 ORDER BY h2.row_count DESC FOR XML PATH('')), 1, 2, ''), 400) AS [largest] FROM (SELECT p.object_id, SUM(p.rows) AS row_count FROM sys.partitions p JOIN sys.tables t ON t.object_id = p.object_id WHERE p.index_id = 0 GROUP BY p.object_id) h WHERE h.row_count >= 100000 HAVING COUNT(*) > 0
GO

View file

@ -5,6 +5,9 @@
-- Titel: Automatiske statistikker er slået fra i en Dynamics AX-database
-- Alvor: High
-- Versioner: SQL Server 2005 og nyere. Kræver ingen særlige capabilities (DeclarativeRule).
-- Kører kun i Dynamics AX-databaser (den fælles AX-genkendelse). Axapta 3.0: verificeret mod en kundes
-- skema; AX 4.0, AX 2009, AX 2012 og F&O: baseret på Microsofts dokumenterede skema - ikke
-- verificeret mod kundedata.
--
-- Hvad tjekkes, og hvorfor:
-- Automatiske statistikker er slået fra i en Dynamics AX-database. AX bygger sine forespørgsler dynamisk ud fra
@ -15,8 +18,10 @@
--
-- Vurdering (sker i C# ud fra de indsamlede data, ikke i SQL'en, medmindre andet er nævnt):
-- Ingen C#-logik ud over at læse resultatet: hver række, forespørgslen returnerer, er et fund (højst 100 rækker pr.
-- regel). Forespørgslen køres i hver online brugerdatabase, agenten kan åbne (CustomQueryCollector.DatabasesSql),
-- og kolonnen database tilføjes hver række. Kører instansen en ældre version end minimumVersion, er reglen "ikke
-- regel). Reglen er markeret "dynamicsAx" (fra 1.4): forespørgslen køres kun i de overvågede
-- databaser (CustomQueryCollector.DatabasesSql), som AX-genkendelsen (AxDatabaseDetector) genkender som Dynamics AX;
-- er der ingen, er reglen "ikke relevant". Fund går kun til SQLInsight Hub for databaser med AX-tilvalget.
-- Kolonnen database tilføjes hver række. Kører instansen en ældre version end minimumVersion, er reglen "ikke
-- relevant". Fejler forespørgslen pga. manglende rettigheder, er reglen "ikke målbar". Fundets titel: "Automatiske
-- statistikker er slået fra i AX-databasen {database}" ({kolonne} udfyldes fra rækken).
--
@ -35,15 +40,20 @@
-- CustomQueryCollector.BuildRuleSql(database, query)
-- Interval: 15 min som standard (SQLInsight:CollectorIntervals:custom-rules)
-- Delt script: SQL Scripts/_collectors/CustomQueryCollector.sql
-- AxDatabaseDetector (den fælles AX-genkendelse)
-- Fil: src/SQLInsight.Collectors/DynamicsAx/AxDatabaseDetector.cs
-- Medlemmer: AxDatabaseDetector.DatabasesSql, AxDatabaseDetector.BuildDetectionSql(database)
-- Interval: hver time pr. database (hvert 5. minut, mens AX-læseadgangen mangler), delt af alle AX-collectors
-- Delt script: SQL Scripts/_collectors/AxDatabaseDetector.sql
-- Regelkode: src/SQLInsight.Rules/Custom/DeclarativeRule.cs : DeclarativeRule.Evaluate; reglen selv:
-- rules/community/dynamics-ax.json (id COM-AX-007, indlejret i regelpakken)
--
-- Read-only: Ja: kun SELECT samt USE (skifter sessionens database), SET-sessionsindstillinger. Intet ændrer data,
-- indstillinger eller objekter.
-- Read-only: Ja: kun SELECT samt USE (skifter sessionens database), SET-sessionsindstillinger og AX-genkendelsen
-- via [db].sys.sp_executesql (kun metadata, ingen AX-tabeldata). Intet ændrer data, indstillinger eller
-- objekter.
-- Kørsel: Kør i SSMS som agentens login (eller et login med samme rettigheder). Hver variant er sin egen batch
-- (GO); en variant for en anden SQL Server-version end din kan fejle - det er forventet.
-- Per-database-forespørgsler er vist for databasen msdb, som findes overalt: ret navnet til den
-- database, du vil se.
-- Forespørgslerne i databasen er vist for AXDB: ret [AXDB] til din Dynamics AX-database.
-- ===================================================================================================================
-- -------------------------------------------------------------------------------------------------------------------
@ -66,10 +76,42 @@ GO
SELECT name FROM sys.databases WHERE database_id > 4 AND state = 0 AND source_database_id IS NULL AND HAS_DBACCESS(name) = 1 ORDER BY name
GO
-- -------------------------------------------------------------------------------------------------------------------
-- AxDatabaseDetector: Hvilke af databaserne er Dynamics AX (kun metadata)
-- -------------------------------------------------------------------------------------------------------------------
-- Variant: Alle versioner fra SQL Server 2005: online brugerdatabaser og deres snapshot-indstillinger
-- Kilde: AxDatabaseDetector.DatabasesSql
-- Kode: src/SQLInsight.Collectors/DynamicsAx/AxDatabaseDetector.cs : AxDatabaseDetector.DatabasesSql
SELECT name, snapshot_isolation_state, is_read_committed_snapshot_on
FROM sys.databases
WHERE database_id > 4 AND state = 0 AND source_database_id IS NULL AND HAS_DBACCESS(name) = 1
ORDER BY name
GO
-- Variant: SQL Server 2005 og nyere: AX-genkendelsen i hver overvåget database (kun metadata; vist for AXDB)
-- Kilde: AxDatabaseDetector.BuildDetectionSql
-- Kode: src/SQLInsight.Collectors/DynamicsAx/AxDatabaseDetector.cs : AxDatabaseDetector.BuildDetectionSql(database) = AxSql.InDatabase(database, AxDetection.MetadataSql)
-- Agenten kører dette i hver overvåget database fra listen ovenfor (ikke *_model). Ret [AXDB] til den database, du vil tjekke.
EXEC [AXDB].sys.sp_executesql N'SET NOCOUNT ON; SET LOCK_TIMEOUT 2000; SET TRANSACTION ISOLATION LEVEL READ COMMITTED; SELECT s.name, UPPER(t.name), UPPER(c.name), TYPE_NAME(c.system_type_id), c.precision, c.max_length,
ISNULL(HAS_PERMS_BY_NAME(QUOTENAME(s.name) + N''.'' + QUOTENAME(t.name), N''OBJECT'', N''SELECT''), 0),
(SELECT TOP 1 p.name FROM sys.database_principals p WHERE p.sid = SUSER_SID())
FROM sys.tables t
JOIN sys.schemas s ON s.schema_id = t.schema_id
JOIN sys.columns c ON c.object_id = t.object_id
WHERE UPPER(t.name) IN (N''SQLDICTIONARY'', N''DATAAREA'', N''SYSCONFIG'', N''BATCH'', N''BATCHJOB'', N''BATCHJOBHISTORY'', N''BATCHGROUP'', N''NUMBERSEQUENCETABLE'', N''NUMBERSEQUENCESCOPE'', N''NUMBERSEQUENCEREFERENCE'', N''SYSTEMSEQUENCES'', N''MODELELEMENT'')
ORDER BY s.name, t.name, c.column_id';
SET LOCK_TIMEOUT 5000; SET TRANSACTION ISOLATION LEVEL READ UNCOMMITTED;
GO
-- -------------------------------------------------------------------------------------------------------------------
-- CustomQueryCollector: reglens forespørgsel i hver genkendt Dynamics AX-database
-- -------------------------------------------------------------------------------------------------------------------
-- Variant: SQL Server 2005 og nyere
-- Kilde: CustomQueryCollector.BuildRuleSql
-- Kode: src/SQLInsight.Collectors/Custom/CustomQueryCollector.cs : CustomQueryCollector.BuildRuleSql(database, query)
-- Agenten kører dette i hver brugerdatabase fra listen ovenfor. Ret [msdb] til den database, du vil tjekke.
USE [msdb];
SELECT CASE WHEN d.is_auto_create_stats_on = 1 THEN 'til' ELSE 'fra' END AS [auto_create_statistics], CASE WHEN d.is_auto_update_stats_on = 1 THEN 'til' ELSE 'fra' END AS [auto_update_statistics] FROM sys.databases d WHERE d.database_id = DB_ID() AND (d.is_auto_create_stats_on = 0 OR d.is_auto_update_stats_on = 0) AND EXISTS (SELECT 1 FROM sys.tables WHERE name = N'SQLDICTIONARY') AND EXISTS (SELECT 1 FROM sys.tables WHERE name = N'DATAAREA') AND EXISTS (SELECT 1 FROM sys.tables WHERE name = N'SYSCONFIG')
-- Agenten kører dette i hver overvåget database, som AX-genkendelsen herover har genkendt. Ret [AXDB] til din AX-database.
USE [AXDB];
SELECT CASE WHEN d.is_auto_create_stats_on = 1 THEN 'til' ELSE 'fra' END AS [auto_create_statistics], CASE WHEN d.is_auto_update_stats_on = 1 THEN 'til' ELSE 'fra' END AS [auto_update_statistics] FROM sys.databases d WHERE d.database_id = DB_ID() AND (d.is_auto_create_stats_on = 0 OR d.is_auto_update_stats_on = 0)
GO

View file

@ -14,6 +14,7 @@ identisk med koden (se [Hold mappen i sync](#hold-mappen-i-sync)).
| `<regel-id>.sql` | Én fil pr. regel, navngivet med regel-id'et præcis som i systemet: `SQLI-…` for de indbyggede regler (C#), `COM-…` for community-regler (JSON i `rules/community/`). |
| `_collectors/<Collector>.sql` | Alle forespørgsler fra én collector. Flere regler deler ofte samme collector; regelfilerne gentager SQL'en, så hver fil kan køres for sig. |
| `_collectors/InstanceProbe.sql` | Registreringen af instansen (version, udgave, Always On) og de sessionsindstillinger, agenten sætter på hver forbindelse. |
| `_collectors/AxDatabaseDetector.sql`, `_collectors/AxBatchCollector.sql`, `_collectors/AxSequenceCollector.sql` | Dynamics AX (fra 1.4): den fælles AX-genkendelse og læsningen af batchjob, nummerserier og RecId i AX-databasen. AX-forespørgslerne er vist for hver AX-version, SQLInsight har et skema for (Axapta 3.0, AX 4.0, AX 2009 og AX 2012), og for databasen `AXDB`. |
| `_collectors/DatabaseDiscovery.sql`, `_collectors/PermissionCheck.sql` | Søgningen efter databaser (med Dynamics AX-genkendelse) og forbindelsestesten med rettighedstjek under *Servere og databaser* (fra 1.3). De køres kun, når der bedes om dem i webbrugerfladen, og søgningen desuden hver time. |
## Opbygning af en regelfil
@ -61,10 +62,9 @@ Bemærk:
- **Read-only.** Alle scripts læser kun: SELECT mod katalog-views og DMV'er, `DBCC TRACESTATUS`, `DBCC SQLPERF(WAITSTATS)`
(kun SQL Server 2000), `xp_readerrorlog`, `xp_msver`, `sys.fn_trace_gettable`, lokale variabler og `USE`.
**Én undtagelse:** `SQLI-DLK-001` / `_collectors/DeadlockCollector.sql` (SQL Server 2012+) opretter en midlertidig
tabel `#system_health` i tempdb og sletter den igen i samme batch. Den er privat for sessionen, og ingen brugerdata,
indstillinger eller permanente objekter ændres, men det er strengt taget en skrivning i tempdb. Det står også i
filernes header.
Ingen undtagelser: heller ikke midlertidige tabeller i tempdb. Dynamics AX-læsningerne (fra 1.4) kører som
`EXEC [AXDB].sys.sp_executesql N'…'`, så sessionen aldrig skifter database; batchen i strengen er selv kun SELECT med
`TOP` (READ COMMITTED med `SET LOCK_TIMEOUT 2000`, eller SNAPSHOT, aldrig NOLOCK), og testen tjekker den på samme måde.
- **Per-database-forespørgsler** (indexes, statistikker, Query Store, database-scoped community-regler) kører agenten i
hver online brugerdatabase. Her er de vist for `msdb`, som findes på alle instanser, så scriptet kan køres uændret.
Ret `[msdb]` / `N'msdb'` til den database, du vil se.
@ -87,14 +87,19 @@ Bemærk:
- `Every_collector_query_is_registered_and_in_a_collector_script`: hver SQL-konstant og hver `Build…Sql`-metode i
collectorerne er registreret, og hver registreret forespørgsel står i et `_collectors/`-script.
- `Community_rule_scripts_contain_the_rule_query_verbatim`: community-reglens `query` fra JSON står ordret i filen.
- `Scripts_are_read_only_apart_from_the_documented_temp_table`: ingen skrivende sætninger ud over undtagelsen ovenfor.
- `Scripts_are_read_only`: ingen skrivende sætninger, heller ikke `SELECT … INTO #temp` - heller ikke i batchen i en
`EXEC [db].sys.sp_executesql N'…'`.
- `tests/SQLInsight.Collectors.Tests/SqlScriptTests.cs` (Dynamics AX): hver AX-regel og AX-collector viser SQL'en for alle
AX-versioner, og headeren siger, hvad der ikke er verificeret mod kundedata.
en collectors SQL ændret, kan blokkene skrives om fra koden: kør testene med `SQLINSIGHT_UPDATE_SQL_SCRIPTS=1`
(kommentarlinjerne bevares). Blokke med værdier, agenten beregner ved kørsel (⟪…⟫), udfyldes kun, når de er nye; værdien så sættes ind i hånden.
En ny regel skal derfor have sin `SQL Scripts/<id>.sql` (se `CONTRIBUTING.md`). Ændres en collectors SQL, peger
testen på de filer, der skal opdateres.
## Regler
98 regler: 82 indbyggede (C#) og 16 community-regler (JSON).
108 regler: 92 indbyggede (C#) og 16 community-regler (JSON).
| Regel-id | Fil | Titel | Collector(s) | Alvor |
|---|---|---|---|---|
@ -102,6 +107,16 @@ testen på de filer, der skal opdateres.
| SQLI-AG-002 | [SQLI-AG-002.sql](SQLI-AG-002.sql) | Sekundær replika er langt bagud | AvailabilityGroupCollector | Medium |
| SQLI-AG-003 | [SQLI-AG-003.sql](SQLI-AG-003.sql) | En node eller et vidne i clusteret er nede | AvailabilityGroupCollector | High |
| SQLI-ANOM-001 | [SQLI-ANOM-001.sql](SQLI-ANOM-001.sql) | Usædvanlig belastning i forhold til det normale | CpuCollector, PerformanceCounterCollector | CPU: Medium. Batch requests: Low ved stigning, Medium ved fald. Page life expectancy: Medium. |
| SQLI-AX-001 | [SQLI-AX-001.sql](SQLI-AX-001.sql) | Dynamics AX-batchjob er endt med fejl | AxDatabaseDetector, AxBatchCollector | High |
| SQLI-AX-002 | [SQLI-AX-002.sql](SQLI-AX-002.sql) | Dynamics AX-batchjob kører længere end normalt | AxDatabaseDetector, AxBatchCollector | Medium; High, når kørslen er dobbelt så lang som grænsen. |
| SQLI-AX-003 | [SQLI-AX-003.sql](SQLI-AX-003.sql) | Dynamics AX-batchjob er ikke startet til tiden | AxDatabaseDetector, AxBatchCollector | Medium; High, når jobbet i praksis aldrig er startet. |
| SQLI-AX-004 | [SQLI-AX-004.sql](SQLI-AX-004.sql) | Dynamics AX-batchjob hænger i Udfører | AxDatabaseDetector, AxBatchCollector | High |
| SQLI-AX-005 | [SQLI-AX-005.sql](SQLI-AX-005.sql) | Dynamics AX-nummerserie er ved at løbe tør | AxDatabaseDetector, AxSequenceCollector | Medium; High over den kritiske procent eller inden for de kritiske dage; Critical, når serien er opbrugt. |
| SQLI-AX-006 | [SQLI-AX-006.sql](SQLI-AX-006.sql) | Dynamics AX-nummerseriens format er for kort til Højeste | AxDatabaseDetector, AxSequenceCollector | Medium; High, når NEXTREC allerede er større end formatets kapacitet. |
| SQLI-AX-007 | [SQLI-AX-007.sql](SQLI-AX-007.sql) | Spærret Dynamics AX-nummerserie bruges stadig | AxDatabaseDetector, AxSequenceCollector | Medium |
| SQLI-AX-008 | [SQLI-AX-008.sql](SQLI-AX-008.sql) | Kontinuerlig Dynamics AX-nummerserie er ikke ryddet op | AxDatabaseDetector, AxSequenceCollector | Low |
| SQLI-AX-009 | [SQLI-AX-009.sql](SQLI-AX-009.sql) | Dynamics AX RecId er ved at løbe tør | AxDatabaseDetector, AxSequenceCollector | Axapta 3.0: High/Critical efter grænserne; 64-bit: High; Critical, når RecId er opbrugt. |
| SQLI-AX-010 | [SQLI-AX-010.sql](SQLI-AX-010.sql) | AX-bruger blokerer andre | AxDatabaseDetector, BlockingCollector | Medium; High ved ventetid over 5 minutter eller mindst 10 blokerede sessioner. |
| SQLI-BLK-001 | [SQLI-BLK-001.sql](SQLI-BLK-001.sql) | Langvarig blocking | BlockingCollector | Medium; High når længste ventetid er mindst 5 minutter eller mindst 10 sessioner er blokeret. |
| SQLI-CFG-001 | [SQLI-CFG-001.sql](SQLI-CFG-001.sql) | max server memory er ikke sat | ConfigurationCollector, ServerCollector | High |
| SQLI-CFG-002 | [SQLI-CFG-002.sql](SQLI-CFG-002.sql) | max server memory efterlader for lidt til Windows | ConfigurationCollector, ServerCollector | Medium |
@ -220,7 +235,7 @@ testen på de filer, der skal opdateres.
| [ReplicationCollector](_collectors/ReplicationCollector.sql) | `src/SQLInsight.Collectors/Instance/ReplicationCollector.cs` | 15 min som standard (SQLInsight:CollectorIntervals:replication) | SQL Server 2005 og nyere | 2 regler |
| [PerformanceCounterCollector](_collectors/PerformanceCounterCollector.sql) | `src/SQLInsight.Collectors/Performance/PerformanceCounterCollector.cs` | 5 min som standard (SQLInsight:CollectorIntervals:performance-counters) | Alle (SQL Server 2000 og nyere) | 2 regler |
| [CpuCollector](_collectors/CpuCollector.sql) | `src/SQLInsight.Collectors/Performance/CpuCollector.cs` | 5 min som standard (SQLInsight:CollectorIntervals:cpu) | SQL Server 2005 og nyere | 2 regler |
| [BlockingCollector](_collectors/BlockingCollector.sql) | `src/SQLInsight.Collectors/Concurrency/BlockingCollector.cs` | 1 min som standard (SQLInsight:CollectorIntervals:blocking) | Alle (SQL Server 2000 og nyere) | 1 regel |
| [BlockingCollector](_collectors/BlockingCollector.sql) | `src/SQLInsight.Collectors/Concurrency/BlockingCollector.cs` | 1 min som standard (SQLInsight:CollectorIntervals:blocking) | Alle (SQL Server 2000 og nyere) | 2 regler |
| [ActivityCollector](_collectors/ActivityCollector.sql) | `src/SQLInsight.Collectors/Concurrency/ActivityCollector.cs` | 1 min som standard (SQLInsight:CollectorIntervals:activity) | SQL Server 2005 og nyere | 3 regler |
| [DeadlockCollector](_collectors/DeadlockCollector.sql) | `src/SQLInsight.Collectors/Concurrency/DeadlockCollector.cs` | 5 min som standard (SQLInsight:CollectorIntervals:deadlocks) | SQL Server 2008 og nyere (filer fra 2012, ellers ring buffer) | 1 regel |
| [QueryStoreCollector](_collectors/QueryStoreCollector.sql) | `src/SQLInsight.Collectors/Performance/QueryStoreCollector.cs` | 30 min som standard (SQLInsight:CollectorIntervals:query-store) | SQL Server 2016 og nyere | 3 regler |
@ -229,3 +244,6 @@ testen på de filer, der skal opdateres.
| [AvailabilityGroupCollector](_collectors/AvailabilityGroupCollector.sql) | `src/SQLInsight.Collectors/Instance/AvailabilityGroupCollector.cs` | 1 min som standard (SQLInsight:CollectorIntervals:availability-groups) | SQL Server 2012 og nyere med Always On slået til | 3 regler |
| [AgentJobCollector](_collectors/AgentJobCollector.sql) | `src/SQLInsight.Collectors/Instance/AgentJobCollector.cs` | 5 min som standard (SQLInsight:CollectorIntervals:agent-jobs) | SQL Server 2005 og nyere | 2 regler |
| [CustomQueryCollector](_collectors/CustomQueryCollector.sql) | `src/SQLInsight.Collectors/Custom/CustomQueryCollector.cs` | 15 min som standard (SQLInsight:CollectorIntervals:custom-rules) | SQL Server 2005 og nyere (pr. regel: minimumVersion) | 16 regler |
| [AxDatabaseDetector](_collectors/AxDatabaseDetector.sql) | `src/SQLInsight.Collectors/DynamicsAx/AxDatabaseDetector.cs` | 1 time som standard (SQLInsight:CollectorIntervals:dynamics-ax); uden AX-læseadgang hvert 5. minut | SQL Server 2005 og nyere | 17 regler (alle AX-regler) |
| [AxBatchCollector](_collectors/AxBatchCollector.sql) | `src/SQLInsight.Collectors/DynamicsAx/AxBatchCollector.cs` | 5 min som standard (SQLInsight:CollectorIntervals:dynamics-ax-batch) | SQL Server 2005 og nyere; kræver valgfri AX-læseadgang | 4 regler |
| [AxSequenceCollector](_collectors/AxSequenceCollector.sql) | `src/SQLInsight.Collectors/DynamicsAx/AxSequenceCollector.cs` | 1 time som standard (SQLInsight:CollectorIntervals:dynamics-ax-sequences) | SQL Server 2005 og nyere; kræver valgfri AX-læseadgang | 5 regler |

159
SQL Scripts/SQLI-AX-001.sql Normal file
View file

@ -0,0 +1,159 @@
-- ===================================================================================================================
-- SQLI-AX-001: Dynamics AX-batchjob er endt med fejl
-- ===================================================================================================================
-- Regel-id: SQLI-AX-001
-- Titel: Dynamics AX-batchjob er endt med fejl
-- Alvor: High
-- Versioner: SQL Server 2005 og nyere (ikke 2000). Axapta 3.0: verificeret mod en kundes skema (docs/dynamics-ax/
-- schema-ax30.tsv). AX 4.0, AX 2009, AX 2012 og F&O: baseret på Microsofts dokumenterede skema - ikke
-- verificeret mod kundedata.
--
-- Hvad tjekkes, og hvorfor:
-- Et batchjob i Dynamics AX er stoppet med fejl. Det, jobbet skulle gøre (bogføring, lagerafslutning, integration
-- ...), er ikke gjort eller kun delvist, og gentagne job fortsætter ikke nødvendigvis af sig selv.
--
-- Vurdering (sker i C# ud fra de indsamlede data, ikke i SQL'en, medmindre andet er nævnt):
-- Et fund pr. job, når jobbet nu har status Fejl og sluttede (eller var planlagt) inden for grænsen, eller når jobbet
-- venter på næste gentagelse, men dets seneste kørsel i SQLInsights egen historik (insight.AxBatchRuns) endte med
-- fejl inden for grænsen.
-- STATUS oversættes af versionens adapter: AX 2009+ 0 Tilbageholdt, 1 Venter, 2 Udfører, 3 Fejl, 4 Afsluttet,
-- 5 Klar, 6 Ikke kørt, 7 Annullerer, 8 Annulleret; Axapta 3.0/4.0 (antaget, ikke verificeret mod kundedata)
-- 0 Venter, 1 Udfører, 2 Fejl, 3 Afsluttet, 4 Tilbageholdt. Højst 25 fund pr. database.
--
-- Grænseværdier (konfigurationsnøgle i agentens appsettings.json og standardværdi):
-- SQLInsight:DynamicsAx:BatchFailedLookbackHours = 24: fejl inden for så mange timer rapporteres.
--
-- Rettigheder til agentens login (se db/permissions/grant-least-privilege.sql og grant-dynamics-ax-read.sql):
-- Valgfri AX-læseadgang, kun SELECT (db/permissions/grant-dynamics-ax-read.sql; [bruger] = agentens databasebruger):
-- Axapta 3.0: GRANT SELECT ON [bmssa].[BATCH] TO [bruger];
-- AX 4.0: GRANT SELECT ON [dbo].[BATCH] TO [bruger];
-- AX 2009/2012/F&O: GRANT SELECT ON [dbo].[BATCHJOB] TO [bruger];
-- GRANT SELECT ON [dbo].[BATCHJOBHISTORY] TO [bruger];
-- Mangler den, er reglen "ikke målbar" med den nøjagtige GRANT for databasen (også under Dynamics AX → AX-databaser
-- og GET /api/v1/dynamics-ax/grant-script). Uden GRANT ændres intet.
-- Genkendelsen: kun grant-least-privilege.sql (VIEW ANY DEFINITION; CONNECT ANY DATABASE fra 2014, ellers en
-- databasebruger: CREATE USER [agent] FOR LOGIN [agent];).
--
-- Data kommer fra (collector, fil : medlemmer, interval):
-- AxDatabaseDetector (den fælles AX-genkendelse)
-- Fil: src/SQLInsight.Collectors/DynamicsAx/AxDatabaseDetector.cs
-- Medlemmer: AxDatabaseDetector.DatabasesSql, AxDatabaseDetector.BuildDetectionSql(database)
-- Interval: hver time pr. database (hvert 5. minut, mens AX-læseadgangen mangler), delt af alle AX-collectors
-- Delt script: SQL Scripts/_collectors/AxDatabaseDetector.sql
-- AxBatchCollector
-- Fil: src/SQLInsight.Collectors/DynamicsAx/AxBatchCollector.cs; SQL: src/SQLInsight.Core/DynamicsAx/AxSchemaAdapter.cs
-- Medlemmer: AxSchemaAdapter.BatchJobs(database), AxSchemaAdapter.BatchHistory(database, afterRecId), AxSql.InDatabase
-- Interval: 5 min som standard (SQLInsight:CollectorIntervals:dynamics-ax-batch)
-- Delt script: SQL Scripts/_collectors/AxBatchCollector.sql
-- Regelkode: src/SQLInsight.Rules/DynamicsAx/DynamicsAxRules.cs : AxBatchFailedRule
--
-- Read-only: Ja: kun SELECT med TOP og SET-sessionsindstillinger. AX-tabellerne læses i AX-databasen via
-- [db].sys.sp_executesql (sessionen skifter aldrig database) under READ COMMITTED med SET LOCK_TIMEOUT 2000
-- (SNAPSHOT, når databasen tillader det), aldrig NOLOCK; bagefter sættes agentens sessionsindstillinger
-- igen. Intet ændrer data, indstillinger eller objekter. Kun i databaser genkendt som AX og valgt til
-- overvågning.
-- Kørsel: Kør i SSMS som agentens login (eller et login med samme rettigheder). Hver variant er sin egen batch
-- (GO); varianter for andre AX-versioner end din fejler eller giver ingen rækker - det er forventet.
-- AX-forespørgslerne er vist for databasen AXDB: ret [AXDB] til din AX-database.
-- ===================================================================================================================
-- -------------------------------------------------------------------------------------------------------------------
-- Forbindelsen: Sessionsindstillinger, agenten sætter på hver forbindelse
-- -------------------------------------------------------------------------------------------------------------------
-- Variant: Alle versioner: sættes på hver forbindelse, agenten åbner
-- Kilde: SqlConnectionFactory.SessionSettings
-- Kode: src/SQLInsight.Connectivity/SqlConnectionFactory.cs : SqlConnectionFactory.SessionSettings
SET LOCK_TIMEOUT 5000; SET DEADLOCK_PRIORITY LOW; SET TRANSACTION ISOLATION LEVEL READ UNCOMMITTED;
GO
-- -------------------------------------------------------------------------------------------------------------------
-- AxDatabaseDetector: Hvilke overvågede databaser er Dynamics AX (kun metadata)
-- -------------------------------------------------------------------------------------------------------------------
-- Variant: Alle versioner fra SQL Server 2005: online brugerdatabaser og deres snapshot-indstillinger
-- Kilde: AxDatabaseDetector.DatabasesSql
-- Kode: src/SQLInsight.Collectors/DynamicsAx/AxDatabaseDetector.cs : AxDatabaseDetector.DatabasesSql
SELECT name, snapshot_isolation_state, is_read_committed_snapshot_on
FROM sys.databases
WHERE database_id > 4 AND state = 0 AND source_database_id IS NULL AND HAS_DBACCESS(name) = 1
ORDER BY name
GO
-- Variant: SQL Server 2005 og nyere: AX-genkendelsen i hver overvåget database (kun metadata; vist for AXDB)
-- Kilde: AxDatabaseDetector.BuildDetectionSql
-- Kode: src/SQLInsight.Collectors/DynamicsAx/AxDatabaseDetector.cs : AxDatabaseDetector.BuildDetectionSql(database) = AxSql.InDatabase(database, AxDetection.MetadataSql)
-- Agenten kører dette i hver overvåget database fra listen ovenfor (ikke *_model). Ret [AXDB] til den database, du vil tjekke.
EXEC [AXDB].sys.sp_executesql N'SET NOCOUNT ON; SET LOCK_TIMEOUT 2000; SET TRANSACTION ISOLATION LEVEL READ COMMITTED; SELECT s.name, UPPER(t.name), UPPER(c.name), TYPE_NAME(c.system_type_id), c.precision, c.max_length,
ISNULL(HAS_PERMS_BY_NAME(QUOTENAME(s.name) + N''.'' + QUOTENAME(t.name), N''OBJECT'', N''SELECT''), 0),
(SELECT TOP 1 p.name FROM sys.database_principals p WHERE p.sid = SUSER_SID())
FROM sys.tables t
JOIN sys.schemas s ON s.schema_id = t.schema_id
JOIN sys.columns c ON c.object_id = t.object_id
WHERE UPPER(t.name) IN (N''SQLDICTIONARY'', N''DATAAREA'', N''SYSCONFIG'', N''BATCH'', N''BATCHJOB'', N''BATCHJOBHISTORY'', N''BATCHGROUP'', N''NUMBERSEQUENCETABLE'', N''NUMBERSEQUENCESCOPE'', N''NUMBERSEQUENCEREFERENCE'', N''SYSTEMSEQUENCES'', N''MODELELEMENT'')
ORDER BY s.name, t.name, c.column_id';
SET LOCK_TIMEOUT 5000; SET TRANSACTION ISOLATION LEVEL READ UNCOMMITTED;
GO
-- -------------------------------------------------------------------------------------------------------------------
-- AxBatchCollector: Batchjob og kørselshistorik i AX-databasen (valgfri AX-læseadgang)
-- -------------------------------------------------------------------------------------------------------------------
-- Variant: Axapta 3.0 (skema bmssa, verificeret mod kundedata): batchjob
-- Kilde: AxSchemaAdapter.BatchJobs(3.0)
-- Kode: src/SQLInsight.Core/DynamicsAx/AxSchemaAdapter.cs : AxSchemaAdapter.BatchJobs(database) via AxSql.InDatabase
EXEC [AXDB].sys.sp_executesql N'SET NOCOUNT ON; SET LOCK_TIMEOUT 2000; SET TRANSACTION ISOLATION LEVEL READ COMMITTED; SELECT TOP (1000) CAST(b.[RECID] AS bigint), b.[CLASSNUM], CAST(NULL AS nvarchar(60)), b.[COMPANY], b.[GROUPID], b.[STATUS], b.[STARTDATE], b.[STARTTIME], CAST(NULL AS datetime), CAST(NULL AS int), b.[ENDDATE], b.[ENDTIME], DATEDIFF(minute, GETDATE(), GETUTCDATE()) FROM [bmssa].[BATCH] b ORDER BY b.[RECID] DESC';
SET LOCK_TIMEOUT 5000; SET TRANSACTION ISOLATION LEVEL READ UNCOMMITTED;
GO
-- Variant: AX 4.0 (skema dbo) - ikke verificeret mod kundedata: batchjob
-- Kilde: AxSchemaAdapter.BatchJobs(4.0)
-- Kode: src/SQLInsight.Core/DynamicsAx/AxSchemaAdapter.cs : AxSchemaAdapter.BatchJobs(database) via AxSql.InDatabase
EXEC [AXDB].sys.sp_executesql N'SET NOCOUNT ON; SET LOCK_TIMEOUT 2000; SET TRANSACTION ISOLATION LEVEL READ COMMITTED; SELECT TOP (1000) CAST(b.[RECID] AS bigint), b.[CLASSNUM], CAST(NULL AS nvarchar(60)), b.[COMPANY], b.[GROUPID], b.[STATUS], b.[STARTDATE], b.[STARTTIME], CAST(NULL AS datetime), CAST(NULL AS int), b.[ENDDATE], b.[ENDTIME], DATEDIFF(minute, GETDATE(), GETUTCDATE()) FROM [dbo].[BATCH] b ORDER BY b.[RECID] DESC';
SET LOCK_TIMEOUT 5000; SET TRANSACTION ISOLATION LEVEL READ UNCOMMITTED;
GO
-- Variant: AX 2009 - baseret på Microsofts dokumenterede skema, ikke verificeret mod kundedata: batchjob
-- Kilde: AxSchemaAdapter.BatchJobs(2009)
-- Kode: src/SQLInsight.Core/DynamicsAx/AxSchemaAdapter.cs : AxSchemaAdapter.BatchJobs(database) via AxSql.InDatabase
EXEC [AXDB].sys.sp_executesql N'SET NOCOUNT ON; SET LOCK_TIMEOUT 2000; SET TRANSACTION ISOLATION LEVEL READ COMMITTED; SELECT TOP (1000) CAST(j.[RECID] AS bigint), CAST(NULL AS int), j.[CAPTION], j.[COMPANY], CAST(NULL AS nvarchar(20)), j.[STATUS], j.[ORIGSTARTDATETIME], CAST(NULL AS int), j.[STARTDATETIME], CAST(NULL AS int), j.[ENDDATETIME], CAST(NULL AS int), DATEDIFF(minute, GETDATE(), GETUTCDATE()) FROM [dbo].[BATCHJOB] j ORDER BY j.[RECID] DESC';
SET LOCK_TIMEOUT 5000; SET TRANSACTION ISOLATION LEVEL READ UNCOMMITTED;
GO
-- Variant: AX 2009 - baseret på Microsofts dokumenterede skema, ikke verificeret mod kundedata: kørselshistorik, første læsning (de nyeste)
-- Kilde: AxSchemaAdapter.BatchHistory(2009, første)
-- Kode: src/SQLInsight.Core/DynamicsAx/AxSchemaAdapter.cs : AxSchemaAdapter.BatchHistory(database, afterRecId: null) via AxSql.InDatabase
EXEC [AXDB].sys.sp_executesql N'SET NOCOUNT ON; SET LOCK_TIMEOUT 2000; SET TRANSACTION ISOLATION LEVEL READ COMMITTED; SELECT TOP (1000) CAST(h.[RECID] AS bigint), CAST(h.[BATCHJOBID] AS bigint), h.[CAPTION], h.[COMPANY], h.[STATUS], h.[STARTDATETIME], h.[ENDDATETIME], DATEDIFF(minute, GETDATE(), GETUTCDATE()) FROM [dbo].[BATCHJOBHISTORY] h ORDER BY h.[RECID] DESC';
SET LOCK_TIMEOUT 5000; SET TRANSACTION ISOLATION LEVEL READ UNCOMMITTED;
GO
-- Variant: AX 2009 - baseret på Microsofts dokumenterede skema, ikke verificeret mod kundedata: kørselshistorik, de næste læsninger (fra den højeste RECID, SQLInsight har læst, minus overlappet)
-- Kilde: AxSchemaAdapter.BatchHistory(2009, næste)
-- Kode: src/SQLInsight.Core/DynamicsAx/AxSchemaAdapter.cs : AxSchemaAdapter.BatchHistory(database, afterRecId) via AxSql.InDatabase
-- Agenten indsætter den højeste RECID, den har læst, minus overlappet, hvor der her står 0.
EXEC [AXDB].sys.sp_executesql N'SET NOCOUNT ON; SET LOCK_TIMEOUT 2000; SET TRANSACTION ISOLATION LEVEL READ COMMITTED; SELECT TOP (3000) CAST(h.[RECID] AS bigint), CAST(h.[BATCHJOBID] AS bigint), h.[CAPTION], h.[COMPANY], h.[STATUS], h.[STARTDATETIME], h.[ENDDATETIME], DATEDIFF(minute, GETDATE(), GETUTCDATE()) FROM [dbo].[BATCHJOBHISTORY] h WHERE h.[RECID] > 0 ORDER BY h.[RECID]';
SET LOCK_TIMEOUT 5000; SET TRANSACTION ISOLATION LEVEL READ UNCOMMITTED;
GO
-- Variant: AX 2012 RTM/R2/R3 - baseret på Microsofts dokumenterede skema, ikke verificeret mod kundedata: batchjob
-- Kilde: AxSchemaAdapter.BatchJobs(2012)
-- Kode: src/SQLInsight.Core/DynamicsAx/AxSchemaAdapter.cs : AxSchemaAdapter.BatchJobs(database) via AxSql.InDatabase
EXEC [AXDB].sys.sp_executesql N'SET NOCOUNT ON; SET LOCK_TIMEOUT 2000; SET TRANSACTION ISOLATION LEVEL READ COMMITTED; SELECT TOP (1000) CAST(j.[RECID] AS bigint), CAST(NULL AS int), j.[CAPTION], j.[COMPANY], CAST(NULL AS nvarchar(20)), j.[STATUS], j.[ORIGSTARTDATETIME], CAST(NULL AS int), j.[STARTDATETIME], CAST(NULL AS int), j.[ENDDATETIME], CAST(NULL AS int), DATEDIFF(minute, GETDATE(), GETUTCDATE()) FROM [dbo].[BATCHJOB] j ORDER BY j.[RECID] DESC';
SET LOCK_TIMEOUT 5000; SET TRANSACTION ISOLATION LEVEL READ UNCOMMITTED;
GO
-- Variant: AX 2012 RTM/R2/R3 - baseret på Microsofts dokumenterede skema, ikke verificeret mod kundedata: kørselshistorik, første læsning (de nyeste)
-- Kilde: AxSchemaAdapter.BatchHistory(2012, første)
-- Kode: src/SQLInsight.Core/DynamicsAx/AxSchemaAdapter.cs : AxSchemaAdapter.BatchHistory(database, afterRecId: null) via AxSql.InDatabase
EXEC [AXDB].sys.sp_executesql N'SET NOCOUNT ON; SET LOCK_TIMEOUT 2000; SET TRANSACTION ISOLATION LEVEL READ COMMITTED; SELECT TOP (1000) CAST(h.[RECID] AS bigint), CAST(h.[BATCHJOBID] AS bigint), h.[CAPTION], h.[COMPANY], h.[STATUS], h.[STARTDATETIME], h.[ENDDATETIME], DATEDIFF(minute, GETDATE(), GETUTCDATE()) FROM [dbo].[BATCHJOBHISTORY] h ORDER BY h.[RECID] DESC';
SET LOCK_TIMEOUT 5000; SET TRANSACTION ISOLATION LEVEL READ UNCOMMITTED;
GO
-- Variant: AX 2012 RTM/R2/R3 - baseret på Microsofts dokumenterede skema, ikke verificeret mod kundedata: kørselshistorik, de næste læsninger (fra den højeste RECID, SQLInsight har læst, minus overlappet)
-- Kilde: AxSchemaAdapter.BatchHistory(2012, næste)
-- Kode: src/SQLInsight.Core/DynamicsAx/AxSchemaAdapter.cs : AxSchemaAdapter.BatchHistory(database, afterRecId) via AxSql.InDatabase
-- Agenten indsætter den højeste RECID, den har læst, minus overlappet, hvor der her står 0.
EXEC [AXDB].sys.sp_executesql N'SET NOCOUNT ON; SET LOCK_TIMEOUT 2000; SET TRANSACTION ISOLATION LEVEL READ COMMITTED; SELECT TOP (3000) CAST(h.[RECID] AS bigint), CAST(h.[BATCHJOBID] AS bigint), h.[CAPTION], h.[COMPANY], h.[STATUS], h.[STARTDATETIME], h.[ENDDATETIME], DATEDIFF(minute, GETDATE(), GETUTCDATE()) FROM [dbo].[BATCHJOBHISTORY] h WHERE h.[RECID] > 0 ORDER BY h.[RECID]';
SET LOCK_TIMEOUT 5000; SET TRANSACTION ISOLATION LEVEL READ UNCOMMITTED;
GO

163
SQL Scripts/SQLI-AX-002.sql Normal file
View file

@ -0,0 +1,163 @@
-- ===================================================================================================================
-- SQLI-AX-002: Dynamics AX-batchjob kører længere end normalt
-- ===================================================================================================================
-- Regel-id: SQLI-AX-002
-- Titel: Dynamics AX-batchjob kører længere end normalt
-- Alvor: Medium; High, når kørslen er dobbelt så lang som grænsen.
-- Versioner: SQL Server 2005 og nyere (ikke 2000). Axapta 3.0: verificeret mod en kundes skema (docs/dynamics-ax/
-- schema-ax30.tsv). AX 4.0, AX 2009, AX 2012 og F&O: baseret på Microsofts dokumenterede skema - ikke
-- verificeret mod kundedata.
--
-- Hvad tjekkes, og hvorfor:
-- Et kørende batchjob har kørt meget længere end jobbets egne seneste kørsler. Det kan skyldes blokering, en ændret
-- datamængde, en dårlig plan, eller at jobbet hænger - og imens kan det holde låse, som brugerne venter på.
--
-- Vurdering (sker i C# ud fra de indsamlede data, ikke i SQL'en, medmindre andet er nævnt):
-- For job med status Udfører: kørt = nu - faktisk start (AX 2009+: BATCHJOB.STARTDATETIME; Axapta 3.0/4.0 gemmer
-- ikke den faktiske start, så det er første gang, SQLInsight så jobbet køre). Grænse = max(faktor x 90 %-fraktil af
-- jobbets seneste op til 20 vellykkede kørsler, minimum). Historikken er SQLInsights egen (insight.AxBatchRuns): fra
-- BATCHJOBHISTORY i AX 2009+, ellers de kørsler, SQLInsight selv ser blive afsluttet. Hænger jobbet (SQLI-AX-004),
-- gives kun det fund.
-- STATUS oversættes af versionens adapter: AX 2009+ 0 Tilbageholdt, 1 Venter, 2 Udfører, 3 Fejl, 4 Afsluttet,
-- 5 Klar, 6 Ikke kørt, 7 Annullerer, 8 Annulleret; Axapta 3.0/4.0 (antaget, ikke verificeret mod kundedata)
-- 0 Venter, 1 Udfører, 2 Fejl, 3 Afsluttet, 4 Tilbageholdt. Højst 25 fund pr. database.
--
-- Grænseværdier (konfigurationsnøgle i agentens appsettings.json og standardværdi):
-- SQLInsight:DynamicsAx:BatchLongRunningFactor = 2.0: kørt mere end så mange gange jobbets 90 %-fraktil
-- SQLInsight:DynamicsAx:BatchLongRunningMinimumMinutes = 10: men aldrig under så mange minutter
-- SQLInsight:DynamicsAx:BatchHistoryMinimumRuns = 5: så mange tidligere kørsler skal der være
--
-- Rettigheder til agentens login (se db/permissions/grant-least-privilege.sql og grant-dynamics-ax-read.sql):
-- Valgfri AX-læseadgang, kun SELECT (db/permissions/grant-dynamics-ax-read.sql; [bruger] = agentens databasebruger):
-- Axapta 3.0: GRANT SELECT ON [bmssa].[BATCH] TO [bruger];
-- AX 4.0: GRANT SELECT ON [dbo].[BATCH] TO [bruger];
-- AX 2009/2012/F&O: GRANT SELECT ON [dbo].[BATCHJOB] TO [bruger];
-- GRANT SELECT ON [dbo].[BATCHJOBHISTORY] TO [bruger];
-- Mangler den, er reglen "ikke målbar" med den nøjagtige GRANT for databasen (også under Dynamics AX → AX-databaser
-- og GET /api/v1/dynamics-ax/grant-script). Uden GRANT ændres intet.
-- Genkendelsen: kun grant-least-privilege.sql (VIEW ANY DEFINITION; CONNECT ANY DATABASE fra 2014, ellers en
-- databasebruger: CREATE USER [agent] FOR LOGIN [agent];).
--
-- Data kommer fra (collector, fil : medlemmer, interval):
-- AxDatabaseDetector (den fælles AX-genkendelse)
-- Fil: src/SQLInsight.Collectors/DynamicsAx/AxDatabaseDetector.cs
-- Medlemmer: AxDatabaseDetector.DatabasesSql, AxDatabaseDetector.BuildDetectionSql(database)
-- Interval: hver time pr. database (hvert 5. minut, mens AX-læseadgangen mangler), delt af alle AX-collectors
-- Delt script: SQL Scripts/_collectors/AxDatabaseDetector.sql
-- AxBatchCollector
-- Fil: src/SQLInsight.Collectors/DynamicsAx/AxBatchCollector.cs; SQL: src/SQLInsight.Core/DynamicsAx/AxSchemaAdapter.cs
-- Medlemmer: AxSchemaAdapter.BatchJobs(database), AxSchemaAdapter.BatchHistory(database, afterRecId), AxSql.InDatabase
-- Interval: 5 min som standard (SQLInsight:CollectorIntervals:dynamics-ax-batch)
-- Delt script: SQL Scripts/_collectors/AxBatchCollector.sql
-- Regelkode: src/SQLInsight.Rules/DynamicsAx/DynamicsAxRules.cs : AxBatchLongRunningRule
--
-- Read-only: Ja: kun SELECT med TOP og SET-sessionsindstillinger. AX-tabellerne læses i AX-databasen via
-- [db].sys.sp_executesql (sessionen skifter aldrig database) under READ COMMITTED med SET LOCK_TIMEOUT 2000
-- (SNAPSHOT, når databasen tillader det), aldrig NOLOCK; bagefter sættes agentens sessionsindstillinger
-- igen. Intet ændrer data, indstillinger eller objekter. Kun i databaser genkendt som AX og valgt til
-- overvågning.
-- Kørsel: Kør i SSMS som agentens login (eller et login med samme rettigheder). Hver variant er sin egen batch
-- (GO); varianter for andre AX-versioner end din fejler eller giver ingen rækker - det er forventet.
-- AX-forespørgslerne er vist for databasen AXDB: ret [AXDB] til din AX-database.
-- ===================================================================================================================
-- -------------------------------------------------------------------------------------------------------------------
-- Forbindelsen: Sessionsindstillinger, agenten sætter på hver forbindelse
-- -------------------------------------------------------------------------------------------------------------------
-- Variant: Alle versioner: sættes på hver forbindelse, agenten åbner
-- Kilde: SqlConnectionFactory.SessionSettings
-- Kode: src/SQLInsight.Connectivity/SqlConnectionFactory.cs : SqlConnectionFactory.SessionSettings
SET LOCK_TIMEOUT 5000; SET DEADLOCK_PRIORITY LOW; SET TRANSACTION ISOLATION LEVEL READ UNCOMMITTED;
GO
-- -------------------------------------------------------------------------------------------------------------------
-- AxDatabaseDetector: Hvilke overvågede databaser er Dynamics AX (kun metadata)
-- -------------------------------------------------------------------------------------------------------------------
-- Variant: Alle versioner fra SQL Server 2005: online brugerdatabaser og deres snapshot-indstillinger
-- Kilde: AxDatabaseDetector.DatabasesSql
-- Kode: src/SQLInsight.Collectors/DynamicsAx/AxDatabaseDetector.cs : AxDatabaseDetector.DatabasesSql
SELECT name, snapshot_isolation_state, is_read_committed_snapshot_on
FROM sys.databases
WHERE database_id > 4 AND state = 0 AND source_database_id IS NULL AND HAS_DBACCESS(name) = 1
ORDER BY name
GO
-- Variant: SQL Server 2005 og nyere: AX-genkendelsen i hver overvåget database (kun metadata; vist for AXDB)
-- Kilde: AxDatabaseDetector.BuildDetectionSql
-- Kode: src/SQLInsight.Collectors/DynamicsAx/AxDatabaseDetector.cs : AxDatabaseDetector.BuildDetectionSql(database) = AxSql.InDatabase(database, AxDetection.MetadataSql)
-- Agenten kører dette i hver overvåget database fra listen ovenfor (ikke *_model). Ret [AXDB] til den database, du vil tjekke.
EXEC [AXDB].sys.sp_executesql N'SET NOCOUNT ON; SET LOCK_TIMEOUT 2000; SET TRANSACTION ISOLATION LEVEL READ COMMITTED; SELECT s.name, UPPER(t.name), UPPER(c.name), TYPE_NAME(c.system_type_id), c.precision, c.max_length,
ISNULL(HAS_PERMS_BY_NAME(QUOTENAME(s.name) + N''.'' + QUOTENAME(t.name), N''OBJECT'', N''SELECT''), 0),
(SELECT TOP 1 p.name FROM sys.database_principals p WHERE p.sid = SUSER_SID())
FROM sys.tables t
JOIN sys.schemas s ON s.schema_id = t.schema_id
JOIN sys.columns c ON c.object_id = t.object_id
WHERE UPPER(t.name) IN (N''SQLDICTIONARY'', N''DATAAREA'', N''SYSCONFIG'', N''BATCH'', N''BATCHJOB'', N''BATCHJOBHISTORY'', N''BATCHGROUP'', N''NUMBERSEQUENCETABLE'', N''NUMBERSEQUENCESCOPE'', N''NUMBERSEQUENCEREFERENCE'', N''SYSTEMSEQUENCES'', N''MODELELEMENT'')
ORDER BY s.name, t.name, c.column_id';
SET LOCK_TIMEOUT 5000; SET TRANSACTION ISOLATION LEVEL READ UNCOMMITTED;
GO
-- -------------------------------------------------------------------------------------------------------------------
-- AxBatchCollector: Batchjob og kørselshistorik i AX-databasen (valgfri AX-læseadgang)
-- -------------------------------------------------------------------------------------------------------------------
-- Variant: Axapta 3.0 (skema bmssa, verificeret mod kundedata): batchjob
-- Kilde: AxSchemaAdapter.BatchJobs(3.0)
-- Kode: src/SQLInsight.Core/DynamicsAx/AxSchemaAdapter.cs : AxSchemaAdapter.BatchJobs(database) via AxSql.InDatabase
EXEC [AXDB].sys.sp_executesql N'SET NOCOUNT ON; SET LOCK_TIMEOUT 2000; SET TRANSACTION ISOLATION LEVEL READ COMMITTED; SELECT TOP (1000) CAST(b.[RECID] AS bigint), b.[CLASSNUM], CAST(NULL AS nvarchar(60)), b.[COMPANY], b.[GROUPID], b.[STATUS], b.[STARTDATE], b.[STARTTIME], CAST(NULL AS datetime), CAST(NULL AS int), b.[ENDDATE], b.[ENDTIME], DATEDIFF(minute, GETDATE(), GETUTCDATE()) FROM [bmssa].[BATCH] b ORDER BY b.[RECID] DESC';
SET LOCK_TIMEOUT 5000; SET TRANSACTION ISOLATION LEVEL READ UNCOMMITTED;
GO
-- Variant: AX 4.0 (skema dbo) - ikke verificeret mod kundedata: batchjob
-- Kilde: AxSchemaAdapter.BatchJobs(4.0)
-- Kode: src/SQLInsight.Core/DynamicsAx/AxSchemaAdapter.cs : AxSchemaAdapter.BatchJobs(database) via AxSql.InDatabase
EXEC [AXDB].sys.sp_executesql N'SET NOCOUNT ON; SET LOCK_TIMEOUT 2000; SET TRANSACTION ISOLATION LEVEL READ COMMITTED; SELECT TOP (1000) CAST(b.[RECID] AS bigint), b.[CLASSNUM], CAST(NULL AS nvarchar(60)), b.[COMPANY], b.[GROUPID], b.[STATUS], b.[STARTDATE], b.[STARTTIME], CAST(NULL AS datetime), CAST(NULL AS int), b.[ENDDATE], b.[ENDTIME], DATEDIFF(minute, GETDATE(), GETUTCDATE()) FROM [dbo].[BATCH] b ORDER BY b.[RECID] DESC';
SET LOCK_TIMEOUT 5000; SET TRANSACTION ISOLATION LEVEL READ UNCOMMITTED;
GO
-- Variant: AX 2009 - baseret på Microsofts dokumenterede skema, ikke verificeret mod kundedata: batchjob
-- Kilde: AxSchemaAdapter.BatchJobs(2009)
-- Kode: src/SQLInsight.Core/DynamicsAx/AxSchemaAdapter.cs : AxSchemaAdapter.BatchJobs(database) via AxSql.InDatabase
EXEC [AXDB].sys.sp_executesql N'SET NOCOUNT ON; SET LOCK_TIMEOUT 2000; SET TRANSACTION ISOLATION LEVEL READ COMMITTED; SELECT TOP (1000) CAST(j.[RECID] AS bigint), CAST(NULL AS int), j.[CAPTION], j.[COMPANY], CAST(NULL AS nvarchar(20)), j.[STATUS], j.[ORIGSTARTDATETIME], CAST(NULL AS int), j.[STARTDATETIME], CAST(NULL AS int), j.[ENDDATETIME], CAST(NULL AS int), DATEDIFF(minute, GETDATE(), GETUTCDATE()) FROM [dbo].[BATCHJOB] j ORDER BY j.[RECID] DESC';
SET LOCK_TIMEOUT 5000; SET TRANSACTION ISOLATION LEVEL READ UNCOMMITTED;
GO
-- Variant: AX 2009 - baseret på Microsofts dokumenterede skema, ikke verificeret mod kundedata: kørselshistorik, første læsning (de nyeste)
-- Kilde: AxSchemaAdapter.BatchHistory(2009, første)
-- Kode: src/SQLInsight.Core/DynamicsAx/AxSchemaAdapter.cs : AxSchemaAdapter.BatchHistory(database, afterRecId: null) via AxSql.InDatabase
EXEC [AXDB].sys.sp_executesql N'SET NOCOUNT ON; SET LOCK_TIMEOUT 2000; SET TRANSACTION ISOLATION LEVEL READ COMMITTED; SELECT TOP (1000) CAST(h.[RECID] AS bigint), CAST(h.[BATCHJOBID] AS bigint), h.[CAPTION], h.[COMPANY], h.[STATUS], h.[STARTDATETIME], h.[ENDDATETIME], DATEDIFF(minute, GETDATE(), GETUTCDATE()) FROM [dbo].[BATCHJOBHISTORY] h ORDER BY h.[RECID] DESC';
SET LOCK_TIMEOUT 5000; SET TRANSACTION ISOLATION LEVEL READ UNCOMMITTED;
GO
-- Variant: AX 2009 - baseret på Microsofts dokumenterede skema, ikke verificeret mod kundedata: kørselshistorik, de næste læsninger (fra den højeste RECID, SQLInsight har læst, minus overlappet)
-- Kilde: AxSchemaAdapter.BatchHistory(2009, næste)
-- Kode: src/SQLInsight.Core/DynamicsAx/AxSchemaAdapter.cs : AxSchemaAdapter.BatchHistory(database, afterRecId) via AxSql.InDatabase
-- Agenten indsætter den højeste RECID, den har læst, minus overlappet, hvor der her står 0.
EXEC [AXDB].sys.sp_executesql N'SET NOCOUNT ON; SET LOCK_TIMEOUT 2000; SET TRANSACTION ISOLATION LEVEL READ COMMITTED; SELECT TOP (3000) CAST(h.[RECID] AS bigint), CAST(h.[BATCHJOBID] AS bigint), h.[CAPTION], h.[COMPANY], h.[STATUS], h.[STARTDATETIME], h.[ENDDATETIME], DATEDIFF(minute, GETDATE(), GETUTCDATE()) FROM [dbo].[BATCHJOBHISTORY] h WHERE h.[RECID] > 0 ORDER BY h.[RECID]';
SET LOCK_TIMEOUT 5000; SET TRANSACTION ISOLATION LEVEL READ UNCOMMITTED;
GO
-- Variant: AX 2012 RTM/R2/R3 - baseret på Microsofts dokumenterede skema, ikke verificeret mod kundedata: batchjob
-- Kilde: AxSchemaAdapter.BatchJobs(2012)
-- Kode: src/SQLInsight.Core/DynamicsAx/AxSchemaAdapter.cs : AxSchemaAdapter.BatchJobs(database) via AxSql.InDatabase
EXEC [AXDB].sys.sp_executesql N'SET NOCOUNT ON; SET LOCK_TIMEOUT 2000; SET TRANSACTION ISOLATION LEVEL READ COMMITTED; SELECT TOP (1000) CAST(j.[RECID] AS bigint), CAST(NULL AS int), j.[CAPTION], j.[COMPANY], CAST(NULL AS nvarchar(20)), j.[STATUS], j.[ORIGSTARTDATETIME], CAST(NULL AS int), j.[STARTDATETIME], CAST(NULL AS int), j.[ENDDATETIME], CAST(NULL AS int), DATEDIFF(minute, GETDATE(), GETUTCDATE()) FROM [dbo].[BATCHJOB] j ORDER BY j.[RECID] DESC';
SET LOCK_TIMEOUT 5000; SET TRANSACTION ISOLATION LEVEL READ UNCOMMITTED;
GO
-- Variant: AX 2012 RTM/R2/R3 - baseret på Microsofts dokumenterede skema, ikke verificeret mod kundedata: kørselshistorik, første læsning (de nyeste)
-- Kilde: AxSchemaAdapter.BatchHistory(2012, første)
-- Kode: src/SQLInsight.Core/DynamicsAx/AxSchemaAdapter.cs : AxSchemaAdapter.BatchHistory(database, afterRecId: null) via AxSql.InDatabase
EXEC [AXDB].sys.sp_executesql N'SET NOCOUNT ON; SET LOCK_TIMEOUT 2000; SET TRANSACTION ISOLATION LEVEL READ COMMITTED; SELECT TOP (1000) CAST(h.[RECID] AS bigint), CAST(h.[BATCHJOBID] AS bigint), h.[CAPTION], h.[COMPANY], h.[STATUS], h.[STARTDATETIME], h.[ENDDATETIME], DATEDIFF(minute, GETDATE(), GETUTCDATE()) FROM [dbo].[BATCHJOBHISTORY] h ORDER BY h.[RECID] DESC';
SET LOCK_TIMEOUT 5000; SET TRANSACTION ISOLATION LEVEL READ UNCOMMITTED;
GO
-- Variant: AX 2012 RTM/R2/R3 - baseret på Microsofts dokumenterede skema, ikke verificeret mod kundedata: kørselshistorik, de næste læsninger (fra den højeste RECID, SQLInsight har læst, minus overlappet)
-- Kilde: AxSchemaAdapter.BatchHistory(2012, næste)
-- Kode: src/SQLInsight.Core/DynamicsAx/AxSchemaAdapter.cs : AxSchemaAdapter.BatchHistory(database, afterRecId) via AxSql.InDatabase
-- Agenten indsætter den højeste RECID, den har læst, minus overlappet, hvor der her står 0.
EXEC [AXDB].sys.sp_executesql N'SET NOCOUNT ON; SET LOCK_TIMEOUT 2000; SET TRANSACTION ISOLATION LEVEL READ COMMITTED; SELECT TOP (3000) CAST(h.[RECID] AS bigint), CAST(h.[BATCHJOBID] AS bigint), h.[CAPTION], h.[COMPANY], h.[STATUS], h.[STARTDATETIME], h.[ENDDATETIME], DATEDIFF(minute, GETDATE(), GETUTCDATE()) FROM [dbo].[BATCHJOBHISTORY] h WHERE h.[RECID] > 0 ORDER BY h.[RECID]';
SET LOCK_TIMEOUT 5000; SET TRANSACTION ISOLATION LEVEL READ UNCOMMITTED;
GO

162
SQL Scripts/SQLI-AX-003.sql Normal file
View file

@ -0,0 +1,162 @@
-- ===================================================================================================================
-- SQLI-AX-003: Dynamics AX-batchjob er ikke startet til tiden
-- ===================================================================================================================
-- Regel-id: SQLI-AX-003
-- Titel: Dynamics AX-batchjob er ikke startet til tiden
-- Alvor: Medium; High, når jobbet i praksis aldrig er startet.
-- Versioner: SQL Server 2005 og nyere (ikke 2000). Axapta 3.0: verificeret mod en kundes skema (docs/dynamics-ax/
-- schema-ax30.tsv). AX 4.0, AX 2009, AX 2012 og F&O: baseret på Microsofts dokumenterede skema - ikke
-- verificeret mod kundedata.
--
-- Hvad tjekkes, og hvorfor:
-- Et ventende batchjob, hvis planlagte start er passeret: batchserveren (AOS med batch, eller batch-klienten i
-- Axapta 3.0) tager det ikke. Mange forsinkede job på én gang betyder typisk, at batchserveren slet ikke kører.
--
-- Vurdering (sker i C# ud fra de indsamlede data, ikke i SQL'en, medmindre andet er nævnt):
-- For job med status Venter: forsinkelse = nu - planlagt start. Planlagt start er i AX 2009+ BATCHJOB.ORIGSTARTDATETIME
-- (UTC). I Axapta 3.0/4.0 er den BATCH.STARTDATE (lokal dato) + STARTTIME (sekunder efter midnat, lokal tid); den
-- regnes om til UTC med AX-tidszonen (SQLInsight:DynamicsAx:LocalTimeZone, ellers agentens tidszone) og de
-- sommertidsregler, der gjaldt på datoen. AX' tomme dato (1900-01-01) er "ingen". Job med status Tilbageholdt er
-- ikke forsinkede. De mest forsinkede først.
-- STATUS oversættes af versionens adapter: AX 2009+ 0 Tilbageholdt, 1 Venter, 2 Udfører, 3 Fejl, 4 Afsluttet,
-- 5 Klar, 6 Ikke kørt, 7 Annullerer, 8 Annulleret; Axapta 3.0/4.0 (antaget, ikke verificeret mod kundedata)
-- 0 Venter, 1 Udfører, 2 Fejl, 3 Afsluttet, 4 Tilbageholdt. Højst 25 fund pr. database.
--
-- Grænseværdier (konfigurationsnøgle i agentens appsettings.json og standardværdi):
-- SQLInsight:DynamicsAx:BatchLateMinutes = 30: forsinket mere end så mange minutter: Medium
-- SQLInsight:DynamicsAx:BatchNeverStartedHours = 24: forsinket mere end så mange timer: High, "aldrig startet"
--
-- Rettigheder til agentens login (se db/permissions/grant-least-privilege.sql og grant-dynamics-ax-read.sql):
-- Valgfri AX-læseadgang, kun SELECT (db/permissions/grant-dynamics-ax-read.sql; [bruger] = agentens databasebruger):
-- Axapta 3.0: GRANT SELECT ON [bmssa].[BATCH] TO [bruger];
-- AX 4.0: GRANT SELECT ON [dbo].[BATCH] TO [bruger];
-- AX 2009/2012/F&O: GRANT SELECT ON [dbo].[BATCHJOB] TO [bruger];
-- GRANT SELECT ON [dbo].[BATCHJOBHISTORY] TO [bruger];
-- Mangler den, er reglen "ikke målbar" med den nøjagtige GRANT for databasen (også under Dynamics AX → AX-databaser
-- og GET /api/v1/dynamics-ax/grant-script). Uden GRANT ændres intet.
-- Genkendelsen: kun grant-least-privilege.sql (VIEW ANY DEFINITION; CONNECT ANY DATABASE fra 2014, ellers en
-- databasebruger: CREATE USER [agent] FOR LOGIN [agent];).
--
-- Data kommer fra (collector, fil : medlemmer, interval):
-- AxDatabaseDetector (den fælles AX-genkendelse)
-- Fil: src/SQLInsight.Collectors/DynamicsAx/AxDatabaseDetector.cs
-- Medlemmer: AxDatabaseDetector.DatabasesSql, AxDatabaseDetector.BuildDetectionSql(database)
-- Interval: hver time pr. database (hvert 5. minut, mens AX-læseadgangen mangler), delt af alle AX-collectors
-- Delt script: SQL Scripts/_collectors/AxDatabaseDetector.sql
-- AxBatchCollector
-- Fil: src/SQLInsight.Collectors/DynamicsAx/AxBatchCollector.cs; SQL: src/SQLInsight.Core/DynamicsAx/AxSchemaAdapter.cs
-- Medlemmer: AxSchemaAdapter.BatchJobs(database), AxSchemaAdapter.BatchHistory(database, afterRecId), AxSql.InDatabase
-- Interval: 5 min som standard (SQLInsight:CollectorIntervals:dynamics-ax-batch)
-- Delt script: SQL Scripts/_collectors/AxBatchCollector.sql
-- Regelkode: src/SQLInsight.Rules/DynamicsAx/DynamicsAxRules.cs : AxBatchLateRule
--
-- Read-only: Ja: kun SELECT med TOP og SET-sessionsindstillinger. AX-tabellerne læses i AX-databasen via
-- [db].sys.sp_executesql (sessionen skifter aldrig database) under READ COMMITTED med SET LOCK_TIMEOUT 2000
-- (SNAPSHOT, når databasen tillader det), aldrig NOLOCK; bagefter sættes agentens sessionsindstillinger
-- igen. Intet ændrer data, indstillinger eller objekter. Kun i databaser genkendt som AX og valgt til
-- overvågning.
-- Kørsel: Kør i SSMS som agentens login (eller et login med samme rettigheder). Hver variant er sin egen batch
-- (GO); varianter for andre AX-versioner end din fejler eller giver ingen rækker - det er forventet.
-- AX-forespørgslerne er vist for databasen AXDB: ret [AXDB] til din AX-database.
-- ===================================================================================================================
-- -------------------------------------------------------------------------------------------------------------------
-- Forbindelsen: Sessionsindstillinger, agenten sætter på hver forbindelse
-- -------------------------------------------------------------------------------------------------------------------
-- Variant: Alle versioner: sættes på hver forbindelse, agenten åbner
-- Kilde: SqlConnectionFactory.SessionSettings
-- Kode: src/SQLInsight.Connectivity/SqlConnectionFactory.cs : SqlConnectionFactory.SessionSettings
SET LOCK_TIMEOUT 5000; SET DEADLOCK_PRIORITY LOW; SET TRANSACTION ISOLATION LEVEL READ UNCOMMITTED;
GO
-- -------------------------------------------------------------------------------------------------------------------
-- AxDatabaseDetector: Hvilke overvågede databaser er Dynamics AX (kun metadata)
-- -------------------------------------------------------------------------------------------------------------------
-- Variant: Alle versioner fra SQL Server 2005: online brugerdatabaser og deres snapshot-indstillinger
-- Kilde: AxDatabaseDetector.DatabasesSql
-- Kode: src/SQLInsight.Collectors/DynamicsAx/AxDatabaseDetector.cs : AxDatabaseDetector.DatabasesSql
SELECT name, snapshot_isolation_state, is_read_committed_snapshot_on
FROM sys.databases
WHERE database_id > 4 AND state = 0 AND source_database_id IS NULL AND HAS_DBACCESS(name) = 1
ORDER BY name
GO
-- Variant: SQL Server 2005 og nyere: AX-genkendelsen i hver overvåget database (kun metadata; vist for AXDB)
-- Kilde: AxDatabaseDetector.BuildDetectionSql
-- Kode: src/SQLInsight.Collectors/DynamicsAx/AxDatabaseDetector.cs : AxDatabaseDetector.BuildDetectionSql(database) = AxSql.InDatabase(database, AxDetection.MetadataSql)
-- Agenten kører dette i hver overvåget database fra listen ovenfor (ikke *_model). Ret [AXDB] til den database, du vil tjekke.
EXEC [AXDB].sys.sp_executesql N'SET NOCOUNT ON; SET LOCK_TIMEOUT 2000; SET TRANSACTION ISOLATION LEVEL READ COMMITTED; SELECT s.name, UPPER(t.name), UPPER(c.name), TYPE_NAME(c.system_type_id), c.precision, c.max_length,
ISNULL(HAS_PERMS_BY_NAME(QUOTENAME(s.name) + N''.'' + QUOTENAME(t.name), N''OBJECT'', N''SELECT''), 0),
(SELECT TOP 1 p.name FROM sys.database_principals p WHERE p.sid = SUSER_SID())
FROM sys.tables t
JOIN sys.schemas s ON s.schema_id = t.schema_id
JOIN sys.columns c ON c.object_id = t.object_id
WHERE UPPER(t.name) IN (N''SQLDICTIONARY'', N''DATAAREA'', N''SYSCONFIG'', N''BATCH'', N''BATCHJOB'', N''BATCHJOBHISTORY'', N''BATCHGROUP'', N''NUMBERSEQUENCETABLE'', N''NUMBERSEQUENCESCOPE'', N''NUMBERSEQUENCEREFERENCE'', N''SYSTEMSEQUENCES'', N''MODELELEMENT'')
ORDER BY s.name, t.name, c.column_id';
SET LOCK_TIMEOUT 5000; SET TRANSACTION ISOLATION LEVEL READ UNCOMMITTED;
GO
-- -------------------------------------------------------------------------------------------------------------------
-- AxBatchCollector: Batchjob og kørselshistorik i AX-databasen (valgfri AX-læseadgang)
-- -------------------------------------------------------------------------------------------------------------------
-- Variant: Axapta 3.0 (skema bmssa, verificeret mod kundedata): batchjob
-- Kilde: AxSchemaAdapter.BatchJobs(3.0)
-- Kode: src/SQLInsight.Core/DynamicsAx/AxSchemaAdapter.cs : AxSchemaAdapter.BatchJobs(database) via AxSql.InDatabase
EXEC [AXDB].sys.sp_executesql N'SET NOCOUNT ON; SET LOCK_TIMEOUT 2000; SET TRANSACTION ISOLATION LEVEL READ COMMITTED; SELECT TOP (1000) CAST(b.[RECID] AS bigint), b.[CLASSNUM], CAST(NULL AS nvarchar(60)), b.[COMPANY], b.[GROUPID], b.[STATUS], b.[STARTDATE], b.[STARTTIME], CAST(NULL AS datetime), CAST(NULL AS int), b.[ENDDATE], b.[ENDTIME], DATEDIFF(minute, GETDATE(), GETUTCDATE()) FROM [bmssa].[BATCH] b ORDER BY b.[RECID] DESC';
SET LOCK_TIMEOUT 5000; SET TRANSACTION ISOLATION LEVEL READ UNCOMMITTED;
GO
-- Variant: AX 4.0 (skema dbo) - ikke verificeret mod kundedata: batchjob
-- Kilde: AxSchemaAdapter.BatchJobs(4.0)
-- Kode: src/SQLInsight.Core/DynamicsAx/AxSchemaAdapter.cs : AxSchemaAdapter.BatchJobs(database) via AxSql.InDatabase
EXEC [AXDB].sys.sp_executesql N'SET NOCOUNT ON; SET LOCK_TIMEOUT 2000; SET TRANSACTION ISOLATION LEVEL READ COMMITTED; SELECT TOP (1000) CAST(b.[RECID] AS bigint), b.[CLASSNUM], CAST(NULL AS nvarchar(60)), b.[COMPANY], b.[GROUPID], b.[STATUS], b.[STARTDATE], b.[STARTTIME], CAST(NULL AS datetime), CAST(NULL AS int), b.[ENDDATE], b.[ENDTIME], DATEDIFF(minute, GETDATE(), GETUTCDATE()) FROM [dbo].[BATCH] b ORDER BY b.[RECID] DESC';
SET LOCK_TIMEOUT 5000; SET TRANSACTION ISOLATION LEVEL READ UNCOMMITTED;
GO
-- Variant: AX 2009 - baseret på Microsofts dokumenterede skema, ikke verificeret mod kundedata: batchjob
-- Kilde: AxSchemaAdapter.BatchJobs(2009)
-- Kode: src/SQLInsight.Core/DynamicsAx/AxSchemaAdapter.cs : AxSchemaAdapter.BatchJobs(database) via AxSql.InDatabase
EXEC [AXDB].sys.sp_executesql N'SET NOCOUNT ON; SET LOCK_TIMEOUT 2000; SET TRANSACTION ISOLATION LEVEL READ COMMITTED; SELECT TOP (1000) CAST(j.[RECID] AS bigint), CAST(NULL AS int), j.[CAPTION], j.[COMPANY], CAST(NULL AS nvarchar(20)), j.[STATUS], j.[ORIGSTARTDATETIME], CAST(NULL AS int), j.[STARTDATETIME], CAST(NULL AS int), j.[ENDDATETIME], CAST(NULL AS int), DATEDIFF(minute, GETDATE(), GETUTCDATE()) FROM [dbo].[BATCHJOB] j ORDER BY j.[RECID] DESC';
SET LOCK_TIMEOUT 5000; SET TRANSACTION ISOLATION LEVEL READ UNCOMMITTED;
GO
-- Variant: AX 2009 - baseret på Microsofts dokumenterede skema, ikke verificeret mod kundedata: kørselshistorik, første læsning (de nyeste)
-- Kilde: AxSchemaAdapter.BatchHistory(2009, første)
-- Kode: src/SQLInsight.Core/DynamicsAx/AxSchemaAdapter.cs : AxSchemaAdapter.BatchHistory(database, afterRecId: null) via AxSql.InDatabase
EXEC [AXDB].sys.sp_executesql N'SET NOCOUNT ON; SET LOCK_TIMEOUT 2000; SET TRANSACTION ISOLATION LEVEL READ COMMITTED; SELECT TOP (1000) CAST(h.[RECID] AS bigint), CAST(h.[BATCHJOBID] AS bigint), h.[CAPTION], h.[COMPANY], h.[STATUS], h.[STARTDATETIME], h.[ENDDATETIME], DATEDIFF(minute, GETDATE(), GETUTCDATE()) FROM [dbo].[BATCHJOBHISTORY] h ORDER BY h.[RECID] DESC';
SET LOCK_TIMEOUT 5000; SET TRANSACTION ISOLATION LEVEL READ UNCOMMITTED;
GO
-- Variant: AX 2009 - baseret på Microsofts dokumenterede skema, ikke verificeret mod kundedata: kørselshistorik, de næste læsninger (fra den højeste RECID, SQLInsight har læst, minus overlappet)
-- Kilde: AxSchemaAdapter.BatchHistory(2009, næste)
-- Kode: src/SQLInsight.Core/DynamicsAx/AxSchemaAdapter.cs : AxSchemaAdapter.BatchHistory(database, afterRecId) via AxSql.InDatabase
-- Agenten indsætter den højeste RECID, den har læst, minus overlappet, hvor der her står 0.
EXEC [AXDB].sys.sp_executesql N'SET NOCOUNT ON; SET LOCK_TIMEOUT 2000; SET TRANSACTION ISOLATION LEVEL READ COMMITTED; SELECT TOP (3000) CAST(h.[RECID] AS bigint), CAST(h.[BATCHJOBID] AS bigint), h.[CAPTION], h.[COMPANY], h.[STATUS], h.[STARTDATETIME], h.[ENDDATETIME], DATEDIFF(minute, GETDATE(), GETUTCDATE()) FROM [dbo].[BATCHJOBHISTORY] h WHERE h.[RECID] > 0 ORDER BY h.[RECID]';
SET LOCK_TIMEOUT 5000; SET TRANSACTION ISOLATION LEVEL READ UNCOMMITTED;
GO
-- Variant: AX 2012 RTM/R2/R3 - baseret på Microsofts dokumenterede skema, ikke verificeret mod kundedata: batchjob
-- Kilde: AxSchemaAdapter.BatchJobs(2012)
-- Kode: src/SQLInsight.Core/DynamicsAx/AxSchemaAdapter.cs : AxSchemaAdapter.BatchJobs(database) via AxSql.InDatabase
EXEC [AXDB].sys.sp_executesql N'SET NOCOUNT ON; SET LOCK_TIMEOUT 2000; SET TRANSACTION ISOLATION LEVEL READ COMMITTED; SELECT TOP (1000) CAST(j.[RECID] AS bigint), CAST(NULL AS int), j.[CAPTION], j.[COMPANY], CAST(NULL AS nvarchar(20)), j.[STATUS], j.[ORIGSTARTDATETIME], CAST(NULL AS int), j.[STARTDATETIME], CAST(NULL AS int), j.[ENDDATETIME], CAST(NULL AS int), DATEDIFF(minute, GETDATE(), GETUTCDATE()) FROM [dbo].[BATCHJOB] j ORDER BY j.[RECID] DESC';
SET LOCK_TIMEOUT 5000; SET TRANSACTION ISOLATION LEVEL READ UNCOMMITTED;
GO
-- Variant: AX 2012 RTM/R2/R3 - baseret på Microsofts dokumenterede skema, ikke verificeret mod kundedata: kørselshistorik, første læsning (de nyeste)
-- Kilde: AxSchemaAdapter.BatchHistory(2012, første)
-- Kode: src/SQLInsight.Core/DynamicsAx/AxSchemaAdapter.cs : AxSchemaAdapter.BatchHistory(database, afterRecId: null) via AxSql.InDatabase
EXEC [AXDB].sys.sp_executesql N'SET NOCOUNT ON; SET LOCK_TIMEOUT 2000; SET TRANSACTION ISOLATION LEVEL READ COMMITTED; SELECT TOP (1000) CAST(h.[RECID] AS bigint), CAST(h.[BATCHJOBID] AS bigint), h.[CAPTION], h.[COMPANY], h.[STATUS], h.[STARTDATETIME], h.[ENDDATETIME], DATEDIFF(minute, GETDATE(), GETUTCDATE()) FROM [dbo].[BATCHJOBHISTORY] h ORDER BY h.[RECID] DESC';
SET LOCK_TIMEOUT 5000; SET TRANSACTION ISOLATION LEVEL READ UNCOMMITTED;
GO
-- Variant: AX 2012 RTM/R2/R3 - baseret på Microsofts dokumenterede skema, ikke verificeret mod kundedata: kørselshistorik, de næste læsninger (fra den højeste RECID, SQLInsight har læst, minus overlappet)
-- Kilde: AxSchemaAdapter.BatchHistory(2012, næste)
-- Kode: src/SQLInsight.Core/DynamicsAx/AxSchemaAdapter.cs : AxSchemaAdapter.BatchHistory(database, afterRecId) via AxSql.InDatabase
-- Agenten indsætter den højeste RECID, den har læst, minus overlappet, hvor der her står 0.
EXEC [AXDB].sys.sp_executesql N'SET NOCOUNT ON; SET LOCK_TIMEOUT 2000; SET TRANSACTION ISOLATION LEVEL READ COMMITTED; SELECT TOP (3000) CAST(h.[RECID] AS bigint), CAST(h.[BATCHJOBID] AS bigint), h.[CAPTION], h.[COMPANY], h.[STATUS], h.[STARTDATETIME], h.[ENDDATETIME], DATEDIFF(minute, GETDATE(), GETUTCDATE()) FROM [dbo].[BATCHJOBHISTORY] h WHERE h.[RECID] > 0 ORDER BY h.[RECID]';
SET LOCK_TIMEOUT 5000; SET TRANSACTION ISOLATION LEVEL READ UNCOMMITTED;
GO

163
SQL Scripts/SQLI-AX-004.sql Normal file
View file

@ -0,0 +1,163 @@
-- ===================================================================================================================
-- SQLI-AX-004: Dynamics AX-batchjob hænger i Udfører
-- ===================================================================================================================
-- Regel-id: SQLI-AX-004
-- Titel: Dynamics AX-batchjob hænger i Udfører
-- Alvor: High
-- Versioner: SQL Server 2005 og nyere (ikke 2000). Axapta 3.0: verificeret mod en kundes skema (docs/dynamics-ax/
-- schema-ax30.tsv). AX 4.0, AX 2009, AX 2012 og F&O: baseret på Microsofts dokumenterede skema - ikke
-- verificeret mod kundedata.
--
-- Hvad tjekkes, og hvorfor:
-- Et batchjob har haft status Udfører i flere timer. Typisk er AOS'en eller batch-klienten genstartet eller gået ned
-- midt i kørslen, så jobbet aldrig blev afsluttet: det står som kørende, intet sker, og gentagne job planlægges ikke
-- igen.
--
-- Vurdering (sker i C# ud fra de indsamlede data, ikke i SQL'en, medmindre andet er nævnt):
-- For job med status Udfører: kørt = nu - faktisk start (AX 2009+: BATCHJOB.STARTDATETIME; Axapta 3.0/4.0: første
-- gang, SQLInsight så jobbet køre, så det kan have kørt længere). Fund, når kørt >= BatchStuckHours, medmindre jobbet
-- efter sin egen historik (insight.AxBatchRuns, mindst BatchHistoryMinimumRuns kørsler) normalt kører så længe
-- (kørt <= faktor x 90 %-fraktil).
-- STATUS oversættes af versionens adapter: AX 2009+ 0 Tilbageholdt, 1 Venter, 2 Udfører, 3 Fejl, 4 Afsluttet,
-- 5 Klar, 6 Ikke kørt, 7 Annullerer, 8 Annulleret; Axapta 3.0/4.0 (antaget, ikke verificeret mod kundedata)
-- 0 Venter, 1 Udfører, 2 Fejl, 3 Afsluttet, 4 Tilbageholdt. Højst 25 fund pr. database.
--
-- Grænseværdier (konfigurationsnøgle i agentens appsettings.json og standardværdi):
-- SQLInsight:DynamicsAx:BatchStuckHours = 6: kørt mindst så mange timer
-- SQLInsight:DynamicsAx:BatchLongRunningFactor = 2.0: ...og mere end så mange gange jobbets 90 %-fraktil, når der er
-- historik
--
-- Rettigheder til agentens login (se db/permissions/grant-least-privilege.sql og grant-dynamics-ax-read.sql):
-- Valgfri AX-læseadgang, kun SELECT (db/permissions/grant-dynamics-ax-read.sql; [bruger] = agentens databasebruger):
-- Axapta 3.0: GRANT SELECT ON [bmssa].[BATCH] TO [bruger];
-- AX 4.0: GRANT SELECT ON [dbo].[BATCH] TO [bruger];
-- AX 2009/2012/F&O: GRANT SELECT ON [dbo].[BATCHJOB] TO [bruger];
-- GRANT SELECT ON [dbo].[BATCHJOBHISTORY] TO [bruger];
-- Mangler den, er reglen "ikke målbar" med den nøjagtige GRANT for databasen (også under Dynamics AX → AX-databaser
-- og GET /api/v1/dynamics-ax/grant-script). Uden GRANT ændres intet.
-- Genkendelsen: kun grant-least-privilege.sql (VIEW ANY DEFINITION; CONNECT ANY DATABASE fra 2014, ellers en
-- databasebruger: CREATE USER [agent] FOR LOGIN [agent];).
--
-- Data kommer fra (collector, fil : medlemmer, interval):
-- AxDatabaseDetector (den fælles AX-genkendelse)
-- Fil: src/SQLInsight.Collectors/DynamicsAx/AxDatabaseDetector.cs
-- Medlemmer: AxDatabaseDetector.DatabasesSql, AxDatabaseDetector.BuildDetectionSql(database)
-- Interval: hver time pr. database (hvert 5. minut, mens AX-læseadgangen mangler), delt af alle AX-collectors
-- Delt script: SQL Scripts/_collectors/AxDatabaseDetector.sql
-- AxBatchCollector
-- Fil: src/SQLInsight.Collectors/DynamicsAx/AxBatchCollector.cs; SQL: src/SQLInsight.Core/DynamicsAx/AxSchemaAdapter.cs
-- Medlemmer: AxSchemaAdapter.BatchJobs(database), AxSchemaAdapter.BatchHistory(database, afterRecId), AxSql.InDatabase
-- Interval: 5 min som standard (SQLInsight:CollectorIntervals:dynamics-ax-batch)
-- Delt script: SQL Scripts/_collectors/AxBatchCollector.sql
-- Regelkode: src/SQLInsight.Rules/DynamicsAx/DynamicsAxRules.cs : AxBatchStuckRule
--
-- Read-only: Ja: kun SELECT med TOP og SET-sessionsindstillinger. AX-tabellerne læses i AX-databasen via
-- [db].sys.sp_executesql (sessionen skifter aldrig database) under READ COMMITTED med SET LOCK_TIMEOUT 2000
-- (SNAPSHOT, når databasen tillader det), aldrig NOLOCK; bagefter sættes agentens sessionsindstillinger
-- igen. Intet ændrer data, indstillinger eller objekter. Kun i databaser genkendt som AX og valgt til
-- overvågning.
-- Kørsel: Kør i SSMS som agentens login (eller et login med samme rettigheder). Hver variant er sin egen batch
-- (GO); varianter for andre AX-versioner end din fejler eller giver ingen rækker - det er forventet.
-- AX-forespørgslerne er vist for databasen AXDB: ret [AXDB] til din AX-database.
-- ===================================================================================================================
-- -------------------------------------------------------------------------------------------------------------------
-- Forbindelsen: Sessionsindstillinger, agenten sætter på hver forbindelse
-- -------------------------------------------------------------------------------------------------------------------
-- Variant: Alle versioner: sættes på hver forbindelse, agenten åbner
-- Kilde: SqlConnectionFactory.SessionSettings
-- Kode: src/SQLInsight.Connectivity/SqlConnectionFactory.cs : SqlConnectionFactory.SessionSettings
SET LOCK_TIMEOUT 5000; SET DEADLOCK_PRIORITY LOW; SET TRANSACTION ISOLATION LEVEL READ UNCOMMITTED;
GO
-- -------------------------------------------------------------------------------------------------------------------
-- AxDatabaseDetector: Hvilke overvågede databaser er Dynamics AX (kun metadata)
-- -------------------------------------------------------------------------------------------------------------------
-- Variant: Alle versioner fra SQL Server 2005: online brugerdatabaser og deres snapshot-indstillinger
-- Kilde: AxDatabaseDetector.DatabasesSql
-- Kode: src/SQLInsight.Collectors/DynamicsAx/AxDatabaseDetector.cs : AxDatabaseDetector.DatabasesSql
SELECT name, snapshot_isolation_state, is_read_committed_snapshot_on
FROM sys.databases
WHERE database_id > 4 AND state = 0 AND source_database_id IS NULL AND HAS_DBACCESS(name) = 1
ORDER BY name
GO
-- Variant: SQL Server 2005 og nyere: AX-genkendelsen i hver overvåget database (kun metadata; vist for AXDB)
-- Kilde: AxDatabaseDetector.BuildDetectionSql
-- Kode: src/SQLInsight.Collectors/DynamicsAx/AxDatabaseDetector.cs : AxDatabaseDetector.BuildDetectionSql(database) = AxSql.InDatabase(database, AxDetection.MetadataSql)
-- Agenten kører dette i hver overvåget database fra listen ovenfor (ikke *_model). Ret [AXDB] til den database, du vil tjekke.
EXEC [AXDB].sys.sp_executesql N'SET NOCOUNT ON; SET LOCK_TIMEOUT 2000; SET TRANSACTION ISOLATION LEVEL READ COMMITTED; SELECT s.name, UPPER(t.name), UPPER(c.name), TYPE_NAME(c.system_type_id), c.precision, c.max_length,
ISNULL(HAS_PERMS_BY_NAME(QUOTENAME(s.name) + N''.'' + QUOTENAME(t.name), N''OBJECT'', N''SELECT''), 0),
(SELECT TOP 1 p.name FROM sys.database_principals p WHERE p.sid = SUSER_SID())
FROM sys.tables t
JOIN sys.schemas s ON s.schema_id = t.schema_id
JOIN sys.columns c ON c.object_id = t.object_id
WHERE UPPER(t.name) IN (N''SQLDICTIONARY'', N''DATAAREA'', N''SYSCONFIG'', N''BATCH'', N''BATCHJOB'', N''BATCHJOBHISTORY'', N''BATCHGROUP'', N''NUMBERSEQUENCETABLE'', N''NUMBERSEQUENCESCOPE'', N''NUMBERSEQUENCEREFERENCE'', N''SYSTEMSEQUENCES'', N''MODELELEMENT'')
ORDER BY s.name, t.name, c.column_id';
SET LOCK_TIMEOUT 5000; SET TRANSACTION ISOLATION LEVEL READ UNCOMMITTED;
GO
-- -------------------------------------------------------------------------------------------------------------------
-- AxBatchCollector: Batchjob og kørselshistorik i AX-databasen (valgfri AX-læseadgang)
-- -------------------------------------------------------------------------------------------------------------------
-- Variant: Axapta 3.0 (skema bmssa, verificeret mod kundedata): batchjob
-- Kilde: AxSchemaAdapter.BatchJobs(3.0)
-- Kode: src/SQLInsight.Core/DynamicsAx/AxSchemaAdapter.cs : AxSchemaAdapter.BatchJobs(database) via AxSql.InDatabase
EXEC [AXDB].sys.sp_executesql N'SET NOCOUNT ON; SET LOCK_TIMEOUT 2000; SET TRANSACTION ISOLATION LEVEL READ COMMITTED; SELECT TOP (1000) CAST(b.[RECID] AS bigint), b.[CLASSNUM], CAST(NULL AS nvarchar(60)), b.[COMPANY], b.[GROUPID], b.[STATUS], b.[STARTDATE], b.[STARTTIME], CAST(NULL AS datetime), CAST(NULL AS int), b.[ENDDATE], b.[ENDTIME], DATEDIFF(minute, GETDATE(), GETUTCDATE()) FROM [bmssa].[BATCH] b ORDER BY b.[RECID] DESC';
SET LOCK_TIMEOUT 5000; SET TRANSACTION ISOLATION LEVEL READ UNCOMMITTED;
GO
-- Variant: AX 4.0 (skema dbo) - ikke verificeret mod kundedata: batchjob
-- Kilde: AxSchemaAdapter.BatchJobs(4.0)
-- Kode: src/SQLInsight.Core/DynamicsAx/AxSchemaAdapter.cs : AxSchemaAdapter.BatchJobs(database) via AxSql.InDatabase
EXEC [AXDB].sys.sp_executesql N'SET NOCOUNT ON; SET LOCK_TIMEOUT 2000; SET TRANSACTION ISOLATION LEVEL READ COMMITTED; SELECT TOP (1000) CAST(b.[RECID] AS bigint), b.[CLASSNUM], CAST(NULL AS nvarchar(60)), b.[COMPANY], b.[GROUPID], b.[STATUS], b.[STARTDATE], b.[STARTTIME], CAST(NULL AS datetime), CAST(NULL AS int), b.[ENDDATE], b.[ENDTIME], DATEDIFF(minute, GETDATE(), GETUTCDATE()) FROM [dbo].[BATCH] b ORDER BY b.[RECID] DESC';
SET LOCK_TIMEOUT 5000; SET TRANSACTION ISOLATION LEVEL READ UNCOMMITTED;
GO
-- Variant: AX 2009 - baseret på Microsofts dokumenterede skema, ikke verificeret mod kundedata: batchjob
-- Kilde: AxSchemaAdapter.BatchJobs(2009)
-- Kode: src/SQLInsight.Core/DynamicsAx/AxSchemaAdapter.cs : AxSchemaAdapter.BatchJobs(database) via AxSql.InDatabase
EXEC [AXDB].sys.sp_executesql N'SET NOCOUNT ON; SET LOCK_TIMEOUT 2000; SET TRANSACTION ISOLATION LEVEL READ COMMITTED; SELECT TOP (1000) CAST(j.[RECID] AS bigint), CAST(NULL AS int), j.[CAPTION], j.[COMPANY], CAST(NULL AS nvarchar(20)), j.[STATUS], j.[ORIGSTARTDATETIME], CAST(NULL AS int), j.[STARTDATETIME], CAST(NULL AS int), j.[ENDDATETIME], CAST(NULL AS int), DATEDIFF(minute, GETDATE(), GETUTCDATE()) FROM [dbo].[BATCHJOB] j ORDER BY j.[RECID] DESC';
SET LOCK_TIMEOUT 5000; SET TRANSACTION ISOLATION LEVEL READ UNCOMMITTED;
GO
-- Variant: AX 2009 - baseret på Microsofts dokumenterede skema, ikke verificeret mod kundedata: kørselshistorik, første læsning (de nyeste)
-- Kilde: AxSchemaAdapter.BatchHistory(2009, første)
-- Kode: src/SQLInsight.Core/DynamicsAx/AxSchemaAdapter.cs : AxSchemaAdapter.BatchHistory(database, afterRecId: null) via AxSql.InDatabase
EXEC [AXDB].sys.sp_executesql N'SET NOCOUNT ON; SET LOCK_TIMEOUT 2000; SET TRANSACTION ISOLATION LEVEL READ COMMITTED; SELECT TOP (1000) CAST(h.[RECID] AS bigint), CAST(h.[BATCHJOBID] AS bigint), h.[CAPTION], h.[COMPANY], h.[STATUS], h.[STARTDATETIME], h.[ENDDATETIME], DATEDIFF(minute, GETDATE(), GETUTCDATE()) FROM [dbo].[BATCHJOBHISTORY] h ORDER BY h.[RECID] DESC';
SET LOCK_TIMEOUT 5000; SET TRANSACTION ISOLATION LEVEL READ UNCOMMITTED;
GO
-- Variant: AX 2009 - baseret på Microsofts dokumenterede skema, ikke verificeret mod kundedata: kørselshistorik, de næste læsninger (fra den højeste RECID, SQLInsight har læst, minus overlappet)
-- Kilde: AxSchemaAdapter.BatchHistory(2009, næste)
-- Kode: src/SQLInsight.Core/DynamicsAx/AxSchemaAdapter.cs : AxSchemaAdapter.BatchHistory(database, afterRecId) via AxSql.InDatabase
-- Agenten indsætter den højeste RECID, den har læst, minus overlappet, hvor der her står 0.
EXEC [AXDB].sys.sp_executesql N'SET NOCOUNT ON; SET LOCK_TIMEOUT 2000; SET TRANSACTION ISOLATION LEVEL READ COMMITTED; SELECT TOP (3000) CAST(h.[RECID] AS bigint), CAST(h.[BATCHJOBID] AS bigint), h.[CAPTION], h.[COMPANY], h.[STATUS], h.[STARTDATETIME], h.[ENDDATETIME], DATEDIFF(minute, GETDATE(), GETUTCDATE()) FROM [dbo].[BATCHJOBHISTORY] h WHERE h.[RECID] > 0 ORDER BY h.[RECID]';
SET LOCK_TIMEOUT 5000; SET TRANSACTION ISOLATION LEVEL READ UNCOMMITTED;
GO
-- Variant: AX 2012 RTM/R2/R3 - baseret på Microsofts dokumenterede skema, ikke verificeret mod kundedata: batchjob
-- Kilde: AxSchemaAdapter.BatchJobs(2012)
-- Kode: src/SQLInsight.Core/DynamicsAx/AxSchemaAdapter.cs : AxSchemaAdapter.BatchJobs(database) via AxSql.InDatabase
EXEC [AXDB].sys.sp_executesql N'SET NOCOUNT ON; SET LOCK_TIMEOUT 2000; SET TRANSACTION ISOLATION LEVEL READ COMMITTED; SELECT TOP (1000) CAST(j.[RECID] AS bigint), CAST(NULL AS int), j.[CAPTION], j.[COMPANY], CAST(NULL AS nvarchar(20)), j.[STATUS], j.[ORIGSTARTDATETIME], CAST(NULL AS int), j.[STARTDATETIME], CAST(NULL AS int), j.[ENDDATETIME], CAST(NULL AS int), DATEDIFF(minute, GETDATE(), GETUTCDATE()) FROM [dbo].[BATCHJOB] j ORDER BY j.[RECID] DESC';
SET LOCK_TIMEOUT 5000; SET TRANSACTION ISOLATION LEVEL READ UNCOMMITTED;
GO
-- Variant: AX 2012 RTM/R2/R3 - baseret på Microsofts dokumenterede skema, ikke verificeret mod kundedata: kørselshistorik, første læsning (de nyeste)
-- Kilde: AxSchemaAdapter.BatchHistory(2012, første)
-- Kode: src/SQLInsight.Core/DynamicsAx/AxSchemaAdapter.cs : AxSchemaAdapter.BatchHistory(database, afterRecId: null) via AxSql.InDatabase
EXEC [AXDB].sys.sp_executesql N'SET NOCOUNT ON; SET LOCK_TIMEOUT 2000; SET TRANSACTION ISOLATION LEVEL READ COMMITTED; SELECT TOP (1000) CAST(h.[RECID] AS bigint), CAST(h.[BATCHJOBID] AS bigint), h.[CAPTION], h.[COMPANY], h.[STATUS], h.[STARTDATETIME], h.[ENDDATETIME], DATEDIFF(minute, GETDATE(), GETUTCDATE()) FROM [dbo].[BATCHJOBHISTORY] h ORDER BY h.[RECID] DESC';
SET LOCK_TIMEOUT 5000; SET TRANSACTION ISOLATION LEVEL READ UNCOMMITTED;
GO
-- Variant: AX 2012 RTM/R2/R3 - baseret på Microsofts dokumenterede skema, ikke verificeret mod kundedata: kørselshistorik, de næste læsninger (fra den højeste RECID, SQLInsight har læst, minus overlappet)
-- Kilde: AxSchemaAdapter.BatchHistory(2012, næste)
-- Kode: src/SQLInsight.Core/DynamicsAx/AxSchemaAdapter.cs : AxSchemaAdapter.BatchHistory(database, afterRecId) via AxSql.InDatabase
-- Agenten indsætter den højeste RECID, den har læst, minus overlappet, hvor der her står 0.
EXEC [AXDB].sys.sp_executesql N'SET NOCOUNT ON; SET LOCK_TIMEOUT 2000; SET TRANSACTION ISOLATION LEVEL READ COMMITTED; SELECT TOP (3000) CAST(h.[RECID] AS bigint), CAST(h.[BATCHJOBID] AS bigint), h.[CAPTION], h.[COMPANY], h.[STATUS], h.[STARTDATETIME], h.[ENDDATETIME], DATEDIFF(minute, GETDATE(), GETUTCDATE()) FROM [dbo].[BATCHJOBHISTORY] h WHERE h.[RECID] > 0 ORDER BY h.[RECID]';
SET LOCK_TIMEOUT 5000; SET TRANSACTION ISOLATION LEVEL READ UNCOMMITTED;
GO

131
SQL Scripts/SQLI-AX-005.sql Normal file
View file

@ -0,0 +1,131 @@
-- ===================================================================================================================
-- SQLI-AX-005: Dynamics AX-nummerserie er ved at løbe tør
-- ===================================================================================================================
-- Regel-id: SQLI-AX-005
-- Titel: Dynamics AX-nummerserie er ved at løbe tør
-- Alvor: Medium; High over den kritiske procent eller inden for de kritiske dage; Critical, når serien er opbrugt.
-- Versioner: SQL Server 2005 og nyere (ikke 2000). Axapta 3.0: verificeret mod en kundes skema (docs/dynamics-ax/
-- schema-ax30.tsv). AX 4.0, AX 2009, AX 2012 og F&O: baseret på Microsofts dokumenterede skema - ikke
-- verificeret mod kundedata.
--
-- Hvad tjekkes, og hvorfor:
-- Når en nummerserie er brugt op, kan AX ikke tildele flere numre: oprettelse eller bogføring af fx ordrer, fakturaer
-- eller posteringer, der bruger serien, fejler for alle brugere.
--
-- Vurdering (sker i C# ud fra de indsamlede data, ikke i SQL'en, medmindre andet er nævnt):
-- Grænse = HIGHEST, eller formatets kapacitet (10^antal # - 1), hvis den er lavere. Brugt % = (NEXTREC - LOWEST) /
-- (grænse - LOWEST + 1). Tilbage = grænse - NEXTREC + 1. Prognose: forbrug pr. dag = (NEXTREC nu - NEXTREC i den
-- ældste daglige måling i vinduet) / dage imellem; dage tilbage = (grænse - NEXTREC) / forbrug pr. dag. Målingerne er
-- SQLInsights egne (insight.AxSequenceSamples, én pr. serie pr. dag). Manuelle, cykliske og spærrede serier springes
-- over. Højst 25 fund pr. database. Tal: src/SQLInsight.Core/DynamicsAx/AxSchemaAdapter.cs : Interpret, AxForecast.From.
--
-- Grænseværdier (konfigurationsnøgle i agentens appsettings.json og standardværdi):
-- SQLInsight:DynamicsAx:NumberSequenceWarnPercent = 80: brugt mindst så mange procent: Medium
-- SQLInsight:DynamicsAx:NumberSequenceCriticalPercent = 95: High
-- SQLInsight:DynamicsAx:ForecastWarnDays = 90: løber tør inden for så mange dage efter prognosen: Medium
-- SQLInsight:DynamicsAx:ForecastCriticalDays = 30: High
-- SQLInsight:DynamicsAx:ForecastBasisDays = 30: prognosen bruger den ældste daglige måling inden for så mange dage
--
-- Rettigheder til agentens login (se db/permissions/grant-least-privilege.sql og grant-dynamics-ax-read.sql):
-- Valgfri AX-læseadgang, kun SELECT (db/permissions/grant-dynamics-ax-read.sql; [bruger] = agentens databasebruger):
-- Axapta 3.0: GRANT SELECT ON [bmssa].[NUMBERSEQUENCETABLE] TO [bruger];
-- GRANT SELECT ON [bmssa].[NUMBERSEQUENCEREFERENCE] TO [bruger]; -- valgfri: om serien bruges
-- AX 4.0/2009: de samme tabeller i [dbo]
-- AX 2012/F&O: desuden GRANT SELECT ON [dbo].[NUMBERSEQUENCESCOPE] TO [bruger]; -- valgfri: firma
-- Mangler NUMBERSEQUENCETABLE, er reglen "ikke målbar" med den nøjagtige GRANT for databasen. Uden GRANT ændres intet.
-- Genkendelsen: kun grant-least-privilege.sql (VIEW ANY DEFINITION; CONNECT ANY DATABASE fra 2014, ellers en
-- databasebruger: CREATE USER [agent] FOR LOGIN [agent];).
--
-- Data kommer fra (collector, fil : medlemmer, interval):
-- AxDatabaseDetector (den fælles AX-genkendelse)
-- Fil: src/SQLInsight.Collectors/DynamicsAx/AxDatabaseDetector.cs
-- Medlemmer: AxDatabaseDetector.DatabasesSql, AxDatabaseDetector.BuildDetectionSql(database)
-- Interval: hver time pr. database (hvert 5. minut, mens AX-læseadgangen mangler), delt af alle AX-collectors
-- Delt script: SQL Scripts/_collectors/AxDatabaseDetector.sql
-- AxSequenceCollector
-- Fil: src/SQLInsight.Collectors/DynamicsAx/AxSequenceCollector.cs; SQL: src/SQLInsight.Core/DynamicsAx/AxSchemaAdapter.cs
-- Medlemmer: AxSchemaAdapter.NumberSequences(database), AxSchemaAdapter.RecIds(database), AxSql.InDatabase
-- Interval: 1 time som standard (SQLInsight:CollectorIntervals:dynamics-ax-sequences)
-- Delt script: SQL Scripts/_collectors/AxSequenceCollector.sql
-- Regelkode: src/SQLInsight.Rules/DynamicsAx/DynamicsAxRules.cs : AxNumberSequenceExhaustionRule
--
-- Read-only: Ja: kun SELECT med TOP og SET-sessionsindstillinger. AX-tabellerne læses i AX-databasen via
-- [db].sys.sp_executesql (sessionen skifter aldrig database) under READ COMMITTED med SET LOCK_TIMEOUT 2000
-- (SNAPSHOT, når databasen tillader det), aldrig NOLOCK; bagefter sættes agentens sessionsindstillinger
-- igen. Intet ændrer data, indstillinger eller objekter. Kun i databaser genkendt som AX og valgt til
-- overvågning.
-- Kørsel: Kør i SSMS som agentens login (eller et login med samme rettigheder). Hver variant er sin egen batch
-- (GO); varianter for andre AX-versioner end din fejler eller giver ingen rækker - det er forventet.
-- AX-forespørgslerne er vist for databasen AXDB: ret [AXDB] til din AX-database.
-- ===================================================================================================================
-- -------------------------------------------------------------------------------------------------------------------
-- Forbindelsen: Sessionsindstillinger, agenten sætter på hver forbindelse
-- -------------------------------------------------------------------------------------------------------------------
-- Variant: Alle versioner: sættes på hver forbindelse, agenten åbner
-- Kilde: SqlConnectionFactory.SessionSettings
-- Kode: src/SQLInsight.Connectivity/SqlConnectionFactory.cs : SqlConnectionFactory.SessionSettings
SET LOCK_TIMEOUT 5000; SET DEADLOCK_PRIORITY LOW; SET TRANSACTION ISOLATION LEVEL READ UNCOMMITTED;
GO
-- -------------------------------------------------------------------------------------------------------------------
-- AxDatabaseDetector: Hvilke overvågede databaser er Dynamics AX (kun metadata)
-- -------------------------------------------------------------------------------------------------------------------
-- Variant: Alle versioner fra SQL Server 2005: online brugerdatabaser og deres snapshot-indstillinger
-- Kilde: AxDatabaseDetector.DatabasesSql
-- Kode: src/SQLInsight.Collectors/DynamicsAx/AxDatabaseDetector.cs : AxDatabaseDetector.DatabasesSql
SELECT name, snapshot_isolation_state, is_read_committed_snapshot_on
FROM sys.databases
WHERE database_id > 4 AND state = 0 AND source_database_id IS NULL AND HAS_DBACCESS(name) = 1
ORDER BY name
GO
-- Variant: SQL Server 2005 og nyere: AX-genkendelsen i hver overvåget database (kun metadata; vist for AXDB)
-- Kilde: AxDatabaseDetector.BuildDetectionSql
-- Kode: src/SQLInsight.Collectors/DynamicsAx/AxDatabaseDetector.cs : AxDatabaseDetector.BuildDetectionSql(database) = AxSql.InDatabase(database, AxDetection.MetadataSql)
-- Agenten kører dette i hver overvåget database fra listen ovenfor (ikke *_model). Ret [AXDB] til den database, du vil tjekke.
EXEC [AXDB].sys.sp_executesql N'SET NOCOUNT ON; SET LOCK_TIMEOUT 2000; SET TRANSACTION ISOLATION LEVEL READ COMMITTED; SELECT s.name, UPPER(t.name), UPPER(c.name), TYPE_NAME(c.system_type_id), c.precision, c.max_length,
ISNULL(HAS_PERMS_BY_NAME(QUOTENAME(s.name) + N''.'' + QUOTENAME(t.name), N''OBJECT'', N''SELECT''), 0),
(SELECT TOP 1 p.name FROM sys.database_principals p WHERE p.sid = SUSER_SID())
FROM sys.tables t
JOIN sys.schemas s ON s.schema_id = t.schema_id
JOIN sys.columns c ON c.object_id = t.object_id
WHERE UPPER(t.name) IN (N''SQLDICTIONARY'', N''DATAAREA'', N''SYSCONFIG'', N''BATCH'', N''BATCHJOB'', N''BATCHJOBHISTORY'', N''BATCHGROUP'', N''NUMBERSEQUENCETABLE'', N''NUMBERSEQUENCESCOPE'', N''NUMBERSEQUENCEREFERENCE'', N''SYSTEMSEQUENCES'', N''MODELELEMENT'')
ORDER BY s.name, t.name, c.column_id';
SET LOCK_TIMEOUT 5000; SET TRANSACTION ISOLATION LEVEL READ UNCOMMITTED;
GO
-- -------------------------------------------------------------------------------------------------------------------
-- AxSequenceCollector: Nummerserier i AX-databasen (valgfri AX-læseadgang)
-- -------------------------------------------------------------------------------------------------------------------
-- Variant: Axapta 3.0 (skema bmssa, verificeret mod kundedata): nummerserier
-- Kilde: AxSchemaAdapter.NumberSequences(3.0)
-- Kode: src/SQLInsight.Core/DynamicsAx/AxSchemaAdapter.cs : AxSchemaAdapter.NumberSequences(database) via AxSql.InDatabase
EXEC [AXDB].sys.sp_executesql N'SET NOCOUNT ON; SET LOCK_TIMEOUT 2000; SET TRANSACTION ISOLATION LEVEL READ COMMITTED; SELECT TOP (5000) CAST(n.[RECID] AS bigint), n.[NUMBERSEQUENCE], n.[DATAAREAID], n.[TXT], CAST(n.[LOWEST] AS bigint), CAST(n.[HIGHEST] AS bigint), CAST(n.[NEXTREC] AS bigint), n.[FORMAT], ISNULL(n.[BLOCKED], 0), ISNULL(n.[CONTINUOUS], 0), ISNULL(n.[CYCLIC], 0), ISNULL(n.[MANUAL], 0), CASE WHEN EXISTS (SELECT 1 FROM [bmssa].[NUMBERSEQUENCEREFERENCE] r WHERE r.[NUMBERSEQUENCE] = n.[NUMBERSEQUENCE] AND r.[DATAAREAID] = n.[DATAAREAID]) THEN 1 ELSE 0 END, n.[LATESTCLEANDATE], n.[LATESTCLEANTIME], CAST(n.[CLEANINTERVAL] AS float), DATEDIFF(minute, GETDATE(), GETUTCDATE()) FROM [bmssa].[NUMBERSEQUENCETABLE] n ORDER BY (CAST(n.[NEXTREC] AS float) - n.[LOWEST]) / NULLIF(CAST(n.[HIGHEST] AS float) - n.[LOWEST], 0) DESC';
SET LOCK_TIMEOUT 5000; SET TRANSACTION ISOLATION LEVEL READ UNCOMMITTED;
GO
-- Variant: AX 4.0 (skema dbo) - ikke verificeret mod kundedata: nummerserier
-- Kilde: AxSchemaAdapter.NumberSequences(4.0)
-- Kode: src/SQLInsight.Core/DynamicsAx/AxSchemaAdapter.cs : AxSchemaAdapter.NumberSequences(database) via AxSql.InDatabase
EXEC [AXDB].sys.sp_executesql N'SET NOCOUNT ON; SET LOCK_TIMEOUT 2000; SET TRANSACTION ISOLATION LEVEL READ COMMITTED; SELECT TOP (5000) CAST(n.[RECID] AS bigint), n.[NUMBERSEQUENCE], n.[DATAAREAID], n.[TXT], CAST(n.[LOWEST] AS bigint), CAST(n.[HIGHEST] AS bigint), CAST(n.[NEXTREC] AS bigint), n.[FORMAT], ISNULL(n.[BLOCKED], 0), ISNULL(n.[CONTINUOUS], 0), ISNULL(n.[CYCLIC], 0), ISNULL(n.[MANUAL], 0), CASE WHEN EXISTS (SELECT 1 FROM [dbo].[NUMBERSEQUENCEREFERENCE] r WHERE r.[NUMBERSEQUENCE] = n.[NUMBERSEQUENCE] AND r.[DATAAREAID] = n.[DATAAREAID]) THEN 1 ELSE 0 END, n.[LATESTCLEANDATE], n.[LATESTCLEANTIME], CAST(n.[CLEANINTERVAL] AS float), DATEDIFF(minute, GETDATE(), GETUTCDATE()) FROM [dbo].[NUMBERSEQUENCETABLE] n ORDER BY (CAST(n.[NEXTREC] AS float) - n.[LOWEST]) / NULLIF(CAST(n.[HIGHEST] AS float) - n.[LOWEST], 0) DESC';
SET LOCK_TIMEOUT 5000; SET TRANSACTION ISOLATION LEVEL READ UNCOMMITTED;
GO
-- Variant: AX 2009 - baseret på Microsofts dokumenterede skema, ikke verificeret mod kundedata: nummerserier
-- Kilde: AxSchemaAdapter.NumberSequences(2009)
-- Kode: src/SQLInsight.Core/DynamicsAx/AxSchemaAdapter.cs : AxSchemaAdapter.NumberSequences(database) via AxSql.InDatabase
EXEC [AXDB].sys.sp_executesql N'SET NOCOUNT ON; SET LOCK_TIMEOUT 2000; SET TRANSACTION ISOLATION LEVEL READ COMMITTED; SELECT TOP (5000) CAST(n.[RECID] AS bigint), n.[NUMBERSEQUENCE], n.[DATAAREAID], n.[TXT], CAST(n.[LOWEST] AS bigint), CAST(n.[HIGHEST] AS bigint), CAST(n.[NEXTREC] AS bigint), n.[FORMAT], ISNULL(n.[BLOCKED], 0), ISNULL(n.[CONTINUOUS], 0), ISNULL(n.[CYCLIC], 0), ISNULL(n.[MANUAL], 0), CASE WHEN EXISTS (SELECT 1 FROM [dbo].[NUMBERSEQUENCEREFERENCE] r WHERE r.[NUMBERSEQUENCE] = n.[NUMBERSEQUENCE] AND r.[DATAAREAID] = n.[DATAAREAID]) THEN 1 ELSE 0 END, n.[LATESTCLEANDATETIME], CAST(NULL AS int), CAST(n.[CLEANINTERVAL] AS float), DATEDIFF(minute, GETDATE(), GETUTCDATE()) FROM [dbo].[NUMBERSEQUENCETABLE] n ORDER BY (CAST(n.[NEXTREC] AS float) - n.[LOWEST]) / NULLIF(CAST(n.[HIGHEST] AS float) - n.[LOWEST], 0) DESC';
SET LOCK_TIMEOUT 5000; SET TRANSACTION ISOLATION LEVEL READ UNCOMMITTED;
GO
-- Variant: AX 2012 RTM/R2/R3 - baseret på Microsofts dokumenterede skema, ikke verificeret mod kundedata: nummerserier
-- Kilde: AxSchemaAdapter.NumberSequences(2012)
-- Kode: src/SQLInsight.Core/DynamicsAx/AxSchemaAdapter.cs : AxSchemaAdapter.NumberSequences(database) via AxSql.InDatabase
EXEC [AXDB].sys.sp_executesql N'SET NOCOUNT ON; SET LOCK_TIMEOUT 2000; SET TRANSACTION ISOLATION LEVEL READ COMMITTED; SELECT TOP (5000) CAST(n.[RECID] AS bigint), n.[NUMBERSEQUENCE], NULLIF(sc.[DATAAREA], N''''), n.[TXT], CAST(n.[LOWEST] AS bigint), CAST(n.[HIGHEST] AS bigint), CAST(n.[NEXTREC] AS bigint), n.[FORMAT], ISNULL(n.[BLOCKED], 0), ISNULL(n.[CONTINUOUS], 0), ISNULL(n.[CYCLIC], 0), ISNULL(n.[MANUAL], 0), CASE WHEN EXISTS (SELECT 1 FROM [dbo].[NUMBERSEQUENCEREFERENCE] r WHERE r.[NUMBERSEQUENCEID] = n.[RECID]) THEN 1 ELSE 0 END, n.[LATESTCLEANDATETIME], CAST(NULL AS int), CAST(n.[CLEANINTERVAL] AS float), DATEDIFF(minute, GETDATE(), GETUTCDATE()) FROM [dbo].[NUMBERSEQUENCETABLE] n LEFT JOIN [dbo].[NUMBERSEQUENCESCOPE] sc ON sc.[RECID] = n.[NUMBERSEQUENCESCOPE] ORDER BY (CAST(n.[NEXTREC] AS float) - n.[LOWEST]) / NULLIF(CAST(n.[HIGHEST] AS float) - n.[LOWEST], 0) DESC';
SET LOCK_TIMEOUT 5000; SET TRANSACTION ISOLATION LEVEL READ UNCOMMITTED;
GO

124
SQL Scripts/SQLI-AX-006.sql Normal file
View file

@ -0,0 +1,124 @@
-- ===================================================================================================================
-- SQLI-AX-006: Dynamics AX-nummerseriens format er for kort til Højeste
-- ===================================================================================================================
-- Regel-id: SQLI-AX-006
-- Titel: Dynamics AX-nummerseriens format er for kort til Højeste
-- Alvor: Medium; High, når NEXTREC allerede er større end formatets kapacitet.
-- Versioner: SQL Server 2005 og nyere (ikke 2000). Axapta 3.0: verificeret mod en kundes skema (docs/dynamics-ax/
-- schema-ax30.tsv). AX 4.0, AX 2009, AX 2012 og F&O: baseret på Microsofts dokumenterede skema - ikke
-- verificeret mod kundedata.
--
-- Hvad tjekkes, og hvorfor:
-- Formatet (fx "FA-####") har færre cifre, end HIGHEST kræver. Når serien når forbi formatets kapacitet, fejler
-- tildelingen af numre, selv om serien ikke er brugt op.
--
-- Vurdering (sker i C# ud fra de indsamlede data, ikke i SQL'en, medmindre andet er nævnt):
-- Kapacitet = 10^(antal # i FORMAT) - 1. Fund, når HIGHEST > kapacitet (ikke for manuelle eller spærrede serier). High,
-- når også NEXTREC > kapacitet. Højst 25 fund pr. database.
--
-- Grænseværdier (konfigurationsnøgle i agentens appsettings.json og standardværdi):
-- Ingen konfigurationsnøgle: ren sammenligning.
--
-- Rettigheder til agentens login (se db/permissions/grant-least-privilege.sql og grant-dynamics-ax-read.sql):
-- Valgfri AX-læseadgang, kun SELECT (db/permissions/grant-dynamics-ax-read.sql; [bruger] = agentens databasebruger):
-- Axapta 3.0: GRANT SELECT ON [bmssa].[NUMBERSEQUENCETABLE] TO [bruger];
-- GRANT SELECT ON [bmssa].[NUMBERSEQUENCEREFERENCE] TO [bruger]; -- valgfri: om serien bruges
-- AX 4.0/2009: de samme tabeller i [dbo]
-- AX 2012/F&O: desuden GRANT SELECT ON [dbo].[NUMBERSEQUENCESCOPE] TO [bruger]; -- valgfri: firma
-- Mangler NUMBERSEQUENCETABLE, er reglen "ikke målbar" med den nøjagtige GRANT for databasen. Uden GRANT ændres intet.
-- Genkendelsen: kun grant-least-privilege.sql (VIEW ANY DEFINITION; CONNECT ANY DATABASE fra 2014, ellers en
-- databasebruger: CREATE USER [agent] FOR LOGIN [agent];).
--
-- Data kommer fra (collector, fil : medlemmer, interval):
-- AxDatabaseDetector (den fælles AX-genkendelse)
-- Fil: src/SQLInsight.Collectors/DynamicsAx/AxDatabaseDetector.cs
-- Medlemmer: AxDatabaseDetector.DatabasesSql, AxDatabaseDetector.BuildDetectionSql(database)
-- Interval: hver time pr. database (hvert 5. minut, mens AX-læseadgangen mangler), delt af alle AX-collectors
-- Delt script: SQL Scripts/_collectors/AxDatabaseDetector.sql
-- AxSequenceCollector
-- Fil: src/SQLInsight.Collectors/DynamicsAx/AxSequenceCollector.cs; SQL: src/SQLInsight.Core/DynamicsAx/AxSchemaAdapter.cs
-- Medlemmer: AxSchemaAdapter.NumberSequences(database), AxSchemaAdapter.RecIds(database), AxSql.InDatabase
-- Interval: 1 time som standard (SQLInsight:CollectorIntervals:dynamics-ax-sequences)
-- Delt script: SQL Scripts/_collectors/AxSequenceCollector.sql
-- Regelkode: src/SQLInsight.Rules/DynamicsAx/DynamicsAxRules.cs : AxNumberSequenceFormatRule
--
-- Read-only: Ja: kun SELECT med TOP og SET-sessionsindstillinger. AX-tabellerne læses i AX-databasen via
-- [db].sys.sp_executesql (sessionen skifter aldrig database) under READ COMMITTED med SET LOCK_TIMEOUT 2000
-- (SNAPSHOT, når databasen tillader det), aldrig NOLOCK; bagefter sættes agentens sessionsindstillinger
-- igen. Intet ændrer data, indstillinger eller objekter. Kun i databaser genkendt som AX og valgt til
-- overvågning.
-- Kørsel: Kør i SSMS som agentens login (eller et login med samme rettigheder). Hver variant er sin egen batch
-- (GO); varianter for andre AX-versioner end din fejler eller giver ingen rækker - det er forventet.
-- AX-forespørgslerne er vist for databasen AXDB: ret [AXDB] til din AX-database.
-- ===================================================================================================================
-- -------------------------------------------------------------------------------------------------------------------
-- Forbindelsen: Sessionsindstillinger, agenten sætter på hver forbindelse
-- -------------------------------------------------------------------------------------------------------------------
-- Variant: Alle versioner: sættes på hver forbindelse, agenten åbner
-- Kilde: SqlConnectionFactory.SessionSettings
-- Kode: src/SQLInsight.Connectivity/SqlConnectionFactory.cs : SqlConnectionFactory.SessionSettings
SET LOCK_TIMEOUT 5000; SET DEADLOCK_PRIORITY LOW; SET TRANSACTION ISOLATION LEVEL READ UNCOMMITTED;
GO
-- -------------------------------------------------------------------------------------------------------------------
-- AxDatabaseDetector: Hvilke overvågede databaser er Dynamics AX (kun metadata)
-- -------------------------------------------------------------------------------------------------------------------
-- Variant: Alle versioner fra SQL Server 2005: online brugerdatabaser og deres snapshot-indstillinger
-- Kilde: AxDatabaseDetector.DatabasesSql
-- Kode: src/SQLInsight.Collectors/DynamicsAx/AxDatabaseDetector.cs : AxDatabaseDetector.DatabasesSql
SELECT name, snapshot_isolation_state, is_read_committed_snapshot_on
FROM sys.databases
WHERE database_id > 4 AND state = 0 AND source_database_id IS NULL AND HAS_DBACCESS(name) = 1
ORDER BY name
GO
-- Variant: SQL Server 2005 og nyere: AX-genkendelsen i hver overvåget database (kun metadata; vist for AXDB)
-- Kilde: AxDatabaseDetector.BuildDetectionSql
-- Kode: src/SQLInsight.Collectors/DynamicsAx/AxDatabaseDetector.cs : AxDatabaseDetector.BuildDetectionSql(database) = AxSql.InDatabase(database, AxDetection.MetadataSql)
-- Agenten kører dette i hver overvåget database fra listen ovenfor (ikke *_model). Ret [AXDB] til den database, du vil tjekke.
EXEC [AXDB].sys.sp_executesql N'SET NOCOUNT ON; SET LOCK_TIMEOUT 2000; SET TRANSACTION ISOLATION LEVEL READ COMMITTED; SELECT s.name, UPPER(t.name), UPPER(c.name), TYPE_NAME(c.system_type_id), c.precision, c.max_length,
ISNULL(HAS_PERMS_BY_NAME(QUOTENAME(s.name) + N''.'' + QUOTENAME(t.name), N''OBJECT'', N''SELECT''), 0),
(SELECT TOP 1 p.name FROM sys.database_principals p WHERE p.sid = SUSER_SID())
FROM sys.tables t
JOIN sys.schemas s ON s.schema_id = t.schema_id
JOIN sys.columns c ON c.object_id = t.object_id
WHERE UPPER(t.name) IN (N''SQLDICTIONARY'', N''DATAAREA'', N''SYSCONFIG'', N''BATCH'', N''BATCHJOB'', N''BATCHJOBHISTORY'', N''BATCHGROUP'', N''NUMBERSEQUENCETABLE'', N''NUMBERSEQUENCESCOPE'', N''NUMBERSEQUENCEREFERENCE'', N''SYSTEMSEQUENCES'', N''MODELELEMENT'')
ORDER BY s.name, t.name, c.column_id';
SET LOCK_TIMEOUT 5000; SET TRANSACTION ISOLATION LEVEL READ UNCOMMITTED;
GO
-- -------------------------------------------------------------------------------------------------------------------
-- AxSequenceCollector: Nummerserier i AX-databasen (valgfri AX-læseadgang)
-- -------------------------------------------------------------------------------------------------------------------
-- Variant: Axapta 3.0 (skema bmssa, verificeret mod kundedata): nummerserier
-- Kilde: AxSchemaAdapter.NumberSequences(3.0)
-- Kode: src/SQLInsight.Core/DynamicsAx/AxSchemaAdapter.cs : AxSchemaAdapter.NumberSequences(database) via AxSql.InDatabase
EXEC [AXDB].sys.sp_executesql N'SET NOCOUNT ON; SET LOCK_TIMEOUT 2000; SET TRANSACTION ISOLATION LEVEL READ COMMITTED; SELECT TOP (5000) CAST(n.[RECID] AS bigint), n.[NUMBERSEQUENCE], n.[DATAAREAID], n.[TXT], CAST(n.[LOWEST] AS bigint), CAST(n.[HIGHEST] AS bigint), CAST(n.[NEXTREC] AS bigint), n.[FORMAT], ISNULL(n.[BLOCKED], 0), ISNULL(n.[CONTINUOUS], 0), ISNULL(n.[CYCLIC], 0), ISNULL(n.[MANUAL], 0), CASE WHEN EXISTS (SELECT 1 FROM [bmssa].[NUMBERSEQUENCEREFERENCE] r WHERE r.[NUMBERSEQUENCE] = n.[NUMBERSEQUENCE] AND r.[DATAAREAID] = n.[DATAAREAID]) THEN 1 ELSE 0 END, n.[LATESTCLEANDATE], n.[LATESTCLEANTIME], CAST(n.[CLEANINTERVAL] AS float), DATEDIFF(minute, GETDATE(), GETUTCDATE()) FROM [bmssa].[NUMBERSEQUENCETABLE] n ORDER BY (CAST(n.[NEXTREC] AS float) - n.[LOWEST]) / NULLIF(CAST(n.[HIGHEST] AS float) - n.[LOWEST], 0) DESC';
SET LOCK_TIMEOUT 5000; SET TRANSACTION ISOLATION LEVEL READ UNCOMMITTED;
GO
-- Variant: AX 4.0 (skema dbo) - ikke verificeret mod kundedata: nummerserier
-- Kilde: AxSchemaAdapter.NumberSequences(4.0)
-- Kode: src/SQLInsight.Core/DynamicsAx/AxSchemaAdapter.cs : AxSchemaAdapter.NumberSequences(database) via AxSql.InDatabase
EXEC [AXDB].sys.sp_executesql N'SET NOCOUNT ON; SET LOCK_TIMEOUT 2000; SET TRANSACTION ISOLATION LEVEL READ COMMITTED; SELECT TOP (5000) CAST(n.[RECID] AS bigint), n.[NUMBERSEQUENCE], n.[DATAAREAID], n.[TXT], CAST(n.[LOWEST] AS bigint), CAST(n.[HIGHEST] AS bigint), CAST(n.[NEXTREC] AS bigint), n.[FORMAT], ISNULL(n.[BLOCKED], 0), ISNULL(n.[CONTINUOUS], 0), ISNULL(n.[CYCLIC], 0), ISNULL(n.[MANUAL], 0), CASE WHEN EXISTS (SELECT 1 FROM [dbo].[NUMBERSEQUENCEREFERENCE] r WHERE r.[NUMBERSEQUENCE] = n.[NUMBERSEQUENCE] AND r.[DATAAREAID] = n.[DATAAREAID]) THEN 1 ELSE 0 END, n.[LATESTCLEANDATE], n.[LATESTCLEANTIME], CAST(n.[CLEANINTERVAL] AS float), DATEDIFF(minute, GETDATE(), GETUTCDATE()) FROM [dbo].[NUMBERSEQUENCETABLE] n ORDER BY (CAST(n.[NEXTREC] AS float) - n.[LOWEST]) / NULLIF(CAST(n.[HIGHEST] AS float) - n.[LOWEST], 0) DESC';
SET LOCK_TIMEOUT 5000; SET TRANSACTION ISOLATION LEVEL READ UNCOMMITTED;
GO
-- Variant: AX 2009 - baseret på Microsofts dokumenterede skema, ikke verificeret mod kundedata: nummerserier
-- Kilde: AxSchemaAdapter.NumberSequences(2009)
-- Kode: src/SQLInsight.Core/DynamicsAx/AxSchemaAdapter.cs : AxSchemaAdapter.NumberSequences(database) via AxSql.InDatabase
EXEC [AXDB].sys.sp_executesql N'SET NOCOUNT ON; SET LOCK_TIMEOUT 2000; SET TRANSACTION ISOLATION LEVEL READ COMMITTED; SELECT TOP (5000) CAST(n.[RECID] AS bigint), n.[NUMBERSEQUENCE], n.[DATAAREAID], n.[TXT], CAST(n.[LOWEST] AS bigint), CAST(n.[HIGHEST] AS bigint), CAST(n.[NEXTREC] AS bigint), n.[FORMAT], ISNULL(n.[BLOCKED], 0), ISNULL(n.[CONTINUOUS], 0), ISNULL(n.[CYCLIC], 0), ISNULL(n.[MANUAL], 0), CASE WHEN EXISTS (SELECT 1 FROM [dbo].[NUMBERSEQUENCEREFERENCE] r WHERE r.[NUMBERSEQUENCE] = n.[NUMBERSEQUENCE] AND r.[DATAAREAID] = n.[DATAAREAID]) THEN 1 ELSE 0 END, n.[LATESTCLEANDATETIME], CAST(NULL AS int), CAST(n.[CLEANINTERVAL] AS float), DATEDIFF(minute, GETDATE(), GETUTCDATE()) FROM [dbo].[NUMBERSEQUENCETABLE] n ORDER BY (CAST(n.[NEXTREC] AS float) - n.[LOWEST]) / NULLIF(CAST(n.[HIGHEST] AS float) - n.[LOWEST], 0) DESC';
SET LOCK_TIMEOUT 5000; SET TRANSACTION ISOLATION LEVEL READ UNCOMMITTED;
GO
-- Variant: AX 2012 RTM/R2/R3 - baseret på Microsofts dokumenterede skema, ikke verificeret mod kundedata: nummerserier
-- Kilde: AxSchemaAdapter.NumberSequences(2012)
-- Kode: src/SQLInsight.Core/DynamicsAx/AxSchemaAdapter.cs : AxSchemaAdapter.NumberSequences(database) via AxSql.InDatabase
EXEC [AXDB].sys.sp_executesql N'SET NOCOUNT ON; SET LOCK_TIMEOUT 2000; SET TRANSACTION ISOLATION LEVEL READ COMMITTED; SELECT TOP (5000) CAST(n.[RECID] AS bigint), n.[NUMBERSEQUENCE], NULLIF(sc.[DATAAREA], N''''), n.[TXT], CAST(n.[LOWEST] AS bigint), CAST(n.[HIGHEST] AS bigint), CAST(n.[NEXTREC] AS bigint), n.[FORMAT], ISNULL(n.[BLOCKED], 0), ISNULL(n.[CONTINUOUS], 0), ISNULL(n.[CYCLIC], 0), ISNULL(n.[MANUAL], 0), CASE WHEN EXISTS (SELECT 1 FROM [dbo].[NUMBERSEQUENCEREFERENCE] r WHERE r.[NUMBERSEQUENCEID] = n.[RECID]) THEN 1 ELSE 0 END, n.[LATESTCLEANDATETIME], CAST(NULL AS int), CAST(n.[CLEANINTERVAL] AS float), DATEDIFF(minute, GETDATE(), GETUTCDATE()) FROM [dbo].[NUMBERSEQUENCETABLE] n LEFT JOIN [dbo].[NUMBERSEQUENCESCOPE] sc ON sc.[RECID] = n.[NUMBERSEQUENCESCOPE] ORDER BY (CAST(n.[NEXTREC] AS float) - n.[LOWEST]) / NULLIF(CAST(n.[HIGHEST] AS float) - n.[LOWEST], 0) DESC';
SET LOCK_TIMEOUT 5000; SET TRANSACTION ISOLATION LEVEL READ UNCOMMITTED;
GO

125
SQL Scripts/SQLI-AX-007.sql Normal file
View file

@ -0,0 +1,125 @@
-- ===================================================================================================================
-- SQLI-AX-007: Spærret Dynamics AX-nummerserie bruges stadig
-- ===================================================================================================================
-- Regel-id: SQLI-AX-007
-- Titel: Spærret Dynamics AX-nummerserie bruges stadig
-- Alvor: Medium
-- Versioner: SQL Server 2005 og nyere (ikke 2000). Axapta 3.0: verificeret mod en kundes skema (docs/dynamics-ax/
-- schema-ax30.tsv). AX 4.0, AX 2009, AX 2012 og F&O: baseret på Microsofts dokumenterede skema - ikke
-- verificeret mod kundedata.
--
-- Hvad tjekkes, og hvorfor:
-- Serien er spærret (BLOCKED = 1), men en nummerseriereference peger stadig på den. Alt i AX, der skal have et nummer
-- fra den reference, fejler med en fejl om, at nummerserien er spærret.
--
-- Vurdering (sker i C# ud fra de indsamlede data, ikke i SQL'en, medmindre andet er nævnt):
-- Fund, når BLOCKED = 1 og der findes en NUMBERSEQUENCEREFERENCE til serien (Axapta 3.0/4.0/2009: samme NUMBERSEQUENCE
-- og DATAAREAID; AX 2012/F&O: NUMBERSEQUENCEID = seriens RECID). Kan referencetabellen ikke læses, vides det ikke, og
-- der gives intet fund. Højst 25 fund pr. database.
--
-- Grænseværdier (konfigurationsnøgle i agentens appsettings.json og standardværdi):
-- Ingen konfigurationsnøgle.
--
-- Rettigheder til agentens login (se db/permissions/grant-least-privilege.sql og grant-dynamics-ax-read.sql):
-- Valgfri AX-læseadgang, kun SELECT (db/permissions/grant-dynamics-ax-read.sql; [bruger] = agentens databasebruger):
-- Axapta 3.0: GRANT SELECT ON [bmssa].[NUMBERSEQUENCETABLE] TO [bruger];
-- GRANT SELECT ON [bmssa].[NUMBERSEQUENCEREFERENCE] TO [bruger]; -- valgfri: om serien bruges
-- AX 4.0/2009: de samme tabeller i [dbo]
-- AX 2012/F&O: desuden GRANT SELECT ON [dbo].[NUMBERSEQUENCESCOPE] TO [bruger]; -- valgfri: firma
-- Mangler NUMBERSEQUENCETABLE, er reglen "ikke målbar" med den nøjagtige GRANT for databasen. Uden GRANT ændres intet.
-- Genkendelsen: kun grant-least-privilege.sql (VIEW ANY DEFINITION; CONNECT ANY DATABASE fra 2014, ellers en
-- databasebruger: CREATE USER [agent] FOR LOGIN [agent];).
--
-- Data kommer fra (collector, fil : medlemmer, interval):
-- AxDatabaseDetector (den fælles AX-genkendelse)
-- Fil: src/SQLInsight.Collectors/DynamicsAx/AxDatabaseDetector.cs
-- Medlemmer: AxDatabaseDetector.DatabasesSql, AxDatabaseDetector.BuildDetectionSql(database)
-- Interval: hver time pr. database (hvert 5. minut, mens AX-læseadgangen mangler), delt af alle AX-collectors
-- Delt script: SQL Scripts/_collectors/AxDatabaseDetector.sql
-- AxSequenceCollector
-- Fil: src/SQLInsight.Collectors/DynamicsAx/AxSequenceCollector.cs; SQL: src/SQLInsight.Core/DynamicsAx/AxSchemaAdapter.cs
-- Medlemmer: AxSchemaAdapter.NumberSequences(database), AxSchemaAdapter.RecIds(database), AxSql.InDatabase
-- Interval: 1 time som standard (SQLInsight:CollectorIntervals:dynamics-ax-sequences)
-- Delt script: SQL Scripts/_collectors/AxSequenceCollector.sql
-- Regelkode: src/SQLInsight.Rules/DynamicsAx/DynamicsAxRules.cs : AxNumberSequenceBlockedRule
--
-- Read-only: Ja: kun SELECT med TOP og SET-sessionsindstillinger. AX-tabellerne læses i AX-databasen via
-- [db].sys.sp_executesql (sessionen skifter aldrig database) under READ COMMITTED med SET LOCK_TIMEOUT 2000
-- (SNAPSHOT, når databasen tillader det), aldrig NOLOCK; bagefter sættes agentens sessionsindstillinger
-- igen. Intet ændrer data, indstillinger eller objekter. Kun i databaser genkendt som AX og valgt til
-- overvågning.
-- Kørsel: Kør i SSMS som agentens login (eller et login med samme rettigheder). Hver variant er sin egen batch
-- (GO); varianter for andre AX-versioner end din fejler eller giver ingen rækker - det er forventet.
-- AX-forespørgslerne er vist for databasen AXDB: ret [AXDB] til din AX-database.
-- ===================================================================================================================
-- -------------------------------------------------------------------------------------------------------------------
-- Forbindelsen: Sessionsindstillinger, agenten sætter på hver forbindelse
-- -------------------------------------------------------------------------------------------------------------------
-- Variant: Alle versioner: sættes på hver forbindelse, agenten åbner
-- Kilde: SqlConnectionFactory.SessionSettings
-- Kode: src/SQLInsight.Connectivity/SqlConnectionFactory.cs : SqlConnectionFactory.SessionSettings
SET LOCK_TIMEOUT 5000; SET DEADLOCK_PRIORITY LOW; SET TRANSACTION ISOLATION LEVEL READ UNCOMMITTED;
GO
-- -------------------------------------------------------------------------------------------------------------------
-- AxDatabaseDetector: Hvilke overvågede databaser er Dynamics AX (kun metadata)
-- -------------------------------------------------------------------------------------------------------------------
-- Variant: Alle versioner fra SQL Server 2005: online brugerdatabaser og deres snapshot-indstillinger
-- Kilde: AxDatabaseDetector.DatabasesSql
-- Kode: src/SQLInsight.Collectors/DynamicsAx/AxDatabaseDetector.cs : AxDatabaseDetector.DatabasesSql
SELECT name, snapshot_isolation_state, is_read_committed_snapshot_on
FROM sys.databases
WHERE database_id > 4 AND state = 0 AND source_database_id IS NULL AND HAS_DBACCESS(name) = 1
ORDER BY name
GO
-- Variant: SQL Server 2005 og nyere: AX-genkendelsen i hver overvåget database (kun metadata; vist for AXDB)
-- Kilde: AxDatabaseDetector.BuildDetectionSql
-- Kode: src/SQLInsight.Collectors/DynamicsAx/AxDatabaseDetector.cs : AxDatabaseDetector.BuildDetectionSql(database) = AxSql.InDatabase(database, AxDetection.MetadataSql)
-- Agenten kører dette i hver overvåget database fra listen ovenfor (ikke *_model). Ret [AXDB] til den database, du vil tjekke.
EXEC [AXDB].sys.sp_executesql N'SET NOCOUNT ON; SET LOCK_TIMEOUT 2000; SET TRANSACTION ISOLATION LEVEL READ COMMITTED; SELECT s.name, UPPER(t.name), UPPER(c.name), TYPE_NAME(c.system_type_id), c.precision, c.max_length,
ISNULL(HAS_PERMS_BY_NAME(QUOTENAME(s.name) + N''.'' + QUOTENAME(t.name), N''OBJECT'', N''SELECT''), 0),
(SELECT TOP 1 p.name FROM sys.database_principals p WHERE p.sid = SUSER_SID())
FROM sys.tables t
JOIN sys.schemas s ON s.schema_id = t.schema_id
JOIN sys.columns c ON c.object_id = t.object_id
WHERE UPPER(t.name) IN (N''SQLDICTIONARY'', N''DATAAREA'', N''SYSCONFIG'', N''BATCH'', N''BATCHJOB'', N''BATCHJOBHISTORY'', N''BATCHGROUP'', N''NUMBERSEQUENCETABLE'', N''NUMBERSEQUENCESCOPE'', N''NUMBERSEQUENCEREFERENCE'', N''SYSTEMSEQUENCES'', N''MODELELEMENT'')
ORDER BY s.name, t.name, c.column_id';
SET LOCK_TIMEOUT 5000; SET TRANSACTION ISOLATION LEVEL READ UNCOMMITTED;
GO
-- -------------------------------------------------------------------------------------------------------------------
-- AxSequenceCollector: Nummerserier i AX-databasen (valgfri AX-læseadgang)
-- -------------------------------------------------------------------------------------------------------------------
-- Variant: Axapta 3.0 (skema bmssa, verificeret mod kundedata): nummerserier
-- Kilde: AxSchemaAdapter.NumberSequences(3.0)
-- Kode: src/SQLInsight.Core/DynamicsAx/AxSchemaAdapter.cs : AxSchemaAdapter.NumberSequences(database) via AxSql.InDatabase
EXEC [AXDB].sys.sp_executesql N'SET NOCOUNT ON; SET LOCK_TIMEOUT 2000; SET TRANSACTION ISOLATION LEVEL READ COMMITTED; SELECT TOP (5000) CAST(n.[RECID] AS bigint), n.[NUMBERSEQUENCE], n.[DATAAREAID], n.[TXT], CAST(n.[LOWEST] AS bigint), CAST(n.[HIGHEST] AS bigint), CAST(n.[NEXTREC] AS bigint), n.[FORMAT], ISNULL(n.[BLOCKED], 0), ISNULL(n.[CONTINUOUS], 0), ISNULL(n.[CYCLIC], 0), ISNULL(n.[MANUAL], 0), CASE WHEN EXISTS (SELECT 1 FROM [bmssa].[NUMBERSEQUENCEREFERENCE] r WHERE r.[NUMBERSEQUENCE] = n.[NUMBERSEQUENCE] AND r.[DATAAREAID] = n.[DATAAREAID]) THEN 1 ELSE 0 END, n.[LATESTCLEANDATE], n.[LATESTCLEANTIME], CAST(n.[CLEANINTERVAL] AS float), DATEDIFF(minute, GETDATE(), GETUTCDATE()) FROM [bmssa].[NUMBERSEQUENCETABLE] n ORDER BY (CAST(n.[NEXTREC] AS float) - n.[LOWEST]) / NULLIF(CAST(n.[HIGHEST] AS float) - n.[LOWEST], 0) DESC';
SET LOCK_TIMEOUT 5000; SET TRANSACTION ISOLATION LEVEL READ UNCOMMITTED;
GO
-- Variant: AX 4.0 (skema dbo) - ikke verificeret mod kundedata: nummerserier
-- Kilde: AxSchemaAdapter.NumberSequences(4.0)
-- Kode: src/SQLInsight.Core/DynamicsAx/AxSchemaAdapter.cs : AxSchemaAdapter.NumberSequences(database) via AxSql.InDatabase
EXEC [AXDB].sys.sp_executesql N'SET NOCOUNT ON; SET LOCK_TIMEOUT 2000; SET TRANSACTION ISOLATION LEVEL READ COMMITTED; SELECT TOP (5000) CAST(n.[RECID] AS bigint), n.[NUMBERSEQUENCE], n.[DATAAREAID], n.[TXT], CAST(n.[LOWEST] AS bigint), CAST(n.[HIGHEST] AS bigint), CAST(n.[NEXTREC] AS bigint), n.[FORMAT], ISNULL(n.[BLOCKED], 0), ISNULL(n.[CONTINUOUS], 0), ISNULL(n.[CYCLIC], 0), ISNULL(n.[MANUAL], 0), CASE WHEN EXISTS (SELECT 1 FROM [dbo].[NUMBERSEQUENCEREFERENCE] r WHERE r.[NUMBERSEQUENCE] = n.[NUMBERSEQUENCE] AND r.[DATAAREAID] = n.[DATAAREAID]) THEN 1 ELSE 0 END, n.[LATESTCLEANDATE], n.[LATESTCLEANTIME], CAST(n.[CLEANINTERVAL] AS float), DATEDIFF(minute, GETDATE(), GETUTCDATE()) FROM [dbo].[NUMBERSEQUENCETABLE] n ORDER BY (CAST(n.[NEXTREC] AS float) - n.[LOWEST]) / NULLIF(CAST(n.[HIGHEST] AS float) - n.[LOWEST], 0) DESC';
SET LOCK_TIMEOUT 5000; SET TRANSACTION ISOLATION LEVEL READ UNCOMMITTED;
GO
-- Variant: AX 2009 - baseret på Microsofts dokumenterede skema, ikke verificeret mod kundedata: nummerserier
-- Kilde: AxSchemaAdapter.NumberSequences(2009)
-- Kode: src/SQLInsight.Core/DynamicsAx/AxSchemaAdapter.cs : AxSchemaAdapter.NumberSequences(database) via AxSql.InDatabase
EXEC [AXDB].sys.sp_executesql N'SET NOCOUNT ON; SET LOCK_TIMEOUT 2000; SET TRANSACTION ISOLATION LEVEL READ COMMITTED; SELECT TOP (5000) CAST(n.[RECID] AS bigint), n.[NUMBERSEQUENCE], n.[DATAAREAID], n.[TXT], CAST(n.[LOWEST] AS bigint), CAST(n.[HIGHEST] AS bigint), CAST(n.[NEXTREC] AS bigint), n.[FORMAT], ISNULL(n.[BLOCKED], 0), ISNULL(n.[CONTINUOUS], 0), ISNULL(n.[CYCLIC], 0), ISNULL(n.[MANUAL], 0), CASE WHEN EXISTS (SELECT 1 FROM [dbo].[NUMBERSEQUENCEREFERENCE] r WHERE r.[NUMBERSEQUENCE] = n.[NUMBERSEQUENCE] AND r.[DATAAREAID] = n.[DATAAREAID]) THEN 1 ELSE 0 END, n.[LATESTCLEANDATETIME], CAST(NULL AS int), CAST(n.[CLEANINTERVAL] AS float), DATEDIFF(minute, GETDATE(), GETUTCDATE()) FROM [dbo].[NUMBERSEQUENCETABLE] n ORDER BY (CAST(n.[NEXTREC] AS float) - n.[LOWEST]) / NULLIF(CAST(n.[HIGHEST] AS float) - n.[LOWEST], 0) DESC';
SET LOCK_TIMEOUT 5000; SET TRANSACTION ISOLATION LEVEL READ UNCOMMITTED;
GO
-- Variant: AX 2012 RTM/R2/R3 - baseret på Microsofts dokumenterede skema, ikke verificeret mod kundedata: nummerserier
-- Kilde: AxSchemaAdapter.NumberSequences(2012)
-- Kode: src/SQLInsight.Core/DynamicsAx/AxSchemaAdapter.cs : AxSchemaAdapter.NumberSequences(database) via AxSql.InDatabase
EXEC [AXDB].sys.sp_executesql N'SET NOCOUNT ON; SET LOCK_TIMEOUT 2000; SET TRANSACTION ISOLATION LEVEL READ COMMITTED; SELECT TOP (5000) CAST(n.[RECID] AS bigint), n.[NUMBERSEQUENCE], NULLIF(sc.[DATAAREA], N''''), n.[TXT], CAST(n.[LOWEST] AS bigint), CAST(n.[HIGHEST] AS bigint), CAST(n.[NEXTREC] AS bigint), n.[FORMAT], ISNULL(n.[BLOCKED], 0), ISNULL(n.[CONTINUOUS], 0), ISNULL(n.[CYCLIC], 0), ISNULL(n.[MANUAL], 0), CASE WHEN EXISTS (SELECT 1 FROM [dbo].[NUMBERSEQUENCEREFERENCE] r WHERE r.[NUMBERSEQUENCEID] = n.[RECID]) THEN 1 ELSE 0 END, n.[LATESTCLEANDATETIME], CAST(NULL AS int), CAST(n.[CLEANINTERVAL] AS float), DATEDIFF(minute, GETDATE(), GETUTCDATE()) FROM [dbo].[NUMBERSEQUENCETABLE] n LEFT JOIN [dbo].[NUMBERSEQUENCESCOPE] sc ON sc.[RECID] = n.[NUMBERSEQUENCESCOPE] ORDER BY (CAST(n.[NEXTREC] AS float) - n.[LOWEST]) / NULLIF(CAST(n.[HIGHEST] AS float) - n.[LOWEST], 0) DESC';
SET LOCK_TIMEOUT 5000; SET TRANSACTION ISOLATION LEVEL READ UNCOMMITTED;
GO

127
SQL Scripts/SQLI-AX-008.sql Normal file
View file

@ -0,0 +1,127 @@
-- ===================================================================================================================
-- SQLI-AX-008: Kontinuerlig Dynamics AX-nummerserie er ikke ryddet op
-- ===================================================================================================================
-- Regel-id: SQLI-AX-008
-- Titel: Kontinuerlig Dynamics AX-nummerserie er ikke ryddet op
-- Alvor: Low
-- Versioner: SQL Server 2005 og nyere (ikke 2000). Axapta 3.0: verificeret mod en kundes skema (docs/dynamics-ax/
-- schema-ax30.tsv). AX 4.0, AX 2009, AX 2012 og F&O: baseret på Microsofts dokumenterede skema - ikke
-- verificeret mod kundedata.
--
-- Hvad tjekkes, og hvorfor:
-- En kontinuerlig serie må ikke have huller. Numre reserveret af afbrudte sessioner bliver liggende som optagede, til
-- serien ryddes op; uden oprydning opstår huller og fejl, og serien bliver en flaskehals, fordi hver tildeling låser
-- rækken i NUMBERSEQUENCETABLE, til transaktionen er færdig.
--
-- Vurdering (sker i C# ud fra de indsamlede data, ikke i SQL'en, medmindre andet er nævnt):
-- For kontinuerlige (CONTINUOUS = 1), ikke spærrede, ikke manuelle serier, der er taget i brug (NEXTREC > LOWEST):
-- fund, når seneste oprydning er ældre end grænsen eller aldrig er sket (1900-01-01). Seneste oprydning er
-- LATESTCLEANDATETIME (UTC) i AX 2009+, og LATESTCLEANDATE + LATESTCLEANTIME (lokal tid, sekunder efter midnat) i
-- Axapta 3.0. Mangler kolonnen, gives intet fund. Højst 25 fund pr. database.
--
-- Grænseværdier (konfigurationsnøgle i agentens appsettings.json og standardværdi):
-- 7 dage (fast, AxNumberSequenceContinuousRule.MinimumDays), eller 2 x seriens eget CLEANINTERVAL (timer), hvis længere.
--
-- Rettigheder til agentens login (se db/permissions/grant-least-privilege.sql og grant-dynamics-ax-read.sql):
-- Valgfri AX-læseadgang, kun SELECT (db/permissions/grant-dynamics-ax-read.sql; [bruger] = agentens databasebruger):
-- Axapta 3.0: GRANT SELECT ON [bmssa].[NUMBERSEQUENCETABLE] TO [bruger];
-- GRANT SELECT ON [bmssa].[NUMBERSEQUENCEREFERENCE] TO [bruger]; -- valgfri: om serien bruges
-- AX 4.0/2009: de samme tabeller i [dbo]
-- AX 2012/F&O: desuden GRANT SELECT ON [dbo].[NUMBERSEQUENCESCOPE] TO [bruger]; -- valgfri: firma
-- Mangler NUMBERSEQUENCETABLE, er reglen "ikke målbar" med den nøjagtige GRANT for databasen. Uden GRANT ændres intet.
-- Genkendelsen: kun grant-least-privilege.sql (VIEW ANY DEFINITION; CONNECT ANY DATABASE fra 2014, ellers en
-- databasebruger: CREATE USER [agent] FOR LOGIN [agent];).
--
-- Data kommer fra (collector, fil : medlemmer, interval):
-- AxDatabaseDetector (den fælles AX-genkendelse)
-- Fil: src/SQLInsight.Collectors/DynamicsAx/AxDatabaseDetector.cs
-- Medlemmer: AxDatabaseDetector.DatabasesSql, AxDatabaseDetector.BuildDetectionSql(database)
-- Interval: hver time pr. database (hvert 5. minut, mens AX-læseadgangen mangler), delt af alle AX-collectors
-- Delt script: SQL Scripts/_collectors/AxDatabaseDetector.sql
-- AxSequenceCollector
-- Fil: src/SQLInsight.Collectors/DynamicsAx/AxSequenceCollector.cs; SQL: src/SQLInsight.Core/DynamicsAx/AxSchemaAdapter.cs
-- Medlemmer: AxSchemaAdapter.NumberSequences(database), AxSchemaAdapter.RecIds(database), AxSql.InDatabase
-- Interval: 1 time som standard (SQLInsight:CollectorIntervals:dynamics-ax-sequences)
-- Delt script: SQL Scripts/_collectors/AxSequenceCollector.sql
-- Regelkode: src/SQLInsight.Rules/DynamicsAx/DynamicsAxRules.cs : AxNumberSequenceContinuousRule
--
-- Read-only: Ja: kun SELECT med TOP og SET-sessionsindstillinger. AX-tabellerne læses i AX-databasen via
-- [db].sys.sp_executesql (sessionen skifter aldrig database) under READ COMMITTED med SET LOCK_TIMEOUT 2000
-- (SNAPSHOT, når databasen tillader det), aldrig NOLOCK; bagefter sættes agentens sessionsindstillinger
-- igen. Intet ændrer data, indstillinger eller objekter. Kun i databaser genkendt som AX og valgt til
-- overvågning.
-- Kørsel: Kør i SSMS som agentens login (eller et login med samme rettigheder). Hver variant er sin egen batch
-- (GO); varianter for andre AX-versioner end din fejler eller giver ingen rækker - det er forventet.
-- AX-forespørgslerne er vist for databasen AXDB: ret [AXDB] til din AX-database.
-- ===================================================================================================================
-- -------------------------------------------------------------------------------------------------------------------
-- Forbindelsen: Sessionsindstillinger, agenten sætter på hver forbindelse
-- -------------------------------------------------------------------------------------------------------------------
-- Variant: Alle versioner: sættes på hver forbindelse, agenten åbner
-- Kilde: SqlConnectionFactory.SessionSettings
-- Kode: src/SQLInsight.Connectivity/SqlConnectionFactory.cs : SqlConnectionFactory.SessionSettings
SET LOCK_TIMEOUT 5000; SET DEADLOCK_PRIORITY LOW; SET TRANSACTION ISOLATION LEVEL READ UNCOMMITTED;
GO
-- -------------------------------------------------------------------------------------------------------------------
-- AxDatabaseDetector: Hvilke overvågede databaser er Dynamics AX (kun metadata)
-- -------------------------------------------------------------------------------------------------------------------
-- Variant: Alle versioner fra SQL Server 2005: online brugerdatabaser og deres snapshot-indstillinger
-- Kilde: AxDatabaseDetector.DatabasesSql
-- Kode: src/SQLInsight.Collectors/DynamicsAx/AxDatabaseDetector.cs : AxDatabaseDetector.DatabasesSql
SELECT name, snapshot_isolation_state, is_read_committed_snapshot_on
FROM sys.databases
WHERE database_id > 4 AND state = 0 AND source_database_id IS NULL AND HAS_DBACCESS(name) = 1
ORDER BY name
GO
-- Variant: SQL Server 2005 og nyere: AX-genkendelsen i hver overvåget database (kun metadata; vist for AXDB)
-- Kilde: AxDatabaseDetector.BuildDetectionSql
-- Kode: src/SQLInsight.Collectors/DynamicsAx/AxDatabaseDetector.cs : AxDatabaseDetector.BuildDetectionSql(database) = AxSql.InDatabase(database, AxDetection.MetadataSql)
-- Agenten kører dette i hver overvåget database fra listen ovenfor (ikke *_model). Ret [AXDB] til den database, du vil tjekke.
EXEC [AXDB].sys.sp_executesql N'SET NOCOUNT ON; SET LOCK_TIMEOUT 2000; SET TRANSACTION ISOLATION LEVEL READ COMMITTED; SELECT s.name, UPPER(t.name), UPPER(c.name), TYPE_NAME(c.system_type_id), c.precision, c.max_length,
ISNULL(HAS_PERMS_BY_NAME(QUOTENAME(s.name) + N''.'' + QUOTENAME(t.name), N''OBJECT'', N''SELECT''), 0),
(SELECT TOP 1 p.name FROM sys.database_principals p WHERE p.sid = SUSER_SID())
FROM sys.tables t
JOIN sys.schemas s ON s.schema_id = t.schema_id
JOIN sys.columns c ON c.object_id = t.object_id
WHERE UPPER(t.name) IN (N''SQLDICTIONARY'', N''DATAAREA'', N''SYSCONFIG'', N''BATCH'', N''BATCHJOB'', N''BATCHJOBHISTORY'', N''BATCHGROUP'', N''NUMBERSEQUENCETABLE'', N''NUMBERSEQUENCESCOPE'', N''NUMBERSEQUENCEREFERENCE'', N''SYSTEMSEQUENCES'', N''MODELELEMENT'')
ORDER BY s.name, t.name, c.column_id';
SET LOCK_TIMEOUT 5000; SET TRANSACTION ISOLATION LEVEL READ UNCOMMITTED;
GO
-- -------------------------------------------------------------------------------------------------------------------
-- AxSequenceCollector: Nummerserier i AX-databasen (valgfri AX-læseadgang)
-- -------------------------------------------------------------------------------------------------------------------
-- Variant: Axapta 3.0 (skema bmssa, verificeret mod kundedata): nummerserier
-- Kilde: AxSchemaAdapter.NumberSequences(3.0)
-- Kode: src/SQLInsight.Core/DynamicsAx/AxSchemaAdapter.cs : AxSchemaAdapter.NumberSequences(database) via AxSql.InDatabase
EXEC [AXDB].sys.sp_executesql N'SET NOCOUNT ON; SET LOCK_TIMEOUT 2000; SET TRANSACTION ISOLATION LEVEL READ COMMITTED; SELECT TOP (5000) CAST(n.[RECID] AS bigint), n.[NUMBERSEQUENCE], n.[DATAAREAID], n.[TXT], CAST(n.[LOWEST] AS bigint), CAST(n.[HIGHEST] AS bigint), CAST(n.[NEXTREC] AS bigint), n.[FORMAT], ISNULL(n.[BLOCKED], 0), ISNULL(n.[CONTINUOUS], 0), ISNULL(n.[CYCLIC], 0), ISNULL(n.[MANUAL], 0), CASE WHEN EXISTS (SELECT 1 FROM [bmssa].[NUMBERSEQUENCEREFERENCE] r WHERE r.[NUMBERSEQUENCE] = n.[NUMBERSEQUENCE] AND r.[DATAAREAID] = n.[DATAAREAID]) THEN 1 ELSE 0 END, n.[LATESTCLEANDATE], n.[LATESTCLEANTIME], CAST(n.[CLEANINTERVAL] AS float), DATEDIFF(minute, GETDATE(), GETUTCDATE()) FROM [bmssa].[NUMBERSEQUENCETABLE] n ORDER BY (CAST(n.[NEXTREC] AS float) - n.[LOWEST]) / NULLIF(CAST(n.[HIGHEST] AS float) - n.[LOWEST], 0) DESC';
SET LOCK_TIMEOUT 5000; SET TRANSACTION ISOLATION LEVEL READ UNCOMMITTED;
GO
-- Variant: AX 4.0 (skema dbo) - ikke verificeret mod kundedata: nummerserier
-- Kilde: AxSchemaAdapter.NumberSequences(4.0)
-- Kode: src/SQLInsight.Core/DynamicsAx/AxSchemaAdapter.cs : AxSchemaAdapter.NumberSequences(database) via AxSql.InDatabase
EXEC [AXDB].sys.sp_executesql N'SET NOCOUNT ON; SET LOCK_TIMEOUT 2000; SET TRANSACTION ISOLATION LEVEL READ COMMITTED; SELECT TOP (5000) CAST(n.[RECID] AS bigint), n.[NUMBERSEQUENCE], n.[DATAAREAID], n.[TXT], CAST(n.[LOWEST] AS bigint), CAST(n.[HIGHEST] AS bigint), CAST(n.[NEXTREC] AS bigint), n.[FORMAT], ISNULL(n.[BLOCKED], 0), ISNULL(n.[CONTINUOUS], 0), ISNULL(n.[CYCLIC], 0), ISNULL(n.[MANUAL], 0), CASE WHEN EXISTS (SELECT 1 FROM [dbo].[NUMBERSEQUENCEREFERENCE] r WHERE r.[NUMBERSEQUENCE] = n.[NUMBERSEQUENCE] AND r.[DATAAREAID] = n.[DATAAREAID]) THEN 1 ELSE 0 END, n.[LATESTCLEANDATE], n.[LATESTCLEANTIME], CAST(n.[CLEANINTERVAL] AS float), DATEDIFF(minute, GETDATE(), GETUTCDATE()) FROM [dbo].[NUMBERSEQUENCETABLE] n ORDER BY (CAST(n.[NEXTREC] AS float) - n.[LOWEST]) / NULLIF(CAST(n.[HIGHEST] AS float) - n.[LOWEST], 0) DESC';
SET LOCK_TIMEOUT 5000; SET TRANSACTION ISOLATION LEVEL READ UNCOMMITTED;
GO
-- Variant: AX 2009 - baseret på Microsofts dokumenterede skema, ikke verificeret mod kundedata: nummerserier
-- Kilde: AxSchemaAdapter.NumberSequences(2009)
-- Kode: src/SQLInsight.Core/DynamicsAx/AxSchemaAdapter.cs : AxSchemaAdapter.NumberSequences(database) via AxSql.InDatabase
EXEC [AXDB].sys.sp_executesql N'SET NOCOUNT ON; SET LOCK_TIMEOUT 2000; SET TRANSACTION ISOLATION LEVEL READ COMMITTED; SELECT TOP (5000) CAST(n.[RECID] AS bigint), n.[NUMBERSEQUENCE], n.[DATAAREAID], n.[TXT], CAST(n.[LOWEST] AS bigint), CAST(n.[HIGHEST] AS bigint), CAST(n.[NEXTREC] AS bigint), n.[FORMAT], ISNULL(n.[BLOCKED], 0), ISNULL(n.[CONTINUOUS], 0), ISNULL(n.[CYCLIC], 0), ISNULL(n.[MANUAL], 0), CASE WHEN EXISTS (SELECT 1 FROM [dbo].[NUMBERSEQUENCEREFERENCE] r WHERE r.[NUMBERSEQUENCE] = n.[NUMBERSEQUENCE] AND r.[DATAAREAID] = n.[DATAAREAID]) THEN 1 ELSE 0 END, n.[LATESTCLEANDATETIME], CAST(NULL AS int), CAST(n.[CLEANINTERVAL] AS float), DATEDIFF(minute, GETDATE(), GETUTCDATE()) FROM [dbo].[NUMBERSEQUENCETABLE] n ORDER BY (CAST(n.[NEXTREC] AS float) - n.[LOWEST]) / NULLIF(CAST(n.[HIGHEST] AS float) - n.[LOWEST], 0) DESC';
SET LOCK_TIMEOUT 5000; SET TRANSACTION ISOLATION LEVEL READ UNCOMMITTED;
GO
-- Variant: AX 2012 RTM/R2/R3 - baseret på Microsofts dokumenterede skema, ikke verificeret mod kundedata: nummerserier
-- Kilde: AxSchemaAdapter.NumberSequences(2012)
-- Kode: src/SQLInsight.Core/DynamicsAx/AxSchemaAdapter.cs : AxSchemaAdapter.NumberSequences(database) via AxSql.InDatabase
EXEC [AXDB].sys.sp_executesql N'SET NOCOUNT ON; SET LOCK_TIMEOUT 2000; SET TRANSACTION ISOLATION LEVEL READ COMMITTED; SELECT TOP (5000) CAST(n.[RECID] AS bigint), n.[NUMBERSEQUENCE], NULLIF(sc.[DATAAREA], N''''), n.[TXT], CAST(n.[LOWEST] AS bigint), CAST(n.[HIGHEST] AS bigint), CAST(n.[NEXTREC] AS bigint), n.[FORMAT], ISNULL(n.[BLOCKED], 0), ISNULL(n.[CONTINUOUS], 0), ISNULL(n.[CYCLIC], 0), ISNULL(n.[MANUAL], 0), CASE WHEN EXISTS (SELECT 1 FROM [dbo].[NUMBERSEQUENCEREFERENCE] r WHERE r.[NUMBERSEQUENCEID] = n.[RECID]) THEN 1 ELSE 0 END, n.[LATESTCLEANDATETIME], CAST(NULL AS int), CAST(n.[CLEANINTERVAL] AS float), DATEDIFF(minute, GETDATE(), GETUTCDATE()) FROM [dbo].[NUMBERSEQUENCETABLE] n LEFT JOIN [dbo].[NUMBERSEQUENCESCOPE] sc ON sc.[RECID] = n.[NUMBERSEQUENCESCOPE] ORDER BY (CAST(n.[NEXTREC] AS float) - n.[LOWEST]) / NULLIF(CAST(n.[HIGHEST] AS float) - n.[LOWEST], 0) DESC';
SET LOCK_TIMEOUT 5000; SET TRANSACTION ISOLATION LEVEL READ UNCOMMITTED;
GO

130
SQL Scripts/SQLI-AX-009.sql Normal file
View file

@ -0,0 +1,130 @@
-- ===================================================================================================================
-- SQLI-AX-009: Dynamics AX RecId er ved at løbe tør
-- ===================================================================================================================
-- Regel-id: SQLI-AX-009
-- Titel: Dynamics AX RecId er ved at løbe tør
-- Alvor: Axapta 3.0 (32-bit): High fra advarselsgrænsen, Critical fra den kritiske grænse; 64-bit: High; altid Critical, når RecId er opbrugt.
-- Versioner: SQL Server 2005 og nyere (ikke 2000). Axapta 3.0: verificeret mod en kundes skema (docs/dynamics-ax/
-- schema-ax30.tsv). AX 4.0, AX 2009, AX 2012 og F&O: baseret på Microsofts dokumenterede skema - ikke
-- verificeret mod kundedata.
--
-- Hvad tjekkes, og hvorfor:
-- Når en tabels RecId-serie er brugt op, kan AX ikke oprette flere poster. Axapta 3.0 har 32-bit RecId (int, højst
-- 2.147.483.647 pr. tabel og firma), og det løses kun med et stort projekt - derfor advares langt tidligere for 3.0.
--
-- Vurdering (sker i C# ud fra de indsamlede data, ikke i SQL'en, medmindre andet er nævnt):
-- Grænse = typens maksimum (int, når SYSTEMSEQUENCES.NEXTVAL er int som i Axapta 3.0; ellers bigint), eller MAXVAL,
-- hvis den er sat lavere. Brugt % = NEXTVAL / grænse. Prognose ud fra SQLInsights egne daglige målinger af NEXTVAL
-- (insight.AxSequenceSamples). Højst 25 fund pr. database, de mest brugte først. Tal: src/SQLInsight.Core/DynamicsAx/AxSchemaAdapter.cs : Interpret(AxRecIdRow).
--
-- Grænseværdier (konfigurationsnøgle i agentens appsettings.json og standardværdi):
-- SQLInsight:DynamicsAx:RecId32WarnPercent = 50: Axapta 3.0: High fra så mange procent af int-intervallet
-- SQLInsight:DynamicsAx:RecId32CriticalPercent = 75: Axapta 3.0: Critical
-- SQLInsight:DynamicsAx:RecId32ForecastDays = 730: Axapta 3.0: High, hvis den løber tør inden for så mange dage;
-- Critical inden for halvdelen
-- SQLInsight:DynamicsAx:RecId64WarnPercent = 80: 64-bit (eller sænket MAXVAL): High
-- SQLInsight:DynamicsAx:ForecastWarnDays = 90: 64-bit: High, hvis den løber tør inden for så mange dage
-- SQLInsight:DynamicsAx:ForecastBasisDays = 30: prognosen bruger den ældste daglige måling inden for så mange dage
--
-- Rettigheder til agentens login (se db/permissions/grant-least-privilege.sql og grant-dynamics-ax-read.sql):
-- Valgfri AX-læseadgang, kun SELECT (db/permissions/grant-dynamics-ax-read.sql; [bruger] = agentens databasebruger):
-- Axapta 3.0: GRANT SELECT ON [bmssa].[SYSTEMSEQUENCES] TO [bruger];
-- GRANT SELECT ON [bmssa].[SQLDICTIONARY] TO [bruger]; -- valgfri: tabelnavne
-- AX 4.0/2009/2012/F&O: de samme tabeller i [dbo]
-- Mangler SYSTEMSEQUENCES, er reglen "ikke målbar" med den nøjagtige GRANT for databasen. Uden GRANT ændres intet.
-- Genkendelsen: kun grant-least-privilege.sql (VIEW ANY DEFINITION; CONNECT ANY DATABASE fra 2014, ellers en
-- databasebruger: CREATE USER [agent] FOR LOGIN [agent];).
--
-- Data kommer fra (collector, fil : medlemmer, interval):
-- AxDatabaseDetector (den fælles AX-genkendelse)
-- Fil: src/SQLInsight.Collectors/DynamicsAx/AxDatabaseDetector.cs
-- Medlemmer: AxDatabaseDetector.DatabasesSql, AxDatabaseDetector.BuildDetectionSql(database)
-- Interval: hver time pr. database (hvert 5. minut, mens AX-læseadgangen mangler), delt af alle AX-collectors
-- Delt script: SQL Scripts/_collectors/AxDatabaseDetector.sql
-- AxSequenceCollector
-- Fil: src/SQLInsight.Collectors/DynamicsAx/AxSequenceCollector.cs; SQL: src/SQLInsight.Core/DynamicsAx/AxSchemaAdapter.cs
-- Medlemmer: AxSchemaAdapter.NumberSequences(database), AxSchemaAdapter.RecIds(database), AxSql.InDatabase
-- Interval: 1 time som standard (SQLInsight:CollectorIntervals:dynamics-ax-sequences)
-- Delt script: SQL Scripts/_collectors/AxSequenceCollector.sql
-- Regelkode: src/SQLInsight.Rules/DynamicsAx/DynamicsAxRules.cs : AxRecIdExhaustionRule
--
-- Read-only: Ja: kun SELECT med TOP og SET-sessionsindstillinger. AX-tabellerne læses i AX-databasen via
-- [db].sys.sp_executesql (sessionen skifter aldrig database) under READ COMMITTED med SET LOCK_TIMEOUT 2000
-- (SNAPSHOT, når databasen tillader det), aldrig NOLOCK; bagefter sættes agentens sessionsindstillinger
-- igen. Intet ændrer data, indstillinger eller objekter. Kun i databaser genkendt som AX og valgt til
-- overvågning.
-- Kørsel: Kør i SSMS som agentens login (eller et login med samme rettigheder). Hver variant er sin egen batch
-- (GO); varianter for andre AX-versioner end din fejler eller giver ingen rækker - det er forventet.
-- AX-forespørgslerne er vist for databasen AXDB: ret [AXDB] til din AX-database.
-- ===================================================================================================================
-- -------------------------------------------------------------------------------------------------------------------
-- Forbindelsen: Sessionsindstillinger, agenten sætter på hver forbindelse
-- -------------------------------------------------------------------------------------------------------------------
-- Variant: Alle versioner: sættes på hver forbindelse, agenten åbner
-- Kilde: SqlConnectionFactory.SessionSettings
-- Kode: src/SQLInsight.Connectivity/SqlConnectionFactory.cs : SqlConnectionFactory.SessionSettings
SET LOCK_TIMEOUT 5000; SET DEADLOCK_PRIORITY LOW; SET TRANSACTION ISOLATION LEVEL READ UNCOMMITTED;
GO
-- -------------------------------------------------------------------------------------------------------------------
-- AxDatabaseDetector: Hvilke overvågede databaser er Dynamics AX (kun metadata)
-- -------------------------------------------------------------------------------------------------------------------
-- Variant: Alle versioner fra SQL Server 2005: online brugerdatabaser og deres snapshot-indstillinger
-- Kilde: AxDatabaseDetector.DatabasesSql
-- Kode: src/SQLInsight.Collectors/DynamicsAx/AxDatabaseDetector.cs : AxDatabaseDetector.DatabasesSql
SELECT name, snapshot_isolation_state, is_read_committed_snapshot_on
FROM sys.databases
WHERE database_id > 4 AND state = 0 AND source_database_id IS NULL AND HAS_DBACCESS(name) = 1
ORDER BY name
GO
-- Variant: SQL Server 2005 og nyere: AX-genkendelsen i hver overvåget database (kun metadata; vist for AXDB)
-- Kilde: AxDatabaseDetector.BuildDetectionSql
-- Kode: src/SQLInsight.Collectors/DynamicsAx/AxDatabaseDetector.cs : AxDatabaseDetector.BuildDetectionSql(database) = AxSql.InDatabase(database, AxDetection.MetadataSql)
-- Agenten kører dette i hver overvåget database fra listen ovenfor (ikke *_model). Ret [AXDB] til den database, du vil tjekke.
EXEC [AXDB].sys.sp_executesql N'SET NOCOUNT ON; SET LOCK_TIMEOUT 2000; SET TRANSACTION ISOLATION LEVEL READ COMMITTED; SELECT s.name, UPPER(t.name), UPPER(c.name), TYPE_NAME(c.system_type_id), c.precision, c.max_length,
ISNULL(HAS_PERMS_BY_NAME(QUOTENAME(s.name) + N''.'' + QUOTENAME(t.name), N''OBJECT'', N''SELECT''), 0),
(SELECT TOP 1 p.name FROM sys.database_principals p WHERE p.sid = SUSER_SID())
FROM sys.tables t
JOIN sys.schemas s ON s.schema_id = t.schema_id
JOIN sys.columns c ON c.object_id = t.object_id
WHERE UPPER(t.name) IN (N''SQLDICTIONARY'', N''DATAAREA'', N''SYSCONFIG'', N''BATCH'', N''BATCHJOB'', N''BATCHJOBHISTORY'', N''BATCHGROUP'', N''NUMBERSEQUENCETABLE'', N''NUMBERSEQUENCESCOPE'', N''NUMBERSEQUENCEREFERENCE'', N''SYSTEMSEQUENCES'', N''MODELELEMENT'')
ORDER BY s.name, t.name, c.column_id';
SET LOCK_TIMEOUT 5000; SET TRANSACTION ISOLATION LEVEL READ UNCOMMITTED;
GO
-- -------------------------------------------------------------------------------------------------------------------
-- AxSequenceCollector: RecId pr. tabel og firma i AX-databasen (valgfri AX-læseadgang)
-- -------------------------------------------------------------------------------------------------------------------
-- Variant: Axapta 3.0 (skema bmssa, verificeret mod kundedata): RecId pr. tabel (SYSTEMSEQUENCES)
-- Kilde: AxSchemaAdapter.RecIds(3.0)
-- Kode: src/SQLInsight.Core/DynamicsAx/AxSchemaAdapter.cs : AxSchemaAdapter.RecIds(database) via AxSql.InDatabase
EXEC [AXDB].sys.sp_executesql N'SET NOCOUNT ON; SET LOCK_TIMEOUT 2000; SET TRANSACTION ISOLATION LEVEL READ COMMITTED; SELECT TOP (500) s.[TABID], s.[DATAAREAID], CAST(s.[NEXTVAL] AS bigint), CAST(s.[MAXVAL] AS bigint), (SELECT TOP 1 d.[NAME] FROM [bmssa].[SQLDICTIONARY] d WHERE d.[TABLEID] = s.[TABID] AND d.[FIELDID] = 0) FROM [bmssa].[SYSTEMSEQUENCES] s WHERE s.[NAME] = N''SEQNO'' ORDER BY s.[NEXTVAL] DESC';
SET LOCK_TIMEOUT 5000; SET TRANSACTION ISOLATION LEVEL READ UNCOMMITTED;
GO
-- Variant: AX 4.0 (skema dbo) - ikke verificeret mod kundedata: RecId pr. tabel (SYSTEMSEQUENCES)
-- Kilde: AxSchemaAdapter.RecIds(4.0)
-- Kode: src/SQLInsight.Core/DynamicsAx/AxSchemaAdapter.cs : AxSchemaAdapter.RecIds(database) via AxSql.InDatabase
EXEC [AXDB].sys.sp_executesql N'SET NOCOUNT ON; SET LOCK_TIMEOUT 2000; SET TRANSACTION ISOLATION LEVEL READ COMMITTED; SELECT TOP (500) s.[TABID], s.[DATAAREAID], CAST(s.[NEXTVAL] AS bigint), CAST(s.[MAXVAL] AS bigint), (SELECT TOP 1 d.[NAME] FROM [dbo].[SQLDICTIONARY] d WHERE d.[TABLEID] = s.[TABID] AND d.[FIELDID] = 0) FROM [dbo].[SYSTEMSEQUENCES] s WHERE s.[NAME] = N''SEQNO'' ORDER BY s.[NEXTVAL] DESC';
SET LOCK_TIMEOUT 5000; SET TRANSACTION ISOLATION LEVEL READ UNCOMMITTED;
GO
-- Variant: AX 2009 - baseret på Microsofts dokumenterede skema, ikke verificeret mod kundedata: RecId pr. tabel (SYSTEMSEQUENCES)
-- Kilde: AxSchemaAdapter.RecIds(2009)
-- Kode: src/SQLInsight.Core/DynamicsAx/AxSchemaAdapter.cs : AxSchemaAdapter.RecIds(database) via AxSql.InDatabase
EXEC [AXDB].sys.sp_executesql N'SET NOCOUNT ON; SET LOCK_TIMEOUT 2000; SET TRANSACTION ISOLATION LEVEL READ COMMITTED; SELECT TOP (500) s.[TABID], s.[DATAAREAID], CAST(s.[NEXTVAL] AS bigint), CAST(s.[MAXVAL] AS bigint), (SELECT TOP 1 d.[NAME] FROM [dbo].[SQLDICTIONARY] d WHERE d.[TABLEID] = s.[TABID] AND d.[FIELDID] = 0) FROM [dbo].[SYSTEMSEQUENCES] s WHERE s.[NAME] = N''SEQNO'' ORDER BY s.[NEXTVAL] DESC';
SET LOCK_TIMEOUT 5000; SET TRANSACTION ISOLATION LEVEL READ UNCOMMITTED;
GO
-- Variant: AX 2012 RTM/R2/R3 - baseret på Microsofts dokumenterede skema, ikke verificeret mod kundedata: RecId pr. tabel (SYSTEMSEQUENCES)
-- Kilde: AxSchemaAdapter.RecIds(2012)
-- Kode: src/SQLInsight.Core/DynamicsAx/AxSchemaAdapter.cs : AxSchemaAdapter.RecIds(database) via AxSql.InDatabase
EXEC [AXDB].sys.sp_executesql N'SET NOCOUNT ON; SET LOCK_TIMEOUT 2000; SET TRANSACTION ISOLATION LEVEL READ COMMITTED; SELECT TOP (500) s.[TABID], s.[DATAAREAID], CAST(s.[NEXTVAL] AS bigint), CAST(s.[MAXVAL] AS bigint), (SELECT TOP 1 d.[NAME] FROM [dbo].[SQLDICTIONARY] d WHERE d.[TABLEID] = s.[TABID] AND d.[FIELDID] = 0) FROM [dbo].[SYSTEMSEQUENCES] s WHERE s.[NAME] = N''SEQNO'' ORDER BY s.[NEXTVAL] DESC';
SET LOCK_TIMEOUT 5000; SET TRANSACTION ISOLATION LEVEL READ UNCOMMITTED;
GO

168
SQL Scripts/SQLI-AX-010.sql Normal file
View file

@ -0,0 +1,168 @@
-- ===================================================================================================================
-- SQLI-AX-010: AX-bruger blokerer andre
-- ===================================================================================================================
-- Regel-id: SQLI-AX-010
-- Titel: AX-bruger blokerer andre
-- Alvor: Medium; High ved ventetid over 5 minutter eller mindst 10 blokerede sessioner.
-- Versioner: Blokering: SQL Server 2000 og nyere; AX-genkendelsen og dermed reglen: SQL Server 2005 og nyere.
-- Axapta 3.0/4.0: AX-brugeren kan ikke ses. AX 2009/2012/F&O: baseret på Microsofts dokumentation - ikke
-- verificeret mod kundedata.
--
-- Hvad tjekkes, og hvorfor:
-- En session i en Dynamics AX-database holder låse, som andre AX-brugere eller batchjob venter på. Alle AX-brugere går
-- gennem AOS'ens login, så SQL Server-login'et siger ikke, hvem det er. Reglen viser AX-brugeren, når SQL Server kan
-- se den.
--
-- Vurdering (sker i C# ud fra de indsamlede data, ikke i SQL'en, medmindre andet er nævnt):
-- For hver head blocker, hvis egen database eller en blokeret sessions database er genkendt som AX: fund, når den
-- længste ventetid i kæden er over grænsen. AX 2009/2012/F&O: AX-brugeren og AX-sessionen læses fra CONTEXT_INFO,
-- som AOS'en sætter, når "connectioncontext" er slået til i AOS-konfigurationen (formatet er dokumenteret af
-- Microsoft for AX 2012 - ikke verificeret mod kundedata). Axapta 3.0 og AX 4.0 sætter aldrig CONTEXT_INFO:
-- brugeren kan ikke ses fra SQL Server, og fundet siger det ærligt. AX-bruger, vært, program og CONTEXT_INFO er
-- følsomme målinger (sendes ikke til SQLInsight Hub uden SendSensitiveEvidence). Brugeren findes af
-- src/SQLInsight.Core/DynamicsAx/AxSessionMapper.cs. AX-tabeller læses ikke (og aldrig med NOLOCK).
--
-- Grænseværdier (konfigurationsnøgle i agentens appsettings.json og standardværdi):
-- SQLInsight:DynamicsAx:BlockingSeconds = 30: den længste ventetid bag sessionen skal være mindst så mange sekunder.
-- High fra 300.000 ms eller 10 blokerede (fast i koden, som SQLI-BLK-001).
--
-- Rettigheder til agentens login (se db/permissions/grant-least-privilege.sql og grant-dynamics-ax-read.sql):
-- Ingen AX-læseadgang: kun grant-least-privilege.sql.
-- GRANT VIEW SERVER STATE TO [agent]; -- SQL Server 2005-2019 (sessionerne)
-- GRANT VIEW SERVER PERFORMANCE STATE TO [agent]; -- SQL Server 2022+
-- GRANT VIEW ANY DEFINITION TO [agent]; -- AX-genkendelsen (metadata)
-- GRANT CONNECT ANY DATABASE TO [agent]; -- SQL Server 2014+; før 2014 en databasebruger i AX-databasen
--
-- Data kommer fra (collector, fil : medlemmer, interval):
-- AxDatabaseDetector (den fælles AX-genkendelse)
-- Fil: src/SQLInsight.Collectors/DynamicsAx/AxDatabaseDetector.cs
-- Medlemmer: AxDatabaseDetector.DatabasesSql, AxDatabaseDetector.BuildDetectionSql(database)
-- Interval: hver time pr. database (hvert 5. minut, mens AX-læseadgangen mangler), delt af alle AX-collectors
-- Delt script: SQL Scripts/_collectors/AxDatabaseDetector.sql
-- BlockingCollector
-- Fil: src/SQLInsight.Collectors/Concurrency/BlockingCollector.cs
-- Medlemmer: BlockingCollector.BuildSql2005(version), BlockingCollector.Sql2000
-- Interval: 1 min som standard (SQLInsight:CollectorIntervals:blocking)
-- Delt script: SQL Scripts/_collectors/BlockingCollector.sql
-- Regelkode: src/SQLInsight.Rules/DynamicsAx/DynamicsAxRules.cs : AxBlockingUserRule
--
-- Read-only: Ja: kun SELECT med TOP og SET-sessionsindstillinger. AX-tabellerne læses i AX-databasen via
-- [db].sys.sp_executesql (sessionen skifter aldrig database) under READ COMMITTED med SET LOCK_TIMEOUT 2000
-- (SNAPSHOT, når databasen tillader det), aldrig NOLOCK; bagefter sættes agentens sessionsindstillinger
-- igen. Intet ændrer data, indstillinger eller objekter. Kun i databaser genkendt som AX og valgt til
-- overvågning.
-- Kørsel: Kør i SSMS som agentens login (eller et login med samme rettigheder). Hver variant er sin egen batch
-- (GO); varianter for andre AX-versioner end din fejler eller giver ingen rækker - det er forventet.
-- AX-forespørgslerne er vist for databasen AXDB: ret [AXDB] til din AX-database.
-- ===================================================================================================================
-- -------------------------------------------------------------------------------------------------------------------
-- Forbindelsen: Sessionsindstillinger, agenten sætter på hver forbindelse
-- -------------------------------------------------------------------------------------------------------------------
-- Variant: Alle versioner: sættes på hver forbindelse, agenten åbner
-- Kilde: SqlConnectionFactory.SessionSettings
-- Kode: src/SQLInsight.Connectivity/SqlConnectionFactory.cs : SqlConnectionFactory.SessionSettings
SET LOCK_TIMEOUT 5000; SET DEADLOCK_PRIORITY LOW; SET TRANSACTION ISOLATION LEVEL READ UNCOMMITTED;
GO
-- -------------------------------------------------------------------------------------------------------------------
-- AxDatabaseDetector: Hvilke overvågede databaser er Dynamics AX (kun metadata)
-- -------------------------------------------------------------------------------------------------------------------
-- Variant: Alle versioner fra SQL Server 2005: online brugerdatabaser og deres snapshot-indstillinger
-- Kilde: AxDatabaseDetector.DatabasesSql
-- Kode: src/SQLInsight.Collectors/DynamicsAx/AxDatabaseDetector.cs : AxDatabaseDetector.DatabasesSql
SELECT name, snapshot_isolation_state, is_read_committed_snapshot_on
FROM sys.databases
WHERE database_id > 4 AND state = 0 AND source_database_id IS NULL AND HAS_DBACCESS(name) = 1
ORDER BY name
GO
-- Variant: SQL Server 2005 og nyere: AX-genkendelsen i hver overvåget database (kun metadata; vist for AXDB)
-- Kilde: AxDatabaseDetector.BuildDetectionSql
-- Kode: src/SQLInsight.Collectors/DynamicsAx/AxDatabaseDetector.cs : AxDatabaseDetector.BuildDetectionSql(database) = AxSql.InDatabase(database, AxDetection.MetadataSql)
-- Agenten kører dette i hver overvåget database fra listen ovenfor (ikke *_model). Ret [AXDB] til den database, du vil tjekke.
EXEC [AXDB].sys.sp_executesql N'SET NOCOUNT ON; SET LOCK_TIMEOUT 2000; SET TRANSACTION ISOLATION LEVEL READ COMMITTED; SELECT s.name, UPPER(t.name), UPPER(c.name), TYPE_NAME(c.system_type_id), c.precision, c.max_length,
ISNULL(HAS_PERMS_BY_NAME(QUOTENAME(s.name) + N''.'' + QUOTENAME(t.name), N''OBJECT'', N''SELECT''), 0),
(SELECT TOP 1 p.name FROM sys.database_principals p WHERE p.sid = SUSER_SID())
FROM sys.tables t
JOIN sys.schemas s ON s.schema_id = t.schema_id
JOIN sys.columns c ON c.object_id = t.object_id
WHERE UPPER(t.name) IN (N''SQLDICTIONARY'', N''DATAAREA'', N''SYSCONFIG'', N''BATCH'', N''BATCHJOB'', N''BATCHJOBHISTORY'', N''BATCHGROUP'', N''NUMBERSEQUENCETABLE'', N''NUMBERSEQUENCESCOPE'', N''NUMBERSEQUENCEREFERENCE'', N''SYSTEMSEQUENCES'', N''MODELELEMENT'')
ORDER BY s.name, t.name, c.column_id';
SET LOCK_TIMEOUT 5000; SET TRANSACTION ISOLATION LEVEL READ UNCOMMITTED;
GO
-- -------------------------------------------------------------------------------------------------------------------
-- BlockingCollector: Blokerede sessioner og deres blokerere lige nu, med sætning, åbne transaktioner og CONTEXT_INFO.
-- -------------------------------------------------------------------------------------------------------------------
-- Variant: SQL Server 2012 og nyere
-- Kilde: BlockingCollector.BuildSql2005(2012+)
-- Kode: src/SQLInsight.Collectors/Concurrency/BlockingCollector.cs : BlockingCollector.BuildSql2005(version)
SELECT s.session_id, NULLIF(r.blocking_session_id, 0), COALESCE(r.status, s.status), r.wait_type, r.wait_time, r.wait_resource,
DB_NAME(COALESCE(r.database_id, s.database_id)), s.login_name, s.host_name, s.program_name, s.open_transaction_count,
LEFT(CASE WHEN r.sql_handle IS NOT NULL AND r.statement_start_offset IS NOT NULL
THEN SUBSTRING(t.text, r.statement_start_offset / 2 + 1,
(CASE WHEN r.statement_end_offset = -1 THEN DATALENGTH(t.text) ELSE r.statement_end_offset END
- r.statement_start_offset) / 2 + 1)
ELSE t.text END, 4000),
s.last_request_end_time, s.context_info
FROM sys.dm_exec_sessions s
LEFT JOIN sys.dm_exec_requests r ON r.session_id = s.session_id
LEFT JOIN sys.dm_exec_connections c ON c.session_id = s.session_id
OUTER APPLY sys.dm_exec_sql_text(COALESCE(r.sql_handle, c.most_recent_sql_handle)) t
WHERE r.blocking_session_id > 0
OR s.session_id IN (SELECT blocking_session_id FROM sys.dm_exec_requests WHERE blocking_session_id > 0)
GO
-- Variant: SQL Server 2008 – 2008 R2
-- Kilde: BlockingCollector.BuildSql2005(2008)
-- Kode: src/SQLInsight.Collectors/Concurrency/BlockingCollector.cs : BlockingCollector.BuildSql2005(version)
SELECT s.session_id, NULLIF(r.blocking_session_id, 0), COALESCE(r.status, s.status), r.wait_type, r.wait_time, r.wait_resource,
DB_NAME(r.database_id), s.login_name, s.host_name, s.program_name, (SELECT COUNT(*) FROM sys.dm_tran_session_transactions st WHERE st.session_id = s.session_id),
LEFT(CASE WHEN r.sql_handle IS NOT NULL AND r.statement_start_offset IS NOT NULL
THEN SUBSTRING(t.text, r.statement_start_offset / 2 + 1,
(CASE WHEN r.statement_end_offset = -1 THEN DATALENGTH(t.text) ELSE r.statement_end_offset END
- r.statement_start_offset) / 2 + 1)
ELSE t.text END, 4000),
s.last_request_end_time, s.context_info
FROM sys.dm_exec_sessions s
LEFT JOIN sys.dm_exec_requests r ON r.session_id = s.session_id
LEFT JOIN sys.dm_exec_connections c ON c.session_id = s.session_id
OUTER APPLY sys.dm_exec_sql_text(COALESCE(r.sql_handle, c.most_recent_sql_handle)) t
WHERE r.blocking_session_id > 0
OR s.session_id IN (SELECT blocking_session_id FROM sys.dm_exec_requests WHERE blocking_session_id > 0)
GO
-- Variant: SQL Server 2005 (CONTEXT_INFO fra sys.sysprocesses)
-- Kilde: BlockingCollector.BuildSql2005(2005)
-- Kode: src/SQLInsight.Collectors/Concurrency/BlockingCollector.cs : BlockingCollector.BuildSql2005(version)
SELECT s.session_id, NULLIF(r.blocking_session_id, 0), COALESCE(r.status, s.status), r.wait_type, r.wait_time, r.wait_resource,
DB_NAME(r.database_id), s.login_name, s.host_name, s.program_name, (SELECT COUNT(*) FROM sys.dm_tran_session_transactions st WHERE st.session_id = s.session_id),
LEFT(CASE WHEN r.sql_handle IS NOT NULL AND r.statement_start_offset IS NOT NULL
THEN SUBSTRING(t.text, r.statement_start_offset / 2 + 1,
(CASE WHEN r.statement_end_offset = -1 THEN DATALENGTH(t.text) ELSE r.statement_end_offset END
- r.statement_start_offset) / 2 + 1)
ELSE t.text END, 4000),
s.last_request_end_time, (SELECT TOP 1 p.context_info FROM sys.sysprocesses p WHERE p.spid = s.session_id AND p.ecid = 0)
FROM sys.dm_exec_sessions s
LEFT JOIN sys.dm_exec_requests r ON r.session_id = s.session_id
LEFT JOIN sys.dm_exec_connections c ON c.session_id = s.session_id
OUTER APPLY sys.dm_exec_sql_text(COALESCE(r.sql_handle, c.most_recent_sql_handle)) t
WHERE r.blocking_session_id > 0
OR s.session_id IN (SELECT blocking_session_id FROM sys.dm_exec_requests WHERE blocking_session_id > 0)
GO
-- Variant: SQL Server 2000 (legacy ODBC-sti)
-- Kilde: BlockingCollector.Sql2000
-- Kode: src/SQLInsight.Collectors/Concurrency/BlockingCollector.cs : BlockingCollector.Sql2000
SELECT p.spid, NULLIF(CASE WHEN p.blocked = p.spid THEN 0 ELSE p.blocked END, 0), RTRIM(p.status), RTRIM(p.lastwaittype),
p.waittime, RTRIM(p.waitresource), DB_NAME(p.dbid), RTRIM(p.loginame), RTRIM(p.hostname), RTRIM(p.program_name),
p.open_tran, CAST(NULL AS nvarchar(4000)), p.last_batch, p.context_info
FROM master.dbo.sysprocesses p
WHERE (p.blocked > 0 AND p.blocked <> p.spid)
OR p.spid IN (SELECT blocked FROM master.dbo.sysprocesses WHERE blocked > 0 AND blocked <> spid)
GO

View file

@ -62,7 +62,7 @@ SELECT s.session_id, NULLIF(r.blocking_session_id, 0), COALESCE(r.status, s.stat
(CASE WHEN r.statement_end_offset = -1 THEN DATALENGTH(t.text) ELSE r.statement_end_offset END
- r.statement_start_offset) / 2 + 1)
ELSE t.text END, 4000),
s.last_request_end_time
s.last_request_end_time, s.context_info
FROM sys.dm_exec_sessions s
LEFT JOIN sys.dm_exec_requests r ON r.session_id = s.session_id
LEFT JOIN sys.dm_exec_connections c ON c.session_id = s.session_id
@ -71,7 +71,26 @@ WHERE r.blocking_session_id > 0
OR s.session_id IN (SELECT blocking_session_id FROM sys.dm_exec_requests WHERE blocking_session_id > 0)
GO
-- Variant: SQL Server 2005 – 2008 R2
-- Variant: SQL Server 2008 – 2008 R2
-- Kilde: BlockingCollector.BuildSql2005(2008)
-- Kode: src/SQLInsight.Collectors/Concurrency/BlockingCollector.cs : BlockingCollector.BuildSql2005(version)
SELECT s.session_id, NULLIF(r.blocking_session_id, 0), COALESCE(r.status, s.status), r.wait_type, r.wait_time, r.wait_resource,
DB_NAME(r.database_id), s.login_name, s.host_name, s.program_name, (SELECT COUNT(*) FROM sys.dm_tran_session_transactions st WHERE st.session_id = s.session_id),
LEFT(CASE WHEN r.sql_handle IS NOT NULL AND r.statement_start_offset IS NOT NULL
THEN SUBSTRING(t.text, r.statement_start_offset / 2 + 1,
(CASE WHEN r.statement_end_offset = -1 THEN DATALENGTH(t.text) ELSE r.statement_end_offset END
- r.statement_start_offset) / 2 + 1)
ELSE t.text END, 4000),
s.last_request_end_time, s.context_info
FROM sys.dm_exec_sessions s
LEFT JOIN sys.dm_exec_requests r ON r.session_id = s.session_id
LEFT JOIN sys.dm_exec_connections c ON c.session_id = s.session_id
OUTER APPLY sys.dm_exec_sql_text(COALESCE(r.sql_handle, c.most_recent_sql_handle)) t
WHERE r.blocking_session_id > 0
OR s.session_id IN (SELECT blocking_session_id FROM sys.dm_exec_requests WHERE blocking_session_id > 0)
GO
-- Variant: SQL Server 2005 (CONTEXT_INFO fra sys.sysprocesses)
-- Kilde: BlockingCollector.BuildSql2005(2005)
-- Kode: src/SQLInsight.Collectors/Concurrency/BlockingCollector.cs : BlockingCollector.BuildSql2005(version)
SELECT s.session_id, NULLIF(r.blocking_session_id, 0), COALESCE(r.status, s.status), r.wait_type, r.wait_time, r.wait_resource,
@ -81,7 +100,7 @@ SELECT s.session_id, NULLIF(r.blocking_session_id, 0), COALESCE(r.status, s.stat
(CASE WHEN r.statement_end_offset = -1 THEN DATALENGTH(t.text) ELSE r.statement_end_offset END
- r.statement_start_offset) / 2 + 1)
ELSE t.text END, 4000),
s.last_request_end_time
s.last_request_end_time, (SELECT TOP 1 p.context_info FROM sys.sysprocesses p WHERE p.spid = s.session_id AND p.ecid = 0)
FROM sys.dm_exec_sessions s
LEFT JOIN sys.dm_exec_requests r ON r.session_id = s.session_id
LEFT JOIN sys.dm_exec_connections c ON c.session_id = s.session_id
@ -95,7 +114,7 @@ GO
-- Kode: src/SQLInsight.Collectors/Concurrency/BlockingCollector.cs : BlockingCollector.Sql2000
SELECT p.spid, NULLIF(CASE WHEN p.blocked = p.spid THEN 0 ELSE p.blocked END, 0), RTRIM(p.status), RTRIM(p.lastwaittype),
p.waittime, RTRIM(p.waitresource), DB_NAME(p.dbid), RTRIM(p.loginame), RTRIM(p.hostname), RTRIM(p.program_name),
p.open_tran, CAST(NULL AS nvarchar(4000)), p.last_batch
p.open_tran, CAST(NULL AS nvarchar(4000)), p.last_batch, p.context_info
FROM master.dbo.sysprocesses p
WHERE (p.blocked > 0 AND p.blocked <> p.spid)
OR p.spid IN (SELECT blocked FROM master.dbo.sysprocesses WHERE blocked > 0 AND blocked <> spid)

View file

@ -31,11 +31,8 @@
-- Delt script: SQL Scripts/_collectors/DeadlockCollector.sql
-- Regelkode: src/SQLInsight.Rules/Concurrency/ConcurrencyRules.cs : DeadlockRule.Evaluate
--
-- Read-only: IKKE STRENGT READ-ONLY. DeadlockCollector.BuildFileSql (SQL Server 2012+) opretter en midlertidig
-- tabel i tempdb (SELECT ... INTO #system_health) og sletter den igen (DROP TABLE #system_health) i
-- samme batch. Den er privat for sessionen; ingen brugerdata, indstillinger eller permanente objekter
-- ændres, men det er en skrivning i tempdb. Ring buffer-varianten (2008/2008 R2) er read-only. Øvrigt:
-- kun SELECT samt SET-sessionsindstillinger.
-- Read-only: Ja: kun SELECT (sys.fn_xe_file_target_read_file og DMV'er) samt SET-sessionsindstillinger. Ingen
-- temp-tabeller; læsepositionen findes med ROW_NUMBER() i samme forespørgsel.
-- Kørsel: Kør i SSMS som agentens login (eller et login med samme rettigheder). Hver variant er sin egen batch
-- (GO); en variant for en anden SQL Server-version end din kan fejle - det er forventet.
-- Per-database-forespørgsler er vist for databasen msdb, som findes overalt: ret navnet til den
@ -59,14 +56,14 @@ GO
-- Variant: SQL Server 2012 og nyere (system_health-filer; første læsning)
-- Kilde: DeadlockCollector.BuildFileSql
-- Kode: src/SQLInsight.Collectors/Concurrency/DeadlockCollector.cs : DeadlockCollector.BuildFileSql(lastFile: null, lastOffset: null)
SELECT object_name, file_name, file_offset,
CASE WHEN object_name = N'xml_deadlock_report' THEN CAST(event_data AS nvarchar(max)) END AS event_data
INTO #system_health
FROM sys.fn_xe_file_target_read_file(N'system_health*.xel', NULL, NULL, NULL);
SELECT event_data FROM #system_health WHERE event_data IS NOT NULL;
SELECT TOP (1) file_name, file_offset FROM #system_health ORDER BY file_name DESC, file_offset DESC;
DROP TABLE #system_health;
SELECT e.event_data, e.file_name, e.file_offset, e.rn
FROM (
SELECT CASE WHEN object_name = N'xml_deadlock_report' THEN CAST(event_data AS nvarchar(max)) END AS event_data,
file_name, file_offset,
ROW_NUMBER() OVER (ORDER BY file_name DESC, file_offset DESC) AS rn
FROM sys.fn_xe_file_target_read_file(N'system_health*.xel', NULL, NULL, NULL)
) AS e
WHERE e.event_data IS NOT NULL OR e.rn = 1;
GO
-- Variant: SQL Server 2008 – 2008 R2, og når event-filerne ikke kan læses

View file

@ -0,0 +1,98 @@
-- ===================================================================================================================
-- Collector: AxBatchCollector ("dynamics-ax-batch")
-- ===================================================================================================================
-- Hvad: Hvert 5. minut, i hver genkendt og overvåget AX-database: batchjob (BATCH i Axapta 3.0/4.0,
-- BATCHJOB fra AX 2009) og - fra AX 2009 - de afsluttede kørsler i BATCHJOBHISTORY efter seneste læste
-- RECID (minus et overlap, fordi AOS'er får RecId i blokke). Axapta 3.0/4.0 har ingen historiktabel; der
-- gemmer SQLInsight de kørsler, den selv ser blive afsluttet. Kørslerne gemmes i SQLInsights repository
-- (insight.AxBatchRuns) - aldrig i AX.
-- Fil: src/SQLInsight.Collectors/DynamicsAx/AxBatchCollector.cs; SQL: src/SQLInsight.Core/DynamicsAx/AxSchemaAdapter.cs
-- Interval: 5 min som standard (SQLInsight:CollectorIntervals:dynamics-ax-batch)
-- Versioner: SQL Server 2005 og nyere (ikke 2000). Axapta 3.0: verificeret mod en kundes skema (docs/dynamics-ax/
-- schema-ax30.tsv). AX 4.0, AX 2009, AX 2012 og F&O: baseret på Microsofts dokumenterede skema - ikke
-- verificeret mod kundedata.
--
-- Rettigheder til agentens login (se db/permissions/grant-least-privilege.sql og grant-dynamics-ax-read.sql):
-- Valgfri AX-læseadgang, kun SELECT (db/permissions/grant-dynamics-ax-read.sql; [bruger] = agentens databasebruger):
-- Axapta 3.0: GRANT SELECT ON [bmssa].[BATCH] TO [bruger];
-- AX 4.0: GRANT SELECT ON [dbo].[BATCH] TO [bruger];
-- AX 2009/2012/F&O: GRANT SELECT ON [dbo].[BATCHJOB] TO [bruger];
-- GRANT SELECT ON [dbo].[BATCHJOBHISTORY] TO [bruger];
-- Mangler den, er reglen "ikke målbar" med den nøjagtige GRANT for databasen (også under Dynamics AX → AX-databaser
-- og GET /api/v1/dynamics-ax/grant-script). Uden GRANT ændres intet.
-- Genkendelsen: kun grant-least-privilege.sql (VIEW ANY DEFINITION; CONNECT ANY DATABASE fra 2014, ellers en
-- databasebruger: CREATE USER [agent] FOR LOGIN [agent];).
--
-- Bruges af: SQLI-AX-001, SQLI-AX-002, SQLI-AX-003, SQLI-AX-004 og Dynamics AX → Batchjob
-- Read-only: Ja: kun SELECT med TOP og SET-sessionsindstillinger. AX-tabellerne læses i AX-databasen via
-- [db].sys.sp_executesql (sessionen skifter aldrig database) under READ COMMITTED med SET LOCK_TIMEOUT 2000
-- (SNAPSHOT, når databasen tillader det), aldrig NOLOCK; bagefter sættes agentens sessionsindstillinger
-- igen. Intet ændrer data, indstillinger eller objekter. Kun i databaser genkendt som AX og valgt til
-- overvågning.
-- Kørsel: Kør i SSMS som agentens login (eller et login med samme rettigheder). Hver variant er sin egen batch
-- (GO); varianter for andre AX-versioner end din fejler eller giver ingen rækker - det er forventet.
-- AX-forespørgslerne er vist for databasen AXDB: ret [AXDB] til din AX-database.
-- ===================================================================================================================
-- -------------------------------------------------------------------------------------------------------------------
-- AxBatchCollector: Batchjob og kørselshistorik i AX-databasen (valgfri AX-læseadgang)
-- -------------------------------------------------------------------------------------------------------------------
-- Variant: Axapta 3.0 (skema bmssa, verificeret mod kundedata): batchjob
-- Kilde: AxSchemaAdapter.BatchJobs(3.0)
-- Kode: src/SQLInsight.Core/DynamicsAx/AxSchemaAdapter.cs : AxSchemaAdapter.BatchJobs(database) via AxSql.InDatabase
EXEC [AXDB].sys.sp_executesql N'SET NOCOUNT ON; SET LOCK_TIMEOUT 2000; SET TRANSACTION ISOLATION LEVEL READ COMMITTED; SELECT TOP (1000) CAST(b.[RECID] AS bigint), b.[CLASSNUM], CAST(NULL AS nvarchar(60)), b.[COMPANY], b.[GROUPID], b.[STATUS], b.[STARTDATE], b.[STARTTIME], CAST(NULL AS datetime), CAST(NULL AS int), b.[ENDDATE], b.[ENDTIME], DATEDIFF(minute, GETDATE(), GETUTCDATE()) FROM [bmssa].[BATCH] b ORDER BY b.[RECID] DESC';
SET LOCK_TIMEOUT 5000; SET TRANSACTION ISOLATION LEVEL READ UNCOMMITTED;
GO
-- Variant: AX 4.0 (skema dbo) - ikke verificeret mod kundedata: batchjob
-- Kilde: AxSchemaAdapter.BatchJobs(4.0)
-- Kode: src/SQLInsight.Core/DynamicsAx/AxSchemaAdapter.cs : AxSchemaAdapter.BatchJobs(database) via AxSql.InDatabase
EXEC [AXDB].sys.sp_executesql N'SET NOCOUNT ON; SET LOCK_TIMEOUT 2000; SET TRANSACTION ISOLATION LEVEL READ COMMITTED; SELECT TOP (1000) CAST(b.[RECID] AS bigint), b.[CLASSNUM], CAST(NULL AS nvarchar(60)), b.[COMPANY], b.[GROUPID], b.[STATUS], b.[STARTDATE], b.[STARTTIME], CAST(NULL AS datetime), CAST(NULL AS int), b.[ENDDATE], b.[ENDTIME], DATEDIFF(minute, GETDATE(), GETUTCDATE()) FROM [dbo].[BATCH] b ORDER BY b.[RECID] DESC';
SET LOCK_TIMEOUT 5000; SET TRANSACTION ISOLATION LEVEL READ UNCOMMITTED;
GO
-- Variant: AX 2009 - baseret på Microsofts dokumenterede skema, ikke verificeret mod kundedata: batchjob
-- Kilde: AxSchemaAdapter.BatchJobs(2009)
-- Kode: src/SQLInsight.Core/DynamicsAx/AxSchemaAdapter.cs : AxSchemaAdapter.BatchJobs(database) via AxSql.InDatabase
EXEC [AXDB].sys.sp_executesql N'SET NOCOUNT ON; SET LOCK_TIMEOUT 2000; SET TRANSACTION ISOLATION LEVEL READ COMMITTED; SELECT TOP (1000) CAST(j.[RECID] AS bigint), CAST(NULL AS int), j.[CAPTION], j.[COMPANY], CAST(NULL AS nvarchar(20)), j.[STATUS], j.[ORIGSTARTDATETIME], CAST(NULL AS int), j.[STARTDATETIME], CAST(NULL AS int), j.[ENDDATETIME], CAST(NULL AS int), DATEDIFF(minute, GETDATE(), GETUTCDATE()) FROM [dbo].[BATCHJOB] j ORDER BY j.[RECID] DESC';
SET LOCK_TIMEOUT 5000; SET TRANSACTION ISOLATION LEVEL READ UNCOMMITTED;
GO
-- Variant: AX 2009 - baseret på Microsofts dokumenterede skema, ikke verificeret mod kundedata: kørselshistorik, første læsning (de nyeste)
-- Kilde: AxSchemaAdapter.BatchHistory(2009, første)
-- Kode: src/SQLInsight.Core/DynamicsAx/AxSchemaAdapter.cs : AxSchemaAdapter.BatchHistory(database, afterRecId: null) via AxSql.InDatabase
EXEC [AXDB].sys.sp_executesql N'SET NOCOUNT ON; SET LOCK_TIMEOUT 2000; SET TRANSACTION ISOLATION LEVEL READ COMMITTED; SELECT TOP (1000) CAST(h.[RECID] AS bigint), CAST(h.[BATCHJOBID] AS bigint), h.[CAPTION], h.[COMPANY], h.[STATUS], h.[STARTDATETIME], h.[ENDDATETIME], DATEDIFF(minute, GETDATE(), GETUTCDATE()) FROM [dbo].[BATCHJOBHISTORY] h ORDER BY h.[RECID] DESC';
SET LOCK_TIMEOUT 5000; SET TRANSACTION ISOLATION LEVEL READ UNCOMMITTED;
GO
-- Variant: AX 2009 - baseret på Microsofts dokumenterede skema, ikke verificeret mod kundedata: kørselshistorik, de næste læsninger (fra den højeste RECID, SQLInsight har læst, minus overlappet)
-- Kilde: AxSchemaAdapter.BatchHistory(2009, næste)
-- Kode: src/SQLInsight.Core/DynamicsAx/AxSchemaAdapter.cs : AxSchemaAdapter.BatchHistory(database, afterRecId) via AxSql.InDatabase
-- Agenten indsætter den højeste RECID, den har læst, minus overlappet, hvor der her står 0.
EXEC [AXDB].sys.sp_executesql N'SET NOCOUNT ON; SET LOCK_TIMEOUT 2000; SET TRANSACTION ISOLATION LEVEL READ COMMITTED; SELECT TOP (3000) CAST(h.[RECID] AS bigint), CAST(h.[BATCHJOBID] AS bigint), h.[CAPTION], h.[COMPANY], h.[STATUS], h.[STARTDATETIME], h.[ENDDATETIME], DATEDIFF(minute, GETDATE(), GETUTCDATE()) FROM [dbo].[BATCHJOBHISTORY] h WHERE h.[RECID] > 0 ORDER BY h.[RECID]';
SET LOCK_TIMEOUT 5000; SET TRANSACTION ISOLATION LEVEL READ UNCOMMITTED;
GO
-- Variant: AX 2012 RTM/R2/R3 - baseret på Microsofts dokumenterede skema, ikke verificeret mod kundedata: batchjob
-- Kilde: AxSchemaAdapter.BatchJobs(2012)
-- Kode: src/SQLInsight.Core/DynamicsAx/AxSchemaAdapter.cs : AxSchemaAdapter.BatchJobs(database) via AxSql.InDatabase
EXEC [AXDB].sys.sp_executesql N'SET NOCOUNT ON; SET LOCK_TIMEOUT 2000; SET TRANSACTION ISOLATION LEVEL READ COMMITTED; SELECT TOP (1000) CAST(j.[RECID] AS bigint), CAST(NULL AS int), j.[CAPTION], j.[COMPANY], CAST(NULL AS nvarchar(20)), j.[STATUS], j.[ORIGSTARTDATETIME], CAST(NULL AS int), j.[STARTDATETIME], CAST(NULL AS int), j.[ENDDATETIME], CAST(NULL AS int), DATEDIFF(minute, GETDATE(), GETUTCDATE()) FROM [dbo].[BATCHJOB] j ORDER BY j.[RECID] DESC';
SET LOCK_TIMEOUT 5000; SET TRANSACTION ISOLATION LEVEL READ UNCOMMITTED;
GO
-- Variant: AX 2012 RTM/R2/R3 - baseret på Microsofts dokumenterede skema, ikke verificeret mod kundedata: kørselshistorik, første læsning (de nyeste)
-- Kilde: AxSchemaAdapter.BatchHistory(2012, første)
-- Kode: src/SQLInsight.Core/DynamicsAx/AxSchemaAdapter.cs : AxSchemaAdapter.BatchHistory(database, afterRecId: null) via AxSql.InDatabase
EXEC [AXDB].sys.sp_executesql N'SET NOCOUNT ON; SET LOCK_TIMEOUT 2000; SET TRANSACTION ISOLATION LEVEL READ COMMITTED; SELECT TOP (1000) CAST(h.[RECID] AS bigint), CAST(h.[BATCHJOBID] AS bigint), h.[CAPTION], h.[COMPANY], h.[STATUS], h.[STARTDATETIME], h.[ENDDATETIME], DATEDIFF(minute, GETDATE(), GETUTCDATE()) FROM [dbo].[BATCHJOBHISTORY] h ORDER BY h.[RECID] DESC';
SET LOCK_TIMEOUT 5000; SET TRANSACTION ISOLATION LEVEL READ UNCOMMITTED;
GO
-- Variant: AX 2012 RTM/R2/R3 - baseret på Microsofts dokumenterede skema, ikke verificeret mod kundedata: kørselshistorik, de næste læsninger (fra den højeste RECID, SQLInsight har læst, minus overlappet)
-- Kilde: AxSchemaAdapter.BatchHistory(2012, næste)
-- Kode: src/SQLInsight.Core/DynamicsAx/AxSchemaAdapter.cs : AxSchemaAdapter.BatchHistory(database, afterRecId) via AxSql.InDatabase
-- Agenten indsætter den højeste RECID, den har læst, minus overlappet, hvor der her står 0.
EXEC [AXDB].sys.sp_executesql N'SET NOCOUNT ON; SET LOCK_TIMEOUT 2000; SET TRANSACTION ISOLATION LEVEL READ COMMITTED; SELECT TOP (3000) CAST(h.[RECID] AS bigint), CAST(h.[BATCHJOBID] AS bigint), h.[CAPTION], h.[COMPANY], h.[STATUS], h.[STARTDATETIME], h.[ENDDATETIME], DATEDIFF(minute, GETDATE(), GETUTCDATE()) FROM [dbo].[BATCHJOBHISTORY] h WHERE h.[RECID] > 0 ORDER BY h.[RECID]';
SET LOCK_TIMEOUT 5000; SET TRANSACTION ISOLATION LEVEL READ UNCOMMITTED;
GO

View file

@ -0,0 +1,89 @@
-- ===================================================================================================================
-- Collector: AxDatabaseDetector ("dynamics-ax", og før hver AX-collector og AX-community-regel)
-- ===================================================================================================================
-- Hvad: Finder de overvågede databaser, der er Dynamics AX: SQLDICTIONARY og DATAAREA i samme skema.
-- Versionen afgøres af skemaet (src/SQLInsight.Core/DynamicsAx/AxDetection.cs : Recognise): RECID int
-- -> Axapta 2.5/3.0 (typisk skema bmssa); bigint uden BATCHJOB -> AX 4.0; BATCHJOB uden
-- NUMBERSEQUENCESCOPE -> AX 2009; NUMBERSEQUENCESCOPE uden PARTITION -> AX 2012 RTM/FP; PARTITION og
-- <db>_model -> AX 2012 R2/R3; PARTITION uden modellager -> Dynamics 365 F&O (formodet). Læser kun
-- metadata (sys.tables, sys.columns, HAS_PERMS_BY_NAME) og - hvis agenten må - firmalisten i DATAAREA.
-- Fil: src/SQLInsight.Collectors/DynamicsAx/AxDatabaseDetector.cs (collector: src/SQLInsight.Collectors/DynamicsAx/DynamicsAxCollector.cs)
-- Interval: 1 time som standard (SQLInsight:CollectorIntervals:dynamics-ax); en AX-database uden AX-læseadgang hvert 5. minut
-- Versioner: SQL Server 2005 og nyere (ikke 2000). Axapta 3.0: verificeret mod en kundes skema (docs/dynamics-ax/
-- schema-ax30.tsv). AX 4.0, AX 2009, AX 2012 og F&O: baseret på Microsofts dokumenterede skema - ikke
-- verificeret mod kundedata.
--
-- Rettigheder til agentens login (se db/permissions/grant-least-privilege.sql og grant-dynamics-ax-read.sql):
-- Genkendelsen: kun grant-least-privilege.sql (VIEW ANY DEFINITION; CONNECT ANY DATABASE fra 2014, ellers en
-- databasebruger). Firmalisten: valgfri AX-læseadgang, GRANT SELECT ON [bmssa].[DATAAREA] TO [bruger];
-- ([dbo] efter Axapta 3.0).
--
-- Bruges af: SQLI-AX-001..010, COM-AX-001..007, AxBatchCollector, AxSequenceCollector og siderne under Dynamics AX
-- Read-only: Ja: kun SELECT med TOP og SET-sessionsindstillinger. AX-tabellerne læses i AX-databasen via
-- [db].sys.sp_executesql (sessionen skifter aldrig database) under READ COMMITTED med SET LOCK_TIMEOUT 2000
-- (SNAPSHOT, når databasen tillader det), aldrig NOLOCK; bagefter sættes agentens sessionsindstillinger
-- igen. Intet ændrer data, indstillinger eller objekter. Kun i databaser genkendt som AX og valgt til
-- overvågning.
-- Kørsel: Kør i SSMS som agentens login (eller et login med samme rettigheder). Hver variant er sin egen batch
-- (GO); varianter for andre AX-versioner end din fejler eller giver ingen rækker - det er forventet.
-- AX-forespørgslerne er vist for databasen AXDB: ret [AXDB] til din AX-database.
-- ===================================================================================================================
-- Variant: Alle versioner fra SQL Server 2005: online brugerdatabaser og deres snapshot-indstillinger
-- Kilde: AxDatabaseDetector.DatabasesSql
-- Kode: src/SQLInsight.Collectors/DynamicsAx/AxDatabaseDetector.cs : AxDatabaseDetector.DatabasesSql
SELECT name, snapshot_isolation_state, is_read_committed_snapshot_on
FROM sys.databases
WHERE database_id > 4 AND state = 0 AND source_database_id IS NULL AND HAS_DBACCESS(name) = 1
ORDER BY name
GO
-- Variant: Agentens login, til GRANT-teksten, når AX-læseadgangen mangler
-- Kilde: AxDatabaseDetector.LoginSql
-- Kode: src/SQLInsight.Collectors/DynamicsAx/AxDatabaseDetector.cs : AxDatabaseDetector.LoginSql
SELECT SUSER_SNAME()
GO
-- Variant: SQL Server 2005 og nyere: AX-genkendelsen i hver overvåget database (kun metadata; vist for AXDB)
-- Kilde: AxDatabaseDetector.BuildDetectionSql
-- Kode: src/SQLInsight.Collectors/DynamicsAx/AxDatabaseDetector.cs : AxDatabaseDetector.BuildDetectionSql(database) = AxSql.InDatabase(database, AxDetection.MetadataSql)
-- Agenten kører dette i hver overvåget database fra listen ovenfor (ikke *_model). Ret [AXDB] til den database, du vil tjekke.
EXEC [AXDB].sys.sp_executesql N'SET NOCOUNT ON; SET LOCK_TIMEOUT 2000; SET TRANSACTION ISOLATION LEVEL READ COMMITTED; SELECT s.name, UPPER(t.name), UPPER(c.name), TYPE_NAME(c.system_type_id), c.precision, c.max_length,
ISNULL(HAS_PERMS_BY_NAME(QUOTENAME(s.name) + N''.'' + QUOTENAME(t.name), N''OBJECT'', N''SELECT''), 0),
(SELECT TOP 1 p.name FROM sys.database_principals p WHERE p.sid = SUSER_SID())
FROM sys.tables t
JOIN sys.schemas s ON s.schema_id = t.schema_id
JOIN sys.columns c ON c.object_id = t.object_id
WHERE UPPER(t.name) IN (N''SQLDICTIONARY'', N''DATAAREA'', N''SYSCONFIG'', N''BATCH'', N''BATCHJOB'', N''BATCHJOBHISTORY'', N''BATCHGROUP'', N''NUMBERSEQUENCETABLE'', N''NUMBERSEQUENCESCOPE'', N''NUMBERSEQUENCEREFERENCE'', N''SYSTEMSEQUENCES'', N''MODELELEMENT'')
ORDER BY s.name, t.name, c.column_id';
SET LOCK_TIMEOUT 5000; SET TRANSACTION ISOLATION LEVEL READ UNCOMMITTED;
GO
-- Variant: Axapta 3.0 (skema bmssa, verificeret mod kundedata): firmaerne (DATAAREA), kun med AX-læseadgang
-- Kilde: AxSchemaAdapter.Companies(3.0)
-- Kode: src/SQLInsight.Core/DynamicsAx/AxSchemaAdapter.cs : AxSchemaAdapter.Companies(database) via AxSql.InDatabase
EXEC [AXDB].sys.sp_executesql N'SET NOCOUNT ON; SET LOCK_TIMEOUT 2000; SET TRANSACTION ISOLATION LEVEL READ COMMITTED; SELECT TOP (500) d.[ID], d.[NAME], d.[ISVIRTUAL] FROM [bmssa].[DATAAREA] d ORDER BY d.[ID]';
SET LOCK_TIMEOUT 5000; SET TRANSACTION ISOLATION LEVEL READ UNCOMMITTED;
GO
-- Variant: AX 4.0 (skema dbo) - ikke verificeret mod kundedata: firmaerne (DATAAREA), kun med AX-læseadgang
-- Kilde: AxSchemaAdapter.Companies(4.0)
-- Kode: src/SQLInsight.Core/DynamicsAx/AxSchemaAdapter.cs : AxSchemaAdapter.Companies(database) via AxSql.InDatabase
EXEC [AXDB].sys.sp_executesql N'SET NOCOUNT ON; SET LOCK_TIMEOUT 2000; SET TRANSACTION ISOLATION LEVEL READ COMMITTED; SELECT TOP (500) d.[ID], d.[NAME], d.[ISVIRTUAL] FROM [dbo].[DATAAREA] d ORDER BY d.[ID]';
SET LOCK_TIMEOUT 5000; SET TRANSACTION ISOLATION LEVEL READ UNCOMMITTED;
GO
-- Variant: AX 2009 - baseret på Microsofts dokumenterede skema, ikke verificeret mod kundedata: firmaerne (DATAAREA), kun med AX-læseadgang
-- Kilde: AxSchemaAdapter.Companies(2009)
-- Kode: src/SQLInsight.Core/DynamicsAx/AxSchemaAdapter.cs : AxSchemaAdapter.Companies(database) via AxSql.InDatabase
EXEC [AXDB].sys.sp_executesql N'SET NOCOUNT ON; SET LOCK_TIMEOUT 2000; SET TRANSACTION ISOLATION LEVEL READ COMMITTED; SELECT TOP (500) d.[ID], d.[NAME], d.[ISVIRTUAL] FROM [dbo].[DATAAREA] d ORDER BY d.[ID]';
SET LOCK_TIMEOUT 5000; SET TRANSACTION ISOLATION LEVEL READ UNCOMMITTED;
GO
-- Variant: AX 2012 RTM/R2/R3 - baseret på Microsofts dokumenterede skema, ikke verificeret mod kundedata: firmaerne (DATAAREA), kun med AX-læseadgang
-- Kilde: AxSchemaAdapter.Companies(2012)
-- Kode: src/SQLInsight.Core/DynamicsAx/AxSchemaAdapter.cs : AxSchemaAdapter.Companies(database) via AxSql.InDatabase
EXEC [AXDB].sys.sp_executesql N'SET NOCOUNT ON; SET LOCK_TIMEOUT 2000; SET TRANSACTION ISOLATION LEVEL READ COMMITTED; SELECT TOP (500) d.[ID], d.[NAME], d.[ISVIRTUAL] FROM [dbo].[DATAAREA] d ORDER BY d.[ID]';
SET LOCK_TIMEOUT 5000; SET TRANSACTION ISOLATION LEVEL READ UNCOMMITTED;
GO

View file

@ -0,0 +1,105 @@
-- ===================================================================================================================
-- Collector: AxSequenceCollector ("dynamics-ax-sequences")
-- ===================================================================================================================
-- Hvad: Hver time, i hver genkendt og overvåget AX-database: de mest brugte nummerserier
-- (NUMBERSEQUENCETABLE, med firma og om en reference bruger serien) og de højeste RecId-serier
-- (SYSTEMSEQUENCES). SQLInsight gemmer én måling pr. serie pr. dag i sit eget repository
-- (insight.AxSequenceSamples) og laver prognoserne ud fra den - aldrig i AX.
-- Fil: src/SQLInsight.Collectors/DynamicsAx/AxSequenceCollector.cs; SQL: src/SQLInsight.Core/DynamicsAx/AxSchemaAdapter.cs
-- Interval: 1 time som standard (SQLInsight:CollectorIntervals:dynamics-ax-sequences)
-- Versioner: SQL Server 2005 og nyere (ikke 2000). Axapta 3.0: verificeret mod en kundes skema (docs/dynamics-ax/
-- schema-ax30.tsv). AX 4.0, AX 2009, AX 2012 og F&O: baseret på Microsofts dokumenterede skema - ikke
-- verificeret mod kundedata.
--
-- Rettigheder til agentens login (se db/permissions/grant-least-privilege.sql og grant-dynamics-ax-read.sql):
-- Valgfri AX-læseadgang, kun SELECT (db/permissions/grant-dynamics-ax-read.sql; [bruger] = agentens databasebruger):
-- Axapta 3.0: GRANT SELECT ON [bmssa].[NUMBERSEQUENCETABLE] TO [bruger];
-- GRANT SELECT ON [bmssa].[NUMBERSEQUENCEREFERENCE] TO [bruger]; -- valgfri: om serien bruges
-- AX 4.0/2009: de samme tabeller i [dbo]
-- AX 2012/F&O: desuden GRANT SELECT ON [dbo].[NUMBERSEQUENCESCOPE] TO [bruger]; -- valgfri: firma
-- Mangler NUMBERSEQUENCETABLE, er reglen "ikke målbar" med den nøjagtige GRANT for databasen. Uden GRANT ændres intet.
-- Genkendelsen: kun grant-least-privilege.sql (VIEW ANY DEFINITION; CONNECT ANY DATABASE fra 2014, ellers en
-- databasebruger: CREATE USER [agent] FOR LOGIN [agent];).
-- Valgfri AX-læseadgang, kun SELECT (db/permissions/grant-dynamics-ax-read.sql; [bruger] = agentens databasebruger):
-- Axapta 3.0: GRANT SELECT ON [bmssa].[SYSTEMSEQUENCES] TO [bruger];
-- GRANT SELECT ON [bmssa].[SQLDICTIONARY] TO [bruger]; -- valgfri: tabelnavne
-- AX 4.0/2009/2012/F&O: de samme tabeller i [dbo]
-- Mangler SYSTEMSEQUENCES, er reglen "ikke målbar" med den nøjagtige GRANT for databasen. Uden GRANT ændres intet.
-- Genkendelsen: kun grant-least-privilege.sql (VIEW ANY DEFINITION; CONNECT ANY DATABASE fra 2014, ellers en
-- databasebruger: CREATE USER [agent] FOR LOGIN [agent];).
--
-- Bruges af: SQLI-AX-005..009 og Dynamics AX → Nummerserier og RecId
-- Read-only: Ja: kun SELECT med TOP og SET-sessionsindstillinger. AX-tabellerne læses i AX-databasen via
-- [db].sys.sp_executesql (sessionen skifter aldrig database) under READ COMMITTED med SET LOCK_TIMEOUT 2000
-- (SNAPSHOT, når databasen tillader det), aldrig NOLOCK; bagefter sættes agentens sessionsindstillinger
-- igen. Intet ændrer data, indstillinger eller objekter. Kun i databaser genkendt som AX og valgt til
-- overvågning.
-- Kørsel: Kør i SSMS som agentens login (eller et login med samme rettigheder). Hver variant er sin egen batch
-- (GO); varianter for andre AX-versioner end din fejler eller giver ingen rækker - det er forventet.
-- AX-forespørgslerne er vist for databasen AXDB: ret [AXDB] til din AX-database.
-- ===================================================================================================================
-- -------------------------------------------------------------------------------------------------------------------
-- AxSequenceCollector: Nummerserier i AX-databasen (valgfri AX-læseadgang)
-- -------------------------------------------------------------------------------------------------------------------
-- Variant: Axapta 3.0 (skema bmssa, verificeret mod kundedata): nummerserier
-- Kilde: AxSchemaAdapter.NumberSequences(3.0)
-- Kode: src/SQLInsight.Core/DynamicsAx/AxSchemaAdapter.cs : AxSchemaAdapter.NumberSequences(database) via AxSql.InDatabase
EXEC [AXDB].sys.sp_executesql N'SET NOCOUNT ON; SET LOCK_TIMEOUT 2000; SET TRANSACTION ISOLATION LEVEL READ COMMITTED; SELECT TOP (5000) CAST(n.[RECID] AS bigint), n.[NUMBERSEQUENCE], n.[DATAAREAID], n.[TXT], CAST(n.[LOWEST] AS bigint), CAST(n.[HIGHEST] AS bigint), CAST(n.[NEXTREC] AS bigint), n.[FORMAT], ISNULL(n.[BLOCKED], 0), ISNULL(n.[CONTINUOUS], 0), ISNULL(n.[CYCLIC], 0), ISNULL(n.[MANUAL], 0), CASE WHEN EXISTS (SELECT 1 FROM [bmssa].[NUMBERSEQUENCEREFERENCE] r WHERE r.[NUMBERSEQUENCE] = n.[NUMBERSEQUENCE] AND r.[DATAAREAID] = n.[DATAAREAID]) THEN 1 ELSE 0 END, n.[LATESTCLEANDATE], n.[LATESTCLEANTIME], CAST(n.[CLEANINTERVAL] AS float), DATEDIFF(minute, GETDATE(), GETUTCDATE()) FROM [bmssa].[NUMBERSEQUENCETABLE] n ORDER BY (CAST(n.[NEXTREC] AS float) - n.[LOWEST]) / NULLIF(CAST(n.[HIGHEST] AS float) - n.[LOWEST], 0) DESC';
SET LOCK_TIMEOUT 5000; SET TRANSACTION ISOLATION LEVEL READ UNCOMMITTED;
GO
-- Variant: AX 4.0 (skema dbo) - ikke verificeret mod kundedata: nummerserier
-- Kilde: AxSchemaAdapter.NumberSequences(4.0)
-- Kode: src/SQLInsight.Core/DynamicsAx/AxSchemaAdapter.cs : AxSchemaAdapter.NumberSequences(database) via AxSql.InDatabase
EXEC [AXDB].sys.sp_executesql N'SET NOCOUNT ON; SET LOCK_TIMEOUT 2000; SET TRANSACTION ISOLATION LEVEL READ COMMITTED; SELECT TOP (5000) CAST(n.[RECID] AS bigint), n.[NUMBERSEQUENCE], n.[DATAAREAID], n.[TXT], CAST(n.[LOWEST] AS bigint), CAST(n.[HIGHEST] AS bigint), CAST(n.[NEXTREC] AS bigint), n.[FORMAT], ISNULL(n.[BLOCKED], 0), ISNULL(n.[CONTINUOUS], 0), ISNULL(n.[CYCLIC], 0), ISNULL(n.[MANUAL], 0), CASE WHEN EXISTS (SELECT 1 FROM [dbo].[NUMBERSEQUENCEREFERENCE] r WHERE r.[NUMBERSEQUENCE] = n.[NUMBERSEQUENCE] AND r.[DATAAREAID] = n.[DATAAREAID]) THEN 1 ELSE 0 END, n.[LATESTCLEANDATE], n.[LATESTCLEANTIME], CAST(n.[CLEANINTERVAL] AS float), DATEDIFF(minute, GETDATE(), GETUTCDATE()) FROM [dbo].[NUMBERSEQUENCETABLE] n ORDER BY (CAST(n.[NEXTREC] AS float) - n.[LOWEST]) / NULLIF(CAST(n.[HIGHEST] AS float) - n.[LOWEST], 0) DESC';
SET LOCK_TIMEOUT 5000; SET TRANSACTION ISOLATION LEVEL READ UNCOMMITTED;
GO
-- Variant: AX 2009 - baseret på Microsofts dokumenterede skema, ikke verificeret mod kundedata: nummerserier
-- Kilde: AxSchemaAdapter.NumberSequences(2009)
-- Kode: src/SQLInsight.Core/DynamicsAx/AxSchemaAdapter.cs : AxSchemaAdapter.NumberSequences(database) via AxSql.InDatabase
EXEC [AXDB].sys.sp_executesql N'SET NOCOUNT ON; SET LOCK_TIMEOUT 2000; SET TRANSACTION ISOLATION LEVEL READ COMMITTED; SELECT TOP (5000) CAST(n.[RECID] AS bigint), n.[NUMBERSEQUENCE], n.[DATAAREAID], n.[TXT], CAST(n.[LOWEST] AS bigint), CAST(n.[HIGHEST] AS bigint), CAST(n.[NEXTREC] AS bigint), n.[FORMAT], ISNULL(n.[BLOCKED], 0), ISNULL(n.[CONTINUOUS], 0), ISNULL(n.[CYCLIC], 0), ISNULL(n.[MANUAL], 0), CASE WHEN EXISTS (SELECT 1 FROM [dbo].[NUMBERSEQUENCEREFERENCE] r WHERE r.[NUMBERSEQUENCE] = n.[NUMBERSEQUENCE] AND r.[DATAAREAID] = n.[DATAAREAID]) THEN 1 ELSE 0 END, n.[LATESTCLEANDATETIME], CAST(NULL AS int), CAST(n.[CLEANINTERVAL] AS float), DATEDIFF(minute, GETDATE(), GETUTCDATE()) FROM [dbo].[NUMBERSEQUENCETABLE] n ORDER BY (CAST(n.[NEXTREC] AS float) - n.[LOWEST]) / NULLIF(CAST(n.[HIGHEST] AS float) - n.[LOWEST], 0) DESC';
SET LOCK_TIMEOUT 5000; SET TRANSACTION ISOLATION LEVEL READ UNCOMMITTED;
GO
-- Variant: AX 2012 RTM/R2/R3 - baseret på Microsofts dokumenterede skema, ikke verificeret mod kundedata: nummerserier
-- Kilde: AxSchemaAdapter.NumberSequences(2012)
-- Kode: src/SQLInsight.Core/DynamicsAx/AxSchemaAdapter.cs : AxSchemaAdapter.NumberSequences(database) via AxSql.InDatabase
EXEC [AXDB].sys.sp_executesql N'SET NOCOUNT ON; SET LOCK_TIMEOUT 2000; SET TRANSACTION ISOLATION LEVEL READ COMMITTED; SELECT TOP (5000) CAST(n.[RECID] AS bigint), n.[NUMBERSEQUENCE], NULLIF(sc.[DATAAREA], N''''), n.[TXT], CAST(n.[LOWEST] AS bigint), CAST(n.[HIGHEST] AS bigint), CAST(n.[NEXTREC] AS bigint), n.[FORMAT], ISNULL(n.[BLOCKED], 0), ISNULL(n.[CONTINUOUS], 0), ISNULL(n.[CYCLIC], 0), ISNULL(n.[MANUAL], 0), CASE WHEN EXISTS (SELECT 1 FROM [dbo].[NUMBERSEQUENCEREFERENCE] r WHERE r.[NUMBERSEQUENCEID] = n.[RECID]) THEN 1 ELSE 0 END, n.[LATESTCLEANDATETIME], CAST(NULL AS int), CAST(n.[CLEANINTERVAL] AS float), DATEDIFF(minute, GETDATE(), GETUTCDATE()) FROM [dbo].[NUMBERSEQUENCETABLE] n LEFT JOIN [dbo].[NUMBERSEQUENCESCOPE] sc ON sc.[RECID] = n.[NUMBERSEQUENCESCOPE] ORDER BY (CAST(n.[NEXTREC] AS float) - n.[LOWEST]) / NULLIF(CAST(n.[HIGHEST] AS float) - n.[LOWEST], 0) DESC';
SET LOCK_TIMEOUT 5000; SET TRANSACTION ISOLATION LEVEL READ UNCOMMITTED;
GO
-- -------------------------------------------------------------------------------------------------------------------
-- AxSequenceCollector: RecId pr. tabel og firma i AX-databasen (valgfri AX-læseadgang)
-- -------------------------------------------------------------------------------------------------------------------
-- Variant: Axapta 3.0 (skema bmssa, verificeret mod kundedata): RecId pr. tabel (SYSTEMSEQUENCES)
-- Kilde: AxSchemaAdapter.RecIds(3.0)
-- Kode: src/SQLInsight.Core/DynamicsAx/AxSchemaAdapter.cs : AxSchemaAdapter.RecIds(database) via AxSql.InDatabase
EXEC [AXDB].sys.sp_executesql N'SET NOCOUNT ON; SET LOCK_TIMEOUT 2000; SET TRANSACTION ISOLATION LEVEL READ COMMITTED; SELECT TOP (500) s.[TABID], s.[DATAAREAID], CAST(s.[NEXTVAL] AS bigint), CAST(s.[MAXVAL] AS bigint), (SELECT TOP 1 d.[NAME] FROM [bmssa].[SQLDICTIONARY] d WHERE d.[TABLEID] = s.[TABID] AND d.[FIELDID] = 0) FROM [bmssa].[SYSTEMSEQUENCES] s WHERE s.[NAME] = N''SEQNO'' ORDER BY s.[NEXTVAL] DESC';
SET LOCK_TIMEOUT 5000; SET TRANSACTION ISOLATION LEVEL READ UNCOMMITTED;
GO
-- Variant: AX 4.0 (skema dbo) - ikke verificeret mod kundedata: RecId pr. tabel (SYSTEMSEQUENCES)
-- Kilde: AxSchemaAdapter.RecIds(4.0)
-- Kode: src/SQLInsight.Core/DynamicsAx/AxSchemaAdapter.cs : AxSchemaAdapter.RecIds(database) via AxSql.InDatabase
EXEC [AXDB].sys.sp_executesql N'SET NOCOUNT ON; SET LOCK_TIMEOUT 2000; SET TRANSACTION ISOLATION LEVEL READ COMMITTED; SELECT TOP (500) s.[TABID], s.[DATAAREAID], CAST(s.[NEXTVAL] AS bigint), CAST(s.[MAXVAL] AS bigint), (SELECT TOP 1 d.[NAME] FROM [dbo].[SQLDICTIONARY] d WHERE d.[TABLEID] = s.[TABID] AND d.[FIELDID] = 0) FROM [dbo].[SYSTEMSEQUENCES] s WHERE s.[NAME] = N''SEQNO'' ORDER BY s.[NEXTVAL] DESC';
SET LOCK_TIMEOUT 5000; SET TRANSACTION ISOLATION LEVEL READ UNCOMMITTED;
GO
-- Variant: AX 2009 - baseret på Microsofts dokumenterede skema, ikke verificeret mod kundedata: RecId pr. tabel (SYSTEMSEQUENCES)
-- Kilde: AxSchemaAdapter.RecIds(2009)
-- Kode: src/SQLInsight.Core/DynamicsAx/AxSchemaAdapter.cs : AxSchemaAdapter.RecIds(database) via AxSql.InDatabase
EXEC [AXDB].sys.sp_executesql N'SET NOCOUNT ON; SET LOCK_TIMEOUT 2000; SET TRANSACTION ISOLATION LEVEL READ COMMITTED; SELECT TOP (500) s.[TABID], s.[DATAAREAID], CAST(s.[NEXTVAL] AS bigint), CAST(s.[MAXVAL] AS bigint), (SELECT TOP 1 d.[NAME] FROM [dbo].[SQLDICTIONARY] d WHERE d.[TABLEID] = s.[TABID] AND d.[FIELDID] = 0) FROM [dbo].[SYSTEMSEQUENCES] s WHERE s.[NAME] = N''SEQNO'' ORDER BY s.[NEXTVAL] DESC';
SET LOCK_TIMEOUT 5000; SET TRANSACTION ISOLATION LEVEL READ UNCOMMITTED;
GO
-- Variant: AX 2012 RTM/R2/R3 - baseret på Microsofts dokumenterede skema, ikke verificeret mod kundedata: RecId pr. tabel (SYSTEMSEQUENCES)
-- Kilde: AxSchemaAdapter.RecIds(2012)
-- Kode: src/SQLInsight.Core/DynamicsAx/AxSchemaAdapter.cs : AxSchemaAdapter.RecIds(database) via AxSql.InDatabase
EXEC [AXDB].sys.sp_executesql N'SET NOCOUNT ON; SET LOCK_TIMEOUT 2000; SET TRANSACTION ISOLATION LEVEL READ COMMITTED; SELECT TOP (500) s.[TABID], s.[DATAAREAID], CAST(s.[NEXTVAL] AS bigint), CAST(s.[MAXVAL] AS bigint), (SELECT TOP 1 d.[NAME] FROM [dbo].[SQLDICTIONARY] d WHERE d.[TABLEID] = s.[TABID] AND d.[FIELDID] = 0) FROM [dbo].[SYSTEMSEQUENCES] s WHERE s.[NAME] = N''SEQNO'' ORDER BY s.[NEXTVAL] DESC';
SET LOCK_TIMEOUT 5000; SET TRANSACTION ISOLATION LEVEL READ UNCOMMITTED;
GO

View file

@ -1,7 +1,8 @@
-- ===================================================================================================================
-- Collector: BlockingCollector ("blocking")
-- ===================================================================================================================
-- Hvad: Blokerede sessioner og deres blokerere lige nu, med sætning og åbne transaktioner.
-- Hvad: Blokerede sessioner og deres blokerere lige nu, med sætning, åbne transaktioner og CONTEXT_INFO (fra 1.4:
-- Dynamics AX 2009/2012 AOS kan lægge AX-brugeren dér).
-- Fil: src/SQLInsight.Collectors/Concurrency/BlockingCollector.cs
-- Interval: 1 min som standard (SQLInsight:CollectorIntervals:blocking)
-- Versioner: Alle (SQL Server 2000 og nyere)
@ -12,7 +13,7 @@
-- GRANT VIEW SERVER SECURITY STATE TO [agent]; -- SQL Server 2022+
-- SQL Server 2000 har ikke VIEW SERVER STATE; se docs/legacy-versions.md (fx kræver DBCC SQLPERF(WAITSTATS) forhøjede rettigheder).
--
-- Bruges af: SQLI-BLK-001
-- Bruges af: SQLI-BLK-001, SQLI-AX-010
-- Read-only: Ja: kun SELECT samt SET-sessionsindstillinger. Intet ændrer data, indstillinger eller objekter.
-- Kørsel: Kør i SSMS som agentens login. Hver variant er sin egen batch (GO); en variant for en anden SQL Server-version
-- end din kan fejle - det er forventet. Agenten kører altid med sessionsindstillingerne i _collectors/InstanceProbe.sql.
@ -28,7 +29,7 @@ SELECT s.session_id, NULLIF(r.blocking_session_id, 0), COALESCE(r.status, s.stat
(CASE WHEN r.statement_end_offset = -1 THEN DATALENGTH(t.text) ELSE r.statement_end_offset END
- r.statement_start_offset) / 2 + 1)
ELSE t.text END, 4000),
s.last_request_end_time
s.last_request_end_time, s.context_info
FROM sys.dm_exec_sessions s
LEFT JOIN sys.dm_exec_requests r ON r.session_id = s.session_id
LEFT JOIN sys.dm_exec_connections c ON c.session_id = s.session_id
@ -37,7 +38,26 @@ WHERE r.blocking_session_id > 0
OR s.session_id IN (SELECT blocking_session_id FROM sys.dm_exec_requests WHERE blocking_session_id > 0)
GO
-- Variant: SQL Server 2005 – 2008 R2
-- Variant: SQL Server 2008 – 2008 R2
-- Kilde: BlockingCollector.BuildSql2005(2008)
-- Kode: src/SQLInsight.Collectors/Concurrency/BlockingCollector.cs : BlockingCollector.BuildSql2005(version)
SELECT s.session_id, NULLIF(r.blocking_session_id, 0), COALESCE(r.status, s.status), r.wait_type, r.wait_time, r.wait_resource,
DB_NAME(r.database_id), s.login_name, s.host_name, s.program_name, (SELECT COUNT(*) FROM sys.dm_tran_session_transactions st WHERE st.session_id = s.session_id),
LEFT(CASE WHEN r.sql_handle IS NOT NULL AND r.statement_start_offset IS NOT NULL
THEN SUBSTRING(t.text, r.statement_start_offset / 2 + 1,
(CASE WHEN r.statement_end_offset = -1 THEN DATALENGTH(t.text) ELSE r.statement_end_offset END
- r.statement_start_offset) / 2 + 1)
ELSE t.text END, 4000),
s.last_request_end_time, s.context_info
FROM sys.dm_exec_sessions s
LEFT JOIN sys.dm_exec_requests r ON r.session_id = s.session_id
LEFT JOIN sys.dm_exec_connections c ON c.session_id = s.session_id
OUTER APPLY sys.dm_exec_sql_text(COALESCE(r.sql_handle, c.most_recent_sql_handle)) t
WHERE r.blocking_session_id > 0
OR s.session_id IN (SELECT blocking_session_id FROM sys.dm_exec_requests WHERE blocking_session_id > 0)
GO
-- Variant: SQL Server 2005 (CONTEXT_INFO fra sys.sysprocesses)
-- Kilde: BlockingCollector.BuildSql2005(2005)
-- Kode: src/SQLInsight.Collectors/Concurrency/BlockingCollector.cs : BlockingCollector.BuildSql2005(version)
SELECT s.session_id, NULLIF(r.blocking_session_id, 0), COALESCE(r.status, s.status), r.wait_type, r.wait_time, r.wait_resource,
@ -47,7 +67,7 @@ SELECT s.session_id, NULLIF(r.blocking_session_id, 0), COALESCE(r.status, s.stat
(CASE WHEN r.statement_end_offset = -1 THEN DATALENGTH(t.text) ELSE r.statement_end_offset END
- r.statement_start_offset) / 2 + 1)
ELSE t.text END, 4000),
s.last_request_end_time
s.last_request_end_time, (SELECT TOP 1 p.context_info FROM sys.sysprocesses p WHERE p.spid = s.session_id AND p.ecid = 0)
FROM sys.dm_exec_sessions s
LEFT JOIN sys.dm_exec_requests r ON r.session_id = s.session_id
LEFT JOIN sys.dm_exec_connections c ON c.session_id = s.session_id
@ -61,7 +81,7 @@ GO
-- Kode: src/SQLInsight.Collectors/Concurrency/BlockingCollector.cs : BlockingCollector.Sql2000
SELECT p.spid, NULLIF(CASE WHEN p.blocked = p.spid THEN 0 ELSE p.blocked END, 0), RTRIM(p.status), RTRIM(p.lastwaittype),
p.waittime, RTRIM(p.waitresource), DB_NAME(p.dbid), RTRIM(p.loginame), RTRIM(p.hostname), RTRIM(p.program_name),
p.open_tran, CAST(NULL AS nvarchar(4000)), p.last_batch
p.open_tran, CAST(NULL AS nvarchar(4000)), p.last_batch, p.context_info
FROM master.dbo.sysprocesses p
WHERE (p.blocked > 0 AND p.blocked <> p.spid)
OR p.spid IN (SELECT blocked FROM master.dbo.sysprocesses WHERE blocked > 0 AND blocked <> spid)

View file

@ -19,8 +19,10 @@
--
-- Bruges af: Ingen regel direkte. Styrer, hvilke databaser de databasespecifikke regler vurderes i, og hvilke
-- AX-databaser der må sende AX-fund (COM-AX-*) til SQLInsight Hub.
-- Read-only: Ja: kun SELECT på metadata, via tre-delte navne. Sessionen skifter aldrig database, og intet ændrer
-- data, indstillinger eller objekter.
-- Read-only: Ja: kun SELECT på metadata, via tre-delte navne og (fra 1.4, AX-genkendelsen på SQL Server 2005 og nyere)
-- [db].sys.sp_executesql under READ COMMITTED med SET LOCK_TIMEOUT 2000, aldrig NOLOCK. Sessionen skifter
-- aldrig database, og intet ændrer data, indstillinger eller objekter. AX-genkendelsen er den samme som i
-- _collectors/AxDatabaseDetector.sql (src/SQLInsight.Core/DynamicsAx/AxDetection.cs).
-- Kørsel: Kør i SSMS som agentens login. Hver variant er sin egen batch (GO); en variant for en anden SQL Server-version
-- end din kan fejle - det er forventet. Agenten kører altid med sessionsindstillingerne i _collectors/InstanceProbe.sql.
-- ===================================================================================================================
@ -54,18 +56,25 @@ GO
-- Variant: SQL Server 2005 og nyere: Dynamics AX-genkendelse, én gang pr. online brugerdatabase, agenten kan åbne (vist for msdb)
-- Kilde: DatabaseDiscovery.DynamicsAxSql(2005)
-- Kode: src/SQLInsight.Collectors/Discovery/DatabaseDiscovery.cs : DatabaseDiscovery.DynamicsAxSql(database, version)
SELECT COUNT(DISTINCT t.name), MAX(CASE WHEN t.name = N'SQLDICTIONARY' THEN c.precision END)
FROM [msdb].sys.tables t
LEFT JOIN [msdb].sys.columns c ON c.object_id = t.object_id AND c.name = N'RECID'
WHERE t.name IN (N'SQLDICTIONARY', N'DATAAREA', N'SYSCONFIG')
EXEC [msdb].sys.sp_executesql N'SET NOCOUNT ON; SET LOCK_TIMEOUT 2000; SET TRANSACTION ISOLATION LEVEL READ COMMITTED; SELECT s.name, UPPER(t.name), UPPER(c.name), TYPE_NAME(c.system_type_id), c.precision, c.max_length,
ISNULL(HAS_PERMS_BY_NAME(QUOTENAME(s.name) + N''.'' + QUOTENAME(t.name), N''OBJECT'', N''SELECT''), 0),
(SELECT TOP 1 p.name FROM sys.database_principals p WHERE p.sid = SUSER_SID())
FROM sys.tables t
JOIN sys.schemas s ON s.schema_id = t.schema_id
JOIN sys.columns c ON c.object_id = t.object_id
WHERE UPPER(t.name) IN (N''SQLDICTIONARY'', N''DATAAREA'', N''SYSCONFIG'', N''BATCH'', N''BATCHJOB'', N''BATCHJOBHISTORY'', N''BATCHGROUP'', N''NUMBERSEQUENCETABLE'', N''NUMBERSEQUENCESCOPE'', N''NUMBERSEQUENCEREFERENCE'', N''SYSTEMSEQUENCES'', N''MODELELEMENT'')
ORDER BY s.name, t.name, c.column_id';
SET LOCK_TIMEOUT 5000; SET TRANSACTION ISOLATION LEVEL READ UNCOMMITTED;
GO
-- Variant: SQL Server 2000 (legacy ODBC-sti): Dynamics AX-genkendelse, én gang pr. online brugerdatabase (vist for msdb)
-- Kilde: DatabaseDiscovery.DynamicsAxSql(2000)
-- Kode: src/SQLInsight.Collectors/Discovery/DatabaseDiscovery.cs : DatabaseDiscovery.DynamicsAxSql(database, version)
SELECT COUNT(DISTINCT o.name), MAX(CASE WHEN o.name = N'SQLDICTIONARY' THEN c.xprec END)
SELECT u.name, UPPER(o.name), UPPER(c.name), TYPE_NAME(c.xtype), c.xprec, c.length, 0, NULL
FROM [msdb].dbo.sysobjects o
LEFT JOIN [msdb].dbo.syscolumns c ON c.id = o.id AND c.name = N'RECID'
WHERE o.xtype = 'U' AND o.name IN (N'SQLDICTIONARY', N'DATAAREA', N'SYSCONFIG')
JOIN [msdb].dbo.sysusers u ON u.uid = o.uid
JOIN [msdb].dbo.syscolumns c ON c.id = o.id
WHERE o.xtype = 'U' AND UPPER(o.name) IN (N'SQLDICTIONARY', N'DATAAREA', N'SYSCONFIG', N'BATCH', N'BATCHJOB', N'BATCHJOBHISTORY', N'BATCHGROUP', N'NUMBERSEQUENCETABLE', N'NUMBERSEQUENCESCOPE', N'NUMBERSEQUENCEREFERENCE', N'SYSTEMSEQUENCES', N'MODELELEMENT')
ORDER BY u.name, o.name, c.colid
GO

View file

@ -12,11 +12,8 @@
-- GRANT VIEW SERVER SECURITY STATE TO [agent]; -- SQL Server 2022+
--
-- Bruges af: SQLI-DLK-001
-- Read-only: IKKE STRENGT READ-ONLY. DeadlockCollector.BuildFileSql (SQL Server 2012+) opretter en midlertidig
-- tabel i tempdb (SELECT ... INTO #system_health) og sletter den igen (DROP TABLE #system_health) i
-- samme batch. Den er privat for sessionen; ingen brugerdata, indstillinger eller permanente objekter
-- ændres, men det er en skrivning i tempdb. Ring buffer-varianten (2008/2008 R2) er read-only. Øvrigt:
-- kun SELECT samt SET-sessionsindstillinger.
-- Read-only: Ja: kun SELECT (sys.fn_xe_file_target_read_file og DMV'er) samt SET-sessionsindstillinger. Ingen
-- temp-tabeller; læsepositionen findes med ROW_NUMBER() i samme forespørgsel.
-- Kørsel: Kør i SSMS som agentens login. Hver variant er sin egen batch (GO); en variant for en anden SQL Server-version
-- end din kan fejle - det er forventet. Agenten kører altid med sessionsindstillingerne i _collectors/InstanceProbe.sql.
-- ===================================================================================================================
@ -24,14 +21,14 @@
-- Variant: SQL Server 2012 og nyere (system_health-filer; første læsning)
-- Kilde: DeadlockCollector.BuildFileSql
-- Kode: src/SQLInsight.Collectors/Concurrency/DeadlockCollector.cs : DeadlockCollector.BuildFileSql(lastFile: null, lastOffset: null)
SELECT object_name, file_name, file_offset,
CASE WHEN object_name = N'xml_deadlock_report' THEN CAST(event_data AS nvarchar(max)) END AS event_data
INTO #system_health
FROM sys.fn_xe_file_target_read_file(N'system_health*.xel', NULL, NULL, NULL);
SELECT event_data FROM #system_health WHERE event_data IS NOT NULL;
SELECT TOP (1) file_name, file_offset FROM #system_health ORDER BY file_name DESC, file_offset DESC;
DROP TABLE #system_health;
SELECT e.event_data, e.file_name, e.file_offset, e.rn
FROM (
SELECT CASE WHEN object_name = N'xml_deadlock_report' THEN CAST(event_data AS nvarchar(max)) END AS event_data,
file_name, file_offset,
ROW_NUMBER() OVER (ORDER BY file_name DESC, file_offset DESC) AS rn
FROM sys.fn_xe_file_target_read_file(N'system_health*.xel', NULL, NULL, NULL)
) AS e
WHERE e.event_data IS NOT NULL OR e.rn = 1;
GO
-- Variant: SQL Server 2008 – 2008 R2, og når event-filerne ikke kan læses

View file

@ -0,0 +1,101 @@
/*
SQLInsight Pro: valgfri læseadgang til Dynamics AX' systemtabeller
=====================================================================
TILVALG. Agenten læser normalt kun metadata (grant-least-privilege.sql). Med dette script må den desuden læse de
få AX-systemtabeller, som AX-tjekkene bruger, i ÉN navngiven AX-database, så SQLInsight kan advare om batchjob i
fejl, forsinkede eller hængende batchjob, nummerserier og RecId'er der er ved at løbe tør:
Axapta 3.0 og AX 4.0: BATCH, NUMBERSEQUENCETABLE, NUMBERSEQUENCEREFERENCE, SYSTEMSEQUENCES, SQLDICTIONARY, DATAAREA
AX 2009: BATCHJOB, BATCHJOBHISTORY, NUMBERSEQUENCETABLE, NUMBERSEQUENCEREFERENCE, SYSTEMSEQUENCES,
SQLDICTIONARY, DATAAREA
AX 2012 og F&O: BATCHJOB, BATCHJOBHISTORY, NUMBERSEQUENCETABLE, NUMBERSEQUENCESCOPE, NUMBERSEQUENCEREFERENCE,
SYSTEMSEQUENCES, SQLDICTIONARY, DATAAREA
Kun SELECT, kun disse tabeller: ingen kundedata (debitorer, ordrer, posteringer), ingen skriverettigheder. Scriptet
finder selv AX-versionen og skemaet, tabellerne ligger i (bmssa i Axapta 3.0, dbo senere). Tabeller, der ikke findes
i databasen, springes over. Scriptet kan køres igen uden skade. Fjern adgangen igen med revoke-dynamics-ax-read.sql.
SQLInsight Web viser det samme som færdige GRANT-sætninger for hver AX-database (Dynamics AX → AX-databaser).
AX 2009, 2012 og F&O er baseret på Microsofts dokumenterede skema – ikke verificeret mod kundedata.
Understøtter SQL Server 2005 og nyere. Kør som sysadmin eller som db_owner/db_securityadmin i AX-databasen.
*/
SET NOCOUNT ON;
DECLARE @DatabaseName sysname;
DECLARE @LoginName sysname;
SET @DatabaseName = N'AXDB'; -- <== ret til AX-databasen
SET @LoginName = N'DOMÆNE\svc-sqlinsight$'; -- <== ret til agentens login
IF DB_ID(@DatabaseName) IS NULL
BEGIN
RAISERROR(N'Databasen %s findes ikke.', 16, 1, @DatabaseName);
RETURN;
END;
IF SUSER_SID(@LoginName) IS NULL
BEGIN
RAISERROR(N'Login %s findes ikke. Kør grant-least-privilege.sql først.', 16, 1, @LoginName);
RETURN;
END;
DECLARE @Procedure nvarchar(300);
DECLARE @Sql nvarchar(max);
SET @Procedure = QUOTENAME(@DatabaseName) + N'.sys.sp_executesql';
SET @Sql = N'
SET NOCOUNT ON;
DECLARE @schema sysname;
DECLARE @precision int;
DECLARE @version nvarchar(10);
DECLARE @tables nvarchar(400);
DECLARE @user sysname;
DECLARE @grant nvarchar(max);
DECLARE @count int;
SELECT TOP 1 @schema = s.name, @precision = c.precision
FROM sys.tables t
JOIN sys.schemas s ON s.schema_id = t.schema_id
LEFT JOIN sys.columns c ON c.object_id = t.object_id AND UPPER(c.name) = N''RECID''
WHERE UPPER(t.name) = N''SQLDICTIONARY'' AND OBJECT_ID(QUOTENAME(s.name) + N''.DATAAREA'', N''U'') IS NOT NULL
ORDER BY CASE WHEN s.name = N''dbo'' THEN 0 ELSE 1 END, s.name;
IF @schema IS NULL
BEGIN
DECLARE @database sysname;
SET @database = DB_NAME();
RAISERROR(N''%s er ikke en Dynamics AX-database (SQLDICTIONARY og DATAAREA findes ikke i samme skema). Intet er ændret.'', 16, 1, @database);
RETURN;
END;
/* The same version rules as SQLInsight''s detection (Core/DynamicsAx/AxDetection.cs). */
IF @precision <= 10 SET @version = N''3.0'';
ELSE IF OBJECT_ID(QUOTENAME(@schema) + N''.BATCHJOB'', N''U'') IS NULL SET @version = N''4.0'';
ELSE IF OBJECT_ID(QUOTENAME(@schema) + N''.NUMBERSEQUENCESCOPE'', N''U'') IS NULL SET @version = N''2009'';
ELSE SET @version = N''2012'';
/* The tables per version: the same lists as the version adapters'' ReadTables (Core/DynamicsAx/AxSchemaAdapter.cs). */
SET @tables = CASE
WHEN @version IN (N''3.0'', N''4.0'') THEN N''|BATCH|NUMBERSEQUENCETABLE|NUMBERSEQUENCEREFERENCE|SYSTEMSEQUENCES|SQLDICTIONARY|DATAAREA|''
WHEN @version = N''2009'' THEN N''|BATCHJOB|BATCHJOBHISTORY|NUMBERSEQUENCETABLE|NUMBERSEQUENCEREFERENCE|SYSTEMSEQUENCES|SQLDICTIONARY|DATAAREA|''
ELSE N''|BATCHJOB|BATCHJOBHISTORY|NUMBERSEQUENCETABLE|NUMBERSEQUENCESCOPE|NUMBERSEQUENCEREFERENCE|SYSTEMSEQUENCES|SQLDICTIONARY|DATAAREA|''
END;
SELECT @user = name FROM sys.database_principals WHERE sid = SUSER_SID(@login);
IF @user IS NULL
BEGIN
SET @user = @login;
SET @grant = N''CREATE USER '' + QUOTENAME(@user) + N'' FOR LOGIN '' + QUOTENAME(@login) + N'';'';
EXEC (@grant);
PRINT N''Bruger oprettet uden rettigheder: '' + @user;
END;
SET @grant = N'''';
SET @count = 0;
SELECT @grant = @grant + N''GRANT SELECT ON '' + QUOTENAME(s.name) + N''.'' + QUOTENAME(t.name) + N'' TO '' + QUOTENAME(@user) + N''; '', @count = @count + 1
FROM sys.tables t
JOIN sys.schemas s ON s.schema_id = t.schema_id
WHERE s.name = @schema AND CHARINDEX(N''|'' + UPPER(t.name) + N''|'', @tables) > 0;
IF @count > 0 EXEC (@grant);
PRINT N''Dynamics AX '' + @version + N'' i skemaet '' + @schema + N'': SELECT givet på '' + CAST(@count AS nvarchar(10)) + N'' tabeller i '' + DB_NAME() + N'' til '' + @user + N''.'';';
EXEC @Procedure @Sql, N'@login sysname', @login = @LoginName;

View file

@ -0,0 +1,66 @@
/*
SQLInsight Pro: fjern den valgfri læseadgang til Dynamics AX' systemtabeller
==============================================================================
Fjerner det, grant-dynamics-ax-read.sql gav, i én AX-database: SELECT på AX' batch-, nummerserie-, RecId- og
firmatabeller. Agentens database-bruger slettes ikke (den kan være oprettet af grant-least-privilege.sql). Bagefter
viser SQLInsight AX-datatjekkene som "ikke målbar" med den GRANT, der skal til for at slå dem til igen.
Understøtter SQL Server 2005 og nyere. Kør som sysadmin eller som db_owner/db_securityadmin i AX-databasen.
*/
SET NOCOUNT ON;
DECLARE @DatabaseName sysname;
DECLARE @LoginName sysname;
SET @DatabaseName = N'AXDB'; -- <== ret til AX-databasen
SET @LoginName = N'DOMÆNE\svc-sqlinsight$'; -- <== ret til agentens login
IF DB_ID(@DatabaseName) IS NULL
BEGIN
RAISERROR(N'Databasen %s findes ikke.', 16, 1, @DatabaseName);
RETURN;
END;
DECLARE @Procedure nvarchar(300);
DECLARE @Sql nvarchar(max);
SET @Procedure = QUOTENAME(@DatabaseName) + N'.sys.sp_executesql';
SET @Sql = N'
SET NOCOUNT ON;
DECLARE @user sysname;
DECLARE @revoke nvarchar(max);
DECLARE @count int;
SELECT @user = name FROM sys.database_principals WHERE sid = SUSER_SID(@login);
IF @user IS NULL
BEGIN
PRINT N''Login''''et har ingen bruger i '' + DB_NAME() + N''. Intet at fjerne.'';
RETURN;
END;
/* Only the AX schema (where SQLDICTIONARY and DATAAREA are, as grant-dynamics-ax-read.sql finds it), and only a SELECT
that was granted: a DENY, or a grant on a same-named table in another schema set up for another reason, is left alone. */
DECLARE @schema sysname;
SELECT TOP 1 @schema = s.name
FROM sys.tables t
JOIN sys.schemas s ON s.schema_id = t.schema_id
WHERE UPPER(t.name) = N''SQLDICTIONARY'' AND OBJECT_ID(QUOTENAME(s.name) + N''.DATAAREA'', N''U'') IS NOT NULL
ORDER BY CASE WHEN s.name = N''dbo'' THEN 0 ELSE 1 END;
IF @schema IS NULL
BEGIN
PRINT DB_NAME() + N'' er ikke en Dynamics AX-database (SQLDICTIONARY og DATAAREA findes ikke i samme skema). Intet er ændret.'';
RETURN;
END;
SET @revoke = N'''';
SET @count = 0;
SELECT @revoke = @revoke + N''REVOKE SELECT ON '' + QUOTENAME(s.name) + N''.'' + QUOTENAME(t.name) + N'' FROM '' + QUOTENAME(@user) + N''; '', @count = @count + 1
FROM sys.tables t
JOIN sys.schemas s ON s.schema_id = t.schema_id
WHERE s.name = @schema
AND UPPER(t.name) IN (N''BATCH'', N''BATCHJOB'', N''BATCHJOBHISTORY'', N''NUMBERSEQUENCETABLE'', N''NUMBERSEQUENCESCOPE'',
N''NUMBERSEQUENCEREFERENCE'', N''SYSTEMSEQUENCES'', N''SQLDICTIONARY'', N''DATAAREA'')
AND EXISTS (SELECT 1 FROM sys.database_permissions p
WHERE p.class = 1 AND p.major_id = t.object_id AND p.minor_id = 0 AND p.grantee_principal_id = USER_ID(@user)
AND p.permission_name = N''SELECT'' AND p.state IN (''G'', ''W''));
IF @count > 0 EXEC (@revoke);
PRINT N''SELECT fjernet på '' + CAST(@count AS nvarchar(10)) + N'' AX-tabeller i '' + DB_NAME() + N'' for '' + @user + N''.'';';
EXEC @Procedure @Sql, N'@login sysname', @login = @LoginName;

View file

@ -27,7 +27,7 @@ Kun når `SQLInsight:Hub:Enabled` er `true` og der er en gyldig licens. Agenten
Der sendes aldrig tabeldata, forespørgselsresultater, adgangskoder eller forbindelsesstrenge.
**Dynamics AX** (fra 1.3): fund fra AX-reglerne (`COM-AX-…`) og deres resultater sendes som standard ikke (en ændring fra 1.2, der sendte dem; se [opdatering.md](opdatering.md#13-servere-og-databaser)). Kunden kan slå det til for én AX-database ad gangen under *Servere og databaser* (se [servere-og-databaser.md](servere-og-databaser.md#dynamics-ax-fund-og-sqlinsight-hub)).
**Dynamics AX** (fra 1.3, udvidet i 1.4): fund fra AX-reglerne (`COM-AX-…` og fra 1.4 `SQLI-AX-…`: batchjob, nummerserier, RecId, AX-brugere bag blokering) og deres resultater sendes som standard ikke (en ændring fra 1.2, der sendte dem; se [opdatering.md](opdatering.md#13-servere-og-databaser)); sendes de, er reglernes årsagstekst erstattet af en fast tekst, og AX-brugernavne er følsomme målinger. Kunden kan slå det til for én AX-database ad gangen under *Servere og databaser* (se [servere-og-databaser.md](servere-og-databaser.md#dynamics-ax-fund-og-sqlinsight-hub)).
**Adgangskoder til SQL-logins**, der indtastes i webbrugerfladen, gemmes i kundens repository krypteret med agentens offentlige nøgle, så kun agenten kan læse dem. De forlader aldrig kundens netværk.

126
docs/dynamics-ax.md Normal file
View file

@ -0,0 +1,126 @@
# Dynamics AX
Fra version 1.4 overvåger SQLInsight Pro Microsoft Dynamics AX-databaser specifikt: batchjob, nummerserier, RecId og hvilken AX-bruger der står bag en blokering. Det findes under **Dynamics AX** i menuen (vises, så snart en overvåget database er genkendt som AX) og i API'et under `/api/v1/dynamics-ax/`.
> **Hvor sikre er vi på skemaet?** Axapta 3.0 er bygget og testet mod en rigtig kundedatabase ([`schema-ax30.tsv`](dynamics-ax/schema-ax30.tsv): tabeller ejet af `bmssa`, 32-bit RECID, `BATCH` med lokal `STARTDATE` + `STARTTIME` i sekunder efter midnat). **AX 4.0, AX 2009, AX 2012 og Dynamics 365 F&O er baseret på Microsofts dokumenterede skema – ikke verificeret mod kundedata.** Det dokumenterede skema står i [`schema-ax2009.documented.tsv`](dynamics-ax/schema-ax2009.documented.tsv) og [`schema-ax2012.documented.tsv`](dynamics-ax/schema-ax2012.documented.tsv). Leverer en kunde det rigtige skema (se [nedenfor](#nyt-kundeskema)), lægges det ind som `schema-ax2009.tsv`/`schema-ax2012.tsv`, og testene kører så mod det i stedet.
## Genkendelse
En database er AX, når systemtabellerne `SQLDICTIONARY` og `DATAAREA` ligger i samme skema. Versionen afgøres af skemaet ét sted ([`AxDetection.cs`](../src/SQLInsight.Core/DynamicsAx/AxDetection.cs)), som bruges af *Servere og databaser*, AX-collectorerne, community-reglerne `COM-AX-*` og webbrugerfladen:
| Kendetegn | Version |
|---|---|
| `SQLDICTIONARY.RECID` er `int` (præcision 10) | Axapta 2.5/3.0 (kan ikke skelnes; typisk skema `bmssa`) |
| `bigint`, ingen `BATCHJOB` | AX 4.0 |
| `BATCHJOB`, ingen `NUMBERSEQUENCESCOPE` | AX 2009 |
| `NUMBERSEQUENCESCOPE`, ingen `PARTITION`-kolonne | AX 2012 RTM/FP |
| `PARTITION` og modeldatabasen `<db>_model` på instansen | AX 2012 R2/R3 |
| `PARTITION`, intet modellager | Dynamics 365 F&O on-premises (formodet) |
Genkendelsen læser kun metadata (`sys.tables`, `sys.columns`, `HAS_PERMS_BY_NAME`) og kræver ikke mere end [`grant-least-privilege.sql`](../db/permissions/grant-least-privilege.sql). Den kører hver time i de databaser, der er valgt til overvågning (hvert 5. minut for en AX-database, hvor agenten endnu ikke har den fulde læseadgang, så en GRANT virker inden for få minutter). **AX-tjek kører kun i databaser, der er genkendt som AX og valgt til overvågning**; de generelle tjek kører i alle valgte databaser som hidtil.
## Valgfri læseadgang til AX-tabellerne
Batchjob, nummerserier og RecId kræver `SELECT` på nogle få AX-systemtabeller – aldrig kundedata (debitorer, ordrer, posteringer) og aldrig skriverettigheder:
| Version | Tabeller |
|---|---|
| Axapta 3.0 og AX 4.0 | `BATCH`, `NUMBERSEQUENCETABLE`, `NUMBERSEQUENCEREFERENCE`, `SYSTEMSEQUENCES`, `SQLDICTIONARY`, `DATAAREA` |
| AX 2009 | `BATCHJOB`, `BATCHJOBHISTORY`, `NUMBERSEQUENCETABLE`, `NUMBERSEQUENCEREFERENCE`, `SYSTEMSEQUENCES`, `SQLDICTIONARY`, `DATAAREA` |
| AX 2012 og F&O | som AX 2009 plus `NUMBERSEQUENCESCOPE` |
Adgangen er et tilvalg pr. database. **Dynamics AX → AX-databaser** viser for hver database, hvilke tabeller agenten må læse, og de nøjagtige `GRANT`-sætninger med skema (`bmssa` i AX 3.0) og agentens login udfyldt. Det samme for enhver AX-database: [`db/permissions/grant-dynamics-ax-read.sql`](../db/permissions/grant-dynamics-ax-read.sql), som selv finder version og skema (fjernes igen med [`revoke-dynamics-ax-read.sql`](../db/permissions/revoke-dynamics-ax-read.sql)). SQLInsight kører aldrig scripts selv.
Uden adgangen er tjekket **ikke målbart**, og årsagen er den nøjagtige `GRANT`, der mangler – aldrig en fejl.
## Sådan læser SQLInsight AX
- **Kun SELECT**, kun på standardtabellerne ovenfor, og aldrig i kundens database ud over det: al historik (batchkørsler, målinger af nummerserier) gemmes i SQLInsights eget repository.
- **Begrænset:** `TOP` på hver forespørgsel (1000 batchjob; 1000 historikrækker første gang og derefter 3000 fra seneste læste RECID minus et overlap på 2000, så mindst 1000 nye kørsler læses hver gang; de 5000 mest brugte nummerserier; de 500 højeste RecId-serier), og historikken læses efter RECID, som AX altid har et unikt indeks på.
- **Uden at vente på AX:** `READ COMMITTED` med `SET LOCK_TIMEOUT 2000` – er en række låst, opgiver læsningen efter 2 sekunder og prøver igen næste gang (det forrige billede beholdes i op til 15 minutter, så fund ikke blinker). Bagefter sættes agentens egne sessionsindstillinger igen (`LOCK_TIMEOUT 5000`, `READ UNCOMMITTED` til DMV-forespørgslerne), så de næste collectors ikke påvirkes. Har databasen `ALLOW_SNAPSHOT_ISOLATION`, bruges `SNAPSHOT`; med `READ_COMMITTED_SNAPSHOT` læses versioner. **Aldrig `NOLOCK`.**
- **Uden at skifte database:** forespørgslerne køres i AX-databasen med `[db].sys.sp_executesql`.
- **Kolonnetjek først:** hver forespørgsel bygges ud fra de kolonner, databasen faktisk har. Mangler en nødvendig kolonne, er tjekket "ikke målbart" med den præcise kolonne; mangler en valgfri, udelades den med en note.
- **SQL Server 2005 og nyere.** På SQL Server 2000 (legacy ODBC) genkendes AX under *Servere og databaser*, men datatjekkene kører ikke.
Den præcise SQL for hver regel står i [`SQL Scripts/`](../SQL%20Scripts/) (fx `SQLI-AX-003.sql`), genereret fra koden og klar til at køre i SSMS.
## Regler
| Regel | Hvad | Tærskel (`SQLInsight:DynamicsAx`) |
|---|---|---|
| SQLI-AX-001 | Batchjob er endt med fejl (nu, eller seneste kørsel af et gentaget job) | `BatchFailedLookbackHours` = 24 |
| SQLI-AX-002 | Batchjob kører længere end normalt, målt mod jobbets egne seneste kørsler | `BatchLongRunningFactor` = 2 × 90 %-fraktilen, mindst `BatchLongRunningMinimumMinutes` = 10, efter `BatchHistoryMinimumRuns` = 5 kørsler |
| SQLI-AX-003 | Batchjob er ikke startet til tiden; efter et døgn "aldrig startet" | `BatchLateMinutes` = 30, `BatchNeverStartedHours` = 24 |
| SQLI-AX-004 | Batchjob hænger i Udfører | `BatchStuckHours` = 6 (medmindre jobbet normalt kører så længe) |
| SQLI-AX-005 | Nummerserie er ved at løbe tør, nu eller efter prognosen | `NumberSequenceWarnPercent` = 80, `NumberSequenceCriticalPercent` = 95, `ForecastWarnDays` = 90, `ForecastCriticalDays` = 30 |
| SQLI-AX-006 | Nummerseriens format har færre cifre end Højeste kræver | – |
| SQLI-AX-007 | Spærret nummerserie bruges stadig af en reference | – |
| SQLI-AX-008 | Kontinuerlig nummerserie er ikke ryddet op | 7 dage eller 2 × seriens interval |
| SQLI-AX-009 | RecId er ved at løbe tør – Axapta 3.0's 32-bit RecId advares langt tidligere | `RecId32WarnPercent` = 50, `RecId32CriticalPercent` = 75, `RecId32ForecastDays` = 730; 64-bit: `RecId64WarnPercent` = 80 |
| SQLI-AX-010 | AX-bruger blokerer andre | `BlockingSeconds` = 30 |
Tærsklerne sættes i agentens `appsettings.json`:
```json
"SQLInsight": {
"DynamicsAx": { "BatchLateMinutes": 30, "BatchStuckHours": 6, "NumberSequenceWarnPercent": 80, "ForecastWarnDays": 90 }
}
```
De eksisterende community-regler `COM-AX-001` til `-007` (support, SQL Server-version, MAXDOP, RecId-indeks, heaps, statistikker) læser kun metadata og kører nu også kun i genkendte og valgte AX-databaser.
### Batchjob
Planlagt start er `BATCHJOB.ORIGSTARTDATETIME` (UTC) i AX 2009 og senere, og `BATCH.STARTDATE` + `STARTTIME` (lokal tid, sekunder efter midnat) i Axapta 3.0 og AX 4.0; de lokale tider regnes om til UTC i AX-tidszonen med de sommertidsregler, der gjaldt på datoen. AX-tidszonen er `SQLInsight:DynamicsAx:LocalTimeZone` (fx `Romance Standard Time`; AOS'ens tidszone), ellers agentens egen, når den lige nu har samme forskel til UTC som SQL Server. Passer ingen af dem, bruges SQL Servers aktuelle forskel, og en datanote siger, at tider fra før et sommertidsskift kan være en time forkerte. En tid i timen, der springes over om foråret, eller som kommer to gange om efteråret, regnes med normaltid (om efteråret den sidste af de to), så et job aldrig ser forsinket ud pga. skiftet.
Hvert jobs historik er SQLInsights egen (`insight.AxBatchRuns`, gemt `EventDays` = 90 dage): i AX 2009 og senere fra `BATCHJOBHISTORY`, læst efter RECID; Axapta 3.0 og 4.0 gemmer ingen historik, så SQLInsight registrerer selv de kørsler, den ser blive afsluttet (kørselstiden måles fra første gang, den så jobbet køre, og korte kørsler, der starter og slutter mellem to målinger, kommer ikke med). Et job i Axapta 3.0 er klasse + firma + batchgruppe, fordi AX 3.0 laver en ny række for hver gentagelse.
**`STATUS` i Axapta 3.0 er ikke verificeret mod kundedata.** SQLInsight antager 0 Venter, 1 Udfører, 2 Fejl, 3 Afsluttet, 4 Tilbageholdt. Siden *Batchjob* viser de rå værdier, der findes, så de kan sammenlignes med batchlisten i AX. AX 2009 og senere følger Microsofts dokumenterede `BatchStatus` (0 Tilbageholdt, 1 Venter, 2 Udfører, 3 Fejl, 4 Afsluttet, 5 Klar, 6 Ikke kørt, 7 Annullerer, 8 Annulleret).
### Nummerserier og RecId
Forbrug = (`NEXTREC` − `LOWEST`) i forhold til `HIGHEST` – eller formatets kapacitet (10^antal `#` − 1), hvis den er lavere. Prognoserne bygger på SQLInsights egne målinger: én pr. serie pr. dag af `NEXTREC` og `SYSTEMSEQUENCES.NEXTVAL` (`insight.AxSequenceSamples`, gemt `HourlyDays` = 400 dage). Forbruget pr. dag regnes fra den ældste måling inden for `ForecastBasisDays` (30) dage, der er mindst et døgn gammel; prognosen kommer altså tidligst et døgn efter, at læseadgangen er givet. Manuelle og cykliske serier får ingen prognose.
RecId-grænsen er typens maksimum – `int` (2.147.483.647) når `NEXTVAL` er `int` som i Axapta 3.0, ellers `bigint` – eller `MAXVAL`, hvis den er sat lavere. I Axapta 3.0 er RecId pr. tabel og firma.
### AX-brugere bag blokering
Alle AX-brugere går gennem AOS'ens login. **AX 2009 og 2012** (og F&O) kan skrive AX-brugeren og AX-sessionen i sessionens `CONTEXT_INFO`, når `connectioncontext` er slået til i AOS-konfigurationen (registreringsdatabasen på AOS-serveren; kræver genstart af AOS). SQLInsight læser den fra blokeringsmålingerne (første ord med bogstaver = bruger, første tal = AX-session; formatet er Microsofts for AX 2012 og ikke verificeret mod kundedata). **Axapta 3.0 og AX 4.0 sætter aldrig `CONTEXT_INFO`**: brugeren kan ikke ses fra SQL Server, og SQLInsight siger det og viser vært (AOS'en ved 3-lags-installation, brugerens pc ved 2-lags-klient) og program. AX-brugeren er en følsom oplysning.
## SQLInsight Hub
AX-fund (`SQLI-AX-*` og `COM-AX-*`) sendes kun til SQLInsight Hub for databaser, hvor *AX-fund til SQLInsight Hub* er slået til under [Servere og databaser](servere-og-databaser.md#dynamics-ax-fund-og-sqlinsight-hub) (standard fra). Hvert fund bærer databasen i sit emne (`database: AX30 · batch: …`), så det følger databasens valg. AX-reglernes udfald sendes kun, når mindst én database har valget slået til, og deres årsag (som kan nævne tabeller, logins og GRANT-tekst) erstattes af en fast tekst. AX-brugere er følsomme og sendes kun med `SendSensitiveEvidence`.
## API
| Endpoint | Indhold |
|---|---|
| `GET /api/v1/dynamics-ax/databases` | Genkendte AX-databaser: version, verificeret, skema, modeldatabase, firmaer, læseadgang pr. tabel, manglende tabeller og GRANT |
| `GET /api/v1/dynamics-ax/detection-problems` | Overvågede databaser, AX-genkendelsen ikke kunne læse (fx ingen databasebruger før SQL Server 2014) |
| `GET /api/v1/dynamics-ax/grant-script?instance=&database=` | GRANT-sætningerne og hele `grant-dynamics-ax-read.sql` udfyldt for databasen |
| `GET /api/v1/dynamics-ax/batch-jobs` | Batchjob nu: status, planlagt/faktisk start, forsinkelse, kørselstid, median og 90 %-fraktil, rå STATUS-værdier og hvad de betyder i versionen |
| `GET /api/v1/dynamics-ax/batch-runs?database=&job=&days=7` | Afsluttede kørsler fra repository'et |
| `GET /api/v1/dynamics-ax/number-sequences` | Nummerserier med forbrug, format, prognose og flag |
| `GET /api/v1/dynamics-ax/recids` | RecId pr. tabel med grænse og prognose |
| `GET /api/v1/dynamics-ax/sequence-samples?instance=&database=&kind=N\|R&key=&days=30` | De daglige målinger bag prognoserne (højst 10.000; `X-Truncated: true`, når der er flere) |
| `GET /api/v1/dynamics-ax/blocking?days=7` | AX-brugere bag blokering |
Alle tager `?instance=` for én instans; fjernede servere er ikke med. AX-bruger, vært, program, sætning og rå `CONTEXT_INFO` er `[udeladt]`, og agentens login og databasebruger erstattes af pladsholderen `DOMÆNE\svc-sqlinsight$` (også i GRANT-teksterne i "ikke målbar" og i `/api/v1/instances/{name}/rule-states`), medmindre `SQLInsight:Web:ApiIncludesSensitiveEvidence` er slået til.
## Nyt kundeskema
Får vi et rigtigt skema fra en AX 2009- eller 2012-kunde, eksporteres det med
```sql
SELECT s.name AS skema, t.name AS tabel, c.name AS kolonne, TYPE_NAME(c.system_type_id) AS type, c.max_length, c.column_id
FROM sys.tables t JOIN sys.schemas s ON s.schema_id = t.schema_id JOIN sys.columns c ON c.object_id = t.object_id
WHERE t.name IN ('SQLDICTIONARY', 'DATAAREA', 'SYSCONFIG', 'BATCH', 'BATCHJOB', 'BATCHJOBHISTORY', 'BATCHGROUP',
'NUMBERSEQUENCETABLE', 'NUMBERSEQUENCESCOPE', 'NUMBERSEQUENCEREFERENCE', 'SYSTEMSEQUENCES')
ORDER BY s.name, t.name, c.column_id;
```
og gemmes tabulatorsepareret som `docs/dynamics-ax/schema-ax2009.tsv` eller `schema-ax2012.tsv`. Testene (`AxSchemaAdapterTests`, `DynamicsAxSqlTests`, `SqlScriptTests`) bruger så det rigtige skema i stedet for det dokumenterede, og viser præcis hvilke kolonner versionens adapter ([`AxSchemaAdapter.cs`](../src/SQLInsight.Core/DynamicsAx/AxSchemaAdapter.cs)) forventer, men som mangler. Al viden om en versions tabeller ligger i dens adapter; ret den dér, og opdatér teksterne "ikke verificeret mod kundedata" for versionen (`AxVersions.IsVerified`).
## Genbrug i andre tjek
Et syntetisk AX-logon-tjek (fase 1.5) hører ikke til her, men kan genbruge AX-viden herfra i stedet for at kende AX' tabeller selv: `GET /api/v1/dynamics-ax/databases` (eller snapshot'et `dynamics-ax` i repository'et, typen `AxDatabaseSnapshot`) giver for hver overvåget AX-database version, skema, firmaer (`DATAAREA`) og modeldatabase; `AxDetection` og `AxSchemaAdapter` i `SQLInsight.Core.DynamicsAx` er den eneste kilde til, hvilke tabeller og kolonner en version har; og `AxSessionMapper` oversætter en SQL-session til AX-bruger og AX-session, hvor AOS'en sætter `CONTEXT_INFO`.

View file

@ -0,0 +1,129 @@
skema tabel kolonne type max_length column_id
# Baseret på Microsofts dokumenterede skema – ikke verificeret mod kundedata. Erstattes af schema-ax2009.tsv, når en kunde leverer det rigtige skema.
dbo SQLDICTIONARY TABLEID int 4 1
dbo SQLDICTIONARY FIELDID int 4 2
dbo SQLDICTIONARY ARRAY int 4 3
dbo SQLDICTIONARY NAME nvarchar 80 4
dbo SQLDICTIONARY SQLNAME nvarchar 80 5
dbo SQLDICTIONARY FIELDTYPE int 4 6
dbo SQLDICTIONARY STRSIZE int 4 7
dbo SQLDICTIONARY SHADOW int 4 8
dbo SQLDICTIONARY RIGHTJUSTIFY int 4 9
dbo SQLDICTIONARY NULLABLE int 4 10
dbo SQLDICTIONARY FLAGS int 4 11
dbo SQLDICTIONARY RECVERSION int 4 12
dbo SQLDICTIONARY RECID bigint 8 13
dbo DATAAREA ID nvarchar 8 1
dbo DATAAREA NAME nvarchar 120 2
dbo DATAAREA ISVIRTUAL int 4 3
dbo DATAAREA ALWAYSNATIVE int 4 4
dbo DATAAREA TIMEZONE int 4 5
dbo DATAAREA RECVERSION int 4 6
dbo DATAAREA RECID bigint 8 7
dbo SYSCONFIG CONFIGTYPE int 4 1
dbo SYSCONFIG ID int 4 2
dbo SYSCONFIG VALUE nvarchar 160 3
dbo SYSCONFIG MODIFIEDDATETIME datetime 8 4
dbo SYSCONFIG MODIFIEDBY nvarchar 10 5
dbo SYSCONFIG CREATEDDATETIME datetime 8 6
dbo SYSCONFIG CREATEDBY nvarchar 10 7
dbo SYSCONFIG RECVERSION int 4 8
dbo SYSCONFIG RECID bigint 8 9
dbo BATCHJOB CAPTION nvarchar 200 1
dbo BATCHJOB STATUS int 4 2
dbo BATCHJOB ORIGSTARTDATETIME datetime 8 3
dbo BATCHJOB ORIGSTARTDATETIMETZID int 4 4
dbo BATCHJOB STARTDATETIME datetime 8 5
dbo BATCHJOB ENDDATETIME datetime 8 6
dbo BATCHJOB RECURRENCEDATA image 16 7
dbo BATCHJOB ALERTSPROCESSED int 4 8
dbo BATCHJOB CANCELEDBY nvarchar 10 9
dbo BATCHJOB COMPANY nvarchar 8 10
dbo BATCHJOB LOGLEVEL int 4 11
dbo BATCHJOB BATCHCREATEDBY nvarchar 10 12
dbo BATCHJOB MODIFIEDDATETIME datetime 8 13
dbo BATCHJOB MODIFIEDBY nvarchar 10 14
dbo BATCHJOB CREATEDDATETIME datetime 8 15
dbo BATCHJOB CREATEDBY nvarchar 10 16
dbo BATCHJOB RECVERSION int 4 17
dbo BATCHJOB RECID bigint 8 18
dbo BATCHJOBHISTORY BATCHJOBID bigint 8 1
dbo BATCHJOBHISTORY CAPTION nvarchar 200 2
dbo BATCHJOBHISTORY STATUS int 4 3
dbo BATCHJOBHISTORY ORIGSTARTDATETIME datetime 8 4
dbo BATCHJOBHISTORY STARTDATETIME datetime 8 5
dbo BATCHJOBHISTORY ENDDATETIME datetime 8 6
dbo BATCHJOBHISTORY COMPANY nvarchar 8 7
dbo BATCHJOBHISTORY CANCELEDBY nvarchar 10 8
dbo BATCHJOBHISTORY BATCHCREATEDBY nvarchar 10 9
dbo BATCHJOBHISTORY RECVERSION int 4 10
dbo BATCHJOBHISTORY RECID bigint 8 11
dbo BATCH BATCHJOBID bigint 8 1
dbo BATCH GROUPID nvarchar 20 2
dbo BATCH STATUS int 4 3
dbo BATCH CLASSNUMBER int 4 4
dbo BATCH CAPTION nvarchar 200 5
dbo BATCH STARTDATETIME datetime 8 6
dbo BATCH ENDDATETIME datetime 8 7
dbo BATCH SERVERID nvarchar 40 8
dbo BATCH COMPANY nvarchar 8 9
dbo BATCH PARAMETERS image 16 10
dbo BATCH INFO image 16 11
dbo BATCH SESSIONIDX int 4 12
dbo BATCH SESSIONLOGINDATETIME datetime 8 13
dbo BATCH RECVERSION int 4 14
dbo BATCH RECID bigint 8 15
dbo BATCHGROUP GROUP_ nvarchar 20 1
dbo BATCHGROUP DESCRIPTION nvarchar 120 2
dbo BATCHGROUP RECVERSION int 4 3
dbo BATCHGROUP RECID bigint 8 4
dbo NUMBERSEQUENCETABLE NUMBERSEQUENCE nvarchar 40 1
dbo NUMBERSEQUENCETABLE TXT nvarchar 120 2
dbo NUMBERSEQUENCETABLE LOWEST int 4 3
dbo NUMBERSEQUENCETABLE HIGHEST int 4 4
dbo NUMBERSEQUENCETABLE NEXTREC int 4 5
dbo NUMBERSEQUENCETABLE BLOCKED int 4 6
dbo NUMBERSEQUENCETABLE FORMAT nvarchar 40 7
dbo NUMBERSEQUENCETABLE CONTINUOUS int 4 8
dbo NUMBERSEQUENCETABLE CYCLIC int 4 9
dbo NUMBERSEQUENCETABLE CLEANATACCESS int 4 10
dbo NUMBERSEQUENCETABLE INUSE int 4 11
dbo NUMBERSEQUENCETABLE NOINCREMENT int 4 12
dbo NUMBERSEQUENCETABLE CLEANINTERVAL numeric 13 13
dbo NUMBERSEQUENCETABLE LATESTCLEANDATETIME datetime 8 14
dbo NUMBERSEQUENCETABLE ALLOWCHANGEUP int 4 15
dbo NUMBERSEQUENCETABLE ALLOWCHANGEDOWN int 4 16
dbo NUMBERSEQUENCETABLE MANUAL int 4 17
dbo NUMBERSEQUENCETABLE FETCHAHEADQTY int 4 18
dbo NUMBERSEQUENCETABLE FETCHAHEAD int 4 19
dbo NUMBERSEQUENCETABLE DATAAREAID nvarchar 8 20
dbo NUMBERSEQUENCETABLE RECVERSION int 4 21
dbo NUMBERSEQUENCETABLE RECID bigint 8 22
dbo NUMBERSEQUENCEREFERENCE NUMBERSEQUENCE nvarchar 40 1
dbo NUMBERSEQUENCEREFERENCE DATATYPEID int 4 2
dbo NUMBERSEQUENCEREFERENCE CONFIGURATIONKEYID int 4 3
dbo NUMBERSEQUENCEREFERENCE MODULE int 4 4
dbo NUMBERSEQUENCEREFERENCE REFERENCEHELP nvarchar 510 5
dbo NUMBERSEQUENCEREFERENCE REFERENCELABEL nvarchar 120 6
dbo NUMBERSEQUENCEREFERENCE DATATYPESAMEASID int 4 7
dbo NUMBERSEQUENCEREFERENCE WIZARDCONTINUOUS int 4 8
dbo NUMBERSEQUENCEREFERENCE ALLOWSAMEAS int 4 9
dbo NUMBERSEQUENCEREFERENCE SORTFIELD int 4 10
dbo NUMBERSEQUENCEREFERENCE GROUPENABLED int 4 11
dbo NUMBERSEQUENCEREFERENCE WIZARDHIGHEST int 4 12
dbo NUMBERSEQUENCEREFERENCE WIZARDLOWEST int 4 13
dbo NUMBERSEQUENCEREFERENCE WIZARDMANUAL int 4 14
dbo NUMBERSEQUENCEREFERENCE WIZARDFETCHAHEADQTY int 4 15
dbo NUMBERSEQUENCEREFERENCE DATAAREAID nvarchar 8 16
dbo NUMBERSEQUENCEREFERENCE RECVERSION int 4 17
dbo NUMBERSEQUENCEREFERENCE RECID bigint 8 18
dbo SYSTEMSEQUENCES ID int 4 1
dbo SYSTEMSEQUENCES NEXTVAL bigint 8 2
dbo SYSTEMSEQUENCES MINVAL bigint 8 3
dbo SYSTEMSEQUENCES MAXVAL bigint 8 4
dbo SYSTEMSEQUENCES CYCLE int 4 5
dbo SYSTEMSEQUENCES NAME nvarchar 40 6
dbo SYSTEMSEQUENCES TABID int 4 7
dbo SYSTEMSEQUENCES DATAAREAID nvarchar 8 8
dbo SYSTEMSEQUENCES RECVERSION int 4 9
dbo SYSTEMSEQUENCES RECID bigint 8 10
Can't render this file because it has a wrong number of fields in line 2.

View file

@ -0,0 +1,135 @@
skema tabel kolonne type max_length column_id
# Baseret på Microsofts dokumenterede skema – ikke verificeret mod kundedata. Erstattes af schema-ax2012.tsv, når en kunde leverer det rigtige skema.
dbo SQLDICTIONARY TABLEID int 4 1
dbo SQLDICTIONARY FIELDID int 4 2
dbo SQLDICTIONARY ARRAY int 4 3
dbo SQLDICTIONARY NAME nvarchar 80 4
dbo SQLDICTIONARY SQLNAME nvarchar 80 5
dbo SQLDICTIONARY FIELDTYPE int 4 6
dbo SQLDICTIONARY STRSIZE int 4 7
dbo SQLDICTIONARY SHADOW int 4 8
dbo SQLDICTIONARY RIGHTJUSTIFY int 4 9
dbo SQLDICTIONARY NULLABLE int 4 10
dbo SQLDICTIONARY FLAGS int 4 11
dbo SQLDICTIONARY RECVERSION int 4 12
dbo SQLDICTIONARY RECID bigint 8 13
dbo DATAAREA ID nvarchar 8 1
dbo DATAAREA NAME nvarchar 120 2
dbo DATAAREA ISVIRTUAL int 4 3
dbo DATAAREA ALWAYSNATIVE int 4 4
dbo DATAAREA TIMEZONE int 4 5
dbo DATAAREA RECVERSION int 4 6
dbo DATAAREA PARTITION bigint 8 7
dbo DATAAREA RECID bigint 8 8
dbo SYSCONFIG CONFIGTYPE int 4 1
dbo SYSCONFIG ID int 4 2
dbo SYSCONFIG VALUE nvarchar 160 3
dbo SYSCONFIG MODIFIEDDATETIME datetime 8 4
dbo SYSCONFIG MODIFIEDBY nvarchar 16 5
dbo SYSCONFIG CREATEDDATETIME datetime 8 6
dbo SYSCONFIG CREATEDBY nvarchar 16 7
dbo SYSCONFIG RECVERSION int 4 8
dbo SYSCONFIG PARTITION bigint 8 9
dbo SYSCONFIG RECID bigint 8 10
dbo BATCHJOB CAPTION nvarchar 200 1
dbo BATCHJOB STATUS int 4 2
dbo BATCHJOB ORIGSTARTDATETIME datetime 8 3
dbo BATCHJOB ORIGSTARTDATETIMETZID int 4 4
dbo BATCHJOB STARTDATETIME datetime 8 5
dbo BATCHJOB ENDDATETIME datetime 8 6
dbo BATCHJOB RECURRENCEDATA image 16 7
dbo BATCHJOB ALERTSPROCESSED int 4 8
dbo BATCHJOB CANCELEDBY nvarchar 16 9
dbo BATCHJOB COMPANY nvarchar 8 10
dbo BATCHJOB LOGLEVEL int 4 11
dbo BATCHJOB BATCHCREATEDBY nvarchar 16 12
dbo BATCHJOB RUNTIMEJOB int 4 13
dbo BATCHJOB MODIFIEDDATETIME datetime 8 14
dbo BATCHJOB MODIFIEDBY nvarchar 16 15
dbo BATCHJOB CREATEDDATETIME datetime 8 16
dbo BATCHJOB CREATEDBY nvarchar 16 17
dbo BATCHJOB RECVERSION int 4 18
dbo BATCHJOB PARTITION bigint 8 19
dbo BATCHJOB RECID bigint 8 20
dbo BATCHJOBHISTORY BATCHJOBID bigint 8 1
dbo BATCHJOBHISTORY CAPTION nvarchar 200 2
dbo BATCHJOBHISTORY STATUS int 4 3
dbo BATCHJOBHISTORY ORIGSTARTDATETIME datetime 8 4
dbo BATCHJOBHISTORY STARTDATETIME datetime 8 5
dbo BATCHJOBHISTORY ENDDATETIME datetime 8 6
dbo BATCHJOBHISTORY COMPANY nvarchar 8 7
dbo BATCHJOBHISTORY CANCELEDBY nvarchar 16 8
dbo BATCHJOBHISTORY BATCHCREATEDBY nvarchar 16 9
dbo BATCHJOBHISTORY RECVERSION int 4 10
dbo BATCHJOBHISTORY PARTITION bigint 8 11
dbo BATCHJOBHISTORY RECID bigint 8 12
dbo BATCH BATCHJOBID bigint 8 1
dbo BATCH GROUPID nvarchar 20 2
dbo BATCH STATUS int 4 3
dbo BATCH CLASSNUMBER int 4 4
dbo BATCH CAPTION nvarchar 200 5
dbo BATCH STARTDATETIME datetime 8 6
dbo BATCH ENDDATETIME datetime 8 7
dbo BATCH SERVERID nvarchar 40 8
dbo BATCH COMPANY nvarchar 8 9
dbo BATCH PARAMETERS image 16 10
dbo BATCH INFO image 16 11
dbo BATCH SESSIONIDX int 4 12
dbo BATCH SESSIONLOGINDATETIME datetime 8 13
dbo BATCH RETRYCOUNT int 4 14
dbo BATCH RECVERSION int 4 15
dbo BATCH PARTITION bigint 8 16
dbo BATCH RECID bigint 8 17
dbo BATCHGROUP GROUP_ nvarchar 20 1
dbo BATCHGROUP DESCRIPTION nvarchar 120 2
dbo BATCHGROUP RECVERSION int 4 3
dbo BATCHGROUP PARTITION bigint 8 4
dbo BATCHGROUP RECID bigint 8 5
dbo NUMBERSEQUENCETABLE NUMBERSEQUENCE nvarchar 40 1
dbo NUMBERSEQUENCETABLE TXT nvarchar 120 2
dbo NUMBERSEQUENCETABLE LOWEST bigint 8 3
dbo NUMBERSEQUENCETABLE HIGHEST bigint 8 4
dbo NUMBERSEQUENCETABLE NEXTREC bigint 8 5
dbo NUMBERSEQUENCETABLE BLOCKED int 4 6
dbo NUMBERSEQUENCETABLE FORMAT nvarchar 80 7
dbo NUMBERSEQUENCETABLE ANNOTATEDFORMAT nvarchar 80 8
dbo NUMBERSEQUENCETABLE CONTINUOUS int 4 9
dbo NUMBERSEQUENCETABLE CYCLIC int 4 10
dbo NUMBERSEQUENCETABLE CLEANATACCESS int 4 11
dbo NUMBERSEQUENCETABLE INUSE int 4 12
dbo NUMBERSEQUENCETABLE NOINCREMENT int 4 13
dbo NUMBERSEQUENCETABLE CLEANINTERVAL numeric 13 14
dbo NUMBERSEQUENCETABLE LATESTCLEANDATETIME datetime 8 15
dbo NUMBERSEQUENCETABLE ALLOWCHANGEUP int 4 16
dbo NUMBERSEQUENCETABLE ALLOWCHANGEDOWN int 4 17
dbo NUMBERSEQUENCETABLE MANUAL int 4 18
dbo NUMBERSEQUENCETABLE FETCHAHEADQTY int 4 19
dbo NUMBERSEQUENCETABLE FETCHAHEAD int 4 20
dbo NUMBERSEQUENCETABLE NUMBERSEQUENCESCOPE bigint 8 21
dbo NUMBERSEQUENCETABLE RECVERSION int 4 22
dbo NUMBERSEQUENCETABLE PARTITION bigint 8 23
dbo NUMBERSEQUENCETABLE RECID bigint 8 24
dbo NUMBERSEQUENCESCOPE DATAAREA nvarchar 8 1
dbo NUMBERSEQUENCESCOPE LEGALENTITY bigint 8 2
dbo NUMBERSEQUENCESCOPE OPERATINGUNIT bigint 8 3
dbo NUMBERSEQUENCESCOPE OPERATINGUNITTYPE int 4 4
dbo NUMBERSEQUENCESCOPE FISCALCALENDARPERIOD bigint 8 5
dbo NUMBERSEQUENCESCOPE RECVERSION int 4 6
dbo NUMBERSEQUENCESCOPE PARTITION bigint 8 7
dbo NUMBERSEQUENCESCOPE RECID bigint 8 8
dbo NUMBERSEQUENCEREFERENCE NUMBERSEQUENCEDATATYPE bigint 8 1
dbo NUMBERSEQUENCEREFERENCE NUMBERSEQUENCEID bigint 8 2
dbo NUMBERSEQUENCEREFERENCE NUMBERSEQUENCESCOPE bigint 8 3
dbo NUMBERSEQUENCEREFERENCE RECVERSION int 4 4
dbo NUMBERSEQUENCEREFERENCE PARTITION bigint 8 5
dbo NUMBERSEQUENCEREFERENCE RECID bigint 8 6
dbo SYSTEMSEQUENCES ID int 4 1
dbo SYSTEMSEQUENCES NEXTVAL bigint 8 2
dbo SYSTEMSEQUENCES MINVAL bigint 8 3
dbo SYSTEMSEQUENCES MAXVAL bigint 8 4
dbo SYSTEMSEQUENCES CYCLE int 4 5
dbo SYSTEMSEQUENCES NAME nvarchar 40 6
dbo SYSTEMSEQUENCES TABID int 4 7
dbo SYSTEMSEQUENCES DATAAREAID nvarchar 8 8
dbo SYSTEMSEQUENCES RECVERSION int 4 9
dbo SYSTEMSEQUENCES RECID bigint 8 10
Can't render this file because it has a wrong number of fields in line 2.

View file

@ -90,6 +90,8 @@ Agenten opretter tabellerne selv ved første start.
Kør [`db/permissions/grant-least-privilege.sql`](../db/permissions/grant-least-privilege.sql) på hver instans efter at have oprettet agentens login. Agenten bliver aldrig sysadmin.
Overvåges Dynamics AX, kan agenten desuden få læseadgang til nogle få AX-systemtabeller (batchjob, nummerserier, RecId) med [`db/permissions/grant-dynamics-ax-read.sql`](../db/permissions/grant-dynamics-ax-read.sql). Det er et tilvalg pr. database; se [dynamics-ax.md](dynamics-ax.md).
## 4. Agenten som Windows-tjeneste
```powershell
@ -184,4 +186,4 @@ Se [opdatering.md](opdatering.md): regelpakken opdateres automatisk, og nye vers
## Menuen i webbrugerfladen
Menuen er delt i grupper, der kan foldes ud og sammen: **Overvågning** (med *Servere og databaser*, se [servere-og-databaser.md](servere-og-databaser.md)), **Analyse**, **Konfiguration og sikkerhed**, **Rapporter**, **Dynamics AX** (kun når en database er genkendt som AX), **Support**, **System** og **Community**. Gruppen med den side, man står på, er åben. Sider, der findes pr. instans (fx Analyse af tidsrum og Konfiguration), åbner et valg af instans; er der kun én instans, går de direkte til den. På en smal skærm (mobil) folder hele menuen sig sammen bag knappen **Menu**. Alt virker uden JavaScript.
Menuen er delt i grupper, der kan foldes ud og sammen: **Overvågning** (med *Servere og databaser*, se [servere-og-databaser.md](servere-og-databaser.md)), **Analyse**, **Konfiguration og sikkerhed**, **Rapporter**, **Dynamics AX** (*AX-databaser*, *Batchjob*, *Nummerserier*, *RecId* og *AX-brugere bag blokering*; kun når en database er genkendt som AX, se [dynamics-ax.md](dynamics-ax.md)), **Support**, **System** og **Community**. Gruppen med den side, man står på, er åben. Sider, der findes pr. instans (fx Analyse af tidsrum og Konfiguration), åbner et valg af instans; er der kun én instans, går de direkte til den. På en smal skærm (mobil) folder hele menuen sig sammen bag knappen **Menu**. Alt virker uden JavaScript.

View file

@ -89,6 +89,7 @@ Alt, hvad webbrugerfladen viser, kan hentes som JSON under `/api/v1/` med de sam
| `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) |
| `GET /api/v1/dynamics-ax/databases`, `…/grant-script`, `…/batch-jobs`, `…/batch-runs`, `…/number-sequences`, `…/recids`, `…/sequence-samples`, `…/blocking` | Dynamics AX: genkendte AX-databaser med læseadgang og GRANT, batchjob og kørsler, nummerserier, RecId, målingerne bag prognoserne og AX-brugere bag blokering. AX-brugere og logins er `[udeladt]`; se [dynamics-ax.md](dynamics-ax.md#api) |
| `GET`/`POST /api/v1/monitored-instances`, `…/{id}`, `…/{id}/connection-test`, `…/{id}/discovery`, `…/{id}/databases`, `…/{id}/grant-script`, `POST /api/v1/instance-discovery`, `GET /api/v1/agent-requests/{id}` | Hvilke servere og databaser der overvåges, forbindelsestest, søgning og GRANT-script: se [servere-og-databaser.md](servere-og-databaser.md#api) (kræver scope `config:write` for at ændre) |
| `GET /metrics` | Prometheus-format |

View file

@ -13,6 +13,16 @@ Begge kommer fra et **opdateringsfeed**: en https-adresse eller en mappe/et fild
Læs dette, før en ny version installeres. Repository-skemaet opgraderes af agenten ved første start.
### 1.4: Dynamics AX
- **Ny menu – Dynamics AX:** vises, så snart en overvåget database er genkendt som Dynamics AX, med siderne AX-databaser, Batchjob, Nummerserier, RecId og AX-brugere bag blokering, og de samme data i API v1 under `/api/v1/dynamics-ax/…` (scope `read`). Nye regler `SQLI-AX-001` … `SQLI-AX-010`. Se [dynamics-ax.md](dynamics-ax.md).
- **Ny, valgfri læseadgang:** batchjob, nummerserier og RecId kræver `SELECT` på nogle få af AX' egne systemtabeller (aldrig kundedata, aldrig mere end `SELECT`). Den gives pr. AX-database med [db/permissions/grant-dynamics-ax-read.sql](../db/permissions/grant-dynamics-ax-read.sql) (og fjernes igen med `revoke-dynamics-ax-read.sql`); den nøjagtige GRANT står under **Dynamics AX → AX-databaser**. SQLInsight kører aldrig selv scriptet.
- **Uden den GRANT ændres intet:** agenten læser ingen AX-tabeller, de nye AX-regler er *ikke målbare* med den GRANT, der skal til, og alt andet virker som i 1.3. Genkendelsen af AX og AX-brugere bag blokering bruger kun de rettigheder, `grant-least-privilege.sql` allerede giver. Læsningerne er altid `SELECT` med `TOP`, med 2 sekunders lock timeout (eller snapshot), aldrig `NOLOCK`, og kun i databaser, der er genkendt som AX **og** valgt til overvågning.
- **AX-fund til SQLInsight Hub** følger stadig tilvalget pr. database fra 1.3 (standard fra) og gælder nu også `SQLI-AX-…`.
- **Axapta 3.0/4.0 og tidszone:** de lokale batchtider omregnes med AOS'ens tidszone. Kører agenten i en anden tidszone end AOS'en, så sæt `SQLInsight:DynamicsAx:LocalTimeZone` (fx `Romance Standard Time`).
- **AX-versioner:** kun Axapta 3.0 er verificeret mod en kundes database. AX 4.0, AX 2009, AX 2012 og Dynamics 365 F&O er bygget efter Microsofts dokumenterede skema og **ikke verificeret mod kundedata** – mangler en tabel eller kolonne, er tjekket *ikke målbart* med det præcise navn i stedet for at fejle.
- **Repository:** migration 0017 (tabellerne `insight.AxBatchRuns` og `insight.AxSequenceSamples`) køres af agenten ved første start; de ryddes op efter `EventDays` og `HourlyDays` som de øvrige data.
### 1.3: Servere og databaser
- **Ændret adfærd – Dynamics AX-fund og SQLInsight Hub:** fra 1.3 sendes AX-fund (regler `COM-AX-…`) og AX-reglernes resultater **kun** til SQLInsight Hub for de databaser, hvor *AX-fund til SQLInsight Hub* er slået til under **Overvågning → Servere og databaser**. Standard er **fra** for alle databaser, også efter opgraderingen. Har I delt AX-fund med hubben i 1.2, stopper det altså ved opgraderingen, indtil I slår det til for hver AX-database. Andre fund sendes som før. Se [servere-og-databaser.md](servere-og-databaser.md#dynamics-ax-fund-og-sqlinsight-hub) og [databehandling.md](databehandling.md).
@ -138,18 +148,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
# MSI til installationer lavet med MSI'en (bygges på Linux)
installer/build-msi.sh 1.3.0
installer/build-msi.sh 1.4.0
# 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
dotnet publish src/SQLInsight.Agent -c Release -r win-x64 --self-contained true -p:Version=1.3.0 -o pkg/win-x64/Agent
dotnet publish src/SQLInsight.Web -c Release -r win-x64 --self-contained true -p:Version=1.3.0 -o pkg/win-x64/Web
dotnet publish src/SQLInsight.Agent -c Release -r win-x64 --self-contained true -p:Version=1.4.0 -o pkg/win-x64/Agent
dotnet publish src/SQLInsight.Web -c Release -r win-x64 --self-contained true -p:Version=1.4.0 -o pkg/win-x64/Web
# 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 \
--feed feed --channel stable --rulepack rulepack-2026.10.14.json \
--release 1.3.0 --asset win-x64=sqlinsight-1.3.0-win-x64.zip --asset win-x64-msi=dist/SQLInsight-Pro-1.3.0-x64.msi \
--asset linux-x64=sqlinsight-1.3.0-linux-x64.zip \
--release 1.4.0 --asset win-x64=sqlinsight-1.4.0-win-x64.zip --asset win-x64-msi=dist/SQLInsight-Pro-1.4.0-x64.msi \
--asset linux-x64=sqlinsight-1.4.0-linux-x64.zip \
--notes https://git.seosoft.dk/seo/sqlinsight-pro/releases
```

View file

@ -39,7 +39,7 @@ Under **Rettigheder til agentens login** står scriptet udfyldt med det rigtige
## Databaser
Agenten finder alle databaser på serveren, når serveren tilføjes, og derefter hver time. For hver database vises tilstand, størrelse, recovery model, compatibility level, om agenten kan åbne den, og om den er en Dynamics AX-database (AX 3.0 eller 4.0 og nyere, genkendt på AX' systemtabeller som AX-reglerne). Søgningen læser kun metadata og skifter aldrig database.
Agenten finder alle databaser på serveren, når serveren tilføjes, og derefter hver time. For hver database vises tilstand, størrelse, recovery model, compatibility level, om agenten kan åbne den, og om den er en Dynamics AX-database og hvilken version (Axapta 3.0, AX 4.0, 2009, 2012, 2012 R2/R3 eller F&O, genkendt på AX' systemtabeller af samme genkendelse som AX-tjekkene; se [dynamics-ax.md](dynamics-ax.md)). Søgningen læser kun metadata og skifter aldrig database.
Valget styrer de **databasespecifikke** tjek: indstillinger, backup, CHECKDB, VLF'er, indexes, Query Store og community-regler. Instansdækkende målinger (ventetyper, CPU, blokeringer, job, fejllog, rettigheder) gælder altid hele instansen, og systemdatabaserne (master, model, msdb, tempdb) overvåges altid. Fravælges en database, løses dens åbne fund ved næste runde.

View file

@ -1,10 +1,10 @@
// Microsoft Dynamics AX (Axapta), with emphasis on AX 3.0.
//
// The agent has no data access (grant-least-privilege.sql), so these rules only read metadata and DMVs. They run in
// every user database, so an AX database is recognised without naming its tables directly (that would fail to compile
// elsewhere): the AX system tables SQLDICTIONARY, DATAAREA and SYSCONFIG in sys.tables, in ANY schema: AX 3.0 owns its
// tables as the database user bmssa (bmssa.SQLDICTIONARY), later versions use dbo. AX 3.0 is told apart from AX 4.0
// and later by RECID being 32-bit (int) instead of 64-bit (bigint).
// The agent has no data access (grant-least-privilege.sql), so these rules only read metadata and DMVs. They are marked
// "dynamicsAx": the agent runs them only in databases its central detection (Core/DynamicsAx/AxDetection.cs) recognises as
// Dynamics AX (SQLDICTIONARY and DATAAREA in one schema: bmssa for AX 3.0, dbo later) and that are chosen for monitoring
// in Servere og databaser, and their findings only reach SQLInsight Hub for databases where AX findings may be sent. AX 3.0 is
// told apart from AX 4.0 and later by RECID being 32-bit (int) instead of 64-bit (bigint).
//
// Everything is SQL Server 2005 syntax (no CONCAT, IIF or STRING_AGG): AX 3.0 typically runs on 2000 or 2005.
[
@ -13,7 +13,8 @@
"title": "Dynamics AX 3.0 er uden support fra Microsoft",
"severity": "Medium",
"scope": "Database",
"query": "SELECT CAST(SERVERPROPERTY('ProductVersion') AS nvarchar(128)) AS [sql_version] WHERE EXISTS (SELECT 1 FROM sys.tables WHERE name = N'SQLDICTIONARY') AND EXISTS (SELECT 1 FROM sys.tables WHERE name = N'DATAAREA') AND EXISTS (SELECT 1 FROM sys.tables WHERE name = N'SYSCONFIG') AND COLUMNPROPERTY((SELECT TOP 1 object_id FROM sys.tables WHERE name = N'SQLDICTIONARY'), N'RECID', 'Precision') = 10",
"dynamicsAx": true,
"query": "SELECT CAST(SERVERPROPERTY('ProductVersion') AS nvarchar(128)) AS [sql_version] WHERE COLUMNPROPERTY((SELECT TOP 1 object_id FROM sys.tables WHERE name = N'SQLDICTIONARY'), N'RECID', 'Precision') = 10",
"subject": "database: {database}",
"findingTitle": "{database} er en Dynamics AX 3.0-database, og AX 3.0 er uden support",
"explanation": "Microsoft Dynamics AX 3.0 er for længst ude af Microsofts support, også den udvidede. Der kommer ingen sikkerhedsopdateringer eller rettelser til AX-kernen, klienten eller AOS, og nyere Windows- og SQL Server-versioner er aldrig testet med AX 3.0. AX 3.0 bruger desuden 32-bit RecId (int), som kan løbe tør på store installationer.",
@ -26,7 +27,8 @@
"title": "Dynamics AX 3.0 kører på en SQL Server-version, der ikke var understøttet til AX 3.0",
"severity": "Medium",
"scope": "Database",
"query": "SELECT CAST(SERVERPROPERTY('ProductVersion') AS nvarchar(128)) AS [sql_version], d.compatibility_level AS [compatibility_level] FROM sys.databases d WHERE d.database_id = DB_ID() AND CAST(PARSENAME(CAST(SERVERPROPERTY('ProductVersion') AS nvarchar(128)), 4) AS int) > 9 AND EXISTS (SELECT 1 FROM sys.tables WHERE name = N'SQLDICTIONARY') AND EXISTS (SELECT 1 FROM sys.tables WHERE name = N'DATAAREA') AND EXISTS (SELECT 1 FROM sys.tables WHERE name = N'SYSCONFIG') AND COLUMNPROPERTY((SELECT TOP 1 object_id FROM sys.tables WHERE name = N'SQLDICTIONARY'), N'RECID', 'Precision') = 10",
"dynamicsAx": true,
"query": "SELECT CAST(SERVERPROPERTY('ProductVersion') AS nvarchar(128)) AS [sql_version], d.compatibility_level AS [compatibility_level] FROM sys.databases d WHERE d.database_id = DB_ID() AND CAST(PARSENAME(CAST(SERVERPROPERTY('ProductVersion') AS nvarchar(128)), 4) AS int) > 9 AND COLUMNPROPERTY((SELECT TOP 1 object_id FROM sys.tables WHERE name = N'SQLDICTIONARY'), N'RECID', 'Precision') = 10",
"subject": "database: {database}",
"findingTitle": "AX 3.0-databasen {database} ligger på SQL Server {sql_version}",
"explanation": "AX 3.0 blev kun frigivet til SQL Server 2000 og 2005. På nyere versioner kan den virke, men optimizeren, låsning og udfasede funktioner opfører sig anderledes end det, AX-kernen blev bygget til, og hverken Microsoft eller en AX-partner kan give support. Fejl viser sig ofte først under belastning, fx ved månedsafslutning. Databasens compatibility level er {compatibility_level}.",
@ -39,7 +41,8 @@
"title": "Dynamics AX-database på en instans, hvor MAXDOP ikke er 1 (SQL Server 2005-2014)",
"severity": "Medium",
"scope": "Database",
"query": "SELECT CAST(c.value_in_use AS int) AS [maxdop] FROM sys.configurations c WHERE c.name = 'max degree of parallelism' AND CAST(c.value_in_use AS int) <> 1 AND CAST(PARSENAME(CAST(SERVERPROPERTY('ProductVersion') AS nvarchar(128)), 4) AS int) < 13 AND EXISTS (SELECT 1 FROM sys.tables WHERE name = N'SQLDICTIONARY') AND EXISTS (SELECT 1 FROM sys.tables WHERE name = N'DATAAREA') AND EXISTS (SELECT 1 FROM sys.tables WHERE name = N'SYSCONFIG')",
"dynamicsAx": true,
"query": "SELECT CAST(c.value_in_use AS int) AS [maxdop] FROM sys.configurations c WHERE c.name = 'max degree of parallelism' AND CAST(c.value_in_use AS int) <> 1 AND CAST(PARSENAME(CAST(SERVERPROPERTY('ProductVersion') AS nvarchar(128)), 4) AS int) < 13",
"subject": "database: {database}",
"findingTitle": "MAXDOP er {maxdop} på instansen med Dynamics AX-databasen {database}",
"explanation": "Dynamics AX sender mange små, hyppige forespørgsler og cursorer fra AOS. Når de får parallelle planer, bruger de flere tråde end nødvendigt, venter på hinanden (CXPACKET) og holder låse længere, så brugerne oplever langsomme formularer og blokering. Microsofts vejledning for Dynamics AX er derfor max degree of parallelism = 1 på instansen.",
@ -55,8 +58,9 @@
"title": "Dynamics AX-database, hvor MAXDOP ikke er 1 (SQL Server 2016 og nyere)",
"severity": "Medium",
"scope": "Database",
"dynamicsAx": true,
"minimumVersion": "13.0",
"query": "SELECT CAST(c.value_in_use AS int) AS [server_maxdop], CAST(s.value AS int) AS [database_maxdop] FROM sys.configurations c CROSS JOIN sys.database_scoped_configurations s WHERE c.name = 'max degree of parallelism' AND s.name = 'MAXDOP' AND (CASE WHEN CAST(s.value AS int) = 0 THEN CAST(c.value_in_use AS int) ELSE CAST(s.value AS int) END) <> 1 AND EXISTS (SELECT 1 FROM sys.tables WHERE name = N'SQLDICTIONARY') AND EXISTS (SELECT 1 FROM sys.tables WHERE name = N'DATAAREA') AND EXISTS (SELECT 1 FROM sys.tables WHERE name = N'SYSCONFIG')",
"query": "SELECT CAST(c.value_in_use AS int) AS [server_maxdop], CAST(s.value AS int) AS [database_maxdop] FROM sys.configurations c CROSS JOIN sys.database_scoped_configurations s WHERE c.name = 'max degree of parallelism' AND s.name = 'MAXDOP' AND (CASE WHEN CAST(s.value AS int) = 0 THEN CAST(c.value_in_use AS int) ELSE CAST(s.value AS int) END) <> 1",
"subject": "database: {database}",
"findingTitle": "Dynamics AX-databasen {database} kører med MAXDOP større end 1",
"explanation": "Dynamics AX sender mange små, hyppige forespørgsler og cursorer fra AOS. Når de får parallelle planer, bruger de flere tråde end nødvendigt, venter på hinanden (CXPACKET) og holder låse længere. Microsofts vejledning for Dynamics AX er MAXDOP 1. Instansen har {server_maxdop}, og databasen har {database_maxdop} (0 = instansens værdi gælder).",
@ -72,7 +76,8 @@
"title": "Dynamics AX-tabeller uden unikt RecId-indeks",
"severity": "Medium",
"scope": "Database",
"query": "SELECT COUNT(*) AS [tables], LEFT(STUFF((SELECT TOP 10 N', ' + t2.name FROM sys.tables t2 WHERE COLUMNPROPERTY(t2.object_id, N'RECID', 'ColumnId') IS NOT NULL AND NOT EXISTS (SELECT 1 FROM sys.indexes i JOIN sys.index_columns ic ON ic.object_id = i.object_id AND ic.index_id = i.index_id AND ic.is_included_column = 0 JOIN sys.columns c ON c.object_id = ic.object_id AND c.column_id = ic.column_id WHERE i.object_id = t2.object_id AND i.is_unique = 1 AND c.name = N'RECID') ORDER BY t2.name FOR XML PATH('')), 1, 2, ''), 400) AS [examples] FROM sys.tables t WHERE COLUMNPROPERTY(t.object_id, N'RECID', 'ColumnId') IS NOT NULL AND NOT EXISTS (SELECT 1 FROM sys.indexes i JOIN sys.index_columns ic ON ic.object_id = i.object_id AND ic.index_id = i.index_id AND ic.is_included_column = 0 JOIN sys.columns c ON c.object_id = ic.object_id AND c.column_id = ic.column_id WHERE i.object_id = t.object_id AND i.is_unique = 1 AND c.name = N'RECID') AND EXISTS (SELECT 1 FROM sys.tables WHERE name = N'SQLDICTIONARY') AND EXISTS (SELECT 1 FROM sys.tables WHERE name = N'DATAAREA') AND EXISTS (SELECT 1 FROM sys.tables WHERE name = N'SYSCONFIG') HAVING COUNT(*) > 0",
"dynamicsAx": true,
"query": "SELECT COUNT(*) AS [tables], LEFT(STUFF((SELECT TOP 10 N', ' + t2.name FROM sys.tables t2 WHERE COLUMNPROPERTY(t2.object_id, N'RECID', 'ColumnId') IS NOT NULL AND NOT EXISTS (SELECT 1 FROM sys.indexes i JOIN sys.index_columns ic ON ic.object_id = i.object_id AND ic.index_id = i.index_id AND ic.is_included_column = 0 JOIN sys.columns c ON c.object_id = ic.object_id AND c.column_id = ic.column_id WHERE i.object_id = t2.object_id AND i.is_unique = 1 AND c.name = N'RECID') ORDER BY t2.name FOR XML PATH('')), 1, 2, ''), 400) AS [examples] FROM sys.tables t WHERE COLUMNPROPERTY(t.object_id, N'RECID', 'ColumnId') IS NOT NULL AND NOT EXISTS (SELECT 1 FROM sys.indexes i JOIN sys.index_columns ic ON ic.object_id = i.object_id AND ic.index_id = i.index_id AND ic.is_included_column = 0 JOIN sys.columns c ON c.object_id = ic.object_id AND c.column_id = ic.column_id WHERE i.object_id = t.object_id AND i.is_unique = 1 AND c.name = N'RECID') HAVING COUNT(*) > 0",
"subject": "database: {database}",
"findingTitle": "{tables} AX-tabeller i {database} har intet unikt indeks på RECID",
"explanation": "AX identificerer hver post med RecId og opretter normalt et unikt indeks på den (I_<tabel-id>RECID) ved synkronisering. Mangler det, er en synkronisering typisk fejlet eller afbrudt, eller indekset er fjernet manuelt. Uden det bliver opslag på RecId (som AX laver hele tiden, fx ved opdatering og sletning af én post) til scanninger, og dubletter opdages ikke. Eksempler: {examples}.",
@ -85,7 +90,8 @@
"title": "Store Dynamics AX-tabeller uden clustered index (heaps)",
"severity": "Medium",
"scope": "Database",
"query": "SELECT COUNT(*) AS [tables], SUM(h.row_count) AS [rows], LEFT(STUFF((SELECT TOP 10 N', ' + OBJECT_NAME(h2.object_id) + N' (' + CAST(h2.row_count AS nvarchar(20)) + N')' FROM (SELECT p.object_id, SUM(p.rows) AS row_count FROM sys.partitions p JOIN sys.tables t ON t.object_id = p.object_id WHERE p.index_id = 0 GROUP BY p.object_id) h2 WHERE h2.row_count >= 100000 ORDER BY h2.row_count DESC FOR XML PATH('')), 1, 2, ''), 400) AS [largest] FROM (SELECT p.object_id, SUM(p.rows) AS row_count FROM sys.partitions p JOIN sys.tables t ON t.object_id = p.object_id WHERE p.index_id = 0 GROUP BY p.object_id) h WHERE h.row_count >= 100000 AND EXISTS (SELECT 1 FROM sys.tables WHERE name = N'SQLDICTIONARY') AND EXISTS (SELECT 1 FROM sys.tables WHERE name = N'DATAAREA') AND EXISTS (SELECT 1 FROM sys.tables WHERE name = N'SYSCONFIG') HAVING COUNT(*) > 0",
"dynamicsAx": true,
"query": "SELECT COUNT(*) AS [tables], SUM(h.row_count) AS [rows], LEFT(STUFF((SELECT TOP 10 N', ' + OBJECT_NAME(h2.object_id) + N' (' + CAST(h2.row_count AS nvarchar(20)) + N')' FROM (SELECT p.object_id, SUM(p.rows) AS row_count FROM sys.partitions p JOIN sys.tables t ON t.object_id = p.object_id WHERE p.index_id = 0 GROUP BY p.object_id) h2 WHERE h2.row_count >= 100000 ORDER BY h2.row_count DESC FOR XML PATH('')), 1, 2, ''), 400) AS [largest] FROM (SELECT p.object_id, SUM(p.rows) AS row_count FROM sys.partitions p JOIN sys.tables t ON t.object_id = p.object_id WHERE p.index_id = 0 GROUP BY p.object_id) h WHERE h.row_count >= 100000 HAVING COUNT(*) > 0",
"subject": "database: {database}",
"findingTitle": "{tables} store AX-tabeller i {database} har intet clustered index",
"explanation": "En tabel uden clustered index (en heap) får spredte data og 'forwarded records', når poster opdateres, og den kan ikke defragmenteres med en indeks-rebuild. I AX styres det af tabellens ClusterIndex-egenskab i AOT, som i AX 3.0 ofte ikke er sat på standard- og kundetabeller. På store tabeller giver det langsomme opslag og mere I/O. I alt {rows} rækker. Største: {largest}.",
@ -98,7 +104,8 @@
"title": "Automatiske statistikker er slået fra i en Dynamics AX-database",
"severity": "High",
"scope": "Database",
"query": "SELECT CASE WHEN d.is_auto_create_stats_on = 1 THEN 'til' ELSE 'fra' END AS [auto_create_statistics], CASE WHEN d.is_auto_update_stats_on = 1 THEN 'til' ELSE 'fra' END AS [auto_update_statistics] FROM sys.databases d WHERE d.database_id = DB_ID() AND (d.is_auto_create_stats_on = 0 OR d.is_auto_update_stats_on = 0) AND EXISTS (SELECT 1 FROM sys.tables WHERE name = N'SQLDICTIONARY') AND EXISTS (SELECT 1 FROM sys.tables WHERE name = N'DATAAREA') AND EXISTS (SELECT 1 FROM sys.tables WHERE name = N'SYSCONFIG')",
"dynamicsAx": true,
"query": "SELECT CASE WHEN d.is_auto_create_stats_on = 1 THEN 'til' ELSE 'fra' END AS [auto_create_statistics], CASE WHEN d.is_auto_update_stats_on = 1 THEN 'til' ELSE 'fra' END AS [auto_update_statistics] FROM sys.databases d WHERE d.database_id = DB_ID() AND (d.is_auto_create_stats_on = 0 OR d.is_auto_update_stats_on = 0)",
"subject": "database: {database}",
"findingTitle": "Automatiske statistikker er slået fra i AX-databasen {database}",
"explanation": "AX bygger sine forespørgsler dynamisk ud fra brugernes filtre, så optimizeren er helt afhængig af, at statistikkerne er der og er opdaterede. Uden automatiske statistikker vælger SQL Server ofte forkerte planer, især efter store kørsler som lageropgørelse og nummerserier, og formularer og batchjob kan pludselig blive meget langsomme. Auto create statistics: {auto_create_statistics}. Auto update statistics: {auto_update_statistics}.",

View file

@ -30,6 +30,8 @@ public sealed class AgentOptions
public TempdbOptions Tempdb { get; set; } = new();
public DynamicsAxOptions DynamicsAx { get; set; } = new();
public IList<MonitoredInstance> Instances { get; } = [];
}
@ -122,3 +124,86 @@ public sealed class TempdbOptions
/// <summary>A session using more tempdb than this (MB) for 15 minutes is a finding (SQLI-TEMP-003).</summary>
public int SessionMegabytes { get; set; } = 1024;
}
/// <summary>
/// The Dynamics AX checks (SQLInsight:DynamicsAx). They only run in databases recognised as AX and chosen for monitoring;
/// the data checks need the optional read access (db/permissions/grant-dynamics-ax-read.sql).
/// </summary>
public sealed class DynamicsAxOptions
{
/// <summary>A waiting batch job this many minutes past its planned start is late (SQLI-AX-003).</summary>
public int BatchLateMinutes { get; set; } = 30;
/// <summary>Late by this many hours: in practice never started (High).</summary>
public int BatchNeverStartedHours { get; set; } = 24;
/// <summary>A batch job executing this many hours is stuck (SQLI-AX-004).</summary>
public int BatchStuckHours { get; set; } = 6;
/// <summary>Running this many times longer than its own 90th percentile is unusually long (SQLI-AX-002).</summary>
public double BatchLongRunningFactor { get; set; } = 2.0;
/// <summary>…but never for runs shorter than this (minutes).</summary>
public int BatchLongRunningMinimumMinutes { get; set; } = 10;
/// <summary>Earlier runs needed before a job is compared with its own history.</summary>
public int BatchHistoryMinimumRuns { get; set; } = 5;
/// <summary>A run that failed within this many hours is reported (SQLI-AX-001).</summary>
public int BatchFailedLookbackHours { get; set; } = 24;
/// <summary>A number sequence used more than this (percent) is a finding (SQLI-AX-005).</summary>
public double NumberSequenceWarnPercent { get; set; } = 80;
/// <summary>…and High from this.</summary>
public double NumberSequenceCriticalPercent { get; set; } = 95;
/// <summary>Forecast to run out within this many days is a finding (number sequences and 64-bit RecId).</summary>
public int ForecastWarnDays { get; set; } = 90;
/// <summary>…and High within this many days.</summary>
public int ForecastCriticalDays { get; set; } = 30;
/// <summary>AX 3.0's 32-bit RecId: a finding from this share of the int range (SQLI-AX-009).</summary>
public double RecId32WarnPercent { get; set; } = 50;
/// <summary>…and Critical from this.</summary>
public double RecId32CriticalPercent { get; set; } = 75;
/// <summary>AX 3.0: forecast to run out within this many days is a finding, Critical within half.</summary>
public int RecId32ForecastDays { get; set; } = 730;
/// <summary>64-bit RecId (or a lowered MAXVAL): a finding from this share.</summary>
public double RecId64WarnPercent { get; set; } = 80;
/// <summary>An AX session blocking others this many seconds is reported with its AX user (SQLI-AX-010).</summary>
public int BlockingSeconds { get; set; } = 30;
/// <summary>How many days back the forecasts look.</summary>
public int ForecastBasisDays { get; set; } = 30;
/// <summary>The AOS's time zone (Windows or IANA id) for Axapta 3.0/4.0's local times; empty: the agent's own zone when it matches the SQL Server.</summary>
public string? LocalTimeZone { get; set; }
public Core.DynamicsAx.AxThresholds ToThresholds() => new()
{
BatchLateMinutes = Math.Max(1, BatchLateMinutes),
BatchNeverStartedHours = Math.Max(1, BatchNeverStartedHours),
BatchStuckHours = Math.Max(1, BatchStuckHours),
BatchLongRunningFactor = Math.Max(1.1, BatchLongRunningFactor),
BatchLongRunningMinimumMinutes = Math.Max(1, BatchLongRunningMinimumMinutes),
BatchHistoryMinimumRuns = Math.Clamp(BatchHistoryMinimumRuns, 1, Core.DynamicsAx.AxBatchHistory.RunsKept),
BatchFailedLookbackHours = Math.Max(1, BatchFailedLookbackHours),
NumberSequenceWarnPercent = Math.Clamp(NumberSequenceWarnPercent, 1, 100),
NumberSequenceCriticalPercent = Math.Clamp(NumberSequenceCriticalPercent, 1, 100),
ForecastWarnDays = Math.Max(1, ForecastWarnDays),
ForecastCriticalDays = Math.Max(1, ForecastCriticalDays),
RecId32WarnPercent = Math.Clamp(RecId32WarnPercent, 1, 100),
RecId32CriticalPercent = Math.Clamp(RecId32CriticalPercent, 1, 100),
RecId32ForecastDays = Math.Max(1, RecId32ForecastDays),
RecId64WarnPercent = Math.Clamp(RecId64WarnPercent, 1, 100),
BlockingSeconds = Math.Max(1, BlockingSeconds),
ForecastBasisDays = Math.Clamp(ForecastBasisDays, 2, 365),
LocalTimeZone = string.IsNullOrWhiteSpace(LocalTimeZone) ? null : LocalTimeZone.Trim(),
};
}

View file

@ -10,6 +10,9 @@ namespace SQLInsight.Agent.Hub;
/// </summary>
public static class SubmissionBuilder
{
/// <summary>What the hub gets instead of an AX rule's reason (which can name databases, tables and logins).</summary>
public const string AxReasonKeptLocally = "Detaljer om Dynamics AX bliver i kundens netværk.";
public static readonly string AgentVersion =
typeof(SubmissionBuilder).Assembly.GetName().Version?.ToString() ?? "0.0.0";
@ -45,7 +48,14 @@ public static class SubmissionBuilder
Edition = info.Edition,
ServerName = options.SendServerName ? info.ServerName : null,
},
Rules = results.Where(r => axRulesAllowed || !Core.Configuration.DynamicsAxFindings.IsDynamicsAxRule(r.RuleId)).Select(r => new RuleOutcomeDto { RuleId = r.RuleId, Outcome = r.Outcome.ToString(), Reason = r.Reason }).ToList(),
// An AX rule's outcome covers every AX database on the instance, and its reason can name databases, tables, logins
// and the GRANT text: only the outcome goes to the hub, with a fixed reason.
Rules = results.Where(r => axRulesAllowed || !Core.Configuration.DynamicsAxFindings.IsDynamicsAxRule(r.RuleId)).Select(r => new RuleOutcomeDto
{
RuleId = r.RuleId,
Outcome = r.Outcome.ToString(),
Reason = Core.Configuration.DynamicsAxFindings.IsDynamicsAxRule(r.RuleId) && r.Reason is not null ? AxReasonKeptLocally : r.Reason,
}).ToList(),
Findings = activeFindings.Where(f => Allowed(f.RuleId, f.Subject)).Select(f => new FindingDto
{
RuleId = f.RuleId,

View file

@ -267,6 +267,32 @@ public sealed partial class InstanceMonitor(
return await repository.GetSnapshotAsync<Core.Data.IndexSnapshot>(instanceId, collector.Name, cancellationToken).ConfigureAwait(false);
}
// Dynamics AX: each batch job's own run history, the first-seen times of running jobs, the history watermark and the
// sequences' daily samples come back from the repository, so comparisons and forecasts continue after a restart.
if (collector is Collectors.DynamicsAx.AxBatchCollector)
{
var stored = await repository.GetSnapshotAsync<Core.DynamicsAx.AxBatchSnapshot>(instanceId, collector.Name, cancellationToken).ConfigureAwait(false)
?? new Core.DynamicsAx.AxBatchSnapshot { CapturedAt = now, Thresholds = new(), Databases = [] };
var history = await repository.GetAxBatchHistoryAsync(instanceId, now.AddDays(-90), cancellationToken).ConfigureAwait(false);
return stored with
{
History = history,
RecentRunKeys = history.ScopedRunKeys().ToHashSet(StringComparer.Ordinal),
FirstSeenExecuting = stored.Databases
.SelectMany(d => d.Jobs.Where(j => j.FirstSeenExecutingUtc is not null)
.Select(j => (Key: Core.DynamicsAx.AxBatchHistory.Key(d.Database, "#" + j.RecId.ToString(System.Globalization.CultureInfo.InvariantCulture)), At: j.FirstSeenExecutingUtc!.Value)))
.GroupBy(x => x.Key, StringComparer.OrdinalIgnoreCase)
.ToDictionary(g => g.Key, g => g.First().At, StringComparer.OrdinalIgnoreCase),
};
}
if (collector is Collectors.DynamicsAx.AxSequenceCollector sequences)
{
var stored = await repository.GetSnapshotAsync<Core.DynamicsAx.AxSequenceSnapshot>(instanceId, collector.Name, cancellationToken).ConfigureAwait(false)
?? new Core.DynamicsAx.AxSequenceSnapshot { CapturedAt = now, Thresholds = new(), Databases = [] };
return stored with { History = await repository.GetAxSequenceHistoryAsync(instanceId, now.AddDays(-sequences.HistoryDays), cancellationToken).ConfigureAwait(false) };
}
if (collector is not Collectors.Concurrency.DeadlockCollector)
{
return null;

View file

@ -81,7 +81,7 @@ public sealed partial class MonitoringService(
private InstanceMonitor CreateMonitor(MonitoredInstance instance, AgentOptions settings, IReadOnlyDictionary<string, TimeSpan> intervals) =>
new(instance, 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,
settings.Tempdb.VersionStoreMegabytes, settings.Tempdb.SessionMegabytes, settings.DynamicsAx.ToThresholds())), intervals, connectionFactory, ruleEngine, repository,
loggerFactory.CreateLogger($"SQLInsight.Instance.{instance.Name}"), notifications, notificationOptions.Value.WebUrl, hubOutbox);
/// <summary>

View file

@ -52,6 +52,16 @@
"EventDays": 90,
"ResolvedFindingDays": 365
},
"DynamicsAx": {
// Dynamics AX-tjek (docs/dynamics-ax.md). Kører kun i databaser genkendt som AX og valgt til overvågning.
"BatchLateMinutes": 30,
"BatchStuckHours": 6,
"BatchLongRunningFactor": 2.0,
"NumberSequenceWarnPercent": 80,
"ForecastWarnDays": 90,
"RecId32WarnPercent": 50
// "LocalTimeZone": "Romance Standard Time" // AOS'ens tidszone for Axapta 3.0/4.0's lokale tider; tom: agentens egen
},
"Instances": [
// Servere tilføjes nemmest i webbrugerfladen (Servere og databaser). Her kan de også stå, fx (se docs/installation.md):
// { "Name": "prod-sql01", "Server": "sql01.domæne.local", "Authentication": "Windows", "Provider": "Auto", "Encryption": "Mandatory" }

View file

@ -12,6 +12,9 @@ public static class BuiltInCollectors
public static IReadOnlyList<ICollector> Create(Core.CustomRules.CustomRuleCatalog? custom = null, CollectorSettings? settings = null)
{
settings ??= new CollectorSettings();
// One Dynamics AX detection per instance, shared by the AX collectors and the AX community rules.
var ax = new DynamicsAx.AxDatabaseDetector();
return
[
new ServerCollector(),
@ -40,7 +43,10 @@ public static class BuiltInCollectors
new IndexCollector(),
new AvailabilityGroupCollector(),
new AgentJobCollector(),
.. custom is null ? Array.Empty<ICollector>() : [new Custom.CustomQueryCollector(custom)],
new DynamicsAx.DynamicsAxCollector(ax),
new DynamicsAx.AxBatchCollector(ax, settings.DynamicsAx),
new DynamicsAx.AxSequenceCollector(ax, settings.DynamicsAx),
.. custom is null ? Array.Empty<ICollector>() : [new Custom.CustomQueryCollector(custom, ax)],
];
}
}
@ -55,6 +61,7 @@ public static class BuiltInCollectors
/// <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>
/// <param name="DynamicsAx">When the Dynamics AX checks raise findings (SQLInsight:DynamicsAx).</param>
public sealed record CollectorSettings(
Core.Data.IGroupMemberResolver? Groups = null,
int MaxSysadminAccounts = 3,
@ -64,4 +71,5 @@ public sealed record CollectorSettings(
int TransactionLogMegabytes = 1024,
int RequestMinutes = 15,
int VersionStoreMegabytes = 1024,
int TempdbSessionMegabytes = 1024);
int TempdbSessionMegabytes = 1024,
Core.DynamicsAx.AxThresholds? DynamicsAx = null);

View file

@ -29,6 +29,9 @@ public sealed class BlockingCollector : Collector<BlockingSnapshot>
: "(SELECT COUNT(*) FROM sys.dm_tran_session_transactions st WHERE st.session_id = s.session_id)";
var database = version.Major >= 11 ? "COALESCE(r.database_id, s.database_id)" : "r.database_id";
// CONTEXT_INFO, where Dynamics AX 2009/2012 AOS can put the AX user (sys.dm_exec_sessions has it from 2008).
var contextInfo = version.Major >= 10 ? "s.context_info" : "(SELECT TOP 1 p.context_info FROM sys.sysprocesses p WHERE p.spid = s.session_id AND p.ecid = 0)";
// blocking_session_id < 0 are special cases (orphaned DTC, deferred recovery, latch) and not a session.
return $"""
SELECT s.session_id, NULLIF(r.blocking_session_id, 0), COALESCE(r.status, s.status), r.wait_type, r.wait_time, r.wait_resource,
@ -38,7 +41,7 @@ public sealed class BlockingCollector : Collector<BlockingSnapshot>
(CASE WHEN r.statement_end_offset = -1 THEN DATALENGTH(t.text) ELSE r.statement_end_offset END
- r.statement_start_offset) / 2 + 1)
ELSE t.text END, 4000),
s.last_request_end_time
s.last_request_end_time, {contextInfo}
FROM sys.dm_exec_sessions s
LEFT JOIN sys.dm_exec_requests r ON r.session_id = s.session_id
LEFT JOIN sys.dm_exec_connections c ON c.session_id = s.session_id
@ -52,7 +55,7 @@ public sealed class BlockingCollector : Collector<BlockingSnapshot>
internal const string Sql2000 = """
SELECT p.spid, NULLIF(CASE WHEN p.blocked = p.spid THEN 0 ELSE p.blocked END, 0), RTRIM(p.status), RTRIM(p.lastwaittype),
p.waittime, RTRIM(p.waitresource), DB_NAME(p.dbid), RTRIM(p.loginame), RTRIM(p.hostname), RTRIM(p.program_name),
p.open_tran, CAST(NULL AS nvarchar(4000)), p.last_batch
p.open_tran, CAST(NULL AS nvarchar(4000)), p.last_batch, p.context_info
FROM master.dbo.sysprocesses p
WHERE (p.blocked > 0 AND p.blocked <> p.spid)
OR p.spid IN (SELECT blocked FROM master.dbo.sysprocesses WHERE blocked > 0 AND blocked <> spid)
@ -73,6 +76,7 @@ public sealed class BlockingCollector : Collector<BlockingSnapshot>
OpenTransactionCount = r.NullableInt(10) ?? 0,
SqlText = r.NullableString(11),
LastRequestEndTime = r.NullableDateTime(12),
ContextInfo = r.NullableHex(13),
};
private static SessionInfo Map2000(Row r) => Map2005(r);

View file

@ -51,16 +51,17 @@ public sealed class DeadlockCollector : Collector<DeadlockSnapshot>
? $"{Db.Literal(lastFile)}, {lastOffset.Value.ToString(CultureInfo.InvariantCulture)}"
: "NULL, NULL";
// Only the deadlock rows carry their XML; the rest are kept just to know where reading stopped.
// One read of the files and no temp table: the deadlock rows carry their XML, and the newest row
// (rn = 1) is returned as well, only to know where reading stopped.
return $"""
SELECT object_name, file_name, file_offset,
CASE WHEN object_name = N'xml_deadlock_report' THEN CAST(event_data AS nvarchar(max)) END AS event_data
INTO #system_health
FROM sys.fn_xe_file_target_read_file(N'system_health*.xel', NULL, {start});
SELECT event_data FROM #system_health WHERE event_data IS NOT NULL;
SELECT TOP (1) file_name, file_offset FROM #system_health ORDER BY file_name DESC, file_offset DESC;
DROP TABLE #system_health;
SELECT e.event_data, e.file_name, e.file_offset, e.rn
FROM (
SELECT CASE WHEN object_name = N'xml_deadlock_report' THEN CAST(event_data AS nvarchar(max)) END AS event_data,
file_name, file_offset,
ROW_NUMBER() OVER (ORDER BY file_name DESC, file_offset DESC) AS rn
FROM sys.fn_xe_file_target_read_file(N'system_health*.xel', NULL, {start})
) AS e
WHERE e.event_data IS NOT NULL OR e.rn = 1;
""";
}
@ -84,14 +85,17 @@ public sealed class DeadlockCollector : Collector<DeadlockSnapshot>
await using (var reader = await command.ExecuteReaderAsync(ct).ConfigureAwait(false))
{
while (await reader.ReadAsync(ct).ConfigureAwait(false))
{
if (!reader.IsDBNull(0))
{
events.Add(reader.GetString(0));
}
if (await reader.NextResultAsync(ct).ConfigureAwait(false) && await reader.ReadAsync(ct).ConfigureAwait(false))
if (Convert.ToInt64(reader.GetValue(3), CultureInfo.InvariantCulture) == 1)
{
file = reader.GetString(0);
offset = Convert.ToInt64(reader.GetValue(1), CultureInfo.InvariantCulture);
file = reader.GetString(1);
offset = Convert.ToInt64(reader.GetValue(2), CultureInfo.InvariantCulture);
}
}
}

View file

@ -6,9 +6,10 @@ namespace SQLInsight.Collectors.Custom;
/// <summary>
/// Runs the queries of the custom rules (community and local). Each rule gets its own result or error, so one broken
/// or unpermitted query only affects its own rule.
/// or unpermitted query only affects its own rule. Dynamics AX rules (dynamicsAx) only run in the monitored databases the
/// shared AX detection recognises; elsewhere they send no query at all.
/// </summary>
public sealed class CustomQueryCollector(CustomRuleCatalog catalog) : Collector<CustomQueryResults>
public sealed class CustomQueryCollector(CustomRuleCatalog catalog, DynamicsAx.AxDatabaseDetector? ax = null) : Collector<CustomQueryResults>
{
/// <summary>SQL Server error numbers that mean the login lacks a permission or access to a database.</summary>
private static readonly HashSet<int> PermissionErrors = [229, 230, 262, 297, 300, 916, 4060, 15247, 15151];
@ -23,6 +24,8 @@ public sealed class CustomQueryCollector(CustomRuleCatalog catalog) : Collector<
public override string Name => "custom-rules";
public override TimeSpan DefaultInterval => TimeSpan.FromMinutes(15);
private readonly DynamicsAx.AxDatabaseDetector _ax = ax ?? new DynamicsAx.AxDatabaseDetector();
protected override async Task<CustomQueryResults> CollectAsync(CollectorContext context, CustomQueryResults? previous)
{
ArgumentNullException.ThrowIfNull(context);
@ -37,6 +40,7 @@ public sealed class CustomQueryCollector(CustomRuleCatalog catalog) : Collector<
try
{
List<string>? databases = null;
HashSet<string>? axDatabases = null;
foreach (var rule in rules)
{
if (rule.Scope == CustomRuleScope.Server)
@ -47,6 +51,19 @@ public sealed class CustomQueryCollector(CustomRuleCatalog catalog) : Collector<
databases ??= (await Db.QueryAsync(context.Connection, DatabasesSql, r => r.String(0), context.CancellationToken).ConfigureAwait(false))
.Where(context.IncludesDatabase).ToList();
if (rule.DynamicsAx)
{
// The AX detection needs SQL Server 2005 (sys.columns); AX rules have nowhere to run before that.
axDatabases ??= context.Instance.Version.Major >= 9
? (await _ax.GetAsync(context).ConfigureAwait(false)).Databases.Select(d => d.Database).ToHashSet(StringComparer.OrdinalIgnoreCase)
: [];
var targets = databases.Where(axDatabases.Contains).ToList();
results[rule.Id] = targets.Count == 0
? new CustomQueryResult([], NotRelevant: "Ikke relevant: ingen af de overvågede databaser på instansen er genkendt som Dynamics AX.")
: await RunPerDatabaseAsync(context, rule, targets).ConfigureAwait(false);
continue;
}
results[rule.Id] = await RunPerDatabaseAsync(context, rule, databases).ConfigureAwait(false);
}
}

View file

@ -11,11 +11,14 @@ internal static class Db
{
public const int CommandTimeoutSeconds = 30;
public static async Task<List<T>> QueryAsync<T>(DbConnection connection, string sql, Func<Row, T> map, CancellationToken cancellationToken)
public static Task<List<T>> QueryAsync<T>(DbConnection connection, string sql, Func<Row, T> map, CancellationToken cancellationToken) =>
QueryAsync(connection, sql, map, CommandTimeoutSeconds, cancellationToken);
public static async Task<List<T>> QueryAsync<T>(DbConnection connection, string sql, Func<Row, T> map, int timeoutSeconds, CancellationToken cancellationToken)
{
await using var command = connection.CreateCommand();
command.CommandText = sql;
command.CommandTimeout = CommandTimeoutSeconds;
command.CommandTimeout = timeoutSeconds;
await using var reader = await command.ExecuteReaderAsync(cancellationToken).ConfigureAwait(false);
var rows = new List<T>();
var row = new Row(reader);
@ -37,6 +40,32 @@ internal static class Db
/// <summary>An N'…' string literal with quotes escaped.</summary>
public static string Literal(string value) => "N'" + value.Replace("'", "''", StringComparison.Ordinal) + "'";
/// <summary>
/// SQL Server's error number: SqlClient exposes it as Number, ODBC per error as NativeError. Null when unknown
/// (also for a command timeout, which SqlClient reports as -2).
/// </summary>
public static int? ErrorNumber(DbException exception)
{
ArgumentNullException.ThrowIfNull(exception);
if (exception.GetType().GetProperty("Number")?.GetValue(exception) is int number)
{
return number;
}
if (exception.GetType().GetProperty("Errors")?.GetValue(exception) is System.Collections.IEnumerable errors)
{
foreach (var error in errors)
{
if (error.GetType().GetProperty("NativeError")?.GetValue(error) is int native && native != 0)
{
return native;
}
}
}
return null;
}
/// <summary>A [bracketed] identifier with brackets escaped.</summary>
public static string Identifier(string name) => "[" + name.Replace("]", "]]", StringComparison.Ordinal) + "]";
@ -105,4 +134,21 @@ internal sealed class Row(DbDataReader reader)
public DateTime? NullableDateTime(int ordinal) => reader.IsDBNull(ordinal) ? null : DateTime(ordinal);
public int FieldCount => reader.FieldCount;
/// <summary>A binary value as hex without trailing zero bytes (CONTEXT_INFO is padded), or null when null or all zeros.</summary>
public string? NullableHex(int ordinal)
{
if (reader.IsDBNull(ordinal) || reader.GetValue(ordinal) is not byte[] bytes)
{
return null;
}
var length = bytes.Length;
while (length > 0 && bytes[length - 1] == 0)
{
length--;
}
return length == 0 ? null : Convert.ToHexString(bytes, 0, length);
}
}

View file

@ -1,5 +1,6 @@
using System.Data.Common;
using SQLInsight.Core.Configuration;
using SQLInsight.Core.DynamicsAx;
using SQLInsight.Core.Instances;
namespace SQLInsight.Collectors.Discovery;
@ -7,7 +8,7 @@ namespace SQLInsight.Collectors.Discovery;
/// <summary>
/// Lists every database the agent can see on an instance, with state, size, recovery model and compatibility level,
/// and recognises Dynamics AX databases. Only metadata is read (sys.databases, sys.master_files, sys.tables and
/// sys.columns, or their SQL Server 2000 counterparts), through three-part names: the session never changes database
/// sys.columns, or their SQL Server 2000 counterparts), through three-part names and [db].sys.sp_executesql: the session never changes database
/// and nothing is written. Works from SQL Server 2000 (over the legacy ODBC connection) and up.
/// </summary>
public static class DatabaseDiscovery
@ -37,33 +38,16 @@ public static class DatabaseDiscovery
""";
/// <summary>
/// The Dynamics AX signature, the same as the AX community rules use (rules/community/dynamics-ax.json): the system
/// tables SQLDICTIONARY, DATAAREA and SYSCONFIG, in any schema (AX 3.0 owns them as bmssa). RECID on SQLDICTIONARY
/// is int (precision 10) in AX 3.0 and bigint (19) from AX 4.0.
/// The central Dynamics AX detection (Core/DynamicsAx/AxDetection.cs), the same as the AX collectors and the AX
/// community rules use: run in the database through [db].sys.sp_executesql on SQL Server 2005 and later, with three-part
/// names to sysobjects on SQL Server 2000. The session never changes database.
/// </summary>
public static string DynamicsAxSql(string database, SqlServerVersion version)
{
ArgumentNullException.ThrowIfNull(database);
var db = Db.Identifier(database);
return version.Major >= 9
? $"""
SELECT COUNT(DISTINCT t.name), MAX(CASE WHEN t.name = N'SQLDICTIONARY' THEN c.precision END)
FROM {db}.sys.tables t
LEFT JOIN {db}.sys.columns c ON c.object_id = t.object_id AND c.name = N'RECID'
WHERE t.name IN (N'SQLDICTIONARY', N'DATAAREA', N'SYSCONFIG')
"""
: $"""
SELECT COUNT(DISTINCT o.name), MAX(CASE WHEN o.name = N'SQLDICTIONARY' THEN c.xprec END)
FROM {db}.dbo.sysobjects o
LEFT JOIN {db}.dbo.syscolumns c ON c.id = o.id AND c.name = N'RECID'
WHERE o.xtype = 'U' AND o.name IN (N'SQLDICTIONARY', N'DATAAREA', N'SYSCONFIG')
""";
return version.Major >= 9 ? AxSql.InDatabase(database, AxDetection.MetadataSql) : AxDetection.MetadataSql2000(database);
}
/// <summary>"3.0" or "4.0+" when all three AX system tables are there, else null.</summary>
public static string? DynamicsAxVersion(int tables, int? recIdPrecision) =>
tables < 3 ? null : recIdPrecision is 10 ? "3.0" : "4.0+";
public static async Task<List<DiscoveredDatabase>> DiscoverAsync(DbConnection connection, InstanceInfo instance, CancellationToken cancellationToken)
{
ArgumentNullException.ThrowIfNull(connection);
@ -82,6 +66,7 @@ public static class DatabaseDiscovery
IsSystem = DatabaseSelection.IsSystemDatabase(r.String(0)),
}, cancellationToken).ConfigureAwait(false);
var names = databases.Select(d => d.Name).ToList();
for (var i = 0; i < databases.Count; i++)
{
var database = databases[i];
@ -92,11 +77,12 @@ public static class DatabaseDiscovery
try
{
var rows = await Db.QueryAsync(connection, DynamicsAxSql(database.Name, instance.Version), r => (Tables: r.Int(0), Precision: r.NullableInt(1)), cancellationToken)
.ConfigureAwait(false);
if (rows.Count > 0 && DynamicsAxVersion(rows[0].Tables, rows[0].Precision) is { } version)
var rows = await Db.QueryAsync(connection, DynamicsAxSql(database.Name, instance.Version),
r => new AxDetectionRow(r.String(0), r.String(1), r.String(2), r.NullableString(3) ?? "?", r.NullableInt(4) ?? 0, r.NullableInt(5) ?? 0, r.Bool(6), r.NullableString(7)),
AxSql.CommandTimeoutSeconds, cancellationToken).ConfigureAwait(false);
if (AxDetection.Recognise(database.Name, rows, names) is { } ax)
{
databases[i] = database with { IsDynamicsAx = true, DynamicsAxVersion = version };
databases[i] = database with { IsDynamicsAx = true, DynamicsAxVersion = AxVersions.ShortText(ax.Version) };
}
}
catch (DbException)

View file

@ -0,0 +1,167 @@
using System.Globalization;
using SQLInsight.Core.DynamicsAx;
using SQLInsight.Core.Instances;
namespace SQLInsight.Collectors.DynamicsAx;
/// <summary>
/// Batch jobs in every monitored AX database where the agent may read the batch tables (optional AX read access),
/// every 5 minutes: status, planned and actual start, and completed runs (from BATCHJOBHISTORY in AX 2009 and later,
/// from what SQLInsight itself sees in AX 3.0/4.0). Each job is compared with its own history, kept by SQLInsight in
/// its repository (insight.AxBatchRuns). Only standard AX tables are read; bounded with TOP; never written to.
/// </summary>
public sealed class AxBatchCollector(AxDatabaseDetector detector, AxThresholds? thresholds = null) : Collector<AxBatchSnapshot>
{
/// <summary>Run keys remembered to avoid storing a run twice; more than this and the set starts over (the repository also ignores duplicates).</summary>
private const int RecentRunKeysKept = 20_000;
/// <summary>Finished rows older than this are not kept in the snapshot (AX 3.0 keeps one BATCH row per recurrence).</summary>
private static readonly TimeSpan FinishedShown = TimeSpan.FromDays(7);
private const int JobsShown = 500;
/// <summary>
/// A lock timeout keeps the earlier picture (so findings do not flap), but no longer than this: running and late times
/// keep growing against an old picture, and a job that has started meanwhile would still be reported late.
/// </summary>
internal static readonly TimeSpan KeptThroughLockTimeouts = TimeSpan.FromMinutes(15);
private readonly AxThresholds _thresholds = thresholds ?? new AxThresholds();
public override string Name => "dynamics-ax-batch";
public override TimeSpan DefaultInterval => TimeSpan.FromMinutes(5);
public override IReadOnlyCollection<Capability> RequiredCapabilities => [Capability.DynamicManagementViews];
protected override async Task<AxBatchSnapshot> CollectAsync(CollectorContext context, AxBatchSnapshot? previous)
{
var ax = await detector.GetAsync(context).ConfigureAwait(false);
var history = previous?.History.Clone() ?? new AxBatchHistory();
var firstSeen = new Dictionary<string, DateTime>(StringComparer.OrdinalIgnoreCase);
var recentKeys = previous?.RecentRunKeys is { Count: < RecentRunKeysKept } kept ? new HashSet<string>(kept) : [];
var databases = new List<AxBatchDatabase>();
foreach (var database in ax.Databases)
{
databases.Add(await CollectDatabaseAsync(context, database, previous, history, firstSeen, recentKeys, _thresholds.LocalTimeZone).ConfigureAwait(false));
}
return new AxBatchSnapshot
{
CapturedAt = context.Now,
Thresholds = _thresholds,
Databases = databases,
History = history,
FirstSeenExecuting = firstSeen,
RecentRunKeys = recentKeys,
};
}
private static async Task<AxBatchDatabase> CollectDatabaseAsync(CollectorContext context, AxDatabase database, AxBatchSnapshot? previous, AxBatchHistory history,
Dictionary<string, DateTime> firstSeen, HashSet<string> recentKeys, string? localTimeZone)
{
var name = database.Database;
var earlier = previous?.Databases.FirstOrDefault(d => string.Equals(d.Database, name, StringComparison.OrdinalIgnoreCase));
if (AxSchemaAdapter.For(database.Version) is not { } adapter)
{
return new AxBatchDatabase { Database = name, Version = database.Version, NotMeasurable = "Ikke målbar: AX-versionen kunne ikke bestemmes ud fra skemaet." };
}
var query = adapter.BatchJobs(database);
var (rows, problem) = await AxReader.ReadAsync(context, database, query, Map).ConfigureAwait(false);
if (rows is null)
{
// A lock timeout keeps the previous picture for a while (marked as such), so findings do not flap.
return AxReader.IsTransient(problem) && earlier is { NotMeasurable: null }
&& context.Now - (earlier.MeasuredAt ?? previous!.CapturedAt) <= KeptThroughLockTimeouts
? earlier with { NewRuns = [], Notes = [.. earlier.Notes.Where(n => n != AxReader.LockTimeoutText), AxReader.LockTimeoutText] }
: new AxBatchDatabase { Database = name, Version = database.Version, NotMeasurable = problem };
}
var notes = new List<string>(query.Notes);
// Axapta 3.0/4.0 store local times: they become UTC in the AX time zone (see AxLocalTime).
var (zone, zoneNote) = adapter.LocalBatchTimes && rows.Count > 0
? AxLocalTime.Resolve(localTimeZone, rows[0].ServerUtcOffsetMinutes, context.Now.UtcDateTime)
: (null, null);
if (zoneNote is not null)
{
notes.Add(zoneNote);
}
var newRuns = new List<AxBatchRun>();
long? watermark = earlier?.HistoryWatermark;
// AX 2009 and later: completed runs from AX's own history, by RECID.
var historyQuery = adapter.BatchHistory(database, watermark);
var (historyRows, historyProblem) = await AxReader.ReadAsync(context, database, historyQuery, MapHistory).ConfigureAwait(false);
var fromHistoryTable = historyRows is not null;
if (historyRows is not null)
{
foreach (var row in historyRows)
{
watermark = Math.Max(watermark ?? 0, row.RecId);
if (adapter.HistoryRun(row) is { } run && recentKeys.Add(AxBatchHistory.ScopedRunKey(name, run.RunKey)))
{
newRuns.Add(run);
}
}
}
else if (historyProblem is not null)
{
notes.Add("Kørselshistorik fra BATCHJOBHISTORY: " + historyProblem + " SQLInsight bruger i stedet de kørsler, den selv ser.");
}
// Pass 1: when jobs were first seen executing, and (AX 3.0/4.0, or no history access) runs SQLInsight saw finish.
var prelim = new List<(AxBatchJobRow Row, AxBatchJob Job)>();
foreach (var row in rows)
{
var seenKey = AxBatchHistory.Key(name, "#" + row.RecId.ToString(CultureInfo.InvariantCulture));
DateTime? seenBefore = previous?.FirstSeenExecuting.TryGetValue(seenKey, out var seen) == true ? seen : null;
var job = adapter.Interpret(row, history, name, null, zone);
if (job.Status == AxBatchStatus.Executing)
{
firstSeen[seenKey] = seenBefore ?? context.Now.UtcDateTime;
}
else if (!fromHistoryTable && AxSchemaAdapter.ObservedRun(job, seenBefore) is { } run && recentKeys.Add(AxBatchHistory.ScopedRunKey(name, run.RunKey)))
{
newRuns.Add(run);
}
prelim.Add((row, job));
}
foreach (var run in newRuns)
{
history.Add(name, run);
}
// Pass 2: the jobs with their history's statistics, the interesting ones first.
var now = context.Now.UtcDateTime;
var jobs = prelim
.Select(p => adapter.Interpret(p.Row, history, name,
firstSeen.TryGetValue(AxBatchHistory.Key(name, "#" + p.Row.RecId.ToString(CultureInfo.InvariantCulture)), out var s) ? s : null, zone))
.Where(j => j.Status is not (AxBatchStatus.Finished or AxBatchStatus.Canceled) || (j.EndedUtc ?? j.PlannedStartUtc ?? now) >= now - FinishedShown)
.OrderBy(j => j.Status switch { AxBatchStatus.Executing => 0, AxBatchStatus.Error => 1, AxBatchStatus.Waiting => 2, _ => 3 })
.ThenByDescending(j => j.RecId)
.Take(JobsShown)
.ToList();
return new AxBatchDatabase
{
Database = name,
Version = database.Version,
Jobs = jobs,
NewRuns = newRuns,
StatusCounts = rows.GroupBy(r => r.Status).ToDictionary(g => g.Key, g => g.Count()),
Notes = notes,
HistoryWatermark = watermark,
MeasuredAt = context.Now,
};
}
private static AxBatchJobRow Map(Row r) => new(
r.Long(0), r.NullableInt(1), r.NullableString(2), r.NullableString(3), r.NullableString(4), r.Int(5),
r.NullableDateTime(6), r.NullableInt(7), r.NullableDateTime(8), r.NullableInt(9), r.NullableDateTime(10), r.NullableInt(11), r.Int(12));
private static AxBatchHistoryRow MapHistory(Row r) => new(
r.Long(0), r.NullableLong(1), r.NullableString(2), r.NullableString(3), r.Int(4), r.NullableDateTime(5), r.NullableDateTime(6), r.Int(7));
}

View file

@ -0,0 +1,160 @@
using System.Data.Common;
using SQLInsight.Core.DynamicsAx;
using SQLInsight.Core.Instances;
namespace SQLInsight.Collectors.DynamicsAx;
/// <summary>
/// Finds the Dynamics AX databases on one instance with the central detection (<see cref="AxDetection"/>) and keeps
/// the answer for <see cref="Refresh"/>, so new and removed AX databases are noticed within the hour. Shared by
/// everything AX-specific on the instance (the AX collectors and the AX community rules). Only databases chosen for
/// monitoring are looked at; reads metadata, plus the company list when the agent may read DATAAREA. Not thread-safe:
/// one instance's collectors run one at a time.
/// </summary>
public sealed class AxDatabaseDetector
{
public static readonly TimeSpan Refresh = TimeSpan.FromHours(1);
/// <summary>An AX database without the full read access is looked at again sooner, so a GRANT takes effect within minutes.</summary>
public static readonly TimeSpan RefreshWithoutReadAccess = TimeSpan.FromMinutes(5);
private static TimeSpan RefreshFor(AxDatabase? database) => database is not null && AxReadAccess.Missing(database).Count > 0 ? RefreshWithoutReadAccess : Refresh;
private readonly Dictionary<string, (DateTimeOffset At, AxDatabase? Database, string? Problem)> _cache = new(StringComparer.OrdinalIgnoreCase);
private string? _login;
/// <summary>Online user databases the login can enter, with their snapshot settings.</summary>
internal const string DatabasesSql = """
SELECT name, snapshot_isolation_state, is_read_committed_snapshot_on
FROM sys.databases
WHERE database_id > 4 AND state = 0 AND source_database_id IS NULL AND HAS_DBACCESS(name) = 1
ORDER BY name
""";
/// <summary>The agent's login, for the GRANT text shown when AX read access is missing.</summary>
internal const string LoginSql = "SELECT SUSER_SNAME()";
/// <summary>The detection, run in each monitored database through [db].sys.sp_executesql (metadata only).</summary>
internal static string BuildDetectionSql(string database) => AxSql.InDatabase(database, AxDetection.MetadataSql);
/// <summary>The monitored AX databases, from the cache when younger than <see cref="Refresh"/> (unless <paramref name="force"/>).</summary>
public async Task<AxDatabaseSnapshot> GetAsync(CollectorContext context, bool force = false)
{
ArgumentNullException.ThrowIfNull(context);
var connection = context.Connection;
var ct = context.CancellationToken;
var databases = await Db.QueryAsync(connection, DatabasesSql, r => (Name: r.String(0), Snapshot: r.Int(1), Rcsi: r.Bool(2)), ct).ConfigureAwait(false);
_login ??= (await Db.QueryAsync(connection, LoginSql, r => r.NullableString(0), ct).ConfigureAwait(false)).FirstOrDefault();
var names = databases.Select(d => d.Name).ToList();
var found = new List<AxDatabase>();
var problems = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);
foreach (var (name, snapshot, rcsi) in databases)
{
if (!context.IncludesDatabase(name) || name.EndsWith("_model", StringComparison.OrdinalIgnoreCase))
{
continue;
}
if (force || !_cache.TryGetValue(name, out var cached) || context.Now - cached.At >= RefreshFor(cached.Database))
{
cached = await DetectAsync(context, name, names, _cache.TryGetValue(name, out var earlier) ? earlier.Database : null).ConfigureAwait(false);
_cache[name] = cached;
}
if (cached.Problem is { } problem)
{
problems[name] = problem;
}
if (cached.Database is { } ax)
{
found.Add(ax with
{
AgentLogin = _login,
Isolation = rcsi ? AxReadIsolation.ReadCommittedSnapshot : snapshot == 1 ? AxReadIsolation.Snapshot : AxReadIsolation.ReadCommitted,
});
}
}
foreach (var gone in _cache.Keys.Where(k => !names.Contains(k, StringComparer.OrdinalIgnoreCase)).ToList())
{
_cache.Remove(gone);
}
return new AxDatabaseSnapshot { CapturedAt = context.Now, Databases = found, AgentLogin = _login, Problems = problems };
}
/// <param name="earlier">What the previous detection found: kept through a passing error (a lock timeout during an AX
/// table synchronisation, a deadlock), so the database does not drop out of AX monitoring for an hour.</param>
private async Task<(DateTimeOffset, AxDatabase?, string?)> DetectAsync(CollectorContext context, string database, IReadOnlyList<string> names, AxDatabase? earlier)
{
try
{
var rows = await Db.QueryAsync(context.Connection, BuildDetectionSql(database),
r => new AxDetectionRow(r.String(0), r.String(1), r.String(2), r.NullableString(3) ?? "?", r.NullableInt(4) ?? 0, r.NullableInt(5) ?? 0, r.Bool(6), r.NullableString(7)),
AxSql.CommandTimeoutSeconds, context.CancellationToken).ConfigureAwait(false);
if (AxDetection.Recognise(database, rows, names) is not { } ax)
{
return (context.Now, null, null);
}
ax = ax with { AgentLogin = _login };
if (AxSchemaAdapter.For(ax.Version) is not null)
{
var (companies, reason) = await AxReader.ReadAsync(context, ax, AxSchemaAdapter.Companies(ax),
r => new AxCompany(r.String(0), r.NullableString(1), r.Bool(2))).ConfigureAwait(false);
ax = ax with { Companies = companies, CompaniesReason = reason };
}
return (context.Now, ax, null);
}
catch (DbException ex) when (!context.CancellationToken.IsCancellationRequested)
{
if (earlier is not null && Db.ErrorNumber(ex) is AxSql.LockTimeoutError or 1205 or null)
{
// A passing error: keep what was found and try again within minutes.
return (context.Now - Refresh + RefreshWithoutReadAccess, earlier, null);
}
// No access to its metadata (e.g. no database user before 2014): it cannot be told apart, and nothing AX-specific runs there.
return (context.Now, null, "AX-genkendelsen kunne ikke læse databasens metadata: " + ex.Message);
}
}
}
/// <summary>Reads an AX query: the rows, or why not (Danish). A lock timeout or a missing permission is a reason, never an exception.</summary>
internal static class AxReader
{
public const string LockTimeoutText = "Ikke målt denne gang: AX-tabellen var låst i mere end 2 sekunder, og SQLInsight venter ikke på AX' låse. Prøves igen ved næste indsamling.";
public static async Task<(List<T>? Rows, string? Problem)> ReadAsync<T>(CollectorContext context, AxDatabase database, AxQuery? query, Func<Row, T> map)
{
if (query is null)
{
return (null, null);
}
if (query.Sql is not { } sql)
{
return (null, query.NotMeasurable);
}
try
{
return (await Db.QueryAsync(context.Connection, AxSql.InDatabase(database.Database, sql, database.Isolation), map, AxSql.CommandTimeoutSeconds,
context.CancellationToken).ConfigureAwait(false), null);
}
catch (DbException ex) when (!context.CancellationToken.IsCancellationRequested)
{
// (A cancelled collection is not an AX problem: SqlClient reports it as a DbException, which is passed on.)
return (null, Db.ErrorNumber(ex) switch
{
AxSql.LockTimeoutError => LockTimeoutText,
229 or 230 or 297 or 916 => "Ikke målbar: adgang nægtet. " + AxReadAccess.NotMeasurable(database, AxReadAccess.Tables(database)),
_ => "Ikke målbar: læsningen fejlede (" + ex.Message + ").",
});
}
}
public static bool IsTransient(string? problem) => problem == LockTimeoutText;
}

View file

@ -0,0 +1,105 @@
using SQLInsight.Core.DynamicsAx;
using SQLInsight.Core.Instances;
namespace SQLInsight.Collectors.DynamicsAx;
/// <summary>
/// Number sequences (NUMBERSEQUENCETABLE) and RecId allocation (SYSTEMSEQUENCES) in every monitored AX database where
/// the agent may read them, every hour. SQLInsight samples NEXTREC and NEXTVAL once a day into its own repository
/// (insight.AxSequenceSamples, bounded retention) and forecasts from that history when each runs out. Bounded with TOP
/// (the most used first); never written to.
/// </summary>
public sealed class AxSequenceCollector(AxDatabaseDetector detector, AxThresholds? thresholds = null) : Collector<AxSequenceSnapshot>
{
private readonly AxThresholds _thresholds = thresholds ?? new AxThresholds();
public override string Name => "dynamics-ax-sequences";
/// <summary>How many days of daily samples the forecasts use (and the agent reads back after a restart).</summary>
public int HistoryDays => _thresholds.ForecastBasisDays + 1;
public override TimeSpan DefaultInterval => TimeSpan.FromHours(1);
public override IReadOnlyCollection<Capability> RequiredCapabilities => [Capability.DynamicManagementViews];
protected override async Task<AxSequenceSnapshot> CollectAsync(CollectorContext context, AxSequenceSnapshot? previous)
{
var ax = await detector.GetAsync(context).ConfigureAwait(false);
var now = context.Now.UtcDateTime;
var history = new Dictionary<string, IReadOnlyList<AxSample>>(StringComparer.Ordinal);
var databases = new List<AxSequenceDatabase>();
foreach (var database in ax.Databases)
{
var name = database.Database;
var earlier = previous?.Databases.FirstOrDefault(d => string.Equals(d.Database, name, StringComparison.OrdinalIgnoreCase));
if (AxSchemaAdapter.For(database.Version) is not { } adapter)
{
const string unknown = "Ikke målbar: AX-versionen kunne ikke bestemmes ud fra skemaet.";
databases.Add(new AxSequenceDatabase { Database = name, Version = database.Version, NumberSequencesNotMeasurable = unknown, RecIdsNotMeasurable = unknown });
continue;
}
IReadOnlyList<AxSample> Samples(char kind, string key, long value)
{
var historyKey = AxSequenceSnapshot.HistoryKey(name, kind, key);
var samples = (previous?.History.TryGetValue(historyKey, out var stored) == true ? stored : [])
.Where(s => s.SampledUtc >= now.AddDays(-HistoryDays) && s.SampledUtc.Date != now.Date)
.Append(new AxSample(now, value))
.ToList();
history[historyKey] = samples;
return samples;
}
var notes = new List<string>();
var numbersQuery = adapter.NumberSequences(database);
notes.AddRange(numbersQuery.Notes);
var (numberRows, numberProblem) = await AxReader.ReadAsync(context, database, numbersQuery, MapNumberSequence).ConfigureAwait(false);
// Axapta 3.0/4.0 store the last cleanup as local time: it becomes UTC in the AX time zone (see AxLocalTime).
var (zone, zoneNote) = adapter.LocalBatchTimes && numberRows is { Count: > 0 }
? AxLocalTime.Resolve(_thresholds.LocalTimeZone, numberRows[0].ServerUtcOffsetMinutes, now)
: (null, null);
if (zoneNote is not null)
{
notes.Add(zoneNote);
}
var sequences = numberRows?.Select(r => adapter.Interpret(r, Samples(AxSequenceSnapshot.NumberSequenceKind, adapter.SequenceKey(r), r.Next), now,
_thresholds.ForecastBasisDays, zone)).ToList();
var recIdQuery = adapter.RecIds(database);
notes.AddRange(recIdQuery.Notes);
var nextValIsInt = string.Equals(database.Table("SYSTEMSEQUENCES")?.Column("NEXTVAL")?.Type, "int", StringComparison.OrdinalIgnoreCase);
var (recIdRows, recIdProblem) = await AxReader.ReadAsync(context, database, recIdQuery, MapRecId).ConfigureAwait(false);
var recIds = recIdRows?.Select(r => AxSchemaAdapter.Interpret(r, nextValIsInt, Samples(AxSequenceSnapshot.RecIdKind, AxSchemaAdapter.RecIdKey(r), r.NextVal), now,
_thresholds.ForecastBasisDays)).ToList();
// A lock timeout keeps the previous values, so findings do not flap.
databases.Add(new AxSequenceDatabase
{
Database = name,
Version = database.Version,
NumberSequences = sequences ?? (AxReader.IsTransient(numberProblem) ? earlier?.NumberSequences ?? [] : []),
NumberSequencesNotMeasurable = sequences is null && !(AxReader.IsTransient(numberProblem) && earlier?.NumberSequencesNotMeasurable is null && earlier is not null) ? numberProblem : null,
RecIds = recIds ?? (AxReader.IsTransient(recIdProblem) ? earlier?.RecIds ?? [] : []),
RecIdsNotMeasurable = recIds is null && !(AxReader.IsTransient(recIdProblem) && earlier?.RecIdsNotMeasurable is null && earlier is not null) ? recIdProblem : null,
Notes = notes,
});
// Sequences not read this time keep their history (a lock timeout must not lose it).
foreach (var (key, samples) in previous?.History ?? new Dictionary<string, IReadOnlyList<AxSample>>())
{
if (key.StartsWith(name + "\u001f", StringComparison.Ordinal) && !history.ContainsKey(key))
{
history[key] = samples.Where(s => s.SampledUtc >= now.AddDays(-HistoryDays)).ToList();
}
}
}
return new AxSequenceSnapshot { CapturedAt = context.Now, Thresholds = _thresholds, Databases = databases, History = history };
}
private static AxNumberSequenceRow MapNumberSequence(Row r) => new(
r.Long(0), r.String(1), r.NullableString(2), r.NullableString(3), r.Long(4), r.Long(5), r.Long(6), r.NullableString(7),
r.Bool(8), r.Bool(9), r.Bool(10), r.Bool(11), r.IsNull(12) ? null : r.Bool(12), r.NullableDateTime(13), r.NullableInt(14),
r.IsNull(15) ? null : r.Double(15), r.Int(16));
private static AxRecIdRow MapRecId(Row r) => new(r.Int(0), r.NullableString(1), r.Long(2), r.NullableLong(3), r.NullableString(4));
}

View file

@ -0,0 +1,19 @@
using SQLInsight.Core.DynamicsAx;
using SQLInsight.Core.Instances;
namespace SQLInsight.Collectors.DynamicsAx;
/// <summary>
/// Which monitored databases on the instance are Dynamics AX, with version, schema, companies and the agent's read
/// access, every hour (collector "dynamics-ax"). The AX pages in SQLInsight Web are built on it.
/// </summary>
public sealed class DynamicsAxCollector(AxDatabaseDetector detector) : Collector<AxDatabaseSnapshot>
{
public override string Name => "dynamics-ax";
public override TimeSpan DefaultInterval => AxDatabaseDetector.Refresh;
/// <summary>The detection uses sys.tables, sys.columns and HAS_PERMS_BY_NAME: SQL Server 2005 and later.</summary>
public override IReadOnlyCollection<Capability> RequiredCapabilities => [Capability.DynamicManagementViews];
protected override Task<AxDatabaseSnapshot> CollectAsync(CollectorContext context, AxDatabaseSnapshot? previous) => detector.GetAsync(context, force: true);
}

View file

@ -12,7 +12,7 @@ public sealed class SqlConnectionFactory : ISqlConnectionFactory
// Keeps the monitoring session from ever being the one that blocks or wins a deadlock on the monitored server.
// Every statement here is valid on SQL Server 2000.
internal const string SessionSettings =
"SET LOCK_TIMEOUT 5000; SET DEADLOCK_PRIORITY LOW; SET TRANSACTION ISOLATION LEVEL READ UNCOMMITTED;";
Core.Instances.AgentSession.Settings;
private readonly Func<ConnectionTarget, DbConnection> _createSqlClient;
private readonly Func<ConnectionTarget, DbConnection> _createOdbc;

View file

@ -116,10 +116,10 @@ public sealed record DiscoveredDatabase
/// <summary>The agent can enter the database (HAS_DBACCESS); without it, database-specific checks cannot run there.</summary>
public bool HasAccess { get; init; }
/// <summary>The Dynamics AX system tables (SQLDICTIONARY, DATAAREA, SYSCONFIG) are there.</summary>
/// <summary>Recognised as Dynamics AX by the central detection (Core/DynamicsAx/AxDetection.cs).</summary>
public bool IsDynamicsAx { get; init; }
/// <summary>"3.0" (32-bit RECID) or "4.0+" (64-bit RECID), when <see cref="IsDynamicsAx"/>.</summary>
/// <summary>The AX version as <see cref="DynamicsAx.AxVersions.ShortText"/> ("3.0", "2009", "2012 R2/R3" …), when <see cref="IsDynamicsAx"/>.</summary>
public string? DynamicsAxVersion { get; init; }
}

View file

@ -68,15 +68,42 @@ public sealed class DatabaseSelection
/// </summary>
public static class DynamicsAxFindings
{
/// <summary>The community AX rules (rules/community/dynamics-ax.json).</summary>
public const string RulePrefix = "COM-AX-";
/// <summary>The built-in AX rules (1.4: batch jobs, number sequences, RecId, AX users behind blocking).</summary>
public const string BuiltInRulePrefix = "SQLI-AX-";
private const string DatabaseSubject = "database: ";
public static bool IsDynamicsAxRule(string ruleId) => ruleId?.StartsWith(RulePrefix, StringComparison.OrdinalIgnoreCase) == true;
/// <summary>Separates the database from the rest of an AX finding's subject: <c>database: AX30 · batch: 4711</c>.</summary>
public const string SubjectSeparator = " · ";
/// <summary>The database a finding is about, from its subject (<c>database: AX30</c>), or null.</summary>
public static string? DatabaseOf(string? subject) =>
subject is not null && subject.StartsWith(DatabaseSubject, StringComparison.OrdinalIgnoreCase) && subject.Length > DatabaseSubject.Length
? subject[DatabaseSubject.Length..]
: null;
public static bool IsDynamicsAxRule(string ruleId) =>
ruleId?.StartsWith(RulePrefix, StringComparison.OrdinalIgnoreCase) == true || ruleId?.StartsWith(BuiltInRulePrefix, StringComparison.OrdinalIgnoreCase) == true;
/// <summary>The subject of an AX finding about <paramref name="detail"/> in <paramref name="database"/>.</summary>
/// <remarks>
/// A middle dot in the database name is doubled, so the separator " · " (one dot) can only be the separator and a
/// database named e.g. "AX · test" can never be read as the database "AX" (whose hub opt-in would then apply).
/// </remarks>
public static string Subject(string database, string? detail = null)
{
ArgumentNullException.ThrowIfNull(database);
var escaped = database.Replace("·", "··", StringComparison.Ordinal);
return detail is null ? DatabaseSubject + escaped : DatabaseSubject + escaped + SubjectSeparator + detail;
}
/// <summary>The database a finding is about, from its subject (<c>database: AX30</c> or <c>database: AX30 · …</c>), or null.</summary>
public static string? DatabaseOf(string? subject)
{
if (subject is null || !subject.StartsWith(DatabaseSubject, StringComparison.OrdinalIgnoreCase) || subject.Length <= DatabaseSubject.Length)
{
return null;
}
var rest = subject[DatabaseSubject.Length..];
var separator = rest.IndexOf(SubjectSeparator, StringComparison.Ordinal);
return (separator > 0 ? rest[..separator] : rest).Replace("··", "·", StringComparison.Ordinal);
}
}

View file

@ -52,6 +52,12 @@ public sealed partial record CustomRuleDefinition
/// <summary>Who wrote the rule (name or Forgejo user). Shown in the rule list.</summary>
public string? Author { get; init; }
/// <summary>
/// A Dynamics AX rule (scope Database): runs only in databases the central AX detection recognises (Core/DynamicsAx) and
/// that are chosen for monitoring, and its findings only reach SQLInsight Hub for databases where AX findings may be sent.
/// </summary>
public bool DynamicsAx { get; init; }
/// <summary>"community" for rules from the signed rule pack, "lokal" for the customer's own. Set when loaded.</summary>
[JsonIgnore]
public string Origin { get; init; } = "lokal";
@ -92,6 +98,11 @@ public sealed partial record CustomRuleDefinition
errors.Add($"minimumVersion '{MinimumVersion}' er ikke en version som 11.0.");
}
if (DynamicsAx && Scope != CustomRuleScope.Database)
{
errors.Add("dynamicsAx kræver scope Database.");
}
if (!string.IsNullOrWhiteSpace(Query))
{
errors.AddRange(QueryProblems(Query));
@ -245,4 +256,6 @@ public sealed record CustomQueryResults(IReadOnlyDictionary<string, CustomQueryR
/// <param name="Rows">Column name → value (as text) per row, when the query ran.</param>
/// <param name="Error">Why it did not run.</param>
/// <param name="PermissionDenied">The error was a missing permission (the rule is then not measurable, not failing).</param>
public sealed record CustomQueryResult(IReadOnlyList<IReadOnlyDictionary<string, string?>> Rows, string? Error = null, bool PermissionDenied = false, bool Truncated = false);
/// <param name="NotRelevant">Why the rule had nowhere to run (a Dynamics AX rule on an instance without monitored AX databases).</param>
public sealed record CustomQueryResult(IReadOnlyList<IReadOnlyDictionary<string, string?>> Rows, string? Error = null, bool PermissionDenied = false, bool Truncated = false,
string? NotRelevant = null);

View file

@ -45,4 +45,10 @@ public sealed record SessionInfo
public string? SqlText { get; init; }
public DateTime? LastRequestEndTime { get; init; }
/// <summary>
/// The session's CONTEXT_INFO as hex, without trailing zero bytes, or null when not set. Dynamics AX 2009/2012 AOS
/// can put the AX user and session there (Core/DynamicsAx/AxSessionMapper.cs). Sensitive: it can hold a user id.
/// </summary>
public string? ContextInfo { get; init; }
}

View file

@ -0,0 +1,226 @@
using System.Text.Json.Serialization;
namespace SQLInsight.Core.DynamicsAx;
/// <summary>A batch job's status, translated from the version's STATUS enum by its adapter.</summary>
public enum AxBatchStatus
{
Unknown,
Hold,
Waiting,
Executing,
Error,
Finished,
Ready,
NotRun,
Cancelling,
Canceled,
}
/// <summary>
/// A batch job row as read from AX, before interpretation: dates and times exactly as stored. AX 3.0/4.0 store a local
/// date (time part 00:00) plus seconds since midnight; AX 2009 and later a UTC datetime and no seconds.
/// </summary>
/// <param name="ServerUtcOffsetMinutes">DATEDIFF(minute, GETDATE(), GETUTCDATE()) on the server when read: turns local AX times into UTC.</param>
public sealed record AxBatchJobRow(
long RecId,
int? ClassNumber,
string? Caption,
string? Company,
string? Group,
int Status,
DateTime? PlannedDate,
int? PlannedSeconds,
DateTime? StartDate,
int? StartSeconds,
DateTime? EndDate,
int? EndSeconds,
int ServerUtcOffsetMinutes);
/// <summary>A finished run from AX's batch history (BATCHJOBHISTORY, 2009 and later).</summary>
public sealed record AxBatchHistoryRow(
long RecId,
long? JobRecId,
string? Caption,
string? Company,
int Status,
DateTime? StartDate,
DateTime? EndDate,
int ServerUtcOffsetMinutes);
/// <summary>A completed run of a batch job: from AX's history, or seen by SQLInsight itself. Stored in insight.AxBatchRuns.</summary>
/// <param name="RunKey">Unique per database, so a run seen twice is stored once.</param>
public sealed record AxBatchRun(string JobKey, string RunKey, string? Caption, string? Company, DateTime StartedUtc, DateTime EndedUtc, AxBatchStatus Status)
{
[JsonIgnore]
public TimeSpan Duration => EndedUtc - StartedUtc;
}
/// <summary>A batch job now, with what its own history says is normal.</summary>
public sealed record AxBatchJob
{
/// <summary>Stable per job: the BATCHJOB RECID (2009 and later), or class, company and group (3.0/4.0).</summary>
public required string Key { get; init; }
public long RecId { get; init; }
public required string Caption { get; init; }
public string? Company { get; init; }
public string? Group { get; init; }
public int StatusCode { get; init; }
public AxBatchStatus Status { get; init; }
public DateTime? PlannedStartUtc { get; init; }
public DateTime? StartedUtc { get; init; }
public DateTime? EndedUtc { get; init; }
/// <summary>When SQLInsight first saw the job executing (AX 3.0/4.0 do not store the actual start of a running job).</summary>
public DateTime? FirstSeenExecutingUtc { get; init; }
/// <summary>Completed runs in SQLInsight's history of the job (at most <see cref="AxBatchHistory.RunsKept"/>).</summary>
public int HistoryRuns { get; init; }
public double? MedianSeconds { get; init; }
public double? P90Seconds { get; init; }
public double? LastRunSeconds { get; init; }
public AxBatchStatus? LastRunStatus { get; init; }
public DateTime? LastRunEndedUtc { get; init; }
/// <summary>When the job started running, as well as SQLInsight knows it.</summary>
[JsonIgnore]
public DateTime? RunningSince => Status == AxBatchStatus.Executing ? StartedUtc ?? FirstSeenExecutingUtc : null;
public TimeSpan? Running(DateTimeOffset now) => RunningSince is { } since ? now.UtcDateTime - since : null;
/// <summary>How late a waiting job is: now minus its planned start, when positive.</summary>
public TimeSpan? Late(DateTimeOffset now) =>
Status == AxBatchStatus.Waiting && PlannedStartUtc is { } planned && now.UtcDateTime > planned ? now.UtcDateTime - planned : null;
}
/// <summary>Batch jobs in one AX database, or why they could not be read.</summary>
public sealed record AxBatchDatabase
{
public required string Database { get; init; }
public AxVersion Version { get; init; }
/// <summary>Null when measured; else "Ikke målbar: …" with the exact GRANT text when it is the read access.</summary>
public string? NotMeasurable { get; init; }
public IReadOnlyList<AxBatchJob> Jobs { get; init; } = [];
/// <summary>Runs completed since the previous collection (stored in insight.AxBatchRuns).</summary>
public IReadOnlyList<AxBatchRun> NewRuns { get; init; } = [];
/// <summary>
/// How many jobs had each raw STATUS value: shows whether the version's status mapping fits the customer's data
/// (the AX 3.0 mapping is not verified, see docs/dynamics-ax.md).
/// </summary>
public IReadOnlyDictionary<int, int> StatusCounts { get; init; } = new Dictionary<int, int>();
/// <summary>Optional columns missing and what that leaves out.</summary>
public IReadOnlyList<string> Notes { get; init; } = [];
/// <summary>The highest BATCHJOBHISTORY RECID read: the next read starts there (2009 and later).</summary>
public long? HistoryWatermark { get; init; }
/// <summary>When the batch tables were last read (a lock timeout keeps the earlier picture for a while, see AxBatchCollector).</summary>
public DateTimeOffset? MeasuredAt { get; init; }
}
/// <summary>The batch jobs of every monitored AX database on an instance (collector "dynamics-ax-batch", every 5 minutes).</summary>
public sealed record AxBatchSnapshot
{
public required DateTimeOffset CapturedAt { get; init; }
public required AxThresholds Thresholds { get; init; }
public required IReadOnlyList<AxBatchDatabase> Databases { get; init; }
/// <summary>
/// Durations of recent runs per database and job, for comparing a job with itself. Kept in memory and rebuilt from
/// insight.AxBatchRuns after a restart; not part of the stored snapshot.
/// </summary>
[JsonIgnore]
public AxBatchHistory History { get; init; } = new();
/// <summary>When each running job was first seen executing, per database and job. Kept in memory.</summary>
[JsonIgnore]
public IReadOnlyDictionary<string, DateTime> FirstSeenExecuting { get; init; } = new Dictionary<string, DateTime>();
/// <summary>Run keys stored lately, so a run read again is not stored twice. Kept in memory.</summary>
[JsonIgnore]
public IReadOnlySet<string> RecentRunKeys { get; init; } = new HashSet<string>();
}
/// <summary>Recent run durations per database and job; bounded to <see cref="RunsKept"/> runs per job.</summary>
public sealed class AxBatchHistory
{
public const int RunsKept = 20;
private readonly Dictionary<string, List<AxBatchRun>> _runs = new(StringComparer.OrdinalIgnoreCase);
public static string Key(string database, string jobKey) => database + "\u001f" + jobKey;
/// <summary>A run key made unique across the instance's databases (<see cref="AxBatchSnapshot.RecentRunKeys"/>).</summary>
public static string ScopedRunKey(string database, string runKey) => database + "\u001f" + runKey;
/// <summary>The scoped run keys of every run kept.</summary>
public IEnumerable<string> ScopedRunKeys() =>
_runs.SelectMany(kv => kv.Value.Select(r => ScopedRunKey(kv.Key[..kv.Key.IndexOf('\u001f', StringComparison.Ordinal)], r.RunKey)));
public void Add(string database, AxBatchRun run)
{
ArgumentNullException.ThrowIfNull(run);
var key = Key(database, run.JobKey);
if (!_runs.TryGetValue(key, out var list))
{
_runs[key] = list = [];
}
if (list.Any(r => r.RunKey == run.RunKey))
{
return;
}
list.Add(run);
list.Sort((a, b) => a.EndedUtc.CompareTo(b.EndedUtc));
if (list.Count > RunsKept)
{
list.RemoveRange(0, list.Count - RunsKept);
}
}
/// <summary>The job's runs, oldest first.</summary>
public IReadOnlyList<AxBatchRun> Runs(string database, string jobKey) =>
_runs.TryGetValue(Key(database, jobKey), out var list) ? list : [];
public int Count => _runs.Count;
/// <summary>A copy, so the previous snapshot is never changed.</summary>
public AxBatchHistory Clone()
{
var copy = new AxBatchHistory();
foreach (var (key, list) in _runs)
{
copy._runs[key] = [.. list];
}
return copy;
}
/// <summary>Median and 90th percentile of the successful runs' durations, in seconds.</summary>
public static (double? Median, double? P90) Statistics(IEnumerable<AxBatchRun> runs)
{
var seconds = runs.Where(r => r.Status == AxBatchStatus.Finished).Select(r => r.Duration.TotalSeconds).Where(s => s >= 0).Order().ToList();
if (seconds.Count == 0)
{
return (null, null);
}
return (Percentile(seconds, 0.5), Percentile(seconds, 0.9));
}
private static double Percentile(List<double> sorted, double p)
{
var position = p * (sorted.Count - 1);
var lower = (int)Math.Floor(position);
var upper = Math.Min(lower + 1, sorted.Count - 1);
return sorted[lower] + (sorted[upper] - sorted[lower]) * (position - lower);
}
}

View file

@ -0,0 +1,99 @@
namespace SQLInsight.Core.DynamicsAx;
/// <summary>A column of an AX table as sys.columns describes it.</summary>
public sealed record AxColumn(string Name, string Type, int Precision, int MaxLength);
/// <param name="Readable">The agent has SELECT on the table (the optional AX read access, grant-dynamics-ax-read.sql).</param>
public sealed record AxTable(string Name, bool Readable, IReadOnlyList<AxColumn> Columns)
{
public bool HasColumn(string column) => Columns.Any(c => string.Equals(c.Name, column, StringComparison.OrdinalIgnoreCase));
public AxColumn? Column(string column) => Columns.FirstOrDefault(c => string.Equals(c.Name, column, StringComparison.OrdinalIgnoreCase));
}
/// <summary>A company (DATAAREA.ID) in an AX database.</summary>
public sealed record AxCompany(string Id, string? Name, bool IsVirtual);
/// <summary>
/// A database recognised as Dynamics AX by the central detection (<see cref="AxDetection"/>): which version, which
/// schema owns the AX tables (bmssa in AX 3.0, dbo later), the AX tables SQLInsight knows with their columns and
/// whether the agent may read them, and the companies when DATAAREA is readable.
/// </summary>
public sealed record AxDatabase
{
public required string Database { get; init; }
/// <summary>The schema of the AX tables: bmssa (AX 3.0, usually), dbo (later).</summary>
public required string Schema { get; init; }
public required AxVersion Version { get; init; }
/// <summary>The AX tables SQLInsight looks at (<see cref="AxDetection.KnownTables"/>) found in <see cref="Schema"/>.</summary>
public IReadOnlyList<AxTable> Tables { get; init; } = [];
/// <summary>The separate model store database (&lt;database&gt;_model) of AX 2012 R2/R3, when it is on the instance.</summary>
public string? ModelDatabase { get; init; }
/// <summary>The companies (DATAAREA), or null when the agent may not read DATAAREA.</summary>
public IReadOnlyList<AxCompany>? Companies { get; init; }
/// <summary>Why <see cref="Companies"/> is null.</summary>
public string? CompaniesReason { get; init; }
/// <summary>The database user the agent's login is in this database, or null when it has none (CONNECT ANY DATABASE).</summary>
public string? DatabaseUser { get; init; }
/// <summary>The agent's login (SUSER_SNAME()), so the GRANT text names it exactly.</summary>
public string? AgentLogin { get; init; }
/// <summary>Reads use snapshot isolation (ALLOW_SNAPSHOT_ISOLATION) or read committed snapshot; else read committed with a short lock timeout.</summary>
public AxReadIsolation Isolation { get; init; } = AxReadIsolation.ReadCommitted;
/// <summary>How the version was decided, and anything uncertain about it (Danish).</summary>
public IReadOnlyList<string> Notes { get; init; } = [];
public string VersionText => AxVersions.Text(Version);
public bool Verified => AxVersions.IsVerified(Version);
public AxTable? Table(string name) => Tables.FirstOrDefault(t => string.Equals(t.Name, name, StringComparison.OrdinalIgnoreCase));
public bool HasTable(string name) => Table(name) is not null;
public bool HasColumn(string table, string column) => Table(table)?.HasColumn(column) == true;
public bool CanRead(string table) => Table(table)?.Readable == true;
/// <summary>[schema].[TABLE], quoted.</summary>
public string Qualified(string table) => AxSql.Identifier(Schema) + "." + AxSql.Identifier(table);
}
public enum AxReadIsolation
{
/// <summary>Read committed with SET LOCK_TIMEOUT: a locked row makes the read give up after a few seconds, never wait.</summary>
ReadCommitted,
/// <summary>The database has READ_COMMITTED_SNAPSHOT on: read committed reads versions and never waits for locks.</summary>
ReadCommittedSnapshot,
/// <summary>ALLOW_SNAPSHOT_ISOLATION is on: reads use SET TRANSACTION ISOLATION LEVEL SNAPSHOT.</summary>
Snapshot,
}
/// <summary>The AX databases on an instance, as the detector last saw them (collector "dynamics-ax", hourly).</summary>
public sealed record AxDatabaseSnapshot
{
public required DateTimeOffset CapturedAt { get; init; }
/// <summary>Recognised AX databases that are monitored (chosen in Servere og databaser).</summary>
public required IReadOnlyList<AxDatabase> Databases { get; init; }
/// <summary>The agent's login (SUSER_SNAME()), for the GRANT text.</summary>
public string? AgentLogin { get; init; }
/// <summary>Databases the detection could not look into, with why (Danish).</summary>
public IReadOnlyDictionary<string, string> Problems { get; init; } = new Dictionary<string, string>();
public AxDatabase? Find(string? database) =>
database is null ? null : Databases.FirstOrDefault(d => string.Equals(d.Database, database, StringComparison.OrdinalIgnoreCase));
}

View file

@ -0,0 +1,154 @@
namespace SQLInsight.Core.DynamicsAx;
/// <summary>One row of the detection query: a column of a known AX table.</summary>
public sealed record AxDetectionRow(string Schema, string Table, string Column, string Type, int Precision, int MaxLength, bool Readable, string? DatabaseUser = null);
/// <summary>
/// The one place Dynamics AX is recognised: discovery (Servere og databaser), the AX collectors, the AX community
/// rules and the web UI all use it. Reads metadata only (sys.tables, sys.columns, HAS_PERMS_BY_NAME), never AX data.
/// <para>
/// A database is AX when SQLDICTIONARY and DATAAREA are in the same schema. The version follows from the schema:
/// RECID int (precision 10) → Axapta 2.5/3.0; bigint without BATCHJOB → AX 4.0; BATCHJOB without NUMBERSEQUENCESCOPE →
/// AX 2009; NUMBERSEQUENCESCOPE without a PARTITION column → AX 2012 RTM/FP; PARTITION with the model store database
/// &lt;db&gt;_model on the instance → AX 2012 R2/R3; PARTITION without any model store → Dynamics 365 F&amp;O (formodet).
/// Only the AX 3.0 rules are verified against a real customer schema; the rest follow Microsoft's documentation.
/// </para>
/// </summary>
public static class AxDetection
{
/// <summary>The AX tables SQLInsight knows: the markers and every table an AX check reads.</summary>
public static readonly IReadOnlyList<string> KnownTables =
[
"SQLDICTIONARY", "DATAAREA", "SYSCONFIG",
"BATCH", "BATCHJOB", "BATCHJOBHISTORY", "BATCHGROUP",
"NUMBERSEQUENCETABLE", "NUMBERSEQUENCESCOPE", "NUMBERSEQUENCEREFERENCE",
"SYSTEMSEQUENCES", "MODELELEMENT",
];
private static string TableList => string.Join(", ", KnownTables.Select(t => "N'" + t + "'"));
/// <summary>
/// Run inside the database (<see cref="AxSql.InDatabase"/>), SQL Server 2005 and later: every column of the known AX
/// tables, whether the agent may SELECT from the table, and the agent's database user. Names are upper-cased, so a
/// case-sensitive collation does not matter.
/// </summary>
public static string MetadataSql => $"""
SELECT s.name, UPPER(t.name), UPPER(c.name), TYPE_NAME(c.system_type_id), c.precision, c.max_length,
ISNULL(HAS_PERMS_BY_NAME(QUOTENAME(s.name) + N'.' + QUOTENAME(t.name), N'OBJECT', N'SELECT'), 0),
(SELECT TOP 1 p.name FROM sys.database_principals p WHERE p.sid = SUSER_SID())
FROM sys.tables t
JOIN sys.schemas s ON s.schema_id = t.schema_id
JOIN sys.columns c ON c.object_id = t.object_id
WHERE UPPER(t.name) IN ({TableList})
ORDER BY s.name, t.name, c.column_id
""";
/// <summary>
/// SQL Server 2000 (legacy ODBC), with a three-part name so the session never changes database. No permission
/// information: the AX data checks need SQL Server 2005 or later.
/// </summary>
public static string MetadataSql2000(string database) => $"""
SELECT u.name, UPPER(o.name), UPPER(c.name), TYPE_NAME(c.xtype), c.xprec, c.length, 0, NULL
FROM {AxSql.Identifier(database)}.dbo.sysobjects o
JOIN {AxSql.Identifier(database)}.dbo.sysusers u ON u.uid = o.uid
JOIN {AxSql.Identifier(database)}.dbo.syscolumns c ON c.id = o.id
WHERE o.xtype = 'U' AND UPPER(o.name) IN ({TableList})
ORDER BY u.name, o.name, c.colid
""";
/// <summary>
/// Decides from a database's detection rows whether it is AX, and which version.
/// </summary>
/// <param name="databasesOnInstance">Every database name on the instance, to find the AX 2012 R2/R3 model store (&lt;db&gt;_model).</param>
public static AxDatabase? Recognise(string database, IReadOnlyList<AxDetectionRow> rows, IEnumerable<string> databasesOnInstance)
{
ArgumentNullException.ThrowIfNull(database);
ArgumentNullException.ThrowIfNull(rows);
ArgumentNullException.ThrowIfNull(databasesOnInstance);
var schemas = rows
.GroupBy(r => r.Schema, StringComparer.OrdinalIgnoreCase)
.Select(g => (Schema: g.Key, Tables: g.GroupBy(r => r.Table, StringComparer.OrdinalIgnoreCase)
.Select(t => new AxTable(t.Key.ToUpperInvariant(), t.Any(r => r.Readable),
t.Select(r => new AxColumn(r.Column.ToUpperInvariant(), r.Type, r.Precision, r.MaxLength)).ToList()))
.ToList()))
.Where(s => s.Tables.Any(t => t.Name == "SQLDICTIONARY") && s.Tables.Any(t => t.Name == "DATAAREA"))
.OrderByDescending(s => s.Tables.Count)
.ThenBy(s => string.Equals(s.Schema, "dbo", StringComparison.OrdinalIgnoreCase) ? 0 : 1)
.ToList();
if (schemas.Count == 0)
{
return null;
}
var (schema, tables) = schemas[0];
var notes = new List<string>();
bool Has(string table) => tables.Any(t => t.Name == table);
var recId = tables.Single(t => t.Name == "SQLDICTIONARY").Column("RECID");
var partition = tables.Any(t => t.Name != "SQLDICTIONARY" && t.HasColumn("PARTITION"));
var modelDatabase = databasesOnInstance.FirstOrDefault(d => string.Equals(d, database + "_model", StringComparison.OrdinalIgnoreCase));
AxVersion version;
if (recId is null)
{
version = AxVersion.Unknown;
notes.Add("SQLDICTIONARY har ingen RECID-kolonne, så versionen kan ikke bestemmes. AX-datatjek køres ikke.");
}
else if (recId.Precision <= 10)
{
version = AxVersion.Axapta30;
notes.Add($"RECID er 32-bit (int) i skemaet {schema}: Axapta 2.5 eller 3.0 (de to kan ikke skelnes ud fra skemaet).");
}
else if (!Has("BATCHJOB"))
{
version = AxVersion.Ax40;
notes.Add("RECID er 64-bit, og BATCHJOB findes ikke: AX 4.0.");
}
else if (!Has("NUMBERSEQUENCESCOPE"))
{
version = AxVersion.Ax2009;
notes.Add("BATCHJOB findes, NUMBERSEQUENCESCOPE gør ikke: AX 2009.");
}
else if (!partition)
{
version = AxVersion.Ax2012;
notes.Add(Has("MODELELEMENT")
? "NUMBERSEQUENCESCOPE uden PARTITION-kolonne, og modellageret ligger i selve databasen: AX 2012 RTM/FP."
: "NUMBERSEQUENCESCOPE uden PARTITION-kolonne: AX 2012 RTM/FP.");
}
else if (modelDatabase is not null || Has("MODELELEMENT"))
{
version = AxVersion.Ax2012R2;
notes.Add(modelDatabase is not null
? $"PARTITION-kolonne og modeldatabasen {modelDatabase}: AX 2012 R2/R3."
: "PARTITION-kolonne og modellager i databasen: AX 2012 R2/R3.");
}
else
{
version = AxVersion.FinanceOperations;
notes.Add($"PARTITION-kolonne, men intet modellager ({database}_model findes ikke på instansen): formodet Dynamics 365 F&O on-premises. "
+ "Ligger AX 2012 R2/R3-modeldatabasen på en anden instans, er det i stedet AX 2012 R2/R3.");
}
if (!Has("SYSCONFIG"))
{
notes.Add("SYSCONFIG findes ikke (genkendt på SQLDICTIONARY og DATAAREA).");
}
if (!AxVersions.IsVerified(version) && version != AxVersion.Unknown)
{
notes.Add(AxVersions.VerificationText(version));
}
return new AxDatabase
{
Database = database,
Schema = schema,
Version = version,
Tables = tables,
ModelDatabase = modelDatabase,
DatabaseUser = rows.FirstOrDefault(r => r.DatabaseUser is not null)?.DatabaseUser,
Notes = notes,
};
}
}

View file

@ -0,0 +1,73 @@
namespace SQLInsight.Core.DynamicsAx;
/// <summary>
/// Axapta 3.0 and AX 4.0 store batch and cleanup times as a local date plus seconds since midnight, in the time of the
/// machine that wrote them (the AOS, or the batch client in a 2-tier installation). They become UTC with the rules of
/// that time zone on that date, so a time from before a change between summer and winter time is not an hour off.
/// <list type="bullet">
/// <item>The zone is SQLInsight:DynamicsAx:LocalTimeZone when set (a Windows or IANA id), else the agent's own zone when
/// its offset right now is the SQL Server's (the usual case: agent, SQL Server and AOS in one country).</item>
/// <item>Otherwise the SQL Server's current offset is used for every time, with a note that times from the other side of a
/// change can be an hour off.</item>
/// <item>A local time that does not exist (the hour skipped in spring) or exists twice (the hour repeated in autumn) gets
/// the zone's standard offset: deterministic, never an exception, and in autumn the later of the two moments, so a job
/// is never reported late because of the repeated hour.</item>
/// </list>
/// </summary>
public static class AxLocalTime
{
/// <summary>The setting that names the AX time zone.</summary>
public const string Setting = "SQLInsight:DynamicsAx:LocalTimeZone";
/// <summary>
/// A local AX date and seconds since midnight as UTC. AX's empty date (1900-01-01 or earlier) and seconds outside
/// 0..86400 are unknown (null); 86400 is midnight at the end of the day.
/// </summary>
/// <param name="serverOffsetMinutes">DATEDIFF(minute, GETDATE(), GETUTCDATE()) on the SQL Server when read (UTC − local).</param>
public static DateTime? ToUtc(DateTime? date, int? seconds, int serverOffsetMinutes, TimeZoneInfo? zone)
{
if (date is not { } d || d.Year <= 1900 || seconds is < 0 or > 86_400)
{
return null;
}
var local = DateTime.SpecifyKind(d.Date.AddSeconds(seconds ?? 0), DateTimeKind.Unspecified);
var offset = zone is null ? TimeSpan.FromMinutes(-RoundedOffset(serverOffsetMinutes)) : zone.GetUtcOffset(local);
return DateTime.SpecifyKind(local - offset, DateTimeKind.Utc);
}
/// <summary>
/// The SQL Server's offset to the nearest quarter of an hour: GETDATE() and GETUTCDATE() are two calls, and
/// DATEDIFF(minute) counts minute boundaries, so the raw value can be a minute off.
/// </summary>
public static int RoundedOffset(int minutes) => (int)Math.Round(minutes / 15.0, MidpointRounding.AwayFromZero) * 15;
/// <summary>
/// The zone for a database's local AX times, or null to use the SQL Server's current offset, and a note (Danish) when
/// that is less exact than it could be.
/// </summary>
/// <param name="configured">SQLInsight:DynamicsAx:LocalTimeZone.</param>
/// <param name="serverOffsetMinutes">The SQL Server's offset (UTC − local) when read, or null when nothing was read.</param>
/// <param name="agentZone">The agent's own zone (TimeZoneInfo.Local; a parameter for tests).</param>
public static (TimeZoneInfo? Zone, string? Note) Resolve(string? configured, int? serverOffsetMinutes, DateTime nowUtc, TimeZoneInfo? agentZone = null)
{
if (!string.IsNullOrWhiteSpace(configured))
{
return TimeZoneInfo.TryFindSystemTimeZoneById(configured.Trim(), out var zone)
? (zone, null)
: (null, $"Tidszonen \"{configured.Trim()}\" ({Setting}) findes ikke på agentens server. AX' lokale tider omregnes i stedet med SQL Servers "
+ "nuværende forskel til UTC, så tider fra før et skift mellem sommer- og vintertid kan være en time forkerte.");
}
agentZone ??= TimeZoneInfo.Local;
var now = DateTime.SpecifyKind(nowUtc, DateTimeKind.Utc);
if (serverOffsetMinutes is not { } server || -(int)Math.Round(agentZone.GetUtcOffset(now).TotalMinutes) == RoundedOffset(server))
{
return (agentZone, null);
}
return (null, $"AX' lokale tider (Axapta 3.0/4.0) omregnes med SQL Servers nuværende forskel til UTC, fordi agentens tidszone ({agentZone.Id}) "
+ $"har en anden forskel lige nu. Tider fra før et skift mellem sommer- og vintertid kan derfor være en time forkerte. Sæt {Setting} "
+ "til AOS'ens tidszone for at få dem helt rigtige.");
}
}

View file

@ -0,0 +1,105 @@
using System.Text;
using System.Text.RegularExpressions;
namespace SQLInsight.Core.DynamicsAx;
/// <summary>
/// The optional read access to AX tables. The agent normally reads only metadata (grant-least-privilege.sql); the AX
/// data checks need SELECT on a few AX system tables (batch jobs, number sequences, RecId allocation, companies), and
/// the customer decides whether to give it, per database. No customer data tables, never more than SELECT.
/// db/permissions/grant-dynamics-ax-read.sql does the same for any database; <see cref="GrantSql"/> makes the exact
/// statements for one database, with its schema (bmssa in AX 3.0) and the agent's login filled in.
/// </summary>
public static class AxReadAccess
{
public const string ScriptPath = "db/permissions/grant-dynamics-ax-read.sql";
public const string RevokeScriptPath = "db/permissions/revoke-dynamics-ax-read.sql";
/// <summary>Used in the GRANT text when the agent's login is not known yet.</summary>
public const string LoginPlaceholder = @"DOMÆNE\svc-sqlinsight$";
/// <summary>The tables the read access covers for a version (those that exist in the database).</summary>
public static IReadOnlyList<string> Tables(AxDatabase database)
{
ArgumentNullException.ThrowIfNull(database);
return AxSchemaAdapter.For(database.Version) is { } adapter
? adapter.ReadTables.Where(database.HasTable).ToList()
: [];
}
/// <summary>The tables the agent may not read yet.</summary>
public static IReadOnlyList<string> Missing(AxDatabase database) => Tables(database).Where(t => !database.CanRead(t)).ToList();
/// <summary>
/// The exact statements that give the agent SELECT on <paramref name="tables"/> (all the version's tables when null)
/// in this database. Run by a sysadmin or db_owner; SQLInsight never runs it.
/// </summary>
public static string GrantSql(AxDatabase database, string? login, IEnumerable<string>? tables = null)
{
ArgumentNullException.ThrowIfNull(database);
login = string.IsNullOrWhiteSpace(login) ? LoginPlaceholder : login.Trim();
var user = database.DatabaseUser ?? login;
var sql = new StringBuilder()
.Append("USE ").Append(AxSql.Identifier(database.Database)).Append(";\r\n");
if (database.DatabaseUser is null)
{
sql.Append("IF NOT EXISTS (SELECT 1 FROM sys.database_principals WHERE sid = SUSER_SID(").Append(AxSql.Literal(login)).Append("))\r\n")
.Append(" CREATE USER ").Append(AxSql.Identifier(login)).Append(" FOR LOGIN ").Append(AxSql.Identifier(login)).Append(";\r\n");
}
foreach (var table in (tables ?? Tables(database)).Distinct(StringComparer.OrdinalIgnoreCase))
{
sql.Append("GRANT SELECT ON ").Append(database.Qualified(table)).Append(" TO ").Append(AxSql.Identifier(user)).Append(";\r\n");
}
return sql.ToString();
}
/// <summary>db/permissions/grant-dynamics-ax-read.sql as shipped.</summary>
public static string Template()
{
using var stream = typeof(AxReadAccess).Assembly.GetManifestResourceStream("SQLInsight.Core.grant-dynamics-ax-read.sql")
?? throw new InvalidOperationException("grant-dynamics-ax-read.sql mangler i programmet.");
using var reader = new StreamReader(stream);
return reader.ReadToEnd();
}
/// <summary>The shipped script with the database and the agent's login filled in.</summary>
public static string Script(string database, string? login)
{
ArgumentNullException.ThrowIfNull(database);
return Template()
.Replace("SET @DatabaseName = N'AXDB';", "SET @DatabaseName = " + AxSql.Literal(database) + ";", StringComparison.Ordinal)
.Replace("SET @LoginName = N'" + LoginPlaceholder + "';", "SET @LoginName = " + AxSql.Literal(string.IsNullOrWhiteSpace(login) ? LoginPlaceholder : login.Trim()) + ";",
StringComparison.Ordinal);
}
/// <summary>
/// <paramref name="text"/> without the agent's login or database user: the GRANT texts in "Ikke målbar", notes and
/// detection problems name them (CREATE USER [x] FOR LOGIN [x], SUSER_SID(N'x'), TO [user]), and so may a SQL Server
/// error message (principal "x"). Logins are sensitive like everywhere else, so API v1 shows the placeholder instead
/// unless sensitive values are allowed.
/// </summary>
public static string? WithoutPrincipals(string? text)
{
if (string.IsNullOrEmpty(text))
{
return text;
}
var placeholder = AxSql.Identifier(LoginPlaceholder);
text = Regex.Replace(text, @"(CREATE USER |FOR LOGIN | TO )\[(?:[^\]]|\]\])*\]", m => m.Groups[1].Value + placeholder);
text = Regex.Replace(text, @"SUSER_SID\(N'(?:[^']|'')*'\)", "SUSER_SID(" + AxSql.Literal(LoginPlaceholder) + ")");
return Regex.Replace(text, @"\b(principal|user|login)\s+""[^""]*""", "$1 \"[udeladt]\"", RegexOptions.IgnoreCase);
}
/// <summary>"Ikke målbar" for a check that needs <paramref name="tables"/>, with the exact GRANT text.</summary>
public static string NotMeasurable(AxDatabase database, IReadOnlyList<string> tables, string? login = null)
{
ArgumentNullException.ThrowIfNull(database);
ArgumentNullException.ThrowIfNull(tables);
return $"Ikke målbar: agenten må ikke læse {string.Join(", ", tables.Select(t => database.Schema + "." + t))} i {database.Database} "
+ $"(valgfri AX-læseadgang). Kør som sysadmin eller db_owner: {GrantSql(database, login ?? database.AgentLogin, tables).Replace("\r\n", " ", StringComparison.Ordinal).Trim()}";
}
}

View file

@ -0,0 +1,735 @@
using System.Globalization;
namespace SQLInsight.Core.DynamicsAx;
/// <summary>
/// Everything SQLInsight knows about one Dynamics AX version's tables, in one place: which tables and columns the AX
/// checks read (and so which tables the optional read access must cover), the batch STATUS enum, and whether times are
/// local or UTC. Every query is built against the columns the database actually has (sys.columns, via
/// <see cref="AxDetection"/>), so a schema that differs gives "ikke målbar" with the exact column, never an error.
/// <para>
/// Only <see cref="Ax30Adapter"/> is verified against a real customer schema (docs/dynamics-ax/schema-ax30.tsv). AX 4.0,
/// 2009, 2012 and F&amp;O are built from Microsoft's documented schemas and NOT verified against customer data; their
/// documented tables and columns are in docs/dynamics-ax/schema-ax*.documented.tsv. When a customer supplies a real
/// schema, drop it in as docs/dynamics-ax/schema-ax2009.tsv or schema-ax2012.tsv: the adapter tests then check this
/// adapter against it instead (AxSchemaFile).
/// </para>
/// </summary>
public abstract class AxSchemaAdapter
{
/// <summary>How many batch jobs are read per collection (newest RECID first).</summary>
public const int MaxBatchJobs = 1000;
/// <summary>How many history rows are read per collection.</summary>
public const int MaxHistoryRows = 1000;
/// <summary>RecIds are handed out in blocks per AOS, so history rows may arrive slightly out of RECID order: re-read this far back.</summary>
public const long HistoryOverlap = 2000;
/// <summary>
/// Rows read after the watermark: the overlap plus <see cref="MaxHistoryRows"/> new rows. AX 4.0 and later hand out
/// RecIds per table, so BATCHJOBHISTORY's RecIds are dense; with no more rows than the overlap the read would return the
/// same old rows every time and the watermark would never move again.
/// </summary>
public const int MaxHistoryRowsAfterWatermark = (int)HistoryOverlap + MaxHistoryRows;
/// <summary>How many number sequences are read (the most used first).</summary>
public const int MaxNumberSequences = 5000;
/// <summary>How many RecId sequences are read (the highest first).</summary>
public const int MaxRecIdSequences = 500;
/// <summary>DATEDIFF from the server's local time to UTC: turns AX 3.0/4.0's local times into UTC.</summary>
protected const string UtcOffset = "DATEDIFF(minute, GETDATE(), GETUTCDATE())";
public static readonly AxSchemaAdapter Ax30 = new Ax30Adapter();
public static readonly AxSchemaAdapter Ax40 = new Ax40Adapter();
public static readonly AxSchemaAdapter Ax2009 = new Ax2009Adapter();
public static readonly AxSchemaAdapter Ax2012 = new Ax2012Adapter();
public static readonly AxSchemaAdapter FinanceOperations = new FinanceOperationsAdapter();
public static IReadOnlyList<AxSchemaAdapter> All => [Ax30, Ax40, Ax2009, Ax2012, FinanceOperations];
/// <summary>The adapter for a version, or null when the version is unknown (then no AX data checks run).</summary>
public static AxSchemaAdapter? For(AxVersion version) => version switch
{
AxVersion.Axapta30 => Ax30,
AxVersion.Ax40 => Ax40,
AxVersion.Ax2009 => Ax2009,
AxVersion.Ax2012 or AxVersion.Ax2012R2 => Ax2012,
AxVersion.FinanceOperations => FinanceOperations,
_ => null,
};
/// <summary>The versions this adapter serves.</summary>
public abstract IReadOnlyList<AxVersion> Versions { get; }
/// <summary>The schema file name under docs/dynamics-ax (without .tsv / .documented.tsv): schema-ax30, schema-ax2009 …</summary>
public abstract string SchemaFileName { get; }
/// <summary>The tables the batch checks read.</summary>
public abstract IReadOnlyList<string> BatchTables { get; }
/// <summary>The tables the number sequence checks read.</summary>
public abstract IReadOnlyList<string> NumberSequenceTables { get; }
/// <summary>The tables the RecId check reads (SQLDICTIONARY only for table names).</summary>
public static IReadOnlyList<string> RecIdTables => ["SYSTEMSEQUENCES", "SQLDICTIONARY"];
/// <summary>Every table the optional AX read access covers for this version: batch, number sequences, RecId, companies.</summary>
public IReadOnlyList<string> ReadTables => [.. BatchTables.Concat(NumberSequenceTables).Concat(RecIdTables).Append("DATAAREA").Distinct(StringComparer.OrdinalIgnoreCase)];
/// <summary>Columns each check cannot do without, per table (the adapter tests check them against the schema files).</summary>
public abstract IReadOnlyDictionary<string, IReadOnlyList<string>> RequiredColumns { get; }
/// <summary>Batch times are a local date plus seconds since midnight (3.0/4.0), not UTC datetimes.</summary>
public abstract bool LocalBatchTimes { get; }
/// <summary>The version's batch STATUS enum.</summary>
public abstract AxBatchStatus BatchStatus(int status);
/// <summary>Whether the batch STATUS mapping is verified against customer data.</summary>
public virtual bool BatchStatusVerified => false;
public abstract AxQuery BatchJobs(AxDatabase database);
/// <summary>Completed runs from AX's own history after <paramref name="afterRecId"/> (newest first when null), or null: no history table in this version.</summary>
public virtual AxQuery? BatchHistory(AxDatabase database, long? afterRecId) => null;
public abstract AxQuery NumberSequences(AxDatabase database);
/// <summary>The companies: DATAAREA (ID, NAME, ISVIRTUAL).</summary>
public static AxQuery Companies(AxDatabase database)
{
ArgumentNullException.ThrowIfNull(database);
if (Unreadable(database, ["DATAAREA"]) is { } reason)
{
return AxQuery.Unmeasurable(reason);
}
var select = new AxSelect(database, "DATAAREA", "d")
.Add("ID", "nvarchar(10)", required: true)
.Add("NAME", "nvarchar(60)")
.Add("ISVIRTUAL");
return select.MissingReason() is { } missing
? AxQuery.Unmeasurable(missing)
: AxQuery.Ok($"SELECT TOP (500) {select.SelectList} FROM {database.Qualified("DATAAREA")} d ORDER BY d.{AxSql.Identifier("ID")}");
}
/// <summary>RecId allocation per table (per company in AX 3.0): SYSTEMSEQUENCES rows named SEQNO, the highest first.</summary>
public virtual AxQuery RecIds(AxDatabase database)
{
ArgumentNullException.ThrowIfNull(database);
if (Unreadable(database, ["SYSTEMSEQUENCES"]) is { } reason)
{
return AxQuery.Unmeasurable(reason);
}
var s = new AxSelect(database, "SYSTEMSEQUENCES", "s");
var notes = new List<string>();
var name = database.CanRead("SQLDICTIONARY") && database.HasColumn("SQLDICTIONARY", "NAME") && database.HasColumn("SQLDICTIONARY", "FIELDID")
&& database.HasColumn("SQLDICTIONARY", "TABLEID")
? $"(SELECT TOP 1 d.{AxSql.Identifier("NAME")} FROM {database.Qualified("SQLDICTIONARY")} d WHERE d.{AxSql.Identifier("TABLEID")} = s.{AxSql.Identifier("TABID")} AND d.{AxSql.Identifier("FIELDID")} = 0)"
: "CAST(NULL AS nvarchar(40))";
if (name.StartsWith("CAST", StringComparison.Ordinal))
{
notes.Add("Tabelnavne vises ikke: agenten må ikke læse SQLDICTIONARY (eller den mangler NAME/FIELDID). Tabellerne vises med deres tabel-id.");
}
s.Add("TABID", required: true)
.Add("DATAAREAID", "nvarchar(10)")
.AddExpression("CAST(" + s.Column("NEXTVAL", "bigint", required: true) + " AS bigint)")
.AddExpression("CAST(" + s.Column("MAXVAL", "bigint") + " AS bigint)")
.AddExpression(name);
var filter = database.HasColumn("SYSTEMSEQUENCES", "NAME") ? $" WHERE s.{AxSql.Identifier("NAME")} = N'SEQNO'" : string.Empty;
return s.MissingReason() is { } missing
? AxQuery.Unmeasurable(missing)
: AxQuery.Ok($"SELECT TOP ({MaxRecIdSequences}) {s.SelectList} FROM {database.Qualified("SYSTEMSEQUENCES")} s{filter} ORDER BY s.{AxSql.Identifier("NEXTVAL")} DESC", [.. notes, .. s.Notes]);
}
/// <summary>
/// "Ikke målbar" with the exact GRANT, when the agent may not read one of <paramref name="tables"/> (or it is missing).
/// </summary>
protected static string? Unreadable(AxDatabase database, IReadOnlyList<string> tables)
{
var missing = tables.Where(t => !database.HasTable(t)).ToList();
if (missing.Count > 0)
{
return $"Ikke målbar: {database.Database} har ikke {(missing.Count == 1 ? "tabellen" : "tabellerne")} {string.Join(", ", missing.Select(t => database.Schema + "." + t))}, "
+ $"som {AxVersions.Text(database.Version)} forventes at have.";
}
var denied = tables.Where(t => !database.CanRead(t)).ToList();
return denied.Count == 0 ? null : AxReadAccess.NotMeasurable(database, denied);
}
// ---------------------------------------------------------------- interpretation (no SQL; unit tested)
/// <summary>A stable key for a job, so its runs can be compared over time.</summary>
public virtual string JobKey(AxBatchJobRow row)
{
ArgumentNullException.ThrowIfNull(row);
return row.RecId.ToString(CultureInfo.InvariantCulture);
}
/// <summary>What the UI calls a job: its caption, or class, company and group when AX stores no caption (3.0/4.0).</summary>
public virtual string Caption(AxBatchJobRow row)
{
ArgumentNullException.ThrowIfNull(row);
return string.IsNullOrWhiteSpace(row.Caption) ? $"Batchjob {row.RecId.ToString(CultureInfo.InvariantCulture)}" : row.Caption.Trim();
}
/// <summary>
/// A time as stored by AX in UTC: a local date plus seconds since midnight (3.0/4.0, in the AX time zone; see
/// <see cref="AxLocalTime"/>), or a UTC datetime. AX's empty date (1900-01-01) is null.
/// </summary>
/// <param name="zone">The AX time zone for local times (<see cref="AxLocalTime.Resolve"/>); null: the server's offset.</param>
public DateTime? ToUtc(DateTime? date, int? seconds, int offsetMinutes, TimeZoneInfo? zone = null)
{
if (date is not { } d || d.Year <= 1900)
{
return null;
}
if (!LocalBatchTimes)
{
return DateTime.SpecifyKind(d, DateTimeKind.Utc);
}
return AxLocalTime.ToUtc(d, seconds, offsetMinutes, zone);
}
public AxBatchJob Interpret(AxBatchJobRow row, AxBatchHistory history, string database, DateTime? firstSeenExecuting, TimeZoneInfo? zone = null)
{
ArgumentNullException.ThrowIfNull(row);
ArgumentNullException.ThrowIfNull(history);
var key = JobKey(row);
var runs = history.Runs(database, key);
var (median, p90) = AxBatchHistory.Statistics(runs);
var last = runs.Count > 0 ? runs[^1] : null;
return new AxBatchJob
{
Key = key,
RecId = row.RecId,
Caption = Caption(row),
Company = Trim(row.Company),
Group = Trim(row.Group),
StatusCode = row.Status,
Status = BatchStatus(row.Status),
PlannedStartUtc = ToUtc(row.PlannedDate, row.PlannedSeconds, row.ServerUtcOffsetMinutes, zone),
StartedUtc = ToUtc(row.StartDate, row.StartSeconds, row.ServerUtcOffsetMinutes, zone),
EndedUtc = ToUtc(row.EndDate, row.EndSeconds, row.ServerUtcOffsetMinutes, zone),
FirstSeenExecutingUtc = firstSeenExecuting,
HistoryRuns = runs.Count,
MedianSeconds = median,
P90Seconds = p90,
LastRunSeconds = last?.Duration.TotalSeconds,
LastRunStatus = last?.Status,
LastRunEndedUtc = last?.EndedUtc,
};
}
/// <summary>
/// A completed run seen in the job table itself: a job that SQLInsight saw executing and now has an end time. The
/// start is the actual start when AX stores it (2009 and later), else when SQLInsight first saw it executing.
/// </summary>
public static AxBatchRun? ObservedRun(AxBatchJob job, DateTime? seenExecutingSince)
{
ArgumentNullException.ThrowIfNull(job);
if (job.Status is not (AxBatchStatus.Finished or AxBatchStatus.Error) || job.EndedUtc is not { } ended)
{
return null;
}
var started = job.StartedUtc ?? seenExecutingSince;
if (started is not { } start || ended < start)
{
return null;
}
return new AxBatchRun(job.Key, "J" + job.RecId.ToString(CultureInfo.InvariantCulture) + "@" + start.ToString("yyyyMMddHHmmss", CultureInfo.InvariantCulture), job.Caption, job.Company, start, ended, job.Status);
}
/// <summary>A run from AX's batch history (2009 and later).</summary>
public AxBatchRun? HistoryRun(AxBatchHistoryRow row)
{
ArgumentNullException.ThrowIfNull(row);
var start = ToUtc(row.StartDate, null, row.ServerUtcOffsetMinutes);
var end = ToUtc(row.EndDate, null, row.ServerUtcOffsetMinutes);
if (start is not { } s || end is not { } e || e < s || row.JobRecId is not { } job)
{
return null;
}
return new AxBatchRun(job.ToString(CultureInfo.InvariantCulture), "H" + row.RecId.ToString(CultureInfo.InvariantCulture),
Trim(row.Caption), Trim(row.Company), s, e, BatchStatus(row.Status));
}
/// <summary>Stable key of a number sequence for its sampled history.</summary>
public virtual string SequenceKey(AxNumberSequenceRow row)
{
ArgumentNullException.ThrowIfNull(row);
return (Trim(row.Company) ?? "*") + "/" + row.Code.Trim();
}
public AxNumberSequence Interpret(AxNumberSequenceRow row, IEnumerable<AxSample> samples, DateTime nowUtc, int forecastDays, TimeZoneInfo? zone = null)
{
ArgumentNullException.ThrowIfNull(row);
var digits = row.Format?.Count(c => c == '#') is > 0 and var n ? n : (int?)null;
long? capacity = digits is { } d ? d >= 19 ? long.MaxValue : (long)Math.Pow(10, d) - 1 : null;
var max = capacity is { } c && c < row.Highest ? c : row.Highest;
var span = (double)max - row.Lowest + 1;
var used = span <= 0 ? 100 : Math.Clamp((row.Next - (double)row.Lowest) / span * 100, 0, 100);
var remaining = (long)Math.Min(long.MaxValue, Math.Max(0m, (decimal)max - row.Next + 1));
// Axapta 3.0/4.0: a local date plus seconds (LATESTCLEANTIME may be missing: midnight); AX 2009 and later: one UTC datetime.
var lastClean = LocalBatchTimes
? AxLocalTime.ToUtc(row.LastCleanDate, row.LastCleanSeconds ?? 0, row.ServerUtcOffsetMinutes, zone)
: row.LastCleanDate is { Year: > 1900 } utcClean ? DateTime.SpecifyKind(utcClean, DateTimeKind.Utc) : null;
return new AxNumberSequence
{
Key = SequenceKey(row),
Code = row.Code.Trim(),
Company = Trim(row.Company),
Text = Trim(row.Text),
Lowest = row.Lowest,
Highest = row.Highest,
Next = row.Next,
Format = row.Format,
FormatDigits = digits,
FormatCapacity = capacity,
Max = max,
UsedPercent = used,
Remaining = remaining,
Blocked = row.Blocked,
Continuous = row.Continuous,
Cyclic = row.Cyclic,
Manual = row.Manual,
Referenced = row.Referenced,
LastCleanUtc = lastClean,
CleanupKnown = row.LastCleanDate is not null,
CleanIntervalHours = row.CleanIntervalHours,
Forecast = row.Manual || row.Cyclic ? null : AxForecast.From(samples, nowUtc, row.Next, max, forecastDays),
};
}
public static string RecIdKey(AxRecIdRow row)
{
ArgumentNullException.ThrowIfNull(row);
return row.TableId.ToString(CultureInfo.InvariantCulture) + "/" + (Trim(row.Company) ?? "*");
}
/// <param name="nextValIsInt">SYSTEMSEQUENCES.NEXTVAL (and RECID) is int: AX 3.0's 32-bit RecId.</param>
public static AxRecIdSequence Interpret(AxRecIdRow row, bool nextValIsInt, IEnumerable<AxSample> samples, DateTime nowUtc, int forecastDays)
{
ArgumentNullException.ThrowIfNull(row);
var typeMax = nextValIsInt ? AxVersions.Int32RecIdMax : long.MaxValue;
var max = row.MaxVal is > 0 and var m && m < typeMax ? m : typeMax;
var used = Math.Clamp((double)row.NextVal / max * 100, 0, 100);
return new AxRecIdSequence
{
Key = RecIdKey(row),
TableId = row.TableId,
TableName = Trim(row.TableName),
Company = Trim(row.Company),
Next = row.NextVal,
Max = max,
Bits = nextValIsInt ? 32 : 64,
UsedPercent = used,
Remaining = Math.Max(0, max - row.NextVal),
Forecast = AxForecast.From(samples, nowUtc, row.NextVal, max, forecastDays),
};
}
protected static string? Trim(string? value) => string.IsNullOrWhiteSpace(value) ? null : value.Trim();
// ---------------------------------------------------------------- shared SQL builders
/// <summary>
/// NUMBERSEQUENCETABLE with the company from DATAAREAID (3.0, 4.0, 2009) or NUMBERSEQUENCESCOPE (2012), and whether a
/// NUMBERSEQUENCEREFERENCE uses it. The most used sequences first, so TOP keeps those that matter.
/// </summary>
internal static AxQuery NumberSequenceQuery(AxDatabase database, string companyExpression, string? companyJoin, string? referencedExpression, IEnumerable<string> extraNotes)
{
ArgumentNullException.ThrowIfNull(database);
if (Unreadable(database, ["NUMBERSEQUENCETABLE"]) is { } reason)
{
return AxQuery.Unmeasurable(reason);
}
var n = new AxSelect(database, "NUMBERSEQUENCETABLE", "n");
string Big(string column) => "CAST(" + n.Column(column, "bigint", required: true) + " AS bigint)";
string Flag(string column, string lost) => "ISNULL(" + n.Column(column, whatIsLost: lost) + ", 0)";
// AX 2009 and later store the last cleanup as one UTC datetime; 3.0 as a local date plus seconds.
string cleanDate, cleanSeconds;
if (database.HasColumn("NUMBERSEQUENCETABLE", "LATESTCLEANDATETIME"))
{
cleanDate = n.Column("LATESTCLEANDATETIME", "datetime");
cleanSeconds = "CAST(NULL AS int)";
}
else
{
cleanDate = n.Column("LATESTCLEANDATE", "datetime", whatIsLost: "seneste oprydning af kontinuerlige nummerserier kendes ikke.");
cleanSeconds = n.Column("LATESTCLEANTIME");
}
n.AddExpression(Big("RECID"))
.AddExpression(n.Column("NUMBERSEQUENCE", "nvarchar(20)", required: true))
.AddExpression(companyExpression)
.AddExpression(n.Column("TXT", "nvarchar(60)"))
.AddExpression(Big("LOWEST"))
.AddExpression(Big("HIGHEST"))
.AddExpression(Big("NEXTREC"))
.AddExpression(n.Column("FORMAT", "nvarchar(40)", whatIsLost: "formatets bredde kan ikke sammenlignes med HIGHEST."))
.AddExpression(Flag("BLOCKED", "spærrede nummerserier kan ikke ses."))
.AddExpression(Flag("CONTINUOUS", "kontinuerlige nummerserier kan ikke ses."))
.AddExpression(Flag("CYCLIC", "cykliske nummerserier kan ikke ses (de regnes som almindelige)."))
.AddExpression(Flag("MANUAL", "manuelle nummerserier kan ikke ses (de regnes som almindelige)."))
.AddExpression(referencedExpression ?? "CAST(NULL AS int)")
.AddExpression(cleanDate)
.AddExpression(cleanSeconds)
.AddExpression("CAST(" + n.Column("CLEANINTERVAL", "float") + " AS float)")
.AddExpression(UtcOffset);
if (n.MissingReason() is { } missing)
{
return AxQuery.Unmeasurable(missing);
}
var used = $"(CAST(n.{AxSql.Identifier("NEXTREC")} AS float) - n.{AxSql.Identifier("LOWEST")}) / NULLIF(CAST(n.{AxSql.Identifier("HIGHEST")} AS float) - n.{AxSql.Identifier("LOWEST")}, 0)";
return AxQuery.Ok(
$"SELECT TOP ({MaxNumberSequences}) {n.SelectList} FROM {database.Qualified("NUMBERSEQUENCETABLE")} n{(companyJoin is null ? string.Empty : " " + companyJoin)} ORDER BY {used} DESC",
[.. n.Notes, .. extraNotes]);
}
/// <summary>The old batch table BATCH of AX 3.0 and 4.0: a local start date plus seconds since midnight, no caption.</summary>
protected static AxQuery LegacyBatchJobs(AxDatabase database)
{
ArgumentNullException.ThrowIfNull(database);
if (Unreadable(database, ["BATCH"]) is { } reason)
{
return AxQuery.Unmeasurable(reason);
}
var b = new AxSelect(database, "BATCH", "b");
b.AddExpression("CAST(" + b.Column("RECID", "bigint", required: true) + " AS bigint)")
.Add("CLASSNUM", whatIsLost: "jobbene kan ikke skelnes på klasse.")
.AddExpression("CAST(NULL AS nvarchar(60))")
.Add("COMPANY", "nvarchar(10)")
.Add("GROUPID", "nvarchar(20)")
.Add("STATUS", required: true)
.Add("STARTDATE", "datetime", required: true)
.Add("STARTTIME", required: true)
.AddExpression("CAST(NULL AS datetime)")
.AddExpression("CAST(NULL AS int)")
.Add("ENDDATE", "datetime", whatIsLost: "kørselstider kan ikke måles.")
.Add("ENDTIME")
.AddExpression(UtcOffset);
return b.MissingReason() is { } missing
? AxQuery.Unmeasurable(missing)
: AxQuery.Ok($"SELECT TOP ({MaxBatchJobs}) {b.SelectList} FROM {database.Qualified("BATCH")} b ORDER BY b.{AxSql.Identifier("RECID")} DESC", b.Notes);
}
/// <summary>BATCHJOB of AX 2009 and later: one row per job, planned start ORIGSTARTDATETIME, actual STARTDATETIME, all UTC.</summary>
protected static AxQuery JobBatchJobs(AxDatabase database)
{
ArgumentNullException.ThrowIfNull(database);
if (Unreadable(database, ["BATCHJOB"]) is { } reason)
{
return AxQuery.Unmeasurable(reason);
}
var j = new AxSelect(database, "BATCHJOB", "j");
j.AddExpression("CAST(" + j.Column("RECID", "bigint", required: true) + " AS bigint)")
.AddExpression("CAST(NULL AS int)")
.Add("CAPTION", "nvarchar(100)", whatIsLost: "jobbene vises med RECID i stedet for navn.")
.Add("COMPANY", "nvarchar(10)")
.AddExpression("CAST(NULL AS nvarchar(20))")
.Add("STATUS", required: true)
.Add("ORIGSTARTDATETIME", "datetime", required: true)
.AddExpression("CAST(NULL AS int)")
.Add("STARTDATETIME", "datetime", whatIsLost: "den faktiske starttid kendes ikke; SQLInsight bruger, hvornår den selv så jobbet køre.")
.AddExpression("CAST(NULL AS int)")
.Add("ENDDATETIME", "datetime", whatIsLost: "sluttider kendes ikke.")
.AddExpression("CAST(NULL AS int)")
.AddExpression(UtcOffset);
return j.MissingReason() is { } missing
? AxQuery.Unmeasurable(missing)
: AxQuery.Ok($"SELECT TOP ({MaxBatchJobs}) {j.SelectList} FROM {database.Qualified("BATCHJOB")} j ORDER BY j.{AxSql.Identifier("RECID")} DESC", j.Notes);
}
/// <summary>
/// BATCHJOBHISTORY of AX 2009 and later, by RECID (always indexed in AX): the newest rows the first time, then the rows
/// after the previous read, re-reading <see cref="HistoryOverlap"/> RecIds back for blocks handed out by other AOS'es.
/// </summary>
protected static AxQuery? JobBatchHistory(AxDatabase database, long? afterRecId)
{
ArgumentNullException.ThrowIfNull(database);
if (!database.HasTable("BATCHJOBHISTORY"))
{
return null;
}
if (Unreadable(database, ["BATCHJOBHISTORY"]) is { } reason)
{
return AxQuery.Unmeasurable(reason);
}
var h = new AxSelect(database, "BATCHJOBHISTORY", "h");
h.AddExpression("CAST(" + h.Column("RECID", "bigint", required: true) + " AS bigint)")
.AddExpression("CAST(" + h.Column("BATCHJOBID", "bigint", required: true) + " AS bigint)")
.Add("CAPTION", "nvarchar(100)")
.Add("COMPANY", "nvarchar(10)")
.Add("STATUS", required: true)
.Add("STARTDATETIME", "datetime", required: true)
.Add("ENDDATETIME", "datetime", required: true)
.AddExpression(UtcOffset);
if (h.MissingReason() is { } missing)
{
return AxQuery.Unmeasurable(missing);
}
var recId = "h." + AxSql.Identifier("RECID");
return AxQuery.Ok(afterRecId is { } after
? $"SELECT TOP ({MaxHistoryRowsAfterWatermark}) {h.SelectList} FROM {database.Qualified("BATCHJOBHISTORY")} h WHERE {recId} > {AxSql.Number(Math.Max(0, after - HistoryOverlap))} ORDER BY {recId}"
: $"SELECT TOP ({MaxHistoryRows}) {h.SelectList} FROM {database.Qualified("BATCHJOBHISTORY")} h ORDER BY {recId} DESC", h.Notes);
}
/// <summary>The 2009+ BatchStatus enum (Microsoft's documentation of BatchStatus).</summary>
protected static AxBatchStatus JobStatus(int status) => status switch
{
0 => AxBatchStatus.Hold,
1 => AxBatchStatus.Waiting,
2 => AxBatchStatus.Executing,
3 => AxBatchStatus.Error,
4 => AxBatchStatus.Finished,
5 => AxBatchStatus.Ready,
6 => AxBatchStatus.NotRun,
7 => AxBatchStatus.Cancelling,
8 => AxBatchStatus.Canceled,
_ => AxBatchStatus.Unknown,
};
}
/// <summary>
/// Axapta 2.5/3.0. Verified against the real AX 3.0 schema from a customer (docs/dynamics-ax/schema-ax30.tsv): tables in
/// the bmssa schema, 32-bit RECID, BATCH with STARTDATE (datetime, local) + STARTTIME (int, seconds since midnight,
/// local), NUMBERSEQUENCETABLE with NEXTREC/HIGHEST/FORMAT/BLOCKED/INUSE per DATAAREAID, SYSTEMSEQUENCES with
/// NEXTVAL/MAXVAL per TABID and DATAAREAID. The batch STATUS values are NOT verified against customer data (the schema
/// file has no data): the UI shows the raw values found so the mapping can be checked.
/// </summary>
public sealed class Ax30Adapter : AxSchemaAdapter
{
public override IReadOnlyList<AxVersion> Versions => [AxVersion.Axapta30];
public override string SchemaFileName => "schema-ax30";
public override IReadOnlyList<string> BatchTables => ["BATCH"];
public override IReadOnlyList<string> NumberSequenceTables => ["NUMBERSEQUENCETABLE", "NUMBERSEQUENCEREFERENCE"];
public override bool LocalBatchTimes => true;
public override IReadOnlyDictionary<string, IReadOnlyList<string>> RequiredColumns => new Dictionary<string, IReadOnlyList<string>>
{
["BATCH"] = ["RECID", "CLASSNUM", "COMPANY", "GROUPID", "STATUS", "STARTDATE", "STARTTIME", "ENDDATE", "ENDTIME"],
["NUMBERSEQUENCETABLE"] = ["RECID", "NUMBERSEQUENCE", "DATAAREAID", "TXT", "LOWEST", "HIGHEST", "NEXTREC", "FORMAT", "BLOCKED", "CONTINUOUS", "CYCLIC", "MANUAL",
"LATESTCLEANDATE", "LATESTCLEANTIME", "CLEANINTERVAL"],
["NUMBERSEQUENCEREFERENCE"] = ["NUMBERSEQUENCE", "DATAAREAID"],
["SYSTEMSEQUENCES"] = ["TABID", "DATAAREAID", "NEXTVAL", "MAXVAL", "NAME"],
["SQLDICTIONARY"] = ["TABLEID", "FIELDID", "NAME", "RECID"],
["DATAAREA"] = ["ID", "NAME", "ISVIRTUAL"],
};
/// <summary>
/// The Axapta 3.0 BatchStatus enum as SQLInsight assumes it: 0 Venter, 1 Udfører, 2 Fejl, 3 Afsluttet, 4 Tilbageholdt.
/// NOT verified against customer data; check with SELECT STATUS, COUNT(*) FROM bmssa.BATCH GROUP BY STATUS and the
/// batch list in AX. The web UI shows the values found.
/// </summary>
public override AxBatchStatus BatchStatus(int status) => status switch
{
0 => AxBatchStatus.Waiting,
1 => AxBatchStatus.Executing,
2 => AxBatchStatus.Error,
3 => AxBatchStatus.Finished,
4 => AxBatchStatus.Hold,
_ => AxBatchStatus.Unknown,
};
public override string JobKey(AxBatchJobRow row)
{
ArgumentNullException.ThrowIfNull(row);
// AX 3.0 makes a new BATCH row for each recurrence, so a job is its class, company and batch group.
return string.Join('|', row.ClassNumber?.ToString(CultureInfo.InvariantCulture) ?? "?", Trim(row.Company) ?? string.Empty, Trim(row.Group) ?? string.Empty);
}
public override string Caption(AxBatchJobRow row)
{
ArgumentNullException.ThrowIfNull(row);
var text = row.ClassNumber is { } c ? $"Klasse {c.ToString(CultureInfo.InvariantCulture)}" : $"Batch {row.RecId.ToString(CultureInfo.InvariantCulture)}";
return Trim(row.Group) is { } group ? $"{text} (gruppe {group})" : text;
}
public override AxQuery BatchJobs(AxDatabase database) => LegacyBatchJobs(database);
public override AxQuery NumberSequences(AxDatabase database) => LegacyNumberSequences(database);
/// <summary>NUMBERSEQUENCETABLE per DATAAREAID; references by code and company (3.0, 4.0, 2009).</summary>
internal static AxQuery LegacyNumberSequences(AxDatabase database)
{
var notes = new List<string>();
string? referenced = null;
if (database.CanRead("NUMBERSEQUENCEREFERENCE") && database.HasColumn("NUMBERSEQUENCEREFERENCE", "NUMBERSEQUENCE")
&& database.HasColumn("NUMBERSEQUENCEREFERENCE", "DATAAREAID") && database.HasColumn("NUMBERSEQUENCETABLE", "DATAAREAID"))
{
referenced = $"CASE WHEN EXISTS (SELECT 1 FROM {database.Qualified("NUMBERSEQUENCEREFERENCE")} r WHERE r.{AxSql.Identifier("NUMBERSEQUENCE")} = n.{AxSql.Identifier("NUMBERSEQUENCE")} "
+ $"AND r.{AxSql.Identifier("DATAAREAID")} = n.{AxSql.Identifier("DATAAREAID")}) THEN 1 ELSE 0 END";
}
else
{
notes.Add("Det kan ikke ses, om en spærret nummerserie stadig bruges: agenten må ikke læse NUMBERSEQUENCEREFERENCE (eller den mangler kolonner).");
}
var company = database.HasColumn("NUMBERSEQUENCETABLE", "DATAAREAID") ? "n." + AxSql.Identifier("DATAAREAID") : "CAST(NULL AS nvarchar(10))";
return NumberSequenceQuery(database, company, null, referenced, notes);
}
}
/// <summary>
/// AX 4.0: like 3.0 (the old BATCH table with local date + seconds, NUMBERSEQUENCETABLE per DATAAREAID) but dbo and
/// 64-bit RECID. Built from Microsoft's documentation and the 3.0 schema – NOT verified against customer data; the batch
/// STATUS values are assumed to be 3.0's.
/// </summary>
public sealed class Ax40Adapter : AxSchemaAdapter
{
public override IReadOnlyList<AxVersion> Versions => [AxVersion.Ax40];
public override string SchemaFileName => "schema-ax40";
public override IReadOnlyList<string> BatchTables => ["BATCH"];
public override IReadOnlyList<string> NumberSequenceTables => ["NUMBERSEQUENCETABLE", "NUMBERSEQUENCEREFERENCE"];
public override bool LocalBatchTimes => true;
public override IReadOnlyDictionary<string, IReadOnlyList<string>> RequiredColumns => Ax30.RequiredColumns;
public override AxBatchStatus BatchStatus(int status) => Ax30.BatchStatus(status);
public override string JobKey(AxBatchJobRow row) => Ax30.JobKey(row);
public override string Caption(AxBatchJobRow row) => Ax30.Caption(row);
public override AxQuery BatchJobs(AxDatabase database) => LegacyBatchJobs(database);
public override AxQuery NumberSequences(AxDatabase database) => Ax30Adapter.LegacyNumberSequences(database);
}
/// <summary>
/// AX 2009: batch framework with BATCHJOB (one row per job: STATUS, ORIGSTARTDATETIME planned, STARTDATETIME and
/// ENDDATETIME actual, all UTC) and BATCHJOBHISTORY; NUMBERSEQUENCETABLE per DATAAREAID with NUMBERSEQUENCE, NEXTREC,
/// HIGHEST; SYSTEMSEQUENCES per table; 64-bit RECID. Baseret på Microsofts dokumenterede skema – ikke verificeret mod
/// kundedata.
/// </summary>
public sealed class Ax2009Adapter : AxSchemaAdapter
{
public override IReadOnlyList<AxVersion> Versions => [AxVersion.Ax2009];
public override string SchemaFileName => "schema-ax2009";
public override IReadOnlyList<string> BatchTables => ["BATCHJOB", "BATCHJOBHISTORY"];
public override IReadOnlyList<string> NumberSequenceTables => ["NUMBERSEQUENCETABLE", "NUMBERSEQUENCEREFERENCE"];
public override bool LocalBatchTimes => false;
public override IReadOnlyDictionary<string, IReadOnlyList<string>> RequiredColumns => new Dictionary<string, IReadOnlyList<string>>
{
["BATCHJOB"] = ["RECID", "CAPTION", "COMPANY", "STATUS", "ORIGSTARTDATETIME", "STARTDATETIME", "ENDDATETIME"],
["BATCHJOBHISTORY"] = ["RECID", "BATCHJOBID", "CAPTION", "COMPANY", "STATUS", "STARTDATETIME", "ENDDATETIME"],
["NUMBERSEQUENCETABLE"] = ["RECID", "NUMBERSEQUENCE", "DATAAREAID", "TXT", "LOWEST", "HIGHEST", "NEXTREC", "FORMAT", "BLOCKED", "CONTINUOUS", "CYCLIC", "MANUAL",
"LATESTCLEANDATETIME", "CLEANINTERVAL"],
["NUMBERSEQUENCEREFERENCE"] = ["NUMBERSEQUENCE", "DATAAREAID"],
["SYSTEMSEQUENCES"] = ["TABID", "DATAAREAID", "NEXTVAL", "MAXVAL", "NAME"],
["SQLDICTIONARY"] = ["TABLEID", "FIELDID", "NAME", "RECID"],
["DATAAREA"] = ["ID", "NAME", "ISVIRTUAL"],
};
public override AxBatchStatus BatchStatus(int status) => JobStatus(status);
public override AxQuery BatchJobs(AxDatabase database) => JobBatchJobs(database);
public override AxQuery? BatchHistory(AxDatabase database, long? afterRecId) => JobBatchHistory(database, afterRecId);
public override AxQuery NumberSequences(AxDatabase database) => Ax30Adapter.LegacyNumberSequences(database);
}
/// <summary>
/// AX 2012 (RTM/FP and R2/R3): BATCHJOB/BATCHJOBHISTORY as in 2009; NUMBERSEQUENCETABLE keyed by RECID with
/// NUMBERSEQUENCESCOPE (the company is NUMBERSEQUENCESCOPE.DATAAREA, empty for shared sequences) and
/// NUMBERSEQUENCEREFERENCE.NUMBERSEQUENCEID; a PARTITION column on every table from R2, whose model store is the
/// separate &lt;db&gt;_model database. Baseret på Microsofts dokumenterede skema – ikke verificeret mod kundedata.
/// </summary>
public class Ax2012Adapter : AxSchemaAdapter
{
public override IReadOnlyList<AxVersion> Versions => [AxVersion.Ax2012, AxVersion.Ax2012R2];
public override string SchemaFileName => "schema-ax2012";
public override IReadOnlyList<string> BatchTables => ["BATCHJOB", "BATCHJOBHISTORY"];
public override IReadOnlyList<string> NumberSequenceTables => ["NUMBERSEQUENCETABLE", "NUMBERSEQUENCESCOPE", "NUMBERSEQUENCEREFERENCE"];
public override bool LocalBatchTimes => false;
public override IReadOnlyDictionary<string, IReadOnlyList<string>> RequiredColumns => new Dictionary<string, IReadOnlyList<string>>
{
["BATCHJOB"] = ["RECID", "CAPTION", "COMPANY", "STATUS", "ORIGSTARTDATETIME", "STARTDATETIME", "ENDDATETIME"],
["BATCHJOBHISTORY"] = ["RECID", "BATCHJOBID", "CAPTION", "COMPANY", "STATUS", "STARTDATETIME", "ENDDATETIME"],
["NUMBERSEQUENCETABLE"] = ["RECID", "NUMBERSEQUENCE", "NUMBERSEQUENCESCOPE", "TXT", "LOWEST", "HIGHEST", "NEXTREC", "FORMAT", "BLOCKED", "CONTINUOUS", "CYCLIC", "MANUAL",
"LATESTCLEANDATETIME", "CLEANINTERVAL"],
["NUMBERSEQUENCESCOPE"] = ["RECID", "DATAAREA"],
["NUMBERSEQUENCEREFERENCE"] = ["NUMBERSEQUENCEID"],
["SYSTEMSEQUENCES"] = ["TABID", "NEXTVAL", "MAXVAL", "NAME"],
["SQLDICTIONARY"] = ["TABLEID", "FIELDID", "NAME", "RECID"],
["DATAAREA"] = ["ID", "NAME", "ISVIRTUAL"],
};
public override AxBatchStatus BatchStatus(int status) => JobStatus(status);
public override AxQuery BatchJobs(AxDatabase database) => JobBatchJobs(database);
public override AxQuery? BatchHistory(AxDatabase database, long? afterRecId) => JobBatchHistory(database, afterRecId);
/// <summary>A 2012 sequence is identified by its RECID: the same code can exist in several scopes.</summary>
public override string SequenceKey(AxNumberSequenceRow row)
{
ArgumentNullException.ThrowIfNull(row);
return row.RecId.ToString(CultureInfo.InvariantCulture);
}
public override AxQuery NumberSequences(AxDatabase database)
{
ArgumentNullException.ThrowIfNull(database);
var notes = new List<string>();
var company = "CAST(NULL AS nvarchar(10))";
string? join = null;
if (database.CanRead("NUMBERSEQUENCESCOPE") && database.HasColumn("NUMBERSEQUENCESCOPE", "DATAAREA") && database.HasColumn("NUMBERSEQUENCESCOPE", "RECID")
&& database.HasColumn("NUMBERSEQUENCETABLE", "NUMBERSEQUENCESCOPE"))
{
company = "NULLIF(sc." + AxSql.Identifier("DATAAREA") + ", N'')";
join = $"LEFT JOIN {database.Qualified("NUMBERSEQUENCESCOPE")} sc ON sc.{AxSql.Identifier("RECID")} = n.{AxSql.Identifier("NUMBERSEQUENCESCOPE")}";
}
else
{
notes.Add("Firma vises ikke: agenten må ikke læse NUMBERSEQUENCESCOPE (eller den mangler DATAAREA).");
}
string? referenced = null;
if (database.CanRead("NUMBERSEQUENCEREFERENCE") && database.HasColumn("NUMBERSEQUENCEREFERENCE", "NUMBERSEQUENCEID"))
{
referenced = $"CASE WHEN EXISTS (SELECT 1 FROM {database.Qualified("NUMBERSEQUENCEREFERENCE")} r WHERE r.{AxSql.Identifier("NUMBERSEQUENCEID")} = n.{AxSql.Identifier("RECID")}) THEN 1 ELSE 0 END";
}
else
{
notes.Add("Det kan ikke ses, om en spærret nummerserie stadig bruges: agenten må ikke læse NUMBERSEQUENCEREFERENCE (eller den mangler NUMBERSEQUENCEID).");
}
return NumberSequenceQuery(database, company, join, referenced, notes);
}
}
/// <summary>
/// Dynamics 365 Finance &amp; Operations on-premises: recognised, and read with the AX 2012 layout (BATCHJOB,
/// NUMBERSEQUENCETABLE with NUMBERSEQUENCESCOPE), which F&amp;O kept. Only the general and column-checked checks run;
/// NOT verified against customer data.
/// </summary>
public sealed class FinanceOperationsAdapter : Ax2012Adapter
{
public override IReadOnlyList<AxVersion> Versions => [AxVersion.FinanceOperations];
public override string SchemaFileName => "schema-fo";
}

View file

@ -0,0 +1,90 @@
using System.Globalization;
namespace SQLInsight.Core.DynamicsAx;
/// <summary>
/// An AX schema as a tab-separated file (docs/dynamics-ax/schema-ax*.tsv: skema, tabel, kolonne, type, max_length,
/// column_id), as exported from a customer's database with sys.columns. Used to check the version adapters against real
/// schemas and to build realistic test databases. A real export (schema-ax2009.tsv) wins over the one SQLInsight wrote
/// from Microsoft's documentation (schema-ax2009.documented.tsv).
/// </summary>
public sealed record AxSchemaFile(string Path, bool Documented, IReadOnlyList<AxSchemaFile.Column> Columns)
{
public sealed record Column(string Schema, string Table, string Name, string Type, int MaxLength, int ColumnId)
{
/// <summary>sys.columns.precision for the types AX uses.</summary>
public int Precision => Type.ToLowerInvariant() switch
{
"int" => 10,
"bigint" => 19,
"smallint" => 5,
"tinyint" => 3,
"datetime" => 23,
"numeric" or "decimal" => 28,
"float" => 53,
_ => 0,
};
/// <summary>The column as T-SQL DDL, e.g. [NEXTREC] int NOT NULL.</summary>
public string Definition => "[" + Name + "] " + Type.ToLowerInvariant() switch
{
"varchar" or "nvarchar" or "char" or "nchar" or "varbinary" or "binary" => $"{Type}({(MaxLength < 0 ? "max" : MaxLength.ToString(CultureInfo.InvariantCulture))})",
"numeric" or "decimal" => $"{Type}(28, 12)",
_ => Type,
} + " NULL";
}
public IEnumerable<string> Tables => Columns.Select(c => c.Table).Distinct(StringComparer.OrdinalIgnoreCase);
public IEnumerable<Column> Of(string table) => Columns.Where(c => string.Equals(c.Table, table, StringComparison.OrdinalIgnoreCase)).OrderBy(c => c.ColumnId);
public bool Has(string table, string column) => Of(table).Any(c => string.Equals(c.Name, column, StringComparison.OrdinalIgnoreCase));
/// <summary>The file for an adapter in <paramref name="directory"/>: the real export when there is one, else the documented one, else null.</summary>
public static AxSchemaFile? Find(string directory, string fileName)
{
var real = System.IO.Path.Combine(directory, fileName + ".tsv");
if (File.Exists(real))
{
return Load(real, documented: false);
}
var documented = System.IO.Path.Combine(directory, fileName + ".documented.tsv");
return File.Exists(documented) ? Load(documented, documented: true) : null;
}
public static AxSchemaFile Load(string path, bool documented)
{
var columns = new List<Column>();
foreach (var line in File.ReadLines(path).Skip(1))
{
var parts = line.TrimEnd('\r').Split('\t');
if (parts.Length < 6 || parts[0].StartsWith('#'))
{
continue;
}
columns.Add(new Column(parts[0].Trim(), parts[1].Trim().ToUpperInvariant(), parts[2].Trim().ToUpperInvariant(), parts[3].Trim(),
int.Parse(parts[4].Trim(), CultureInfo.InvariantCulture), int.Parse(parts[5].Trim(), CultureInfo.InvariantCulture)));
}
return new AxSchemaFile(path, documented, columns);
}
/// <summary>The detection rows the schema would give, every table readable or none.</summary>
public IReadOnlyList<AxDetectionRow> DetectionRows(bool readable = true, string? databaseUser = null) =>
Columns.Where(c => AxDetection.KnownTables.Contains(c.Table, StringComparer.OrdinalIgnoreCase))
.Select(c => new AxDetectionRow(c.Schema, c.Table, c.Name, c.Type, c.Precision, c.MaxLength, readable, databaseUser))
.ToList();
/// <summary>CREATE TABLE statements for the known AX tables in the file (for test databases).</summary>
public IEnumerable<string> CreateTables(IEnumerable<string>? onlyTables = null)
{
var wanted = onlyTables?.ToHashSet(StringComparer.OrdinalIgnoreCase);
foreach (var table in Tables.Where(t => wanted?.Contains(t) ?? true))
{
var columns = Of(table).ToList();
yield return $"CREATE TABLE [{columns[0].Schema}].[{table}] ({string.Join(", ", columns.Select(c => c.Definition))});";
}
}
}

View file

@ -0,0 +1,156 @@
using System.Text.Json.Serialization;
namespace SQLInsight.Core.DynamicsAx;
/// <summary>A NUMBERSEQUENCETABLE row as read, before interpretation.</summary>
/// <param name="Referenced">Used by at least one NUMBERSEQUENCEREFERENCE; null when the reference table cannot be read.</param>
/// <param name="CleanIntervalHours">How often a continuous sequence's unused numbers are cleaned up (CLEANINTERVAL, hours).</param>
public sealed record AxNumberSequenceRow(
long RecId,
string Code,
string? Company,
string? Text,
long Lowest,
long Highest,
long Next,
string? Format,
bool Blocked,
bool Continuous,
bool Cyclic,
bool Manual,
bool? Referenced,
DateTime? LastCleanDate,
int? LastCleanSeconds,
double? CleanIntervalHours,
int ServerUtcOffsetMinutes);
/// <summary>A number sequence now, with its forecast from SQLInsight's own sampled history of NEXTREC.</summary>
public sealed record AxNumberSequence
{
/// <summary>Company/code, or the RECID for a shared sequence (2012).</summary>
public required string Key { get; init; }
public required string Code { get; init; }
public string? Company { get; init; }
public string? Text { get; init; }
public long Lowest { get; init; }
public long Highest { get; init; }
public long Next { get; init; }
public string? Format { get; init; }
/// <summary>The number of # in the format: the digits the number gets.</summary>
public int? FormatDigits { get; init; }
/// <summary>The largest number the format can show (10^digits − 1).</summary>
public long? FormatCapacity { get; init; }
/// <summary>The last number the sequence can hand out: HIGHEST, or the format's capacity when that is lower.</summary>
public long Max { get; init; }
public double UsedPercent { get; init; }
public long Remaining { get; init; }
public bool Blocked { get; init; }
public bool Continuous { get; init; }
public bool Cyclic { get; init; }
public bool Manual { get; init; }
public bool? Referenced { get; init; }
public DateTime? LastCleanUtc { get; init; }
/// <summary>The last cleanup is known (the column exists); <see cref="LastCleanUtc"/> null then means never cleaned up.</summary>
public bool CleanupKnown { get; init; }
public double? CleanIntervalHours { get; init; }
public AxForecast? Forecast { get; init; }
/// <summary>HIGHEST is larger than the format can show: numbers above the format's capacity cannot be made.</summary>
[JsonIgnore]
public bool FormatTooShort => FormatCapacity is { } capacity && Highest > capacity;
}
/// <summary>A SYSTEMSEQUENCES row (RecId allocation) as read.</summary>
public sealed record AxRecIdRow(int TableId, string? Company, long NextVal, long? MaxVal, string? TableName);
/// <summary>A RecId sequence now: one per table (per company in AX 3.0), with its forecast.</summary>
public sealed record AxRecIdSequence
{
public required string Key { get; init; }
public int TableId { get; init; }
public string? TableName { get; init; }
public string? Company { get; init; }
public long Next { get; init; }
/// <summary>The last RecId that can be handed out: the column type's limit (int in AX 3.0), or MAXVAL when lower.</summary>
public long Max { get; init; }
public int Bits { get; init; }
public double UsedPercent { get; init; }
public long Remaining { get; init; }
public AxForecast? Forecast { get; init; }
}
/// <summary>The consumption rate from the oldest sample in the window to now, and when that runs out.</summary>
/// <param name="PerDay">Numbers used per day.</param>
/// <param name="DaysLeft">Days until exhaustion at that rate; null when nothing is being used.</param>
/// <param name="BasisDays">How many days of history the rate is based on.</param>
public sealed record AxForecast(double PerDay, double? DaysLeft, double BasisDays)
{
/// <summary>
/// The forecast from daily samples of the value, or null with less than a day of history. Uses the oldest sample
/// at most <paramref name="windowDays"/> old (a recent rate) and at least one day old.
/// </summary>
public static AxForecast? From(IEnumerable<AxSample> samples, DateTime nowUtc, long current, long max, int windowDays = 30)
{
ArgumentNullException.ThrowIfNull(samples);
var basis = samples
.Where(s => s.SampledUtc <= nowUtc.AddDays(-1) && s.SampledUtc >= nowUtc.AddDays(-windowDays))
.OrderBy(s => s.SampledUtc)
.FirstOrDefault();
if (basis is null)
{
return null;
}
var days = (nowUtc - basis.SampledUtc).TotalDays;
var perDay = (current - basis.Value) / days;
if (perDay <= 0)
{
return new AxForecast(0, null, days);
}
return new AxForecast(perDay, Math.Max(0, (max - current) / perDay), days);
}
}
/// <summary>One stored daily sample of a sequence's value (insight.AxSequenceSamples).</summary>
public sealed record AxSample(DateTime SampledUtc, long Value);
/// <summary>Number sequences and RecIds of one AX database, or why they could not be read.</summary>
public sealed record AxSequenceDatabase
{
public required string Database { get; init; }
public AxVersion Version { get; init; }
public string? NumberSequencesNotMeasurable { get; init; }
public IReadOnlyList<AxNumberSequence> NumberSequences { get; init; } = [];
public string? RecIdsNotMeasurable { get; init; }
public IReadOnlyList<AxRecIdSequence> RecIds { get; init; } = [];
public IReadOnlyList<string> Notes { get; init; } = [];
}
/// <summary>Number sequences and RecIds of every monitored AX database on an instance (collector "dynamics-ax-sequences", hourly).</summary>
public sealed record AxSequenceSnapshot
{
public const char NumberSequenceKind = 'N';
public const char RecIdKind = 'R';
public required DateTimeOffset CapturedAt { get; init; }
public required AxThresholds Thresholds { get; init; }
public required IReadOnlyList<AxSequenceDatabase> Databases { get; init; }
/// <summary>
/// Daily samples per database, kind (<see cref="NumberSequenceKind"/>/<see cref="RecIdKind"/>) and key, for the
/// forecasts. Kept in memory (bounded to the forecast window) and rebuilt from insight.AxSequenceSamples after a restart.
/// </summary>
[JsonIgnore]
public IReadOnlyDictionary<string, IReadOnlyList<AxSample>> History { get; init; } = new Dictionary<string, IReadOnlyList<AxSample>>();
public static string HistoryKey(string database, char kind, string key) => database + "\u001f" + kind + "\u001f" + key;
}

View file

@ -0,0 +1,101 @@
using System.Globalization;
using System.Text;
using SQLInsight.Core.Data;
namespace SQLInsight.Core.DynamicsAx;
/// <summary>Who in AX is behind a SQL Server session, as far as SQL Server can tell.</summary>
/// <param name="AxUser">The AX user id, when the AOS put it in CONTEXT_INFO.</param>
/// <param name="AxSessionId">The AX session id, when the AOS put it in CONTEXT_INFO.</param>
/// <param name="Source">"context_info", or "ikke tilgængelig".</param>
/// <param name="Explanation">How it was found, or why it cannot be (Danish).</param>
/// <param name="RawContext">CONTEXT_INFO as text, when it is printable.</param>
public sealed record AxSessionIdentity(string? AxUser, int? AxSessionId, string Source, string Explanation, string? RawContext = null)
{
public bool Known => AxUser is not null;
}
/// <summary>
/// Maps SQL Server sessions to Dynamics AX users. All AX users reach SQL Server through the AOS's own login, so the
/// login says nothing about the AX user. AX 2009 and 2012 (and F&amp;O) AOS can write the AX user id and AX session id to
/// CONTEXT_INFO on every connection when "connectioncontext" is turned on in the AOS configuration (registry); Microsoft
/// documents this for AX 2012 – the exact text layout is not verified against customer data, so the user is taken as the
/// first token with a letter and the session as the first number. AX 3.0 and 4.0 never set CONTEXT_INFO: the AX user
/// cannot be found from SQL Server, only the host (the AOS in a 3-tier installation, the user's own PC for a 2-tier
/// client) and the program.
/// </summary>
public static class AxSessionMapper
{
public static AxSessionIdentity Map(SessionInfo session, AxVersion version)
{
ArgumentNullException.ThrowIfNull(session);
var text = Decode(session.ContextInfo);
if (version is AxVersion.Axapta30 or AxVersion.Ax40)
{
return new AxSessionIdentity(null, null, "ikke tilgængelig",
// No host or program name here: the explanation is not sensitive (it may reach SQLInsight Hub); they are
// in the finding's sensitive evidence "Vært / program".
$"{AxVersions.Text(version)} sætter ikke CONTEXT_INFO, så AX-brugeren kan ikke ses fra SQL Server. Værten under målingerne er "
+ "AOS'en ved en 3-lags-installation og brugerens egen pc ved en 2-lags-klient. Brugeren kan findes i AX under Online brugere ud fra "
+ "tidspunktet og værten.", text);
}
if (text is not null)
{
var tokens = text.Split((char[]?)null, StringSplitOptions.RemoveEmptyEntries);
var user = tokens.FirstOrDefault(t => t.Any(char.IsLetter));
int? axSession = tokens.FirstOrDefault(t => t.All(char.IsAsciiDigit)) is { } digits
&& int.TryParse(digits, NumberStyles.None, CultureInfo.InvariantCulture, out var id) ? id : null;
if (user is not null)
{
return new AxSessionIdentity(user, axSession, "context_info",
"Fra CONTEXT_INFO, som AOS'en sætter, når connectioncontext er slået til (første ord med bogstaver er taget som AX-brugeren; "
+ "tekstformatet er dokumenteret af Microsoft for AX 2012, men ikke verificeret mod kundedata – se den rå CONTEXT_INFO under målingerne).", text);
}
}
return new AxSessionIdentity(null, null, "ikke tilgængelig",
"AOS'en har ikke sat CONTEXT_INFO på sessionen. Slå connectioncontext til i AOS-konfigurationen (registreringsdatabasen på AOS-serveren, "
+ "kræver genstart af AOS) for at se AX-brugeren bag SQL-sessioner.", text);
}
/// <summary>CONTEXT_INFO hex as printable text (ASCII, or UTF-16 when every second byte is zero), or null.</summary>
public static string? Decode(string? hex)
{
if (string.IsNullOrWhiteSpace(hex))
{
return null;
}
byte[] bytes;
try
{
bytes = Convert.FromHexString(hex.StartsWith("0x", StringComparison.OrdinalIgnoreCase) ? hex[2..] : hex);
}
catch (FormatException)
{
return null;
}
var length = bytes.Length;
while (length > 0 && bytes[length - 1] == 0)
{
length--;
}
if (length == 0)
{
return null;
}
var unicode = length >= 4 && Enumerable.Range(0, (length + 1) / 2).All(i => 2 * i + 1 >= length || bytes[2 * i + 1] == 0);
var text = unicode
? Encoding.Unicode.GetString(bytes, 0, Math.Min(bytes.Length, length + (length % 2)))
: Encoding.Latin1.GetString(bytes, 0, length);
var printable = new string(text.Select(c => char.IsControl(c) ? ' ' : c).ToArray()).Trim();
return printable.Length == 0 || printable.Count(c => char.IsAsciiLetterOrDigit(c) || c is ' ' or '\\' or '-' or '_' or '.' or '@' or 'æ' or 'ø' or 'å' or 'Æ' or 'Ø' or 'Å')
< printable.Length * 0.8
? null
: printable;
}
}

View file

@ -0,0 +1,146 @@
using System.Globalization;
using System.Text;
namespace SQLInsight.Core.DynamicsAx;
/// <summary>
/// How SQLInsight reads AX tables: only SELECT, bounded with TOP, in the AX database's own context through
/// <c>[db].sys.sp_executesql</c> (the session never changes database), with a short lock timeout under read committed
/// so a read gives up instead of waiting behind AX, or snapshot isolation when the database allows it. Never NOLOCK.
/// </summary>
public static class AxSql
{
/// <summary>A read of an AX table gives up after this long waiting for a lock (SET LOCK_TIMEOUT).</summary>
public const int LockTimeoutMilliseconds = 2000;
/// <summary>SQL Server's error number for "Lock request time out period exceeded".</summary>
public const int LockTimeoutError = 1222;
/// <summary>Command timeout for AX reads: short; they are small and indexed.</summary>
public const int CommandTimeoutSeconds = 15;
public static string Identifier(string name)
{
ArgumentNullException.ThrowIfNull(name);
return "[" + name.Replace("]", "]]", StringComparison.Ordinal) + "]";
}
public static string Literal(string value)
{
ArgumentNullException.ThrowIfNull(value);
return "N'" + value.Replace("'", "''", StringComparison.Ordinal) + "'";
}
public static string Number(long value) => value.ToString(CultureInfo.InvariantCulture);
/// <summary>
/// Runs <paramref name="innerSql"/> in <paramref name="database"/> without changing the session's database, with
/// the isolation and lock timeout for AX reads. The SET options only apply inside sp_executesql; they are reset
/// afterwards anyway.
/// </summary>
public static string InDatabase(string database, string innerSql, AxReadIsolation isolation = AxReadIsolation.ReadCommitted) =>
"EXEC " + Identifier(database) + ".sys.sp_executesql " + Literal(Batch(innerSql, isolation)) + ";\n" + Reset;
/// <summary>The batch sp_executesql runs inside the AX database: lock timeout, isolation, then the query.</summary>
public static string Batch(string innerSql, AxReadIsolation isolation = AxReadIsolation.ReadCommitted)
{
ArgumentNullException.ThrowIfNull(innerSql);
return new StringBuilder()
.Append("SET NOCOUNT ON; SET LOCK_TIMEOUT ").Append(LockTimeoutMilliseconds.ToString(CultureInfo.InvariantCulture)).Append("; ")
.Append(isolation == AxReadIsolation.Snapshot ? "SET TRANSACTION ISOLATION LEVEL SNAPSHOT; " : "SET TRANSACTION ISOLATION LEVEL READ COMMITTED; ")
.Append(innerSql)
.ToString();
}
/// <summary>
/// Sent after every AX read: the agent's own session settings again (<see cref="Instances.AgentSession"/>), so the next
/// collector on the shared connection keeps its lock timeout and isolation. (The SET options inside sp_executesql only
/// last for that batch anyway; this makes it explicit.)
/// </summary>
public const string Reset = Instances.AgentSession.LockTimeout + " " + Instances.AgentSession.Isolation;
}
/// <summary>
/// A query an AX check would run, or why it cannot (Danish, "Ikke målbar: …"). Built by the version adapters, which
/// check every table and column in sys.columns first, so a query never fails on a schema it does not fit.
/// </summary>
public sealed record AxQuery
{
private AxQuery(string? sql, string? notMeasurable, IReadOnlyList<string> notes)
{
Sql = sql;
NotMeasurable = notMeasurable;
Notes = notes;
}
/// <summary>The inner SQL (run with <see cref="AxSql.InDatabase"/>), when measurable.</summary>
public string? Sql { get; }
public string? NotMeasurable { get; }
/// <summary>Optional columns or tables that were missing or unreadable, and what that leaves out.</summary>
public IReadOnlyList<string> Notes { get; }
public bool IsMeasurable => Sql is not null;
public static AxQuery Ok(string sql, IReadOnlyList<string>? notes = null) => new(sql, null, notes ?? []);
public static AxQuery Unmeasurable(string reason) => new(null, reason, []);
}
/// <summary>
/// Builds a SELECT list where every column is checked against the database's actual columns: a missing required column
/// makes the query not measurable with the exact name, a missing optional column becomes NULL with a note.
/// </summary>
public sealed class AxSelect(AxDatabase database, string table, string alias)
{
private readonly List<string> _missingRequired = [];
private readonly List<string> _notes = [];
private readonly List<string> _columns = [];
public IReadOnlyList<string> MissingRequired => _missingRequired;
public IReadOnlyList<string> Notes => _notes;
public string Table => table;
/// <summary>The expression for a column, or NULL of <paramref name="nullType"/> when optional and missing.</summary>
public string Column(string column, string nullType = "int", bool required = false, string? whatIsLost = null)
{
if (database.HasColumn(table, column))
{
return alias + "." + AxSql.Identifier(column);
}
if (required)
{
_missingRequired.Add($"{database.Schema}.{table}.{column}");
}
else if (whatIsLost is not null)
{
_notes.Add($"Kolonnen {database.Schema}.{table}.{column} findes ikke: {whatIsLost}");
}
return "CAST(NULL AS " + nullType + ")";
}
/// <summary>Adds a column to the SELECT list (in order).</summary>
public AxSelect Add(string column, string nullType = "int", bool required = false, string? whatIsLost = null)
{
_columns.Add(Column(column, nullType, required, whatIsLost));
return this;
}
/// <summary>Adds an expression to the SELECT list (in order).</summary>
public AxSelect AddExpression(string expression)
{
_columns.Add(expression);
return this;
}
public string SelectList => string.Join(", ", _columns);
/// <summary>"Ikke målbar" with the exact missing columns, or null when all required columns exist.</summary>
public string? MissingReason() => _missingRequired.Count == 0
? null
: $"Ikke målbar: {database.Database} mangler {(_missingRequired.Count == 1 ? "kolonnen" : "kolonnerne")} {string.Join(", ", _missingRequired)}, "
+ $"som {AxVersions.Text(database.Version)}-skemaet forventes at have. Skemaet afviger fra det kendte, så tjekket springes over.";
}

View file

@ -0,0 +1,63 @@
namespace SQLInsight.Core.DynamicsAx;
/// <summary>
/// When the AX checks raise a finding (SQLInsight:DynamicsAx in the agent's configuration). Carried in the snapshots,
/// so the rules and the web UI use the same values as the collection.
/// </summary>
public sealed record AxThresholds
{
/// <summary>A waiting batch job whose planned start is more than this many minutes ago is late (SQLI-AX-003).</summary>
public int BatchLateMinutes { get; init; } = 30;
/// <summary>Late by more than this many hours: it has in practice never started (High).</summary>
public int BatchNeverStartedHours { get; init; } = 24;
/// <summary>A batch job executing longer than this many hours is stuck (SQLI-AX-004).</summary>
public int BatchStuckHours { get; init; } = 6;
/// <summary>Running longer than this many times its own 90th percentile is unusually long (SQLI-AX-002).</summary>
public double BatchLongRunningFactor { get; init; } = 2.0;
/// <summary>…but never for runs shorter than this many minutes.</summary>
public int BatchLongRunningMinimumMinutes { get; init; } = 10;
/// <summary>Earlier runs needed before a job's own history is used.</summary>
public int BatchHistoryMinimumRuns { get; init; } = 5;
/// <summary>A run that failed within this many hours is reported (SQLI-AX-001).</summary>
public int BatchFailedLookbackHours { get; init; } = 24;
/// <summary>A number sequence more used than this (percent) is a finding (SQLI-AX-005); <see cref="NumberSequenceCriticalPercent"/> is High.</summary>
public double NumberSequenceWarnPercent { get; init; } = 80;
public double NumberSequenceCriticalPercent { get; init; } = 95;
/// <summary>Forecast to run out within this many days: a finding; within <see cref="ForecastCriticalDays"/>: High.</summary>
public int ForecastWarnDays { get; init; } = 90;
public int ForecastCriticalDays { get; init; } = 30;
/// <summary>AX 3.0 (32-bit RecId): a finding from this share of the int range, earlier than for 64-bit.</summary>
public double RecId32WarnPercent { get; init; } = 50;
/// <summary>AX 3.0: Critical from this share.</summary>
public double RecId32CriticalPercent { get; init; } = 75;
/// <summary>AX 3.0: forecast to run out within this many days is a finding (Critical within half).</summary>
public int RecId32ForecastDays { get; init; } = 730;
/// <summary>AX 4.0 and later (64-bit RecId, or a lowered MAXVAL): a finding from this share.</summary>
public double RecId64WarnPercent { get; init; } = 80;
/// <summary>An AX session blocking others for at least this many seconds is reported with its AX user (SQLI-AX-010).</summary>
public int BlockingSeconds { get; init; } = 30;
/// <summary>How many days back the forecasts look for their oldest sample.</summary>
public int ForecastBasisDays { get; init; } = 30;
/// <summary>
/// The time zone of the AOS (Windows or IANA id) for Axapta 3.0/4.0's local batch and cleanup times; null: the agent's
/// own zone when it matches the SQL Server (see <see cref="AxLocalTime.Resolve"/>).
/// </summary>
public string? LocalTimeZone { get; init; }
}

View file

@ -0,0 +1,90 @@
namespace SQLInsight.Core.DynamicsAx;
/// <summary>
/// The Dynamics AX generation of a database, as far as its schema tells (<see cref="AxDetection"/>). Stored as a number
/// in snapshots: only add at the end.
/// </summary>
public enum AxVersion
{
Unknown = 0,
/// <summary>Axapta 2.5 or 3.0: 32-bit RECID, tables usually owned by bmssa. The two cannot be told apart from the schema.</summary>
Axapta30 = 1,
/// <summary>AX 4.0: 64-bit RECID, the old batch table (BATCH with STARTDATE/STARTTIME), no BATCHJOB.</summary>
Ax40 = 2,
/// <summary>AX 2009: BATCHJOB/BATCHJOBHISTORY with UTC times, no NUMBERSEQUENCESCOPE.</summary>
Ax2009 = 3,
/// <summary>AX 2012 RTM/FP: NUMBERSEQUENCESCOPE, model store inside the business database, no PARTITION column.</summary>
Ax2012 = 4,
/// <summary>AX 2012 R2/R3: PARTITION column, model store in the separate &lt;database&gt;_model database.</summary>
Ax2012R2 = 5,
/// <summary>
/// Dynamics 365 Finance &amp; Operations on-premises (formodet): PARTITION column but no model store anywhere on the
/// instance. Only recognised, not verified.
/// </summary>
FinanceOperations = 6,
}
public static class AxVersions
{
/// <summary>The largest RECID AX 3.0 can hand out: RECID is int (32-bit).</summary>
public const long Int32RecIdMax = int.MaxValue;
/// <summary>Short text, as stored in insight.ConfiguredDatabases.DynamicsAxVersion (varchar(16)).</summary>
public static string ShortText(AxVersion version) => version switch
{
AxVersion.Axapta30 => "3.0",
AxVersion.Ax40 => "4.0",
AxVersion.Ax2009 => "2009",
AxVersion.Ax2012 => "2012",
AxVersion.Ax2012R2 => "2012 R2/R3",
AxVersion.FinanceOperations => "F&O",
_ => "ukendt",
};
/// <summary>The name shown in the web UI.</summary>
public static string Text(AxVersion version) => version switch
{
AxVersion.Axapta30 => "Axapta 3.0 (eller 2.5)",
AxVersion.Ax40 => "Dynamics AX 4.0",
AxVersion.Ax2009 => "Dynamics AX 2009",
AxVersion.Ax2012 => "Dynamics AX 2012 (RTM/FP)",
AxVersion.Ax2012R2 => "Dynamics AX 2012 R2/R3",
AxVersion.FinanceOperations => "Dynamics 365 Finance & Operations (on-premises, formodet)",
_ => "Ukendt AX-version",
};
/// <summary>
/// Whether SQLInsight's knowledge of the version's tables is checked against a real customer schema
/// (docs/dynamics-ax/schema-ax30.tsv). Everything else is built from Microsoft's documentation.
/// </summary>
public static bool IsVerified(AxVersion version) => version == AxVersion.Axapta30;
/// <summary>What the UI and the docs say about how sure SQLInsight is of the version's schema.</summary>
public static string VerificationText(AxVersion version) => IsVerified(version)
? "Verificeret mod en rigtig AX 3.0-database (kundeskema, docs/dynamics-ax/schema-ax30.tsv)."
: "Baseret på Microsofts dokumenterede skema – ikke verificeret mod kundedata.";
/// <summary>RECID is 32-bit (int) in Axapta 2.5/3.0 and 64-bit (bigint) from AX 4.0.</summary>
public static bool Has32BitRecId(AxVersion version) => version == AxVersion.Axapta30;
/// <summary>Batch times: local date + seconds since midnight (3.0/4.0) or UTC datetime (2009 and later).</summary>
public static bool HasUtcBatchTimes(AxVersion version) => version is AxVersion.Ax2009 or AxVersion.Ax2012 or AxVersion.Ax2012R2 or AxVersion.FinanceOperations;
/// <summary>Parses <see cref="ShortText"/> back; also the 1.3 values "3.0" and "4.0+".</summary>
public static AxVersion Parse(string? text) => text switch
{
"3.0" => AxVersion.Axapta30,
"4.0" => AxVersion.Ax40,
"2009" => AxVersion.Ax2009,
"2012" => AxVersion.Ax2012,
"2012 R2/R3" => AxVersion.Ax2012R2,
"F&O" => AxVersion.FinanceOperations,
_ => AxVersion.Unknown,
};
}

View file

@ -0,0 +1,18 @@
namespace SQLInsight.Core.Instances;
/// <summary>
/// The session settings the agent sets on every monitoring connection (SqlConnectionFactory), so the monitoring session
/// is never the one that blocks or wins a deadlock on the monitored server. Every statement is valid on SQL Server 2000.
/// Code that changes a setting for one read (the Dynamics AX reads) sets it back to these values afterwards, because
/// the collectors of an instance share one connection.
/// </summary>
public static class AgentSession
{
public const string LockTimeout = "SET LOCK_TIMEOUT 5000;";
public const string DeadlockPriority = "SET DEADLOCK_PRIORITY LOW;";
public const string Isolation = "SET TRANSACTION ISOLATION LEVEL READ UNCOMMITTED;";
public const string Settings = LockTimeout + " " + DeadlockPriority + " " + Isolation;
}

View file

@ -5,5 +5,7 @@
<ItemGroup>
<!-- Shown in SQLInsight Web for a newly added server (Configuration/GrantScript.cs). -->
<EmbeddedResource Include="..\..\db\permissions\grant-least-privilege.sql" LogicalName="SQLInsight.Core.grant-least-privilege.sql" />
<!-- The optional Dynamics AX read access, shown under Dynamics AX → AX-databaser (DynamicsAx/AxReadAccess.cs). -->
<EmbeddedResource Include="..\..\db\permissions\grant-dynamics-ax-read.sql" LogicalName="SQLInsight.Core.grant-dynamics-ax-read.sql" />
</ItemGroup>
</Project>

View file

@ -0,0 +1,192 @@
using System.Text.Json;
using Dapper;
using Microsoft.Data.SqlClient;
using SQLInsight.Core.DynamicsAx;
namespace SQLInsight.Repository;
/// <summary>A stored completed AX batch run (insight.AxBatchRuns).</summary>
public sealed record StoredAxBatchRun(string InstanceName, string DatabaseName, string JobKey, string RunKey, string? Caption, string? Company,
DateTime StartedAt, DateTime EndedAt, AxBatchStatus Status)
{
public TimeSpan Duration => EndedAt - StartedAt;
}
/// <summary>One stored daily sample of an AX sequence (insight.AxSequenceSamples).</summary>
public sealed record StoredAxSequenceSample(string DatabaseName, char Kind, string SequenceKey, DateTime SampledAt, long Value);
public sealed partial class InsightRepository
{
/// <summary>A job's runs read back after a restart, so it is compared with its own history at once.</summary>
public const int AxBatchRunsPerJob = AxBatchHistory.RunsKept;
private static Task<int> SaveAxBatchRunsAsync(SqlConnection connection, int instanceId, AxBatchSnapshot snapshot)
{
var runs = snapshot.Databases.SelectMany(d => d.NewRuns.Select(r => new
{
Database = d.Database,
r.RunKey,
JobKey = Truncate(r.JobKey, 200),
Caption = r.Caption is null ? null : Truncate(r.Caption, 200),
Company = r.Company is null ? null : Truncate(r.Company, 10),
StartedAt = Iso(r.StartedUtc),
EndedAt = Iso(r.EndedUtc),
Status = (byte)r.Status,
})).Where(r => r.RunKey.Length <= 100)
.GroupBy(r => (r.Database, r.RunKey)).Select(g => g.First()) // one row per key: two runs with one key would break the primary key
.ToList();
return runs.Count == 0
? Task.FromResult(0)
: connection.ExecuteAsync("""
INSERT insight.AxBatchRuns (InstanceId, DatabaseName, RunKey, JobKey, Caption, Company, StartedAt, EndedAt, Status)
SELECT @instanceId, j.DatabaseName, j.RunKey, j.JobKey, j.Caption, j.Company, j.StartedAt, j.EndedAt, j.Status
FROM (SELECT DISTINCT * FROM OPENJSON(@json) WITH (DatabaseName nvarchar(128) '$.Database', RunKey nvarchar(100), JobKey nvarchar(200), Caption nvarchar(200),
Company nvarchar(10), StartedAt datetime2(0), EndedAt datetime2(0), Status tinyint)) j
WHERE NOT EXISTS (SELECT 1 FROM insight.AxBatchRuns r WHERE r.InstanceId = @instanceId AND r.DatabaseName = j.DatabaseName AND r.RunKey = j.RunKey);
""", new { instanceId, json = JsonSerializer.Serialize(runs, Json) });
}
/// <summary>Today's sample of every sequence read (the latest of the day wins).</summary>
private static Task<int> SaveAxSequenceSamplesAsync(SqlConnection connection, int instanceId, AxSequenceSnapshot snapshot)
{
var at = Utc(snapshot.CapturedAt);
var samples = snapshot.Databases.SelectMany(d =>
d.NumberSequences.Select(n => (d.Database, Kind: AxSequenceSnapshot.NumberSequenceKind, n.Key, Value: n.Next))
.Concat(d.RecIds.Select(r => (d.Database, Kind: AxSequenceSnapshot.RecIdKind, r.Key, Value: r.Next))))
.Where(s => s.Key.Length <= 100)
.Select(s => new { s.Database, Kind = s.Kind.ToString(), s.Key, s.Value })
.ToList();
return samples.Count == 0
? Task.FromResult(0)
: connection.ExecuteAsync("""
MERGE insight.AxSequenceSamples AS t
USING (SELECT DISTINCT DatabaseName, Kind, SequenceKey, Value FROM OPENJSON(@json)
WITH (DatabaseName nvarchar(128) '$.Database', Kind char(1), SequenceKey nvarchar(100) '$.Key', Value bigint)) AS s
ON t.InstanceId = @instanceId AND t.DatabaseName = s.DatabaseName AND t.Kind = s.Kind AND t.SequenceKey = s.SequenceKey AND t.[Day] = @day
WHEN MATCHED THEN UPDATE SET Value = s.Value, SampledAt = @at
WHEN NOT MATCHED THEN INSERT (InstanceId, DatabaseName, Kind, SequenceKey, [Day], SampledAt, Value)
VALUES (@instanceId, s.DatabaseName, s.Kind, s.SequenceKey, @day, @at, s.Value);
""", new { instanceId, at, day = at.Date, json = JsonSerializer.Serialize(samples, Json) });
}
private static string Iso(DateTime utc) => utc.ToString("yyyy-MM-ddTHH:mm:ss", System.Globalization.CultureInfo.InvariantCulture);
private static string Truncate(string value, int length) => value.Length <= length ? value : value[..length];
/// <summary>
/// The latest <see cref="AxBatchRunsPerJob"/> runs of every job since <paramref name="since"/>: the agent's batch
/// collector starts from them after a restart.
/// </summary>
public async Task<AxBatchHistory> GetAxBatchHistoryAsync(int instanceId, DateTimeOffset since, CancellationToken cancellationToken = default)
{
await using var connection = await OpenAsync(cancellationToken).ConfigureAwait(false);
var rows = await connection.QueryAsync<AxBatchRunRow>("""
SELECT DatabaseName, JobKey, RunKey, Caption, Company, StartedAt, EndedAt, Status
FROM (SELECT r.*, ROW_NUMBER() OVER (PARTITION BY r.DatabaseName, r.JobKey ORDER BY r.EndedAt DESC) AS n
FROM insight.AxBatchRuns r WHERE r.InstanceId = @instanceId AND r.EndedAt >= @since) x
WHERE n <= @perJob;
""", new { instanceId, since = Utc(since), perJob = AxBatchRunsPerJob }).ConfigureAwait(false);
var history = new AxBatchHistory();
foreach (var r in rows)
{
history.Add(r.DatabaseName, new AxBatchRun(r.JobKey, r.RunKey, r.Caption, r.Company, AsUtc(r.StartedAt), AsUtc(r.EndedAt), (AxBatchStatus)r.Status));
}
return history;
}
/// <summary>Completed AX batch runs, newest first; for one instance, database and job when given.</summary>
public async Task<IReadOnlyList<StoredAxBatchRun>> GetAxBatchRunsAsync(int? instanceId, string? database, string? jobKey, DateTimeOffset from, DateTimeOffset to, int top = 500,
CancellationToken cancellationToken = default)
{
await using var connection = await OpenAsync(cancellationToken).ConfigureAwait(false);
var rows = await connection.QueryAsync<AxBatchRunRow>("""
SELECT TOP (@top) i.Name AS InstanceName, r.DatabaseName, r.JobKey, r.RunKey, r.Caption, r.Company, r.StartedAt, r.EndedAt, r.Status
FROM insight.AxBatchRuns r JOIN insight.Instances i ON i.InstanceId = r.InstanceId
WHERE (@instanceId IS NULL OR r.InstanceId = @instanceId) AND (@database IS NULL OR r.DatabaseName = @database) AND (@jobKey IS NULL OR r.JobKey = @jobKey)
AND r.EndedAt >= @from AND r.EndedAt <= @to
ORDER BY r.EndedAt DESC;
""", new { instanceId, database, jobKey, from = Utc(from), to = Utc(to), top }).ConfigureAwait(false);
return rows.Select(r => new StoredAxBatchRun(r.InstanceName ?? string.Empty, r.DatabaseName, r.JobKey, r.RunKey, r.Caption, r.Company, AsUtc(r.StartedAt), AsUtc(r.EndedAt),
(AxBatchStatus)r.Status)).ToList();
}
/// <summary>The daily samples since <paramref name="since"/>, keyed as <see cref="AxSequenceSnapshot.HistoryKey"/>: the agent's forecasts start from them after a restart.</summary>
public async Task<IReadOnlyDictionary<string, IReadOnlyList<AxSample>>> GetAxSequenceHistoryAsync(int instanceId, DateTimeOffset since, CancellationToken cancellationToken = default)
{
var samples = await GetAxSequenceSamplesAsync(instanceId, null, null, null, since, cancellationToken: cancellationToken).ConfigureAwait(false);
return samples.GroupBy(s => AxSequenceSnapshot.HistoryKey(s.DatabaseName, s.Kind, s.SequenceKey), StringComparer.Ordinal)
.ToDictionary(g => g.Key, g => (IReadOnlyList<AxSample>)g.OrderBy(s => s.SampledAt).Select(s => new AxSample(s.SampledAt, s.Value)).ToList(), StringComparer.Ordinal);
}
/// <summary>
/// Daily samples of AX sequences, oldest first; for one database, kind and key when given. At most
/// <paramref name="top"/> rows when given (API v1), in the order database, kind, key, day.
/// </summary>
public async Task<IReadOnlyList<StoredAxSequenceSample>> GetAxSequenceSamplesAsync(int instanceId, string? database, char? kind, string? key, DateTimeOffset since,
int? top = null, CancellationToken cancellationToken = default)
{
await using var connection = await OpenAsync(cancellationToken).ConfigureAwait(false);
var rows = await connection.QueryAsync<AxSampleRow>(new CommandDefinition("""
SELECT TOP (@top) DatabaseName, Kind, SequenceKey, SampledAt, Value
FROM insight.AxSequenceSamples
WHERE InstanceId = @instanceId AND [Day] >= @since
AND (@database IS NULL OR DatabaseName = @database) AND (@kind IS NULL OR Kind = @kind) AND (@key IS NULL OR SequenceKey = @key)
ORDER BY DatabaseName, Kind, SequenceKey, [Day];
""", new { instanceId, database, kind = kind?.ToString(), key, since = Utc(since).Date, top = top ?? int.MaxValue },
cancellationToken: cancellationToken)).ConfigureAwait(false);
return rows.Select(r => new StoredAxSequenceSample(r.DatabaseName, r.Kind[0], r.SequenceKey, AsUtc(r.SampledAt), r.Value)).ToList();
}
/// <summary>
/// Blocking events since <paramref name="from"/> where the head blocker or a blocked session was in one of
/// <paramref name="databases"/> (the AX databases), newest first, at most <paramref name="top"/>. Filtered in SQL, so
/// blocking elsewhere on the instance cannot push the AX events out of the limit.
/// </summary>
public async Task<IReadOnlyList<StoredBlocking>> GetBlockingInDatabasesAsync(int instanceId, IReadOnlyCollection<string> databases, DateTimeOffset from, int top,
CancellationToken cancellationToken = default)
{
ArgumentNullException.ThrowIfNull(databases);
if (databases.Count == 0)
{
return [];
}
await using var connection = await OpenAsync(cancellationToken).ConfigureAwait(false);
var rows = await connection.QueryAsync<(DateTime SampledAt, int HeadSessionId, int BlockedCount, long MaxWaitMs, string SessionsJson)>(new CommandDefinition("""
SELECT TOP (@top) b.SampledAt, b.HeadSessionId, b.BlockedCount, b.MaxWaitMs, b.SessionsJson
FROM insight.BlockingEvents b
WHERE b.InstanceId = @instanceId AND b.SampledAt >= @from
AND EXISTS (SELECT 1 FROM OPENJSON(b.SessionsJson) WITH (DatabaseName nvarchar(128) '$.DatabaseName') s
WHERE s.DatabaseName IN (SELECT [value] FROM OPENJSON(@databases)))
ORDER BY b.SampledAt DESC;
""", new { instanceId, from = from.UtcDateTime, top, databases = JsonSerializer.Serialize(databases, Json) }, cancellationToken: cancellationToken))
.ConfigureAwait(false);
return rows.Select(r => new StoredBlocking(r.SampledAt, r.HeadSessionId, r.BlockedCount, r.MaxWaitMs,
JsonSerializer.Deserialize<List<Core.Data.SessionInfo>>(r.SessionsJson, Json) ?? [])).ToList();
}
private static DateTime AsUtc(DateTime value) => DateTime.SpecifyKind(value, DateTimeKind.Utc);
private sealed record AxBatchRunRow
{
public string? InstanceName { get; init; }
public string DatabaseName { get; init; } = string.Empty;
public string JobKey { get; init; } = string.Empty;
public string RunKey { get; init; } = string.Empty;
public string? Caption { get; init; }
public string? Company { get; init; }
public DateTime StartedAt { get; init; }
public DateTime EndedAt { get; init; }
public byte Status { get; init; }
}
private sealed record AxSampleRow
{
public string DatabaseName { get; init; } = string.Empty;
public string Kind { get; init; } = "N";
public string SequenceKey { get; init; } = string.Empty;
public DateTime SampledAt { get; init; }
public long Value { get; init; }
}
}

View file

@ -7,10 +7,10 @@ public sealed class RetentionOptions
/// <summary>Raw samples (5-minute waits, per-minute CPU …).</summary>
public int RawDays { get; set; } = 14;
/// <summary>Hourly aggregates, for long trends.</summary>
/// <summary>Hourly aggregates, for long trends, and the daily samples of Dynamics AX number sequences and RecIds.</summary>
public int HourlyDays { get; set; } = 400;
/// <summary>Blocking events and deadlock graphs.</summary>
/// <summary>Blocking events, deadlock graphs and completed Dynamics AX batch runs.</summary>
public int EventDays { get; set; } = 90;
/// <summary>Resolved findings.</summary>
@ -92,6 +92,8 @@ public sealed partial class InsightRepository
await DeleteInBatchesAsync(connection, "insight.IndexUsageDaily", "[Day] < @cutoff", hourly).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.AxBatchRuns", "EndedAt < @cutoff", events).ConfigureAwait(false);
await DeleteInBatchesAsync(connection, "insight.AxSequenceSamples", "[Day] < @cutoff", hourly).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.ConfigurationChanges", "DetectedAt < @cutoff", resolved).ConfigureAwait(false);

View file

@ -289,6 +289,14 @@ public sealed partial class InsightRepository(string connectionString)
await SaveLatePrivilegeAuthorsAsync(connection, instanceId, privileges.RecentChanges).ConfigureAwait(false);
await SaveSnapshotJsonAsync(connection, instanceId, kind, data).ConfigureAwait(false);
return;
case Core.DynamicsAx.AxBatchSnapshot axBatch:
await SaveAxBatchRunsAsync(connection, instanceId, axBatch).ConfigureAwait(false);
await SaveSnapshotJsonAsync(connection, instanceId, kind, data).ConfigureAwait(false);
return;
case Core.DynamicsAx.AxSequenceSnapshot axSequences:
await SaveAxSequenceSamplesAsync(connection, instanceId, axSequences).ConfigureAwait(false);
await SaveSnapshotJsonAsync(connection, instanceId, kind, data).ConfigureAwait(false);
return;
default:
await SaveSnapshotJsonAsync(connection, instanceId, kind, data).ConfigureAwait(false);
return;

View file

@ -0,0 +1,37 @@
/* 1.4: Dynamics AX. SQLInsight's own history of what it reads from AX, kept in the repository and never in the customer's
AX database (SQLInsight only ever SELECTs from AX, and only with the optional read access).
AxBatchRuns completed batch job runs: from BATCHJOBHISTORY (AX 2009 and later) or seen by SQLInsight itself
(AX 3.0/4.0, which keep no history). A job's own runs are what "longer than normal" is measured
against. RunKey is unique per database, so a run read twice is stored once. Kept EventDays (90).
AxSequenceSamples one sample a day of each number sequence's NEXTREC (Kind 'N') and each RecId sequence's NEXTVAL
(Kind 'R', SYSTEMSEQUENCES), the latest of the day: the forecasts of when they run out are made from
it. Kept HourlyDays (400). */
CREATE TABLE insight.AxBatchRuns
(
InstanceId int NOT NULL,
DatabaseName nvarchar(128) NOT NULL,
RunKey nvarchar(100) NOT NULL,
JobKey nvarchar(200) NOT NULL,
Caption nvarchar(200) NULL,
Company nvarchar(10) NULL,
StartedAt datetime2(0) NOT NULL,
EndedAt datetime2(0) NOT NULL,
Status tinyint NOT NULL,
CONSTRAINT PK_AxBatchRuns PRIMARY KEY (InstanceId, DatabaseName, RunKey)
);
CREATE INDEX IX_AxBatchRuns_Job ON insight.AxBatchRuns (InstanceId, DatabaseName, JobKey, EndedAt);
CREATE INDEX IX_AxBatchRuns_EndedAt ON insight.AxBatchRuns (EndedAt);
CREATE TABLE insight.AxSequenceSamples
(
InstanceId int NOT NULL,
DatabaseName nvarchar(128) NOT NULL,
Kind char(1) NOT NULL CONSTRAINT CK_AxSequenceSamples_Kind CHECK (Kind IN ('N', 'R')),
SequenceKey nvarchar(100) NOT NULL,
[Day] date NOT NULL,
SampledAt datetime2(0) NOT NULL,
Value bigint NOT NULL,
CONSTRAINT PK_AxSequenceSamples PRIMARY KEY (InstanceId, DatabaseName, Kind, SequenceKey, [Day])
);
CREATE INDEX IX_AxSequenceSamples_Day ON insight.AxSequenceSamples ([Day]);

View file

@ -2,6 +2,7 @@ using SQLInsight.Rules.Availability;
using SQLInsight.Rules.Concurrency;
using SQLInsight.Rules.Configuration;
using SQLInsight.Rules.Databases;
using SQLInsight.Rules.DynamicsAx;
using SQLInsight.Rules.Engine;
using SQLInsight.Rules.Indexes;
using SQLInsight.Rules.Instance;
@ -101,6 +102,18 @@ public static class BuiltInRules
// Concurrency
new BlockingRule(),
new DeadlockRule(),
// Dynamics AX (only in databases recognised as AX and chosen for monitoring)
new AxBatchFailedRule(),
new AxBatchLongRunningRule(),
new AxBatchLateRule(),
new AxBatchStuckRule(),
new AxNumberSequenceExhaustionRule(),
new AxNumberSequenceFormatRule(),
new AxNumberSequenceBlockedRule(),
new AxNumberSequenceContinuousRule(),
new AxRecIdExhaustionRule(),
new AxBlockingUserRule(),
];
}
}

View file

@ -29,6 +29,11 @@ public sealed class DeclarativeRule(CustomRuleDefinition definition) : IRule
throw new DataUnavailableException("Reglens forespørgsel er ikke kørt endnu.");
}
if (result.NotRelevant is { } notRelevant)
{
return RuleEvaluation.NotApplicable(notRelevant);
}
if (result.Error is { } error)
{
if (result.PermissionDenied)
@ -50,7 +55,7 @@ public sealed class DeclarativeRule(CustomRuleDefinition definition) : IRule
RuleId = Definition.Id,
Severity = Definition.Severity,
Title = CustomRuleDefinition.Fill(Definition.FindingTitle, row),
Subject = Definition.Subject is { } subject ? CustomRuleDefinition.Fill(subject, row) : row.Values.FirstOrDefault(),
Subject = Subject(row),
Explanation = CustomRuleDefinition.Fill(Definition.Explanation, row)
+ (Definition.Origin == "community" ? " (Community-regel" + (Definition.Author is { } a ? $" af {a}" : "") + ".)" : " (Lokal regel.)"),
Recommendation = CustomRuleDefinition.Fill(Definition.Recommendation, row),
@ -63,4 +68,20 @@ public sealed class DeclarativeRule(CustomRuleDefinition definition) : IRule
}).ToArray();
return RuleEvaluation.Found(findings);
}
/// <summary>
/// The finding's subject. A Dynamics AX rule's findings always start with the database they are about (built like the
/// built-in AX rules'), because the hub opt-in is per database: a subject that does not name it could never be sent, and
/// one that names it differently could be read as another database.
/// </summary>
private string? Subject(IReadOnlyDictionary<string, string?> row)
{
var subject = Definition.Subject is { } s ? CustomRuleDefinition.Fill(s, row) : row.Values.FirstOrDefault();
if (!Definition.DynamicsAx || !row.TryGetValue("database", out var database) || database is null)
{
return subject;
}
return Core.Configuration.DynamicsAxFindings.Subject(database, subject is null || subject == "database: " + database ? null : subject);
}
}

View file

@ -0,0 +1,538 @@
using System.Globalization;
using SQLInsight.Core.Configuration;
using SQLInsight.Core.Data;
using SQLInsight.Core.DynamicsAx;
using SQLInsight.Core.Findings;
using SQLInsight.Core.Instances;
using SQLInsight.Rules.Engine;
namespace SQLInsight.Rules.DynamicsAx;
/// <summary>
/// Shared by the Dynamics AX rules: they only look at databases recognised as AX and chosen for monitoring (the
/// collectors only read those), report "ikke relevant" without any, and "ikke målbar" with the exact reason (often the
/// GRANT text for the optional read access) when no AX database could be read. Findings are about one database
/// (subject "database: X · …"), so they only reach SQLInsight Hub where the AX opt-in is ticked.
/// </summary>
public abstract class DynamicsAxRule : Rule
{
/// <summary>Most findings per database and rule, so one broken AX installation cannot flood the list.</summary>
protected const int MaxFindingsPerDatabase = 25;
public override IReadOnlyCollection<Capability> RequiredCapabilities => [Capability.DynamicManagementViews];
protected const string NoAxDatabases = "Ikke relevant: ingen af de overvågede databaser på instansen er genkendt som Dynamics AX.";
/// <summary>The databases that could be measured, or the outcome when none could.</summary>
protected static (IReadOnlyList<T> Measured, RuleEvaluation? Outcome) Measured<T>(IReadOnlyList<T> databases, Func<T, string> name, Func<T, string?> notMeasurable)
{
if (databases.Count == 0)
{
return ([], RuleEvaluation.NotApplicable(NoAxDatabases));
}
var measured = databases.Where(d => notMeasurable(d) is null).ToList();
return measured.Count > 0
? (measured, null)
: ([], RuleEvaluation.NotMeasurable(string.Join(" ", databases.Select(d => $"{name(d)}: {notMeasurable(d)}"))));
}
protected static string Time(DateTime? utc) => utc is { } t ? t.ToString("yyyy-MM-dd HH:mm 'UTC'", CultureInfo.InvariantCulture) : "–";
protected static string Duration(TimeSpan span) => span.TotalHours >= 2
? $"{Format.Number(span.TotalHours, 1)} timer"
: $"{Format.Number(span.TotalMinutes)} minutter";
protected static string Seconds(double? seconds) => seconds is { } s ? Duration(TimeSpan.FromSeconds(s)) : "–";
/// <summary>When a forecast runs out: "om ca. 12 dage", or "inden for et døgn" (never "om ca. 0 dage").</summary>
protected static string DaysText(double days) => days < 1 ? "inden for et døgn" : $"om ca. {Format.Number(days)} dage";
protected static Evidence Version(AxVersion version) => new("AX-version", AxVersions.Text(version) + (AxVersions.IsVerified(version) ? string.Empty : " – " + AxVersions.VerificationText(version)));
}
/// <summary>Batch rules: read <see cref="AxBatchSnapshot"/>.</summary>
public abstract class DynamicsAxBatchRule : DynamicsAxRule
{
public override RuleEvaluation Evaluate(RuleContext context)
{
ArgumentNullException.ThrowIfNull(context);
var snapshot = context.Data.Get<AxBatchSnapshot>();
var (measured, outcome) = Measured(snapshot.Databases, d => d.Database, d => d.NotMeasurable);
// One finding per subject: Axapta 3.0/4.0 keep a BATCH row per recurrence, so several rows share a job key (e.g. the
// failed run and the next waiting one). The most severe wins, then the first (the newest), so the finding the hub
// and the repository keep is the same.
return outcome ?? Result(measured.SelectMany(d => Check(d, snapshot.Thresholds, context.Now)
.GroupBy(f => f.Subject, StringComparer.Ordinal)
.Select(g => g.OrderByDescending(f => f.Severity).First())
.Take(MaxFindingsPerDatabase)));
}
protected abstract IEnumerable<Finding> Check(AxBatchDatabase database, AxThresholds thresholds, DateTimeOffset now);
/// <summary>How long an executing job has run, or null when unknown (or negative: clocks or time zones disagree).</summary>
protected static TimeSpan? RunningFor(AxBatchJob job, DateTimeOffset now) =>
job.Status == AxBatchStatus.Executing && job.Running(now) is { } running && running >= TimeSpan.Zero ? running : null;
/// <summary>
/// SQLI-AX-004's condition: executing at least BatchStuckHours and not normally that long by the job's own history
/// (which needs BatchHistoryMinimumRuns runs, as for SQLI-AX-002).
/// </summary>
protected static bool IsStuck(AxBatchJob job, TimeSpan running, AxThresholds thresholds)
{
var normallyLong = job.HistoryRuns >= thresholds.BatchHistoryMinimumRuns && job.P90Seconds is { } p90
&& running.TotalSeconds <= p90 * thresholds.BatchLongRunningFactor;
return running >= TimeSpan.FromHours(thresholds.BatchStuckHours) && !normallyLong;
}
protected Finding JobFinding(AxBatchDatabase database, AxBatchJob job, Severity severity, string title, string explanation, string recommendation,
IEnumerable<Evidence> evidence) =>
CreateFinding(severity, title, explanation, recommendation,
[
new Evidence("Database", database.Database),
new Evidence("Batchjob", job.Caption),
new Evidence("Firma", job.Company ?? "–"),
new Evidence("Status i AX", $"{StatusText(job.Status)} (STATUS = {job.StatusCode.ToString(CultureInfo.InvariantCulture)})"),
.. evidence,
Version(database.Version),
],
DynamicsAxFindings.Subject(database.Database, "batch: " + job.Key));
public static string StatusText(AxBatchStatus status) => status switch
{
AxBatchStatus.Hold => "Tilbageholdt",
AxBatchStatus.Waiting => "Venter",
AxBatchStatus.Executing => "Udfører",
AxBatchStatus.Error => "Fejl",
AxBatchStatus.Finished => "Afsluttet",
AxBatchStatus.Ready => "Klar",
AxBatchStatus.NotRun => "Ikke kørt",
AxBatchStatus.Cancelling => "Annullerer",
AxBatchStatus.Canceled => "Annulleret",
_ => "Ukendt",
};
}
/// <summary>A batch job ended in error, now or in a run within the lookback (a recurring job is already waiting for its next run).</summary>
public sealed class AxBatchFailedRule : DynamicsAxBatchRule
{
public override string Id => "SQLI-AX-001";
public override string Title => "Dynamics AX-batchjob er endt med fejl";
protected override IEnumerable<Finding> Check(AxBatchDatabase database, AxThresholds thresholds, DateTimeOffset now)
{
var since = now.UtcDateTime.AddHours(-thresholds.BatchFailedLookbackHours);
foreach (var job in database.Jobs.Where(j => j.Status == AxBatchStatus.Error && (j.EndedUtc ?? j.PlannedStartUtc ?? now.UtcDateTime) >= since
|| j.Status != AxBatchStatus.Error && j.LastRunStatus == AxBatchStatus.Error && j.LastRunEndedUtc >= since)
.OrderByDescending(j => j.EndedUtc ?? j.LastRunEndedUtc))
{
var failedAt = job.Status == AxBatchStatus.Error ? job.EndedUtc : job.LastRunEndedUtc;
yield return JobFinding(database, job, Severity.High,
$"Batchjobbet \"{job.Caption}\" i {database.Database} er endt med fejl",
"Et batchjob i Dynamics AX er stoppet med fejl. Det, jobbet skulle gøre (fx bogføring, lagerafslutning, integration eller afsendelse), "
+ "er ikke gjort eller kun delvist gjort, og gentagne job fortsætter ikke nødvendigvis af sig selv.",
"Åbn batchjobbet i AX (Basis → Forespørgsler → Batchjob) og læs loggen/infologgen for fejlen. Ret årsagen, og sæt jobbet til Venter igen "
+ "eller kør det manuelt. Gentager fejlen sig, så kontakt AX-partneren med loggen.",
[new Evidence("Fejlede", Time(failedAt)), new Evidence("Planlagt start", Time(job.PlannedStartUtc))]);
}
}
}
/// <summary>A running batch job has run much longer than its own history says is normal.</summary>
public sealed class AxBatchLongRunningRule : DynamicsAxBatchRule
{
public override string Id => "SQLI-AX-002";
public override string Title => "Dynamics AX-batchjob kører længere end normalt";
protected override IEnumerable<Finding> Check(AxBatchDatabase database, AxThresholds thresholds, DateTimeOffset now)
{
foreach (var job in database.Jobs.Where(j => j.Status == AxBatchStatus.Executing && j.HistoryRuns >= thresholds.BatchHistoryMinimumRuns && j.P90Seconds is not null))
{
// A job that hangs is SQLI-AX-004's finding, not also this one.
if (RunningFor(job, now) is not { } running || IsStuck(job, running, thresholds))
{
continue;
}
var limit = TimeSpan.FromSeconds(Math.Max(job.P90Seconds!.Value * thresholds.BatchLongRunningFactor, thresholds.BatchLongRunningMinimumMinutes * 60.0));
if (running < limit)
{
continue;
}
yield return JobFinding(database, job, running >= limit * 2 ? Severity.High : Severity.Medium,
$"Batchjobbet \"{job.Caption}\" i {database.Database} har kørt i {Duration(running)} – normalt {Seconds(job.MedianSeconds)}",
$"Jobbet har kørt mere end {Format.Number(thresholds.BatchLongRunningFactor, 1)} gange så længe som 90 % af dets egne seneste {job.HistoryRuns} kørsler. "
+ "Det kan skyldes blokering, en ændret datamængde, en dårlig forespørgselsplan eller at jobbet hænger. Imens kan det holde låse, som brugerne venter på.",
"Se under Analyse af tidsrum og Blokering, hvad sessionen venter på lige nu. Sammenlign med datamængden (fx antal posteringer) i perioden. "
+ "Er jobbet gået i stå, så annullér det i AX og kør det igen uden for åbningstiden.",
[
new Evidence("Kører siden", Time(job.RunningSince)),
new Evidence("Median", Seconds(job.MedianSeconds)),
new Evidence("90 %-fraktil", Seconds(job.P90Seconds)),
new Evidence("Kørsler i historikken", job.HistoryRuns.ToString(CultureInfo.InvariantCulture)),
.. job.StartedUtc is null
? [new Evidence("Bemærk", "AX gemmer ikke det faktiske starttidspunkt i denne version; SQLInsight måler fra første gang, den så jobbet køre.")]
: Array.Empty<Evidence>(),
]);
}
}
}
/// <summary>A waiting batch job's planned start passed long ago: the batch server does not pick it up.</summary>
public sealed class AxBatchLateRule : DynamicsAxBatchRule
{
public override string Id => "SQLI-AX-003";
public override string Title => "Dynamics AX-batchjob er ikke startet til tiden";
protected override IEnumerable<Finding> Check(AxBatchDatabase database, AxThresholds thresholds, DateTimeOffset now)
{
var late = database.Jobs.Where(j => j.Late(now) is { } l && l >= TimeSpan.FromMinutes(thresholds.BatchLateMinutes)).OrderByDescending(j => j.Late(now)).ToList();
foreach (var job in late)
{
var behind = job.Late(now)!.Value;
var never = behind >= TimeSpan.FromHours(thresholds.BatchNeverStartedHours);
yield return JobFinding(database, job, never ? Severity.High : Severity.Medium,
never
? $"Batchjobbet \"{job.Caption}\" i {database.Database} er aldrig startet ({Duration(behind)} over planlagt start)"
: $"Batchjobbet \"{job.Caption}\" i {database.Database} er {Duration(behind)} forsinket",
"Jobbet venter stadig, selv om dets planlagte start er passeret. Batchserveren (AOS med batch, eller batch-klienten i AX 3.0) tager det ikke: "
+ "den kører ikke, er optaget af andre job, eller betjener ikke jobbets batchgruppe."
+ (late.Count >= 5 ? $" {late.Count} job i databasen er forsinkede, hvilket typisk betyder, at batchserveren slet ikke kører." : string.Empty),
"Tjek at batchserveren kører (AX 3.0: batch-klienten er startet og har gruppen; AX 2009/2012: AOS-instansen er sat op som batchserver "
+ "med gruppen under Batchservergrupper), og at antallet af samtidige batchtråde ikke er brugt op.",
[
new Evidence("Planlagt start", Time(job.PlannedStartUtc)),
new Evidence("Forsinkelse", Duration(behind)),
new Evidence("Batchgruppe", job.Group ?? "–"),
.. AxVersions.HasUtcBatchTimes(database.Version)
? Array.Empty<Evidence>()
: [new Evidence("Tid", "AX gemmer lokal dato + sekunder efter midnat; omregnet til UTC i AX-tidszonen med de sommertidsregler, der gjaldt på datoen (SQLInsight:DynamicsAx:LocalTimeZone, ellers agentens tidszone; se datanoterne under Dynamics AX → Batchjob).")],
]);
}
}
}
/// <summary>A batch job has been executing for hours, longer than it ever runs: it is stuck.</summary>
public sealed class AxBatchStuckRule : DynamicsAxBatchRule
{
public override string Id => "SQLI-AX-004";
public override string Title => "Dynamics AX-batchjob hænger i Udfører";
protected override IEnumerable<Finding> Check(AxBatchDatabase database, AxThresholds thresholds, DateTimeOffset now)
{
foreach (var job in database.Jobs.Where(j => j.Status == AxBatchStatus.Executing))
{
if (RunningFor(job, now) is not { } running || !IsStuck(job, running, thresholds))
{
continue;
}
yield return JobFinding(database, job, Severity.High,
$"Batchjobbet \"{job.Caption}\" i {database.Database} har stået i Udfører i {Duration(running)}",
"Jobbet har haft status Udfører i flere timer. Typisk er AOS'en eller batch-klienten genstartet eller gået ned midt i kørslen, så jobbet aldrig "
+ "blev afsluttet: det står som kørende, men intet sker, og gentagne job planlægges ikke igen. Det kan også være gået i stå på en lås.",
"Tjek under Blokering, om der er en session for jobbet. Er der ikke, så sæt jobbet til Fejl/Venter i AX (eller annullér det) og kør det igen. "
+ "Find ud af, hvorfor batchserveren stoppede (Windows-hændelseslog på AOS-serveren).",
[
new Evidence("Kører siden", Time(job.RunningSince)),
new Evidence("Normal varighed (median)", Seconds(job.MedianSeconds)),
.. job.StartedUtc is null
? [new Evidence("Bemærk", "Målt fra første gang SQLInsight så jobbet køre; det kan have kørt længere.")]
: Array.Empty<Evidence>(),
]);
}
}
}
/// <summary>Number sequence rules: read <see cref="AxSequenceSnapshot"/>.</summary>
public abstract class DynamicsAxNumberSequenceRule : DynamicsAxRule
{
public override RuleEvaluation Evaluate(RuleContext context)
{
ArgumentNullException.ThrowIfNull(context);
var snapshot = context.Data.Get<AxSequenceSnapshot>();
var (measured, outcome) = Measured(snapshot.Databases, d => d.Database, d => d.NumberSequencesNotMeasurable);
return outcome ?? Result(measured.SelectMany(d => Check(d, snapshot.Thresholds, context.Now).Take(MaxFindingsPerDatabase)));
}
protected abstract IEnumerable<Finding> Check(AxSequenceDatabase database, AxThresholds thresholds, DateTimeOffset now);
protected Finding SequenceFinding(AxSequenceDatabase database, AxNumberSequence sequence, Severity severity, string title, string explanation, string recommendation,
IEnumerable<Evidence>? evidence = null) =>
CreateFinding(severity, title, explanation, recommendation,
[
new Evidence("Database", database.Database),
new Evidence("Firma", sequence.Company ?? "delt/ukendt"),
new Evidence("Nummerserie", sequence.Code + (sequence.Text is { } text ? $" ({text})" : string.Empty)),
new Evidence("Næste nummer (NEXTREC)", Format.Number(sequence.Next)),
new Evidence("Højeste (HIGHEST)", Format.Number(sequence.Highest)),
new Evidence("Format", sequence.Format ?? "–"),
.. evidence ?? [],
Version(database.Version),
],
DynamicsAxFindings.Subject(database.Database, "nummerserie: " + sequence.Key));
protected static string Name(AxNumberSequence sequence) => sequence.Company is { } company ? $"{sequence.Code} ({company})" : sequence.Code;
}
/// <summary>A number sequence is nearly used up, now or within the forecast.</summary>
public sealed class AxNumberSequenceExhaustionRule : DynamicsAxNumberSequenceRule
{
public override string Id => "SQLI-AX-005";
public override string Title => "Dynamics AX-nummerserie er ved at løbe tør";
protected override IEnumerable<Finding> Check(AxSequenceDatabase database, AxThresholds thresholds, DateTimeOffset now)
{
foreach (var s in database.NumberSequences
.Where(s => !s.Manual && !s.Cyclic && !s.Blocked)
.Where(s => s.UsedPercent >= thresholds.NumberSequenceWarnPercent || s.Forecast?.DaysLeft <= thresholds.ForecastWarnDays)
.OrderBy(s => s.Remaining == 0 ? 0 : 1).ThenBy(s => s.Forecast?.DaysLeft ?? double.MaxValue).ThenByDescending(s => s.UsedPercent))
{
var severity = s.Remaining == 0 ? Severity.Critical
: s.UsedPercent >= thresholds.NumberSequenceCriticalPercent || s.Forecast?.DaysLeft <= thresholds.ForecastCriticalDays ? Severity.High
: Severity.Medium;
yield return SequenceFinding(database, s, severity,
s.Remaining == 0
? $"Nummerserien {Name(s)} i {database.Database} er opbrugt"
: s.Forecast?.DaysLeft is { } days && days <= thresholds.ForecastWarnDays
? $"Nummerserien {Name(s)} i {database.Database} løber tør {DaysText(days)}"
: $"Nummerserien {Name(s)} i {database.Database} er {Format.Number(s.UsedPercent, 1)} % brugt",
"Når en nummerserie er brugt op, kan AX ikke tildele flere numre: oprettelse eller bogføring af fx ordrer, fakturaer eller posteringer, "
+ "der bruger serien, fejler for alle brugere. Prognosen bygger på SQLInsights egne daglige målinger af NEXTREC.",
"Hæv Højeste i nummerserien i AX (og udvid formatet med flere #, hvis numrene ikke kan være der), eller opret en ny serie og skift "
+ "referencen til den, før serien løber tør. Gør det uden for travle perioder, og aftal det med regnskab, hvis numrene skal være fortløbende.",
[
new Evidence("Brugt", $"{Format.Number(s.UsedPercent, 1)} %"),
new Evidence("Numre tilbage", Format.Number(s.Remaining)),
new Evidence("Forbrug pr. dag", s.Forecast is { PerDay: > 0 } f ? $"{Format.Number(f.PerDay, 1)} (målt over {Format.Number(f.BasisDays, 1)} dage)" : "ukendt endnu"),
new Evidence("Løber tør", s.Forecast?.DaysLeft is { } left ? DaysText(left) : "–"),
]);
}
}
}
/// <summary>The format has fewer digits than HIGHEST needs: numbers above the format's capacity cannot be shown.</summary>
public sealed class AxNumberSequenceFormatRule : DynamicsAxNumberSequenceRule
{
public override string Id => "SQLI-AX-006";
public override string Title => "Dynamics AX-nummerseriens format er for kort til Højeste";
protected override IEnumerable<Finding> Check(AxSequenceDatabase database, AxThresholds thresholds, DateTimeOffset now)
{
foreach (var s in database.NumberSequences.Where(s => s.FormatTooShort && !s.Manual && !s.Blocked).OrderByDescending(s => s.Next))
{
var overflowing = s.Next > s.FormatCapacity;
yield return SequenceFinding(database, s, overflowing ? Severity.High : Severity.Medium,
overflowing
? $"Numrene i nummerserien {Name(s)} i {database.Database} kan ikke længere være i formatet {s.Format}"
: $"Formatet {s.Format} for nummerserien {Name(s)} i {database.Database} har plads til færre numre end Højeste",
$"Formatet har {s.FormatDigits} cifre (#), så det største nummer, der kan vises, er {Format.Number(s.FormatCapacity ?? 0)}, men Højeste er "
+ $"{Format.Number(s.Highest)}. Når serien når forbi {Format.Number(s.FormatCapacity ?? 0)}, fejler tildelingen af numre, selv om serien ikke er brugt op.",
"Udvid formatet med flere #, eller sæt Højeste ned til formatets kapacitet, så serien opfører sig, som den ser ud. Tjek, at felterne, "
+ "numrene gemmes i, er lange nok til det længere format.",
[new Evidence("Formatets kapacitet", Format.Number(s.FormatCapacity ?? 0)), new Evidence("Brugt af formatet", $"{Format.Number(s.UsedPercent, 1)} %")]);
}
}
}
/// <summary>A blocked number sequence is still referenced: whatever uses it fails.</summary>
public sealed class AxNumberSequenceBlockedRule : DynamicsAxNumberSequenceRule
{
public override string Id => "SQLI-AX-007";
public override string Title => "Spærret Dynamics AX-nummerserie bruges stadig";
protected override IEnumerable<Finding> Check(AxSequenceDatabase database, AxThresholds thresholds, DateTimeOffset now) =>
database.NumberSequences.Where(s => s.Blocked && s.Referenced == true).OrderBy(s => s.Key, StringComparer.Ordinal).Select(s =>
SequenceFinding(database, s, Severity.Medium,
$"Nummerserien {Name(s)} i {database.Database} er spærret, men bruges stadig",
"Serien er spærret (Spærret), men en nummerseriereference peger stadig på den. Alt i AX, der skal have et nummer fra den reference, fejler "
+ "med en fejl om, at nummerserien er spærret.",
"Find referencen under Nummerserier → Referencer (eller i modulets parametre under Nummerserier) og skift den til en aktiv serie, "
+ "eller fjern spærringen, hvis den er sat ved en fejl."));
}
/// <summary>A continuous number sequence's unused numbers have not been cleaned up for a long time.</summary>
public sealed class AxNumberSequenceContinuousRule : DynamicsAxNumberSequenceRule
{
/// <summary>Cleanup older than this is a finding, unless the sequence's own interval is longer (then twice that).</summary>
public const int MinimumDays = 7;
public override string Id => "SQLI-AX-008";
public override string Title => "Kontinuerlig Dynamics AX-nummerserie er ikke ryddet op";
protected override IEnumerable<Finding> Check(AxSequenceDatabase database, AxThresholds thresholds, DateTimeOffset now)
{
foreach (var s in database.NumberSequences.Where(s => s.Continuous && !s.Blocked && !s.Manual && s.CleanupKnown && s.Next > s.Lowest))
{
var limit = TimeSpan.FromDays(Math.Max(MinimumDays, 2 * (s.CleanIntervalHours ?? 0) / 24));
var age = s.LastCleanUtc is { } last ? now.UtcDateTime - last : (TimeSpan?)null;
if (age is { } a && a < limit)
{
continue;
}
yield return SequenceFinding(database, s, Severity.Low,
age is null
? $"Den kontinuerlige nummerserie {Name(s)} i {database.Database} er aldrig ryddet op"
: $"Den kontinuerlige nummerserie {Name(s)} i {database.Database} er ikke ryddet op i {Format.Number(age.Value.TotalDays)} dage",
"En kontinuerlig nummerserie må ikke have huller. Numre, der er reserveret af en session, som blev afbrudt (fx en klient, der gik ned), "
+ "bliver liggende som optagede, til serien ryddes op. Uden oprydning opstår huller og fejl om optagede numre, og serien bliver en flaskehals, "
+ "fordi hver tildeling låser rækken i NUMBERSEQUENCETABLE til transaktionen er færdig.",
"Kør Oprydning på nummerserien (Nummerserier → Funktioner → Oprydning) eller slå automatisk oprydning til med et passende interval. "
+ "Overvej om serien virkelig skal være kontinuerlig: ikke-kontinuerlige serier låser ikke.",
[
new Evidence("Seneste oprydning", age is null ? "aldrig" : Time(s.LastCleanUtc)),
new Evidence("Oprydningsinterval", s.CleanIntervalHours is > 0 and var h ? $"{Format.Number(h, 1)} timer" : "ikke sat"),
]);
}
}
}
/// <summary>A table's RecId sequence is approaching its limit: 32-bit in AX 3.0, so flagged much earlier there.</summary>
public sealed class AxRecIdExhaustionRule : DynamicsAxRule
{
public override string Id => "SQLI-AX-009";
public override string Title => "Dynamics AX RecId er ved at løbe tør";
public override RuleEvaluation Evaluate(RuleContext context)
{
ArgumentNullException.ThrowIfNull(context);
var snapshot = context.Data.Get<AxSequenceSnapshot>();
var (measured, outcome) = Measured(snapshot.Databases, d => d.Database, d => d.RecIdsNotMeasurable);
return outcome ?? Result(measured.SelectMany(d => Check(d, snapshot.Thresholds, context.Now).Take(MaxFindingsPerDatabase)));
}
/// <summary>The severity for a RecId sequence, or null when it is fine.</summary>
public static Severity? Assess(AxRecIdSequence r, AxThresholds t)
{
ArgumentNullException.ThrowIfNull(r);
ArgumentNullException.ThrowIfNull(t);
if (r.Remaining == 0)
{
return Severity.Critical;
}
var days = r.Forecast?.DaysLeft;
if (r.Bits == 32)
{
return r.UsedPercent >= t.RecId32CriticalPercent || days <= t.RecId32ForecastDays / 2.0 ? Severity.Critical
: r.UsedPercent >= t.RecId32WarnPercent || days <= t.RecId32ForecastDays ? Severity.High
: null;
}
return r.UsedPercent >= t.RecId64WarnPercent || days <= t.ForecastWarnDays ? Severity.High : null;
}
private IEnumerable<Finding> Check(AxSequenceDatabase database, AxThresholds thresholds, DateTimeOffset now)
{
foreach (var (r, severity) in database.RecIds.Select(r => (r, Assess(r, thresholds))).Where(x => x.Item2 is not null).OrderByDescending(x => x.r.UsedPercent))
{
var table = r.TableName ?? $"tabel-id {r.TableId.ToString(CultureInfo.InvariantCulture)}";
yield return CreateFinding(severity!.Value,
$"RecId for {table}{(r.Company is { } c ? $" ({c})" : string.Empty)} i {database.Database} er {Format.Number(r.UsedPercent, 1)} % brugt",
r.Bits == 32
? "AX 3.0 bruger 32-bit RecId (int): højst 2.147.483.647 pr. tabel/firma. Når RecId er brugt op, kan AX ikke oprette flere poster, "
+ "og hele installationen står stille. Det kan ikke løses ved at hæve en grænse, kun ved et større oprydnings- eller omnummereringsprojekt, "
+ "som tager tid at planlægge – derfor advares tidligt."
: "RecId-serien nærmer sig sin grænse (MAXVAL i SYSTEMSEQUENCES). Når den er nået, kan AX ikke oprette flere poster i tabellen.",
r.Bits == 32
? "Kontakt AX-partneren og planlæg en løsning nu: oprydning af gamle data, omnummerering af RecId (særligt værktøj) eller opgradering "
+ "til en AX-version med 64-bit RecId. Følg forbruget her: prognosen bygger på SQLInsights egne daglige målinger af NEXTVAL."
: "Undersøg hvorfor serien bruges så hurtigt, eller hvorfor MAXVAL er sat lavt, sammen med AX-partneren.",
[
new Evidence("Database", database.Database),
new Evidence("Tabel", table),
new Evidence("Firma", r.Company ?? "–"),
new Evidence("Næste RecId (NEXTVAL)", Format.Number(r.Next)),
new Evidence("Grænse", Format.Number(r.Max) + (r.Bits == 32 ? " (32-bit)" : string.Empty)),
new Evidence("Forbrug pr. dag", r.Forecast is { PerDay: > 0 } f ? Format.Number(f.PerDay) : "ukendt endnu"),
new Evidence("Løber tør", r.Forecast?.DaysLeft is { } d ? DaysText(d) : "–"),
Version(database.Version),
],
DynamicsAxFindings.Subject(database.Database, "recid: " + r.Key));
}
}
}
/// <summary>
/// An AX session blocks others: who in AX is behind it, as far as SQL Server can tell (CONTEXT_INFO from the AOS in
/// AX 2009/2012; never in AX 3.0/4.0, which the finding says).
/// </summary>
public sealed class AxBlockingUserRule : DynamicsAxRule
{
public override string Id => "SQLI-AX-010";
public override string Title => "AX-bruger blokerer andre";
public override RuleEvaluation Evaluate(RuleContext context)
{
ArgumentNullException.ThrowIfNull(context);
var ax = context.Data.Get<AxDatabaseSnapshot>();
if (ax.Databases.Count == 0)
{
return RuleEvaluation.NotApplicable(NoAxDatabases);
}
var blocking = context.Data.Get<BlockingSnapshot>();
var thresholds = context.Data.TryGet<AxBatchSnapshot>(out var batch) ? batch.Thresholds : new AxThresholds();
var findings = new List<Finding>();
foreach (var head in blocking.HeadBlockers)
{
var victims = Chain(blocking, head.SessionId);
var longest = victims.Count == 0 ? 0 : victims.Max(v => v.WaitTimeMs ?? 0);
var database = ax.Find(head.DatabaseName) ?? victims.Select(v => ax.Find(v.DatabaseName)).FirstOrDefault(d => d is not null);
if (database is null || longest < thresholds.BlockingSeconds * 1000L)
{
continue;
}
var who = AxSessionMapper.Map(head, database.Version);
findings.Add(CreateFinding(longest >= 300_000 || victims.Count >= 10 ? Severity.High : Severity.Medium,
who.Known
? $"AX-brugeren bag session {head.SessionId} blokerer {victims.Count} session(er) i {database.Database}"
: $"Session {head.SessionId} i AX-databasen blokerer {victims.Count} session(er) i {database.Database}",
"En session i Dynamics AX-databasen holder låse, som andre AX-brugere eller batchjob venter på (er det ikke AX selv, ses det af "
+ "programmet under målingerne). I AX går alle brugere gennem AOS'ens login, "
+ "så SQL Server-login'et siger ikke, hvem det er. " + who.Explanation,
who.Known
? "Kontakt brugeren (se AX-bruger under målinger) og find ud af, hvad de er i gang med – typisk en stor bogføring, en rapport eller en "
+ "dialog, der er efterladt midt i en opdatering. Kan det ikke vente, så afslut brugerens AX-session under Online brugere."
: "Find brugeren i AX under Online brugere ud fra tidspunktet og værten. Afslut sessionen der, hvis det ikke kan vente.",
[
new Evidence("Database", database.Database),
new Evidence("AX-bruger", who.AxUser ?? "ukendt", Sensitive: true),
new Evidence("AX-session", who.AxSessionId?.ToString(CultureInfo.InvariantCulture) ?? "–"),
new Evidence("Kilde", who.Source),
new Evidence("SQL-session", $"{head.SessionId} ({head.Status}), {head.OpenTransactionCount} åbne transaktioner"),
new Evidence("Vært / program", $"{head.HostName} / {head.ProgramName}", Sensitive: true),
new Evidence("CONTEXT_INFO (rå)", who.RawContext ?? "–", Sensitive: true),
new Evidence("Blokerede sessioner", victims.Count.ToString(CultureInfo.InvariantCulture)),
new Evidence("Længste ventetid", $"{Format.Number(longest / 1000.0)} s"),
new Evidence("Sætning", head.SqlText is { Length: > 500 } s ? s[..500] + " …" : head.SqlText ?? "–", Sensitive: true),
Version(database.Version),
],
DynamicsAxFindings.Subject(database.Database, $"blokering: session {head.SessionId.ToString(CultureInfo.InvariantCulture)}")));
}
return Result(findings);
}
private static List<SessionInfo> Chain(BlockingSnapshot snapshot, int head)
{
var result = new List<SessionInfo>();
var queue = new Queue<int>([head]);
var seen = new HashSet<int> { head };
while (queue.Count > 0)
{
foreach (var s in snapshot.BlockedBy(queue.Dequeue()).Where(s => seen.Add(s.SessionId)))
{
result.Add(s);
queue.Enqueue(s.SessionId);
}
}
return result;
}
}

View file

@ -181,7 +181,7 @@ public static class ApiV1Endpoints
.Select(r => new { r.Id, r.Title, Category = Category(r.Id), r.Source }));
}
private static async Task<IResult> RuleStatesAsync(string name, InsightRepository repository, CancellationToken ct)
private static async Task<IResult> RuleStatesAsync(string name, InsightRepository repository, IOptions<WebOptions> options, CancellationToken ct)
{
if (await repository.GetInstanceAsync(name, ct).ConfigureAwait(false) is not { } instance)
{
@ -201,7 +201,8 @@ public static class ApiV1Endpoints
Rules.Engine.RuleOutcome.ManualCheck => "manualCheck",
_ => "error",
},
s.Reason,
// A reason may name the agent's login (the GRANT text of the Dynamics AX rules, or a permission error): redacted like logins elsewhere.
Reason = RedactorFor(options).Include ? s.Reason : Core.DynamicsAx.AxReadAccess.WithoutPrincipals(s.Reason),
s.EvaluatedAt,
}));
}

View file

@ -0,0 +1,337 @@
using Microsoft.Extensions.Options;
using SQLInsight.Core.DynamicsAx;
using SQLInsight.Repository;
namespace SQLInsight.Web.Api;
/// <summary>
/// Everything under Dynamics AX in SQLInsight Web, through API v1 (scope read): the recognised AX databases with read
/// access and GRANT text, batch jobs and their runs, number sequences, RecId, the daily samples behind the forecasts,
/// and AX users behind blocking. Built from the same data as the pages (<see cref="DynamicsAxView"/>). AX users, hosts,
/// programs, statements and the agent's login are sensitive and returned as "[udeladt]" unless
/// SQLInsight:Web:ApiIncludesSensitiveEvidence is on.
/// </summary>
public static class DynamicsAxApiEndpoints
{
public static void Map(IEndpointRouteBuilder app)
{
ArgumentNullException.ThrowIfNull(app);
var api = app.MapGroup("/api/v1/dynamics-ax").DisableAntiforgery().WithTags("SQLInsight Pro API v1").RequireRead();
const string filter = "Optional ?instance= limits it to one instance.";
api.MapGet("/databases", DatabasesAsync)
.WithSummary("AX-databaser: version, skema, firmaer og læseadgang / Recognised Dynamics AX databases")
.WithDescription("Version (verified only for Axapta 3.0), schema owner (bmssa/dbo), model database, companies, which AX tables the agent may read, the missing tables and the GRANT that gives them. " + filter);
api.MapGet("/detection-problems", DetectionProblemsAsync)
.WithSummary("Databaser, AX-genkendelsen ikke kunne læse / Databases the AX detection could not read")
.WithDescription("Per monitored database: why its metadata could not be read (typically no database user before SQL Server 2014), as shown under Dynamics AX → AX-databaser. Logins are redacted unless sensitive values are allowed. " + filter);
api.MapGet("/grant-script", GrantScriptAsync)
.WithSummary("GRANT-sætninger til den valgfri AX-læseadgang / GRANT statements for the optional AX read access")
.WithDescription("?instance= and ?database= are required. Returns the exact statements for the database (only SELECT on the version's AX system tables) and the full grant-dynamics-ax-read.sql with database and login filled in.");
api.MapGet("/batch-jobs", BatchJobsAsync)
.WithSummary("AX-batchjob lige nu med deres egen historik / Dynamics AX batch jobs now")
.WithDescription("Status, planned and actual start (UTC), how late or how long running, median and 90th percentile of the job's own runs, the raw STATUS counts and what each STATUS value means in this AX version. " + filter);
api.MapGet("/batch-runs", BatchRunsAsync)
.WithSummary("Afsluttede AX-batchkørsler / Completed Dynamics AX batch runs")
.WithDescription("From SQLInsight's repository, newest first. ?instance=, ?database=, ?job= (job key) and ?days= (1–365, default 7) filter; at most 1000.");
api.MapGet("/number-sequences", NumberSequencesAsync)
.WithSummary("AX-nummerserier med forbrug og prognose / Dynamics AX number sequences")
.WithDescription("Used share, format capacity, numbers per day and days left from SQLInsight's own daily samples, blocked/continuous flags. " + filter);
api.MapGet("/recids", RecIdsAsync)
.WithSummary("AX RecId pr. tabel med prognose / Dynamics AX RecId per table")
.WithDescription("NEXTVAL against the limit (32-bit in Axapta 3.0), per day and days left. " + filter);
api.MapGet("/sequence-samples", SamplesAsync)
.WithSummary("Daglige målinger bag prognoserne / Daily samples behind the forecasts")
.WithDescription("?instance= is required; ?database=, ?kind= (N = number sequence, R = RecId), ?key= and ?days= (1–400, default 30) filter. At most 10,000 rows; the header X-Truncated: true says there are more.");
api.MapGet("/blocking", BlockingAsync)
.WithSummary("AX-brugere bag blokering / Dynamics AX users behind blocking")
.WithDescription("Blocking in AX databases with the AX user from CONTEXT_INFO (AX 2009/2012 with connectioncontext; never in Axapta 3.0/4.0, which the explanation says). ?days= (1–365, default 7). " + filter);
}
private sealed class Redactor(bool include)
{
public string? Value(string? value) => !include && value is not null ? ApiV1Endpoints.Redacted : value;
/// <summary>A text that may name the agent's login or database user (GRANT texts, "Ikke målbar", detection problems).</summary>
public string? Text(string? text) => include ? text : AxReadAccess.WithoutPrincipals(text);
public List<string> Texts(IEnumerable<string> texts) => texts.Select(t => Text(t) ?? string.Empty).ToList();
}
private static Redactor RedactorFor(IOptions<WebOptions> options) => new(options.Value.ApiIncludesSensitiveEvidence);
/// <summary>At most this many batch runs per call.</summary>
private const int MaxRuns = 1000;
/// <summary>At most this many daily samples per call (X-Truncated: true when there are more).</summary>
private const int MaxSamples = 10_000;
private static async Task<(IReadOnlyList<AxInstanceView> Views, IResult? Error)> LoadAsync(InsightRepository repository, string? instance, CancellationToken ct,
bool withProblems = false)
{
if (instance is { Length: > 0 } && await repository.GetInstanceAsync(instance, ct).ConfigureAwait(false) is null)
{
return ([], Results.NotFound());
}
return (await DynamicsAxView.LoadAsync(repository, instance, withProblems, ct).ConfigureAwait(false), null);
}
private static async Task<IResult> DatabasesAsync(InsightRepository repository, IOptions<WebOptions> options, CancellationToken ct, string? instance = null)
{
var (views, error) = await LoadAsync(repository, instance, ct).ConfigureAwait(false);
if (error is not null)
{
return error;
}
var redact = RedactorFor(options);
return Results.Ok(views.SelectMany(v => v.Databases.Databases.Select(d =>
{
var missing = AxReadAccess.Missing(d);
return new
{
Instance = v.Instance.Name,
d.Database,
Version = d.Version.ToString(),
VersionText = d.VersionText,
d.Verified,
Verification = AxVersions.VerificationText(d.Version),
d.Schema,
d.ModelDatabase,
Companies = d.Companies?.Select(c => new { c.Id, c.Name, c.IsVirtual }),
CompaniesReason = redact.Text(d.CompaniesReason),
Isolation = d.Isolation.ToString(),
Notes = redact.Texts(d.Notes),
ReadAccess = AxReadAccess.Tables(d).Select(t => new { Table = d.Schema + "." + t, Readable = d.CanRead(t) }),
MissingTables = missing,
// The agent's login and database user are sensitive like every login: the placeholder unless sensitive values are allowed.
Grant = missing.Count == 0 ? null : redact.Text(AxReadAccess.GrantSql(d, d.AgentLogin ?? v.Databases.AgentLogin, missing)),
DetectedAt = v.Databases.CapturedAt,
};
})));
}
private static async Task<IResult> DetectionProblemsAsync(InsightRepository repository, IOptions<WebOptions> options, CancellationToken ct, string? instance = null)
{
var (views, error) = await LoadAsync(repository, instance, ct, withProblems: true).ConfigureAwait(false);
if (error is not null)
{
return error;
}
var redact = RedactorFor(options);
return Results.Ok(views.SelectMany(v => v.Databases.Problems.Select(p => new
{
Instance = v.Instance.Name,
Database = p.Key,
Problem = redact.Text(p.Value),
DetectedAt = v.Databases.CapturedAt,
})));
}
private static async Task<IResult> GrantScriptAsync(InsightRepository repository, IOptions<WebOptions> options, CancellationToken ct, string? instance = null, string? database = null)
{
if (instance is not { Length: > 0 } || database is not { Length: > 0 })
{
return Results.BadRequest("instance og database er påkrævet.");
}
var (views, error) = await LoadAsync(repository, instance, ct).ConfigureAwait(false);
if (error is not null)
{
return error;
}
if (views.SingleOrDefault()?.Databases is not { } snapshot || snapshot.Find(database) is not { } ax)
{
return Results.NotFound();
}
var redact = RedactorFor(options);
var login = ax.AgentLogin ?? snapshot.AgentLogin;
var shownLogin = redact.Value(login) == login ? login : AxReadAccess.LoginPlaceholder;
if (shownLogin != login && ax.DatabaseUser is not null)
{
// The database user is as sensitive as the login.
ax = ax with { DatabaseUser = AxReadAccess.LoginPlaceholder };
}
return Results.Ok(new
{
Instance = instance,
ax.Database,
Version = ax.Version.ToString(),
ax.Schema,
Tables = AxReadAccess.Tables(ax),
MissingTables = AxReadAccess.Missing(ax),
Statements = AxReadAccess.GrantSql(ax, shownLogin),
Script = AxReadAccess.Script(ax.Database, shownLogin),
RevokeScript = AxReadAccess.RevokeScriptPath,
});
}
private static async Task<IResult> BatchJobsAsync(InsightRepository repository, IOptions<WebOptions> options, CancellationToken ct, string? instance = null)
{
var (views, error) = await LoadAsync(repository, instance, ct).ConfigureAwait(false);
if (error is not null)
{
return error;
}
var redact = RedactorFor(options);
return Results.Ok(views.Where(v => v.Batch is not null).SelectMany(v => v.Batch!.Databases.Select(d => new
{
Instance = v.Instance.Name,
d.Database,
Version = d.Version.ToString(),
v.Batch!.CapturedAt,
v.Batch.Thresholds,
NotMeasurable = redact.Text(d.NotMeasurable),
Notes = redact.Texts(d.Notes),
StatusCounts = d.StatusCounts.ToDictionary(s => s.Key.ToString(System.Globalization.CultureInfo.InvariantCulture), s => s.Value),
// What each raw STATUS means in this AX version, as the page shows it (Axapta 3.0/4.0's values are not verified).
Statuses = d.StatusCounts.OrderBy(s => s.Key).Select(s => new
{
Code = s.Key,
Status = AxSchemaAdapter.For(d.Version)?.BatchStatus(s.Key).ToString(),
Text = AxSchemaAdapter.For(d.Version) is { } adapter ? DynamicsAxView.StatusText(adapter.BatchStatus(s.Key)) : null,
Count = s.Value,
}),
Jobs = d.Jobs.Select(j => new
{
j.Key, j.RecId, j.Caption, j.Company, j.Group, Status = j.Status.ToString(), j.StatusCode, j.PlannedStartUtc, j.StartedUtc, j.EndedUtc,
j.FirstSeenExecutingUtc, RunningMinutes = j.Running(v.Batch.CapturedAt)?.TotalMinutes, LateMinutes = j.Late(v.Batch.CapturedAt)?.TotalMinutes,
j.HistoryRuns, j.MedianSeconds, j.P90Seconds, j.LastRunSeconds, LastRunStatus = j.LastRunStatus?.ToString(), j.LastRunEndedUtc,
}),
})));
}
private static async Task<IResult> BatchRunsAsync(InsightRepository repository, CancellationToken ct, string? instance = null, string? database = null, string? job = null,
int? days = null)
{
// The instances the AX pages show (not removed, with AX databases), each with its newest runs.
var (views, error) = await LoadAsync(repository, instance, ct).ConfigureAwait(false);
if (error is not null)
{
return error;
}
var now = DateTimeOffset.UtcNow;
var runs = new List<StoredAxBatchRun>();
foreach (var view in views)
{
runs.AddRange(await repository.GetAxBatchRunsAsync(view.Instance.InstanceId, database, job, now.AddDays(-Math.Clamp(days ?? 7, 1, 365)), now, MaxRuns, ct)
.ConfigureAwait(false));
}
return Results.Ok(runs.OrderByDescending(r => r.EndedAt).Take(MaxRuns).Select(r => new
{
Instance = r.InstanceName, r.DatabaseName, r.JobKey, r.RunKey, r.Caption, r.Company, r.StartedAt, r.EndedAt, DurationSeconds = r.Duration.TotalSeconds,
Status = r.Status.ToString(),
}));
}
private static async Task<IResult> NumberSequencesAsync(InsightRepository repository, IOptions<WebOptions> options, CancellationToken ct, string? instance = null)
{
var (views, error) = await LoadAsync(repository, instance, ct).ConfigureAwait(false);
if (error is not null)
{
return error;
}
var redact = RedactorFor(options);
return Results.Ok(views.Where(v => v.Sequences is not null).SelectMany(v => v.Sequences!.Databases.Select(d => new
{
Instance = v.Instance.Name,
d.Database,
Version = d.Version.ToString(),
v.Sequences!.CapturedAt,
v.Sequences.Thresholds,
NotMeasurable = redact.Text(d.NumberSequencesNotMeasurable),
Notes = redact.Texts(d.Notes),
Sequences = d.NumberSequences.Select(s => new
{
s.Key, s.Company, s.Code, s.Text, s.Lowest, s.Highest, s.Next, s.Format, s.FormatDigits, s.FormatCapacity, s.FormatTooShort, s.Max, s.UsedPercent,
s.Remaining, s.Blocked, s.Referenced, s.Continuous, s.Cyclic, s.Manual, s.LastCleanUtc, s.CleanIntervalHours,
PerDay = s.Forecast?.PerDay, DaysLeft = s.Forecast?.DaysLeft, ForecastBasisDays = s.Forecast?.BasisDays,
}),
})));
}
private static async Task<IResult> RecIdsAsync(InsightRepository repository, IOptions<WebOptions> options, CancellationToken ct, string? instance = null)
{
var (views, error) = await LoadAsync(repository, instance, ct).ConfigureAwait(false);
if (error is not null)
{
return error;
}
var redact = RedactorFor(options);
return Results.Ok(views.Where(v => v.Sequences is not null).SelectMany(v => v.Sequences!.Databases.Select(d => new
{
Instance = v.Instance.Name,
d.Database,
Version = d.Version.ToString(),
v.Sequences!.CapturedAt,
v.Sequences.Thresholds,
NotMeasurable = redact.Text(d.RecIdsNotMeasurable),
RecIds = d.RecIds.Select(r => new
{
r.Key, r.TableId, r.TableName, r.Company, r.Next, r.Max, r.Bits, r.UsedPercent, r.Remaining,
PerDay = r.Forecast?.PerDay, DaysLeft = r.Forecast?.DaysLeft, ForecastBasisDays = r.Forecast?.BasisDays,
}),
})));
}
private static async Task<IResult> SamplesAsync(HttpContext context, InsightRepository repository, CancellationToken ct, string? instance = null, string? database = null,
string? kind = null,
string? key = null, int? days = null)
{
if (instance is not { Length: > 0 } || await repository.GetInstanceAsync(instance, ct).ConfigureAwait(false) is not { } target)
{
return Results.BadRequest("instance er påkrævet og skal være en kendt instans.");
}
char? k = kind?.ToUpperInvariant() switch
{
null or "" => null,
"N" => AxSequenceSnapshot.NumberSequenceKind,
"R" => AxSequenceSnapshot.RecIdKind,
_ => '?',
};
if (k == '?')
{
return Results.BadRequest("kind skal være N (nummerserie) eller R (RecId).");
}
// Bounded in SQL; one row more than the limit tells the caller that ?database=, ?kind= or ?key= is needed for the rest.
var samples = await repository.GetAxSequenceSamplesAsync(target.InstanceId, database, k, key, DateTimeOffset.UtcNow.AddDays(-Math.Clamp(days ?? 30, 1, 400)),
MaxSamples + 1, ct).ConfigureAwait(false);
if (samples.Count > MaxSamples)
{
context.Response.Headers["X-Truncated"] = "true";
}
return Results.Ok(samples.Take(MaxSamples).Select(s => new { s.DatabaseName, Kind = s.Kind.ToString(), s.SequenceKey, s.SampledAt, s.Value }));
}
private static async Task<IResult> BlockingAsync(InsightRepository repository, IOptions<WebOptions> options, CancellationToken ct, string? instance = null, int? days = null)
{
var (views, error) = await LoadAsync(repository, instance, ct).ConfigureAwait(false);
if (error is not null)
{
return error;
}
var redact = RedactorFor(options);
var rows = await DynamicsAxView.BlockingAsync(repository, views, DateTimeOffset.UtcNow.AddDays(-Math.Clamp(days ?? 7, 1, 365)), ct).ConfigureAwait(false);
return Results.Ok(rows.Take(1000).Select(r => new
{
r.Instance, r.Database, Version = r.Version.ToString(), r.SampledAt, r.BlockedCount, r.MaxWaitMs,
AxUser = redact.Value(r.Identity.AxUser), r.Identity.AxSessionId, r.Identity.Source, r.Identity.Explanation,
Session = r.Head.SessionId, r.Head.Status, r.Head.OpenTransactionCount,
HostName = redact.Value(r.Head.HostName), ProgramName = redact.Value(r.Head.ProgramName), LoginName = redact.Value(r.Head.LoginName),
SqlText = redact.Value(r.Head.SqlText), ContextInfo = redact.Value(r.Identity.RawContext),
}));
}
}

View file

@ -62,7 +62,14 @@
new("Rapporter", "/rapporter"),
new("Revisionsrapport", "/rapporter#rettighedsrevision"),
]),
new("ax", "Dynamics AX", "cube", []),
new("ax", "Dynamics AX", "cube",
[
new("AX-databaser", "/dynamics-ax/databaser"),
new("Batchjob", "/dynamics-ax/batch"),
new("Nummerserier", "/dynamics-ax/nummerserier"),
new("RecId", "/dynamics-ax/recid"),
new("AX-brugere bag blokering", "/dynamics-ax/blokering"),
]),
];
private string _path = "/";

View file

@ -0,0 +1,133 @@
@page "/dynamics-ax/batch"
@using SQLInsight.Core.DynamicsAx
@inject InsightRepository Repository
@inject Display Display
<PageTitle>Batchjob · Dynamics AX · SQLInsight Pro</PageTitle>
<h1>Batchjob</h1>
<p class="lead">Dynamics AX-batchjob i de overvågede AX-databaser: job i fejl, kørende, forsinkede og hængende, sammenlignet med jobbets egen historik. Læses hvert 5. minut fra AX' standardtabeller; historikken gemmes i SQLInsights eget repository.</p>
@if (_views.Count == 0)
{
<p class="muted">Ingen overvågede databaser er genkendt som Dynamics AX. Se <a href="/dynamics-ax/databaser">AX-databaser</a>.</p>
}
@foreach (var view in _views)
{
if (view.Batch is null)
{
<p class="muted">@view.Instance.Name: batchjob er endnu ikke læst (første gang inden for 5 minutter).</p>
continue;
}
var now = view.Batch.CapturedAt;
foreach (var db in view.Batch.Databases)
{
<section class="card">
<div class="card-head"><h2>@db.Database <span class="muted">på @view.Instance.Name · @AxVersions.Text(db.Version)</span></h2><span class="muted">læst @Display.Ago(now.UtcDateTime)</span></div>
@if (db.NotMeasurable is { } reason)
{
<div class="alert warn"><p>@reason</p><p><a href="/dynamics-ax/databaser">Se AX-læseadgangen under AX-databaser.</a></p></div>
}
else
{
@foreach (var note in db.Notes)
{
<p class="muted small">@note</p>
}
@if (db.Jobs.Count == 0)
{
<p class="muted">Ingen batchjob.</p>
}
else
{
<table>
<thead><tr><th>Status</th><th>Batchjob</th><th>Firma</th><th>Planlagt start</th><th>Kører / forsinket</th><th class="num">Median</th><th class="num">90 %</th><th class="num">Kørsler</th></tr></thead>
<tbody>
@foreach (var job in db.Jobs.Take(200))
{
var late = job.Late(now);
var running = job.Running(now);
<tr class="@(job.Status == AxBatchStatus.Error || late?.TotalMinutes >= view.Batch.Thresholds.BatchLateMinutes ? "bad" : "")">
<td>@DynamicsAxView.StatusText(job.Status) <span class="muted small">(@job.StatusCode)</span></td>
<td>@job.Caption</td>
<td>@(job.Company ?? "–")</td>
<td class="nowrap">@Display.Time(job.PlannedStartUtc)</td>
<td>@(running is { } r ? "kører " + Minutes(r) : late is { } l ? "forsinket " + Minutes(l) : "–")</td>
<td class="num">@Seconds(job.MedianSeconds)</td>
<td class="num">@Seconds(job.P90Seconds)</td>
<td class="num">@job.HistoryRuns</td>
</tr>
}
</tbody>
</table>
}
<p class="muted small">STATUS-værdier fundet: @string.Join(", ", db.StatusCounts.OrderBy(s => s.Key).Select(s => $"{s.Key} = {DynamicsAxView.StatusText(AxSchemaAdapter.For(db.Version)?.BatchStatus(s.Key) ?? AxBatchStatus.Unknown)} ({s.Value})")).
@if (!AxVersions.HasUtcBatchTimes(db.Version))
{
<text> Oversættelsen af STATUS i @AxVersions.Text(db.Version) er ikke verificeret mod kundedata; sammenlign med batchlisten i AX. Tider gemmes af AX som lokal dato + sekunder efter midnat og vises omregnet.</text>
}
</p>
}
</section>
}
}
@if (_runs.Count > 0)
{
<section class="card">
<div class="card-head"><h2>Afsluttede kørsler</h2><span class="muted">seneste @Dage dage</span></div>
<PeriodLinks Days="Dage!.Value" />
<table>
<thead><tr><th>Sluttede</th><th>Instans / database</th><th>Batchjob</th><th>Firma</th><th>Status</th><th class="num">Varighed</th></tr></thead>
<tbody>
@foreach (var run in _runs)
{
<tr class="@(run.Status == AxBatchStatus.Error ? "bad" : "")">
<td class="nowrap">@Display.Time(run.EndedAt)</td>
<td>@run.InstanceName / @run.DatabaseName</td>
<td>@(run.Caption ?? run.JobKey)</td>
<td>@(run.Company ?? "–")</td>
<td>@DynamicsAxView.StatusText(run.Status)</td>
<td class="num">@Minutes(run.Duration)</td>
</tr>
}
</tbody>
</table>
</section>
}
<section class="card">
<details>
<summary>Hvad betyder det?</summary>
<p>Reglerne SQLI-AX-001 til -004 advarer om job i fejl, job der kører meget længere end deres egne seneste kørsler, job der venter, selv om de skulle være startet, og job der har stået i Udfører i timevis. I AX 2009 og senere kommer kørslerne fra BATCHJOBHISTORY; Axapta 3.0 og AX 4.0 gemmer ingen historik, så SQLInsight bygger den selv ud fra det, den ser (og måler kørselstiden fra første gang, den så jobbet køre). Kun AX' standardtabeller og SQLInsights eget repository bruges.</p>
</details>
</section>
@code {
[SupplyParameterFromQuery] public string? Instans { get; set; }
[SupplyParameterFromQuery] public int? Dage { get; set; }
private IReadOnlyList<AxInstanceView> _views = [];
private IReadOnlyList<StoredAxBatchRun> _runs = [];
protected override async Task OnParametersSetAsync()
{
Dage = Math.Clamp(Dage ?? 7, 1, 365);
_views = await DynamicsAxView.LoadAsync(Repository, Instans);
var now = DateTimeOffset.UtcNow;
var runs = new List<StoredAxBatchRun>();
foreach (var view in _views)
{
runs.AddRange(await Repository.GetAxBatchRunsAsync(view.Instance.InstanceId, null, null, now.AddDays(-Dage.Value), now, 200));
}
_runs = runs.OrderByDescending(r => r.EndedAt).Take(200).ToList();
}
private static string Minutes(TimeSpan span) => span.TotalHours >= 2 ? $"{Display.Number(span.TotalHours, 1)} t" : $"{Display.Number(span.TotalMinutes)} min";
private static string Seconds(double? seconds) => seconds is { } s ? Minutes(TimeSpan.FromSeconds(s)) : "–";
}

View file

@ -0,0 +1,64 @@
@page "/dynamics-ax/blokering"
@using SQLInsight.Core.DynamicsAx
@inject InsightRepository Repository
@inject Display Display
<PageTitle>AX-brugere bag blokering · SQLInsight Pro</PageTitle>
<h1>AX-brugere bag blokering</h1>
<p class="lead">Blokering i AX-databaserne (over 5 sekunder) med den AX-bruger, der står bag den blokerende session – så vidt SQL Server kan se det.</p>
<div class="alert">
<p>Alle AX-brugere går gennem AOS'ens login, så SQL Server-login'et siger ikke, hvem det er. AX 2009 og 2012 kan skrive AX-brugeren i sessionens CONTEXT_INFO, når <strong>connectioncontext</strong> er slået til i AOS-konfigurationen. <strong>Axapta 3.0 og AX 4.0 kan ikke</strong>: der vises vært og program, og brugeren må findes under Online brugere i AX ud fra tidspunktet.</p>
</div>
@if (_views.Count == 0)
{
<p class="muted">Ingen overvågede databaser er genkendt som Dynamics AX. Se <a href="/dynamics-ax/databaser">AX-databaser</a>.</p>
}
else
{
<section class="card">
<div class="card-head"><h2>Blokering</h2><span class="muted">seneste @Dage dage</span></div>
<PeriodLinks Days="Dage!.Value" />
@if (_rows.Count == 0)
{
<p class="muted">Ingen blokering i AX-databaserne i perioden.</p>
}
else
{
<table>
<thead><tr><th>Tidspunkt</th><th>Instans / database</th><th>AX-bruger</th><th class="num">AX-session</th><th>Vært / program</th><th class="num">SQL-session</th><th class="num">Blokerede</th><th class="num">Længste ventetid</th></tr></thead>
<tbody>
@foreach (var r in _rows)
{
<tr>
<td class="nowrap">@Display.Time(r.SampledAt)</td>
<td>@r.Instance / @r.Database</td>
<td>@(r.Identity.AxUser ?? "ukendt") <span class="muted small" title="@r.Identity.Explanation">(@r.Identity.Source)</span></td>
<td class="num">@(r.Identity.AxSessionId?.ToString() ?? "–")</td>
<td>@r.Head.HostName / @r.Head.ProgramName</td>
<td class="num">@r.Head.SessionId</td>
<td class="num">@r.BlockedCount</td>
<td class="num">@Display.Number(r.MaxWaitMs / 1000.0) s</td>
</tr>
}
</tbody>
</table>
}
</section>
}
@code {
[SupplyParameterFromQuery] public string? Instans { get; set; }
[SupplyParameterFromQuery] public int? Dage { get; set; }
private IReadOnlyList<AxInstanceView> _views = [];
private IReadOnlyList<AxBlockingRow> _rows = [];
protected override async Task OnParametersSetAsync()
{
Dage = Math.Clamp(Dage ?? 7, 1, 365);
_views = await DynamicsAxView.LoadAsync(Repository, Instans);
_rows = (await DynamicsAxView.BlockingAsync(Repository, _views, DateTimeOffset.UtcNow.AddDays(-Dage.Value))).Take(500).ToList();
}
}

View file

@ -0,0 +1,87 @@
@page "/dynamics-ax/databaser"
@using SQLInsight.Core.DynamicsAx
@inject InsightRepository Repository
@inject Display Display
<PageTitle>AX-databaser · SQLInsight Pro</PageTitle>
<h1>AX-databaser</h1>
<p class="lead">De overvågede databaser, som SQLInsight genkender som Microsoft Dynamics AX: version, skema, firmaer og hvilke AX-tabeller agenten må læse. Genkendelsen læser kun metadata og opdateres hver time.</p>
@if (_views.Count == 0)
{
<p class="muted">Ingen overvågede databaser er genkendt som Dynamics AX endnu. Databaser vælges under <a href="/opsaetning/instanser">Servere og databaser</a>.</p>
}
@foreach (var view in _views)
{
foreach (var ax in view.Databases.Databases)
{
var missing = AxReadAccess.Missing(ax);
<section class="card" id="@($"{view.Instance.Name}-{ax.Database}")">
<div class="card-head">
<h2>@ax.Database <span class="muted">på @view.Instance.Name</span></h2>
<span class="badge @(ax.Verified ? "sev-ok" : "sev-medium")">@(ax.Verified ? "Skema verificeret" : "Skema ikke verificeret")</span>
</div>
<table class="evidence">
<tr><th>Version</th><td>@ax.VersionText</td></tr>
<tr><th>Skema</th><td>@AxVersions.VerificationText(ax.Version)</td></tr>
<tr><th>Tabellernes ejer</th><td class="mono">@ax.Schema</td></tr>
@if (ax.ModelDatabase is { } model)
{
<tr><th>Modeldatabase</th><td class="mono">@model</td></tr>
}
<tr><th>Firmaer</th><td>@(ax.Companies is { } companies ? companies.Count == 0 ? "ingen" : string.Join(", ", companies.Select(c => c.Id + (c.Name is { Length: > 0 } n ? $" ({n})" : string.Empty))) : "ukendt – " + ax.CompaniesReason)</td></tr>
<tr><th>Læsning</th><td>@DynamicsAxView.IsolationText(ax.Isolation)</td></tr>
<tr><th>Genkendt</th><td>@Display.Ago(view.Databases.CapturedAt.UtcDateTime)</td></tr>
</table>
@foreach (var note in ax.Notes)
{
<p class="muted small">@note</p>
}
<h3>AX-læseadgang (valgfri)</h3>
<table>
<thead><tr><th>Tabel</th><th>Agenten må læse</th></tr></thead>
<tbody>
@foreach (var table in AxReadAccess.Tables(ax))
{
<tr class="@(ax.CanRead(table) ? "" : "bad")"><td class="mono">@ax.Schema.@table</td><td>@(ax.CanRead(table) ? "ja" : "nej")</td></tr>
}
</tbody>
</table>
@if (missing.Count == 0)
{
<p class="muted">Agenten må læse alle de AX-tabeller, tjekkene for batchjob, nummerserier og RecId bruger. Kun SELECT; ingen kundedata.</p>
}
else
{
<div class="alert warn">
<p>Uden læseadgang er AX-tjekkene for batchjob, nummerserier og RecId <strong>ikke målbare</strong> i @ax.Database. Adgangen er et tilvalg: kun SELECT på disse systemtabeller, ingen kundedata, ingen skriverettigheder. Kør som sysadmin eller db_owner:</p>
<pre class="mono small">@AxReadAccess.GrantSql(ax, ax.AgentLogin ?? view.Databases.AgentLogin, missing)</pre>
<p class="muted small">Det samme for enhver AX-database: <code>db/permissions/grant-dynamics-ax-read.sql</code> (fjernes igen med <code>revoke-dynamics-ax-read.sql</code>).</p>
</div>
}
</section>
}
@foreach (var (database, problem) in view.Databases.Problems)
{
<p class="notice bad">@view.Instance.Name / @database: @problem</p>
}
}
<section class="card">
<details>
<summary>Hvad betyder det?</summary>
<p>SQLInsight genkender en AX-database på systemtabellerne SQLDICTIONARY og DATAAREA og finder versionen ud fra skemaet: 32-bit RECID (Axapta 3.0, tabeller ejet af bmssa), BATCHJOB (AX 2009), NUMBERSEQUENCESCOPE (AX 2012) og PARTITION-kolonnen med en separat modeldatabase (AX 2012 R2/R3). Kun Axapta 3.0 er verificeret mod en rigtig kundedatabase; AX 4.0, 2009, 2012 og Dynamics 365 F&amp;O er bygget efter Microsofts dokumenterede skema og ikke verificeret mod kundedata. Hver forespørgsel tjekker først, at tabeller og kolonner findes, og viser ellers "ikke målbar" med den præcise kolonne.</p>
<p>AX-tjek kører kun i databaser, der er genkendt som AX og valgt til overvågning. AX-fund sendes kun til SQLInsight Hub for databaser, hvor det er slået til under <a href="/opsaetning/instanser">Servere og databaser</a>.</p>
</details>
</section>
@code {
[SupplyParameterFromQuery] public string? Instans { get; set; }
private IReadOnlyList<AxInstanceView> _views = [];
protected override async Task OnParametersSetAsync() => _views = await DynamicsAxView.LoadAsync(Repository, Instans);
}

View file

@ -0,0 +1,91 @@
@page "/dynamics-ax/nummerserier"
@using SQLInsight.Core.DynamicsAx
@inject InsightRepository Repository
@inject Display Display
<PageTitle>Nummerserier · Dynamics AX · SQLInsight Pro</PageTitle>
<h1>Nummerserier</h1>
<p class="lead">Hvor meget af hver Dynamics AX-nummerserie der er brugt, og hvornår den løber tør ud fra SQLInsights egne daglige målinger af NEXTREC. Læses hver time; de mest brugte vises først.</p>
@if (_views.Count == 0)
{
<p class="muted">Ingen overvågede databaser er genkendt som Dynamics AX. Se <a href="/dynamics-ax/databaser">AX-databaser</a>.</p>
}
@foreach (var view in _views)
{
if (view.Sequences is null)
{
<p class="muted">@view.Instance.Name: nummerserierne er endnu ikke læst (første gang inden for en time).</p>
continue;
}
var t = view.Sequences.Thresholds;
foreach (var db in view.Sequences.Databases)
{
<section class="card">
<div class="card-head"><h2>@db.Database <span class="muted">på @view.Instance.Name · @AxVersions.Text(db.Version)</span></h2><span class="muted">læst @Display.Ago(view.Sequences.CapturedAt.UtcDateTime)</span></div>
@if (db.NumberSequencesNotMeasurable is { } reason)
{
<div class="alert warn"><p>@reason</p><p><a href="/dynamics-ax/databaser">Se AX-læseadgangen under AX-databaser.</a></p></div>
}
else
{
@foreach (var note in db.Notes)
{
<p class="muted small">@note</p>
}
<table>
<thead><tr><th>Firma</th><th>Nummerserie</th><th>Tekst</th><th class="num">Næste</th><th class="num">Højeste</th><th>Format</th><th class="num">Brugt</th><th class="num">Pr. dag</th><th class="num">Dage tilbage</th><th>Bemærk</th></tr></thead>
<tbody>
@foreach (var s in db.NumberSequences.Take(200))
{
var bad = !s.Manual && !s.Cyclic && !s.Blocked && (s.UsedPercent >= t.NumberSequenceWarnPercent || s.Forecast?.DaysLeft <= t.ForecastWarnDays) || s.FormatTooShort;
<tr class="@(bad ? "bad" : "")">
<td>@(s.Company ?? "delt")</td>
<td class="mono">@s.Code</td>
<td>@s.Text</td>
<td class="num">@Display.Number(s.Next)</td>
<td class="num">@Display.Number(s.Highest)</td>
<td class="mono">@s.Format</td>
<td class="num">@Display.Number(s.UsedPercent, 1) %</td>
<td class="num">@(s.Forecast is { } f ? Display.Number(f.PerDay, 1) : "–")</td>
<td class="num">@(s.Forecast?.DaysLeft is { } d ? Display.Number(d) : "–")</td>
<td>@Flags(s)</td>
</tr>
}
</tbody>
</table>
@if (db.NumberSequences.Count > 200)
{
<p class="muted small">Viser de 200 mest brugte af @db.NumberSequences.Count. Alle findes i API'et.</p>
}
}
</section>
}
}
<section class="card">
<details>
<summary>Hvad betyder det?</summary>
<p>Brugt er (NEXTREC − Laveste) i forhold til Højeste – eller formatets kapacitet (10^antal # − 1), hvis den er mindre. Prognosen bygger på SQLInsights egne målinger (én pr. dag, gemt i repository'et); den kræver mindst et døgns historik. Manuelle og cykliske serier får ingen prognose. Reglerne SQLI-AX-005 til -008 advarer om serier, der løber tør, formater der er for korte, spærrede serier der stadig bruges, og kontinuerlige serier der ikke ryddes op.</p>
</details>
</section>
@code {
[SupplyParameterFromQuery] public string? Instans { get; set; }
private IReadOnlyList<AxInstanceView> _views = [];
protected override async Task OnParametersSetAsync() => _views = await DynamicsAxView.LoadAsync(Repository, Instans);
private static string Flags(AxNumberSequence s) => string.Join(", ", new[]
{
s.Blocked ? "spærret" + (s.Referenced == true ? " men i brug" : string.Empty) : null,
s.Continuous ? "kontinuerlig" : null,
s.Manual ? "manuel" : null,
s.Cyclic ? "cyklisk" : null,
s.FormatTooShort ? "format for kort" : null,
}.Where(f => f is not null));
}

View file

@ -0,0 +1,61 @@
@page "/dynamics-ax/recid"
@using SQLInsight.Core.DynamicsAx
@inject InsightRepository Repository
@inject Display Display
<PageTitle>RecId · Dynamics AX · SQLInsight Pro</PageTitle>
<h1>RecId</h1>
<p class="lead">Hvor langt hver tabels RecId-serie (SYSTEMSEQUENCES) er nået, og hvornår den løber tør ud fra SQLInsights egne daglige målinger. Axapta 3.0 har 32-bit RecId (højst 2.147.483.647) og advares derfor langt tidligere end senere versioner.</p>
@if (_views.Count == 0)
{
<p class="muted">Ingen overvågede databaser er genkendt som Dynamics AX. Se <a href="/dynamics-ax/databaser">AX-databaser</a>.</p>
}
@foreach (var view in _views)
{
if (view.Sequences is null)
{
<p class="muted">@view.Instance.Name: RecId er endnu ikke læst (første gang inden for en time).</p>
continue;
}
foreach (var db in view.Sequences.Databases)
{
<section class="card">
<div class="card-head"><h2>@db.Database <span class="muted">på @view.Instance.Name · @AxVersions.Text(db.Version)</span></h2><span class="muted">læst @Display.Ago(view.Sequences.CapturedAt.UtcDateTime)</span></div>
@if (db.RecIdsNotMeasurable is { } reason)
{
<div class="alert warn"><p>@reason</p><p><a href="/dynamics-ax/databaser">Se AX-læseadgangen under AX-databaser.</a></p></div>
}
else
{
<table>
<thead><tr><th>Tabel</th><th>Firma</th><th class="num">Næste RecId</th><th class="num">Grænse</th><th class="num">Brugt</th><th class="num">Pr. dag</th><th class="num">Dage tilbage</th></tr></thead>
<tbody>
@foreach (var r in db.RecIds.Take(100))
{
<tr class="@(Rules.DynamicsAx.AxRecIdExhaustionRule.Assess(r, view.Sequences.Thresholds) is null ? "" : "bad")">
<td>@(r.TableName ?? $"tabel-id {r.TableId}")</td>
<td>@(r.Company ?? "–")</td>
<td class="num">@Display.Number(r.Next)</td>
<td class="num">@Display.Number(r.Max) @(r.Bits == 32 ? "(32-bit)" : "")</td>
<td class="num">@Display.Number(r.UsedPercent, 2) %</td>
<td class="num">@(r.Forecast is { } f ? Display.Number(f.PerDay) : "–")</td>
<td class="num">@(r.Forecast?.DaysLeft is { } d ? Display.Number(d) : "–")</td>
</tr>
}
</tbody>
</table>
}
</section>
}
}
@code {
[SupplyParameterFromQuery] public string? Instans { get; set; }
private IReadOnlyList<AxInstanceView> _views = [];
protected override async Task OnParametersSetAsync() => _views = await DynamicsAxView.LoadAsync(Repository, Instans);
}

View file

@ -0,0 +1,91 @@
using SQLInsight.Core.Data;
using SQLInsight.Core.DynamicsAx;
using SQLInsight.Repository;
namespace SQLInsight.Web;
/// <summary>What the agent knows about Dynamics AX on one instance (the snapshots of the AX collectors).</summary>
public sealed record AxInstanceView(InstanceOverview Instance, AxDatabaseSnapshot Databases, AxBatchSnapshot? Batch, AxSequenceSnapshot? Sequences);
/// <summary>An AX session that blocked others, with the AX user as far as SQL Server can tell.</summary>
public sealed record AxBlockingRow(string Instance, string Database, AxVersion Version, DateTime SampledAt, SessionInfo Head, AxSessionIdentity Identity, int BlockedCount,
long MaxWaitMs);
/// <summary>
/// Loads the Dynamics AX pages' data, shared by SQLInsight Web (Dynamics AX in the menu) and API v1
/// (/api/v1/dynamics-ax/…), so both show the same.
/// </summary>
public static class DynamicsAxView
{
public const string DatabasesKind = "dynamics-ax";
public const string BatchKind = "dynamics-ax-batch";
public const string SequencesKind = "dynamics-ax-sequences";
/// <summary>At most this many blocking events per instance (the newest).</summary>
public const int MaxBlockingEvents = 1000;
/// <summary>The instances (not removed) with recognised, monitored AX databases; one instance when <paramref name="instance"/> is given.</summary>
/// <param name="withProblems">Also instances without AX databases where the detection could not read a database.</param>
public static async Task<IReadOnlyList<AxInstanceView>> LoadAsync(InsightRepository repository, string? instance, bool withProblems = false,
CancellationToken cancellationToken = default)
{
ArgumentNullException.ThrowIfNull(repository);
IReadOnlyList<InstanceOverview> instances = instance is { Length: > 0 }
? (await repository.GetInstanceAsync(instance, cancellationToken).ConfigureAwait(false)) is { } one ? [one] : []
: await repository.GetInstancesAsync(cancellationToken).ConfigureAwait(false);
// Like the overview: an instance removed from monitoring is not shown (its history stays in the repository).
instances = instances.Where(i => !i.Removed).ToList();
var result = new List<AxInstanceView>();
foreach (var i in instances)
{
var databases = await repository.GetSnapshotAsync<AxDatabaseSnapshot>(i.InstanceId, DatabasesKind, cancellationToken).ConfigureAwait(false);
if (databases is null || (databases.Databases.Count == 0 && !(withProblems && databases.Problems.Count > 0)))
{
continue;
}
result.Add(new AxInstanceView(i, databases,
await repository.GetSnapshotAsync<AxBatchSnapshot>(i.InstanceId, BatchKind, cancellationToken).ConfigureAwait(false),
await repository.GetSnapshotAsync<AxSequenceSnapshot>(i.InstanceId, SequencesKind, cancellationToken).ConfigureAwait(false)));
}
return result;
}
/// <summary>Blocking in AX databases since <paramref name="from"/>, newest first, with the AX user behind each head blocker.</summary>
public static async Task<IReadOnlyList<AxBlockingRow>> BlockingAsync(InsightRepository repository, IReadOnlyList<AxInstanceView> instances, DateTimeOffset from,
CancellationToken cancellationToken = default)
{
ArgumentNullException.ThrowIfNull(repository);
ArgumentNullException.ThrowIfNull(instances);
var rows = new List<AxBlockingRow>();
foreach (var view in instances)
{
var names = view.Databases.Databases.Select(d => d.Database).ToList();
foreach (var e in await repository.GetBlockingInDatabasesAsync(view.Instance.InstanceId, names, from, MaxBlockingEvents, cancellationToken).ConfigureAwait(false))
{
var head = e.Sessions.FirstOrDefault(s => s.SessionId == e.HeadSessionId);
var database = view.Databases.Find(head?.DatabaseName) ?? e.Sessions.Select(s => view.Databases.Find(s.DatabaseName)).FirstOrDefault(d => d is not null);
if (head is null || database is null)
{
continue;
}
rows.Add(new AxBlockingRow(view.Instance.Name, database.Database, database.Version, DateTime.SpecifyKind(e.SampledAt, DateTimeKind.Utc), head,
AxSessionMapper.Map(head, database.Version), e.BlockedCount, e.MaxWaitMs));
}
}
return rows.OrderByDescending(r => r.SampledAt).ToList();
}
public static string StatusText(AxBatchStatus status) => Rules.DynamicsAx.DynamicsAxBatchRule.StatusText(status);
public static string IsolationText(AxReadIsolation isolation) => isolation switch
{
AxReadIsolation.Snapshot => "snapshot isolation",
AxReadIsolation.ReadCommittedSnapshot => "read committed snapshot",
_ => "read committed med 2 s lock timeout",
};
}

View file

@ -117,6 +117,7 @@ IntegrationEndpoints.Map(app);
ApiV1Endpoints.Map(app);
SupportApiEndpoints.Map(app);
ConfigurationApiEndpoints.Map(app);
DynamicsAxApiEndpoints.Map(app);
app.MapOpenApi("/api/v1/openapi.json").RequireRead();
Support.Map(app);
InstanceSetupEndpoints.Map(app);

View file

@ -146,4 +146,27 @@ public sealed class DynamicsAxHubTests
Assert.Equal(["database: AX30", "database: AX30"], submission.Findings.Select(f => f.Subject));
Assert.Contains(submission.Rules, r => r.RuleId == "COM-AX-001");
}
[Fact]
public void The_built_in_ax_rules_follow_the_same_opt_in_and_keep_their_reasons_local()
{
var selection = new DatabaseSelection(true, ["AX30", "AXTEST"], [], ["AX30"]);
RuleResult[] results =
[
new() { RuleId = "SQLI-AX-001", RuleTitle = "Batchjob fejlet", Outcome = RuleOutcome.Findings },
new() { RuleId = "SQLI-AX-005", RuleTitle = "Nummerserie", Outcome = RuleOutcome.NotMeasurable, Reason = "Ikke målbar: GRANT SELECT ON [bmssa].[NUMBERSEQUENCETABLE] TO [svc]" },
];
StoredFinding[] findings =
[
Finding("SQLI-AX-001", "database: AX30 · batch: 1234|dat|NAT"), Finding("SQLI-AX-001", "database: AXTEST · batch: 99"),
];
var none = SubmissionBuilder.Build("ax", Info, results, findings, new HubOptions(), Now);
var allowed = SubmissionBuilder.Build("ax", Info, results, findings, new HubOptions(), Now, selection);
Assert.Empty(none.Findings);
Assert.Empty(none.Rules);
Assert.Equal(["database: AX30 · batch: 1234|dat|NAT"], allowed.Findings.Select(f => f.Subject));
Assert.Equal(SubmissionBuilder.AxReasonKeptLocally, allowed.Rules.Single(r => r.RuleId == "SQLI-AX-005").Reason);
}
}

View file

@ -12,22 +12,14 @@ public sealed class DiscoveryTests
ServerName = "SQL01", Version = SqlServerVersion.Parse(version), Edition = "Standard Edition", EngineEdition = edition,
};
[Theory]
[InlineData(3, 10, "3.0")]
[InlineData(3, 19, "4.0+")]
[InlineData(3, null, "4.0+")]
[InlineData(2, 10, null)]
[InlineData(0, null, null)]
public void Dynamics_ax_is_recognised_by_its_three_system_tables_and_the_recid_type(int tables, int? precision, string? expected) =>
Assert.Equal(expected, DatabaseDiscovery.DynamicsAxVersion(tables, precision));
[Fact]
public void Dynamics_ax_detection_uses_three_part_names_and_catalog_views_by_version()
public void Dynamics_ax_detection_is_the_central_one_run_in_the_database_or_with_three_part_names_on_2000()
{
var modern = DatabaseDiscovery.DynamicsAxSql("AX]30", SqlServerVersion.Parse("9.0.5000.0"));
var legacy = DatabaseDiscovery.DynamicsAxSql("AX30", SqlServerVersion.Parse("8.0.2039.0"));
Assert.Contains("[AX]]30].sys.tables", modern, StringComparison.Ordinal);
Assert.StartsWith("EXEC [AX]]30].sys.sp_executesql", modern, StringComparison.Ordinal);
Assert.Contains("FROM sys.tables", modern, StringComparison.Ordinal);
Assert.DoesNotContain("USE ", modern, StringComparison.OrdinalIgnoreCase);
Assert.Contains("[AX30].dbo.sysobjects", legacy, StringComparison.Ordinal);
Assert.DoesNotContain("sys.", legacy, StringComparison.Ordinal);
@ -61,7 +53,7 @@ public sealed class DiscoveryTests
}
/// <summary>2008+ constructs that make a batch fail on 2005 (as in Sql2005CompatibilityTests).</summary>
private static readonly Regex[] Sql2008Only =
internal static readonly Regex[] Sql2008Only =
[
new(@"DECLARE\s+@\w+\s+\w+(\s*\([\d\s,]+\))?\s*=", RegexOptions.IgnoreCase),
new(@"\bMERGE\s", RegexOptions.IgnoreCase),
@ -77,6 +69,7 @@ public sealed class DiscoveryTests
var batches = new[]
{
DatabaseDiscovery.Databases2005Sql, DatabaseDiscovery.Databases2000Sql, DatabaseDiscovery.DynamicsAxSql("AX30", sql2005.Version),
DynamicsAx.AxDatabaseDetector.DatabasesSql,
PermissionCheck.BuildSql(PermissionCheck.ChecksFor(sql2005)), PermissionCheck.DatabasesWithoutAccessSql,
};

View file

@ -0,0 +1,61 @@
using SQLInsight.Core.DynamicsAx;
namespace SQLInsight.Collectors.Tests;
/// <summary>
/// The SQL the AX collectors send, for every version adapter with a schema file: SQL Server 2005 syntax, bounded,
/// read-only, run in the AX database without changing the session's database, and never NOLOCK.
/// </summary>
public sealed class DynamicsAxSqlTests
{
private static string Directory => Path.Combine(AppContext.BaseDirectory, "DynamicsAx");
public static TheoryData<string> Adapters() => [.. AxSchemaAdapter.All.Where(a => AxSchemaFile.Find(Directory, a.SchemaFileName) is not null).Select(a => a.SchemaFileName)];
private static (AxSchemaAdapter Adapter, AxDatabase Database) Load(string fileName)
{
var adapter = AxSchemaAdapter.All.First(a => a.SchemaFileName == fileName);
var schema = AxSchemaFile.Find(Directory, fileName)!;
var database = AxDetection.Recognise("AXDB", schema.DetectionRows(), ["AXDB", "AXDB_model"])!;
return (adapter, database with { Isolation = AxReadIsolation.Snapshot });
}
[Theory]
[MemberData(nameof(Adapters))]
public void Every_ax_query_is_bounded_read_only_sql_server_2005_syntax(string fileName)
{
var (adapter, database) = Load(fileName);
var queries = new[]
{
adapter.BatchJobs(database), adapter.BatchHistory(database, null), adapter.BatchHistory(database, 123_456),
adapter.NumberSequences(database), adapter.RecIds(database), AxSchemaAdapter.Companies(database),
}.Where(q => q is not null).Select(q => q!).ToList();
Assert.All(queries, q => Assert.True(q.IsMeasurable, q.NotMeasurable));
foreach (var sql in queries.Select(q => AxSql.InDatabase(database.Database, q.Sql!, database.Isolation)).Append(AxSql.InDatabase("AXDB", AxDetection.MetadataSql)))
{
Assert.DoesNotContain(DiscoveryTests.Sql2008Only, p => p.IsMatch(sql));
Assert.DoesNotContain("NOLOCK", sql, StringComparison.OrdinalIgnoreCase);
// Only AX's own standard tables, never anything else.
var tables = System.Text.RegularExpressions.Regex.Matches(sql, @"\[(?:bmssa|dbo)\]\.\[(?<t>[A-Z_]+)\]").Select(m => m.Groups["t"].Value).ToList();
Assert.All(tables, t => Assert.Contains(t, AxDetection.KnownTables));
Assert.DoesNotContain("USE ", sql, StringComparison.Ordinal);
Assert.DoesNotMatch(@"\b(INSERT|UPDATE|DELETE|MERGE|TRUNCATE|ALTER|CREATE|DROP|EXEC\s*\()\b", sql.Replace("sp_executesql", string.Empty, StringComparison.Ordinal));
Assert.StartsWith("EXEC [AXDB].sys.sp_executesql", sql, StringComparison.Ordinal);
Assert.Contains("SET LOCK_TIMEOUT 2000", sql, StringComparison.Ordinal);
}
Assert.All(queries, q => Assert.Contains("SELECT TOP (", q.Sql!, StringComparison.Ordinal));
}
[Fact]
public void Reads_use_snapshot_isolation_when_the_database_allows_it_and_read_committed_otherwise()
{
Assert.Contains("ISOLATION LEVEL SNAPSHOT", AxSql.InDatabase("AX", "SELECT 1", AxReadIsolation.Snapshot), StringComparison.Ordinal);
Assert.Contains("ISOLATION LEVEL READ COMMITTED; SELECT 1", AxSql.InDatabase("AX", "SELECT 1", AxReadIsolation.ReadCommittedSnapshot), StringComparison.Ordinal);
// Afterwards the agent's own session settings again, so the next collector on the shared connection keeps them.
Assert.EndsWith("SET LOCK_TIMEOUT 5000; SET TRANSACTION ISOLATION LEVEL READ UNCOMMITTED;", AxSql.InDatabase("AX", "SELECT 1"), StringComparison.Ordinal);
Assert.Contains(Core.Instances.AgentSession.LockTimeout, Core.Instances.AgentSession.Settings, StringComparison.Ordinal);
Assert.Contains(Core.Instances.AgentSession.Isolation, Core.Instances.AgentSession.Settings, StringComparison.Ordinal);
}
}

View file

@ -11,6 +11,10 @@
<ProjectReference Include="..\..\src\SQLInsight.Rules\SQLInsight.Rules.csproj" />
<ProjectReference Include="..\..\src\SQLInsight.Connectivity\SQLInsight.Connectivity.csproj" />
</ItemGroup>
<ItemGroup>
<!-- Dynamics AX schemas (real and documented): the AX collectors' SQL is checked for every version adapter. -->
<None Include="..\..\docs\dynamics-ax\*.tsv" Link="DynamicsAx\%(Filename)%(Extension)" CopyToOutputDirectory="PreserveNewest" />
</ItemGroup>
<ItemGroup>
<Using Include="Xunit" />
</ItemGroup>

View file

@ -0,0 +1,111 @@
using SQLInsight.Collectors.Tests.SqlScripts;
namespace SQLInsight.Collectors.Tests;
/// <summary>
/// The Dynamics AX part of "SQL Scripts" (1.4): every AX rule script and AX collector script shows the SQL its rule or
/// collector sends, for every AX version SQLInsight has a schema for. The SQL itself is compared with the code by
/// <see cref="SqlScripts.SqlScriptTests"/> (the <c>-- Kilde:</c> blocks, registered in <see cref="SqlScriptSources"/>);
/// rewrite out-of-date blocks with SQLINSIGHT_UPDATE_SQL_SCRIPTS=1.
/// </summary>
public sealed class SqlScriptTests
{
private static readonly string[] AxVersions = ["3.0", "4.0", "2009", "2012"];
private static readonly string[] Detection = ["AxDatabaseDetector.DatabasesSql", "AxDatabaseDetector.BuildDetectionSql"];
private static string[] Batch =>
[
.. Detection, .. AxVersions.Select(v => $"AxSchemaAdapter.BatchJobs({v})"),
"AxSchemaAdapter.BatchHistory(2009, første)", "AxSchemaAdapter.BatchHistory(2009, næste)",
"AxSchemaAdapter.BatchHistory(2012, første)", "AxSchemaAdapter.BatchHistory(2012, næste)",
];
private static string[] NumberSequences => [.. Detection, .. AxVersions.Select(v => $"AxSchemaAdapter.NumberSequences({v})")];
/// <summary>File → the sources (keys) whose SQL it must show.</summary>
internal static Dictionary<string, string[]> Expected()
{
var files = new Dictionary<string, string[]>(StringComparer.Ordinal)
{
["_collectors/AxDatabaseDetector.sql"] = [.. Detection, "AxDatabaseDetector.LoginSql", .. AxVersions.Select(v => $"AxSchemaAdapter.Companies({v})")],
["_collectors/AxBatchCollector.sql"] = Batch[Detection.Length..],
["_collectors/AxSequenceCollector.sql"] = [.. AxVersions.Select(v => $"AxSchemaAdapter.NumberSequences({v})"), .. AxVersions.Select(v => $"AxSchemaAdapter.RecIds({v})")],
["SQLI-AX-001.sql"] = Batch,
["SQLI-AX-002.sql"] = Batch,
["SQLI-AX-003.sql"] = Batch,
["SQLI-AX-004.sql"] = Batch,
["SQLI-AX-005.sql"] = NumberSequences,
["SQLI-AX-006.sql"] = NumberSequences,
["SQLI-AX-007.sql"] = NumberSequences,
["SQLI-AX-008.sql"] = NumberSequences,
["SQLI-AX-009.sql"] = [.. Detection, .. AxVersions.Select(v => $"AxSchemaAdapter.RecIds({v})")],
["SQLI-AX-010.sql"] =
[
.. Detection, "BlockingCollector.BuildSql2005(2012+)", "BlockingCollector.BuildSql2005(2008)", "BlockingCollector.BuildSql2005(2005)", "BlockingCollector.Sql2000",
],
};
// The AX community rules run only in the monitored databases the shared detection recognises.
for (var i = 1; i <= 7; i++)
{
files[$"COM-AX-00{i}.sql"] = ["CustomQueryCollector.DatabasesSql", .. Detection, "CustomQueryCollector.BuildRuleSql"];
}
return files;
}
public static TheoryData<string> Files() => [.. Expected().Keys];
[Theory]
[MemberData(nameof(Files))]
public void Script_holds_the_sql_sqlinsight_runs_for_every_ax_version(string file)
{
var path = Path.Combine(Folder, file);
Assert.True(File.Exists(path), $"SQL Scripts/{file} mangler.");
var keys = File.ReadAllLines(path).Where(l => l.StartsWith("-- Kilde: ", StringComparison.Ordinal)).Select(l => l["-- Kilde: ".Length..].Trim()).ToHashSet(StringComparer.Ordinal);
var missing = Expected()[file].Where(k => !keys.Contains(k)).ToList();
Assert.True(missing.Count == 0, $"SQL Scripts/{file} mangler blokkene: " + string.Join(", ", missing));
Assert.All(Expected()[file], k => Assert.True(SqlScriptSources.ByKey.ContainsKey(k), $"'{k}' er ikke registreret i SqlScriptSources"));
}
[Theory]
[MemberData(nameof(Files))]
public void Header_says_what_the_reader_needs(string file)
{
var text = File.ReadAllText(Path.Combine(Folder, file));
// The header as running text: comment lines wrapped at 120 characters are joined again.
var header = System.Text.RegularExpressions.Regex.Replace(text[..text.IndexOf("-- Variant:", StringComparison.Ordinal)], @"\r?\n--\s+", " ");
Assert.Contains("Versioner:", header, StringComparison.Ordinal);
Assert.Contains("Read-only:", header, StringComparison.Ordinal);
Assert.Contains("ikke verificeret mod kundedata", header, StringComparison.Ordinal);
Assert.Contains("sp_executesql", header, StringComparison.Ordinal);
if (!file.StartsWith("COM-", StringComparison.Ordinal))
{
// The AX table reads (the community rules read metadata only).
Assert.Contains("aldrig NOLOCK", header, StringComparison.Ordinal);
}
Assert.DoesNotContain("PXI_", text, StringComparison.OrdinalIgnoreCase);
if (!file.StartsWith('_'))
{
Assert.Contains("Alvor:", header, StringComparison.Ordinal);
Assert.Contains("Rettigheder", header, StringComparison.Ordinal);
}
}
private static string Folder
{
get
{
var directory = new DirectoryInfo(AppContext.BaseDirectory);
while (directory is not null && !File.Exists(Path.Combine(directory.FullName, "SQLInsight.slnx")))
{
directory = directory.Parent;
}
return Path.Combine(directory?.FullName ?? throw new InvalidOperationException("Repository-roden blev ikke fundet."), "SQL Scripts");
}
}
}

View file

@ -257,8 +257,10 @@ internal static class SqlScriptSources
// Concurrency.
new("BlockingCollector.BuildSql2005(2012+)", "BlockingCollector", "SQL Server 2012 og nyere",
C + "Concurrency/BlockingCollector.cs", "BlockingCollector.BuildSql2005(version)", () => BlockingCollector.BuildSql2005(V2012)),
new("BlockingCollector.BuildSql2005(2005)", "BlockingCollector", "SQL Server 2005 – 2008 R2",
new("BlockingCollector.BuildSql2005(2008)", "BlockingCollector", "SQL Server 2008 – 2008 R2",
C + "Concurrency/BlockingCollector.cs", "BlockingCollector.BuildSql2005(version)", () => BlockingCollector.BuildSql2005(V2008R2Sp1)),
new("BlockingCollector.BuildSql2005(2005)", "BlockingCollector", "SQL Server 2005 (CONTEXT_INFO fra sys.sysprocesses)",
C + "Concurrency/BlockingCollector.cs", "BlockingCollector.BuildSql2005(version)", () => BlockingCollector.BuildSql2005(V2005)),
new("BlockingCollector.Sql2000", "BlockingCollector", "SQL Server 2000 (legacy ODBC-sti)",
C + "Concurrency/BlockingCollector.cs", "BlockingCollector.Sql2000", () => BlockingCollector.Sql2000),
new("ActivityCollector.BuildTransactionsSql", "ActivityCollector", "SQL Server 2005 og nyere (vist med standardgrænsen 1024 MB log)",
@ -343,9 +345,88 @@ internal static class SqlScriptSources
C + "Discovery/PermissionCheck.cs", "PermissionCheck.DatabasesWithoutAccessSql", () => PermissionCheck.DatabasesWithoutAccessSql),
};
sources.AddRange(DynamicsAx());
return sources;
}
/// <summary>The AX database the Dynamics AX scripts are shown for: there is no AX database on every instance, so the reader changes it.</summary>
public const string AxExampleDatabase = "AXDB";
/// <summary>A RecId the history watermark is rendered with, replaced by the token ⟪fra⟫ (the agent uses the highest RecId it has read).</summary>
private const long AxWatermark = 1_000_000_000;
private const string Ax = "src/SQLInsight.Core/DynamicsAx/";
private static string AxDirectory => Path.Combine(AppContext.BaseDirectory, "DynamicsAx");
/// <summary>
/// The AX versions SQLInsight has a schema for (docs/dynamics-ax/*.tsv): Axapta 3.0 from a real customer database,
/// AX 4.0 as 3.0 with bigint RecId in dbo, AX 2009 and 2012 R2/R3 from Microsoft's documentation.
/// </summary>
private static IReadOnlyList<(string Key, string Title, Core.DynamicsAx.AxDatabase Database)> AxVersions()
{
static Core.DynamicsAx.AxDatabase Load(string file) =>
Core.DynamicsAx.AxDetection.Recognise(AxExampleDatabase, Core.DynamicsAx.AxSchemaFile.Find(AxDirectory, file)!.DetectionRows(), [AxExampleDatabase, AxExampleDatabase + "_model"])!;
var ax40 = Core.DynamicsAx.AxDetection.Recognise(AxExampleDatabase, Core.DynamicsAx.AxSchemaFile.Find(AxDirectory, "schema-ax30")!.DetectionRows()
.Select(r => r with { Schema = "dbo", Precision = r.Column == "RECID" ? 19 : r.Precision, Type = r.Column == "RECID" ? "bigint" : r.Type }).ToList(), [AxExampleDatabase])!;
return
[
("3.0", "Axapta 3.0 (skema bmssa, verificeret mod kundedata)", Load("schema-ax30")),
("4.0", "AX 4.0 (skema dbo) - ikke verificeret mod kundedata", ax40),
("2009", "AX 2009 - baseret på Microsofts dokumenterede skema, ikke verificeret mod kundedata", Load("schema-ax2009")),
("2012", "AX 2012 RTM/R2/R3 - baseret på Microsofts dokumenterede skema, ikke verificeret mod kundedata", Load("schema-ax2012")),
];
}
private static string AxRead(Core.DynamicsAx.AxDatabase database, Core.DynamicsAx.AxQuery? query) =>
Core.DynamicsAx.AxSql.InDatabase(database.Database, query?.Sql ?? throw new InvalidOperationException(query?.NotMeasurable ?? "Ingen forespørgsel."), database.Isolation);
private static IEnumerable<Source> DynamicsAx()
{
// The shared detection: which monitored databases are AX (collector dynamics-ax, and before every AX check).
yield return new("AxDatabaseDetector.DatabasesSql", "AxDatabaseDetector", "Alle versioner fra SQL Server 2005: online brugerdatabaser og deres snapshot-indstillinger",
C + "DynamicsAx/AxDatabaseDetector.cs", "AxDatabaseDetector.DatabasesSql", () => Collectors.DynamicsAx.AxDatabaseDetector.DatabasesSql);
yield return new("AxDatabaseDetector.LoginSql", "AxDatabaseDetector", "Agentens login, til GRANT-teksten, når AX-læseadgangen mangler",
C + "DynamicsAx/AxDatabaseDetector.cs", "AxDatabaseDetector.LoginSql", () => Collectors.DynamicsAx.AxDatabaseDetector.LoginSql);
yield return new("AxDatabaseDetector.BuildDetectionSql", "AxDatabaseDetector",
"SQL Server 2005 og nyere: AX-genkendelsen i hver overvåget database (kun metadata; vist for " + AxExampleDatabase + ")",
C + "DynamicsAx/AxDatabaseDetector.cs", "AxDatabaseDetector.BuildDetectionSql(database) = AxSql.InDatabase(database, AxDetection.MetadataSql)",
() => Collectors.DynamicsAx.AxDatabaseDetector.BuildDetectionSql(AxExampleDatabase));
foreach (var (key, title, database) in AxVersions())
{
yield return new($"AxSchemaAdapter.Companies({key})", "AxDatabaseDetector", title + ": firmaerne (DATAAREA), kun med AX-læseadgang",
Ax + "AxSchemaAdapter.cs", "AxSchemaAdapter.Companies(database) via AxSql.InDatabase", () => AxRead(database, Core.DynamicsAx.AxSchemaAdapter.Companies(database)));
}
// Batch jobs (collector dynamics-ax-batch).
foreach (var (key, title, database) in AxVersions())
{
var adapter = Core.DynamicsAx.AxSchemaAdapter.For(database.Version)!;
yield return new($"AxSchemaAdapter.BatchJobs({key})", "AxBatchCollector", title + ": batchjob",
Ax + "AxSchemaAdapter.cs", "AxSchemaAdapter.BatchJobs(database) via AxSql.InDatabase", () => AxRead(database, adapter.BatchJobs(database)));
if (adapter.BatchHistory(database, null) is not null)
{
yield return new($"AxSchemaAdapter.BatchHistory({key}, første)", "AxBatchCollector", title + ": kørselshistorik, første læsning (de nyeste)",
Ax + "AxSchemaAdapter.cs", "AxSchemaAdapter.BatchHistory(database, afterRecId: null) via AxSql.InDatabase", () => AxRead(database, adapter.BatchHistory(database, null)));
yield return new($"AxSchemaAdapter.BatchHistory({key}, næste)", "AxBatchCollector",
title + $": kørselshistorik, de næste læsninger (fra den højeste RECID, SQLInsight har læst, minus {Core.DynamicsAx.AxSchemaAdapter.HistoryOverlap})",
Ax + "AxSchemaAdapter.cs", "AxSchemaAdapter.BatchHistory(database, afterRecId) via AxSql.InDatabase",
() => AxRead(database, adapter.BatchHistory(database, AxWatermark + Core.DynamicsAx.AxSchemaAdapter.HistoryOverlap))
.Replace("> " + AxWatermark.ToString(System.Globalization.CultureInfo.InvariantCulture), "> ⟪fra⟫", StringComparison.Ordinal));
}
}
// Number sequences and RecId (collector dynamics-ax-sequences).
foreach (var (key, title, database) in AxVersions())
{
var adapter = Core.DynamicsAx.AxSchemaAdapter.For(database.Version)!;
yield return new($"AxSchemaAdapter.NumberSequences({key})", "AxSequenceCollector", title + ": nummerserier",
Ax + "AxSchemaAdapter.cs", "AxSchemaAdapter.NumberSequences(database) via AxSql.InDatabase", () => AxRead(database, adapter.NumberSequences(database)));
yield return new($"AxSchemaAdapter.RecIds({key})", "AxSequenceCollector", title + ": RecId pr. tabel (SYSTEMSEQUENCES)",
Ax + "AxSchemaAdapter.cs", "AxSchemaAdapter.RecIds(database) via AxSql.InDatabase", () => AxRead(database, adapter.RecIds(database)));
}
}
private static Source StartTime(string collector, SqlServerVersion version, string variant) => new(
$"{collector}.StartTimeSql({(version.Major >= 10 ? "2008+" : version.Major == 9 ? "2005" : "2000")})",
collector, variant, C + "Db.cs", "Db.StartTimeSql(version)", () => Db.StartTimeSql(version));

View file

@ -60,6 +60,11 @@ public sealed partial class SqlScriptTests
foreach (var file in AllScripts())
{
var name = Path.GetRelativePath(Folder, file);
if (Environment.GetEnvironmentVariable("SQLINSIGHT_UPDATE_SQL_SCRIPTS") == "1")
{
Update(file);
}
var blocks = Blocks(Read(file)).ToList();
if (blocks.Count == 0)
{
@ -134,21 +139,12 @@ public sealed partial class SqlScriptTests
}
[Fact]
public void Scripts_are_read_only_apart_from_the_documented_temp_table()
public void Scripts_are_read_only()
{
var problems = new List<string>();
foreach (var file in AllScripts())
{
var text = Read(file);
var code = CommentsAndStrings().Replace(text, " ");
code = AllowedStatements().Replace(code, " ");
if (text.Contains("-- Kilde: DeadlockCollector.BuildFileSql", StringComparison.Ordinal))
{
// Documented exception: a session-private temp table in tempdb (see the script's Read-only line).
code = DeadlockTempTable().Replace(code, " ");
}
problems.AddRange(Writes().Matches(code).Select(m => $"{Path.GetRelativePath(Folder, file)}: {m.Value.ToUpperInvariant()}").Distinct());
problems.AddRange(WritesIn(Read(file)).Select(w => $"{Path.GetRelativePath(Folder, file)}: {w}").Distinct());
}
Assert.True(problems.Count == 0, "Skrivende sætninger i \"SQL Scripts\": " + string.Join("; ", problems));
@ -156,14 +152,73 @@ public sealed partial class SqlScriptTests
[Theory]
[InlineData("SELECT name INTO dbo.x FROM sys.objects", true)]
[InlineData("SELECT name INTO #x FROM sys.objects", true)]
[InlineData("UPDATE dbo.t SET a = 1", true)]
[InlineData("EXEC sys.sp_configure N'x', 1", true)]
[InlineData("SELECT is_auto_update_stats_on, create_date FROM sys.databases -- DROP TABLE x", false)]
[InlineData("EXEC master.dbo.xp_readerrorlog 0, 1, NULL, NULL, @fra, @til", false)]
[InlineData("DBCC TRACESTATUS(-1) WITH NO_INFOMSGS", false)]
[InlineData("SELECT N'DELETE' AS [UPDATE]", false)]
public void The_read_only_check_itself_recognises_writes(string sql, bool writes) =>
Assert.Equal(writes, Writes().IsMatch(AllowedStatements().Replace(CommentsAndStrings().Replace(sql, " "), " ")));
[InlineData("EXEC [AXDB].sys.sp_executesql N'SET LOCK_TIMEOUT 2000; SELECT TOP (10) a FROM [dbo].[BATCH] WHERE b = N''x''';", false)]
[InlineData("EXEC [AXDB].sys.sp_executesql N'DELETE FROM [dbo].[BATCH]';", true)]
[InlineData("EXEC [AXDB].sys.sp_executesql N'SELECT N''x'' AS a INTO #t';", true)]
[InlineData("EXEC [AXDB].sys.sp_executesql @sql;", true)]
public void The_read_only_check_itself_recognises_writes(string sql, bool writes) => Assert.Equal(writes, WritesIn(sql).Any());
/// <summary>
/// The writing keywords in <paramref name="sql"/>, outside comments and strings. The batch of an
/// <c>EXEC [db].sys.sp_executesql N'…'</c> (how the Dynamics AX reads run in the AX database) is checked the same way:
/// the call itself only runs that batch, so it is read-only exactly when the batch is.
/// </summary>
private static IEnumerable<string> WritesIn(string sql)
{
var batches = new List<string>();
sql = DynamicSql().Replace(sql, m =>
{
batches.Add(m.Groups["sql"].Value.Replace("''", "'", StringComparison.Ordinal));
return " ";
});
var code = AllowedStatements().Replace(CommentsAndStrings().Replace(sql, " "), " ");
return Writes().Matches(code).Select(m => m.Value.ToUpperInvariant()).Concat(batches.SelectMany(WritesIn)).ToList();
}
/// <summary>
/// SQLINSIGHT_UPDATE_SQL_SCRIPTS=1: a block whose SQL is no longer what the code sends gets the current SQL (its
/// comment lines are kept). A block with run-time values (⟪tokens⟫) is only filled while it has no SQL yet.
/// </summary>
private static void Update(string file)
{
var lines = Read(file).Split('\n').ToList();
var output = new List<string>();
for (var i = 0; i < lines.Count; i++)
{
output.Add(lines[i]);
if (!lines[i].StartsWith("-- Kilde: ", StringComparison.Ordinal)
|| !SqlScriptSources.ByKey.TryGetValue(lines[i]["-- Kilde: ".Length..].Trim(), out var source))
{
continue;
}
var end = lines.FindIndex(i + 1, l => l.Trim() == "GO");
if (end < 0 || SqlScriptSources.Pattern(source.Sql).IsMatch(string.Join("\n", lines[(i + 1)..end])))
{
continue;
}
// A block with run-time values is only filled when it is new (no SQL yet); the ⟪tokens⟫ must then be replaced by hand.
var comments = lines[(i + 1)..end].TakeWhile(l => l.StartsWith("--", StringComparison.Ordinal)).ToList();
if (source.Sql.Contains('⟪', StringComparison.Ordinal) && comments.Count < end - i - 1)
{
continue;
}
output.AddRange(comments);
output.AddRange(source.Sql.TrimEnd('\n').Split('\n'));
i = end - 1;
}
File.WriteAllText(file, string.Join("\r\n", output), new System.Text.UTF8Encoding(false));
}
/// <summary>The <c>-- Kilde: key</c> blocks: everything after the line up to the next GO.</summary>
private static IEnumerable<(string Key, string Body)> Blocks(string text)
@ -213,9 +268,10 @@ public sealed partial class SqlScriptTests
[GeneratedRegex(@"\bEXEC\s+master\.dbo\.xp_(readerrorlog|msver)\b|\bDBCC\s+TRACESTATUS\b|\bDBCC\s+SQLPERF\s*\(\s*WAITSTATS\s*\)", RegexOptions.IgnoreCase)]
private static partial Regex AllowedStatements();
[GeneratedRegex(@"\bINTO\s+#system_health\b|\bDROP\s+TABLE\s+#system_health\b", RegexOptions.IgnoreCase)]
private static partial Regex DeadlockTempTable();
[GeneratedRegex(@"\b(INSERT|UPDATE|DELETE|MERGE|DROP|ALTER|CREATE|TRUNCATE|GRANT|REVOKE|DENY|BACKUP|RESTORE|RECONFIGURE|KILL|SHUTDOWN|BULK|OPENROWSET|OPENQUERY|OPENDATASOURCE|WAITFOR|INTO|EXEC|EXECUTE|DBCC|SETUSER)\b", RegexOptions.IgnoreCase)]
private static partial Regex Writes();
/// <summary>A call of sp_executesql in a named database with a literal batch (not a variable, which cannot be checked).</summary>
[GeneratedRegex(@"\bEXEC\s+\[[^\]]+\]\.sys\.sp_executesql\s+N'(?<sql>(?:[^']|'')*)'", RegexOptions.IgnoreCase)]
private static partial Regex DynamicSql();
}

View file

@ -0,0 +1,511 @@
using System.Text.RegularExpressions;
using SQLInsight.Core.Configuration;
using SQLInsight.Core.Data;
using SQLInsight.Core.DynamicsAx;
namespace SQLInsight.Core.Tests;
/// <summary>Builds AX databases as the detector would see them, from the schema files in docs/dynamics-ax.</summary>
internal static class AxFixtures
{
public static string Directory => Path.Combine(AppContext.BaseDirectory, "DynamicsAx");
public static AxSchemaFile Schema(string fileName) => AxSchemaFile.Find(Directory, fileName)
?? throw new InvalidOperationException($"{fileName}.tsv mangler i docs/dynamics-ax.");
/// <summary>The real AX 3.0 schema from a customer (bmssa, 32-bit RECID).</summary>
public static AxSchemaFile Ax30 => Schema("schema-ax30");
public static AxDatabase Recognise(AxSchemaFile schema, string name = "AXDB", bool readable = true, string? user = null, IEnumerable<string>? databases = null,
Func<AxDetectionRow, bool>? keep = null) =>
AxDetection.Recognise(name, schema.DetectionRows(readable, user).Where(keep ?? (_ => true)).ToList(), databases ?? [name])
?? throw new InvalidOperationException("Ikke genkendt som AX.");
}
public class AxDetectionTests
{
[Fact]
public void The_real_ax30_schema_is_axapta_30_in_bmssa_and_verified()
{
var ax = AxFixtures.Recognise(AxFixtures.Ax30);
Assert.Equal((AxVersion.Axapta30, "bmssa", true), (ax.Version, ax.Schema, ax.Verified));
Assert.Equal("int", ax.Table("BATCH")!.Column("STARTTIME")!.Type);
Assert.Equal(10, ax.Table("SQLDICTIONARY")!.Column("RECID")!.Precision);
Assert.False(AxFixtures.Ax30.Documented);
}
[Fact]
public void Ax2009_and_2012_are_told_apart_by_batchjob_scope_partition_and_the_model_database()
{
var ax2009 = AxFixtures.Schema("schema-ax2009");
var ax2012 = AxFixtures.Schema("schema-ax2012");
Assert.Equal(AxVersion.Ax2009, AxFixtures.Recognise(ax2009).Version);
Assert.Equal(AxVersion.Ax2012R2, AxFixtures.Recognise(ax2012, "AX2012", databases: ["AX2012", "AX2012_model"]).Version);
Assert.Equal("AX2012_model", AxFixtures.Recognise(ax2012, "AX2012", databases: ["AX2012", "ax2012_MODEL"]).ModelDatabase, StringComparer.OrdinalIgnoreCase);
Assert.Equal(AxVersion.FinanceOperations, AxFixtures.Recognise(ax2012, "AxDB").Version);
Assert.Equal(AxVersion.Ax2012, AxFixtures.Recognise(ax2012, keep: r => r.Column != "PARTITION").Version);
Assert.Equal(AxVersion.Ax40, AxFixtures.Recognise(ax2009, keep: r => r.Table is not ("BATCHJOB" or "BATCHJOBHISTORY")).Version);
Assert.All(new[] { AxVersion.Ax40, AxVersion.Ax2009, AxVersion.Ax2012, AxVersion.Ax2012R2, AxVersion.FinanceOperations }, v => Assert.False(AxVersions.IsVerified(v)));
Assert.Contains(AxFixtures.Recognise(ax2009).Notes, n => n.Contains("ikke verificeret mod kundedata", StringComparison.Ordinal));
}
[Fact]
public void A_database_without_sqldictionary_and_dataarea_in_one_schema_is_not_ax()
{
var rows = AxFixtures.Ax30.DetectionRows().Where(r => r.Table != "DATAAREA").ToList();
Assert.Null(AxDetection.Recognise("NotAx", rows, ["NotAx"]));
Assert.Null(AxDetection.Recognise("Empty", [], ["Empty"]));
Assert.Equal(AxVersion.Unknown, AxFixtures.Recognise(AxFixtures.Ax30, keep: r => !(r.Table == "SQLDICTIONARY" && r.Column == "RECID")).Version);
}
[Theory]
[InlineData("3.0", AxVersion.Axapta30)]
[InlineData("2012 R2/R3", AxVersion.Ax2012R2)]
[InlineData("F&O", AxVersion.FinanceOperations)]
[InlineData("4.0+", AxVersion.Unknown)]
public void Short_version_texts_round_trip(string text, AxVersion version)
{
Assert.Equal(version, AxVersions.Parse(text));
if (version != AxVersion.Unknown)
{
Assert.Equal(text, AxVersions.ShortText(version));
Assert.True(text.Length <= 16); // insight.ConfiguredDatabases.DynamicsAxVersion varchar(16)
}
}
}
public class AxSchemaAdapterTests
{
public static TheoryData<string> Adapters() => [.. AxSchemaAdapter.All.Select(a => a.GetType().Name)];
private static AxSchemaAdapter Adapter(string name) => AxSchemaAdapter.All.Single(a => a.GetType().Name == name);
[Theory]
[MemberData(nameof(Adapters))]
public void Every_column_an_adapter_needs_is_in_its_schema_file(string name)
{
var adapter = Adapter(name);
var schema = AxSchemaFile.Find(AxFixtures.Directory, adapter.SchemaFileName);
if (schema is null)
{
// AX 4.0 and F&O: no schema file yet (neither from a customer nor written from the documentation).
Assert.Contains(adapter.Versions, v => v is AxVersion.Ax40 or AxVersion.FinanceOperations);
return;
}
Assert.Equal(adapter is not Ax30Adapter, schema.Documented);
foreach (var (table, columns) in adapter.RequiredColumns)
{
Assert.All(columns, c => Assert.True(schema.Has(table, c), $"{schema.Path}: {table}.{c} mangler"));
}
Assert.All(adapter.ReadTables, t => Assert.Contains(t, schema.Tables, StringComparer.OrdinalIgnoreCase));
}
[Fact]
public void Ax30_queries_use_bmssa_top_and_no_nolock()
{
var ax = AxFixtures.Recognise(AxFixtures.Ax30);
var adapter = AxSchemaAdapter.For(ax.Version)!;
var queries = new[] { adapter.BatchJobs(ax), adapter.NumberSequences(ax), adapter.RecIds(ax), AxSchemaAdapter.Companies(ax) };
Assert.All(queries, q => Assert.True(q.IsMeasurable, q.NotMeasurable));
Assert.All(queries, q => Assert.Contains("TOP (", q.Sql!, StringComparison.Ordinal));
Assert.All(queries, q => Assert.DoesNotContain("NOLOCK", q.Sql!, StringComparison.OrdinalIgnoreCase));
Assert.Contains("[bmssa].[BATCH]", queries[0].Sql!, StringComparison.Ordinal);
Assert.Contains("[bmssa].[NUMBERSEQUENCEREFERENCE]", queries[1].Sql!, StringComparison.Ordinal);
Assert.Contains("N'SEQNO'", queries[2].Sql!, StringComparison.Ordinal);
Assert.Null(adapter.BatchHistory(ax, null));
Assert.Empty(queries[0].Notes);
}
[Fact]
public void Ax2012_reads_the_company_from_the_scope_and_history_by_recid()
{
var ax = AxFixtures.Recognise(AxFixtures.Schema("schema-ax2012"), "AX2012", databases: ["AX2012", "AX2012_model"]);
var adapter = AxSchemaAdapter.For(ax.Version)!;
var sequences = adapter.NumberSequences(ax);
var first = adapter.BatchHistory(ax, null)!;
var next = adapter.BatchHistory(ax, 10_000)!;
Assert.Contains("LEFT JOIN [dbo].[NUMBERSEQUENCESCOPE]", sequences.Sql!, StringComparison.Ordinal);
Assert.Contains("[NUMBERSEQUENCEID]", sequences.Sql!, StringComparison.Ordinal);
Assert.Contains("ORDER BY h.[RECID] DESC", first.Sql!, StringComparison.Ordinal);
Assert.Contains($"h.[RECID] > {10_000 - AxSchemaAdapter.HistoryOverlap}", next.Sql!, StringComparison.Ordinal);
Assert.Contains("[ORIGSTARTDATETIME]", adapter.BatchJobs(ax).Sql!, StringComparison.Ordinal);
}
[Fact]
public void Without_read_access_a_check_is_not_measurable_with_the_exact_grant()
{
var ax = AxFixtures.Recognise(AxFixtures.Ax30, "AX30", readable: false) with { AgentLogin = @"KUNDE\svc-sqlinsight$" };
var batch = AxSchemaAdapter.Ax30.BatchJobs(ax);
Assert.False(batch.IsMeasurable);
Assert.StartsWith("Ikke målbar: agenten må ikke læse bmssa.BATCH i AX30", batch.NotMeasurable, StringComparison.Ordinal);
Assert.Contains(@"CREATE USER [KUNDE\svc-sqlinsight$] FOR LOGIN [KUNDE\svc-sqlinsight$];", batch.NotMeasurable, StringComparison.Ordinal);
Assert.Contains(@"GRANT SELECT ON [bmssa].[BATCH] TO [KUNDE\svc-sqlinsight$];", batch.NotMeasurable, StringComparison.Ordinal);
}
[Fact]
public void A_missing_required_column_names_it_and_a_missing_optional_one_becomes_null_with_a_note()
{
var withoutStartTime = AxFixtures.Recognise(AxFixtures.Ax30, keep: r => !(r.Table == "BATCH" && r.Column == "STARTTIME"));
var withoutFormat = AxFixtures.Recognise(AxFixtures.Ax30, keep: r => !(r.Table == "NUMBERSEQUENCETABLE" && r.Column == "FORMAT"));
var batch = AxSchemaAdapter.Ax30.BatchJobs(withoutStartTime);
var sequences = AxSchemaAdapter.Ax30.NumberSequences(withoutFormat);
Assert.Contains("bmssa.BATCH.STARTTIME", batch.NotMeasurable, StringComparison.Ordinal);
Assert.True(sequences.IsMeasurable);
Assert.Contains("CAST(NULL AS nvarchar(40))", sequences.Sql!, StringComparison.Ordinal);
Assert.Contains(sequences.Notes, n => n.Contains("FORMAT", StringComparison.Ordinal));
}
[Fact]
public void Grant_script_covers_exactly_the_adapters_tables_per_version()
{
var lists = Regex.Matches(AxReadAccess.Template(), @"N''\|([A-Z|]+)\|''").Select(m => m.Groups[1].Value.Split('|')).ToList();
Assert.Equal(3, lists.Count);
Assert.Equal(AxSchemaAdapter.Ax30.ReadTables.Order(), lists[0].Order());
Assert.Equal(AxSchemaAdapter.Ax40.ReadTables.Order(), lists[0].Order());
Assert.Equal(AxSchemaAdapter.Ax2009.ReadTables.Order(), lists[1].Order());
Assert.Equal(AxSchemaAdapter.Ax2012.ReadTables.Order(), lists[2].Order());
Assert.Equal(AxSchemaAdapter.FinanceOperations.ReadTables.Order(), lists[2].Order());
Assert.Contains("SET @DatabaseName = N'AX30';", AxReadAccess.Script("AX30", "svc"), StringComparison.Ordinal);
}
[Fact]
public void Grant_text_uses_the_existing_database_user_and_only_existing_tables()
{
var ax = AxFixtures.Recognise(AxFixtures.Ax30, "AX30", user: "sqlinsight") with { AgentLogin = "svc" };
var grant = AxReadAccess.GrantSql(ax, ax.AgentLogin);
Assert.DoesNotContain("CREATE USER", grant, StringComparison.Ordinal);
Assert.Contains("GRANT SELECT ON [bmssa].[SYSTEMSEQUENCES] TO [sqlinsight];", grant, StringComparison.Ordinal);
Assert.DoesNotContain("BATCHJOB", grant, StringComparison.Ordinal);
Assert.Empty(AxReadAccess.Missing(ax));
}
}
public class AxInterpretationTests
{
private static readonly DateTime Now = new(2026, 10, 2, 10, 0, 0, DateTimeKind.Utc);
[Fact]
public void Ax30_times_are_a_local_date_plus_seconds_since_midnight()
{
// 02:30:00 local on a server two hours ahead of UTC (offset -120 minutes) is 00:30 UTC.
Assert.Equal(new DateTime(2026, 10, 2, 0, 30, 0, DateTimeKind.Utc), AxSchemaAdapter.Ax30.ToUtc(new DateTime(2026, 10, 2), 9000, -120));
Assert.Null(AxSchemaAdapter.Ax30.ToUtc(new DateTime(1900, 1, 1), 0, 0));
Assert.Equal(new DateTime(2026, 10, 2, 8, 0, 0, DateTimeKind.Utc), AxSchemaAdapter.Ax2009.ToUtc(new DateTime(2026, 10, 2, 8, 0, 0), null, -120));
}
[Fact]
public void Batch_status_enums_per_version()
{
Assert.Equal(AxBatchStatus.Executing, AxSchemaAdapter.Ax30.BatchStatus(1));
Assert.Equal(AxBatchStatus.Error, AxSchemaAdapter.Ax30.BatchStatus(2));
Assert.Equal(AxBatchStatus.Waiting, AxSchemaAdapter.Ax2009.BatchStatus(1));
Assert.Equal(AxBatchStatus.Executing, AxSchemaAdapter.Ax2012.BatchStatus(2));
Assert.Equal(AxBatchStatus.Canceled, AxSchemaAdapter.Ax2012.BatchStatus(8));
Assert.Equal(AxBatchStatus.Unknown, AxSchemaAdapter.Ax2012.BatchStatus(42));
Assert.All(AxSchemaAdapter.All, a => Assert.False(a.BatchStatusVerified));
}
[Fact]
public void Ax30_jobs_are_keyed_by_class_company_and_group_and_late_jobs_are_measured_from_the_planned_start()
{
var row = new AxBatchJobRow(17, 1234, null, "dat", "NAT", 0, new DateTime(2026, 10, 2), 9 * 3600, null, null, null, null, -120);
var job = AxSchemaAdapter.Ax30.Interpret(row, new AxBatchHistory(), "AX30", null);
Assert.Equal("1234|dat|NAT", job.Key);
Assert.Equal("Klasse 1234 (gruppe NAT)", job.Caption);
Assert.Equal(AxBatchStatus.Waiting, job.Status);
Assert.Equal(new DateTime(2026, 10, 2, 7, 0, 0, DateTimeKind.Utc), job.PlannedStartUtc);
Assert.Equal(TimeSpan.FromHours(3), job.Late(new DateTimeOffset(Now)));
}
[Fact]
public void Job_history_gives_median_and_90th_percentile_of_successful_runs_and_is_bounded()
{
var history = new AxBatchHistory();
for (var i = 1; i <= 30; i++)
{
history.Add("AX", new AxBatchRun("7", "H" + i, "Lager", "dat", Now.AddHours(-i), Now.AddHours(-i).AddMinutes(i <= 25 ? 10 : 200), i == 1 ? AxBatchStatus.Error : AxBatchStatus.Finished));
}
history.Add("AX", new AxBatchRun("7", "H3", "Lager", "dat", Now, Now.AddMinutes(500), AxBatchStatus.Finished)); // seen before: ignored
var runs = history.Runs("AX", "7");
var (median, p90) = AxBatchHistory.Statistics(runs);
Assert.Equal(AxBatchHistory.RunsKept, runs.Count);
Assert.Equal(600, median);
Assert.Equal(600, p90);
Assert.Empty(history.Runs("AX", "8"));
}
[Fact]
public void Observed_run_needs_an_end_and_a_start_from_ax_or_from_sqlinsight()
{
var finished = new AxBatchJob { Key = "k", RecId = 5, Caption = "c", Status = AxBatchStatus.Finished, EndedUtc = Now };
Assert.Null(AxSchemaAdapter.ObservedRun(finished, null));
Assert.Equal(TimeSpan.FromMinutes(30), AxSchemaAdapter.ObservedRun(finished, Now.AddMinutes(-30))!.Duration);
Assert.Equal(TimeSpan.FromMinutes(5), AxSchemaAdapter.ObservedRun(finished with { StartedUtc = Now.AddMinutes(-5) }, Now.AddMinutes(-30))!.Duration);
Assert.Null(AxSchemaAdapter.ObservedRun(finished with { Status = AxBatchStatus.Executing }, Now.AddMinutes(-30)));
}
[Fact]
public void Number_sequence_usage_format_width_and_forecast()
{
var row = new AxNumberSequenceRow(1, "Debitor", "dat", "Debitorer", 1, 9_999_999, 95_001, "DEB-####", false, false, false, false, true, null, null, null, -120);
var samples = new[] { new AxSample(Now.AddDays(-20), 85_001), new AxSample(Now.AddDays(-10), 90_001) };
var sequence = AxSchemaAdapter.Ax30.Interpret(row, samples, Now, 30);
Assert.Equal(4, sequence.FormatDigits);
Assert.Equal(9_999, sequence.FormatCapacity);
Assert.True(sequence.FormatTooShort);
Assert.Equal(9_999, sequence.Max);
Assert.Equal(100, sequence.UsedPercent);
Assert.Equal(0, sequence.Remaining);
Assert.Equal("dat/Debitor", sequence.Key);
Assert.Equal(500, sequence.Forecast!.PerDay, 3);
Assert.Equal(0, sequence.Forecast.DaysLeft);
}
[Fact]
public void Number_sequence_forecast_needs_a_day_of_history_and_ignores_manual_and_cyclic()
{
var row = new AxNumberSequenceRow(1, "Ordre", "dat", null, 1, 1_000_000, 500_001, "######", false, false, false, false, null, null, null, null, 0);
Assert.Null(AxSchemaAdapter.Ax2009.Interpret(row, [new AxSample(Now.AddHours(-2), 1)], Now, 30).Forecast);
Assert.Null(AxSchemaAdapter.Ax2009.Interpret(row with { Manual = true }, [new AxSample(Now.AddDays(-5), 1)], Now, 30).Forecast);
var sequence = AxSchemaAdapter.Ax2009.Interpret(row, [new AxSample(Now.AddDays(-5), 450_001), new AxSample(Now.AddDays(-60), 1)], Now, 30);
Assert.Equal(10_000, sequence.Forecast!.PerDay, 3);
Assert.Equal(50, sequence.Forecast.DaysLeft!.Value, 3);
Assert.Equal(50, sequence.UsedPercent, 3);
Assert.Equal("1", AxSchemaAdapter.Ax2012.SequenceKey(row));
}
[Fact]
public void Ax30_recid_is_limited_by_the_int_range()
{
var recId = AxSchemaAdapter.Interpret(new AxRecIdRow(-1, "dat", 1_610_612_736, 0, null), nextValIsInt: true,
[new AxSample(Now.AddDays(-10), 1_600_612_736)], Now, 30);
var bigint = AxSchemaAdapter.Interpret(new AxRecIdRow(77, null, 5_000_000_000, null, "CUSTTABLE"), nextValIsInt: false, [], Now, 30);
Assert.Equal((32, int.MaxValue, 75.0), (recId.Bits, recId.Max, Math.Round(recId.UsedPercent, 1)));
Assert.Equal(1_000_000, recId.Forecast!.PerDay, 3);
Assert.Equal(536.87, recId.Forecast.DaysLeft!.Value, 2);
Assert.Equal("-1/dat", recId.Key);
Assert.Equal((64, long.MaxValue), (bigint.Bits, bigint.Max));
Assert.True(bigint.UsedPercent < 0.001);
}
}
public class AxSessionMapperTests
{
private static string Hex(string text, bool unicode = false) =>
Convert.ToHexString(unicode ? System.Text.Encoding.Unicode.GetBytes(text) : System.Text.Encoding.ASCII.GetBytes(text)) + "000000";
private static SessionInfo Session(string? context) => new() { SessionId = 61, HostName = "AOS01", ProgramName = "Microsoft Dynamics AX", ContextInfo = context };
[Fact]
public void Ax2012_context_info_gives_the_ax_user_and_session()
{
var identity = AxSessionMapper.Map(Session(Hex("anne 23")), AxVersion.Ax2012R2);
Assert.Equal(("anne", 23, "context_info"), (identity.AxUser, identity.AxSessionId, identity.Source));
Assert.Equal("anne", AxSessionMapper.Map(Session(Hex("23 anne", unicode: true)), AxVersion.Ax2009).AxUser);
}
[Fact]
public void Ax30_is_honest_that_the_user_cannot_be_seen()
{
var identity = AxSessionMapper.Map(Session(null), AxVersion.Axapta30);
Assert.False(identity.Known);
Assert.Contains("sætter ikke CONTEXT_INFO", identity.Explanation, StringComparison.Ordinal);
// Host and program are sensitive: in the finding's evidence, not in the explanation (which may reach the hub).
Assert.DoesNotContain("AOS01", identity.Explanation, StringComparison.Ordinal);
Assert.Contains("Online brugere", identity.Explanation, StringComparison.Ordinal);
}
[Fact]
public void Without_context_info_the_explanation_says_how_to_turn_it_on()
{
var identity = AxSessionMapper.Map(Session(null), AxVersion.Ax2012);
Assert.False(identity.Known);
Assert.Contains("connectioncontext", identity.Explanation, StringComparison.Ordinal);
Assert.Null(AxSessionMapper.Decode("00000000"));
Assert.Null(AxSessionMapper.Decode("zz"));
Assert.Null(AxSessionMapper.Decode("01FF02FE"));
}
}
public class DynamicsAxHubSubjectTests
{
[Theory]
[InlineData("database: AX30", "AX30")]
[InlineData("database: AX30 · batch: 17", "AX30")]
[InlineData("database: AX 30 · nummerserie: dat/Debitor", "AX 30")]
[InlineData("job: x", null)]
public void Database_of_an_ax_subject(string subject, string? database) => Assert.Equal(database, DynamicsAxFindings.DatabaseOf(subject));
[Theory]
[InlineData("SQLI-AX-005", true)]
[InlineData("COM-AX-001", true)]
[InlineData("SQLI-AG-001", false)]
public void Ax_rules(string ruleId, bool ax) => Assert.Equal(ax, DynamicsAxFindings.IsDynamicsAxRule(ruleId));
[Fact]
public void Subjects_round_trip() => Assert.Equal("AX30", DynamicsAxFindings.DatabaseOf(DynamicsAxFindings.Subject("AX30", "batch: 1")));
}
public class AxBatchHistoryWatermarkTests
{
[Fact]
public void The_history_watermark_keeps_moving_through_dense_recids()
{
// AX 4.0 and later: BATCHJOBHISTORY's RecIds are dense. A read returns the rows after watermark - overlap, in RECID
// order, at most MaxHistoryRowsAfterWatermark of them.
Assert.True(AxSchemaAdapter.MaxHistoryRowsAfterWatermark > AxSchemaAdapter.HistoryOverlap);
var history = Enumerable.Range(1, 20_000).Select(i => (long)i).ToList();
long watermark = history[AxSchemaAdapter.MaxHistoryRows - 1];
for (var read = 0; read < 25 && watermark < history[^1]; read++)
{
var after = Math.Max(0, watermark - AxSchemaAdapter.HistoryOverlap);
var rows = history.Where(r => r > after).Take(AxSchemaAdapter.MaxHistoryRowsAfterWatermark).ToList();
Assert.True(rows[^1] > watermark, $"Læsning {read} kom ikke forbi RECID {watermark}.");
watermark = rows[^1];
}
Assert.Equal(history[^1], watermark);
var database = AxFixtures.Recognise(AxFixtures.Schema("schema-ax2012"), databases: ["AXDB", "AXDB_model"]);
Assert.Contains($"SELECT TOP ({AxSchemaAdapter.MaxHistoryRowsAfterWatermark}) ", AxSchemaAdapter.Ax2012.BatchHistory(database, 50_000)!.Sql, StringComparison.Ordinal);
}
}
public class AxLocalTimeTests
{
private static readonly TimeZoneInfo Copenhagen = TimeZoneInfo.FindSystemTimeZoneById("Europe/Copenhagen");
private static DateTime Utc(int y, int mo, int d, int h, int mi) => new(y, mo, d, h, mi, 0, DateTimeKind.Utc);
[Fact]
public void A_time_from_before_a_dst_change_uses_the_offset_of_its_own_date()
{
// Read in winter (server offset -60), planned in summer: 10:00 CEST is 08:00Z, not 09:00Z.
Assert.Equal(Utc(2026, 8, 1, 8, 0), AxLocalTime.ToUtc(new DateTime(2026, 8, 1), 36_000, -60, Copenhagen));
Assert.Equal(Utc(2026, 1, 10, 9, 0), AxLocalTime.ToUtc(new DateTime(2026, 1, 10), 36_000, -120, Copenhagen));
}
[Fact]
public void The_skipped_and_the_repeated_hour_get_standard_time_and_never_throw()
{
// 29 March 2026 02:30 does not exist in Copenhagen; 25 October 2026 02:30 exists twice.
Assert.Equal(Utc(2026, 3, 29, 1, 30), AxLocalTime.ToUtc(new DateTime(2026, 3, 29), 9_000, -120, Copenhagen));
Assert.Equal(Utc(2026, 10, 25, 1, 30), AxLocalTime.ToUtc(new DateTime(2026, 10, 25), 9_000, -60, Copenhagen));
// A job planned 01:50 CET on the spring night, read at 01:00Z (03:00 CEST): 10 minutes late, not 70.
var planned = AxLocalTime.ToUtc(new DateTime(2026, 3, 29), 6_600, -120, Copenhagen)!.Value;
Assert.Equal(TimeSpan.FromMinutes(10), Utc(2026, 3, 29, 1, 0) - planned);
}
[Theory]
[InlineData(0, 0)]
[InlineData(86_399, 86_399)]
[InlineData(86_400, 86_400)]
public void Seconds_from_midnight_up_to_the_end_of_the_day(int seconds, int expected) =>
Assert.Equal(Utc(2026, 1, 9, 23, 0).AddSeconds(expected), AxLocalTime.ToUtc(new DateTime(2026, 1, 10), seconds, -60, Copenhagen));
[Theory]
[InlineData(-1)]
[InlineData(90_000)]
public void Seconds_outside_a_day_are_unknown(int seconds) => Assert.Null(AxLocalTime.ToUtc(new DateTime(2026, 1, 10), seconds, -60, Copenhagen));
[Fact]
public void Ax_empty_date_is_unknown_and_utc_versions_are_not_converted_again()
{
Assert.Null(AxLocalTime.ToUtc(new DateTime(1900, 1, 1), 3_600, -60, Copenhagen));
var utc = Utc(2026, 7, 1, 12, 0);
Assert.Equal(utc, AxSchemaAdapter.Ax2009.ToUtc(utc, null, -120, Copenhagen));
}
[Fact]
public void Without_a_zone_the_servers_offset_is_rounded_to_a_quarter_of_an_hour()
{
Assert.Equal(Utc(2026, 10, 2, 0, 30), AxLocalTime.ToUtc(new DateTime(2026, 10, 2), 9_000, -119, null));
Assert.Equal(-120, AxLocalTime.RoundedOffset(-121));
}
[Fact]
public void The_zone_is_the_setting_else_the_agents_when_it_matches_the_server_else_the_servers_offset_with_a_note()
{
var summer = Utc(2026, 7, 1, 12, 0);
Assert.Equal("Europe/Copenhagen", AxLocalTime.Resolve("Europe/Copenhagen", 0, summer).Zone?.Id);
Assert.Same(Copenhagen, AxLocalTime.Resolve(null, -120, summer, Copenhagen).Zone);
var (zone, note) = AxLocalTime.Resolve(null, 0, summer, Copenhagen);
Assert.Null(zone);
Assert.Contains("LocalTimeZone", note, StringComparison.Ordinal);
var (unknown, unknownNote) = AxLocalTime.Resolve("Ingen/Zone", -120, summer, Copenhagen);
Assert.Null(unknown);
Assert.Contains("Ingen/Zone", unknownNote, StringComparison.Ordinal);
}
[Fact]
public void Axapta30_cleanup_without_a_time_column_is_local_midnight_not_utc()
{
var row = new AxNumberSequenceRow(1, "Debitor", "dat", null, 1, 999_999, 10, "######", false, true, false, false, null,
new DateTime(2026, 7, 1), null, null, -120);
var sequence = AxSchemaAdapter.Ax30.Interpret(row, [], Utc(2026, 7, 2, 0, 0), 30, Copenhagen);
Assert.Equal(Utc(2026, 6, 30, 22, 0), sequence.LastCleanUtc);
}
}
public class AxRedactionTests
{
[Fact]
public void A_database_name_with_the_separator_is_never_read_as_another_database()
{
var subject = DynamicsAxFindings.Subject("AX · test", "batch: 1");
Assert.Equal("AX · test", DynamicsAxFindings.DatabaseOf(subject));
Assert.Equal("AX · test", DynamicsAxFindings.DatabaseOf(DynamicsAxFindings.Subject("AX · test")));
Assert.Equal("a··b", DynamicsAxFindings.DatabaseOf(DynamicsAxFindings.Subject("a··b", "recid: 1/dat")));
Assert.Equal("database: AX30 · batch: 1", DynamicsAxFindings.Subject("AX30", "batch: 1"));
}
[Fact]
public void Grant_texts_and_error_messages_lose_the_login_and_the_database_user()
{
var database = AxFixtures.Recognise(AxFixtures.Ax30, readable: false, user: "svc") with { AgentLogin = @"KUNDE\svc-sqlinsight$" };
var text = AxReadAccess.NotMeasurable(database, ["BATCH"]);
Assert.Contains("svc", text, StringComparison.Ordinal);
var redacted = AxReadAccess.WithoutPrincipals(text)!;
Assert.DoesNotContain("KUNDE", redacted, StringComparison.Ordinal);
Assert.DoesNotContain("[svc]", redacted, StringComparison.Ordinal);
Assert.Contains("GRANT SELECT ON [bmssa].[BATCH] TO [" + AxReadAccess.LoginPlaceholder + "]", redacted, StringComparison.Ordinal);
var noUser = AxReadAccess.WithoutPrincipals(AxReadAccess.GrantSql(database with { DatabaseUser = null }, @"KUNDE\svc-sqlinsight$"))!;
Assert.DoesNotContain("KUNDE", noUser, StringComparison.Ordinal);
Assert.Equal("The server principal \"[udeladt]\" is not able to access the database",
AxReadAccess.WithoutPrincipals("The server principal \"KUNDE\\svc\" is not able to access the database"));
}
}

View file

@ -8,6 +8,10 @@
<ItemGroup>
<ProjectReference Include="..\..\src\SQLInsight.Core\SQLInsight.Core.csproj" />
</ItemGroup>
<ItemGroup>
<!-- Real (customer) and documented Dynamics AX schemas: the version adapters are tested against them. -->
<None Include="..\..\docs\dynamics-ax\*.tsv" Link="DynamicsAx\%(Filename)%(Extension)" CopyToOutputDirectory="PreserveNewest" />
</ItemGroup>
<ItemGroup>
<Using Include="Xunit" />
</ItemGroup>

View file

@ -21,6 +21,8 @@ internal sealed class AgentSession : IAsyncDisposable
public InstanceInfo Instance { get; }
public InstanceCapabilities Capabilities { get; }
public System.Data.Common.DbConnection Connection => _opened.Connection;
public static async Task<AgentSession> OpenAsync(SqlServerFixture sql)
{
var opened = await new SqlConnectionFactory().OpenAsync(new ConnectionTarget

Some files were not shown because too many files have changed in this diff Show more