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>
This commit is contained in:
Oğuzhan Koral
2025-05-14 21:35:20 +03:00
committed by GitHub
parent bee0030e42
commit 68a0ed3334
21 changed files with 606 additions and 58 deletions
@@ -4,11 +4,11 @@ namespace Speckle.Connectors.GrasshopperShared.Components;
public static class ComponentCategories
{
public const string PRIMARY_RIBBON = "Speckle";
public const string OPERATIONS = "1-Ops";
public const string OBJECTS = "2-Objects";
public const string COLLECTIONS = "3-Collections";
public const string PARAMETERS = "4-Parameters";
public const string DEVELOPER = "5-Dev";
public const string OPERATIONS = " Ops";
public const string OBJECTS = " Objects";
public const string COLLECTIONS = " Collections";
public const string PARAMETERS = " Params";
public const string DEVELOPER = "Dev";
}
public enum ComponentState
@@ -0,0 +1,16 @@
using Speckle.Connectors.Common.Operations;
namespace Speckle.Connectors.GrasshopperShared.Components.Operations.Receive;
public record GrasshopperReceiveInfo(
string AccountId,
Uri ServerUrl,
string? WorkspaceId,
string ProjectId,
string ProjectName,
string ModelId,
string ModelName,
string SelectedVersionId,
string SourceApplication,
string? SelectedVersionUserId
) : ReceiveInfo(AccountId, ServerUrl, ProjectId, ProjectName, ModelId, ModelName, SelectedVersionId, SourceApplication);
@@ -6,6 +6,8 @@ using Grasshopper.Kernel.Attributes;
using GrasshopperAsyncComponent;
using Microsoft.Extensions.DependencyInjection;
using Rhino;
using Speckle.Connectors.Common;
using Speckle.Connectors.Common.Analytics;
using Speckle.Connectors.Common.Instances;
using Speckle.Connectors.Common.Operations;
using Speckle.Connectors.Common.Operations.Receive;
@@ -48,10 +50,12 @@ public class ReceiveAsyncComponent : GH_AsyncComponent
// DI props
public IClient ApiClient { get; private set; }
public MixPanelManager MixPanelManager { get; private set; }
public GrasshopperReceiveOperation ReceiveOperation { get; private set; }
public RootObjectUnpacker RootObjectUnpacker { get; private set; }
public static IServiceScope? Scope { get; private set; }
public AccountService AccountManager { get; private set; }
public AccountService AccountService { get; private set; }
public AccountManager AccountManager { get; private set; }
public IClientFactory ClientFactory { get; private set; }
protected override void RegisterInputParams(GH_InputParamManager pManager)
@@ -77,8 +81,11 @@ public class ReceiveAsyncComponent : GH_AsyncComponent
// Dependency Injection
Scope = PriorityLoader.Container.CreateScope();
ReceiveOperation = Scope.ServiceProvider.GetRequiredService<GrasshopperReceiveOperation>();
MixPanelManager = Scope.ServiceProvider.GetRequiredService<MixPanelManager>();
RootObjectUnpacker = Scope.ServiceProvider.GetService<RootObjectUnpacker>();
AccountManager = Scope.ServiceProvider.GetRequiredService<AccountService>();
AccountService = Scope.ServiceProvider.GetRequiredService<AccountService>();
AccountManager = Scope.ServiceProvider.GetRequiredService<AccountManager>();
ClientFactory = Scope.ServiceProvider.GetRequiredService<IClientFactory>();
// We need to call this always in here to be able to react and set events :/
@@ -280,8 +287,11 @@ public class ReceiveAsyncComponent : GH_AsyncComponent
{
try
{
// TODO: Get any account for this server, as we don't have a mechanism yet to pass accountIds through
Account account = AccountManager.GetAccountWithServerUrlFallback("", new Uri(urlResource.Server));
Account? account =
urlResource.AccountId != null
? AccountManager.GetAccount(urlResource.AccountId)
: AccountService.GetAccountWithServerUrlFallback("", new Uri(urlResource.Server)); // fallback the account that matches with URL if any
if (account is null)
{
throw new SpeckleAccountManagerException($"No default account was found");
@@ -345,7 +355,9 @@ public class ReceiveComponentWorker : WorkerInstance
da.SetData(0, Result);
}
#pragma warning disable CA1506
public override void DoWork(Action<string, double> reportProgress, Action done)
#pragma warning restore CA1506
{
var receiveComponent = (ReceiveAsyncComponent)Parent;
@@ -438,6 +450,31 @@ public class ReceiveComponentWorker : WorkerInstance
Result = new SpeckleCollectionWrapperGoo(collectionRebuilder.RootCollectionWrapper);
// TODO: If we have NodeRun events later, better to have `ComponentTracker` to use across components
var customProperties = new Dictionary<string, object>()
{
{ "isAsync", true },
{ "sourceHostApp", HostApplications.GetSlugFromHostAppNameAndVersion(receiveInfo.SourceApplication) },
{ "auto", receiveComponent.AutoReceive }
};
if (receiveInfo.WorkspaceId != null)
{
customProperties.Add("workspace_id", receiveInfo.WorkspaceId);
}
if (receiveInfo.SelectedVersionUserId != null)
{
customProperties.Add(
"isMultiplayer",
receiveInfo.SelectedVersionUserId != receiveComponent.ApiClient.Account.userInfo.id
);
}
await receiveComponent.MixPanelManager.TrackEvent(
MixPanelEvents.Receive,
receiveComponent.ApiClient.Account,
customProperties
);
// DONE
done();
});
@@ -1,5 +1,7 @@
using Grasshopper.Kernel;
using Microsoft.Extensions.DependencyInjection;
using Speckle.Connectors.Common;
using Speckle.Connectors.Common.Analytics;
using Speckle.Connectors.Common.Instances;
using Speckle.Connectors.Common.Operations;
using Speckle.Connectors.Common.Operations.Receive;
@@ -8,6 +10,7 @@ using Speckle.Connectors.GrasshopperShared.HostApp;
using Speckle.Connectors.GrasshopperShared.Operations.Receive;
using Speckle.Connectors.GrasshopperShared.Parameters;
using Speckle.Connectors.GrasshopperShared.Properties;
using Speckle.Connectors.GrasshopperShared.Registration;
using Speckle.Sdk;
using Speckle.Sdk.Api;
using Speckle.Sdk.Credentials;
@@ -34,6 +37,8 @@ public class ReceiveComponentOutput
public class ReceiveComponent : SpeckleScopedTaskCapableComponent<ReceiveComponentInput, ReceiveComponentOutput>
{
private readonly MixPanelManager _mixpanel;
public ReceiveComponent()
: base(
"(Sync) Load",
@@ -41,7 +46,10 @@ public class ReceiveComponent : SpeckleScopedTaskCapableComponent<ReceiveCompone
"Load a model from Speckle, synchronously",
ComponentCategories.PRIMARY_RIBBON,
ComponentCategories.DEVELOPER
) { }
)
{
_mixpanel = PriorityLoader.Container.GetRequiredService<MixPanelManager>();
}
public override Guid ComponentGuid => new("74954F59-B1B7-41FD-97DE-4C6B005F2801");
protected override Bitmap Icon => Resources.speckle_operations_syncload;
@@ -102,15 +110,17 @@ public class ReceiveComponent : SpeckleScopedTaskCapableComponent<ReceiveCompone
return new();
}
// TODO: Resolving dependencies here may be overkill in most cases. Must re-evaluate.
var accountManager = scope.ServiceProvider.GetRequiredService<AccountService>();
var accountService = scope.ServiceProvider.GetRequiredService<AccountService>();
var accountManager = scope.ServiceProvider.GetRequiredService<AccountManager>();
var clientFactory = scope.ServiceProvider.GetRequiredService<IClientFactory>();
var receiveOperation = scope.ServiceProvider.GetRequiredService<GrasshopperReceiveOperation>();
// Do the thing 👇🏼
// TODO: Get any account for this server, as we don't have a mechanism yet to pass accountIds through
var account = accountManager.GetAccountWithServerUrlFallback("", new Uri(input.Resource.Server));
Account? account =
input.Resource.AccountId != null
? accountManager.GetAccount(input.Resource.AccountId)
: accountService.GetAccountWithServerUrlFallback("", new Uri(input.Resource.Server)); // fallback the account that matches with URL if any
if (account is null)
{
@@ -130,6 +140,22 @@ public class ReceiveComponent : SpeckleScopedTaskCapableComponent<ReceiveCompone
.ReceiveCommitObject(receiveInfo, progress, cancellationToken)
.ConfigureAwait(false);
// TODO: If we have NodeRun events later, better to have `ComponentTracker` to use across components
var customProperties = new Dictionary<string, object>()
{
{ "isAsync", false },
{ "sourceHostApp", HostApplications.GetSlugFromHostAppNameAndVersion(receiveInfo.SourceApplication) }
};
if (receiveInfo.WorkspaceId != null)
{
customProperties.Add("workspace_id", receiveInfo.WorkspaceId);
}
if (receiveInfo.SelectedVersionUserId != null)
{
customProperties.Add("isMultiplayer", receiveInfo.SelectedVersionUserId != client.Account.userInfo.id);
}
await _mixpanel.TrackEvent(MixPanelEvents.Receive, account, customProperties);
// We need to rethink these lovely unpackers, there's a bit too many of 'em
var rootObjectUnpacker = scope.ServiceProvider.GetService<RootObjectUnpacker>();
var localToGlobalUnpacker = new LocalToGlobalUnpacker();
@@ -0,0 +1,12 @@
using Speckle.Connectors.Common.Operations;
namespace Speckle.Connectors.GrasshopperShared.Components.Operations.Send;
public record GrasshopperSendInfo(
string AccountId,
Uri ServerUrl,
string? WorkspaceId,
string ProjectId,
string ModelId,
string SourceApplication
) : SendInfo(AccountId, ServerUrl, ProjectId, ModelId, SourceApplication);
@@ -8,6 +8,7 @@ using Grasshopper.Kernel.Attributes;
using GrasshopperAsyncComponent;
using Microsoft.Extensions.DependencyInjection;
using Rhino;
using Speckle.Connectors.Common.Analytics;
using Speckle.Connectors.Common.Operations;
using Speckle.Connectors.GrasshopperShared.HostApp;
using Speckle.Connectors.GrasshopperShared.Parameters;
@@ -56,6 +57,7 @@ public class SendAsyncComponent : GH_AsyncComponent
public double OverallProgress { get; set; }
public string? Url { get; set; }
public IClient ApiClient { get; set; }
public MixPanelManager MixPanelManager { get; set; }
public HostApp.SpeckleUrlModelResource? UrlModelResource { get; set; }
public SpeckleCollectionWrapperGoo? RootCollectionWrapper { get; set; }
@@ -141,11 +143,13 @@ public class SendAsyncComponent : GH_AsyncComponent
Scope = PriorityLoader.Container.CreateScope();
SendOperation = Scope.ServiceProvider.GetRequiredService<SendOperation<SpeckleCollectionWrapperGoo>>();
var accountManager = Scope.ServiceProvider.GetRequiredService<AccountService>();
MixPanelManager = Scope.ServiceProvider.GetRequiredService<MixPanelManager>();
var accountService = Scope.ServiceProvider.GetRequiredService<AccountService>();
var accountManager = Scope.ServiceProvider.GetRequiredService<AccountManager>();
var clientFactory = Scope.ServiceProvider.GetRequiredService<IClientFactory>();
// We need to call this always in here to be able to react and set events :/
ParseInput(da, accountManager, clientFactory);
ParseInput(da, accountService, accountManager, clientFactory);
if (
(AutoSend || CurrentComponentState == ComponentState.Ready || CurrentComponentState == ComponentState.Sending)
@@ -225,7 +229,12 @@ public class SendAsyncComponent : GH_AsyncComponent
base.DocumentContextChanged(document, context);
}
private void ParseInput(IGH_DataAccess da, AccountService accountManager, IClientFactory clientFactory)
private void ParseInput(
IGH_DataAccess da,
AccountService accountService,
AccountManager accountManager,
IClientFactory clientFactory
)
{
HostApp.SpeckleUrlModelResource? dataInput = null;
da.GetData(0, ref dataInput);
@@ -239,8 +248,10 @@ public class SendAsyncComponent : GH_AsyncComponent
UrlModelResource = dataInput;
try
{
// TODO: Get any account for this server, as we don't have a mechanism yet to pass accountIds through
Account account = accountManager.GetAccountWithServerUrlFallback("", new Uri(dataInput.Server));
Account? account =
dataInput.AccountId != null
? accountManager.GetAccount(dataInput.AccountId)
: accountService.GetAccountWithServerUrlFallback("", new Uri(dataInput.Server)); // fallback the account that matches with URL if any
if (account is null)
{
throw new SpeckleAccountManagerException($"No default account was found");
@@ -392,8 +403,31 @@ public class SendComponentWorker : WorkerInstance
)
.ConfigureAwait(false);
// TODO: If we have NodeRun events later, better to have `ComponentTracker` to use across components
var customProperties = new Dictionary<string, object>()
{
{ "isAsync", true },
{ "auto", sendComponent.AutoSend }
};
if (sendInfo.WorkspaceId != null)
{
customProperties.Add("workspace_id", sendInfo.WorkspaceId);
}
await sendComponent.MixPanelManager.TrackEvent(
MixPanelEvents.Send,
sendComponent.ApiClient.Account,
customProperties
);
SpeckleUrlModelVersionResource? createdVersion =
new(sendInfo.ServerUrl.ToString(), sendInfo.ProjectId, sendInfo.ModelId, result.VersionId);
new(
sendInfo.AccountId,
sendInfo.ServerUrl.ToString(),
sendInfo.WorkspaceId,
sendInfo.ProjectId,
sendInfo.ModelId,
result.VersionId
);
OutputParam = createdVersion;
sendComponent.Url =
$"{createdVersion.Server}projects/{sendInfo.ProjectId}/models/{sendInfo.ModelId}@{result.VersionId}";
@@ -1,11 +1,13 @@
using System.Diagnostics;
using Grasshopper.Kernel;
using Microsoft.Extensions.DependencyInjection;
using Speckle.Connectors.Common.Analytics;
using Speckle.Connectors.Common.Operations;
using Speckle.Connectors.GrasshopperShared.Components.BaseComponents;
using Speckle.Connectors.GrasshopperShared.HostApp;
using Speckle.Connectors.GrasshopperShared.Parameters;
using Speckle.Connectors.GrasshopperShared.Properties;
using Speckle.Connectors.GrasshopperShared.Registration;
using Speckle.Sdk;
using Speckle.Sdk.Api;
using Speckle.Sdk.Common;
@@ -34,6 +36,8 @@ public class SendComponentOutput(SpeckleUrlModelResource? resource)
public class SendComponent : SpeckleScopedTaskCapableComponent<SendComponentInput, SendComponentOutput>
{
private readonly MixPanelManager _mixpanel;
public SendComponent()
: base(
"(Sync) Publish",
@@ -41,7 +45,10 @@ public class SendComponent : SpeckleScopedTaskCapableComponent<SendComponentInpu
"Publish a collection to Speckle, synchronously",
ComponentCategories.PRIMARY_RIBBON,
ComponentCategories.DEVELOPER
) { }
)
{
_mixpanel = PriorityLoader.Container.GetRequiredService<MixPanelManager>();
}
public override Guid ComponentGuid => new("0CF0D173-BDF0-4AC2-9157-02822B90E9FB");
@@ -133,12 +140,15 @@ public class SendComponent : SpeckleScopedTaskCapableComponent<SendComponentInpu
return new(null);
}
var accountManager = scope.ServiceProvider.GetRequiredService<AccountService>();
var accountService = scope.ServiceProvider.GetRequiredService<AccountService>();
var accountManager = scope.ServiceProvider.GetRequiredService<AccountManager>();
var clientFactory = scope.ServiceProvider.GetRequiredService<IClientFactory>();
var sendOperation = scope.ServiceProvider.GetRequiredService<SendOperation<SpeckleCollectionWrapperGoo>>();
// TODO: Get any account for this server, as we don't have a mechanism yet to pass accountIds through
var account = accountManager.GetAccountWithServerUrlFallback("", new Uri(input.Resource.Server));
Account? account =
input.Resource.AccountId != null
? accountManager.GetAccount(input.Resource.AccountId)
: accountService.GetAccountWithServerUrlFallback("", new Uri(input.Resource.Server)); // fallback the account that matches with URL if any
if (account is null)
{
@@ -157,8 +167,22 @@ public class SendComponent : SpeckleScopedTaskCapableComponent<SendComponentInpu
.Execute(new List<SpeckleCollectionWrapperGoo>() { input.Input }, sendInfo, progress, cancellationToken)
.ConfigureAwait(false);
// TODO: If we have NodeRun events later, better to have `ComponentTracker` to use across components
var customProperties = new Dictionary<string, object>() { { "isAsync", false } };
if (sendInfo.WorkspaceId != null)
{
customProperties.Add("workspace_id", sendInfo.WorkspaceId);
}
await _mixpanel.TrackEvent(MixPanelEvents.Send, account, customProperties);
SpeckleUrlLatestModelVersionResource createdVersionResource =
new(sendInfo.ServerUrl.ToString(), sendInfo.ProjectId, sendInfo.ModelId);
new(
sendInfo.AccountId,
sendInfo.ServerUrl.ToString(),
sendInfo.WorkspaceId,
sendInfo.ProjectId,
sendInfo.ModelId
);
Url = $"{createdVersionResource.Server}projects/{sendInfo.ProjectId}/models/{sendInfo.ModelId}"; // TODO: missing "@VersionId"
return new SendComponentOutput(createdVersionResource);
@@ -250,7 +250,9 @@ public class SpeckleSelectModelComponent : GH_Component
da.SetData(
0,
new SpeckleUrlLatestModelVersionResource(
SpeckleOperationWizard.SelectedAccount.id,
SpeckleOperationWizard.SelectedAccount.serverInfo.url,
SpeckleOperationWizard.SelectedWorkspace?.id,
SpeckleOperationWizard.SelectedProject.id,
SpeckleOperationWizard.SelectedModel.id
)
@@ -262,7 +264,9 @@ public class SpeckleSelectModelComponent : GH_Component
da.SetData(
0,
new SpeckleUrlModelVersionResource(
SpeckleOperationWizard.SelectedAccount.id,
SpeckleOperationWizard.SelectedAccount.serverInfo.url,
SpeckleOperationWizard.SelectedWorkspace?.id,
SpeckleOperationWizard.SelectedProject.id,
SpeckleOperationWizard.SelectedModel.id,
SpeckleOperationWizard.SelectedVersion.id
@@ -1,4 +1,5 @@
using Speckle.Connectors.Common.Operations;
using Speckle.Connectors.GrasshopperShared.Components.Operations.Receive;
using Speckle.Connectors.GrasshopperShared.Components.Operations.Send;
using Speckle.Sdk.Api;
using Speckle.Sdk.Api.GraphQL.Models;
using Speckle.Sdk.Common;
@@ -6,17 +7,30 @@ using Version = Speckle.Sdk.Api.GraphQL.Models.Version;
namespace Speckle.Connectors.GrasshopperShared.HostApp;
public abstract record SpeckleUrlModelResource(string Server, string ProjectId)
// noting that if the user inputs a model url string, this will not contain account info
// (and that's why the accountID is nullable in the record resource)
public abstract record SpeckleUrlModelResource(string? AccountId, string Server, string? WorkspaceId, string ProjectId)
{
public abstract Task<ReceiveInfo> GetReceiveInfo(IClient client, CancellationToken cancellationToken = default);
public abstract Task<GrasshopperReceiveInfo> GetReceiveInfo(
IClient client,
CancellationToken cancellationToken = default
);
public abstract Task<SendInfo> GetSendInfo(IClient client, CancellationToken cancellationToken = default);
public abstract Task<GrasshopperSendInfo> GetSendInfo(IClient client, CancellationToken cancellationToken = default);
}
public record SpeckleUrlLatestModelVersionResource(string Server, string ProjectId, string ModelId)
: SpeckleUrlModelResource(Server, ProjectId)
public record SpeckleUrlLatestModelVersionResource(
string? AccountId,
string Server,
string? WorkspaceId,
string ProjectId,
string ModelId
) : SpeckleUrlModelResource(AccountId, Server, WorkspaceId, ProjectId)
{
public override async Task<ReceiveInfo> GetReceiveInfo(IClient client, CancellationToken cancellationToken = default)
public override async Task<GrasshopperReceiveInfo> GetReceiveInfo(
IClient client,
CancellationToken cancellationToken = default
)
{
Project project = await client.Project.Get(ProjectId, cancellationToken).ConfigureAwait(false);
ModelWithVersions model = await client
@@ -24,29 +38,35 @@ public record SpeckleUrlLatestModelVersionResource(string Server, string Project
.ConfigureAwait(false);
Version version = model.versions.items[0];
var info = new ReceiveInfo(
var info = new GrasshopperReceiveInfo(
client.Account.id,
new Uri(Server),
project.workspaceId,
ProjectId,
project.name,
ModelId,
model.name,
version.id,
version.sourceApplication.NotNull()
version.sourceApplication.NotNull(),
version.authorUser?.id
);
return info;
}
public override async Task<SendInfo> GetSendInfo(IClient client, CancellationToken cancellationToken = default)
public override async Task<GrasshopperSendInfo> GetSendInfo(
IClient client,
CancellationToken cancellationToken = default
)
{
// We don't care about the return info, we just want to be sure we have access and everything exists.
await client.Project.Get(ProjectId, cancellationToken).ConfigureAwait(false);
await client.Model.Get(ModelId, ProjectId, cancellationToken).ConfigureAwait(false);
return new SendInfo(
return new GrasshopperSendInfo(
client.Account.id,
new Uri(Server),
WorkspaceId,
ProjectId,
ModelId,
"Grasshopper8" // TODO: Grab from the right place!
@@ -54,38 +74,53 @@ public record SpeckleUrlLatestModelVersionResource(string Server, string Project
}
}
public record SpeckleUrlModelVersionResource(string Server, string ProjectId, string ModelId, string VersionId)
: SpeckleUrlModelResource(Server, ProjectId)
public record SpeckleUrlModelVersionResource(
string? AccountId,
string Server,
string? WorkspaceId,
string ProjectId,
string ModelId,
string VersionId
) : SpeckleUrlModelResource(AccountId, Server, WorkspaceId, ProjectId)
{
public override async Task<ReceiveInfo> GetReceiveInfo(IClient client, CancellationToken cancellationToken = default)
public override async Task<GrasshopperReceiveInfo> GetReceiveInfo(
IClient client,
CancellationToken cancellationToken = default
)
{
Project project = await client.Project.Get(ProjectId, cancellationToken).ConfigureAwait(false);
Model model = await client.Model.Get(ModelId, ProjectId, cancellationToken).ConfigureAwait(false);
Version version = await client.Version.Get(VersionId, ProjectId, cancellationToken).ConfigureAwait(false);
var info = new ReceiveInfo(
var info = new GrasshopperReceiveInfo(
client.Account.id,
new Uri(Server),
project.workspaceId,
ProjectId,
project.name,
ModelId,
model.name,
VersionId,
version.sourceApplication.NotNull()
version.sourceApplication.NotNull(),
version.authorUser?.id
);
return info;
}
public override async Task<SendInfo> GetSendInfo(IClient client, CancellationToken cancellationToken = default)
public override async Task<GrasshopperSendInfo> GetSendInfo(
IClient client,
CancellationToken cancellationToken = default
)
{
// We don't care about the return info, we just want to be sure we have access and everything exists.
await client.Project.Get(ProjectId, cancellationToken).ConfigureAwait(false);
await client.Model.Get(ModelId, ProjectId, cancellationToken).ConfigureAwait(false);
return new SendInfo(
return new GrasshopperSendInfo(
client.Account.id,
new Uri(Server),
WorkspaceId,
ProjectId,
ModelId,
"Grasshopper8" // TODO: Grab from the right place!
@@ -93,12 +128,21 @@ public record SpeckleUrlModelVersionResource(string Server, string ProjectId, st
}
}
public record SpeckleUrlModelObjectResource(string Server, string ProjectId, string ObjectId)
: SpeckleUrlModelResource(Server, ProjectId)
public record SpeckleUrlModelObjectResource(
string? AccountId,
string Server,
string? WorkspaceId,
string ProjectId,
string ObjectId
) : SpeckleUrlModelResource(AccountId, Server, WorkspaceId, ProjectId)
{
public override Task<ReceiveInfo> GetReceiveInfo(IClient client, CancellationToken cancellationToken = default) =>
throw new NotImplementedException("Object Resources are not supported yet");
public override Task<GrasshopperReceiveInfo> GetReceiveInfo(
IClient client,
CancellationToken cancellationToken = default
) => throw new NotImplementedException("Object Resources are not supported yet");
public override Task<SendInfo> GetSendInfo(IClient client, CancellationToken cancellationToken = default) =>
throw new NotImplementedException("Object Resources are not supported yet");
public override Task<GrasshopperSendInfo> GetSendInfo(
IClient client,
CancellationToken cancellationToken = default
) => throw new NotImplementedException("Object Resources are not supported yet");
}
@@ -48,7 +48,7 @@ public record SpeckleResourceBuilder
throw new NotSupportedException("Federation model urls are not supported");
}
var modelRes = GetUrlModelResource(serverUrl, projectId.Value, model.Value);
var modelRes = GetUrlModelResource(null, serverUrl, null, projectId.Value, model.Value);
var result = new List<SpeckleUrlModelResource> { modelRes };
@@ -56,7 +56,7 @@ public record SpeckleResourceBuilder
{
foreach (Capture additionalModelsCapture in additionalModels.Captures)
{
var extraModel = GetUrlModelResource(serverUrl, projectId.Value, additionalModelsCapture.Value);
var extraModel = GetUrlModelResource(null, serverUrl, null, projectId.Value, additionalModelsCapture.Value);
result.Add(extraModel);
}
}
@@ -64,19 +64,25 @@ public record SpeckleResourceBuilder
return result.ToArray();
}
private static SpeckleUrlModelResource GetUrlModelResource(string serverUrl, string projectId, string modelValue)
private static SpeckleUrlModelResource GetUrlModelResource(
string? accountId,
string serverUrl,
string? workspaceId,
string projectId,
string modelValue
)
{
if (modelValue.Length == 32)
{
return new SpeckleUrlModelObjectResource(serverUrl, projectId, modelValue); // Model value is an ObjectID
return new SpeckleUrlModelObjectResource(accountId, serverUrl, workspaceId, projectId, modelValue); // Model value is an ObjectID
}
if (!modelValue.Contains('@'))
{
return new SpeckleUrlLatestModelVersionResource(serverUrl, projectId, modelValue); // Model has no version attached
return new SpeckleUrlLatestModelVersionResource(accountId, serverUrl, workspaceId, projectId, modelValue); // Model has no version attached
}
var res = modelValue.Split('@');
return new SpeckleUrlModelVersionResource(serverUrl, projectId, res[0], res[1]);
return new SpeckleUrlModelVersionResource(accountId, serverUrl, workspaceId, projectId, res[0], res[1]);
}
}
@@ -1,6 +1,7 @@
using Grasshopper.Kernel;
using Microsoft.Extensions.DependencyInjection;
using Speckle.Connectors.Common;
using Speckle.Connectors.Common.Analytics;
using Speckle.Connectors.Common.Builders;
using Speckle.Connectors.Common.Operations;
using Speckle.Connectors.Common.Operations.Receive;
@@ -32,6 +33,7 @@ public class PriorityLoader : GH_AssemblyPriority
// receive
services.AddTransient<GrasshopperReceiveOperation>();
services.AddTransient<AccountService>();
services.AddSingleton<MixPanelManager>();
services.AddSingleton(DefaultTraversal.CreateTraversalFunc());
services.AddScoped<RootObjectUnpacker>();
services.AddTransient<TraversalContextUnpacker>();
@@ -24,6 +24,8 @@
<Compile Include="$(MSBuildThisFileDirectory)Components\Dev\DeconstructSpeckleParam.cs" />
<Compile Include="$(MSBuildThisFileDirectory)Components\Objects\CreateSpeckleObject.cs" />
<Compile Include="$(MSBuildThisFileDirectory)Components\Objects\CreateSpeckleProperties.cs" />
<Compile Include="$(MSBuildThisFileDirectory)Components\Operations\Receive\GrasshopperReceiveInfo.cs" />
<Compile Include="$(MSBuildThisFileDirectory)Components\Operations\Send\GrasshopperSendInfo.cs" />
<Compile Include="$(MSBuildThisFileDirectory)Components\Operations\Wizard\ModelMenuHandler.cs" />
<Compile Include="$(MSBuildThisFileDirectory)Components\Operations\Wizard\ProjectMenuHandler.cs" />
<Compile Include="$(MSBuildThisFileDirectory)Components\Operations\Wizard\SearchToolStripMenuItem.cs" />
@@ -3,6 +3,7 @@ using FluentAssertions;
using Microsoft.Extensions.DependencyInjection;
using Moq;
using NUnit.Framework;
using Speckle.Connectors.Common.Analytics;
using Speckle.Connectors.Common.Builders;
using Speckle.Connectors.Common.Operations;
using Speckle.Connectors.Common.Threading;
@@ -89,6 +90,7 @@ public class ReceiveOperationTests : MoqTest
var receiveVersionRetriever = Create<IReceiveVersionRetriever>();
var activityFactory = Create<ISdkActivityFactory>(MockBehavior.Loose);
var threadContext = Create<IThreadContext>();
var mixPanelManager = Create<IMixPanelManager>();
var @base = new TestBase();
var token = "token";
@@ -3,6 +3,7 @@ using FluentAssertions;
using Microsoft.Extensions.DependencyInjection;
using Moq;
using NUnit.Framework;
using Speckle.Connectors.Common.Analytics;
using Speckle.Connectors.Common.Builders;
using Speckle.Connectors.Common.Caching;
using Speckle.Connectors.Common.Conversion;
@@ -40,6 +41,7 @@ public class SendOperationTests : MoqTest
var sendOperationVersionRecorder = Create<ISendOperationVersionRecorder>();
var activityFactory = Create<ISdkActivityFactory>();
var threadContext = Create<IThreadContext>();
var mixPanelManager = Create<IMixPanelManager>();
var ct = new CancellationToken();
var objects = new List<object>();
@@ -0,0 +1,17 @@
namespace Speckle.Connectors.Common.Analytics;
/// <summary>
/// Default Mixpanel events
/// </summary>
public enum MixPanelEvents
{
/// <summary>
/// Event triggered when data is sent to a Speckle Server
/// </summary>
Send,
/// <summary>
/// Event triggered when data is received from a Speckle Server
/// </summary>
Receive
}
@@ -0,0 +1,254 @@
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";
}
}
@@ -1,6 +1,7 @@
using System.Reflection;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging;
using Speckle.Connectors.Common.Analytics;
using Speckle.Connectors.Common.Cancellation;
using Speckle.Connectors.Common.Operations;
using Speckle.Connectors.Common.Operations.Receive;
@@ -19,6 +20,7 @@ public static class ContainerRegistration
serviceCollection.AddScoped<RootObjectUnpacker>();
serviceCollection.AddScoped<ReceiveOperation>();
serviceCollection.AddSingleton<IAccountService, AccountService>();
serviceCollection.AddSingleton<IMixPanelManager, MixPanelManager>();
serviceCollection.AddTransient(typeof(ILogger<>), typeof(Logger<>));
}
@@ -1,4 +1,5 @@
using Speckle.Sdk;
using System.Text.RegularExpressions;
using Speckle.Sdk;
namespace Speckle.Connectors.Common;
@@ -39,4 +40,65 @@ public static class HostApplications
Navisworks = new("Navisworks", "navisworks"),
AdvanceSteel = new("Advance Steel", "advancesteel"),
Other = new("Other", "other");
/// <summary>
/// Gets a slug from a host application name and version.
/// </summary>
/// <param name="appName">Application name with its version, e.g., "Rhino 7", "Revit 2024".</param>
/// <returns>Slug string.</returns>
public static string GetSlugFromHostAppNameAndVersion(string appName)
{
if (string.IsNullOrWhiteSpace(appName))
{
return "other";
}
// Remove whitespace and convert to lowercase
appName = Regex.Replace(appName.ToLowerInvariant(), @"\s+", "");
var keywords = new List<string>
{
"dynamo",
"revit",
"autocad",
"civil",
"rhino",
"grasshopper",
"unity",
"gsa",
"microstation",
"openroads",
"openrail",
"openbuildings",
"etabs",
"sap",
"csibridge",
"safe",
"teklastructures",
"dxf",
"excel",
"unreal",
"powerbi",
"blender",
"qgis",
"arcgis",
"sketchup",
"archicad",
"topsolid",
"python",
"net",
"navisworks",
"advancesteel"
};
foreach (var keyword in keywords)
{
if (appName.Contains(keyword))
{
return keyword;
}
}
return appName;
}
}
@@ -50,7 +50,6 @@ public sealed class ReceiveOperation(
cancellationToken.ThrowIfCancellationRequested();
await receiveVersionRetriever.VersionReceived(account, version, receiveInfo, cancellationToken);
return res;
}
@@ -81,7 +81,6 @@ public sealed class SendOperation<T>(
// 8 - Create the version (commit)
var versionId = await sendOperationVersionRecorder.RecordVersion(sendResult.RootId, sendInfo, account, ct);
return (sendResult, versionId);
}
}
@@ -8,6 +8,10 @@
<PackageReference Include="Microsoft.Extensions.DependencyInjection" />
<ProjectReference Include="..\Speckle.Connectors.Logging\Speckle.Connectors.Logging.csproj" />
</ItemGroup>
<ItemGroup Condition="'$(TargetFramework)' == 'net48'">
<Reference Include="System.Net.Http" />
<Reference Include="System.Web" />
</ItemGroup>
<ItemGroup Condition="'$(Configuration)' == 'Local'">
<ProjectReference Include="..\..\..\speckle-sharp-sdk\src\Speckle.Sdk\Speckle.Sdk.csproj" />
<ProjectReference Include="..\..\..\speckle-sharp-sdk\src\Speckle.Objects\Speckle.Objects.csproj" />