Skip to content

Commit 4a41086

Browse files
Lustre implementation follow-up (DataDog#20727)
* Handle dict access better * Use `caplog` fixture to test logging * Update IP extraction logic * Add test for malformed IP in param name * Remove useless import * Add changelog * Update lustre/changelog.d/20727.fixed Co-authored-by: NouemanKHAL <noueman.khalikine@datadoghq.com> * Apply suggestions from code review Co-authored-by: NouemanKHAL <noueman.khalikine@datadoghq.com> * Improve logging as suggested in code review * Add debug log for non 0 returncode * Assert at least one filesystem was found in discovery * Separate log tests * Improve handle_ip function logic --------- Co-authored-by: NouemanKHAL <noueman.khalikine@datadoghq.com>
1 parent 94ff783 commit 4a41086

4 files changed

Lines changed: 197 additions & 40 deletions

File tree

lustre/changelog.d/20727.fixed

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
Improve error handling implementation with enhanced debug logs and refined logic

lustre/datadog_checks/lustre/check.py

Lines changed: 64 additions & 37 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44
import re
55
import subprocess
66
from datetime import datetime, timezone
7+
from ipaddress import ip_address
78
from typing import Any, Dict, List, Set, Tuple, Union
89

910
import yaml
@@ -36,20 +37,24 @@ def _get_stat_type(suffix: str, unit: str) -> str:
3637
return 'gauge'
3738

3839

39-
def _handle_ip_in_param(parts: List[str]) -> List[str]:
40+
def _handle_ip_in_param(parts: List[str]) -> Tuple[List[str], bool]:
4041
"""
4142
Merge parameter parts corresponding to an IP address.
4243
4344
Example:
4445
['some','172','0','0','12@tcp','param']
4546
=> ['some','172.0.0.12@tcp', 'param']
4647
"""
47-
match_index = 0
48-
for i, part in enumerate(parts):
49-
if '@' in part:
50-
match_index = i
51-
new_part = ".".join(parts[match_index - 3 : match_index + 1])
52-
return [*parts[: match_index - 3], new_part, *parts[match_index + 1 :]]
48+
match_indexes = [i for i in range(len(parts)) if '@' in parts[i]]
49+
if len(match_indexes) != 1 or match_indexes[0] < 3:
50+
return [], False
51+
index = match_indexes[0]
52+
new_part = ".".join(parts[index - 3 : index + 1])
53+
try:
54+
ip_address(new_part.split('@')[0])
55+
except ValueError:
56+
return [], False
57+
return [*parts[: index - 3], new_part, *parts[index + 1 :]], True
5358

5459

5560
class LustreCheck(AgentCheck):
@@ -160,6 +165,7 @@ def _update_filesystems(self) -> None:
160165
filesystem = match.group(0)
161166
filesystems.append(filesystem)
162167
self.filesystems = list(set(filesystems)) # Remove duplicates
168+
assert self.filesystems, f'Nothing matched regex `{filesystem_regex}` in params {lines}'
163169
self.log.debug('Found filesystem(s): %s', self.filesystems)
164170
except Exception as e:
165171
self.log.error('Failed to find filesystems: %s', e)
@@ -194,6 +200,11 @@ def _run_command(self, bin: str, *args: str, sudo: bool = False) -> str:
194200
try:
195201
self.log.debug('Running command: %s', cmd)
196202
output = subprocess.run(cmd, timeout=5, shell=True, capture_output=True, text=True)
203+
if not output.returncode == 0 and output.stderr:
204+
self.log.debug(
205+
'Command %s exited with returncode %s. Captured stderr: %s', cmd, output.returncode, output.stderr
206+
)
207+
return ''
197208
if output.stdout is None:
198209
self.log.debug(
199210
'Command %s returned no output, check if dd-agent is running\
@@ -218,12 +229,12 @@ def submit_jobstats_metrics(self, filesystems: List[str]) -> None:
218229
device_name = jobstats_param.split('.')[1] # For example: lustre-MDT0000
219230
if not any(device_name.startswith(fs) for fs in filesystems):
220231
continue
221-
jobstats_metrics = self._get_jobstats_metrics(jobstats_param)['job_stats']
232+
jobstats_metrics = self._get_jobstats_metrics(jobstats_param).get('job_stats')
222233
if jobstats_metrics is None:
223234
self.log.debug('No jobstats metrics found for %s', jobstats_param)
224235
continue
225236
for job in jobstats_metrics:
226-
job_id = job['job_id']
237+
job_id = job.get('job_id', "unknown")
227238
tags = self.tags + [f'device_name:{device_name}', f'job_id:{job_id}']
228239
for metric_name, metric_values in job.items():
229240
if not isinstance(metric_values, dict):
@@ -265,15 +276,19 @@ def _get_jobstats_metrics(self, jobstats_param: str) -> Dict[str, Any]:
265276
jobstats_output = self._run_command('lctl', 'get_param', '-ny', jobstats_param, sudo=True)
266277
try:
267278
return yaml.safe_load(jobstats_output) or {}
268-
except KeyError:
269-
self.log.debug('No jobstats metrics found for %s', jobstats_param)
279+
except Exception as e:
280+
self.log.debug('Could not get data for "%s", caught exception: %s', jobstats_param, e)
270281
return {}
271282

272283
def submit_lnet_stats_metrics(self) -> None:
273284
'''
274285
Submit the lnet stats metrics.
275286
'''
276-
lnet_metrics = self._get_lnet_metrics('stats')['statistics']
287+
lnet_metrics = self._get_lnet_metrics('stats')
288+
if 'statistics' not in lnet_metrics:
289+
self.log.debug('Could not find `statistics` property in the output of lnet stats. Output: %s', lnet_metrics)
290+
return
291+
lnet_metrics = lnet_metrics['statistics']
277292
for metric in lnet_metrics:
278293
if metric.endswith('_count') or metric == 'errors':
279294
metric_type = 'count'
@@ -285,7 +300,11 @@ def submit_lnet_local_ni_metrics(self) -> None:
285300
'''
286301
Submit the lnet local ni metrics.
287302
'''
288-
lnet_local_stats = self._get_lnet_metrics('net')['net']
303+
lnet_local_stats = self._get_lnet_metrics('net')
304+
if 'net' not in lnet_local_stats:
305+
self.log.debug('Could not find `net` property in the output of lnet stats. Output: %s', lnet_local_stats)
306+
return
307+
lnet_local_stats = lnet_local_stats['net']
289308
for net in lnet_local_stats:
290309
net_type = net.get('net type')
291310
for ni in net.get('local NI(s)', []):
@@ -302,7 +321,11 @@ def submit_lnet_peer_ni_metrics(self) -> None:
302321
'''
303322
Submit the lnet peer ni metrics.
304323
'''
305-
lnet_peer_stats = self._get_lnet_metrics('peer')['peer']
324+
lnet_peer_stats = self._get_lnet_metrics('peer')
325+
if 'peer' not in lnet_peer_stats:
326+
self.log.debug('Could not find `peer` property in the output of lnet stats. Output: %s', lnet_peer_stats)
327+
return
328+
lnet_peer_stats = lnet_peer_stats['peer']
306329
for peer in lnet_peer_stats:
307330
nid = peer.get('primary nid')
308331
for ni in peer.get('peer ni', []):
@@ -347,8 +370,8 @@ def _get_lnet_metrics(self, stats_type: str = 'stats') -> Dict[str, Any]:
347370
lnet_stats = self._run_command('lnetctl', stats_type, 'show', '-v', self.lnetctl_verbosity, sudo=True)
348371
try:
349372
return yaml.safe_load(lnet_stats) or {}
350-
except (KeyError, ValueError):
351-
self.log.debug('No lnet stats found')
373+
except Exception as e:
374+
self.log.debug('Could not get lnet %s, caught exception: %s', stats_type, e)
352375
return {}
353376

354377
def submit_param_data(self, params: Set[LustreParam], filesystems: List[str]) -> None:
@@ -400,32 +423,36 @@ def _submit_stat(self, prefix: str, name: str, value: Dict[str, Any], tags: List
400423
else:
401424
self.log.debug('Unexpected metric value for %s.%s: %s', name, suffix, metric_value)
402425

403-
def _extract_tags_from_param(self, param_regex: str, param_name: str, wildcards: Tuple[str]) -> List[str]:
426+
def _extract_tags_from_param(self, param_regex: str, param_name: str, wildcards: Tuple[str, ...]) -> List[str]:
404427
'''
405428
Extract tags from the parameter name based on the regex and wildcard meanings.
406429
'''
430+
if not wildcards:
431+
return []
407432
tags = []
408-
if wildcards:
409-
regex_parts = param_regex.split('.')
410-
param_parts = param_name.split('.')
411-
wildcard_number = 0
412-
if not len(regex_parts) == len(param_parts):
413-
# Edge case: mdt.lustre-MDT0000.exports.172.31.16.218@tcp.stats
414-
if any("@" in part for part in param_parts):
415-
# We need to reconstruct the address
416-
param_parts = _handle_ip_in_param(param_parts)
417-
else:
418-
self.log.debug('Parameter name %s does not match regex %s', param_name, param_regex)
433+
regex_parts = param_regex.split('.')
434+
param_parts = param_name.split('.')
435+
wildcard_number = 0
436+
if not len(regex_parts) == len(param_parts):
437+
# Edge case: mdt.lustre-MDT0000.exports.172.31.16.218@tcp.stats
438+
if len(regex_parts) + 3 == len(param_parts):
439+
# We need to reconstruct the address
440+
param_parts, is_valid_ip = _handle_ip_in_param(param_parts)
441+
if not is_valid_ip:
442+
self.log.debug("Skipping tags for parameter %s", param_name)
443+
return []
444+
else:
445+
self.log.debug('Parameter name %s does not match regex %s', param_name, param_regex)
446+
return tags
447+
for part_number, part in enumerate(regex_parts):
448+
if part == '*':
449+
if wildcard_number >= len(wildcards):
450+
self.log.debug(
451+
'Found %s wildcards, which exceeds available wildcard tags %s', wildcard_number, wildcards
452+
)
419453
return tags
420-
for part_number, part in enumerate(regex_parts):
421-
if part == '*':
422-
if wildcard_number >= len(wildcards):
423-
self.log.debug(
424-
'Found %s wildcards, which exceeds available wildcard tags %s', wildcard_number, wildcards
425-
)
426-
return tags
427-
tags.append(f'{wildcards[wildcard_number]}:{param_parts[part_number]}')
428-
wildcard_number += 1
454+
tags.append(f'{wildcards[wildcard_number]}:{param_parts[part_number]}')
455+
wildcard_number += 1
429456
return tags
430457

431458
def _parse_stats(self, raw_stats: str) -> Dict[str, Dict[str, Union[int, str]]]:

lustre/datadog_checks/lustre/constants.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -24,8 +24,8 @@
2424
@dataclass(frozen=True)
2525
class LustreParam:
2626
regex: str
27-
node_types: Tuple[str]
28-
wildcards: Tuple[str] = ()
27+
node_types: Tuple[str, ...]
28+
wildcards: Tuple[str, ...] = ()
2929
prefix: str = ''
3030
fixture: str = ''
3131

lustre/tests/test_unit.py

Lines changed: 130 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,12 +2,14 @@
22
# All rights reserved
33
# Licensed under a 3-clause BSD style license (see LICENSE)
44

5+
import logging
6+
57
import mock
68
import pytest
79

810
from datadog_checks.dev.utils import get_metadata_metrics
911
from datadog_checks.lustre import LustreCheck
10-
from datadog_checks.lustre.constants import CURATED_PARAMS, DEFAULT_STATS, EXTRA_STATS, JOBSTATS_PARAMS
12+
from datadog_checks.lustre.constants import CURATED_PARAMS, DEFAULT_STATS, EXTRA_STATS, JOBSTATS_PARAMS, LustreParam
1113

1214
from .metrics import (
1315
CLIENT_METRICS,
@@ -268,6 +270,10 @@ def test_extract_tags_from_param(mock_lustre_commands):
268270
)
269271
assert tags == ['device_name:lustre-MDT0000', 'nid:172.31.16.218@tcp']
270272

273+
# Test with malformed IP
274+
tags = check._extract_tags_from_param('mdt.*.stats', 'mdt.172.malformed.16.218@tcp.stats', ('nid',))
275+
assert tags == []
276+
271277
# Test with no wildcards
272278
tags = check._extract_tags_from_param('mds.MDS.mdt.stats', 'mds.MDS.mdt.stats', ())
273279
assert tags == []
@@ -473,3 +479,126 @@ def test_run_command_exceptions():
473479
# Test subprocess exception
474480
with mock.patch('subprocess.run', side_effect=Exception("Command failed")):
475481
assert check._run_command('lctl', 'test') == ''
482+
483+
484+
def test_node_type_determination_logging(caplog, mock_lustre_commands):
485+
"""Test logging for node type determination errors."""
486+
mapping = {
487+
'lctl get_param -ny version': 'all_version.txt',
488+
'lctl dl': 'client_dl_yaml.txt',
489+
}
490+
491+
with mock_lustre_commands(mapping):
492+
with caplog.at_level(logging.DEBUG):
493+
with mock.patch.object(LustreCheck, '_update_devices', side_effect=Exception("Device error")):
494+
LustreCheck('lustre', {}, [{}])
495+
496+
log_text = caplog.text
497+
assert 'Failed to determine node type:' in log_text
498+
499+
500+
def test_filesystem_discovery_logging(caplog, mock_lustre_commands):
501+
"""Test logging for filesystem discovery."""
502+
filesystem = 'testfilesystem'
503+
mapping = {
504+
'lctl get_param -ny version': 'all_version.txt',
505+
'lctl dl': 'client_dl_yaml.txt',
506+
'lctl list_param mdt.*.job_stats': f'mdt.{filesystem}-MDT0000.job_stats',
507+
}
508+
509+
with mock_lustre_commands(mapping):
510+
with caplog.at_level(logging.DEBUG):
511+
LustreCheck('lustre', {}, [{'node_type': 'mds'}]).update()
512+
513+
log_text = caplog.text
514+
assert 'Finding filesystems...' in log_text
515+
assert f'Found filesystem(s): [\'{filesystem}\']' in log_text
516+
517+
mapping.update(
518+
{
519+
'lctl list_param mdt.*.job_stats': 'mdt.000.job_stats',
520+
}
521+
)
522+
523+
with mock_lustre_commands(mapping):
524+
with caplog.at_level(logging.DEBUG):
525+
LustreCheck('lustre', {}, [{'node_type': 'mds'}]).update()
526+
527+
log_text = caplog.text
528+
assert 'Finding filesystems...' in log_text
529+
assert 'Failed to find filesystems: Nothing matched regex' in log_text
530+
531+
532+
def test_lnet_error_logging(caplog, mock_lustre_commands):
533+
"""Test logging for LNET data retrieval errors."""
534+
mapping = {
535+
'lctl get_param -ny version': 'all_version.txt',
536+
'lctl dl': 'client_dl_yaml.txt',
537+
'lnetctl stats show': 'invalid yaml',
538+
}
539+
540+
with mock_lustre_commands(mapping):
541+
with caplog.at_level(logging.DEBUG):
542+
check = LustreCheck('lustre', {}, [{}])
543+
with mock.patch('yaml.safe_load', side_effect=Exception("YAML error")):
544+
check._get_lnet_metrics('stats')
545+
546+
log_text = caplog.text
547+
assert 'Could not get lnet stats, caught exception:' in log_text
548+
549+
550+
def test_parameter_filtering_logging(caplog, mock_lustre_commands):
551+
"""Test logging for parameter filtering based on node type and filesystem."""
552+
wrong_filesystem_param = 'some.wrongfilesystem-MDT0000.param.mds'
553+
mapping = {
554+
'lctl get_param -ny version': 'all_version.txt',
555+
'lctl dl': 'client_dl_yaml.txt',
556+
'lctl list_param some.*.param.mds': wrong_filesystem_param,
557+
}
558+
559+
with mock_lustre_commands(mapping):
560+
with caplog.at_level(logging.DEBUG):
561+
check = LustreCheck('lustre', {}, [{'node_type': 'mds'}])
562+
params = {
563+
LustreParam(
564+
regex="some.*.param.client",
565+
node_types=("client",),
566+
wildcards=("device_name",),
567+
prefix="",
568+
fixture="",
569+
),
570+
LustreParam(
571+
regex="some.*.param.mds",
572+
node_types=("mds",),
573+
wildcards=("device_name",),
574+
prefix="",
575+
fixture="",
576+
),
577+
}
578+
check.submit_param_data(params, ['lustre'])
579+
580+
log_text = caplog.text
581+
assert 'Skipping param some.*.param.client for node type mds' in log_text
582+
assert f'Skipping param {wrong_filesystem_param} as it did not match any filesystem' in log_text
583+
584+
585+
def test_changelog_logging(caplog, mock_lustre_commands):
586+
"""Test logging for changelog operations."""
587+
mapping = {
588+
'lctl get_param -ny version': 'all_version.txt',
589+
'lctl dl': 'client_dl_yaml.txt',
590+
'lfs changelog': 'test_changelog',
591+
}
592+
593+
with mock_lustre_commands(mapping):
594+
with caplog.at_level(logging.DEBUG):
595+
check = LustreCheck('lustre', {}, [{}])
596+
597+
# Test changelog cursor error
598+
with mock.patch.object(check, 'get_log_cursor', side_effect=Exception("Cursor error")):
599+
check._get_changelog('lustre-MDT0000', 100)
600+
601+
log_text = caplog.text
602+
assert 'Could not retrieve log cursor, assuming initialization' in log_text
603+
assert 'Fetching changelog from index 0 to 100' in log_text
604+
assert 'Collecting changelogs for:' in log_text

0 commit comments

Comments
 (0)