Analyser le Query Store avec un agent IA

Confier l’analyse du Query Store à Claude Code ou Codex par sqlcmd, avec un login qui lit le Query Store sans lire les tables, ou sur un clone sans données.

Article coécrit avec Claude, qui a mené les tests en laboratoire et une partie des recherches.

Le Query Store (magasin des requêtes) est la mémoire des requêtes d’une base de données. Depuis SQL Server 2016, il enregistre le texte de chaque requête capturée, ses plans d’exécution et ses statistiques d’exécution par intervalle de temps, dans la base elle-même. Il est activé par défaut sur les bases créées à partir de SQL Server 2022. Vous voyez son état dans la vue sys.database_query_store_options de chaque base.

C’est souvent la seule trace durable des requêtes qui ont tourné sur un serveur. Sans audit ni session d’événements étendus préparés à l’avance, il ne reste que le cache des plans, vidé au redémarrage et à chaque éviction, et le Query Store. Je m’en sers pour chercher une régression, une requête qui a changé de plan, une lecture inhabituelle d’une table sensible, ou simplement pour savoir si une requête précise a tourné hier à 13 h 32.

J’ai récemment fait cette recherche chez un client, à la main, avec une équipe qui voulait savoir si deux tables avaient été lues par des requêtes inhabituelles sur huit bases. Il a fallu filtrer des centaines de requêtes Entity Framework, déjouer les jokers de LIKE, recommencer base par base, et nous n’avons pas fini. Cet article décrit l’approche que je recommande maintenant : donner le Query Store à lire à un agent, sans lui donner les données. Toutes les commandes ont été testées sur SQL Server 2025 CU9 en conteneur Linux, avec la base de démonstration PachaDataFormation.

Ce que le Query Store enregistre

Le Query Store enregistre, pour chaque requête capturée :

  • son texte, dans sys.query_store_query_text, tel que l’application l’a envoyé, sans les espaces et les commentaires du début et de la fin, et réécrit quand le moteur a paramétré la requête ;
  • ses plans, en XML, dans sys.query_store_plan ;
  • ses statistiques d’exécution par intervalle (60 minutes par défaut) dans sys.query_store_runtime_stats : nombre d’exécutions, durée, CPU, lectures, nombre de lignes renvoyées (avg_rowcount, max_rowcount), première et dernière exécution.

Il n’enregistre ni le login, ni la machine cliente, ni le nom du programme. Vous saurez qu’une requête a lu une table, à quelle heure et combien de lignes elle a renvoyées, pas qui l’a lancée.

Trois mécanismes décident de ce qui y entre.

Le mode de capture

En mode ALL, toutes les requêtes sont capturées. En mode AUTO, la valeur par défaut depuis SQL Server 2019 (ALL en 2016 et 2017), le Query Store ignore les requêtes peu fréquentes et peu coûteuses. La page de ALTER DATABASE SET donne ses seuils : une requête est capturée dès qu’elle atteint 30 exécutions, 1 seconde de CPU de compilation ou 100 ms de CPU d’exécution, cumulés sur une journée. La page sur la gestion du Query Store les dit au contraire « déterminés en interne ». La documentation ajoute que les requêtes des procédures stockées, les curseurs et les requêtes compilées en natif sont toujours capturés. C’est vrai des procédures stockées, mais depuis SQL Server 2022 un curseur est soumis aux seuils : le détail est dans l’article sur les modes de capture du Query Store.

J’ai vérifié ces seuils sur la base de démonstration, en mode AUTO, chaque requête ad hoc lancée par un login applicatif :

RequêteCPU de la première exécution (SET STATISTICS TIME)ExécutionsExécutions enregistrées
SELECT * FROM Contact.Contact (120 140 lignes)127 ms22, dès la première
SELECT * FROM Contact.Adresse (128 183 lignes)55 ms2120, à partir de la deuxième
Requête paramétrée de type Entity Framework, une lignemoins de 1 ms5021, à partir de la trentième
SELECT Email FROM Contact.Contact WHERE ContactId = 42420 ms1aucune
25 tranches de 5 000 identifiants de Contact.Contact (noms, courriels, téléphones)non mesuré25aucune

Les captures tombent là où les seuils publiés les placent. Le nombre de lignes n’y entre pas : une lecture complète de 128 000 lignes lancée une seule fois passe sous le seuil si elle coûte peu, et une extraction découpée en petites requêtes ad hoc peut ne laisser aucune trace. Les exécutions qui précèdent la capture ne sont pas comptées non plus : count_executions donne 21 pour une requête lancée 50 fois.

La base de contexte

Une requête est enregistrée dans le Query Store de la base dans laquelle elle s’exécute, pas dans celui de la base qu’elle lit. Lancée depuis la base Ventes, avec le Query Store de Ventes en mode ALL, la requête suivante apparaît dans le Query Store de Ventes, et pas dans celui de PachaDataFormation :

SELECT * FROM PachaDataFormation.Contact.Adresse WHERE AdresseId = 4812;

Elle y apparaît réécrite par la paramétrisation simple : (@1 smallint)SELECT * FROM [PachaDataFormation].[Contact].[Adresse] WHERE [AdresseId]=@1. Chercher Contact.Adresse ne la trouve pas, chercher Adresse la trouve.

Lancée depuis master ou tempdb, la même requête n’est enregistrée nulle part, puisque le Query Store ne peut être activé ni sur master ni sur tempdb. Or tout login peut se connecter à ces deux bases. La recherche doit donc couvrir toutes les bases accessibles au login qui vous intéresse, et elle garde un angle mort.

La rétention

Les données sont gardées 30 jours par défaut (STALE_QUERY_THRESHOLD_DAYS), et le nettoyage par taille peut les purger plus tôt quand le Query Store approche de MAX_STORAGE_SIZE_MB (1 000 Mo par défaut depuis SQL Server 2019). Comme le Query Store est stocké dans la base, une sauvegarde de la base le préserve : si vous avez besoin de garder l’état d’aujourd’hui, sauvegardez.

Une base dont le Query Store est à OFF ne capture plus rien. Elle garde ce qu’elle avait enregistré avant sa désactivation, et une base où il n’a jamais été activé ne contient rien.

La recherche à la main

La méthode manuelle part d’une recherche par texte, comme mon script find-query-by-text.sql, à laquelle on ajoute un filtre sur last_execution_time. On retire ensuite les requêtes de l’application, reconnaissables quand elle passe par Entity Framework 6 : paramètres @p0, alias [Extent1] (EF Core écrit plutôt des alias courts comme [c] et des paramètres comme @__id_0).

Dans LIKE, les crochets définissent un ensemble de caractères : '%[Extent1]%' veut dire « contient un des caractères E, x, t, e, n ou 1 ». Le filtre NOT LIKE '%[Extent1]%' écarte donc presque tout. Le trait de soulignement est aussi un joker, qui remplace n’importe quel caractère. Sur le Query Store du labo, qui contenait alors quatre requêtes dont une seule d’Entity Framework :

USE PachaDataFormation;

SELECT
    COUNT(*) AS total,
    SUM(CASE WHEN query_sql_text NOT LIKE N'%[Extent1]%' THEN 1 ELSE 0 END) AS sans_echappement,
    SUM(CASE WHEN query_sql_text NOT LIKE N'%\[Extent1\]%' ESCAPE N'\' THEN 1 ELSE 0 END) AS avec_escape,
    SUM(CASE WHEN CHARINDEX(N'[Extent1]', query_sql_text) = 0 THEN 1 ELSE 0 END) AS avec_charindex
FROM sys.query_store_query_text
WHERE CHARINDEX(N'query_store', query_sql_text) = 0;
totalsans_echappementavec_escapeavec_charindex
4033

Sans échappement, le filtre rend zéro requête, et on croit qu’il n’y a rien à voir. CHARINDEX cherche une chaîne littérale et évite la question.

Vient ensuite le tri : les lectures complètes (SELECT *) des tables sensibles, puis le rapport « Requêtes les plus consommatrices » de SSMS sur la période, trié par nombre de lignes. Une extraction de données renvoie beaucoup de lignes. Recommencé dans chaque base, ce tri est le travail qu’un agent fait mieux que moi.

Un login qui lit le Query Store sans lire les tables

La permission qui donne accès aux vues du Query Store est VIEW DATABASE STATE, au niveau de la base. Depuis SQL Server 2022, VIEW DATABASE PERFORMANCE STATE, plus restreinte, suffit aussi. Aucune des deux ne donne SELECT sur les tables.

Le script suivant crée le login et, dans chaque base utilisateur en ligne et accessible en écriture, un utilisateur qui n’a que cette permission. Il se lance avec sqlcmd et un compte sysadmin. Le mot de passe vient de la variable d’environnement AGENT_PWD, que sqlcmd lit comme une variable de script : il n’est écrit ni dans le fichier ni sur la ligne de commande, où -v AGENT_PWD=... le montrerait dans la liste des processus.

-- À exécuter avec un compte sysadmin, par sqlcmd
USE master;
IF SUSER_ID(N'agent_qs') IS NULL
    CREATE LOGIN agent_qs WITH PASSWORD = '$(AGENT_PWD)', CHECK_POLICY = ON, DEFAULT_DATABASE = master;
GO

-- Un utilisateur dans chaque base utilisateur en ligne et accessible en écriture,
-- avec la seule permission VIEW DATABASE STATE
DECLARE @base sysname, @proc nvarchar(300);
DECLARE @sql nvarchar(max) = N'
IF DATABASE_PRINCIPAL_ID(N''agent_qs'') IS NULL
    CREATE USER agent_qs FOR LOGIN agent_qs;
GRANT VIEW DATABASE STATE TO agent_qs;';

DECLARE bases CURSOR LOCAL FAST_FORWARD FOR
    SELECT name
    FROM sys.databases
    WHERE database_id > 4
      AND state_desc = N'ONLINE'
      AND is_read_only = 0;

OPEN bases;
FETCH NEXT FROM bases INTO @base;
WHILE @@FETCH_STATUS = 0
BEGIN
    SET @proc = QUOTENAME(@base) + N'.sys.sp_executesql';
    EXEC @proc @sql;
    PRINT CONCAT(N'agent_qs : ', @base);
    FETCH NEXT FROM bases INTO @base;
END;
CLOSE bases;
DEALLOCATE bases;

L’appel de sp_executesql préfixé par le nom de la base ([Base].sys.sp_executesql) exécute le lot dans cette base, sans USE dynamique. Sous SQL Server 2022 ou 2025, remplacez VIEW DATABASE STATE par VIEW DATABASE PERFORMANCE STATE si vous voulez la permission la plus étroite. Une base créée plus tard n’a pas l’utilisateur : relancez le script, qui ne recrée ni le login ni les utilisateurs existants. Il suppose un login neuf : un agent_qs qui existait déjà peut avoir d’autres droits, que le script ne retire pas. Le filtre database_id > 4 écarte les bases système, msdb comprise : si son Query Store est activé chez vous, ajoutez-la.

Connecté sous agent_qs, vérifiez qu’il ne lit pas les données :

USE PachaDataFormation;

SELECT TOP (1) Nom FROM Contact.Contact;
-- Msg 229 : The SELECT permission was denied on the object 'Contact'

DBCC SHOW_STATISTICS ('Contact.Contact', nix_contact_nom) WITH HISTOGRAM;
-- Msg 2557 : pas de permission pour DBCC SHOW_STATISTICS

SELECT actual_state_desc, query_capture_mode_desc
FROM sys.database_query_store_options;
-- READ_WRITE, AUTO : le Query Store est lisible

Les histogrammes de statistiques, qui contiennent des valeurs réelles des colonnes, lui sont refusés, et sys.dm_db_stats_histogram lui renvoie zéro ligne. Faute de VIEW DEFINITION, il ne voit pas non plus les tables dans sys.tables, qui lui renvoie zéro ligne. Les vues de niveau serveur (sys.dm_exec_query_stats) lui sont refusées, comme les procédures du Query Store (sp_query_store_remove_query, Msg 12421).

Les pages Microsoft de sys.query_store_query_text et de sys.query_store_query_hints indiquent que ces vues demandent VIEW SERVER STATE, ou VIEW SERVER PERFORMANCE STATE depuis SQL Server 2022, au niveau du serveur. Je ne l’ai mesuré nulle part : un utilisateur qui n’a que VIEW DATABASE STATE lit sys.query_store_query_text sur 2019, 2022 et 2025, et sys.query_store_query_hints sur 2025. Sur 2022 et 2025, un utilisateur sans permission reçoit le message 262, qui nomme VIEW DATABASE PERFORMANCE STATE. Les autres pages du Query Store indiquent la permission de niveau base.

Pourquoi un login aussi restreint, alors que l’agent tourne sur votre poste ? Un agent lit du texte qu’il ne contrôle pas, et le Query Store en contient : un texte de requête peut contenir des instructions écrites pour lui (une injection de prompt). Un agent qui n’a pas le droit de lire les tables ne peut pas les recopier dans sa réponse, même s’il est trompé. Il ne peut modifier ni les données ni le Query Store. Ses propres requêtes y sont enregistrées, en revanche, comme celles de n’importe quel login.

L’état du Query Store dans chaque base

Avant de chercher, vérifiez ce qui est cherchable. Ce script tourne sous agent_qs lui-même : il parcourt les bases auxquelles le login a accès (HAS_DBACCESS) et liste celles qu’il ne peut pas ouvrir.

SET NOCOUNT ON;

CREATE TABLE #etat (
    base sysname,
    desired_state_desc nvarchar(60),
    actual_state_desc nvarchar(60),
    readonly_reason int,
    query_capture_mode_desc nvarchar(60),
    stale_query_threshold_days bigint,
    max_storage_size_mb bigint,
    current_storage_size_mb bigint,
    plus_ancienne_donnee datetimeoffset
);

DECLARE @base sysname, @proc nvarchar(300);
DECLARE @sql nvarchar(max) = N'
SELECT DB_NAME(), o.desired_state_desc, o.actual_state_desc, o.readonly_reason,
       o.query_capture_mode_desc, o.stale_query_threshold_days,
       o.max_storage_size_mb, o.current_storage_size_mb,
       (SELECT MIN(start_time) FROM sys.query_store_runtime_stats_interval)
FROM sys.database_query_store_options AS o;';

DECLARE bases CURSOR LOCAL FAST_FORWARD FOR
    SELECT name
    FROM sys.databases
    WHERE database_id > 4
      AND state_desc = N'ONLINE'
      AND HAS_DBACCESS(name) = 1;

OPEN bases;
FETCH NEXT FROM bases INTO @base;
WHILE @@FETCH_STATUS = 0
BEGIN
    SET @proc = QUOTENAME(@base) + N'.sys.sp_executesql';
    INSERT INTO #etat EXEC @proc @sql;
    FETCH NEXT FROM bases INTO @base;
END;
CLOSE bases;
DEALLOCATE bases;

SELECT * FROM #etat ORDER BY base;

-- Bases que ce login ne peut pas ouvrir
SELECT name AS base_inaccessible
FROM sys.databases
WHERE database_id > 4
  AND HAS_DBACCESS(name) = 0;

La colonne plus_ancienne_donnee donne le début du plus ancien intervalle conservé : une période antérieure n’est pas couverte. Un actual_state_desc à READ_ONLY alors que desired_state_desc vaut READ_WRITE signale un Query Store qui ne capture plus rien, et readonly_reason dit pourquoi : 65536 quand il a atteint MAX_STORAGE_SIZE_MB.

La connexion de l’agent

L’agent a besoin d’un client en ligne de commande. Deux existent, décrits sur la même page : sqlcmd historique (ODBC, paquet mssql-tools18) et go-sqlcmd, un binaire unique sans pilote ODBC. Tous deux lisent le mot de passe dans la variable d’environnement SQLCMDPASSWORD quand -U est donné sans -P.

Le mot de passe ne doit apparaître ni dans le prompt, ni dans un fichier versionné, ni dans la ligne de commande (où ps le montre), ni dans l’historique du shell. Je le mets dans un fichier lisible par moi seul, que je charge avant de lancer l’agent :

# Une fois : le mot de passe est lu sans écho, il ne passe pas dans l'historique
install -m 600 /dev/null ~/.config/agent-qs.env
read -rs p && printf 'SQLCMDPASSWORD=%q\n' "$p" > ~/.config/agent-qs.env; unset p

# À chaque session
set -a; . ~/.config/agent-qs.env; set +a
sqlcmd -S monserveur,1433 -U agent_qs -d master -C -b -Q "SELECT SUSER_NAME();"
claude    # ou codex : l'agent hérite de la variable

Sous Windows, avec PowerShell 7, $env:SQLCMDPASSWORD = Read-Host -MaskInput dans la session fait la même chose (Windows PowerShell 5.1 n’a pas -MaskInput). L’option -C fait confiance au certificat du serveur sans le vérifier, ce qu’il faut avec un certificat auto-signé : retirez-la si le serveur a un certificat valide. L’option -b rend un code de sortie non nul en cas d’erreur SQL : sans elle, go-sqlcmd rend 0 après une erreur 229. Le sqlcmd ODBC 18 chiffre la connexion par défaut ; go-sqlcmd ne la chiffre que si vous le demandez avec -N.

L’agent peut lire la variable, bien sûr. On lui donne un accès, et c’est pourquoi le login n’a que le droit de lire le Query Store.

Les requêtes de base

L’agent écrit ses requêtes lui-même, mais lui montrer la forme attendue lui fait gagner du temps et vous permet de relire ce qu’il fait. Toutes partent des mêmes jointures : le texte, la requête, le plan, les statistiques.

La recherche suivante parcourt toutes les bases accessibles, cherche un texte et une période, et trie par nombre total de lignes renvoyées. Elle utilise CHARINDEX plutôt que LIKE, et une date de début avec son fuseau : les colonnes de date du Query Store sont des datetimeoffset, et la comparaison se fait sur l’instant, quel que soit le fuseau.

SET NOCOUNT ON;

DECLARE @texte nvarchar(400) = N'Contact';                       -- nom de table, sans schéma ni crochets
DECLARE @depuis datetimeoffset = '2026-10-21T00:00:00+02:00';   -- début de la période, avec son fuseau

CREATE TABLE #trouve (
    base sysname,
    query_id bigint,
    derniere_execution datetimeoffset,
    executions bigint,
    lignes_max bigint,
    lignes_total bigint,
    texte nvarchar(max)
);

DECLARE @base sysname, @proc nvarchar(300);
DECLARE @sql nvarchar(max) = N'
SELECT DB_NAME(), q.query_id, MAX(rs.last_execution_time),
       SUM(rs.count_executions), MAX(rs.max_rowcount),
       CAST(SUM(rs.avg_rowcount * rs.count_executions) AS bigint),
       t.query_sql_text
FROM sys.query_store_query AS q
JOIN sys.query_store_query_text AS t ON t.query_text_id = q.query_text_id
JOIN sys.query_store_plan AS p ON p.query_id = q.query_id
JOIN sys.query_store_runtime_stats AS rs ON rs.plan_id = p.plan_id
WHERE CHARINDEX(@texte, t.query_sql_text) > 0
  AND CHARINDEX(N''query_store'', t.query_sql_text) = 0
  AND rs.last_execution_time >= @depuis
GROUP BY q.query_id, t.query_sql_text;';

DECLARE bases CURSOR LOCAL FAST_FORWARD FOR
    SELECT name
    FROM sys.databases
    WHERE database_id > 4
      AND state_desc = N'ONLINE'
      AND HAS_DBACCESS(name) = 1;

OPEN bases;
FETCH NEXT FROM bases INTO @base;
WHILE @@FETCH_STATUS = 0
BEGIN
    SET @proc = QUOTENAME(@base) + N'.sys.sp_executesql';
    INSERT INTO #trouve
    EXEC @proc @sql, N'@texte nvarchar(400), @depuis datetimeoffset', @texte, @depuis;
    FETCH NEXT FROM bases INTO @base;
END;
CLOSE bases;
DEALLOCATE bases;

SELECT base, query_id, derniere_execution, executions, lignes_max, lignes_total,
       LEFT(texte, 200) AS debut_texte
FROM #trouve
ORDER BY lignes_total DESC;

Le filtre CHARINDEX(N'query_store', ...) = 0 écarte les requêtes de l’agent sur les vues du Query Store, que le Query Store capture comme les autres, et toute autre requête dont le texte contient ce mot. Les totaux sont ceux des intervalles retenus : un intervalle commencé avant @depuis compte aussi ses exécutions antérieures.

Pour les suppressions et les modifications, la même recherche avec @texte = N'DELETE', puis N'UPDATE', suffit. Elle remonte aussi les textes qui contiennent le mot ailleurs (une colonne IsDeleted, un ON DELETE CASCADE) : relisez-les. Un TRUNCATE TABLE n’y apparaît jamais, même en mode ALL. Une requête retenue se lit ensuite avec ses plans et ses intervalles :

USE PachaDataFormation;

DECLARE @query_id bigint = 2;   -- à remplacer par le query_id trouvé

SELECT i.start_time, i.end_time, rs.count_executions,
       rs.avg_rowcount, rs.max_rowcount,
       rs.avg_duration / 1000. AS duree_moy_ms,
       rs.avg_cpu_time / 1000. AS cpu_moy_ms,
       rs.avg_logical_io_reads
FROM sys.query_store_plan AS p
JOIN sys.query_store_runtime_stats AS rs ON rs.plan_id = p.plan_id
JOIN sys.query_store_runtime_stats_interval AS i
    ON i.runtime_stats_interval_id = rs.runtime_stats_interval_id
WHERE p.query_id = @query_id
ORDER BY i.start_time;

SELECT plan_id, TRY_CAST(query_plan AS xml) AS plan_xml
FROM sys.query_store_plan
WHERE query_id = @query_id;

Le prompt

Le prompt décrit la situation en deux phrases, dit ce que l’agent a le droit de faire et ce qu’on attend en sortie. Voici celui que j’ai donné à Claude Code sur le labo, dans un répertoire vide, corrigé d’une ligne : la version testée cherchait aussi les TRUNCATE, que le Query Store n’enregistre pas.

Tu as accès à une instance SQL Server par sqlcmd : serveur localhost,11980, login agent_qs,
option -C pour le certificat. Le mot de passe est dans la variable d'environnement
SQLCMDPASSWORD : ne l'affiche pas et ne le recopie nulle part.

Ce login n'a que la permission VIEW DATABASE STATE dans chaque base : il lit le Query Store,
pas les tables.

Explore le Query Store de toutes les bases de l'instance auxquelles tu as accès, pour la
période qui commence le 21 octobre 2026 à 00:00, heure de Paris. Cherche les requêtes
inhabituelles :
- les lectures complètes ou volumineuses des tables qui contiennent des données personnelles
  (contacts, adresses, factures) ;
- les requêtes ad hoc qui ne ressemblent pas au code de l'application, qui passe par
  Entity Framework (paramètres @p0, alias [Extent1]) ;
- les DELETE et les UPDATE ;
- toute autre chose qui te paraît anormale.

Pour chaque requête retenue, donne la base, le query_id, la dernière exécution en heure de
Paris, le nombre d'exécutions, le nombre de lignes renvoyées et la raison pour laquelle tu
la retiens.

Dis ensuite ce que tu n'as pas pu vérifier : bases sans Query Store actif, mode de capture,
période couverte par la rétention.

Contraintes : n'exécute que des SELECT sur les vues système, ne modifie rien.
Écris ton rapport dans rapport.md.

Sur le labo, Claude Code (modèle Sonnet) a travaillé cinq minutes. Il a listé les bases, constaté que SansQS n’avait pas de Query Store, et trouvé les lectures complètes de Contact.Contact, Contact.Adresse, Inscription.Facture et Inscription.LigneFacture, ainsi que la lecture d’une adresse par un nom en trois parties, depuis Ventes. Il a mis à part la seule requête Entity Framework comme référence du trafic normal. Il a écrit de lui-même que la base était en mode AUTO, que l’absence de DELETE ne prouvait donc rien, et que rien n’était capturé avant 16 h 41 le jour demandé. Il n’a vu ni les 25 tranches de Contact.Contact ni la lecture faite depuis master, que le Query Store n’avait pas enregistrées.

Il a aussi relevé mes propres requêtes de contrôle, qui cherchaient le mot Adresse dans le Query Store, comme celles de « quelqu’un qui sait ce qu’il cherche ». Relisez son rapport comme celui d’un collègue : chaque requête qu’il cite a un query_id que vous pouvez vérifier.

Le même prompt sert pour une analyse de performances : remplacez la liste de ce qu’il faut chercher par « les requêtes dont la durée moyenne a augmenté depuis lundi, avec les changements de plan ».

Travailler sur un clone

Si vous ne voulez pas que l’agent se connecte à la production, même avec un login aussi restreint, DBCC CLONEDATABASE produit une copie de la base qui contient le schéma, les statistiques et le Query Store, sans aucune ligne de données. Microsoft la destine au diagnostic de performances. Elle demande un compte sysadmin.

USE master;

-- Écrire sur disque les statistiques d'exécution encore en mémoire
EXEC PachaDataFormation.sys.sp_query_store_flush_db;

-- Clone sans histogrammes, sauvegardé et vérifié dans la foulée
DBCC CLONEDATABASE (PachaDataFormation, PachaDataFormation_Clone)
WITH NO_STATISTICS, BACKUP_CLONEDB;

Les options :

OptionEffet
NO_STATISTICSn’emporte pas le contenu des statistiques (histogrammes et densités), qui contient des valeurs réelles des colonnes. Les valeurs réelles qui restent sont alors les littéraux des textes, les valeurs compilées des plans et celles du schéma (valeurs par défaut, contraintes)
NO_QUERYSTOREn’emporte pas le Query Store, à ne pas utiliser ici
VERIFY_CLONEDBvérifie la cohérence du clone, mais implique NO_STATISTICS et NO_QUERYSTORE : à ne pas utiliser ici non plus
BACKUP_CLONEDBsauvegarde le clone et vérifie la sauvegarde, dans le répertoire de sauvegarde par défaut de l’instance
SERVICEBROKERemporte les catalogues de Service Broker

Sur le labo, la base de 2 Go a donné un clone de 16 Mo et une sauvegarde de 6 Mo, avec tous les textes, les plans et les statistiques d’exécution, et zéro ligne dans Contact.Contact. Le message de fin indique le nom du fichier : Clone backup succeeded and is stored in 'PachaDataFormation_Clone_1710638105.bak'. Une fois la sauvegarde vérifiée, retirez le clone de l’instance de production (DROP DATABASE PachaDataFormation_Clone;) : Microsoft recommande de le détacher une fois créé.

Cette sauvegarde se restaure sur une autre instance de même version ou plus récente. Une Express suffit, puisque le clone est petit. J’ai restauré celle du labo sur une SQL Server 2025 Express. Les utilisateurs de la base sont dans le clone, mais pas les logins : agent_qs est orphelin sur la nouvelle instance. Le plus simple est de recréer le login avec le même SID, lu sur l’instance d’origine par SELECT SUSER_SID('agent_qs'); :

-- Sur l'instance d'analyse, par sqlcmd avec la variable d'environnement AGENT_PWD,
-- après avoir copié le fichier dans son répertoire de sauvegarde
RESTORE DATABASE PachaDataFormation_Clone
FROM DISK = N'/var/opt/mssql/backup/PachaDataFormation_Clone_1710638105.bak'
WITH MOVE N'PachaDataFormation' TO N'/var/opt/mssql/data/PachaDataFormation_Clone.mdf',
     MOVE N'PachaDataFormation_log' TO N'/var/opt/mssql/data/PachaDataFormation_Clone_log.ldf';

CREATE LOGIN agent_qs WITH PASSWORD = '$(AGENT_PWD)', SID = 0x71EEFB92B941D548AE09FAFA223FC899;  -- SID lu sur l'instance d'origine

ALTER DATABASE PachaDataFormation_Clone SET READ_ONLY WITH ROLLBACK IMMEDIATE;

La base restaurée était en lecture-écriture, et son Query Store aussi : les requêtes de l’agent y auraient été capturées et mêlées à celles de la production. En lecture seule, le Query Store ne capture plus rien. Lancez ce dernier ALTER DATABASE avant de donner la base à l’agent : il demande un accès exclusif.

La documentation dit qu’un clone est en lecture seule. C’est vrai pour le clone d’une base sans READ_COMMITTED_SNAPSHOT, pas pour celui de PachaDataFormation, qui l’a activé : sur SQL Server 2019, 2022 et 2025, le clone d’une base en RCSI reste en lecture-écriture. Avec BACKUP_CLONEDB, il passe en lecture seule (mesuré sur 2022 et 2025). Vérifiez is_read_only dans sys.databases plutôt que de le supposer.

Le clone n’est pas supporté en production, il ne copie que les objets que Microsoft liste, et il ne contient que ce que le Query Store contenait au moment du clone. Les littéraux et les valeurs compilées des plans y sont toujours.

Un export en CSV

La solution la plus simple ne donne à l’agent aucun accès au serveur : exporter les textes et les statistiques de la période dans des fichiers, un par base, les poser dans un répertoire, ouvrir l’agent dans ce répertoire et lui poser la même question. Elle convient quand le serveur n’est pas joignable depuis le poste où tourne l’agent.

La requête d’export, dans un fichier export.sql, retire les retours à la ligne des textes pour garder une ligne par enregistrement. Elle convertit aussi le texte en nvarchar(4000) : sqlcmd coupe à 256 caractères les colonnes nvarchar(max), et son option -y 0, qui lève cette limite, ne se combine pas avec -W.

SET NOCOUNT ON;

SELECT q.query_id, p.plan_id, i.start_time, rs.count_executions,
       rs.avg_rowcount, rs.max_rowcount, rs.avg_duration, rs.avg_cpu_time,
       rs.avg_logical_io_reads,
       CAST(REPLACE(REPLACE(t.query_sql_text, CHAR(13), N' '), CHAR(10), N' ') AS nvarchar(4000)) AS query_sql_text
FROM sys.query_store_query AS q
JOIN sys.query_store_query_text AS t ON t.query_text_id = q.query_text_id
JOIN sys.query_store_plan AS p ON p.query_id = q.query_id
JOIN sys.query_store_runtime_stats AS rs ON rs.plan_id = p.plan_id
JOIN sys.query_store_runtime_stats_interval AS i
    ON i.runtime_stats_interval_id = rs.runtime_stats_interval_id
WHERE i.end_time >= '2026-10-21T00:00:00+02:00';

Elle se lance base par base. Le séparateur | coupe moins de textes que la virgule, mais un littéral qui contient | décale encore les colonnes, et le fichier garde l’en-tête et sa ligne de tirets :

sqlcmd -S monserveur,1433 -U agent_qs -d PachaDataFormation -C -b -s"|" -W -i export.sql -o PachaDataFormation.csv

L’agent ne peut alors ni affiner sa recherche ni lire un plan qu’on n’a pas exporté, et il faut recommencer l’export à chaque nouvelle question.

Dans les trois cas, login restreint, clone ou export, le texte des requêtes part chez le fournisseur du modèle, et les plans aussi sauf dans l’export. Décidez-le avec le responsable des données avant de commencer.

Les limites de la méthode

  • Une trace dans le Query Store prouve qu’une requête a tourné. Son absence ne prouve rien.
  • En mode AUTO, les requêtes ad hoc peu fréquentes et peu coûteuses ne sont pas capturées, et une extraction découpée en petites requêtes peut ne laisser aucune trace. Les requêtes des procédures stockées sont toujours capturées.
  • Une requête lancée depuis master ou tempdb n’est enregistrée nulle part, et une requête lancée depuis une autre base est enregistrée dans celle-ci.
  • Un TRUNCATE TABLE n’est pas enregistré, même en mode ALL.
  • Une requête lancée sur un réplica secondaire lisible n’est pas enregistrée, sauf si le Query Store des réplicas secondaires, en préversion depuis SQL Server 2025, est activé.
  • Une base sans Query Store actif, ou dont le Query Store est passé en READ_ONLY, ne donne aucune trace pour la période concernée.
  • L’historique ne remonte pas au-delà de la rétention, 30 jours par défaut, ou moins si le nettoyage par taille a purgé.
  • Le Query Store ne dit pas qui a lancé la requête.
  • Les données peuvent sortir d’une base par des requêtes, par une sauvegarde ou par une copie des fichiers de données. Le Query Store ne voit que les requêtes.
  • Pour savoir si des lignes ont été supprimées, cherchez les DELETE dans le Query Store, ou comparez avec une sauvegarde antérieure restaurée à côté.

Si vous voulez une trace complète des lectures d’une table, il faut la préparer à l’avance : SQL Server Audit avec une spécification d’audit de base de données sur SELECT, limitée aux tables sensibles pour ne pas remplir les disques.