├── .gitignore ├── client ├── audience.swf ├── Audience.as └── jquery.audience.js ├── package.json ├── lib ├── utils.js ├── stats.js ├── notification.js ├── single-stats.js ├── demo.js ├── master.js ├── hadoop-stats.js ├── subscribers.js ├── worker.js ├── cluster.js └── audience.js ├── demo.html ├── bench.js ├── audience-meter.js └── README.md /.gitignore: -------------------------------------------------------------------------------- 1 | flash-client/ 2 | node_modules/ 3 | -------------------------------------------------------------------------------- /client/audience.swf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/hc/audience-meter/master/client/audience.swf -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "audience-meter", 3 | "version": "2.2.0", 4 | "engines": 5 | { 6 | "node": "~0.10.0" 7 | }, 8 | "dependencies": 9 | { 10 | "commander": "2.1.0", 11 | "node-uuid": "1.4.1", 12 | "msgpack": "0.2.2" 13 | } 14 | } 15 | -------------------------------------------------------------------------------- /lib/utils.js: -------------------------------------------------------------------------------- 1 | exports.merge = function() 2 | { 3 | var result = {}; 4 | 5 | Array.prototype.forEach.call(arguments, function(obj) 6 | { 7 | for (var name in obj) 8 | { 9 | if (obj.hasOwnProperty(name)) 10 | { 11 | result[name] = obj[name]; 12 | } 13 | } 14 | }); 15 | 16 | return result; 17 | }; -------------------------------------------------------------------------------- /lib/stats.js: -------------------------------------------------------------------------------- 1 | var net = require('net'), 2 | merge = require('./utils').merge; 3 | 4 | exports.StatsServer = StatsServer; 5 | 6 | function StatsServer(options) 7 | { 8 | if (!(this instanceof StatsServer)) return new StatsServer(options); 9 | 10 | options = merge 11 | ({ 12 | audience: null, 13 | port: 8080 14 | }, options); 15 | 16 | var server = net.Server(); 17 | server.listen(options.port, 'localhost'); 18 | server.on('connection', function(sock) 19 | { 20 | sock.write(JSON.stringify(options.audience.stats())); 21 | sock.end(); 22 | }); 23 | } -------------------------------------------------------------------------------- /demo.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | Connected users to {namespace}: - 4 | 5 | 6 | 12 |
13 | <script src="http://cdn.sockjs.org/sockjs-0.1.min.js"></script>
14 | <script>
15 | $.audience('http://{hostname}/{namespace}').progress(function(data)
16 | {
17 |     document.getElementById("total").innerHTML = data.total;
18 | });
19 | </script>
20 | 
21 | 22 | -------------------------------------------------------------------------------- /lib/notification.js: -------------------------------------------------------------------------------- 1 | var http = require('http'), 2 | url = require('url'), 3 | merge = require('./utils').merge; 4 | 5 | exports.NotificationServer = NotificationServer; 6 | 7 | function NotificationServer(options) 8 | { 9 | if (!(this instanceof NotificationServer)) return new NotificationServer(options); 10 | 11 | options = merge 12 | ({ 13 | audience: null, 14 | port: 8080 15 | }, options); 16 | 17 | var server = http.Server(); 18 | server.listen(options.port); 19 | server.on('request', function(req, res) 20 | { 21 | var pathname = url.parse(req.url, true).pathname; 22 | var path2ns = /^\/([^\/]+)(?:\/([^\/]+))(?:\/([^\/]*))$/; 23 | if ((pathInfo = pathname.match(path2ns))) 24 | { 25 | var namespace = pathInfo[1], 26 | serviceName = pathInfo[2], 27 | msg = pathInfo[3]; 28 | options.audience.sendMessage(namespace, serviceName + '=' + msg); 29 | } 30 | res.writeHead(200, {'Content-Type': 'text/html'}); 31 | res.end(); 32 | }); 33 | } 34 | -------------------------------------------------------------------------------- /lib/single-stats.js: -------------------------------------------------------------------------------- 1 | var http = require('http'), 2 | url = require('url'), 3 | merge = require('./utils').merge; 4 | 5 | exports.SingleStatsServer = SingleStatsServer; 6 | 7 | function SingleStatsServer(options) 8 | { 9 | if (!(this instanceof SingleStatsServer)) return new SingleStatsServer(options); 10 | 11 | options = merge 12 | ({ 13 | audience: null, 14 | port: 8080 15 | }, options); 16 | 17 | var server = http.Server(); 18 | server.listen(options.port); 19 | server.on('request', function(req, res) 20 | { 21 | var pathname = url.parse(req.url, true).pathname; 22 | var path2ns = /^\/([^\/]+)?$/; 23 | res.writeHead(200, {'Content-Type': 'text/html'}); 24 | if ((pathInfo = pathname.match(path2ns))) 25 | { 26 | var key = '@' + pathInfo[1]; 27 | if (key == '@total') 28 | { 29 | res.write(options.audience.get_total_audience().toString()); 30 | } 31 | else 32 | { 33 | var data = options.audience.get_audience(key); 34 | if (data) 35 | { 36 | res.write(JSON.stringify(data)); 37 | } 38 | } 39 | } 40 | res.end(); 41 | }); 42 | } 43 | -------------------------------------------------------------------------------- /lib/demo.js: -------------------------------------------------------------------------------- 1 | var http = require('http'), 2 | url = require('url'), 3 | fs = require('fs'), 4 | merge = require('./utils').merge; 5 | 6 | exports.DemoServer = DemoServer; 7 | 8 | function DemoServer(options) 9 | { 10 | if (!(this instanceof DemoServer)) return new DemoServer(options); 11 | 12 | options = merge({port: 8080}, options); 13 | 14 | var server = http.Server(); 15 | server.listen(options.port); 16 | server.on('request', function(req, res) 17 | { 18 | var path = url.parse(req.url, true).pathname; 19 | 20 | if (path == '/') 21 | { 22 | res.writeHead(200, {'Content-Type': 'text/html'}); 23 | res.end('Welcome to audience-meter. Try an event.'); 24 | } 25 | else if(/^[a-z.\/]+\.(js|swf)$/.test(path)) 26 | { 27 | if (/\.js$/.test(path)) 28 | { 29 | res.writeHead(200, {'Content-Type': 'application/javascript'}); 30 | } 31 | else 32 | { 33 | res.writeHead(200, {'Content-Type': 'application/x-shockwave-flash'}); 34 | } 35 | fs.readFile('./' + path, function (err, data) 36 | { 37 | res.end(data); 38 | }); 39 | } 40 | else 41 | { 42 | res.writeHead(200, {'Content-Type': 'text/html'}); 43 | fs.readFile('./demo.html', function (err, data) 44 | { 45 | res.end(data.toString() 46 | .replace(/\{hostname\}/g, req.headers.host.split(':')[0]) 47 | .replace(/\{namespace\}/g, path.replace(/^\/|\/.*/g, ''))); 48 | }); 49 | } 50 | }); 51 | } -------------------------------------------------------------------------------- /lib/master.js: -------------------------------------------------------------------------------- 1 | var cluster = require('cluster'); 2 | 3 | exports.Master = Master; 4 | 5 | function Master(options) 6 | { 7 | if (!(this instanceof Master)) return new Master(options); 8 | 9 | if (!options.workers) 10 | { 11 | options.workers = require('os').cpus().length; 12 | } 13 | 14 | var eachWorker = function(callback) 15 | { 16 | for (var id in cluster.workers) 17 | { 18 | callback(cluster.workers[id]); 19 | } 20 | }; 21 | 22 | cluster.on('online', function(worker) 23 | { 24 | worker.on('message', function(msg) 25 | { 26 | switch (msg.cmd) 27 | { 28 | case 'join': 29 | options.audience.join(msg.namespace); 30 | break; 31 | case 'leave': 32 | options.audience.leave(msg.namespace); 33 | break; 34 | 35 | case 'exclude': 36 | eachWorker(function(otherWorker) 37 | { 38 | if (worker !== otherWorker) 39 | { 40 | otherWorker.send(msg); 41 | } 42 | }); 43 | // TODO: instruct other peers of same UDP multicast segment if cluster is activated 44 | break; 45 | } 46 | }); 47 | }); 48 | 49 | cluster.on('exit', function(worker, code, signal) 50 | { 51 | if (worker.suicide === true) 52 | { 53 | return; 54 | } 55 | 56 | options.log('warn', 'Respawn worker'); 57 | cluster.fork(); 58 | }); 59 | 60 | for (var i = 0; i < options.workers; i++) 61 | { 62 | cluster.fork(); 63 | } 64 | 65 | options.audience.on('notify', function(namespace, msg) 66 | { 67 | eachWorker(function(worker) 68 | { 69 | if (typeof msg == 'undefined') 70 | { 71 | msg = namespace.members; 72 | } 73 | 74 | worker.send({cmd: 'notify', namespace: namespace.name, msg: msg}); 75 | }); 76 | }); 77 | 78 | process.on('SIGTERM', function() 79 | { 80 | eachWorker(function(worker) 81 | { 82 | options.log('debug', 'Disconnect worker ' + worker.id); 83 | worker.kill(); 84 | }); 85 | process.exit(); 86 | }); 87 | } -------------------------------------------------------------------------------- /bench.js: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env node 2 | 3 | var options = require('commander'), 4 | http = require('http'), 5 | util = require('util'); 6 | 7 | options 8 | .option('-H, --host ', 'The host to connect to (default localhost)', String, '127.0.0.1') 9 | .option('-e, --concurrent-events ', 'Number of concurrent events to create', parseInt, 10) 10 | .option('-c, --concurrent-clients ', 'Number of concurrent clients', parseInt, 1000) 11 | .option('-p, --event-prefix ', 'String to prefix to generated event names', String, 'bench') 12 | .option('-s, --speed ', 'Number of approx milliseconds to wait between connections', parseInt, 100) 13 | .parse(process.argv); 14 | 15 | 16 | var started = 0, 17 | connected = 0, 18 | ended = 0, 19 | misses = 0, 20 | messages = 0, 21 | errors = 0, 22 | eventNames = []; 23 | 24 | for (var i = 0; i <= options.concurrentEvents; i++) 25 | { 26 | eventNames.push(options.eventPrefix + i); 27 | } 28 | 29 | spawnClient(); 30 | 31 | setInterval(function() 32 | { 33 | process.stdout.write(util.format('Started: %d, connected: %d, closed: %d, errors: %d, missed: %d, msgs: %d\r', 34 | started, connected, ended, errors, misses, messages)); 35 | }, 500); 36 | 37 | function spawnClient(eventName) 38 | { 39 | if (!eventName) 40 | { 41 | eventName = eventNames[Math.floor(Math.random() * options.concurrentEvents)]; 42 | } 43 | 44 | var client = http.get 45 | ({ 46 | host: options.host, 47 | port: 80, 48 | path: '/' + eventName, 49 | headers: {Accept: 'text/event-stream'}, 50 | agent: false 51 | }); 52 | var interval = setInterval(function() 53 | { 54 | if (new Date().getTime() - client.lastMessage > 60000) 55 | { 56 | misses++; 57 | } 58 | }, 60000); 59 | client.on('error', function() 60 | { 61 | started--; 62 | errors++; 63 | }); 64 | client.on('response', function(res) 65 | { 66 | connected++; 67 | res.on('data', function(data) 68 | { 69 | client.lastMessage = new Date().getTime(); 70 | messages++; 71 | }); 72 | res.on('end', function() 73 | { 74 | clearInterval(interval); 75 | started--; 76 | connected--; 77 | ended++; 78 | // reconnect 79 | spawnClient(eventName); 80 | }); 81 | }); 82 | 83 | 84 | if (++started < options.concurrentClients) 85 | { 86 | setTimeout(spawnClient, Math.random() * options.speed); 87 | } 88 | } -------------------------------------------------------------------------------- /lib/hadoop-stats.js: -------------------------------------------------------------------------------- 1 | var http = require('http'), 2 | url = require('url'), 3 | merge = require('./utils').merge; 4 | 5 | exports.HadoopStatsServer = HadoopStatsServer; 6 | 7 | function HadoopStatsServer(options) 8 | { 9 | if (!(this instanceof HadoopStatsServer)) return new HadoopStatsServer(options); 10 | 11 | options = merge 12 | ({ 13 | audience: null, 14 | port: 8080, 15 | hostname: '', 16 | auth_username: 'hadoop_stats', 17 | auth_password: 'GwKBkEiBXLyqRODTbBWpkhXY5FJvwVk7RthQzasgqTNQlpQDTksYEOnKbejlKBsR' 18 | }, options); 19 | 20 | var server = http.Server(); 21 | server.listen(options.port); 22 | server.on('request', function(req, res) 23 | { 24 | if (req.method === 'POST') 25 | { 26 | var header = req.headers['authorization'] || ''; 27 | var token = header.split(/\s+/).pop(); 28 | var auth = new Buffer(token, 'base64').toString(); 29 | var parts=auth.split(/:/); 30 | var username = parts[0]; 31 | var password=parts[1]; 32 | if (username == options.auth_username && password == options.auth_password) 33 | { 34 | var body = ''; 35 | req.on('data', function (data) { 36 | body += data; 37 | }); 38 | req.on('end', function () { 39 | data = JSON.parse(body); 40 | options.audience.setHadoopNamespaces(data); 41 | }); 42 | res.writeHead(200, 43 | { 44 | 'Content-Length': '0', 45 | 'Connection': 'close' 46 | }); 47 | } 48 | else 49 | { 50 | res.writeHead(403, 51 | { 52 | 'Content-Length': '0', 53 | 'Connection': 'close' 54 | }); 55 | } 56 | } 57 | else 58 | { 59 | var pathname = url.parse(req.url, true).pathname; 60 | var path2ns = /^\/([^\/]+)$/; 61 | if ((pathInfo = pathname.match(path2ns))) 62 | { 63 | var key = '@' + pathInfo[1]; 64 | var data = options.audience.get_audience_from_hadoop(key); 65 | if (data) 66 | { 67 | res.write(JSON.stringify(data)); 68 | } 69 | } 70 | else 71 | { 72 | res.write(JSON.stringify(options.audience.getHadoopNamespaces())); 73 | } 74 | } 75 | res.end(); 76 | }); 77 | } 78 | -------------------------------------------------------------------------------- /lib/subscribers.js: -------------------------------------------------------------------------------- 1 | var util = require('util'), 2 | events = require('events'); 3 | 4 | util.inherits(Subscribers, events.EventEmitter); 5 | exports.Subscribers = Subscribers; 6 | 7 | function Subscribers() 8 | { 9 | if (!(this instanceof Subscribers)) return new Subscribers(); 10 | 11 | this.lastTotal = 0; 12 | this.setMaxListeners(0); 13 | } 14 | 15 | Subscribers.prototype.createNotifyMessage = function(msg) 16 | { 17 | return 'data: ' + msg + '\n\n'; 18 | }; 19 | 20 | Subscribers.prototype.notify = function(total) 21 | { 22 | this.lastTotal = total; 23 | 24 | if (total === 0) 25 | { 26 | this.emit('empty'); 27 | } 28 | else 29 | { 30 | this.emit('notify', this.createNotifyMessage(total)); 31 | } 32 | }; 33 | 34 | Subscribers.prototype.addClient = function(client) 35 | { 36 | var self = this; 37 | 38 | function notify(data) {client.write(data);} 39 | client.write(this.createNotifyMessage(this.lastTotal + 1)); 40 | 41 | this.on('notify', notify); 42 | 43 | client.on('close', function() 44 | { 45 | self.removeListener('notify', notify); 46 | self.emit('remove', this); 47 | }); 48 | 49 | this.emit('add', client); 50 | }; 51 | 52 | 53 | function SubscribersGroup(options) 54 | { 55 | if (!(this instanceof SubscribersGroup)) return new SubscribersGroup(options); 56 | 57 | this.groups = {}; 58 | this.options = 59 | { 60 | log: function(severity, message) {console.log(message);} 61 | }; 62 | 63 | for (var opt in options) 64 | { 65 | if (options.hasOwnProperty(opt)) 66 | { 67 | this.options[opt] = options[opt]; 68 | } 69 | } 70 | 71 | this.log = this.options.log; 72 | } 73 | 74 | module.exports.SubscribersGroup = SubscribersGroup; 75 | 76 | SubscribersGroup.prototype.get = function(name, auto_create) 77 | { 78 | var subscribers = this.groups[name]; 79 | if (!subscribers && auto_create !== false) 80 | { 81 | this.log('debug', 'Create `' + name + '\' subscribers group'); 82 | this.groups[name] = subscribers = new Subscribers(); 83 | 84 | var self = this; 85 | subscribers.on('empty', function() 86 | { 87 | self.log('debug', 'Drop `' + name + '\' empty subscribers group'); 88 | delete self.groups[name]; 89 | }); 90 | subscribers.on('add', function(client) 91 | { 92 | self.log('debug', 'Client subscribed to `' + name + '\''); 93 | }); 94 | subscribers.on('remove', function(client) 95 | { 96 | self.log('debug', 'Client unsubscribed from `' + name + '\''); 97 | }); 98 | } 99 | return subscribers; 100 | }; 101 | -------------------------------------------------------------------------------- /client/Audience.as: -------------------------------------------------------------------------------- 1 | package 2 | { 3 | import flash.display.LoaderInfo; 4 | import flash.display.Sprite; 5 | import flash.external.ExternalInterface; 6 | import flash.net.URLStream; 7 | import flash.events.Event; 8 | import flash.events.ProgressEvent; 9 | import flash.events.IOErrorEvent; 10 | import flash.net.URLRequest; 11 | import flash.net.URLRequestHeader; 12 | import flash.net.URLRequestMethod; 13 | import flash.utils.setTimeout; 14 | 15 | public dynamic class Audience extends Sprite 16 | { 17 | private var offset:Number; 18 | private var stream:URLStream; 19 | private var callback:String; 20 | private var request:URLRequest; 21 | private var retryDelay:Number; 22 | 23 | public function Audience():void 24 | { 25 | var params:Object = LoaderInfo(this.root.loaderInfo).parameters; 26 | var url:String = String(params['url']); 27 | this.callback = String(params['callback']); 28 | 29 | this.request = new URLRequest(url); 30 | this.request.method = URLRequestMethod.POST; 31 | this.request.requestHeaders = new Array(new URLRequestHeader('Accept','text/event-stream')); 32 | this.request.data = 0; 33 | 34 | this.stream = new URLStream(); 35 | this.stream.addEventListener(ProgressEvent.PROGRESS, this.dataReceived); 36 | this.stream.addEventListener(Event.COMPLETE, this.reconnect); 37 | this.stream.addEventListener(IOErrorEvent.IO_ERROR, this.reconnect); 38 | 39 | this.connect(); 40 | } 41 | 42 | private function connect():void 43 | { 44 | this.offset = 0; 45 | this.stream.load(this.request); 46 | } 47 | 48 | private function reconnect(e:Event):void 49 | { 50 | if (retryDelay >= 0) 51 | { 52 | setTimeout(this.connect, retryDelay); 53 | } 54 | } 55 | 56 | public function dataReceived(e:ProgressEvent):void 57 | { 58 | var buffer:String = stream.readUTFBytes(e.bytesLoaded - this.offset); 59 | this.offset = e.bytesLoaded; 60 | if (!buffer) return; 61 | 62 | var lines:Array = buffer.split('\n'); 63 | for (var i:int = 0, l:int = lines.length; i < l; i++) 64 | { 65 | var info:Array = lines[i].split(/:/, 2), 66 | value:Number = parseInt(info[1], 10); 67 | switch (info[0]) 68 | { 69 | case 'data': 70 | ExternalInterface.call(this.callback, value); 71 | break; 72 | case 'retry': 73 | this.retryDelay = value; 74 | break; 75 | } 76 | } 77 | } 78 | } 79 | } -------------------------------------------------------------------------------- /client/jquery.audience.js: -------------------------------------------------------------------------------- 1 | (function($, global) 2 | { 3 | $.extend($.ajaxSettings.accepts, {stream: "text/event-stream"}); 4 | 5 | function openXHRConnection(url, deferred) 6 | { 7 | var offset = 0, 8 | retryDelay = 2000, 9 | xhr = global.XDomainRequest ? new global.XDomainRequest() : new global.XMLHttpRequest(); 10 | xhr.open('POST', url, true); 11 | xhr.withCredentials = false; 12 | xhr.setRequestHeader('Accept', 'text/event-stream'); 13 | $(xhr).bind('error abort load', function() 14 | { 15 | if (retryDelay >= 0) 16 | { 17 | setTimeout(function() {openConnection(url, deferred);}, retryDelay); 18 | } 19 | }); 20 | $(xhr).bind('progress', function() 21 | { 22 | while (true) 23 | { 24 | var nextOffset = this.responseText.indexOf('\n\n', offset); 25 | if (nextOffset === -1) break; 26 | var data = this.responseText.substring(offset, nextOffset); 27 | offset = nextOffset + 2; 28 | 29 | var lines = data.split('\n'); 30 | for (var i = 0, l = lines.length; i < l; i++) 31 | { 32 | var info = lines[i].split(/:/, 2), 33 | value = parseInt(info[1], 10); 34 | switch (info[0]) 35 | { 36 | case 'data': 37 | deferred.notify(value); 38 | break; 39 | case 'retry': 40 | retryDelay = value; 41 | break; 42 | } 43 | } 44 | } 45 | }); 46 | deferred.done(function() 47 | { 48 | $(xhr).unbind(); 49 | xhr.abort(); 50 | }); 51 | xhr.send(); 52 | } 53 | 54 | function openFlashConnection(url, deferred) 55 | { 56 | var flashObject = null, 57 | callback = 'audience' + new Date().getTime(); 58 | 59 | global[callback] = function(data) 60 | { 61 | deferred.notify(data); 62 | }; 63 | 64 | if (navigator.plugins && navigator.mimeTypes && navigator.mimeTypes.length) // Netscape plugin architecture 65 | { 66 | flashObject = $('').attr 67 | ({ 68 | type: 'application/x-shockwave-flash', 69 | src: '/client/audience.swf', // TODO make this customizable 70 | allowScriptAccess: 'always', 71 | flashvars: 'callback=' + callback + '&url=' + encodeURI(url), 72 | width: '0', 73 | height: '0' 74 | }); 75 | } 76 | else // IE 77 | { 78 | flashObject = 79 | $( 80 | '' + 81 | '' + // TODO make this customizable 82 | '' + 83 | '' + 84 | '' 85 | ); 86 | } 87 | 88 | flashObject.appendTo(document.body); 89 | 90 | deferred.done(function() 91 | { 92 | flashObject.remove(); 93 | }); 94 | } 95 | 96 | $.audience = function(url) 97 | { 98 | var deferred = $.Deferred(); 99 | if ($.support.cors) 100 | { 101 | // setTimeout to schedule the connection in the next tick in order prevent infinit loading 102 | setTimeout(function() {openXHRConnection(url, deferred);}, 0); 103 | } 104 | else 105 | { 106 | openFlashConnection(url, deferred); 107 | } 108 | var promise = deferred.promise(); 109 | promise.close = function() 110 | { 111 | deferred.resolve(); 112 | }; 113 | return promise; 114 | }; 115 | 116 | })(jQuery, this); 117 | -------------------------------------------------------------------------------- /audience-meter.js: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env node 2 | 3 | var options = require('commander'), 4 | cluster = require('cluster'); 5 | 6 | options 7 | .version('2.0.1') 8 | .option('-d, --debug', 'Log everything') 9 | .option('-w, --workers ', 'Number of worker processes to spawn (default to the number of CPUs)', parseInt) 10 | .option('-m, --cluster-addr ', 'Use a given multicast IP:PORT to sync several instances of audience-meter ' + 11 | '(disabled by default, prefered address is 239.255.13.37:314)') 12 | .option('--max-conn-duration ', 'The maximum number of seconds a connection can stay established (default 3600, 0 to disable)', parseInt, 3600) 13 | .option('--enable-uuid', 'Enable connection UUID to prevent several connections with same (EXPERIMENTAL, known to leak memory)') 14 | .option('--cluster-notify-interval ', 'Interval between notifications for a node\'s notification (default 2 seconds', parseInt, 2) 15 | .option('--cluster-node-timeout ', 'Delay after which node\'s namespace info will be forgotten if no notification ' + 16 | 'is recieved by a node (default 5 seconds)', parseInt, 5) 17 | .option('--notify-delta-ratio ', 'Minimum delta of number of members to reach before to notify ' + 18 | 'listeners based on a fraction of the current number of members (default 0.1)', parseFloat, 0.1) 19 | .option('--notify-min-delay ', 'Minimum delay between notifications (default 2)', parseFloat, 2) 20 | .option('--notify-max-delay ', 'Maximum delay to wait before not sending notification ' + 21 | 'because of min-delta not reached (default 25)', parseFloat, 25) 22 | .option('--increment-delay ', 'Number of seconds to wait before to increment the counter in order to mitigate de/connection floods', parseFloat, 0) 23 | .option('--namespace-clean-delay ', 'Minimum delay to wait before to clean an empty namespace (default 60)', parseFloat, 60) 24 | .option('--demo-port ', 'Public port on which to bind the demo server (default 8080, 0 to disable)', parseInt, 8080) 25 | .option('--stats-port ', 'Local port on which to bind the global stats server (default 1442, 0 to disable)', parseInt, 1442) 26 | .option('--notification-port ', 'Local port on which to bind the global notification server (default 2442, 0 to disable)', parseInt, 2442) 27 | .option('--hadoop-stats-port ', 'Local port on which to bind the hadoop stat server (default 3442, 0 to disable)', parseInt, 3442) 28 | .option('--single-stats-port ', 'Local port on which to bind the single stat server (default 4442, 0 to disable)', parseInt, 4442) 29 | .parse(process.argv); 30 | 31 | function logger(severity, message) 32 | { 33 | if (severity == 'error') 34 | { 35 | console.error('[%s] [%s] %s', cluster.isMaster ? 'master' : 'child#' + process.pid, severity, message); 36 | } 37 | else if (options.debug) 38 | { 39 | console.log('[%s] [%s] %s', cluster.isMaster ? 'master' : 'child#' + process.pid, severity, message); 40 | } 41 | } 42 | 43 | var workerIdx = 0; 44 | 45 | if (cluster.isMaster) 46 | { 47 | process.title = 'audience-meter: master'; 48 | 49 | var audience = require('./lib/audience').Audience 50 | ({ 51 | notify_delta_ratio: options.notifyDeltaRatio, 52 | notify_min_delay: options.notifyMinDelay, 53 | notify_max_delay: options.notifyMaxDelay, 54 | namespace_clean_delay: options.namespaceCleanDelay, 55 | log: logger 56 | }); 57 | 58 | require('./lib/master').Master 59 | ({ 60 | workers: options.workers, 61 | audience: audience, 62 | log: logger 63 | }); 64 | 65 | if (options.notificationPort) 66 | { 67 | require('./lib/notification').NotificationServer({port: options.notificationPort, audience: audience}); 68 | } 69 | 70 | if (options.demoPort) 71 | { 72 | require('./lib/demo').DemoServer({port: options.demoPort}); 73 | } 74 | 75 | if (options.statsPort) 76 | { 77 | require('./lib/stats').StatsServer({port: options.statsPort, audience: audience}); 78 | } 79 | 80 | if (options.singleStatsPort) 81 | { 82 | require('./lib/single-stats').SingleStatsServer({port: options.singleStatsPort, audience: audience}); 83 | } 84 | 85 | if (options.hadoopStatsPort) 86 | { 87 | require('./lib/hadoop-stats').HadoopStatsServer({port: options.hadoopStatsPort, audience: audience}); 88 | } 89 | 90 | if (options.clusterAddr) 91 | { 92 | require('./lib/cluster').ClusterManager 93 | ({ 94 | notify_interval: options.clusterNotifyInterval, 95 | node_timeout: options.clusterNodeTimeout, 96 | multicast_addr: options.clusterAddr, 97 | audience: audience, 98 | log: logger 99 | }); 100 | } 101 | } 102 | else 103 | { 104 | process.title = 'audience-meter: worker '; 105 | 106 | require('./lib/worker').Worker 107 | ({ 108 | uuid: options.enableUuid, 109 | increment_delay: options.incrementDelay, 110 | max_conn_duration: options.maxConnDuration, 111 | log: logger 112 | }); 113 | } 114 | -------------------------------------------------------------------------------- /lib/worker.js: -------------------------------------------------------------------------------- 1 | var events = require('events'), 2 | url = require('url'), 3 | http = require('http'), 4 | merge = require('./utils').merge; 5 | 6 | exports.Worker = Worker; 7 | 8 | function Worker(options) 9 | { 10 | if (!(this instanceof Worker)) return new Worker(options); 11 | 12 | this.options = options = merge 13 | ({ 14 | uuid: false, 15 | increment_delay: 0, 16 | max_conn_duration: 0, 17 | log: function(severity, message) {console.log(message);} 18 | }, options); 19 | 20 | this.groups = require('./subscribers').SubscribersGroup({log: options.log}); 21 | 22 | var self = this; 23 | this.clientRegistry = {}; 24 | 25 | process.on('message', function(msg) 26 | { 27 | switch (msg.cmd) 28 | { 29 | case 'notify': 30 | self.notify(msg.namespace, msg.msg); 31 | break; 32 | 33 | case 'exclude': 34 | self.exclude(msg.uuid, false); 35 | break; 36 | } 37 | }); 38 | 39 | var path2ns = /^\/([^\/]+)(?:\/([^\/]+))?$/, 40 | policyFile = '' + 41 | '' + 42 | '' + 43 | '' + 44 | '' + 45 | '' + 46 | ''; 47 | 48 | var server = http.Server(); 49 | server.listen((process.env.PORT || 80), process.env.HOST); 50 | server.on('request', function(req, res) 51 | { 52 | var pathname = url.parse(req.url).pathname, 53 | pathInfo = null; 54 | 55 | if (typeof pathname != 'string') 56 | { 57 | // Go to catch all 58 | } 59 | else if (pathname == '/crossdomain.xml') 60 | { 61 | options.log('debug', 'Sending policy file'); 62 | res.writeHead(200, {'Content-Type': 'application/xml'}); 63 | return res.end(policyFile); 64 | } 65 | else if ((pathInfo = pathname.match(path2ns))) 66 | { 67 | var namespace = pathInfo[1], 68 | uuid = options.uuid ? pathInfo[2] : null; 69 | 70 | if (res.socket && req.headers.accept && req.headers.accept.indexOf('text/event-stream') != -1) 71 | { 72 | res.writeHead(200, 73 | { 74 | 'Content-Type': 'text/event-stream', 75 | 'Cache-Control': 'no-cache', 76 | 'Access-Control-Allow-Origin': '*', 77 | 'Connection': 'close' 78 | }); 79 | 80 | req.connection.setNoDelay(true); 81 | 82 | if (req.headers['user-agent'] && req.headers['user-agent'].indexOf('MSIE') != -1) 83 | { 84 | // Work around MSIE bug preventing Progress handler from behing thrown before first 2048 bytes 85 | // See http://forums.adobe.com/message/478731 86 | res.write(new Array(2048).join('\n')); 87 | } 88 | 89 | self.join(namespace, res, uuid); 90 | 91 | if (options.max_conn_duration > 0) 92 | { 93 | // Force end of the connection if maximum duration is reached 94 | res.maxDurationTimeout = setTimeout(function() {res.end();}, options.max_conn_duration * 1000); 95 | } 96 | return; 97 | } 98 | else 99 | { 100 | self.exclude(uuid); 101 | } 102 | } 103 | 104 | // TODO: reimplement subscriptions 105 | 106 | // Catch all 107 | res.writeHead(200, 108 | { 109 | 'Content-Length': '0', 110 | 'Connection': 'close' 111 | }); 112 | res.end(); 113 | }); 114 | 115 | options.log('debug', 'Worker started'); 116 | } 117 | 118 | Worker.prototype.exclude = function(uuid, broadcast) 119 | { 120 | if (!uuid) return; 121 | var formerClient = this.clientRegistry[uuid]; 122 | if (formerClient) formerClient.end('retry: -1\n\n'); 123 | if (broadcast !== false) 124 | { 125 | process.send({cmd: 'exclude', uuid: uuid}); 126 | } 127 | }; 128 | 129 | Worker.prototype.notify = function(namespace, total) 130 | { 131 | var subscribers = this.groups.get(namespace, false); 132 | if (subscribers) subscribers.notify(total); 133 | }; 134 | 135 | Worker.prototype.join = function(namespace, client, uuid) 136 | { 137 | var self = this; 138 | // Subscribe 139 | this.groups.get(namespace).addClient(client); 140 | // Increment namespace counter on master (after a configured delay to mitigate +/- flood) 141 | client.incrementTimeout = setTimeout(function() 142 | { 143 | process.send({cmd: 'join', namespace: namespace}); 144 | delete client.incrementTimeout; 145 | }, this.options.increment_delay * 1000); 146 | // Decrement on exit 147 | client.socket.on('close', function() 148 | { 149 | // Notify about the decrement only if the increment happended 150 | if ('incrementTimeout' in client) 151 | { 152 | clearTimeout(client.incrementTimeout); 153 | } 154 | else 155 | { 156 | process.send({cmd: 'leave', namespace: namespace}); 157 | } 158 | 159 | if (uuid && self.clientRegistry[uuid] == client) 160 | { 161 | delete self.clientRegistry[uuid]; 162 | } 163 | 164 | if (client.hasOwnProperty('maxDurationTimeout')) 165 | { 166 | clearTimeout(client.maxDurationTimeout); 167 | } 168 | }); 169 | // If uuid is provided, ensure only one client with same uuid is connected to this namespace 170 | if (uuid) 171 | { 172 | this.exclude(uuid); 173 | this.clientRegistry[uuid] = client; 174 | } 175 | }; 176 | 177 | Worker.prototype.subscribe = function(namespaces, client) 178 | { 179 | if (!util.isArray(namespaces)) 180 | { 181 | // array required 182 | return; 183 | } 184 | 185 | // Subscribe to a list of namespaces 186 | namespaces.forEach(function(namespace) 187 | { 188 | if (typeof namespace != 'string') 189 | { 190 | // array of string required 191 | return; 192 | } 193 | 194 | if (namespace) 195 | { 196 | this.groups.get(namespace).addClient(client); 197 | } 198 | }); 199 | }; 200 | -------------------------------------------------------------------------------- /lib/cluster.js: -------------------------------------------------------------------------------- 1 | var dgram = require('dgram'), 2 | util = require('util'), 3 | uuid = require('node-uuid'), 4 | merge = require('./utils').merge, 5 | msgpack = require('msgpack'); 6 | 7 | exports.ClusterManager = ClusterManager; 8 | 9 | function ClusterManager(options) 10 | { 11 | if (!(this instanceof ClusterManager)) return new ClusterManager(options); 12 | 13 | this.options = merge 14 | ({ 15 | multicast_addr: '239.255.13.37:314', 16 | notify_interval: 2, 17 | notify_max_items: 100, 18 | timeout: 5, 19 | audience: null, 20 | log: function(severity, message) {console.log(message);} 21 | }, options); 22 | 23 | this.sid = uuid.v4(); 24 | this.audience = this.options.audience; 25 | this.log = this.options.log; 26 | this.namespacesInfo = {}; 27 | 28 | var self = this; 29 | this.mcastIP = this.options.multicast_addr.split(':')[0]; 30 | this.mcastPort = this.options.multicast_addr.split(':')[1]; 31 | this.log('debug', 'Cluster started on ' + this.mcastIP + ':' + this.mcastPort); 32 | 33 | this.socket = dgram.createSocket("udp4"); 34 | this.socket.bind(this.mcastPort); 35 | try 36 | { 37 | this.socket.addMembership(this.mcastIP); 38 | } 39 | catch (e) 40 | { 41 | throw new Error('The cluster feature needs a version of node.js with UDP multicast support. ' + 42 | 'You may use the following fork, waiting for an upstream merge: https://github.com/rs/node/tree/multicast-broadcast'); 43 | } 44 | this.socket.on('message', function(message, rinfo) 45 | { 46 | self.receiveMessage(message, rinfo); 47 | }); 48 | 49 | setInterval(function() {self.checkNamespaces();}, this.options.timeout * 1000); 50 | setInterval(function() {self.advertise();}, this.options.notify_interval * 1000); 51 | } 52 | 53 | ClusterManager.prototype.advertise = function() 54 | { 55 | var self = this, 56 | idx = 0, 57 | queue = []; 58 | 59 | function flush() 60 | { 61 | var message = msgpack.pack({sid: self.sid, vals: queue}); 62 | self.socket.send(message, 0, message.length, self.mcastPort, self.mcastIP); 63 | queue = []; 64 | } 65 | 66 | this.audience.eachNamespace(function(namespace) 67 | { 68 | var members = namespace.members - (namespace.remoteMembers ? namespace.remoteMembers : 0), 69 | connections = namespace.connections - (namespace.remoteConnections ? namespace.remoteConnections : 0); 70 | 71 | self.log('debug', 'Cluster advertise `' + namespace.name + '\' namespace with ' + members + ' members'); 72 | if (members > 0) 73 | { 74 | queue.push(namespace.name, members, connections); 75 | } 76 | if ((++idx % self.options.notify_max_items) === 0) flush(); 77 | }); 78 | 79 | if (queue.length > 0) flush(); 80 | }; 81 | 82 | ClusterManager.prototype.receiveMessage = function(message, rinfo) 83 | { 84 | var update; 85 | 86 | try 87 | { 88 | update = msgpack.unpack(message); 89 | } 90 | catch (e) 91 | { 92 | this.log('error', 'Cluster received invalid multicast message from ' + rinfo.address + ':' + rinfo.port); 93 | return; 94 | } 95 | 96 | var err; 97 | if (typeof update.sid != 'string') err = 'no sid'; 98 | else if (!update.vals || !util.isArray(update.vals)) err = 'no vals array'; 99 | else if (update.vals % 3 !== 0) error = 'invalid vals count'; 100 | if (err) 101 | { 102 | this.log('error', 'Cluster received invalid multicast message from ' + rinfo.address + ':' + rinfo.port + ': ' + err); 103 | return; 104 | } 105 | 106 | // Ignore self messages 107 | if (update.sid == this.sid) return; 108 | 109 | for (var i = 0, max = update.vals.length; i < max; i += 3) 110 | { 111 | this.updateRemoteNamespace(update.sid, update.vals[i], update.vals[i + 1], update.vals[i + 2]); 112 | } 113 | }; 114 | 115 | ClusterManager.prototype.updateRemoteNamespace = function(sid, name, members, connections) 116 | { 117 | var err; 118 | if (typeof name != 'string') err = 'namespace name is not a string: ' + name; 119 | else if (parseInt(members, 10) != members || members < 0) err = 'members is not a positive integer: ' + members; 120 | else if (parseInt(connections, 10) != connections || connections < 0) err = 'connections is not a positive integer: ' + connections; 121 | if (err) 122 | { 123 | this.log('error', 'Cluster received invalid update from `' + sid + '\': ' + err); 124 | return; 125 | } 126 | 127 | // Ignore self messages 128 | if (sid == this.sid) return; 129 | 130 | var key = sid + ':' + name; 131 | var info = this.namespacesInfo[key]; 132 | var membersDelta, connectionsDelta; 133 | 134 | if (info) 135 | { 136 | membersDelta = members - info.members; 137 | connectionsDelta = connections - info.connections; 138 | info.members = members; 139 | info.connections = connections; 140 | } 141 | else 142 | { 143 | membersDelta = members; 144 | connectionsDelta = connections; 145 | this.namespacesInfo[key] = 146 | { 147 | sid: sid, 148 | name: name, 149 | members: members, 150 | connections: connections 151 | }; 152 | } 153 | 154 | this.namespacesInfo[key].lastUpdate = new Date().getTime() / 1000; 155 | 156 | this.log('debug', 'Cluster received update for `' + name + '\' namespace from `' + sid + '\' with ' + members + ' members'); 157 | 158 | this.updateLocalNamespace(name, membersDelta, connectionsDelta); 159 | }; 160 | 161 | ClusterManager.prototype.updateLocalNamespace = function(name, membersDelta, connectionsDelta) 162 | { 163 | var namespace = this.audience.namespace(name); 164 | 165 | if (!('remoteMembers' in namespace)) 166 | { 167 | namespace.remoteMembers = 0; 168 | namespace.remoteConnections = 0; 169 | } 170 | 171 | namespace.members -= namespace.remoteMembers; 172 | namespace.connections -= namespace.remoteConnections; 173 | 174 | namespace.remoteMembers += membersDelta; 175 | namespace.remoteConnections += connectionsDelta; 176 | 177 | namespace.members += namespace.remoteMembers; 178 | namespace.connections += namespace.remoteConnections; 179 | }; 180 | 181 | ClusterManager.prototype.checkNamespaces = function() 182 | { 183 | var now = new Date().getTime() / 1000; 184 | 185 | for (var key in this.namespacesInfo) 186 | { 187 | if (!this.namespacesInfo.hasOwnProperty(key)) continue; 188 | 189 | var info = this.namespacesInfo[key]; 190 | if (now - info.lastUpdate > this.options.timeout) 191 | { 192 | this.log('debug', 'The `' + info.name + '\' namespace from `' + info.sid + ' expired'); 193 | this.updateLocalNamespace(info.name, -info.members, -info.connections); 194 | delete this.namespacesInfo[key]; 195 | } 196 | } 197 | }; 198 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | Audience Meter: Lightweight daemon to mesure audience of a live event 2 | ===================================================================== 3 | 4 | Audience Meter is a simple daemon written in [Node.js](http://nodejs.org) to mesure the number of users currently online. This can be used to mesure the audience of live events. 5 | 6 | ## Features 7 | 8 | - Namespaces to track an unlimited number of events 9 | - Cross browser push notification thru [EventSource](http://dev.w3.org/html5/eventsource/) polyfill using xhr-streaming-CORS for recent browsers and flash/URLStream for older browser 10 | - Monitoring interface on a dedicated port 11 | - Spreads the load on multiple CPUs and/or multiple servers 12 | 13 | ## How to use 14 | 15 | Start by running the daemon on a server, root privilege is required to let the daemon listen on ports 80: 16 | 17 | $ sudo node audience-meter.js 18 | 19 | Here are available parameters: 20 | 21 | Usage: audience-meter.js [options] 22 | 23 | Options: 24 | 25 | -h, --help output usage information 26 | -V, --version output the version number 27 | -d, --debug Log everything 28 | -w, --workers Number of worker processes to spawn (default to the number of CPUs) 29 | -m, --cluster-addr Use a given multicast IP:PORT to sync several instances of audience-meter 30 | (disabled by default, prefered address is 239.255.13.37:314) 31 | --max-conn-duration The maximum number of seconds a connection can stay established 32 | (default 3600, 0 to disable) 33 | --enable-uuid Enable connection UUID to prevent several connections with same 34 | (EXPERIMENTAL, known to leak memory) 35 | --cluster-notify-interval Interval between notifications for a node's notification (default 2 seconds 36 | --cluster-node-timeout Delay after which node's namespace info will be forgotten if no notification 37 | is recieved by a node (default 5 seconds) 38 | --notify-delta-ratio Minimum delta of number of members to reach before to notify listeners based 39 | on a fraction of the current number of members (default 0.1) 40 | --notify-min-delay Minimum delay between notifications (default 2) 41 | --notify-max-delay Maximum delay to wait before not sending notification because of min-delta not 42 | reached (default 25) 43 | --increment-delay Number of seconds to wait before to increment the counter in order to mitigate 44 | de/connection floods 45 | --namespace-clean-delay Minimum delay to wait before to clean an empty namespace (default 60) 46 | --demo-port Public port on which to bind the demo server (default 8080, 0 to disable) 47 | --stats-port Local port on which to bind the global stats server (default 1442, 0 to disable) 48 | --notification-port Local port on which to bind the global notification server (default 2442, 0 to disable) 49 | --hadoop-stats-port Local port on which to bind the hadoop stat server (default 3442, 0 to disable) 50 | --single-stats-port Local port on which to bind the single stat server (default 4442, 0 to disable) 51 | 52 | In the webpage of the event, add the following javascript to join an event.: 53 | 54 | 55 | 56 | 59 | 60 | Note that you can only join a single event at a time in a single page. 61 | 62 | You may want to report the current number of online users on the event. By default, joining an event listen for it. To get event members count when it changes, listen for incoming messages like this: 63 | 64 | 65 | 66 | 72 | 73 | Connected users - 74 | 75 | 76 | ## Monitoring Interface 77 | 78 | The daemon listen on the 1442 port on localhost in order to let another process to dump all namespace counters for monitoring or graphing purpose. One possible usage is to update RRD files to track the evolution of the audiance over time. 79 | 80 | The server send all namespaces and their associated info separated formated as a JSON object. Each namespace is stored in a proprety, with an object containing info on the namespace. Namespace fields are: 81 | 82 | * *created*: the UNIX timestamp of the namespace creationg time 83 | * *connections*: the total number of connections to the namespace since its creation 84 | * *members*: the current number of participents in the namespace 85 | 86 | Here is a usage example using netcat (indentation added for clarity): 87 | 88 | $ nc localhost 1442 89 | { 90 | "namespace1": 91 | { 92 | "created":1300804962, 93 | "connections":234, 94 | "members":123 95 | }, 96 | "namespace2": 97 | { 98 | "created":1300804302, 99 | "connections":456, 100 | "members":345 101 | }, 102 | "namespace3": 103 | { 104 | "created":1300824940, 105 | "connections":789, 106 | "members":678 107 | } 108 | } 109 | 110 | ## License 111 | 112 | (The MIT License) 113 | 114 | Copyright (c) 2011 Olivier Poitrey 115 | 116 | Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the 'Software'), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: 117 | 118 | The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. 119 | 120 | THE SOFTWARE IS PROVIDED 'AS IS', WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 121 | -------------------------------------------------------------------------------- /lib/audience.js: -------------------------------------------------------------------------------- 1 | var util = require('util'), 2 | events = require('events'), 3 | merge = require('./utils').merge; 4 | 5 | util.inherits(Audience, events.EventEmitter); 6 | exports.Audience = Audience; 7 | 8 | function Audience(options) 9 | { 10 | if (!(this instanceof Audience)) return new Audience(options); 11 | 12 | this.namespaces = {}; 13 | this.h_namespaces = {}; 14 | this.use_hadoop = true; 15 | this.options = merge 16 | ({ 17 | notify_delta_ratio: 0.1, 18 | notify_min_delay: 2, 19 | notify_max_delay: 25, 20 | namespace_clean_delay: 60, 21 | log: function(severity, message) {console.log(message);} 22 | }, options); 23 | 24 | var self = this; 25 | setInterval(function() {self.notifyAll();}, this.options.notify_min_delay * 1000); 26 | this.log = this.options.log; 27 | } 28 | 29 | Audience.prototype.eachNamespace = function(cb) 30 | { 31 | for (var key in this.namespaces) 32 | { 33 | if (!this.namespaces.hasOwnProperty(key)) continue; 34 | cb(this.namespaces[key]); 35 | } 36 | }; 37 | 38 | Audience.prototype.namespace = function(name, auto_create) 39 | { 40 | if (!name) return; 41 | 42 | var namespace = this.namespaces['@' + name]; 43 | 44 | if (namespace && namespace.garbageTimer) 45 | { 46 | clearTimeout(namespace.garbageTimer); 47 | delete namespace.garbageTimer; 48 | } 49 | 50 | if (!namespace && auto_create !== false) 51 | { 52 | namespace = 53 | { 54 | name: name, 55 | created: Math.round(new Date().getTime() / 1000), 56 | connections: 0, 57 | members: 0, 58 | last: 59 | { 60 | members: 0, 61 | timestamp: 0 62 | } 63 | }; 64 | this.namespaces['@' + name] = namespace; 65 | 66 | this.log('debug', 'Create `' + namespace.name + '\' namespace'); 67 | } 68 | 69 | return namespace; 70 | }; 71 | 72 | Audience.prototype.cleanNamespace = function(namespace) 73 | { 74 | if (namespace.members === 0 && !namespace.garbageTimer) 75 | { 76 | var self = this; 77 | this.log('debug', 'Schedule delete of `' + namespace.name + '\' namespace'); 78 | 79 | namespace.garbageTimer = setTimeout(function() 80 | { 81 | self.log('debug', 'Delete `' + namespace.name + '\' namespace'); 82 | delete self.namespaces['@' + namespace.name]; 83 | }, this.options.namespace_clean_delay * 1000); 84 | } 85 | }; 86 | 87 | Audience.prototype.join = function(namespaceName) 88 | { 89 | var self = this, 90 | namespace = this.namespace(namespaceName); 91 | 92 | if (!namespace) throw new Error('Invalid Namespace'); 93 | 94 | namespace.members++; 95 | namespace.connections++; 96 | 97 | this.log('debug', 'Join `' + namespace.name + '\' namespace: #' + namespace.members); 98 | }; 99 | 100 | Audience.prototype.leave = function(namespaceName) 101 | { 102 | var namespace = this.namespace(namespaceName); 103 | if (!namespace) return; 104 | namespace.members--; 105 | 106 | this.log('debug', 'Leave `' + namespace.name + '\' namespace: #' + namespace.members); 107 | }; 108 | 109 | Audience.prototype.notifyAll = function() 110 | { 111 | var namespaces = this.use_hadoop ? this.h_namespaces : this.namespaces; 112 | for (var key in namespaces) 113 | { 114 | if (!namespaces.hasOwnProperty(key)) continue; 115 | 116 | var namespace = namespaces[key]; 117 | if (namespace.members === 0) 118 | { 119 | if (!this.use_hadoop) 120 | { 121 | this.cleanNamespace(namespace); 122 | } 123 | continue; 124 | } 125 | 126 | if (Math.round(new Date().getTime() / 1000) - namespace.last.timestamp < this.options.notify_max_delay) 127 | { 128 | minDelta = Math.max(Math.floor(namespace.last.members * this.options.notify_delta_ratio), 1); 129 | 130 | if (Math.abs(namespace.last.members - namespace.members) < minDelta) 131 | { 132 | // Only notify if total members significantly changed since the last notice 133 | continue; 134 | } 135 | } 136 | 137 | namespace.last = {members: namespace.members, timestamp: Math.round(new Date().getTime() / 1000)}; 138 | this.log('debug', 'Notify `' + namespace.name + '\' namespace with ' + namespace.members + ' members'); 139 | this.emit('notify', namespace); 140 | } 141 | }; 142 | 143 | Audience.prototype.info = function(namespaceName) 144 | { 145 | var namespace = this.namespace(namespaceName, false); 146 | return namespace ? namespace.members + ':' + namespace.connections : '0:0'; 147 | }; 148 | 149 | Audience.prototype.stats = function() 150 | { 151 | var stats = {}; 152 | for (var key in this.namespaces) 153 | { 154 | if (!this.namespaces.hasOwnProperty(key)) continue; 155 | 156 | var namespace = this.namespaces[key]; 157 | stats[namespace.name] = 158 | { 159 | created: namespace.created, 160 | members: namespace.members, 161 | connections: namespace.connections, 162 | modified: namespace.last.timestamp 163 | }; 164 | } 165 | 166 | return stats; 167 | }; 168 | 169 | Audience.prototype.get_audience = function(key) 170 | { 171 | if (!this.namespaces.hasOwnProperty(key)) return; 172 | 173 | var namespace = this.namespaces[key]; 174 | return { 175 | created: namespace.created, 176 | members: namespace.members, 177 | connections: namespace.connections, 178 | modified: namespace.last.timestamp 179 | }; 180 | }; 181 | 182 | Audience.prototype.get_total_audience = function() 183 | { 184 | var total = 0; 185 | for (var key in this.namespaces) 186 | { 187 | if (!this.namespaces.hasOwnProperty(key)) continue; 188 | 189 | total += this.namespaces[key].members; 190 | } 191 | return total; 192 | }; 193 | 194 | Audience.prototype.sendMessage = function(namespace, msg) 195 | { 196 | var namespace = this.namespaces['@' + namespace]; 197 | if (typeof namespace != 'undefined') 198 | { 199 | this.emit('notify', namespace, msg); 200 | } 201 | }; 202 | 203 | Audience.prototype.setHadoopNamespaces = function(h_namespaces) { 204 | for (var key in h_namespaces) 205 | { 206 | if (!h_namespaces.hasOwnProperty(key)) continue; 207 | 208 | var h_namespace = h_namespaces[key]; 209 | 210 | h_namespace.name = key; 211 | if (!this.h_namespaces.hasOwnProperty('@' + key)) 212 | { 213 | h_namespace.created = Math.round(new Date().getTime() / 1000); 214 | h_namespace.last = {members: h_namespace.members, connections: 0, timestamp: Math.round(new Date().getTime() / 1000)}; 215 | } 216 | else 217 | { 218 | var old_namespace = this.h_namespaces['@' + h_namespace.name]; 219 | h_namespace.last = {members: old_namespace.members, connections: 0, timestamp: Math.round(new Date().getTime() / 1000)}; 220 | } 221 | this.h_namespaces['@' + h_namespace.name] = h_namespace; 222 | } 223 | for (var key in this.h_namespaces) 224 | { 225 | if (!h_namespaces.hasOwnProperty(key.substring(1))) 226 | { 227 | delete(this.h_namespaces[key]); 228 | } 229 | } 230 | }; 231 | 232 | Audience.prototype.getHadoopNamespaces = function() { 233 | var h_stats = {}; 234 | for (var key in this.h_namespaces) 235 | { 236 | if (!this.h_namespaces.hasOwnProperty(key)) continue; 237 | 238 | var h_namespace = this.h_namespaces[key]; 239 | h_stats[h_namespace.name] = 240 | { 241 | created: h_namespace.created, 242 | members: h_namespace.last.members, 243 | connections: h_namespace.last.members, 244 | modified: h_namespace.last.timestamp 245 | }; 246 | } 247 | 248 | return h_stats; 249 | }; 250 | 251 | Audience.prototype.get_audience_from_hadoop = function(key) { 252 | if (!this.h_namespaces.hasOwnProperty(key)) return; 253 | 254 | var h_namespace = this.h_namespaces[key]; 255 | return { 256 | created: h_namespace.created, 257 | members: h_namespace.last.members, 258 | connections: h_namespace.last.members, 259 | modified: h_namespace.last.timestamp 260 | }; 261 | }; 262 | --------------------------------------------------------------------------------