]> arthur.barton.de Git - netdata.git/blob - python.d/web_log.chart.py
6b66763fb5237012a119009642d861f5881393ec
[netdata.git] / python.d / web_log.chart.py
1 # -*- coding: utf-8 -*-
2 # Description: web log netdata python.d module
3 # Author: l2isbad
4
5 from base import LogService
6 import re
7 import bisect
8 from os import access, R_OK
9 from os.path import getsize
10 from collections import defaultdict, namedtuple
11
12 priority = 60000
13 retries = 60
14
15 ORDER = ['response_codes', 'request_time', 'requests_per_url', 'http_method', 'bandwidth', 'clients_cur', 'clients_all']
16 CHARTS = {
17     'response_codes': {
18         'options': [None, 'Response Codes', 'requests/s', 'responses', 'web_log.response', 'stacked'],
19         'lines': [
20             ['2xx', '2xx', 'absolute'],
21             ['5xx', '5xx', 'absolute'],
22             ['3xx', '3xx', 'absolute'],
23             ['4xx', '4xx', 'absolute'],
24             ['1xx', '1xx', 'absolute'],
25             ['0xx', 'other', 'absolute']
26         ]},
27     'bandwidth': {
28         'options': [None, 'Bandwidth', 'KB/s', 'bandwidth', 'web_log.bw', 'area'],
29         'lines': [
30             ['resp_length', 'received', 'absolute', 1, 1024],
31             ['bytes_sent', 'sent', 'absolute', -1, 1024]
32         ]},
33     'request_time': {
34         'options': [None, 'Processing Time', 'milliseconds', 'timings', 'web_log.request', 'area'],
35         'lines': [
36             ['resp_time_min', 'min', 'absolute', 1, 1],
37             ['resp_time_max', 'max', 'absolute', 1, 1],
38             ['resp_time_avg', 'avg', 'absolute', 1, 1]
39         ]},
40     'clients_cur': {
41         'options': [None, 'Current Poll Unique Client IPs', 'unique ips', 'unique clients', 'web_log.clients', 'line'],
42         'lines': [
43             ['unique_cur', 'clients', 'absolute', 1, 1]
44         ]},
45     'clients_all': {
46         'options': [None, 'All Time Unique Client IPs', 'unique ips', 'unique clients', 'web_log.clients', 'line'],
47         'lines': [
48             ['unique_tot', 'clients', 'absolute', 1, 1]
49         ]},
50     'http_method': {
51         'options': [None, 'Requests Per HTTP Method', 'requests/s', 'requests', 'web_log.http_method', 'stacked'],
52         'lines': [
53         ]}
54 }
55
56 NAMED_URL_PATTERN = namedtuple('URL_PATTERN', ['description', 'pattern'])
57
58
59 class Service(LogService):
60     def __init__(self, configuration=None, name=None):
61         LogService.__init__(self, configuration=configuration, name=name)
62         # Vars from module configuration file
63         self.log_path = self.configuration.get('path')
64         self.detailed_response_codes = self.configuration.get('detailed_response_codes', True)
65         self.all_time = self.configuration.get('all_time', True)
66         self.url_pattern = self.configuration.get('categories')  # dict
67         # REGEX: 1.IPv4 address 2.HTTP method 3. URL 4. Response code
68         # 5. Bytes sent 6. Response length 7. Response process time
69         #self.regex = re.compile(r'(\d{1,3}(?:\.\d{1,3}){3}) .*?"([A-Z]+) (.*?)" ([1-9]\d{2}) (\d+) (\d+)? ?([\d.]+)? ?"(.*?)" "(.*?)"')
70         self.regex = re.compile(r'(\d{1,3}(?:\.\d{1,3}){3}) .*?"([A-Z]+) (.*?)" ([1-9]\d{2}) (\d+) (\d+)? ?([\d.]+)?')
71         # sorted list of unique IPs
72         self.unique_all_time = list()
73         # dict for values that should not be zeroed every poll
74         self.storage = {'unique_tot': 0}
75         # if there is no new logs this dict + self.storage returned to netdata
76         self.data = {'bytes_sent': 0, 'resp_length': 0, 'resp_time_min': 0,
77                      'resp_time_max': 0, 'resp_time_avg': 0, 'unique_cur': 0,
78                      '2xx': 0, '5xx': 0, '3xx': 0, '4xx': 0, '1xx': 0, '0xx': 0}
79
80     def check(self):
81         if not self.log_path:
82             self.error('log path is not specified')
83             return False
84
85         # log_path must be readable
86         if not access(self.log_path, R_OK):
87             self.error('%s not readable or not exist' % self.log_path)
88             return False
89
90         # log_path file should not be empty
91         if not getsize(self.log_path):
92             self.error('%s is empty' % self.log_path)
93             return False
94
95         # Read last line (or first if there is only one line)
96         with open(self.log_path, 'rb') as logs:
97             logs.seek(-2, 2)
98             while logs.read(1) != b'\n':
99                 logs.seek(-2, 1)
100                 if logs.tell() == 0:
101                     break
102             last_line = logs.readline().decode(encoding='utf-8')
103
104         # Parse last line
105         parsed_line = self.regex.findall(last_line)
106         if not parsed_line:
107             self.error('Can\'t parse output')
108             return False
109
110         # parsed_line[0][6] - response process time
111         self.create_charts(parsed_line[0][6])
112         return True
113
114     def create_charts(self, parsed_line):
115         def find_job_name(override_name, name):
116             add_to_name = override_name or name
117             if add_to_name:
118                 return '_'.join(['web_log', add_to_name])
119             else:
120                 return 'web_log'
121
122         job_name = find_job_name(self.override_name, self.name)
123         self.detailed_chart = 'CHART %s.detailed_response_codes ""' \
124                               ' "Response Codes" requests/s responses' \
125                               ' web_log.detailed_resp stacked 1 %s\n' % (job_name, self.update_every)
126         self.http_method_chart = 'CHART %s.http_method' \
127                                  ' "" "HTTP Methods" requests/s requests' \
128                                  ' web_log.http_method stacked 2 %s\n' % (job_name, self.update_every)
129         self.order = ORDER[:]
130         self.definitions = CHARTS
131
132         # Remove 'request_time' chart from ORDER if request_time not in logs
133         if parsed_line == '':
134             self.order.remove('request_time')
135         # Remove 'clients_all' chart from ORDER if specified in the configuration
136         if not self.all_time:
137             self.order.remove('clients_all')
138         # Add 'detailed_response_codes' chart if specified in the configuration
139         if self.detailed_response_codes:
140             self.order.append('detailed_response_codes')
141             self.definitions['detailed_response_codes'] = {'options': [None, 'Detailed Response Codes', 'requests/s',
142                                                                        'responses', 'web_log.detailed_resp', 'stacked'],
143                                                            'lines': []}
144
145         # Add 'requests_per_url' chart if specified in the configuration
146         if self.url_pattern:
147             self.url_pattern = [NAMED_URL_PATTERN(description=k, pattern=re.compile(v)) for k, v in self.url_pattern.items()]
148             self.definitions['requests_per_url'] = {'options': [None, 'Requests Per Url', 'requests/s',
149                                                                 'requests', 'web_log.url_pattern', 'stacked'],
150                                                     'lines': [['other_url', 'other', 'absolute']]}
151             for elem in self.url_pattern:
152                 self.definitions['requests_per_url']['lines'].append([elem.description, elem.description, 'absolute'])
153                 self.data.update({elem.description: 0})
154             self.data.update({'other_url': 0})
155         else:
156             self.order.remove('requests_per_url')
157
158     def add_new_dimension(self, dimension, line_list, chart_string, key):
159         self.storage.update({dimension: 0})
160         # SET method check if dim in _dimensions
161         self._dimensions.append(dimension)
162         # UPDATE method do SET only if dim in definitions
163         self.definitions[key]['lines'].append(line_list)
164         chart = chart_string
165         chart += "%s %s\n" % ('DIMENSION', ' '.join(line_list))
166         print(chart)
167         return chart
168
169     def _get_data(self):
170         """
171         Parse new log lines
172         :return: dict
173         """
174         raw = self._get_raw_data()
175         if raw is None:
176             return None
177
178         request_time, unique_current = list(), list()
179         request_counter = {'count': 0, 'sum': 0}
180         to_netdata = dict()
181         to_netdata.update(self.data)
182         default_dict = defaultdict(lambda: 0)
183
184         for line in raw:
185             match = self.regex.findall(line)
186             if match:
187                 match_dict = dict(zip('address method url code sent resp_length resp_time'.split(), match[0]))
188                 try:
189                     code = ''.join([match_dict['code'][0], 'xx'])
190                     to_netdata[code] += 1
191                 except KeyError:
192                     to_netdata['0xx'] += 1
193                 # detailed response code
194                 if self.detailed_response_codes:
195                     self._get_data_detailed_response_codes(match_dict['code'], default_dict)
196                 # requests per url
197                 if self.url_pattern:
198                     self._get_data_per_url(match_dict['url'], default_dict)
199                 # requests per http method
200                 self._get_data_http_method(match_dict['method'], default_dict)
201
202                 to_netdata['bytes_sent'] += int(match_dict['sent'])
203
204                 if match_dict['resp_length'] != '' and match_dict['resp_time'] != '':
205                     to_netdata['resp_length'] += int(match_dict['resp_length'])
206                     resp_time = float(match_dict['resp_time']) * 1000
207                     bisect.insort_left(request_time, resp_time)
208                     request_counter['count'] += 1
209                     request_counter['sum'] += resp_time
210                 # unique clients ips
211                 if address_not_in_pool(self.unique_all_time, match_dict['address'], self.storage['unique_tot']):
212                     self.storage['unique_tot'] += 1
213                 if address_not_in_pool(unique_current, match_dict['address'], to_netdata['unique_cur']):
214                     to_netdata['unique_cur'] += 1
215         # timings
216         if request_time:
217             to_netdata['resp_time_min'] = request_time[0]
218             to_netdata['resp_time_avg'] = float(request_counter['sum']) / request_counter['count']
219             to_netdata['resp_time_max'] = request_time[-1]
220
221         to_netdata.update(self.storage)
222         to_netdata.update(default_dict)
223         return to_netdata
224
225     def _get_data_detailed_response_codes(self, code, default_dict):
226         if code not in self.storage:
227             chart_string_copy = self.detailed_chart
228             self.detailed_chart = self.add_new_dimension(code, [code, code, 'absolute'],
229                                                          chart_string_copy, 'detailed_response_codes')
230         default_dict[code] += 1
231
232     def _get_data_http_method(self, method, default_dict):
233         if method not in self.storage:
234             chart_string_copy = self.http_method_chart
235             self.http_method_chart = self.add_new_dimension(method, [method, method, 'absolute'],
236                                                             chart_string_copy, 'http_method')
237         default_dict[method] += 1
238
239     def _get_data_per_url(self, url, default_dict):
240         match = None
241         for elem in self.url_pattern:
242             if elem.pattern.search(url):
243                 default_dict[elem.description] += 1
244                 match = True
245                 break
246         if not match:
247             default_dict['other_url'] += 1
248
249
250 def address_not_in_pool(pool, address, pool_size):
251     index = bisect.bisect_left(pool, address)
252     if index < pool_size:
253         if pool[index] == address:
254             return False
255         else:
256             bisect.insort_left(pool, address)
257             return True
258     else:
259         bisect.insort_left(pool, address)
260         return True