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