From 2d4e4eaa5317f3f553f1bfe508880b415a630fa1 Mon Sep 17 00:00:00 2001 From: Blake Niemyjski Date: Thu, 27 Aug 2026 22:02:25 -0500 Subject: [PATCH 01/43] Harden guided tours and usage reporting --- .../Models/Data/ProductTourProgress.cs | 22 ++ src/Exceptionless.Core/Models/User.cs | 2 + .../Api/Endpoints/AdminEndpoints.cs | 15 + .../Api/Endpoints/UserEndpoints.cs | 22 ++ .../Api/Handlers/AdminHandler.cs | 139 +++++++ .../Api/Handlers/UserHandler.cs | 52 ++- .../Api/Messages/AdminMessages.cs | 1 + .../Api/Messages/UserMessages.cs | 1 + .../ClientApp/e2e/fixtures/api-client.ts | 9 + .../ClientApp/e2e/fixtures/e2e-test.ts | 8 +- .../ClientApp/e2e/tests/product-tours.e2e.ts | 271 ++++++++++++++ .../ClientApp/package-lock.json | 7 + src/Exceptionless.Web/ClientApp/package.json | 1 + .../src/lib/features/admin/api.svelte.ts | 25 ++ .../src/lib/features/admin/models.ts | 2 + .../components/assistant-panel.svelte | 1 + .../events/components/events-overview.svelte | 195 +++++----- .../components/investigation-list-tour.svelte | 29 ++ .../features/product-tours/actions.svelte.ts | 58 +++ .../features/product-tours/catalog.test.ts | 47 +++ .../src/lib/features/product-tours/catalog.ts | 93 +++++ .../product-tour-catalog-dialog.svelte | 50 +++ .../product-tour-welcome-dialog.svelte | 48 +++ ...product-tour-welcome-dialog.svelte.test.ts | 41 +++ .../product-tour-feature-announcement.svelte | 44 +++ .../components/product-tour-host.svelte | 275 ++++++++++++++ .../product-tour-inline-callout.svelte | 30 ++ .../product-tour-shell-spotlight.svelte | 119 ++++++ .../components/product-tour-spotlight.svelte | 74 ++++ .../product-tours/eligibility.test.ts | 24 ++ .../lib/features/product-tours/eligibility.ts | 9 + .../product-tours/session.svelte.test.ts | 42 +++ .../src/lib/features/product-tours/session.ts | 66 ++++ .../product-tours/state.svelte.test.ts | 36 ++ .../features/product-tours/state.svelte.ts | 66 ++++ .../features/product-tours/telemetry.test.ts | 13 + .../lib/features/product-tours/telemetry.ts | 16 + .../src/lib/features/product-tours/types.ts | 56 +++ .../src/lib/features/projects/api.svelte.ts | 3 +- .../components/save-view-dialog.svelte | 104 +++++- .../components/saved-view-picker.svelte | 119 +++++- .../components/ui/sidebar/sidebar.svelte | 1 + .../stacks/components/stack-card.svelte | 11 +- .../src/lib/features/users/api.svelte.ts | 46 ++- .../src/lib/features/users/models.ts | 10 +- .../ClientApp/src/lib/generated/api.ts | 67 ++++ .../ClientApp/src/lib/generated/schemas.ts | 67 ++++ .../(app)/(components)/layouts/navbar.svelte | 4 +- .../(components)/layouts/sidebar-user.svelte | 20 +- .../(app)/(components)/layouts/sidebar.svelte | 4 +- .../(components)/navigation-command.svelte | 31 ++ .../navigation-command.svelte.test.ts | 48 +++ .../ClientApp/src/routes/(app)/+layout.svelte | 107 +++++- .../src/routes/(app)/event/+page.svelte | 135 +++---- .../event/[eventId=objectid]/+page.svelte | 1 + .../src/routes/(app)/event/query-filters.ts | 70 ++++ .../(app)/organization/add/+page.svelte | 40 +- .../[projectId]/configure/+page.svelte | 87 ++++- .../src/routes/(app)/project/add/+page.svelte | 24 +- .../(app)/system/product-tours/+page.svelte | 154 ++++++++ .../src/routes/(app)/system/routes.svelte.ts | 8 + .../Admin/AdminProductTourUsageResponse.cs | 28 ++ .../Models/Admin/ProductTourUsageSource.cs | 44 +++ .../Models/User/UpdateProductTourProgress.cs | 14 + .../Models/User/ViewCurrentUser.cs | 12 +- .../Api/Data/endpoint-manifest.json | 26 ++ .../Exceptionless.Tests/Api/Data/openapi.json | 343 +++++++++++++++++- .../AdminProductTourUsageEndpointTests.cs | 135 +++++++ .../Api/Endpoints/OAuthGrantEndpointTests.cs | 280 ++++++++++++++ .../Api/Endpoints/ProductTourEndpointTests.cs | 180 +++++++++ .../Api/Endpoints/UserEndpointTests.cs | 251 ------------- .../Api/OpenApiSnapshotTests.cs | 31 ++ .../Serializer/Models/UserSerializerTests.cs | 55 +++ tests/http/admin.http | 4 + tests/http/users.http | 10 + 75 files changed, 4109 insertions(+), 474 deletions(-) create mode 100644 src/Exceptionless.Core/Models/Data/ProductTourProgress.cs create mode 100644 src/Exceptionless.Web/ClientApp/e2e/tests/product-tours.e2e.ts create mode 100644 src/Exceptionless.Web/ClientApp/src/lib/features/events/components/investigation-list-tour.svelte create mode 100644 src/Exceptionless.Web/ClientApp/src/lib/features/product-tours/actions.svelte.ts create mode 100644 src/Exceptionless.Web/ClientApp/src/lib/features/product-tours/catalog.test.ts create mode 100644 src/Exceptionless.Web/ClientApp/src/lib/features/product-tours/catalog.ts create mode 100644 src/Exceptionless.Web/ClientApp/src/lib/features/product-tours/components/dialogs/product-tour-catalog-dialog.svelte create mode 100644 src/Exceptionless.Web/ClientApp/src/lib/features/product-tours/components/dialogs/product-tour-welcome-dialog.svelte create mode 100644 src/Exceptionless.Web/ClientApp/src/lib/features/product-tours/components/dialogs/product-tour-welcome-dialog.svelte.test.ts create mode 100644 src/Exceptionless.Web/ClientApp/src/lib/features/product-tours/components/product-tour-feature-announcement.svelte create mode 100644 src/Exceptionless.Web/ClientApp/src/lib/features/product-tours/components/product-tour-host.svelte create mode 100644 src/Exceptionless.Web/ClientApp/src/lib/features/product-tours/components/product-tour-inline-callout.svelte create mode 100644 src/Exceptionless.Web/ClientApp/src/lib/features/product-tours/components/product-tour-shell-spotlight.svelte create mode 100644 src/Exceptionless.Web/ClientApp/src/lib/features/product-tours/components/product-tour-spotlight.svelte create mode 100644 src/Exceptionless.Web/ClientApp/src/lib/features/product-tours/eligibility.test.ts create mode 100644 src/Exceptionless.Web/ClientApp/src/lib/features/product-tours/eligibility.ts create mode 100644 src/Exceptionless.Web/ClientApp/src/lib/features/product-tours/session.svelte.test.ts create mode 100644 src/Exceptionless.Web/ClientApp/src/lib/features/product-tours/session.ts create mode 100644 src/Exceptionless.Web/ClientApp/src/lib/features/product-tours/state.svelte.test.ts create mode 100644 src/Exceptionless.Web/ClientApp/src/lib/features/product-tours/state.svelte.ts create mode 100644 src/Exceptionless.Web/ClientApp/src/lib/features/product-tours/telemetry.test.ts create mode 100644 src/Exceptionless.Web/ClientApp/src/lib/features/product-tours/telemetry.ts create mode 100644 src/Exceptionless.Web/ClientApp/src/lib/features/product-tours/types.ts create mode 100644 src/Exceptionless.Web/ClientApp/src/routes/(app)/event/query-filters.ts create mode 100644 src/Exceptionless.Web/ClientApp/src/routes/(app)/system/product-tours/+page.svelte create mode 100644 src/Exceptionless.Web/Models/Admin/AdminProductTourUsageResponse.cs create mode 100644 src/Exceptionless.Web/Models/Admin/ProductTourUsageSource.cs create mode 100644 src/Exceptionless.Web/Models/User/UpdateProductTourProgress.cs create mode 100644 tests/Exceptionless.Tests/Api/Endpoints/AdminProductTourUsageEndpointTests.cs create mode 100644 tests/Exceptionless.Tests/Api/Endpoints/OAuthGrantEndpointTests.cs create mode 100644 tests/Exceptionless.Tests/Api/Endpoints/ProductTourEndpointTests.cs diff --git a/src/Exceptionless.Core/Models/Data/ProductTourProgress.cs b/src/Exceptionless.Core/Models/Data/ProductTourProgress.cs new file mode 100644 index 0000000000..fbcf95fd89 --- /dev/null +++ b/src/Exceptionless.Core/Models/Data/ProductTourProgress.cs @@ -0,0 +1,22 @@ +using System.Runtime.Serialization; +using System.Text.Json.Serialization; + +namespace Exceptionless.Core.Models.Data; + +public record ProductTourProgress +{ + public ProductTourStatus Status { get; set; } + public DateTime UpdatedUtc { get; set; } + public int Version { get; set; } +} + +[JsonConverter(typeof(JsonStringEnumConverter))] +public enum ProductTourStatus +{ + [JsonStringEnumMemberName("completed")] + [EnumMember(Value = "completed")] + Completed, + [JsonStringEnumMemberName("dismissed")] + [EnumMember(Value = "dismissed")] + Dismissed +} diff --git a/src/Exceptionless.Core/Models/User.cs b/src/Exceptionless.Core/Models/User.cs index 8168c0302e..d11525d864 100644 --- a/src/Exceptionless.Core/Models/User.cs +++ b/src/Exceptionless.Core/Models/User.cs @@ -1,6 +1,7 @@ using System.Collections.ObjectModel; using System.ComponentModel.DataAnnotations; using Exceptionless.Core.Attributes; +using Exceptionless.Core.Models.Data; using Foundatio.Repositories.Models; namespace Exceptionless.Core.Models; @@ -25,6 +26,7 @@ public record User : IIdentity, IHaveDates, IValidatableObject public ICollection OAuthAccounts { get; init; } = new Collection(); public ICollection OrganizationPreferences { get; init; } = new Collection(); public ICollection SavedViewOrders { get; init; } = new Collection(); + public IDictionary ProductTours { get; init; } = new Dictionary(StringComparer.Ordinal); /// /// Gets or sets the users Full Name. diff --git a/src/Exceptionless.Web/Api/Endpoints/AdminEndpoints.cs b/src/Exceptionless.Web/Api/Endpoints/AdminEndpoints.cs index 7d6bbbbfe4..22bce2dc6e 100644 --- a/src/Exceptionless.Web/Api/Endpoints/AdminEndpoints.cs +++ b/src/Exceptionless.Web/Api/Endpoints/AdminEndpoints.cs @@ -87,6 +87,14 @@ public static IEndpointRouteBuilder MapAdminEndpoints(this IEndpointRouteBuilder .Produces(StatusCodes.Status401Unauthorized) .Produces(StatusCodes.Status403Forbidden); + endpoints.MapGet("api/v2/admin/product-tour-usage", GetProductTourUsageAsync) + .RequireAuthorization(AuthorizationRoles.GlobalAdminPolicy) + .AddEndpointFilter() + .Produces(StatusCodes.Status200OK) + .ProducesProblem(StatusCodes.Status400BadRequest) + .Produces(StatusCodes.Status401Unauthorized) + .Produces(StatusCodes.Status403Forbidden); + group.MapPost("change-plan", async (HttpContext httpContext, IMediator mediator, IMediatorResultMapper resultMapper, string organizationId, string planId) => (await mediator.InvokeAsync>(new AdminChangePlan(organizationId, planId, httpContext))).ToHttpResult(resultMapper)); @@ -130,4 +138,11 @@ private static EventSubmissionSettings CreateEventSubmissionSettings(bool? enabl bool configuredEnabled = !appOptions.EventSubmissionDisabled; return new EventSubmissionSettings(enabledOverride ?? configuredEnabled, configuredEnabled, enabledOverride.HasValue); } + + private static async Task GetProductTourUsageAsync( + IMediator mediator, + IMediatorResultMapper resultMapper, + DateTime? month = null, + int limit = 100) + => (await mediator.InvokeAsync>(new GetAdminProductTourUsage(month, limit))).ToHttpResult(resultMapper); } diff --git a/src/Exceptionless.Web/Api/Endpoints/UserEndpoints.cs b/src/Exceptionless.Web/Api/Endpoints/UserEndpoints.cs index 44ce3acd29..c82519ed57 100644 --- a/src/Exceptionless.Web/Api/Endpoints/UserEndpoints.cs +++ b/src/Exceptionless.Web/Api/Endpoints/UserEndpoints.cs @@ -1,4 +1,5 @@ using Exceptionless.Core.Authorization; +using Exceptionless.Core.Models.Data; using Exceptionless.Core.Extensions; using Exceptionless.Web.Api.Filters; using Exceptionless.Web.Api.Infrastructure; @@ -37,6 +38,27 @@ public static IEndpointRouteBuilder MapUserEndpoints(this IEndpointRouteBuilder } }); + group.MapPut("users/me/product-tours/{tourName:regex(^[a-z0-9]+(?:-[a-z0-9]+)*$):maxlength(64)}", async (string tourName, IMediator mediator, IMediatorResultMapper resultMapper, [FromBody] UpdateProductTourProgress progress) + => (await mediator.InvokeAsync>(new UserMessages.UpdateCurrentUserProductTour(tourName, progress))).ToHttpResult(resultMapper)) + .Accepts(false, "application/json", "application/*+json") + .Produces() + .ProducesProblem(StatusCodes.Status400BadRequest) + .ProducesProblem(StatusCodes.Status422UnprocessableEntity) + .ProducesProblem(StatusCodes.Status404NotFound) + .WithSummary("Update current user product tour progress") + .WithMetadata(new EndpointDocumentation { + RequestBodyDescription = "The versioned product tour outcome.", + RequestBodyRequired = true, + ParameterDescriptions = new() { + ["tourName"] = "The stable product tour name.", + }, + ResponseDescriptions = new() { + ["400"] = "The request body is missing or malformed.", + ["422"] = "The product tour progress is invalid.", + ["404"] = "The current user could not be found.", + } + }); + group.MapGet("users/me/oauth-grants", async (IMediator mediator, IMediatorResultMapper resultMapper) => (await mediator.InvokeAsync>>(new UserMessages.GetCurrentUserOAuthGrants())).ToHttpResult(resultMapper)) .Produces>() diff --git a/src/Exceptionless.Web/Api/Handlers/AdminHandler.cs b/src/Exceptionless.Web/Api/Handlers/AdminHandler.cs index 0fe5e82b12..3d5e49e9e3 100644 --- a/src/Exceptionless.Web/Api/Handlers/AdminHandler.cs +++ b/src/Exceptionless.Web/Api/Handlers/AdminHandler.cs @@ -1,3 +1,4 @@ +using Elastic.Clients.Elasticsearch.QueryDsl; using Exceptionless.Core; using Exceptionless.Core.Billing; using Exceptionless.Core.Extensions; @@ -17,6 +18,8 @@ using Foundatio.Queues; using Foundatio.Repositories; using Foundatio.Repositories.Migrations; +using Foundatio.Repositories.Models; +using Foundatio.Serializer; using Foundatio.Storage; using Foundatio.Mediator; @@ -38,9 +41,11 @@ public class AdminHandler( BillingPlans plans, IMigrationStateRepository migrationStateRepository, SampleDataService sampleDataService, + ITextSerializer serializer, TimeProvider timeProvider, ILoggerFactory loggerFactory) { + private const string ProductTourSourceField = EventIndex.Alias.Source + ".keyword"; private readonly ILogger _logger = loggerFactory.CreateLogger(); [HandlerEndpoint(HandlerMethod.Get, "settings", Group = "Admin")] @@ -134,6 +139,86 @@ public async Task> Handle(GetAdminAssistantUsage message) rows.Take(limit).ToArray()); } + public async Task> Handle(GetAdminProductTourUsage message) + { + var requestedMonth = message.Month ?? timeProvider.GetUtcNow().UtcDateTime; + var month = new DateTime(requestedMonth.Year, requestedMonth.Month, 1, 0, 0, 0, DateTimeKind.Utc); + var nextMonth = month.AddMonths(1); + int limit = Math.Clamp(message.Limit, 1, 500); + + var countTask = eventRepository.CountAsync((IRepositoryQuery query) => ApplyProductTourUsageFilter(query, month, nextMonth) + .AggregationsExpression("terms:(source~500 sum:count~1 max:date)")); + var recentTask = eventRepository.FindAsync(query => ApplyProductTourUsageFilter(query, month, nextMonth) + .SortDescending(ev => ev.Date), options => options.PageLimit(500)); + + await Task.WhenAll(countTask, recentTask); + + var sourceBuckets = (await countTask).Aggregations.Terms("terms_source")?.Buckets ?? []; + var parsedBuckets = sourceBuckets + .Select(bucket => ProductTourUsageSource.TryParse(bucket.Key, out var source) + ? new ProductTourUsageBucket(source, Convert.ToInt64(bucket.Aggregations.Sum("sum_count")?.Value ?? bucket.Total ?? 0), bucket.Aggregations.Max("max_date")?.Value) + : null) + .OfType() + .ToArray(); + + var groupedBuckets = parsedBuckets.GroupBy(bucket => bucket.Source.TourName, StringComparer.Ordinal).ToArray(); + string[] tourNames = groupedBuckets.Select(group => group.Key).ToArray(); + Task[] uniqueUserTasks = groupedBuckets + .Select(group => eventRepository.CountAsync((IRepositoryQuery query) => ApplyProductTourUsageFilter( + query, + month, + nextMonth, + group.Select(bucket => bucket.Source.Raw).ToArray()) + .AggregationsExpression("cardinality:user"))) + .ToArray(); + CountResult[] uniqueUserResults = await Task.WhenAll(uniqueUserTasks); + var uniqueUsersByTour = tourNames + .Zip(uniqueUserResults, (tourName, result) => new + { + TourName = tourName, + UniqueUsers = Convert.ToInt64(result.Aggregations.Cardinality("cardinality_user")?.Value ?? 0) + }) + .ToDictionary(item => item.TourName, item => item.UniqueUsers, StringComparer.Ordinal); + + var tours = groupedBuckets + .Select(group => + { + long shown = SumEvent(group, ProductTourUsageSource.ShownEvent); + long started = SumEvent(group, ProductTourUsageSource.StartedEvent); + long completed = SumEvent(group, ProductTourUsageSource.CompletedEvent); + long dismissed = SumEvent(group, ProductTourUsageSource.DismissedEvent); + long decisionDenominator = started > 0 ? started : shown; + long uniqueUsers = uniqueUsersByTour[group.Key]; + DateTime? lastRunUtc = group.Select(bucket => bucket.LastUtc).Max(); + + return new AdminProductTourSummary( + group.Key, + shown, + started, + completed, + dismissed, + uniqueUsers, + lastRunUtc, + CalculateRate(completed, decisionDenominator), + CalculateRate(dismissed, decisionDenominator)); + }) + .OrderByDescending(tour => tour.Started) + .ThenBy(tour => tour.Name, StringComparer.Ordinal) + .ToArray(); + + var recentActivity = (await recentTask).Documents + .Select(ev => ProductTourUsageSource.TryParse(ev.Source, out var source) ? CreateActivity(ev, source) : null) + .OfType() + .Take(limit) + .ToArray(); + + return new AdminProductTourUsageResponse( + month, + !String.IsNullOrWhiteSpace(appOptions.ExceptionlessApiKey), + tours, + recentActivity); + } + [HandlerEndpoint(HandlerMethod.Get, "migrations", Group = "Admin")] public async Task> Handle(GetAdminMigrations message) { @@ -172,6 +257,60 @@ public Task> Handle(GetAdminEcho message) }); } + private AdminProductTourActivity CreateActivity(PersistentEvent ev, ProductTourUsageSource source) + { + var user = ev.GetUserIdentity(serializer, _logger); + return new AdminProductTourActivity( + ev.Date.UtcDateTime, + source.Event, + source.LaunchSource, + source.TourName, + user?.Identity, + user?.Name, + source.Version, + ev.Count ?? 1); + } + + private static decimal? CalculateRate(long value, long denominator) + { + return denominator > 0 ? Decimal.Round(value / (decimal)denominator, 4) : null; + } + + private static long SumEvent(IEnumerable buckets, string eventName) + { + return buckets.Where(bucket => String.Equals(bucket.Source.Event, eventName, StringComparison.Ordinal)).Sum(bucket => bucket.Count); + } + + private IRepositoryQuery ApplyProductTourUsageFilter( + IRepositoryQuery query, + DateTime utcStart, + DateTime utcEnd, + IReadOnlyCollection? sources = null) + { + query + .Project(appOptions.InternalProjectId) + .FieldEquals(ev => ev.Type, Event.KnownTypes.FeatureUsage) + .DateRange(utcStart, utcEnd, (PersistentEvent ev) => ev.Date) + .Index(utcStart, utcEnd); + + if (sources is null) + { + return query.ElasticFilter(new PrefixQuery + { + Field = ProductTourSourceField, + Value = ProductTourUsageSource.Prefix + }); + } + + return query.ElasticFilter(new TermsQuery + { + Field = ProductTourSourceField, + Terms = new TermsQueryField(sources.Select(source => (Elastic.Clients.Elasticsearch.FieldValue)source).ToArray()) + }); + } + + private sealed record ProductTourUsageBucket(ProductTourUsageSource Source, long Count, DateTime? LastUtc); + [HandlerEndpoint(HandlerMethod.Get, "assemblies", Group = "Admin")] public Task> Handle(GetAdminAssemblies message) { diff --git a/src/Exceptionless.Web/Api/Handlers/UserHandler.cs b/src/Exceptionless.Web/Api/Handlers/UserHandler.cs index 8a517f525d..8d9f88675d 100644 --- a/src/Exceptionless.Web/Api/Handlers/UserHandler.cs +++ b/src/Exceptionless.Web/Api/Handlers/UserHandler.cs @@ -3,6 +3,7 @@ using Exceptionless.Core.Extensions; using Exceptionless.Core.Mail; using Exceptionless.Core.Models; +using Exceptionless.Core.Models.Data; using Exceptionless.Core.Repositories; using Exceptionless.DateTimeExtensions; using Exceptionless.Web.Api.Infrastructure; @@ -14,8 +15,9 @@ using Exceptionless.Web.Models.OAuth; using Exceptionless.Web.Utility; using Foundatio.Caching; -using Foundatio.Repositories; using Foundatio.Mediator; +using Foundatio.Repositories; +using Foundatio.Repositories.Models; namespace Exceptionless.Web.Api.Handlers; @@ -33,6 +35,7 @@ public class UserHandler( IHttpContextAccessor httpContextAccessor, ILoggerFactory loggerFactory) { + private const int MaximumProductTours = 32; private readonly ICacheClient _cache = new ScopedCacheClient(cacheClient, "User"); private readonly ILogger _logger = loggerFactory.CreateLogger(); private HttpContext HttpContext => httpContextAccessor.HttpContext ?? throw new InvalidOperationException("HttpContext is unavailable."); @@ -49,6 +52,53 @@ public async Task> Handle(GetCurrentUser message) }; } + public async Task> Handle(UpdateCurrentUserProductTour message) + { + bool maximumExceeded = false; + ProductTourProgress? progress = null; + await repository.PatchAsync( + GetCurrentUserId(), + new ActionPatch(user => + { + user.ProductTours.TryGetValue(message.TourName, out var currentProgress); + if (currentProgress is null && user.ProductTours.Count >= MaximumProductTours) + { + maximumExceeded = true; + return false; + } + + if (!ShouldUpdateProductTourProgress(currentProgress, message.Progress)) + { + progress = currentProgress; + return false; + } + + progress = new ProductTourProgress + { + Status = message.Progress.Status!.Value, + UpdatedUtc = timeProvider.GetUtcNow().UtcDateTime, + Version = message.Progress.Version + }; + user.ProductTours[message.TourName] = progress; + return true; + }), + options => options.Cache()); + + if (maximumExceeded) + return Result.Invalid(ValidationError.Create("tour_name", $"A user cannot track more than {MaximumProductTours} product tours.")); + + return progress is null ? Result.NotFound("User not found.") : progress; + } + + private static bool ShouldUpdateProductTourProgress(ProductTourProgress? current, UpdateProductTourProgress requested) + { + return current is null + || requested.Version > current.Version + || (requested.Version == current.Version + && current.Status is ProductTourStatus.Dismissed + && requested.Status is ProductTourStatus.Completed); + } + public async Task>> Handle(GetCurrentUserOAuthGrants message) { var tokens = new List(); diff --git a/src/Exceptionless.Web/Api/Messages/AdminMessages.cs b/src/Exceptionless.Web/Api/Messages/AdminMessages.cs index df738edd85..43f3144691 100644 --- a/src/Exceptionless.Web/Api/Messages/AdminMessages.cs +++ b/src/Exceptionless.Web/Api/Messages/AdminMessages.cs @@ -3,6 +3,7 @@ namespace Exceptionless.Web.Api.Messages; public record GetAdminSettings; public record GetAdminStats; public record GetAdminAssistantUsage(DateTime? Month, int Limit, HttpContext Context); +public record GetAdminProductTourUsage(DateTime? Month, int Limit); public record GetAdminMigrations; public record GetAdminEcho(HttpContext Context); public record GetAdminAssemblies; diff --git a/src/Exceptionless.Web/Api/Messages/UserMessages.cs b/src/Exceptionless.Web/Api/Messages/UserMessages.cs index 7cc329710b..7973ceae86 100644 --- a/src/Exceptionless.Web/Api/Messages/UserMessages.cs +++ b/src/Exceptionless.Web/Api/Messages/UserMessages.cs @@ -6,6 +6,7 @@ namespace Exceptionless.Web.Api.Messages; public record GetCurrentUser; public record GetCurrentUserOAuthGrants; public record RevokeCurrentUserOAuthGrant(string Id); +public record UpdateCurrentUserProductTour(string TourName, UpdateProductTourProgress Progress); public record GetUserById(string Id); public record GetUsersByOrganization(string OrganizationId, int Page, int Limit); public record UpdateUserMessage(string Id, Delta Changes); diff --git a/src/Exceptionless.Web/ClientApp/e2e/fixtures/api-client.ts b/src/Exceptionless.Web/ClientApp/e2e/fixtures/api-client.ts index 8b3f0f6eaf..1451c7f25c 100644 --- a/src/Exceptionless.Web/ClientApp/e2e/fixtures/api-client.ts +++ b/src/Exceptionless.Web/ClientApp/e2e/fixtures/api-client.ts @@ -309,6 +309,15 @@ export class E2EApiClient { await expectStatus(response, [202], 'submit event'); } + async updateProductTour(token: string, tourName: string, version: number, status: 'completed' | 'dismissed'): Promise { + const response = await this.request.put(this.url(`users/me/product-tours/${tourName}`), { + data: { status, version }, + headers: this.authHeaders(token) + }); + + await expectStatus(response, [200], 'update product tour'); + } + async waitForCurrentUserDeleted(token: string, timeoutMs = 30_000): Promise { await waitForCondition( async () => !(await this.getCurrentUser(token)), diff --git a/src/Exceptionless.Web/ClientApp/e2e/fixtures/e2e-test.ts b/src/Exceptionless.Web/ClientApp/e2e/fixtures/e2e-test.ts index 154144230d..380578c279 100644 --- a/src/Exceptionless.Web/ClientApp/e2e/fixtures/e2e-test.ts +++ b/src/Exceptionless.Web/ClientApp/e2e/fixtures/e2e-test.ts @@ -43,6 +43,7 @@ export interface E2ESecondaryProject { interface E2EFixtures { e2eApi: E2EApiClient; e2eCleanupPassword: string; + e2eDismissProductTourWelcome: boolean; e2eScenario: E2EScenario; e2eSecondaryOrganization: E2ESecondaryOrganization; e2eSecondaryProject: E2ESecondaryProject; @@ -57,7 +58,9 @@ export const test = base.extend({ e2eCleanupPassword: [E2E_TEST_PASSWORD, { option: true }], - e2eScenario: async ({ e2eApi, e2eCleanupPassword, e2eUseGeneratedUser, page }, use, testInfo) => { + e2eDismissProductTourWelcome: [true, { option: true }], + + e2eScenario: async ({ e2eApi, e2eCleanupPassword, e2eDismissProductTourWelcome, e2eUseGeneratedUser, page }, use, testInfo) => { const run = createRunName(e2eApi.environment.runId, testInfo); const userName = `Playwright User ${run}`; const email = `playwright-${run}@exceptionless.test`.toLowerCase(); @@ -85,6 +88,9 @@ export const test = base.extend({ const project = await e2eApi.createProject(userToken, organization.id, projectName); projectId = project.id; const projectToken = await e2eApi.getProjectDefaultToken(userToken, project.id); + if (e2eDismissProductTourWelcome) { + await e2eApi.updateProductTour(userToken, 'welcome', 1, 'dismissed'); + } await page.addInitScript( ({ organizationId, token }) => { diff --git a/src/Exceptionless.Web/ClientApp/e2e/tests/product-tours.e2e.ts b/src/Exceptionless.Web/ClientApp/e2e/tests/product-tours.e2e.ts new file mode 100644 index 0000000000..04496a2594 --- /dev/null +++ b/src/Exceptionless.Web/ClientApp/e2e/tests/product-tours.e2e.ts @@ -0,0 +1,271 @@ +import type { Page, Request, Response } from '@playwright/test'; + +import { E2E_TEST_PASSWORD, expect, test } from '../fixtures/e2e-test'; +import { seedRepresentativeEvent } from '../support/event-data'; +import { createRepresentativeEvent } from '../support/synthetic-event'; + +test.use({ actionTimeout: 15_000, e2eUseGeneratedUser: true }); + +test.describe('first-run welcome', () => { + test.use({ e2eDismissProductTourWelcome: false }); + + test('Browse Guides persists before the catalog opens', async ({ e2eScenario, page }) => { + await test.step(`show the first-run prompt for ${e2eScenario.email}`, async () => { + await page.goto('/next/stack'); + await expect(page.getByRole('dialog', { name: 'Welcome to Exceptionless' })).toBeVisible(); + }); + + const persisted = page.waitForResponse(isSuccessfulTourProgress('welcome')); + await page.getByRole('dialog', { name: 'Welcome to Exceptionless' }).getByRole('button', { name: 'Browse Guides' }).click(); + await persisted; + + const catalog = page.getByRole('dialog', { name: 'Guided Tours' }); + await expect(catalog).toBeVisible(); + await catalog.getByRole('button', { name: 'Close' }).click(); + await page.reload(); + await expect(page.getByRole('dialog', { name: 'Welcome to Exceptionless' })).toBeHidden(); + }); +}); + +test.describe('shell and identity checkpoints', () => { + test.use({ e2eDismissProductTourWelcome: false }); + + test('supports responsive resume and never carries checkpoints across identities', async ({ e2eApi, e2eScenario, e2eSecondaryOrganization, page }) => { + test.setTimeout(240_000); + const progressWrites: string[] = []; + page.on('request', (request) => { + if (request.method() === 'PUT' && request.url().includes('/api/v2/users/me/product-tours/')) { + progressWrites.push(new URL(request.url()).pathname); + } + }); + + await test.step('closing the welcome persists dismissal', async () => { + await page.goto('/next/stack'); + await expect(page.getByRole('dialog', { name: 'Welcome to Exceptionless' })).toBeVisible(); + const dismissed = page.waitForResponse(isSuccessfulTourProgress('welcome')); + await page.keyboard.press('Escape'); + await dismissed; + await expect(page.getByRole('dialog', { name: 'Welcome to Exceptionless' })).toBeHidden(); + }); + + await test.step('the shell tour renders on mobile and resumes on desktop with reduced motion', async () => { + await page.setViewportSize({ height: 844, width: 390 }); + await startTourFromCommand(page, 'Explore Exceptionless'); + const tour = page.locator('.driver-popover'); + await expect(page.locator('[data-tour="app-navigation"]')).toBeVisible(); + await expect(tour.getByText('Your workspace navigation')).toBeVisible(); + + await page.emulateMedia({ reducedMotion: 'reduce' }); + await page.setViewportSize({ height: 900, width: 1440 }); + await tour.getByRole('button', { name: 'Continue' }).click(); + await expect(tour.getByText('Find anything quickly')).toBeVisible(); + await page.reload(); + await expect(tour.getByText('Find anything quickly')).toBeVisible(); + + const dismissed = page.waitForResponse(isSuccessfulTourProgress('ui-overview')); + await tour.getByRole('button', { name: 'Close' }).click(); + await dismissed; + await expectProductTourSession(page, false); + }); + + await test.step('an organization change clears an active checkpoint without recording progress', async () => { + await mockAssistantAccess(page); + await page.reload(); + await startTourFromCommand(page, 'Meet Exie'); + await expectProductTourSession(page, true); + const writesBeforeSwitch = progressWrites.length; + + const identityTab = await page.context().newPage(); + await identityTab.goto('/next/stack'); + await identityTab.evaluate((organizationId) => { + window.localStorage.setItem('organization', JSON.stringify(organizationId)); + }, e2eSecondaryOrganization.organizationId); + await identityTab.close(); + await expectProductTourSession(page, false); + expect(progressWrites).toHaveLength(writesBeforeSwitch); + }); + + await test.step('logout clears an active checkpoint without recording progress', async () => { + await startTourFromCommand(page, 'Meet Exie'); + await expectProductTourSession(page, true); + const writesBeforeLogout = progressWrites.length; + + await page.getByRole('button', { name: new RegExp(e2eScenario.userName) }).dispatchEvent('click'); + await page.getByRole('menuitem', { name: 'Log Out' }).dispatchEvent('click'); + await expect(page).toHaveURL(/\/next\/login/); + await expectProductTourSession(page, false); + expect(progressWrites).toHaveLength(writesBeforeLogout); + + e2eScenario.userToken = await e2eApi.login(e2eScenario.email, E2E_TEST_PASSWORD); + }); + }); +}); + +test('domain workflows advance only on real success', async ({ e2eApi, e2eScenario, page }) => { + test.setTimeout(300_000); + + await test.step('project configuration advances after creation and the first event', async () => { + await page.goto('/next/stack'); + await startTourFromCommand(page, 'Configure a project'); + await expect(page.getByRole('heading', { name: 'Add Project' })).toBeVisible(); + + const projectName = `Tour Project ${e2eScenario.run}`; + await page.getByLabel('Project Name', { exact: true }).fill(projectName); + await page.getByRole('button', { name: 'Continue to Client Setup' }).click(); + await page.waitForURL(/\/next\/project\/[^/]+\/configure\?redirect=true/); + const projectId = page.url().match(/\/project\/([^/]+)\/configure/)?.[1]; + expect(projectId).toBeTruthy(); + + await page.locator('[data-tour="project-configure-platform"]').click(); + await page.getByRole('option', { name: 'Browser applications' }).click(); + await page.locator('[data-product-tour-inline="configure-project"]').getByRole('button', { name: 'Continue' }).click(); + await expect(page.getByText('Waiting for your first event')).toBeVisible(); + + try { + const token = await e2eApi.getProjectDefaultToken(e2eScenario.userToken, projectId!); + await e2eApi.submitEvent( + projectId!, + token.id, + createRepresentativeEvent({ + appUrl: e2eApi.environment.appUrl, + message: e2eScenario.message, + referenceId: e2eScenario.referenceId, + runId: e2eApi.environment.runId + }) + ); + await expect(page).toHaveURL(/\/next\/event/); + await expectProductTourSession(page, false); + } finally { + await e2eApi.deleteProject(e2eScenario.userToken, projectId!); + await e2eApi.waitForProjectDeleted(e2eScenario.userToken, projectId!); + } + }); + + await test.step('saved-view progress retry never repeats the successful POST', async () => { + let createRequests = 0; + let progressRequests = 0; + const countSavedViewCreation = (request: Request) => { + const path = new URL(request.url()).pathname; + if (request.method() === 'POST' && /^\/api\/v2\/organizations\/[^/]+\/saved-views$/.test(path)) createRequests += 1; + }; + const progressRoute = (url: URL) => url.pathname === '/api/v2/users/me/product-tours/create-saved-view'; + page.on('request', countSavedViewCreation); + await page.route(progressRoute, async (route) => { + progressRequests += 1; + if (progressRequests === 1) { + await route.fulfill({ json: { title: 'Injected progress failure' }, status: 500 }); + return; + } + + await route.continue(); + }); + + try { + await page.goto('/next/event'); + await startTourFromCommand(page, 'Create a saved view'); + await expectProductTourSession(page, true); + const tour = page.locator('.driver-popover'); + await tour.getByRole('button', { name: 'Continue' }).click(); + await tour.getByRole('button', { name: 'Continue' }).click(); + + await page.getByLabel('Name', { exact: true }).fill(`Tour View ${e2eScenario.run}`); + await page.getByRole('button', { name: 'Continue' }).click(); + await page.getByRole('button', { name: 'Continue' }).click(); + await page.getByRole('button', { exact: true, name: 'Save' }).click(); + await expect(page.getByText('Retry guide completion')).toBeVisible(); + expect(createRequests).toBe(1); + + await page.reload(); + await expect(page.getByRole('button', { name: 'Retry guide completion' })).toBeVisible(); + const completed = page.waitForResponse(isSuccessfulTourProgress('create-saved-view')); + await page.getByRole('button', { name: 'Retry guide completion' }).click(); + await completed; + await expect.poll(() => createRequests).toBe(1); + await expectProductTourSession(page, false); + } finally { + page.off('request', countSavedViewCreation); + await page.unroute(progressRoute); + } + }); + + await test.step('investigation advances when a real error opens', async () => { + await seedRepresentativeEvent(e2eApi, e2eScenario.userToken, { + message: e2eScenario.message, + projectId: e2eScenario.projectId, + projectToken: e2eScenario.projectToken, + referenceId: e2eScenario.referenceId + }); + await page.goto('/next/event?time=all&type=error'); + await expect(page.getByText(e2eScenario.message).first()).toBeVisible({ timeout: 30_000 }); + await startTourFromCommand(page, 'Investigate an error'); + await page.locator('.driver-popover').getByRole('button', { name: 'Continue' }).click(); + await page.locator('tr').filter({ hasText: e2eScenario.message }).first().click(); + const callout = page.locator('[data-product-tour-inline="investigate-error"]'); + await expect(callout.getByText('Understand the grouped issue')).toBeVisible(); + for (const title of ['Triage deliberately', 'Inspect the occurrence', 'Begin with the overview', 'Compare every occurrence']) { + await callout.getByRole('button', { name: 'Continue' }).click(); + await expect(callout.getByText(title)).toBeVisible(); + } + + const completed = page.waitForResponse(isSuccessfulTourProgress('investigate-error')); + await callout.getByRole('button', { name: 'Finish guide' }).click(); + await completed; + await expectProductTourSession(page, false); + await page.reload(); + await expect(page.locator('[data-product-tour-inline="investigate-error"]')).toBeHidden(); + }); + + await test.step('Exie opens context without provider submission', async () => { + await mockAssistantAccess(page); + let chatRequests = 0; + const countChatRequest = (request: Request) => { + if (new URL(request.url()).pathname === '/api/v2/assistant/chat') chatRequests += 1; + }; + page.on('request', countChatRequest); + + try { + await page.goto('/next/stack'); + await startTourFromCommand(page, 'Meet Exie'); + const tour = page.locator('.driver-popover'); + await tour.getByRole('button', { name: 'Continue' }).click(); + await expect(tour.getByText('You control every request')).toBeVisible(); + expect(chatRequests).toBe(0); + } finally { + page.off('request', countChatRequest); + } + }); +}); + +async function expectProductTourSession(page: Page, present: boolean): Promise { + const assertion = expect.poll(() => page.evaluate(() => sessionStorage.getItem('exceptionless.product-tour'))); + if (present) { + await assertion.not.toBeNull(); + } else { + await assertion.toBeNull(); + } +} + +function isSuccessfulTourProgress(tourName: string) { + return (response: Response): boolean => { + const path = new URL(response.url()).pathname; + return response.request().method() === 'PUT' && path === `/api/v2/users/me/product-tours/${tourName}` && response.status() === 200; + }; +} + +async function mockAssistantAccess(page: Page): Promise { + await page.route( + (url) => url.pathname === '/api/v2/assistant/access', + (route) => route.fulfill({ json: { enabled: true, has_access: true, message: null, upgrade_required: false } }) + ); +} + +async function startTourFromCommand(page: Page, title: string): Promise { + const announcementStart = page.getByRole('button', { name: 'See how it works' }); + if (title === 'Meet Exie' && (await announcementStart.isVisible())) { + await announcementStart.click(); + return; + } + + await page.getByRole('button', { name: 'Search Exceptionless' }).click(); + await page.getByRole('dialog').getByText(title, { exact: true }).click(); +} diff --git a/src/Exceptionless.Web/ClientApp/package-lock.json b/src/Exceptionless.Web/ClientApp/package-lock.json index 23a4f6bd0e..483f1f2f02 100644 --- a/src/Exceptionless.Web/ClientApp/package-lock.json +++ b/src/Exceptionless.Web/ClientApp/package-lock.json @@ -24,6 +24,7 @@ "clsx": "^2.1.1", "d3-scale": "^4.0.2", "dompurify": "^3.4.14", + "driver.js": "^1.8.0", "layerchart": "^2.3.0", "mode-watcher": "^1.1.0", "oidc-client-ts": "^3.5.0", @@ -6069,6 +6070,12 @@ "url": "https://dotenvx.com" } }, + "node_modules/driver.js": { + "version": "1.8.0", + "resolved": "https://registry.npmjs.org/driver.js/-/driver.js-1.8.0.tgz", + "integrity": "sha512-+8/IO7h1v14IzWh2GP60N7T3PFZweXwdn5e5POuxRSBoCYUojsBxzqawPeXh3YZIibRy7EehYNEyxe7slwwtdg==", + "license": "MIT" + }, "node_modules/emoji-regex": { "version": "8.0.0", "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", diff --git a/src/Exceptionless.Web/ClientApp/package.json b/src/Exceptionless.Web/ClientApp/package.json index f3fc1b0a20..f9d84a2dbd 100644 --- a/src/Exceptionless.Web/ClientApp/package.json +++ b/src/Exceptionless.Web/ClientApp/package.json @@ -94,6 +94,7 @@ "clsx": "^2.1.1", "d3-scale": "^4.0.2", "dompurify": "^3.4.14", + "driver.js": "^1.8.0", "layerchart": "^2.3.0", "mode-watcher": "^1.1.0", "oidc-client-ts": "^3.5.0", diff --git a/src/Exceptionless.Web/ClientApp/src/lib/features/admin/api.svelte.ts b/src/Exceptionless.Web/ClientApp/src/lib/features/admin/api.svelte.ts index 824e988995..cee4ec4d77 100644 --- a/src/Exceptionless.Web/ClientApp/src/lib/features/admin/api.svelte.ts +++ b/src/Exceptionless.Web/ClientApp/src/lib/features/admin/api.svelte.ts @@ -6,6 +6,7 @@ import type { AdminAssistantSettings, AdminAssistantUsage, AdminEventSubmissionSettings, + AdminProductTourUsage, AdminStats, ElasticsearchInfo, ElasticsearchSnapshotsResponse, @@ -44,6 +45,7 @@ export const queryKeys = { migrations: ['admin', 'migrations'] as const, oauthApplication: (id: string | undefined) => [...queryKeys.oauthApplications, id] as const, oauthApplications: ['admin', 'oauth-applications'] as const, + productTourUsage: (month: string) => ['admin', 'product-tour-usage', month] as const, snapshots: ['admin', 'elasticsearch', 'snapshots'] as const, stats: ['admin', 'stats'] as const }; @@ -110,6 +112,29 @@ export function getAdminAssistantUsageQuery(month: () => string) { })); } +export function getAdminProductTourUsageQuery(month: () => string) { + return createQuery(() => ({ + queryFn: async ({ signal }: { signal: AbortSignal }) => { + const client = useFetchClient(); + const response = await client.getJSON('admin/product-tour-usage', { + params: { + limit: 100, + month: `${month()}-01` + }, + signal + }); + + if (!response.ok) { + throw response.problem; + } + + return response.data!; + }, + queryKey: queryKeys.productTourUsage(month()), + staleTime: 60 * 1000 + })); +} + export function getAdminStatsQuery() { return createQuery(() => ({ queryFn: async ({ signal }: { signal: AbortSignal }) => { diff --git a/src/Exceptionless.Web/ClientApp/src/lib/features/admin/models.ts b/src/Exceptionless.Web/ClientApp/src/lib/features/admin/models.ts index cf424cf37d..4ede3b8b22 100644 --- a/src/Exceptionless.Web/ClientApp/src/lib/features/admin/models.ts +++ b/src/Exceptionless.Web/ClientApp/src/lib/features/admin/models.ts @@ -1,4 +1,5 @@ import type { + AdminProductTourUsageResponse, AssistantModelSettings, CountResult, EventSubmissionSettings, @@ -50,6 +51,7 @@ export type AdminAssistantUsage = { }; export type AdminEventSubmissionSettings = EventSubmissionSettings; +export type AdminProductTourUsage = AdminProductTourUsageResponse; export type AdminStats = { events: CountResult; diff --git a/src/Exceptionless.Web/ClientApp/src/lib/features/assistant/components/assistant-panel.svelte b/src/Exceptionless.Web/ClientApp/src/lib/features/assistant/components/assistant-panel.svelte index 916b8454b4..7001b41ba7 100644 --- a/src/Exceptionless.Web/ClientApp/src/lib/features/assistant/components/assistant-panel.svelte +++ b/src/Exceptionless.Web/ClientApp/src/lib/features/assistant/components/assistant-panel.svelte @@ -508,6 +508,7 @@ void; handleError: (problem: ProblemDetails) => void; id: string; + loadProjectDetails?: boolean; onEventLoaded?: (event: PersistentEvent) => void; onNavigate?: (eventId: string) => void; prepareStackAssistantContext?: () => void; @@ -57,6 +60,7 @@ filterChanged, handleError, id, + loadProjectDetails = true, onEventLoaded, onNavigate, prepareStackAssistantContext @@ -127,6 +131,7 @@ const navigation = $derived(eventQuery.data?.navigation); const projectQuery = getProjectQuery({ + enabled: () => loadProjectDetails, route: { get id() { return event?.project_id; @@ -156,13 +161,44 @@ let activeTab = $state('Overview'); let tabs = $derived(getTabs(event, projectQuery.data)); - let tabsListRef = $state(null); - let canScrollTabsLeft = $state(false); - let canScrollTabsRight = $state(false); let draggedPromotedTab = $state(null); let notifiedEventId = $state(''); let showJsonDialog = $state(false); + const tourActions = createProductTourActions(); + const investigationCheckpoint = $derived(productTourCheckpoint.current?.tourName === 'investigate-error' ? productTourCheckpoint.current : undefined); + const investigationCopy = $derived.by(() => { + switch (investigationCheckpoint?.checkpointName) { + case 'event-occurrence': + return { + description: 'This occurrence contains its timestamp, raw JSON, and navigation to nearby events.', + title: 'Inspect the occurrence' + }; + case 'filter-stack-events': + return { + description: 'Show all events filters the list to this stack when you are ready to compare occurrences.', + title: 'Compare every occurrence' + }; + case 'stack-summary': + return { + description: 'Use the grouped stack title, affected users, occurrence count, and trend to judge scope and impact.', + title: 'Understand the grouped issue' + }; + case 'stack-triage': + return { + description: 'Status and options change shared issue state. Review them here; this guide will not invoke them.', + title: 'Triage deliberately' + }; + case 'tab-overview': + return { + description: 'Overview summarizes the message and useful event fields. Choose other tabs when the evidence calls for them.', + title: 'Begin with the overview' + }; + default: + return undefined; + } + }); + $effect(() => { if (shouldResetActiveEventTab(!!event, projectQuery.isPending, tabs, activeTab)) { activeTab = 'Overview'; @@ -173,29 +209,6 @@ return !!projectQuery.data?.promoted_tabs?.includes(tab); } - function updateTabsOverflow(): void { - if (!tabsListRef) { - canScrollTabsLeft = false; - canScrollTabsRight = false; - return; - } - - const maxScrollLeft = tabsListRef.scrollWidth - tabsListRef.clientWidth; - canScrollTabsLeft = tabsListRef.scrollLeft > 1; - canScrollTabsRight = tabsListRef.scrollLeft < maxScrollLeft - 1; - } - - function scrollTabs(direction: 'left' | 'right'): void { - if (!tabsListRef) { - return; - } - - tabsListRef.scrollBy({ - behavior: 'smooth', - left: direction === 'left' ? -tabsListRef.clientWidth / 2 : tabsListRef.clientWidth / 2 - }); - } - function onPromoted(title: string): void { activeTab = title; } @@ -282,6 +295,35 @@ } } + async function continueInvestigationTour(): Promise { + const checkpoint = investigationCheckpoint; + if (!checkpoint) { + return; + } + switch (checkpoint.checkpointName) { + case 'event-occurrence': + productTourCheckpoint.advance(checkpoint, 'tab-overview'); + break; + case 'stack-summary': + productTourCheckpoint.advance(checkpoint, 'stack-triage'); + break; + case 'stack-triage': + productTourCheckpoint.advance(checkpoint, 'event-occurrence'); + break; + case 'tab-overview': + productTourCheckpoint.advance(checkpoint, 'filter-stack-events'); + break; + default: + await tourActions.complete(checkpoint); + } + } + + async function dismissInvestigationTour(): Promise { + if (investigationCheckpoint) { + await tourActions.dismiss(investigationCheckpoint); + } + } + function prepareEventAssistantContext(): void { if (event) { assistantPageContext.setPageEvent(event); @@ -301,39 +343,28 @@ $effect(() => { if (event && event.id !== notifiedEventId) { notifiedEventId = event.id; - onEventLoaded?.(event); - } - }); - - $effect(() => { - const tabCount = tabs.length; - void tick().then(() => { - if (tabCount === tabs.length) { - updateTabsOverflow(); + const checkpoint = investigationCheckpoint; + if (checkpoint?.checkpointName === 'choose-error' && hasErrorOrSimpleError(event)) { + productTourCheckpoint.advance(checkpoint, 'stack-summary'); } - }); - }); - - onMount(() => { - updateTabsOverflow(); - - const resizeObserver = new ResizeObserver(updateTabsOverflow); - if (tabsListRef) { - resizeObserver.observe(tabsListRef); + onEventLoaded?.(event); } - - window.addEventListener('resize', updateTabsOverflow); - - return () => { - resizeObserver.disconnect(); - window.removeEventListener('resize', updateTabsOverflow); - }; }); -
+{#if event && investigationCopy && ['stack-summary', 'stack-triage'].includes(investigationCheckpoint?.checkpointName ?? '')} + +{/if} + +

Stack

- {#if event?.stack_id} + {#if loadProjectDetails && event?.stack_id} -
+{#if event && investigationCopy && ['event-occurrence', 'filter-stack-events'].includes(investigationCheckpoint?.checkpointName ?? '')} + +{/if} + +

Event

@@ -355,6 +397,7 @@ {#if event?.stack_id} - {/if} - +
+ {#each tabs as tab (tab)} handlePromotedTabDragStart(event, tab)} ondragover={(event) => handlePromotedTabDragOver(event, tab)} ondrop={(event) => handlePromotedTabDrop(event, tab)} @@ -433,17 +471,6 @@ > {/each} - {#if canScrollTabsRight} - - {/if}
{#each tabs as tab (tab)} diff --git a/src/Exceptionless.Web/ClientApp/src/lib/features/events/components/investigation-list-tour.svelte b/src/Exceptionless.Web/ClientApp/src/lib/features/events/components/investigation-list-tour.svelte new file mode 100644 index 0000000000..29f1dcb8de --- /dev/null +++ b/src/Exceptionless.Web/ClientApp/src/lib/features/events/components/investigation-list-tour.svelte @@ -0,0 +1,29 @@ + + +{#if checkpoint?.checkpointName === 'filter-errors'} + { + productTourCheckpoint.advance(current, 'choose-error'); + }} + target="[data-tour='event-filters']" + title="Start with the right errors" + /> +{:else if checkpoint?.checkpointName === 'choose-error'} + +{/if} diff --git a/src/Exceptionless.Web/ClientApp/src/lib/features/product-tours/actions.svelte.ts b/src/Exceptionless.Web/ClientApp/src/lib/features/product-tours/actions.svelte.ts new file mode 100644 index 0000000000..611213ff6c --- /dev/null +++ b/src/Exceptionless.Web/ClientApp/src/lib/features/product-tours/actions.svelte.ts @@ -0,0 +1,58 @@ +import { submitFeatureUsage } from '$features/auth/exceptionless-session'; +import { putCurrentUserProductTour } from '$features/users/api.svelte'; +import { ProductTourStatus } from '$features/users/models'; +import { toast } from 'svelte-sonner'; + +import type { ProductTourCheckpoint, ProductTourKey, ProductTourLaunchSource } from './types'; + +import { getProductTour } from './catalog'; +import { productTourCheckpoint } from './state.svelte'; +import { buildProductTourTelemetryEvent, type ProductTourTelemetryEvent } from './telemetry'; + +export function createProductTourActions() { + const progressMutation = putCurrentUserProductTour(); + + async function complete(checkpoint: ProductTourCheckpoint): Promise { + return finish(checkpoint, ProductTourStatus.Completed); + } + + async function dismiss(checkpoint: ProductTourCheckpoint): Promise { + return finish(checkpoint, ProductTourStatus.Dismissed); + } + + async function finish(checkpoint: ProductTourCheckpoint, status: ProductTourStatus): Promise { + const definition = getProductTour(checkpoint.tourName); + try { + await progressMutation.mutateAsync({ + progress: { + status, + version: definition.version + }, + tourName: checkpoint.tourName + }); + } catch { + toast.error('We could not save your guided-tour progress. Please try again.'); + return false; + } + + if (!productTourCheckpoint.clear(checkpoint)) { + return false; + } + void track(status === ProductTourStatus.Completed ? 'completed' : 'dismissed', checkpoint.tourName, definition.version, checkpoint.source); + return true; + } + + return { + complete, + dismiss, + progressMutation + }; +} + +export async function track(event: ProductTourTelemetryEvent, name: ProductTourKey, version: number, source: ProductTourLaunchSource): Promise { + try { + await submitFeatureUsage(buildProductTourTelemetryEvent(event, name, version, source)); + } catch (error) { + console.warn('Unable to submit product tour telemetry.', error); + } +} diff --git a/src/Exceptionless.Web/ClientApp/src/lib/features/product-tours/catalog.test.ts b/src/Exceptionless.Web/ClientApp/src/lib/features/product-tours/catalog.test.ts new file mode 100644 index 0000000000..c17eb9f560 --- /dev/null +++ b/src/Exceptionless.Web/ClientApp/src/lib/features/product-tours/catalog.test.ts @@ -0,0 +1,47 @@ +import { describe, expect, it } from 'vitest'; + +import type { ProductTourContext } from './types'; + +import { getProductTourItems, getRecommendedProductTourName, productTourCatalog } from './catalog'; + +function context(overrides: Partial = {}): ProductTourContext { + return { + errorEventAvailability: 'available', + isSetupPage: false, + organizationId: 'organization-id', + pathname: '/next', + projects: [], + ...overrides + }; +} + +describe('product tour catalog', () => { + it('contains only durable metadata for the five named tours', () => { + expect(productTourCatalog.map((tour) => tour.name)).toEqual([ + 'ui-overview', + 'configure-project', + 'create-saved-view', + 'investigate-error', + 'meet-exie' + ]); + expect(productTourCatalog.every((tour) => tour.version > 0 && tour.keywords.length > 0)).toBe(true); + expect(JSON.stringify(productTourCatalog)).not.toContain('data-tour'); + }); + + it('recommends setup until an organization has configured projects', () => { + expect(getRecommendedProductTourName(context({ organizationId: undefined }))).toBe('configure-project'); + expect(getRecommendedProductTourName(context({ projects: [{ is_configured: false }] }))).toBe('configure-project'); + expect(getRecommendedProductTourName(context({ projects: [{ is_configured: true }] }))).toBe('ui-overview'); + }); + + it('reports availability separately from catalog metadata', () => { + const items = getProductTourItems( + context({ + assistantAccess: { enabled: false, has_access: false, upgrade_required: false }, + errorEventAvailability: 'empty' + }) + ); + expect(items.find((item) => item.name === 'meet-exie')?.currentAvailability.available).toBe(false); + expect(items.find((item) => item.name === 'investigate-error')?.currentAvailability.available).toBe(false); + }); +}); diff --git a/src/Exceptionless.Web/ClientApp/src/lib/features/product-tours/catalog.ts b/src/Exceptionless.Web/ClientApp/src/lib/features/product-tours/catalog.ts new file mode 100644 index 0000000000..307807a260 --- /dev/null +++ b/src/Exceptionless.Web/ClientApp/src/lib/features/product-tours/catalog.ts @@ -0,0 +1,93 @@ +import type { ProductTourProgress } from '$features/users/models'; + +import { resolve } from '$app/paths'; + +import type { ProductTourContext, ProductTourDefinition, ProductTourListItem, ProductTourName } from './types'; + +function requireApplicationShell(context: ProductTourContext) { + return context.isSetupPage || !context.organizationId + ? { available: false, reason: 'Finish organization setup to explore Exceptionless.' } + : { available: true }; +} + +function requireError(context: ProductTourContext) { + if (!context.organizationId) return { available: false, reason: 'Create an organization and project first.' }; + if (context.errorEventAvailability === 'loading') return { available: false, reason: 'Checking for an accessible error report…' }; + if (context.errorEventAvailability === 'error') return { available: false, reason: 'Error reports could not be checked. Try again shortly.' }; + if (context.errorEventAvailability === 'empty') return { available: false, reason: 'Send an error report before starting this guide.' }; + return { available: true }; +} + +function requireOrganization(context: ProductTourContext) { + return context.organizationId ? { available: true } : { available: false, reason: 'Create an organization and project first.' }; +} + +export const productTourCatalog: readonly ProductTourDefinition[] = [ + { + availability: requireApplicationShell, + description: 'Learn navigation, command search, saved views, Exie, and where to get help.', + initialCheckpoint: 'navigation', + keywords: ['navigation', 'ui', 'search', 'command', 'help', 'saved views'], + name: 'ui-overview', + startingRoute: () => resolve('/'), + title: 'Explore Exceptionless', + version: 1 + }, + { + availability: () => ({ available: true }), + description: 'Create or resume a project, connect an SDK, and wait for its first real event.', + initialCheckpoint: 'project-name', + keywords: ['add project', 'configure', 'sdk', 'api key', 'first event'], + name: 'configure-project', + startingRoute: (context) => (context.organizationId ? resolve('/(app)/project/add') : resolve('/(app)/organization/add')), + title: 'Configure a project', + version: 1 + }, + { + availability: requireOrganization, + description: 'Save the current Events configuration as a private view that only you can see.', + initialCheckpoint: 'open-view-menu', + keywords: ['saved view', 'filter', 'columns', 'private', 'dashboard'], + name: 'create-saved-view', + startingRoute: () => resolve('/(app)/event'), + title: 'Create a saved view', + version: 1 + }, + { + availability: requireError, + description: 'Open a real error, assess its stack and status, then inspect the occurrence.', + initialCheckpoint: 'filter-errors', + keywords: ['error report', 'event details', 'exception', 'filter', 'stack', 'triage'], + name: 'investigate-error', + startingRoute: () => `${resolve('/(app)/event')}?time=all&type=error`, + title: 'Investigate an error', + version: 1 + }, + { + availability: (context) => + context.assistantAccess?.enabled ? { available: true } : { available: false, reason: 'Exie is not enabled by this Exceptionless installation.' }, + description: 'See how Exie uses the current page as context without sending a prompt.', + initialCheckpoint: 'open-exie', + keywords: ['exie', 'assistant', 'ai', 'help', 'investigate'], + name: 'meet-exie', + startingRoute: () => resolve('/'), + title: 'Meet Exie', + version: 1 + } +] as const; + +export function getProductTour(name: ProductTourName): ProductTourDefinition { + return productTourCatalog.find((tour) => tour.name === name)!; +} + +export function getProductTourItems(context: ProductTourContext, progress: Record = {}): ProductTourListItem[] { + return productTourCatalog.map((definition) => ({ + ...definition, + currentAvailability: definition.availability(context), + progress: progress[definition.name] + })); +} + +export function getRecommendedProductTourName(context: ProductTourContext): ProductTourName { + return !context.organizationId || context.projects.some((project) => !project.is_configured) ? 'configure-project' : 'ui-overview'; +} diff --git a/src/Exceptionless.Web/ClientApp/src/lib/features/product-tours/components/dialogs/product-tour-catalog-dialog.svelte b/src/Exceptionless.Web/ClientApp/src/lib/features/product-tours/components/dialogs/product-tour-catalog-dialog.svelte new file mode 100644 index 0000000000..942730a992 --- /dev/null +++ b/src/Exceptionless.Web/ClientApp/src/lib/features/product-tours/components/dialogs/product-tour-catalog-dialog.svelte @@ -0,0 +1,50 @@ + + + + + + Guided Tours + Learn Exceptionless with short guides that use your real data. + + +
+ {#each items as item (item.name)} +
+
+
+
+ {#if item.progress?.status === 'completed' && item.progress.version >= item.version} + Completed + {/if} +
+
+

{item.title}

+

{item.description}

+ {#if !item.currentAvailability.available} +

{item.currentAvailability.reason}

+ {/if} +
+ +
+ {/each} +
+
+
diff --git a/src/Exceptionless.Web/ClientApp/src/lib/features/product-tours/components/dialogs/product-tour-welcome-dialog.svelte b/src/Exceptionless.Web/ClientApp/src/lib/features/product-tours/components/dialogs/product-tour-welcome-dialog.svelte new file mode 100644 index 0000000000..3fde333876 --- /dev/null +++ b/src/Exceptionless.Web/ClientApp/src/lib/features/product-tours/components/dialogs/product-tour-welcome-dialog.svelte @@ -0,0 +1,48 @@ + + + + + +
+
+ Welcome to Exceptionless + Take a short guided tour now, or browse the guides whenever you need them. +
+ +
+

Recommended: {recommended.title}

+

{recommended.description}

+
+ + + +
+ + +
+
+
+
diff --git a/src/Exceptionless.Web/ClientApp/src/lib/features/product-tours/components/dialogs/product-tour-welcome-dialog.svelte.test.ts b/src/Exceptionless.Web/ClientApp/src/lib/features/product-tours/components/dialogs/product-tour-welcome-dialog.svelte.test.ts new file mode 100644 index 0000000000..a3744dc382 --- /dev/null +++ b/src/Exceptionless.Web/ClientApp/src/lib/features/product-tours/components/dialogs/product-tour-welcome-dialog.svelte.test.ts @@ -0,0 +1,41 @@ +import { fireEvent, render, screen } from '@testing-library/svelte'; +import { describe, expect, it, vi } from 'vitest'; + +import ProductTourWelcomeDialog from './product-tour-welcome-dialog.svelte'; + +const recommended = { + availability: vi.fn(() => ({ available: true })), + currentAvailability: { available: true }, + description: 'Learn navigation and search.', + initialCheckpoint: 'navigation' as const, + keywords: ['navigation'], + name: 'ui-overview' as const, + startingRoute: vi.fn(() => '/next'), + title: 'Explore Exceptionless', + version: 1 +}; + +describe('ProductTourWelcomeDialog', () => { + it('records dismissal from Escape', async () => { + const onBrowse = vi.fn(); + const onDismiss = vi.fn(); + const onStart = vi.fn(); + render(ProductTourWelcomeDialog, { onBrowse, onDismiss, onStart, open: true, recommended }); + + await fireEvent.keyDown(screen.getByRole('dialog'), { key: 'Escape' }); + expect(onBrowse).not.toHaveBeenCalled(); + expect(onDismiss).toHaveBeenCalledOnce(); + expect(onStart).not.toHaveBeenCalled(); + }); + + it('provides Browse Guides and Skip choices', async () => { + const onBrowse = vi.fn(); + const onDismiss = vi.fn(); + render(ProductTourWelcomeDialog, { onBrowse, onDismiss, onStart: vi.fn(), open: true, recommended }); + + await fireEvent.click(screen.getByRole('button', { name: 'Browse Guides' })); + expect(onBrowse).toHaveBeenCalledOnce(); + await fireEvent.click(screen.getByRole('button', { name: 'Skip' })); + expect(onDismiss).toHaveBeenCalledOnce(); + }); +}); diff --git a/src/Exceptionless.Web/ClientApp/src/lib/features/product-tours/components/product-tour-feature-announcement.svelte b/src/Exceptionless.Web/ClientApp/src/lib/features/product-tours/components/product-tour-feature-announcement.svelte new file mode 100644 index 0000000000..910b93917d --- /dev/null +++ b/src/Exceptionless.Web/ClientApp/src/lib/features/product-tours/components/product-tour-feature-announcement.svelte @@ -0,0 +1,44 @@ + + +{#if open} + + +{/if} diff --git a/src/Exceptionless.Web/ClientApp/src/lib/features/product-tours/components/product-tour-host.svelte b/src/Exceptionless.Web/ClientApp/src/lib/features/product-tours/components/product-tour-host.svelte new file mode 100644 index 0000000000..08e6b4c308 --- /dev/null +++ b/src/Exceptionless.Web/ClientApp/src/lib/features/product-tours/components/product-tour-host.svelte @@ -0,0 +1,275 @@ + + + + +{#if exieAnnouncementOpen && assistantAccess} + +{/if} + + startTour(name, catalogSource)} /> + +{#if checkpoint && (checkpoint.tourName === 'meet-exie' || checkpoint.tourName === 'ui-overview')} + {#key checkpoint} + + {/key} +{/if} diff --git a/src/Exceptionless.Web/ClientApp/src/lib/features/product-tours/components/product-tour-inline-callout.svelte b/src/Exceptionless.Web/ClientApp/src/lib/features/product-tours/components/product-tour-inline-callout.svelte new file mode 100644 index 0000000000..325b931d75 --- /dev/null +++ b/src/Exceptionless.Web/ClientApp/src/lib/features/product-tours/components/product-tour-inline-callout.svelte @@ -0,0 +1,30 @@ + + + + diff --git a/src/Exceptionless.Web/ClientApp/src/lib/features/product-tours/components/product-tour-shell-spotlight.svelte b/src/Exceptionless.Web/ClientApp/src/lib/features/product-tours/components/product-tour-shell-spotlight.svelte new file mode 100644 index 0000000000..cc38a9ab01 --- /dev/null +++ b/src/Exceptionless.Web/ClientApp/src/lib/features/product-tours/components/product-tour-shell-spotlight.svelte @@ -0,0 +1,119 @@ + + +{#if spotlight && (!isAnyOverlayOpen || checkpoint.tourName === 'meet-exie')} + +{/if} diff --git a/src/Exceptionless.Web/ClientApp/src/lib/features/product-tours/components/product-tour-spotlight.svelte b/src/Exceptionless.Web/ClientApp/src/lib/features/product-tours/components/product-tour-spotlight.svelte new file mode 100644 index 0000000000..2f80f74774 --- /dev/null +++ b/src/Exceptionless.Web/ClientApp/src/lib/features/product-tours/components/product-tour-spotlight.svelte @@ -0,0 +1,74 @@ + diff --git a/src/Exceptionless.Web/ClientApp/src/lib/features/product-tours/eligibility.test.ts b/src/Exceptionless.Web/ClientApp/src/lib/features/product-tours/eligibility.test.ts new file mode 100644 index 0000000000..eed9c6af2a --- /dev/null +++ b/src/Exceptionless.Web/ClientApp/src/lib/features/product-tours/eligibility.test.ts @@ -0,0 +1,24 @@ +import { ProductTourStatus } from '$generated/api'; +import { describe, expect, it } from 'vitest'; + +import { shouldOfferProductTourAnnouncement, shouldOfferProductTourWelcome } from './eligibility'; + +describe('product tour welcome eligibility', () => { + it('offers legacy users and a newer welcome version', () => { + expect(shouldOfferProductTourWelcome(undefined, 1)).toBe(true); + expect(shouldOfferProductTourWelcome({ status: ProductTourStatus.Completed, updated_utc: '', version: 1 }, 2)).toBe(true); + }); + + it('suppresses both explicit Start and Skip outcomes for the current version', () => { + expect(shouldOfferProductTourWelcome({ status: ProductTourStatus.Completed, updated_utc: '', version: 1 }, 1)).toBe(false); + expect(shouldOfferProductTourWelcome({ status: ProductTourStatus.Dismissed, updated_utc: '', version: 1 }, 1)).toBe(false); + }); +}); + +describe('product tour feature announcement eligibility', () => { + it('offers a new announcement version until explicitly recorded', () => { + expect(shouldOfferProductTourAnnouncement(undefined, 1)).toBe(true); + expect(shouldOfferProductTourAnnouncement({ status: ProductTourStatus.Dismissed, updated_utc: '', version: 1 }, 1)).toBe(false); + expect(shouldOfferProductTourAnnouncement({ status: ProductTourStatus.Completed, updated_utc: '', version: 2 }, 1)).toBe(false); + }); +}); diff --git a/src/Exceptionless.Web/ClientApp/src/lib/features/product-tours/eligibility.ts b/src/Exceptionless.Web/ClientApp/src/lib/features/product-tours/eligibility.ts new file mode 100644 index 0000000000..6967783729 --- /dev/null +++ b/src/Exceptionless.Web/ClientApp/src/lib/features/product-tours/eligibility.ts @@ -0,0 +1,9 @@ +import type { ProductTourProgress } from '$features/users/models'; + +export function shouldOfferProductTourAnnouncement(progress: ProductTourProgress | undefined, announcementVersion: number): boolean { + return !progress || progress.version < announcementVersion; +} + +export function shouldOfferProductTourWelcome(progress: ProductTourProgress | undefined, welcomeVersion: number): boolean { + return !progress || progress.version < welcomeVersion; +} diff --git a/src/Exceptionless.Web/ClientApp/src/lib/features/product-tours/session.svelte.test.ts b/src/Exceptionless.Web/ClientApp/src/lib/features/product-tours/session.svelte.test.ts new file mode 100644 index 0000000000..a4a104d26b --- /dev/null +++ b/src/Exceptionless.Web/ClientApp/src/lib/features/product-tours/session.svelte.test.ts @@ -0,0 +1,42 @@ +import { beforeEach, describe, expect, it } from 'vitest'; + +import type { ProductTourCheckpoint } from './types'; + +import { clearProductTourSession, readProductTourSession, writeProductTourSession } from './session'; + +const checkpoint: ProductTourCheckpoint = { + checkpointName: 'choose-error', + organizationId: 'organization-id', + phase: { type: 'active' }, + source: 'command-palette', + tourName: 'investigate-error', + userId: 'user-id' +}; + +describe('product tour session', () => { + beforeEach(() => { + sessionStorage.clear(); + }); + + it('round-trips an explicit checkpoint', () => { + writeProductTourSession(checkpoint); + expect(readProductTourSession()).toEqual(checkpoint); + clearProductTourSession(); + expect(sessionStorage).toHaveLength(0); + }); + + it.each([ + '{not-json', + JSON.stringify({ ...checkpoint, tourName: 'unknown-tour' }), + JSON.stringify({ ...checkpoint, checkpointName: 'unknown-step' }), + JSON.stringify({ ...checkpoint, source: 'unknown-source' }), + JSON.stringify({ ...checkpoint, phase: { type: 'saved-view-created' } }), + JSON.stringify({ ...checkpoint, phase: { type: 'saved-view-created', viewId: 'view-id' } }), + JSON.stringify({ ...checkpoint, phase: { type: 'saved-view-loaded', viewId: 'view-id' } }), + JSON.stringify({ ...checkpoint, userId: 42 }) + ])('clears malformed or unknown stored state: %s', (value) => { + sessionStorage.setItem('exceptionless.product-tour', value); + expect(readProductTourSession()).toBeUndefined(); + expect(sessionStorage).toHaveLength(0); + }); +}); diff --git a/src/Exceptionless.Web/ClientApp/src/lib/features/product-tours/session.ts b/src/Exceptionless.Web/ClientApp/src/lib/features/product-tours/session.ts new file mode 100644 index 0000000000..3baa4db065 --- /dev/null +++ b/src/Exceptionless.Web/ClientApp/src/lib/features/product-tours/session.ts @@ -0,0 +1,66 @@ +import type { ProductTourCheckpoint, ProductTourLaunchSource, ProductTourName, ProductTourPhase } from './types'; + +import { PRODUCT_TOUR_CHECKPOINTS } from './types'; + +const SESSION_KEY = 'exceptionless.product-tour'; +const SOURCES: readonly ProductTourLaunchSource[] = ['automatic', 'catalog', 'command-palette', 'feature-announcement', 'help-menu']; + +export function clearProductTourSession(storage: Pick = sessionStorage): void { + storage.removeItem(SESSION_KEY); +} + +export function readProductTourSession(storage: Pick = sessionStorage): ProductTourCheckpoint | undefined { + try { + const value = storage.getItem(SESSION_KEY); + if (!value) return undefined; + + const candidate: unknown = JSON.parse(value); + if (!isProductTourCheckpoint(candidate)) { + clearProductTourSession(storage); + return undefined; + } + + return candidate; + } catch { + clearProductTourSession(storage); + return undefined; + } +} + +export function writeProductTourSession(checkpoint: ProductTourCheckpoint, storage: Pick = sessionStorage): void { + storage.setItem(SESSION_KEY, JSON.stringify(checkpoint)); +} + +function isPhase(value: unknown, tourName: string, checkpointName: unknown): value is ProductTourPhase { + if (!isRecord(value) || typeof value.type !== 'string') return false; + if (value.type === 'active') return true; + return ( + tourName === 'create-saved-view' && + checkpointName === 'view-created' && + (value.type === 'saved-view-created' || value.type === 'saved-view-loaded') && + typeof value.viewId === 'string' && + !!value.viewId + ); +} + +function isProductTourCheckpoint(value: unknown): value is ProductTourCheckpoint { + if (!isRecord(value) || typeof value.userId !== 'string' || !value.userId || typeof value.tourName !== 'string') return false; + if (value.organizationId !== undefined && typeof value.organizationId !== 'string') return false; + if (!isProductTourLaunchSource(value.source) || !isProductTourName(value.tourName)) return false; + + const checkpoints: readonly string[] = PRODUCT_TOUR_CHECKPOINTS[value.tourName]; + if (typeof value.checkpointName !== 'string' || !checkpoints.includes(value.checkpointName)) return false; + return isPhase(value.phase, value.tourName, value.checkpointName); +} + +function isProductTourLaunchSource(value: unknown): value is ProductTourLaunchSource { + return typeof value === 'string' && (SOURCES as readonly string[]).includes(value); +} + +function isProductTourName(value: string): value is ProductTourName { + return Object.hasOwn(PRODUCT_TOUR_CHECKPOINTS, value); +} + +function isRecord(value: unknown): value is Record { + return typeof value === 'object' && value !== null && !Array.isArray(value); +} diff --git a/src/Exceptionless.Web/ClientApp/src/lib/features/product-tours/state.svelte.test.ts b/src/Exceptionless.Web/ClientApp/src/lib/features/product-tours/state.svelte.test.ts new file mode 100644 index 0000000000..a4abfb8833 --- /dev/null +++ b/src/Exceptionless.Web/ClientApp/src/lib/features/product-tours/state.svelte.test.ts @@ -0,0 +1,36 @@ +import { beforeEach, describe, expect, it } from 'vitest'; + +import type { ProductTourCheckpoint } from './types'; + +import { productTourCheckpoint } from './state.svelte'; + +const checkpoint: ProductTourCheckpoint = { + checkpointName: 'navigation', + organizationId: 'organization-id', + phase: { type: 'active' }, + source: 'catalog', + tourName: 'ui-overview', + userId: 'user-id' +}; + +describe('product tour checkpoint store', () => { + beforeEach(() => productTourCheckpoint.clear()); + + it('does not let stale work advance or clear a newer tour', () => { + const first = productTourCheckpoint.start(checkpoint); + const second = productTourCheckpoint.start({ ...checkpoint, source: 'help-menu' }); + + expect(productTourCheckpoint.advance(first, 'command-search')).toBeUndefined(); + expect(productTourCheckpoint.clear(first)).toBe(false); + expect(productTourCheckpoint.current).toBe(second); + }); + + it('clears a checkpoint restored for another identity', () => { + productTourCheckpoint.start(checkpoint); + productTourCheckpoint.clear(); + sessionStorage.setItem('exceptionless.product-tour', JSON.stringify(checkpoint)); + + expect(productTourCheckpoint.restore('another-user', 'organization-id')).toBeUndefined(); + expect(sessionStorage.getItem('exceptionless.product-tour')).toBeNull(); + }); +}); diff --git a/src/Exceptionless.Web/ClientApp/src/lib/features/product-tours/state.svelte.ts b/src/Exceptionless.Web/ClientApp/src/lib/features/product-tours/state.svelte.ts new file mode 100644 index 0000000000..5f67e71c8d --- /dev/null +++ b/src/Exceptionless.Web/ClientApp/src/lib/features/product-tours/state.svelte.ts @@ -0,0 +1,66 @@ +import type { ProductTourCheckpoint, ProductTourCheckpointName, ProductTourPhase } from './types'; + +import { clearProductTourSession, readProductTourSession, writeProductTourSession } from './session'; + +class ProductTourCheckpointStore { + current = $state.raw(); + + advance( + expected: ProductTourCheckpoint, + checkpointName: ProductTourCheckpointName, + phase: ProductTourPhase = { + type: 'active' + }, + organizationId = expected.organizationId + ) { + if (this.current !== expected) { + return undefined; + } + return this.save({ + ...expected, + checkpointName, + organizationId, + phase + }); + } + + clear(expected?: ProductTourCheckpoint): boolean { + if (expected && this.current !== expected) { + return false; + } + this.current = undefined; + clearProductTourSession(); + return true; + } + + restore(userId: string, organizationId?: string): ProductTourCheckpoint | undefined { + if (this.current) { + return this.current; + } + + const stored = readProductTourSession(); + if (!stored) { + return undefined; + } + + if (stored.userId !== userId || stored.organizationId !== organizationId) { + clearProductTourSession(); + return undefined; + } + + this.current = stored; + return stored; + } + + start(checkpoint: ProductTourCheckpoint): ProductTourCheckpoint { + return this.save(checkpoint); + } + + private save(checkpoint: ProductTourCheckpoint): ProductTourCheckpoint { + this.current = checkpoint; + writeProductTourSession(checkpoint); + return checkpoint; + } +} + +export const productTourCheckpoint = new ProductTourCheckpointStore(); diff --git a/src/Exceptionless.Web/ClientApp/src/lib/features/product-tours/telemetry.test.ts b/src/Exceptionless.Web/ClientApp/src/lib/features/product-tours/telemetry.test.ts new file mode 100644 index 0000000000..a7ff2b5165 --- /dev/null +++ b/src/Exceptionless.Web/ClientApp/src/lib/features/product-tours/telemetry.test.ts @@ -0,0 +1,13 @@ +import { describe, expect, it } from 'vitest'; + +import { buildProductTourTelemetryEvent } from './telemetry'; + +describe('product tour telemetry', () => { + it('records stable lifecycle events without resource data', () => { + expect(buildProductTourTelemetryEvent('started', 'ui-overview', 1, 'command-palette')).toBe('product-tour.started.ui-overview.v1.command-palette'); + }); + + it('rejects invalid versions', () => { + expect(() => buildProductTourTelemetryEvent('started', 'meet-exie', 0, 'catalog')).toThrow(); + }); +}); diff --git a/src/Exceptionless.Web/ClientApp/src/lib/features/product-tours/telemetry.ts b/src/Exceptionless.Web/ClientApp/src/lib/features/product-tours/telemetry.ts new file mode 100644 index 0000000000..0c8ecc8429 --- /dev/null +++ b/src/Exceptionless.Web/ClientApp/src/lib/features/product-tours/telemetry.ts @@ -0,0 +1,16 @@ +import type { ProductTourKey, ProductTourLaunchSource } from './types'; + +export type ProductTourTelemetryEvent = 'completed' | 'dismissed' | 'shown' | 'started'; + +export function buildProductTourTelemetryEvent( + event: ProductTourTelemetryEvent, + name: ProductTourKey, + version: number, + source: ProductTourLaunchSource +): string { + if (!Number.isSafeInteger(version) || version < 1) { + throw new Error('Product tour telemetry requires a positive version.'); + } + + return ['product-tour', event, name, `v${version}`, source].join('.'); +} diff --git a/src/Exceptionless.Web/ClientApp/src/lib/features/product-tours/types.ts b/src/Exceptionless.Web/ClientApp/src/lib/features/product-tours/types.ts new file mode 100644 index 0000000000..7d438257a7 --- /dev/null +++ b/src/Exceptionless.Web/ClientApp/src/lib/features/product-tours/types.ts @@ -0,0 +1,56 @@ +import type { AssistantAccess } from '$features/assistant/models'; +import type { ViewProject } from '$features/projects/models'; +import type { ProductTourProgress } from '$features/users/models'; + +export const PRODUCT_TOUR_CHECKPOINTS = { + 'configure-project': ['organization-name', 'project-name', 'choose-platform', 'sdk-instructions', 'wait-for-event'], + 'create-saved-view': ['open-view-menu', 'review-settings', 'name-view', 'private-view', 'save-view', 'view-created'], + 'investigate-error': ['filter-errors', 'choose-error', 'stack-summary', 'stack-triage', 'event-occurrence', 'tab-overview', 'filter-stack-events'], + 'meet-exie': ['open-exie', 'exie-context'], + 'ui-overview': ['navigation', 'command-search', 'saved-views', 'exie', 'help'] +} as const; + +export interface ProductTourAvailability { + available: boolean; + reason?: string; +} +export interface ProductTourCheckpoint { + checkpointName: ProductTourCheckpointName; + organizationId?: string; + phase: ProductTourPhase; + source: ProductTourLaunchSource; + tourName: ProductTourName; + userId: string; +} +export type ProductTourCheckpointName = (typeof PRODUCT_TOUR_CHECKPOINTS)[ProductTourName][number]; +export interface ProductTourContext { + assistantAccess?: AssistantAccess; + errorEventAvailability: 'available' | 'empty' | 'error' | 'loading'; + isSetupPage: boolean; + organizationId?: string; + pathname: string; + projects: Pick[]; +} +export interface ProductTourDefinition { + availability: (context: ProductTourContext) => ProductTourAvailability; + description: string; + initialCheckpoint: ProductTourCheckpointName; + keywords: readonly string[]; + name: ProductTourName; + startingRoute: (context: ProductTourContext) => string; + title: string; + version: number; +} + +export type ProductTourKey = 'exie-announcement' | 'welcome' | ProductTourName; + +export type ProductTourLaunchSource = 'automatic' | 'catalog' | 'command-palette' | 'feature-announcement' | 'help-menu'; + +export interface ProductTourListItem extends ProductTourDefinition { + currentAvailability: ProductTourAvailability; + progress?: ProductTourProgress; +} + +export type ProductTourName = keyof typeof PRODUCT_TOUR_CHECKPOINTS; + +export type ProductTourPhase = { type: 'active' } | { type: 'saved-view-created' | 'saved-view-loaded'; viewId: string }; diff --git a/src/Exceptionless.Web/ClientApp/src/lib/features/projects/api.svelte.ts b/src/Exceptionless.Web/ClientApp/src/lib/features/projects/api.svelte.ts index cf35e91986..5d957ee165 100644 --- a/src/Exceptionless.Web/ClientApp/src/lib/features/projects/api.svelte.ts +++ b/src/Exceptionless.Web/ClientApp/src/lib/features/projects/api.svelte.ts @@ -140,6 +140,7 @@ export interface GetProjectIntegrationNotificationSettingsRequest { } export interface GetProjectRequest { + enabled?: () => boolean; refetchInterval?: false | number; route: { id: string | undefined; @@ -440,7 +441,7 @@ export function getProjectQuery(request: GetProjectRequest) { const id = request.route.id; return { - enabled: () => !!accessToken.current && !!id, + enabled: () => !!accessToken.current && !!id && (request.enabled?.() ?? true), // Like event and stack reads, finish across remounts so the next observer can reuse the result. queryFn: async () => { const client = useFetchClient(); diff --git a/src/Exceptionless.Web/ClientApp/src/lib/features/saved-views/components/save-view-dialog.svelte b/src/Exceptionless.Web/ClientApp/src/lib/features/saved-views/components/save-view-dialog.svelte index 95fc6808ec..645228ecd3 100644 --- a/src/Exceptionless.Web/ClientApp/src/lib/features/saved-views/components/save-view-dialog.svelte +++ b/src/Exceptionless.Web/ClientApp/src/lib/features/saved-views/components/save-view-dialog.svelte @@ -1,10 +1,13 @@ - - + { + if (nextOpen || !saving) { + open = nextOpen; + if (!nextOpen) { + onCancel?.(); + } + } + }} +> + saving && event.preventDefault()} + onInteractOutside={(event) => saving && event.preventDefault()} + > Save View Save the current view configuration for quick access. - {#if duplicateView} + {#if defaultPrivate && tourCheckpointName === 'name-view'} + onTourContinue?.('private-view')} + onDismiss={dismissTour} + title="Review and name your view" + tourName="create-saved-view" + /> + {:else if defaultPrivate && tourCheckpointName === 'private-view'} + onTourContinue?.('save-view')} + onDismiss={dismissTour} + title="Keep it private" + tourName="create-saved-view" + /> + {:else if defaultPrivate && (tourCheckpointName === 'save-view' || tourCheckpointName === 'view-created')} + + {/if} + {#if duplicateView && !pendingCompletion}
Current filters match "{duplicateView.name}". You can instead, or save with a different name. @@ -146,6 +213,7 @@
{#if visibleNameError}

{visibleNameError}

@@ -169,6 +238,7 @@ aria-invalid={!!visibleSlugError} aria-describedby={visibleSlugError ? 'view-slug-error' : undefined} required + disabled={pendingCompletion} oninput={() => { isSlugDirty = true; }} @@ -177,17 +247,17 @@

{visibleSlugError}

{/if}
-
+
- Only visible to you + {defaultPrivate ? 'Required for this guided practice view' : 'Only visible to you'}
- +
- - + diff --git a/src/Exceptionless.Web/ClientApp/src/lib/features/saved-views/components/saved-view-picker.svelte b/src/Exceptionless.Web/ClientApp/src/lib/features/saved-views/components/saved-view-picker.svelte index 2cee06835d..81fbdd3dcf 100644 --- a/src/Exceptionless.Web/ClientApp/src/lib/features/saved-views/components/saved-view-picker.svelte +++ b/src/Exceptionless.Web/ClientApp/src/lib/features/saved-views/components/saved-view-picker.svelte @@ -15,6 +15,9 @@ import { serializeFilters } from '$features/events/components/filters/helpers.svelte'; import { getOrganizationQuery, getOrganizationsQuery } from '$features/organizations/api.svelte'; import { organization } from '$features/organizations/context.svelte'; + import { createProductTourActions } from '$features/product-tours/actions.svelte'; + import ProductTourSpotlight from '$features/product-tours/components/product-tour-spotlight.svelte'; + import { productTourCheckpoint } from '$features/product-tours/state.svelte'; import { supportsColumnWrapping } from '$features/shared/components/data-table/column-meta'; import { getMeQuery } from '$features/users/api.svelte'; import Building2 from '@lucide/svelte/icons/building-2'; @@ -69,7 +72,7 @@ filters: IFilter[]; isModified: boolean; onClearSavedView: () => Promise; - onLoadView: (view: SavedView) => void; + onLoadView: (view: SavedView) => Promise | void; onResetToSaved: () => void; onSavedViewUpdated: (view: SavedView) => void; savedViews: SavedView[]; @@ -114,12 +117,23 @@ wrappedColumnIds }: Props = $props(); - let isSaveDialogOpen = $state(false); + let isSaveDialogOpenManually = $state(false); let isRenameDialogOpen = $state(false); let isDeleteDialogOpen = $state(false); let isColumnDialogOpen = $state(false); let isMenuOpen = $state(false); let viewToDelete = $state(null); + const tourActions = createProductTourActions(); + const savedViewCheckpoint = $derived(productTourCheckpoint.current?.tourName === 'create-saved-view' ? productTourCheckpoint.current : undefined); + const isSaveDialogOpen = $derived( + isSaveDialogOpenManually || savedViewCheckpoint?.phase.type === 'saved-view-created' || savedViewCheckpoint?.phase.type === 'saved-view-loaded' + ); + const pendingTourView = $derived.by(() => { + const phase = savedViewCheckpoint?.phase; + return phase?.type === 'saved-view-created' || phase?.type === 'saved-view-loaded' + ? savedViews.find((savedView) => savedView.id === phase.viewId) + : undefined; + }); const organizationId = $derived(organization.current); const activeView = $derived(activeSavedView); @@ -218,7 +232,7 @@ async function openSaveDialog() { await tick(); - isSaveDialogOpen = true; + isSaveDialogOpenManually = true; } async function openRenameDialog() { @@ -259,6 +273,35 @@ return; } + const checkpoint = savedViewCheckpoint; + if (checkpoint?.phase.type === 'saved-view-loaded') { + if (await tourActions.complete(checkpoint)) { + isSaveDialogOpenManually = false; + } + return; + } + + if (checkpoint?.phase.type === 'saved-view-created') { + if (!pendingTourView) { + toast.error('The created view could not be loaded. Refresh and try again.'); + return; + } + + try { + await onLoadView(pendingTourView); + const loadedCheckpoint = productTourCheckpoint.advance(checkpoint, 'view-created', { + type: 'saved-view-loaded', + viewId: checkpoint.phase.viewId + }); + if (loadedCheckpoint && (await tourActions.complete(loadedCheckpoint))) { + isSaveDialogOpenManually = false; + } + } catch (error) { + toast.error(getErrorMessage(error, 'Failed to load the created view. Please try again.')); + } + return; + } + const filterDefinitions = serializeFilters(filters); const body: NewSavedView = { columns: getSavedColumnSettings(), @@ -277,8 +320,25 @@ try { const result = await createMutation.mutateAsync(body); - isSaveDialogOpen = false; - onLoadView(result); + if (checkpoint) { + const createdCheckpoint = productTourCheckpoint.advance(checkpoint, 'view-created', { + type: 'saved-view-created', + viewId: result.id + }); + await onLoadView(result); + const loadedCheckpoint = createdCheckpoint + ? productTourCheckpoint.advance(createdCheckpoint, 'view-created', { + type: 'saved-view-loaded', + viewId: result.id + }) + : undefined; + if (loadedCheckpoint && (await tourActions.complete(loadedCheckpoint))) { + isSaveDialogOpenManually = false; + } + } else { + isSaveDialogOpenManually = false; + await onLoadView(result); + } toast.success(`Saved view "${result.name}" created.`); } catch (error) { toast.error(getErrorMessage(error, 'Failed to save view. Please try again.')); @@ -393,7 +453,7 @@ {#snippet child({ props })} - {/snippet} - + Saved View {#if activeView} @@ -493,16 +553,57 @@ {#if isSaveDialogOpen} { + if (savedViewCheckpoint) { + await tourActions.dismiss(savedViewCheckpoint); + } + }} {savedViews} {saving} onSave={handleSave} - onClose={() => (isSaveDialogOpen = false)} + onClose={() => (isSaveDialogOpenManually = false)} + onTourContinue={(checkpointName) => { + const checkpoint = savedViewCheckpoint; + if (checkpoint) { + productTourCheckpoint.advance(checkpoint, checkpointName); + } + }} + pendingCompletion={savedViewCheckpoint?.phase.type === 'saved-view-created' || savedViewCheckpoint?.phase.type === 'saved-view-loaded'} + tourCheckpointName={savedViewCheckpoint?.checkpointName} {onLoadView} /> {/if} +{#if savedViewCheckpoint?.checkpointName === 'open-view-menu'} + { + isMenuOpen = true; + productTourCheckpoint.advance(checkpoint, 'review-settings'); + }} + target="[data-tour='saved-view-trigger']" + title="Open View settings" + /> +{:else if savedViewCheckpoint?.checkpointName === 'review-settings'} + { + isMenuOpen = false; + isSaveDialogOpenManually = true; + productTourCheckpoint.advance(checkpoint, 'name-view'); + }} + target="[data-tour='saved-view-settings']" + title="Configure what the view remembers" + /> +{/if} + {#if isRenameDialogOpen && activeView} @@ -184,10 +185,12 @@
- - - - +
+ + + + +
diff --git a/src/Exceptionless.Web/ClientApp/src/lib/features/users/api.svelte.ts b/src/Exceptionless.Web/ClientApp/src/lib/features/users/api.svelte.ts index ff97db0e6d..3a55928f6c 100644 --- a/src/Exceptionless.Web/ClientApp/src/lib/features/users/api.svelte.ts +++ b/src/Exceptionless.Web/ClientApp/src/lib/features/users/api.svelte.ts @@ -7,7 +7,16 @@ import { fetchApiJson } from '$features/shared/api/api.svelte'; import { type FetchClientResponse, ProblemDetails, useFetchClient } from '@foundatiofx/fetchclient'; import { createMutation, createQuery, QueryClient, useQueryClient } from '@tanstack/svelte-query'; -import type { OAuthGrant, UpdateEmailAddressResult, UpdateUser, UpdateUserEmailAddress, ViewCurrentUser, ViewUser } from './models'; +import type { + OAuthGrant, + ProductTourProgress, + UpdateEmailAddressResult, + UpdateProductTourProgress, + UpdateUser, + UpdateUserEmailAddress, + ViewCurrentUser, + ViewUser +} from './models'; export async function invalidateUserQueries(queryClient: QueryClient, message: WebSocketMessageValue<'UserChanged'>) { const { id } = message; @@ -41,6 +50,7 @@ export const queryKeys = { organization: (id: string | undefined) => [...queryKeys.type, 'organization', id] as const, patchUser: (id: string | undefined) => [...queryKeys.id(id), 'patch'] as const, postEmailAddress: (id: string | undefined) => [...queryKeys.idEmailAddress(id), 'update'] as const, + productTour: (tourName: string | undefined) => [...queryKeys.me(), 'product-tours', tourName] as const, type: ['User'] as const }; @@ -68,6 +78,11 @@ export interface PostEmailAddressRequest { }; } +export interface PutCurrentUserProductTourRequest { + progress: UpdateProductTourProgress; + tourName: string; +} + export interface ResendVerificationEmailRequest { route: { id: string | undefined; @@ -260,6 +275,35 @@ export function postEmailAddress(request: PostEmailAddressRequest) { })); } +export function putCurrentUserProductTour() { + const queryClient = useQueryClient(); + return createMutation(() => ({ + enabled: () => !!accessToken.current, + mutationFn: async ({ progress, tourName }) => { + const client = useFetchClient(); + const response = await client.putJSON(`users/me/product-tours/${tourName}`, progress); + return response.data!; + }, + mutationKey: queryKeys.productTour(undefined), + onSuccess: (progress, { tourName }) => { + const currentUser = queryClient.getQueryData(queryKeys.me()); + if (!currentUser) { + return; + } + + const updatedUser = { + ...currentUser, + product_tours: { + ...currentUser.product_tours, + [tourName]: progress + } + }; + queryClient.setQueryData(queryKeys.me(), updatedUser); + queryClient.setQueryData(queryKeys.id(currentUser.id), updatedUser); + } + })); +} + export function resendVerificationEmail(request: ResendVerificationEmailRequest) { return createMutation(() => ({ enabled: () => !!accessToken.current && !!request.route.id, diff --git a/src/Exceptionless.Web/ClientApp/src/lib/features/users/models.ts b/src/Exceptionless.Web/ClientApp/src/lib/features/users/models.ts index a262d71122..67d005712d 100644 --- a/src/Exceptionless.Web/ClientApp/src/lib/features/users/models.ts +++ b/src/Exceptionless.Web/ClientApp/src/lib/features/users/models.ts @@ -1,4 +1,12 @@ -export type { ViewOAuthGrant as OAuthGrant, UpdateEmailAddressResult, ViewCurrentUser, ViewUser } from '$generated/api'; +export { ProductTourStatus } from '$generated/api'; +export type { + ViewOAuthGrant as OAuthGrant, + ProductTourProgress, + UpdateEmailAddressResult, + UpdateProductTourProgress, + ViewCurrentUser, + ViewUser +} from '$generated/api'; export interface InviteUserForm { email: string; diff --git a/src/Exceptionless.Web/ClientApp/src/lib/generated/api.ts b/src/Exceptionless.Web/ClientApp/src/lib/generated/api.ts index 8db136b95e..0c38fc5593 100644 --- a/src/Exceptionless.Web/ClientApp/src/lib/generated/api.ts +++ b/src/Exceptionless.Web/ClientApp/src/lib/generated/api.ts @@ -7,6 +7,11 @@ export enum StackStatus { Discarded = "discarded", } +export enum ProductTourStatus { + Completed = "completed", + Dismissed = "dismissed", +} + export enum BillingStatus { Trialing = 0, Active = 1, @@ -73,6 +78,48 @@ export interface AdminAssistantUsageResponse { organizations: AdminAssistantOrganizationUsage[]; } +export interface AdminProductTourActivity { + /** @format date-time */ + date_utc: string; + event: string; + launch_source: string; + tour_name: string; + user_identity?: null | string; + user_name?: null | string; + /** @format int32 */ + version: number; + /** @format int64 */ + count: number; +} + +export interface AdminProductTourSummary { + name: string; + /** @format int64 */ + shown: number; + /** @format int64 */ + started: number; + /** @format int64 */ + completed: number; + /** @format int64 */ + dismissed: number; + /** @format int64 */ + unique_users: number; + /** @format date-time */ + last_run_utc?: null | string; + /** @format double */ + completion_rate?: null | number; + /** @format double */ + dismissal_rate?: null | number; +} + +export interface AdminProductTourUsageResponse { + /** @format date-time */ + month: string; + telemetry_configured: boolean; + tours: AdminProductTourSummary[]; + recent_activity: AdminProductTourActivity[]; +} + export interface AssistantAccessResponse { enabled: boolean; has_access: boolean; @@ -494,6 +541,14 @@ export interface ProblemDetails { instance?: null | string; } +export interface ProductTourProgress { + status: ProductTourStatus; + /** @format date-time */ + updated_utc: string; + /** @format int32 */ + version: number; +} + export interface ResetPasswordModel { password_reset_token: string; password: string; @@ -648,6 +703,16 @@ export interface UpdateEventSubmissionSettings { enabled?: null | boolean; } +export interface UpdateProductTourProgress { + status?: null | ProductTourStatus; + /** + * @format int32 + * @min 1 + * @max 2147483647 + */ + version: number; +} + /** A class the tracks changes (i.e. the Delta) for a particular TEntityType. */ export interface UpdateProject { name: string; @@ -737,6 +802,7 @@ export interface User { o_auth_accounts: OAuthAccount[]; organization_preferences: UserOrganizationPreference[]; saved_view_orders: UserSavedViewOrderPreference[]; + product_tours: Record; /** Gets or sets the users Full Name. */ full_name: string; /** @format email */ @@ -784,6 +850,7 @@ export interface ViewCurrentUser { o_auth_accounts: OAuthAccount[]; organization_preferences: UserOrganizationPreference[]; saved_view_orders: UserSavedViewOrderPreference[]; + product_tours: Record; /** @pattern ^[a-fA-F0-9]{24}$ */ id: string; organization_ids: string[]; diff --git a/src/Exceptionless.Web/ClientApp/src/lib/generated/schemas.ts b/src/Exceptionless.Web/ClientApp/src/lib/generated/schemas.ts index ec58c99678..c541054d33 100644 --- a/src/Exceptionless.Web/ClientApp/src/lib/generated/schemas.ts +++ b/src/Exceptionless.Web/ClientApp/src/lib/generated/schemas.ts @@ -27,6 +27,7 @@ export const StackStatusSchema = zodEnum([ "ignored", "discarded", ]); +export const ProductTourStatusSchema = zodEnum(["completed", "dismissed"]); export const BillingStatusSchema = union([ literal(0), literal(1), @@ -75,6 +76,45 @@ export type AdminAssistantUsageResponseFormData = Infer< typeof AdminAssistantUsageResponseSchema >; +export const AdminProductTourActivitySchema = object({ + date_utc: iso.datetime(), + event: string().min(1, "Event is required"), + launch_source: string().min(1, "Launch source is required"), + tour_name: string().min(1, "Tour name is required"), + user_identity: string().min(1, "User identity is required").nullable(), + user_name: string().min(1, "User name is required").nullable(), + version: int32(), + count: int(), +}); +export type AdminProductTourActivityFormData = Infer< + typeof AdminProductTourActivitySchema +>; + +export const AdminProductTourSummarySchema = object({ + name: string().min(1, "Name is required"), + shown: int(), + started: int(), + completed: int(), + dismissed: int(), + unique_users: int(), + last_run_utc: iso.datetime().nullable(), + completion_rate: number().nullable(), + dismissal_rate: number().nullable(), +}); +export type AdminProductTourSummaryFormData = Infer< + typeof AdminProductTourSummarySchema +>; + +export const AdminProductTourUsageResponseSchema = object({ + month: iso.datetime(), + telemetry_configured: boolean(), + tours: array(lazy(() => AdminProductTourSummarySchema)), + recent_activity: array(lazy(() => AdminProductTourActivitySchema)), +}); +export type AdminProductTourUsageResponseFormData = Infer< + typeof AdminProductTourUsageResponseSchema +>; + export const AssistantAccessResponseSchema = object({ enabled: boolean(), has_access: boolean(), @@ -626,6 +666,15 @@ export const ProblemDetailsSchema = object({ }); export type ProblemDetailsFormData = Infer; +export const ProductTourProgressSchema = object({ + status: ProductTourStatusSchema, + updated_utc: iso.datetime(), + version: int32(), +}); +export type ProductTourProgressFormData = Infer< + typeof ProductTourProgressSchema +>; + export const ResetPasswordModelSchema = object({ password_reset_token: string().length( 40, @@ -768,6 +817,16 @@ export type UpdateEventSubmissionSettingsFormData = Infer< typeof UpdateEventSubmissionSettingsSchema >; +export const UpdateProductTourProgressSchema = object({ + status: ProductTourStatusSchema, + version: int32() + .min(1, "Version must be at least 1") + .max(2147483647, "Version must be at most 2147483647"), +}); +export type UpdateProductTourProgressFormData = Infer< + typeof UpdateProductTourProgressSchema +>; + export const UpdateProjectSchema = object({ name: string().min(1, "Name is required").optional(), delete_bot_data_enabled: boolean().optional(), @@ -862,6 +921,10 @@ export const UserSchema = object({ o_auth_accounts: array(lazy(() => OAuthAccountSchema)), organization_preferences: array(lazy(() => UserOrganizationPreferenceSchema)), saved_view_orders: array(lazy(() => UserSavedViewOrderPreferenceSchema)), + product_tours: record( + string(), + lazy(() => ProductTourProgressSchema), + ), full_name: string().min(1, "Full name is required"), email_address: email(), avatar_file_name: string() @@ -919,6 +982,10 @@ export const ViewCurrentUserSchema = object({ o_auth_accounts: array(lazy(() => OAuthAccountSchema)), organization_preferences: array(lazy(() => UserOrganizationPreferenceSchema)), saved_view_orders: array(lazy(() => UserSavedViewOrderPreferenceSchema)), + product_tours: record( + string(), + lazy(() => ProductTourProgressSchema), + ), id: string() .length(24, "Id must be exactly 24 characters") .regex(/^[a-fA-F0-9]{24}$/, "Id has invalid format"), diff --git a/src/Exceptionless.Web/ClientApp/src/routes/(app)/(components)/layouts/navbar.svelte b/src/Exceptionless.Web/ClientApp/src/routes/(app)/(components)/layouts/navbar.svelte index dda09bd8e4..5998ebf0fb 100644 --- a/src/Exceptionless.Web/ClientApp/src/routes/(app)/(components)/layouts/navbar.svelte +++ b/src/Exceptionless.Web/ClientApp/src/routes/(app)/(components)/layouts/navbar.svelte @@ -27,7 +27,7 @@
- + {#if isMediumScreenQuery.current} @@ -41,6 +41,7 @@ + {/if} + {#each tabs as tab (tab)} {/each} + {#if canScrollTabsRight} + + {/if}
{#each tabs as tab (tab)} diff --git a/src/Exceptionless.Web/ClientApp/src/lib/features/product-tours/actions.svelte.ts b/src/Exceptionless.Web/ClientApp/src/lib/features/product-tours/actions.svelte.ts index 611213ff6c..2a2764fb8e 100644 --- a/src/Exceptionless.Web/ClientApp/src/lib/features/product-tours/actions.svelte.ts +++ b/src/Exceptionless.Web/ClientApp/src/lib/features/product-tours/actions.svelte.ts @@ -38,7 +38,7 @@ export function createProductTourActions() { if (!productTourCheckpoint.clear(checkpoint)) { return false; } - void track(status === ProductTourStatus.Completed ? 'completed' : 'dismissed', checkpoint.tourName, definition.version, checkpoint.source); + await track(status === ProductTourStatus.Completed ? 'completed' : 'dismissed', checkpoint.tourName, definition.version, checkpoint.source); return true; } diff --git a/src/Exceptionless.Web/ClientApp/src/lib/features/product-tours/components/product-tour-host.svelte b/src/Exceptionless.Web/ClientApp/src/lib/features/product-tours/components/product-tour-host.svelte index 08e6b4c308..d0b49f5a71 100644 --- a/src/Exceptionless.Web/ClientApp/src/lib/features/product-tours/components/product-tour-host.svelte +++ b/src/Exceptionless.Web/ClientApp/src/lib/features/product-tours/components/product-tour-host.svelte @@ -181,7 +181,7 @@ tourName: name, userId: currentUser.id }); - void Promise.all([track('shown', name, item.version, source), track('started', name, item.version, source)]); + await Promise.all([track('shown', name, item.version, source), track('started', name, item.version, source)]); const destination = item.startingRoute(context); if (`${pathname}${window.location.search}` !== destination) { @@ -214,7 +214,7 @@ return; } welcomeHandled = true; - void track('completed', 'welcome', WELCOME_VERSION, 'automatic'); + await track('completed', 'welcome', WELCOME_VERSION, 'automatic'); await startTour(recommended.name, 'automatic'); } @@ -223,7 +223,7 @@ return; } welcomeHandled = true; - void track('completed', 'welcome', WELCOME_VERSION, 'automatic'); + await track('completed', 'welcome', WELCOME_VERSION, 'automatic'); openCatalog('catalog'); } @@ -232,14 +232,14 @@ return; } welcomeHandled = true; - void track('dismissed', 'welcome', WELCOME_VERSION, 'automatic'); + await track('dismissed', 'welcome', WELCOME_VERSION, 'automatic'); } async function onExieAnnouncementStart(): Promise { if (!(await recordPreference('exie-announcement', EXIE_ANNOUNCEMENT_VERSION, ProductTourStatus.Completed))) { return; } - void track('completed', 'exie-announcement', EXIE_ANNOUNCEMENT_VERSION, 'feature-announcement'); + await track('completed', 'exie-announcement', EXIE_ANNOUNCEMENT_VERSION, 'feature-announcement'); await startTour('meet-exie', 'feature-announcement'); } @@ -247,7 +247,7 @@ if (!(await recordPreference('exie-announcement', EXIE_ANNOUNCEMENT_VERSION, ProductTourStatus.Dismissed))) { return; } - void track('dismissed', 'exie-announcement', EXIE_ANNOUNCEMENT_VERSION, 'feature-announcement'); + await track('dismissed', 'exie-announcement', EXIE_ANNOUNCEMENT_VERSION, 'feature-announcement'); } function getItem(name: ProductTourName): ProductTourListItem { diff --git a/src/Exceptionless.Web/ClientApp/src/lib/features/projects/api.svelte.ts b/src/Exceptionless.Web/ClientApp/src/lib/features/projects/api.svelte.ts index 5d957ee165..cf35e91986 100644 --- a/src/Exceptionless.Web/ClientApp/src/lib/features/projects/api.svelte.ts +++ b/src/Exceptionless.Web/ClientApp/src/lib/features/projects/api.svelte.ts @@ -140,7 +140,6 @@ export interface GetProjectIntegrationNotificationSettingsRequest { } export interface GetProjectRequest { - enabled?: () => boolean; refetchInterval?: false | number; route: { id: string | undefined; @@ -441,7 +440,7 @@ export function getProjectQuery(request: GetProjectRequest) { const id = request.route.id; return { - enabled: () => !!accessToken.current && !!id && (request.enabled?.() ?? true), + enabled: () => !!accessToken.current && !!id, // Like event and stack reads, finish across remounts so the next observer can reuse the result. queryFn: async () => { const client = useFetchClient(); diff --git a/src/Exceptionless.Web/ClientApp/src/lib/features/saved-views/use-saved-views.svelte.ts b/src/Exceptionless.Web/ClientApp/src/lib/features/saved-views/use-saved-views.svelte.ts index 7a9d2d4737..331a8884b9 100644 --- a/src/Exceptionless.Web/ClientApp/src/lib/features/saved-views/use-saved-views.svelte.ts +++ b/src/Exceptionless.Web/ClientApp/src/lib/features/saved-views/use-saved-views.svelte.ts @@ -99,7 +99,7 @@ export interface UseSavedViewsReturn { autoFillColumnId: AutoFillColumnSelection; canModifySavedView: boolean; handleClearSavedView: () => Promise; - handleLoadView: (view: SavedView) => void; + handleLoadView: (view: SavedView) => Promise; handleResetToSaved: () => void; handleSavedViewUpdated: (view: SavedView) => void; hydratedSavedViewId: string | undefined; @@ -1209,9 +1209,9 @@ export function useSavedViews(options: UseSavedViewsOptions): UseSavedViewsRetur }) ); - function handleLoadView(view: SavedView) { + async function handleLoadView(view: SavedView): Promise { if (options.baseHref) { - goto(savedViewHref(view)); + await goto(savedViewHref(view)); return; } diff --git a/src/Exceptionless.Web/ClientApp/src/lib/features/sessions/components/sessions-dashboard-chart.svelte b/src/Exceptionless.Web/ClientApp/src/lib/features/sessions/components/sessions-dashboard-chart.svelte index ee57bbeeb2..b76f528af6 100644 --- a/src/Exceptionless.Web/ClientApp/src/lib/features/sessions/components/sessions-dashboard-chart.svelte +++ b/src/Exceptionless.Web/ClientApp/src/lib/features/sessions/components/sessions-dashboard-chart.svelte @@ -49,50 +49,51 @@ ]; - - - Math.max(d.sessions, d.users))))]} - {series} - axis={false} - grid={false} - brush={{ - onBrushEnd: (e) => { - if (!e.brush.active) { - return; - } + + {#if isLoading} + + {:else} + + Math.max(d.sessions, d.users))))]} + {series} + axis={false} + grid={false} + brush={{ + onBrushEnd: (e) => { + if (!e.brush.active) { + return; + } - const [start, end] = e.brush.x; - if (start instanceof Date && end instanceof Date) { - onRangeSelect?.(start, end); + const [start, end] = e.brush.x; + if (start instanceof Date && end instanceof Date) { + onRangeSelect?.(start, end); + } } - } - }} - props={{ - area: { - curve: curveLinear - }, - canvas: { - class: 'cursor-crosshair' - }, - svg: { - class: 'cursor-crosshair' - } - }} - > - {#snippet tooltip()} - (v instanceof Date ? formatDateLabel(v) : typeof v === 'number' ? formatDateLabel(new Date(v)) : String(v))} - /> - {/snippet} - - - {#if isLoading} - + }} + props={{ + area: { + curve: curveLinear + }, + canvas: { + class: 'cursor-crosshair' + }, + svg: { + class: 'cursor-crosshair' + } + }} + > + {#snippet tooltip()} + (v instanceof Date ? formatDateLabel(v) : typeof v === 'number' ? formatDateLabel(new Date(v)) : String(v))} + /> + {/snippet} + + {/if} diff --git a/src/Exceptionless.Web/ClientApp/src/lib/features/users/api.svelte.ts b/src/Exceptionless.Web/ClientApp/src/lib/features/users/api.svelte.ts index 3a55928f6c..5795789d13 100644 --- a/src/Exceptionless.Web/ClientApp/src/lib/features/users/api.svelte.ts +++ b/src/Exceptionless.Web/ClientApp/src/lib/features/users/api.svelte.ts @@ -282,6 +282,11 @@ export function putCurrentUserProductTour() { mutationFn: async ({ progress, tourName }) => { const client = useFetchClient(); const response = await client.putJSON(`users/me/product-tours/${tourName}`, progress); + + if (!response.ok) { + throw response.problem; + } + return response.data!; }, mutationKey: queryKeys.productTour(undefined), diff --git a/src/Exceptionless.Web/ClientApp/src/routes/(app)/event/+page.svelte b/src/Exceptionless.Web/ClientApp/src/routes/(app)/event/+page.svelte index 347fe88087..5d41eb6981 100644 --- a/src/Exceptionless.Web/ClientApp/src/routes/(app)/event/+page.svelte +++ b/src/Exceptionless.Web/ClientApp/src/routes/(app)/event/+page.svelte @@ -1,7 +1,4 @@ -{#if event && investigationCopy && ['stack-summary', 'stack-triage'].includes(investigationCheckpoint?.checkpointName ?? '')} - -{/if} +

Stack

@@ -423,16 +346,7 @@ {/if}
-{#if event && investigationCopy && ['event-occurrence', 'filter-stack-events'].includes(investigationCheckpoint?.checkpointName ?? '')} - -{/if} +
@@ -489,15 +403,7 @@ {#if event} - {#if investigationCheckpoint?.checkpointName === 'tab-overview' && investigationCopy} - - {/if} +
{#if canScrollTabsLeft} diff --git a/src/Exceptionless.Web/ClientApp/src/lib/features/events/components/investigation-detail-tour.svelte b/src/Exceptionless.Web/ClientApp/src/lib/features/events/components/investigation-detail-tour.svelte new file mode 100644 index 0000000000..8c7afe291d --- /dev/null +++ b/src/Exceptionless.Web/ClientApp/src/lib/features/events/components/investigation-detail-tour.svelte @@ -0,0 +1,114 @@ + + +{#if event && checkpoint && copy} + +{/if} diff --git a/src/Exceptionless.Web/ClientApp/src/lib/features/product-tours/actions.svelte.ts b/src/Exceptionless.Web/ClientApp/src/lib/features/product-tours/actions.svelte.ts index 2a2764fb8e..e2e2951648 100644 --- a/src/Exceptionless.Web/ClientApp/src/lib/features/product-tours/actions.svelte.ts +++ b/src/Exceptionless.Web/ClientApp/src/lib/features/product-tours/actions.svelte.ts @@ -5,7 +5,6 @@ import { toast } from 'svelte-sonner'; import type { ProductTourCheckpoint, ProductTourKey, ProductTourLaunchSource } from './types'; -import { getProductTour } from './catalog'; import { productTourCheckpoint } from './state.svelte'; import { buildProductTourTelemetryEvent, type ProductTourTelemetryEvent } from './telemetry'; @@ -21,12 +20,11 @@ export function createProductTourActions() { } async function finish(checkpoint: ProductTourCheckpoint, status: ProductTourStatus): Promise { - const definition = getProductTour(checkpoint.tourName); try { await progressMutation.mutateAsync({ progress: { status, - version: definition.version + version: checkpoint.version }, tourName: checkpoint.tourName }); @@ -38,7 +36,7 @@ export function createProductTourActions() { if (!productTourCheckpoint.clear(checkpoint)) { return false; } - await track(status === ProductTourStatus.Completed ? 'completed' : 'dismissed', checkpoint.tourName, definition.version, checkpoint.source); + await track(status === ProductTourStatus.Completed ? 'completed' : 'dismissed', checkpoint.tourName, checkpoint.version, checkpoint.source); return true; } diff --git a/src/Exceptionless.Web/ClientApp/src/lib/features/product-tours/catalog.test.ts b/src/Exceptionless.Web/ClientApp/src/lib/features/product-tours/catalog.test.ts index c17eb9f560..13f1c376f8 100644 --- a/src/Exceptionless.Web/ClientApp/src/lib/features/product-tours/catalog.test.ts +++ b/src/Exceptionless.Web/ClientApp/src/lib/features/product-tours/catalog.test.ts @@ -16,6 +16,8 @@ function context(overrides: Partial = {}): ProductTourContex } describe('product tour catalog', () => { + const versions = Object.fromEntries(productTourCatalog.map((tour) => [tour.name, 1])); + it('contains only durable metadata for the five named tours', () => { expect(productTourCatalog.map((tour) => tour.name)).toEqual([ 'ui-overview', @@ -24,7 +26,7 @@ describe('product tour catalog', () => { 'investigate-error', 'meet-exie' ]); - expect(productTourCatalog.every((tour) => tour.version > 0 && tour.keywords.length > 0)).toBe(true); + expect(productTourCatalog.every((tour) => tour.keywords.length > 0)).toBe(true); expect(JSON.stringify(productTourCatalog)).not.toContain('data-tour'); }); @@ -39,9 +41,15 @@ describe('product tour catalog', () => { context({ assistantAccess: { enabled: false, has_access: false, upgrade_required: false }, errorEventAvailability: 'empty' - }) + }), + versions ); expect(items.find((item) => item.name === 'meet-exie')?.currentAvailability.available).toBe(false); expect(items.find((item) => item.name === 'investigate-error')?.currentAvailability.available).toBe(false); }); + + it('uses server versions as the availability boundary', () => { + const items = getProductTourItems(context(), {}); + expect(items.every((item) => !item.currentAvailability.available && item.version === 0)).toBe(true); + }); }); diff --git a/src/Exceptionless.Web/ClientApp/src/lib/features/product-tours/catalog.ts b/src/Exceptionless.Web/ClientApp/src/lib/features/product-tours/catalog.ts index 307807a260..8b26d1f198 100644 --- a/src/Exceptionless.Web/ClientApp/src/lib/features/product-tours/catalog.ts +++ b/src/Exceptionless.Web/ClientApp/src/lib/features/product-tours/catalog.ts @@ -30,8 +30,7 @@ export const productTourCatalog: readonly ProductTourDefinition[] = [ keywords: ['navigation', 'ui', 'search', 'command', 'help', 'saved views'], name: 'ui-overview', startingRoute: () => resolve('/'), - title: 'Explore Exceptionless', - version: 1 + title: 'Explore Exceptionless' }, { availability: () => ({ available: true }), @@ -40,8 +39,7 @@ export const productTourCatalog: readonly ProductTourDefinition[] = [ keywords: ['add project', 'configure', 'sdk', 'api key', 'first event'], name: 'configure-project', startingRoute: (context) => (context.organizationId ? resolve('/(app)/project/add') : resolve('/(app)/organization/add')), - title: 'Configure a project', - version: 1 + title: 'Configure a project' }, { availability: requireOrganization, @@ -50,8 +48,7 @@ export const productTourCatalog: readonly ProductTourDefinition[] = [ keywords: ['saved view', 'filter', 'columns', 'private', 'dashboard'], name: 'create-saved-view', startingRoute: () => resolve('/(app)/event'), - title: 'Create a saved view', - version: 1 + title: 'Create a saved view' }, { availability: requireError, @@ -60,8 +57,7 @@ export const productTourCatalog: readonly ProductTourDefinition[] = [ keywords: ['error report', 'event details', 'exception', 'filter', 'stack', 'triage'], name: 'investigate-error', startingRoute: () => `${resolve('/(app)/event')}?time=all&type=error`, - title: 'Investigate an error', - version: 1 + title: 'Investigate an error' }, { availability: (context) => @@ -71,21 +67,25 @@ export const productTourCatalog: readonly ProductTourDefinition[] = [ keywords: ['exie', 'assistant', 'ai', 'help', 'investigate'], name: 'meet-exie', startingRoute: () => resolve('/'), - title: 'Meet Exie', - version: 1 + title: 'Meet Exie' } ] as const; -export function getProductTour(name: ProductTourName): ProductTourDefinition { - return productTourCatalog.find((tour) => tour.name === name)!; -} - -export function getProductTourItems(context: ProductTourContext, progress: Record = {}): ProductTourListItem[] { - return productTourCatalog.map((definition) => ({ - ...definition, - currentAvailability: definition.availability(context), - progress: progress[definition.name] - })); +export function getProductTourItems( + context: ProductTourContext, + versions: Record, + progress: Record = {} +): ProductTourListItem[] { + return productTourCatalog.map((definition) => { + const version = versions[definition.name] ?? 0; + return { + ...definition, + currentAvailability: + version > 0 ? definition.availability(context) : { available: false, reason: 'This guided tour is not supported by the server.' }, + progress: progress[definition.name], + version + }; + }); } export function getRecommendedProductTourName(context: ProductTourContext): ProductTourName { diff --git a/src/Exceptionless.Web/ClientApp/src/lib/features/product-tours/components/create-saved-view-tour.svelte b/src/Exceptionless.Web/ClientApp/src/lib/features/product-tours/components/create-saved-view-tour.svelte new file mode 100644 index 0000000000..adc4c9d0a5 --- /dev/null +++ b/src/Exceptionless.Web/ClientApp/src/lib/features/product-tours/components/create-saved-view-tour.svelte @@ -0,0 +1,179 @@ + + +{#if checkpoint?.checkpointName === 'open-view-menu'} + { + openMenu(); + productTourCheckpoint.advance(active, 'review-settings'); + }} + target="[data-tour='saved-view-trigger']" + title="Open View settings" + /> +{:else if checkpoint?.checkpointName === 'review-settings'} + { + closeMenu(); + openSaveDialog(); + productTourCheckpoint.advance(active, 'name-view'); + }} + target="[data-tour='saved-view-settings']" + title="Configure what the view remembers" + /> +{:else if checkpoint?.checkpointName === 'name-view'} + { + productTourCheckpoint.advance(active, 'private-view'); + }} + target="[data-tour='saved-view-name']" + title="Name your view" + /> +{:else if checkpoint?.checkpointName === 'private-view'} + { + productTourCheckpoint.advance(active, 'save-view'); + }} + target="[data-tour='saved-view-private']" + title="Keep it private" + /> +{:else if checkpoint?.checkpointName === 'save-view'} + +{:else if checkpoint?.phase.type === 'saved-view-created' || checkpoint?.phase.type === 'saved-view-loaded'} + +{/if} diff --git a/src/Exceptionless.Web/ClientApp/src/lib/features/product-tours/components/product-tour-host.svelte b/src/Exceptionless.Web/ClientApp/src/lib/features/product-tours/components/product-tour-host.svelte index d0b49f5a71..735ee4de5b 100644 --- a/src/Exceptionless.Web/ClientApp/src/lib/features/product-tours/components/product-tour-host.svelte +++ b/src/Exceptionless.Web/ClientApp/src/lib/features/product-tours/components/product-tour-host.svelte @@ -37,8 +37,6 @@ stateSettled: boolean; } - const WELCOME_VERSION = 1; - const EXIE_ANNOUNCEMENT_VERSION = 1; const SYSTEM_PATH = resolve('/(app)/system'); let { @@ -74,9 +72,11 @@ pathname, projects }); - const items = $derived(getProductTourItems(context, currentUser?.product_tours)); + const items = $derived(getProductTourItems(context, currentUser?.product_tour_versions ?? {}, currentUser?.product_tours)); const recommended = $derived(items.find((item) => item.name === getRecommendedProductTourName(context)) ?? items[0]!); const checkpoint = $derived(productTourCheckpoint.current); + const welcomeVersion = $derived(currentUser?.product_tour_versions.welcome ?? 0); + const exieAnnouncementVersion = $derived(currentUser?.product_tour_versions['exie-announcement'] ?? 0); const welcomeOpen = $derived( !!( stateSettled && @@ -87,7 +87,8 @@ !isImpersonating && !isSetupPage && !pathname.startsWith(SYSTEM_PATH) && - shouldOfferProductTourWelcome(currentUser.product_tours?.welcome, WELCOME_VERSION) + welcomeVersion > 0 && + shouldOfferProductTourWelcome(currentUser.product_tours?.welcome, welcomeVersion) ) ); const exieAnnouncementOpen = $derived( @@ -102,8 +103,9 @@ !welcomeOpen && !catalogOpen && !isAnyOverlayOpen && - !shouldOfferProductTourWelcome(currentUser.product_tours?.welcome, WELCOME_VERSION) && - shouldOfferProductTourAnnouncement(currentUser.product_tours?.['exie-announcement'], EXIE_ANNOUNCEMENT_VERSION) + !shouldOfferProductTourWelcome(currentUser.product_tours?.welcome, welcomeVersion) && + exieAnnouncementVersion > 0 && + shouldOfferProductTourAnnouncement(currentUser.product_tours?.['exie-announcement'], exieAnnouncementVersion) ) ); @@ -128,10 +130,10 @@ return; } - const impression = `${currentUser.id}:${WELCOME_VERSION}`; + const impression = `${currentUser.id}:${welcomeVersion}`; if (welcomeOpen && lastTrackedWelcomeImpression !== impression) { lastTrackedWelcomeImpression = impression; - void track('shown', 'welcome', WELCOME_VERSION, 'automatic'); + void track('shown', 'welcome', welcomeVersion, 'automatic'); } }); @@ -140,10 +142,10 @@ return; } - const impression = `${currentUser.id}:${EXIE_ANNOUNCEMENT_VERSION}`; + const impression = `${currentUser.id}:${exieAnnouncementVersion}`; if (exieAnnouncementOpen && lastTrackedAnnouncementImpression !== impression) { lastTrackedAnnouncementImpression = impression; - void track('shown', 'exie-announcement', EXIE_ANNOUNCEMENT_VERSION, 'feature-announcement'); + void track('shown', 'exie-announcement', exieAnnouncementVersion, 'feature-announcement'); } }); @@ -154,7 +156,7 @@ catalogOpen = true; } - export async function startTour(name: ProductTourName, source: ProductTourLaunchSource = 'catalog'): Promise { + export async function startTour(name: Name, source: ProductTourLaunchSource = 'catalog'): Promise { if (!currentUser) { return; } @@ -171,16 +173,7 @@ closeOverlays(); catalogOpen = false; - const next = productTourCheckpoint.start({ - checkpointName: item.initialCheckpoint, - organizationId, - phase: { - type: 'active' - }, - source, - tourName: name, - userId: currentUser.id - }); + const next = productTourCheckpoint.start(name, item.initialCheckpoint, source, currentUser.id, item.version, organizationId); await Promise.all([track('shown', name, item.version, source), track('started', name, item.version, source)]); const destination = item.startingRoute(context); @@ -210,48 +203,48 @@ } async function onWelcomeStart(): Promise { - if (!(await recordPreference('welcome', WELCOME_VERSION, ProductTourStatus.Completed))) { + if (!(await recordPreference('welcome', welcomeVersion, ProductTourStatus.Completed))) { return; } welcomeHandled = true; - await track('completed', 'welcome', WELCOME_VERSION, 'automatic'); + await track('completed', 'welcome', welcomeVersion, 'automatic'); await startTour(recommended.name, 'automatic'); } async function onWelcomeBrowse(): Promise { - if (!(await recordPreference('welcome', WELCOME_VERSION, ProductTourStatus.Completed))) { + if (!(await recordPreference('welcome', welcomeVersion, ProductTourStatus.Completed))) { return; } welcomeHandled = true; - await track('completed', 'welcome', WELCOME_VERSION, 'automatic'); + await track('completed', 'welcome', welcomeVersion, 'automatic'); openCatalog('catalog'); } async function onWelcomeSkip(): Promise { - if (!(await recordPreference('welcome', WELCOME_VERSION, ProductTourStatus.Dismissed))) { + if (!(await recordPreference('welcome', welcomeVersion, ProductTourStatus.Dismissed))) { return; } welcomeHandled = true; - await track('dismissed', 'welcome', WELCOME_VERSION, 'automatic'); + await track('dismissed', 'welcome', welcomeVersion, 'automatic'); } async function onExieAnnouncementStart(): Promise { - if (!(await recordPreference('exie-announcement', EXIE_ANNOUNCEMENT_VERSION, ProductTourStatus.Completed))) { + if (!(await recordPreference('exie-announcement', exieAnnouncementVersion, ProductTourStatus.Completed))) { return; } - await track('completed', 'exie-announcement', EXIE_ANNOUNCEMENT_VERSION, 'feature-announcement'); + await track('completed', 'exie-announcement', exieAnnouncementVersion, 'feature-announcement'); await startTour('meet-exie', 'feature-announcement'); } async function onExieAnnouncementDismiss(): Promise { - if (!(await recordPreference('exie-announcement', EXIE_ANNOUNCEMENT_VERSION, ProductTourStatus.Dismissed))) { + if (!(await recordPreference('exie-announcement', exieAnnouncementVersion, ProductTourStatus.Dismissed))) { return; } - await track('dismissed', 'exie-announcement', EXIE_ANNOUNCEMENT_VERSION, 'feature-announcement'); + await track('dismissed', 'exie-announcement', exieAnnouncementVersion, 'feature-announcement'); } - function getItem(name: ProductTourName): ProductTourListItem { - return items.find((item) => item.name === name)!; + function getItem(name: Name): ProductTourListItem { + return items.find((item) => item.name === name)! as ProductTourListItem; } diff --git a/src/Exceptionless.Web/ClientApp/src/lib/features/product-tours/components/product-tour-spotlight.svelte b/src/Exceptionless.Web/ClientApp/src/lib/features/product-tours/components/product-tour-spotlight.svelte index 2f80f74774..a00e3de4e7 100644 --- a/src/Exceptionless.Web/ClientApp/src/lib/features/product-tours/components/product-tour-spotlight.svelte +++ b/src/Exceptionless.Web/ClientApp/src/lib/features/product-tours/components/product-tour-spotlight.svelte @@ -8,6 +8,7 @@ interface Props { checkpoint: ProductTourCheckpoint; + continueLabel?: string; description: string; onDismiss: (checkpoint: ProductTourCheckpoint) => Promise; onNext?: (checkpoint: ProductTourCheckpoint) => Promise | void; @@ -16,7 +17,7 @@ title: string; } - let { checkpoint, description, onDismiss, onNext, side, target, title }: Props = $props(); + let { checkpoint, continueLabel = 'Continue', description, onDismiss, onNext, side, target, title }: Props = $props(); let activeDriver: Driver | undefined; onMount(() => { @@ -38,7 +39,7 @@ element: target, popover: { description, - doneBtnText: 'Continue', + doneBtnText: continueLabel, onNextClick: onNext ? async () => { await onNext(checkpoint); diff --git a/src/Exceptionless.Web/ClientApp/src/lib/features/product-tours/session.svelte.test.ts b/src/Exceptionless.Web/ClientApp/src/lib/features/product-tours/session.svelte.test.ts index a4a104d26b..a14662905a 100644 --- a/src/Exceptionless.Web/ClientApp/src/lib/features/product-tours/session.svelte.test.ts +++ b/src/Exceptionless.Web/ClientApp/src/lib/features/product-tours/session.svelte.test.ts @@ -10,7 +10,8 @@ const checkpoint: ProductTourCheckpoint = { phase: { type: 'active' }, source: 'command-palette', tourName: 'investigate-error', - userId: 'user-id' + userId: 'user-id', + version: 1 }; describe('product tour session', () => { @@ -30,6 +31,7 @@ describe('product tour session', () => { JSON.stringify({ ...checkpoint, tourName: 'unknown-tour' }), JSON.stringify({ ...checkpoint, checkpointName: 'unknown-step' }), JSON.stringify({ ...checkpoint, source: 'unknown-source' }), + JSON.stringify({ ...checkpoint, version: 0 }), JSON.stringify({ ...checkpoint, phase: { type: 'saved-view-created' } }), JSON.stringify({ ...checkpoint, phase: { type: 'saved-view-created', viewId: 'view-id' } }), JSON.stringify({ ...checkpoint, phase: { type: 'saved-view-loaded', viewId: 'view-id' } }), diff --git a/src/Exceptionless.Web/ClientApp/src/lib/features/product-tours/session.ts b/src/Exceptionless.Web/ClientApp/src/lib/features/product-tours/session.ts index 3baa4db065..0b52603cc7 100644 --- a/src/Exceptionless.Web/ClientApp/src/lib/features/product-tours/session.ts +++ b/src/Exceptionless.Web/ClientApp/src/lib/features/product-tours/session.ts @@ -1,9 +1,11 @@ +import { ProductTourLaunchSource as ProductTourLaunchSourceContract } from '$generated/api'; + import type { ProductTourCheckpoint, ProductTourLaunchSource, ProductTourName, ProductTourPhase } from './types'; import { PRODUCT_TOUR_CHECKPOINTS } from './types'; const SESSION_KEY = 'exceptionless.product-tour'; -const SOURCES: readonly ProductTourLaunchSource[] = ['automatic', 'catalog', 'command-palette', 'feature-announcement', 'help-menu']; +const SOURCES = new Set(Object.values(ProductTourLaunchSourceContract)); export function clearProductTourSession(storage: Pick = sessionStorage): void { storage.removeItem(SESSION_KEY); @@ -44,7 +46,16 @@ function isPhase(value: unknown, tourName: string, checkpointName: unknown): val } function isProductTourCheckpoint(value: unknown): value is ProductTourCheckpoint { - if (!isRecord(value) || typeof value.userId !== 'string' || !value.userId || typeof value.tourName !== 'string') return false; + if ( + !isRecord(value) || + typeof value.userId !== 'string' || + !value.userId || + typeof value.tourName !== 'string' || + typeof value.version !== 'number' || + !Number.isSafeInteger(value.version) || + value.version < 1 + ) + return false; if (value.organizationId !== undefined && typeof value.organizationId !== 'string') return false; if (!isProductTourLaunchSource(value.source) || !isProductTourName(value.tourName)) return false; @@ -54,7 +65,7 @@ function isProductTourCheckpoint(value: unknown): value is ProductTourCheckpoint } function isProductTourLaunchSource(value: unknown): value is ProductTourLaunchSource { - return typeof value === 'string' && (SOURCES as readonly string[]).includes(value); + return typeof value === 'string' && SOURCES.has(value); } function isProductTourName(value: string): value is ProductTourName { diff --git a/src/Exceptionless.Web/ClientApp/src/lib/features/product-tours/state.svelte.test.ts b/src/Exceptionless.Web/ClientApp/src/lib/features/product-tours/state.svelte.test.ts index a4abfb8833..d1d4d2f6f7 100644 --- a/src/Exceptionless.Web/ClientApp/src/lib/features/product-tours/state.svelte.test.ts +++ b/src/Exceptionless.Web/ClientApp/src/lib/features/product-tours/state.svelte.test.ts @@ -10,15 +10,30 @@ const checkpoint: ProductTourCheckpoint = { phase: { type: 'active' }, source: 'catalog', tourName: 'ui-overview', - userId: 'user-id' + userId: 'user-id', + version: 1 }; describe('product tour checkpoint store', () => { beforeEach(() => productTourCheckpoint.clear()); it('does not let stale work advance or clear a newer tour', () => { - const first = productTourCheckpoint.start(checkpoint); - const second = productTourCheckpoint.start({ ...checkpoint, source: 'help-menu' }); + const first = productTourCheckpoint.start( + checkpoint.tourName, + checkpoint.checkpointName, + checkpoint.source, + checkpoint.userId, + checkpoint.version, + checkpoint.organizationId + ); + const second = productTourCheckpoint.start( + checkpoint.tourName, + checkpoint.checkpointName, + 'help-menu', + checkpoint.userId, + checkpoint.version, + checkpoint.organizationId + ); expect(productTourCheckpoint.advance(first, 'command-search')).toBeUndefined(); expect(productTourCheckpoint.clear(first)).toBe(false); @@ -26,7 +41,14 @@ describe('product tour checkpoint store', () => { }); it('clears a checkpoint restored for another identity', () => { - productTourCheckpoint.start(checkpoint); + productTourCheckpoint.start( + checkpoint.tourName, + checkpoint.checkpointName, + checkpoint.source, + checkpoint.userId, + checkpoint.version, + checkpoint.organizationId + ); productTourCheckpoint.clear(); sessionStorage.setItem('exceptionless.product-tour', JSON.stringify(checkpoint)); diff --git a/src/Exceptionless.Web/ClientApp/src/lib/features/product-tours/state.svelte.ts b/src/Exceptionless.Web/ClientApp/src/lib/features/product-tours/state.svelte.ts index 5f67e71c8d..7ba2fcb1d4 100644 --- a/src/Exceptionless.Web/ClientApp/src/lib/features/product-tours/state.svelte.ts +++ b/src/Exceptionless.Web/ClientApp/src/lib/features/product-tours/state.svelte.ts @@ -1,27 +1,28 @@ -import type { ProductTourCheckpoint, ProductTourCheckpointName, ProductTourPhase } from './types'; +import type { ProductTourCheckpoint, ProductTourCheckpointName, ProductTourLaunchSource, ProductTourName, ProductTourPhase } from './types'; import { clearProductTourSession, readProductTourSession, writeProductTourSession } from './session'; class ProductTourCheckpointStore { current = $state.raw(); - advance( - expected: ProductTourCheckpoint, - checkpointName: ProductTourCheckpointName, - phase: ProductTourPhase = { + advance( + expected: ProductTourCheckpoint, + checkpointName: ProductTourCheckpointName, + phase: ProductTourPhase = { type: 'active' }, organizationId = expected.organizationId - ) { + ): ProductTourCheckpoint | undefined { if (this.current !== expected) { return undefined; } - return this.save({ + const next = { ...expected, checkpointName, organizationId, phase - }); + } as ProductTourCheckpoint; + return this.save(next); } clear(expected?: ProductTourCheckpoint): boolean { @@ -52,11 +53,29 @@ class ProductTourCheckpointStore { return stored; } - start(checkpoint: ProductTourCheckpoint): ProductTourCheckpoint { + start( + tourName: Name, + checkpointName: ProductTourCheckpointName, + source: ProductTourLaunchSource, + userId: string, + version: number, + organizationId?: string + ): ProductTourCheckpoint { + const checkpoint = { + checkpointName, + organizationId, + phase: { + type: 'active' + }, + source, + tourName, + userId, + version + } as ProductTourCheckpoint; return this.save(checkpoint); } - private save(checkpoint: ProductTourCheckpoint): ProductTourCheckpoint { + private save(checkpoint: ProductTourCheckpoint): ProductTourCheckpoint { this.current = checkpoint; writeProductTourSession(checkpoint); return checkpoint; diff --git a/src/Exceptionless.Web/ClientApp/src/lib/features/product-tours/telemetry.ts b/src/Exceptionless.Web/ClientApp/src/lib/features/product-tours/telemetry.ts index 0c8ecc8429..8eeb6df23d 100644 --- a/src/Exceptionless.Web/ClientApp/src/lib/features/product-tours/telemetry.ts +++ b/src/Exceptionless.Web/ClientApp/src/lib/features/product-tours/telemetry.ts @@ -1,6 +1,8 @@ +import type { ProductTourTelemetryEvent as ProductTourTelemetryEventContract } from '$generated/api'; + import type { ProductTourKey, ProductTourLaunchSource } from './types'; -export type ProductTourTelemetryEvent = 'completed' | 'dismissed' | 'shown' | 'started'; +export type ProductTourTelemetryEvent = `${ProductTourTelemetryEventContract}`; export function buildProductTourTelemetryEvent( event: ProductTourTelemetryEvent, diff --git a/src/Exceptionless.Web/ClientApp/src/lib/features/product-tours/types.ts b/src/Exceptionless.Web/ClientApp/src/lib/features/product-tours/types.ts index 7d438257a7..87d794902b 100644 --- a/src/Exceptionless.Web/ClientApp/src/lib/features/product-tours/types.ts +++ b/src/Exceptionless.Web/ClientApp/src/lib/features/product-tours/types.ts @@ -1,6 +1,7 @@ import type { AssistantAccess } from '$features/assistant/models'; import type { ViewProject } from '$features/projects/models'; import type { ProductTourProgress } from '$features/users/models'; +import type { ProductTourLaunchSource as ProductTourLaunchSourceContract } from '$generated/api'; export const PRODUCT_TOUR_CHECKPOINTS = { 'configure-project': ['organization-name', 'project-name', 'choose-platform', 'sdk-instructions', 'wait-for-event'], @@ -14,15 +15,18 @@ export interface ProductTourAvailability { available: boolean; reason?: string; } -export interface ProductTourCheckpoint { - checkpointName: ProductTourCheckpointName; - organizationId?: string; - phase: ProductTourPhase; - source: ProductTourLaunchSource; - tourName: ProductTourName; - userId: string; -} -export type ProductTourCheckpointName = (typeof PRODUCT_TOUR_CHECKPOINTS)[ProductTourName][number]; +export type ProductTourCheckpoint = Name extends ProductTourName + ? { + checkpointName: ProductTourCheckpointName; + organizationId?: string; + phase: ProductTourPhase; + source: ProductTourLaunchSource; + tourName: Name; + userId: string; + version: number; + } + : never; +export type ProductTourCheckpointName = (typeof PRODUCT_TOUR_CHECKPOINTS)[Name][number]; export interface ProductTourContext { assistantAccess?: AssistantAccess; errorEventAvailability: 'available' | 'empty' | 'error' | 'loading'; @@ -31,26 +35,27 @@ export interface ProductTourContext { pathname: string; projects: Pick[]; } -export interface ProductTourDefinition { +export interface ProductTourDefinition { availability: (context: ProductTourContext) => ProductTourAvailability; description: string; - initialCheckpoint: ProductTourCheckpointName; + initialCheckpoint: ProductTourCheckpointName; keywords: readonly string[]; - name: ProductTourName; + name: Name; startingRoute: (context: ProductTourContext) => string; title: string; - version: number; } export type ProductTourKey = 'exie-announcement' | 'welcome' | ProductTourName; -export type ProductTourLaunchSource = 'automatic' | 'catalog' | 'command-palette' | 'feature-announcement' | 'help-menu'; +export type ProductTourLaunchSource = `${ProductTourLaunchSourceContract}`; -export interface ProductTourListItem extends ProductTourDefinition { +export interface ProductTourListItem extends ProductTourDefinition { currentAvailability: ProductTourAvailability; progress?: ProductTourProgress; + version: number; } export type ProductTourName = keyof typeof PRODUCT_TOUR_CHECKPOINTS; -export type ProductTourPhase = { type: 'active' } | { type: 'saved-view-created' | 'saved-view-loaded'; viewId: string }; +export type ProductTourPhase = + (Name extends 'create-saved-view' ? { type: 'saved-view-created' | 'saved-view-loaded'; viewId: string } : never) | { type: 'active' }; diff --git a/src/Exceptionless.Web/ClientApp/src/lib/features/saved-views/components/save-view-dialog.svelte b/src/Exceptionless.Web/ClientApp/src/lib/features/saved-views/components/save-view-dialog.svelte index 645228ecd3..4c5d0cb627 100644 --- a/src/Exceptionless.Web/ClientApp/src/lib/features/saved-views/components/save-view-dialog.svelte +++ b/src/Exceptionless.Web/ClientApp/src/lib/features/saved-views/components/save-view-dialog.svelte @@ -1,13 +1,10 @@ { - if (nextOpen || !saving) { - open = nextOpen; - if (!nextOpen) { - onCancel?.(); - } + if (!nextOpen) { + onClose(); } }} > - saving && event.preventDefault()} - onInteractOutside={(event) => saving && event.preventDefault()} - > + Save View Save the current view configuration for quick access. - {#if defaultPrivate && tourCheckpointName === 'name-view'} - onTourContinue?.('private-view')} - onDismiss={dismissTour} - title="Review and name your view" - tourName="create-saved-view" - /> - {:else if defaultPrivate && tourCheckpointName === 'private-view'} - onTourContinue?.('save-view')} - onDismiss={dismissTour} - title="Keep it private" - tourName="create-saved-view" - /> - {:else if defaultPrivate && (tourCheckpointName === 'save-view' || tourCheckpointName === 'view-created')} - - {/if} - {#if duplicateView && !pendingCompletion} + {#if duplicateView}
Current filters match "{duplicateView.name}". You can instead, or save with a different name. @@ -222,7 +163,6 @@ aria-describedby={visibleNameError ? 'view-name-error' : undefined} required autofocus - disabled={pendingCompletion} /> {#if visibleNameError}

{visibleNameError}

@@ -238,7 +178,6 @@ aria-invalid={!!visibleSlugError} aria-describedby={visibleSlugError ? 'view-slug-error' : undefined} required - disabled={pendingCompletion} oninput={() => { isSlugDirty = true; }} @@ -250,14 +189,14 @@
- {defaultPrivate ? 'Required for this guided practice view' : 'Only visible to you'} + Only visible to you
- +
- + diff --git a/src/Exceptionless.Web/ClientApp/src/lib/features/saved-views/components/saved-view-picker.svelte b/src/Exceptionless.Web/ClientApp/src/lib/features/saved-views/components/saved-view-picker.svelte index 81fbdd3dcf..74ad57afcf 100644 --- a/src/Exceptionless.Web/ClientApp/src/lib/features/saved-views/components/saved-view-picker.svelte +++ b/src/Exceptionless.Web/ClientApp/src/lib/features/saved-views/components/saved-view-picker.svelte @@ -15,9 +15,7 @@ import { serializeFilters } from '$features/events/components/filters/helpers.svelte'; import { getOrganizationQuery, getOrganizationsQuery } from '$features/organizations/api.svelte'; import { organization } from '$features/organizations/context.svelte'; - import { createProductTourActions } from '$features/product-tours/actions.svelte'; - import ProductTourSpotlight from '$features/product-tours/components/product-tour-spotlight.svelte'; - import { productTourCheckpoint } from '$features/product-tours/state.svelte'; + import CreateSavedViewTour from '$features/product-tours/components/create-saved-view-tour.svelte'; import { supportsColumnWrapping } from '$features/shared/components/data-table/column-meta'; import { getMeQuery } from '$features/users/api.svelte'; import Building2 from '@lucide/svelte/icons/building-2'; @@ -117,23 +115,13 @@ wrappedColumnIds }: Props = $props(); - let isSaveDialogOpenManually = $state(false); + let isSaveDialogOpen = $state(false); let isRenameDialogOpen = $state(false); let isDeleteDialogOpen = $state(false); let isColumnDialogOpen = $state(false); let isMenuOpen = $state(false); let viewToDelete = $state(null); - const tourActions = createProductTourActions(); - const savedViewCheckpoint = $derived(productTourCheckpoint.current?.tourName === 'create-saved-view' ? productTourCheckpoint.current : undefined); - const isSaveDialogOpen = $derived( - isSaveDialogOpenManually || savedViewCheckpoint?.phase.type === 'saved-view-created' || savedViewCheckpoint?.phase.type === 'saved-view-loaded' - ); - const pendingTourView = $derived.by(() => { - const phase = savedViewCheckpoint?.phase; - return phase?.type === 'saved-view-created' || phase?.type === 'saved-view-loaded' - ? savedViews.find((savedView) => savedView.id === phase.viewId) - : undefined; - }); + let createSavedViewTour = $state(); const organizationId = $derived(organization.current); const activeView = $derived(activeSavedView); @@ -232,7 +220,7 @@ async function openSaveDialog() { await tick(); - isSaveDialogOpenManually = true; + isSaveDialogOpen = true; } async function openRenameDialog() { @@ -273,32 +261,8 @@ return; } - const checkpoint = savedViewCheckpoint; - if (checkpoint?.phase.type === 'saved-view-loaded') { - if (await tourActions.complete(checkpoint)) { - isSaveDialogOpenManually = false; - } - return; - } - - if (checkpoint?.phase.type === 'saved-view-created') { - if (!pendingTourView) { - toast.error('The created view could not be loaded. Refresh and try again.'); - return; - } - - try { - await onLoadView(pendingTourView); - const loadedCheckpoint = productTourCheckpoint.advance(checkpoint, 'view-created', { - type: 'saved-view-loaded', - viewId: checkpoint.phase.viewId - }); - if (loadedCheckpoint && (await tourActions.complete(loadedCheckpoint))) { - isSaveDialogOpenManually = false; - } - } catch (error) { - toast.error(getErrorMessage(error, 'Failed to load the created view. Please try again.')); - } + const tour = createSavedViewTour; + if (tour && !tour.validateSave(isPrivate)) { return; } @@ -320,25 +284,9 @@ try { const result = await createMutation.mutateAsync(body); - if (checkpoint) { - const createdCheckpoint = productTourCheckpoint.advance(checkpoint, 'view-created', { - type: 'saved-view-created', - viewId: result.id - }); - await onLoadView(result); - const loadedCheckpoint = createdCheckpoint - ? productTourCheckpoint.advance(createdCheckpoint, 'view-created', { - type: 'saved-view-loaded', - viewId: result.id - }) - : undefined; - if (loadedCheckpoint && (await tourActions.complete(loadedCheckpoint))) { - isSaveDialogOpenManually = false; - } - } else { - isSaveDialogOpenManually = false; - await onLoadView(result); - } + const tourCompletion = tour ? tour.created(result) : onLoadView(result); + isSaveDialogOpen = false; + await tourCompletion; toast.success(`Saved view "${result.name}" created.`); } catch (error) { toast.error(getErrorMessage(error, 'Failed to save view. Please try again.')); @@ -553,56 +501,25 @@ {#if isSaveDialogOpen} { - if (savedViewCheckpoint) { - await tourActions.dismiss(savedViewCheckpoint); - } - }} {savedViews} {saving} onSave={handleSave} - onClose={() => (isSaveDialogOpenManually = false)} - onTourContinue={(checkpointName) => { - const checkpoint = savedViewCheckpoint; - if (checkpoint) { - productTourCheckpoint.advance(checkpoint, checkpointName); - } - }} - pendingCompletion={savedViewCheckpoint?.phase.type === 'saved-view-created' || savedViewCheckpoint?.phase.type === 'saved-view-loaded'} - tourCheckpointName={savedViewCheckpoint?.checkpointName} + onClose={() => createSavedViewTour?.closed()} {onLoadView} /> {/if} -{#if savedViewCheckpoint?.checkpointName === 'open-view-menu'} - { - isMenuOpen = true; - productTourCheckpoint.advance(checkpoint, 'review-settings'); - }} - target="[data-tour='saved-view-trigger']" - title="Open View settings" - /> -{:else if savedViewCheckpoint?.checkpointName === 'review-settings'} - { - isMenuOpen = false; - isSaveDialogOpenManually = true; - productTourCheckpoint.advance(checkpoint, 'name-view'); - }} - target="[data-tour='saved-view-settings']" - title="Configure what the view remembers" - /> -{/if} + (isMenuOpen = false)} + openMenu={() => (isMenuOpen = true)} + openSaveDialog={() => (isSaveDialogOpen = true)} + {onLoadView} + {savedViews} +/> {#if isRenameDialogOpen && activeView} ; + product_tour_versions: Record; /** @pattern ^[a-fA-F0-9]{24}$ */ id: string; organization_ids: string[]; diff --git a/src/Exceptionless.Web/ClientApp/src/lib/generated/schemas.ts b/src/Exceptionless.Web/ClientApp/src/lib/generated/schemas.ts index 0eb7c6d692..45ac028abe 100644 --- a/src/Exceptionless.Web/ClientApp/src/lib/generated/schemas.ts +++ b/src/Exceptionless.Web/ClientApp/src/lib/generated/schemas.ts @@ -27,7 +27,20 @@ export const StackStatusSchema = zodEnum([ "ignored", "discarded", ]); +export const ProductTourTelemetryEventSchema = zodEnum([ + "completed", + "dismissed", + "shown", + "started", +]); export const ProductTourStatusSchema = zodEnum(["completed", "dismissed"]); +export const ProductTourLaunchSourceSchema = zodEnum([ + "automatic", + "catalog", + "command-palette", + "feature-announcement", + "help-menu", +]); export const BillingStatusSchema = union([ literal(0), literal(1), @@ -78,8 +91,8 @@ export type AdminAssistantUsageResponseFormData = Infer< export const AdminProductTourActivitySchema = object({ date_utc: iso.datetime(), - event: string().min(1, "Event is required"), - launch_source: string().min(1, "Launch source is required"), + event: ProductTourTelemetryEventSchema, + launch_source: ProductTourLaunchSourceSchema, tour_name: string().min(1, "Tour name is required"), user_identity: string().min(1, "User identity is required").nullable(), user_name: string().min(1, "User name is required").nullable(), @@ -985,6 +998,7 @@ export const ViewCurrentUserSchema = object({ string(), lazy(() => ProductTourProgressSchema), ), + product_tour_versions: record(string(), number()).optional(), id: string() .length(24, "Id must be exactly 24 characters") .regex(/^[a-fA-F0-9]{24}$/, "Id has invalid format"), diff --git a/src/Exceptionless.Web/ClientApp/src/routes/(app)/+layout.svelte b/src/Exceptionless.Web/ClientApp/src/routes/(app)/+layout.svelte index df33ceb996..5e77ef3470 100644 --- a/src/Exceptionless.Web/ClientApp/src/routes/(app)/+layout.svelte +++ b/src/Exceptionless.Web/ClientApp/src/routes/(app)/+layout.svelte @@ -740,6 +740,7 @@ pathname: page.url.pathname, projects }, + meQuery.data?.product_tour_versions ?? {}, meQuery.data?.product_tours ) : [] diff --git a/src/Exceptionless.Web/Models/Admin/AdminProductTourUsageResponse.cs b/src/Exceptionless.Web/Models/Admin/AdminProductTourUsageResponse.cs index 04b1b616f8..475360e1ed 100644 --- a/src/Exceptionless.Web/Models/Admin/AdminProductTourUsageResponse.cs +++ b/src/Exceptionless.Web/Models/Admin/AdminProductTourUsageResponse.cs @@ -1,3 +1,5 @@ +using Exceptionless.Core.Models.Data; + namespace Exceptionless.Web.Models.Admin; public sealed record AdminProductTourUsageResponse( @@ -18,8 +20,8 @@ public sealed record AdminProductTourSummary( public sealed record AdminProductTourActivity( DateTime DateUtc, - string Event, - string LaunchSource, + ProductTourTelemetryEvent Event, + ProductTourLaunchSource LaunchSource, string TourName, string? UserIdentity, string? UserName, diff --git a/src/Exceptionless.Web/Models/User/ViewCurrentUser.cs b/src/Exceptionless.Web/Models/User/ViewCurrentUser.cs index 831150634f..af109dfdae 100644 --- a/src/Exceptionless.Web/Models/User/ViewCurrentUser.cs +++ b/src/Exceptionless.Web/Models/User/ViewCurrentUser.cs @@ -34,6 +34,7 @@ public ViewCurrentUser(User user, IntercomOptions options) public ICollection OrganizationPreferences { get; set; } public ICollection SavedViewOrders { get; set; } public IDictionary ProductTours { get; set; } = new Dictionary(StringComparer.Ordinal); + public IReadOnlyDictionary ProductTourVersions { get; } = Exceptionless.Core.Models.Data.ProductTours.Versions; private static string? HMACSHA256HashString(string value, IntercomOptions options) { diff --git a/tests/Exceptionless.Tests/Api/Data/openapi.json b/tests/Exceptionless.Tests/Api/Data/openapi.json index 0859b7dc1e..1b9e665694 100644 --- a/tests/Exceptionless.Tests/Api/Data/openapi.json +++ b/tests/Exceptionless.Tests/Api/Data/openapi.json @@ -11843,10 +11843,10 @@ "format": "date-time" }, "event": { - "type": "string" + "$ref": "#/components/schemas/ProductTourTelemetryEvent" }, "launch_source": { - "type": "string" + "$ref": "#/components/schemas/ProductTourLaunchSource" }, "tour_name": { "type": "string" @@ -13491,6 +13491,22 @@ } } }, + "ProductTourLaunchSource": { + "enum": [ + "automatic", + "catalog", + "command-palette", + "feature-announcement", + "help-menu" + ], + "x-enumNames": [ + "Automatic", + "Catalog", + "CommandPalette", + "FeatureAnnouncement", + "HelpMenu" + ] + }, "ProductTourProgress": { "required": [ "status", @@ -13522,6 +13538,20 @@ "Dismissed" ] }, + "ProductTourTelemetryEvent": { + "enum": [ + "completed", + "dismissed", + "shown", + "started" + ], + "x-enumNames": [ + "Completed", + "Dismissed", + "Shown", + "Started" + ] + }, "ResetPasswordModel": { "required": [ "password_reset_token", @@ -14433,6 +14463,14 @@ "$ref": "#/components/schemas/ProductTourProgress" } }, + "product_tour_versions": { + "type": "object", + "additionalProperties": { + "type": "integer", + "format": "int32" + }, + "readOnly": true + }, "id": { "maxLength": 24, "minLength": 24, @@ -15279,4 +15317,4 @@ "name": "Source Map" } ] -} +} \ No newline at end of file diff --git a/tests/Exceptionless.Tests/Api/Endpoints/ProductTourEndpointTests.cs b/tests/Exceptionless.Tests/Api/Endpoints/ProductTourEndpointTests.cs index e68ddc0fb9..4b579de86a 100644 --- a/tests/Exceptionless.Tests/Api/Endpoints/ProductTourEndpointTests.cs +++ b/tests/Exceptionless.Tests/Api/Endpoints/ProductTourEndpointTests.cs @@ -1,3 +1,4 @@ +using System.Text.Json; using Exceptionless.Core.Models; using Exceptionless.Core.Models.Data; using Exceptionless.Core.Repositories; @@ -45,6 +46,20 @@ public async Task UpdateCurrentUserProductTourAsync_NewProgress_PersistsAndRetur Assert.Equal(progress, persistedUser.ProductTours["ui-overview"]); } + [Fact] + public async Task GetCurrentUserAsync_ReturnsAuthoritativeProductTourVersions() + { + var currentUser = await SendRequestAsAsync(request => request + .AsTestOrganizationUser() + .AppendPaths("users", "me") + .StatusCodeShouldBeOk()); + + var versions = currentUser.GetProperty("product_tour_versions") + .Deserialize>(); + + Assert.Equal(ProductTours.Versions, versions); + } + [Fact] public async Task UpdateCurrentUserProductTourAsync_OlderProgress_PreservesStoredValue() { diff --git a/tests/Exceptionless.Tests/Repositories/EventRepositoryTests.cs b/tests/Exceptionless.Tests/Repositories/EventRepositoryTests.cs index dd037d56b7..196877c9d5 100644 --- a/tests/Exceptionless.Tests/Repositories/EventRepositoryTests.cs +++ b/tests/Exceptionless.Tests/Repositories/EventRepositoryTests.cs @@ -64,14 +64,14 @@ await CreateDataAsync(builder => .UserIdentity("user-7"); }); - var result = await _repository.GetProductTourUsageAsync(_appOptions.InternalProjectId, month, month.AddMonths(1)); + var result = await _repository.GetProductTourUsageAsync(_appOptions.InternalProjectId, month, month.AddMonths(1), recentLimit: 3); - Assert.Equal(5, result.RecentEvents.Count); + Assert.Equal(3, result.RecentEvents.Count); Assert.Equal(2, result.Tours.Count); var overview = Assert.Single(result.Tours, tour => String.Equals(tour.Name, ProductTours.UiOverview, StringComparison.Ordinal)); Assert.Equal(3, overview.UniqueUsers); - Assert.Equal(4, overview.Buckets.Where(bucket => String.Equals(bucket.Source.Event, "started", StringComparison.Ordinal)).Sum(bucket => bucket.Count)); - Assert.Equal(1, overview.Buckets.Where(bucket => String.Equals(bucket.Source.Event, "completed", StringComparison.Ordinal)).Sum(bucket => bucket.Count)); + Assert.Equal(4, overview.Buckets.Where(bucket => bucket.Source.Event == ProductTourTelemetryEvent.Started).Sum(bucket => bucket.Count)); + Assert.Equal(1, overview.Buckets.Where(bucket => bucket.Source.Event == ProductTourTelemetryEvent.Completed).Sum(bucket => bucket.Count)); Assert.Equal(month.AddDays(8), overview.Buckets.Max(bucket => bucket.LastUtc)); var welcome = Assert.Single(result.Tours, tour => String.Equals(tour.Name, ProductTours.Welcome, StringComparison.Ordinal)); From 160a65b089bfc9df5abda68bfe421b720c35d374 Mon Sep 17 00:00:00 2001 From: Blake Niemyjski Date: Sat, 29 Aug 2026 10:23:28 -0500 Subject: [PATCH 06/43] Minimize guided tour integration surface --- .../components/product-tour-host.svelte | 4 +- .../src/routes/(app)/event/+page.svelte | 75 ++++- .../src/routes/(app)/event/query-filters.ts | 70 ----- .../[projectId]/configure/+page.svelte | 5 +- .../(app)/system/product-tours/+page.svelte | 6 +- .../Api/Endpoints/OAuthGrantEndpointTests.cs | 280 ------------------ .../Api/Endpoints/UserEndpointTests.cs | 251 ++++++++++++++++ 7 files changed, 332 insertions(+), 359 deletions(-) delete mode 100644 src/Exceptionless.Web/ClientApp/src/routes/(app)/event/query-filters.ts delete mode 100644 tests/Exceptionless.Tests/Api/Endpoints/OAuthGrantEndpointTests.cs diff --git a/src/Exceptionless.Web/ClientApp/src/lib/features/product-tours/components/product-tour-host.svelte b/src/Exceptionless.Web/ClientApp/src/lib/features/product-tours/components/product-tour-host.svelte index 735ee4de5b..b1e9d10e1c 100644 --- a/src/Exceptionless.Web/ClientApp/src/lib/features/product-tours/components/product-tour-host.svelte +++ b/src/Exceptionless.Web/ClientApp/src/lib/features/product-tours/components/product-tour-host.svelte @@ -37,6 +37,8 @@ stateSettled: boolean; } + const EVENT_PATH = resolve('/(app)/event'); + const STACK_PATH = resolve('/(app)/stack'); const SYSTEM_PATH = resolve('/(app)/system'); let { @@ -96,7 +98,7 @@ stateSettled && currentUser && assistantAccess?.enabled && - (pathname.startsWith('/next/event') || pathname.startsWith('/next/stack')) && + (pathname.startsWith(EVENT_PATH) || pathname.startsWith(STACK_PATH)) && !isSetupPage && !isImpersonating && !checkpoint && diff --git a/src/Exceptionless.Web/ClientApp/src/routes/(app)/event/+page.svelte b/src/Exceptionless.Web/ClientApp/src/routes/(app)/event/+page.svelte index 5d41eb6981..5b34277341 100644 --- a/src/Exceptionless.Web/ClientApp/src/routes/(app)/event/+page.svelte +++ b/src/Exceptionless.Web/ClientApp/src/routes/(app)/event/+page.svelte @@ -79,8 +79,6 @@ redirectToEventsWithFilter, serializeTimeQueryParam } from '../redirect-to-events.svelte'; - import { getEventQueryFilters } from './query-filters'; - let selectedEventId: null | string = $state(null); const investigationCheckpoint = $derived(productTourCheckpoint.current?.tourName === 'investigate-error' ? productTourCheckpoint.current : undefined); @@ -148,6 +146,77 @@ return filter || null; } + function getQueryFilters(params: ListFilterQueryParams = queryParams): FacetedFilter.IFilter[] | null { + const filters: FacetedFilter.IFilter[] = []; + + if (params.project) { + filters.push(new ProjectFilter(splitQueryParam(params.project))); + } + + if (params.stack) { + filters.push(new StringFilter('stack', params.stack)); + } + + const bot = parseBooleanQueryParam(params.bot); + if (bot !== undefined) { + filters.push(new BooleanFilter('bot', bot)); + } + + const first = parseBooleanQueryParam(params.first); + if (first !== undefined) { + filters.push(new BooleanFilter('first', first)); + } + + if (params.level) { + filters.push(new LevelFilter(splitQueryParam(params.level) as never[])); + } + + if (params.reference) { + filters.push(new ReferenceFilter(params.reference)); + } + + if (params.session) { + filters.push(new SessionFilter(params.session)); + } + + if (params.status) { + filters.push(new StatusFilter(splitQueryParam(params.status) as never[])); + } + + if (params.tag) { + filters.push(new TagFilter(splitQueryParam(params.tag) as never[])); + } + + if (params.type) { + filters.push(new TypeFilter(splitQueryParam(params.type) as never[])); + } + + if (params.version) { + filters.push(new VersionFilter('version', params.version)); + } + + return filters.length > 0 ? filters : null; + } + + function parseBooleanQueryParam(value: null | string | undefined): boolean | undefined { + if (value === 'true') { + return true; + } + + if (value === 'false') { + return false; + } + + return undefined; + } + + function splitQueryParam(value: string): string[] { + return value + .split(',') + .map((item) => item.trim()) + .filter((item) => item); + } + function getEffectiveSort(): null | string | undefined { if (queryParams.sort != null) { return queryParams.sort || undefined; @@ -268,7 +337,7 @@ function getCurrentFiltersWithoutTime(params: ListFilterQueryParams = queryParams): FacetedFilter.IFilter[] { const savedViewFilters = getSavedViewFilters(); - const queryFilters = getEventQueryFilters(params) ?? []; + const queryFilters = getQueryFilters(params) ?? []; const expressionFilters = params.filter != null ? getFiltersFromCache(filterCacheKey(params.filter), params.filter).filter((filter) => filter.type !== 'date') : []; diff --git a/src/Exceptionless.Web/ClientApp/src/routes/(app)/event/query-filters.ts b/src/Exceptionless.Web/ClientApp/src/routes/(app)/event/query-filters.ts deleted file mode 100644 index 99b0685152..0000000000 --- a/src/Exceptionless.Web/ClientApp/src/routes/(app)/event/query-filters.ts +++ /dev/null @@ -1,70 +0,0 @@ -import type { PersistentEventKnownTypes } from '$features/events/models'; -import type { LogLevel } from '$features/events/models/event-data'; - -import * as FacetedFilter from '$comp/faceted-filter'; -import { - BooleanFilter, - LevelFilter, - ProjectFilter, - ReferenceFilter, - SessionFilter, - StatusFilter, - StringFilter, - TagFilter, - TypeFilter, - VersionFilter -} from '$features/events/components/filters'; -import { StackStatus } from '$features/stacks/models'; - -import type { ListFilterQueryParams } from '../redirect-to-events.svelte'; - -export function getEventQueryFilters(params: ListFilterQueryParams): FacetedFilter.IFilter[] | null { - const filters: FacetedFilter.IFilter[] = []; - - if (params.project) { - filters.push(new ProjectFilter(splitQueryParam(params.project))); - } - if (params.stack) { - filters.push(new StringFilter('stack', params.stack)); - } - - addBooleanFilter(filters, 'bot', params.bot); - addBooleanFilter(filters, 'first', params.first); - - if (params.level) { - filters.push(new LevelFilter(splitQueryParam(params.level) as LogLevel[])); - } - if (params.reference) { - filters.push(new ReferenceFilter(params.reference)); - } - if (params.session) { - filters.push(new SessionFilter(params.session)); - } - if (params.status) { - filters.push(new StatusFilter(splitQueryParam(params.status) as StackStatus[])); - } - if (params.tag) { - filters.push(new TagFilter(splitQueryParam(params.tag) as PersistentEventKnownTypes[])); - } - if (params.type) { - filters.push(new TypeFilter(splitQueryParam(params.type) as PersistentEventKnownTypes[])); - } - if (params.version) { - filters.push(new VersionFilter('version', params.version)); - } - - return filters.length > 0 ? filters : null; -} - -function addBooleanFilter(filters: FacetedFilter.IFilter[], field: 'bot' | 'first', value: null | string | undefined): void { - if (value === 'true' || value === 'false') { - filters.push(new BooleanFilter(field, value === 'true')); - } -} - -function splitQueryParam(value: string): string[] { - return value - .split(',') - .map((item) => item.trim()) - .filter(Boolean); -} diff --git a/src/Exceptionless.Web/ClientApp/src/routes/(app)/project/[projectId]/configure/+page.svelte b/src/Exceptionless.Web/ClientApp/src/routes/(app)/project/[projectId]/configure/+page.svelte index 6a0458f4ff..ab73f50656 100644 --- a/src/Exceptionless.Web/ClientApp/src/routes/(app)/project/[projectId]/configure/+page.svelte +++ b/src/Exceptionless.Web/ClientApp/src/routes/(app)/project/[projectId]/configure/+page.svelte @@ -444,7 +444,8 @@ public partial class App : Application { }); async function refreshConfiguredProject(): Promise { - if (!queryParams.redirect) { + const checkpoint = configureCheckpoint; + if (!queryParams.redirect || !checkpoint) { return; } @@ -453,7 +454,7 @@ public partial class App : Application { return; } - if (configureCheckpoint && !(await tourActions.complete(configureCheckpoint))) { + if (!(await tourActions.complete(checkpoint))) { return; } diff --git a/src/Exceptionless.Web/ClientApp/src/routes/(app)/system/product-tours/+page.svelte b/src/Exceptionless.Web/ClientApp/src/routes/(app)/system/product-tours/+page.svelte index d20d291eb6..ddf6b428b6 100644 --- a/src/Exceptionless.Web/ClientApp/src/routes/(app)/system/product-tours/+page.svelte +++ b/src/Exceptionless.Web/ClientApp/src/routes/(app)/system/product-tours/+page.svelte @@ -58,7 +58,7 @@ {:else if usage?.tours.length === 0}

No guided-tour activity was recorded for this month.

{:else} - + Tour @@ -113,7 +113,7 @@ {#if usage?.recent_activity.length === 0}

No recent tour activity was recorded.

{:else} - + User @@ -124,7 +124,7 @@ - {#each usage?.recent_activity ?? [] as activity (`${activity.date_utc}-${activity.user_identity}-${activity.event}-${activity.tour_name}`)} + {#each usage?.recent_activity ?? [] as activity (activity)}
{activity.user_name || activity.user_identity || 'Unknown user'}
diff --git a/tests/Exceptionless.Tests/Api/Endpoints/OAuthGrantEndpointTests.cs b/tests/Exceptionless.Tests/Api/Endpoints/OAuthGrantEndpointTests.cs deleted file mode 100644 index e125d7ff0f..0000000000 --- a/tests/Exceptionless.Tests/Api/Endpoints/OAuthGrantEndpointTests.cs +++ /dev/null @@ -1,280 +0,0 @@ -using Exceptionless.Core.Authorization; -using Exceptionless.Core.Extensions; -using Exceptionless.Core.Models; -using Exceptionless.Core.Repositories; -using Exceptionless.Core.Services; -using Exceptionless.Core.Utility; -using Exceptionless.Tests.Extensions; -using Exceptionless.Web.Models.OAuth; -using Foundatio.Repositories; -using Foundatio.Repositories.Utility; -using Xunit; - -namespace Exceptionless.Tests.Api.Endpoints; - -public sealed class OAuthGrantEndpointTests : IntegrationTestsBase -{ - private readonly IOAuthApplicationRepository _oauthApplicationRepository; - private readonly IOAuthTokenRepository _oauthTokenRepository; - private readonly IUserRepository _userRepository; - - public OAuthGrantEndpointTests(ITestOutputHelper output, AppWebHostFactory factory) : base(output, factory) - { - _oauthApplicationRepository = GetService(); - _oauthTokenRepository = GetService(); - _userRepository = GetService(); - } - - protected override async Task ResetDataAsync() - { - await base.ResetDataAsync(); - await GetService().CreateDataAsync(); - } - - [Fact] - public async Task GetOAuthGrantsAsync_WithActiveOAuthTokens_ReturnsGroupedApplications() - { - // Arrange - var user = await _userRepository.GetByEmailAddressAsync(SampleDataService.TEST_ORG_USER_EMAIL); - Assert.NotNull(user); - - const string clientId = "test-oauth-grant-client"; - await CreateOAuthApplicationAsync(clientId, "Test AI Client"); - string grantId = StringExtensions.GetNewToken(); - await CreateOAuthGrantTokenAsync(user.Id, clientId, "http://localhost:7110/mcp", [AuthorizationRoles.McpRead, AuthorizationRoles.ProjectsRead, AuthorizationRoles.OfflineAccess], grantId: grantId); - await CreateOAuthGrantTokenAsync(user.Id, clientId, "http://localhost:7110/api/v2", [AuthorizationRoles.ProjectsRead, AuthorizationRoles.StacksRead], grantId: grantId); - await CreateOAuthGrantTokenAsync(user.Id, "disabled-oauth-grant-client", "http://localhost:7110/mcp", [AuthorizationRoles.McpRead], isDisabled: true); - - // Act - var grants = await SendRequestAsAsync>(r => r - .AsTestOrganizationUser() - .AppendPath("users/me/oauth-grants") - .StatusCodeShouldBeOk() - ); - - // Assert - Assert.NotNull(grants); - var grant = Assert.Single(grants); - Assert.Equal(clientId, grant.ClientId); - Assert.Equal("Test AI Client", grant.ApplicationName); - Assert.Contains(SampleDataService.TEST_ORG_ID, grant.OrganizationIds); - Assert.Contains(AuthorizationRoles.McpRead, grant.Scopes); - Assert.Contains(AuthorizationRoles.StacksRead, grant.Scopes); - Assert.Equal(2, grant.Resources.Count); - Assert.Contains(grant.Resources, resource => String.Equals(resource.Resource, "http://localhost:7110/mcp", StringComparison.Ordinal) && resource.Scopes.Contains(AuthorizationRoles.McpRead)); - Assert.Contains(grant.Resources, resource => String.Equals(resource.Resource, "http://localhost:7110/api/v2", StringComparison.Ordinal) && resource.Scopes.Contains(AuthorizationRoles.StacksRead)); - } - - [Fact] - public async Task GetOAuthGrantsAsync_WhenDisabledTokensExceedPageLimit_ReturnsActiveGrant() - { - var user = await _userRepository.GetByEmailAddressAsync(SampleDataService.TEST_ORG_USER_EMAIL); - Assert.NotNull(user); - const string clientId = "paged-oauth-grant-client"; - await CreateOAuthApplicationAsync(clientId, "Paged OAuth Grant Client"); - var utcNow = TimeProvider.GetUtcNow().UtcDateTime; - var disabledTokens = Enumerable.Range(0, 1005) - .Select(i => CreateOAuthGrantToken(user.Id, $"disabled-paged-client-{i}", "http://localhost:7110/mcp", [AuthorizationRoles.McpRead], utcNow.AddMinutes(1), isDisabled: true)) - .ToArray(); - await _oauthTokenRepository.AddAsync(disabledTokens, o => o.ImmediateConsistency()); - string grantId = StringExtensions.GetNewToken(); - await _oauthTokenRepository.AddAsync(CreateOAuthGrantToken( - user.Id, - clientId, - "http://localhost:7110/mcp", - [AuthorizationRoles.McpRead, AuthorizationRoles.OfflineAccess], - utcNow, - grantId: grantId), o => o.ImmediateConsistency()); - - var grants = await SendRequestAsAsync>(r => r - .AsTestOrganizationUser() - .AppendPath("users/me/oauth-grants") - .StatusCodeShouldBeOk() - ); - - Assert.NotNull(grants); - var grant = Assert.Single(grants); - Assert.Equal(clientId, grant.ClientId); - Assert.Equal("Paged OAuth Grant Client", grant.ApplicationName); - Assert.Contains(AuthorizationRoles.McpRead, grant.Scopes); - } - - [Fact] - public async Task RevokeOAuthGrantAsync_WithCurrentUserGrant_DisablesAllClientTokens() - { - // Arrange - var user = await _userRepository.GetByEmailAddressAsync(SampleDataService.TEST_ORG_USER_EMAIL); - Assert.NotNull(user); - - const string clientId = "test-revoke-client"; - await CreateOAuthApplicationAsync(clientId, "Revoked AI Client"); - await CreateOAuthApplicationAsync("unrelated-revoke-client", "Unrelated AI Client"); - var firstToken = await CreateOAuthGrantTokenAsync(user.Id, clientId, "http://localhost:7110/mcp", [AuthorizationRoles.McpRead, AuthorizationRoles.OfflineAccess]); - var secondToken = await CreateOAuthGrantTokenAsync(user.Id, clientId, "http://localhost:7110/api/v2", [AuthorizationRoles.ProjectsRead, AuthorizationRoles.OfflineAccess]); - var unrelatedToken = await CreateOAuthGrantTokenAsync(user.Id, "unrelated-revoke-client", "http://localhost:7110/mcp", [AuthorizationRoles.McpRead, AuthorizationRoles.OfflineAccess]); - - // Act - await SendRequestAsync(r => r - .Delete() - .AsTestOrganizationUser() - .AppendPaths("users", "me", "oauth-grants", firstToken.GrantId!) - .StatusCodeShouldBeNoContent() - ); - - // Assert - var revokedFirstToken = await _oauthTokenRepository.GetByIdAsync(firstToken.Id, o => o.ImmediateConsistency()); - var revokedSecondToken = await _oauthTokenRepository.GetByIdAsync(secondToken.Id, o => o.ImmediateConsistency()); - var stillActiveToken = await _oauthTokenRepository.GetByIdAsync(unrelatedToken.Id, o => o.ImmediateConsistency()); - Assert.NotNull(revokedFirstToken); - Assert.NotNull(revokedSecondToken); - Assert.NotNull(stillActiveToken); - Assert.True(revokedFirstToken.IsDisabled); - Assert.True(revokedSecondToken.IsDisabled); - Assert.Null(revokedFirstToken.RefreshTokenHash); - Assert.Null(revokedSecondToken.RefreshTokenHash); - Assert.False(stillActiveToken.IsDisabled); - Assert.NotNull(stillActiveToken.RefreshTokenHash); - } - - [Fact] - public async Task RevokeOAuthGrantAsync_WhenClientTokensExceedPageLimit_DisablesAllClientTokens() - { - var user = await _userRepository.GetByEmailAddressAsync(SampleDataService.TEST_ORG_USER_EMAIL); - Assert.NotNull(user); - const string clientId = "paged-revoke-client"; - await CreateOAuthApplicationAsync(clientId, "Paged Revoke Client"); - var utcNow = TimeProvider.GetUtcNow().UtcDateTime; - var tokens = Enumerable.Range(0, 1005) - .Select(i => CreateOAuthGrantToken(user.Id, clientId, "http://localhost:7110/mcp", [AuthorizationRoles.McpRead, AuthorizationRoles.OfflineAccess], utcNow)) - .ToList(); - string targetGrantId = StringExtensions.GetNewToken(); - var targetToken = CreateOAuthGrantToken(user.Id, clientId, "http://localhost:7110/api/v2", [AuthorizationRoles.ProjectsRead, AuthorizationRoles.OfflineAccess], utcNow, grantId: targetGrantId); - tokens.Add(targetToken); - await _oauthTokenRepository.AddAsync(tokens, o => o.ImmediateConsistency()); - - await SendRequestAsync(r => r - .Delete() - .AsTestOrganizationUser() - .AppendPaths("users", "me", "oauth-grants", targetGrantId) - .StatusCodeShouldBeNoContent() - ); - - var results = await _oauthTokenRepository.GetByUserIdAndClientIdForUpdateAsync(user.Id, clientId, o => o.ImmediateConsistency().SearchAfterPaging().PageLimit(1000)); - int tokenCount = 0; - do - { - foreach (var token in results.Documents) - { - tokenCount++; - Assert.True(token.IsDisabled); - Assert.Null(token.RefreshTokenHash); - } - } while (await results.NextPageAsync()); - - Assert.Equal(tokens.Count, tokenCount); - } - - [Fact] - public async Task RevokeOAuthGrantAsync_ForAnotherUserGrant_ReturnsNotFound() - { - // Arrange - var freeUser = await _userRepository.GetByEmailAddressAsync(SampleDataService.FREE_USER_EMAIL); - Assert.NotNull(freeUser); - - const string clientId = "other-user-revoke-client"; - await CreateOAuthApplicationAsync(clientId, "Other User AI Client"); - var token = await CreateOAuthGrantTokenAsync(freeUser.Id, clientId, "http://localhost:7110/mcp", [AuthorizationRoles.McpRead, AuthorizationRoles.OfflineAccess], organizationIds: [SampleDataService.FREE_ORG_ID]); - - // Act - await SendRequestAsync(r => r - .Delete() - .AsTestOrganizationUser() - .AppendPaths("users", "me", "oauth-grants", token.GrantId!) - .StatusCodeShouldBeNotFound() - ); - - // Assert - var storedToken = await _oauthTokenRepository.GetByIdAsync(token.Id, o => o.ImmediateConsistency()); - Assert.NotNull(storedToken); - Assert.False(storedToken.IsDisabled); - Assert.NotNull(storedToken.RefreshTokenHash); - } - private Task CreateOAuthApplicationAsync(string clientId, string name) - { - var utcNow = TimeProvider.GetUtcNow().UtcDateTime; - var application = new OAuthApplication - { - Id = ObjectId.GenerateNewId().ToString(), - ClientId = clientId, - Name = name, - RedirectUris = ["http://localhost/callback"], - Scopes = - [ - AuthorizationRoles.McpRead, - AuthorizationRoles.ProjectsRead, - AuthorizationRoles.StacksRead, - AuthorizationRoles.StacksWrite, - AuthorizationRoles.EventsRead, - AuthorizationRoles.OfflineAccess - ], - CreatedByUserId = OAuthApplication.SystemUserId, - UpdatedByUserId = OAuthApplication.SystemUserId, - CreatedUtc = utcNow, - UpdatedUtc = utcNow - }; - - return _oauthApplicationRepository.AddAsync(application, o => o.ImmediateConsistency()); - } - - private async Task CreateOAuthGrantTokenAsync(string userId, string clientId, string resource, string[] scopes, bool isDisabled = false, string[]? organizationIds = null, string? grantId = null) - { - var utcNow = TimeProvider.GetUtcNow().UtcDateTime; - var accessToken = StringExtensions.GetRandomString(OAuthService.OAuthTokenLength); - var refreshToken = scopes.Contains(AuthorizationRoles.OfflineAccess, StringComparer.Ordinal) ? StringExtensions.GetRandomString(OAuthService.OAuthTokenLength) : null; - var token = new OAuthToken - { - Id = ObjectId.GenerateNewId().ToString(), - UserId = userId, - ClientId = clientId, - GrantId = String.IsNullOrWhiteSpace(grantId) ? StringExtensions.GetNewToken() : grantId, - Resource = resource, - AccessTokenHash = OAuthService.CreateTokenHash(accessToken), - RefreshTokenHash = refreshToken is null ? null : OAuthService.CreateTokenHash(refreshToken), - Scopes = scopes.ToHashSet(StringComparer.Ordinal), - OrganizationIds = (organizationIds ?? [SampleDataService.TEST_ORG_ID]).ToHashSet(StringComparer.Ordinal), - ExpiresUtc = utcNow.AddHours(1), - RefreshExpiresUtc = refreshToken is not null ? utcNow.AddDays(30) : null, - IsDisabled = isDisabled, - CreatedBy = userId, - CreatedUtc = utcNow, - UpdatedUtc = utcNow - }; - - await _oauthTokenRepository.AddAsync(token, o => o.ImmediateConsistency()); - return token; - } - - private static OAuthToken CreateOAuthGrantToken(string userId, string clientId, string resource, string[] scopes, DateTime utcNow, bool isDisabled = false, string[]? organizationIds = null, string? grantId = null) - { - var accessToken = StringExtensions.GetRandomString(OAuthService.OAuthTokenLength); - var refreshToken = scopes.Contains(AuthorizationRoles.OfflineAccess, StringComparer.Ordinal) ? StringExtensions.GetRandomString(OAuthService.OAuthTokenLength) : null; - return new OAuthToken - { - Id = ObjectId.GenerateNewId().ToString(), - UserId = userId, - ClientId = clientId, - GrantId = String.IsNullOrWhiteSpace(grantId) ? StringExtensions.GetNewToken() : grantId, - Resource = resource, - AccessTokenHash = OAuthService.CreateTokenHash(accessToken), - RefreshTokenHash = refreshToken is null ? null : OAuthService.CreateTokenHash(refreshToken), - Scopes = scopes.ToHashSet(StringComparer.Ordinal), - OrganizationIds = (organizationIds ?? [SampleDataService.TEST_ORG_ID]).ToHashSet(StringComparer.Ordinal), - ExpiresUtc = utcNow.AddHours(1), - RefreshExpiresUtc = refreshToken is not null ? utcNow.AddDays(30) : null, - IsDisabled = isDisabled, - CreatedBy = userId, - CreatedUtc = utcNow, - UpdatedUtc = utcNow - }; - } -} diff --git a/tests/Exceptionless.Tests/Api/Endpoints/UserEndpointTests.cs b/tests/Exceptionless.Tests/Api/Endpoints/UserEndpointTests.cs index f05a690a5b..2bcd528a36 100644 --- a/tests/Exceptionless.Tests/Api/Endpoints/UserEndpointTests.cs +++ b/tests/Exceptionless.Tests/Api/Endpoints/UserEndpointTests.cs @@ -8,6 +8,7 @@ using Exceptionless.Tests.Extensions; using Exceptionless.Web.Api.Results; using Exceptionless.Web.Models; +using Exceptionless.Web.Models.OAuth; using Exceptionless.Web.Utility; using FluentRest; using Foundatio.Repositories; @@ -19,10 +20,14 @@ namespace Exceptionless.Tests.Api.Endpoints; public sealed class UserEndpointTests : IntegrationTestsBase { private readonly IUserRepository _userRepository; + private readonly IOAuthApplicationRepository _oauthApplicationRepository; + private readonly IOAuthTokenRepository _oauthTokenRepository; public UserEndpointTests(ITestOutputHelper output, AppWebHostFactory factory) : base(output, factory) { _userRepository = GetService(); + _oauthApplicationRepository = GetService(); + _oauthTokenRepository = GetService(); } protected override async Task ResetDataAsync() @@ -427,6 +432,174 @@ public async Task GetCurrentUserAsync_WithAvatar_ReturnsRoutableAvatarUrl() Assert.Equal($"/api/v2/users/{currentUser.Id}/avatar/avatar.png", user.AvatarUrl); } + [Fact] + public async Task GetOAuthGrantsAsync_WithActiveOAuthTokens_ReturnsGroupedApplications() + { + // Arrange + var user = await _userRepository.GetByEmailAddressAsync(SampleDataService.TEST_ORG_USER_EMAIL); + Assert.NotNull(user); + + const string clientId = "test-oauth-grant-client"; + await CreateOAuthApplicationAsync(clientId, "Test AI Client"); + string grantId = StringExtensions.GetNewToken(); + await CreateOAuthGrantTokenAsync(user.Id, clientId, "http://localhost:7110/mcp", [AuthorizationRoles.McpRead, AuthorizationRoles.ProjectsRead, AuthorizationRoles.OfflineAccess], grantId: grantId); + await CreateOAuthGrantTokenAsync(user.Id, clientId, "http://localhost:7110/api/v2", [AuthorizationRoles.ProjectsRead, AuthorizationRoles.StacksRead], grantId: grantId); + await CreateOAuthGrantTokenAsync(user.Id, "disabled-oauth-grant-client", "http://localhost:7110/mcp", [AuthorizationRoles.McpRead], isDisabled: true); + + // Act + var grants = await SendRequestAsAsync>(r => r + .AsTestOrganizationUser() + .AppendPath("users/me/oauth-grants") + .StatusCodeShouldBeOk() + ); + + // Assert + Assert.NotNull(grants); + var grant = Assert.Single(grants); + Assert.Equal(clientId, grant.ClientId); + Assert.Equal("Test AI Client", grant.ApplicationName); + Assert.Contains(SampleDataService.TEST_ORG_ID, grant.OrganizationIds); + Assert.Contains(AuthorizationRoles.McpRead, grant.Scopes); + Assert.Contains(AuthorizationRoles.StacksRead, grant.Scopes); + Assert.Equal(2, grant.Resources.Count); + Assert.Contains(grant.Resources, r => r.Resource == "http://localhost:7110/mcp" && r.Scopes.Contains(AuthorizationRoles.McpRead)); + Assert.Contains(grant.Resources, r => r.Resource == "http://localhost:7110/api/v2" && r.Scopes.Contains(AuthorizationRoles.StacksRead)); + } + + [Fact] + public async Task GetOAuthGrantsAsync_WhenDisabledTokensExceedPageLimit_ReturnsActiveGrant() + { + var user = await _userRepository.GetByEmailAddressAsync(SampleDataService.TEST_ORG_USER_EMAIL); + Assert.NotNull(user); + const string clientId = "paged-oauth-grant-client"; + await CreateOAuthApplicationAsync(clientId, "Paged OAuth Grant Client"); + var utcNow = TimeProvider.GetUtcNow().UtcDateTime; + var disabledTokens = Enumerable.Range(0, 1005) + .Select(i => CreateOAuthGrantToken(user.Id, $"disabled-paged-client-{i}", "http://localhost:7110/mcp", [AuthorizationRoles.McpRead], utcNow.AddMinutes(1), isDisabled: true)) + .ToArray(); + await _oauthTokenRepository.AddAsync(disabledTokens, o => o.ImmediateConsistency()); + string grantId = StringExtensions.GetNewToken(); + await _oauthTokenRepository.AddAsync(CreateOAuthGrantToken( + user.Id, + clientId, + "http://localhost:7110/mcp", + [AuthorizationRoles.McpRead, AuthorizationRoles.OfflineAccess], + utcNow, + grantId: grantId), o => o.ImmediateConsistency()); + + var grants = await SendRequestAsAsync>(r => r + .AsTestOrganizationUser() + .AppendPath("users/me/oauth-grants") + .StatusCodeShouldBeOk() + ); + + Assert.NotNull(grants); + var grant = Assert.Single(grants); + Assert.Equal(clientId, grant.ClientId); + Assert.Equal("Paged OAuth Grant Client", grant.ApplicationName); + Assert.Contains(AuthorizationRoles.McpRead, grant.Scopes); + } + + [Fact] + public async Task RevokeOAuthGrantAsync_WithCurrentUserGrant_DisablesAllClientTokens() + { + // Arrange + var user = await _userRepository.GetByEmailAddressAsync(SampleDataService.TEST_ORG_USER_EMAIL); + Assert.NotNull(user); + + const string clientId = "test-revoke-client"; + await CreateOAuthApplicationAsync(clientId, "Revoked AI Client"); + await CreateOAuthApplicationAsync("unrelated-revoke-client", "Unrelated AI Client"); + var firstToken = await CreateOAuthGrantTokenAsync(user.Id, clientId, "http://localhost:7110/mcp", [AuthorizationRoles.McpRead, AuthorizationRoles.OfflineAccess]); + var secondToken = await CreateOAuthGrantTokenAsync(user.Id, clientId, "http://localhost:7110/api/v2", [AuthorizationRoles.ProjectsRead, AuthorizationRoles.OfflineAccess]); + var unrelatedToken = await CreateOAuthGrantTokenAsync(user.Id, "unrelated-revoke-client", "http://localhost:7110/mcp", [AuthorizationRoles.McpRead, AuthorizationRoles.OfflineAccess]); + + // Act + await SendRequestAsync(r => r + .Delete() + .AsTestOrganizationUser() + .AppendPaths("users", "me", "oauth-grants", firstToken.GrantId!) + .StatusCodeShouldBeNoContent() + ); + + // Assert + var revokedFirstToken = await _oauthTokenRepository.GetByIdAsync(firstToken.Id, o => o.ImmediateConsistency()); + var revokedSecondToken = await _oauthTokenRepository.GetByIdAsync(secondToken.Id, o => o.ImmediateConsistency()); + var stillActiveToken = await _oauthTokenRepository.GetByIdAsync(unrelatedToken.Id, o => o.ImmediateConsistency()); + Assert.NotNull(revokedFirstToken); + Assert.NotNull(revokedSecondToken); + Assert.NotNull(stillActiveToken); + Assert.True(revokedFirstToken.IsDisabled); + Assert.True(revokedSecondToken.IsDisabled); + Assert.Null(revokedFirstToken.RefreshTokenHash); + Assert.Null(revokedSecondToken.RefreshTokenHash); + Assert.False(stillActiveToken.IsDisabled); + Assert.NotNull(stillActiveToken.RefreshTokenHash); + } + + [Fact] + public async Task RevokeOAuthGrantAsync_WhenClientTokensExceedPageLimit_DisablesAllClientTokens() + { + var user = await _userRepository.GetByEmailAddressAsync(SampleDataService.TEST_ORG_USER_EMAIL); + Assert.NotNull(user); + const string clientId = "paged-revoke-client"; + await CreateOAuthApplicationAsync(clientId, "Paged Revoke Client"); + var utcNow = TimeProvider.GetUtcNow().UtcDateTime; + var tokens = Enumerable.Range(0, 1005) + .Select(i => CreateOAuthGrantToken(user.Id, clientId, "http://localhost:7110/mcp", [AuthorizationRoles.McpRead, AuthorizationRoles.OfflineAccess], utcNow)) + .ToList(); + string targetGrantId = StringExtensions.GetNewToken(); + var targetToken = CreateOAuthGrantToken(user.Id, clientId, "http://localhost:7110/api/v2", [AuthorizationRoles.ProjectsRead, AuthorizationRoles.OfflineAccess], utcNow, grantId: targetGrantId); + tokens.Add(targetToken); + await _oauthTokenRepository.AddAsync(tokens, o => o.ImmediateConsistency()); + + await SendRequestAsync(r => r + .Delete() + .AsTestOrganizationUser() + .AppendPaths("users", "me", "oauth-grants", targetGrantId) + .StatusCodeShouldBeNoContent() + ); + + var results = await _oauthTokenRepository.GetByUserIdAndClientIdForUpdateAsync(user.Id, clientId, o => o.ImmediateConsistency().SearchAfterPaging().PageLimit(1000)); + int tokenCount = 0; + do + { + foreach (var token in results.Documents) + { + tokenCount++; + Assert.True(token.IsDisabled); + Assert.Null(token.RefreshTokenHash); + } + } while (await results.NextPageAsync()); + + Assert.Equal(tokens.Count, tokenCount); + } + + [Fact] + public async Task RevokeOAuthGrantAsync_ForAnotherUserGrant_ReturnsNotFound() + { + // Arrange + var freeUser = await _userRepository.GetByEmailAddressAsync(SampleDataService.FREE_USER_EMAIL); + Assert.NotNull(freeUser); + + const string clientId = "other-user-revoke-client"; + await CreateOAuthApplicationAsync(clientId, "Other User AI Client"); + var token = await CreateOAuthGrantTokenAsync(freeUser.Id, clientId, "http://localhost:7110/mcp", [AuthorizationRoles.McpRead, AuthorizationRoles.OfflineAccess], organizationIds: [SampleDataService.FREE_ORG_ID]); + + // Act + await SendRequestAsync(r => r + .Delete() + .AsTestOrganizationUser() + .AppendPaths("users", "me", "oauth-grants", token.GrantId!) + .StatusCodeShouldBeNotFound() + ); + + // Assert + var storedToken = await _oauthTokenRepository.GetByIdAsync(token.Id, o => o.ImmediateConsistency()); + Assert.NotNull(storedToken); + Assert.False(storedToken.IsDisabled); + Assert.NotNull(storedToken.RefreshTokenHash); + } [Fact] public async Task UploadAvatarAsync_ImageOverGlobalRequestLimit_ReturnsUpdatedUser() @@ -789,6 +962,84 @@ await SendRequestAsync(r => r Assert.Null(updatedUser.VerifyEmailAddressToken); } + private Task CreateOAuthApplicationAsync(string clientId, string name) + { + var utcNow = TimeProvider.GetUtcNow().UtcDateTime; + var application = new OAuthApplication + { + Id = ObjectId.GenerateNewId().ToString(), + ClientId = clientId, + Name = name, + RedirectUris = ["http://localhost/callback"], + Scopes = + [ + AuthorizationRoles.McpRead, + AuthorizationRoles.ProjectsRead, + AuthorizationRoles.StacksRead, + AuthorizationRoles.StacksWrite, + AuthorizationRoles.EventsRead, + AuthorizationRoles.OfflineAccess + ], + CreatedByUserId = OAuthApplication.SystemUserId, + UpdatedByUserId = OAuthApplication.SystemUserId, + CreatedUtc = utcNow, + UpdatedUtc = utcNow + }; + + return _oauthApplicationRepository.AddAsync(application, o => o.ImmediateConsistency()); + } + + private async Task CreateOAuthGrantTokenAsync(string userId, string clientId, string resource, string[] scopes, bool isDisabled = false, string[]? organizationIds = null, string? grantId = null) + { + var utcNow = TimeProvider.GetUtcNow().UtcDateTime; + var accessToken = StringExtensions.GetRandomString(OAuthService.OAuthTokenLength); + var refreshToken = scopes.Contains(AuthorizationRoles.OfflineAccess, StringComparer.Ordinal) ? StringExtensions.GetRandomString(OAuthService.OAuthTokenLength) : null; + var token = new OAuthToken + { + Id = ObjectId.GenerateNewId().ToString(), + UserId = userId, + ClientId = clientId, + GrantId = String.IsNullOrWhiteSpace(grantId) ? StringExtensions.GetNewToken() : grantId, + Resource = resource, + AccessTokenHash = OAuthService.CreateTokenHash(accessToken), + RefreshTokenHash = refreshToken is null ? null : OAuthService.CreateTokenHash(refreshToken), + Scopes = scopes.ToHashSet(StringComparer.Ordinal), + OrganizationIds = (organizationIds ?? [SampleDataService.TEST_ORG_ID]).ToHashSet(StringComparer.Ordinal), + ExpiresUtc = utcNow.AddHours(1), + RefreshExpiresUtc = refreshToken is not null ? utcNow.AddDays(30) : null, + IsDisabled = isDisabled, + CreatedBy = userId, + CreatedUtc = utcNow, + UpdatedUtc = utcNow + }; + + await _oauthTokenRepository.AddAsync(token, o => o.ImmediateConsistency()); + return token; + } + + private static OAuthToken CreateOAuthGrantToken(string userId, string clientId, string resource, string[] scopes, DateTime utcNow, bool isDisabled = false, string[]? organizationIds = null, string? grantId = null) + { + var accessToken = StringExtensions.GetRandomString(OAuthService.OAuthTokenLength); + var refreshToken = scopes.Contains(AuthorizationRoles.OfflineAccess, StringComparer.Ordinal) ? StringExtensions.GetRandomString(OAuthService.OAuthTokenLength) : null; + return new OAuthToken + { + Id = ObjectId.GenerateNewId().ToString(), + UserId = userId, + ClientId = clientId, + GrantId = String.IsNullOrWhiteSpace(grantId) ? StringExtensions.GetNewToken() : grantId, + Resource = resource, + AccessTokenHash = OAuthService.CreateTokenHash(accessToken), + RefreshTokenHash = refreshToken is null ? null : OAuthService.CreateTokenHash(refreshToken), + Scopes = scopes.ToHashSet(StringComparer.Ordinal), + OrganizationIds = (organizationIds ?? [SampleDataService.TEST_ORG_ID]).ToHashSet(StringComparer.Ordinal), + ExpiresUtc = utcNow.AddHours(1), + RefreshExpiresUtc = refreshToken is not null ? utcNow.AddDays(30) : null, + IsDisabled = isDisabled, + CreatedBy = userId, + CreatedUtc = utcNow, + UpdatedUtc = utcNow + }; + } private async Task GetTestOrganizationUserAsync() { From c58579cb1ad3e92ae7cf75bb083eb8a9e3c43e90 Mon Sep 17 00:00:00 2001 From: Blake Niemyjski Date: Sat, 29 Aug 2026 12:09:42 -0500 Subject: [PATCH 07/43] Harden guided tour architecture --- .../Models/Data/ProductTourProgress.cs | 13 +- .../Models/Data/ProductTours.cs | 31 +- .../Repositories/EventRepository.cs | 133 ++------ .../Repositories/ProductTourUsageResult.cs | 4 +- .../Api/Endpoints/AdminEndpoints.cs | 2 +- .../Api/Handlers/AdminHandler.cs | 32 +- .../Api/Handlers/UserHandler.cs | 1 - .../ClientApp/e2e/fixtures/api-client.ts | 2 +- .../ClientApp/e2e/fixtures/e2e-test.ts | 2 +- .../ClientApp/e2e/tests/chart-refresh.e2e.ts | 2 +- .../ClientApp/e2e/tests/product-tours.e2e.ts | 18 +- .../src/lib/features/admin/api.svelte.ts | 6 +- .../src/lib/features/admin/models.ts | 5 +- .../investigation-detail-tour.svelte | 4 +- .../components/investigation-list-tour.svelte | 2 +- .../features/product-tours/catalog.test.ts | 30 +- .../src/lib/features/product-tours/catalog.ts | 40 ++- .../product-tour-catalog-dialog.svelte | 5 +- .../product-tour-welcome-dialog.svelte | 2 +- ...product-tour-welcome-dialog.svelte.test.ts | 2 +- .../components/product-tour-host.svelte | 50 ++- .../product-tour-shell-spotlight.svelte | 10 +- ...r.svelte => saved-view-create-tour.svelte} | 4 +- .../product-tours/eligibility.test.ts | 10 +- .../product-tours/session.svelte.test.ts | 2 +- .../src/lib/features/product-tours/session.ts | 8 +- .../product-tours/state.svelte.test.ts | 2 +- .../features/product-tours/telemetry.test.ts | 4 +- .../src/lib/features/product-tours/types.ts | 22 +- .../components/saved-view-picker.svelte | 14 +- .../stacks/components/stack-card.svelte | 10 +- .../ClientApp/src/lib/generated/api.ts | 87 +++--- .../ClientApp/src/lib/generated/schemas.ts | 75 +++-- .../navigation-command.svelte.test.ts | 4 +- .../ClientApp/src/routes/(app)/+layout.svelte | 1 - .../src/routes/(app)/event/+page.svelte | 2 +- .../(app)/organization/add/+page.svelte | 18 +- .../[projectId]/configure/+page.svelte | 61 +--- .../src/routes/(app)/project/add/+page.svelte | 8 +- .../(app)/system/product-tours/+page.svelte | 6 +- ...esponse.cs => ProductTourUsageResponse.cs} | 11 +- .../Models/User/ViewCurrentUser.cs | 1 - .../Exceptionless.Tests/Api/Data/openapi.json | 285 ++++++++---------- .../AdminProductTourUsageEndpointTests.cs | 46 +-- .../Api/Endpoints/ProductTourEndpointTests.cs | 57 ++-- .../Api/OpenApiSnapshotTests.cs | 8 +- .../Repositories/EventRepositoryTests.cs | 37 +-- .../Serializer/Models/UserSerializerTests.cs | 20 +- tests/http/users.http | 4 +- 49 files changed, 515 insertions(+), 688 deletions(-) rename src/Exceptionless.Web/ClientApp/src/lib/features/product-tours/components/{create-saved-view-tour.svelte => saved-view-create-tour.svelte} (98%) rename src/Exceptionless.Web/Models/Admin/{AdminProductTourUsageResponse.cs => ProductTourUsageResponse.cs} (62%) diff --git a/src/Exceptionless.Core/Models/Data/ProductTourProgress.cs b/src/Exceptionless.Core/Models/Data/ProductTourProgress.cs index fbcf95fd89..33b1ca5ea4 100644 --- a/src/Exceptionless.Core/Models/Data/ProductTourProgress.cs +++ b/src/Exceptionless.Core/Models/Data/ProductTourProgress.cs @@ -1,22 +1,13 @@ -using System.Runtime.Serialization; -using System.Text.Json.Serialization; - namespace Exceptionless.Core.Models.Data; public record ProductTourProgress { public ProductTourStatus Status { get; set; } - public DateTime UpdatedUtc { get; set; } public int Version { get; set; } } -[JsonConverter(typeof(JsonStringEnumConverter))] public enum ProductTourStatus { - [JsonStringEnumMemberName("completed")] - [EnumMember(Value = "completed")] - Completed, - [JsonStringEnumMemberName("dismissed")] - [EnumMember(Value = "dismissed")] - Dismissed + Completed = 1, + Dismissed = 2 } diff --git a/src/Exceptionless.Core/Models/Data/ProductTours.cs b/src/Exceptionless.Core/Models/Data/ProductTours.cs index 2ce860ec3b..bf54ebe9d1 100644 --- a/src/Exceptionless.Core/Models/Data/ProductTours.cs +++ b/src/Exceptionless.Core/Models/Data/ProductTours.cs @@ -7,28 +7,25 @@ namespace Exceptionless.Core.Models.Data; public static class ProductTours { - public const string ConfigureProject = "configure-project"; - public const string CreateSavedView = "create-saved-view"; + public const string AppOverview = "app-overview"; + public const string AppWelcome = "app-welcome"; public const string ExieAnnouncement = "exie-announcement"; - public const string InvestigateError = "investigate-error"; - public const string MeetExie = "meet-exie"; - public const string UiOverview = "ui-overview"; - public const string Welcome = "welcome"; + public const string ExieOverview = "exie-overview"; + public const string EventInvestigate = "event-investigate"; + public const string ProjectConfigure = "project-configure"; + public const string SavedViewCreate = "saved-view-create"; public static IReadOnlyDictionary Versions { get; } = new Dictionary(StringComparer.Ordinal) { - [ConfigureProject] = 1, - [CreateSavedView] = 1, + [AppOverview] = 1, + [AppWelcome] = 1, [ExieAnnouncement] = 1, - [InvestigateError] = 1, - [MeetExie] = 1, - [UiOverview] = 1, - [Welcome] = 1 + [ExieOverview] = 1, + [EventInvestigate] = 1, + [ProjectConfigure] = 1, + [SavedViewCreate] = 1 }.ToFrozenDictionary(StringComparer.Ordinal); - public static IReadOnlyCollection TelemetryEvents { get; } = Enum.GetValues(); - public static IReadOnlyCollection LaunchSources { get; } = Enum.GetValues(); - public static bool IsKnown(string name) => Versions.ContainsKey(name); public static bool IsValid(string name, int version) @@ -45,9 +42,9 @@ public static string CreateTelemetrySource( return $"product-tour.{GetTelemetryName(telemetryEvent)}.{tourName}.v{version}.{GetLaunchSourceName(launchSource)}"; } - public static string GetTelemetryName(ProductTourTelemetryEvent telemetryEvent) => telemetryEvent.ToString().ToLowerUnderscoredWords('-'); + private static string GetTelemetryName(ProductTourTelemetryEvent telemetryEvent) => telemetryEvent.ToString().ToLowerUnderscoredWords('-'); - public static string GetLaunchSourceName(ProductTourLaunchSource launchSource) => launchSource.ToString().ToLowerUnderscoredWords('-'); + private static string GetLaunchSourceName(ProductTourLaunchSource launchSource) => launchSource.ToString().ToLowerUnderscoredWords('-'); } [JsonConverter(typeof(JsonStringEnumConverter))] diff --git a/src/Exceptionless.Core/Repositories/EventRepository.cs b/src/Exceptionless.Core/Repositories/EventRepository.cs index 264c90241c..ce1f494bdf 100644 --- a/src/Exceptionless.Core/Repositories/EventRepository.cs +++ b/src/Exceptionless.Core/Repositories/EventRepository.cs @@ -1,5 +1,4 @@ -using Elastic.Clients.Elasticsearch.Aggregations; -using Elastic.Clients.Elasticsearch.QueryDsl; +using Elastic.Clients.Elasticsearch.QueryDsl; using Exceptionless.Core.Models; using Exceptionless.Core.Models.Data; using Exceptionless.Core.Repositories.Configuration; @@ -8,7 +7,6 @@ using Exceptionless.DateTimeExtensions; using Foundatio.Repositories; using Foundatio.Repositories.Elasticsearch.Extensions; -using Foundatio.Repositories.Exceptions; using Foundatio.Repositories.Models; namespace Exceptionless.Core.Repositories; @@ -93,126 +91,35 @@ public async Task GetProductTourUsageAsync(string projec if (utcEnd <= utcStart) throw new ArgumentOutOfRangeException(nameof(utcEnd), "The end date must be later than the start date."); - var sourcesByTour = ProductTours.Versions.ToDictionary( - pair => pair.Key, - pair => CreateProductTourSources(pair.Key, pair.Value), - StringComparer.Ordinal); - var sourcesByName = sourcesByTour.Values - .SelectMany(sources => sources) + var sourcesByName = ProductTours.Versions + .SelectMany(pair => CreateProductTourSources(pair.Key, pair.Value)) .ToDictionary(source => source.Raw, StringComparer.Ordinal); string[] allSources = sourcesByName.Keys.ToArray(); + string sourceField = InferField(ev => ev.Source); + string countField = InferField(ev => ev.Count); + string dateField = InferField(ev => ev.Date); - var aggregationTask = GetProductTourUsageToursAsync(projectId, utcStart, utcEnd, sourcesByTour, allSources); + var aggregationTask = CountAsync(query => ApplyProductTourUsageFilter(query, projectId, utcStart, utcEnd, allSources) + .AggregationsExpression($"terms:({sourceField}~{allSources.Length} sum:{countField}~1 max:{dateField})")); var recentTask = FindAsync(query => ApplyProductTourUsageFilter(query, projectId, utcStart, utcEnd, allSources) .SortDescending(ev => ev.Date), options => options.PageLimit(recentLimit)); await Task.WhenAll(aggregationTask, recentTask); + var sourceBuckets = (await aggregationTask).Aggregations.Terms($"terms_{sourceField}")?.Buckets ?? []; + var usage = sourceBuckets + .Select(bucket => sourcesByName.TryGetValue(bucket.Key, out var source) + ? new ProductTourUsageBucket( + source, + Convert.ToInt64(bucket.Aggregations.Sum($"sum_{countField}")?.Value ?? bucket.Total.GetValueOrDefault()), + bucket.Aggregations.Max($"max_{dateField}")?.Value) + : null) + .OfType() + .ToArray(); var recentEvents = (await recentTask).Documents .Select(ev => ev.Source is not null && sourcesByName.TryGetValue(ev.Source, out var source) ? new ProductTourUsageEvent(ev, source) : null) .OfType() .ToArray(); - return new ProductTourUsageResult(await aggregationTask, recentEvents); - } - - private async Task> GetProductTourUsageToursAsync( - string projectId, - DateTime utcStart, - DateTime utcEnd, - IReadOnlyDictionary sourcesByTour, - string[] allSources) - { - var query = ApplyProductTourUsageFilter(NewQuery(), projectId, utcStart, utcEnd, allSources); - var options = ConfigureOptions(null); - await OnBeforeQueryAsync(query, options, typeof(PersistentEvent)); - await RefreshForConsistency(query, options); - - var search = (await CreateSearchDescriptorAsync(query, options)) - .Size(0) - .Aggregations(CreateProductTourAggregations(sourcesByTour)); - var response = await _client.SearchAsync(search); - _logger.LogRequest(response); - - if (!response.IsValidResponse) - throw new DocumentException($"Error getting product tour usage: {response.ElasticsearchServerError?.Error?.Reason}", response.ApiCallDetails.OriginalException); - - var tours = new List(); - foreach ((string tourName, ProductTourUsageSource[] sources) in sourcesByTour) - { - if (response.Aggregations is null - || !response.Aggregations.TryGetValue(tourName, out var aggregate) - || aggregate is not FilterAggregate filterAggregate - || filterAggregate.Aggregations is null) - continue; - - if (!filterAggregate.Aggregations.TryGetValue("sources", out var sourceAggregate) || sourceAggregate is not StringTermsAggregate sourceTerms) - continue; - - var buckets = new List(); - foreach (var bucket in sourceTerms.Buckets) - { - if (!bucket.Key.TryGetString(out string? sourceValue)) - continue; - - var source = sources.FirstOrDefault(source => String.Equals(source.Raw, sourceValue, StringComparison.Ordinal)); - if (source is null) - continue; - - long count = bucket.Aggregations is { } bucketAggregations - && bucketAggregations.TryGetValue("count", out var countAggregate) - && countAggregate is SumAggregate sum - ? Convert.ToInt64(sum.Value) - : bucket.DocCount; - DateTime? lastUtc = bucket.Aggregations is { } lastAggregations - && lastAggregations.TryGetValue("last", out var lastAggregate) - && lastAggregate is MaxAggregate max - ? max.ValueAsString is null ? null : DateTime.Parse(max.ValueAsString, null, System.Globalization.DateTimeStyles.RoundtripKind) - : null; - buckets.Add(new ProductTourUsageBucket(source, count, lastUtc)); - } - - long uniqueUsers = filterAggregate.Aggregations.TryGetValue("users", out var usersAggregate) && usersAggregate is CardinalityAggregate cardinality - ? Convert.ToInt64(cardinality.Value) - : 0; - if (buckets.Count > 0) - tours.Add(new ProductTourUsageTour(tourName, uniqueUsers, buckets)); - } - - return tours; - } - - private IDictionary CreateProductTourAggregations(IReadOnlyDictionary sourcesByTour) - { - string sourceField = ElasticIndex.MappingResolver.GetNonAnalyzedFieldName(InferField(ev => ev.Source))!; - string userPath = EventIndexExtensions.DataPath(Event.KnownDataKeys.UserInfo, user => user.Identity); - string userField = ElasticIndex.MappingResolver.GetNonAnalyzedFieldName(userPath) ?? userPath; - var aggregations = new Dictionary(StringComparer.Ordinal); - - foreach ((string tourName, ProductTourUsageSource[] sources) in sourcesByTour) - { - aggregations[tourName] = new Aggregation - { - Filter = new TermsQuery - { - Field = sourceField, - Terms = new TermsQueryField(sources.Select(source => (Elastic.Clients.Elasticsearch.FieldValue)source.Raw).ToArray()) - }, - Aggregations = new Dictionary - { - ["sources"] = new Aggregation - { - Terms = new TermsAggregation { Field = sourceField, Size = sources.Length }, - Aggregations = new Dictionary - { - ["count"] = new SumAggregation { Field = InferField(ev => ev.Count), Missing = 1 }, - ["last"] = new MaxAggregation { Field = InferField(ev => ev.Date) } - } - }, - ["users"] = new CardinalityAggregation { Field = userField } - } - }; - } - - return aggregations; + return new ProductTourUsageResult(usage, recentEvents); } private static IRepositoryQuery ApplyProductTourUsageFilter( @@ -233,7 +140,7 @@ private static IRepositoryQuery ApplyProductTourUsageFilter( private static ProductTourUsageSource[] CreateProductTourSources(string tourName, int currentVersion) { return Enumerable.Range(1, currentVersion) - .SelectMany(version => ProductTours.TelemetryEvents.SelectMany(telemetryEvent => ProductTours.LaunchSources.Select(launchSource => + .SelectMany(version => Enum.GetValues().SelectMany(telemetryEvent => Enum.GetValues().Select(launchSource => new ProductTourUsageSource( ProductTours.CreateTelemetrySource(telemetryEvent, tourName, version, launchSource), telemetryEvent, diff --git a/src/Exceptionless.Core/Repositories/ProductTourUsageResult.cs b/src/Exceptionless.Core/Repositories/ProductTourUsageResult.cs index b6fbc20df6..5db431cf37 100644 --- a/src/Exceptionless.Core/Repositories/ProductTourUsageResult.cs +++ b/src/Exceptionless.Core/Repositories/ProductTourUsageResult.cs @@ -4,11 +4,9 @@ namespace Exceptionless.Core.Repositories; public sealed record ProductTourUsageResult( - IReadOnlyCollection Tours, + IReadOnlyCollection Buckets, IReadOnlyCollection RecentEvents); -public sealed record ProductTourUsageTour(string Name, long UniqueUsers, IReadOnlyCollection Buckets); - public sealed record ProductTourUsageBucket(ProductTourUsageSource Source, long Count, DateTime? LastUtc); public sealed record ProductTourUsageEvent(PersistentEvent Event, ProductTourUsageSource Source); diff --git a/src/Exceptionless.Web/Api/Endpoints/AdminEndpoints.cs b/src/Exceptionless.Web/Api/Endpoints/AdminEndpoints.cs index 22bce2dc6e..e67422c46b 100644 --- a/src/Exceptionless.Web/Api/Endpoints/AdminEndpoints.cs +++ b/src/Exceptionless.Web/Api/Endpoints/AdminEndpoints.cs @@ -90,7 +90,7 @@ public static IEndpointRouteBuilder MapAdminEndpoints(this IEndpointRouteBuilder endpoints.MapGet("api/v2/admin/product-tour-usage", GetProductTourUsageAsync) .RequireAuthorization(AuthorizationRoles.GlobalAdminPolicy) .AddEndpointFilter() - .Produces(StatusCodes.Status200OK) + .Produces(StatusCodes.Status200OK) .ProducesProblem(StatusCodes.Status400BadRequest) .Produces(StatusCodes.Status401Unauthorized) .Produces(StatusCodes.Status403Forbidden); diff --git a/src/Exceptionless.Web/Api/Handlers/AdminHandler.cs b/src/Exceptionless.Web/Api/Handlers/AdminHandler.cs index f9b3b1e8e1..8847922634 100644 --- a/src/Exceptionless.Web/Api/Handlers/AdminHandler.cs +++ b/src/Exceptionless.Web/Api/Handlers/AdminHandler.cs @@ -146,23 +146,23 @@ public async Task> Handle(GetAdminProductTourUsage message) int limit = Math.Clamp(message.Limit, 1, 500); var usage = await eventRepository.GetProductTourUsageAsync(appOptions.InternalProjectId, month, nextMonth, limit); - var tours = usage.Tours - .Select(tour => + var tours = usage.Buckets + .GroupBy(bucket => bucket.Source.TourName, StringComparer.Ordinal) + .Select(buckets => { - long shown = SumEvent(tour.Buckets, ProductTourTelemetryEvent.Shown); - long started = SumEvent(tour.Buckets, ProductTourTelemetryEvent.Started); - long completed = SumEvent(tour.Buckets, ProductTourTelemetryEvent.Completed); - long dismissed = SumEvent(tour.Buckets, ProductTourTelemetryEvent.Dismissed); + long shown = SumEvent(buckets, ProductTourTelemetryEvent.Shown); + long started = SumEvent(buckets, ProductTourTelemetryEvent.Started); + long completed = SumEvent(buckets, ProductTourTelemetryEvent.Completed); + long dismissed = SumEvent(buckets, ProductTourTelemetryEvent.Dismissed); long decisionDenominator = started > 0 ? started : shown; - DateTime? lastRunUtc = tour.Buckets.Select(bucket => bucket.LastUtc).Max(); + DateTime? lastRunUtc = buckets.Select(bucket => bucket.LastUtc).Max(); - return new AdminProductTourSummary( - tour.Name, + return new ProductTourSummary( + buckets.Key, shown, started, completed, dismissed, - tour.UniqueUsers, lastRunUtc, CalculateRate(completed, decisionDenominator), CalculateRate(dismissed, decisionDenominator)); @@ -171,15 +171,15 @@ public async Task> Handle(GetAdminProductTourUsage message) .ThenBy(tour => tour.Name, StringComparer.Ordinal) .ToArray(); - var recentActivity = usage.RecentEvents - .Select(item => CreateActivity(item.Event, item.Source)) + var recentEvents = usage.RecentEvents + .Select(item => CreateRecentEvent(item.Event, item.Source)) .Take(limit) .ToArray(); - return new AdminProductTourUsageResponse( + return new ProductTourUsageResponse( month, tours, - recentActivity); + recentEvents); } [HandlerEndpoint(HandlerMethod.Get, "migrations", Group = "Admin")] @@ -220,10 +220,10 @@ public Task> Handle(GetAdminEcho message) }); } - private AdminProductTourActivity CreateActivity(PersistentEvent ev, ProductTourUsageSource source) + private ProductTourEvent CreateRecentEvent(PersistentEvent ev, ProductTourUsageSource source) { var user = ev.GetUserIdentity(serializer, _logger); - return new AdminProductTourActivity( + return new ProductTourEvent( ev.Date.UtcDateTime, source.Event, source.LaunchSource, diff --git a/src/Exceptionless.Web/Api/Handlers/UserHandler.cs b/src/Exceptionless.Web/Api/Handlers/UserHandler.cs index 387f60a0d2..68e3933dfd 100644 --- a/src/Exceptionless.Web/Api/Handlers/UserHandler.cs +++ b/src/Exceptionless.Web/Api/Handlers/UserHandler.cs @@ -74,7 +74,6 @@ await repository.PatchAsync( progress = new ProductTourProgress { Status = message.Progress.Status!.Value, - UpdatedUtc = timeProvider.GetUtcNow().UtcDateTime, Version = message.Progress.Version }; user.ProductTours[message.TourName] = progress; diff --git a/src/Exceptionless.Web/ClientApp/e2e/fixtures/api-client.ts b/src/Exceptionless.Web/ClientApp/e2e/fixtures/api-client.ts index 1451c7f25c..4092bedede 100644 --- a/src/Exceptionless.Web/ClientApp/e2e/fixtures/api-client.ts +++ b/src/Exceptionless.Web/ClientApp/e2e/fixtures/api-client.ts @@ -309,7 +309,7 @@ export class E2EApiClient { await expectStatus(response, [202], 'submit event'); } - async updateProductTour(token: string, tourName: string, version: number, status: 'completed' | 'dismissed'): Promise { + async updateProductTour(token: string, tourName: string, version: number, status: 1 | 2): Promise { const response = await this.request.put(this.url(`users/me/product-tours/${tourName}`), { data: { status, version }, headers: this.authHeaders(token) diff --git a/src/Exceptionless.Web/ClientApp/e2e/fixtures/e2e-test.ts b/src/Exceptionless.Web/ClientApp/e2e/fixtures/e2e-test.ts index 380578c279..0b3877461f 100644 --- a/src/Exceptionless.Web/ClientApp/e2e/fixtures/e2e-test.ts +++ b/src/Exceptionless.Web/ClientApp/e2e/fixtures/e2e-test.ts @@ -89,7 +89,7 @@ export const test = base.extend({ projectId = project.id; const projectToken = await e2eApi.getProjectDefaultToken(userToken, project.id); if (e2eDismissProductTourWelcome) { - await e2eApi.updateProductTour(userToken, 'welcome', 1, 'dismissed'); + await e2eApi.updateProductTour(userToken, 'app-welcome', 1, 2); } await page.addInitScript( diff --git a/src/Exceptionless.Web/ClientApp/e2e/tests/chart-refresh.e2e.ts b/src/Exceptionless.Web/ClientApp/e2e/tests/chart-refresh.e2e.ts index b192730f74..5304344114 100644 --- a/src/Exceptionless.Web/ClientApp/e2e/tests/chart-refresh.e2e.ts +++ b/src/Exceptionless.Web/ClientApp/e2e/tests/chart-refresh.e2e.ts @@ -4,7 +4,7 @@ import { expect, test } from '../fixtures/e2e-test'; test('dashboard charts stay mounted while list data refreshes', async ({ e2eApi, page }) => { const userToken = await e2eApi.login(); - await e2eApi.updateProductTour(userToken, 'welcome', 1, 'dismissed'); + await e2eApi.updateProductTour(userToken, 'app-welcome', 1, 2); const organizations = await e2eApi.getOrganizations(userToken); const organizationId = organizations[0]?.id; expect(organizationId).toBeTruthy(); diff --git a/src/Exceptionless.Web/ClientApp/e2e/tests/product-tours.e2e.ts b/src/Exceptionless.Web/ClientApp/e2e/tests/product-tours.e2e.ts index 04496a2594..d9021fd3e0 100644 --- a/src/Exceptionless.Web/ClientApp/e2e/tests/product-tours.e2e.ts +++ b/src/Exceptionless.Web/ClientApp/e2e/tests/product-tours.e2e.ts @@ -15,7 +15,7 @@ test.describe('first-run welcome', () => { await expect(page.getByRole('dialog', { name: 'Welcome to Exceptionless' })).toBeVisible(); }); - const persisted = page.waitForResponse(isSuccessfulTourProgress('welcome')); + const persisted = page.waitForResponse(isSuccessfulTourProgress('app-welcome')); await page.getByRole('dialog', { name: 'Welcome to Exceptionless' }).getByRole('button', { name: 'Browse Guides' }).click(); await persisted; @@ -42,7 +42,7 @@ test.describe('shell and identity checkpoints', () => { await test.step('closing the welcome persists dismissal', async () => { await page.goto('/next/stack'); await expect(page.getByRole('dialog', { name: 'Welcome to Exceptionless' })).toBeVisible(); - const dismissed = page.waitForResponse(isSuccessfulTourProgress('welcome')); + const dismissed = page.waitForResponse(isSuccessfulTourProgress('app-welcome')); await page.keyboard.press('Escape'); await dismissed; await expect(page.getByRole('dialog', { name: 'Welcome to Exceptionless' })).toBeHidden(); @@ -62,7 +62,7 @@ test.describe('shell and identity checkpoints', () => { await page.reload(); await expect(tour.getByText('Find anything quickly')).toBeVisible(); - const dismissed = page.waitForResponse(isSuccessfulTourProgress('ui-overview')); + const dismissed = page.waitForResponse(isSuccessfulTourProgress('app-overview')); await tour.getByRole('button', { name: 'Close' }).click(); await dismissed; await expectProductTourSession(page, false); @@ -118,7 +118,7 @@ test('domain workflows advance only on real success', async ({ e2eApi, e2eScenar await page.locator('[data-tour="project-configure-platform"]').click(); await page.getByRole('option', { name: 'Browser applications' }).click(); - await page.locator('[data-product-tour-inline="configure-project"]').getByRole('button', { name: 'Continue' }).click(); + await page.locator('[data-product-tour-inline="project-configure"]').getByRole('button', { name: 'Continue' }).click(); await expect(page.getByText('Waiting for your first event')).toBeVisible(); try { @@ -148,7 +148,7 @@ test('domain workflows advance only on real success', async ({ e2eApi, e2eScenar const path = new URL(request.url()).pathname; if (request.method() === 'POST' && /^\/api\/v2\/organizations\/[^/]+\/saved-views$/.test(path)) createRequests += 1; }; - const progressRoute = (url: URL) => url.pathname === '/api/v2/users/me/product-tours/create-saved-view'; + const progressRoute = (url: URL) => url.pathname === '/api/v2/users/me/product-tours/saved-view-create'; page.on('request', countSavedViewCreation); await page.route(progressRoute, async (route) => { progressRequests += 1; @@ -177,7 +177,7 @@ test('domain workflows advance only on real success', async ({ e2eApi, e2eScenar await page.reload(); await expect(page.getByRole('button', { name: 'Retry guide completion' })).toBeVisible(); - const completed = page.waitForResponse(isSuccessfulTourProgress('create-saved-view')); + const completed = page.waitForResponse(isSuccessfulTourProgress('saved-view-create')); await page.getByRole('button', { name: 'Retry guide completion' }).click(); await completed; await expect.poll(() => createRequests).toBe(1); @@ -200,19 +200,19 @@ test('domain workflows advance only on real success', async ({ e2eApi, e2eScenar await startTourFromCommand(page, 'Investigate an error'); await page.locator('.driver-popover').getByRole('button', { name: 'Continue' }).click(); await page.locator('tr').filter({ hasText: e2eScenario.message }).first().click(); - const callout = page.locator('[data-product-tour-inline="investigate-error"]'); + const callout = page.locator('[data-product-tour-inline="event-investigate"]'); await expect(callout.getByText('Understand the grouped issue')).toBeVisible(); for (const title of ['Triage deliberately', 'Inspect the occurrence', 'Begin with the overview', 'Compare every occurrence']) { await callout.getByRole('button', { name: 'Continue' }).click(); await expect(callout.getByText(title)).toBeVisible(); } - const completed = page.waitForResponse(isSuccessfulTourProgress('investigate-error')); + const completed = page.waitForResponse(isSuccessfulTourProgress('event-investigate')); await callout.getByRole('button', { name: 'Finish guide' }).click(); await completed; await expectProductTourSession(page, false); await page.reload(); - await expect(page.locator('[data-product-tour-inline="investigate-error"]')).toBeHidden(); + await expect(page.locator('[data-product-tour-inline="event-investigate"]')).toBeHidden(); }); await test.step('Exie opens context without provider submission', async () => { diff --git a/src/Exceptionless.Web/ClientApp/src/lib/features/admin/api.svelte.ts b/src/Exceptionless.Web/ClientApp/src/lib/features/admin/api.svelte.ts index cee4ec4d77..8c5fe6d06e 100644 --- a/src/Exceptionless.Web/ClientApp/src/lib/features/admin/api.svelte.ts +++ b/src/Exceptionless.Web/ClientApp/src/lib/features/admin/api.svelte.ts @@ -6,7 +6,6 @@ import type { AdminAssistantSettings, AdminAssistantUsage, AdminEventSubmissionSettings, - AdminProductTourUsage, AdminStats, ElasticsearchInfo, ElasticsearchSnapshotsResponse, @@ -14,6 +13,7 @@ import type { OAuthApplication, OAuthApplicationRequest, PredefinedSavedViewDefinition, + ProductTourUsageResponse, UpdateAssistantEnabledSettingsRequest, UpdateAssistantSettingsRequest, UpdateEventSubmissionSettingsRequest @@ -113,10 +113,10 @@ export function getAdminAssistantUsageQuery(month: () => string) { } export function getAdminProductTourUsageQuery(month: () => string) { - return createQuery(() => ({ + return createQuery(() => ({ queryFn: async ({ signal }: { signal: AbortSignal }) => { const client = useFetchClient(); - const response = await client.getJSON('admin/product-tour-usage', { + const response = await client.getJSON('admin/product-tour-usage', { params: { limit: 100, month: `${month()}-01` diff --git a/src/Exceptionless.Web/ClientApp/src/lib/features/admin/models.ts b/src/Exceptionless.Web/ClientApp/src/lib/features/admin/models.ts index f128097c5e..06fc5b56d6 100644 --- a/src/Exceptionless.Web/ClientApp/src/lib/features/admin/models.ts +++ b/src/Exceptionless.Web/ClientApp/src/lib/features/admin/models.ts @@ -1,12 +1,13 @@ import type { AssistantModelSettings, CountResult, + EventSubmissionSettings, UpdateAssistantEnabledSettings, UpdateAssistantSettings, UpdateEventSubmissionSettings } from '$generated/api'; -export type { EventSubmissionSettings as AdminEventSubmissionSettings, AdminProductTourUsageResponse as AdminProductTourUsage } from '$generated/api'; +export type { ProductTourUsageResponse } from '$generated/api'; export enum MigrationType { Versioned = 0, @@ -50,6 +51,8 @@ export type AdminAssistantUsage = { turns: number; }; +export type AdminEventSubmissionSettings = EventSubmissionSettings; + export type AdminStats = { events: CountResult; organizations: CountResult; diff --git a/src/Exceptionless.Web/ClientApp/src/lib/features/events/components/investigation-detail-tour.svelte b/src/Exceptionless.Web/ClientApp/src/lib/features/events/components/investigation-detail-tour.svelte index 8c7afe291d..1802508462 100644 --- a/src/Exceptionless.Web/ClientApp/src/lib/features/events/components/investigation-detail-tour.svelte +++ b/src/Exceptionless.Web/ClientApp/src/lib/features/events/components/investigation-detail-tour.svelte @@ -16,7 +16,7 @@ let advancedEventId = $state(''); const actions = createProductTourActions(); - const checkpoint = $derived(productTourCheckpoint.current?.tourName === 'investigate-error' ? productTourCheckpoint.current : undefined); + const checkpoint = $derived(productTourCheckpoint.current?.tourName === 'event-investigate' ? productTourCheckpoint.current : undefined); const copy = $derived.by(() => { switch (checkpoint?.checkpointName) { case 'event-occurrence': @@ -109,6 +109,6 @@ onContinue={continueTour} onDismiss={dismiss} title={copy.title} - tourName="investigate-error" + tourName="event-investigate" /> {/if} diff --git a/src/Exceptionless.Web/ClientApp/src/lib/features/events/components/investigation-list-tour.svelte b/src/Exceptionless.Web/ClientApp/src/lib/features/events/components/investigation-list-tour.svelte index 29f1dcb8de..07d9315c05 100644 --- a/src/Exceptionless.Web/ClientApp/src/lib/features/events/components/investigation-list-tour.svelte +++ b/src/Exceptionless.Web/ClientApp/src/lib/features/events/components/investigation-list-tour.svelte @@ -4,7 +4,7 @@ import { productTourCheckpoint } from '$features/product-tours/state.svelte'; const actions = createProductTourActions(); - const checkpoint = $derived(productTourCheckpoint.current?.tourName === 'investigate-error' ? productTourCheckpoint.current : undefined); + const checkpoint = $derived(productTourCheckpoint.current?.tourName === 'event-investigate' ? productTourCheckpoint.current : undefined); {#if checkpoint?.checkpointName === 'filter-errors'} diff --git a/src/Exceptionless.Web/ClientApp/src/lib/features/product-tours/catalog.test.ts b/src/Exceptionless.Web/ClientApp/src/lib/features/product-tours/catalog.test.ts index 13f1c376f8..227a8fbfdb 100644 --- a/src/Exceptionless.Web/ClientApp/src/lib/features/product-tours/catalog.test.ts +++ b/src/Exceptionless.Web/ClientApp/src/lib/features/product-tours/catalog.test.ts @@ -16,24 +16,22 @@ function context(overrides: Partial = {}): ProductTourContex } describe('product tour catalog', () => { - const versions = Object.fromEntries(productTourCatalog.map((tour) => [tour.name, 1])); - it('contains only durable metadata for the five named tours', () => { expect(productTourCatalog.map((tour) => tour.name)).toEqual([ - 'ui-overview', - 'configure-project', - 'create-saved-view', - 'investigate-error', - 'meet-exie' + 'app-overview', + 'project-configure', + 'saved-view-create', + 'event-investigate', + 'exie-overview' ]); expect(productTourCatalog.every((tour) => tour.keywords.length > 0)).toBe(true); expect(JSON.stringify(productTourCatalog)).not.toContain('data-tour'); }); it('recommends setup until an organization has configured projects', () => { - expect(getRecommendedProductTourName(context({ organizationId: undefined }))).toBe('configure-project'); - expect(getRecommendedProductTourName(context({ projects: [{ is_configured: false }] }))).toBe('configure-project'); - expect(getRecommendedProductTourName(context({ projects: [{ is_configured: true }] }))).toBe('ui-overview'); + expect(getRecommendedProductTourName(context({ organizationId: undefined }))).toBe('project-configure'); + expect(getRecommendedProductTourName(context({ projects: [{ is_configured: false }] }))).toBe('project-configure'); + expect(getRecommendedProductTourName(context({ projects: [{ is_configured: true }] }))).toBe('app-overview'); }); it('reports availability separately from catalog metadata', () => { @@ -41,15 +39,13 @@ describe('product tour catalog', () => { context({ assistantAccess: { enabled: false, has_access: false, upgrade_required: false }, errorEventAvailability: 'empty' - }), - versions + }) ); - expect(items.find((item) => item.name === 'meet-exie')?.currentAvailability.available).toBe(false); - expect(items.find((item) => item.name === 'investigate-error')?.currentAvailability.available).toBe(false); + expect(items.find((item) => item.name === 'exie-overview')?.currentAvailability.available).toBe(false); + expect(items.find((item) => item.name === 'event-investigate')?.currentAvailability.available).toBe(false); }); - it('uses server versions as the availability boundary', () => { - const items = getProductTourItems(context(), {}); - expect(items.every((item) => !item.currentAvailability.available && item.version === 0)).toBe(true); + it('defines a positive version for every tour', () => { + expect(getProductTourItems(context()).every((item) => item.version > 0)).toBe(true); }); }); diff --git a/src/Exceptionless.Web/ClientApp/src/lib/features/product-tours/catalog.ts b/src/Exceptionless.Web/ClientApp/src/lib/features/product-tours/catalog.ts index 8b26d1f198..025e60e68f 100644 --- a/src/Exceptionless.Web/ClientApp/src/lib/features/product-tours/catalog.ts +++ b/src/Exceptionless.Web/ClientApp/src/lib/features/product-tours/catalog.ts @@ -28,36 +28,40 @@ export const productTourCatalog: readonly ProductTourDefinition[] = [ description: 'Learn navigation, command search, saved views, Exie, and where to get help.', initialCheckpoint: 'navigation', keywords: ['navigation', 'ui', 'search', 'command', 'help', 'saved views'], - name: 'ui-overview', + name: 'app-overview', startingRoute: () => resolve('/'), - title: 'Explore Exceptionless' + title: 'Explore Exceptionless', + version: 1 }, { availability: () => ({ available: true }), description: 'Create or resume a project, connect an SDK, and wait for its first real event.', initialCheckpoint: 'project-name', keywords: ['add project', 'configure', 'sdk', 'api key', 'first event'], - name: 'configure-project', + name: 'project-configure', startingRoute: (context) => (context.organizationId ? resolve('/(app)/project/add') : resolve('/(app)/organization/add')), - title: 'Configure a project' + title: 'Configure a project', + version: 1 }, { availability: requireOrganization, description: 'Save the current Events configuration as a private view that only you can see.', initialCheckpoint: 'open-view-menu', keywords: ['saved view', 'filter', 'columns', 'private', 'dashboard'], - name: 'create-saved-view', + name: 'saved-view-create', startingRoute: () => resolve('/(app)/event'), - title: 'Create a saved view' + title: 'Create a saved view', + version: 1 }, { availability: requireError, description: 'Open a real error, assess its stack and status, then inspect the occurrence.', initialCheckpoint: 'filter-errors', keywords: ['error report', 'event details', 'exception', 'filter', 'stack', 'triage'], - name: 'investigate-error', + name: 'event-investigate', startingRoute: () => `${resolve('/(app)/event')}?time=all&type=error`, - title: 'Investigate an error' + title: 'Investigate an error', + version: 1 }, { availability: (context) => @@ -65,29 +69,23 @@ export const productTourCatalog: readonly ProductTourDefinition[] = [ description: 'See how Exie uses the current page as context without sending a prompt.', initialCheckpoint: 'open-exie', keywords: ['exie', 'assistant', 'ai', 'help', 'investigate'], - name: 'meet-exie', + name: 'exie-overview', startingRoute: () => resolve('/'), - title: 'Meet Exie' + title: 'Meet Exie', + version: 1 } ] as const; -export function getProductTourItems( - context: ProductTourContext, - versions: Record, - progress: Record = {} -): ProductTourListItem[] { +export function getProductTourItems(context: ProductTourContext, progress: Record = {}): ProductTourListItem[] { return productTourCatalog.map((definition) => { - const version = versions[definition.name] ?? 0; return { ...definition, - currentAvailability: - version > 0 ? definition.availability(context) : { available: false, reason: 'This guided tour is not supported by the server.' }, - progress: progress[definition.name], - version + currentAvailability: definition.availability(context), + progress: progress[definition.name] }; }); } export function getRecommendedProductTourName(context: ProductTourContext): ProductTourName { - return !context.organizationId || context.projects.some((project) => !project.is_configured) ? 'configure-project' : 'ui-overview'; + return !context.organizationId || context.projects.some((project) => !project.is_configured) ? 'project-configure' : 'app-overview'; } diff --git a/src/Exceptionless.Web/ClientApp/src/lib/features/product-tours/components/dialogs/product-tour-catalog-dialog.svelte b/src/Exceptionless.Web/ClientApp/src/lib/features/product-tours/components/dialogs/product-tour-catalog-dialog.svelte index 942730a992..097ae08e3a 100644 --- a/src/Exceptionless.Web/ClientApp/src/lib/features/product-tours/components/dialogs/product-tour-catalog-dialog.svelte +++ b/src/Exceptionless.Web/ClientApp/src/lib/features/product-tours/components/dialogs/product-tour-catalog-dialog.svelte @@ -2,6 +2,7 @@ import { Badge } from '$comp/ui/badge'; import { Button } from '$comp/ui/button'; import * as Dialog from '$comp/ui/dialog'; + import { ProductTourStatus } from '$features/users/models'; import Compass from '@lucide/svelte/icons/compass'; import type { ProductTourListItem, ProductTourName } from '../../types'; @@ -29,7 +30,7 @@
- {#if item.progress?.status === 'completed' && item.progress.version >= item.version} + {#if item.progress?.status === ProductTourStatus.Completed && item.progress.version >= item.version} Completed {/if}
@@ -41,7 +42,7 @@ {/if}
{/each} diff --git a/src/Exceptionless.Web/ClientApp/src/lib/features/product-tours/components/dialogs/product-tour-welcome-dialog.svelte b/src/Exceptionless.Web/ClientApp/src/lib/features/product-tours/components/dialogs/product-tour-welcome-dialog.svelte index 3fde333876..5c7b70c523 100644 --- a/src/Exceptionless.Web/ClientApp/src/lib/features/product-tours/components/dialogs/product-tour-welcome-dialog.svelte +++ b/src/Exceptionless.Web/ClientApp/src/lib/features/product-tours/components/dialogs/product-tour-welcome-dialog.svelte @@ -41,7 +41,7 @@
- +
diff --git a/src/Exceptionless.Web/ClientApp/src/lib/features/product-tours/components/dialogs/product-tour-welcome-dialog.svelte.test.ts b/src/Exceptionless.Web/ClientApp/src/lib/features/product-tours/components/dialogs/product-tour-welcome-dialog.svelte.test.ts index a3744dc382..08f6bbe948 100644 --- a/src/Exceptionless.Web/ClientApp/src/lib/features/product-tours/components/dialogs/product-tour-welcome-dialog.svelte.test.ts +++ b/src/Exceptionless.Web/ClientApp/src/lib/features/product-tours/components/dialogs/product-tour-welcome-dialog.svelte.test.ts @@ -9,7 +9,7 @@ const recommended = { description: 'Learn navigation and search.', initialCheckpoint: 'navigation' as const, keywords: ['navigation'], - name: 'ui-overview' as const, + name: 'app-overview' as const, startingRoute: vi.fn(() => '/next'), title: 'Explore Exceptionless', version: 1 diff --git a/src/Exceptionless.Web/ClientApp/src/lib/features/product-tours/components/product-tour-host.svelte b/src/Exceptionless.Web/ClientApp/src/lib/features/product-tours/components/product-tour-host.svelte index b1e9d10e1c..dc64f73a1f 100644 --- a/src/Exceptionless.Web/ClientApp/src/lib/features/product-tours/components/product-tour-host.svelte +++ b/src/Exceptionless.Web/ClientApp/src/lib/features/product-tours/components/product-tour-host.svelte @@ -38,8 +38,10 @@ } const EVENT_PATH = resolve('/(app)/event'); + const EXIE_ANNOUNCEMENT_VERSION = 1; const STACK_PATH = resolve('/(app)/stack'); const SYSTEM_PATH = resolve('/(app)/system'); + const WELCOME_VERSION = 1; let { assistantAccess, @@ -74,11 +76,9 @@ pathname, projects }); - const items = $derived(getProductTourItems(context, currentUser?.product_tour_versions ?? {}, currentUser?.product_tours)); + const items = $derived(getProductTourItems(context, currentUser?.product_tours)); const recommended = $derived(items.find((item) => item.name === getRecommendedProductTourName(context)) ?? items[0]!); const checkpoint = $derived(productTourCheckpoint.current); - const welcomeVersion = $derived(currentUser?.product_tour_versions.welcome ?? 0); - const exieAnnouncementVersion = $derived(currentUser?.product_tour_versions['exie-announcement'] ?? 0); const welcomeOpen = $derived( !!( stateSettled && @@ -89,8 +89,7 @@ !isImpersonating && !isSetupPage && !pathname.startsWith(SYSTEM_PATH) && - welcomeVersion > 0 && - shouldOfferProductTourWelcome(currentUser.product_tours?.welcome, welcomeVersion) + shouldOfferProductTourWelcome(currentUser.product_tours?.['app-welcome'], WELCOME_VERSION) ) ); const exieAnnouncementOpen = $derived( @@ -105,9 +104,8 @@ !welcomeOpen && !catalogOpen && !isAnyOverlayOpen && - !shouldOfferProductTourWelcome(currentUser.product_tours?.welcome, welcomeVersion) && - exieAnnouncementVersion > 0 && - shouldOfferProductTourAnnouncement(currentUser.product_tours?.['exie-announcement'], exieAnnouncementVersion) + !shouldOfferProductTourWelcome(currentUser.product_tours?.['app-welcome'], WELCOME_VERSION) && + shouldOfferProductTourAnnouncement(currentUser.product_tours?.['exie-announcement'], EXIE_ANNOUNCEMENT_VERSION) ) ); @@ -132,10 +130,10 @@ return; } - const impression = `${currentUser.id}:${welcomeVersion}`; + const impression = `${currentUser.id}:${WELCOME_VERSION}`; if (welcomeOpen && lastTrackedWelcomeImpression !== impression) { lastTrackedWelcomeImpression = impression; - void track('shown', 'welcome', welcomeVersion, 'automatic'); + void track('shown', 'app-welcome', WELCOME_VERSION, 'automatic'); } }); @@ -144,10 +142,10 @@ return; } - const impression = `${currentUser.id}:${exieAnnouncementVersion}`; + const impression = `${currentUser.id}:${EXIE_ANNOUNCEMENT_VERSION}`; if (exieAnnouncementOpen && lastTrackedAnnouncementImpression !== impression) { lastTrackedAnnouncementImpression = impression; - void track('shown', 'exie-announcement', exieAnnouncementVersion, 'feature-announcement'); + void track('shown', 'exie-announcement', EXIE_ANNOUNCEMENT_VERSION, 'feature-announcement'); } }); @@ -183,12 +181,12 @@ await goto(destination); } - if (next.tourName === 'meet-exie' && next.checkpointName === 'open-exie') { + if (next.tourName === 'exie-overview' && next.checkpointName === 'open-exie') { setMobileNavigationOpen(false); } } - async function recordPreference(name: 'exie-announcement' | 'welcome', version: number, status: ProductTourStatus): Promise { + async function recordPreference(name: 'app-welcome' | 'exie-announcement', version: number, status: ProductTourStatus): Promise { try { await progressMutation.mutateAsync({ progress: { @@ -205,44 +203,44 @@ } async function onWelcomeStart(): Promise { - if (!(await recordPreference('welcome', welcomeVersion, ProductTourStatus.Completed))) { + if (!(await recordPreference('app-welcome', WELCOME_VERSION, ProductTourStatus.Completed))) { return; } welcomeHandled = true; - await track('completed', 'welcome', welcomeVersion, 'automatic'); + await track('completed', 'app-welcome', WELCOME_VERSION, 'automatic'); await startTour(recommended.name, 'automatic'); } async function onWelcomeBrowse(): Promise { - if (!(await recordPreference('welcome', welcomeVersion, ProductTourStatus.Completed))) { + if (!(await recordPreference('app-welcome', WELCOME_VERSION, ProductTourStatus.Completed))) { return; } welcomeHandled = true; - await track('completed', 'welcome', welcomeVersion, 'automatic'); + await track('completed', 'app-welcome', WELCOME_VERSION, 'automatic'); openCatalog('catalog'); } async function onWelcomeSkip(): Promise { - if (!(await recordPreference('welcome', welcomeVersion, ProductTourStatus.Dismissed))) { + if (!(await recordPreference('app-welcome', WELCOME_VERSION, ProductTourStatus.Dismissed))) { return; } welcomeHandled = true; - await track('dismissed', 'welcome', welcomeVersion, 'automatic'); + await track('dismissed', 'app-welcome', WELCOME_VERSION, 'automatic'); } async function onExieAnnouncementStart(): Promise { - if (!(await recordPreference('exie-announcement', exieAnnouncementVersion, ProductTourStatus.Completed))) { + if (!(await recordPreference('exie-announcement', EXIE_ANNOUNCEMENT_VERSION, ProductTourStatus.Completed))) { return; } - await track('completed', 'exie-announcement', exieAnnouncementVersion, 'feature-announcement'); - await startTour('meet-exie', 'feature-announcement'); + await track('completed', 'exie-announcement', EXIE_ANNOUNCEMENT_VERSION, 'feature-announcement'); + await startTour('exie-overview', 'feature-announcement'); } async function onExieAnnouncementDismiss(): Promise { - if (!(await recordPreference('exie-announcement', exieAnnouncementVersion, ProductTourStatus.Dismissed))) { + if (!(await recordPreference('exie-announcement', EXIE_ANNOUNCEMENT_VERSION, ProductTourStatus.Dismissed))) { return; } - await track('dismissed', 'exie-announcement', exieAnnouncementVersion, 'feature-announcement'); + await track('dismissed', 'exie-announcement', EXIE_ANNOUNCEMENT_VERSION, 'feature-announcement'); } function getItem(name: Name): ProductTourListItem { @@ -263,7 +261,7 @@ startTour(name, catalogSource)} /> -{#if checkpoint && (checkpoint.tourName === 'meet-exie' || checkpoint.tourName === 'ui-overview')} +{#if checkpoint && (checkpoint.tourName === 'exie-overview' || checkpoint.tourName === 'app-overview')} {#key checkpoint} {/key} diff --git a/src/Exceptionless.Web/ClientApp/src/lib/features/product-tours/components/product-tour-shell-spotlight.svelte b/src/Exceptionless.Web/ClientApp/src/lib/features/product-tours/components/product-tour-shell-spotlight.svelte index cc38a9ab01..e41e8616d2 100644 --- a/src/Exceptionless.Web/ClientApp/src/lib/features/product-tours/components/product-tour-shell-spotlight.svelte +++ b/src/Exceptionless.Web/ClientApp/src/lib/features/product-tours/components/product-tour-shell-spotlight.svelte @@ -28,7 +28,7 @@ const currentAssistantAccess = untrack(() => assistantAccess); const currentCheckpoint = untrack(() => checkpoint); const actions = createProductTourActions(); - const meetExieSteps: ShellStep[] = [ + const exieOverviewSteps: ShellStep[] = [ { checkpointName: 'open-exie', description: 'Open Exie to see the page context available for your next question.', @@ -42,7 +42,7 @@ title: 'You control every request' } ]; - const uiOverviewSteps: ShellStep[] = [ + const appOverviewSteps: ShellStep[] = [ { checkpointName: 'navigation', description: 'Move between dashboards, saved views, and settings from the application navigation.', @@ -78,7 +78,7 @@ title: 'Help is always nearby' } ]; - const steps = currentCheckpoint.tourName === 'ui-overview' ? uiOverviewSteps : meetExieSteps; + const steps = currentCheckpoint.tourName === 'app-overview' ? appOverviewSteps : exieOverviewSteps; const spotlight = steps.find((step) => step.checkpointName === currentCheckpoint.checkpointName); onMount(() => { @@ -90,7 +90,7 @@ }); async function advance(): Promise { - if (currentCheckpoint.tourName === 'meet-exie' && currentCheckpoint.checkpointName === 'open-exie') { + if (currentCheckpoint.tourName === 'exie-overview' && currentCheckpoint.checkpointName === 'open-exie') { await openAssistant(); productTourCheckpoint.advance(currentCheckpoint, 'exie-context'); return; @@ -107,7 +107,7 @@ } -{#if spotlight && (!isAnyOverlayOpen || checkpoint.tourName === 'meet-exie')} +{#if spotlight && (!isAnyOverlayOpen || checkpoint.tourName === 'exie-overview')} { const phase = checkpoint?.phase; return phase?.type === 'saved-view-created' || phase?.type === 'saved-view-loaded' @@ -93,7 +93,7 @@ } } - async function loadAndComplete(active: ProductTourCheckpoint<'create-saved-view'>, view: SavedView): Promise { + async function loadAndComplete(active: ProductTourCheckpoint<'saved-view-create'>, view: SavedView): Promise { try { await onLoadView(view); const loadedCheckpoint = productTourCheckpoint.advance(active, 'view-created', { diff --git a/src/Exceptionless.Web/ClientApp/src/lib/features/product-tours/eligibility.test.ts b/src/Exceptionless.Web/ClientApp/src/lib/features/product-tours/eligibility.test.ts index 65b7d7b3f1..9b2cee9e20 100644 --- a/src/Exceptionless.Web/ClientApp/src/lib/features/product-tours/eligibility.test.ts +++ b/src/Exceptionless.Web/ClientApp/src/lib/features/product-tours/eligibility.test.ts @@ -17,19 +17,19 @@ describe('product tour setup routes', () => { describe('product tour welcome eligibility', () => { it('offers legacy users and a newer welcome version', () => { expect(shouldOfferProductTourWelcome(undefined, 1)).toBe(true); - expect(shouldOfferProductTourWelcome({ status: ProductTourStatus.Completed, updated_utc: '', version: 1 }, 2)).toBe(true); + expect(shouldOfferProductTourWelcome({ status: ProductTourStatus.Completed, version: 1 }, 2)).toBe(true); }); it('suppresses both explicit Start and Skip outcomes for the current version', () => { - expect(shouldOfferProductTourWelcome({ status: ProductTourStatus.Completed, updated_utc: '', version: 1 }, 1)).toBe(false); - expect(shouldOfferProductTourWelcome({ status: ProductTourStatus.Dismissed, updated_utc: '', version: 1 }, 1)).toBe(false); + expect(shouldOfferProductTourWelcome({ status: ProductTourStatus.Completed, version: 1 }, 1)).toBe(false); + expect(shouldOfferProductTourWelcome({ status: ProductTourStatus.Dismissed, version: 1 }, 1)).toBe(false); }); }); describe('product tour feature announcement eligibility', () => { it('offers a new announcement version until explicitly recorded', () => { expect(shouldOfferProductTourAnnouncement(undefined, 1)).toBe(true); - expect(shouldOfferProductTourAnnouncement({ status: ProductTourStatus.Dismissed, updated_utc: '', version: 1 }, 1)).toBe(false); - expect(shouldOfferProductTourAnnouncement({ status: ProductTourStatus.Completed, updated_utc: '', version: 2 }, 1)).toBe(false); + expect(shouldOfferProductTourAnnouncement({ status: ProductTourStatus.Dismissed, version: 1 }, 1)).toBe(false); + expect(shouldOfferProductTourAnnouncement({ status: ProductTourStatus.Completed, version: 2 }, 1)).toBe(false); }); }); diff --git a/src/Exceptionless.Web/ClientApp/src/lib/features/product-tours/session.svelte.test.ts b/src/Exceptionless.Web/ClientApp/src/lib/features/product-tours/session.svelte.test.ts index a14662905a..b548dc623c 100644 --- a/src/Exceptionless.Web/ClientApp/src/lib/features/product-tours/session.svelte.test.ts +++ b/src/Exceptionless.Web/ClientApp/src/lib/features/product-tours/session.svelte.test.ts @@ -9,7 +9,7 @@ const checkpoint: ProductTourCheckpoint = { organizationId: 'organization-id', phase: { type: 'active' }, source: 'command-palette', - tourName: 'investigate-error', + tourName: 'event-investigate', userId: 'user-id', version: 1 }; diff --git a/src/Exceptionless.Web/ClientApp/src/lib/features/product-tours/session.ts b/src/Exceptionless.Web/ClientApp/src/lib/features/product-tours/session.ts index 0b52603cc7..822de6d2f3 100644 --- a/src/Exceptionless.Web/ClientApp/src/lib/features/product-tours/session.ts +++ b/src/Exceptionless.Web/ClientApp/src/lib/features/product-tours/session.ts @@ -1,11 +1,9 @@ -import { ProductTourLaunchSource as ProductTourLaunchSourceContract } from '$generated/api'; - import type { ProductTourCheckpoint, ProductTourLaunchSource, ProductTourName, ProductTourPhase } from './types'; -import { PRODUCT_TOUR_CHECKPOINTS } from './types'; +import { PRODUCT_TOUR_CHECKPOINTS, PRODUCT_TOUR_LAUNCH_SOURCES } from './types'; const SESSION_KEY = 'exceptionless.product-tour'; -const SOURCES = new Set(Object.values(ProductTourLaunchSourceContract)); +const SOURCES = new Set(PRODUCT_TOUR_LAUNCH_SOURCES); export function clearProductTourSession(storage: Pick = sessionStorage): void { storage.removeItem(SESSION_KEY); @@ -37,7 +35,7 @@ function isPhase(value: unknown, tourName: string, checkpointName: unknown): val if (!isRecord(value) || typeof value.type !== 'string') return false; if (value.type === 'active') return true; return ( - tourName === 'create-saved-view' && + tourName === 'saved-view-create' && checkpointName === 'view-created' && (value.type === 'saved-view-created' || value.type === 'saved-view-loaded') && typeof value.viewId === 'string' && diff --git a/src/Exceptionless.Web/ClientApp/src/lib/features/product-tours/state.svelte.test.ts b/src/Exceptionless.Web/ClientApp/src/lib/features/product-tours/state.svelte.test.ts index d1d4d2f6f7..fcb3e1a479 100644 --- a/src/Exceptionless.Web/ClientApp/src/lib/features/product-tours/state.svelte.test.ts +++ b/src/Exceptionless.Web/ClientApp/src/lib/features/product-tours/state.svelte.test.ts @@ -9,7 +9,7 @@ const checkpoint: ProductTourCheckpoint = { organizationId: 'organization-id', phase: { type: 'active' }, source: 'catalog', - tourName: 'ui-overview', + tourName: 'app-overview', userId: 'user-id', version: 1 }; diff --git a/src/Exceptionless.Web/ClientApp/src/lib/features/product-tours/telemetry.test.ts b/src/Exceptionless.Web/ClientApp/src/lib/features/product-tours/telemetry.test.ts index a7ff2b5165..a99276303c 100644 --- a/src/Exceptionless.Web/ClientApp/src/lib/features/product-tours/telemetry.test.ts +++ b/src/Exceptionless.Web/ClientApp/src/lib/features/product-tours/telemetry.test.ts @@ -4,10 +4,10 @@ import { buildProductTourTelemetryEvent } from './telemetry'; describe('product tour telemetry', () => { it('records stable lifecycle events without resource data', () => { - expect(buildProductTourTelemetryEvent('started', 'ui-overview', 1, 'command-palette')).toBe('product-tour.started.ui-overview.v1.command-palette'); + expect(buildProductTourTelemetryEvent('started', 'app-overview', 1, 'command-palette')).toBe('product-tour.started.app-overview.v1.command-palette'); }); it('rejects invalid versions', () => { - expect(() => buildProductTourTelemetryEvent('started', 'meet-exie', 0, 'catalog')).toThrow(); + expect(() => buildProductTourTelemetryEvent('started', 'exie-overview', 0, 'catalog')).toThrow(); }); }); diff --git a/src/Exceptionless.Web/ClientApp/src/lib/features/product-tours/types.ts b/src/Exceptionless.Web/ClientApp/src/lib/features/product-tours/types.ts index 87d794902b..f13f8916c5 100644 --- a/src/Exceptionless.Web/ClientApp/src/lib/features/product-tours/types.ts +++ b/src/Exceptionless.Web/ClientApp/src/lib/features/product-tours/types.ts @@ -1,16 +1,16 @@ import type { AssistantAccess } from '$features/assistant/models'; import type { ViewProject } from '$features/projects/models'; import type { ProductTourProgress } from '$features/users/models'; -import type { ProductTourLaunchSource as ProductTourLaunchSourceContract } from '$generated/api'; - export const PRODUCT_TOUR_CHECKPOINTS = { - 'configure-project': ['organization-name', 'project-name', 'choose-platform', 'sdk-instructions', 'wait-for-event'], - 'create-saved-view': ['open-view-menu', 'review-settings', 'name-view', 'private-view', 'save-view', 'view-created'], - 'investigate-error': ['filter-errors', 'choose-error', 'stack-summary', 'stack-triage', 'event-occurrence', 'tab-overview', 'filter-stack-events'], - 'meet-exie': ['open-exie', 'exie-context'], - 'ui-overview': ['navigation', 'command-search', 'saved-views', 'exie', 'help'] + 'app-overview': ['navigation', 'command-search', 'saved-views', 'exie', 'help'], + 'event-investigate': ['filter-errors', 'choose-error', 'stack-summary', 'stack-triage', 'event-occurrence', 'tab-overview', 'filter-stack-events'], + 'exie-overview': ['open-exie', 'exie-context'], + 'project-configure': ['organization-name', 'project-name', 'choose-platform', 'sdk-instructions', 'wait-for-event'], + 'saved-view-create': ['open-view-menu', 'review-settings', 'name-view', 'private-view', 'save-view', 'view-created'] } as const; +export const PRODUCT_TOUR_LAUNCH_SOURCES = ['automatic', 'catalog', 'command-palette', 'feature-announcement', 'help-menu'] as const; + export interface ProductTourAvailability { available: boolean; reason?: string; @@ -43,19 +43,19 @@ export interface ProductTourDefinition string; title: string; + version: number; } -export type ProductTourKey = 'exie-announcement' | 'welcome' | ProductTourName; +export type ProductTourKey = 'app-welcome' | 'exie-announcement' | ProductTourName; -export type ProductTourLaunchSource = `${ProductTourLaunchSourceContract}`; +export type ProductTourLaunchSource = (typeof PRODUCT_TOUR_LAUNCH_SOURCES)[number]; export interface ProductTourListItem extends ProductTourDefinition { currentAvailability: ProductTourAvailability; progress?: ProductTourProgress; - version: number; } export type ProductTourName = keyof typeof PRODUCT_TOUR_CHECKPOINTS; export type ProductTourPhase = - (Name extends 'create-saved-view' ? { type: 'saved-view-created' | 'saved-view-loaded'; viewId: string } : never) | { type: 'active' }; + (Name extends 'saved-view-create' ? { type: 'saved-view-created' | 'saved-view-loaded'; viewId: string } : never) | { type: 'active' }; diff --git a/src/Exceptionless.Web/ClientApp/src/lib/features/saved-views/components/saved-view-picker.svelte b/src/Exceptionless.Web/ClientApp/src/lib/features/saved-views/components/saved-view-picker.svelte index 74ad57afcf..11b6ffd02f 100644 --- a/src/Exceptionless.Web/ClientApp/src/lib/features/saved-views/components/saved-view-picker.svelte +++ b/src/Exceptionless.Web/ClientApp/src/lib/features/saved-views/components/saved-view-picker.svelte @@ -15,7 +15,7 @@ import { serializeFilters } from '$features/events/components/filters/helpers.svelte'; import { getOrganizationQuery, getOrganizationsQuery } from '$features/organizations/api.svelte'; import { organization } from '$features/organizations/context.svelte'; - import CreateSavedViewTour from '$features/product-tours/components/create-saved-view-tour.svelte'; + import SavedViewCreateTour from '$features/product-tours/components/saved-view-create-tour.svelte'; import { supportsColumnWrapping } from '$features/shared/components/data-table/column-meta'; import { getMeQuery } from '$features/users/api.svelte'; import Building2 from '@lucide/svelte/icons/building-2'; @@ -121,7 +121,7 @@ let isColumnDialogOpen = $state(false); let isMenuOpen = $state(false); let viewToDelete = $state(null); - let createSavedViewTour = $state(); + let savedViewCreateTour = $state(); const organizationId = $derived(organization.current); const activeView = $derived(activeSavedView); @@ -261,7 +261,7 @@ return; } - const tour = createSavedViewTour; + const tour = savedViewCreateTour; if (tour && !tour.validateSave(isPrivate)) { return; } @@ -502,18 +502,18 @@ {#if isSaveDialogOpen} createSavedViewTour?.closed()} + onClose={() => savedViewCreateTour?.closed()} {onLoadView} /> {/if} - (isMenuOpen = false)} openMenu={() => (isMenuOpen = true)} openSaveDialog={() => (isSaveDialogOpen = true)} diff --git a/src/Exceptionless.Web/ClientApp/src/lib/features/stacks/components/stack-card.svelte b/src/Exceptionless.Web/ClientApp/src/lib/features/stacks/components/stack-card.svelte index a5dce7ded1..c4e17c0212 100644 --- a/src/Exceptionless.Web/ClientApp/src/lib/features/stacks/components/stack-card.svelte +++ b/src/Exceptionless.Web/ClientApp/src/lib/features/stacks/components/stack-card.svelte @@ -185,12 +185,10 @@
-
- - - - -
+ + + +
diff --git a/src/Exceptionless.Web/ClientApp/src/lib/generated/api.ts b/src/Exceptionless.Web/ClientApp/src/lib/generated/api.ts index d3244c3f8e..b4fab356dc 100644 --- a/src/Exceptionless.Web/ClientApp/src/lib/generated/api.ts +++ b/src/Exceptionless.Web/ClientApp/src/lib/generated/api.ts @@ -15,8 +15,8 @@ export enum ProductTourTelemetryEvent { } export enum ProductTourStatus { - Completed = "completed", - Dismissed = "dismissed", + Completed = 1, + Dismissed = 2, } export enum ProductTourLaunchSource { @@ -93,47 +93,6 @@ export interface AdminAssistantUsageResponse { organizations: AdminAssistantOrganizationUsage[]; } -export interface AdminProductTourActivity { - /** @format date-time */ - date_utc: string; - event: ProductTourTelemetryEvent; - launch_source: ProductTourLaunchSource; - tour_name: string; - user_identity?: null | string; - user_name?: null | string; - /** @format int32 */ - version: number; - /** @format int64 */ - count: number; -} - -export interface AdminProductTourSummary { - name: string; - /** @format int64 */ - shown: number; - /** @format int64 */ - started: number; - /** @format int64 */ - completed: number; - /** @format int64 */ - dismissed: number; - /** @format int64 */ - unique_users: number; - /** @format date-time */ - last_run_utc?: null | string; - /** @format double */ - completion_rate?: null | number; - /** @format double */ - dismissal_rate?: null | number; -} - -export interface AdminProductTourUsageResponse { - /** @format date-time */ - month: string; - tours: AdminProductTourSummary[]; - recent_activity: AdminProductTourActivity[]; -} - export interface AssistantAccessResponse { enabled: boolean; has_access: boolean; @@ -555,14 +514,51 @@ export interface ProblemDetails { instance?: null | string; } +export interface ProductTourEvent { + /** @format date-time */ + date_utc: string; + event: ProductTourTelemetryEvent; + launch_source: ProductTourLaunchSource; + tour_name: string; + user_identity?: null | string; + user_name?: null | string; + /** @format int32 */ + version: number; + /** @format int64 */ + count: number; +} + export interface ProductTourProgress { status: ProductTourStatus; - /** @format date-time */ - updated_utc: string; /** @format int32 */ version: number; } +export interface ProductTourSummary { + name: string; + /** @format int64 */ + shown: number; + /** @format int64 */ + started: number; + /** @format int64 */ + completed: number; + /** @format int64 */ + dismissed: number; + /** @format date-time */ + last_run_utc?: null | string; + /** @format double */ + completion_rate?: null | number; + /** @format double */ + dismissal_rate?: null | number; +} + +export interface ProductTourUsageResponse { + /** @format date-time */ + month: string; + tours: ProductTourSummary[]; + recent_events: ProductTourEvent[]; +} + export interface ResetPasswordModel { password_reset_token: string; password: string; @@ -865,7 +861,6 @@ export interface ViewCurrentUser { organization_preferences: UserOrganizationPreference[]; saved_view_orders: UserSavedViewOrderPreference[]; product_tours: Record; - product_tour_versions: Record; /** @pattern ^[a-fA-F0-9]{24}$ */ id: string; organization_ids: string[]; diff --git a/src/Exceptionless.Web/ClientApp/src/lib/generated/schemas.ts b/src/Exceptionless.Web/ClientApp/src/lib/generated/schemas.ts index 45ac028abe..d963cd56a6 100644 --- a/src/Exceptionless.Web/ClientApp/src/lib/generated/schemas.ts +++ b/src/Exceptionless.Web/ClientApp/src/lib/generated/schemas.ts @@ -33,7 +33,7 @@ export const ProductTourTelemetryEventSchema = zodEnum([ "shown", "started", ]); -export const ProductTourStatusSchema = zodEnum(["completed", "dismissed"]); +export const ProductTourStatusSchema = union([literal(1), literal(2)]); export const ProductTourLaunchSourceSchema = zodEnum([ "automatic", "catalog", @@ -89,44 +89,6 @@ export type AdminAssistantUsageResponseFormData = Infer< typeof AdminAssistantUsageResponseSchema >; -export const AdminProductTourActivitySchema = object({ - date_utc: iso.datetime(), - event: ProductTourTelemetryEventSchema, - launch_source: ProductTourLaunchSourceSchema, - tour_name: string().min(1, "Tour name is required"), - user_identity: string().min(1, "User identity is required").nullable(), - user_name: string().min(1, "User name is required").nullable(), - version: int32(), - count: int(), -}); -export type AdminProductTourActivityFormData = Infer< - typeof AdminProductTourActivitySchema ->; - -export const AdminProductTourSummarySchema = object({ - name: string().min(1, "Name is required"), - shown: int(), - started: int(), - completed: int(), - dismissed: int(), - unique_users: int(), - last_run_utc: iso.datetime().nullable(), - completion_rate: number().nullable(), - dismissal_rate: number().nullable(), -}); -export type AdminProductTourSummaryFormData = Infer< - typeof AdminProductTourSummarySchema ->; - -export const AdminProductTourUsageResponseSchema = object({ - month: iso.datetime(), - tours: array(lazy(() => AdminProductTourSummarySchema)), - recent_activity: array(lazy(() => AdminProductTourActivitySchema)), -}); -export type AdminProductTourUsageResponseFormData = Infer< - typeof AdminProductTourUsageResponseSchema ->; - export const AssistantAccessResponseSchema = object({ enabled: boolean(), has_access: boolean(), @@ -678,15 +640,47 @@ export const ProblemDetailsSchema = object({ }); export type ProblemDetailsFormData = Infer; +export const ProductTourEventSchema = object({ + date_utc: iso.datetime(), + event: ProductTourTelemetryEventSchema, + launch_source: ProductTourLaunchSourceSchema, + tour_name: string().min(1, "Tour name is required"), + user_identity: string().min(1, "User identity is required").nullable(), + user_name: string().min(1, "User name is required").nullable(), + version: int32(), + count: int(), +}); +export type ProductTourEventFormData = Infer; + export const ProductTourProgressSchema = object({ status: ProductTourStatusSchema, - updated_utc: iso.datetime(), version: int32(), }); export type ProductTourProgressFormData = Infer< typeof ProductTourProgressSchema >; +export const ProductTourSummarySchema = object({ + name: string().min(1, "Name is required"), + shown: int(), + started: int(), + completed: int(), + dismissed: int(), + last_run_utc: iso.datetime().nullable(), + completion_rate: number().nullable(), + dismissal_rate: number().nullable(), +}); +export type ProductTourSummaryFormData = Infer; + +export const ProductTourUsageResponseSchema = object({ + month: iso.datetime(), + tours: array(lazy(() => ProductTourSummarySchema)), + recent_events: array(lazy(() => ProductTourEventSchema)), +}); +export type ProductTourUsageResponseFormData = Infer< + typeof ProductTourUsageResponseSchema +>; + export const ResetPasswordModelSchema = object({ password_reset_token: string().length( 40, @@ -998,7 +992,6 @@ export const ViewCurrentUserSchema = object({ string(), lazy(() => ProductTourProgressSchema), ), - product_tour_versions: record(string(), number()).optional(), id: string() .length(24, "Id must be exactly 24 characters") .regex(/^[a-fA-F0-9]{24}$/, "Id has invalid format"), diff --git a/src/Exceptionless.Web/ClientApp/src/routes/(app)/(components)/navigation-command.svelte.test.ts b/src/Exceptionless.Web/ClientApp/src/routes/(app)/(components)/navigation-command.svelte.test.ts index 035504f0a6..093b8c446d 100644 --- a/src/Exceptionless.Web/ClientApp/src/routes/(app)/(components)/navigation-command.svelte.test.ts +++ b/src/Exceptionless.Web/ClientApp/src/routes/(app)/(components)/navigation-command.svelte.test.ts @@ -342,7 +342,7 @@ describe('NavigationCommand guided tours', () => { description: 'Learn the UI.', initialCheckpoint: 'navigation', keywords: ['tour'], - name: 'ui-overview', + name: 'app-overview', startingRoute: vi.fn(() => '/next'), title: 'Explore Exceptionless', version: 1 @@ -353,7 +353,7 @@ describe('NavigationCommand guided tours', () => { }); await fireEvent.click(screen.getByText('Explore Exceptionless')); - expect(startGuidedTour).toHaveBeenCalledWith('ui-overview'); + expect(startGuidedTour).toHaveBeenCalledWith('app-overview'); tourPalette.unmount(); await vi.runAllTimersAsync(); diff --git a/src/Exceptionless.Web/ClientApp/src/routes/(app)/+layout.svelte b/src/Exceptionless.Web/ClientApp/src/routes/(app)/+layout.svelte index 5e77ef3470..df33ceb996 100644 --- a/src/Exceptionless.Web/ClientApp/src/routes/(app)/+layout.svelte +++ b/src/Exceptionless.Web/ClientApp/src/routes/(app)/+layout.svelte @@ -740,7 +740,6 @@ pathname: page.url.pathname, projects }, - meQuery.data?.product_tour_versions ?? {}, meQuery.data?.product_tours ) : [] diff --git a/src/Exceptionless.Web/ClientApp/src/routes/(app)/event/+page.svelte b/src/Exceptionless.Web/ClientApp/src/routes/(app)/event/+page.svelte index 5b34277341..323e8896fe 100644 --- a/src/Exceptionless.Web/ClientApp/src/routes/(app)/event/+page.svelte +++ b/src/Exceptionless.Web/ClientApp/src/routes/(app)/event/+page.svelte @@ -80,7 +80,7 @@ serializeTimeQueryParam } from '../redirect-to-events.svelte'; let selectedEventId: null | string = $state(null); - const investigationCheckpoint = $derived(productTourCheckpoint.current?.tourName === 'investigate-error' ? productTourCheckpoint.current : undefined); + const investigationCheckpoint = $derived(productTourCheckpoint.current?.tourName === 'event-investigate' ? productTourCheckpoint.current : undefined); function handleEventError(problem: ProblemDetails) { showBillingDialogOnUpgradeProblem(problem, organization.current); diff --git a/src/Exceptionless.Web/ClientApp/src/routes/(app)/organization/add/+page.svelte b/src/Exceptionless.Web/ClientApp/src/routes/(app)/organization/add/+page.svelte index d966b7898a..87e39c6e2f 100644 --- a/src/Exceptionless.Web/ClientApp/src/routes/(app)/organization/add/+page.svelte +++ b/src/Exceptionless.Web/ClientApp/src/routes/(app)/organization/add/+page.svelte @@ -36,7 +36,7 @@ const createOrganization = postOrganization(); const createProject = postProject(); const tourActions = createProductTourActions(); - const configureCheckpoint = $derived(productTourCheckpoint.current?.tourName === 'configure-project' ? productTourCheckpoint.current : undefined); + const projectConfigureCheckpoint = $derived(productTourCheckpoint.current?.tourName === 'project-configure' ? productTourCheckpoint.current : undefined); const CREATE_ERROR_MESSAGE = 'Error creating setup. Please try again.'; useHideOrganizationNotifications(); @@ -62,7 +62,7 @@ organization_id: createdOrganization.id } as NewProject); - const checkpoint = configureCheckpoint; + const checkpoint = projectConfigureCheckpoint; if (checkpoint) { productTourCheckpoint.advance( checkpoint, @@ -172,20 +172,22 @@ -{#if configureCheckpoint} +{#if projectConfigureCheckpoint} { productTourCheckpoint.advance(checkpoint, 'project-name'); } : undefined} side="top" - target={configureCheckpoint.checkpointName === 'organization-name' ? "[data-tour='setup-organization-name']" : "[data-tour='project-setup-form']"} - title={configureCheckpoint.checkpointName === 'organization-name' ? 'Name your organization' : 'Name your first project'} + target={projectConfigureCheckpoint.checkpointName === 'organization-name' + ? "[data-tour='setup-organization-name']" + : "[data-tour='project-setup-form']"} + title={projectConfigureCheckpoint.checkpointName === 'organization-name' ? 'Name your organization' : 'Name your first project'} /> {/if} diff --git a/src/Exceptionless.Web/ClientApp/src/routes/(app)/project/[projectId]/configure/+page.svelte b/src/Exceptionless.Web/ClientApp/src/routes/(app)/project/[projectId]/configure/+page.svelte index ab73f50656..ab15681aa2 100644 --- a/src/Exceptionless.Web/ClientApp/src/routes/(app)/project/[projectId]/configure/+page.svelte +++ b/src/Exceptionless.Web/ClientApp/src/routes/(app)/project/[projectId]/configure/+page.svelte @@ -16,7 +16,6 @@ import ProductTourInlineCallout from '$features/product-tours/components/product-tour-inline-callout.svelte'; import ProductTourSpotlight from '$features/product-tours/components/product-tour-spotlight.svelte'; import { productTourCheckpoint } from '$features/product-tours/state.svelte'; - import { getProjectQuery } from '$features/projects/api.svelte'; import { getProjectDefaultTokenQuery, patchToken } from '$features/tokens/api.svelte'; import EnableTokenDialog from '$features/tokens/components/dialogs/enable-token-dialog.svelte'; import { ChangeType, type WebSocketMessageValue } from '$features/websockets/models'; @@ -42,14 +41,6 @@ } } }); - const projectQuery = getProjectQuery({ - route: { - get id() { - return projectId; - } - } - }); - const apiKey = $derived(defaultTokenQuery.data?.id || 'YOUR_API_KEY'); const serverUrl = (env.PUBLIC_EXCEPTIONLESS_SERVER_URL || '').trim(); const showServerUrl = env.PUBLIC_EXCEPTIONLESS_CLIENT_SETUP_SHOW_SERVER_URL !== 'false'; @@ -62,7 +53,7 @@ let isProjectTypeOpen = $state(false); let openEnableTokenDialog = $state(false); const tourActions = createProductTourActions(); - const configureCheckpoint = $derived(productTourCheckpoint.current?.tourName === 'configure-project' ? productTourCheckpoint.current : undefined); + const projectConfigureCheckpoint = $derived(productTourCheckpoint.current?.tourName === 'project-configure' ? productTourCheckpoint.current : undefined); const enableTokenMutation = patchToken({ route: { @@ -434,8 +425,8 @@ public partial class App : Application { const message = (event as CustomEvent>).detail; if (queryParams.redirect && message.project_id === projectId && message.change_type !== ChangeType.Removed) { - if (configureCheckpoint && !(await tourActions.complete(configureCheckpoint))) { - return; + if (projectConfigureCheckpoint) { + await tourActions.complete(projectConfigureCheckpoint); } toast.success('First event received. Opening Events...'); @@ -443,32 +434,6 @@ public partial class App : Application { } }); - async function refreshConfiguredProject(): Promise { - const checkpoint = configureCheckpoint; - if (!queryParams.redirect || !checkpoint) { - return; - } - - const result = await projectQuery.refetch(); - if (!result.data?.is_configured) { - return; - } - - if (!(await tourActions.complete(checkpoint))) { - return; - } - - toast.success('First event received. Opening Events...'); - await redirectToEventsWithFilter(organization.current, new ProjectFilter([projectId])); - } - - useEventListener(window, 'focus', async () => { - await refreshConfiguredProject(); - }); - useEventListener(document, 'refresh', async () => { - await refreshConfiguredProject(); - }); - // Use Intercom from parent provider context const intercom = getIntercom(); @@ -511,12 +476,12 @@ public partial class App : Application { Waiting for your first event

Send an event from your app. When it arrives, we'll open the project Events page automatically.

- {#if configureCheckpoint} + {#if projectConfigureCheckpoint}

You can leave this tab while updating your application. The guide will resume here when you return.

{ selectedProjectType = projectTypes.find((P) => P.id === value) || null; queryParams.type = value; - if (configureCheckpoint?.checkpointName === 'choose-platform') { - productTourCheckpoint.advance(configureCheckpoint, 'sdk-instructions'); + if (projectConfigureCheckpoint?.checkpointName === 'choose-platform') { + productTourCheckpoint.advance(projectConfigureCheckpoint, 'sdk-instructions'); } }} > @@ -828,23 +793,23 @@ public partial class App : Application { {/if} - {#if configureCheckpoint?.checkpointName === 'sdk-instructions'} + {#if projectConfigureCheckpoint?.checkpointName === 'sdk-instructions'} { - productTourCheckpoint.advance(configureCheckpoint, 'wait-for-event'); + productTourCheckpoint.advance(projectConfigureCheckpoint, 'wait-for-event'); }} onDismiss={async () => { - await tourActions.dismiss(configureCheckpoint); + await tourActions.dismiss(projectConfigureCheckpoint); }} title="Connect your application" - tourName="configure-project" + tourName="project-configure" /> {/if} - {#if configureCheckpoint?.checkpointName === 'choose-platform' && !isProjectTypeOpen} + {#if projectConfigureCheckpoint?.checkpointName === 'choose-platform' && !isProjectTypeOpen} (); const createProject = postProject(); const tourActions = createProductTourActions(); - const configureCheckpoint = $derived(productTourCheckpoint.current?.tourName === 'configure-project' ? productTourCheckpoint.current : undefined); + const projectConfigureCheckpoint = $derived(productTourCheckpoint.current?.tourName === 'project-configure' ? productTourCheckpoint.current : undefined); const form = createForm(() => ({ defaultValues: { @@ -42,7 +42,7 @@ ...value, organization_id: organization.current ?? value.organization_id } as NewProject); - const checkpoint = configureCheckpoint; + const checkpoint = projectConfigureCheckpoint; if (checkpoint) { productTourCheckpoint.advance(checkpoint, 'choose-platform'); } @@ -128,9 +128,9 @@
-{#if configureCheckpoint?.checkpointName === 'project-name'} +{#if projectConfigureCheckpoint?.checkpointName === 'project-name'} Tour Shown Started - Users Completed Dismissed Last Run @@ -76,7 +75,6 @@ {title(tour.name)} - @@ -110,7 +108,7 @@ Latest identified-user tour events for investigating support and onboarding patterns. - {#if usage?.recent_activity.length === 0} + {#if usage?.recent_events.length === 0}

No recent tour activity was recorded.

{:else} @@ -124,7 +122,7 @@ - {#each usage?.recent_activity ?? [] as activity (activity)} + {#each usage?.recent_events ?? [] as activity (activity)}
{activity.user_name || activity.user_identity || 'Unknown user'}
diff --git a/src/Exceptionless.Web/Models/Admin/AdminProductTourUsageResponse.cs b/src/Exceptionless.Web/Models/Admin/ProductTourUsageResponse.cs similarity index 62% rename from src/Exceptionless.Web/Models/Admin/AdminProductTourUsageResponse.cs rename to src/Exceptionless.Web/Models/Admin/ProductTourUsageResponse.cs index 475360e1ed..066201a888 100644 --- a/src/Exceptionless.Web/Models/Admin/AdminProductTourUsageResponse.cs +++ b/src/Exceptionless.Web/Models/Admin/ProductTourUsageResponse.cs @@ -2,23 +2,22 @@ namespace Exceptionless.Web.Models.Admin; -public sealed record AdminProductTourUsageResponse( +public sealed record ProductTourUsageResponse( DateTime Month, - IReadOnlyCollection Tours, - IReadOnlyCollection RecentActivity); + IReadOnlyCollection Tours, + IReadOnlyCollection RecentEvents); -public sealed record AdminProductTourSummary( +public sealed record ProductTourSummary( string Name, long Shown, long Started, long Completed, long Dismissed, - long UniqueUsers, DateTime? LastRunUtc, decimal? CompletionRate, decimal? DismissalRate); -public sealed record AdminProductTourActivity( +public sealed record ProductTourEvent( DateTime DateUtc, ProductTourTelemetryEvent Event, ProductTourLaunchSource LaunchSource, diff --git a/src/Exceptionless.Web/Models/User/ViewCurrentUser.cs b/src/Exceptionless.Web/Models/User/ViewCurrentUser.cs index af109dfdae..831150634f 100644 --- a/src/Exceptionless.Web/Models/User/ViewCurrentUser.cs +++ b/src/Exceptionless.Web/Models/User/ViewCurrentUser.cs @@ -34,7 +34,6 @@ public ViewCurrentUser(User user, IntercomOptions options) public ICollection OrganizationPreferences { get; set; } public ICollection SavedViewOrders { get; set; } public IDictionary ProductTours { get; set; } = new Dictionary(StringComparer.Ordinal); - public IReadOnlyDictionary ProductTourVersions { get; } = Exceptionless.Core.Models.Data.ProductTours.Versions; private static string? HMACSHA256HashString(string value, IntercomOptions options) { diff --git a/tests/Exceptionless.Tests/Api/Data/openapi.json b/tests/Exceptionless.Tests/Api/Data/openapi.json index 1b9e665694..afd36a8459 100644 --- a/tests/Exceptionless.Tests/Api/Data/openapi.json +++ b/tests/Exceptionless.Tests/Api/Data/openapi.json @@ -458,7 +458,7 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/AdminProductTourUsageResponse" + "$ref": "#/components/schemas/ProductTourUsageResponse" } } } @@ -11825,140 +11825,6 @@ } } }, - "AdminProductTourActivity": { - "required": [ - "date_utc", - "event", - "launch_source", - "tour_name", - "user_identity", - "user_name", - "version", - "count" - ], - "type": "object", - "properties": { - "date_utc": { - "type": "string", - "format": "date-time" - }, - "event": { - "$ref": "#/components/schemas/ProductTourTelemetryEvent" - }, - "launch_source": { - "$ref": "#/components/schemas/ProductTourLaunchSource" - }, - "tour_name": { - "type": "string" - }, - "user_identity": { - "type": [ - "null", - "string" - ] - }, - "user_name": { - "type": [ - "null", - "string" - ] - }, - "version": { - "type": "integer", - "format": "int32" - }, - "count": { - "type": "integer", - "format": "int64" - } - } - }, - "AdminProductTourSummary": { - "required": [ - "name", - "shown", - "started", - "completed", - "dismissed", - "unique_users", - "last_run_utc", - "completion_rate", - "dismissal_rate" - ], - "type": "object", - "properties": { - "name": { - "type": "string" - }, - "shown": { - "type": "integer", - "format": "int64" - }, - "started": { - "type": "integer", - "format": "int64" - }, - "completed": { - "type": "integer", - "format": "int64" - }, - "dismissed": { - "type": "integer", - "format": "int64" - }, - "unique_users": { - "type": "integer", - "format": "int64" - }, - "last_run_utc": { - "type": [ - "null", - "string" - ], - "format": "date-time" - }, - "completion_rate": { - "type": [ - "null", - "number" - ], - "format": "double" - }, - "dismissal_rate": { - "type": [ - "null", - "number" - ], - "format": "double" - } - } - }, - "AdminProductTourUsageResponse": { - "required": [ - "month", - "tours", - "recent_activity" - ], - "type": "object", - "properties": { - "month": { - "type": "string", - "format": "date-time" - }, - "tours": { - "type": "array", - "items": { - "$ref": "#/components/schemas/AdminProductTourSummary" - } - }, - "recent_activity": { - "type": "array", - "items": { - "$ref": "#/components/schemas/AdminProductTourActivity" - } - } - } - }, "AssistantAccessResponse": { "required": [ "enabled", @@ -13491,6 +13357,54 @@ } } }, + "ProductTourEvent": { + "required": [ + "date_utc", + "event", + "launch_source", + "tour_name", + "user_identity", + "user_name", + "version", + "count" + ], + "type": "object", + "properties": { + "date_utc": { + "type": "string", + "format": "date-time" + }, + "event": { + "$ref": "#/components/schemas/ProductTourTelemetryEvent" + }, + "launch_source": { + "$ref": "#/components/schemas/ProductTourLaunchSource" + }, + "tour_name": { + "type": "string" + }, + "user_identity": { + "type": [ + "null", + "string" + ] + }, + "user_name": { + "type": [ + "null", + "string" + ] + }, + "version": { + "type": "integer", + "format": "int32" + }, + "count": { + "type": "integer", + "format": "int64" + } + } + }, "ProductTourLaunchSource": { "enum": [ "automatic", @@ -13510,7 +13424,6 @@ "ProductTourProgress": { "required": [ "status", - "updated_utc", "version" ], "type": "object", @@ -13518,10 +13431,6 @@ "status": { "$ref": "#/components/schemas/ProductTourStatus" }, - "updated_utc": { - "type": "string", - "format": "date-time" - }, "version": { "type": "integer", "format": "int32" @@ -13530,14 +13439,70 @@ }, "ProductTourStatus": { "enum": [ - "completed", - "dismissed" + 1, + 2 ], + "type": "integer", "x-enumNames": [ "Completed", "Dismissed" ] }, + "ProductTourSummary": { + "required": [ + "name", + "shown", + "started", + "completed", + "dismissed", + "last_run_utc", + "completion_rate", + "dismissal_rate" + ], + "type": "object", + "properties": { + "name": { + "type": "string" + }, + "shown": { + "type": "integer", + "format": "int64" + }, + "started": { + "type": "integer", + "format": "int64" + }, + "completed": { + "type": "integer", + "format": "int64" + }, + "dismissed": { + "type": "integer", + "format": "int64" + }, + "last_run_utc": { + "type": [ + "null", + "string" + ], + "format": "date-time" + }, + "completion_rate": { + "type": [ + "null", + "number" + ], + "format": "double" + }, + "dismissal_rate": { + "type": [ + "null", + "number" + ], + "format": "double" + } + } + }, "ProductTourTelemetryEvent": { "enum": [ "completed", @@ -13552,6 +13517,32 @@ "Started" ] }, + "ProductTourUsageResponse": { + "required": [ + "month", + "tours", + "recent_events" + ], + "type": "object", + "properties": { + "month": { + "type": "string", + "format": "date-time" + }, + "tours": { + "type": "array", + "items": { + "$ref": "#/components/schemas/ProductTourSummary" + } + }, + "recent_events": { + "type": "array", + "items": { + "$ref": "#/components/schemas/ProductTourEvent" + } + } + } + }, "ResetPasswordModel": { "required": [ "password_reset_token", @@ -14463,14 +14454,6 @@ "$ref": "#/components/schemas/ProductTourProgress" } }, - "product_tour_versions": { - "type": "object", - "additionalProperties": { - "type": "integer", - "format": "int32" - }, - "readOnly": true - }, "id": { "maxLength": 24, "minLength": 24, @@ -15317,4 +15300,4 @@ "name": "Source Map" } ] -} \ No newline at end of file +} diff --git a/tests/Exceptionless.Tests/Api/Endpoints/AdminProductTourUsageEndpointTests.cs b/tests/Exceptionless.Tests/Api/Endpoints/AdminProductTourUsageEndpointTests.cs index 7367b4f277..b1c73bf755 100644 --- a/tests/Exceptionless.Tests/Api/Endpoints/AdminProductTourUsageEndpointTests.cs +++ b/tests/Exceptionless.Tests/Api/Endpoints/AdminProductTourUsageEndpointTests.cs @@ -1,5 +1,6 @@ using Exceptionless.Core; using Exceptionless.Core.Models; +using Exceptionless.Core.Models.Data; using Exceptionless.Core.Utility; using Exceptionless.Tests.Extensions; using Exceptionless.Tests.Utility; @@ -26,20 +27,21 @@ protected override async Task ResetDataAsync() [Fact] public async Task GetProductTourUsageAsync_AsGlobalAdmin_ReturnsInternalMonthlyUsage() { + // Arrange var month = new DateTime(2026, 8, 1, 0, 0, 0, DateTimeKind.Utc); TimeProvider.SetUtcNow(month.AddDays(20)); await CreateDataAsync(builder => { - AddUsage(builder, "product-tour.shown.ui-overview.v1.automatic", month.AddDays(1), "user-1", 2); - AddUsage(builder, "product-tour.started.ui-overview.v1.catalog", month.AddDays(2), "user-1"); - AddUsage(builder, "product-tour.started.ui-overview.v1.help-menu", month.AddDays(3), "user-1"); - AddUsage(builder, "product-tour.started.ui-overview.v1.catalog", month.AddDays(4), "user-2"); - AddUsage(builder, "product-tour.completed.ui-overview.v1.catalog", month.AddDays(5), "user-1"); - AddUsage(builder, "product-tour.dismissed.ui-overview.v1.catalog", month.AddDays(6), "user-2"); - AddUsage(builder, "product-tour.started.meet-exie.v1.command-palette", month.AddDays(7), "user-3"); - AddUsage(builder, "product-tour.shown.welcome.v1.automatic", month.AddDays(1), "user-1", 2); - AddUsage(builder, "product-tour.dismissed.welcome.v1.automatic", month.AddDays(2), "user-1"); + AddUsage(builder, ProductTours.CreateTelemetrySource(ProductTourTelemetryEvent.Shown, ProductTours.AppOverview, 1, ProductTourLaunchSource.Automatic), month.AddDays(1), "user-1", 2); + AddUsage(builder, ProductTours.CreateTelemetrySource(ProductTourTelemetryEvent.Started, ProductTours.AppOverview, 1, ProductTourLaunchSource.Catalog), month.AddDays(2), "user-1"); + AddUsage(builder, ProductTours.CreateTelemetrySource(ProductTourTelemetryEvent.Started, ProductTours.AppOverview, 1, ProductTourLaunchSource.HelpMenu), month.AddDays(3), "user-1"); + AddUsage(builder, ProductTours.CreateTelemetrySource(ProductTourTelemetryEvent.Started, ProductTours.AppOverview, 1, ProductTourLaunchSource.Catalog), month.AddDays(4), "user-2"); + AddUsage(builder, ProductTours.CreateTelemetrySource(ProductTourTelemetryEvent.Completed, ProductTours.AppOverview, 1, ProductTourLaunchSource.Catalog), month.AddDays(5), "user-1"); + AddUsage(builder, ProductTours.CreateTelemetrySource(ProductTourTelemetryEvent.Dismissed, ProductTours.AppOverview, 1, ProductTourLaunchSource.Catalog), month.AddDays(6), "user-2"); + AddUsage(builder, ProductTours.CreateTelemetrySource(ProductTourTelemetryEvent.Started, ProductTours.ExieOverview, 1, ProductTourLaunchSource.CommandPalette), month.AddDays(7), "user-3"); + AddUsage(builder, ProductTours.CreateTelemetrySource(ProductTourTelemetryEvent.Shown, ProductTours.AppWelcome, 1, ProductTourLaunchSource.Automatic), month.AddDays(1), "user-1", 2); + AddUsage(builder, ProductTours.CreateTelemetrySource(ProductTourTelemetryEvent.Dismissed, ProductTours.AppWelcome, 1, ProductTourLaunchSource.Automatic), month.AddDays(2), "user-1"); AddUsage(builder, "product-tour.started.unknown.v1.unknown-source", month.AddDays(8), "user-4"); builder.Event() @@ -51,64 +53,68 @@ await CreateDataAsync(builder => AddUsage(builder, "product-tour.started.old-tour.v1.catalog", month.AddMonths(-1), "user-6"); }); - var response = await SendRequestAsAsync(request => request + // Act + var response = await SendRequestAsAsync(request => request .AsGlobalAdminUser() .AppendPaths("admin", "product-tour-usage") .QueryString("month", "2026-08-01") .QueryString("limit", "3") .StatusCodeShouldBeOk()); + // Assert Assert.NotNull(response); Assert.Equal(month, response.Month); Assert.Equal(3, response.Tours.Count); - var overview = Assert.Single(response.Tours, tour => String.Equals(tour.Name, "ui-overview", StringComparison.Ordinal)); + var overview = Assert.Single(response.Tours, tour => String.Equals(tour.Name, ProductTours.AppOverview, StringComparison.Ordinal)); Assert.Equal(2, overview.Shown); Assert.Equal(3, overview.Started); Assert.Equal(1, overview.Completed); Assert.Equal(1, overview.Dismissed); - Assert.Equal(2, overview.UniqueUsers); Assert.Equal(month.AddDays(6), overview.LastRunUtc); Assert.Equal(0.3333m, overview.CompletionRate); Assert.Equal(0.3333m, overview.DismissalRate); - var exie = Assert.Single(response.Tours, tour => String.Equals(tour.Name, "meet-exie", StringComparison.Ordinal)); + var exie = Assert.Single(response.Tours, tour => String.Equals(tour.Name, ProductTours.ExieOverview, StringComparison.Ordinal)); Assert.Equal(1, exie.Started); - Assert.Equal(1, exie.UniqueUsers); Assert.Equal(month.AddDays(7), exie.LastRunUtc); Assert.Equal(0m, exie.CompletionRate); Assert.Equal(0m, exie.DismissalRate); - var welcome = Assert.Single(response.Tours, tour => String.Equals(tour.Name, "welcome", StringComparison.Ordinal)); + var welcome = Assert.Single(response.Tours, tour => String.Equals(tour.Name, ProductTours.AppWelcome, StringComparison.Ordinal)); Assert.Equal(2, welcome.Shown); Assert.Equal(1, welcome.Dismissed); Assert.Equal(month.AddDays(2), welcome.LastRunUtc); Assert.Equal(0.5m, welcome.DismissalRate); - Assert.Equal(3, response.RecentActivity.Count); - Assert.Equal(month.AddDays(7), response.RecentActivity.First().DateUtc); - Assert.All(response.RecentActivity, activity => Assert.StartsWith("user-", activity.UserIdentity)); + Assert.Equal(3, response.RecentEvents.Count); + Assert.Equal(month.AddDays(7), response.RecentEvents.First().DateUtc); + Assert.All(response.RecentEvents, productTourEvent => Assert.StartsWith("user-", productTourEvent.UserIdentity)); } [Fact] public async Task GetProductTourUsageAsync_WithoutMonth_UsesCurrentUtcMonth() { + // Arrange TimeProvider.SetUtcNow(new DateTime(2026, 9, 17, 12, 0, 0, DateTimeKind.Utc)); - var response = await SendRequestAsAsync(request => request + // Act + var response = await SendRequestAsAsync(request => request .AsGlobalAdminUser() .AppendPaths("admin", "product-tour-usage") .StatusCodeShouldBeOk()); + // Assert Assert.NotNull(response); Assert.Equal(new DateTime(2026, 9, 1, 0, 0, 0, DateTimeKind.Utc), response.Month); Assert.Empty(response.Tours); - Assert.Empty(response.RecentActivity); + Assert.Empty(response.RecentEvents); } [Fact] public Task GetProductTourUsageAsync_AsOrganizationUser_ReturnsForbidden() { + // Act & Assert return SendRequestAsync(request => request .AsTestOrganizationUser() .AppendPaths("admin", "product-tour-usage") diff --git a/tests/Exceptionless.Tests/Api/Endpoints/ProductTourEndpointTests.cs b/tests/Exceptionless.Tests/Api/Endpoints/ProductTourEndpointTests.cs index 4b579de86a..87096a9149 100644 --- a/tests/Exceptionless.Tests/Api/Endpoints/ProductTourEndpointTests.cs +++ b/tests/Exceptionless.Tests/Api/Endpoints/ProductTourEndpointTests.cs @@ -1,4 +1,3 @@ -using System.Text.Json; using Exceptionless.Core.Models; using Exceptionless.Core.Models.Data; using Exceptionless.Core.Repositories; @@ -28,79 +27,75 @@ protected override async Task ResetDataAsync() [Fact] public async Task UpdateCurrentUserProductTourAsync_NewProgress_PersistsAndReturnsProgress() { + // Arrange var currentUser = await GetTestOrganizationUserAsync(); + // Act var progress = await SendRequestAsAsync(request => request .Put() .AsTestOrganizationUser() - .AppendPaths("users", "me", "product-tours", "ui-overview") + .AppendPaths("users", "me", "product-tours", "app-overview") .Content(new UpdateProductTourProgress { Status = ProductTourStatus.Dismissed, Version = 1 }) .StatusCodeShouldBeOk()); + // Assert Assert.NotNull(progress); Assert.Equal(ProductTourStatus.Dismissed, progress.Status); Assert.Equal(1, progress.Version); var persistedUser = await _userRepository.GetByIdAsync(currentUser.Id, options => options.Cache(false)); Assert.NotNull(persistedUser); - Assert.Equal(progress, persistedUser.ProductTours["ui-overview"]); - } - - [Fact] - public async Task GetCurrentUserAsync_ReturnsAuthoritativeProductTourVersions() - { - var currentUser = await SendRequestAsAsync(request => request - .AsTestOrganizationUser() - .AppendPaths("users", "me") - .StatusCodeShouldBeOk()); - - var versions = currentUser.GetProperty("product_tour_versions") - .Deserialize>(); - - Assert.Equal(ProductTours.Versions, versions); + Assert.Equal(progress, persistedUser.ProductTours["app-overview"]); } [Fact] public async Task UpdateCurrentUserProductTourAsync_OlderProgress_PreservesStoredValue() { + // Arrange var currentUser = await GetTestOrganizationUserAsync(); - currentUser.ProductTours[ProductTours.MeetExie] = new ProductTourProgress + currentUser.ProductTours[ProductTours.ExieOverview] = new ProductTourProgress { Status = ProductTourStatus.Completed, - UpdatedUtc = TimeProvider.GetUtcNow().UtcDateTime, Version = 3 }; await _userRepository.SaveAsync(currentUser, options => options.Cache().ImmediateConsistency()); - var replacement = await UpdateProgressAsync(ProductTours.MeetExie, ProductTourStatus.Dismissed, 1); + // Act + var replacement = await UpdateProgressAsync(ProductTours.ExieOverview, ProductTourStatus.Dismissed, 1); + // Assert Assert.Equal(ProductTourStatus.Completed, replacement.Status); Assert.Equal(3, replacement.Version); var persistedUser = await _userRepository.GetByIdAsync(currentUser.Id, options => options.Cache(false)); Assert.NotNull(persistedUser); - Assert.Equal(replacement, persistedUser.ProductTours["meet-exie"]); + Assert.Equal(replacement, persistedUser.ProductTours["exie-overview"]); } [Fact] public async Task UpdateCurrentUserProductTourAsync_CompletedProgress_ReplacesDismissedProgressForSameVersion() { + // Arrange var currentUser = await GetTestOrganizationUserAsync(); - await UpdateProgressAsync(ProductTours.MeetExie, ProductTourStatus.Dismissed, 1); + await UpdateProgressAsync(ProductTours.ExieOverview, ProductTourStatus.Dismissed, 1); - var replacement = await UpdateProgressAsync(ProductTours.MeetExie, ProductTourStatus.Completed, 1); + // Act + var replacement = await UpdateProgressAsync(ProductTours.ExieOverview, ProductTourStatus.Completed, 1); + // Assert Assert.Equal(ProductTourStatus.Completed, replacement.Status); Assert.Equal(1, replacement.Version); var persistedUser = await _userRepository.GetByIdAsync(currentUser.Id, options => options.Cache(false)); Assert.NotNull(persistedUser); - Assert.Equal(replacement, persistedUser.ProductTours["meet-exie"]); + Assert.Equal(replacement, persistedUser.ProductTours["exie-overview"]); } [Fact] public async Task UpdateCurrentUserProductTourAsync_UnknownTourName_ReturnsUnprocessableEntity() { + // Arrange var currentUser = await GetTestOrganizationUserAsync(); + // Act await SendRequestAsync(request => request .Put() .AsTestOrganizationUser() @@ -108,6 +103,7 @@ await SendRequestAsync(request => request .Content(new UpdateProductTourProgress { Status = ProductTourStatus.Completed, Version = 1 }) .StatusCodeShouldBeUnprocessableEntity()); + // Assert var persistedUser = await _userRepository.GetByIdAsync(currentUser.Id, options => options.Cache(false)); Assert.NotNull(persistedUser); Assert.DoesNotContain("unknown-tour", persistedUser.ProductTours); @@ -116,6 +112,7 @@ await SendRequestAsync(request => request [Fact] public Task UpdateCurrentUserProductTourAsync_InvalidTourName_DoesNotMatchRoute() { + // Act & Assert return SendRequestAsync(request => request .Put() .AsTestOrganizationUser() @@ -127,9 +124,10 @@ public Task UpdateCurrentUserProductTourAsync_InvalidTourName_DoesNotMatchRoute( [Fact] public Task UpdateCurrentUserProductTourAsync_AnonymousUser_ReturnsUnauthorized() { + // Act & Assert return SendRequestAsync(request => request .Put() - .AppendPaths("users", "me", "product-tours", "welcome") + .AppendPaths("users", "me", "product-tours", ProductTours.AppWelcome) .Content(new UpdateProductTourProgress { Status = ProductTourStatus.Dismissed, Version = 1 }) .StatusCodeShouldBeUnauthorized()); } @@ -137,10 +135,11 @@ public Task UpdateCurrentUserProductTourAsync_AnonymousUser_ReturnsUnauthorized( [Fact] public Task UpdateCurrentUserProductTourAsync_MissingBody_ReturnsBadRequest() { + // Act & Assert return SendRequestAsync(request => request .Put() .AsTestOrganizationUser() - .AppendPaths("users", "me", "product-tours", "ui-overview") + .AppendPaths("users", "me", "product-tours", "app-overview") .StatusCodeShouldBeBadRequest()); } @@ -150,10 +149,11 @@ public Task UpdateCurrentUserProductTourAsync_MissingBody_ReturnsBadRequest() [InlineData(2)] public Task UpdateCurrentUserProductTourAsync_UnsupportedVersion_ReturnsUnprocessableEntity(int version) { + // Act & Assert return SendRequestAsync(request => request .Put() .AsTestOrganizationUser() - .AppendPaths("users", "me", "product-tours", "ui-overview") + .AppendPaths("users", "me", "product-tours", "app-overview") .Content(new UpdateProductTourProgress { Status = ProductTourStatus.Completed, Version = version }) .StatusCodeShouldBeUnprocessableEntity()); } @@ -161,10 +161,11 @@ public Task UpdateCurrentUserProductTourAsync_UnsupportedVersion_ReturnsUnproces [Fact] public Task UpdateCurrentUserProductTourAsync_UndefinedStatus_ReturnsUnprocessableEntity() { + // Act & Assert return SendRequestAsync(request => request .Put() .AsTestOrganizationUser() - .AppendPaths("users", "me", "product-tours", "ui-overview") + .AppendPaths("users", "me", "product-tours", "app-overview") .Content(new { Status = 999, Version = 1 }) .StatusCodeShouldBeUnprocessableEntity()); } diff --git a/tests/Exceptionless.Tests/Api/OpenApiSnapshotTests.cs b/tests/Exceptionless.Tests/Api/OpenApiSnapshotTests.cs index f0e22b33ad..7e95223a9a 100644 --- a/tests/Exceptionless.Tests/Api/OpenApiSnapshotTests.cs +++ b/tests/Exceptionless.Tests/Api/OpenApiSnapshotTests.cs @@ -70,7 +70,7 @@ public async Task GetOpenApiJson_Default_ContainsExpectedRoutesOperationsAndResp Assert.True(paths.TryGetProperty("/api/v2/admin/product-tour-usage", out var productTourUsagePath)); Assert.True(productTourUsagePath.TryGetProperty("get", out var productTourUsageGet)); AssertResponseCodes(productTourUsageGet, "200", "400", "401", "403"); - AssertResponseSchema(productTourUsageGet, "200", "AdminProductTourUsageResponse"); + AssertResponseSchema(productTourUsageGet, "200", "ProductTourUsageResponse"); Assert.True(paths.TryGetProperty("/api/v2/assistant/chat", out var assistantChatPath)); Assert.True(assistantChatPath.TryGetProperty("post", out var assistantChatPost)); @@ -113,9 +113,9 @@ public async Task GetOpenApiJson_Default_ContainsExpectedSchemasAndSecuritySchem Assert.True(schemas.TryGetProperty("TokenResult", out _)); Assert.True(schemas.TryGetProperty("ProductTourProgress", out _)); Assert.True(schemas.TryGetProperty("UpdateProductTourProgress", out _)); - Assert.True(schemas.TryGetProperty("AdminProductTourActivity", out _)); - Assert.True(schemas.TryGetProperty("AdminProductTourSummary", out _)); - Assert.True(schemas.TryGetProperty("AdminProductTourUsageResponse", out _)); + Assert.True(schemas.TryGetProperty("ProductTourEvent", out _)); + Assert.True(schemas.TryGetProperty("ProductTourSummary", out _)); + Assert.True(schemas.TryGetProperty("ProductTourUsageResponse", out _)); Assert.True(schemas.TryGetProperty("ViewOrganization", out _)); var savedViewColumnProperties = savedViewColumnSettings.GetProperty("properties"); diff --git a/tests/Exceptionless.Tests/Repositories/EventRepositoryTests.cs b/tests/Exceptionless.Tests/Repositories/EventRepositoryTests.cs index 196877c9d5..d6df13ff06 100644 --- a/tests/Exceptionless.Tests/Repositories/EventRepositoryTests.cs +++ b/tests/Exceptionless.Tests/Repositories/EventRepositoryTests.cs @@ -39,44 +39,45 @@ public EventRepositoryTests(ITestOutputHelper output, AppWebHostFactory factory) [Fact] public async Task GetProductTourUsageAsync_KnownSources_ReturnsPerTourAggregations() { + // Arrange var month = new DateTime(2026, 8, 1, 0, 0, 0, DateTimeKind.Utc); await CreateDataAsync(builder => { - AddProductTourUsage(builder, "product-tour.started.ui-overview.v1.catalog", month.AddDays(1), "user-1", 2); - AddProductTourUsage(builder, "product-tour.started.ui-overview.v1.help-menu", month.AddDays(2), "user-1"); - AddProductTourUsage(builder, "product-tour.completed.ui-overview.v1.catalog", month.AddDays(3), "user-2"); - AddProductTourUsage(builder, "product-tour.shown.welcome.v1.automatic", month.AddDays(4), "user-3"); - AddProductTourUsage(builder, "product-tour.started.ui-overview.v2.catalog", month.AddDays(5), "user-4"); + AddProductTourUsage(builder, ProductTours.CreateTelemetrySource(ProductTourTelemetryEvent.Started, ProductTours.AppOverview, 1, ProductTourLaunchSource.Catalog), month.AddDays(1), "user-1", 2); + AddProductTourUsage(builder, ProductTours.CreateTelemetrySource(ProductTourTelemetryEvent.Started, ProductTours.AppOverview, 1, ProductTourLaunchSource.HelpMenu), month.AddDays(2), "user-1"); + AddProductTourUsage(builder, ProductTours.CreateTelemetrySource(ProductTourTelemetryEvent.Completed, ProductTours.AppOverview, 1, ProductTourLaunchSource.Catalog), month.AddDays(3), "user-2"); + AddProductTourUsage(builder, ProductTours.CreateTelemetrySource(ProductTourTelemetryEvent.Shown, ProductTours.AppWelcome, 1, ProductTourLaunchSource.Automatic), month.AddDays(4), "user-3"); + AddProductTourUsage(builder, "product-tour.started.app-overview.v2.catalog", month.AddDays(5), "user-4"); AddProductTourUsage(builder, "product-tour.started.unknown-tour.v1.catalog", month.AddDays(6), "user-5"); - AddProductTourUsage(builder, "product-tour.started.ui-overview.v1.catalog", month.AddMonths(-1), "user-6"); + AddProductTourUsage(builder, ProductTours.CreateTelemetrySource(ProductTourTelemetryEvent.Started, ProductTours.AppOverview, 1, ProductTourLaunchSource.Catalog), month.AddMonths(-1), "user-6"); builder.Event() .Organization(TestConstants.OrganizationId) .Project(_appOptions.InternalProjectId) .Type(Event.KnownTypes.FeatureUsage) - .Source("product-tour.started.ui-overview.v1.catalog") + .Source(ProductTours.CreateTelemetrySource(ProductTourTelemetryEvent.Started, ProductTours.AppOverview, 1, ProductTourLaunchSource.Catalog)) .Date(month.AddDays(8)) .UserIdentity("user-8"); builder.Event() .TestProject() .Type(Event.KnownTypes.FeatureUsage) - .Source("product-tour.started.ui-overview.v1.catalog") + .Source(ProductTours.CreateTelemetrySource(ProductTourTelemetryEvent.Started, ProductTours.AppOverview, 1, ProductTourLaunchSource.Catalog)) .Date(month.AddDays(7)) .UserIdentity("user-7"); }); + // Act var result = await _repository.GetProductTourUsageAsync(_appOptions.InternalProjectId, month, month.AddMonths(1), recentLimit: 3); + // Assert Assert.Equal(3, result.RecentEvents.Count); - Assert.Equal(2, result.Tours.Count); - var overview = Assert.Single(result.Tours, tour => String.Equals(tour.Name, ProductTours.UiOverview, StringComparison.Ordinal)); - Assert.Equal(3, overview.UniqueUsers); - Assert.Equal(4, overview.Buckets.Where(bucket => bucket.Source.Event == ProductTourTelemetryEvent.Started).Sum(bucket => bucket.Count)); - Assert.Equal(1, overview.Buckets.Where(bucket => bucket.Source.Event == ProductTourTelemetryEvent.Completed).Sum(bucket => bucket.Count)); - Assert.Equal(month.AddDays(8), overview.Buckets.Max(bucket => bucket.LastUtc)); - - var welcome = Assert.Single(result.Tours, tour => String.Equals(tour.Name, ProductTours.Welcome, StringComparison.Ordinal)); - Assert.Equal(1, welcome.UniqueUsers); - Assert.Equal(1, Assert.Single(welcome.Buckets).Count); + Assert.Equal(2, result.Buckets.Select(bucket => bucket.Source.TourName).Distinct(StringComparer.Ordinal).Count()); + var overview = result.Buckets.Where(bucket => String.Equals(bucket.Source.TourName, ProductTours.AppOverview, StringComparison.Ordinal)).ToArray(); + Assert.Equal(4, overview.Where(bucket => bucket.Source.Event == ProductTourTelemetryEvent.Started).Sum(bucket => bucket.Count)); + Assert.Equal(1, overview.Where(bucket => bucket.Source.Event == ProductTourTelemetryEvent.Completed).Sum(bucket => bucket.Count)); + Assert.Equal(month.AddDays(8), overview.Max(bucket => bucket.LastUtc)); + + var welcome = Assert.Single(result.Buckets, bucket => String.Equals(bucket.Source.TourName, ProductTours.AppWelcome, StringComparison.Ordinal)); + Assert.Equal(1, welcome.Count); Assert.Equal(month.AddDays(8), result.RecentEvents.First().Event.Date); Assert.All(result.RecentEvents, item => Assert.True(ProductTours.IsValid(item.Source.TourName, item.Source.Version))); diff --git a/tests/Exceptionless.Tests/Serializer/Models/UserSerializerTests.cs b/tests/Exceptionless.Tests/Serializer/Models/UserSerializerTests.cs index fdb4f5b9bf..dc1f1ccaae 100644 --- a/tests/Exceptionless.Tests/Serializer/Models/UserSerializerTests.cs +++ b/tests/Exceptionless.Tests/Serializer/Models/UserSerializerTests.cs @@ -242,6 +242,7 @@ public void Deserialize_SnakeCaseJson_PreservesOrganizationIds() [Fact] public void Deserialize_User_PreservesProductTourProgress() { + // Arrange var original = new User { Id = "tour-user", @@ -250,29 +251,30 @@ public void Deserialize_User_PreservesProductTourProgress() IsEmailAddressVerified = true, ProductTours = new Dictionary(StringComparer.Ordinal) { - ["welcome"] = new() + ["app-welcome"] = new() { Version = 1, - Status = ProductTourStatus.Dismissed, - UpdatedUtc = FixedDateTime + Status = ProductTourStatus.Dismissed }, - ["ui-overview"] = new() + ["app-overview"] = new() { Version = 2, - Status = ProductTourStatus.Completed, - UpdatedUtc = FixedDateTime.AddMinutes(1) + Status = ProductTourStatus.Completed } } }; + // Act string? json = _serializer.SerializeToString(original); var deserialized = _serializer.Deserialize(json); + // Assert + Assert.Contains("\"status\":2", json); Assert.NotNull(deserialized); Assert.Equal(2, deserialized.ProductTours.Count); - Assert.Equal(ProductTourStatus.Dismissed, deserialized.ProductTours["welcome"].Status); - Assert.Equal(2, deserialized.ProductTours["ui-overview"].Version); - Assert.Equal(ProductTourStatus.Completed, deserialized.ProductTours["ui-overview"].Status); + Assert.Equal(ProductTourStatus.Dismissed, deserialized.ProductTours["app-welcome"].Status); + Assert.Equal(2, deserialized.ProductTours["app-overview"].Version); + Assert.Equal(ProductTourStatus.Completed, deserialized.ProductTours["app-overview"].Status); } [Fact] diff --git a/tests/http/users.http b/tests/http/users.http index 2fc983c203..74801b5b3b 100644 --- a/tests/http/users.http +++ b/tests/http/users.http @@ -27,13 +27,13 @@ Authorization: Bearer {{token}} @oauthGrantId = replace-with-oauth-grant-id ### Record Product Tour Progress -PUT {{apiUrl}}/users/me/product-tours/ui-overview +PUT {{apiUrl}}/users/me/product-tours/app-overview Authorization: Bearer {{token}} Content-Type: application/json { "version": 1, - "status": "completed" + "status": 1 } ### Get OAuth Grants From 1a0f39ce163caf5d3e4ec3ca8c564dc75ba5ddaa Mon Sep 17 00:00:00 2001 From: Blake Niemyjski Date: Sat, 29 Aug 2026 13:34:15 -0500 Subject: [PATCH 08/43] Clarify guided tour usage reporting --- .../Models/Data/ProductTours.cs | 2 + .../Repositories/EventRepository.cs | 23 +++++--- .../Interfaces/IEventRepository.cs | 2 +- .../Api/Endpoints/AdminEndpoints.cs | 4 +- .../Api/Handlers/AdminHandler.cs | 16 ++++-- .../Api/Messages/AdminMessages.cs | 2 +- .../src/lib/features/admin/api.svelte.ts | 19 +++++-- .../components/product-tour-host.svelte | 4 +- .../ClientApp/src/lib/generated/api.ts | 8 ++- .../ClientApp/src/lib/generated/schemas.ts | 5 +- .../(app)/system/product-tours/+page.svelte | 44 ++++++++++++--- .../Models/Admin/ProductTourUsageResponse.cs | 5 +- .../Exceptionless.Tests/Api/Data/openapi.json | 46 +++++++++++++++- .../AdminProductTourUsageEndpointTests.cs | 55 ++++++++++++++++++- .../Api/OpenApiSnapshotTests.cs | 2 +- .../Repositories/EventRepositoryTests.cs | 19 +++++++ tests/http/admin.http | 5 ++ 17 files changed, 222 insertions(+), 39 deletions(-) diff --git a/src/Exceptionless.Core/Models/Data/ProductTours.cs b/src/Exceptionless.Core/Models/Data/ProductTours.cs index bf54ebe9d1..ba8521375a 100644 --- a/src/Exceptionless.Core/Models/Data/ProductTours.cs +++ b/src/Exceptionless.Core/Models/Data/ProductTours.cs @@ -28,6 +28,8 @@ public static class ProductTours public static bool IsKnown(string name) => Versions.ContainsKey(name); + public static bool IsPrompt(string name) => name is AppWelcome or ExieAnnouncement; + public static bool IsValid(string name, int version) { return Versions.TryGetValue(name, out int currentVersion) && version > 0 && version <= currentVersion; diff --git a/src/Exceptionless.Core/Repositories/EventRepository.cs b/src/Exceptionless.Core/Repositories/EventRepository.cs index ce1f494bdf..08f78664b3 100644 --- a/src/Exceptionless.Core/Repositories/EventRepository.cs +++ b/src/Exceptionless.Core/Repositories/EventRepository.cs @@ -84,11 +84,11 @@ public Task> GetByReferenceIdAsync(string projectId return FindAsync(q => q.Project(projectId).FieldEquals(e => e.ReferenceId, referenceId).SortDescending(e => e.Date), o => o.PageLimit(10)); } - public async Task GetProductTourUsageAsync(string projectId, DateTime utcStart, DateTime utcEnd, int recentLimit = 500) + public async Task GetProductTourUsageAsync(string projectId, DateTime? utcStart = null, DateTime? utcEnd = null, int recentLimit = 500) { ArgumentException.ThrowIfNullOrEmpty(projectId); ArgumentOutOfRangeException.ThrowIfLessThan(recentLimit, 1); - if (utcEnd <= utcStart) + if (utcStart.HasValue && utcEnd.HasValue && utcEnd <= utcStart) throw new ArgumentOutOfRangeException(nameof(utcEnd), "The end date must be later than the start date."); var sourcesByName = ProductTours.Versions @@ -125,16 +125,23 @@ public async Task GetProductTourUsageAsync(string projec private static IRepositoryQuery ApplyProductTourUsageFilter( IRepositoryQuery query, string projectId, - DateTime utcStart, - DateTime utcEnd, + DateTime? utcStart, + DateTime? utcEnd, string[] sources) { - return query + query = query .Project(projectId) .FieldEquals(ev => ev.Type, Event.KnownTypes.FeatureUsage) - .FieldEquals(ev => ev.Source, sources) - .DateRange(utcStart, utcEnd, (PersistentEvent ev) => ev.Date) - .Index(utcStart, utcEnd); + .FieldEquals(ev => ev.Source, sources); + + if (utcStart.HasValue && utcEnd.HasValue) + return query.DateRange(utcStart, utcEnd, (PersistentEvent ev) => ev.Date).Index(utcStart, utcEnd); + if (utcStart.HasValue) + return query.DateRange(utcStart, null, (PersistentEvent ev) => ev.Date); + if (utcEnd.HasValue) + return query.DateRange(null, utcEnd, (PersistentEvent ev) => ev.Date); + + return query; } private static ProductTourUsageSource[] CreateProductTourSources(string tourName, int currentVersion) diff --git a/src/Exceptionless.Core/Repositories/Interfaces/IEventRepository.cs b/src/Exceptionless.Core/Repositories/Interfaces/IEventRepository.cs index 9545942087..1519d7bef5 100644 --- a/src/Exceptionless.Core/Repositories/Interfaces/IEventRepository.cs +++ b/src/Exceptionless.Core/Repositories/Interfaces/IEventRepository.cs @@ -13,7 +13,7 @@ public interface IEventRepository : IRepositoryOwnedByOrganizationAndProject UpdateSessionStartLastActivityAsync(string id, DateTime lastActivityUtc, bool isSessionEnd = false, bool hasError = false, bool sendNotifications = true); Task RemoveAllAsync(string organizationId, string? clientIpAddress, DateTime? utcStart, DateTime? utcEnd, CommandOptionsDescriptor? options = null); Task RemoveAllByStackIdsAsync(string[] stackIds); - Task GetProductTourUsageAsync(string projectId, DateTime utcStart, DateTime utcEnd, int recentLimit = 500); + Task GetProductTourUsageAsync(string projectId, DateTime? utcStart = null, DateTime? utcEnd = null, int recentLimit = 500); } public static class EventRepositoryExtensions diff --git a/src/Exceptionless.Web/Api/Endpoints/AdminEndpoints.cs b/src/Exceptionless.Web/Api/Endpoints/AdminEndpoints.cs index e67422c46b..279fcbd8df 100644 --- a/src/Exceptionless.Web/Api/Endpoints/AdminEndpoints.cs +++ b/src/Exceptionless.Web/Api/Endpoints/AdminEndpoints.cs @@ -92,6 +92,7 @@ public static IEndpointRouteBuilder MapAdminEndpoints(this IEndpointRouteBuilder .AddEndpointFilter() .Produces(StatusCodes.Status200OK) .ProducesProblem(StatusCodes.Status400BadRequest) + .ProducesValidationProblem(StatusCodes.Status422UnprocessableEntity) .Produces(StatusCodes.Status401Unauthorized) .Produces(StatusCodes.Status403Forbidden); @@ -143,6 +144,7 @@ private static async Task GetProductTourUsageAsync( IMediator mediator, IMediatorResultMapper resultMapper, DateTime? month = null, + bool all = false, int limit = 100) - => (await mediator.InvokeAsync>(new GetAdminProductTourUsage(month, limit))).ToHttpResult(resultMapper); + => (await mediator.InvokeAsync>(new GetAdminProductTourUsage(month, all, limit))).ToHttpResult(resultMapper); } diff --git a/src/Exceptionless.Web/Api/Handlers/AdminHandler.cs b/src/Exceptionless.Web/Api/Handlers/AdminHandler.cs index 8847922634..5dea5a23db 100644 --- a/src/Exceptionless.Web/Api/Handlers/AdminHandler.cs +++ b/src/Exceptionless.Web/Api/Handlers/AdminHandler.cs @@ -140,9 +140,11 @@ public async Task> Handle(GetAdminAssistantUsage message) public async Task> Handle(GetAdminProductTourUsage message) { - var requestedMonth = message.Month ?? timeProvider.GetUtcNow().UtcDateTime; - var month = requestedMonth.ToUniversalTime().StartOfMonth(); - var nextMonth = month.AddMonths(1); + if (message.All && message.Month.HasValue) + return Result.Invalid(ValidationError.Create("month", "Month cannot be specified when requesting all-time usage.")); + + DateTime? month = message.All ? null : (message.Month ?? timeProvider.GetUtcNow().UtcDateTime).ToUniversalTime().StartOfMonth(); + DateTime? nextMonth = month?.AddMonths(1); int limit = Math.Clamp(message.Limit, 1, 500); var usage = await eventRepository.GetProductTourUsageAsync(appOptions.InternalProjectId, month, nextMonth, limit); @@ -152,18 +154,24 @@ public async Task> Handle(GetAdminProductTourUsage message) { long shown = SumEvent(buckets, ProductTourTelemetryEvent.Shown); long started = SumEvent(buckets, ProductTourTelemetryEvent.Started); + long manualStarted = buckets + .Where(bucket => bucket.Source.Event == ProductTourTelemetryEvent.Started && bucket.Source.LaunchSource != ProductTourLaunchSource.Automatic) + .Sum(bucket => bucket.Count); long completed = SumEvent(buckets, ProductTourTelemetryEvent.Completed); long dismissed = SumEvent(buckets, ProductTourTelemetryEvent.Dismissed); - long decisionDenominator = started > 0 ? started : shown; + long decisionDenominator = ProductTours.IsPrompt(buckets.Key) ? shown : started; DateTime? lastRunUtc = buckets.Select(bucket => bucket.LastUtc).Max(); return new ProductTourSummary( buckets.Key, shown, started, + manualStarted, completed, dismissed, lastRunUtc, + CalculateRate(started, shown), + CalculateRate(manualStarted, started), CalculateRate(completed, decisionDenominator), CalculateRate(dismissed, decisionDenominator)); }) diff --git a/src/Exceptionless.Web/Api/Messages/AdminMessages.cs b/src/Exceptionless.Web/Api/Messages/AdminMessages.cs index 43f3144691..e1aea90bd6 100644 --- a/src/Exceptionless.Web/Api/Messages/AdminMessages.cs +++ b/src/Exceptionless.Web/Api/Messages/AdminMessages.cs @@ -3,7 +3,7 @@ namespace Exceptionless.Web.Api.Messages; public record GetAdminSettings; public record GetAdminStats; public record GetAdminAssistantUsage(DateTime? Month, int Limit, HttpContext Context); -public record GetAdminProductTourUsage(DateTime? Month, int Limit); +public record GetAdminProductTourUsage(DateTime? Month, bool All, int Limit); public record GetAdminMigrations; public record GetAdminEcho(HttpContext Context); public record GetAdminAssemblies; diff --git a/src/Exceptionless.Web/ClientApp/src/lib/features/admin/api.svelte.ts b/src/Exceptionless.Web/ClientApp/src/lib/features/admin/api.svelte.ts index 8c5fe6d06e..9d368712f0 100644 --- a/src/Exceptionless.Web/ClientApp/src/lib/features/admin/api.svelte.ts +++ b/src/Exceptionless.Web/ClientApp/src/lib/features/admin/api.svelte.ts @@ -45,7 +45,7 @@ export const queryKeys = { migrations: ['admin', 'migrations'] as const, oauthApplication: (id: string | undefined) => [...queryKeys.oauthApplications, id] as const, oauthApplications: ['admin', 'oauth-applications'] as const, - productTourUsage: (month: string) => ['admin', 'product-tour-usage', month] as const, + productTourUsage: (month?: string) => ['admin', 'product-tour-usage', month ?? 'all'] as const, snapshots: ['admin', 'elasticsearch', 'snapshots'] as const, stats: ['admin', 'stats'] as const }; @@ -112,15 +112,22 @@ export function getAdminAssistantUsageQuery(month: () => string) { })); } -export function getAdminProductTourUsageQuery(month: () => string) { +export function getAdminProductTourUsageQuery(month: () => string | undefined) { return createQuery(() => ({ queryFn: async ({ signal }: { signal: AbortSignal }) => { const client = useFetchClient(); + const selectedMonth = month(); + const params = selectedMonth + ? { + limit: 100, + month: `${selectedMonth}-01` + } + : { + all: true, + limit: 100 + }; const response = await client.getJSON('admin/product-tour-usage', { - params: { - limit: 100, - month: `${month()}-01` - }, + params, signal }); diff --git a/src/Exceptionless.Web/ClientApp/src/lib/features/product-tours/components/product-tour-host.svelte b/src/Exceptionless.Web/ClientApp/src/lib/features/product-tours/components/product-tour-host.svelte index dc64f73a1f..7038df9819 100644 --- a/src/Exceptionless.Web/ClientApp/src/lib/features/product-tours/components/product-tour-host.svelte +++ b/src/Exceptionless.Web/ClientApp/src/lib/features/product-tours/components/product-tour-host.svelte @@ -174,7 +174,7 @@ closeOverlays(); catalogOpen = false; const next = productTourCheckpoint.start(name, item.initialCheckpoint, source, currentUser.id, item.version, organizationId); - await Promise.all([track('shown', name, item.version, source), track('started', name, item.version, source)]); + await track('started', name, item.version, source); const destination = item.startingRoute(context); if (`${pathname}${window.location.search}` !== destination) { @@ -207,6 +207,7 @@ return; } welcomeHandled = true; + await track('started', 'app-welcome', WELCOME_VERSION, 'automatic'); await track('completed', 'app-welcome', WELCOME_VERSION, 'automatic'); await startTour(recommended.name, 'automatic'); } @@ -232,6 +233,7 @@ if (!(await recordPreference('exie-announcement', EXIE_ANNOUNCEMENT_VERSION, ProductTourStatus.Completed))) { return; } + await track('started', 'exie-announcement', EXIE_ANNOUNCEMENT_VERSION, 'feature-announcement'); await track('completed', 'exie-announcement', EXIE_ANNOUNCEMENT_VERSION, 'feature-announcement'); await startTour('exie-overview', 'feature-announcement'); } diff --git a/src/Exceptionless.Web/ClientApp/src/lib/generated/api.ts b/src/Exceptionless.Web/ClientApp/src/lib/generated/api.ts index b4fab356dc..841759d02a 100644 --- a/src/Exceptionless.Web/ClientApp/src/lib/generated/api.ts +++ b/src/Exceptionless.Web/ClientApp/src/lib/generated/api.ts @@ -541,12 +541,18 @@ export interface ProductTourSummary { /** @format int64 */ started: number; /** @format int64 */ + manual_started: number; + /** @format int64 */ completed: number; /** @format int64 */ dismissed: number; /** @format date-time */ last_run_utc?: null | string; /** @format double */ + started_rate?: null | number; + /** @format double */ + manual_started_rate?: null | number; + /** @format double */ completion_rate?: null | number; /** @format double */ dismissal_rate?: null | number; @@ -554,7 +560,7 @@ export interface ProductTourSummary { export interface ProductTourUsageResponse { /** @format date-time */ - month: string; + month?: null | string; tours: ProductTourSummary[]; recent_events: ProductTourEvent[]; } diff --git a/src/Exceptionless.Web/ClientApp/src/lib/generated/schemas.ts b/src/Exceptionless.Web/ClientApp/src/lib/generated/schemas.ts index d963cd56a6..959fecd2f0 100644 --- a/src/Exceptionless.Web/ClientApp/src/lib/generated/schemas.ts +++ b/src/Exceptionless.Web/ClientApp/src/lib/generated/schemas.ts @@ -664,16 +664,19 @@ export const ProductTourSummarySchema = object({ name: string().min(1, "Name is required"), shown: int(), started: int(), + manual_started: int(), completed: int(), dismissed: int(), last_run_utc: iso.datetime().nullable(), + started_rate: number().nullable(), + manual_started_rate: number().nullable(), completion_rate: number().nullable(), dismissal_rate: number().nullable(), }); export type ProductTourSummaryFormData = Infer; export const ProductTourUsageResponseSchema = object({ - month: iso.datetime(), + month: iso.datetime().nullable(), tours: array(lazy(() => ProductTourSummarySchema)), recent_events: array(lazy(() => ProductTourEventSchema)), }); diff --git a/src/Exceptionless.Web/ClientApp/src/routes/(app)/system/product-tours/+page.svelte b/src/Exceptionless.Web/ClientApp/src/routes/(app)/system/product-tours/+page.svelte index e83280dc99..6d24664346 100644 --- a/src/Exceptionless.Web/ClientApp/src/routes/(app)/system/product-tours/+page.svelte +++ b/src/Exceptionless.Web/ClientApp/src/routes/(app)/system/product-tours/+page.svelte @@ -4,6 +4,8 @@ import TimeAgo from '$comp/formatters/time-ago.svelte'; import { Muted } from '$comp/typography'; import { Badge } from '$comp/ui/badge'; + import { Button } from '$comp/ui/button'; + import * as ButtonGroup from '$comp/ui/button-group'; import * as Card from '$comp/ui/card'; import { Input } from '$comp/ui/input'; import { Skeleton } from '$comp/ui/skeleton'; @@ -12,8 +14,9 @@ import { getUtcMonthKey } from '$features/admin/assistant-usage'; const currentMonth = getUtcMonthKey(); + let range = $state<'all' | 'month'>('month'); let selectedMonth = $state(currentMonth); - const usageQuery = getAdminProductTourUsageQuery(() => selectedMonth); + const usageQuery = getAdminProductTourUsageQuery(() => (range === 'month' ? selectedMonth : undefined)); const usage = $derived(usageQuery.data); function title(value: string): string { @@ -27,10 +30,18 @@
Tour starts, outcomes, and recent user activity recorded by Exceptionless Feature Usage events - +
+ + + + + {#if range === 'month'} + + {/if} +
{#if usageQuery.isError} @@ -44,8 +55,8 @@ Tour Outcomes - Tour rates use starts as the denominator. Welcome and announcement decision rates use shown prompts because they do not have a separate - start event. + Start rate uses real prompt impressions when available. Outcome rates use starts, or shown prompts for prompt-only experiences. Manual + starts come from the catalog, command palette, feature announcements, and help menu. @@ -56,7 +67,9 @@ {/each}
{:else if usage?.tours.length === 0} -

No guided-tour activity was recorded for this month.

+

+ No guided-tour activity was recorded {range === 'month' ? 'for this month' : 'yet'}. +

{:else} @@ -74,7 +87,20 @@ {title(tour.name)} - + + + + ({#if tour.started_rate == null}—{:else}{/if}) + + {#if tour.manual_started > 0} +
+ manual + {#if tour.manual_started_rate != null} + () + {/if} +
+ {/if} +
diff --git a/src/Exceptionless.Web/Models/Admin/ProductTourUsageResponse.cs b/src/Exceptionless.Web/Models/Admin/ProductTourUsageResponse.cs index 066201a888..162fe5b144 100644 --- a/src/Exceptionless.Web/Models/Admin/ProductTourUsageResponse.cs +++ b/src/Exceptionless.Web/Models/Admin/ProductTourUsageResponse.cs @@ -3,7 +3,7 @@ namespace Exceptionless.Web.Models.Admin; public sealed record ProductTourUsageResponse( - DateTime Month, + DateTime? Month, IReadOnlyCollection Tours, IReadOnlyCollection RecentEvents); @@ -11,9 +11,12 @@ public sealed record ProductTourSummary( string Name, long Shown, long Started, + long ManualStarted, long Completed, long Dismissed, DateTime? LastRunUtc, + decimal? StartedRate, + decimal? ManualStartedRate, decimal? CompletionRate, decimal? DismissalRate); diff --git a/tests/Exceptionless.Tests/Api/Data/openapi.json b/tests/Exceptionless.Tests/Api/Data/openapi.json index afd36a8459..4f4a9178a2 100644 --- a/tests/Exceptionless.Tests/Api/Data/openapi.json +++ b/tests/Exceptionless.Tests/Api/Data/openapi.json @@ -442,6 +442,14 @@ "format": "date-time" } }, + { + "name": "all", + "in": "query", + "schema": { + "type": "boolean", + "default": false + } + }, { "name": "limit", "in": "query", @@ -473,6 +481,16 @@ } } }, + "422": { + "description": "Unprocessable Entity", + "content": { + "application/problem\u002Bjson": { + "schema": { + "$ref": "#/components/schemas/HttpValidationProblemDetails" + } + } + } + }, "401": { "description": "Unauthorized" }, @@ -13453,9 +13471,12 @@ "name", "shown", "started", + "manual_started", "completed", "dismissed", "last_run_utc", + "started_rate", + "manual_started_rate", "completion_rate", "dismissal_rate" ], @@ -13472,6 +13493,10 @@ "type": "integer", "format": "int64" }, + "manual_started": { + "type": "integer", + "format": "int64" + }, "completed": { "type": "integer", "format": "int64" @@ -13487,6 +13512,20 @@ ], "format": "date-time" }, + "started_rate": { + "type": [ + "null", + "number" + ], + "format": "double" + }, + "manual_started_rate": { + "type": [ + "null", + "number" + ], + "format": "double" + }, "completion_rate": { "type": [ "null", @@ -13526,7 +13565,10 @@ "type": "object", "properties": { "month": { - "type": "string", + "type": [ + "null", + "string" + ], "format": "date-time" }, "tours": { @@ -15300,4 +15342,4 @@ "name": "Source Map" } ] -} +} \ No newline at end of file diff --git a/tests/Exceptionless.Tests/Api/Endpoints/AdminProductTourUsageEndpointTests.cs b/tests/Exceptionless.Tests/Api/Endpoints/AdminProductTourUsageEndpointTests.cs index b1c73bf755..5ba287faf3 100644 --- a/tests/Exceptionless.Tests/Api/Endpoints/AdminProductTourUsageEndpointTests.cs +++ b/tests/Exceptionless.Tests/Api/Endpoints/AdminProductTourUsageEndpointTests.cs @@ -33,7 +33,6 @@ public async Task GetProductTourUsageAsync_AsGlobalAdmin_ReturnsInternalMonthlyU await CreateDataAsync(builder => { - AddUsage(builder, ProductTours.CreateTelemetrySource(ProductTourTelemetryEvent.Shown, ProductTours.AppOverview, 1, ProductTourLaunchSource.Automatic), month.AddDays(1), "user-1", 2); AddUsage(builder, ProductTours.CreateTelemetrySource(ProductTourTelemetryEvent.Started, ProductTours.AppOverview, 1, ProductTourLaunchSource.Catalog), month.AddDays(2), "user-1"); AddUsage(builder, ProductTours.CreateTelemetrySource(ProductTourTelemetryEvent.Started, ProductTours.AppOverview, 1, ProductTourLaunchSource.HelpMenu), month.AddDays(3), "user-1"); AddUsage(builder, ProductTours.CreateTelemetrySource(ProductTourTelemetryEvent.Started, ProductTours.AppOverview, 1, ProductTourLaunchSource.Catalog), month.AddDays(4), "user-2"); @@ -41,6 +40,8 @@ await CreateDataAsync(builder => AddUsage(builder, ProductTours.CreateTelemetrySource(ProductTourTelemetryEvent.Dismissed, ProductTours.AppOverview, 1, ProductTourLaunchSource.Catalog), month.AddDays(6), "user-2"); AddUsage(builder, ProductTours.CreateTelemetrySource(ProductTourTelemetryEvent.Started, ProductTours.ExieOverview, 1, ProductTourLaunchSource.CommandPalette), month.AddDays(7), "user-3"); AddUsage(builder, ProductTours.CreateTelemetrySource(ProductTourTelemetryEvent.Shown, ProductTours.AppWelcome, 1, ProductTourLaunchSource.Automatic), month.AddDays(1), "user-1", 2); + AddUsage(builder, ProductTours.CreateTelemetrySource(ProductTourTelemetryEvent.Started, ProductTours.AppWelcome, 1, ProductTourLaunchSource.Automatic), month.AddDays(1).AddHours(1), "user-1"); + AddUsage(builder, ProductTours.CreateTelemetrySource(ProductTourTelemetryEvent.Completed, ProductTours.AppWelcome, 1, ProductTourLaunchSource.Automatic), month.AddDays(1).AddHours(2), "user-1"); AddUsage(builder, ProductTours.CreateTelemetrySource(ProductTourTelemetryEvent.Dismissed, ProductTours.AppWelcome, 1, ProductTourLaunchSource.Automatic), month.AddDays(2), "user-1"); AddUsage(builder, "product-tour.started.unknown.v1.unknown-source", month.AddDays(8), "user-4"); @@ -67,24 +68,35 @@ await CreateDataAsync(builder => Assert.Equal(3, response.Tours.Count); var overview = Assert.Single(response.Tours, tour => String.Equals(tour.Name, ProductTours.AppOverview, StringComparison.Ordinal)); - Assert.Equal(2, overview.Shown); + Assert.Equal(0, overview.Shown); Assert.Equal(3, overview.Started); + Assert.Equal(3, overview.ManualStarted); Assert.Equal(1, overview.Completed); Assert.Equal(1, overview.Dismissed); Assert.Equal(month.AddDays(6), overview.LastRunUtc); + Assert.Null(overview.StartedRate); + Assert.Equal(1m, overview.ManualStartedRate); Assert.Equal(0.3333m, overview.CompletionRate); Assert.Equal(0.3333m, overview.DismissalRate); var exie = Assert.Single(response.Tours, tour => String.Equals(tour.Name, ProductTours.ExieOverview, StringComparison.Ordinal)); Assert.Equal(1, exie.Started); + Assert.Equal(1, exie.ManualStarted); + Assert.Null(exie.StartedRate); + Assert.Equal(1m, exie.ManualStartedRate); Assert.Equal(month.AddDays(7), exie.LastRunUtc); Assert.Equal(0m, exie.CompletionRate); Assert.Equal(0m, exie.DismissalRate); var welcome = Assert.Single(response.Tours, tour => String.Equals(tour.Name, ProductTours.AppWelcome, StringComparison.Ordinal)); Assert.Equal(2, welcome.Shown); + Assert.Equal(1, welcome.Started); + Assert.Equal(0.5m, welcome.StartedRate); + Assert.Equal(0m, welcome.ManualStartedRate); + Assert.Equal(1, welcome.Completed); Assert.Equal(1, welcome.Dismissed); Assert.Equal(month.AddDays(2), welcome.LastRunUtc); + Assert.Equal(0.5m, welcome.CompletionRate); Assert.Equal(0.5m, welcome.DismissalRate); Assert.Equal(3, response.RecentEvents.Count); @@ -92,6 +104,45 @@ await CreateDataAsync(builder => Assert.All(response.RecentEvents, productTourEvent => Assert.StartsWith("user-", productTourEvent.UserIdentity)); } + [Fact] + public async Task GetProductTourUsageAsync_ForAllTime_ReturnsUsageAcrossMonths() + { + // Arrange + var currentMonth = new DateTime(2026, 8, 1, 0, 0, 0, DateTimeKind.Utc); + await CreateDataAsync(builder => + { + AddUsage(builder, ProductTours.CreateTelemetrySource(ProductTourTelemetryEvent.Started, ProductTours.AppOverview, 1, ProductTourLaunchSource.Catalog), currentMonth.AddMonths(-1), "user-1"); + AddUsage(builder, ProductTours.CreateTelemetrySource(ProductTourTelemetryEvent.Started, ProductTours.AppOverview, 1, ProductTourLaunchSource.Automatic), currentMonth.AddDays(1), "user-2"); + }); + + // Act + var response = await SendRequestAsAsync(request => request + .AsGlobalAdminUser() + .AppendPaths("admin", "product-tour-usage") + .QueryString("all", "true") + .StatusCodeShouldBeOk()); + + // Assert + Assert.NotNull(response); + Assert.Null(response.Month); + var overview = Assert.Single(response.Tours); + Assert.Equal(2, overview.Started); + Assert.Equal(1, overview.ManualStarted); + Assert.Equal(0.5m, overview.ManualStartedRate); + } + + [Fact] + public Task GetProductTourUsageAsync_WithMonthAndAllTime_ReturnsValidationProblem() + { + // Act & Assert + return SendRequestAsync(request => request + .AsGlobalAdminUser() + .AppendPaths("admin", "product-tour-usage") + .QueryString("month", "2026-08-01") + .QueryString("all", "true") + .StatusCodeShouldBeUnprocessableEntity()); + } + [Fact] public async Task GetProductTourUsageAsync_WithoutMonth_UsesCurrentUtcMonth() { diff --git a/tests/Exceptionless.Tests/Api/OpenApiSnapshotTests.cs b/tests/Exceptionless.Tests/Api/OpenApiSnapshotTests.cs index 7e95223a9a..8d800774db 100644 --- a/tests/Exceptionless.Tests/Api/OpenApiSnapshotTests.cs +++ b/tests/Exceptionless.Tests/Api/OpenApiSnapshotTests.cs @@ -69,7 +69,7 @@ public async Task GetOpenApiJson_Default_ContainsExpectedRoutesOperationsAndResp Assert.True(paths.TryGetProperty("/api/v2/admin/product-tour-usage", out var productTourUsagePath)); Assert.True(productTourUsagePath.TryGetProperty("get", out var productTourUsageGet)); - AssertResponseCodes(productTourUsageGet, "200", "400", "401", "403"); + AssertResponseCodes(productTourUsageGet, "200", "400", "401", "403", "422"); AssertResponseSchema(productTourUsageGet, "200", "ProductTourUsageResponse"); Assert.True(paths.TryGetProperty("/api/v2/assistant/chat", out var assistantChatPath)); diff --git a/tests/Exceptionless.Tests/Repositories/EventRepositoryTests.cs b/tests/Exceptionless.Tests/Repositories/EventRepositoryTests.cs index d6df13ff06..c0aecf2eb2 100644 --- a/tests/Exceptionless.Tests/Repositories/EventRepositoryTests.cs +++ b/tests/Exceptionless.Tests/Repositories/EventRepositoryTests.cs @@ -83,6 +83,25 @@ await CreateDataAsync(builder => Assert.All(result.RecentEvents, item => Assert.True(ProductTours.IsValid(item.Source.TourName, item.Source.Version))); } + [Fact] + public async Task GetProductTourUsageAsync_WithoutDates_ReturnsAllUsage() + { + // Arrange + var month = new DateTime(2026, 8, 1, 0, 0, 0, DateTimeKind.Utc); + await CreateDataAsync(builder => + { + AddProductTourUsage(builder, ProductTours.CreateTelemetrySource(ProductTourTelemetryEvent.Started, ProductTours.AppOverview, 1, ProductTourLaunchSource.Catalog), month.AddMonths(-1), "user-1"); + AddProductTourUsage(builder, ProductTours.CreateTelemetrySource(ProductTourTelemetryEvent.Started, ProductTours.AppOverview, 1, ProductTourLaunchSource.Automatic), month.AddDays(1), "user-2"); + }); + + // Act + var result = await _repository.GetProductTourUsageAsync(_appOptions.InternalProjectId); + + // Assert + Assert.Equal(2, result.RecentEvents.Count); + Assert.Equal(2, result.Buckets.Sum(bucket => bucket.Count)); + } + [Fact] public async Task GetAsync() { diff --git a/tests/http/admin.http b/tests/http/admin.http index e887ce5045..85e13d94d2 100644 --- a/tests/http/admin.http +++ b/tests/http/admin.http @@ -120,6 +120,11 @@ Content-Type: application/json GET {{apiUrl}}/admin/product-tour-usage?month=2026-08-01&limit=100 Authorization: Bearer {{token}} +### + +GET {{apiUrl}}/admin/product-tour-usage?all=true&limit=100 +Authorization: Bearer {{token}} + ### Suspend POST {{apiUrl}}/organizations/{{organizationId}}/suspend?code=1 Authorization: Bearer {{token}} From f792d3ac59487181618d40ec9cdded2e612d6e77 Mon Sep 17 00:00:00 2001 From: Blake Niemyjski Date: Sun, 30 Aug 2026 08:58:07 -0500 Subject: [PATCH 09/43] Simplify product tour usage reporting --- .../Models/Data/ProductTours.cs | 65 +++++-- .../Repositories/EventRepository.cs | 32 ++-- .../Interfaces/IEventRepository.cs | 2 +- .../Repositories/ProductTourUsageResult.cs | 6 +- .../Api/Endpoints/AdminEndpoints.cs | 5 +- .../Api/Handlers/AdminHandler.cs | 105 +++++------ .../Api/Handlers/UserHandler.cs | 46 +++-- .../Api/Messages/AdminMessages.cs | 2 +- .../Models/Admin/ProductTourUsageResponse.cs | 24 +-- .../Exceptionless.Tests/Api/Data/openapi.json | 170 ++++++------------ .../AdminProductTourUsageEndpointTests.cs | 101 +++++------ .../Api/Endpoints/ProductTourEndpointTests.cs | 51 ++++++ .../Api/OpenApiSnapshotTests.cs | 3 +- .../Repositories/EventRepositoryTests.cs | 13 +- tests/http/admin.http | 12 +- 15 files changed, 305 insertions(+), 332 deletions(-) diff --git a/src/Exceptionless.Core/Models/Data/ProductTours.cs b/src/Exceptionless.Core/Models/Data/ProductTours.cs index ba8521375a..dfcff97942 100644 --- a/src/Exceptionless.Core/Models/Data/ProductTours.cs +++ b/src/Exceptionless.Core/Models/Data/ProductTours.cs @@ -1,7 +1,6 @@ using System.Collections.Frozen; using System.Runtime.Serialization; using System.Text.Json.Serialization; -using Exceptionless.Core.Extensions; namespace Exceptionless.Core.Models.Data; @@ -15,26 +14,28 @@ public static class ProductTours public const string ProjectConfigure = "project-configure"; public const string SavedViewCreate = "saved-view-create"; - public static IReadOnlyDictionary Versions { get; } = new Dictionary(StringComparer.Ordinal) + public static FrozenDictionary Definitions { get; } = new[] { - [AppOverview] = 1, - [AppWelcome] = 1, - [ExieAnnouncement] = 1, - [ExieOverview] = 1, - [EventInvestigate] = 1, - [ProjectConfigure] = 1, - [SavedViewCreate] = 1 - }.ToFrozenDictionary(StringComparer.Ordinal); + new ProductTourDefinition(AppOverview, 1, ProductTourKind.Guide), + new ProductTourDefinition(AppWelcome, 1, ProductTourKind.Prompt), + new ProductTourDefinition(ExieAnnouncement, 1, ProductTourKind.Prompt), + new ProductTourDefinition(ExieOverview, 1, ProductTourKind.Guide), + new ProductTourDefinition(EventInvestigate, 1, ProductTourKind.Guide), + new ProductTourDefinition(ProjectConfigure, 1, ProductTourKind.Guide), + new ProductTourDefinition(SavedViewCreate, 1, ProductTourKind.Guide) + }.ToFrozenDictionary(definition => definition.Name, StringComparer.Ordinal); - public static bool IsKnown(string name) => Versions.ContainsKey(name); + public static bool IsKnown(string name) => Definitions.ContainsKey(name); - public static bool IsPrompt(string name) => name is AppWelcome or ExieAnnouncement; + public static bool IsPrompt(string name) => Find(name)?.Kind is ProductTourKind.Prompt; public static bool IsValid(string name, int version) { - return Versions.TryGetValue(name, out int currentVersion) && version > 0 && version <= currentVersion; + return Find(name) is { } definition && version > 0 && version <= definition.CurrentVersion; } + public static ProductTourDefinition? Find(string name) => Definitions.GetValueOrDefault(name); + public static string CreateTelemetrySource( ProductTourTelemetryEvent telemetryEvent, string tourName, @@ -44,9 +45,37 @@ public static string CreateTelemetrySource( return $"product-tour.{GetTelemetryName(telemetryEvent)}.{tourName}.v{version}.{GetLaunchSourceName(launchSource)}"; } - private static string GetTelemetryName(ProductTourTelemetryEvent telemetryEvent) => telemetryEvent.ToString().ToLowerUnderscoredWords('-'); + private static string GetTelemetryName(ProductTourTelemetryEvent telemetryEvent) => telemetryEvent switch + { + ProductTourTelemetryEvent.Completed => "completed", + ProductTourTelemetryEvent.Dismissed => "dismissed", + ProductTourTelemetryEvent.Shown => "shown", + ProductTourTelemetryEvent.Started => "started", + _ => throw new ArgumentOutOfRangeException(nameof(telemetryEvent), telemetryEvent, "Unknown product tour telemetry event.") + }; + + private static string GetLaunchSourceName(ProductTourLaunchSource launchSource) => launchSource switch + { + ProductTourLaunchSource.Welcome => "welcome", + ProductTourLaunchSource.Catalog => "catalog", + ProductTourLaunchSource.CommandPalette => "command-palette", + ProductTourLaunchSource.FeatureAnnouncement => "feature-announcement", + ProductTourLaunchSource.HelpMenu => "help-menu", + _ => throw new ArgumentOutOfRangeException(nameof(launchSource), launchSource, "Unknown product tour launch source.") + }; +} + +public sealed record ProductTourDefinition(string Name, int CurrentVersion, ProductTourKind Kind); - private static string GetLaunchSourceName(ProductTourLaunchSource launchSource) => launchSource.ToString().ToLowerUnderscoredWords('-'); +[JsonConverter(typeof(JsonStringEnumConverter))] +public enum ProductTourKind +{ + [JsonStringEnumMemberName("guide")] + [EnumMember(Value = "guide")] + Guide, + [JsonStringEnumMemberName("prompt")] + [EnumMember(Value = "prompt")] + Prompt } [JsonConverter(typeof(JsonStringEnumConverter))] @@ -69,9 +98,9 @@ public enum ProductTourTelemetryEvent [JsonConverter(typeof(JsonStringEnumConverter))] public enum ProductTourLaunchSource { - [JsonStringEnumMemberName("automatic")] - [EnumMember(Value = "automatic")] - Automatic, + [JsonStringEnumMemberName("welcome")] + [EnumMember(Value = "welcome")] + Welcome, [JsonStringEnumMemberName("catalog")] [EnumMember(Value = "catalog")] Catalog, diff --git a/src/Exceptionless.Core/Repositories/EventRepository.cs b/src/Exceptionless.Core/Repositories/EventRepository.cs index 08f78664b3..161cb9db22 100644 --- a/src/Exceptionless.Core/Repositories/EventRepository.cs +++ b/src/Exceptionless.Core/Repositories/EventRepository.cs @@ -84,28 +84,24 @@ public Task> GetByReferenceIdAsync(string projectId return FindAsync(q => q.Project(projectId).FieldEquals(e => e.ReferenceId, referenceId).SortDescending(e => e.Date), o => o.PageLimit(10)); } - public async Task GetProductTourUsageAsync(string projectId, DateTime? utcStart = null, DateTime? utcEnd = null, int recentLimit = 500) + public async Task GetProductTourUsageAsync(string projectId, DateTime? utcStart, DateTime utcEnd) { ArgumentException.ThrowIfNullOrEmpty(projectId); - ArgumentOutOfRangeException.ThrowIfLessThan(recentLimit, 1); - if (utcStart.HasValue && utcEnd.HasValue && utcEnd <= utcStart) + if (utcStart.HasValue && utcEnd <= utcStart) throw new ArgumentOutOfRangeException(nameof(utcEnd), "The end date must be later than the start date."); - var sourcesByName = ProductTours.Versions - .SelectMany(pair => CreateProductTourSources(pair.Key, pair.Value)) + var sourcesByName = ProductTours.Definitions.Values + .SelectMany(definition => CreateProductTourSources(definition.Name, definition.CurrentVersion)) .ToDictionary(source => source.Raw, StringComparer.Ordinal); string[] allSources = sourcesByName.Keys.ToArray(); string sourceField = InferField(ev => ev.Source); string countField = InferField(ev => ev.Count); string dateField = InferField(ev => ev.Date); - var aggregationTask = CountAsync(query => ApplyProductTourUsageFilter(query, projectId, utcStart, utcEnd, allSources) + var aggregation = await CountAsync(query => ApplyProductTourUsageFilter(query, projectId, utcStart, utcEnd, allSources) .AggregationsExpression($"terms:({sourceField}~{allSources.Length} sum:{countField}~1 max:{dateField})")); - var recentTask = FindAsync(query => ApplyProductTourUsageFilter(query, projectId, utcStart, utcEnd, allSources) - .SortDescending(ev => ev.Date), options => options.PageLimit(recentLimit)); - await Task.WhenAll(aggregationTask, recentTask); - var sourceBuckets = (await aggregationTask).Aggregations.Terms($"terms_{sourceField}")?.Buckets ?? []; + var sourceBuckets = aggregation.Aggregations.Terms($"terms_{sourceField}")?.Buckets ?? []; var usage = sourceBuckets .Select(bucket => sourcesByName.TryGetValue(bucket.Key, out var source) ? new ProductTourUsageBucket( @@ -115,18 +111,14 @@ public async Task GetProductTourUsageAsync(string projec : null) .OfType() .ToArray(); - var recentEvents = (await recentTask).Documents - .Select(ev => ev.Source is not null && sourcesByName.TryGetValue(ev.Source, out var source) ? new ProductTourUsageEvent(ev, source) : null) - .OfType() - .ToArray(); - return new ProductTourUsageResult(usage, recentEvents); + return new ProductTourUsageResult(usage); } private static IRepositoryQuery ApplyProductTourUsageFilter( IRepositoryQuery query, string projectId, DateTime? utcStart, - DateTime? utcEnd, + DateTime utcEnd, string[] sources) { query = query @@ -134,14 +126,10 @@ private static IRepositoryQuery ApplyProductTourUsageFilter( .FieldEquals(ev => ev.Type, Event.KnownTypes.FeatureUsage) .FieldEquals(ev => ev.Source, sources); - if (utcStart.HasValue && utcEnd.HasValue) - return query.DateRange(utcStart, utcEnd, (PersistentEvent ev) => ev.Date).Index(utcStart, utcEnd); if (utcStart.HasValue) - return query.DateRange(utcStart, null, (PersistentEvent ev) => ev.Date); - if (utcEnd.HasValue) - return query.DateRange(null, utcEnd, (PersistentEvent ev) => ev.Date); + return query.DateRange(utcStart, utcEnd, (PersistentEvent ev) => ev.Date).Index(utcStart, utcEnd); - return query; + return query.DateRange(null, utcEnd, (PersistentEvent ev) => ev.Date); } private static ProductTourUsageSource[] CreateProductTourSources(string tourName, int currentVersion) diff --git a/src/Exceptionless.Core/Repositories/Interfaces/IEventRepository.cs b/src/Exceptionless.Core/Repositories/Interfaces/IEventRepository.cs index 1519d7bef5..03731764c5 100644 --- a/src/Exceptionless.Core/Repositories/Interfaces/IEventRepository.cs +++ b/src/Exceptionless.Core/Repositories/Interfaces/IEventRepository.cs @@ -13,7 +13,7 @@ public interface IEventRepository : IRepositoryOwnedByOrganizationAndProject UpdateSessionStartLastActivityAsync(string id, DateTime lastActivityUtc, bool isSessionEnd = false, bool hasError = false, bool sendNotifications = true); Task RemoveAllAsync(string organizationId, string? clientIpAddress, DateTime? utcStart, DateTime? utcEnd, CommandOptionsDescriptor? options = null); Task RemoveAllByStackIdsAsync(string[] stackIds); - Task GetProductTourUsageAsync(string projectId, DateTime? utcStart = null, DateTime? utcEnd = null, int recentLimit = 500); + Task GetProductTourUsageAsync(string projectId, DateTime? utcStart, DateTime utcEnd); } public static class EventRepositoryExtensions diff --git a/src/Exceptionless.Core/Repositories/ProductTourUsageResult.cs b/src/Exceptionless.Core/Repositories/ProductTourUsageResult.cs index 5db431cf37..7ce0e080e5 100644 --- a/src/Exceptionless.Core/Repositories/ProductTourUsageResult.cs +++ b/src/Exceptionless.Core/Repositories/ProductTourUsageResult.cs @@ -1,16 +1,12 @@ -using Exceptionless.Core.Models; using Exceptionless.Core.Models.Data; namespace Exceptionless.Core.Repositories; public sealed record ProductTourUsageResult( - IReadOnlyCollection Buckets, - IReadOnlyCollection RecentEvents); + IReadOnlyCollection Buckets); public sealed record ProductTourUsageBucket(ProductTourUsageSource Source, long Count, DateTime? LastUtc); -public sealed record ProductTourUsageEvent(PersistentEvent Event, ProductTourUsageSource Source); - public sealed record ProductTourUsageSource( string Raw, ProductTourTelemetryEvent Event, diff --git a/src/Exceptionless.Web/Api/Endpoints/AdminEndpoints.cs b/src/Exceptionless.Web/Api/Endpoints/AdminEndpoints.cs index 279fcbd8df..5477ea05b6 100644 --- a/src/Exceptionless.Web/Api/Endpoints/AdminEndpoints.cs +++ b/src/Exceptionless.Web/Api/Endpoints/AdminEndpoints.cs @@ -144,7 +144,6 @@ private static async Task GetProductTourUsageAsync( IMediator mediator, IMediatorResultMapper resultMapper, DateTime? month = null, - bool all = false, - int limit = 100) - => (await mediator.InvokeAsync>(new GetAdminProductTourUsage(month, all, limit))).ToHttpResult(resultMapper); + bool history = false) + => (await mediator.InvokeAsync>(new GetAdminProductTourUsage(month, history))).ToHttpResult(resultMapper); } diff --git a/src/Exceptionless.Web/Api/Handlers/AdminHandler.cs b/src/Exceptionless.Web/Api/Handlers/AdminHandler.cs index 5dea5a23db..4b19ffc7f9 100644 --- a/src/Exceptionless.Web/Api/Handlers/AdminHandler.cs +++ b/src/Exceptionless.Web/Api/Handlers/AdminHandler.cs @@ -19,7 +19,6 @@ using Foundatio.Repositories; using Foundatio.Repositories.Migrations; using Foundatio.Repositories.Models; -using Foundatio.Serializer; using Foundatio.Storage; using Foundatio.Mediator; @@ -41,7 +40,6 @@ public class AdminHandler( BillingPlans plans, IMigrationStateRepository migrationStateRepository, SampleDataService sampleDataService, - ITextSerializer serializer, TimeProvider timeProvider, ILoggerFactory loggerFactory) { @@ -140,54 +138,54 @@ public async Task> Handle(GetAdminAssistantUsage message) public async Task> Handle(GetAdminProductTourUsage message) { - if (message.All && message.Month.HasValue) - return Result.Invalid(ValidationError.Create("month", "Month cannot be specified when requesting all-time usage.")); - - DateTime? month = message.All ? null : (message.Month ?? timeProvider.GetUtcNow().UtcDateTime).ToUniversalTime().StartOfMonth(); - DateTime? nextMonth = month?.AddMonths(1); - int limit = Math.Clamp(message.Limit, 1, 500); - - var usage = await eventRepository.GetProductTourUsageAsync(appOptions.InternalProjectId, month, nextMonth, limit); - var tours = usage.Buckets - .GroupBy(bucket => bucket.Source.TourName, StringComparer.Ordinal) - .Select(buckets => + if (message.History && message.Month.HasValue) + return Result.Invalid(ValidationError.Create("month", "Month cannot be specified when requesting available history.")); + + DateTime utcEnd = timeProvider.GetUtcNow().UtcDateTime; + DateTime monthStart = (message.Month ?? utcEnd).ToUniversalTime().StartOfMonth(); + DateTime? utcStart = message.History + ? appOptions.MaximumRetentionDays > 0 + ? utcEnd.SubtractDays(appOptions.MaximumRetentionDays) + : null + : monthStart; + if (!message.History) + utcEnd = monthStart.AddMonths(1); + + var usage = await eventRepository.GetProductTourUsageAsync(appOptions.InternalProjectId, utcStart, utcEnd); + var bucketsByTour = usage.Buckets + .GroupBy(bucket => (bucket.Source.TourName, bucket.Source.Version)) + .ToDictionary(group => group.Key); + var tours = ProductTours.Definitions.Values + .SelectMany(definition => Enumerable.Range(1, definition.CurrentVersion).Select(version => { - long shown = SumEvent(buckets, ProductTourTelemetryEvent.Shown); - long started = SumEvent(buckets, ProductTourTelemetryEvent.Started); - long manualStarted = buckets - .Where(bucket => bucket.Source.Event == ProductTourTelemetryEvent.Started && bucket.Source.LaunchSource != ProductTourLaunchSource.Automatic) - .Sum(bucket => bucket.Count); - long completed = SumEvent(buckets, ProductTourTelemetryEvent.Completed); - long dismissed = SumEvent(buckets, ProductTourTelemetryEvent.Dismissed); - long decisionDenominator = ProductTours.IsPrompt(buckets.Key) ? shown : started; - DateTime? lastRunUtc = buckets.Select(bucket => bucket.LastUtc).Max(); + IEnumerable buckets = bucketsByTour.TryGetValue((definition.Name, version), out var matchingBuckets) + ? matchingBuckets + : []; return new ProductTourSummary( - buckets.Key, - shown, - started, - manualStarted, - completed, - dismissed, - lastRunUtc, - CalculateRate(started, shown), - CalculateRate(manualStarted, started), - CalculateRate(completed, decisionDenominator), - CalculateRate(dismissed, decisionDenominator)); - }) - .OrderByDescending(tour => tour.Started) - .ThenBy(tour => tour.Name, StringComparer.Ordinal) - .ToArray(); - - var recentEvents = usage.RecentEvents - .Select(item => CreateRecentEvent(item.Event, item.Source)) - .Take(limit) + definition.Name, + version, + definition.Kind, + SumEvent(buckets, ProductTourTelemetryEvent.Shown), + SumEvent(buckets, ProductTourTelemetryEvent.Started), + SumEvent(buckets, ProductTourTelemetryEvent.Completed), + SumEvent(buckets, ProductTourTelemetryEvent.Dismissed), + buckets.Select(bucket => bucket.LastUtc).Max(), + buckets + .Where(bucket => bucket.Source.Event is ProductTourTelemetryEvent.Started) + .GroupBy(bucket => bucket.Source.LaunchSource) + .Select(group => new ProductTourStartSource(group.Key, group.Sum(bucket => bucket.Count))) + .OrderBy(source => source.Source) + .ToArray()); + })) + .OrderBy(tour => tour.Name, StringComparer.Ordinal) + .ThenBy(tour => tour.Version) .ToArray(); return new ProductTourUsageResponse( - month, - tours, - recentEvents); + utcStart, + utcEnd, + tours); } [HandlerEndpoint(HandlerMethod.Get, "migrations", Group = "Admin")] @@ -228,25 +226,6 @@ public Task> Handle(GetAdminEcho message) }); } - private ProductTourEvent CreateRecentEvent(PersistentEvent ev, ProductTourUsageSource source) - { - var user = ev.GetUserIdentity(serializer, _logger); - return new ProductTourEvent( - ev.Date.UtcDateTime, - source.Event, - source.LaunchSource, - source.TourName, - user?.Identity, - user?.Name, - source.Version, - ev.Count ?? 1); - } - - private static decimal? CalculateRate(long value, long denominator) - { - return denominator > 0 ? Decimal.Round(value / (decimal)denominator, 4) : null; - } - private static long SumEvent(IEnumerable buckets, ProductTourTelemetryEvent telemetryEvent) { return buckets.Where(bucket => bucket.Source.Event == telemetryEvent).Sum(bucket => bucket.Count); diff --git a/src/Exceptionless.Web/Api/Handlers/UserHandler.cs b/src/Exceptionless.Web/Api/Handlers/UserHandler.cs index 68e3933dfd..3766cf716b 100644 --- a/src/Exceptionless.Web/Api/Handlers/UserHandler.cs +++ b/src/Exceptionless.Web/Api/Handlers/UserHandler.cs @@ -17,6 +17,7 @@ using Foundatio.Caching; using Foundatio.Mediator; using Foundatio.Repositories; +using Foundatio.Repositories.Exceptions; using Foundatio.Repositories.Models; namespace Exceptionless.Web.Api.Handlers; @@ -60,26 +61,33 @@ public async Task> Handle(UpdateCurrentUserProductTo return Result.Invalid(ValidationError.Create("version", "The product tour version is not supported.")); ProductTourProgress? progress = null; - await repository.PatchAsync( - GetCurrentUserId(), - new ActionPatch(user => - { - user.ProductTours.TryGetValue(message.TourName, out var currentProgress); - if (!ShouldUpdateProductTourProgress(currentProgress, message.Progress)) - { - progress = currentProgress; - return false; - } - - progress = new ProductTourProgress + try + { + await repository.PatchAsync( + GetCurrentUserId(), + new ActionPatch(user => { - Status = message.Progress.Status!.Value, - Version = message.Progress.Version - }; - user.ProductTours[message.TourName] = progress; - return true; - }), - options => options.Cache()); + user.ProductTours.TryGetValue(message.TourName, out var currentProgress); + if (!ShouldUpdateProductTourProgress(currentProgress, message.Progress)) + { + progress = currentProgress; + return false; + } + + progress = new ProductTourProgress + { + Status = message.Progress.Status!.Value, + Version = message.Progress.Version + }; + user.ProductTours[message.TourName] = progress; + return true; + }), + options => options.Cache()); + } + catch (DocumentNotFoundException) + { + return Result.NotFound("User not found."); + } return progress is null ? Result.NotFound("User not found.") : progress; } diff --git a/src/Exceptionless.Web/Api/Messages/AdminMessages.cs b/src/Exceptionless.Web/Api/Messages/AdminMessages.cs index e1aea90bd6..7183ab7392 100644 --- a/src/Exceptionless.Web/Api/Messages/AdminMessages.cs +++ b/src/Exceptionless.Web/Api/Messages/AdminMessages.cs @@ -3,7 +3,7 @@ namespace Exceptionless.Web.Api.Messages; public record GetAdminSettings; public record GetAdminStats; public record GetAdminAssistantUsage(DateTime? Month, int Limit, HttpContext Context); -public record GetAdminProductTourUsage(DateTime? Month, bool All, int Limit); +public record GetAdminProductTourUsage(DateTime? Month, bool History); public record GetAdminMigrations; public record GetAdminEcho(HttpContext Context); public record GetAdminAssemblies; diff --git a/src/Exceptionless.Web/Models/Admin/ProductTourUsageResponse.cs b/src/Exceptionless.Web/Models/Admin/ProductTourUsageResponse.cs index 162fe5b144..11e3ae40d9 100644 --- a/src/Exceptionless.Web/Models/Admin/ProductTourUsageResponse.cs +++ b/src/Exceptionless.Web/Models/Admin/ProductTourUsageResponse.cs @@ -3,29 +3,19 @@ namespace Exceptionless.Web.Models.Admin; public sealed record ProductTourUsageResponse( - DateTime? Month, - IReadOnlyCollection Tours, - IReadOnlyCollection RecentEvents); + DateTime? UtcStart, + DateTime UtcEnd, + IReadOnlyCollection Tours); public sealed record ProductTourSummary( string Name, + int Version, + ProductTourKind Kind, long Shown, long Started, - long ManualStarted, long Completed, long Dismissed, DateTime? LastRunUtc, - decimal? StartedRate, - decimal? ManualStartedRate, - decimal? CompletionRate, - decimal? DismissalRate); + IReadOnlyCollection StartSources); -public sealed record ProductTourEvent( - DateTime DateUtc, - ProductTourTelemetryEvent Event, - ProductTourLaunchSource LaunchSource, - string TourName, - string? UserIdentity, - string? UserName, - int Version, - long Count); +public sealed record ProductTourStartSource(ProductTourLaunchSource Source, long Count); diff --git a/tests/Exceptionless.Tests/Api/Data/openapi.json b/tests/Exceptionless.Tests/Api/Data/openapi.json index 4f4a9178a2..e2faa7182f 100644 --- a/tests/Exceptionless.Tests/Api/Data/openapi.json +++ b/tests/Exceptionless.Tests/Api/Data/openapi.json @@ -443,21 +443,12 @@ } }, { - "name": "all", + "name": "history", "in": "query", "schema": { "type": "boolean", "default": false } - }, - { - "name": "limit", - "in": "query", - "schema": { - "type": "integer", - "format": "int32", - "default": 100 - } } ], "responses": { @@ -13375,70 +13366,32 @@ } } }, - "ProductTourEvent": { - "required": [ - "date_utc", - "event", - "launch_source", - "tour_name", - "user_identity", - "user_name", - "version", - "count" - ], - "type": "object", - "properties": { - "date_utc": { - "type": "string", - "format": "date-time" - }, - "event": { - "$ref": "#/components/schemas/ProductTourTelemetryEvent" - }, - "launch_source": { - "$ref": "#/components/schemas/ProductTourLaunchSource" - }, - "tour_name": { - "type": "string" - }, - "user_identity": { - "type": [ - "null", - "string" - ] - }, - "user_name": { - "type": [ - "null", - "string" - ] - }, - "version": { - "type": "integer", - "format": "int32" - }, - "count": { - "type": "integer", - "format": "int64" - } - } - }, "ProductTourLaunchSource": { "enum": [ - "automatic", + "welcome", "catalog", "command-palette", "feature-announcement", "help-menu" ], "x-enumNames": [ - "Automatic", + "Welcome", "Catalog", "CommandPalette", "FeatureAnnouncement", "HelpMenu" ] }, + "ProductTourKind": { + "enum": [ + "guide", + "prompt" + ], + "x-enumNames": [ + "Guide", + "Prompt" + ] + }, "ProductTourProgress": { "required": [ "status", @@ -13469,31 +13422,32 @@ "ProductTourSummary": { "required": [ "name", + "version", + "kind", "shown", "started", - "manual_started", "completed", "dismissed", "last_run_utc", - "started_rate", - "manual_started_rate", - "completion_rate", - "dismissal_rate" + "start_sources" ], "type": "object", "properties": { "name": { "type": "string" }, - "shown": { + "version": { "type": "integer", - "format": "int64" + "format": "int32" }, - "started": { + "kind": { + "$ref": "#/components/schemas/ProductTourKind" + }, + "shown": { "type": "integer", "format": "int64" }, - "manual_started": { + "started": { "type": "integer", "format": "int64" }, @@ -13512,76 +13466,54 @@ ], "format": "date-time" }, - "started_rate": { - "type": [ - "null", - "number" - ], - "format": "double" - }, - "manual_started_rate": { - "type": [ - "null", - "number" - ], - "format": "double" - }, - "completion_rate": { - "type": [ - "null", - "number" - ], - "format": "double" - }, - "dismissal_rate": { - "type": [ - "null", - "number" - ], - "format": "double" + "start_sources": { + "type": "array", + "items": { + "$ref": "#/components/schemas/ProductTourStartSource" + } } } }, - "ProductTourTelemetryEvent": { - "enum": [ - "completed", - "dismissed", - "shown", - "started" + "ProductTourStartSource": { + "required": [ + "source", + "count" ], - "x-enumNames": [ - "Completed", - "Dismissed", - "Shown", - "Started" - ] + "type": "object", + "properties": { + "source": { + "$ref": "#/components/schemas/ProductTourLaunchSource" + }, + "count": { + "type": "integer", + "format": "int64" + } + } }, "ProductTourUsageResponse": { "required": [ - "month", - "tours", - "recent_events" + "utc_start", + "utc_end", + "tours" ], "type": "object", "properties": { - "month": { + "utc_start": { "type": [ "null", "string" ], "format": "date-time" }, + "utc_end": { + "type": "string", + "format": "date-time" + }, "tours": { "type": "array", "items": { "$ref": "#/components/schemas/ProductTourSummary" } - }, - "recent_events": { - "type": "array", - "items": { - "$ref": "#/components/schemas/ProductTourEvent" - } } } }, @@ -15342,4 +15274,4 @@ "name": "Source Map" } ] -} \ No newline at end of file +} diff --git a/tests/Exceptionless.Tests/Api/Endpoints/AdminProductTourUsageEndpointTests.cs b/tests/Exceptionless.Tests/Api/Endpoints/AdminProductTourUsageEndpointTests.cs index 5ba287faf3..6de3cd1eb3 100644 --- a/tests/Exceptionless.Tests/Api/Endpoints/AdminProductTourUsageEndpointTests.cs +++ b/tests/Exceptionless.Tests/Api/Endpoints/AdminProductTourUsageEndpointTests.cs @@ -2,6 +2,7 @@ using Exceptionless.Core.Models; using Exceptionless.Core.Models.Data; using Exceptionless.Core.Utility; +using Exceptionless.DateTimeExtensions; using Exceptionless.Tests.Extensions; using Exceptionless.Tests.Utility; using Exceptionless.Web.Models.Admin; @@ -25,7 +26,7 @@ protected override async Task ResetDataAsync() } [Fact] - public async Task GetProductTourUsageAsync_AsGlobalAdmin_ReturnsInternalMonthlyUsage() + public async Task GetProductTourUsageAsync_AsGlobalAdmin_ReturnsMonthlyCountsAndKnownRows() { // Arrange var month = new DateTime(2026, 8, 1, 0, 0, 0, DateTimeKind.Utc); @@ -38,11 +39,10 @@ await CreateDataAsync(builder => AddUsage(builder, ProductTours.CreateTelemetrySource(ProductTourTelemetryEvent.Started, ProductTours.AppOverview, 1, ProductTourLaunchSource.Catalog), month.AddDays(4), "user-2"); AddUsage(builder, ProductTours.CreateTelemetrySource(ProductTourTelemetryEvent.Completed, ProductTours.AppOverview, 1, ProductTourLaunchSource.Catalog), month.AddDays(5), "user-1"); AddUsage(builder, ProductTours.CreateTelemetrySource(ProductTourTelemetryEvent.Dismissed, ProductTours.AppOverview, 1, ProductTourLaunchSource.Catalog), month.AddDays(6), "user-2"); - AddUsage(builder, ProductTours.CreateTelemetrySource(ProductTourTelemetryEvent.Started, ProductTours.ExieOverview, 1, ProductTourLaunchSource.CommandPalette), month.AddDays(7), "user-3"); - AddUsage(builder, ProductTours.CreateTelemetrySource(ProductTourTelemetryEvent.Shown, ProductTours.AppWelcome, 1, ProductTourLaunchSource.Automatic), month.AddDays(1), "user-1", 2); - AddUsage(builder, ProductTours.CreateTelemetrySource(ProductTourTelemetryEvent.Started, ProductTours.AppWelcome, 1, ProductTourLaunchSource.Automatic), month.AddDays(1).AddHours(1), "user-1"); - AddUsage(builder, ProductTours.CreateTelemetrySource(ProductTourTelemetryEvent.Completed, ProductTours.AppWelcome, 1, ProductTourLaunchSource.Automatic), month.AddDays(1).AddHours(2), "user-1"); - AddUsage(builder, ProductTours.CreateTelemetrySource(ProductTourTelemetryEvent.Dismissed, ProductTours.AppWelcome, 1, ProductTourLaunchSource.Automatic), month.AddDays(2), "user-1"); + AddUsage(builder, ProductTours.CreateTelemetrySource(ProductTourTelemetryEvent.Shown, ProductTours.AppWelcome, 1, ProductTourLaunchSource.Welcome), month.AddDays(1), "user-1", 2); + AddUsage(builder, ProductTours.CreateTelemetrySource(ProductTourTelemetryEvent.Started, ProductTours.AppWelcome, 1, ProductTourLaunchSource.Welcome), month.AddDays(1).AddHours(1), "user-1"); + AddUsage(builder, ProductTours.CreateTelemetrySource(ProductTourTelemetryEvent.Completed, ProductTours.AppWelcome, 1, ProductTourLaunchSource.Welcome), month.AddDays(1).AddHours(2), "user-1"); + AddUsage(builder, ProductTours.CreateTelemetrySource(ProductTourTelemetryEvent.Dismissed, ProductTours.AppWelcome, 1, ProductTourLaunchSource.Welcome), month.AddDays(2), "user-1"); AddUsage(builder, "product-tour.started.unknown.v1.unknown-source", month.AddDays(8), "user-4"); builder.Event() @@ -51,7 +51,7 @@ await CreateDataAsync(builder => .Source("product-tour.started.ignored-tour.v1.catalog") .Date(month.AddDays(9)) .UserIdentity("user-5"); - AddUsage(builder, "product-tour.started.old-tour.v1.catalog", month.AddMonths(-1), "user-6"); + AddUsage(builder, ProductTours.CreateTelemetrySource(ProductTourTelemetryEvent.Started, ProductTours.AppOverview, 1, ProductTourLaunchSource.Catalog), month.AddMonths(-1), "user-6"); }); // Act @@ -59,95 +59,81 @@ await CreateDataAsync(builder => .AsGlobalAdminUser() .AppendPaths("admin", "product-tour-usage") .QueryString("month", "2026-08-01") - .QueryString("limit", "3") .StatusCodeShouldBeOk()); // Assert Assert.NotNull(response); - Assert.Equal(month, response.Month); - Assert.Equal(3, response.Tours.Count); + Assert.Equal(month, response.UtcStart); + Assert.Equal(month.AddMonths(1), response.UtcEnd); + Assert.Equal(ProductTours.Definitions.Values.Sum(definition => definition.CurrentVersion), response.Tours.Count); var overview = Assert.Single(response.Tours, tour => String.Equals(tour.Name, ProductTours.AppOverview, StringComparison.Ordinal)); + Assert.Equal(1, overview.Version); + Assert.Equal(ProductTourKind.Guide, overview.Kind); Assert.Equal(0, overview.Shown); Assert.Equal(3, overview.Started); - Assert.Equal(3, overview.ManualStarted); Assert.Equal(1, overview.Completed); Assert.Equal(1, overview.Dismissed); Assert.Equal(month.AddDays(6), overview.LastRunUtc); - Assert.Null(overview.StartedRate); - Assert.Equal(1m, overview.ManualStartedRate); - Assert.Equal(0.3333m, overview.CompletionRate); - Assert.Equal(0.3333m, overview.DismissalRate); - - var exie = Assert.Single(response.Tours, tour => String.Equals(tour.Name, ProductTours.ExieOverview, StringComparison.Ordinal)); - Assert.Equal(1, exie.Started); - Assert.Equal(1, exie.ManualStarted); - Assert.Null(exie.StartedRate); - Assert.Equal(1m, exie.ManualStartedRate); - Assert.Equal(month.AddDays(7), exie.LastRunUtc); - Assert.Equal(0m, exie.CompletionRate); - Assert.Equal(0m, exie.DismissalRate); + Assert.Equal(2, Assert.Single(overview.StartSources, source => source.Source == ProductTourLaunchSource.Catalog).Count); + Assert.Equal(1, Assert.Single(overview.StartSources, source => source.Source == ProductTourLaunchSource.HelpMenu).Count); var welcome = Assert.Single(response.Tours, tour => String.Equals(tour.Name, ProductTours.AppWelcome, StringComparison.Ordinal)); + Assert.Equal(ProductTourKind.Prompt, welcome.Kind); Assert.Equal(2, welcome.Shown); Assert.Equal(1, welcome.Started); - Assert.Equal(0.5m, welcome.StartedRate); - Assert.Equal(0m, welcome.ManualStartedRate); Assert.Equal(1, welcome.Completed); Assert.Equal(1, welcome.Dismissed); - Assert.Equal(month.AddDays(2), welcome.LastRunUtc); - Assert.Equal(0.5m, welcome.CompletionRate); - Assert.Equal(0.5m, welcome.DismissalRate); - - Assert.Equal(3, response.RecentEvents.Count); - Assert.Equal(month.AddDays(7), response.RecentEvents.First().DateUtc); - Assert.All(response.RecentEvents, productTourEvent => Assert.StartsWith("user-", productTourEvent.UserIdentity)); + Assert.Equal(1, Assert.Single(welcome.StartSources).Count); + Assert.Equal(ProductTourLaunchSource.Welcome, welcome.StartSources.Single().Source); + Assert.DoesNotContain(response.Tours, tour => tour.Started > 0 && (tour.Name is "unknown" or "ignored-tour")); } [Fact] - public async Task GetProductTourUsageAsync_ForAllTime_ReturnsUsageAcrossMonths() + public async Task GetProductTourUsageAsync_History_ReturnsConfiguredAvailableRange() { // Arrange - var currentMonth = new DateTime(2026, 8, 1, 0, 0, 0, DateTimeKind.Utc); - await CreateDataAsync(builder => - { - AddUsage(builder, ProductTours.CreateTelemetrySource(ProductTourTelemetryEvent.Started, ProductTours.AppOverview, 1, ProductTourLaunchSource.Catalog), currentMonth.AddMonths(-1), "user-1"); - AddUsage(builder, ProductTours.CreateTelemetrySource(ProductTourTelemetryEvent.Started, ProductTours.AppOverview, 1, ProductTourLaunchSource.Automatic), currentMonth.AddDays(1), "user-2"); - }); + var now = new DateTime(2026, 8, 21, 12, 30, 0, DateTimeKind.Utc); + TimeProvider.SetUtcNow(now); + var retainedEvent = now.SubtractDays(_appOptions.MaximumRetentionDays - 1); + await CreateDataAsync(builder => AddUsage(builder, + ProductTours.CreateTelemetrySource(ProductTourTelemetryEvent.Started, ProductTours.AppOverview, 1, ProductTourLaunchSource.Catalog), + retainedEvent, + "user-1")); // Act var response = await SendRequestAsAsync(request => request .AsGlobalAdminUser() .AppendPaths("admin", "product-tour-usage") - .QueryString("all", "true") + .QueryString("history", "true") .StatusCodeShouldBeOk()); // Assert Assert.NotNull(response); - Assert.Null(response.Month); - var overview = Assert.Single(response.Tours); - Assert.Equal(2, overview.Started); - Assert.Equal(1, overview.ManualStarted); - Assert.Equal(0.5m, overview.ManualStartedRate); + Assert.Equal(now.SubtractDays(_appOptions.MaximumRetentionDays), response.UtcStart); + Assert.Equal(now, response.UtcEnd); + var overview = Assert.Single(response.Tours, tour => String.Equals(tour.Name, ProductTours.AppOverview, StringComparison.Ordinal)); + Assert.Equal(1, overview.Started); } [Fact] - public Task GetProductTourUsageAsync_WithMonthAndAllTime_ReturnsValidationProblem() + public Task GetProductTourUsageAsync_WithMonthAndHistory_ReturnsValidationProblem() { // Act & Assert return SendRequestAsync(request => request .AsGlobalAdminUser() .AppendPaths("admin", "product-tour-usage") .QueryString("month", "2026-08-01") - .QueryString("all", "true") + .QueryString("history", "true") .StatusCodeShouldBeUnprocessableEntity()); } [Fact] - public async Task GetProductTourUsageAsync_WithoutMonth_UsesCurrentUtcMonth() + public async Task GetProductTourUsageAsync_WithoutMonth_UsesCurrentUtcMonthAndReturnsZeroRows() { // Arrange - TimeProvider.SetUtcNow(new DateTime(2026, 9, 17, 12, 0, 0, DateTimeKind.Utc)); + var now = new DateTime(2026, 9, 17, 12, 0, 0, DateTimeKind.Utc); + TimeProvider.SetUtcNow(now); // Act var response = await SendRequestAsAsync(request => request @@ -157,9 +143,18 @@ public async Task GetProductTourUsageAsync_WithoutMonth_UsesCurrentUtcMonth() // Assert Assert.NotNull(response); - Assert.Equal(new DateTime(2026, 9, 1, 0, 0, 0, DateTimeKind.Utc), response.Month); - Assert.Empty(response.Tours); - Assert.Empty(response.RecentEvents); + Assert.Equal(now.StartOfMonth(), response.UtcStart); + Assert.Equal(now.StartOfMonth().AddMonths(1), response.UtcEnd); + Assert.Equal(ProductTours.Definitions.Values.Sum(definition => definition.CurrentVersion), response.Tours.Count); + Assert.All(response.Tours, tour => + { + Assert.Equal(0, tour.Shown); + Assert.Equal(0, tour.Started); + Assert.Equal(0, tour.Completed); + Assert.Equal(0, tour.Dismissed); + Assert.Empty(tour.StartSources); + Assert.Null(tour.LastRunUtc); + }); } [Fact] diff --git a/tests/Exceptionless.Tests/Api/Endpoints/ProductTourEndpointTests.cs b/tests/Exceptionless.Tests/Api/Endpoints/ProductTourEndpointTests.cs index 87096a9149..95cfc7334a 100644 --- a/tests/Exceptionless.Tests/Api/Endpoints/ProductTourEndpointTests.cs +++ b/tests/Exceptionless.Tests/Api/Endpoints/ProductTourEndpointTests.cs @@ -89,6 +89,57 @@ public async Task UpdateCurrentUserProductTourAsync_CompletedProgress_ReplacesDi Assert.Equal(replacement, persistedUser.ProductTours["exie-overview"]); } + [Fact] + public async Task UpdateCurrentUserProductTourAsync_ConcurrentUpdatesPreserveBothTourKeys() + { + // Arrange + var currentUser = await GetTestOrganizationUserAsync(); + + // Act + await Task.WhenAll( + UpdateProgressAsync(ProductTours.AppOverview, ProductTourStatus.Completed, 1), + UpdateProgressAsync(ProductTours.SavedViewCreate, ProductTourStatus.Dismissed, 1)); + + // Assert + var persistedUser = await _userRepository.GetByIdAsync(currentUser.Id, options => options.Cache(false)); + Assert.NotNull(persistedUser); + Assert.Equal(ProductTourStatus.Completed, persistedUser.ProductTours[ProductTours.AppOverview].Status); + Assert.Equal(ProductTourStatus.Dismissed, persistedUser.ProductTours[ProductTours.SavedViewCreate].Status); + } + + [Fact] + public async Task UpdateCurrentUserProductTourAsync_ConcurrentDismissAndCompleteLeavesCompletedProgress() + { + // Arrange + var currentUser = await GetTestOrganizationUserAsync(); + + // Act + await Task.WhenAll( + UpdateProgressAsync(ProductTours.ExieOverview, ProductTourStatus.Dismissed, 1), + UpdateProgressAsync(ProductTours.ExieOverview, ProductTourStatus.Completed, 1)); + + // Assert + var persistedUser = await _userRepository.GetByIdAsync(currentUser.Id, options => options.Cache(false)); + Assert.NotNull(persistedUser); + Assert.Equal(ProductTourStatus.Completed, persistedUser.ProductTours[ProductTours.ExieOverview].Status); + } + + [Fact] + public async Task UpdateCurrentUserProductTourAsync_MissingUser_ReturnsNotFound() + { + // Arrange + var currentUser = await GetTestOrganizationUserAsync(); + await _userRepository.RemoveAsync(currentUser.Id, options => options.ImmediateConsistency()); + + // Act & Assert + await SendRequestAsync(request => request + .Put() + .AsTestOrganizationUser() + .AppendPaths("users", "me", "product-tours", ProductTours.AppOverview) + .Content(new UpdateProductTourProgress { Status = ProductTourStatus.Completed, Version = 1 }) + .StatusCodeShouldBeNotFound()); + } + [Fact] public async Task UpdateCurrentUserProductTourAsync_UnknownTourName_ReturnsUnprocessableEntity() { diff --git a/tests/Exceptionless.Tests/Api/OpenApiSnapshotTests.cs b/tests/Exceptionless.Tests/Api/OpenApiSnapshotTests.cs index 8d800774db..417777c325 100644 --- a/tests/Exceptionless.Tests/Api/OpenApiSnapshotTests.cs +++ b/tests/Exceptionless.Tests/Api/OpenApiSnapshotTests.cs @@ -113,7 +113,8 @@ public async Task GetOpenApiJson_Default_ContainsExpectedSchemasAndSecuritySchem Assert.True(schemas.TryGetProperty("TokenResult", out _)); Assert.True(schemas.TryGetProperty("ProductTourProgress", out _)); Assert.True(schemas.TryGetProperty("UpdateProductTourProgress", out _)); - Assert.True(schemas.TryGetProperty("ProductTourEvent", out _)); + Assert.True(schemas.TryGetProperty("ProductTourKind", out _)); + Assert.True(schemas.TryGetProperty("ProductTourStartSource", out _)); Assert.True(schemas.TryGetProperty("ProductTourSummary", out _)); Assert.True(schemas.TryGetProperty("ProductTourUsageResponse", out _)); Assert.True(schemas.TryGetProperty("ViewOrganization", out _)); diff --git a/tests/Exceptionless.Tests/Repositories/EventRepositoryTests.cs b/tests/Exceptionless.Tests/Repositories/EventRepositoryTests.cs index c0aecf2eb2..4e878370ee 100644 --- a/tests/Exceptionless.Tests/Repositories/EventRepositoryTests.cs +++ b/tests/Exceptionless.Tests/Repositories/EventRepositoryTests.cs @@ -46,7 +46,7 @@ await CreateDataAsync(builder => AddProductTourUsage(builder, ProductTours.CreateTelemetrySource(ProductTourTelemetryEvent.Started, ProductTours.AppOverview, 1, ProductTourLaunchSource.Catalog), month.AddDays(1), "user-1", 2); AddProductTourUsage(builder, ProductTours.CreateTelemetrySource(ProductTourTelemetryEvent.Started, ProductTours.AppOverview, 1, ProductTourLaunchSource.HelpMenu), month.AddDays(2), "user-1"); AddProductTourUsage(builder, ProductTours.CreateTelemetrySource(ProductTourTelemetryEvent.Completed, ProductTours.AppOverview, 1, ProductTourLaunchSource.Catalog), month.AddDays(3), "user-2"); - AddProductTourUsage(builder, ProductTours.CreateTelemetrySource(ProductTourTelemetryEvent.Shown, ProductTours.AppWelcome, 1, ProductTourLaunchSource.Automatic), month.AddDays(4), "user-3"); + AddProductTourUsage(builder, ProductTours.CreateTelemetrySource(ProductTourTelemetryEvent.Shown, ProductTours.AppWelcome, 1, ProductTourLaunchSource.Welcome), month.AddDays(4), "user-3"); AddProductTourUsage(builder, "product-tour.started.app-overview.v2.catalog", month.AddDays(5), "user-4"); AddProductTourUsage(builder, "product-tour.started.unknown-tour.v1.catalog", month.AddDays(6), "user-5"); AddProductTourUsage(builder, ProductTours.CreateTelemetrySource(ProductTourTelemetryEvent.Started, ProductTours.AppOverview, 1, ProductTourLaunchSource.Catalog), month.AddMonths(-1), "user-6"); @@ -66,10 +66,9 @@ await CreateDataAsync(builder => }); // Act - var result = await _repository.GetProductTourUsageAsync(_appOptions.InternalProjectId, month, month.AddMonths(1), recentLimit: 3); + var result = await _repository.GetProductTourUsageAsync(_appOptions.InternalProjectId, month, month.AddMonths(1)); // Assert - Assert.Equal(3, result.RecentEvents.Count); Assert.Equal(2, result.Buckets.Select(bucket => bucket.Source.TourName).Distinct(StringComparer.Ordinal).Count()); var overview = result.Buckets.Where(bucket => String.Equals(bucket.Source.TourName, ProductTours.AppOverview, StringComparison.Ordinal)).ToArray(); Assert.Equal(4, overview.Where(bucket => bucket.Source.Event == ProductTourTelemetryEvent.Started).Sum(bucket => bucket.Count)); @@ -79,8 +78,7 @@ await CreateDataAsync(builder => var welcome = Assert.Single(result.Buckets, bucket => String.Equals(bucket.Source.TourName, ProductTours.AppWelcome, StringComparison.Ordinal)); Assert.Equal(1, welcome.Count); - Assert.Equal(month.AddDays(8), result.RecentEvents.First().Event.Date); - Assert.All(result.RecentEvents, item => Assert.True(ProductTours.IsValid(item.Source.TourName, item.Source.Version))); + Assert.All(result.Buckets, item => Assert.True(ProductTours.IsValid(item.Source.TourName, item.Source.Version))); } [Fact] @@ -91,14 +89,13 @@ public async Task GetProductTourUsageAsync_WithoutDates_ReturnsAllUsage() await CreateDataAsync(builder => { AddProductTourUsage(builder, ProductTours.CreateTelemetrySource(ProductTourTelemetryEvent.Started, ProductTours.AppOverview, 1, ProductTourLaunchSource.Catalog), month.AddMonths(-1), "user-1"); - AddProductTourUsage(builder, ProductTours.CreateTelemetrySource(ProductTourTelemetryEvent.Started, ProductTours.AppOverview, 1, ProductTourLaunchSource.Automatic), month.AddDays(1), "user-2"); + AddProductTourUsage(builder, ProductTours.CreateTelemetrySource(ProductTourTelemetryEvent.Started, ProductTours.AppOverview, 1, ProductTourLaunchSource.Welcome), month.AddDays(1), "user-2"); }); // Act - var result = await _repository.GetProductTourUsageAsync(_appOptions.InternalProjectId); + var result = await _repository.GetProductTourUsageAsync(_appOptions.InternalProjectId, null, month.AddMonths(1)); // Assert - Assert.Equal(2, result.RecentEvents.Count); Assert.Equal(2, result.Buckets.Sum(bucket => bucket.Count)); } diff --git a/tests/http/admin.http b/tests/http/admin.http index 85e13d94d2..fe3a895a49 100644 --- a/tests/http/admin.http +++ b/tests/http/admin.http @@ -54,6 +54,14 @@ Authorization: Bearer {{token}} GET {{apiUrl}}/admin/assistant-usage Authorization: Bearer {{token}} +### Product Tour Usage (current retained month) +GET {{apiUrl}}/admin/product-tour-usage +Authorization: Bearer {{token}} + +### Product Tour Usage (available retained history) +GET {{apiUrl}}/admin/product-tour-usage?history=true +Authorization: Bearer {{token}} + ### Get Exie Settings GET {{apiUrl}}/admin/assistant-settings Authorization: Bearer {{token}} @@ -117,12 +125,12 @@ Content-Type: application/json } ### Product Tour Usage -GET {{apiUrl}}/admin/product-tour-usage?month=2026-08-01&limit=100 +GET {{apiUrl}}/admin/product-tour-usage?month=2026-08-01 Authorization: Bearer {{token}} ### -GET {{apiUrl}}/admin/product-tour-usage?all=true&limit=100 +GET {{apiUrl}}/admin/product-tour-usage?history=true Authorization: Bearer {{token}} ### Suspend From ee7f1ecba4e51fcb28fa1e51ae23cb8b3bb02e09 Mon Sep 17 00:00:00 2001 From: Blake Niemyjski Date: Sun, 30 Aug 2026 09:42:38 -0500 Subject: [PATCH 10/43] Harden guided tour workflows and reporting --- .../Models/Data/ProductTours.cs | 6 +- .../Interfaces/IUserRepository.cs | 2 + .../Repositories/UserRepository.cs | 36 ++ .../Api/Handlers/UserHandler.cs | 38 +- .../ClientApp/e2e/tests/product-tours.e2e.ts | 48 ++- .../src/lib/features/admin/api.svelte.ts | 50 ++- .../features/admin/product-tour-usage.test.ts | 23 ++ .../lib/features/admin/product-tour-usage.ts | 28 ++ .../events/components/events-overview.svelte | 6 +- .../investigation-detail-tour.svelte | 2 +- .../features/product-tours/actions.svelte.ts | 48 ++- .../features/product-tours/catalog.test.ts | 23 +- .../src/lib/features/product-tours/catalog.ts | 44 ++- .../product-tour-catalog-dialog.svelte | 15 +- .../product-tour-welcome-dialog.svelte | 11 +- ...product-tour-welcome-dialog.svelte.test.ts | 3 +- .../product-tour-feature-announcement.svelte | 9 +- .../components/product-tour-host.svelte | 227 +++++++++-- .../product-tour-inline-callout.svelte | 13 +- .../product-tour-shell-spotlight.svelte | 24 +- .../components/product-tour-spotlight.svelte | 124 +++++- .../components/saved-view-create-tour.svelte | 68 +--- .../product-tours/session.svelte.test.ts | 9 +- .../src/lib/features/product-tours/session.ts | 25 +- .../product-tours/state.svelte.test.ts | 1 - .../features/product-tours/state.svelte.ts | 11 +- .../lib/features/product-tours/telemetry.ts | 4 +- .../src/lib/features/product-tours/types.ts | 15 +- .../components/save-view-dialog.svelte | 2 +- .../components/saved-view-picker.svelte | 8 +- .../saved-views/use-saved-views.svelte.ts | 6 +- .../stacks/components/stack-card.svelte | 3 +- .../ClientApp/src/lib/generated/api.ts | 53 +-- .../ClientApp/src/lib/generated/schemas.ts | 41 +- .../(app)/(components)/layouts/navbar.svelte | 2 +- .../(components)/navigation-command.svelte | 20 +- .../navigation-command.svelte.test.ts | 34 +- .../ClientApp/src/routes/(app)/+layout.svelte | 72 +--- .../src/routes/(app)/event/+page.svelte | 6 - .../(app)/organization/add/+page.svelte | 11 +- .../[projectId]/configure/+page.svelte | 6 +- .../src/routes/(app)/project/add/+page.svelte | 2 +- .../(app)/system/product-tours/+page.svelte | 365 ++++++++++++------ .../Api/Endpoints/ProductTourEndpointTests.cs | 13 +- 44 files changed, 975 insertions(+), 582 deletions(-) create mode 100644 src/Exceptionless.Web/ClientApp/src/lib/features/admin/product-tour-usage.test.ts create mode 100644 src/Exceptionless.Web/ClientApp/src/lib/features/admin/product-tour-usage.ts diff --git a/src/Exceptionless.Core/Models/Data/ProductTours.cs b/src/Exceptionless.Core/Models/Data/ProductTours.cs index dfcff97942..65ef2328f7 100644 --- a/src/Exceptionless.Core/Models/Data/ProductTours.cs +++ b/src/Exceptionless.Core/Models/Data/ProductTours.cs @@ -27,15 +27,11 @@ public static class ProductTours public static bool IsKnown(string name) => Definitions.ContainsKey(name); - public static bool IsPrompt(string name) => Find(name)?.Kind is ProductTourKind.Prompt; - public static bool IsValid(string name, int version) { - return Find(name) is { } definition && version > 0 && version <= definition.CurrentVersion; + return Definitions.TryGetValue(name, out var definition) && version > 0 && version <= definition.CurrentVersion; } - public static ProductTourDefinition? Find(string name) => Definitions.GetValueOrDefault(name); - public static string CreateTelemetrySource( ProductTourTelemetryEvent telemetryEvent, string tourName, diff --git a/src/Exceptionless.Core/Repositories/Interfaces/IUserRepository.cs b/src/Exceptionless.Core/Repositories/Interfaces/IUserRepository.cs index 4835daa616..6c5f355915 100644 --- a/src/Exceptionless.Core/Repositories/Interfaces/IUserRepository.cs +++ b/src/Exceptionless.Core/Repositories/Interfaces/IUserRepository.cs @@ -1,4 +1,5 @@ using Exceptionless.Core.Models; +using Exceptionless.Core.Models.Data; using Foundatio.Repositories; using Foundatio.Repositories.Models; @@ -12,4 +13,5 @@ public interface IUserRepository : ISearchableRepository Task GetUserByOAuthProviderAsync(string provider, string providerUserId); Task GetByVerifyEmailAddressTokenAsync(string token); Task> GetByOrganizationIdAsync(string organizationId, CommandOptionsDescriptor? options = null); + Task UpdateProductTourProgressAsync(string userId, string tourName, ProductTourProgress progress); } diff --git a/src/Exceptionless.Core/Repositories/UserRepository.cs b/src/Exceptionless.Core/Repositories/UserRepository.cs index 91919a6beb..f5cafa976a 100644 --- a/src/Exceptionless.Core/Repositories/UserRepository.cs +++ b/src/Exceptionless.Core/Repositories/UserRepository.cs @@ -1,7 +1,9 @@ using Exceptionless.Core.Extensions; +using Exceptionless.Core.Models.Data; using Exceptionless.Core.Repositories.Configuration; using Exceptionless.Core.Validation; using Foundatio.Repositories; +using Foundatio.Repositories.Exceptions; using Foundatio.Repositories.Models; using Foundatio.Repositories.Options; using User = Exceptionless.Core.Models.User; @@ -78,6 +80,40 @@ public Task> GetByOrganizationIdAsync(string organizationId, C return FindAsync(q => q.FieldEquals(u => u.OrganizationIds, organizationId).SortAscending(u => u.EmailAddress), o => commandOptions); } + public async Task UpdateProductTourProgressAsync(string userId, string tourName, ProductTourProgress progress) + { + const string script = """ + if (ctx._source.product_tours == null) { + ctx._source.product_tours = [:]; + } + + def current = ctx._source.product_tours[params.tourName]; + if (current != null && (current.version > params.version || + (current.version == params.version && current.status <= params.status))) { + ctx.op = 'none'; + } else { + ctx._source.product_tours[params.tourName] = ['status': params.status, 'version': params.version]; + } + """; + var patch = new ScriptPatch(script.TrimScript()) + { + Params = new Dictionary + { + ["status"] = (int)progress.Status, + ["tourName"] = tourName, + ["version"] = progress.Version + } + }; + + await PatchAsync(userId, patch, options => options.Cache()); + + var user = await GetByIdAsync(userId, options => options.Cache(false)); + if (user is null || !user.ProductTours.TryGetValue(tourName, out var storedProgress)) + throw new DocumentNotFoundException(userId); + + return storedProgress; + } + protected override async Task AddDocumentsToCacheAsync(ICollection> findHits, ICommandOptions options, bool isDirtyRead) { await base.AddDocumentsToCacheAsync(findHits, options, isDirtyRead); diff --git a/src/Exceptionless.Web/Api/Handlers/UserHandler.cs b/src/Exceptionless.Web/Api/Handlers/UserHandler.cs index 3766cf716b..18656626b6 100644 --- a/src/Exceptionless.Web/Api/Handlers/UserHandler.cs +++ b/src/Exceptionless.Web/Api/Handlers/UserHandler.cs @@ -18,7 +18,6 @@ using Foundatio.Mediator; using Foundatio.Repositories; using Foundatio.Repositories.Exceptions; -using Foundatio.Repositories.Models; namespace Exceptionless.Web.Api.Handlers; @@ -60,45 +59,22 @@ public async Task> Handle(UpdateCurrentUserProductTo if (!ProductTours.IsValid(message.TourName, message.Progress.Version)) return Result.Invalid(ValidationError.Create("version", "The product tour version is not supported.")); - ProductTourProgress? progress = null; try { - await repository.PatchAsync( + var progress = await repository.UpdateProductTourProgressAsync( GetCurrentUserId(), - new ActionPatch(user => + message.TourName, + new ProductTourProgress { - user.ProductTours.TryGetValue(message.TourName, out var currentProgress); - if (!ShouldUpdateProductTourProgress(currentProgress, message.Progress)) - { - progress = currentProgress; - return false; - } - - progress = new ProductTourProgress - { - Status = message.Progress.Status!.Value, - Version = message.Progress.Version - }; - user.ProductTours[message.TourName] = progress; - return true; - }), - options => options.Cache()); + Status = message.Progress.Status!.Value, + Version = message.Progress.Version + }); + return progress; } catch (DocumentNotFoundException) { return Result.NotFound("User not found."); } - - return progress is null ? Result.NotFound("User not found.") : progress; - } - - private static bool ShouldUpdateProductTourProgress(ProductTourProgress? current, UpdateProductTourProgress requested) - { - return current is null - || requested.Version > current.Version - || (requested.Version == current.Version - && current.Status is ProductTourStatus.Dismissed - && requested.Status is ProductTourStatus.Completed); } public async Task>> Handle(GetCurrentUserOAuthGrants message) diff --git a/src/Exceptionless.Web/ClientApp/e2e/tests/product-tours.e2e.ts b/src/Exceptionless.Web/ClientApp/e2e/tests/product-tours.e2e.ts index d9021fd3e0..c49d86224a 100644 --- a/src/Exceptionless.Web/ClientApp/e2e/tests/product-tours.e2e.ts +++ b/src/Exceptionless.Web/ClientApp/e2e/tests/product-tours.e2e.ts @@ -63,11 +63,38 @@ test.describe('shell and identity checkpoints', () => { await expect(tour.getByText('Find anything quickly')).toBeVisible(); const dismissed = page.waitForResponse(isSuccessfulTourProgress('app-overview')); - await tour.getByRole('button', { name: 'Close' }).click(); + await tour.getByRole('button', { name: 'End guide' }).click(); await dismissed; await expectProductTourSession(page, false); }); + await test.step('every shell target remains visible on mobile', async () => { + await mockAssistantAccess(page); + await page.reload(); + await page.setViewportSize({ height: 844, width: 390 }); + await startTourFromCommand(page, 'Explore Exceptionless'); + const tour = page.locator('.driver-popover'); + + for (const [title, target] of [ + ['Your workspace navigation', '[data-tour="app-navigation"]'], + ['Find anything quickly', '[data-tour="command-search"]'], + ['Reuse configured views', '[data-tour="saved-view-navigation"]'], + ['Ask Exie with context', '[data-tour="exie-trigger"]'], + ['Help is always nearby', '[data-tour="help-menu"]'] + ] as const) { + await expect(tour.getByText(title)).toBeVisible(); + await expect(page.locator(target)).toBeVisible(); + if (title !== 'Help is always nearby') { + await tour.getByRole('button', { name: 'Continue' }).click(); + } + } + + const completed = page.waitForResponse(isSuccessfulTourProgress('app-overview')); + await tour.getByRole('button', { name: 'Continue' }).click(); + await completed; + await expectProductTourSession(page, false); + }); + await test.step('an organization change clears an active checkpoint without recording progress', async () => { await mockAssistantAccess(page); await page.reload(); @@ -121,6 +148,12 @@ test('domain workflows advance only on real success', async ({ e2eApi, e2eScenar await page.locator('[data-product-tour-inline="project-configure"]').getByRole('button', { name: 'Continue' }).click(); await expect(page.getByText('Waiting for your first event')).toBeVisible(); + let projectProgressRequests = 0; + const projectProgressRoute = (url: URL) => url.pathname === '/api/v2/users/me/product-tours/project-configure'; + await page.route(projectProgressRoute, async (route) => { + projectProgressRequests += 1; + await route.fulfill({ json: { title: 'Injected progress failure' }, status: 500 }); + }); try { const token = await e2eApi.getProjectDefaultToken(e2eScenario.userToken, projectId!); await e2eApi.submitEvent( @@ -134,8 +167,16 @@ test('domain workflows advance only on real success', async ({ e2eApi, e2eScenar }) ); await expect(page).toHaveURL(/\/next\/event/); + await expectProductTourSession(page, true); + await expect.poll(() => projectProgressRequests).toBe(1); + + await page.unroute(projectProgressRoute); + const completed = page.waitForResponse(isSuccessfulTourProgress('project-configure')); + await page.goto(`/next/project/${projectId}/configure`); + await completed; await expectProductTourSession(page, false); } finally { + await page.unroute(projectProgressRoute); await e2eApi.deleteProject(e2eScenario.userToken, projectId!); await e2eApi.waitForProjectDeleted(e2eScenario.userToken, projectId!); } @@ -267,5 +308,8 @@ async function startTourFromCommand(page: Page, title: string): Promise { } await page.getByRole('button', { name: 'Search Exceptionless' }).click(); - await page.getByRole('dialog').getByText(title, { exact: true }).click(); + await page.getByRole('dialog').getByText('Guided Tours…', { exact: true }).click(); + const catalog = page.getByRole('dialog', { name: 'Guided Tours' }); + const tour = catalog.locator('section').filter({ has: catalog.getByRole('heading', { name: title }) }); + await tour.getByRole('button', { name: /^(Continue|Restart|Start)$/ }).click(); } diff --git a/src/Exceptionless.Web/ClientApp/src/lib/features/admin/api.svelte.ts b/src/Exceptionless.Web/ClientApp/src/lib/features/admin/api.svelte.ts index 9d368712f0..1526ee6510 100644 --- a/src/Exceptionless.Web/ClientApp/src/lib/features/admin/api.svelte.ts +++ b/src/Exceptionless.Web/ClientApp/src/lib/features/admin/api.svelte.ts @@ -19,6 +19,8 @@ import type { UpdateEventSubmissionSettingsRequest } from './models'; +import { getProductTourUsageParams, type ProductTourUsageRange } from './product-tour-usage'; + export type GetOAuthApplicationsParams = { criteria?: string; limit?: number; @@ -45,7 +47,7 @@ export const queryKeys = { migrations: ['admin', 'migrations'] as const, oauthApplication: (id: string | undefined) => [...queryKeys.oauthApplications, id] as const, oauthApplications: ['admin', 'oauth-applications'] as const, - productTourUsage: (month?: string) => ['admin', 'product-tour-usage', month ?? 'all'] as const, + productTourUsage: (range: ProductTourUsageRange) => ['admin', 'product-tour-usage', range] as const, snapshots: ['admin', 'elasticsearch', 'snapshots'] as const, stats: ['admin', 'stats'] as const }; @@ -112,34 +114,28 @@ export function getAdminAssistantUsageQuery(month: () => string) { })); } -export function getAdminProductTourUsageQuery(month: () => string | undefined) { - return createQuery(() => ({ - queryFn: async ({ signal }: { signal: AbortSignal }) => { - const client = useFetchClient(); - const selectedMonth = month(); - const params = selectedMonth - ? { - limit: 100, - month: `${selectedMonth}-01` - } - : { - all: true, - limit: 100 - }; - const response = await client.getJSON('admin/product-tour-usage', { - params, - signal - }); +export function getAdminProductTourUsageQuery(range: () => ProductTourUsageRange) { + return createQuery(() => { + const selectedRange = range(); - if (!response.ok) { - throw response.problem; - } + return { + queryFn: async ({ signal }: { signal: AbortSignal }) => { + const client = useFetchClient(); + const response = await client.getJSON('admin/product-tour-usage', { + params: getProductTourUsageParams(selectedRange), + signal + }); - return response.data!; - }, - queryKey: queryKeys.productTourUsage(month()), - staleTime: 60 * 1000 - })); + if (!response.ok) { + throw response.problem; + } + + return response.data!; + }, + queryKey: queryKeys.productTourUsage(selectedRange), + staleTime: 60 * 1000 + }; + }); } export function getAdminStatsQuery() { diff --git a/src/Exceptionless.Web/ClientApp/src/lib/features/admin/product-tour-usage.test.ts b/src/Exceptionless.Web/ClientApp/src/lib/features/admin/product-tour-usage.test.ts new file mode 100644 index 0000000000..c37e68175e --- /dev/null +++ b/src/Exceptionless.Web/ClientApp/src/lib/features/admin/product-tour-usage.test.ts @@ -0,0 +1,23 @@ +import { describe, expect, it } from 'vitest'; + +import { getOutcomeShare, getProductTourUsageParams, getRate, getStartSourceShare } from './product-tour-usage'; + +describe('product tour usage helpers', () => { + it('maps each immutable range to the API query parameters', () => { + expect(getProductTourUsageParams({ kind: 'month', month: '2026-08' })).toEqual({ month: '2026-08-01' }); + expect(getProductTourUsageParams({ kind: 'history' })).toEqual({ history: true }); + }); + + it('returns null when a percentage has no denominator', () => { + expect(getRate(1, 0)).toBeNull(); + expect(getOutcomeShare({ completed: 0, dismissed: 0 }, 'completed')).toBeNull(); + expect(getStartSourceShare({ count: 1 }, 0)).toBeNull(); + }); + + it('calculates prompt and guide rates from their declared denominators', () => { + expect(getRate(3, 4)).toBe(0.75); + expect(getOutcomeShare({ completed: 3, dismissed: 1 }, 'completed')).toBe(0.75); + expect(getOutcomeShare({ completed: 3, dismissed: 1 }, 'dismissed')).toBe(0.25); + expect(getStartSourceShare({ count: 2 }, 5)).toBe(0.4); + }); +}); diff --git a/src/Exceptionless.Web/ClientApp/src/lib/features/admin/product-tour-usage.ts b/src/Exceptionless.Web/ClientApp/src/lib/features/admin/product-tour-usage.ts new file mode 100644 index 0000000000..98297d2b95 --- /dev/null +++ b/src/Exceptionless.Web/ClientApp/src/lib/features/admin/product-tour-usage.ts @@ -0,0 +1,28 @@ +export type ProductTourUsageRange = + | { + kind: 'history'; + } + | { + kind: 'month'; + month: string; + }; + +export function getOutcomeShare(value: { completed: number; dismissed: number }, outcome: 'completed' | 'dismissed'): null | number { + return getRate(value[outcome], value.completed + value.dismissed); +} + +export function getProductTourUsageParams(range: ProductTourUsageRange): Record { + if (range.kind === 'history') { + return { history: true }; + } + + return { month: `${range.month}-01` }; +} + +export function getRate(numerator: number, denominator: number): null | number { + return denominator > 0 ? numerator / denominator : null; +} + +export function getStartSourceShare(source: { count: number }, started: number): null | number { + return getRate(source.count, started); +} diff --git a/src/Exceptionless.Web/ClientApp/src/lib/features/events/components/events-overview.svelte b/src/Exceptionless.Web/ClientApp/src/lib/features/events/components/events-overview.svelte index 493cd91988..ecdfb6514b 100644 --- a/src/Exceptionless.Web/ClientApp/src/lib/features/events/components/events-overview.svelte +++ b/src/Exceptionless.Web/ClientApp/src/lib/features/events/components/events-overview.svelte @@ -334,7 +334,7 @@ -
+

Stack

{#if event?.stack_id} -
+

Event

@@ -360,7 +360,6 @@ {#if event?.stack_id}
{/each} diff --git a/src/Exceptionless.Web/ClientApp/src/lib/features/product-tours/components/dialogs/product-tour-welcome-dialog.svelte b/src/Exceptionless.Web/ClientApp/src/lib/features/product-tours/components/dialogs/product-tour-welcome-dialog.svelte index 5c7b70c523..90e4998c0b 100644 --- a/src/Exceptionless.Web/ClientApp/src/lib/features/product-tours/components/dialogs/product-tour-welcome-dialog.svelte +++ b/src/Exceptionless.Web/ClientApp/src/lib/features/product-tours/components/dialogs/product-tour-welcome-dialog.svelte @@ -6,6 +6,7 @@ import type { ProductTourListItem } from '../../types'; interface Props { + busy?: boolean; onBrowse: () => Promise; onDismiss: () => Promise; onStart: () => Promise; @@ -13,10 +14,10 @@ recommended: ProductTourListItem; } - let { onBrowse, onDismiss, onStart, open = $bindable(false), recommended }: Props = $props(); + let { busy = false, onBrowse, onDismiss, onStart, open = $bindable(false), recommended }: Props = $props(); async function onOpenChange(nextOpen: boolean): Promise { - if (!nextOpen && open) { + if (!nextOpen && open && !busy) { await onDismiss(); } } @@ -38,10 +39,10 @@
- +
- - + +
diff --git a/src/Exceptionless.Web/ClientApp/src/lib/features/product-tours/components/dialogs/product-tour-welcome-dialog.svelte.test.ts b/src/Exceptionless.Web/ClientApp/src/lib/features/product-tours/components/dialogs/product-tour-welcome-dialog.svelte.test.ts index 08f6bbe948..ffb99d649f 100644 --- a/src/Exceptionless.Web/ClientApp/src/lib/features/product-tours/components/dialogs/product-tour-welcome-dialog.svelte.test.ts +++ b/src/Exceptionless.Web/ClientApp/src/lib/features/product-tours/components/dialogs/product-tour-welcome-dialog.svelte.test.ts @@ -7,10 +7,9 @@ const recommended = { availability: vi.fn(() => ({ available: true })), currentAvailability: { available: true }, description: 'Learn navigation and search.', - initialCheckpoint: 'navigation' as const, keywords: ['navigation'], name: 'app-overview' as const, - startingRoute: vi.fn(() => '/next'), + start: vi.fn(() => ({ checkpointName: 'navigation' as const, route: '/next' })), title: 'Explore Exceptionless', version: 1 }; diff --git a/src/Exceptionless.Web/ClientApp/src/lib/features/product-tours/components/product-tour-feature-announcement.svelte b/src/Exceptionless.Web/ClientApp/src/lib/features/product-tours/components/product-tour-feature-announcement.svelte index 910b93917d..732482c2f1 100644 --- a/src/Exceptionless.Web/ClientApp/src/lib/features/product-tours/components/product-tour-feature-announcement.svelte +++ b/src/Exceptionless.Web/ClientApp/src/lib/features/product-tours/components/product-tour-feature-announcement.svelte @@ -5,6 +5,7 @@ import X from '@lucide/svelte/icons/x'; interface Props { + busy?: boolean; hasAccess: boolean; message?: string; onDismiss: () => void; @@ -12,7 +13,7 @@ open?: boolean; } - let { hasAccess, message, onDismiss, onStart, open = true }: Props = $props(); + let { busy = false, hasAccess, message, onDismiss, onStart, open = true }: Props = $props(); {#if open} @@ -31,13 +32,13 @@ : (message ?? 'Exie is available with an eligible organization plan.')}

-
- - + +
diff --git a/src/Exceptionless.Web/ClientApp/src/lib/features/product-tours/components/product-tour-host.svelte b/src/Exceptionless.Web/ClientApp/src/lib/features/product-tours/components/product-tour-host.svelte index 7038df9819..92fb88e1a2 100644 --- a/src/Exceptionless.Web/ClientApp/src/lib/features/product-tours/components/product-tour-host.svelte +++ b/src/Exceptionless.Web/ClientApp/src/lib/features/product-tours/components/product-tour-host.svelte @@ -1,15 +1,17 @@ - + {#if exieAnnouncementOpen && assistantAccess} {/if} - startTour(name, catalogSource)} /> + startTour(name, catalogSource)} + resumableTourName={checkpoint && isActiveTourRenderable(checkpoint) ? checkpoint.tourName : undefined} +/> {#if checkpoint && (checkpoint.tourName === 'exie-overview' || checkpoint.tourName === 'app-overview')} {#key checkpoint} - + {/key} {/if} diff --git a/src/Exceptionless.Web/ClientApp/src/lib/features/product-tours/components/product-tour-inline-callout.svelte b/src/Exceptionless.Web/ClientApp/src/lib/features/product-tours/components/product-tour-inline-callout.svelte index 325b931d75..408aa47e3d 100644 --- a/src/Exceptionless.Web/ClientApp/src/lib/features/product-tours/components/product-tour-inline-callout.svelte +++ b/src/Exceptionless.Web/ClientApp/src/lib/features/product-tours/components/product-tour-inline-callout.svelte @@ -3,22 +3,29 @@ import { Button } from '$comp/ui/button'; import Info from '@lucide/svelte/icons/info'; + import type { ProductTourCheckpoint } from '../types'; + + import { PRODUCT_TOUR_CHECKPOINTS } from '../types'; + interface Props { + checkpoint: ProductTourCheckpoint; continueLabel?: string; description: string; onContinue?: () => Promise | void; onDismiss: () => Promise | void; title: string; - tourName: string; } - let { continueLabel = 'Continue', description, onContinue, onDismiss, title, tourName }: Props = $props(); + let { checkpoint, continueLabel = 'Continue', description, onContinue, onDismiss, title }: Props = $props(); + const checkpoints = $derived(PRODUCT_TOUR_CHECKPOINTS[checkpoint.tourName]); + const stepNumber = $derived(checkpoints.indexOf(checkpoint.checkpointName) + 1); - +
{#if onContinue} diff --git a/src/Exceptionless.Web/ClientApp/src/lib/features/product-tours/components/product-tour-shell-spotlight.svelte b/src/Exceptionless.Web/ClientApp/src/lib/features/product-tours/components/product-tour-shell-spotlight.svelte index e41e8616d2..802234c706 100644 --- a/src/Exceptionless.Web/ClientApp/src/lib/features/product-tours/components/product-tour-shell-spotlight.svelte +++ b/src/Exceptionless.Web/ClientApp/src/lib/features/product-tours/components/product-tour-shell-spotlight.svelte @@ -1,7 +1,7 @@ -{#if spotlight && (!isAnyOverlayOpen || checkpoint.tourName === 'exie-overview')} +{#if spotlight && targetReady && (!isAnyOverlayOpen || checkpoint.tourName === 'exie-overview')} diff --git a/src/Exceptionless.Web/ClientApp/src/lib/features/product-tours/components/product-tour-spotlight.svelte b/src/Exceptionless.Web/ClientApp/src/lib/features/product-tours/components/product-tour-spotlight.svelte index a00e3de4e7..fefcbc1755 100644 --- a/src/Exceptionless.Web/ClientApp/src/lib/features/product-tours/components/product-tour-spotlight.svelte +++ b/src/Exceptionless.Web/ClientApp/src/lib/features/product-tours/components/product-tour-spotlight.svelte @@ -1,9 +1,12 @@ + + diff --git a/src/Exceptionless.Web/ClientApp/src/lib/features/product-tours/components/saved-view-create-tour.svelte b/src/Exceptionless.Web/ClientApp/src/lib/features/product-tours/components/saved-view-create-tour.svelte index 69e05695ff..d3dfd36e30 100644 --- a/src/Exceptionless.Web/ClientApp/src/lib/features/product-tours/components/saved-view-create-tour.svelte +++ b/src/Exceptionless.Web/ClientApp/src/lib/features/product-tours/components/saved-view-create-tour.svelte @@ -1,31 +1,20 @@ @@ -164,13 +124,11 @@ target="[data-tour='saved-view-submit']" title="Create the saved view" /> -{:else if checkpoint?.phase.type === 'saved-view-created' || checkpoint?.phase.type === 'saved-view-loaded'} +{:else if checkpoint?.checkpointName === 'view-created' && !completionPending} { JSON.stringify({ ...checkpoint, checkpointName: 'unknown-step' }), JSON.stringify({ ...checkpoint, source: 'unknown-source' }), JSON.stringify({ ...checkpoint, version: 0 }), - JSON.stringify({ ...checkpoint, phase: { type: 'saved-view-created' } }), - JSON.stringify({ ...checkpoint, phase: { type: 'saved-view-created', viewId: 'view-id' } }), - JSON.stringify({ ...checkpoint, phase: { type: 'saved-view-loaded', viewId: 'view-id' } }), JSON.stringify({ ...checkpoint, userId: 42 }) ])('clears malformed or unknown stored state: %s', (value) => { sessionStorage.setItem('exceptionless.product-tour', value); expect(readProductTourSession()).toBeUndefined(); expect(sessionStorage).toHaveLength(0); }); + + it('drops obsolete workflow state from an existing browser session', () => { + sessionStorage.setItem('exceptionless.product-tour', JSON.stringify({ ...checkpoint, phase: { type: 'saved-view-created', viewId: 'view-id' } })); + expect(readProductTourSession()).toEqual(checkpoint); + }); }); diff --git a/src/Exceptionless.Web/ClientApp/src/lib/features/product-tours/session.ts b/src/Exceptionless.Web/ClientApp/src/lib/features/product-tours/session.ts index 822de6d2f3..a68574eb3a 100644 --- a/src/Exceptionless.Web/ClientApp/src/lib/features/product-tours/session.ts +++ b/src/Exceptionless.Web/ClientApp/src/lib/features/product-tours/session.ts @@ -1,4 +1,4 @@ -import type { ProductTourCheckpoint, ProductTourLaunchSource, ProductTourName, ProductTourPhase } from './types'; +import type { ProductTourCheckpoint, ProductTourLaunchSource, ProductTourName } from './types'; import { PRODUCT_TOUR_CHECKPOINTS, PRODUCT_TOUR_LAUNCH_SOURCES } from './types'; @@ -20,7 +20,14 @@ export function readProductTourSession(storage: Pick( expected: ProductTourCheckpoint, checkpointName: ProductTourCheckpointName, - phase: ProductTourPhase = { - type: 'active' - }, organizationId = expected.organizationId ): ProductTourCheckpoint | undefined { if (this.current !== expected) { @@ -19,8 +16,7 @@ class ProductTourCheckpointStore { const next = { ...expected, checkpointName, - organizationId, - phase + organizationId } as ProductTourCheckpoint; return this.save(next); } @@ -64,9 +60,6 @@ class ProductTourCheckpointStore { const checkpoint = { checkpointName, organizationId, - phase: { - type: 'active' - }, source, tourName, userId, diff --git a/src/Exceptionless.Web/ClientApp/src/lib/features/product-tours/telemetry.ts b/src/Exceptionless.Web/ClientApp/src/lib/features/product-tours/telemetry.ts index 8eeb6df23d..0c8ecc8429 100644 --- a/src/Exceptionless.Web/ClientApp/src/lib/features/product-tours/telemetry.ts +++ b/src/Exceptionless.Web/ClientApp/src/lib/features/product-tours/telemetry.ts @@ -1,8 +1,6 @@ -import type { ProductTourTelemetryEvent as ProductTourTelemetryEventContract } from '$generated/api'; - import type { ProductTourKey, ProductTourLaunchSource } from './types'; -export type ProductTourTelemetryEvent = `${ProductTourTelemetryEventContract}`; +export type ProductTourTelemetryEvent = 'completed' | 'dismissed' | 'shown' | 'started'; export function buildProductTourTelemetryEvent( event: ProductTourTelemetryEvent, diff --git a/src/Exceptionless.Web/ClientApp/src/lib/features/product-tours/types.ts b/src/Exceptionless.Web/ClientApp/src/lib/features/product-tours/types.ts index f13f8916c5..8be111572e 100644 --- a/src/Exceptionless.Web/ClientApp/src/lib/features/product-tours/types.ts +++ b/src/Exceptionless.Web/ClientApp/src/lib/features/product-tours/types.ts @@ -9,7 +9,7 @@ export const PRODUCT_TOUR_CHECKPOINTS = { 'saved-view-create': ['open-view-menu', 'review-settings', 'name-view', 'private-view', 'save-view', 'view-created'] } as const; -export const PRODUCT_TOUR_LAUNCH_SOURCES = ['automatic', 'catalog', 'command-palette', 'feature-announcement', 'help-menu'] as const; +export const PRODUCT_TOUR_LAUNCH_SOURCES = ['welcome', 'catalog', 'command-palette', 'feature-announcement', 'help-menu'] as const; export interface ProductTourAvailability { available: boolean; @@ -19,7 +19,6 @@ export type ProductTourCheckpoint; organizationId?: string; - phase: ProductTourPhase; source: ProductTourLaunchSource; tourName: Name; userId: string; @@ -33,19 +32,17 @@ export interface ProductTourContext { isSetupPage: boolean; organizationId?: string; pathname: string; - projects: Pick[]; + projects: Pick[]; } export interface ProductTourDefinition { availability: (context: ProductTourContext) => ProductTourAvailability; description: string; - initialCheckpoint: ProductTourCheckpointName; keywords: readonly string[]; name: Name; - startingRoute: (context: ProductTourContext) => string; + start: (context: ProductTourContext) => ProductTourStart; title: string; version: number; } - export type ProductTourKey = 'app-welcome' | 'exie-announcement' | ProductTourName; export type ProductTourLaunchSource = (typeof PRODUCT_TOUR_LAUNCH_SOURCES)[number]; @@ -57,5 +54,7 @@ export interface ProductTourListItem = - (Name extends 'saved-view-create' ? { type: 'saved-view-created' | 'saved-view-loaded'; viewId: string } : never) | { type: 'active' }; +export interface ProductTourStart { + checkpointName: ProductTourCheckpointName; + route: string; +} diff --git a/src/Exceptionless.Web/ClientApp/src/lib/features/saved-views/components/save-view-dialog.svelte b/src/Exceptionless.Web/ClientApp/src/lib/features/saved-views/components/save-view-dialog.svelte index 4c5d0cb627..0452ec7930 100644 --- a/src/Exceptionless.Web/ClientApp/src/lib/features/saved-views/components/save-view-dialog.svelte +++ b/src/Exceptionless.Web/ClientApp/src/lib/features/saved-views/components/save-view-dialog.svelte @@ -124,7 +124,7 @@ } }} > - + Save View Save the current view configuration for quick access. diff --git a/src/Exceptionless.Web/ClientApp/src/lib/features/saved-views/components/saved-view-picker.svelte b/src/Exceptionless.Web/ClientApp/src/lib/features/saved-views/components/saved-view-picker.svelte index 11b6ffd02f..2040dd7fd5 100644 --- a/src/Exceptionless.Web/ClientApp/src/lib/features/saved-views/components/saved-view-picker.svelte +++ b/src/Exceptionless.Web/ClientApp/src/lib/features/saved-views/components/saved-view-picker.svelte @@ -70,7 +70,7 @@ filters: IFilter[]; isModified: boolean; onClearSavedView: () => Promise; - onLoadView: (view: SavedView) => Promise | void; + onLoadView: (view: SavedView) => void; onResetToSaved: () => void; onSavedViewUpdated: (view: SavedView) => void; savedViews: SavedView[]; @@ -284,9 +284,9 @@ try { const result = await createMutation.mutateAsync(body); - const tourCompletion = tour ? tour.created(result) : onLoadView(result); isSaveDialogOpen = false; - await tourCompletion; + onLoadView(result); + void tour?.created(); toast.success(`Saved view "${result.name}" created.`); } catch (error) { toast.error(getErrorMessage(error, 'Failed to save view. Please try again.')); @@ -517,8 +517,6 @@ closeMenu={() => (isMenuOpen = false)} openMenu={() => (isMenuOpen = true)} openSaveDialog={() => (isSaveDialogOpen = true)} - {onLoadView} - {savedViews} /> {#if isRenameDialogOpen && activeView} diff --git a/src/Exceptionless.Web/ClientApp/src/lib/features/saved-views/use-saved-views.svelte.ts b/src/Exceptionless.Web/ClientApp/src/lib/features/saved-views/use-saved-views.svelte.ts index 331a8884b9..7a9d2d4737 100644 --- a/src/Exceptionless.Web/ClientApp/src/lib/features/saved-views/use-saved-views.svelte.ts +++ b/src/Exceptionless.Web/ClientApp/src/lib/features/saved-views/use-saved-views.svelte.ts @@ -99,7 +99,7 @@ export interface UseSavedViewsReturn { autoFillColumnId: AutoFillColumnSelection; canModifySavedView: boolean; handleClearSavedView: () => Promise; - handleLoadView: (view: SavedView) => Promise; + handleLoadView: (view: SavedView) => void; handleResetToSaved: () => void; handleSavedViewUpdated: (view: SavedView) => void; hydratedSavedViewId: string | undefined; @@ -1209,9 +1209,9 @@ export function useSavedViews(options: UseSavedViewsOptions): UseSavedViewsRetur }) ); - async function handleLoadView(view: SavedView): Promise { + function handleLoadView(view: SavedView) { if (options.baseHref) { - await goto(savedViewHref(view)); + goto(savedViewHref(view)); return; } diff --git a/src/Exceptionless.Web/ClientApp/src/lib/features/stacks/components/stack-card.svelte b/src/Exceptionless.Web/ClientApp/src/lib/features/stacks/components/stack-card.svelte index c4e17c0212..d28effe22b 100644 --- a/src/Exceptionless.Web/ClientApp/src/lib/features/stacks/components/stack-card.svelte +++ b/src/Exceptionless.Web/ClientApp/src/lib/features/stacks/components/stack-card.svelte @@ -173,7 +173,6 @@ {#if stack} @@ -185,7 +184,7 @@
- + diff --git a/src/Exceptionless.Web/ClientApp/src/lib/generated/api.ts b/src/Exceptionless.Web/ClientApp/src/lib/generated/api.ts index 841759d02a..d762ce9ff9 100644 --- a/src/Exceptionless.Web/ClientApp/src/lib/generated/api.ts +++ b/src/Exceptionless.Web/ClientApp/src/lib/generated/api.ts @@ -7,20 +7,18 @@ export enum StackStatus { Discarded = "discarded", } -export enum ProductTourTelemetryEvent { - Completed = "completed", - Dismissed = "dismissed", - Shown = "shown", - Started = "started", -} - export enum ProductTourStatus { Completed = 1, Dismissed = 2, } +export enum ProductTourKind { + Guide = "guide", + Prompt = "prompt", +} + export enum ProductTourLaunchSource { - Automatic = "automatic", + Welcome = "welcome", Catalog = "catalog", CommandPalette = "command-palette", FeatureAnnouncement = "feature-announcement", @@ -514,20 +512,6 @@ export interface ProblemDetails { instance?: null | string; } -export interface ProductTourEvent { - /** @format date-time */ - date_utc: string; - event: ProductTourTelemetryEvent; - launch_source: ProductTourLaunchSource; - tour_name: string; - user_identity?: null | string; - user_name?: null | string; - /** @format int32 */ - version: number; - /** @format int64 */ - count: number; -} - export interface ProductTourProgress { status: ProductTourStatus; /** @format int32 */ @@ -536,33 +520,34 @@ export interface ProductTourProgress { export interface ProductTourSummary { name: string; + /** @format int32 */ + version: number; + kind: ProductTourKind; /** @format int64 */ shown: number; /** @format int64 */ started: number; /** @format int64 */ - manual_started: number; - /** @format int64 */ completed: number; /** @format int64 */ dismissed: number; /** @format date-time */ last_run_utc?: null | string; - /** @format double */ - started_rate?: null | number; - /** @format double */ - manual_started_rate?: null | number; - /** @format double */ - completion_rate?: null | number; - /** @format double */ - dismissal_rate?: null | number; + start_sources: ProductTourStartSource[]; +} + +export interface ProductTourStartSource { + source: ProductTourLaunchSource; + /** @format int64 */ + count: number; } export interface ProductTourUsageResponse { /** @format date-time */ - month?: null | string; + utc_start?: null | string; + /** @format date-time */ + utc_end: string; tours: ProductTourSummary[]; - recent_events: ProductTourEvent[]; } export interface ResetPasswordModel { diff --git a/src/Exceptionless.Web/ClientApp/src/lib/generated/schemas.ts b/src/Exceptionless.Web/ClientApp/src/lib/generated/schemas.ts index 959fecd2f0..dc7f6f66b4 100644 --- a/src/Exceptionless.Web/ClientApp/src/lib/generated/schemas.ts +++ b/src/Exceptionless.Web/ClientApp/src/lib/generated/schemas.ts @@ -27,15 +27,10 @@ export const StackStatusSchema = zodEnum([ "ignored", "discarded", ]); -export const ProductTourTelemetryEventSchema = zodEnum([ - "completed", - "dismissed", - "shown", - "started", -]); export const ProductTourStatusSchema = union([literal(1), literal(2)]); +export const ProductTourKindSchema = zodEnum(["guide", "prompt"]); export const ProductTourLaunchSourceSchema = zodEnum([ - "automatic", + "welcome", "catalog", "command-palette", "feature-announcement", @@ -640,18 +635,6 @@ export const ProblemDetailsSchema = object({ }); export type ProblemDetailsFormData = Infer; -export const ProductTourEventSchema = object({ - date_utc: iso.datetime(), - event: ProductTourTelemetryEventSchema, - launch_source: ProductTourLaunchSourceSchema, - tour_name: string().min(1, "Tour name is required"), - user_identity: string().min(1, "User identity is required").nullable(), - user_name: string().min(1, "User name is required").nullable(), - version: int32(), - count: int(), -}); -export type ProductTourEventFormData = Infer; - export const ProductTourProgressSchema = object({ status: ProductTourStatusSchema, version: int32(), @@ -662,23 +645,29 @@ export type ProductTourProgressFormData = Infer< export const ProductTourSummarySchema = object({ name: string().min(1, "Name is required"), + version: int32(), + kind: ProductTourKindSchema, shown: int(), started: int(), - manual_started: int(), completed: int(), dismissed: int(), last_run_utc: iso.datetime().nullable(), - started_rate: number().nullable(), - manual_started_rate: number().nullable(), - completion_rate: number().nullable(), - dismissal_rate: number().nullable(), + start_sources: array(lazy(() => ProductTourStartSourceSchema)), }); export type ProductTourSummaryFormData = Infer; +export const ProductTourStartSourceSchema = object({ + source: ProductTourLaunchSourceSchema, + count: int(), +}); +export type ProductTourStartSourceFormData = Infer< + typeof ProductTourStartSourceSchema +>; + export const ProductTourUsageResponseSchema = object({ - month: iso.datetime().nullable(), + utc_start: iso.datetime().nullable(), + utc_end: iso.datetime(), tours: array(lazy(() => ProductTourSummarySchema)), - recent_events: array(lazy(() => ProductTourEventSchema)), }); export type ProductTourUsageResponseFormData = Infer< typeof ProductTourUsageResponseSchema diff --git a/src/Exceptionless.Web/ClientApp/src/routes/(app)/(components)/layouts/navbar.svelte b/src/Exceptionless.Web/ClientApp/src/routes/(app)/(components)/layouts/navbar.svelte index 5998ebf0fb..f5f87f094c 100644 --- a/src/Exceptionless.Web/ClientApp/src/routes/(app)/(components)/layouts/navbar.svelte +++ b/src/Exceptionless.Web/ClientApp/src/routes/(app)/(components)/layouts/navbar.svelte @@ -27,7 +27,7 @@
- + {#if isMediumScreenQuery.current} diff --git a/src/Exceptionless.Web/ClientApp/src/routes/(app)/(components)/navigation-command.svelte b/src/Exceptionless.Web/ClientApp/src/routes/(app)/(components)/navigation-command.svelte index 2a19d607a3..bd17bea309 100644 --- a/src/Exceptionless.Web/ClientApp/src/routes/(app)/(components)/navigation-command.svelte +++ b/src/Exceptionless.Web/ClientApp/src/routes/(app)/(components)/navigation-command.svelte @@ -1,6 +1,5 @@
- Tour starts, outcomes, and recent user activity recorded by Exceptionless Feature Usage events +
+ Feature Usage events for guided-tour prompts and guides. Counts are events, not unique users. + {#if usage} +

+ Bounds: {#if usage.utc_start}{:else}available history{/if} – +

+ {/if} +
- - + + - {#if range === 'month'} + {#if range.kind === 'month'} {/if}
@@ -51,121 +99,198 @@ {:else} - - - Tour Outcomes - - Start rate uses real prompt impressions when available. Outcome rates use starts, or shown prompts for prompt-only experiences. Manual - starts come from the catalog, command palette, feature announcements, and help menu. - - - - {#if usageQuery.isPending} -
- {#each [0, 1, 2, 3, 4] as row (row)} - - {/each} -
- {:else if usage?.tours.length === 0} -

- No guided-tour activity was recorded {range === 'month' ? 'for this month' : 'yet'}. -

- {:else} - - - - Tour - Shown - Started - Completed - Dismissed - Last Run - - - - {#each usage?.tours ?? [] as tour (tour.name)} - - {title(tour.name)} - - - - - ({#if tour.started_rate == null}—{:else}{/if}) - - {#if tour.manual_started > 0} -
- manual - {#if tour.manual_started_rate != null} - () - {/if} -
- {/if} -
- - - - ({#if tour.completion_rate == null}—{:else}{/if}) - - - - - - ({#if tour.dismissal_rate == null}—{:else}{/if}) - - - - {#if tour.last_run_utc} - - {:else} - Never - {/if} - -
+ {#if usageQuery.isPending} + + + Guided-tour usage + + + {#each [0, 1, 2, 3] as row (row)} + + {/each} + + + {:else if usage?.tours.length === 0} + + No guided-tour usage events were recorded for this range. + + {:else} + {@const prompts = usage?.tours.filter((tour) => tour.kind === 'prompt') ?? []} + {@const guides = usage?.tours.filter((tour) => tour.kind === 'guide') ?? []} + + {#if prompts.length > 0} + + + Prompts + Prompt impressions and responses. Percentages use shown impressions as the denominator. + + + +
+ {#each prompts as tour (`${tour.name}:${tour.version}`)} + {@render PromptCard(tour)} {/each} - - - {/if} - - +
+
+
+ {/if} - - - Recent Activity - Latest identified-user tour events for investigating support and onboarding patterns. - - - {#if usage?.recent_events.length === 0} -

No recent tour activity was recorded.

- {:else} - - - - User - Tour - Event - Source - When - - - - {#each usage?.recent_events ?? [] as activity (activity)} - - -
{activity.user_name || activity.user_identity || 'Unknown user'}
- {#if activity.user_name && activity.user_identity} -
{activity.user_identity}
- {/if} -
- {title(activity.tour_name)} v{activity.version} - {title(activity.event)} - {title(activity.launch_source)} - -
+ {#if guides.length > 0} + + + Guides + Guide outcomes use completed plus dismissed events. Entry-point percentages use starts. + + + +
+ {#each guides as tour (`${tour.name}:${tour.version}`)} + {@render GuideCard(tour)} {/each} - - - {/if} - - +
+
+
+ {/if} + {/if} {/if}
+ +{#snippet RateCell(value: number, rate: null | number)} + + + ({#if rate === null}—{:else}{/if}) + +{/snippet} + +{#snippet LastRun(value: null | string | undefined)} + + {#if value} + + {:else} + Never + {/if} + +{/snippet} + +{#snippet SourceMix(tour: ProductTourSummary)} + {#if tour.started === 0 || tour.start_sources.length === 0} + + {:else} +
+ {#each tour.start_sources as source (source.source)} + + {formatSource(source.source)} + + () + + {/each} +
+ {/if} +{/snippet} + +{#snippet PromptCard(tour: ProductTourSummary)} +
+
+
{title(tour.name)}
+ v{tour.version} +
+
+ {@render Metric('Shown', tour.shown)} + {@render Metric('Started', tour.started, promptStart(tour))} + {@render Metric('Engaged', tour.completed, promptEngagement(tour))} + {@render Metric('Dismissed', tour.dismissed, promptDismissal(tour))} +
+ {#if tour.last_run_utc} +

Last event

+ {/if} +
+{/snippet} + +{#snippet GuideCard(tour: ProductTourSummary)} +
+
+
{title(tour.name)}
+ v{tour.version} +
+
+ {@render Metric('Started', tour.started)} + {@render Metric('Completed', tour.completed, getOutcomeShare(tour, 'completed'))} + {@render Metric('Dismissed', tour.dismissed, getOutcomeShare(tour, 'dismissed'))} +
+
+

Entry points

+ {@render SourceMix(tour)} +
+ {#if tour.last_run_utc} +

Last event

+ {/if} +
+{/snippet} + +{#snippet Metric(label: string, value: number, rate?: null | number)} +
+
{label}
+
+ + {#if rate !== undefined}({#if rate === null}—{:else}{/if}){/if} +
+
+{/snippet} diff --git a/tests/Exceptionless.Tests/Api/Endpoints/ProductTourEndpointTests.cs b/tests/Exceptionless.Tests/Api/Endpoints/ProductTourEndpointTests.cs index 95cfc7334a..8b0f2de3a5 100644 --- a/tests/Exceptionless.Tests/Api/Endpoints/ProductTourEndpointTests.cs +++ b/tests/Exceptionless.Tests/Api/Endpoints/ProductTourEndpointTests.cs @@ -5,6 +5,7 @@ using Exceptionless.Tests.Extensions; using Exceptionless.Web.Models; using Foundatio.Repositories; +using Foundatio.Repositories.Exceptions; using Xunit; namespace Exceptionless.Tests.Api.Endpoints; @@ -125,19 +126,17 @@ await Task.WhenAll( } [Fact] - public async Task UpdateCurrentUserProductTourAsync_MissingUser_ReturnsNotFound() + public async Task UpdateProductTourProgressAsync_MissingUser_ThrowsNotFound() { // Arrange var currentUser = await GetTestOrganizationUserAsync(); await _userRepository.RemoveAsync(currentUser.Id, options => options.ImmediateConsistency()); // Act & Assert - await SendRequestAsync(request => request - .Put() - .AsTestOrganizationUser() - .AppendPaths("users", "me", "product-tours", ProductTours.AppOverview) - .Content(new UpdateProductTourProgress { Status = ProductTourStatus.Completed, Version = 1 }) - .StatusCodeShouldBeNotFound()); + await Assert.ThrowsAsync(() => _userRepository.UpdateProductTourProgressAsync( + currentUser.Id, + ProductTours.AppOverview, + new ProductTourProgress { Status = ProductTourStatus.Completed, Version = 1 })); } [Fact] From d38f77e1167cc077b1b706d824e514b3fca93b8d Mon Sep 17 00:00:00 2001 From: Blake Niemyjski Date: Sun, 30 Aug 2026 09:57:55 -0500 Subject: [PATCH 11/43] Regenerate guided tour API contracts --- .../ClientApp/src/lib/generated/api.ts | 22 ++++---- .../ClientApp/src/lib/generated/schemas.ts | 18 +++---- .../Exceptionless.Tests/Api/Data/openapi.json | 54 +++++++++---------- 3 files changed, 47 insertions(+), 47 deletions(-) diff --git a/src/Exceptionless.Web/ClientApp/src/lib/generated/api.ts b/src/Exceptionless.Web/ClientApp/src/lib/generated/api.ts index d762ce9ff9..9cc43a52a4 100644 --- a/src/Exceptionless.Web/ClientApp/src/lib/generated/api.ts +++ b/src/Exceptionless.Web/ClientApp/src/lib/generated/api.ts @@ -12,11 +12,6 @@ export enum ProductTourStatus { Dismissed = 2, } -export enum ProductTourKind { - Guide = "guide", - Prompt = "prompt", -} - export enum ProductTourLaunchSource { Welcome = "welcome", Catalog = "catalog", @@ -25,6 +20,11 @@ export enum ProductTourLaunchSource { HelpMenu = "help-menu", } +export enum ProductTourKind { + Guide = "guide", + Prompt = "prompt", +} + export enum BillingStatus { Trialing = 0, Active = 1, @@ -518,6 +518,12 @@ export interface ProductTourProgress { version: number; } +export interface ProductTourStartSource { + source: ProductTourLaunchSource; + /** @format int64 */ + count: number; +} + export interface ProductTourSummary { name: string; /** @format int32 */ @@ -536,12 +542,6 @@ export interface ProductTourSummary { start_sources: ProductTourStartSource[]; } -export interface ProductTourStartSource { - source: ProductTourLaunchSource; - /** @format int64 */ - count: number; -} - export interface ProductTourUsageResponse { /** @format date-time */ utc_start?: null | string; diff --git a/src/Exceptionless.Web/ClientApp/src/lib/generated/schemas.ts b/src/Exceptionless.Web/ClientApp/src/lib/generated/schemas.ts index dc7f6f66b4..47f51b1dab 100644 --- a/src/Exceptionless.Web/ClientApp/src/lib/generated/schemas.ts +++ b/src/Exceptionless.Web/ClientApp/src/lib/generated/schemas.ts @@ -28,7 +28,6 @@ export const StackStatusSchema = zodEnum([ "discarded", ]); export const ProductTourStatusSchema = union([literal(1), literal(2)]); -export const ProductTourKindSchema = zodEnum(["guide", "prompt"]); export const ProductTourLaunchSourceSchema = zodEnum([ "welcome", "catalog", @@ -36,6 +35,7 @@ export const ProductTourLaunchSourceSchema = zodEnum([ "feature-announcement", "help-menu", ]); +export const ProductTourKindSchema = zodEnum(["guide", "prompt"]); export const BillingStatusSchema = union([ literal(0), literal(1), @@ -643,6 +643,14 @@ export type ProductTourProgressFormData = Infer< typeof ProductTourProgressSchema >; +export const ProductTourStartSourceSchema = object({ + source: ProductTourLaunchSourceSchema, + count: int(), +}); +export type ProductTourStartSourceFormData = Infer< + typeof ProductTourStartSourceSchema +>; + export const ProductTourSummarySchema = object({ name: string().min(1, "Name is required"), version: int32(), @@ -656,14 +664,6 @@ export const ProductTourSummarySchema = object({ }); export type ProductTourSummaryFormData = Infer; -export const ProductTourStartSourceSchema = object({ - source: ProductTourLaunchSourceSchema, - count: int(), -}); -export type ProductTourStartSourceFormData = Infer< - typeof ProductTourStartSourceSchema ->; - export const ProductTourUsageResponseSchema = object({ utc_start: iso.datetime().nullable(), utc_end: iso.datetime(), diff --git a/tests/Exceptionless.Tests/Api/Data/openapi.json b/tests/Exceptionless.Tests/Api/Data/openapi.json index e2faa7182f..2dfd0b2976 100644 --- a/tests/Exceptionless.Tests/Api/Data/openapi.json +++ b/tests/Exceptionless.Tests/Api/Data/openapi.json @@ -13366,6 +13366,16 @@ } } }, + "ProductTourKind": { + "enum": [ + "guide", + "prompt" + ], + "x-enumNames": [ + "Guide", + "Prompt" + ] + }, "ProductTourLaunchSource": { "enum": [ "welcome", @@ -13382,16 +13392,6 @@ "HelpMenu" ] }, - "ProductTourKind": { - "enum": [ - "guide", - "prompt" - ], - "x-enumNames": [ - "Guide", - "Prompt" - ] - }, "ProductTourProgress": { "required": [ "status", @@ -13408,6 +13408,22 @@ } } }, + "ProductTourStartSource": { + "required": [ + "source", + "count" + ], + "type": "object", + "properties": { + "source": { + "$ref": "#/components/schemas/ProductTourLaunchSource" + }, + "count": { + "type": "integer", + "format": "int64" + } + } + }, "ProductTourStatus": { "enum": [ 1, @@ -13474,22 +13490,6 @@ } } }, - "ProductTourStartSource": { - "required": [ - "source", - "count" - ], - "type": "object", - "properties": { - "source": { - "$ref": "#/components/schemas/ProductTourLaunchSource" - }, - "count": { - "type": "integer", - "format": "int64" - } - } - }, "ProductTourUsageResponse": { "required": [ "utc_start", @@ -15274,4 +15274,4 @@ "name": "Source Map" } ] -} +} \ No newline at end of file From abb75e08d493559481ebb0c3502e88eb7bdd90c2 Mon Sep 17 00:00:00 2001 From: Blake Niemyjski Date: Sun, 30 Aug 2026 10:22:16 -0500 Subject: [PATCH 12/43] Stabilize guided tour end-to-end coverage --- .../ClientApp/e2e/fixtures/api-client.ts | 2 ++ .../ClientApp/e2e/tests/product-tours.e2e.ts | 32 +++++++++++++------ .../product-tour-catalog-dialog.svelte | 2 +- 3 files changed, 25 insertions(+), 11 deletions(-) diff --git a/src/Exceptionless.Web/ClientApp/e2e/fixtures/api-client.ts b/src/Exceptionless.Web/ClientApp/e2e/fixtures/api-client.ts index 4092bedede..a2cd26d404 100644 --- a/src/Exceptionless.Web/ClientApp/e2e/fixtures/api-client.ts +++ b/src/Exceptionless.Web/ClientApp/e2e/fixtures/api-client.ts @@ -22,6 +22,7 @@ export interface E2EOrganization { export interface E2EProject { id: string; + is_configured?: boolean; name: string; organization_id?: string; } @@ -469,6 +470,7 @@ function toProject(value: unknown): E2EProject { return { id: getRequiredString(record, 'id', 'project response'), + is_configured: typeof record.is_configured === 'boolean' ? record.is_configured : undefined, name: getRequiredString(record, 'name', 'project response'), organization_id: getOptionalString(record, 'organization_id') }; diff --git a/src/Exceptionless.Web/ClientApp/e2e/tests/product-tours.e2e.ts b/src/Exceptionless.Web/ClientApp/e2e/tests/product-tours.e2e.ts index c49d86224a..8bb7238e85 100644 --- a/src/Exceptionless.Web/ClientApp/e2e/tests/product-tours.e2e.ts +++ b/src/Exceptionless.Web/ClientApp/e2e/tests/product-tours.e2e.ts @@ -113,6 +113,7 @@ test.describe('shell and identity checkpoints', () => { }); await test.step('logout clears an active checkpoint without recording progress', async () => { + await page.setViewportSize({ height: 900, width: 1440 }); await startTourFromCommand(page, 'Meet Exie'); await expectProductTourSession(page, true); const writesBeforeLogout = progressWrites.length; @@ -131,16 +132,24 @@ test.describe('shell and identity checkpoints', () => { test('domain workflows advance only on real success', async ({ e2eApi, e2eScenario, page }) => { test.setTimeout(300_000); - await test.step('project configuration advances after creation and the first event', async () => { + await test.step('project configuration advances after setup and the first event', async () => { await page.goto('/next/stack'); await startTourFromCommand(page, 'Configure a project'); - await expect(page.getByRole('heading', { name: 'Add Project' })).toBeVisible(); + await page.waitForURL(/\/next\/project\/(?:add|[^/]+\/configure)/); + + let createdProject = false; + let projectId = page.url().match(/\/project\/([^/]+)\/configure/)?.[1]; + if (!projectId) { + createdProject = true; + await expect(page.getByRole('heading', { name: 'Add Project' })).toBeVisible(); + await page.getByLabel('Project Name', { exact: true }).fill(`Tour Project ${e2eScenario.run}`); + await page.getByRole('button', { name: 'Continue to Client Setup' }).click(); + await page.waitForURL(/\/next\/project\/[^/]+\/configure\?redirect=true/); + projectId = page.url().match(/\/project\/([^/]+)\/configure/)?.[1]; + } else { + expect(projectId).toBe(e2eScenario.projectId); + } - const projectName = `Tour Project ${e2eScenario.run}`; - await page.getByLabel('Project Name', { exact: true }).fill(projectName); - await page.getByRole('button', { name: 'Continue to Client Setup' }).click(); - await page.waitForURL(/\/next\/project\/[^/]+\/configure\?redirect=true/); - const projectId = page.url().match(/\/project\/([^/]+)\/configure/)?.[1]; expect(projectId).toBeTruthy(); await page.locator('[data-tour="project-configure-platform"]').click(); @@ -169,6 +178,7 @@ test('domain workflows advance only on real success', async ({ e2eApi, e2eScenar await expect(page).toHaveURL(/\/next\/event/); await expectProductTourSession(page, true); await expect.poll(() => projectProgressRequests).toBe(1); + await expect.poll(async () => (await e2eApi.getProject(e2eScenario.userToken, projectId!))?.is_configured).toBe(true); await page.unroute(projectProgressRoute); const completed = page.waitForResponse(isSuccessfulTourProgress('project-configure')); @@ -177,8 +187,10 @@ test('domain workflows advance only on real success', async ({ e2eApi, e2eScenar await expectProductTourSession(page, false); } finally { await page.unroute(projectProgressRoute); - await e2eApi.deleteProject(e2eScenario.userToken, projectId!); - await e2eApi.waitForProjectDeleted(e2eScenario.userToken, projectId!); + if (createdProject) { + await e2eApi.deleteProject(e2eScenario.userToken, projectId!); + await e2eApi.waitForProjectDeleted(e2eScenario.userToken, projectId!); + } } }); @@ -310,6 +322,6 @@ async function startTourFromCommand(page: Page, title: string): Promise { await page.getByRole('button', { name: 'Search Exceptionless' }).click(); await page.getByRole('dialog').getByText('Guided Tours…', { exact: true }).click(); const catalog = page.getByRole('dialog', { name: 'Guided Tours' }); - const tour = catalog.locator('section').filter({ has: catalog.getByRole('heading', { name: title }) }); + const tour = catalog.getByRole('region', { name: title }); await tour.getByRole('button', { name: /^(Continue|Restart|Start)$/ }).click(); } diff --git a/src/Exceptionless.Web/ClientApp/src/lib/features/product-tours/components/dialogs/product-tour-catalog-dialog.svelte b/src/Exceptionless.Web/ClientApp/src/lib/features/product-tours/components/dialogs/product-tour-catalog-dialog.svelte index 3e41311269..1c81852ba4 100644 --- a/src/Exceptionless.Web/ClientApp/src/lib/features/product-tours/components/dialogs/product-tour-catalog-dialog.svelte +++ b/src/Exceptionless.Web/ClientApp/src/lib/features/product-tours/components/dialogs/product-tour-catalog-dialog.svelte @@ -29,7 +29,7 @@
{#each items as item (item.name)} -
+
- + {/snippet} + + +
{ + event.preventDefault(); + selectMonth(); + }} + > + + + Month (UTC) + + + + + +
+
+ diff --git a/src/Exceptionless.Web/ClientApp/src/lib/features/admin/components/product-tour-period.svelte.test.ts b/src/Exceptionless.Web/ClientApp/src/lib/features/admin/components/product-tour-period.svelte.test.ts new file mode 100644 index 0000000000..45f5a49b26 --- /dev/null +++ b/src/Exceptionless.Web/ClientApp/src/lib/features/admin/components/product-tour-period.svelte.test.ts @@ -0,0 +1,33 @@ +import { fireEvent, render, screen } from '@testing-library/svelte'; +import { describe, expect, it } from 'vitest'; + +import ProductTourPeriod from './product-tour-period.svelte'; + +describe('ProductTourPeriod', () => { + it('edits the selected month inside the popover rather than adding a toolbar input', async () => { + // Arrange + render(ProductTourPeriod, { range: { kind: 'month', month: '2020-08' } }); + expect(screen.queryByLabelText('Month (UTC)')).toBeNull(); + + // Act + await fireEvent.click(screen.getByRole('button', { name: 'Usage period: August 2020' })); + + // Assert + expect((screen.getByLabelText('Month (UTC)') as HTMLInputElement).value).toBe('2020-08'); + }); + + it('switches history back to the remembered month using the same trigger', async () => { + // Arrange + render(ProductTourPeriod, { range: { kind: 'month', month: '2020-08' } }); + await fireEvent.click(screen.getByRole('button', { name: 'Usage period: August 2020' })); + + // Act + await fireEvent.click(screen.getByRole('button', { name: 'Available history' })); + await fireEvent.click(screen.getByRole('button', { name: 'Usage period: Available history' })); + await fireEvent.click(screen.getByRole('button', { name: 'Show month' })); + + // Assert + expect(screen.getByRole('button', { name: 'Usage period: August 2020' })).toBeTruthy(); + expect(screen.queryByLabelText('Month (UTC)')).toBeNull(); + }); +}); diff --git a/src/Exceptionless.Web/ClientApp/src/lib/features/admin/product-tour-usage.test.ts b/src/Exceptionless.Web/ClientApp/src/lib/features/admin/product-tour-usage.test.ts index ef2df8033c..f01d13eafa 100644 --- a/src/Exceptionless.Web/ClientApp/src/lib/features/admin/product-tour-usage.test.ts +++ b/src/Exceptionless.Web/ClientApp/src/lib/features/admin/product-tour-usage.test.ts @@ -1,6 +1,7 @@ +import { ProductTourUsageInterval } from '$generated/api'; import { describe, expect, it } from 'vitest'; -import { getGuideOutcomeRate, getProductTourUsageParams, getRate, getStartSourceShare } from './product-tour-usage'; +import { getGuideOutcomeRate, getProductTourActivity, getProductTourUsageParams, getRate, getStartSourceShare } from './product-tour-usage'; describe('product tour usage helpers', () => { it('maps each immutable range to the API query parameters', () => { @@ -20,4 +21,49 @@ describe('product tour usage helpers', () => { expect(getGuideOutcomeRate({ completed: 3, dismissed: 1, started: 5 }, 'dismissed')).toBe(0.2); expect(getStartSourceShare({ count: 2 }, 5)).toBe(0.4); }); + + it('fills missing UTC days without inventing future activity', () => { + // Arrange + const activity = [{ completed: 0, date_utc: '2026-08-02T00:00:00Z', dismissed: 0, shown: 2, started: 1 }]; + + // Act + const data = getProductTourActivity( + activity, + ProductTourUsageInterval.Day, + '2026-08-01T00:00:00Z', + '2026-09-01T00:00:00Z', + new Date('2026-08-03T12:00:00Z') + ); + + // Assert + expect(data).toHaveLength(3); + expect(data.map((period) => period.shown)).toEqual([0, 2, 0]); + expect(data[0]?.date.toISOString()).toBe('2026-08-01T00:00:00.000Z'); + }); + + it('keeps monthly history buckets at UTC month starts', () => { + // Arrange + const activity = [{ completed: 3, date_utc: '2026-02-01T00:00:00Z', dismissed: 1, shown: 0, started: 5 }]; + + // Act + const data = getProductTourActivity( + activity, + ProductTourUsageInterval.Month, + '2026-01-31T15:00:00Z', + '2026-04-01T00:00:00Z', + new Date('2026-05-01T00:00:00Z') + ); + + // Assert + expect(data.map((period) => period.date.getUTCMonth())).toEqual([0, 1, 2]); + expect(data.map((period) => period.started)).toEqual([0, 5, 0]); + }); + + it('does not invent a start date for empty unlimited history', () => { + // Act + const data = getProductTourActivity([], ProductTourUsageInterval.Month, null, '2026-04-01T00:00:00Z'); + + // Assert + expect(data).toEqual([]); + }); }); diff --git a/src/Exceptionless.Web/ClientApp/src/lib/features/admin/product-tour-usage.ts b/src/Exceptionless.Web/ClientApp/src/lib/features/admin/product-tour-usage.ts index bed55ed8a5..12d166e917 100644 --- a/src/Exceptionless.Web/ClientApp/src/lib/features/admin/product-tour-usage.ts +++ b/src/Exceptionless.Web/ClientApp/src/lib/features/admin/product-tour-usage.ts @@ -1,3 +1,5 @@ +import type { ProductTourActivity, ProductTourUsageInterval } from '$generated/api'; + export type ProductTourUsageRange = | { kind: 'history'; @@ -11,6 +13,38 @@ export function getGuideOutcomeRate(value: { completed: number; dismissed: numbe return getRate(value[outcome], value.started); } +export function getProductTourActivity( + activity: ProductTourActivity[], + interval: ProductTourUsageInterval, + start: null | string | undefined, + end: string, + now = new Date() +): (ProductTourActivity & { date: Date })[] { + const endDate = new Date(Math.min(new Date(end).getTime(), now.getTime())); + const firstDate = start ?? activity[0]?.date_utc; + if (!firstDate) { + return []; + } + + const cursor = new Date(firstDate); + cursor.setUTCHours(0, 0, 0, 0); + if (interval === 'month') { + cursor.setUTCDate(1); + } + const byDate = new Map(activity.map((period) => [period.date_utc.slice(0, 10), period])); + const result: (ProductTourActivity & { date: Date })[] = []; + while (cursor < endDate) { + const key = cursor.toISOString().slice(0, 10); + result.push({ completed: 0, dismissed: 0, shown: 0, started: 0, ...byDate.get(key), date: new Date(cursor), date_utc: cursor.toISOString() }); + if (interval === 'month') { + cursor.setUTCMonth(cursor.getUTCMonth() + 1); + } else { + cursor.setUTCDate(cursor.getUTCDate() + 1); + } + } + return result; +} + export function getProductTourUsageParams(range: ProductTourUsageRange): Record { if (range.kind === 'history') { return { history: true }; diff --git a/src/Exceptionless.Web/ClientApp/src/lib/features/events/components/events-overview.svelte b/src/Exceptionless.Web/ClientApp/src/lib/features/events/components/events-overview.svelte index ecdfb6514b..da1a008902 100644 --- a/src/Exceptionless.Web/ClientApp/src/lib/features/events/components/events-overview.svelte +++ b/src/Exceptionless.Web/ClientApp/src/lib/features/events/components/events-overview.svelte @@ -62,6 +62,7 @@ onNavigate, prepareStackAssistantContext }: Props = $props(); + let tourStackId = $state(); function getTabs(event?: null | PersistentEvent, project?: ViewProject): TabType[] { if (!event) { @@ -332,22 +333,20 @@ }); - -

Stack

+ {#if event?.stack_id} (tourStackId = stack.id)} prepareAssistantContext={assistantResource === 'event' ? prepareEventAssistantContext : prepareStackAssistantContext} > {/if}
- -

Event

@@ -360,6 +359,7 @@ {#if event?.stack_id}
- + {#if event} @@ -402,7 +402,6 @@ {#if event} -
{#if canScrollTabsLeft} @@ -423,6 +422,7 @@ > {#each tabs as tab (tab)} import { createProductTourActions } from '$features/product-tours/actions.svelte'; - import ProductTourInlineCallout from '$features/product-tours/components/product-tour-inline-callout.svelte'; + import ProductTourSpotlight from '$features/product-tours/components/product-tour-spotlight.svelte'; import { productTourCheckpoint } from '$features/product-tours/state.svelte'; + import { PRODUCT_TOUR_CHECKPOINTS } from '$features/product-tours/types'; import type { PersistentEvent } from '../models'; @@ -9,58 +10,54 @@ interface Props { event?: PersistentEvent; - placement: 'occurrence' | 'overview' | 'stack'; } - let { event, placement }: Props = $props(); + let { event }: Props = $props(); let advancedEventId = $state(''); const actions = createProductTourActions(); const checkpoint = $derived(productTourCheckpoint.current?.tourName === 'event-investigate' ? productTourCheckpoint.current : undefined); + const steps = PRODUCT_TOUR_CHECKPOINTS['event-investigate']; + const stepIndex = $derived(checkpoint ? steps.indexOf(checkpoint.checkpointName) : -1); const copy = $derived.by(() => { switch (checkpoint?.checkpointName) { case 'event-occurrence': - return placement === 'occurrence' - ? { - description: 'This occurrence contains its timestamp, raw JSON, and navigation to nearby events.', - title: 'Inspect the occurrence' - } - : undefined; + return { + description: 'An occurrence is one event. This is when it happened. Use the buttons beside Event to view JSON or browse older occurrences.', + target: '[data-tour="event-occurrence"]', + title: 'Inspect the occurrence' + }; case 'filter-stack-events': - return placement === 'occurrence' - ? { - description: 'Show all events filters the list to this stack when you are ready to compare occurrences.', - title: 'Compare every occurrence' - } - : undefined; + return { + description: 'Select “Show all events” to compare occurrences of this stack. You can also finish this guide and keep exploring this event.', + target: '[data-tour="stack-events"]', + title: 'Compare every occurrence' + }; case 'stack-summary': - return placement === 'stack' - ? { - description: 'Use the grouped stack title, affected users, occurrence count, and trend to judge scope and impact.', - title: 'Understand the grouped issue' - } - : undefined; + return { + description: 'A stack groups similar events. Check its event count and users affected.', + target: '[data-tour="stack-metrics"]', + title: 'Understand the grouped issue' + }; case 'stack-triage': - return placement === 'stack' - ? { - description: 'Status and options change shared issue state. Review them here; this guide will not invoke them.', - title: 'Triage deliberately' - } - : undefined; + return { + description: 'Status changes affect everyone in the project. This guide does not change the status.', + target: '[data-tour="stack-status"]', + title: 'Review the issue status' + }; case 'tab-overview': - return placement === 'overview' - ? { - description: 'Overview summarizes the message and useful event fields. Choose other tabs when the evidence calls for them.', - title: 'Begin with the overview' - } - : undefined; + return { + description: 'Select “Overview” to read the message and event details. Other tabs show more context.', + target: '[data-tour="event-overview"]', + title: 'Begin with the overview' + }; default: return undefined; } }); $effect(() => { - if (placement !== 'stack' || !event || event.id === advancedEventId) { + if (!event || event.id === advancedEventId) { return; } @@ -77,38 +74,33 @@ return; } - switch (active.checkpointName) { - case 'event-occurrence': - productTourCheckpoint.advance(active, 'tab-overview'); - break; - case 'stack-summary': - productTourCheckpoint.advance(active, 'stack-triage'); - break; - case 'stack-triage': - productTourCheckpoint.advance(active, 'event-occurrence'); - break; - case 'tab-overview': - productTourCheckpoint.advance(active, 'filter-stack-events'); - break; - default: - await actions.complete(active); + const next = steps[stepIndex + 1]; + if (next) { + productTourCheckpoint.advance(active, next); + } else { + await actions.complete(active); } } - async function dismiss(): Promise { - if (checkpoint) { - await actions.dismiss(checkpoint); + function back(): void { + const previous = steps[stepIndex - 1]; + if (checkpoint && previous && stepIndex > steps.indexOf('stack-summary')) { + productTourCheckpoint.advance(checkpoint, previous); } } {#if event && checkpoint && copy} - + {#key checkpoint} + steps.indexOf('stack-summary') ? back : undefined} + onDismiss={actions.dismiss} + target={copy.target} + title={copy.title} + /> + {/key} {/if} diff --git a/src/Exceptionless.Web/ClientApp/src/lib/features/events/components/investigation-detail-tour.svelte.test.ts b/src/Exceptionless.Web/ClientApp/src/lib/features/events/components/investigation-detail-tour.svelte.test.ts new file mode 100644 index 0000000000..0ad94d562d --- /dev/null +++ b/src/Exceptionless.Web/ClientApp/src/lib/features/events/components/investigation-detail-tour.svelte.test.ts @@ -0,0 +1,116 @@ +import { productTourCheckpoint } from '$features/product-tours/state.svelte'; +import { cleanup, fireEvent, render, screen } from '@testing-library/svelte'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +import type { PersistentEvent } from '../models'; + +import InvestigationDetailTour from './investigation-detail-tour.svelte'; + +const actions = vi.hoisted(() => ({ complete: vi.fn(), dismiss: vi.fn() })); +vi.mock('$features/product-tours/actions.svelte', () => ({ createProductTourActions: () => actions })); + +const event: PersistentEvent = { + created_utc: '2026-09-01T00:00:00Z', + data: { '@simple_error': { message: 'Example error', type: 'ExampleException' } }, + date: '2026-09-01T00:00:00Z', + id: 'event', + is_first_occurrence: false, + organization_id: 'organization', + project_id: 'project', + stack_id: 'stack', + type: 'error' +}; + +describe('InvestigationDetailTour', () => { + let targets: HTMLElement[]; + beforeEach(() => { + vi.stubGlobal( + 'ResizeObserver', + class { + disconnect() {} + observe() {} + } + ); + targets = ['stack-metrics', 'stack-status', 'event-occurrence', 'event-overview', 'stack-events'].map((name) => { + const element = document.createElement('button'); + element.dataset.tour = name; + element.scrollIntoView = vi.fn(); + document.body.append(element); + return element; + }); + }); + + afterEach(() => { + cleanup(); + targets.forEach((target) => target.remove()); + vi.unstubAllGlobals(); + productTourCheckpoint.clear(); + vi.clearAllMocks(); + }); + + it('uses a spotlight on the actual control at every detail step', async () => { + // Arrange + productTourCheckpoint.start('event-investigate', 'choose-error', 'catalog', 'user', 1); + render(InvestigationDetailTour, { event }); + await screen.findByText('Understand the grouped issue'); + + // Act and assert + for (const [index, title] of [ + 'Understand the grouped issue', + 'Review the issue status', + 'Inspect the occurrence', + 'Begin with the overview', + 'Compare every occurrence' + ].entries()) { + await screen.findByText(title); + expect(targets[index]?.classList.contains('driver-active-element')).toBe(true); + if (index < targets.length - 1) { + await fireEvent.click(screen.getByRole('button', { name: 'Continue' })); + } + } + await fireEvent.click(screen.getByRole('button', { name: 'Finish guide' })); + expect(actions.complete).toHaveBeenCalledExactlyOnceWith(productTourCheckpoint.current); + expect(actions.dismiss).not.toHaveBeenCalled(); + }); + + it('does not advance for a non-error event', () => { + // Arrange + productTourCheckpoint.start('event-investigate', 'choose-error', 'catalog', 'user', 1); + + // Act + render(InvestigationDetailTour, { event: { ...event, data: {}, type: 'log' } }); + + // Assert + expect(productTourCheckpoint.current?.checkpointName).toBe('choose-error'); + expect(screen.queryByRole('region', { name: 'Guide' })).toBeNull(); + }); + + it('goes back through detail steps without reopening events or saving progress', async () => { + // Arrange + productTourCheckpoint.start('event-investigate', 'stack-triage', 'catalog', 'user', 1); + render(InvestigationDetailTour, { event }); + + // Act + await fireEvent.click(await screen.findByRole('button', { name: 'Back' })); + await screen.findByText('Understand the grouped issue'); + + // Assert + expect(productTourCheckpoint.current?.checkpointName).toBe('stack-summary'); + expect(screen.queryByRole('button', { name: 'Back' })).toBeNull(); + expect(actions.complete).not.toHaveBeenCalled(); + expect(actions.dismiss).not.toHaveBeenCalled(); + }); + + it('retains an accessible end-guide action', async () => { + // Arrange + productTourCheckpoint.start('event-investigate', 'stack-summary', 'catalog', 'user', 1); + render(InvestigationDetailTour, { event }); + + // Act + await fireEvent.click(await screen.findByRole('button', { name: 'End guide' })); + + // Assert + expect(actions.dismiss).toHaveBeenCalledExactlyOnceWith(productTourCheckpoint.current); + expect(actions.complete).not.toHaveBeenCalled(); + }); +}); diff --git a/src/Exceptionless.Web/ClientApp/src/lib/features/events/components/investigation-list-tour.svelte b/src/Exceptionless.Web/ClientApp/src/lib/features/events/components/investigation-list-tour.svelte index 07d9315c05..6b2aec2ec0 100644 --- a/src/Exceptionless.Web/ClientApp/src/lib/features/events/components/investigation-list-tour.svelte +++ b/src/Exceptionless.Web/ClientApp/src/lib/features/events/components/investigation-list-tour.svelte @@ -3,6 +3,13 @@ import ProductTourSpotlight from '$features/product-tours/components/product-tour-spotlight.svelte'; import { productTourCheckpoint } from '$features/product-tours/state.svelte'; + interface Props { + firstErrorId?: string; + onOpenError: (eventId: string) => void; + } + + let { firstErrorId, onOpenError }: Props = $props(); + const actions = createProductTourActions(); const checkpoint = $derived(productTourCheckpoint.current?.tourName === 'event-investigate' ? productTourCheckpoint.current : undefined); @@ -10,7 +17,7 @@ {#if checkpoint?.checkpointName === 'filter-errors'} { productTourCheckpoint.advance(current, 'choose-error'); @@ -19,11 +26,26 @@ title="Start with the right errors" /> {:else if checkpoint?.checkpointName === 'choose-error'} - + {#key firstErrorId} + { + productTourCheckpoint.advance(current, 'filter-errors'); + }} + onNext={firstErrorId + ? () => { + if (firstErrorId) { + onOpenError(firstErrorId); + } + } + : undefined} + target="[data-tour='event-list']" + title="Open an error" + /> + {/key} {/if} diff --git a/src/Exceptionless.Web/ClientApp/src/lib/features/events/components/investigation-list-tour.svelte.test.ts b/src/Exceptionless.Web/ClientApp/src/lib/features/events/components/investigation-list-tour.svelte.test.ts new file mode 100644 index 0000000000..9106517cbf --- /dev/null +++ b/src/Exceptionless.Web/ClientApp/src/lib/features/events/components/investigation-list-tour.svelte.test.ts @@ -0,0 +1,65 @@ +import { productTourCheckpoint } from '$features/product-tours/state.svelte'; +import { cleanup, fireEvent, render, screen } from '@testing-library/svelte'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +import InvestigationListTour from './investigation-list-tour.svelte'; + +vi.mock('$features/product-tours/actions.svelte', () => ({ + createProductTourActions: () => ({ dismiss: vi.fn() }) +})); + +describe('InvestigationListTour', () => { + let target: HTMLDivElement; + + beforeEach(() => { + vi.stubGlobal( + 'ResizeObserver', + class { + disconnect() {} + observe() {} + } + ); + target = document.createElement('div'); + target.dataset.tour = 'event-list'; + document.body.append(target); + target.scrollIntoView = vi.fn(); + productTourCheckpoint.start('event-investigate', 'choose-error', 'catalog', 'user', 1); + }); + + afterEach(() => { + cleanup(); + target.remove(); + vi.unstubAllGlobals(); + productTourCheckpoint.clear(); + }); + + it('opens the supplied first error only after the user chooses the action', async () => { + // Arrange + const onOpenError = vi.fn(); + render(InvestigationListTour, { firstErrorId: 'first-error', onOpenError }); + const open = await screen.findByRole('button', { name: 'Open first error' }); + expect(onOpenError).not.toHaveBeenCalled(); + + // Act + await fireEvent.click(open); + + // Assert + expect(onOpenError).toHaveBeenCalledExactlyOnceWith('first-error'); + expect(productTourCheckpoint.current?.checkpointName).toBe('choose-error'); + }); + + it('offers no open action until an error is available', async () => { + // Arrange + const onOpenError = vi.fn(); + const component = render(InvestigationListTour, { onOpenError }); + await screen.findByText('No errors are ready to open in this list. Adjust the filters or wait for the results to load.'); + expect(screen.queryByRole('button', { name: 'Open first error' })).toBeNull(); + + // Act + await component.rerender({ firstErrorId: 'loaded-error', onOpenError }); + await fireEvent.click(await screen.findByRole('button', { name: 'Open first error' })); + + // Assert + expect(onOpenError).toHaveBeenCalledExactlyOnceWith('loaded-error'); + }); +}); diff --git a/src/Exceptionless.Web/ClientApp/src/lib/features/product-tours/actions.svelte.test.ts b/src/Exceptionless.Web/ClientApp/src/lib/features/product-tours/actions.svelte.test.ts new file mode 100644 index 0000000000..4586f9af22 --- /dev/null +++ b/src/Exceptionless.Web/ClientApp/src/lib/features/product-tours/actions.svelte.test.ts @@ -0,0 +1,121 @@ +import { afterEach, describe, expect, it, vi } from 'vitest'; + +import { createProductTourActions } from './actions.svelte'; +import { productTourCheckpoint } from './state.svelte'; + +const mocks = vi.hoisted(() => ({ + error: vi.fn(), + mutateAsync: vi.fn<() => Promise>(), + openCatalog: vi.fn(), + submitFeatureUsage: vi.fn(), + success: vi.fn() +})); +vi.mock('$features/auth/exceptionless-session', () => ({ submitFeatureUsage: mocks.submitFeatureUsage })); +vi.mock('$features/users/api.svelte', () => ({ putCurrentUserProductTour: () => ({ mutateAsync: mocks.mutateAsync }) })); +vi.mock('./controls.svelte', () => ({ tryUseProductTourControls: () => ({ openCatalog: mocks.openCatalog }) })); +vi.mock('svelte-sonner', () => ({ toast: { error: mocks.error, success: mocks.success } })); + +describe('product tour completion', () => { + afterEach(() => { + productTourCheckpoint.clear(); + vi.resetAllMocks(); + }); + + it('offers an actionable next step only after progress is saved', async () => { + // Arrange + const checkpoint = productTourCheckpoint.start('event-investigate', 'filter-stack-events', 'catalog', 'user', 1); + const actions = createProductTourActions(); + + // Act + const completed = await actions.complete(checkpoint); + + // Assert + expect(completed).toBe(true); + expect(productTourCheckpoint.current).toBeUndefined(); + expect(mocks.success).toHaveBeenCalledExactlyOnceWith('You’ve explored an error and its occurrences', { + action: { label: 'Browse guides', onClick: mocks.openCatalog }, + description: 'For more guides, select your name in the sidebar → Help → Guided Tours.' + }); + const options = mocks.success.mock.calls[0]![1]; + options.action.onClick(); + expect(mocks.openCatalog).toHaveBeenCalledOnce(); + }); + + it('leaves the overview menu handoff unobstructed by a completion toast', async () => { + // Arrange + const checkpoint = productTourCheckpoint.start('app-overview', 'help', 'catalog', 'user', 1); + + // Act + const completed = await createProductTourActions().complete(checkpoint); + + // Assert + expect(completed).toBe(true); + expect(mocks.success).not.toHaveBeenCalled(); + }); + + it('keeps the last step available when progress cannot be saved', async () => { + // Arrange + const checkpoint = productTourCheckpoint.start('app-overview', 'help', 'catalog', 'user', 1); + mocks.mutateAsync.mockRejectedValueOnce(new Error('Unavailable')); + + // Act + const completed = await createProductTourActions().complete(checkpoint); + + // Assert + expect(completed).toBe(false); + expect(productTourCheckpoint.current).toBe(checkpoint); + expect(mocks.error).toHaveBeenCalledOnce(); + expect(mocks.success).not.toHaveBeenCalled(); + expect(mocks.openCatalog).not.toHaveBeenCalled(); + }); + + it('does not show a completion action for dismissal or a stale checkpoint', async () => { + // Arrange + const checkpoint = productTourCheckpoint.start('app-overview', 'help', 'catalog', 'user', 1); + const actions = createProductTourActions(); + + // Act + await actions.dismiss(checkpoint); + const completed = await actions.complete(checkpoint); + + // Assert + expect(completed).toBe(false); + expect(mocks.success).not.toHaveBeenCalled(); + expect(mocks.openCatalog).not.toHaveBeenCalled(); + }); + + it('offers the next guide once after a first event succeeds', async () => { + // Arrange + const checkpoint = productTourCheckpoint.start('project-configure', 'event-received', 'catalog', 'user', 1); + mocks.mutateAsync.mockResolvedValueOnce(undefined); + const actions = createProductTourActions(); + + // Act + actions.completeAfterDomainSuccess(checkpoint); + actions.completeAfterDomainSuccess(checkpoint); + await vi.waitFor(() => expect(productTourCheckpoint.current).toBeUndefined()); + + // Assert + expect(mocks.mutateAsync).toHaveBeenCalledOnce(); + expect(mocks.success).toHaveBeenCalledExactlyOnceWith( + 'Your project received its first event', + expect.objectContaining({ + action: { label: 'Browse guides', onClick: mocks.openCatalog } + }) + ); + }); + + it('preserves a domain-success checkpoint for retry when progress saving fails', async () => { + // Arrange + const checkpoint = productTourCheckpoint.start('project-configure', 'event-received', 'catalog', 'user', 1); + mocks.mutateAsync.mockRejectedValueOnce(new Error('Unavailable')); + + // Act + createProductTourActions().completeAfterDomainSuccess(checkpoint); + await vi.waitFor(() => expect(mocks.error).toHaveBeenCalledOnce()); + + // Assert + expect(productTourCheckpoint.current).toBe(checkpoint); + expect(mocks.success).not.toHaveBeenCalled(); + }); +}); diff --git a/src/Exceptionless.Web/ClientApp/src/lib/features/product-tours/actions.svelte.ts b/src/Exceptionless.Web/ClientApp/src/lib/features/product-tours/actions.svelte.ts index e3b18de31e..45e89d8de2 100644 --- a/src/Exceptionless.Web/ClientApp/src/lib/features/product-tours/actions.svelte.ts +++ b/src/Exceptionless.Web/ClientApp/src/lib/features/product-tours/actions.svelte.ts @@ -5,20 +5,21 @@ import { toast } from 'svelte-sonner'; import type { ProductTourCheckpoint, ProductTourKey, ProductTourLaunchSource } from './types'; +import { tryUseProductTourControls } from './controls.svelte'; import { productTourCheckpoint } from './state.svelte'; import { buildProductTourTelemetryEvent, type ProductTourTelemetryEvent } from './telemetry'; -const COMPLETION_NEXT_STEPS: Record = { - 'app-overview': 'Next: open Guided Tours from Help whenever you want a focused workflow.', - 'event-investigate': 'Next: save a useful Events view for the investigation you repeat most.', - 'exie-overview': 'Next: open Exie from a real event when you want help investigating it.', - 'project-configure': 'Next: open the received event and investigate its stack.', - 'saved-view-create': 'Next: reuse the saved view from the application navigation.' +const COMPLETION_MESSAGES: Record, string> = { + 'event-investigate': 'You’ve explored an error and its occurrences', + 'exie-overview': 'You’re ready to ask Exie a question', + 'project-configure': 'Your project received its first event', + 'saved-view-create': 'Your saved view is ready' }; const domainCompletionRequests = new WeakSet(); export function createProductTourActions() { + const controls = tryUseProductTourControls(); const progressMutation = putCurrentUserProductTour(); async function complete(checkpoint: ProductTourCheckpoint): Promise { @@ -46,6 +47,7 @@ export function createProductTourActions() { .then(() => { if (productTourCheckpoint.clear(checkpoint)) { void track('completed', checkpoint.tourName, checkpoint.version, checkpoint.source); + showCompletion(checkpoint); } }) .catch(() => { @@ -75,13 +77,26 @@ export function createProductTourActions() { } void track(status === ProductTourStatus.Completed ? 'completed' : 'dismissed', checkpoint.tourName, checkpoint.version, checkpoint.source); if (status === ProductTourStatus.Completed) { - toast.success('Guide complete', { - description: COMPLETION_NEXT_STEPS[checkpoint.tourName] - }); + showCompletion(checkpoint); } return true; } + function showCompletion(checkpoint: ProductTourCheckpoint): void { + // The overview hands off to the Help menu; a toast would cover that menu. + if (checkpoint.tourName !== 'app-overview') { + toast.success(COMPLETION_MESSAGES[checkpoint.tourName], { + action: controls + ? { + label: 'Browse guides', + onClick: controls.openCatalog + } + : undefined, + description: 'For more guides, select your name in the sidebar → Help → Guided Tours.' + }); + } + } + return { complete, completeAfterDomainSuccess, diff --git a/src/Exceptionless.Web/ClientApp/src/lib/features/product-tours/catalog.test.ts b/src/Exceptionless.Web/ClientApp/src/lib/features/product-tours/catalog.test.ts index 0b96727eb2..d1a5549dbb 100644 --- a/src/Exceptionless.Web/ClientApp/src/lib/features/product-tours/catalog.test.ts +++ b/src/Exceptionless.Web/ClientApp/src/lib/features/product-tours/catalog.test.ts @@ -61,6 +61,36 @@ describe('product tour catalog', () => { }); }); + it('keeps the current project and SDK when starting from Client Setup', () => { + // Arrange + const definition = productTourCatalog.find((tour) => tour.name === 'project-configure')!; + const currentContext = context({ + pathname: '/next/project/current-project/configure', + projects: [ + { id: 'other-project', is_configured: false }, + { id: 'current-project', is_configured: true } + ], + search: '?type=dotnet-legacy-mvc' + }); + + // Act + const start = definition.start(currentContext); + + // Assert + expect(start).toEqual({ checkpointName: 'choose-platform', route: '/next/project/current-project/configure?type=dotnet-legacy-mvc&redirect=true' }); + }); + + it('does not carry another page SDK selection into project setup', () => { + // Arrange + const definition = productTourCatalog.find((tour) => tour.name === 'project-configure')!; + + // Act + const start = definition.start(context({ projects: [{ id: 'project-id', is_configured: false }], search: '?type=error' })); + + // Assert + expect(start).toEqual({ checkpointName: 'choose-platform', route: '/next/project/project-id/configure?redirect=true' }); + }); + it('requires actual Exie access', () => { const item = getProductTourItems(context({ assistantAccess: { enabled: true, has_access: false, upgrade_required: true } })).find( (tour) => tour.name === 'exie-overview' diff --git a/src/Exceptionless.Web/ClientApp/src/lib/features/product-tours/catalog.ts b/src/Exceptionless.Web/ClientApp/src/lib/features/product-tours/catalog.ts index 94c8f8fa04..1d3eba3cdc 100644 --- a/src/Exceptionless.Web/ClientApp/src/lib/features/product-tours/catalog.ts +++ b/src/Exceptionless.Web/ClientApp/src/lib/features/product-tours/catalog.ts @@ -25,8 +25,8 @@ function requireOrganization(context: ProductTourContext) { export const productTourCatalog: readonly ProductTourDefinition[] = [ { availability: requireApplicationShell, - description: 'Learn navigation, command search, saved views, Exie, and where to get help.', - keywords: ['navigation', 'ui', 'search', 'command', 'help', 'saved views'], + description: 'Navigate stacks and events, use the command palette, and reopen saved views.', + keywords: ['navigation', 'ui', 'search', 'command palette', 'help', 'saved views', 'stacks', 'occurrences'], name: 'app-overview', start: () => ({ checkpointName: 'navigation', route: resolve('/') }), title: 'Explore Exceptionless', @@ -34,7 +34,7 @@ export const productTourCatalog: readonly ProductTourDefinition[] = [ }, { availability: () => ({ available: true }), - description: 'Create or resume a project, connect an SDK, and wait for its first real event.', + description: 'Continue an unfinished project, or create one and send its first event.', keywords: ['add project', 'configure', 'sdk', 'api key', 'first event'], name: 'project-configure', start: (context) => { @@ -42,6 +42,15 @@ export const productTourCatalog: readonly ProductTourDefinition[] = [ return { checkpointName: 'organization-name', route: resolve('/(app)/organization/add') }; } + const currentProject = context.projects.find( + (project) => project.id && context.pathname === resolve('/(app)/project/[projectId]/configure', { projectId: project.id }) + ); + if (currentProject) { + const search = new URLSearchParams(context.search); + search.set('redirect', 'true'); + return { checkpointName: 'choose-platform', route: `${context.pathname}?${search}` }; + } + const unconfiguredProject = context.projects.find((project) => !project.is_configured); if (unconfiguredProject?.id) { return { @@ -57,7 +66,7 @@ export const productTourCatalog: readonly ProductTourDefinition[] = [ }, { availability: requireOrganization, - description: 'Save the current Events configuration as a private view that only you can see.', + description: 'Save your event filters and layout in a view only you can see.', keywords: ['saved view', 'filter', 'columns', 'private', 'dashboard'], name: 'saved-view-create', start: () => ({ checkpointName: 'open-view-menu', route: resolve('/(app)/event') }), @@ -66,8 +75,8 @@ export const productTourCatalog: readonly ProductTourDefinition[] = [ }, { availability: requireError, - description: 'Open a real error, assess its stack and status, then inspect the occurrence.', - keywords: ['error report', 'event details', 'exception', 'filter', 'stack', 'triage'], + description: 'Understand stacks, review status, and inspect individual event occurrences.', + keywords: ['error report', 'event details', 'occurrences', 'exception', 'filter', 'stack', 'triage'], name: 'event-investigate', start: () => ({ checkpointName: 'filter-errors', route: `${resolve('/(app)/event')}?time=all&type=error` }), title: 'Investigate an error', @@ -83,7 +92,7 @@ export const productTourCatalog: readonly ProductTourDefinition[] = [ ? { available: true } : { available: false, reason: context.assistantAccess.message ?? 'Exie requires access.' }; }, - description: 'See how Exie uses the current page as context without sending a prompt.', + description: 'Explore the AI assistant. This guide does not send an AI request.', keywords: ['exie', 'assistant', 'ai', 'help', 'investigate'], name: 'exie-overview', start: () => ({ checkpointName: 'open-exie', route: resolve('/') }), diff --git a/src/Exceptionless.Web/ClientApp/src/lib/features/product-tours/components/dialogs/product-tour-catalog-dialog.svelte b/src/Exceptionless.Web/ClientApp/src/lib/features/product-tours/components/dialogs/product-tour-catalog-dialog.svelte index 7f4603495b..a365c65fd2 100644 --- a/src/Exceptionless.Web/ClientApp/src/lib/features/product-tours/components/dialogs/product-tour-catalog-dialog.svelte +++ b/src/Exceptionless.Web/ClientApp/src/lib/features/product-tours/components/dialogs/product-tour-catalog-dialog.svelte @@ -3,12 +3,14 @@ import { Button } from '$comp/ui/button'; import * as Dialog from '$comp/ui/dialog'; import { ProductTourStatus } from '$features/users/models'; - import Compass from '@lucide/svelte/icons/compass'; + import Bookmark from '@lucide/svelte/icons/bookmark'; + import Bot from '@lucide/svelte/icons/bot'; + import Folder from '@lucide/svelte/icons/folder'; + import Layers from '@lucide/svelte/icons/layers'; + import PanelLeft from '@lucide/svelte/icons/panel-left'; import type { ProductTourListItem, ProductTourName } from '../../types'; - import { PRODUCT_TOUR_CHECKPOINTS } from '../../types'; - interface Props { activeTourName?: ProductTourName; items: ProductTourListItem[]; @@ -19,45 +21,57 @@ } let { activeTourName, items, onStart, open = $bindable(false), ready, resumableTourName }: Props = $props(); + const id = $props.id(); + const icons = { + 'app-overview': PanelLeft, + 'event-investigate': Layers, + 'exie-overview': Bot, + 'project-configure': Folder, + 'saved-view-create': Bookmark + }; Guided Tours - Learn Exceptionless with short guides that use your real data. + Choose a short, step-by-step guide. Guides use your workspace, not sample data. -
+
    {#each items as item (item.name)} -
    -
    -
    -
    -
    -

    {item.title}

    -

    {item.description}

    -

    - {item.name === 'app-overview' ? 'Up to ' : ''}{PRODUCT_TOUR_CHECKPOINTS[item.name].length} steps -

    - {#if !item.currentAvailability.available} -

    {item.currentAvailability.reason}

    - {/if} -
    - -
    + +
+ {/each} -
+ diff --git a/src/Exceptionless.Web/ClientApp/src/lib/features/product-tours/components/dialogs/product-tour-catalog-dialog.svelte.test.ts b/src/Exceptionless.Web/ClientApp/src/lib/features/product-tours/components/dialogs/product-tour-catalog-dialog.svelte.test.ts new file mode 100644 index 0000000000..ae9feeb08f --- /dev/null +++ b/src/Exceptionless.Web/ClientApp/src/lib/features/product-tours/components/dialogs/product-tour-catalog-dialog.svelte.test.ts @@ -0,0 +1,55 @@ +import { ProductTourStatus } from '$features/users/models'; +import { fireEvent, render, screen } from '@testing-library/svelte'; +import { describe, expect, it, vi } from 'vitest'; + +import { getProductTourItems } from '../../catalog'; +import ProductTourCatalogDialog from './product-tour-catalog-dialog.svelte'; + +describe('ProductTourCatalogDialog', () => { + it('distinguishes guides and preserves restart, continue, and unavailable actions', async () => { + // Arrange + const items = getProductTourItems( + { errorEventAvailability: 'empty', isSetupPage: false, organizationId: 'organization', pathname: '/next', projects: [] }, + { 'app-overview': { status: ProductTourStatus.Completed, version: 1 } } + ); + const onStart = vi.fn(async () => {}); + render(ProductTourCatalogDialog, { items, onStart, open: true, ready: true, resumableTourName: 'saved-view-create' }); + + // Act + await fireEvent.click(screen.getByRole('button', { name: 'Restart Explore Exceptionless' })); + await fireEvent.click(screen.getByRole('button', { name: 'Continue Create a saved view' })); + screen.getByRole('button', { name: 'Start Investigate an error' }).click(); + + // Assert + expect(onStart.mock.calls).toEqual([['app-overview'], ['saved-view-create']]); + expect(screen.getByText('Completed')).toBeTruthy(); + const unavailable = screen.getByRole('button', { name: 'Start Investigate an error' }); + expect(unavailable.hasAttribute('disabled')).toBe(true); + expect(document.getElementById(unavailable.getAttribute('aria-describedby')!)?.textContent).toContain('Send an error report'); + expect(screen.getByRole('list', { name: 'Available guides' })).toBeTruthy(); + expect(screen.getAllByRole('listitem')).toHaveLength(5); + const icons = items.map((item) => { + const icon = screen.getByRole('region', { name: item.title }).querySelector('svg'); + expect(icon?.getAttribute('aria-hidden')).toBe('true'); + return icon?.innerHTML; + }); + expect(new Set(icons).size).toBe(5); + }); + + it('keeps the picker focused on outcomes without step counts or documentation detours', () => { + // Arrange + const items = getProductTourItems({ errorEventAvailability: 'empty', isSetupPage: false, pathname: '/next', projects: [] }); + const onStart = vi.fn(); + + // Act + render(ProductTourCatalogDialog, { items, onStart, open: true, ready: false }); + + // Assert + for (const item of items) { + expect(screen.getByText(item.description)).toBeTruthy(); + } + expect(screen.queryAllByRole('link')).toHaveLength(0); + expect(screen.queryByText(/\d+ steps/)).toBeNull(); + expect(onStart).not.toHaveBeenCalled(); + }); +}); diff --git a/src/Exceptionless.Web/ClientApp/src/lib/features/product-tours/components/dialogs/product-tour-welcome-dialog.svelte b/src/Exceptionless.Web/ClientApp/src/lib/features/product-tours/components/dialogs/product-tour-welcome-dialog.svelte deleted file mode 100644 index 90e4998c0b..0000000000 --- a/src/Exceptionless.Web/ClientApp/src/lib/features/product-tours/components/dialogs/product-tour-welcome-dialog.svelte +++ /dev/null @@ -1,49 +0,0 @@ - - - - - -
-
- Welcome to Exceptionless - Take a short guided tour now, or browse the guides whenever you need them. -
- -
-

Recommended: {recommended.title}

-

{recommended.description}

-
- - - -
- - -
-
-
-
diff --git a/src/Exceptionless.Web/ClientApp/src/lib/features/product-tours/components/dialogs/product-tour-welcome-dialog.svelte.test.ts b/src/Exceptionless.Web/ClientApp/src/lib/features/product-tours/components/dialogs/product-tour-welcome-dialog.svelte.test.ts deleted file mode 100644 index ffb99d649f..0000000000 --- a/src/Exceptionless.Web/ClientApp/src/lib/features/product-tours/components/dialogs/product-tour-welcome-dialog.svelte.test.ts +++ /dev/null @@ -1,40 +0,0 @@ -import { fireEvent, render, screen } from '@testing-library/svelte'; -import { describe, expect, it, vi } from 'vitest'; - -import ProductTourWelcomeDialog from './product-tour-welcome-dialog.svelte'; - -const recommended = { - availability: vi.fn(() => ({ available: true })), - currentAvailability: { available: true }, - description: 'Learn navigation and search.', - keywords: ['navigation'], - name: 'app-overview' as const, - start: vi.fn(() => ({ checkpointName: 'navigation' as const, route: '/next' })), - title: 'Explore Exceptionless', - version: 1 -}; - -describe('ProductTourWelcomeDialog', () => { - it('records dismissal from Escape', async () => { - const onBrowse = vi.fn(); - const onDismiss = vi.fn(); - const onStart = vi.fn(); - render(ProductTourWelcomeDialog, { onBrowse, onDismiss, onStart, open: true, recommended }); - - await fireEvent.keyDown(screen.getByRole('dialog'), { key: 'Escape' }); - expect(onBrowse).not.toHaveBeenCalled(); - expect(onDismiss).toHaveBeenCalledOnce(); - expect(onStart).not.toHaveBeenCalled(); - }); - - it('provides Browse Guides and Skip choices', async () => { - const onBrowse = vi.fn(); - const onDismiss = vi.fn(); - render(ProductTourWelcomeDialog, { onBrowse, onDismiss, onStart: vi.fn(), open: true, recommended }); - - await fireEvent.click(screen.getByRole('button', { name: 'Browse Guides' })); - expect(onBrowse).toHaveBeenCalledOnce(); - await fireEvent.click(screen.getByRole('button', { name: 'Skip' })); - expect(onDismiss).toHaveBeenCalledOnce(); - }); -}); diff --git a/src/Exceptionless.Web/ClientApp/src/lib/features/product-tours/components/product-tour-description.svelte b/src/Exceptionless.Web/ClientApp/src/lib/features/product-tours/components/product-tour-description.svelte new file mode 100644 index 0000000000..e832696fcf --- /dev/null +++ b/src/Exceptionless.Web/ClientApp/src/lib/features/product-tours/components/product-tour-description.svelte @@ -0,0 +1,35 @@ + + + + +

+ {#if typeof description === 'string'} + {description} + {:else} + {@render description()} + {/if} +

+{#if shortcuts.length} +
+ {#each shortcuts as { label, shortcut } (label)} + + {label} + {formatKeyboardShortcut(shortcut.keys)} + + {/each} +
+{/if} diff --git a/src/Exceptionless.Web/ClientApp/src/lib/features/product-tours/components/product-tour-feature-announcement.svelte b/src/Exceptionless.Web/ClientApp/src/lib/features/product-tours/components/product-tour-feature-announcement.svelte index 732482c2f1..16af7af542 100644 --- a/src/Exceptionless.Web/ClientApp/src/lib/features/product-tours/components/product-tour-feature-announcement.svelte +++ b/src/Exceptionless.Web/ClientApp/src/lib/features/product-tours/components/product-tour-feature-announcement.svelte @@ -28,7 +28,7 @@ New: Meet Exie

{hasAccess - ? 'See how Exie uses the page you are viewing as context for investigations.' + ? 'Take a short guide to Exie, your AI assistant for investigating errors.' : (message ?? 'Exie is available with an eligible organization plan.')}

diff --git a/src/Exceptionless.Web/ClientApp/src/lib/features/product-tours/components/product-tour-host.svelte b/src/Exceptionless.Web/ClientApp/src/lib/features/product-tours/components/product-tour-host.svelte index 86477f0dd8..afe7296b94 100644 --- a/src/Exceptionless.Web/ClientApp/src/lib/features/product-tours/components/product-tour-host.svelte +++ b/src/Exceptionless.Web/ClientApp/src/lib/features/product-tours/components/product-tour-host.svelte @@ -18,9 +18,9 @@ import { shouldOfferProductTourAnnouncement, shouldOfferProductTourWelcome } from '../eligibility'; import { productTourCheckpoint } from '../state.svelte'; import ProductTourCatalogDialog from './dialogs/product-tour-catalog-dialog.svelte'; - import ProductTourWelcomeDialog from './dialogs/product-tour-welcome-dialog.svelte'; import ProductTourFeatureAnnouncement from './product-tour-feature-announcement.svelte'; import ProductTourShellSpotlight from './product-tour-shell-spotlight.svelte'; + import ProductTourWelcome from './product-tour-welcome.svelte'; interface Props { assistantAccess?: AssistantAccess; @@ -248,7 +248,11 @@ } }); - export function openCatalog(source: ProductTourLaunchSource = 'catalog'): void { + export async function openCatalog(source: ProductTourLaunchSource = 'catalog'): Promise { + const active = checkpoint; + if (active?.tourName === 'app-overview' && active.checkpointName === 'help' && !(await actions.complete(active))) { + return; + } closeOverlays(); checkErrorAvailability = true; catalogSource = source; @@ -261,7 +265,7 @@ } const item = getItem(name); if (!item.currentAvailability.available) { - openCatalog(source); + await openCatalog(source); return; } @@ -278,7 +282,10 @@ closeOverlays(); catalogOpen = false; - const start = item.start(context); + const start = item.start({ + ...context, + search: window.location.search + }); const next = productTourCheckpoint.start(name, start.checkpointName, source, currentUser.id, item.version, organizationId); void track('started', name, item.version, source); @@ -329,7 +336,7 @@ welcomeHandled = true; automaticSurface = undefined; void track('completed', 'app-welcome', WELCOME_VERSION, 'welcome'); - openCatalog('catalog'); + await openCatalog('catalog'); } async function onWelcomeSkip(): Promise { @@ -404,7 +411,7 @@ } - - import * as Alert from '$comp/ui/alert'; - import { Button } from '$comp/ui/button'; - import Info from '@lucide/svelte/icons/info'; - - import type { ProductTourCheckpoint } from '../types'; - - import { PRODUCT_TOUR_CHECKPOINTS } from '../types'; - - interface Props { - checkpoint: ProductTourCheckpoint; - continueLabel?: string; - description: string; - onContinue?: () => Promise | void; - onDismiss: () => Promise | void; - title: string; - } - - let { checkpoint, continueLabel = 'Continue', description, onContinue, onDismiss, title }: Props = $props(); - const checkpoints = $derived(PRODUCT_TOUR_CHECKPOINTS[checkpoint.tourName]); - const stepNumber = $derived(checkpoints.indexOf(checkpoint.checkpointName) + 1); - - - - diff --git a/src/Exceptionless.Web/ClientApp/src/lib/features/product-tours/components/product-tour-shell-spotlight.svelte b/src/Exceptionless.Web/ClientApp/src/lib/features/product-tours/components/product-tour-shell-spotlight.svelte index 802234c706..f45b3d017a 100644 --- a/src/Exceptionless.Web/ClientApp/src/lib/features/product-tours/components/product-tour-shell-spotlight.svelte +++ b/src/Exceptionless.Web/ClientApp/src/lib/features/product-tours/components/product-tour-shell-spotlight.svelte @@ -1,11 +1,14 @@ -{#if spotlight && targetReady && (!isAnyOverlayOpen || checkpoint.tourName === 'exie-overview')} - +{#if spotlight && targetReady && (!isAnyOverlayOpen || helpTarget || checkpoint.tourName === 'exie-overview')} + {#key helpTarget} + 0 ? back : undefined} + shortcuts={spotlight.shortcuts} + side={isHelpStep ? 'top' : undefined} + stepCount={steps.length} + stepNumber={stepIndex + 1} + target={helpTarget ?? spotlight.target} + title={spotlight.title} + /> + {/key} {/if} diff --git a/src/Exceptionless.Web/ClientApp/src/lib/features/product-tours/components/product-tour-spotlight.svelte b/src/Exceptionless.Web/ClientApp/src/lib/features/product-tours/components/product-tour-spotlight.svelte index fefcbc1755..904101e603 100644 --- a/src/Exceptionless.Web/ClientApp/src/lib/features/product-tours/components/product-tour-spotlight.svelte +++ b/src/Exceptionless.Web/ClientApp/src/lib/features/product-tours/components/product-tour-spotlight.svelte @@ -1,20 +1,24 @@