Ottomata has uploaded a new change for review. https://gerrit.wikimedia.org/r/225485
Change subject: POC for Realtime Trending Pageviews ...................................................................... POC for Realtime Trending Pageviews This job was originally developed at https://gerrit.wikimedia.org/r/#/c/201474/ It was modified to use spark 1.3.0 Kafka Direct Stream API To run spark streaming job: ``` spark-submit \ --master yarn \ --num-executors 12 \ --class org.wikimedia.analytics.refinery.job.RealtimeTrendingPages \ refinery-job/target/refinery-job-0.0.15-SNAPSHOT.jar \ 10 600 10 otto-pagecounts-spark-streaming-$(date +%s) webrequest_text \ > /tmp/topPageViews.out ``` To run Visualization: ``` cd realtime_trending_viz npm install nodejs index.js ``` To view visualization from stat1002: ``` ssh -N bast1001.wikimedia.org -L 3000:stat1002.eqiad.wmnet:3000 ``` Then browse to http://localhost:3000 Change-Id: I62b50f052cdc66144854a16696389a439b2c31e2 --- A realtime_trending_viz/index.html A realtime_trending_viz/index.js A realtime_trending_viz/static/cloud.js A realtime_trending_viz/static/d3.layout.cloud.js M refinery-job/pom.xml A refinery-job/src/main/scala/org/wikimedia/analytics/refinery/job/RealtimeTrendingPages.scala 6 files changed, 946 insertions(+), 0 deletions(-) git pull ssh://gerrit.wikimedia.org:29418/analytics/refinery/source refs/changes/85/225485/1 diff --git a/realtime_trending_viz/index.html b/realtime_trending_viz/index.html new file mode 100644 index 0000000..03f3c95 --- /dev/null +++ b/realtime_trending_viz/index.html @@ -0,0 +1,127 @@ +<!doctype html> +<html> + <head> + <title>Wikimedia Pageviews</title> + <link rel='stylesheet' type='text/css' + href='//cdnjs.cloudflare.com/ajax/libs/rickshaw/1.5.1/rickshaw.min.css'> + + <style> + * { margin: 0; padding: 0; } + html, body { width: 100%; height: 100%; } + .top, .bottom { width: 100%; height: 100%; left: 0; right: 0; position: fixed; } + .top { height: 52%; top: 8%; } + .bottom { height: 40%; bottom: 0; } + + .header { position: fixed; top: 10px; left: 20px; } + .header span:nth-child(1) { font-size: 18pt; } + .header span:nth-child(2) { color: #777; } + </style> + </head> + <body> + <section class="header"> + <span data-bind="text: hoverText, style: {color: articleColor}"></span> + <span data-bind="text: hoverInfo"></span> + </section> + <section class="top"> + <div class="cloud"></div> + </section> + <section class="bottom"> + <div class="total"></div> + </section> + + <script src='/socket.io/socket.io.js'></script> + <!--<script src="https://cdn.socket.io/socket.io-1.2.0.js"></script>--> + <script src='//cdnjs.cloudflare.com/ajax/libs/d3/3.5.5/d3.min.js'></script> + <script src='/static/d3.layout.cloud.js'></script> + <script src='//cdnjs.cloudflare.com/ajax/libs/knockout/3.2.0/knockout-min.js'></script> + <script src='//cdnjs.cloudflare.com/ajax/libs/jquery/2.1.3/jquery.min.js'></script> + <script src='//cdnjs.cloudflare.com/ajax/libs/rickshaw/1.5.1/rickshaw.min.js'></script> + <script src='/static/cloud.js'></script> + + <script> + var topTitle = 'Top 10 Articles'; + var topSubtitle = 'all wikis, last 10 minutes'; + var topTitleColor = '#2ca02c'; + var vm = { + hoverText: ko.observable(topTitle), + hoverInfo: ko.observable(topSubtitle), + articleColor: ko.observable(topTitleColor) + }; + ko.applyBindings(vm); + + var socket = io(); + + socket.on('total', function(newTotal){ + graph.series.addData({ one: +newTotal }); + graph.render(); + }); + + // Total count of all pageviews graph + var graph = new Rickshaw.Graph( { + element: $('.total')[0], + width: $('.bottom').innerWidth(), + height: $('.bottom').innerHeight(), + renderer: 'line', + series: new Rickshaw.Series.FixedDuration([{ + name: 'Total Pageviews' + }], undefined, { + timeInterval: 1000, + maxDataPoints: 100 + }) + } ); + graph.series[0].color = topTitleColor; + + var xAxis = new Rickshaw.Graph.Axis.Time({ graph: graph }); + var yAxis = new Rickshaw.Graph.Axis.Y({ + graph: graph, + orientation: 'right', + tickFormat: Rickshaw.Fixtures.Number.formatKMBT + }); + + var hoverDetail = new Rickshaw.Graph.HoverDetail( { + graph: graph + } ); + + graph.render(); + xAxis.render(); + yAxis.render(); + + + // render the top 10 cloud + var top10cloud = cloudLayout( + '.cloud', + 12, 60, + $('.top').innerWidth(), $('.top').innerHeight(), + function (d) { + vm.hoverText(d.text); + vm.articleColor(top10cloud.fillScale(d.article)); + vm.hoverInfo(d.count + ' views, last 10 minutes'); + }, + function (d) { + vm.hoverText(topTitle); + vm.articleColor(topTitleColor); + vm.hoverInfo(topSubtitle); + } + ); + + socket.on('tops', function(articles){ + var words = articles.split(';').map(function (article){ + var a = article.split('='); + return {article: decodeURIComponent(a[0]).replace(/_/g,' '), count: a[1]}; + }); + var counts = words.map(function(a){ return +a.count; }); + top10cloud.fontSizeScale.domain(d3.extent(counts)); + + top10cloud.stop().words(words).start(); + }); + + // really cool: + function downloadSVG() { + d3.select(this).attr("href", "data:image/svg+xml;charset=utf-8;base64," + btoa(unescape(encodeURIComponent( + svg.attr("version", "1.1") + .attr("xmlns", "http://www.w3.org/2000/svg") + .node().parentNode.innerHTML)))); + } + </script> + </body> +</html> diff --git a/realtime_trending_viz/index.js b/realtime_trending_viz/index.js new file mode 100644 index 0000000..1fd87aa --- /dev/null +++ b/realtime_trending_viz/index.js @@ -0,0 +1,25 @@ +var express = require('express'); +var path = require('path'); +var app = express(); +var http = require('http').Server(app); +var io = require('socket.io')(http); + +app.use('/static', express.static(path.join(__dirname, 'static'))); + +app.get('/', function(req, res){ + console.log(req.route); + res.sendFile(path.join(__dirname, 'index.html')); +}); + +app.get('/update/:type/:msg', function(req, res){ + console.log(new Date(), req.params); + io.emit(req.params.type, req.params.msg); + res.end(); +}); + +//io.on('connection', function(socket){ +//}); + +http.listen(3000, function(){ + console.log('listening on *:3000'); +}); diff --git a/realtime_trending_viz/static/cloud.js b/realtime_trending_viz/static/cloud.js new file mode 100644 index 0000000..f8da65d --- /dev/null +++ b/realtime_trending_viz/static/cloud.js @@ -0,0 +1,108 @@ +function cloudLayout(selector, smallFont, largeFont, w, h, hoverinCallback, hoveroutCallback) { + var fill = d3.scale.category20b(); + + var fontSize = d3.scale.linear().range([smallFont, largeFont]).clamp(true), + container = d3.select(selector); + + function progress() { return; } + function getArticle(d) { return d.article; } + + var a = 0; + function draw(data, bounds) { + + var wordsJoin = data.map(getArticle).sort().join(''); + var oldWords = vis.selectAll('a').data(); + var oldWordsJoin = oldWords.map(getArticle).sort().join(''); + + var newWords = wordsJoin !== oldWordsJoin; + + var scale = bounds ? Math.min( + w / Math.abs(bounds[1].x - w / 2), + w / Math.abs(bounds[0].x - w / 2), + h / Math.abs(bounds[1].y - h / 2), + h / Math.abs(bounds[0].y - h / 2)) / 2 : 1; + + var text = vis.selectAll("a") + .data(data, function(d) { return d.text; }); + + var animateText = text.transition().duration(1000); + + if (newWords) { + animateText.attr("transform", function(d) { return "translate(" + [d.x, d.y] + ")rotate(" + d.rotate + ")"; }) + + text.enter().append('a') + .attr('xlink:href', function(d) { return d.article; }) + .attr('target', '_blank') + .attr("text-anchor", "middle") + .attr("transform", function(d) { return "translate(" + [d.x, d.y] + ")rotate(" + d.rotate + ")"; }) + .style("font-size", "1px") + .transition() + .duration(1000) + .style("font-size", function(d) { return d.size + "px"; }); + + text.style("font-family", function(d) { return d.font; }) + .style("fill", function(d) { return fill(d.article); }) + .append('text').text(function(d) { return d.text; }) + .on('mouseenter', hoverinCallback) + .on('mouseleave', hoveroutCallback); + + var exitGroup = background.append("g") + .attr("transform", vis.attr("transform")); + + var exitGroupNode = exitGroup.node(); + + text.exit().each(function() { + exitGroupNode.appendChild(this); + }); + exitGroup.transition() + .duration(1000) + .style("opacity", 1e-6) + .remove(); + vis.transition() + .delay(1000) + .duration(750) + .attr("transform", "translate(" + [w >> 1, h >> 1] + ")scale(" + scale + ")"); + } + + animateText.style("font-size", function(d) { return d.size + "px"; }); + } + + var layout = d3.layout.cloud() + .timeInterval(10) + .rotate(function (d) { return 0; }) + .size([w, h]) + .fontSize(function(d) { return fontSize(+d.count); }) + .text(function(d) { + var link = d.article, + wiki = link.indexOf('/wiki/'), + text = link.substr(wiki >= 0 ? wiki + 6 : 0); + return text; + }) + .on('word', progress) + .on('end', draw) + .font('Impact') + .spiral('archimedean'); + + layout.fontSizeScale = fontSize; + layout.fillScale = fill; + + var svg = container.append('svg') + .attr('width', w) + .attr('height', h), + background = svg.append('g'), + vis = svg.append('g') + .attr('transform', 'translate(' + [w >> 1, h >> 1] + ')'); + + return layout; +} + + +function downloadSVG() { + d3.select(this).attr('href', 'data:image/svg+xml;charset=utf-8;base64,' + btoa(unescape(encodeURIComponent( + svg.attr('version', '1.1') + .attr('xmlns', 'http://www.w3.org/2000/svg') + .node().parentNode.innerHTML)))); +} + + +//var tops = setInterval(function (){$.get('http://localhost:3000/update/tops/http%3A%2F%2Fde.wikipedia.org%2Fwiki%2FHelge_Schneider%3D' + (1017 + Math.random()*100) + '%3Bhttp%3A%2F%2Fes.wikipedia.org%2Fwiki%2FEj%C3%A9rcito_Nacional_de_Venezuela%3D' + (958 + Math.random()*100) + '%3Bhttp%3A%2F%2Fes.wikipedia.org%2Fwiki%2FCayo_Mario%3D' + (927 + Math.random()*100) + '%3Bhttp%3A%2F%2Fes.wikipedia.org%2Fwiki%2FMichelle_Bachelet%3D' + (862 + Math.random()*100) + '%3Bhttp%3A%2F%2Fen.wikipedia.org%2Fwiki%2FList_of_Avengers_Assemble_episodes%3D' + (720 + Math.random()*100) + '%3Bhttp%3A%2F%2Fen.wikipedia.org%2Fwiki%2FAcademy_Awards%3D' + (575 + Math.random()*100) + '%3Bhttp%3A%2F%2Fen.wikipedia.org%2Fwiki%2F87th_Academy_Awards%3D' + (472 + Math.random()*100) + '%3Bhttp%3A%2F%2Fen.m.wikipedia.org%2Fwiki%2FAngelsberg%3D' + (394 + Math.random()*100) + '%3Bhttp%3A%2F%2Fja.wikipedia.org%2Fwiki%2F%E5%AE%89%E8%97%A4%E7%99%BE%E7%A6%8F%3D' + (386 + Math.random()*100) + '%3Bhttp%3A%2F%2Fes.wikipedia.org%2Fwiki%2FLlanos%3D' + (374 + Math.random()*100));}, 5000); diff --git a/realtime_trending_viz/static/d3.layout.cloud.js b/realtime_trending_viz/static/d3.layout.cloud.js new file mode 100644 index 0000000..f035837 --- /dev/null +++ b/realtime_trending_viz/static/d3.layout.cloud.js @@ -0,0 +1,401 @@ +// Word cloud layout by Jason Davies, http://www.jasondavies.com/word-cloud/ +// Algorithm due to Jonathan Feinberg, http://static.mrfeinberg.com/bv_ch03.pdf +(function() { + function cloud() { + var size = [256, 256], + text = cloudText, + font = cloudFont, + fontSize = cloudFontSize, + fontStyle = cloudFontNormal, + fontWeight = cloudFontNormal, + rotate = cloudRotate, + padding = cloudPadding, + spiral = archimedeanSpiral, + words = [], + timeInterval = Infinity, + event = d3.dispatch("word", "end"), + timer = null, + cloud = {}; + + cloud.start = function() { + var board = zeroArray((size[0] >> 5) * size[1]), + bounds = null, + n = words.length, + i = -1, + tags = [], + data = words.map(function(d, i) { + d.text = text.call(this, d, i); + d.font = font.call(this, d, i); + d.style = fontStyle.call(this, d, i); + d.weight = fontWeight.call(this, d, i); + d.rotate = rotate.call(this, d, i); + d.size = ~~fontSize.call(this, d, i); + d.padding = padding.call(this, d, i); + return d; + }).sort(function(a, b) { return b.size - a.size; }); + + if (timer) clearInterval(timer); + timer = setInterval(step, 0); + step(); + + return cloud; + + function step() { + var start = +new Date, + d; + while (+new Date - start < timeInterval && ++i < n && timer) { + d = data[i]; + d.x = (size[0] * (Math.random() + .5)) >> 1; + d.y = (size[1] * (Math.random() + .5)) >> 1; + cloudSprite(d, data, i); + if (d.hasText && place(board, d, bounds)) { + tags.push(d); + event.word(d); + if (bounds) cloudBounds(bounds, d); + else bounds = [{x: d.x + d.x0, y: d.y + d.y0}, {x: d.x + d.x1, y: d.y + d.y1}]; + // Temporary hack + d.x -= size[0] >> 1; + d.y -= size[1] >> 1; + } + } + if (i >= n) { + cloud.stop(); + event.end(tags, bounds); + } + } + } + + cloud.stop = function() { + if (timer) { + clearInterval(timer); + timer = null; + } + return cloud; + }; + + cloud.timeInterval = function(x) { + if (!arguments.length) return timeInterval; + timeInterval = x == null ? Infinity : x; + return cloud; + }; + + function place(board, tag, bounds) { + var perimeter = [{x: 0, y: 0}, {x: size[0], y: size[1]}], + startX = tag.x, + startY = tag.y, + maxDelta = Math.sqrt(size[0] * size[0] + size[1] * size[1]), + s = spiral(size), + dt = Math.random() < .5 ? 1 : -1, + t = -dt, + dxdy, + dx, + dy; + + while (dxdy = s(t += dt)) { + dx = ~~dxdy[0]; + dy = ~~dxdy[1]; + + if (Math.min(dx, dy) > maxDelta) break; + + tag.x = startX + dx; + tag.y = startY + dy; + + if (tag.x + tag.x0 < 0 || tag.y + tag.y0 < 0 || + tag.x + tag.x1 > size[0] || tag.y + tag.y1 > size[1]) continue; + // TODO only check for collisions within current bounds. + if (!bounds || !cloudCollide(tag, board, size[0])) { + if (!bounds || collideRects(tag, bounds)) { + var sprite = tag.sprite, + w = tag.width >> 5, + sw = size[0] >> 5, + lx = tag.x - (w << 4), + sx = lx & 0x7f, + msx = 32 - sx, + h = tag.y1 - tag.y0, + x = (tag.y + tag.y0) * sw + (lx >> 5), + last; + for (var j = 0; j < h; j++) { + last = 0; + for (var i = 0; i <= w; i++) { + board[x + i] |= (last << msx) | (i < w ? (last = sprite[j * w + i]) >>> sx : 0); + } + x += sw; + } + delete tag.sprite; + return true; + } + } + } + return false; + } + + cloud.words = function(x) { + if (!arguments.length) return words; + words = x; + return cloud; + }; + + cloud.size = function(x) { + if (!arguments.length) return size; + size = [+x[0], +x[1]]; + return cloud; + }; + + cloud.font = function(x) { + if (!arguments.length) return font; + font = d3.functor(x); + return cloud; + }; + + cloud.fontStyle = function(x) { + if (!arguments.length) return fontStyle; + fontStyle = d3.functor(x); + return cloud; + }; + + cloud.fontWeight = function(x) { + if (!arguments.length) return fontWeight; + fontWeight = d3.functor(x); + return cloud; + }; + + cloud.rotate = function(x) { + if (!arguments.length) return rotate; + rotate = d3.functor(x); + return cloud; + }; + + cloud.text = function(x) { + if (!arguments.length) return text; + text = d3.functor(x); + return cloud; + }; + + cloud.spiral = function(x) { + if (!arguments.length) return spiral; + spiral = spirals[x + ""] || x; + return cloud; + }; + + cloud.fontSize = function(x) { + if (!arguments.length) return fontSize; + fontSize = d3.functor(x); + return cloud; + }; + + cloud.padding = function(x) { + if (!arguments.length) return padding; + padding = d3.functor(x); + return cloud; + }; + + return d3.rebind(cloud, event, "on"); + } + + function cloudText(d) { + return d.text; + } + + function cloudFont() { + return "serif"; + } + + function cloudFontNormal() { + return "normal"; + } + + function cloudFontSize(d) { + return Math.sqrt(d.value); + } + + function cloudRotate() { + return (~~(Math.random() * 6) - 3) * 30; + } + + function cloudPadding() { + return 1; + } + + // Fetches a monochrome sprite bitmap for the specified text. + // Load in batches for speed. + function cloudSprite(d, data, di) { + if (d.sprite) return; + c.clearRect(0, 0, (cw << 5) / ratio, ch / ratio); + var x = 0, + y = 0, + maxh = 0, + n = data.length; + --di; + while (++di < n) { + d = data[di]; + c.save(); + c.font = d.style + " " + d.weight + " " + ~~((d.size + 1) / ratio) + "px " + d.font; + var w = c.measureText(d.text + "m").width * ratio, + h = d.size << 1; + if (d.rotate) { + var sr = Math.sin(d.rotate * cloudRadians), + cr = Math.cos(d.rotate * cloudRadians), + wcr = w * cr, + wsr = w * sr, + hcr = h * cr, + hsr = h * sr; + w = (Math.max(Math.abs(wcr + hsr), Math.abs(wcr - hsr)) + 0x1f) >> 5 << 5; + h = ~~Math.max(Math.abs(wsr + hcr), Math.abs(wsr - hcr)); + } else { + w = (w + 0x1f) >> 5 << 5; + } + if (h > maxh) maxh = h; + if (x + w >= (cw << 5)) { + x = 0; + y += maxh; + maxh = 0; + } + if (y + h >= ch) break; + c.translate((x + (w >> 1)) / ratio, (y + (h >> 1)) / ratio); + if (d.rotate) c.rotate(d.rotate * cloudRadians); + c.fillText(d.text, 0, 0); + if (d.padding) c.lineWidth = 2 * d.padding, c.strokeText(d.text, 0, 0); + c.restore(); + d.width = w; + d.height = h; + d.xoff = x; + d.yoff = y; + d.x1 = w >> 1; + d.y1 = h >> 1; + d.x0 = -d.x1; + d.y0 = -d.y1; + d.hasText = true; + x += w; + } + var pixels = c.getImageData(0, 0, (cw << 5) / ratio, ch / ratio).data, + sprite = []; + while (--di >= 0) { + d = data[di]; + if (!d.hasText) continue; + var w = d.width, + w32 = w >> 5, + h = d.y1 - d.y0; + // Zero the buffer + for (var i = 0; i < h * w32; i++) sprite[i] = 0; + x = d.xoff; + if (x == null) return; + y = d.yoff; + var seen = 0, + seenRow = -1; + for (var j = 0; j < h; j++) { + for (var i = 0; i < w; i++) { + var k = w32 * j + (i >> 5), + m = pixels[((y + j) * (cw << 5) + (x + i)) << 2] ? 1 << (31 - (i % 32)) : 0; + sprite[k] |= m; + seen |= m; + } + if (seen) seenRow = j; + else { + d.y0++; + h--; + j--; + y++; + } + } + d.y1 = d.y0 + seenRow; + d.sprite = sprite.slice(0, (d.y1 - d.y0) * w32); + } + } + + // Use mask-based collision detection. + function cloudCollide(tag, board, sw) { + sw >>= 5; + var sprite = tag.sprite, + w = tag.width >> 5, + lx = tag.x - (w << 4), + sx = lx & 0x7f, + msx = 32 - sx, + h = tag.y1 - tag.y0, + x = (tag.y + tag.y0) * sw + (lx >> 5), + last; + for (var j = 0; j < h; j++) { + last = 0; + for (var i = 0; i <= w; i++) { + if (((last << msx) | (i < w ? (last = sprite[j * w + i]) >>> sx : 0)) + & board[x + i]) return true; + } + x += sw; + } + return false; + } + + function cloudBounds(bounds, d) { + var b0 = bounds[0], + b1 = bounds[1]; + if (d.x + d.x0 < b0.x) b0.x = d.x + d.x0; + if (d.y + d.y0 < b0.y) b0.y = d.y + d.y0; + if (d.x + d.x1 > b1.x) b1.x = d.x + d.x1; + if (d.y + d.y1 > b1.y) b1.y = d.y + d.y1; + } + + function collideRects(a, b) { + return a.x + a.x1 > b[0].x && a.x + a.x0 < b[1].x && a.y + a.y1 > b[0].y && a.y + a.y0 < b[1].y; + } + + function archimedeanSpiral(size) { + var e = size[0] / size[1]; + return function(t) { + return [e * (t *= .1) * Math.cos(t), t * Math.sin(t)]; + }; + } + + function rectangularSpiral(size) { + var dy = 4, + dx = dy * size[0] / size[1], + x = 0, + y = 0; + return function(t) { + var sign = t < 0 ? -1 : 1; + // See triangular numbers: T_n = n * (n + 1) / 2. + switch ((Math.sqrt(1 + 4 * sign * t) - sign) & 3) { + case 0: x += dx; break; + case 1: y += dy; break; + case 2: x -= dx; break; + default: y -= dy; break; + } + return [x, y]; + }; + } + + // TODO reuse arrays? + function zeroArray(n) { + var a = [], + i = -1; + while (++i < n) a[i] = 0; + return a; + } + + var cloudRadians = Math.PI / 180, + cw = 1 << 11 >> 5, + ch = 1 << 11, + canvas, + ratio = 1; + + if (typeof document !== "undefined") { + canvas = document.createElement("canvas"); + canvas.width = 1; + canvas.height = 1; + ratio = Math.sqrt(canvas.getContext("2d").getImageData(0, 0, 1, 1).data.length >> 2); + canvas.width = (cw << 5) / ratio; + canvas.height = ch / ratio; + } else { + // Attempt to use node-canvas. + canvas = new Canvas(cw << 5, ch); + } + + var c = canvas.getContext("2d"), + spirals = { + archimedean: archimedeanSpiral, + rectangular: rectangularSpiral + }; + c.fillStyle = c.strokeStyle = "red"; + c.textAlign = "center"; + + if (typeof module === "object" && module.exports) module.exports = cloud; + else (d3.layout || (d3.layout = {})).cloud = cloud; +})(); diff --git a/refinery-job/pom.xml b/refinery-job/pom.xml index 83a834c..6c8d803 100644 --- a/refinery-job/pom.xml +++ b/refinery-job/pom.xml @@ -78,6 +78,48 @@ <version>2.0.0</version> </dependency> + + + + + <dependency> + <groupId>org.apache.spark</groupId> + <artifactId>spark-streaming_2.10</artifactId> + <version>${spark.version}</version> + <scope>provided</scope> + </dependency> + + <dependency> + <groupId>org.apache.spark</groupId> + <artifactId>spark-streaming-kafka_2.10</artifactId> + <version>${spark.version}</version> + </dependency> + + <dependency> + <groupId>eu.piotrbuda</groupId> + <artifactId>scalawebsocket_2.10</artifactId> + <version>0.1.1</version> + </dependency> + + <dependency> + <groupId>org.json4s</groupId> + <artifactId>json4s_2.10</artifactId> + <version>3.2.11</version> + </dependency> + + <dependency> + <groupId>org.json4s</groupId> + <artifactId>json4s-jackson_2.10</artifactId> + <version>3.2.11</version> + </dependency> + + <dependency> + <groupId>org.scalaj</groupId> + <artifactId>scalaj-http_2.10</artifactId> + <version>1.1.4</version> + </dependency> + + </dependencies> <build> diff --git a/refinery-job/src/main/scala/org/wikimedia/analytics/refinery/job/RealtimeTrendingPages.scala b/refinery-job/src/main/scala/org/wikimedia/analytics/refinery/job/RealtimeTrendingPages.scala new file mode 100644 index 0000000..478ed16 --- /dev/null +++ b/refinery-job/src/main/scala/org/wikimedia/analytics/refinery/job/RealtimeTrendingPages.scala @@ -0,0 +1,243 @@ +package org.wikimedia.analytics.refinery.job + +import kafka.serializer.StringDecoder + +import org.apache.spark._ +import org.apache.spark.SparkContext._ +import org.apache.spark.streaming._ +import org.apache.spark.streaming.dstream.DStream +import org.apache.spark.streaming.StreamingContext._ +import org.apache.spark.streaming.kafka._ +import org.json4s +import org.json4s.jackson.JsonMethods +import scalaj.http.Http +import org.wikimedia.analytics.refinery.core.PageviewDefinition +import scalawebsocket.WebSocket + +/** + * Consumes messages from one or more topics in Kafka and does X + * Usage: RealtimeTrendingPages <consumer-group> <batch-interval> <window-length> <sliding-interval> <num-partitions> + * <group> is the name of kafka consumer group + * + * Example: + * `$ bin/run-example \ + * org.wikimedia.analytics.refinery.spark.RealtimeTrendingPages \ + * my-consumer-group` 1 10 1 12 + * + */ +object RealtimeTrendingPages { + + // domain model for a Webrequest + case class Webrequest( + hostname: String, + sequence: Long, + dt: String, + time_firstbyte: Double, + ip: String, + cache_status: String, + http_status: String, + response_size: Long, + http_method: String, + uri_host: String, + uri_path: String, + uri_query: String, + content_type: String, + referer: String, + x_forwarded_for: String, + user_agent: String, + accept_language: String, + x_analytics: String, + range: String + ) + + + implicit val formats = json4s.DefaultFormats + + + val mainPages = List( + "Main_Page", + "Wikipedia:Portada", + "Заглавная_страница", + "Pagina_principale", + "Wikipedia:Hauptseite", + "メインページ", + "Hoofdpagina", + "Wikipedia:首页", + "Wikipédia:Página_principal", + "Wikipédia:Accueil_principal", + "위키백과:대문", + "Portal:Huvudsida", + "Syahan_nga_Pakli", + "Unang_Panid", + "Trang_Chính", + "Wikipedia:Strona_główna", + "Portada", + "Головна_сторінка", + "Halaman_Utama", + "صفحهٔ_اصلی", + "Portal:Forside", + "Wikipedia:Etusivu", + "الصفحة_الرئيسية", + "Hlavní_strana", + "Главна_страна", + "Kezdőlap" + ) + + def createWebrequestFromJson(json: String): Webrequest = { + try { + return JsonMethods.parse(json).extract[Webrequest] + } catch { + case _: org.json4s.MappingException => System.err.println(s"ERROR: Failed to parse json: '$json', return empty Webrequest") + return Webrequest("-", 0, "-", 0.0, "-", "-", "-", 0, "-", "-", "-", "-", "-", "-", "-", "-", "-", "-", "-") + } + } + + def isPageview(webrequest: Webrequest) = { + (webrequest.x_analytics.contains("ns=0") && + PageviewDefinition.getInstance().isPageview( + webrequest.uri_host, + webrequest.uri_path, + webrequest.uri_query, + webrequest.http_status, + webrequest.content_type, + webrequest.user_agent + ) + ) + } + + def getQueryParamValue(query: String, key: String): Option[String] = { + if (query.contains(key)) { + val startIndex = query.indexOf(key + "=") + (key + "=").length + var stopIndex = query.indexOf("&", startIndex) + if (stopIndex == -1) { + stopIndex = query.length + } + + return Some(query.substring(startIndex, stopIndex)) + } + + return None + } + + def stripMobile(uri_host: String) = { + val mobileSubdomain = ".m." + if (uri_host.contains(mobileSubdomain)) + uri_host.patch(uri_host.indexOf(mobileSubdomain), ".", mobileSubdomain.length) + else + uri_host + } + + def getNormalizedPageURI(webrequest: Webrequest): Option[String] = { + val title = PageviewDefinition.getInstance(). + getPageTitleFromUri(webrequest.uri_path, webrequest.uri_query) + // THIS filter LOGIC NEEDS TO BE IN A BETTER PLACE! + + if (title.isEmpty() || mainPages.contains(title)) + None + else + Some("http://" + stripMobile(webrequest.uri_host) + "/wiki/" + title) + } + + + + def printTopPageViews(topPages: Array[(Long, String)]) = { + println("--------------------------------------------------") + topPages.foreach { case (v,k) => println(s"$k\t$v") } + } + + + def sendToWebsocketApp(destination: String, value: String) = { + val uri = s"http://stat1002.eqiad.wmnet:3000/update/$destination/$value" + System.err.println("INFO: Sending request to " + uri) + try { + Http(uri).proxy("webproxy.eqiad.wmnet",8080).asString + } + catch { + case _: java.net.ConnectException => System.err.println("ERROR: Connection refused at " + uri) + } + } + + + + + + def viewCountsByPageURI( + webrequestStream: DStream[Webrequest], + windowLength: Long, + slidingInterval: Long + ): DStream[Tuple2[String,Long]] = { + + webrequestStream. + // Filter for pageviews only. + filter(isPageview). + // Normalize the uri_* fields into a page URI. + // This also filters out some unwanted pages (Main_Page, etc.) + map(getNormalizedPageURI). + // Remove any unwanted pages (These will be Nones instead of Somes) + flatMap(identity[Option[String]]). + // Count by the normalized page URIs over the last windowLength + // and emit counts every slidingInterval. + countByValueAndWindow(Seconds(windowLength), Seconds(slidingInterval)) + } + + + + def main(args: Array[String]) { + if (args.length < 5) { + System.err.println("Usage: RealtimeTrendingPages <batch-interval> <window-length> <sliding-interval> <consumer-group> <topics> <receiver-count>") + System.exit(1) + } + + val Array( + batchInterval, + windowLength, + slidingInterval, + consumerGroup, + topics + ) = args + + val brokers = "analytics1012.eqiad.wmnet:9092,analytics1018.eqiad.wmnet:9092,analytics1021.eqiad.wmnet:9092,analytics1022.eqiad.wmnet:9092" + + val sparkConf = new SparkConf().setAppName("RealtimeTrendingPages") + val ssc = new StreamingContext(sparkConf, Seconds(batchInterval.toLong)) + ssc.checkpoint("/tmp/RealtimeTrendingPages-spark-streaming-checkpoint") + + + val topicsSet = topics.split(",").toSet + val kafkaParams = Map[String, String]("metadata.broker.list" -> brokers) + + val messageStream = KafkaUtils.createDirectStream[String, String, StringDecoder, StringDecoder]( + ssc, + kafkaParams, + topicsSet + ) + + val webrequestStream = messageStream. + // Extract the JSON message from the Kafka (Key, Value) message. + map(_._2). + // Create a webrequest object from the JSON message. + map(createWebrequestFromJson) + + viewCountsByPageURI(webrequestStream, windowLength.toLong, slidingInterval.toLong). + map(_.swap). + foreachRDD( + rdd => { + val topPages = rdd.top(10) + printTopPageViews(topPages) + + //Send to socketio node cool thang + val topPagesString = topPages.map(t => java.net.URLEncoder.encode(t._2, "UTF-8") + "=" + t._1).mkString(";") + sendToWebsocketApp("tops", topPagesString) + } + ) + + + + + // print them! + // pageURICounts.foreachRDD(rdd => printTopPageViews(rdd.top(10))) + + ssc.start() + ssc.awaitTermination() + } +} \ No newline at end of file -- To view, visit https://gerrit.wikimedia.org/r/225485 To unsubscribe, visit https://gerrit.wikimedia.org/r/settings Gerrit-MessageType: newchange Gerrit-Change-Id: I62b50f052cdc66144854a16696389a439b2c31e2 Gerrit-PatchSet: 1 Gerrit-Project: analytics/refinery/source Gerrit-Branch: master Gerrit-Owner: Ottomata <[email protected]> _______________________________________________ MediaWiki-commits mailing list [email protected] https://lists.wikimedia.org/mailman/listinfo/mediawiki-commits
