Skip to content

Commit a2abe6b

Browse files
Add incremental query metrics primitives to the base package (DataDog#24791)
* Copy the Postgres incremental query metrics primitives into base MySQL needs DeltaDetector and ObfuscationLookup too. Copy them verbatim first so the rest of the stack reads as a diff against the reviewed Postgres code; git diff --no-index against the originals prints nothing. Tests are lifted from postgres/tests/test_statements_v2.py with their bodies unchanged. Postgres keeps its own copies until a follow-up PR. * Generalize ObfuscationLookup over the statement key type The cache never interprets its key, so PgssKey was incidental; MySQL identifies a statement by a digest string. Make the class generic over K and rename queryid_map_size to key_map_size. The docstring no longer claims a rejection is permanent. That holds for a MySQL digest, not in general, so the caller decides. * Generalize DeltaDetector over the row key Take a key callable instead of reading queryid, dbid and userid off each row. PgssKey is gone and DeltaResult now exposes changed_keys and vanished_keys. The two DeltaResult field docstrings were also attached to the wrong fields. * Stop mutating the caller's rows when collapsing duplicates The first row of each duplicate group was stored by reference and then summed into, so the caller's snapshot came back rewritten: given rows with calls=8 and calls=7, the first was left holding 15. Copy instead. * Add a maxsize property that trims when the cache shrinks Callers resize the cache from a server setting on every collection, and with no public setter Postgres assigns _maxsize directly. That skips trimming, so lowering the bound evicts nothing until a later populate. * Return obfuscation failures from populate A statement the obfuscator rejects was skipped silently and stayed a miss, so every collection in which it changed fetched and re-attempted it. Hand the failed keys back so callers can negative-cache them. * Extract a module-level obfuscate_statement Obfuscation was only reachable through the cache, which assumes a key determines its text. MySQL's prepared_statements_instances is keyed on a reusable address, so its rows must be obfuscated afresh each cycle. * Add resolve_obfuscations to own the cache miss path Postgres and MySQL wrap the cache in the same sequence, whose ordering constraints fail quietly when broken. TextDisposition also makes the integration say whether a rejected text is permanent (DDIGNORE, EXPLAIN) or transient (<insufficient privilege>), which a chain of continue statements blurs. Emits no telemetry: counts come back in ResolveStats for the caller to report under its own metric names. * Add changelog entry Co-authored-by: Cursor <cursoragent@cursor.com> * Drive cache retention from the live keys, not the vanished ones resolve_obfuscations bound the delta key and the cache key to one type variable, but MySQL keys counters on (schema, digest) and caches on the digest alone. Projecting vanished_keys down to a digest drops entries another schema is still running, and since eviction precedes the lookup the digest is re-fetched in the same cycle. Against a live MySQL 8.4 with one statement shared by two schemas, the obvious projection re-fetched the shared digest on 4 of 8 collections; the live set makes that 1. So evict() becomes retain(live_keys) and a caller can only misstate what is present, which shows up in the hit rate, rather than silently discard an entry that is still needed. Sweeping the whole cache costs 0.55 ms at maxsize=10000, and the count comes back as ResolveStats.dropped. Retention no longer depends on the delta, so DeltaResult.vanished_keys goes, along with a set difference the detector computed twice. The docstrings claimed eviction stopped a returning key being served a result cached against its previous incarnation. Neither source can do that: a queryid and a digest are both derived from the normalized statement, so a key cannot name two texts. Retention reclaims memory, and saying so is what makes it clear it has to run on quiet collections too. Co-authored-by: Cursor <cursoragent@cursor.com> * Group the primitives into a query_metrics package and rename for clarity The two modules were named for their mechanisms rather than their subject, which left the vocabulary inconsistent with the rest of DBM and made the classification enum read as an instruction to the base library rather than a description of what the integration saw. - db/query_metrics/ now holds the set, split into stats, obfuscation, cache and resolver. The package re-exports the public names lazily, mirroring db/__init__.py, so the file layout is not part of the interface. - DeltaDetector.compute() becomes QueryStats.diff(snapshot), returning a Delta. "Query stats" is what every source calls itself (pg_stat_statements, dm_exec_query_stats, $queryStats), and it keeps stats (what the database exposes) distinct from metrics (what we emit). derivative_rows becomes rows, and metric_columns becomes counter_columns to match. - TextDisposition becomes TextKind, with STATEMENT/EXCLUDED/UNAVAILABLE replacing CACHE/IGNORE/SKIP. Integrations now report what a text turned out to be and the resolver owns the caching policy those kinds imply, rather than each integration re-deriving whether a rejection is permanent. The kinds split on whether the text is the statement's own and, if not, whether that can change; text that is permanently unavailable would need a kind of its own. Co-authored-by: Cursor <cursoragent@cursor.com> * Keep derivative_rows as the name of the delta rows rows is an actual pg_stat_statements counter column, so Delta.rows produced expressions like delta.rows[0]['rows'] at the places worth reading carefully. Co-authored-by: Cursor <cursoragent@cursor.com> * Add DBM as a codeowner for the query_metrics package * Reclaim obfuscation results whose last key is gone retain() dropped the key mappings but left the results they named, and the results are what hold the obfuscated text, so nothing that costs memory was reclaimed. Because only an overflow evicts a result, the stranded ones accumulated until the result tier sat at maxsize however small the live set was, and then displaced results of statements still live but not recently run. With four keys per signature and two live signatures, a 50-entry cache filled from cycle 48 and evicted a statement present in every snapshot. Keys also leave by LRU trimming, so the sweep runs on every retain rather than only when a stale key was dropped. At maxsize=10000 with nothing shared it costs 0.66 ms, against 0.40 ms for the stale key set it follows. Co-authored-by: Cursor <cursoragent@cursor.com> --------- Co-authored-by: Cursor <cursoragent@cursor.com>
1 parent 4e724a0 commit a2abe6b

13 files changed

Lines changed: 1295 additions & 0 deletions

File tree

.github/CODEOWNERS

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -154,6 +154,8 @@ datadog_checks_base/tests/**/test_util.py @DataDog/agent-integrations
154154
datadog_checks_base/tests/**/test_db_sql.py @DataDog/database-monitoring-agent @DataDog/agent-integrations
155155
**/base/utils/db/statement_metrics.py @DataDog/database-monitoring-agent @DataDog/agent-integrations
156156
datadog_checks_base/tests/**/test_db_statements.py @DataDog/database-monitoring-agent @DataDog/agent-integrations
157+
**/base/utils/db/query_metrics/ @DataDog/database-monitoring-agent @DataDog/agent-integrations
158+
datadog_checks_base/tests/**/query_metrics/ @DataDog/database-monitoring-agent @DataDog/agent-integrations
157159
/postgres/ @DataDog/database-monitoring-agent @DataDog/agent-integrations
158160
/postgres/*.md @DataDog/database-monitoring @DataDog/agent-integrations @DataDog/documentation
159161
/postgres/manifest.json @DataDog/database-monitoring @DataDog/agent-integrations @DataDog/documentation
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
Add QueryStats, ObfuscationLookup and resolve_obfuscations for incremental query metrics collection.
Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
# (C) Datadog, Inc. 2026-present
2+
# All rights reserved
3+
# Licensed under a 3-clause BSD style license (see LICENSE)
4+
import lazy_loader
5+
6+
__getattr__, __dir__, __all__ = lazy_loader.attach_stub(__name__, __file__)
Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
# (C) Datadog, Inc. 2026-present
2+
# All rights reserved
3+
# Licensed under a 3-clause BSD style license (see LICENSE)
4+
from .cache import ObfuscationLookup
5+
from .obfuscation import ObfuscationResult, obfuscate_statement
6+
from .resolver import ResolveResult, ResolveStats, TextKind, resolve_obfuscations
7+
from .stats import Delta, QueryStats
8+
9+
__all__ = [
10+
'Delta',
11+
'ObfuscationLookup',
12+
'ObfuscationResult',
13+
'QueryStats',
14+
'ResolveResult',
15+
'ResolveStats',
16+
'TextKind',
17+
'obfuscate_statement',
18+
'resolve_obfuscations',
19+
]
Lines changed: 247 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,247 @@
1+
# (C) Datadog, Inc. 2026-present
2+
# All rights reserved
3+
# Licensed under a 3-clause BSD style license (see LICENSE)
4+
from __future__ import annotations
5+
6+
import logging
7+
from collections import OrderedDict
8+
from collections.abc import Hashable
9+
10+
from .obfuscation import ObfuscationResult, obfuscate_statement
11+
12+
logger = logging.getLogger(__name__)
13+
14+
15+
class ObfuscationLookup[K: Hashable]:
16+
"""LRU cache mapping statement identity keys to obfuscated query results.
17+
18+
``K`` is whatever identifies a statement in the source table: a
19+
``(queryid, dbid, userid)`` triple for ``pg_stat_statements``, a digest string for MySQL's
20+
``events_statements_summary_by_digest``, and so on. The cache never interprets the key.
21+
22+
Caching is only sound where the key functionally determines the text, which is why both of
23+
those work: a queryid is derived from the normalized parse tree and a digest from the
24+
normalized token stream, so neither can name two statements. A source whose identity outlives
25+
the text it named, such as MySQL's ``prepared_statements_instances``, must go through
26+
:func:`~.obfuscation.obfuscate_statement` instead.
27+
28+
A lookup resolves a key to one of three outcomes:
29+
30+
- hit: the obfuscated result is cached, avoiding both the text fetch and FFI obfuscation.
31+
Stored as two tiers, key -> query_signature -> result, so multiple keys sharing a
32+
query_signature share one result.
33+
- miss: nothing is cached for the key; the caller must fetch its text and pass it to
34+
:meth:`populate` to obfuscate, store, and discard the raw text.
35+
- ignored: the key is known to resolve to nothing usable. These are neither hit nor miss;
36+
lookup skips them so they are never fetched again.
37+
38+
The cache does not decide what is non-cacheable; :meth:`mark_ignored` is driven by
39+
:func:`~.resolver.resolve_obfuscations`, which owns that policy. The cache owns only storage
40+
and lifecycle. All three tiers are LRU-bounded by ``maxsize``, and :meth:`retain` drops the
41+
keys that have left the source table, along with any result no live key still names, so entries
42+
do not sit at that bound long after the statements they describe.
43+
"""
44+
45+
def __init__(self, maxsize: int, obfuscate_options: str, log_unobfuscated_queries: bool = False):
46+
self._maxsize = maxsize
47+
self._obfuscate_options = obfuscate_options
48+
self._log_unobfuscated_queries = log_unobfuscated_queries
49+
50+
self._key_to_sig: OrderedDict[K, str] = OrderedDict()
51+
self._sig_to_result: OrderedDict[str, ObfuscationResult] = OrderedDict()
52+
# Negative cache: keys we have learned resolve to nothing cacheable.
53+
self._ignored_keys: OrderedDict[K, None] = OrderedDict()
54+
55+
self._hits = 0
56+
self._misses = 0
57+
58+
@property
59+
def maxsize(self) -> int:
60+
return self._maxsize
61+
62+
@maxsize.setter
63+
def maxsize(self, value: int):
64+
self._maxsize = value
65+
self._trim()
66+
67+
@property
68+
def key_map_size(self) -> int:
69+
return len(self._key_to_sig)
70+
71+
@property
72+
def signature_map_size(self) -> int:
73+
return len(self._sig_to_result)
74+
75+
@property
76+
def ignored_map_size(self) -> int:
77+
return len(self._ignored_keys)
78+
79+
@property
80+
def hits(self) -> int:
81+
return self._hits
82+
83+
@property
84+
def misses(self) -> int:
85+
return self._misses
86+
87+
def reset_stats(self):
88+
self._hits = 0
89+
self._misses = 0
90+
91+
def lookup(self, keys: set[K]) -> tuple[dict[K, ObfuscationResult], set[K]]:
92+
"""Return (hits, misses) for the given statement keys.
93+
94+
Keys in the negative cache are excluded from both: they are neither a hit
95+
(no result to return) nor a miss (must not be re-fetched).
96+
"""
97+
hits: dict[K, ObfuscationResult] = {}
98+
misses: set[K] = set()
99+
ignored = 0
100+
101+
for key in keys:
102+
if key in self._ignored_keys:
103+
self._ignored_keys.move_to_end(key)
104+
ignored += 1
105+
continue
106+
sig = self._key_to_sig.get(key)
107+
if sig is not None:
108+
self._key_to_sig.move_to_end(key)
109+
result = self._sig_to_result.get(sig)
110+
if result is not None:
111+
self._sig_to_result.move_to_end(sig)
112+
self._hits += 1
113+
hits[key] = result
114+
continue
115+
self._misses += 1
116+
misses.add(key)
117+
118+
logger.debug(
119+
"lookup: requested=%d hits=%d misses=%d ignored=%d key_map=%d sig_map=%d ignored_map=%d",
120+
len(keys),
121+
len(hits),
122+
len(misses),
123+
ignored,
124+
len(self._key_to_sig),
125+
len(self._sig_to_result),
126+
len(self._ignored_keys),
127+
)
128+
return hits, misses
129+
130+
def mark_ignored(self, keys: set[K]) -> None:
131+
"""Record keys that resolve to nothing usable so future lookups skip them.
132+
133+
Entries are forgotten via :meth:`retain` once their key disappears from the source table.
134+
"""
135+
for key in keys:
136+
# Drop any stale positive mapping so an ignored key can never resurface as a
137+
# hit (e.g. if its signature is later repopulated by another key after this
138+
# negative entry is LRU-trimmed).
139+
self._key_to_sig.pop(key, None)
140+
self._ignored_keys[key] = None
141+
self._ignored_keys.move_to_end(key)
142+
if keys:
143+
self._trim_ignored()
144+
logger.debug("mark_ignored: added=%d ignored_map=%d", len(keys), len(self._ignored_keys))
145+
146+
def populate(self, raw_texts: dict[K, str]) -> tuple[dict[K, ObfuscationResult], set[K]]:
147+
"""Obfuscate raw texts and store the results.
148+
149+
Returns (results, failures), where failures are the keys whose text could not be
150+
obfuscated. Obfuscation depends only on the text, so a failure will recur for as long as
151+
the key keeps resolving to that text; callers that know the text is stable should pass
152+
these to :meth:`mark_ignored` rather than re-fetching them every collection.
153+
"""
154+
results: dict[K, ObfuscationResult] = {}
155+
failures: set[K] = set()
156+
157+
for key, raw_text in raw_texts.items():
158+
result = self._obfuscate_single(raw_text)
159+
if result is None:
160+
failures.add(key)
161+
continue
162+
163+
self._key_to_sig[key] = result.query_signature
164+
self._trim_keys()
165+
166+
if result.query_signature not in self._sig_to_result:
167+
self._sig_to_result[result.query_signature] = result
168+
self._trim_sig()
169+
else:
170+
self._sig_to_result.move_to_end(result.query_signature)
171+
172+
results[key] = result
173+
174+
logger.debug(
175+
"populate: input=%d obfuscated=%d failed=%d key_map=%d sig_map=%d",
176+
len(raw_texts),
177+
len(results),
178+
len(failures),
179+
len(self._key_to_sig),
180+
len(self._sig_to_result),
181+
)
182+
return results, failures
183+
184+
def retain(self, live_keys: set[K]) -> int:
185+
"""Forget all state, positive and negative, for keys absent from *live_keys*.
186+
187+
Returns how many keys were dropped.
188+
189+
Callers pass the keys currently in the source table rather than the ones that left, so a
190+
caller whose cache key is a projection of a wider counter key cannot report a key as gone
191+
while another live row still needs it. MySQL is the case in point: its counters are keyed
192+
on ``(schema, digest)`` but one digest has one text, so a digest is only finished once no
193+
schema references it.
194+
195+
This reclaims memory rather than protecting correctness. Because a key determines its
196+
text, an entry that outlives its key is unreachable rather than wrong. It does need to run
197+
on every collection, including quiet ones, or the cache sits at ``maxsize`` indefinitely.
198+
199+
Both tiers are pruned, and a result goes once no live key names it. The results are what
200+
hold the obfuscated text, so pruning the key mappings alone would reclaim nothing worth
201+
reclaiming; and because only an overflow evicts a result, any left stranded accumulate
202+
until the tier is full however small the live set is, and then displace the results of
203+
statements that are still live but have not run recently.
204+
"""
205+
stale = (self._key_to_sig.keys() | self._ignored_keys.keys()) - live_keys
206+
for key in stale:
207+
self._key_to_sig.pop(key, None)
208+
self._ignored_keys.pop(key, None)
209+
210+
# Results are shared by every key with the same signature, so one is reclaimable only once
211+
# no live key names it. Keys also leave by LRU trimming, so this sweeps on every call
212+
# rather than only when retain itself dropped one.
213+
orphaned = self._sig_to_result.keys() - set(self._key_to_sig.values())
214+
for signature in orphaned:
215+
del self._sig_to_result[signature]
216+
217+
if stale or orphaned:
218+
logger.debug(
219+
"retain: live=%d dropped=%d orphaned=%d key_map=%d sig_map=%d ignored_map=%d",
220+
len(live_keys),
221+
len(stale),
222+
len(orphaned),
223+
len(self._key_to_sig),
224+
len(self._sig_to_result),
225+
len(self._ignored_keys),
226+
)
227+
return len(stale)
228+
229+
def _obfuscate_single(self, raw_text: str) -> ObfuscationResult | None:
230+
return obfuscate_statement(raw_text, self._obfuscate_options, self._log_unobfuscated_queries)
231+
232+
def _trim(self):
233+
self._trim_keys()
234+
self._trim_sig()
235+
self._trim_ignored()
236+
237+
def _trim_keys(self):
238+
while len(self._key_to_sig) > self._maxsize:
239+
self._key_to_sig.popitem(last=False)
240+
241+
def _trim_sig(self):
242+
while len(self._sig_to_result) > self._maxsize:
243+
self._sig_to_result.popitem(last=False)
244+
245+
def _trim_ignored(self):
246+
while len(self._ignored_keys) > self._maxsize:
247+
self._ignored_keys.popitem(last=False)
Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,51 @@
1+
# (C) Datadog, Inc. 2026-present
2+
# All rights reserved
3+
# Licensed under a 3-clause BSD style license (see LICENSE)
4+
from __future__ import annotations
5+
6+
import logging
7+
from dataclasses import dataclass
8+
9+
from datadog_checks.base.utils.db.sql import compute_sql_signature
10+
from datadog_checks.base.utils.db.utils import obfuscate_sql_with_metadata
11+
12+
logger = logging.getLogger(__name__)
13+
14+
15+
@dataclass(frozen=True, slots=True)
16+
class ObfuscationResult:
17+
obfuscated_query: str
18+
query_signature: str
19+
tables: list[str] | None
20+
commands: list[str] | None
21+
comments: list[str] | None
22+
23+
24+
def obfuscate_statement(
25+
raw_text: str, obfuscate_options: str, log_unobfuscated_queries: bool = False
26+
) -> ObfuscationResult | None:
27+
"""Obfuscate one statement via the FFI, returning None if it cannot be obfuscated.
28+
29+
Exposed separately from :class:`~.cache.ObfuscationLookup` for statement sources whose identity
30+
does not determine their text, which therefore cannot be cached. MySQL's
31+
``prepared_statements_instances`` is one: it is keyed on a reusable memory address, so a
32+
recycled instance can carry unrelated text and every row has to be obfuscated afresh.
33+
"""
34+
try:
35+
statement = obfuscate_sql_with_metadata(raw_text, obfuscate_options)
36+
except Exception as e:
37+
if log_unobfuscated_queries:
38+
logger.warning("Failed to obfuscate query=[%s] | err=[%s]", raw_text, e)
39+
else:
40+
logger.debug("Failed to obfuscate query | err=[%s]", e)
41+
return None
42+
43+
obfuscated_query = statement['query']
44+
metadata = statement['metadata']
45+
return ObfuscationResult(
46+
obfuscated_query=obfuscated_query,
47+
query_signature=compute_sql_signature(obfuscated_query),
48+
tables=metadata.get('tables', None),
49+
commands=metadata.get('commands', None),
50+
comments=metadata.get('comments', None),
51+
)

0 commit comments

Comments
 (0)