chiark / gitweb /
Merge branch 'liblzma' into 'master'
[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 - set \"update_stats = True\" in your config.py")
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         with open('stats/total_downloads_app.txt', 'w') as f:
204             f.write('# Total downloads by application, since October 2011\n')
205             for line in sorted(lst):
206                 f.write(line + '\n')
207
208         lst = []
209         for appver in appsvercount:
210             count = appsvercount[appver]
211             lst.append(appver + " " + str(count))
212
213         with open('stats/total_downloads_app_version.txt', 'w') as f:
214             f.write('# Total downloads by application and version, '
215                     'since October 2011\n')
216             for line in sorted(lst):
217                 f.write(line + "\n")
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     with open('stats/repotypes.txt', 'w') as f:
228         for rtype, count in repotypes.most_common():
229             f.write(rtype + ' ' + str(count) + '\n')
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     with open('stats/update_check_modes.txt', 'w') as f:
242         for checkmode, count in ucms.most_common():
243             f.write(checkmode + ' ' + str(count) + '\n')
244
245     logging.info("Processing categories...")
246     ctgs = Counter()
247     for app in metaapps:
248         for category in app['Categories']:
249             ctgs[category] += 1
250     with open('stats/categories.txt', 'w') as f:
251         for category, count in ctgs.most_common():
252             f.write(category + ' ' + str(count) + '\n')
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     with open('stats/antifeatures.txt', 'w') as f:
263         for antifeature, count in afs.most_common():
264             f.write(antifeature + ' ' + str(count) + '\n')
265
266     # Calculate and write stats for licenses...
267     logging.info("Processing licenses...")
268     licenses = Counter()
269     for app in metaapps:
270         license = app['License']
271         licenses[license] += 1
272     with open('stats/licenses.txt', 'w') as f:
273         for license, count in licenses.most_common():
274             f.write(license + ' ' + str(count) + '\n')
275
276     # Write list of disabled apps...
277     logging.info("Processing disabled apps...")
278     disabled = [a['id'] for a in allmetaapps if a['Disabled']]
279     with open('stats/disabled_apps.txt', 'w') as f:
280         for appid in sorted(disabled):
281             f.write(appid + '\n')
282
283     # Write list of latest apps added to the repo...
284     logging.info("Processing latest apps...")
285     latest = knownapks.getlatest(10)
286     with open('stats/latestapps.txt', 'w') as f:
287         for appid in latest:
288             f.write(appid + '\n')
289
290     if unknownapks:
291         logging.info('\nUnknown apks:')
292         for apk in unknownapks:
293             logging.info(apk)
294
295     logging.info("Finished.")
296
297 if __name__ == "__main__":
298     main()