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