chiark / gitweb /
stats: don't include disabled apps
[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() 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         if len(app['Repo Type']) == 0:
218             rtype = 'none'
219         else:
220             if app['Repo Type'] == 'srclib':
221                 rtype = common.getsrclibvcs(app['Repo'])
222             else:
223                 rtype = app['Repo Type']
224         repotypes[rtype] += 1
225     f = open('stats/repotypes.txt', 'w')
226     for rtype in repotypes:
227         count = repotypes[rtype]
228         f.write(rtype + ' ' + str(count) + '\n')
229     f.close()
230
231     # Calculate and write stats for update check modes...
232     logging.info("Processing update check modes...")
233     ucms = Counter()
234     for app in metaapps:
235         checkmode = app['Update Check Mode']
236         if checkmode.startswith('RepoManifest/'):
237             checkmode = checkmode[:12]
238         if checkmode.startswith('Tags '):
239             checkmode = checkmode[:4]
240         ucms[checkmode] += 1
241     f = open('stats/update_check_modes.txt', 'w')
242     for checkmode in ucms:
243         count = ucms[checkmode]
244         f.write(checkmode + ' ' + str(count) + '\n')
245     f.close()
246
247     logging.info("Processing categories...")
248     ctgs = Counter()
249     for app in metaapps:
250         for category in app['Categories']:
251             ctgs[category] += 1
252     f = open('stats/categories.txt', 'w')
253     for category in ctgs:
254         count = ctgs[category]
255         f.write(category + ' ' + str(count) + '\n')
256     f.close()
257
258     logging.info("Processing antifeatures...")
259     afs = Counter()
260     for app in metaapps:
261         if app['AntiFeatures'] is None:
262             continue
263         antifeatures = [a.strip() for a in app['AntiFeatures'].split(',')]
264         for antifeature in antifeatures:
265             afs[antifeature] += 1
266     f = open('stats/antifeatures.txt', 'w')
267     for antifeature in afs:
268         count = afs[antifeature]
269         f.write(antifeature + ' ' + str(count) + '\n')
270     f.close()
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     f = open('stats/licenses.txt', 'w')
279     for license in licenses:
280         count = licenses[license]
281         f.write(license + ' ' + str(count) + '\n')
282     f.close()
283
284     # Write list of latest apps added to the repo...
285     logging.info("Processing latest apps...")
286     latest = knownapks.getlatest(10)
287     f = open('stats/latestapps.txt', 'w')
288     for app in latest:
289         f.write(app + '\n')
290     f.close()
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()