smartd_log plugin: initial version added
Ilya committed
Feb 1, 2017 at 15:19 UTC
ddca522169be26780475be170564aed1ca107fea
1 file changed
+220
python.d/smartd_log.chart.py
new
+220
@@ -0,0 +1,220 @@
1
+# -*- coding: utf-8 -*-
2
+# Description: smart netdata python.d module
3
+# Author: l2isbad, vorph1
4
+
5
+from re import compile
6
+from os import listdir, access, R_OK
7
+from os.path import isfile, join, getsize, basename
8
+try:
9
+ from queue import Queue
10
+except ImportError:
11
+ from Queue import Queue
12
+from threading import Thread
13
+from base import SimpleService
14
+from collections import namedtuple
15
+
16
+# default module values (can be overridden per job in `config`)
17
+update_every = 5
18
+priority = 60000
19
+
20
+# charts order (can be overridden if you want less charts, or different order)
21
+ORDER = ['1', '4', '5', '7', '9', '12', '193', '194', '197', '198', '200']
22
+
23
+SMART_ATTR = {
24
+ '1': 'Read Error Rate',
25
+ '2': 'Throughput Performance',
26
+ '3': 'Spin-Up Time',
27
+ '4': 'Start/Stop Count',
28
+ '5': 'Reallocated Sectors Count',
29
+ '6': 'Read Channel Margin',
30
+ '7': 'Seek Error Rate',
31
+ '8': 'Seek Time Performance',
32
+ '9': 'Power-On Hours Count',
33
+ '10': 'Spin-up Retries',
34
+ '11': 'Calibration Retries',
35
+ '12': 'Power Cycle Count',
36
+ '13': 'Soft Read Error Rate',
37
+ '100': 'Erase/Program Cycles',
38
+ '103': 'Translation Table Rebuild',
39
+ '108': 'Unknown (108)',
40
+ '170': 'Reserved Block Count',
41
+ '171': 'Program Fail Count',
42
+ '172': 'Erase Fail Count',
43
+ '173': 'Wear Leveller Worst Case Erase Count',
44
+ '174': 'Unexpected Power Loss',
45
+ '175': 'Program Fail Count',
46
+ '176': 'Erase Fail Count',
47
+ '177': 'Wear Leveling Count',
48
+ '178': 'Used Reserved Block Count',
49
+ '179': 'Used Reserved Block Count',
50
+ '180': 'Unused Reserved Block Count',
51
+ '181': 'Program Fail Count',
52
+ '182': 'Erase Fail Count',
53
+ '183': 'SATA Downshifts',
54
+ '184': 'End-to-End error',
55
+ '185': 'Head Stability',
56
+ '186': 'Induced Op-Vibration Detection',
57
+ '187': 'Reported Uncorrectable Errors',
58
+ '188': 'Command Timeout',
59
+ '189': 'High Fly Writes',
60
+ '190': 'Temperature',
61
+ '191': 'G-Sense Errors',
62
+ '192': 'Power-Off Retract Cycles',
63
+ '193': 'Load/Unload Cycles',
64
+ '194': 'Temperature',
65
+ '195': 'Hardware ECC Recovered',
66
+ '196': 'Reallocation Events',
67
+ '197': 'Current Pending Sectors',
68
+ '198': 'Off-line Uncorrectable',
69
+ '199': 'UDMA CRC Error Rate',
70
+ '200': 'Write Error Rate',
71
+ '201': 'Soft Read Errors',
72
+ '202': 'Data Address Mark Errors',
73
+ '203': 'Run Out Cancel',
74
+ '204': 'Soft ECC Corrections',
75
+ '205': 'Thermal Asperity Rate',
76
+ '206': 'Flying Height',
77
+ '207': 'Spin High Current',
78
+ '209': 'Offline Seek Performance',
79
+ '220': 'Disk Shift',
80
+ '221': 'G-Sense Error Rate',
81
+ '222': 'Loaded Hours',
82
+ '223': 'Load/Unload Retries',
83
+ '224': 'Load Friction',
84
+ '225': 'Load/Unload Cycles',
85
+ '226': 'Load-in Time',
86
+ '227': 'Torque Amplification Count',
87
+ '228': 'Power-Off Retracts',
88
+ '230': 'GMR Head Amplitude',
89
+ '231': 'Temperature',
90
+ '232': 'Available Reserved Space',
91
+ '233': 'Media Wearout Indicator',
92
+ '240': 'Head Flying Hours',
93
+ '241': 'Total LBAs Written',
94
+ '242': 'Total LBAs Read',
95
+ '250': 'Read Error Retry Rate'
96
+}
97
+
98
+NAMED_DISKS = namedtuple('disks', ['name', 'size', 'number'])
99
+
100
+
101
+class Service(SimpleService):
102
+ def __init__(self, configuration=None, name=None):
103
+ SimpleService.__init__(self, configuration=configuration, name=name)
104
+ self.regex = compile(r'(\d+);(\d+);(\d+)')
105
+ self.log_path = self.configuration.get('log_path', '/var/log/smartd')
106
+ self.raw_values = self.configuration.get('raw_values')
107
+ self.attr = self.configuration.get('smart_attributes', [])
108
+ self.previous_data = dict()
109
+
110
+ def check(self):
111
+ # Can\'t start without smartd readable diks log files
112
+ disks = find_disks_in_log_path(self.log_path)
113
+ if not disks:
114
+ self.error('Can\'t locate any smartd log files in %s' % self.log_path)
115
+ return False
116
+
117
+ # List of namedtuples to track smartd log file size
118
+ self.disks = [NAMED_DISKS(name=disks[i], size=0, number=i) for i in range(len(disks))]
119
+
120
+ if self._get_data():
121
+ self.create_charts()
122
+ return True
123
+ else:
124
+ self.error('Can\'t collect any data. Sorry.')
125
+ return False
126
+
127
+ def _get_raw_data(self, queue, disk):
128
+ # The idea is to open a file.
129
+ # Jump to the end.
130
+ # Seek backward until '\n' symbol appears
131
+ # If '\n' is found or it's the beginning of the file
132
+ # readline()! (last or first line)
133
+ with open(disk, 'rb') as f:
134
+ f.seek(-2, 2)
135
+ while f.read(1) != b'\n':
136
+ f.seek(-2, 1)
137
+ if f.tell() == 0:
138
+ break
139
+ result = f.readline()
140
+
141
+ result = result.decode(encoding='utf-8')
142
+ result = self.regex.findall(result)
143
+
144
+ queue.put([basename(disk), result])
145
+
146
+ def _get_data(self):
147
+ threads, result = list(), list()
148
+ queue = Queue()
149
+ to_netdata = dict()
150
+
151
+ # If the size has not changed there is no reason to poll log files.
152
+ disks = [disk for disk in self.disks if self.size_changed(disk)]
153
+ if disks:
154
+ for disk in disks:
155
+ th = Thread(target=self._get_raw_data, args=(queue, disk.name))
156
+ th.start()
157
+ threads.append(th)
158
+
159
+ for thread in threads:
160
+ thread.join()
161
+ result.append(queue.get())
162
+ else:
163
+ # Data from last real poll
164
+ return self.previous_data or None
165
+
166
+ for elem in result:
167
+ for a, n, r in elem[1]:
168
+ to_netdata.update({'_'.join([elem[0], a]): r if self.raw_values else n})
169
+
170
+ self.previous_data.update(to_netdata)
171
+
172
+ return to_netdata or None
173
+
174
+ def size_changed(self, disk):
175
+ # We are not interested in log files:
176
+ # 1. zero size
177
+ # 2. size is not changed since last poll
178
+ try:
179
+ size = getsize(disk.name)
180
+ if size != disk.size and size:
181
+ self.disks[disk.number] = disk._replace(size=size)
182
+ return True
183
+ else:
184
+ return False
185
+ except OSError:
186
+ # Remove unreadable/nonexisting log files from list of disks and previous_data
187
+ self.disks.remove(disk)
188
+ self.previous_data = {k: v for k, v in self.previous_data.items() if basename(disk.name) not in k}
189
+ return False
190
+
191
+ def create_charts(self):
192
+
193
+ def create_lines(attrid):
194
+ result = list()
195
+ for disk in self.disks:
196
+ name = basename(disk.name)
197
+ result.append(['_'.join([name, attrid]), name[:name.index('.')], 'absolute'])
198
+ return result
199
+
200
+ # Add additional smart attributes to the ORDER. If something goes wrong we don't care.
201
+ try:
202
+ ORDER.extend(list(set(self.attr.split()) & SMART_ATTR.keys() - set(ORDER)))
203
+ except Exception:
204
+ pass
205
+ self.order = [''.join(['attrid', i]) for i in ORDER]
206
+ self.definitions = dict()
207
+ units = 'raw' if self.raw_values else 'normalized'
208
+
209
+ for k, v in {k: v for k, v in SMART_ATTR.items() if k in ORDER}.items():
210
+ self.definitions.update({''.join(['attrid', k]): {
211
+ 'options': [None, v, units, v, 'smartd.attrid' + k, 'line'],
212
+ 'lines': create_lines(k)}})
213
+
214
+def find_disks_in_log_path(log_path):
215
+ # smartd log file is OK if:
216
+ # 1. it is a file
217
+ # 2. file name endswith with 'csv'
218
+ # 3. file is readable
219
+ return [join(log_path, f) for f in listdir(log_path)
220
+ if all([isfile(join(log_path, f)), f.endswith('.csv'), access(join(log_path, f), R_OK)])]