chiark / gitweb /
More logging
[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 from optparse import OptionParser
27 import paramiko
28 import socket
29 import logging
30
31 import common, metadata
32 from common import FDroidPopen
33
34 def carbon_send(key, value):
35     s = socket.socket()
36     s.connect((config['carbon_host'], config['carbon_port']))
37     msg = '%s %d %d\n' % (key, value, int(time.time()))
38     s.sendall(msg)
39     s.close()
40
41 options = None
42 config = None
43
44 def main():
45
46     global options, config
47
48     # Parse command line...
49     parser = OptionParser()
50     parser.add_option("-v", "--verbose", action="store_true", default=False,
51                       help="Spew out even more information than normal")
52     parser.add_option("-d", "--download", action="store_true", default=False,
53                       help="Download logs we don't have")
54     parser.add_option("--nologs", action="store_true", default=False,
55                       help="Don't do anything logs-related")
56     (options, args) = parser.parse_args()
57
58     config = common.read_config(options)
59
60     if not config['update_stats']:
61         logging.info("Stats are disabled - check your configuration")
62         sys.exit(1)
63
64     # Get all metadata-defined apps...
65     metaapps = metadata.read_metadata(options.verbose)
66
67     statsdir = 'stats'
68     logsdir = os.path.join(statsdir, 'logs')
69     datadir = os.path.join(statsdir, 'data')
70     if not os.path.exists(statsdir):
71         os.mkdir(statsdir)
72     if not os.path.exists(logsdir):
73         os.mkdir(logsdir)
74     if not os.path.exists(datadir):
75         os.mkdir(datadir)
76
77     if options.download:
78         # Get any access logs we don't have...
79         ssh = None
80         ftp = None
81         try:
82             logging.info('Retrieving logs')
83             ssh = paramiko.SSHClient()
84             ssh.load_system_host_keys()
85             ssh.connect('f-droid.org', username='fdroid', timeout=10,
86                     key_filename=config['webserver_keyfile'])
87             ftp = ssh.open_sftp()
88             ftp.get_channel().settimeout(60)
89             logging.info("...connected")
90
91             ftp.chdir('logs')
92             files = ftp.listdir()
93             for f in files:
94                 if f.startswith('access-') and f.endswith('.log.gz'):
95
96                     destpath = os.path.join(logsdir, f)
97                     destsize = ftp.stat(f).st_size
98                     if (not os.path.exists(destpath) or
99                             os.path.getsize(destpath) != destsize):
100                         logging.info("...retrieving " + f)
101                         ftp.get(f, destpath)
102         except Exception:
103             traceback.print_exc()
104             sys.exit(1)
105         finally:
106             #Disconnect
107             if ftp is not None:
108                 ftp.close()
109             if ssh is not None:
110                 ssh.close()
111
112     knownapks = common.KnownApks()
113     unknownapks = []
114
115     if not options.nologs:
116         # Process logs
117         logging.info('Processing logs...')
118         apps = {}
119         appsVer = {}
120         logexpr = '(?P<ip>[.:0-9a-fA-F]+) - - \[(?P<time>.*?)\] "GET (?P<uri>.*?) HTTP/1.\d" (?P<statuscode>\d+) \d+ "(?P<referral>.*?)" "(?P<useragent>.*?)"'
121         logsearch = re.compile(logexpr).search
122         for logfile in glob.glob(os.path.join(logsdir,'access-*.log.gz')):
123             logging.info('...' + logfile)
124             p = FDroidPopen(["zcat", logfile])
125             matches = (logsearch(line) for line in p.stdout)
126             for match in matches:
127                 if match and match.group('statuscode') == '200':
128                     uri = match.group('uri')
129                     if not uri.endswith('.apk'):
130                         continue
131                     _, apkname = os.path.split(uri)
132                     app = knownapks.getapp(apkname)
133                     if app:
134                         appid, _ = app
135                         if appid in apps:
136                             apps[appid] += 1
137                         else:
138                             apps[appid] = 1
139                         # Strip the '.apk' from apkname
140                         appVer = apkname[:-4]
141                         if appVer in appsVer:
142                             appsVer[appVer] += 1
143                         else:
144                             appsVer[appVer] = 1
145                     else:
146                         if not apkname in unknownapks:
147                             unknownapks.append(apkname)
148
149         # Calculate and write stats for total downloads...
150         lst = []
151         alldownloads = 0
152         for app, count in apps.iteritems():
153             lst.append(app + " " + str(count))
154             if config['stats_to_carbon']:
155                 carbon_send('fdroid.download.' + app.replace('.', '_'), count)
156             alldownloads += count
157         lst.append("ALL " + str(alldownloads))
158         f = open('stats/total_downloads_app.txt', 'w')
159         f.write('# Total downloads by application, since October 2011\n')
160         for line in sorted(lst):
161             f.write(line + '\n')
162         f.close()
163
164         f = open('stats/total_downloads_app_version.txt', 'w')
165         f.write('# Total downloads by application and version, since October 2011\n')
166         lst = []
167         for appver, count in appsVer.iteritems():
168             lst.append(appver + " " + str(count))
169         for line in sorted(lst):
170             f.write(line + "\n")
171         f.close()
172
173     # Calculate and write stats for repo types...
174     logging.info("Processing repo types...")
175     repotypes = {}
176     for app in metaapps:
177         if len(app['Repo Type']) == 0:
178             rtype = 'none'
179         else:
180             if app['Repo Type'] == 'srclib':
181                 rtype = common.getsrclibvcs(app['Repo'])
182             else:
183                 rtype = app['Repo Type']
184         if rtype in repotypes:
185             repotypes[rtype] += 1;
186         else:
187             repotypes[rtype] = 1
188     f = open('stats/repotypes.txt', 'w')
189     for rtype, count in repotypes.iteritems():
190         f.write(rtype + ' ' + str(count) + '\n')
191     f.close()
192
193     # Calculate and write stats for update check modes...
194     logging.info("Processing update check modes...")
195     ucms = {}
196     for app in metaapps:
197         checkmode = app['Update Check Mode'].split('/')[0]
198         if checkmode in ucms:
199             ucms[checkmode] += 1;
200         else:
201             ucms[checkmode] = 1
202     f = open('stats/update_check_modes.txt', 'w')
203     for checkmode, count in ucms.iteritems():
204         f.write(checkmode + ' ' + str(count) + '\n')
205     f.close()
206
207     logging.info("Processing categories...")
208     ctgs = {}
209     for app in metaapps:
210         if app['Categories'] is None:
211             continue
212         categories = [c.strip() for c in app['Categories'].split(',')]
213         for category in categories:
214             if category in ctgs:
215                 ctgs[category] += 1;
216             else:
217                 ctgs[category] = 1
218     f = open('stats/categories.txt', 'w')
219     for category, count in ctgs.iteritems():
220         f.write(category + ' ' + str(count) + '\n')
221     f.close()
222
223     logging.info("Processing antifeatures...")
224     afs = {}
225     for app in metaapps:
226         if app['AntiFeatures'] is None:
227             continue
228         antifeatures = [a.strip() for a in app['AntiFeatures'].split(',')]
229         for antifeature in antifeatures:
230             if antifeature in afs:
231                 afs[antifeature] += 1;
232             else:
233                 afs[antifeature] = 1
234     f = open('stats/antifeatures.txt', 'w')
235     for antifeature, count in afs.iteritems():
236         f.write(antifeature + ' ' + str(count) + '\n')
237     f.close()
238
239     # Calculate and write stats for licenses...
240     logging.info("Processing licenses...")
241     licenses = {}
242     for app in metaapps:
243         license = app['License']
244         if license in licenses:
245             licenses[license] += 1;
246         else:
247             licenses[license] = 1
248     f = open('stats/licenses.txt', 'w')
249     for license, count in licenses.iteritems():
250         f.write(license + ' ' + str(count) + '\n')
251     f.close()
252
253     # Write list of latest apps added to the repo...
254     logging.info("Processing latest apps...")
255     latest = knownapks.getlatest(10)
256     f = open('stats/latestapps.txt', 'w')
257     for app in latest:
258         f.write(app + '\n')
259     f.close()
260
261     if unknownapks:
262         logging.info('\nUnknown apks:')
263         for apk in unknownapks:
264             logging.info(apk)
265
266     logging.info("Finished.")
267
268 if __name__ == "__main__":
269     main()
270