Added improved reporting and utils
This commit is contained in:
parent
aabec581b2
commit
391fc48829
|
@ -0,0 +1,9 @@
|
||||||
|
#!/usr/bin/env bash
|
||||||
|
|
||||||
|
echo "Building sitestat"
|
||||||
|
dub clean
|
||||||
|
dub build --build=release --compiler=/opt/ldc2/ldc2-1.33.0-linux-x86_64/bin/ldc2
|
||||||
|
|
||||||
|
ssh -f root@andrewlalis.com 'systemctl stop sitestat.service'
|
||||||
|
scp sitestat root@andrewlalis.com:/opt/sitestat/
|
||||||
|
ssh -f root@andrewlalis.com 'systemctl start sitestat.service'
|
2
dub.json
2
dub.json
|
@ -5,7 +5,7 @@
|
||||||
"copyright": "Copyright © 2023, Andrew Lalis",
|
"copyright": "Copyright © 2023, Andrew Lalis",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"d-properties": "~>1.0.5",
|
"d-properties": "~>1.0.5",
|
||||||
"handy-httpd": "~>7.11.0",
|
"handy-httpd": "~>7.11.1",
|
||||||
"d2sqlite3": "~>1.0.0"
|
"d2sqlite3": "~>1.0.0"
|
||||||
},
|
},
|
||||||
"description": "A simple webserver and script for tracking basic, non-intrusive site statistics.",
|
"description": "A simple webserver and script for tracking basic, non-intrusive site statistics.",
|
||||||
|
|
|
@ -3,7 +3,7 @@
|
||||||
"versions": {
|
"versions": {
|
||||||
"d-properties": "1.0.5",
|
"d-properties": "1.0.5",
|
||||||
"d2sqlite3": "1.0.0",
|
"d2sqlite3": "1.0.0",
|
||||||
"handy-httpd": "7.11.0",
|
"handy-httpd": "7.11.1",
|
||||||
"httparsed": "1.2.1",
|
"httparsed": "1.2.1",
|
||||||
"slf4d": "2.4.3",
|
"slf4d": "2.4.3",
|
||||||
"streams": "3.5.0"
|
"streams": "3.5.0"
|
||||||
|
|
|
@ -0,0 +1,13 @@
|
||||||
|
[Unit]
|
||||||
|
Description=sitestat
|
||||||
|
After=network.target
|
||||||
|
|
||||||
|
[Service]
|
||||||
|
Type=simple
|
||||||
|
User=root
|
||||||
|
WorkingDirectory=/opt/sitestat
|
||||||
|
ExecStart=/opt/sitestat/sitestat
|
||||||
|
Restart=always
|
||||||
|
|
||||||
|
[Install]
|
||||||
|
WantedBy=multi-user.target
|
|
@ -2,6 +2,13 @@ import server : startServer;
|
||||||
import report : makeReport;
|
import report : makeReport;
|
||||||
|
|
||||||
void main(string[] args) {
|
void main(string[] args) {
|
||||||
|
import slf4d;
|
||||||
|
import slf4d.default_provider;
|
||||||
|
auto provider = new shared DefaultProvider(false, Levels.INFO);
|
||||||
|
provider.getLoggerFactory().setModuleLevelPrefix("handy_httpd", Levels.WARN);
|
||||||
|
// provider.getLoggerFactory().setModuleLevel("live_tracker", Levels.DEBUG);
|
||||||
|
configureLoggingProvider(provider);
|
||||||
|
|
||||||
if (args.length <= 1) {
|
if (args.length <= 1) {
|
||||||
startServer();
|
startServer();
|
||||||
} else if (args[1] == "report") {
|
} else if (args[1] == "report") {
|
||||||
|
|
|
@ -1,8 +1,8 @@
|
||||||
module data;
|
module data;
|
||||||
|
|
||||||
|
import utils;
|
||||||
import std.file;
|
import std.file;
|
||||||
import std.datetime;
|
import std.datetime;
|
||||||
import std.format;
|
|
||||||
import d2sqlite3;
|
import d2sqlite3;
|
||||||
|
|
||||||
static immutable FS_ORIGIN = "_LOCAL_FILESYSTEM_";
|
static immutable FS_ORIGIN = "_LOCAL_FILESYSTEM_";
|
||||||
|
@ -30,8 +30,8 @@ void storeSession(StoredSession s) {
|
||||||
"(start_timestamp, end_timestamp, url, full_url, user_agent, event_count) " ~
|
"(start_timestamp, end_timestamp, url, full_url, user_agent, event_count) " ~
|
||||||
"VALUES (?, ?, ?, ?, ?, ?)"
|
"VALUES (?, ?, ?, ?, ?, ?)"
|
||||||
);
|
);
|
||||||
stmt.bind(1, formatTimestamp(s.startTimestamp));
|
stmt.bind(1, formatSqliteTimestamp(s.startTimestamp));
|
||||||
stmt.bind(2, formatTimestamp(s.endTimestamp));
|
stmt.bind(2, formatSqliteTimestamp(s.endTimestamp));
|
||||||
stmt.bind(3, s.url);
|
stmt.bind(3, s.url);
|
||||||
stmt.bind(4, s.fullUrl);
|
stmt.bind(4, s.fullUrl);
|
||||||
stmt.bind(5, s.userAgent);
|
stmt.bind(5, s.userAgent);
|
||||||
|
@ -63,62 +63,3 @@ private void initDb(string path) {
|
||||||
SQL"
|
SQL"
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
ulong countSessions(string origin) {
|
|
||||||
Database db = getOrCreateDatabase(origin);
|
|
||||||
return db.execute("SELECT COUNT(id) FROM session;").oneValue!ulong;
|
|
||||||
}
|
|
||||||
|
|
||||||
private string formatTimestamp(SysTime t) {
|
|
||||||
return format!"%04d-%02d-%02d %02d:%02d:%02d"(
|
|
||||||
t.year, t.month, t.day,
|
|
||||||
t.hour, t.minute, t.second
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
string extractOrigin(string url) {
|
|
||||||
import std.algorithm : countUntil, startsWith;
|
|
||||||
ptrdiff_t idx = countUntil(url, "://");
|
|
||||||
if (idx == -1) return null;
|
|
||||||
string origin = url[idx + 3 .. $];
|
|
||||||
ptrdiff_t trailingSlashIdx = countUntil(origin, "/");
|
|
||||||
if (trailingSlashIdx != -1) {
|
|
||||||
origin = origin[0 .. trailingSlashIdx];
|
|
||||||
}
|
|
||||||
if (startsWith(origin, "www.")) {
|
|
||||||
origin = origin[4 .. $];
|
|
||||||
}
|
|
||||||
return origin;
|
|
||||||
}
|
|
||||||
|
|
||||||
unittest {
|
|
||||||
assert(extractOrigin("https://www.google.com/search") == "google.com");
|
|
||||||
assert(extractOrigin("https://litelist.andrewlalis.com") == "litelist.andrewlalis.com");
|
|
||||||
}
|
|
||||||
|
|
||||||
string dbPath(string origin) {
|
|
||||||
return "sitestat-db_" ~ origin ~ ".sqlite";
|
|
||||||
}
|
|
||||||
|
|
||||||
string originFromDbPath(string path) {
|
|
||||||
import std.algorithm : countUntil;
|
|
||||||
ptrdiff_t idx = countUntil(path, "sitestat-db_");
|
|
||||||
if (idx == -1) return null;
|
|
||||||
return path[(idx + 12)..$-7];
|
|
||||||
}
|
|
||||||
|
|
||||||
unittest {
|
|
||||||
assert(originFromDbPath("sitestat-db__LOCAL_FILESYSTEM_.sqlite") == "_LOCAL_FILESYSTEM_");
|
|
||||||
}
|
|
||||||
|
|
||||||
string[] listAllOrigins(string dir = ".") {
|
|
||||||
import std.array;
|
|
||||||
auto app = appender!(string[]);
|
|
||||||
foreach (DirEntry entry; dirEntries(dir, SpanMode.shallow, false)) {
|
|
||||||
string origin = originFromDbPath(entry.name);
|
|
||||||
if (origin !is null) {
|
|
||||||
app ~= origin;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return app[];
|
|
||||||
}
|
|
||||||
|
|
|
@ -16,7 +16,7 @@ import core.sync.rwmutex;
|
||||||
*/
|
*/
|
||||||
class LiveTracker : WebSocketMessageHandler {
|
class LiveTracker : WebSocketMessageHandler {
|
||||||
private StatSession[UUID] sessions;
|
private StatSession[UUID] sessions;
|
||||||
static immutable uint DEFAULT_MIN_SESSION_DURATION = 1000;
|
static immutable uint DEFAULT_MIN_SESSION_DURATION = 2000;
|
||||||
private immutable uint minSessionDurationMillis;
|
private immutable uint minSessionDurationMillis;
|
||||||
private TaskPool sessionPersistencePool;
|
private TaskPool sessionPersistencePool;
|
||||||
private ReadWriteMutex sessionsMutex;
|
private ReadWriteMutex sessionsMutex;
|
||||||
|
@ -35,7 +35,7 @@ class LiveTracker : WebSocketMessageHandler {
|
||||||
Clock.currTime(UTC())
|
Clock.currTime(UTC())
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
infoF!"Started tracking session %s"(conn.id);
|
infoF!"Started tracking session %s. Tracking %d sessions right now."(conn.id, sessions.length);
|
||||||
}
|
}
|
||||||
|
|
||||||
override void onTextMessage(WebSocketTextMessage msg) {
|
override void onTextMessage(WebSocketTextMessage msg) {
|
||||||
|
@ -70,17 +70,17 @@ class LiveTracker : WebSocketMessageHandler {
|
||||||
Duration dur = endTimestamp - session.connectedAt;
|
Duration dur = endTimestamp - session.connectedAt;
|
||||||
if (dur.total!"msecs" >= minSessionDurationMillis) {
|
if (dur.total!"msecs" >= minSessionDurationMillis) {
|
||||||
infoF!"Session lasted %d seconds, %d events."(dur.total!"seconds", session.eventCount);
|
infoF!"Session lasted %d seconds, %d events."(dur.total!"seconds", session.eventCount);
|
||||||
|
immutable storedSession = StoredSession(
|
||||||
|
-1,
|
||||||
|
session.connectedAt,
|
||||||
|
endTimestamp,
|
||||||
|
session.url,
|
||||||
|
session.href,
|
||||||
|
session.userAgent,
|
||||||
|
session.eventCount
|
||||||
|
);
|
||||||
|
this.sessionPersistencePool.put(task!storeSession(storedSession));
|
||||||
}
|
}
|
||||||
immutable storedSession = StoredSession(
|
|
||||||
-1,
|
|
||||||
session.connectedAt,
|
|
||||||
endTimestamp,
|
|
||||||
session.url,
|
|
||||||
session.href,
|
|
||||||
session.userAgent,
|
|
||||||
session.eventCount
|
|
||||||
);
|
|
||||||
this.sessionPersistencePool.put(task!storeSession(storedSession));
|
|
||||||
}
|
}
|
||||||
synchronized(sessionsMutex.writer) {
|
synchronized(sessionsMutex.writer) {
|
||||||
sessions.remove(conn.id);
|
sessions.remove(conn.id);
|
||||||
|
|
|
@ -1,17 +1,25 @@
|
||||||
module report;
|
module report;
|
||||||
|
|
||||||
|
import utils;
|
||||||
import std.stdio;
|
import std.stdio;
|
||||||
import std.datetime;
|
import std.datetime;
|
||||||
import std.algorithm;
|
import std.algorithm;
|
||||||
import std.array;
|
import std.array;
|
||||||
|
import d2sqlite3;
|
||||||
import data;
|
import data;
|
||||||
|
import std.json;
|
||||||
|
|
||||||
void makeReport(string[] sites) {
|
void makeReport(string[] sites) {
|
||||||
string[] sitesToReport = sites.length == 0 ? listAllOrigins() : sites;
|
string[] sitesToReport = sites.length == 0 ? listAllOrigins() : sites;
|
||||||
|
if (sitesToReport.length == 0) {
|
||||||
|
writeln("No sites to report.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
writeln("Creating report for " ~ createSiteNamesSummary(sitesToReport) ~ ".");
|
writeln("Creating report for " ~ createSiteNamesSummary(sitesToReport) ~ ".");
|
||||||
foreach (site; sitesToReport) {
|
foreach (site; sitesToReport) {
|
||||||
writeln("Site: " ~ site);
|
writeln("Site: " ~ site);
|
||||||
writefln!"\nTotal sessions recorded: %d"(countSessions(site));
|
SiteReport report = generateReport(site, Clock.currTime(UTC()) - hours(48), Clock.currTime(UTC()));
|
||||||
|
writeln(report.toJson().toPrettyString());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -37,5 +45,55 @@ struct SiteReport {
|
||||||
SysTime periodStart;
|
SysTime periodStart;
|
||||||
SysTime periodEnd;
|
SysTime periodEnd;
|
||||||
|
|
||||||
ulong totalVisitors;
|
ulong totalSessions;
|
||||||
|
double meanSessionDurationSeconds;
|
||||||
|
double meanEventsPerSession;
|
||||||
|
|
||||||
|
JSONValue toJson() const {
|
||||||
|
JSONValue obj = JSONValue(string[string].init);
|
||||||
|
obj.object["siteName"] = siteName;
|
||||||
|
obj.object["periodStart"] = periodStart.toISOExtString();
|
||||||
|
obj.object["periodEnd"] = periodEnd.toISOExtString();
|
||||||
|
obj.object["totalSessions"] = totalSessions;
|
||||||
|
obj.object["meanSessionDurationSeconds"] = meanSessionDurationSeconds;
|
||||||
|
obj.object["meanEventsPerSession"] = meanEventsPerSession;
|
||||||
|
return obj;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
SiteReport generateReport(string site, SysTime periodStart, SysTime periodEnd) {
|
||||||
|
SiteReport report;
|
||||||
|
report.siteName = site;
|
||||||
|
report.periodStart = periodStart;
|
||||||
|
report.periodEnd = periodEnd;
|
||||||
|
immutable string TS_START = formatSqliteTimestamp(periodStart);
|
||||||
|
immutable string TS_END = formatSqliteTimestamp(periodEnd);
|
||||||
|
writefln!"TS_START = %s, TS_END = %s"(TS_START, TS_END);
|
||||||
|
Database db = getOrCreateDatabase(site);
|
||||||
|
|
||||||
|
report.totalSessions = db.execute(q"SQL
|
||||||
|
SELECT COUNT(id)
|
||||||
|
FROM session
|
||||||
|
WHERE start_timestamp >= ? AND end_timestamp <= ?
|
||||||
|
SQL",
|
||||||
|
TS_START, TS_END
|
||||||
|
).oneValue!ulong();
|
||||||
|
|
||||||
|
report.meanEventsPerSession = db.execute(q"SQL
|
||||||
|
SELECT AVG(event_count)
|
||||||
|
FROM session
|
||||||
|
WHERE start_timestamp >= ? AND end_timestamp <= ?
|
||||||
|
SQL",
|
||||||
|
TS_START, TS_END
|
||||||
|
).oneValue!double();
|
||||||
|
|
||||||
|
report.meanSessionDurationSeconds = db.execute(q"SQL
|
||||||
|
SELECT AVG((julianday(end_timestamp) - julianday(start_timestamp)) * 24 * 60 * 60)
|
||||||
|
FROM session
|
||||||
|
WHERE start_timestamp >= ? AND end_timestamp <= ?
|
||||||
|
SQL",
|
||||||
|
TS_START, TS_END
|
||||||
|
).oneValue!double();
|
||||||
|
|
||||||
|
return report;
|
||||||
}
|
}
|
||||||
|
|
|
@ -4,20 +4,15 @@ import std.file;
|
||||||
import handy_httpd;
|
import handy_httpd;
|
||||||
import handy_httpd.handlers.path_delegating_handler;
|
import handy_httpd.handlers.path_delegating_handler;
|
||||||
import d_properties;
|
import d_properties;
|
||||||
|
import slf4d;
|
||||||
|
|
||||||
void startServer() {
|
void startServer() {
|
||||||
Properties props;
|
Properties props;
|
||||||
if (exists("sitestat.properties")) {
|
if (exists("sitestat.properties")) {
|
||||||
props = Properties("sitestat.properties");
|
props = Properties("sitestat.properties");
|
||||||
|
} else {
|
||||||
|
warn("No sitestat.properties file was found. Using defaults!");
|
||||||
}
|
}
|
||||||
|
|
||||||
import slf4d;
|
|
||||||
import slf4d.default_provider;
|
|
||||||
auto provider = new shared DefaultProvider(true, Levels.INFO);
|
|
||||||
provider.getLoggerFactory().setModuleLevelPrefix("handy_httpd", Levels.WARN);
|
|
||||||
// provider.getLoggerFactory().setModuleLevel("live_tracker", Levels.DEBUG);
|
|
||||||
configureLoggingProvider(provider);
|
|
||||||
|
|
||||||
new HttpServer(prepareHandler(props), prepareConfig(props)).start();
|
new HttpServer(prepareHandler(props), prepareConfig(props)).start();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -58,5 +53,8 @@ private ServerConfig prepareConfig(Properties props) {
|
||||||
if (props.has("server.workers")) {
|
if (props.has("server.workers")) {
|
||||||
config.workerPoolSize = props.get!size_t("server.workers");
|
config.workerPoolSize = props.get!size_t("server.workers");
|
||||||
}
|
}
|
||||||
|
config.defaultHeaders["Access-Control-Allow-Origin"] = "*";
|
||||||
|
config.defaultHeaders["Access-Control-Allow-Methods"] = "*";
|
||||||
|
config.defaultHeaders["Access-Control-Allow-Headers"] = "*";
|
||||||
return config;
|
return config;
|
||||||
}
|
}
|
|
@ -0,0 +1,59 @@
|
||||||
|
module utils;
|
||||||
|
|
||||||
|
import std.datetime : SysTime;
|
||||||
|
|
||||||
|
string formatSqliteTimestamp(SysTime t) {
|
||||||
|
import std.format : format;
|
||||||
|
return format!"%04d-%02d-%02d %02d:%02d:%02d.%03d"(
|
||||||
|
t.year, t.month, t.day,
|
||||||
|
t.hour, t.minute, t.second, t.fracSecs.total!"msecs"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
string extractOrigin(string url) {
|
||||||
|
import std.algorithm : countUntil, startsWith;
|
||||||
|
ptrdiff_t idx = countUntil(url, "://");
|
||||||
|
if (idx == -1) return null;
|
||||||
|
string origin = url[idx + 3 .. $];
|
||||||
|
ptrdiff_t trailingSlashIdx = countUntil(origin, "/");
|
||||||
|
if (trailingSlashIdx != -1) {
|
||||||
|
origin = origin[0 .. trailingSlashIdx];
|
||||||
|
}
|
||||||
|
if (startsWith(origin, "www.")) {
|
||||||
|
origin = origin[4 .. $];
|
||||||
|
}
|
||||||
|
return origin;
|
||||||
|
}
|
||||||
|
|
||||||
|
unittest {
|
||||||
|
assert(extractOrigin("https://www.google.com/search") == "google.com");
|
||||||
|
assert(extractOrigin("https://litelist.andrewlalis.com") == "litelist.andrewlalis.com");
|
||||||
|
}
|
||||||
|
|
||||||
|
string dbPath(string origin) {
|
||||||
|
return "sitestat-db_" ~ origin ~ ".sqlite";
|
||||||
|
}
|
||||||
|
|
||||||
|
string originFromDbPath(string path) {
|
||||||
|
import std.algorithm : countUntil;
|
||||||
|
ptrdiff_t idx = countUntil(path, "sitestat-db_");
|
||||||
|
if (idx == -1) return null;
|
||||||
|
return path[(idx + 12)..$-7];
|
||||||
|
}
|
||||||
|
|
||||||
|
unittest {
|
||||||
|
assert(originFromDbPath("sitestat-db__LOCAL_FILESYSTEM_.sqlite") == "_LOCAL_FILESYSTEM_");
|
||||||
|
}
|
||||||
|
|
||||||
|
string[] listAllOrigins(string dir = ".") {
|
||||||
|
import std.file;
|
||||||
|
import std.array;
|
||||||
|
auto app = appender!(string[]);
|
||||||
|
foreach (DirEntry entry; dirEntries(dir, SpanMode.shallow, false)) {
|
||||||
|
string origin = originFromDbPath(entry.name);
|
||||||
|
if (origin !is null) {
|
||||||
|
app ~= origin;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return app[];
|
||||||
|
}
|
Loading…
Reference in New Issue