First implementation, after fixing tons of handy-http bugs
This commit is contained in:
parent
386b1283bf
commit
1175f7f891
|
@ -0,0 +1,16 @@
|
||||||
|
.dub
|
||||||
|
docs.json
|
||||||
|
__dummy.html
|
||||||
|
docs/
|
||||||
|
/sitestat
|
||||||
|
sitestat.so
|
||||||
|
sitestat.dylib
|
||||||
|
sitestat.dll
|
||||||
|
sitestat.a
|
||||||
|
sitestat.lib
|
||||||
|
sitestat-test-*
|
||||||
|
*.exe
|
||||||
|
*.pdb
|
||||||
|
*.o
|
||||||
|
*.obj
|
||||||
|
*.lst
|
|
@ -0,0 +1,13 @@
|
||||||
|
{
|
||||||
|
"authors": [
|
||||||
|
"Andrew Lalis"
|
||||||
|
],
|
||||||
|
"copyright": "Copyright © 2023, Andrew Lalis",
|
||||||
|
"dependencies": {
|
||||||
|
"d-properties": "~>1.0.5",
|
||||||
|
"handy-httpd": "~>7.10.5"
|
||||||
|
},
|
||||||
|
"description": "A simple webserver and script for tracking basic, non-intrusive site statistics.",
|
||||||
|
"license": "MIT",
|
||||||
|
"name": "sitestat"
|
||||||
|
}
|
|
@ -0,0 +1,10 @@
|
||||||
|
{
|
||||||
|
"fileVersion": 1,
|
||||||
|
"versions": {
|
||||||
|
"d-properties": "1.0.5",
|
||||||
|
"handy-httpd": "7.10.5",
|
||||||
|
"httparsed": "1.2.1",
|
||||||
|
"slf4d": "2.4.3",
|
||||||
|
"streams": "3.5.0"
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,91 @@
|
||||||
|
/*
|
||||||
|
sitestat.js is meant to be included in the <head> like so:
|
||||||
|
|
||||||
|
<script src="path/to/sitestat.js" async></script>
|
||||||
|
|
||||||
|
See test-site/index.html for an example.
|
||||||
|
*/
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The global websocket singleton. It's initialized at the bottom of this script.
|
||||||
|
* @type {WebSocket | null}
|
||||||
|
*/
|
||||||
|
let WS = null;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sends some JSON object to sitestat.
|
||||||
|
* @param {object} data The data to send.
|
||||||
|
*/
|
||||||
|
function sendStat(data) {
|
||||||
|
if (WS.readyState === WebSocket.OPEN) {
|
||||||
|
WS.send(JSON.stringify(data));
|
||||||
|
} else {
|
||||||
|
console.warn("Couldn't send data because websocket is not open: ", data);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Handles any event encountered, and sends a small message to sitestat about it.
|
||||||
|
* @param {Event} event The event that occurred.
|
||||||
|
*/
|
||||||
|
function handleEvent(event) {
|
||||||
|
sendStat({
|
||||||
|
type: "event",
|
||||||
|
event: event.type
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Gets the remote URL that sitestat is running at, from the query params of
|
||||||
|
* the script's `src` attribute. Throws an error if such a URL could not be
|
||||||
|
* found.
|
||||||
|
* @returns {string} The remote URL to connect to.
|
||||||
|
*/
|
||||||
|
function getRemoteUrl() {
|
||||||
|
const scriptUrl = document.currentScript.src;
|
||||||
|
const paramsIdx = scriptUrl.indexOf("?");
|
||||||
|
if (paramsIdx !== -1) {
|
||||||
|
const paramsStr = scriptUrl.substring(paramsIdx);
|
||||||
|
const params = new URLSearchParams(paramsStr);
|
||||||
|
const remoteUrl = params.get("remote-url");
|
||||||
|
if (remoteUrl !== null) {
|
||||||
|
return remoteUrl;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
throw new Error("Missing `remote-url=...` query parameter on script src attribute.")
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
// The main script starts below:
|
||||||
|
if (window.navigator.webdriver) {
|
||||||
|
throw new Error("sitestat disabled for automated user agents.");
|
||||||
|
}
|
||||||
|
const remoteUrl = getRemoteUrl();
|
||||||
|
WS = new WebSocket(`ws://${remoteUrl}/ws`);
|
||||||
|
WS.onopen = () => {
|
||||||
|
// As soon as the connection is established, send some basic information
|
||||||
|
// about the current browsing session.
|
||||||
|
console.info(
|
||||||
|
"📈 Established a connection to %csitestat%c for %cnon-intrusive, non-identifiable%csite analytics. Learn more here: https://github.com/andrewlalis/sitestat",
|
||||||
|
"font-weight: bold; font-style: italic; font-size: large; color: #32a852; background-color: #2e2e2e; padding: 5px;",
|
||||||
|
"",
|
||||||
|
"font-style: italic;"
|
||||||
|
);
|
||||||
|
sendStat({
|
||||||
|
type: "ident",
|
||||||
|
href: window.location.href,
|
||||||
|
userAgent: window.navigator.userAgent,
|
||||||
|
viewport: {
|
||||||
|
width: window.innerWidth,
|
||||||
|
height: window.innerHeight
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
WS.onerror = console.error;
|
||||||
|
|
||||||
|
// Register various event listeners.
|
||||||
|
const events = ["click", "keyup", "keydown", "scroll", "copy"];
|
||||||
|
for (let i = 0; i < events.length; i++) {
|
||||||
|
document.addEventListener(events[i], handleEvent);
|
||||||
|
}
|
|
@ -0,0 +1,52 @@
|
||||||
|
import handy_httpd;
|
||||||
|
import handy_httpd.handlers.path_delegating_handler;
|
||||||
|
|
||||||
|
void main() {
|
||||||
|
import slf4d;
|
||||||
|
import slf4d.default_provider;
|
||||||
|
auto provider = new shared DefaultProvider(true, Levels.INFO);
|
||||||
|
// provider.getLoggerFactory().setModuleLevel("live_tracker", Levels.DEBUG);
|
||||||
|
configureLoggingProvider(provider);
|
||||||
|
|
||||||
|
new HttpServer(prepareHandler(), prepareConfig()).start();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Prepares the main request handler for the server.
|
||||||
|
* Returns: The request handler.
|
||||||
|
*/
|
||||||
|
private HttpRequestHandler prepareHandler() {
|
||||||
|
import live_tracker;
|
||||||
|
PathDelegatingHandler pathHandler = new PathDelegatingHandler();
|
||||||
|
|
||||||
|
pathHandler.addMapping(Method.GET, "/ws", new WebSocketHandler(new LiveTracker()));
|
||||||
|
|
||||||
|
return pathHandler;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Prepares the server's configuration using sensible default values that can
|
||||||
|
* be overridded by a "sitestat.properties" file in the program's working dir.
|
||||||
|
* Returns: The config to use.
|
||||||
|
*/
|
||||||
|
private ServerConfig prepareConfig() {
|
||||||
|
import std.file;
|
||||||
|
import d_properties;
|
||||||
|
ServerConfig config = ServerConfig.defaultValues();
|
||||||
|
config.workerPoolSize = 3;
|
||||||
|
config.port = 8081;
|
||||||
|
config.enableWebSockets = true;
|
||||||
|
if (exists("sitestat.properties")) {
|
||||||
|
Properties props = Properties("sitestat.properties");
|
||||||
|
if (props.has("server.host")) {
|
||||||
|
config.hostname = props.get("host");
|
||||||
|
}
|
||||||
|
if (props.has("server.port")) {
|
||||||
|
config.port = props.get!ushort("server.port");
|
||||||
|
}
|
||||||
|
if (props.has("server.workers")) {
|
||||||
|
config.workerPoolSize = props.get!size_t("server.workers");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return config;
|
||||||
|
}
|
|
@ -0,0 +1,88 @@
|
||||||
|
module live_tracker;
|
||||||
|
|
||||||
|
import handy_httpd.components.websocket;
|
||||||
|
import slf4d;
|
||||||
|
import std.uuid;
|
||||||
|
import std.datetime;
|
||||||
|
import std.json;
|
||||||
|
import std.string;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A websocket message handler that keeps track of each connected session, and
|
||||||
|
* records information about that session's activities.
|
||||||
|
*/
|
||||||
|
class LiveTracker : WebSocketMessageHandler {
|
||||||
|
private StatSession[UUID] sessions;
|
||||||
|
private immutable uint minSessionDurationMillis = 1000;
|
||||||
|
|
||||||
|
override void onConnectionEstablished(WebSocketConnection conn) {
|
||||||
|
debugF!"Connection established: %s"(conn.getId());
|
||||||
|
sessions[conn.getId()] = StatSession(
|
||||||
|
conn.getId(),
|
||||||
|
Clock.currTime(UTC())
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
override void onTextMessage(WebSocketTextMessage msg) {
|
||||||
|
debugF!"Got message from %s: %s"(msg.conn.getId(), msg.payload);
|
||||||
|
StatSession* session = msg.conn.getId() in sessions;
|
||||||
|
if (session is null) {
|
||||||
|
warnF!"Got a websocket text message from a client without a session: %s"(msg.conn.getId());
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
JSONValue obj = parseJSON(msg.payload);
|
||||||
|
immutable string msgType = obj.object["type"].str;
|
||||||
|
if (msgType == MessageTypes.IDENT) {
|
||||||
|
string fullUrl = obj.object["href"].str;
|
||||||
|
session.href = fullUrl;
|
||||||
|
ptrdiff_t paramsIdx = std.string.indexOf(fullUrl, '?');
|
||||||
|
if (paramsIdx == -1) {
|
||||||
|
session.url = fullUrl;
|
||||||
|
} else {
|
||||||
|
session.url = fullUrl[0 .. paramsIdx];
|
||||||
|
}
|
||||||
|
session.userAgent = obj.object["userAgent"].str;
|
||||||
|
} else if (msgType == MessageTypes.EVENT) {
|
||||||
|
session.events ~= EventRecord(
|
||||||
|
Clock.currTime(UTC()),
|
||||||
|
obj.object["event"].str
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override void onConnectionClosed(WebSocketConnection conn) {
|
||||||
|
debugF!"Connection closed: %s"(conn.getId());
|
||||||
|
StatSession* session = conn.getId() in sessions;
|
||||||
|
if (session !is null && session.isValid) {
|
||||||
|
Duration dur = Clock.currTime(UTC()) - session.connectedAt;
|
||||||
|
if (dur.total!"msecs" >= minSessionDurationMillis) {
|
||||||
|
infoF!"Session lasted %d seconds, %d events."(dur.total!"seconds", session.events.length);
|
||||||
|
infoF!"%s, %s"(session.href, session.userAgent);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
sessions.remove(conn.getId());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private enum MessageTypes : string {
|
||||||
|
IDENT = "ident",
|
||||||
|
EVENT = "event"
|
||||||
|
}
|
||||||
|
|
||||||
|
struct StatSession {
|
||||||
|
UUID id;
|
||||||
|
SysTime connectedAt;
|
||||||
|
string url = null;
|
||||||
|
string href = null;
|
||||||
|
string userAgent = null;
|
||||||
|
EventRecord[] events;
|
||||||
|
|
||||||
|
bool isValid() const {
|
||||||
|
return url !is null && href !is null && userAgent !is null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
struct EventRecord {
|
||||||
|
SysTime timestamp;
|
||||||
|
string eventType;
|
||||||
|
}
|
|
@ -0,0 +1,18 @@
|
||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
|
||||||
|
<head>
|
||||||
|
<title>About this Site</title>
|
||||||
|
|
||||||
|
<script src="../sitestat.js?remote-url=localhost:8081" async></script>
|
||||||
|
</head>
|
||||||
|
|
||||||
|
<body>
|
||||||
|
<h1>About this Site</h1>
|
||||||
|
<a href="index.html">Back to index.html</a>
|
||||||
|
<p>
|
||||||
|
This is a page with information about this testing website.
|
||||||
|
</p>
|
||||||
|
</body>
|
||||||
|
|
||||||
|
</html>
|
|
@ -0,0 +1,23 @@
|
||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
|
||||||
|
<head>
|
||||||
|
<title>Sample Page</title>
|
||||||
|
|
||||||
|
<script src="../sitestat.js?remote-url=localhost:8081" async></script>
|
||||||
|
</head>
|
||||||
|
|
||||||
|
<body>
|
||||||
|
<h1>Testing Site</h1>
|
||||||
|
<nav>
|
||||||
|
<ul>
|
||||||
|
<li><a href="about.html">About</a></li>
|
||||||
|
</ul>
|
||||||
|
</nav>
|
||||||
|
<p>
|
||||||
|
This is a testing site used for testing the various features of <em>sitestat</em>.
|
||||||
|
</p>
|
||||||
|
<button id="test-button">Click me!</button>
|
||||||
|
</body>
|
||||||
|
|
||||||
|
</html>
|
Loading…
Reference in New Issue