Skip to content

Commit e1501f8

Browse files
authored
Only retrieve updated query statement metrics (#19321)
* Cache digest text for MySQL * WIP * Metric * Fix time * Last seen * WIP * Fixed * Clean * Clean * Feature flag * Changelog * Clean * Handle case of skipped queries between runs * Clean * Clean
1 parent 36642e0 commit e1501f8

File tree

6 files changed

+121
-12
lines changed

6 files changed

+121
-12
lines changed

mysql/assets/configuration/spec.yaml

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -423,6 +423,16 @@ files:
423423
value:
424424
type: number
425425
example: 10
426+
- name: only_query_recent_statements
427+
description: |
428+
Enable querying only for statements that have been run since last collection. This may improve agent
429+
performance and reduce database load. Enabling this option should not alter the total number of query
430+
metrics available.
431+
value:
432+
type: boolean
433+
example: false
434+
display_default: false
435+
426436
- name: query_samples
427437
description: Configure collection of query samples
428438
options:

mysql/changelog.d/19321.added

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
Added an optional optimization to MySQL statement metrics collection to only query for queries that have run since the last check collection.

mysql/datadog_checks/mysql/config_models/instance.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -131,6 +131,7 @@ class QueryMetrics(BaseModel):
131131
)
132132
collection_interval: Optional[float] = None
133133
enabled: Optional[bool] = None
134+
only_query_recent_statements: Optional[bool] = None
134135

135136

136137
class QuerySamples(BaseModel):

mysql/datadog_checks/mysql/data/conf.yaml.example

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -395,6 +395,13 @@ instances:
395395
#
396396
# collection_interval: 10
397397

398+
## @param only_query_recent_statements - boolean - optional - default: false
399+
## Enable querying only for statements that have been run since last collection. This may improve agent
400+
## performance and reduce database load. Enabling this option should not alter the total number of query
401+
## metrics available.
402+
#
403+
# only_query_recent_statements: false
404+
398405
## Configure collection of query samples
399406
#
400407
# query_samples:

mysql/datadog_checks/mysql/statements.py

Lines changed: 92 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -85,12 +85,19 @@ def __init__(self, check, config, connection_args):
8585
self.log = get_check_logger()
8686
self._state = StatementMetrics()
8787
self._obfuscate_options = to_native_string(json.dumps(self._config.obfuscator_options))
88+
# last_seen: the last query execution time seen by the check
89+
# This is used to limit the queries to fetch from the performance schema to only the new ones
90+
self._last_seen = '1970-01-01'
8891
# full_statement_text_cache: limit the ingestion rate of full statement text events per query_signature
8992
self._full_statement_text_cache = TTLCache(
9093
maxsize=self._config.full_statement_text_cache_max_size,
9194
ttl=60 * 60 / self._config.full_statement_text_samples_per_hour_per_query,
9295
) # type: TTLCache
9396

97+
# statement_rows: cache of all rows for each digest, keyed by (schema_name, query_signature)
98+
# This is used to cache the metrics for queries that have the same query_signature but different digests
99+
self._statement_rows = {} # type: Dict[(str, str), Dict[str, PyMysqlRow]]
100+
94101
def _get_db_connection(self):
95102
"""
96103
lazy reconnect db
@@ -111,7 +118,14 @@ def _close_db_conn(self):
111118
self._db = None
112119

113120
def run_job(self):
121+
start = time.time()
114122
self.collect_per_statement_metrics()
123+
self._check.gauge(
124+
"dd.mysql.statement_metrics.collect_metrics.elapsed_ms",
125+
(time.time() - start) * 1000,
126+
tags=self._check.tags + self._check._get_debug_tags(),
127+
hostname=self._check.resolved_hostname,
128+
)
115129

116130
@tracked_method(agent_check_getter=attrgetter('_check'))
117131
def collect_per_statement_metrics(self):
@@ -134,12 +148,14 @@ def collect_per_statement_metrics(self):
134148
)
135149
return
136150

137-
rows = self._collect_per_statement_metrics()
138-
if not rows:
139-
return
140151
# Omit internal tags for dbm payloads since those are only relevant to metrics processed directly
141152
# by the agent
142153
tags = [t for t in self._tags if not t.startswith('dd.internal')]
154+
155+
rows = self._collect_per_statement_metrics(tags)
156+
if not rows:
157+
# No rows to process, can skip the rest of the payload generation and avoid an empty payload
158+
return
143159
for event in self._rows_to_fqt_events(rows, tags):
144160
self._check.database_monitoring_query_sample(json.dumps(event, default=default_json_event_encoding))
145161
payload = {
@@ -156,20 +172,45 @@ def collect_per_statement_metrics(self):
156172
'mysql_rows': rows,
157173
}
158174
self._check.database_monitoring_query_metrics(json.dumps(payload, default=default_json_event_encoding))
159-
self._check.count(
175+
self._check.gauge(
160176
"dd.mysql.collect_per_statement_metrics.rows",
161177
len(rows),
162178
tags=tags + self._check._get_debug_tags(),
163179
hostname=self._check.resolved_hostname,
164180
)
165181

166-
def _collect_per_statement_metrics(self):
167-
# type: () -> List[PyMysqlRow]
182+
def _collect_per_statement_metrics(self, tags):
183+
# type: (List[str]) -> List[PyMysqlRow]
184+
185+
self._get_statement_count(tags)
186+
168187
monotonic_rows = self._query_summary_per_statement()
188+
self._check.gauge(
189+
"dd.mysql.statement_metrics.query_rows",
190+
len(monotonic_rows),
191+
tags=tags + self._check._get_debug_tags(),
192+
hostname=self._check.resolved_hostname,
193+
)
194+
195+
monotonic_rows = self._filter_query_rows(monotonic_rows)
169196
monotonic_rows = self._normalize_queries(monotonic_rows)
197+
monotonic_rows = self._add_associated_rows(monotonic_rows)
170198
rows = self._state.compute_derivative_rows(monotonic_rows, METRICS_COLUMNS, key=_row_key)
171199
return rows
172200

201+
def _get_statement_count(self, tags):
202+
with closing(self._get_db_connection().cursor(CommenterDictCursor)) as cursor:
203+
cursor.execute("SELECT count(*) AS count from performance_schema.events_statements_summary_by_digest")
204+
205+
rows = cursor.fetchall() or [] # type: ignore
206+
if rows:
207+
self._check.gauge(
208+
"dd.mysql.statement_metrics.events_statements_summary_by_digest.total_rows",
209+
rows[0]['count'],
210+
tags=tags + self._check._get_debug_tags(),
211+
hostname=self._check.resolved_hostname,
212+
)
213+
173214
def _query_summary_per_statement(self):
174215
# type: () -> List[PyMysqlRow]
175216
"""
@@ -178,6 +219,14 @@ def _query_summary_per_statement(self):
178219
values to get the counts for the elapsed period. This is similar to monotonic_count, but
179220
several fields must be further processed from the delta values.
180221
"""
222+
only_query_recent_statements = self._config.statement_metrics_config.get('only_query_recent_statements', False)
223+
condition = (
224+
"WHERE `last_seen` >= %s"
225+
if only_query_recent_statements
226+
else """WHERE `digest_text` NOT LIKE 'EXPLAIN %' OR `digest_text` IS NULL
227+
ORDER BY `count_star` DESC
228+
LIMIT 10000"""
229+
)
181230

182231
sql_statement_summary = """\
183232
SELECT `schema_name`,
@@ -193,19 +242,34 @@ def _query_summary_per_statement(self):
193242
`sum_select_scan`,
194243
`sum_select_full_join`,
195244
`sum_no_index_used`,
196-
`sum_no_good_index_used`
245+
`sum_no_good_index_used`,
246+
`last_seen`
197247
FROM performance_schema.events_statements_summary_by_digest
198-
WHERE `digest_text` NOT LIKE 'EXPLAIN %' OR `digest_text` IS NULL
199-
ORDER BY `count_star` DESC
200-
LIMIT 10000"""
248+
{}
249+
""".format(
250+
condition
251+
)
201252

202253
with closing(self._get_db_connection().cursor(CommenterDictCursor)) as cursor:
203-
cursor.execute(sql_statement_summary)
254+
args = [self._last_seen] if only_query_recent_statements else None
255+
cursor.execute(sql_statement_summary, args)
204256

205257
rows = cursor.fetchall() or [] # type: ignore
206258

259+
if rows:
260+
self._last_seen = max(row['last_seen'] for row in rows)
261+
207262
return rows
208263

264+
def _filter_query_rows(self, rows):
265+
# type: (List[PyMysqlRow]) -> List[PyMysqlRow]
266+
"""
267+
Filter out rows that are EXPLAIN statements
268+
"""
269+
return [
270+
row for row in rows if row['digest_text'] is None or not row['digest_text'].lower().startswith('explain')
271+
]
272+
209273
def _normalize_queries(self, rows):
210274
normalized_rows = []
211275
for row in rows:
@@ -227,6 +291,23 @@ def _normalize_queries(self, rows):
227291

228292
return normalized_rows
229293

294+
def _add_associated_rows(self, rows):
295+
"""
296+
If two or more statements with different digests have the same query_signature, they are considered the same
297+
Because only one digest statement may be updated, we cache all the rows for each digest,
298+
update with any new rows and then return all the rows for all the query_signatures.
299+
300+
We return all rows to guard against the case where a signature wasn't collected on the immediately previous run
301+
but was present on runs before that.
302+
"""
303+
for row in rows:
304+
key = (row['schema_name'], row['query_signature'])
305+
if key not in self._statement_rows:
306+
self._statement_rows[key] = {}
307+
self._statement_rows[key][row['digest']] = row
308+
309+
return [row for statement_row in self._statement_rows.values() for row in statement_row.values()]
310+
230311
def _rows_to_fqt_events(self, rows, tags):
231312
for row in rows:
232313
query_cache_key = _row_key(row)

mysql/tests/test_statements.py

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -103,10 +103,19 @@ def test_statement_samples_enabled_config(dbm_instance, statement_samples_key, s
103103
)
104104
@pytest.mark.parametrize("default_schema", [None, "testdb"])
105105
@pytest.mark.parametrize("aurora_replication_role", [None, "writer", "reader"])
106+
@pytest.mark.parametrize("only_query_recent_statements", [False, True])
106107
@mock.patch.dict('os.environ', {'DDEV_SKIP_GENERIC_TAGS_CHECK': 'true'})
107108
def test_statement_metrics(
108-
aggregator, dd_run_check, dbm_instance, query, default_schema, datadog_agent, aurora_replication_role
109+
aggregator,
110+
dd_run_check,
111+
dbm_instance,
112+
query,
113+
default_schema,
114+
datadog_agent,
115+
aurora_replication_role,
116+
only_query_recent_statements,
109117
):
118+
dbm_instance['query_metrics']['only_query_recent_statements'] = only_query_recent_statements
110119
mysql_check = MySql(common.CHECK_NAME, {}, [dbm_instance])
111120

112121
def run_query(q):

0 commit comments

Comments
 (0)