From 1e0d075dddae85fecbfaa1dde049f2b58155a517 Mon Sep 17 00:00:00 2001 From: Patrick Kastner <1919773+kastnerp@users.noreply.github.com> Date: Thu, 10 Sep 2026 23:25:27 -0400 Subject: [PATCH 1/2] feat: add privacy-first Umami usage analytics Ports Eddy3D's budgeted, daily-deduped Umami scheme: reports which ribbon tab is used (once per machine per day) and when Massing Generator actually produces output, capped at 200 billed events/machine/month. Opt out with MYCELIUM_ANALYTICS=0. Co-Authored-By: Claude Sonnet 5 --- src/Mycelium/Analytics/Analytics.cs | 464 ++++++++++++++++++ src/Mycelium/Analytics/AnalyticsBudget.cs | 273 +++++++++++ src/Mycelium/Analytics/Identity.cs | 48 ++ src/Mycelium/Analytics/TabUsage.cs | 163 ++++++ .../Components/MassingGeneratorComponent.cs | 2 + src/Mycelium/MyceliumUpdateNotifier.cs | 4 + 6 files changed, 954 insertions(+) create mode 100644 src/Mycelium/Analytics/Analytics.cs create mode 100644 src/Mycelium/Analytics/AnalyticsBudget.cs create mode 100644 src/Mycelium/Analytics/Identity.cs create mode 100644 src/Mycelium/Analytics/TabUsage.cs diff --git a/src/Mycelium/Analytics/Analytics.cs b/src/Mycelium/Analytics/Analytics.cs new file mode 100644 index 0000000..08db191 --- /dev/null +++ b/src/Mycelium/Analytics/Analytics.cs @@ -0,0 +1,464 @@ +using System; +using System.Net.Http; +using System.Reflection; +using System.Text; +using System.Text.Json.Nodes; +using System.Threading.Tasks; + +#nullable enable + +namespace Mycelium.Analytics +{ + /// + /// Privacy-first usage analytics via Umami — ported from Eddy3D's GUI.Analytics.Analytics + /// (Eddy3D-Dev/Eddy3D). Answers ONE question: which ribbon tab do people use — + /// Building Types, Vegetation, Terrain — not which component, and not how often they reopen + /// a file. + /// + /// + /// What is sent, and what a number in the dashboard means. Everything is + /// reported at most once per machine per UTC day (): + /// + /// /startup — a page view the first time the plugin loads each day. + /// /tab/<tab> — a page view the first time each day an object from that + /// ribbon tab is on a canvas — is the hook, + /// the sink. + /// /tab/<tab>/<action> — an event the first time each day a + /// generator actually produces massing/terrain/street output. Opening a definition is not + /// using it; running one is. + /// + /// Umami bills per hit AND per stored data property ("Each website hit counts + /// as one event. If you save event data, each data property stored counts as one event." — + /// docs.umami.is/docs/cloud/faq). Two consequences that must not be undone: + /// + /// The visitor hash rides in the User-Agent, not in data — free, and one + /// of the three inputs to Umami's session id. + /// Low-cardinality dimensions (tab, action) belong in the url path, not + /// data. + /// + /// Opt-out: set the environment variable MYCELIUM_ANALYTICS=0 before starting + /// Rhino (). + /// + public static class Analytics + { + // Self-hosted Umami — the same instance Eddy3D reports to, one "website" per + // Eddy3D-Dev/MyceliumGH-Dev product. This id is Mycelium's website on that instance. + private const string UmamiEndpoint = "https://umami-theta-drab.vercel.app/api/send"; + + private const string WebsiteId = "7cdd143e-2b40-4832-804f-07aaac80c238"; + + /// + /// Environment variable that disables every send when set to 0, false, + /// off or no. Read once, when the plugin first touches analytics. + /// + public const string OptOutVariable = "MYCELIUM_ANALYTICS"; + + private const string UnknownSlug = "unknown"; + + // 1 hit + the identify payload's data properties (rhino_version, mycelium_version). + private const int IdentifyCost = 3; + + // Use SocketsHttpHandler to disable proxy detection which reads system config + private static readonly HttpClient HttpClient = new HttpClient(new SocketsHttpHandler + { + UseProxy = false, // Critical: Don't read system proxy settings + ConnectTimeout = TimeSpan.FromSeconds(5) + }); + + /// + /// Configuration for the analytics endpoint. + /// + public static class Config + { + /// Gets or sets the Umami API endpoint URL. + public static string Endpoint { get; set; } = UmamiEndpoint; + + /// Gets or sets the Umami website ID. + public static string WebsiteId { get; set; } = Analytics.WebsiteId; + + /// Gets or sets whether tracking is enabled. Defaults from . + public static bool Enabled { get; set; } = + ReadEnabledFromEnvironment(Environment.GetEnvironmentVariable(OptOutVariable)); + } + + /// + /// Interprets the value: unset or anything else means + /// enabled; 0, false, off, no (any case) mean disabled. + /// + public static bool ReadEnabledFromEnvironment(string? value) + { + if (string.IsNullOrWhiteSpace(value)) return true; + var v = value.Trim(); + return !(v.Equals("0", StringComparison.OrdinalIgnoreCase) + || v.Equals("false", StringComparison.OrdinalIgnoreCase) + || v.Equals("off", StringComparison.OrdinalIgnoreCase) + || v.Equals("no", StringComparison.OrdinalIgnoreCase)); + } + + // ── What Mycelium reports ─────────────────────────────────────────────────────────── + + /// + /// The daily /startup page view — the denominator every tab share is read + /// against. Called from , i.e. from the plugin's priority + /// load. + /// + public static void TrackStartup() + { + SendDaily("startup", "/startup", "Startup", eventName: null); + } + + /// + /// Reports that an object from the ribbon tab is on a + /// canvas, as a /tab/<slug> page view — once per machine per day. Anything + /// that does not slug to a tab is ignored. + /// + public static void TrackTabUse(string? subCategory) + { + var slug = TabSlug(subCategory); + if (slug == UnknownSlug) return; + + SendDaily("tab/" + slug, "/tab/" + slug, "Tab: " + PanelName(subCategory), eventName: null); + } + + /// + /// Reports that an action was actually run on a tab — the difference between opening a + /// definition and using it. Sent as an event named <action>-run at + /// /tab/<tab>/<action>, once per machine per day per combination. + /// + /// The ribbon tab the run belongs to (a panel string or just its name). + /// What happened, e.g. generate. + public static void TrackRun(string? tab, string action) + { + var t = TabSlug(tab); + var a = Slug(action); + var url = $"/tab/{t}/{a}"; + + SendDaily("run" + url, url, $"Run: {PanelName(tab)} {a}", eventName: a + "-run"); + } + + /// + /// One send per machine per UTC day for . The claim is taken + /// synchronously (a hash lookup, plus a small file write the first time each day) so the + /// caller's thread decides; the network round trip is off-thread and best-effort. + /// + private static void SendDaily(string key, string url, string title, string? eventName) + { + if (!Config.Enabled) return; + if (!AnalyticsBudget.TryClaimDaily(key, 1)) return; + + Task.Run(async () => + { + try + { + await SendPayloadAsync(url, eventName, title, null, budgetClaimed: true); + } + catch + { + // Best-effort; the daily claim is spent either way. + } + }); + } + + // ── Slugs ─────────────────────────────────────────────────────────────────────────── + + /// + /// The URL segment for a ribbon panel: "Building Types" → building-types. + /// The number prefix, if any, is dropped. Returns unknown for anything empty. + /// + public static string TabSlug(string? subCategory) + { + var name = PanelName(subCategory); + if (string.IsNullOrWhiteSpace(name)) return UnknownSlug; + return Slug(name.Replace("+", " plus")); + } + + /// + /// A panel string without its NN | prefix, if any. A string that carries no such + /// prefix comes back trimmed and otherwise untouched. + /// + public static string PanelName(string? subCategory) + { + if (subCategory == null) return ""; + var text = subCategory.Trim(); + + int bar = text.IndexOf('|'); + if (bar > 0) + { + bool numericPrefix = true; + for (int i = 0; i < bar; i++) + { + if (!char.IsDigit(text[i]) && !char.IsWhiteSpace(text[i])) + { + numericPrefix = false; + break; + } + } + + if (numericPrefix) text = text.Substring(bar + 1).Trim(); + } + + return text; + } + + /// + /// Normalizes a value for use as a URL path segment (lowercase, runs of anything that is + /// not a letter or digit become one hyphen). Keeps the reported paths low-cardinality so + /// they stay groupable in Umami. + /// + private static string Slug(string? value) + { + if (string.IsNullOrWhiteSpace(value)) return UnknownSlug; + + var builder = new StringBuilder(value!.Length); + foreach (char c in value) + { + if (char.IsLetterOrDigit(c)) builder.Append(char.ToLowerInvariant(c)); + else if (builder.Length > 0 && builder[builder.Length - 1] != '-') builder.Append('-'); + } + + string slug = builder.ToString().Trim('-'); + return slug.Length > 0 ? slug : UnknownSlug; + } + + // ── Generic senders (kept for ad-hoc use) ────────────────────────────────────────── + + /// + /// Tracks an event in Umami analytics. + /// + public static void TrackEvent( + string eventName, + string url = "/", + JsonObject? additionalData = null, + Action? callback = null) + { + if (!Config.Enabled) + { + callback?.Invoke("Tracking disabled"); + return; + } + + Task.Run(async () => + { + try + { + string result = await TrackEventAsync(eventName, url, additionalData); + callback?.Invoke(result); + } + catch (Exception ex) + { + callback?.Invoke($"Error: {ex.Message}"); + } + }); + } + + /// + /// Tracks a page view in Umami analytics asynchronously. + /// + public static async Task TrackPageViewAsync(string url, string title) + { + return await SendPayloadAsync(url, null, title, null); + } + + /// + /// Tracks an event in Umami analytics asynchronously. + /// + public static async Task TrackEventAsync( + string eventName, + string url = "/", + JsonObject? additionalData = null) + { + return await SendPayloadAsync(url, eventName, eventName, additionalData); + } + + /// + /// Shared logic to send payloads to Umami. If eventName is null, it is treated as a + /// Page View. + /// + /// True when the caller already charged the monthly budget + /// (the daily ledger claims and charges in one step); false charges it here. + private static async Task SendPayloadAsync( + string url, + string? eventName, + string title, + JsonObject? additionalData, + bool budgetClaimed = false) + { + if (!Config.Enabled) return "Tracking disabled"; + + try + { + string visitorId = Identity.GetUniqueId(); + string userAgent = BuildUserAgent(visitorId); + + // 1. Send IDENTIFY request (Session Setup) — ONCE PER MACHINE PER MONTH. + string myceliumVersion = GetPluginVersion(); + if (AnalyticsBudget.TryClaimIdentify(IdentifyCost, myceliumVersion)) + { + await SendIdentifyAsync(url, eventName, title, userAgent, myceliumVersion); + } + + // 2. Send DATA request (Event or PageView) + var dataObject = new JsonObject(); + if (additionalData != null) + { + // JsonNode instances are single-parent; net7.0 lacks JsonNode.DeepClone (net8+ + // only), so round-trip through text to reattach the value to dataObject. + foreach (var prop in additionalData) + dataObject[prop.Key] = prop.Value is null ? null : JsonNode.Parse(prop.Value.ToJsonString()); + } + + // Umami bills the hit plus one event per stored data property. + if (!budgetClaimed) + { + int cost = 1 + dataObject.Count; + if (!AnalyticsBudget.TryConsume(cost)) + { + return "Skipped: monthly analytics budget spent"; + } + } + + var payloadData = new JsonObject + { + ["website"] = Config.WebsiteId, + ["hostname"] = "mycelium-plugin", + ["language"] = "en-US", + ["screen"] = "1920x1080", + ["url"] = url, + ["referrer"] = "https://mycelium-gh.netlify.app", + ["title"] = title + }; + + // An empty data object would still be sent as a property bag; omit it entirely. + if (dataObject.Count > 0) + { + payloadData["data"] = dataObject; + } + + if (!string.IsNullOrEmpty(eventName)) + { + payloadData["name"] = eventName; + } + + var payload = new JsonObject + { + ["type"] = "event", + ["payload"] = payloadData + }; + + var request = new HttpRequestMessage(HttpMethod.Post, Config.Endpoint); + request.Content = new StringContent(payload.ToJsonString(), Encoding.UTF8, "application/json"); + request.Headers.UserAgent.ParseAdd(userAgent); + + var response = await HttpClient.SendAsync(request); + + return response.IsSuccessStatusCode + ? $"Success: {(eventName ?? "Page View")} tracked" + : $"Failed: {response.StatusCode}"; + } + catch (Exception ex) + { + return $"Error: {ex.Message}"; + } + } + + /// + /// Sends the once-per-month identify payload describing this machine. The caller + /// has already reserved from the budget. + /// + private static async Task SendIdentifyAsync( + string url, string? eventName, string title, string userAgent, string myceliumVersion) + { + try + { + var identifyData = new JsonObject + { + ["rhino_version"] = GetRhinoVersion(), + ["mycelium_version"] = myceliumVersion + }; + + var identifyPayload = new JsonObject + { + ["type"] = "identify", + ["payload"] = new JsonObject + { + ["website"] = Config.WebsiteId, + ["hostname"] = "mycelium-plugin", + ["language"] = "en-US", + ["screen"] = "1920x1080", + ["url"] = url, + ["referrer"] = "https://mycelium-gh.netlify.app", + ["title"] = title, + ["name"] = eventName, + ["data"] = identifyData + } + }; + + var identifyRequest = new HttpRequestMessage(HttpMethod.Post, Config.Endpoint); + identifyRequest.Content = + new StringContent(identifyPayload.ToJsonString(), Encoding.UTF8, "application/json"); + identifyRequest.Headers.UserAgent.ParseAdd(userAgent); + + await HttpClient.SendAsync(identifyRequest); + } + catch + { + // Identify is best-effort; the follow-up hit still carries the visitor via the UA. + } + } + + /// + /// Builds the User-Agent header. The visitor hash is appended here rather than stored in + /// data because the header is part of the hit and therefore free, and because + /// Umami derives its session id from exactly three inputs plus a salt. + /// + private static string BuildUserAgent(string visitorId) + { + return "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) " + + $"Chrome/120.0.0.0 Safari/537.36 Mycelium/1.0 (id:{visitorId})"; + } + + /// + /// Gets the Rhino version if available. + /// + private static string GetRhinoVersion() + { + try + { + return Rhino.RhinoApp.Version.ToString(); + } + catch + { + return "unknown"; + } + } + + /// + /// Gets the Mycelium release version as a bare version string, preferring the + /// SourceLink-free informational version so a pre-release suffix is reported as cut. + /// + private static string GetPluginVersion() + { + try + { + var assembly = typeof(Analytics).Assembly; + + string? informational = assembly + .GetCustomAttribute() + ?.InformationalVersion; + + if (!string.IsNullOrWhiteSpace(informational)) + { + int plus = informational.IndexOf('+'); + if (plus >= 0) informational = informational.Substring(0, plus); + if (!string.IsNullOrWhiteSpace(informational)) return informational.Trim(); + } + + return assembly.GetName().Version?.ToString() ?? "unknown"; + } + catch + { + return "unknown"; + } + } + } +} diff --git a/src/Mycelium/Analytics/AnalyticsBudget.cs b/src/Mycelium/Analytics/AnalyticsBudget.cs new file mode 100644 index 0000000..9d0edc3 --- /dev/null +++ b/src/Mycelium/Analytics/AnalyticsBudget.cs @@ -0,0 +1,273 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using System.Text.Json.Nodes; + +#nullable enable + +namespace Mycelium.Analytics +{ + /// + /// Machine-specific send budget for : a calendar-month cap on billed + /// events, plus a calendar-day ledger of what has already been reported. + /// + /// + /// Ported from Eddy3D's GUI.Analytics.AnalyticsBudget (Eddy3D-Dev/Eddy3D). Umami bills + /// every hit AND every stored data property as one event, so two mechanisms bound what one + /// machine can contribute: + /// + /// billed events per machine per calendar month, plus one + /// identify per machine per month instead of one per Rhino session. + /// : a key (a ribbon tab, the startup hit, a run) is sent at + /// most once per machine per UTC day, so a Views figure is a count of machine-days rather + /// than how often someone reopens a definition. + /// + /// State lives next to the plugin's own app-data folder (%APPDATA%/Mycelium, + /// ~/Library/Application Support/Mycelium on macOS under net7.0). If the file cannot + /// be read or written the budget degrades to an in-memory ledger for the current process — + /// still capped, never unbounded. + /// + public static class AnalyticsBudget + { + /// Billed events one machine may send per calendar month. + public const int MonthlyEventCap = 200; + + private static readonly object Gate = new object(); + + private static readonly string DefaultStatePath = Path.Combine( + Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData), "Mycelium", "analytics.json"); + + private static string _statePath = DefaultStatePath; + + /// UTC clock. Test hook — the day and month keys are derived from it. + public static Func Clock { get; set; } = () => DateTime.UtcNow; + + // Mirrors the on-disk state. Also *is* the state when the disk is unusable. + private static string _month = ""; + private static int _spent; + private static bool _identified; + private static string _identifiedVersion = ""; + private static string _day = ""; + private static readonly HashSet _sentToday = new HashSet(StringComparer.Ordinal); + private static bool _loaded; + + /// Calendar month key. UTC so a timezone change cannot hand out a second budget. + private static string CurrentMonth => Clock().ToString("yyyy-MM"); + + /// Calendar day key, UTC for the same reason. + private static string CurrentDay => Clock().ToString("yyyy-MM-dd"); + + /// + /// Reserves billed events from this month's budget. + /// + /// Billed events the payload will cost (1 hit + 1 per data property). + /// true if the budget covered it; false if the caller must drop the payload. + public static bool TryConsume(int cost) + { + if (cost <= 0) return false; + + lock (Gate) + { + Load(); + if (_spent + cost > MonthlyEventCap) return false; + + _spent += cost; + Save(); + return true; + } + } + + /// + /// Claims the once-per-machine-per-day send for , charging + /// against the monthly budget in the same step. + /// + /// What is being reported — startup, tab/vegetation, + /// run/tab/mycelium/generate. Case-sensitive. + /// Billed events the payload will cost. + /// true for the first caller today with this key, and only if the monthly + /// budget still covers it. false means: already sent today, or budget spent — + /// either way the caller drops the payload. + public static bool TryClaimDaily(string key, int cost = 1) + { + if (string.IsNullOrWhiteSpace(key) || cost <= 0) return false; + + lock (Gate) + { + Load(); + if (_sentToday.Contains(key)) return false; + if (_spent + cost > MonthlyEventCap) return false; + + _sentToday.Add(key); + _spent += cost; + Save(); + return true; + } + } + + /// Whether has already been claimed today. Diagnostics only. + public static bool SentToday(string key) + { + lock (Gate) + { + Load(); + return _sentToday.Contains(key); + } + } + + /// + /// Claims the single identify send allowed per machine per month, charging + /// against the same budget. + /// + /// Billed events the identify payload will cost. + /// Plugin version the payload will report. A machine that upgrades + /// mid-month re-arms the claim, or the profile keeps reporting the version installed when + /// the month began for up to 31 days after the upgrade. + /// true for the first caller this month at this version (and only if the + /// budget covers it). + public static bool TryClaimIdentify(int cost, string version = "") + { + lock (Gate) + { + Load(); + if (_identified && _identifiedVersion == version) return false; + if (_spent + cost > MonthlyEventCap) return false; + + _spent += cost; + _identified = true; + _identifiedVersion = version; + Save(); + return true; + } + } + + /// Billed events already spent this month. Diagnostics only. + public static int Spent + { + get { lock (Gate) { Load(); return _spent; } } + } + + /// Billed events still available this month. Diagnostics only. + public static int Remaining => Math.Max(0, MonthlyEventCap - Spent); + + /// Discards the cached state so the next call re-reads disk. Test hook. + public static void Reset() + { + lock (Gate) + { + _loaded = false; + _month = ""; + _spent = 0; + _identified = false; + _identifiedVersion = ""; + _day = ""; + _sentToday.Clear(); + } + } + + /// + /// Points the ledger at a different state file (null restores the default) and + /// discards the cached state. Test hook. + /// + public static void UseStateFile(string? path) + { + lock (Gate) + { + _statePath = string.IsNullOrWhiteSpace(path) ? DefaultStatePath : path!; + } + + Reset(); + } + + // Caller must hold Gate. + private static void Load() + { + if (!_loaded) + { + ReadFromDisk(); + _loaded = true; + } + + bool changed = false; + + // Month rollover resets the budget and re-arms the identify send. + if (_month != CurrentMonth) + { + _month = CurrentMonth; + _spent = 0; + _identified = false; + _identifiedVersion = ""; + changed = true; + } + + // Day rollover forgets what was reported yesterday. + if (_day != CurrentDay) + { + _day = CurrentDay; + _sentToday.Clear(); + changed = true; + } + + if (changed) Save(); + } + + // Caller must hold Gate. A missing/corrupt file simply starts a fresh month. + private static void ReadFromDisk() + { + try + { + if (!File.Exists(_statePath)) return; + + var json = JsonNode.Parse(File.ReadAllText(_statePath)) as JsonObject; + if (json is null) return; + + _month = json["month"]?.GetValue() ?? ""; + _spent = json["spent"]?.GetValue() ?? 0; + _identified = json["identified"]?.GetValue() ?? false; + _identifiedVersion = json["identified_version"]?.GetValue() ?? ""; + _day = json["day"]?.GetValue() ?? ""; + + _sentToday.Clear(); + if (json["sent"] is JsonArray sent) + { + foreach (var item in sent) + { + var key = item?.GetValue(); + if (!string.IsNullOrWhiteSpace(key)) _sentToday.Add(key!); + } + } + } + catch + { + // Unreadable state: fall back to the in-memory ledger for this process. + } + } + + // Caller must hold Gate. Failures are ignored — the in-memory ledger still caps the process. + private static void Save() + { + try + { + var dir = Path.GetDirectoryName(_statePath); + if (!string.IsNullOrEmpty(dir)) Directory.CreateDirectory(dir); + + var json = new JsonObject + { + ["month"] = _month, + ["spent"] = _spent, + ["identified"] = _identified, + ["identified_version"] = _identifiedVersion, + ["day"] = _day, + ["sent"] = new JsonArray(_sentToday.OrderBy(k => k, StringComparer.Ordinal) + .Select(k => (JsonNode?)JsonValue.Create(k)).ToArray()) + }; + + File.WriteAllText(_statePath, json.ToJsonString()); + } + catch + { + // Read-only or roaming-profile failures must never break the plugin. + } + } + } +} diff --git a/src/Mycelium/Analytics/Identity.cs b/src/Mycelium/Analytics/Identity.cs new file mode 100644 index 0000000..7ae7a4b --- /dev/null +++ b/src/Mycelium/Analytics/Identity.cs @@ -0,0 +1,48 @@ +using System; +using System.Security.Cryptography; +using System.Text; + +namespace Mycelium.Analytics +{ + /// + /// Generates a stable, privacy-compliant unique identifier for analytics. + /// The ID is based on machine and user details, but hashed with a salt + /// so it cannot be reversed to identify the actual user. + /// + public static class Identity + { + // Salt for hashing - makes the hash unique to this project + private const string Salt = "Mycelium_Analytics_2026!"; + + /// + /// Gets a stable unique identifier for the current user/machine combination. + /// This ID persists across sessions but cannot be reversed to identify the user. + /// + /// A 12-character lowercase hex string, or "anonymous" on error. + public static string GetUniqueId() + { + try + { + // Combine machine name and user name for a stable identifier + string rawId = Environment.MachineName + Environment.UserName; + + // Hash with salt using SHA256 + using (var sha256 = SHA256.Create()) + { + byte[] bytes = Encoding.UTF8.GetBytes(rawId + Salt); + byte[] hash = sha256.ComputeHash(bytes); + + // Take first 12 characters for a short but unique ID + return BitConverter.ToString(hash) + .Replace("-", "") + .Substring(0, 12) + .ToLower(); + } + } + catch + { + return "anonymous"; + } + } + } +} diff --git a/src/Mycelium/Analytics/TabUsage.cs b/src/Mycelium/Analytics/TabUsage.cs new file mode 100644 index 0000000..e06987e --- /dev/null +++ b/src/Mycelium/Analytics/TabUsage.cs @@ -0,0 +1,163 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Runtime.CompilerServices; +using Grasshopper; +using Grasshopper.Kernel; + +#nullable enable + +namespace Mycelium.Analytics +{ + /// + /// Reports which Mycelium ribbon tabs a machine uses, by watching every Grasshopper + /// document for objects whose category is and handing their + /// SubCategory to . The reporter — + /// in production — turns that into one /tab/<slug> page view per machine per day. + /// + /// + /// Ported from Eddy3D's GUI.Analytics.TabUsage (Eddy3D-Dev/Eddy3D), which documents the + /// full rationale: a document-server hook (not a base-class AddedToDocument override) + /// sees every object regardless of which class it derives from, and both the + /// DocumentAdded event and an explicit scan of already-open documents are needed to + /// catch objects that arrived from a loaded file. Call once from the + /// plugin's GH_AssemblyPriority (MyceliumUpdateNotifier); it is idempotent and + /// never throws. + /// + public static class TabUsage + { + /// The Grasshopper category every Mycelium component registers under. + public const string Category = "Mycelium"; + + private static readonly object Gate = new object(); + private static readonly object Marker = new object(); + private static bool _installed; + + // Documents currently subscribed. Weak so a document that was closed without a + // DocumentRemoved (the server is not the only owner) cannot be kept alive by this table. + private static readonly ConditionalWeakTable Hooked = new ConditionalWeakTable(); + + /// + /// Receives the raw SubCategory of every Mycelium object observed. Defaults to + /// ; tests replace it to capture the stream. + /// + public static Action? Reporter { get; set; } = Analytics.TrackTabUse; + + /// Whether has run in this process. + public static bool IsInstalled + { + get { lock (Gate) return _installed; } + } + + /// + /// Subscribes to the document server (once per process), scans the documents already + /// open, and sends the daily /startup hit. Safe to call from the plugin's + /// GH_AssemblyPriority.PriorityLoad. + /// + public static void Install() + { + lock (Gate) + { + if (_installed) return; + _installed = true; + } + + try + { + var server = Instances.DocumentServer; + server.DocumentAdded += OnDocumentAdded; + server.DocumentRemoved += OnDocumentRemoved; + + // A plugin reloaded into a running Grasshopper already has documents to look at. + for (int i = 0; i < server.DocumentCount; i++) Hook(server[i]); + } + catch + { + // Analytics must never break plugin loading. + } + + Analytics.TrackStartup(); + } + + private static void OnDocumentAdded(GH_DocumentServer sender, GH_Document doc) => Hook(doc); + + private static void OnDocumentRemoved(GH_DocumentServer sender, GH_Document doc) + { + try + { + if (doc == null) return; + doc.ObjectsAdded -= OnObjectsAdded; + Hooked.Remove(doc); + } + catch + { + // Best-effort unhook. + } + } + + private static void Hook(GH_Document? doc) + { + if (doc == null) return; + + try + { + if (!Hooked.TryAdd(doc, Marker)) return; + doc.ObjectsAdded += OnObjectsAdded; + + // The objects a file brought in arrive without an ObjectsAdded — see the class remarks. + Observe(doc.Objects.ToArray()); + } + catch + { + // Never let a bad document break the canvas. + } + } + + private static void OnObjectsAdded(object sender, GH_DocObjectEventArgs e) + { + try + { + Observe(e?.Objects); + } + catch + { + // Never let analytics throw into Grasshopper's event dispatch. + } + } + + /// + /// Hands the SubCategory of every Mycelium object in to + /// . Public so a test can drive it without a document. + /// + public static void Observe(IEnumerable? objects) + { + if (objects == null) return; + var reporter = Reporter; + if (reporter == null) return; + + foreach (var obj in objects) + { + if (IsMycelium(obj)) reporter(obj.SubCategory); + } + } + + /// + /// A Mycelium object is anything registered under with a ribbon + /// panel. Params and components alike — the question is which TAB it came from, not what + /// it is. + /// + public static bool IsMycelium(IGH_DocumentObject? obj) + { + if (obj == null) return false; + try + { + return string.Equals(obj.Category, Category, StringComparison.Ordinal) + && !string.IsNullOrWhiteSpace(obj.SubCategory); + } + catch + { + return false; + } + } + } +} diff --git a/src/Mycelium/Components/MassingGeneratorComponent.cs b/src/Mycelium/Components/MassingGeneratorComponent.cs index 76a2e3a..a81fa88 100644 --- a/src/Mycelium/Components/MassingGeneratorComponent.cs +++ b/src/Mycelium/Components/MassingGeneratorComponent.cs @@ -284,6 +284,8 @@ protected override void SolveInstance(IGH_DataAccess DA) DA.SetData(9, metrics); DA.SetData(10, morphology.ToDisplayString()); DA.SetData(11, manifest.ToJson()); + + Mycelium.Analytics.Analytics.TrackRun("Massing", "generate"); } protected override void AppendAdditionalComponentMenuItems(ToolStripDropDown menu) diff --git a/src/Mycelium/MyceliumUpdateNotifier.cs b/src/Mycelium/MyceliumUpdateNotifier.cs index 497f5e0..9ff0134 100644 --- a/src/Mycelium/MyceliumUpdateNotifier.cs +++ b/src/Mycelium/MyceliumUpdateNotifier.cs @@ -23,6 +23,10 @@ public class MyceliumUpdateNotifier : GH_AssemblyPriority public override GH_LoadingInstruction PriorityLoad() { + // Privacy-first usage analytics (ported from Eddy3D) — tracks which ribbon tab is + // used, once per machine per day. See Mycelium.Analytics.Analytics for the opt-out. + Mycelium.Analytics.TabUsage.Install(); + try { // A canvas may already exist (plugin reloaded into a running Grasshopper); otherwise From b89fe07dda27477f6d5f03da0cd4264d6836cf5d Mon Sep 17 00:00:00 2001 From: Patrick Kastner <1919773+kastnerp@users.noreply.github.com> Date: Thu, 10 Sep 2026 23:37:37 -0400 Subject: [PATCH 2/2] fix: drop the monthly event cap now that Umami is self-hosted MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The cap protected Umami Cloud's 100k-events/month free tier; the self-hosted instance has no such billing ceiling. Daily dedup (TryClaimDaily) is unaffected — it's what keeps Views a count of machine-days, unrelated to billing. Co-Authored-By: Claude Sonnet 5 --- src/Mycelium/Analytics/AnalyticsBudget.cs | 64 +++++++++-------------- 1 file changed, 25 insertions(+), 39 deletions(-) diff --git a/src/Mycelium/Analytics/AnalyticsBudget.cs b/src/Mycelium/Analytics/AnalyticsBudget.cs index 9d0edc3..a49e324 100644 --- a/src/Mycelium/Analytics/AnalyticsBudget.cs +++ b/src/Mycelium/Analytics/AnalyticsBudget.cs @@ -9,30 +9,23 @@ namespace Mycelium.Analytics { /// - /// Machine-specific send budget for : a calendar-month cap on billed - /// events, plus a calendar-day ledger of what has already been reported. + /// Machine-specific send ledger for : a calendar-day record of what + /// has already been reported, plus a running monthly event count kept for observability. /// /// - /// Ported from Eddy3D's GUI.Analytics.AnalyticsBudget (Eddy3D-Dev/Eddy3D). Umami bills - /// every hit AND every stored data property as one event, so two mechanisms bound what one - /// machine can contribute: - /// - /// billed events per machine per calendar month, plus one - /// identify per machine per month instead of one per Rhino session. - /// : a key (a ribbon tab, the startup hit, a run) is sent at - /// most once per machine per UTC day, so a Views figure is a count of machine-days rather - /// than how often someone reopens a definition. - /// + /// Ported from Eddy3D's GUI.Analytics.AnalyticsBudget (Eddy3D-Dev/Eddy3D). That class + /// enforced a monthly cap to protect Umami Cloud's 100k-events/month free tier; Mycelium + /// reports to a self-hosted instance with no such billing ceiling, so no cap is enforced + /// here either. is the mechanism that still matters: a key (a + /// ribbon tab, the startup hit, a run) is sent at most once per machine per UTC day, so a + /// Views figure is a count of machine-days rather than how often someone reopens a + /// definition — that has nothing to do with billing. /// State lives next to the plugin's own app-data folder (%APPDATA%/Mycelium, /// ~/Library/Application Support/Mycelium on macOS under net7.0). If the file cannot - /// be read or written the budget degrades to an in-memory ledger for the current process — - /// still capped, never unbounded. + /// be read or written the ledger degrades to an in-memory one for the current process. /// public static class AnalyticsBudget { - /// Billed events one machine may send per calendar month. - public const int MonthlyEventCap = 200; - private static readonly object Gate = new object(); private static readonly string DefaultStatePath = Path.Combine( @@ -59,10 +52,12 @@ public static class AnalyticsBudget private static string CurrentDay => Clock().ToString("yyyy-MM-dd"); /// - /// Reserves billed events from this month's budget. + /// Records events against this month's running count. Always + /// succeeds (no monthly cap on the self-hosted instance); the bool return is kept so + /// call sites that check it don't need to change. /// - /// Billed events the payload will cost (1 hit + 1 per data property). - /// true if the budget covered it; false if the caller must drop the payload. + /// Events the payload will cost (1 hit + 1 per data property). + /// true unless is not positive. public static bool TryConsume(int cost) { if (cost <= 0) return false; @@ -70,8 +65,6 @@ public static bool TryConsume(int cost) lock (Gate) { Load(); - if (_spent + cost > MonthlyEventCap) return false; - _spent += cost; Save(); return true; @@ -79,15 +72,14 @@ public static bool TryConsume(int cost) } /// - /// Claims the once-per-machine-per-day send for , charging - /// against the monthly budget in the same step. + /// Claims the once-per-machine-per-day send for , recording + /// against the monthly count in the same step. /// /// What is being reported — startup, tab/vegetation, /// run/tab/mycelium/generate. Case-sensitive. - /// Billed events the payload will cost. - /// true for the first caller today with this key, and only if the monthly - /// budget still covers it. false means: already sent today, or budget spent — - /// either way the caller drops the payload. + /// Events the payload will cost. + /// true for the first caller today with this key; false means + /// already sent today, and the caller drops the payload. public static bool TryClaimDaily(string key, int cost = 1) { if (string.IsNullOrWhiteSpace(key) || cost <= 0) return false; @@ -96,7 +88,6 @@ public static bool TryClaimDaily(string key, int cost = 1) { Load(); if (_sentToday.Contains(key)) return false; - if (_spent + cost > MonthlyEventCap) return false; _sentToday.Add(key); _spent += cost; @@ -116,22 +107,20 @@ public static bool SentToday(string key) } /// - /// Claims the single identify send allowed per machine per month, charging - /// against the same budget. + /// Claims the single identify send allowed per machine per month, recording + /// against the same running count. /// - /// Billed events the identify payload will cost. + /// Events the identify payload will cost. /// Plugin version the payload will report. A machine that upgrades /// mid-month re-arms the claim, or the profile keeps reporting the version installed when /// the month began for up to 31 days after the upgrade. - /// true for the first caller this month at this version (and only if the - /// budget covers it). + /// true for the first caller this month at this version. public static bool TryClaimIdentify(int cost, string version = "") { lock (Gate) { Load(); if (_identified && _identifiedVersion == version) return false; - if (_spent + cost > MonthlyEventCap) return false; _spent += cost; _identified = true; @@ -141,15 +130,12 @@ public static bool TryClaimIdentify(int cost, string version = "") } } - /// Billed events already spent this month. Diagnostics only. + /// Events reported this month. Diagnostics only — nothing is capped against it. public static int Spent { get { lock (Gate) { Load(); return _spent; } } } - /// Billed events still available this month. Diagnostics only. - public static int Remaining => Math.Max(0, MonthlyEventCap - Spent); - /// Discards the cached state so the next call re-reads disk. Test hook. public static void Reset() {