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