From 68a0ed3334e2e4df743960fa8d0fc24002fbbcb9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?O=C4=9Fuzhan=20Koral?= <45078678+oguzhankoral@users.noreply.github.com> Date: Wed, 14 May 2025 21:35:20 +0300 Subject: [PATCH] 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 Co-authored-by: Claire Kuang --- .../Components/ComponentUtils.cs | 10 +- .../Receive/GrasshopperReceiveInfo.cs | 16 ++ .../Receive/ReceiveAsyncComponent.cs | 45 +++- .../Operations/Receive/ReceiveComponent.cs | 36 ++- .../Operations/Send/GrasshopperSendInfo.cs | 12 + .../Operations/Send/SendAsyncComponent.cs | 46 +++- .../Operations/Send/SendComponent.cs | 34 ++- .../Operations/SpeckleSelectModelComponent.cs | 4 + .../HostApp/SpeckleResource.cs | 92 +++++-- .../HostApp/SpeckleResourceBuilder.cs | 18 +- .../Registration/PriorityLoader.cs | 2 + ...kle.Connectors.GrasshopperShared.projitems | 2 + .../Operations/ReceiveOperationTests.cs | 2 + .../Operations/SendOperationTests.cs | 2 + .../Analytics/MixPanelEvents.cs | 17 ++ .../Analytics/MixPanelManager.cs | 254 ++++++++++++++++++ .../ContainerRegistration.cs | 2 + .../HostApplications.cs | 64 ++++- .../Operations/ReceiveOperation.cs | 1 - .../Operations/SendOperation.cs | 1 - .../Speckle.Connectors.Common.csproj | 4 + 21 files changed, 606 insertions(+), 58 deletions(-) create mode 100644 Connectors/Rhino/Speckle.Connectors.GrasshopperShared/Components/Operations/Receive/GrasshopperReceiveInfo.cs create mode 100644 Connectors/Rhino/Speckle.Connectors.GrasshopperShared/Components/Operations/Send/GrasshopperSendInfo.cs create mode 100644 Sdk/Speckle.Connectors.Common/Analytics/MixPanelEvents.cs create mode 100644 Sdk/Speckle.Connectors.Common/Analytics/MixPanelManager.cs diff --git a/Connectors/Rhino/Speckle.Connectors.GrasshopperShared/Components/ComponentUtils.cs b/Connectors/Rhino/Speckle.Connectors.GrasshopperShared/Components/ComponentUtils.cs index cb6e41d29..bb9c1f2b4 100644 --- a/Connectors/Rhino/Speckle.Connectors.GrasshopperShared/Components/ComponentUtils.cs +++ b/Connectors/Rhino/Speckle.Connectors.GrasshopperShared/Components/ComponentUtils.cs @@ -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 diff --git a/Connectors/Rhino/Speckle.Connectors.GrasshopperShared/Components/Operations/Receive/GrasshopperReceiveInfo.cs b/Connectors/Rhino/Speckle.Connectors.GrasshopperShared/Components/Operations/Receive/GrasshopperReceiveInfo.cs new file mode 100644 index 000000000..a9b3cbbcc --- /dev/null +++ b/Connectors/Rhino/Speckle.Connectors.GrasshopperShared/Components/Operations/Receive/GrasshopperReceiveInfo.cs @@ -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); diff --git a/Connectors/Rhino/Speckle.Connectors.GrasshopperShared/Components/Operations/Receive/ReceiveAsyncComponent.cs b/Connectors/Rhino/Speckle.Connectors.GrasshopperShared/Components/Operations/Receive/ReceiveAsyncComponent.cs index d930edbb2..e51237cd3 100644 --- a/Connectors/Rhino/Speckle.Connectors.GrasshopperShared/Components/Operations/Receive/ReceiveAsyncComponent.cs +++ b/Connectors/Rhino/Speckle.Connectors.GrasshopperShared/Components/Operations/Receive/ReceiveAsyncComponent.cs @@ -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(); + + MixPanelManager = Scope.ServiceProvider.GetRequiredService(); RootObjectUnpacker = Scope.ServiceProvider.GetService(); - AccountManager = Scope.ServiceProvider.GetRequiredService(); + AccountService = Scope.ServiceProvider.GetRequiredService(); + AccountManager = Scope.ServiceProvider.GetRequiredService(); ClientFactory = Scope.ServiceProvider.GetRequiredService(); // 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 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() + { + { "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(); }); diff --git a/Connectors/Rhino/Speckle.Connectors.GrasshopperShared/Components/Operations/Receive/ReceiveComponent.cs b/Connectors/Rhino/Speckle.Connectors.GrasshopperShared/Components/Operations/Receive/ReceiveComponent.cs index 379ea3921..e249fa3d6 100644 --- a/Connectors/Rhino/Speckle.Connectors.GrasshopperShared/Components/Operations/Receive/ReceiveComponent.cs +++ b/Connectors/Rhino/Speckle.Connectors.GrasshopperShared/Components/Operations/Receive/ReceiveComponent.cs @@ -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 { + private readonly MixPanelManager _mixpanel; + public ReceiveComponent() : base( "(Sync) Load", @@ -41,7 +46,10 @@ public class ReceiveComponent : SpeckleScopedTaskCapableComponent(); + } 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(); + var accountService = scope.ServiceProvider.GetRequiredService(); + var accountManager = scope.ServiceProvider.GetRequiredService(); var clientFactory = scope.ServiceProvider.GetRequiredService(); var receiveOperation = scope.ServiceProvider.GetRequiredService(); // 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() + { + { "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(); var localToGlobalUnpacker = new LocalToGlobalUnpacker(); diff --git a/Connectors/Rhino/Speckle.Connectors.GrasshopperShared/Components/Operations/Send/GrasshopperSendInfo.cs b/Connectors/Rhino/Speckle.Connectors.GrasshopperShared/Components/Operations/Send/GrasshopperSendInfo.cs new file mode 100644 index 000000000..a90c8ca71 --- /dev/null +++ b/Connectors/Rhino/Speckle.Connectors.GrasshopperShared/Components/Operations/Send/GrasshopperSendInfo.cs @@ -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); diff --git a/Connectors/Rhino/Speckle.Connectors.GrasshopperShared/Components/Operations/Send/SendAsyncComponent.cs b/Connectors/Rhino/Speckle.Connectors.GrasshopperShared/Components/Operations/Send/SendAsyncComponent.cs index cb7888122..5cc80b69f 100644 --- a/Connectors/Rhino/Speckle.Connectors.GrasshopperShared/Components/Operations/Send/SendAsyncComponent.cs +++ b/Connectors/Rhino/Speckle.Connectors.GrasshopperShared/Components/Operations/Send/SendAsyncComponent.cs @@ -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>(); - var accountManager = Scope.ServiceProvider.GetRequiredService(); + MixPanelManager = Scope.ServiceProvider.GetRequiredService(); + var accountService = Scope.ServiceProvider.GetRequiredService(); + var accountManager = Scope.ServiceProvider.GetRequiredService(); var clientFactory = Scope.ServiceProvider.GetRequiredService(); // 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() + { + { "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}"; diff --git a/Connectors/Rhino/Speckle.Connectors.GrasshopperShared/Components/Operations/Send/SendComponent.cs b/Connectors/Rhino/Speckle.Connectors.GrasshopperShared/Components/Operations/Send/SendComponent.cs index 9de5cf921..09fa31989 100644 --- a/Connectors/Rhino/Speckle.Connectors.GrasshopperShared/Components/Operations/Send/SendComponent.cs +++ b/Connectors/Rhino/Speckle.Connectors.GrasshopperShared/Components/Operations/Send/SendComponent.cs @@ -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 { + private readonly MixPanelManager _mixpanel; + public SendComponent() : base( "(Sync) Publish", @@ -41,7 +45,10 @@ public class SendComponent : SpeckleScopedTaskCapableComponent(); + } public override Guid ComponentGuid => new("0CF0D173-BDF0-4AC2-9157-02822B90E9FB"); @@ -133,12 +140,15 @@ public class SendComponent : SpeckleScopedTaskCapableComponent(); + var accountService = scope.ServiceProvider.GetRequiredService(); + var accountManager = scope.ServiceProvider.GetRequiredService(); var clientFactory = scope.ServiceProvider.GetRequiredService(); var sendOperation = scope.ServiceProvider.GetRequiredService>(); - // 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() { 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() { { "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); diff --git a/Connectors/Rhino/Speckle.Connectors.GrasshopperShared/Components/Operations/SpeckleSelectModelComponent.cs b/Connectors/Rhino/Speckle.Connectors.GrasshopperShared/Components/Operations/SpeckleSelectModelComponent.cs index 3fcfa015e..032228dd7 100644 --- a/Connectors/Rhino/Speckle.Connectors.GrasshopperShared/Components/Operations/SpeckleSelectModelComponent.cs +++ b/Connectors/Rhino/Speckle.Connectors.GrasshopperShared/Components/Operations/SpeckleSelectModelComponent.cs @@ -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 diff --git a/Connectors/Rhino/Speckle.Connectors.GrasshopperShared/HostApp/SpeckleResource.cs b/Connectors/Rhino/Speckle.Connectors.GrasshopperShared/HostApp/SpeckleResource.cs index 0e18795fc..7b358df30 100644 --- a/Connectors/Rhino/Speckle.Connectors.GrasshopperShared/HostApp/SpeckleResource.cs +++ b/Connectors/Rhino/Speckle.Connectors.GrasshopperShared/HostApp/SpeckleResource.cs @@ -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 GetReceiveInfo(IClient client, CancellationToken cancellationToken = default); + public abstract Task GetReceiveInfo( + IClient client, + CancellationToken cancellationToken = default + ); - public abstract Task GetSendInfo(IClient client, CancellationToken cancellationToken = default); + public abstract Task 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 GetReceiveInfo(IClient client, CancellationToken cancellationToken = default) + public override async Task 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 GetSendInfo(IClient client, CancellationToken cancellationToken = default) + public override async Task 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 GetReceiveInfo(IClient client, CancellationToken cancellationToken = default) + public override async Task 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 GetSendInfo(IClient client, CancellationToken cancellationToken = default) + public override async Task 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 GetReceiveInfo(IClient client, CancellationToken cancellationToken = default) => - throw new NotImplementedException("Object Resources are not supported yet"); + public override Task GetReceiveInfo( + IClient client, + CancellationToken cancellationToken = default + ) => throw new NotImplementedException("Object Resources are not supported yet"); - public override Task GetSendInfo(IClient client, CancellationToken cancellationToken = default) => - throw new NotImplementedException("Object Resources are not supported yet"); + public override Task GetSendInfo( + IClient client, + CancellationToken cancellationToken = default + ) => throw new NotImplementedException("Object Resources are not supported yet"); } diff --git a/Connectors/Rhino/Speckle.Connectors.GrasshopperShared/HostApp/SpeckleResourceBuilder.cs b/Connectors/Rhino/Speckle.Connectors.GrasshopperShared/HostApp/SpeckleResourceBuilder.cs index fc051fec5..0ab508027 100644 --- a/Connectors/Rhino/Speckle.Connectors.GrasshopperShared/HostApp/SpeckleResourceBuilder.cs +++ b/Connectors/Rhino/Speckle.Connectors.GrasshopperShared/HostApp/SpeckleResourceBuilder.cs @@ -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 { 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]); } } diff --git a/Connectors/Rhino/Speckle.Connectors.GrasshopperShared/Registration/PriorityLoader.cs b/Connectors/Rhino/Speckle.Connectors.GrasshopperShared/Registration/PriorityLoader.cs index 861d60373..0ff23d8ba 100644 --- a/Connectors/Rhino/Speckle.Connectors.GrasshopperShared/Registration/PriorityLoader.cs +++ b/Connectors/Rhino/Speckle.Connectors.GrasshopperShared/Registration/PriorityLoader.cs @@ -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(); services.AddTransient(); + services.AddSingleton(); services.AddSingleton(DefaultTraversal.CreateTraversalFunc()); services.AddScoped(); services.AddTransient(); diff --git a/Connectors/Rhino/Speckle.Connectors.GrasshopperShared/Speckle.Connectors.GrasshopperShared.projitems b/Connectors/Rhino/Speckle.Connectors.GrasshopperShared/Speckle.Connectors.GrasshopperShared.projitems index 95211629a..63a04c6c1 100644 --- a/Connectors/Rhino/Speckle.Connectors.GrasshopperShared/Speckle.Connectors.GrasshopperShared.projitems +++ b/Connectors/Rhino/Speckle.Connectors.GrasshopperShared/Speckle.Connectors.GrasshopperShared.projitems @@ -24,6 +24,8 @@ + + diff --git a/Sdk/Speckle.Connectors.Common.Tests/Operations/ReceiveOperationTests.cs b/Sdk/Speckle.Connectors.Common.Tests/Operations/ReceiveOperationTests.cs index 4a2683be4..f1d103e48 100644 --- a/Sdk/Speckle.Connectors.Common.Tests/Operations/ReceiveOperationTests.cs +++ b/Sdk/Speckle.Connectors.Common.Tests/Operations/ReceiveOperationTests.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(); var activityFactory = Create(MockBehavior.Loose); var threadContext = Create(); + var mixPanelManager = Create(); var @base = new TestBase(); var token = "token"; diff --git a/Sdk/Speckle.Connectors.Common.Tests/Operations/SendOperationTests.cs b/Sdk/Speckle.Connectors.Common.Tests/Operations/SendOperationTests.cs index 2cebde68c..ad9d4422e 100644 --- a/Sdk/Speckle.Connectors.Common.Tests/Operations/SendOperationTests.cs +++ b/Sdk/Speckle.Connectors.Common.Tests/Operations/SendOperationTests.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.Caching; using Speckle.Connectors.Common.Conversion; @@ -40,6 +41,7 @@ public class SendOperationTests : MoqTest var sendOperationVersionRecorder = Create(); var activityFactory = Create(); var threadContext = Create(); + var mixPanelManager = Create(); var ct = new CancellationToken(); var objects = new List(); diff --git a/Sdk/Speckle.Connectors.Common/Analytics/MixPanelEvents.cs b/Sdk/Speckle.Connectors.Common/Analytics/MixPanelEvents.cs new file mode 100644 index 000000000..e6f9154c6 --- /dev/null +++ b/Sdk/Speckle.Connectors.Common/Analytics/MixPanelEvents.cs @@ -0,0 +1,17 @@ +namespace Speckle.Connectors.Common.Analytics; + +/// +/// Default Mixpanel events +/// +public enum MixPanelEvents +{ + /// + /// Event triggered when data is sent to a Speckle Server + /// + Send, + + /// + /// Event triggered when data is received from a Speckle Server + /// + Receive +} diff --git a/Sdk/Speckle.Connectors.Common/Analytics/MixPanelManager.cs b/Sdk/Speckle.Connectors.Common/Analytics/MixPanelManager.cs new file mode 100644 index 000000000..4bd2f5275 --- /dev/null +++ b/Sdk/Speckle.Connectors.Common/Analytics/MixPanelManager.cs @@ -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; + +/// +/// 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! +/// +[GenerateAutoInterface] +public class MixPanelManager(ISpeckleApplication application, ISpeckleHttp speckleHttp, ILogger logger) + : IMixPanelManager +{ + private const string MIXPANEL_TOKEN = "acd87c5a50b56df91a795e999812a3a4"; + private static readonly Uri s_mixpanelServer = new("https://analytics.speckle.systems"); + + /// + /// Cached email + /// + private string? LastEmail { get; set; } + + /// + /// Cached server URL + /// + private string? LastServer { get; set; } + + /// + /// when the DEBUG pre-processor directive is , otherwise + /// + /// This must be kept as a computed property, not a compile time const + private static bool IsReleaseMode => +#if DEBUG + false; +#else + true; +#endif + + /// + /// 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. + /// + /// Name of the even + /// Additional parameters to pass in to event + /// True if it's an action performed by a logged user + public async Task TrackEvent( + MixPanelEvents eventName, + Account? account, + Dictionary? 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); + } + + /// + /// Tracks an event from a specified email and server, anonymizes personal information + /// + /// Email of the user anonymized + /// Server URL anonymized + /// Name of the event + /// Additional parameters to pass to the event + /// True if it's an action performed by a logged user + private async Task TrackEvent( + string? hashedEmail, + string hashedServer, + MixPanelEvents eventName, + string? email, + Dictionary? customProperties = null, + bool isAction = true + ) + { + LastEmail = hashedEmail; + LastServer = hashedServer; + + if (!IsReleaseMode) + { + //only track in prod + return; + } + + try + { + var properties = new Dictionary + { + { "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 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 + { + { "$token", MIXPANEL_TOKEN }, + { "$distinct_id", hashedEmail }, + { + "$union", + new Dictionary + { + { + "Connectors", + new List { 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 + { + { "$token", MIXPANEL_TOKEN }, + { "$distinct_id", hashedEmail }, + { + "$set", + new Dictionary { { "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"; + } +} diff --git a/Sdk/Speckle.Connectors.Common/ContainerRegistration.cs b/Sdk/Speckle.Connectors.Common/ContainerRegistration.cs index d7bfe9144..afec56e68 100644 --- a/Sdk/Speckle.Connectors.Common/ContainerRegistration.cs +++ b/Sdk/Speckle.Connectors.Common/ContainerRegistration.cs @@ -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(); serviceCollection.AddScoped(); serviceCollection.AddSingleton(); + serviceCollection.AddSingleton(); serviceCollection.AddTransient(typeof(ILogger<>), typeof(Logger<>)); } diff --git a/Sdk/Speckle.Connectors.Common/HostApplications.cs b/Sdk/Speckle.Connectors.Common/HostApplications.cs index c02520fd2..3b2e9646c 100644 --- a/Sdk/Speckle.Connectors.Common/HostApplications.cs +++ b/Sdk/Speckle.Connectors.Common/HostApplications.cs @@ -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"); + + /// + /// Gets a slug from a host application name and version. + /// + /// Application name with its version, e.g., "Rhino 7", "Revit 2024". + /// Slug string. + 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 + { + "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; + } } diff --git a/Sdk/Speckle.Connectors.Common/Operations/ReceiveOperation.cs b/Sdk/Speckle.Connectors.Common/Operations/ReceiveOperation.cs index efd67640c..b4981104d 100644 --- a/Sdk/Speckle.Connectors.Common/Operations/ReceiveOperation.cs +++ b/Sdk/Speckle.Connectors.Common/Operations/ReceiveOperation.cs @@ -50,7 +50,6 @@ public sealed class ReceiveOperation( cancellationToken.ThrowIfCancellationRequested(); await receiveVersionRetriever.VersionReceived(account, version, receiveInfo, cancellationToken); - return res; } diff --git a/Sdk/Speckle.Connectors.Common/Operations/SendOperation.cs b/Sdk/Speckle.Connectors.Common/Operations/SendOperation.cs index dad448b70..82fca2f3a 100644 --- a/Sdk/Speckle.Connectors.Common/Operations/SendOperation.cs +++ b/Sdk/Speckle.Connectors.Common/Operations/SendOperation.cs @@ -81,7 +81,6 @@ public sealed class SendOperation( // 8 - Create the version (commit) var versionId = await sendOperationVersionRecorder.RecordVersion(sendResult.RootId, sendInfo, account, ct); - return (sendResult, versionId); } } diff --git a/Sdk/Speckle.Connectors.Common/Speckle.Connectors.Common.csproj b/Sdk/Speckle.Connectors.Common/Speckle.Connectors.Common.csproj index 5d87c72c3..8915acf68 100644 --- a/Sdk/Speckle.Connectors.Common/Speckle.Connectors.Common.csproj +++ b/Sdk/Speckle.Connectors.Common/Speckle.Connectors.Common.csproj @@ -8,6 +8,10 @@ + + + +