chiark / gitweb /
Merge branch 'changelog' of https://gitlab.com/krt/fdroidserver
[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     allmetaapps = [a for a in metadata.read_metadata().itervalues()]
75     metaapps = [a for a in allmetaapps if not a['Disabled']]
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(config['stats_server'], username=config['stats_user'],
96                         timeout=10, 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 not match:
159                         continue
160                     if match.group('statuscode') != '200':
161                         continue
162                     if match.group('ip') in config['stats_ignore']:
163                         continue
164                     uri = match.group('uri')
165                     if not uri.endswith('.apk'):
166                         continue
167                     _, apkname = os.path.split(uri)
168                     app = knownapks.getapp(apkname)
169                     if app:
170                         appid, _ = app
171                         today['apps'][appid] += 1
172                         # Strip the '.apk' from apkname
173                         appver = apkname[:-4]
174                         today['appsver'][appver] += 1
175                     else:
176                         if apkname not in today['unknown']:
177                             today['unknown'].append(apkname)
178
179                 # Save calculated aggregate data for today to cache
180                 with open(agg_path, 'w') as f:
181                     json.dump(today, f)
182
183             # Add today's stats (whether cached or recalculated) to the total
184             for appid in today['apps']:
185                 appscount[appid] += today['apps'][appid]
186             for appid in today['appsver']:
187                 appsvercount[appid] += today['appsver'][appid]
188             for uk in today['unknown']:
189                 if uk not in unknownapks:
190                     unknownapks.append(uk)
191
192         # Calculate and write stats for total downloads...
193         lst = []
194         alldownloads = 0
195         for appid in appscount:
196             count = appscount[appid]
197             lst.append(appid + " " + str(count))
198             if config['stats_to_carbon']:
199                 carbon_send('fdroid.download.' + appid.replace('.', '_'),
200                             count)
201             alldownloads += count
202         lst.append("ALL " + str(alldownloads))
203         f = open('stats/total_downloads_app.txt', 'w')
204         f.write('# Total downloads by application, since October 2011\n')
205         for line in sorted(lst):
206             f.write(line + '\n')
207         f.close()
208
209         f = open('stats/total_downloads_app_version.txt', 'w')
210         f.write('# Total downloads by application and version, '
211                 'since October 2011\n')
212         lst = []
213         for appver in appsvercount:
214             count = appsvercount[appver]
215             lst.append(appver + " " + str(count))
216         for line in sorted(lst):
217             f.write(line + "\n")
218         f.close()
219
220     # Calculate and write stats for repo types...
221     logging.info("Processing repo types...")
222     repotypes = Counter()
223     for app in metaapps:
224         rtype = app['Repo Type'] or 'none'
225         if rtype == 'srclib':
226             rtype = common.getsrclibvcs(app['Repo'])
227         repotypes[rtype] += 1
228     f = open('stats/repotypes.txt', 'w')
229     for rtype, count in repotypes.most_common():
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, count in ucms.most_common():
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, count in ctgs.most_common():
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, count in afs.most_common():
268         f.write(antifeature + ' ' + str(count) + '\n')
269     f.close()
270
271     # Calculate and write stats for licenses...
272     logging.info("Processing licenses...")
273     licenses = Counter()
274     for app in metaapps:
275         license = app['License']
276         licenses[license] += 1
277     f = open('stats/licenses.txt', 'w')
278     for license, count in licenses.most_common():
279         f.write(license + ' ' + str(count) + '\n')
280     f.close()
281
282     # Write list of disabled apps...
283     logging.info("Processing disabled apps...")
284     disabled = [a['id'] for a in allmetaapps if a['Disabled']]
285     f = open('stats/disabled_apps.txt', 'w')
286     for appid in sorted(disabled):
287         f.write(appid + '\n')
288     f.close()
289
290     # Write list of latest apps added to the repo...
291     logging.info("Processing latest apps...")
292     latest = knownapks.getlatest(10)
293     f = open('stats/latestapps.txt', 'w')
294     for appid in latest:
295         f.write(appid + '\n')
296     f.close()
297
298     if unknownapks:
299         logging.info('\nUnknown apks:')
300         for apk in unknownapks:
301             logging.info(apk)
302
303     logging.info("Finished.")
304
305 if __name__ == "__main__":
306     main()