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