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..a49e324
--- /dev/null
+++ b/src/Mycelium/Analytics/AnalyticsBudget.cs
@@ -0,0 +1,259 @@
+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 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). 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 ledger degrades to an in-memory one for the current process.
+ ///
+ public static class AnalyticsBudget
+ {
+ 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");
+
+ ///
+ /// 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.
+ ///
+ /// 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;
+
+ lock (Gate)
+ {
+ Load();
+ _spent += cost;
+ Save();
+ return true;
+ }
+ }
+
+ ///
+ /// 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.
+ /// 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;
+
+ lock (Gate)
+ {
+ Load();
+ if (_sentToday.Contains(key)) 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, recording
+ /// against the same running count.
+ ///
+ /// 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.
+ public static bool TryClaimIdentify(int cost, string version = "")
+ {
+ lock (Gate)
+ {
+ Load();
+ if (_identified && _identifiedVersion == version) return false;
+
+ _spent += cost;
+ _identified = true;
+ _identifiedVersion = version;
+ Save();
+ return true;
+ }
+ }
+
+ /// Events reported this month. Diagnostics only — nothing is capped against it.
+ public static int Spent
+ {
+ get { lock (Gate) { Load(); return _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