chiark / gitweb /
Map apps in memory from appid to appinfo
[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('f-droid.org', username='fdroid', timeout=10,
95                         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 match and match.group('statuscode') == '200':
158                         uri = match.group('uri')
159                         if uri.endswith('.apk'):
160                             _, apkname = os.path.split(uri)
161                             app = knownapks.getapp(apkname)
162                             if app:
163                                 appid, _ = app
164                                 today['apps'][appid] += 1
165                                 # Strip the '.apk' from apkname
166                                 appver = apkname[:-4]
167                                 today['appsver'][appver] += 1
168                             else:
169                                 if apkname not in today['unknown']:
170                                     today['unknown'].append(apkname)
171
172                 # Save calculated aggregate data for today to cache
173                 with open(agg_path, 'w') as f:
174                     json.dump(today, f)
175
176             # Add today's stats (whether cached or recalculated) to the total
177             for appid in today['apps']:
178                 appscount[appid] += today['apps'][appid]
179             for appid in today['appsver']:
180                 appsvercount[appid] += today['appsver'][appid]
181             for uk in today['unknown']:
182                 if uk not in unknownapks:
183                     unknownapks.append(uk)
184
185         # Calculate and write stats for total downloads...
186         lst = []
187         alldownloads = 0
188         for appid in appscount:
189             count = appscount[appid]
190             lst.append(appid + " " + str(count))
191             if config['stats_to_carbon']:
192                 carbon_send('fdroid.download.' + appid.replace('.', '_'),
193                             count)
194             alldownloads += count
195         lst.append("ALL " + str(alldownloads))
196         f = open('stats/total_downloads_app.txt', 'w')
197         f.write('# Total downloads by application, since October 2011\n')
198         for line in sorted(lst):
199             f.write(line + '\n')
200         f.close()
201
202         f = open('stats/total_downloads_app_version.txt', 'w')
203         f.write('# Total downloads by application and version, '
204                 'since October 2011\n')
205         lst = []
206         for appver in appsvercount:
207             count = appsvercount[appver]
208             lst.append(appver + " " + str(count))
209         for line in sorted(lst):
210             f.write(line + "\n")
211         f.close()
212
213     # Calculate and write stats for repo types...
214     logging.info("Processing repo types...")
215     repotypes = Counter()
216     for app in metaapps:
217         rtype = app['Repo Type'] or 'none'
218         if rtype == 'srclib':
219             rtype = common.getsrclibvcs(app['Repo'])
220         repotypes[rtype] += 1
221     f = open('stats/repotypes.txt', 'w')
222     for rtype in repotypes:
223         count = repotypes[rtype]
224         f.write(rtype + ' ' + str(count) + '\n')
225     f.close()
226
227     # Calculate and write stats for update check modes...
228     logging.info("Processing update check modes...")
229     ucms = Counter()
230     for app in metaapps:
231         checkmode = app['Update Check Mode']
232         if checkmode.startswith('RepoManifest/'):
233             checkmode = checkmode[:12]
234         if checkmode.startswith('Tags '):
235             checkmode = checkmode[:4]
236         ucms[checkmode] += 1
237     f = open('stats/update_check_modes.txt', 'w')
238     for checkmode in ucms:
239         count = ucms[checkmode]
240         f.write(checkmode + ' ' + str(count) + '\n')
241     f.close()
242
243     logging.info("Processing categories...")
244     ctgs = Counter()
245     for app in metaapps:
246         for category in app['Categories']:
247             ctgs[category] += 1
248     f = open('stats/categories.txt', 'w')
249     for category in ctgs:
250         count = ctgs[category]
251         f.write(category + ' ' + str(count) + '\n')
252     f.close()
253
254     logging.info("Processing antifeatures...")
255     afs = Counter()
256     for app in metaapps:
257         if app['AntiFeatures'] is None:
258             continue
259         antifeatures = [a.strip() for a in app['AntiFeatures'].split(',')]
260         for antifeature in antifeatures:
261             afs[antifeature] += 1
262     f = open('stats/antifeatures.txt', 'w')
263     for antifeature in afs:
264         count = afs[antifeature]
265         f.write(antifeature + ' ' + str(count) + '\n')
266     f.close()
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     f = open('stats/licenses.txt', 'w')
275     for license in licenses:
276         count = licenses[license]
277         f.write(license + ' ' + str(count) + '\n')
278     f.close()
279
280     # Write list of latest apps added to the repo...
281     logging.info("Processing latest apps...")
282     latest = knownapks.getlatest(10)
283     f = open('stats/latestapps.txt', 'w')
284     for app in latest:
285         f.write(app + '\n')
286     f.close()
287
288     if unknownapks:
289         logging.info('\nUnknown apks:')
290         for apk in unknownapks:
291             logging.info(apk)
292
293     logging.info("Finished.")
294
295 if __name__ == "__main__":
296     main()