From 7fb8e4530d226b6ceb38fcde4be3c3f0a77fc751 Mon Sep 17 00:00:00 2001 From: RandomityGuy <31925790+RandomityGuy@users.noreply.github.com> Date: Mon, 2 Oct 2023 01:48:42 +0530 Subject: [PATCH] non-user identifying analytics --- src/Analytics.hx | 147 +++++++++++++++++++++++++++++++++++++++++++++ src/Http.hx | 45 +++++++++++++- src/MarbleGame.hx | 9 +++ src/MarbleWorld.hx | 10 +++ src/Util.hx | 13 ++++ 5 files changed, 221 insertions(+), 3 deletions(-) create mode 100644 src/Analytics.hx diff --git a/src/Analytics.hx b/src/Analytics.hx new file mode 100644 index 00000000..4026b7f8 --- /dev/null +++ b/src/Analytics.hx @@ -0,0 +1,147 @@ +package src; + +import haxe.Json; +import src.Util; +import src.Settings; +import src.Http; +import src.Console; + +typedef PayloadData = { + type:String, + payload:{ + hostname:String, language:String, referrer:String, screen:String, title:String, url:String, website:String, name:String, ?data:Dynamic + } +}; + +// I'm sorry to add this +// Your data is private and anonymous and we don't track you at all, I promise! +// The analytics are stored in a self hosted Umami instance inside EU. +class Analytics { + static var umami = "https://analytics.randomityguy.me/api/send"; + + public static function trackSingle(eventName:String) { + var p = payload(eventName, null); + var json = Json.stringify(p); + Http.post(umami, json, (b) -> { + // Console.log("Analytics suceeded: " + b.toString()); + }, (e) -> { + // Console.log("Analytics failed: " + e); + }); + } + + public static function trackLevelPlay(levelName:String, levelFile:String) { + var p = payload("level-play", { + name: levelName, + file: levelFile + }); + var json = Json.stringify(p); + Http.post(umami, json, (b) -> { + // Console.log("Analytics suceeded: " + b.toString()); + }, (e) -> { + // Console.log("Analytics failed: " + e); + }); + } + + public static function trackLevelScore(levelName:String, levelFile:String, time:Int, oobs:Int, respawns:Int, rewind:Bool) { + var p = payload("level-score", { + name: levelName, + file: levelFile, + time: time, + oobs: oobs, + respawns: respawns, + rewind: rewind + }); + var json = Json.stringify(p); + Http.post(umami, json, (b) -> { + // Console.log("Analytics suceeded: " + b.toString()); + }, (e) -> { + // Console.log("Analytics failed: " + e); + }); + } + + public static function trackLevelQuit(levelName:String, levelFile:String, time:Int, oobs:Int, respawns:Int, rewind:Bool) { + var p = payload("level-quit", { + name: levelName, + file: levelFile, + time: time, + oobs: oobs, + respawns: respawns, + rewind: rewind + }); + var json = Json.stringify(p); + Http.post(umami, json, (b) -> { + // Console.log("Analytics suceeded: " + b.toString()); + }, (e) -> { + // Console.log("Analytics failed: " + e); + }); + } + + public static function trackPlatformInfo() { + var p = payload("device-telemetry", { + platform: Util.getPlatform(), + screen: screen(), + }); + var json = Json.stringify(p); + Http.post(umami, json, (b) -> { + // Console.log("Analytics suceeded: " + b.toString()); + }, (e) -> { + // Console.log("Analytics failed: " + e); + }); + } + + static function payload(eventName:String, eventData:Dynamic):PayloadData { + var p:PayloadData = { + type: "event", + payload: { + hostname: hostname(), + language: language(), + referrer: referrer(), + screen: screen(), + title: "MBHaxe Platinum", + url: "/", + website: "e6da43f0-fc6a-49cb-a4a9-4b7e7745e538", + name: eventName + } + }; + if (eventData == null) + return p; + p.payload.data = eventData; + return p; + } + + static function hostname() { + #if js + return js.Browser.window.location.hostname; + #end + #if hl + return "marbleblast.randomityguy.me"; + #end + } + + static function language() { + #if js + return js.Browser.window.navigator.language; + #end + #if hl + return "en-us"; + #end + } + + static function referrer() { + #if js + return js.Browser.window.document.referrer; + #end + #if hl + return ""; + #end + } + + static function screen() { + #if js + return '${js.Browser.window.screen.width}x${js.Browser.window.screen.height}'; + #end + #if hl + return '${Settings.optionsSettings.screenWidth}x${Settings.optionsSettings.screenHeight}'; + #end + } +} diff --git a/src/Http.hx b/src/Http.hx index 992fdf04..fc33df86 100644 --- a/src/Http.hx +++ b/src/Http.hx @@ -1,6 +1,7 @@ package src; import src.Console; +import src.Util; typedef HttpRequest = { var url:String; @@ -8,6 +9,8 @@ typedef HttpRequest = { var errCallback:String->Void; var cancelled:Bool; var fulfilled:Bool; + var post:Bool; + var postData:String; }; class Http { @@ -40,7 +43,7 @@ class Http { cancellationMutex.release(); var http = new sys.Http(req.url); http.onError = (e) -> { - responses.add(() -> req.errCallback(e)); + responses.add(() -> req.errCallback(e + ":" + http.responseBytes.toString())); req.fulfilled = true; }; http.onBytes = (b) -> { @@ -49,7 +52,12 @@ class Http { }; hl.Gc.enable(false); hl.Gc.blocking(true); // Wtf is this shit - http.request(false); + if (req.post) { + http.setHeader('User-Agent', 'MBHaxe/1.0 ${Util.getPlatform()}'); + http.setHeader('Content-Type', "application/json"); // support json data only (for now) + http.setPostData(req.postData); + } + http.request(req.post); hl.Gc.blocking(false); hl.Gc.enable(true); } @@ -63,7 +71,9 @@ class Http { callback: callback, errCallback: errCallback, cancelled: false, - fulfilled: false + fulfilled: false, + post: false, + postData: null, }; #if sys requests.add(req); @@ -75,6 +85,35 @@ class Http { #end } + // Returns HTTPRequest on sys, Int on js + public static function post(url:String, postData:String, callback:haxe.io.Bytes->Void, errCallback:String->Void) { + var req = { + url: url, + callback: callback, + errCallback: errCallback, + cancelled: false, + fulfilled: false, + post: true, + postData: postData + }; + #if sys + requests.add(req); + return req; + #else + return js.Browser.window.setTimeout(() -> { + js.Browser.window.fetch(url, + { + method: "POST", + headers: { + "User-Agent": js.Browser.window.navigator.userAgent, + "Content-Type": "application/json", + }, + body: postData + }).then(r -> r.arrayBuffer().then(b -> callback(haxe.io.Bytes.ofData(b))), e -> errCallback(e.toString())); + }, 75); + #end + } + public static function loop() { #if sys var resp = responses.pop(false); diff --git a/src/MarbleGame.hx b/src/MarbleGame.hx index 8877f4db..597ddea5 100644 --- a/src/MarbleGame.hx +++ b/src/MarbleGame.hx @@ -26,6 +26,7 @@ import src.Settings; import src.Console; import src.Debug; import src.Gamepad; +import src.Analytics; @:publicFields class MarbleGame { @@ -174,6 +175,9 @@ class MarbleGame { scene2d.addEventListener(e -> { _mouseWheelDelta = e.wheelDelta; }); + + Analytics.trackSingle("game-start"); + Analytics.trackPlatformInfo(); } public function update(dt:Float) { @@ -265,6 +269,9 @@ class MarbleGame { public function quitMission() { Console.log("Quitting mission"); world.setCursorLock(false); + var stats = Settings.levelStatistics[world.mission.path]; + Analytics.trackLevelQuit(world.mission.title, world.mission.path, Std.int(world.timeState.timeSinceLoad * 1000), stats.oobs, stats.respawns, + Settings.optionsSettings.rewindEnabled); paused = false; if (world.isWatching) { #if !js @@ -292,12 +299,14 @@ class MarbleGame { if (world != null) { world.dispose(); } + Analytics.trackLevelPlay(mission.title, mission.path); world = new MarbleWorld(scene, scene2d, mission, toRecord); world.init(); } public function watchMissionReplay(mission:Mission, replay:Replay) { canvas.clearContent(); + Analytics.trackSingle("replay-watch"); world = new MarbleWorld(scene, scene2d, mission); world.replay = replay; world.isWatching = true; diff --git a/src/MarbleWorld.hx b/src/MarbleWorld.hx index bc06e72f..9f95c036 100644 --- a/src/MarbleWorld.hx +++ b/src/MarbleWorld.hx @@ -99,6 +99,7 @@ import src.ResourceLoaderWorker; import haxe.io.Path; import src.Console; import src.Gamepad; +import src.Analytics; class MarbleWorld extends Scheduler { public var collisionWorld:CollisionWorld; @@ -1531,6 +1532,15 @@ class MarbleWorld extends Scheduler { this.finishYaw = this.marble.camera.CameraYaw; this.finishPitch = this.marble.camera.CameraPitch; displayAlert("Congratulations! You've finished!"); + if (!Settings.levelStatistics.exists(mission.path)) { + Settings.levelStatistics.set(mission.path, { + oobs: 0, + respawns: 0, + totalTime: 0, + }); + } + Analytics.trackLevelScore(mission.title, mission.path, Std.int(finishTime.gameplayClock * 1000), Settings.levelStatistics[mission.path].oobs, + Settings.levelStatistics[mission.path].respawns, Settings.optionsSettings.rewindEnabled); if (!this.isWatching) this.schedule(this.timeState.currentAttemptTime + 2, () -> cast showFinishScreen()); // Stop the ongoing sounds diff --git a/src/Util.hx b/src/Util.hx index ef3ec171..c3176e24 100644 --- a/src/Util.hx +++ b/src/Util.hx @@ -440,4 +440,17 @@ class Util { return totBytes.getBytes().toString(); } + + public static function getPlatform() { + #if js + return js.Browser.navigator.platform; + #end + #if hl + #if MACOS_BUNDLE + return "MacOS"; + #else + return "Windows"; + #end + #end + } }