chiark / gitweb /
Use Counter objects when processing logs
[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 import common, metadata
31 import subprocess
32 from collections import Counter
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         appscount = Counter()
119         appsvercount = Counter()
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             if options.verbose:
125                 print '...' + logfile
126             p = subprocess.Popen(["zcat", logfile], stdout = subprocess.PIPE)
127             matches = (logsearch(line) for line in p.stdout)
128             for match in matches:
129                 if match and match.group('statuscode') == '200':
130                     uri = match.group('uri')
131                     if uri.endswith('.apk'):
132                         _, apkname = os.path.split(uri)
133                         app = knownapks.getapp(apkname)
134                         if app:
135                             appid, _ = app
136                             appscount[appid] += 1
137                             # Strip the '.apk' from apkname
138                             appver = apkname[:-4]
139                             appsvercount[appver] += 1
140                         else:
141                             if not apkname in unknownapks:
142                                 unknownapks.append(apkname)
143
144         # Calculate and write stats for total downloads...
145         lst = []
146         alldownloads = 0
147         for appid in appscount:
148             count = appscount[appid]
149             lst.append(app + " " + str(count))
150             if config['stats_to_carbon']:
151                 carbon_send('fdroid.download.' + app.replace('.', '_'), count)
152             alldownloads += count
153         lst.append("ALL " + str(alldownloads))
154         f = open('stats/total_downloads_app.txt', 'w')
155         f.write('# Total downloads by application, since October 2011\n')
156         for line in sorted(lst):
157             f.write(line + '\n')
158         f.close()
159
160         f = open('stats/total_downloads_app_version.txt', 'w')
161         f.write('# Total downloads by application and version, since October 2011\n')
162         lst = []
163         for appver in appsvercount:
164             count = appsvercount[appver]
165             lst.append(appver + " " + str(count))
166         for line in sorted(lst):
167             f.write(line + "\n")
168         f.close()
169
170     # Calculate and write stats for repo types...
171     logging.info("Processing repo types...")
172     repotypes = {}
173     for app in metaapps:
174         if len(app['Repo Type']) == 0:
175             rtype = 'none'
176         else:
177             if app['Repo Type'] == 'srclib':
178                 rtype = common.getsrclibvcs(app['Repo'])
179             else:
180                 rtype = app['Repo Type']
181         if rtype in repotypes:
182             repotypes[rtype] += 1;
183         else:
184             repotypes[rtype] = 1
185     f = open('stats/repotypes.txt', 'w')
186     for rtype, count in repotypes.iteritems():
187         f.write(rtype + ' ' + str(count) + '\n')
188     f.close()
189
190     # Calculate and write stats for update check modes...
191     logging.info("Processing update check modes...")
192     ucms = {}
193     for app in metaapps:
194         checkmode = app['Update Check Mode']
195         if checkmode.startswith('RepoManifest/'):
196             checkmode = checkmode[:12]
197         if checkmode.startswith('Tags '):
198             checkmode = checkmode[:4]
199         if checkmode in ucms:
200             ucms[checkmode] += 1;
201         else:
202             ucms[checkmode] = 1
203     f = open('stats/update_check_modes.txt', 'w')
204     for checkmode, count in ucms.iteritems():
205         f.write(checkmode + ' ' + str(count) + '\n')
206     f.close()
207
208     logging.info("Processing categories...")
209     ctgs = {}
210     for app in metaapps:
211         if app['Categories'] is None:
212             continue
213         categories = [c.strip() for c in app['Categories'].split(',')]
214         for category in categories:
215             if category in ctgs:
216                 ctgs[category] += 1;
217             else:
218                 ctgs[category] = 1
219     f = open('stats/categories.txt', 'w')
220     for category, count in ctgs.iteritems():
221         f.write(category + ' ' + str(count) + '\n')
222     f.close()
223
224     logging.info("Processing antifeatures...")
225     afs = {}
226     for app in metaapps:
227         if app['AntiFeatures'] is None:
228             continue
229         antifeatures = [a.strip() for a in app['AntiFeatures'].split(',')]
230         for antifeature in antifeatures:
231             if antifeature in afs:
232                 afs[antifeature] += 1;
233             else:
234                 afs[antifeature] = 1
235     f = open('stats/antifeatures.txt', 'w')
236     for antifeature, count in afs.iteritems():
237         f.write(antifeature + ' ' + str(count) + '\n')
238     f.close()
239
240     # Calculate and write stats for licenses...
241     logging.info("Processing licenses...")
242     licenses = {}
243     for app in metaapps:
244         license = app['License']
245         if license in licenses:
246             licenses[license] += 1;
247         else:
248             licenses[license] = 1
249     f = open('stats/licenses.txt', 'w')
250     for license, count in licenses.iteritems():
251         f.write(license + ' ' + str(count) + '\n')
252     f.close()
253
254     # Write list of latest apps added to the repo...
255     logging.info("Processing latest apps...")
256     latest = knownapks.getlatest(10)
257     f = open('stats/latestapps.txt', 'w')
258     for app in latest:
259         f.write(app + '\n')
260     f.close()
261
262     if unknownapks:
263         logging.info('\nUnknown apks:')
264         for apk in unknownapks:
265             logging.info(apk)
266
267     logging.info("Finished.")
268
269 if __name__ == "__main__":
270     main()
271