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