chiark / gitweb /
Merge branch 'buildserver-verbose' into 'master'
[fdroidserver.git] / fdroidserver / stats.py
1 #!/usr/bin/env python3
2 #
3 # stats.py - part of the FDroid server tools
4 # Copyright (C) 2010-13, Ciaran Gultnieks, ciaran@ciarang.com
5 #
6 # This program is free software: you can redistribute it and/or modify
7 # it under the terms of the GNU Affero General Public License as published by
8 # the Free Software Foundation, either version 3 of the License, or
9 # (at your option) any later version.
10 #
11 # This program is distributed in the hope that it will be useful,
12 # but WITHOUT ANY WARRANTY; without even the implied warranty of
13 # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
14 # GNU Affero General Public License for more details.
15 #
16 # You should have received a copy of the GNU Affero General Public License
17 # along with this program.  If not, see <http://www.gnu.org/licenses/>.
18
19 import sys
20 import os
21 import re
22 import time
23 import traceback
24 import glob
25 import json
26 from argparse import ArgumentParser
27 import paramiko
28 import socket
29 import logging
30 import subprocess
31 from collections import Counter
32
33 from . import common
34 from . import metadata
35
36
37 def carbon_send(key, value):
38     s = socket.socket()
39     s.connect((config['carbon_host'], config['carbon_port']))
40     msg = '%s %d %d\n' % (key, value, int(time.time()))
41     s.sendall(msg)
42     s.close()
43
44
45 options = None
46 config = None
47
48
49 def most_common_stable(counts):
50     pairs = []
51     for s in counts:
52         pairs.append((s, counts[s]))
53     return sorted(pairs, key=lambda t: (-t[1], t[0]))
54
55
56 def main():
57
58     global options, config
59
60     # Parse command line...
61     parser = ArgumentParser()
62     common.setup_global_opts(parser)
63     parser.add_argument("-d", "--download", action="store_true", default=False,
64                         help="Download logs we don't have")
65     parser.add_argument("--recalc", action="store_true", default=False,
66                         help="Recalculate aggregate stats - use when changes "
67                         "have been made that would invalidate old cached data.")
68     parser.add_argument("--nologs", action="store_true", default=False,
69                         help="Don't do anything logs-related")
70     metadata.add_metadata_arguments(parser)
71     options = parser.parse_args()
72     metadata.warnings_action = options.W
73
74     config = common.read_config(options)
75
76     if not config['update_stats']:
77         logging.info("Stats are disabled - set \"update_stats = True\" in your config.py")
78         sys.exit(1)
79
80     # Get all metadata-defined apps...
81     allmetaapps = [app for app in metadata.read_metadata().values()]
82     metaapps = [app for app in allmetaapps if not app.Disabled]
83
84     statsdir = 'stats'
85     logsdir = os.path.join(statsdir, 'logs')
86     datadir = os.path.join(statsdir, 'data')
87     if not os.path.exists(statsdir):
88         os.mkdir(statsdir)
89     if not os.path.exists(logsdir):
90         os.mkdir(logsdir)
91     if not os.path.exists(datadir):
92         os.mkdir(datadir)
93
94     if options.download:
95         # Get any access logs we don't have...
96         ssh = None
97         ftp = None
98         try:
99             logging.info('Retrieving logs')
100             ssh = paramiko.SSHClient()
101             ssh.load_system_host_keys()
102             ssh.connect(config['stats_server'], username=config['stats_user'],
103                         timeout=10, key_filename=config['webserver_keyfile'])
104             ftp = ssh.open_sftp()
105             ftp.get_channel().settimeout(60)
106             logging.info("...connected")
107
108             ftp.chdir('logs')
109             files = ftp.listdir()
110             for f in files:
111                 if f.startswith('access-') and f.endswith('.log.gz'):
112
113                     destpath = os.path.join(logsdir, f)
114                     destsize = ftp.stat(f).st_size
115                     if (not os.path.exists(destpath) or
116                             os.path.getsize(destpath) != destsize):
117                         logging.debug("...retrieving " + f)
118                         ftp.get(f, destpath)
119         except Exception:
120             traceback.print_exc()
121             sys.exit(1)
122         finally:
123             # Disconnect
124             if ftp is not None:
125                 ftp.close()
126             if ssh is not None:
127                 ssh.close()
128
129     knownapks = common.KnownApks()
130     unknownapks = []
131
132     if not options.nologs:
133         # Process logs
134         logging.info('Processing logs...')
135         appscount = Counter()
136         appsvercount = Counter()
137         logexpr = '(?P<ip>[.:0-9a-fA-F]+) - - \[(?P<time>.*?)\] ' + \
138             '"GET (?P<uri>.*?) HTTP/1.\d" (?P<statuscode>\d+) ' + \
139             '\d+ "(?P<referral>.*?)" "(?P<useragent>.*?)"'
140         logsearch = re.compile(logexpr).search
141         for logfile in glob.glob(os.path.join(logsdir, 'access-*.log.gz')):
142             logging.debug('...' + logfile)
143
144             # Get the date for this log - e.g. 2012-02-28
145             thisdate = os.path.basename(logfile)[7:-7]
146
147             agg_path = os.path.join(datadir, thisdate + '.json')
148             if not options.recalc and os.path.exists(agg_path):
149                 # Use previously calculated aggregate data
150                 with open(agg_path, 'r') as f:
151                     today = json.load(f)
152
153             else:
154                 # Calculate from logs...
155
156                 today = {
157                     'apps': Counter(),
158                     'appsver': Counter(),
159                     'unknown': []
160                 }
161
162                 p = subprocess.Popen(["zcat", logfile], stdout=subprocess.PIPE)
163                 matches = (logsearch(line) for line in p.stdout)
164                 for match in matches:
165                     if not match:
166                         continue
167                     if match.group('statuscode') != '200':
168                         continue
169                     if match.group('ip') in config['stats_ignore']:
170                         continue
171                     uri = match.group('uri')
172                     if not uri.endswith('.apk'):
173                         continue
174                     _, apkname = os.path.split(uri)
175                     app = knownapks.getapp(apkname)
176                     if app:
177                         appid, _ = app
178                         today['apps'][appid] += 1
179                         # Strip the '.apk' from apkname
180                         appver = apkname[:-4]
181                         today['appsver'][appver] += 1
182                     else:
183                         if apkname not in today['unknown']:
184                             today['unknown'].append(apkname)
185
186                 # Save calculated aggregate data for today to cache
187                 with open(agg_path, 'w') as f:
188                     json.dump(today, f)
189
190             # Add today's stats (whether cached or recalculated) to the total
191             for appid in today['apps']:
192                 appscount[appid] += today['apps'][appid]
193             for appid in today['appsver']:
194                 appsvercount[appid] += today['appsver'][appid]
195             for uk in today['unknown']:
196                 if uk not in unknownapks:
197                     unknownapks.append(uk)
198
199         # Calculate and write stats for total downloads...
200         lst = []
201         alldownloads = 0
202         for appid in appscount:
203             count = appscount[appid]
204             lst.append(appid + " " + str(count))
205             if config['stats_to_carbon']:
206                 carbon_send('fdroid.download.' + appid.replace('.', '_'),
207                             count)
208             alldownloads += count
209         lst.append("ALL " + str(alldownloads))
210         with open(os.path.join(statsdir, 'total_downloads_app.txt'), 'w') as f:
211             f.write('# Total downloads by application, since October 2011\n')
212             for line in sorted(lst):
213                 f.write(line + '\n')
214
215         lst = []
216         for appver in appsvercount:
217             count = appsvercount[appver]
218             lst.append(appver + " " + str(count))
219
220         with open(os.path.join(statsdir, 'total_downloads_app_version.txt'), 'w') as f:
221             f.write('# Total downloads by application and version, '
222                     'since October 2011\n')
223             for line in sorted(lst):
224                 f.write(line + "\n")
225
226     # Calculate and write stats for repo types...
227     logging.info("Processing repo types...")
228     repotypes = Counter()
229     for app in metaapps:
230         rtype = app.RepoType or 'none'
231         if rtype == 'srclib':
232             rtype = common.getsrclibvcs(app.Repo)
233         repotypes[rtype] += 1
234     with open(os.path.join(statsdir, 'repotypes.txt'), 'w') as f:
235         for rtype, count in most_common_stable(repotypes):
236             f.write(rtype + ' ' + str(count) + '\n')
237
238     # Calculate and write stats for update check modes...
239     logging.info("Processing update check modes...")
240     ucms = Counter()
241     for app in metaapps:
242         checkmode = app.UpdateCheckMode
243         if checkmode.startswith('RepoManifest/'):
244             checkmode = checkmode[:12]
245         if checkmode.startswith('Tags '):
246             checkmode = checkmode[:4]
247         ucms[checkmode] += 1
248     with open(os.path.join(statsdir, 'update_check_modes.txt'), 'w') as f:
249         for checkmode, count in most_common_stable(ucms):
250             f.write(checkmode + ' ' + str(count) + '\n')
251
252     logging.info("Processing categories...")
253     ctgs = Counter()
254     for app in metaapps:
255         for category in app.Categories:
256             ctgs[category] += 1
257     with open(os.path.join(statsdir, 'categories.txt'), 'w') as f:
258         for category, count in most_common_stable(ctgs):
259             f.write(category + ' ' + str(count) + '\n')
260
261     logging.info("Processing antifeatures...")
262     afs = Counter()
263     for app in metaapps:
264         if app.AntiFeatures is None:
265             continue
266         for antifeature in app.AntiFeatures:
267             afs[antifeature] += 1
268     with open(os.path.join(statsdir, 'antifeatures.txt'), 'w') as f:
269         for antifeature, count in most_common_stable(afs):
270             f.write(antifeature + ' ' + str(count) + '\n')
271
272     # Calculate and write stats for licenses...
273     logging.info("Processing licenses...")
274     licenses = Counter()
275     for app in metaapps:
276         license = app.License
277         licenses[license] += 1
278     with open(os.path.join(statsdir, 'licenses.txt'), 'w') as f:
279         for license, count in most_common_stable(licenses):
280             f.write(license + ' ' + str(count) + '\n')
281
282     # Write list of disabled apps...
283     logging.info("Processing disabled apps...")
284     disabled = [app.id for app in allmetaapps if app.Disabled]
285     with open(os.path.join(statsdir, 'disabled_apps.txt'), 'w') as f:
286         for appid in sorted(disabled):
287             f.write(appid + '\n')
288
289     # Write list of latest apps added to the repo...
290     logging.info("Processing latest apps...")
291     latest = knownapks.getlatest(10)
292     with open(os.path.join(statsdir, 'latestapps.txt'), 'w') as f:
293         for appid in latest:
294             f.write(appid + '\n')
295
296     if unknownapks:
297         logging.info('\nUnknown apks:')
298         for apk in unknownapks:
299             logging.info(apk)
300
301     logging.info("Finished.")
302
303
304 if __name__ == "__main__":
305     main()