-
Notifications
You must be signed in to change notification settings - Fork 837
New integration for Chrony #2943
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: master
Are you sure you want to change the base?
Changes from 11 commits
b6d360d
6b33b46
b12c057
41902b3
30efa39
bc0eaf7
133f094
b09a197
025b02f
58ab8ce
cd80286
2976d64
b908410
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,7 @@ | ||
| # CHANGELOG - chrony | ||
|
|
||
| ## 1.0.0 / 2026-03-26 | ||
|
|
||
| ***Added*** | ||
|
|
||
| * Initial Release |
| Original file line number | Diff line number | Diff line change | ||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| @@ -0,0 +1,76 @@ | ||||||||||||||||
| # Agent Check: chrony | ||||||||||||||||
|
|
||||||||||||||||
| ## Overview | ||||||||||||||||
|
|
||||||||||||||||
| This check monitors [chrony][1]. | ||||||||||||||||
|
|
||||||||||||||||
| The check executes `chronyc tracking` command and parses the output to extract key time synchronization metrics. It's designed to monitor the health and performance of chrony NTP service. | ||||||||||||||||
|
|
||||||||||||||||
| ## Setup | ||||||||||||||||
|
|
||||||||||||||||
| ### Installation | ||||||||||||||||
|
|
||||||||||||||||
| To install the chrony check for development testing: | ||||||||||||||||
|
|
||||||||||||||||
| 1. Install the [developer toolkit] | ||||||||||||||||
| (https://docs.datadoghq.com/developers/integrations/python/) | ||||||||||||||||
| on any machine. | ||||||||||||||||
| 2. Run `ddev release build chrony` to build the package. | ||||||||||||||||
| 3. [Download the Datadog Agent][2]. | ||||||||||||||||
| 4. Upload the build artifact to a host with an Agent and run: | ||||||||||||||||
| `datadog-agent integration install -w path/to/chrony/dist/<ARTIFACT_NAME>.whl`. | ||||||||||||||||
|
|
||||||||||||||||
| ### Configuration | ||||||||||||||||
|
|
||||||||||||||||
| 1. Ensure `chronyc` is installed on the host where the Agent runs (the check executes `chronyc tracking`). | ||||||||||||||||
| 2. Create the Agent configuration file: | ||||||||||||||||
| - Linux: `/etc/datadog-agent/conf.d/chrony.d/conf.yaml` | ||||||||||||||||
| - macOS (developer mode): `/opt/datadog-agent/etc/conf.d/chrony.d/conf.yaml` | ||||||||||||||||
| 3. Minimal config example: | ||||||||||||||||
| ``` | ||||||||||||||||
| init_config: | ||||||||||||||||
|
|
||||||||||||||||
| instances: | ||||||||||||||||
| - {} | ||||||||||||||||
| ``` | ||||||||||||||||
| 4. For additional options, see the example config: [conf.yaml.example][4]. | ||||||||||||||||
|
|
||||||||||||||||
| ### Validation | ||||||||||||||||
|
|
||||||||||||||||
| Run the Agent check to verify it collects metrics: | ||||||||||||||||
|
|
||||||||||||||||
| ``` | ||||||||||||||||
| datadog-agent check chrony | ||||||||||||||||
| ``` | ||||||||||||||||
|
|
||||||||||||||||
| You should see the service check `chrony.can_connect` report `OK` when the `chronyc` command executes successfully. | ||||||||||||||||
|
|
||||||||||||||||
| ## Data Collected | ||||||||||||||||
|
|
||||||||||||||||
| ### Metrics | ||||||||||||||||
|
|
||||||||||||||||
| - **`chrony.stratum`**: NTP stratum level (unitless) | ||||||||||||||||
| - **`chrony.systime`**: System time offset from NTP time (seconds) | ||||||||||||||||
| - **`chrony.frequency`**: Frequency offset (unitless, ppm) | ||||||||||||||||
| - **`chrony.residualfreq`**: Residual frequency (unitless, ppm) | ||||||||||||||||
| - **`chrony.skew`**: Skew (unitless, ppm) | ||||||||||||||||
| - **`chrony.rootdelay`**: Root delay (seconds) | ||||||||||||||||
| - **`chrony.rootdispersion`**: Root dispersion (seconds) | ||||||||||||||||
|
|
||||||||||||||||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
Suggested change
The integration build process automatically inserts all of the metrics from your metadata.csv file into the docs under this heading. You don't need to include them. |
||||||||||||||||
| ## Service Checks | ||||||||||||||||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
Suggested change
|
||||||||||||||||
|
|
||||||||||||||||
| - **`chrony.can_connect`**: Returns `OK` if chronyc command executes successfully, `CRITICAL` otherwise | ||||||||||||||||
|
|
||||||||||||||||
| ### Events | ||||||||||||||||
|
|
||||||||||||||||
| chrony does not include any events. | ||||||||||||||||
|
|
||||||||||||||||
| ## Troubleshooting | ||||||||||||||||
|
|
||||||||||||||||
| Need help? Contact [Datadog support][3]. | ||||||||||||||||
|
|
||||||||||||||||
| [1]: https://chrony-project.org | ||||||||||||||||
| [2]: https://app.datadoghq.com/account/settings/agent/latest | ||||||||||||||||
| [3]: https://docs.datadoghq.com/help/ | ||||||||||||||||
| [4]: https://github.com/DataDog/integrations-extras/blob/master/chrony/datadog_checks/chrony/data/conf.yaml.example | ||||||||||||||||
|
|
||||||||||||||||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,10 @@ | ||
| name: chrony | ||
| files: | ||
| - name: chrony.yaml | ||
| options: | ||
| - template: init_config | ||
| options: | ||
| - template: init_config/default | ||
| - template: instances | ||
| options: | ||
| - template: instances/default |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,11 @@ | ||
| [ | ||
| { | ||
| "agent_version": "6.0.0", | ||
| "integration": "Chrony", | ||
| "check": "chrony.can_connect", | ||
| "statuses": ["ok", "critical"], | ||
| "groups": [], | ||
| "name": "Chrony can connect", | ||
| "description": "Returns CRITICAL if the chronyc command fails to execute, otherwise OK." | ||
| } | ||
| ] |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1 @@ | ||
| __version__ = '1.0.0' |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,5 @@ | ||
|
|
||
| from .__about__ import __version__ | ||
| from .check import ChronyCheck | ||
|
|
||
| __all__ = ['__version__', 'ChronyCheck'] |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,164 @@ | ||
| #!/usr/bin/env python3 | ||
|
|
||
| import re | ||
| import subprocess | ||
|
|
||
| from datadog_checks.base import AgentCheck | ||
|
|
||
|
|
||
| class ChronyCheck(AgentCheck): | ||
| def __init__(self, name, init_config, instances): | ||
| super(ChronyCheck, self).__init__(name, init_config, instances) | ||
|
|
||
| # Field definitions - using only Datadog supported units | ||
| # Format: fieldname, regex_pattern, description, unit (None for unitless) | ||
| self.fields = [ | ||
| ('stratum', r'^Stratum', 'Stratum', None), # Unitless (level not supported) | ||
| ('systime', r'^System.time', 'System Time', 'second'), | ||
| ('frequency', r'^Frequency', 'Frequency', None), # Unitless (ppm not supported) | ||
| ('residualfreq', r'^Residual.freq', 'Residual Frequency', None), # Unitless (ppm not supported) | ||
| ('skew', r'^Skew', 'Skew', None), # Unitless (ppm not supported) | ||
| ('rootdelay', r'^Root.delay', 'Root delay', 'second'), | ||
| ('rootdispersion', r'^Root.dispersion', 'Root dispersion', 'second'), | ||
| ] | ||
|
|
||
| self.chronyc_path = self._find_chronyc() | ||
|
|
||
| def _find_chronyc(self): | ||
| """Find the chronyc executable path""" | ||
| try: | ||
| result = subprocess.run(['which', 'chronyc'], capture_output=True, text=True, check=True) | ||
| return result.stdout.strip() | ||
| except subprocess.CalledProcessError: | ||
| return None | ||
|
|
||
| def _get_chrony_tracking(self): | ||
| """Execute chronyc tracking command and return output""" | ||
| if not self.chronyc_path: | ||
| raise Exception("chronyc executable not found") | ||
|
|
||
| try: | ||
| result = subprocess.run( | ||
| [self.chronyc_path, 'tracking'], capture_output=True, text=True, check=True, timeout=30 | ||
| ) | ||
| return result.stdout | ||
| except subprocess.CalledProcessError as e: | ||
| raise Exception(f"Failed to execute chronyc tracking: {e}") | ||
| except subprocess.TimeoutExpired: | ||
| raise Exception("chronyc tracking command timed out") | ||
|
|
||
| def _parse_chrony_output(self, output): | ||
| """Parse chronyc tracking output and extract metrics""" | ||
| metrics = {} | ||
|
|
||
| for fieldname, regex_pattern, _description, _unit in self.fields: | ||
| # Find the line matching the regex pattern | ||
| pattern = re.compile(regex_pattern, re.IGNORECASE) | ||
|
|
||
| for line in output.split('\n'): | ||
| if pattern.match(line): | ||
| # Extract the value part after the colon | ||
| parts = line.split(':', 1) | ||
| if len(parts) == 2: | ||
| value_part = parts[1].strip() | ||
|
|
||
| # Parse the numeric value (no scaling factor) | ||
| value = self._extract_numeric_value(value_part) | ||
| if value is not None: | ||
| metrics[fieldname] = value | ||
| break | ||
|
|
||
| return metrics | ||
|
|
||
| def _extract_numeric_value(self, value_part): | ||
| """Extract numeric value from chrony output line""" | ||
| # Handle cases like "0.143763348 seconds slow of NTP time" | ||
| # or "9.733 ppm slow" or "+0.086 ppm" | ||
|
|
||
| # Extract the first number from the string | ||
| number_match = re.search(r'[+-]?\d+\.?\d*', value_part) | ||
| if not number_match: | ||
| return None | ||
|
|
||
| try: | ||
| value = float(number_match.group()) | ||
|
|
||
| # Check if the line contains "slow" which indicates negative values | ||
| if 'slow' in value_part.lower(): | ||
| value = -value | ||
|
|
||
| # Return the value in its original units | ||
| return value | ||
|
|
||
| except ValueError: | ||
| return None | ||
|
|
||
| def _get_reference_info(self, output): | ||
| """Extract reference server information for tagging""" | ||
| reference_info = {} | ||
|
|
||
| for line in output.split('\n'): | ||
| if line.strip().startswith('Reference ID'): | ||
| # Extract reference ID and server name | ||
| parts = line.split(':', 1) | ||
| if len(parts) == 2: | ||
| ref_part = parts[1].strip() | ||
| # Parse format like "89BE0204 (time1.weber.edu)" | ||
| if '(' in ref_part and ')' in ref_part: | ||
| ip = ref_part.split('(')[0].strip() | ||
| server = ref_part.split('(')[1].split(')')[0].strip() | ||
| reference_info['reference_ip'] = ip | ||
| reference_info['reference_server'] = server | ||
| else: | ||
| reference_info['reference_ip'] = ref_part | ||
| break | ||
|
|
||
| return reference_info | ||
|
|
||
| def check(self, instance): | ||
| """Main check method called by Datadog agent""" | ||
| try: | ||
| # Get chrony tracking output | ||
| output = self._get_chrony_tracking() | ||
|
|
||
| # Parse metrics | ||
| metrics = self._parse_chrony_output(output) | ||
|
|
||
| # Get reference server info for tagging | ||
| reference_info = self._get_reference_info(output) | ||
|
|
||
| # Prepare tags | ||
| tags = list(instance.get('tags', [])) if instance else [] | ||
| service = instance.get('service') if instance else None | ||
| if not service: | ||
| service = self.init_config.get('service') | ||
| if service: | ||
| tags.append(f"service:{service}") | ||
| if reference_info.get('reference_server'): | ||
| tags.append(f"reference_server:{reference_info['reference_server']}") | ||
| if reference_info.get('reference_ip'): | ||
| tags.append(f"reference_ip:{reference_info['reference_ip']}") | ||
|
|
||
| # Submit metrics to Datadog with proper units | ||
| for fieldname, value in metrics.items(): | ||
| metric_name = f"chrony.{fieldname}" | ||
|
|
||
| # Find the unit for this metric | ||
| unit = None | ||
| for field_name, _regex_pattern, _description, field_unit in self.fields: | ||
| if field_name == fieldname: | ||
| unit = field_unit | ||
| break | ||
|
|
||
| # Submit metric with unit information | ||
| self.gauge(metric_name, value, tags=tags) | ||
| self.log.debug("Submitted metric %s: %s %s", metric_name, value, unit or '') | ||
|
|
||
| # Submit a service check for chrony availability | ||
| self.service_check('chrony.can_connect', AgentCheck.OK, tags=tags) | ||
|
|
||
| self.log.info("Successfully collected %d chrony metrics", len(metrics)) | ||
|
|
||
| except Exception as e: | ||
| self.log.error("Error collecting chrony metrics: %s", e) | ||
| self.service_check('chrony.can_connect', AgentCheck.CRITICAL, message=str(e)) |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,20 @@ | ||
| # This file is autogenerated. | ||
| # To change this file you should edit assets/configuration/spec.yaml and then run the following commands: | ||
| # ddev -x validate config -s <INTEGRATION_NAME> | ||
| # ddev -x validate models -s <INTEGRATION_NAME> | ||
|
|
||
| from .instance import InstanceConfig | ||
| from .shared import SharedConfig | ||
|
|
||
|
|
||
| class ConfigMixin: | ||
| _config_model_instance: InstanceConfig | ||
| _config_model_shared: SharedConfig | ||
|
|
||
| @property | ||
| def config(self) -> InstanceConfig: | ||
| return self._config_model_instance | ||
|
|
||
| @property | ||
| def shared_config(self) -> SharedConfig: | ||
| return self._config_model_shared |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,20 @@ | ||
| # This file is autogenerated. | ||
| # To change this file you should edit assets/configuration/spec.yaml and then run the following commands: | ||
| # ddev -x validate config -s <INTEGRATION_NAME> | ||
| # ddev -x validate models -s <INTEGRATION_NAME> | ||
|
|
||
|
|
||
| def instance_disable_generic_tags(): | ||
| return False | ||
|
|
||
|
|
||
| def instance_empty_default_hostname(): | ||
| return False | ||
|
|
||
|
|
||
| def instance_enable_legacy_tags_normalization(): | ||
| return True | ||
|
|
||
|
|
||
| def instance_min_collection_interval(): | ||
| return 15 |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Can you make this into a numbered link like the other links in this doc? Numbered links are required for the translation process to work.