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