Files
speckle-sharp-connectors/Sdk/Speckle.Connectors.Common/Analytics/MixPanelManager.cs
T
Oğuzhan Koral 68a0ed3334 Feat(gh): add tracking for metrics (#833)
* add MixPanel manager like v2

* add mixpanel to send and receive

* fix tests

* Delete old events

* Don't track receive and send operation

They are already tracked by UI - we shouldn't track them on low level, they always need to be tracked with UI clicks etc

* Pass account from outside

* Add email if available

* Add mixpanel to GH

* Add ui dui3 prop as default

* Remove mixpanel object from tests

* renames categories

* TODO notes for NodeRun later

* Add note for account id nullability

* Grasshopper specific send and receive info for workspace ids

* Auto property

* isMultiplayer prop for mixpanel

* fix mismatch in account id and user id

* Helper function for convertion source app name to slug

---------

Co-authored-by: Adam Hathcock <adam@hathcock.uk>
Co-authored-by: Claire Kuang <kuang.claire@gmail.com>
2025-05-14 21:35:20 +03:00

255 lines
7.5 KiB
C#

using System.Net.Http.Headers;
using System.Net.NetworkInformation;
using System.Runtime.InteropServices;
using System.Text;
using System.Web;
using Microsoft.Extensions.Logging;
using Speckle.InterfaceGenerator;
using Speckle.Newtonsoft.Json;
using Speckle.Sdk;
using Speckle.Sdk.Credentials;
using Speckle.Sdk.Helpers;
#if NETFRAMEWORK
using System.Net.Http;
#endif
namespace Speckle.Connectors.Common.Analytics;
/// <summary>
/// Anonymous telemetry to help us understand how to make a better Speckle.
/// This really helps us to deliver a better open source project and product!
/// </summary>
[GenerateAutoInterface]
public class MixPanelManager(ISpeckleApplication application, ISpeckleHttp speckleHttp, ILogger<MixPanelManager> logger)
: IMixPanelManager
{
private const string MIXPANEL_TOKEN = "acd87c5a50b56df91a795e999812a3a4";
private static readonly Uri s_mixpanelServer = new("https://analytics.speckle.systems");
/// <summary>
/// Cached email
/// </summary>
private string? LastEmail { get; set; }
/// <summary>
/// Cached server URL
/// </summary>
private string? LastServer { get; set; }
/// <summary>
/// <see langword="false"/> when the DEBUG pre-processor directive is <see langword="true"/>, <see langword="false"/> otherwise
/// </summary>
/// <remarks>This must be kept as a computed property, not a compile time const</remarks>
private static bool IsReleaseMode =>
#if DEBUG
false;
#else
true;
#endif
/// <summary>
/// Tracks an event without specifying the email and server.
/// It's not always possible to know which account the user has selected, especially in visual programming.
/// Therefore we are caching the email and server values so that they can be used also when nodes such as "Serialize" are used.
/// If no account info is cached, we use the default account data.
/// </summary>
/// <param name="eventName">Name of the even</param>
/// <param name="customProperties">Additional parameters to pass in to event</param>
/// <param name="isAction">True if it's an action performed by a logged user</param>
public async Task TrackEvent(
MixPanelEvents eventName,
Account? account,
Dictionary<string, object>? customProperties = null,
bool isAction = true
)
{
string? email = account?.userInfo.email;
string? hashedEmail;
string? server;
if (LastEmail != null && LastServer != null && LastServer != "no-account-server")
{
hashedEmail = LastEmail;
server = LastServer;
}
else
{
if (account == null)
{
var macAddr = NetworkInterface
.GetAllNetworkInterfaces()
.Where(nic =>
nic.OperationalStatus == OperationalStatus.Up && nic.NetworkInterfaceType != NetworkInterfaceType.Loopback
)
.Select(nic => nic.GetPhysicalAddress().ToString())
.FirstOrDefault();
hashedEmail = macAddr;
server = "no-account-server";
isAction = false;
}
else
{
hashedEmail = account.GetHashedEmail();
server = account.GetHashedServer();
}
}
await TrackEvent(hashedEmail, server, eventName, email, customProperties, isAction);
}
/// <summary>
/// Tracks an event from a specified email and server, anonymizes personal information
/// </summary>
/// <param name="hashedEmail">Email of the user anonymized</param>
/// <param name="hashedServer">Server URL anonymized</param>
/// <param name="eventName">Name of the event</param>
/// <param name="customProperties">Additional parameters to pass to the event</param>
/// <param name="isAction">True if it's an action performed by a logged user</param>
private async Task TrackEvent(
string? hashedEmail,
string hashedServer,
MixPanelEvents eventName,
string? email,
Dictionary<string, object>? customProperties = null,
bool isAction = true
)
{
LastEmail = hashedEmail;
LastServer = hashedServer;
if (!IsReleaseMode)
{
//only track in prod
return;
}
try
{
var properties = new Dictionary<string, object>
{
{ "distinct_id", hashedEmail ?? string.Empty },
{ "server_id", hashedServer },
{ "token", MIXPANEL_TOKEN },
{ "hostApp", application.Slug },
{ "ui", "dui3" }, // this is the convention we use with next gen
{ "hostAppVersion", application.HostApplicationVersion },
{ "core_version", application.SpeckleVersion },
{ "$os", GetOs() }
};
if (email != null)
{
properties.Add("email", email);
}
if (isAction)
{
properties.Add("type", "action");
}
if (customProperties != null)
{
foreach (KeyValuePair<string, object> customProp in customProperties)
{
properties[customProp.Key] = customProp.Value;
}
}
string json = JsonConvert.SerializeObject(new { @event = eventName.ToString(), properties });
await SendAnalytics("/track?ip=1", json).ConfigureAwait(false);
}
catch (Exception ex) when (!ex.IsFatal())
{
logger.LogWarning(
ex,
"Analytics event {event} {isAction} failed {exceptionMessage}",
eventName.ToString(),
isAction,
ex.Message
);
}
}
public async Task AddConnectorToProfile(string hashedEmail, string connector)
{
try
{
var data = new Dictionary<string, object>
{
{ "$token", MIXPANEL_TOKEN },
{ "$distinct_id", hashedEmail },
{
"$union",
new Dictionary<string, object>
{
{
"Connectors",
new List<string> { connector }
}
}
}
};
string json = JsonConvert.SerializeObject(data);
await SendAnalytics("/engage#profile-union", json).ConfigureAwait(false);
}
catch (Exception ex) when (!ex.IsFatal())
{
logger.LogWarning(ex, "Failed add connector {connector} to profile", connector);
}
}
public async Task IdentifyProfile(string hashedEmail, string connector)
{
try
{
var data = new Dictionary<string, object>
{
{ "$token", MIXPANEL_TOKEN },
{ "$distinct_id", hashedEmail },
{
"$set",
new Dictionary<string, object> { { "Identified", true } }
}
};
string json = JsonConvert.SerializeObject(data);
await SendAnalytics("/engage#profile-set", json).ConfigureAwait(false);
}
catch (Exception ex) when (!ex.IsFatal())
{
logger.LogWarning(ex, "Failed identify profile: connector {connector}", connector);
}
}
private async Task SendAnalytics(string relativeUri, string json)
{
var query = new StreamContent(new MemoryStream(Encoding.UTF8.GetBytes("data=" + HttpUtility.UrlEncode(json))));
using HttpClient client = speckleHttp.CreateHttpClient();
client.DefaultRequestHeaders.Accept.Add(new MediaTypeWithQualityHeaderValue("text/plain"));
query.Headers.ContentType = new MediaTypeHeaderValue("application/json");
var res = await client.PostAsync(new Uri(s_mixpanelServer, relativeUri), query).ConfigureAwait(false);
res.EnsureSuccessStatusCode();
}
private static string GetOs()
{
if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows))
{
return "Windows";
}
if (RuntimeInformation.IsOSPlatform(OSPlatform.OSX))
{
return "Mac OS X";
}
if (RuntimeInformation.IsOSPlatform(OSPlatform.Linux))
{
return "Linux";
}
return "Unknown";
}
}