Skip to content

Commit c3a948a

Browse files
committed
(SLV-653) Add a script for generating system stats
The script is intended to be used as part of an optionial feature to gate system stats the same way other stats are gathered and managed. It uses sar which will poll every x seconds and generate an average, which can then be captured into a file at the same interval as the puppet-metrics-collector polling interval. This keeps the total data down while not missing things that cause short duration spikes (like compiles that last 6 seconds). Initial use will be in performace testing. Had to disable GetText/DecorateString cop for the entire script in the rubocop.yml because inline disables aren't working for some reason. And rubocop is parsing for ruby 2.1 where the fix works, but the same fix doesn't work with later versions of ruby, like the one installed by puppet...
1 parent 8607626 commit c3a948a

File tree

2 files changed

+259
-0
lines changed

2 files changed

+259
-0
lines changed

.rubocop.yml

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -694,3 +694,6 @@ Style/YodaCondition:
694694
Enabled: false
695695
Style/ZeroLengthPredicate:
696696
Enabled: false
697+
GetText/DecorateString:
698+
Exclude:
699+
- 'files/generate_system_metrics'

files/generate_system_metrics

Lines changed: 256 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,256 @@
1+
#!/opt/puppetlabs/puppet/bin/ruby
2+
# frozen_string_literal: true
3+
4+
# rubocop::disable GetText/DecorateFunctionMessage
5+
6+
require "optparse"
7+
require "json"
8+
require "time"
9+
require "fileutils"
10+
11+
# This script is intended to be run on a puppet infrastructure node as part of the
12+
# puppet_metrics_collector module. It will generate system statistics in the file format that
13+
# other puppet_metrics_collector stats follow. Over a time period defined as the file interval,
14+
# it will poll the system for data based on the polling interval, then calculate an average from
15+
# the polling data to put in the file as a single data point at the end of the file interval.
16+
# sar_metric.pp will setup a cron job to run this similar to how pe_metric runs tk_metrics
17+
#
18+
# Example execution
19+
# generate_system_metrics --metric_type cpu --file_interval 300 --polling_interval 1
20+
# --metrics_dir /opt/puppetlabs/puppet-metrics-collector
21+
22+
# General namespace for SystemMetrics module
23+
module SystemMetrics
24+
# Main class for collecting system metrics into a json file
25+
#
26+
# @author Randell Pelak
27+
#
28+
# @attr [integer] polling_interval Time in seconds between calls to poll the system for data.
29+
# @attr [integer] file_interval Time in seconds between the creation of each output file.
30+
# @attr [string] metric_type cpu|memory
31+
# @attr [string] metrics_dir The puppet_metrics_collector output directory.
32+
# @attr [boolean] verbose Verbose output
33+
# @attr [string] hostname Name of the host the metrics are from. In directory name and json file.
34+
#
35+
class GenerateSystemMetrics
36+
#
37+
# Initialize class
38+
#
39+
# @author Randell Pelak
40+
#
41+
# @param [integer] polling_interval Time in seconds between calls to poll the system for data.
42+
# @param [integer] file_interval Time in seconds between the creation of each output file.
43+
# @param [string] metric_type cpu|memory
44+
# @param [string] metrics_dir The puppet_metrics_collector output directory.
45+
# @param [boolean] verbose Verbose output
46+
#
47+
# @return [void]
48+
def initialize(polling_interval, file_interval, metric_type, metrics_dir, verbose = false)
49+
@polling_interval = polling_interval
50+
@file_interval = file_interval
51+
@metric_type = metric_type
52+
@metrics_dir = metrics_dir
53+
@verbose = verbose
54+
55+
@hostname = %x[hostname].strip
56+
puts "Hostname is: #{@hostname}" if @verbose
57+
FileUtils.mkdir_p(@metrics_dir) unless File.directory?(@metrics_dir)
58+
end
59+
60+
# Run sar to collect the raw system data
61+
#
62+
# @author Randell Pelak
63+
#
64+
# @return [string] raw output from sar
65+
def run_sar
66+
times_to_poll = (@file_interval / @polling_interval).round
67+
# sar inputs are polling interval and how many times to poll
68+
comm_flags = " -r" if @metric_type =~ /memory/
69+
comm = "sar #{comm_flags} #{@polling_interval} #{times_to_poll}"
70+
puts "sar command is: #{comm}" if @verbose
71+
%x[#{comm}]
72+
end
73+
74+
# Parse the sar output and extract the metrics data
75+
#
76+
# @author Randell Pelak
77+
#
78+
# @param [array] sar_output
79+
#
80+
# @raise [RuntimeError] if sar_output doesn't parse correctly
81+
#
82+
# @return [hash] The metrics data
83+
def parse_sar_output(sar_output)
84+
sar_output_arr = sar_output.split(/\n+|\r+/).reject(&:empty?).map { |line| line.split }
85+
86+
if ( @metric_type == "memory")
87+
unique_header_str = "%memused"
88+
else
89+
unique_header_str = "%user"
90+
end
91+
headers_line = sar_output_arr.find { |e| e.include? unique_header_str }
92+
93+
sar_error_missing_headers = "sar output invalid or missing headers." +
94+
"failed to find line with #{unique_header_str}."
95+
"\nFull output:\n#{sar_output}"
96+
raise(sar_error_missing_headers) if headers_line.nil?
97+
98+
averages_line = sar_output_arr.find { |e| e.include? "Average:" }
99+
sar_error_missing_averages = "sar output missing \"Average:\"\nFull output:\n#{sar_output}"
100+
raise(sar_error_missing_averages) if averages_line.nil?
101+
102+
Hash[headers_line.reverse.zip(averages_line.reverse).reverse]
103+
104+
puts "sar headers and averages:\n#{headers_line.join(",")}\n#{averages_line.join(",")}" if @verbose
105+
106+
#example of array data
107+
# 04:59:13,PM,CPU,%user,%nice,%system,%iowait,%steal,%idle
108+
# Average:,all,0.58,0.00,0.08,0.00,0.00,99.33
109+
#combine the arrays into a hash starting from the deal with the unmatched columns in the front
110+
data_hash = Hash[headers_line.reverse.zip(averages_line.reverse).reverse]
111+
# remove anything that doesn't have a number for an average like "Average:" or "all"
112+
data_hash.select{ |k,v| v =~ /\A[-+]?[0-9]*\.?[0-9]+\Z/ }
113+
end
114+
115+
# Convert the inputted sar output into json and raise errors if the sar output is invalid
116+
#
117+
# @author Randell Pelak
118+
#
119+
# @param [string] sar_output
120+
# @param [obj] time_stamp_obj Time object to use for generating the filename
121+
#
122+
# @raise [RuntimeError] if sar_output doesn't parse correctly
123+
#
124+
# @return [string] json of sar output
125+
def convert_sar_output_to_json(sar_output, time_stamp_obj)
126+
hostkey = @hostname.gsub('.', '-')
127+
dataset = {'time_stamp_obj' => time_stamp_obj.utc.iso8601, 'servers' => {}}
128+
metrics_data = parse_sar_output(sar_output)
129+
dataset['servers'][hostkey] = {@metric_type => metrics_data}
130+
json_dataset = JSON.pretty_generate(dataset)
131+
return json_dataset
132+
end
133+
134+
# Create the file and put the json data in it
135+
#
136+
# @author Randell Pelak
137+
#
138+
# @param [string] json_dataset data in json format to put in file
139+
# @param [obj] time_stamp_obj Time object to use for generating the filename
140+
#
141+
# @return [void]
142+
def create_file(json_dataset, time_stamp_obj)
143+
filename = time_stamp_obj.utc.strftime('%Y%m%dT%H%M%SZ') + '.json'
144+
dirname = "#{@metrics_dir}/system_#{@metric_type}/#{@hostname}"
145+
file_path = "#{dirname}/#{filename}"
146+
FileUtils.mkdir_p(dirname) unless File.directory?(dirname)
147+
puts "Creating json file: #{file_path}" if @verbose
148+
File.write(file_path, json_dataset)
149+
end
150+
151+
# Get the data and create the file
152+
#
153+
# @author Randell Pelak
154+
#
155+
# @return [void]
156+
def generate_system_metrics
157+
sar_output = run_sar
158+
# time stamp generated after sar run to be consistent with
159+
# pe metrics from puppet metrics collector
160+
time_stamp_obj = Time.now
161+
json_data = convert_sar_output_to_json(sar_output, time_stamp_obj)
162+
create_file(json_data, time_stamp_obj)
163+
end
164+
end
165+
end
166+
167+
if $PROGRAM_NAME == __FILE__
168+
169+
VALID_METRIC_TYPES = %w[cpu memory]
170+
FILE_INTERVAL_DEFAULT = 60 * 5
171+
POLLING_INTERVAL_DEFAULT = 1
172+
METRIC_TYPE_DEFAULT = "cpu"
173+
METRICS_DIR_DEFAULT = "/opt/puppetlabs/puppet-metrics-collector"
174+
175+
DESCRIPTION = <<-DESCRIPTION
176+
This script is intended to be run on a puppet infrastructure node as part of the
177+
puppet_metrics_collector module. It will generate system statistics in the file format that
178+
other puppet_metrics_collector stats follow. It will poll the system for data at a given
179+
interval, and then output the average to a file once per given file interval.
180+
DESCRIPTION
181+
182+
DEFAULTS = <<-DEFAULTS
183+
The following default values are used if the options are not specified:
184+
* polling_interval (-p, --polling_interval): #{POLLING_INTERVAL_DEFAULT}
185+
* file_interval (-f, --file_interval): #{FILE_INTERVAL_DEFAULT}
186+
* metric_type (-t, --metric_type): #{METRIC_TYPE_DEFAULT}
187+
* metrics_dir (-m, --metrics_dir): #{METRICS_DIR_DEFAULT}
188+
* verbose (-v, --verbose): False
189+
DEFAULTS
190+
191+
options = {}
192+
193+
OptionParser.new do |opts|
194+
opts.banner = "Usage: generate_system_stats.rb [options]"
195+
196+
opts.on("-h", "--help", "Display the help text") do
197+
puts DESCRIPTION
198+
puts opts
199+
puts DEFAULTS
200+
exit
201+
end
202+
203+
opts.on("-p", "--polling_interval seconds", Integer,
204+
"Time in seconds between calls to poll the system for data.") do |interval|
205+
options[:polling_interval] = interval
206+
end
207+
opts.on("-f", "--file_interval seconds", Integer,
208+
"Time in seconds between the creation of each output file.") do |interval|
209+
options[:file_interval] = interval
210+
end
211+
opts.on("-t", "--metric_type type", String,
212+
"One of: #{VALID_METRIC_TYPES.join(', ')}") do |type|
213+
options[:metric_type] = type.downcase
214+
end
215+
opts.on("-m", "--metrics_dir dir_path", String,
216+
"The puppet_metrics_collector output directory") do |metrics_dir|
217+
options[:metrics_dir] = metrics_dir
218+
end
219+
opts.on("-v", "--verbose", String, "Enable Verbose output") { options[:verbose] = true }
220+
end.parse!
221+
222+
if options[:metric_type]
223+
unless VALID_METRIC_TYPES.include?(options[:metric_type])
224+
options_error = "Invalid metric type #{options[:metric_type]}." +
225+
" Must be one of: #{VALID_METRIC_TYPES.join(', ')}."
226+
raise options_error
227+
end
228+
end
229+
230+
polling_interval = options[:polling_interval] || POLLING_INTERVAL_DEFAULT
231+
file_interval = options[:file_interval] || FILE_INTERVAL_DEFAULT
232+
metric_type = options[:metric_type] || METRIC_TYPE_DEFAULT
233+
metrics_dir = options[:metrics_dir] || METRICS_DIR_DEFAULT
234+
verbose = options[:verbose] || false
235+
236+
if options[:polling_interval] || options[:file_interval]
237+
options_error = "Polling interval must be less than file interval"
238+
raise options_error unless polling_interval < file_interval
239+
end
240+
241+
if verbose
242+
OPTION_SETTINGS = <<-SETTINGS
243+
The following are the resulting options settings:
244+
* polling_interval: #{polling_interval}
245+
* file_interval: #{file_interval}
246+
* metric_type: #{metric_type}
247+
* metrics_dir: #{metrics_dir}
248+
* verbose: #{verbose}
249+
SETTINGS
250+
puts OPTION_SETTINGS
251+
end
252+
253+
obj = SystemMetrics::GenerateSystemMetrics.new(polling_interval, file_interval, metric_type,
254+
metrics_dir, verbose)
255+
obj.generate_system_metrics
256+
end

0 commit comments

Comments
 (0)