55import re
66import subprocess
77import time
8+ from dataclasses import dataclass
89from datetime import timedelta
910
1011from datadog_checks .base import AgentCheck , is_affirmative
@@ -51,6 +52,12 @@ def parse_duration(time_str):
5152 return None
5253
5354
55+ @dataclass
56+ class ProcessPidMatch :
57+ host_pid : str
58+ namespace_pids : list [str ]
59+
60+
5461class SlurmCheck (AgentCheck , ConfigMixin ):
5562 # This will be the prefix of every metric and service check the integration sends
5663 __NAMESPACE__ = 'slurm'
@@ -70,6 +77,7 @@ def __init__(self, name, init_config, instances):
7077 self .collect_sacct_stats = is_affirmative (self .instance .get ('collect_sacct_stats' , True ))
7178 self .collect_scontrol_stats = is_affirmative (self .instance .get ('collect_scontrol_stats' , False ))
7279 self .collect_seff_stats = is_affirmative (self .instance .get ('collect_seff_stats' , False ))
80+ self .resolve_scontrol_host_pids = is_affirmative (self .instance .get ('resolve_scontrol_host_pids' , False ))
7381
7482 # Additional configurations
7583 self .gpu_stats = is_affirmative (self .instance .get ('collect_gpu_stats' , False ))
@@ -589,6 +597,9 @@ def process_scontrol(self, output):
589597
590598 # Cache for job details to avoid duplicate calls
591599 job_details_cache = {}
600+ host_pid_matches_by_namespace_pid : dict [str , list [ProcessPidMatch ]] = (
601+ self ._get_host_pid_matches_by_namespace_pid () if self .resolve_scontrol_host_pids else {}
602+ )
592603
593604 for line in lines [1 :]:
594605 tags = [f"slurm_node_name:{ slurm_node .strip ()} " ]
@@ -597,14 +608,17 @@ def process_scontrol(self, output):
597608
598609 for header , value in zip (headers , fields ):
599610 new_header = SCONTROL_TAG_MAPPING .get (header , f"slurm_{ header .lower ()} " )
600- tags .append (f"{ new_header } :{ value } " )
601611
602612 if new_header == "pid" :
603- # Example gpu tags being returned:
604- # ['gpu_vendor:nvidia', 'gpu_device:tesla_v100', 'gpu_uuid:gpu_xxxx...']
605- pidtags = tagger .tag (f"process://{ value } " , tagger .ORCHESTRATOR )
606- if pidtags : # Guard against tagger.tag returning None
607- tags .extend (pidtags )
613+ host_pid_match = self ._resolve_scontrol_host_pid (value , host_pid_matches_by_namespace_pid )
614+ tags .extend (self ._get_process_tags (host_pid_match .host_pid ))
615+ for namespace_pid in host_pid_match .namespace_pids :
616+ if namespace_pid == host_pid_match .host_pid :
617+ continue
618+ tags .append (f"nspid:{ namespace_pid } " )
619+ value = host_pid_match .host_pid
620+
621+ tags .append (f"{ new_header } :{ value } " )
608622
609623 if header == "JOBID" and value .isdigit ():
610624 job_id = value
@@ -617,6 +631,78 @@ def process_scontrol(self, output):
617631
618632 self .gauge ("scontrol.jobs.info" , 1 , tags = tags + self .tags )
619633
634+ def _resolve_scontrol_host_pid (
635+ self , namespace_pid : str , host_pid_matches_by_namespace_pid : dict [str , list [ProcessPidMatch ]]
636+ ) -> ProcessPidMatch :
637+ host_pid_matches = host_pid_matches_by_namespace_pid .get (namespace_pid )
638+ if not host_pid_matches :
639+ return ProcessPidMatch (host_pid = namespace_pid , namespace_pids = [])
640+
641+ if len (host_pid_matches ) > 1 :
642+ namespace_match = host_pid_matches [0 ]
643+ for host_pid_match in host_pid_matches :
644+ # Prefer a translated namespace PID, but do not disambiguate by container yet.
645+ if namespace_pid != host_pid_match .host_pid and namespace_pid in host_pid_match .namespace_pids :
646+ namespace_match = host_pid_match
647+ break
648+
649+ matches = [
650+ {"host_pid" : match .host_pid , "namespace_pids" : match .namespace_pids } for match in host_pid_matches
651+ ]
652+ self .log .debug (
653+ "Found multiple host PID matches for scontrol namespace PID %s: %s. Using host PID %s." ,
654+ namespace_pid ,
655+ matches ,
656+ namespace_match .host_pid ,
657+ )
658+ return namespace_match
659+
660+ return host_pid_matches [0 ]
661+
662+ def _get_process_tags (self , pid ):
663+ # Example gpu tags being returned:
664+ # ['gpu_vendor:nvidia', 'gpu_device:tesla_v100', 'gpu_uuid:gpu_xxxx...']
665+ pidtags = tagger .tag (f"process://{ pid } " , tagger .ORCHESTRATOR )
666+ if pidtags : # Guard against tagger.tag returning None
667+ return pidtags
668+
669+ return []
670+
671+ def _get_host_pid_matches_by_namespace_pid (self ):
672+ host_proc = os .environ .get ("HOST_PROC" , "/host/proc" )
673+ host_pid_matches_by_namespace_pid = {}
674+
675+ try :
676+ proc_entries = os .scandir (host_proc )
677+ except OSError as e :
678+ self .log .debug ("Unable to scan host proc path '%s': %s" , host_proc , e )
679+ return host_pid_matches_by_namespace_pid
680+
681+ with proc_entries :
682+ for entry in proc_entries :
683+ if not entry .name .isdigit ():
684+ continue
685+
686+ pid_match = ProcessPidMatch (
687+ host_pid = entry .name ,
688+ namespace_pids = self ._read_namespace_pids (entry .path , entry .name ),
689+ )
690+ for namespace_pid in pid_match .namespace_pids :
691+ host_pid_matches_by_namespace_pid .setdefault (namespace_pid , []).append (pid_match )
692+
693+ return host_pid_matches_by_namespace_pid
694+
695+ def _read_namespace_pids (self , proc_path , host_pid ):
696+ try :
697+ with open (os .path .join (proc_path , "status" )) as status_file :
698+ for line in status_file :
699+ if line .startswith ("NSpid:" ):
700+ return line .split ()[1 :]
701+ except OSError as e :
702+ self .log .debug ("Unable to read process status for PID %s: %s" , host_pid , e )
703+
704+ return [host_pid ]
705+
620706 def _enrich_scontrol_tags (self , job_id ):
621707 # Tries to enrich the scontrol job with additional details from squeue.
622708 try :
0 commit comments