Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
57 commits
Select commit Hold shift + click to select a range
f2a6c06
Harden guided tours and usage reporting
niemyjski Aug 28, 2026
97bd981
Keep guided tours out of setup flows
niemyjski Aug 28, 2026
65fdba5
Refine product tour usage aggregation
niemyjski Aug 28, 2026
bee65cc
Tighten guided tours implementation
niemyjski Aug 28, 2026
3d9fc35
Harden guided tour implementation
niemyjski Aug 28, 2026
f8971be
Minimize guided tour integration surface
niemyjski Aug 29, 2026
bb34c99
Harden guided tour architecture
niemyjski Aug 29, 2026
5fe0668
Clarify guided tour usage reporting
niemyjski Aug 29, 2026
e08fb6c
Simplify product tour usage reporting
niemyjski Aug 30, 2026
94ac243
Harden guided tour workflows and reporting
niemyjski Aug 30, 2026
b3e237e
Regenerate guided tour API contracts
niemyjski Aug 30, 2026
dd4f1ad
Stabilize guided tour end-to-end coverage
niemyjski Aug 30, 2026
f4a3869
Harden guided tour recovery and metrics
niemyjski Sep 1, 2026
7d54a6b
Refine guided tour accessibility and usage insights
niemyjski Sep 2, 2026
69b5aae
Simplify guided tour usage metrics and chart presentation
niemyjski Sep 2, 2026
41f7f52
Make guided tour analytics a chart-first overview
niemyjski Sep 2, 2026
755e129
Simplify tour chart cards and remove detail disclosures
niemyjski Sep 2, 2026
4371f4d
Refine guided tour chart spacing to match overview
niemyjski Sep 3, 2026
e449050
Separate guided tour activity from browser telemetry and refine usage…
niemyjski Sep 3, 2026
23b4137
Improve tour chart contrast and verify keyboard tooltips
niemyjski Sep 3, 2026
e759190
Condense guided tour chart diagnostics
niemyjski Sep 3, 2026
1e781e1
Harden guided tour runtime and browser coverage
niemyjski Sep 3, 2026
c261825
Improve guided tour progress and collection reliability
niemyjski Sep 4, 2026
a03192b
Guard stale tour actions and aggregation growth
niemyjski Sep 4, 2026
0ef1d2a
Preserve tour cache updates across account changes
niemyjski Sep 4, 2026
8f162ff
Use date-filtered buckets for guided-tour history
niemyjski Sep 4, 2026
24ccbf4
Refine guided-tour activity limits and validation
niemyjski Sep 5, 2026
b8daad9
Remove tour-specific activity throttling
niemyjski Sep 5, 2026
f72a98e
Improve guided tour failure handling and regression coverage
niemyjski Sep 5, 2026
e92b03b
Clarify guided-tour activity preferences
niemyjski Sep 5, 2026
138f34a
Stabilize browser and dialog test lifecycles
niemyjski Sep 5, 2026
8126581
Align guided tour components with application conventions
niemyjski Sep 5, 2026
4819265
Simplify guided tour preference caching
niemyjski Sep 5, 2026
ae305ae
Simplify guided tour activity and date range queries
niemyjski Sep 5, 2026
921e6cd
Remove retired tour collector remnants
niemyjski Sep 5, 2026
82d613e
Keep welcome invitations out of manually started guides
niemyjski Sep 5, 2026
f6888b8
Clarify guided tour invitation activity
niemyjski Sep 6, 2026
e26b025
Simplify guided tour coordination and completion
niemyjski Sep 6, 2026
9dff490
Preserve filter controls during initialization
niemyjski Sep 6, 2026
5ac3cdd
Keep duplicate filter instances distinct
niemyjski Sep 6, 2026
ae53334
Preserve saved-view drafts when a guide ends
niemyjski Sep 6, 2026
af7cbb5
Preserve newer guided-tour progress in the user cache
niemyjski Sep 6, 2026
1cf502d
Format guided-tour cache regressions
niemyjski Sep 6, 2026
b3c98e0
Use server-authoritative guided-tour progress
niemyjski Sep 7, 2026
fa1c135
Simplify guided tour state and activity boundaries
niemyjski Sep 7, 2026
061d5d0
Wait for mounted guide targets and isolate catalog availability
niemyjski Sep 8, 2026
76ac733
Wait for fixture visibility and completed guide dismissal
niemyjski Sep 8, 2026
d2ef1ac
Refine guided tour reporting and invitation handling
niemyjski Sep 8, 2026
8bba742
Refresh cached user after guided tour progress updates
niemyjski Sep 8, 2026
5b45ad1
Resolve guided tour date ranges on the server
niemyjski Sep 8, 2026
991ac90
Preserve guided setup through first organization creation
niemyjski Sep 8, 2026
e58ee77
Preserve legacy guided tour progress decoding
niemyjski Sep 8, 2026
3ef0cf7
Use standard rolling range for guided tour activity
niemyjski Sep 8, 2026
77755ff
Simplify guided tour caching and serialization
niemyjski Sep 8, 2026
7ca4a8a
Read persisted tour progress independently of cached users
niemyjski Sep 8, 2026
f275caa
Document upstream tour cache consistency issue
niemyjski Sep 8, 2026
f992143
Simplify product tour persistence
niemyjski Sep 9, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
44 changes: 44 additions & 0 deletions docs/product-tour-persistence.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
# Product Tour Persistence

This note records the persistence boundary for the Svelte product tours. Tour state is a small user preference, not a reporting schema.

## Scope

The supported shape is one `ProductTourState` record on `User` with seven nullable UTC dates:

- `app_overview`, `exie_overview`, `event_investigate`, `project_configure`, and `saved_view_create` record the first completion of a guide.
- `app_welcome` and `exie_announcement` record the first acknowledgment of an automatic invitation. Accepting and dismissing an invitation both acknowledge it.

The self-only `PUT /api/v2/users/me/product-tours/{tourName}/record` operation resolves an allowlisted stable record name on the server, records the date only when it is empty, and returns the authoritative timestamp. The client maps the existing UI IDs at its catalog boundary: `new-ui-overview` → `app-overview`, `meet-exie` → `exie-overview`, `investigate-error` → `event-investigate`, `configure-project` → `project-configure`, and `create-saved-view` → `saved-view-create`; `welcome` → `app-welcome` and the Exie announcement maps to `exie-announcement`. The server maps those record names to the typed fields. It must use the existing targeted repository patch and cache behavior. A missing user is not created, and a client cannot provide a timestamp, field path, status, or version.

The UI keeps its local active-tour checkpoint. Finishing or dismissing a guide, and accepting or dismissing an invitation, completes locally even if persistence or feature-usage telemetry fails. Guide dismissal does not record completion. Existing `product-tour.completed.*` and `product-tour.dismissed.*` feature-usage events remain action counts; no tour dashboard, aggregation endpoint, custom collector, or funnel is part of this feature.

The dates use the existing serializer (`snake_case_lower`, null omission) and remain in Elasticsearch `_source`. `UserIndex` stays dynamically unmapped for these fields. Do not add a mapping or reindex unless querying or aggregating tour dates becomes a separately approved requirement.

## Baseline and deployment evidence

The reviewed implementation baseline for PR #2506 is `f275caae14e91229e4484ffa685ca1d6244ba91b` on `feature/ui-guided-tours-review`, based on `origin/main` `5aeca2d898048cf0307495758e1234352e005379`.

The integration checkout initially pointed at `a057330fc12e91db92b0b25ce0df507880a03a29`, an earlier restored-tour snapshot, and therefore did not match the reviewed PR head. Changes made from that checkout must be reconciled and validated against `f275caae14e91229e4484ffa685ca1d6244ba91b` before they are treated as PR evidence.

The earlier guided-tour implementation was merged by PR #2458 at `b178420820d927d7a5d73699ced9f92e6ea4c70a` on 2026-08-20 13:18 UTC and reverted by PR #2505 at `074e97bf62d0f9c804155c732f48f4ab2326f0cf` on 2026-08-20 14:43 UTC. Both commits are ancestors of release tags `v8.8.1` and later. Repository history therefore shows no released version after the revert that contains the old `status`/`version` tour shape.

That history does not prove whether a deployment occurred during the short interval between the two merges. Before removing compatibility code, check the hosted deployment history for that interval. If no deployment used the old shape, replace it directly. If one did, document the observed records and choose the smallest explicit transition, preferably a deliberate reset of optional tour state rather than invented completion dates.

## Rollout and reset rules

Do not run an uncontrolled rolling deployment where an old server can read a newly written date-valued `product_tours` object. Coordinate the cutover or retain a tested compatibility window based on deployment evidence.

If a future release must show a tour again, use a one-time bounded user migration that explicitly removes the target date from the persisted document. A null-valued partial object is not sufficient because nulls are omitted by the serializer. Inspect source state, preserve later completions and unrelated user fields, and follow established cache migration conventions. Do not add runtime tour versions or a generic reset service.

## Verification recorded for this change

- `dotnet build Exceptionless.slnx --no-restore`: passed with 0 warnings and 0 errors.
- The full backend suite passed 2,960 of 2,963 tests with 3 documented skips (assistant evaluation, performance-data, and EventRepository performance tests); there were 0 failures.
- `ProductTourEndpointTests`: 10 passed against the isolated Elasticsearch test host, including duplicate timestamp, concurrent fields, cache freshness, invalid input, and deleted-user no-create coverage.
- `OpenApiSnapshotTests`: 4 passed; `EndpointManifestTests`: 1 passed. The generated client was regenerated from the resulting canonical OpenAPI snapshot.
- `UserSerializerTests`: 14 passed. The Svelte unit suite passed 834 tests across 108 files; `npm run validate` passed with 0 Svelte diagnostics, 0 warnings, and clean Prettier/ESLint checks; `npm run build` passed.

The isolated runtime used Elasticsearch on a task-owned local port and the existing local Redis service. The default shared Elasticsearch volume had an 8.19.21/8.19.15 image mismatch and was left untouched. Browser checks covered the rendered tour flows separately; deployment data presence for the old shape remains unknown and requires a hosted audit before rollout.

The final rendered evidence is `/private/tmp/exceptionless-tour-qa-final/welcome-desktop.png` and `/private/tmp/exceptionless-tour-qa-final/welcome-mobile.png`. Those checks cover the welcome surface at desktop and mobile sizes; the remaining spotlight and keyboard paths require separate browser evidence before claiming complete visual coverage.
12 changes: 12 additions & 0 deletions src/Exceptionless.Core/Models/Data/ProductTourState.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
namespace Exceptionless.Core.Models.Data;

public sealed record ProductTourState
{
public DateTime? AppOverview { get; set; }
public DateTime? ExieOverview { get; set; }
public DateTime? EventInvestigate { get; set; }
public DateTime? ProjectConfigure { get; set; }
public DateTime? SavedViewCreate { get; set; }
public DateTime? AppWelcome { get; set; }
public DateTime? ExieAnnouncement { get; set; }
}
12 changes: 12 additions & 0 deletions src/Exceptionless.Core/Models/Data/ProductTours.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
namespace Exceptionless.Core.Models.Data;

public static class ProductTours
{
public const string AppOverview = "app-overview";
public const string AppWelcome = "app-welcome";
public const string ExieAnnouncement = "exie-announcement";
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";
}
10 changes: 10 additions & 0 deletions src/Exceptionless.Core/Models/User.cs
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
using System.Collections.ObjectModel;
using System.ComponentModel.DataAnnotations;
using System.Diagnostics.CodeAnalysis;
using Exceptionless.Core.Attributes;
using Exceptionless.Core.Models.Data;
using Foundatio.Repositories.Models;

namespace Exceptionless.Core.Models;
Expand All @@ -25,6 +27,14 @@ public record User : IIdentity, IHaveDates, IValidatableObject
public ICollection<OAuthAccount> OAuthAccounts { get; init; } = new Collection<OAuthAccount>();
public ICollection<UserOrganizationPreference> OrganizationPreferences { get; init; } = new Collection<UserOrganizationPreference>();
public ICollection<UserSavedViewOrderPreference> SavedViewOrders { get; init; } = new Collection<UserSavedViewOrderPreference>();
private ProductTourState? _productTours = new();

[AllowNull]
public ProductTourState ProductTours
{
get => _productTours ??= new();
init => _productTours = value ?? new();
}

/// <summary>
/// Gets or sets the users Full Name.
Expand Down
2 changes: 2 additions & 0 deletions src/Exceptionless.Core/Repositories/EventRepository.cs
Original file line number Diff line number Diff line change
@@ -1,10 +1,12 @@
using Elastic.Clients.Elasticsearch.QueryDsl;
using Exceptionless.Core.Models;
using Exceptionless.Core.Models.Data;
using Exceptionless.Core.Repositories.Configuration;
using Exceptionless.Core.Repositories.Queries;
using Exceptionless.Core.Validation;
using Exceptionless.DateTimeExtensions;
using Foundatio.Repositories;
using Foundatio.Repositories.Elasticsearch.Extensions;
using Foundatio.Repositories.Models;

namespace Exceptionless.Core.Repositories;
Expand Down
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
using Exceptionless.Core.Models;
using Exceptionless.Core.Models.Data;
using Foundatio.Repositories;
using Foundatio.Repositories.Models;

Expand All @@ -12,4 +13,5 @@ public interface IUserRepository : ISearchableRepository<User>
Task<User?> GetUserByOAuthProviderAsync(string provider, string providerUserId);
Task<User?> GetByVerifyEmailAddressTokenAsync(string token);
Task<FindResults<User>> GetByOrganizationIdAsync(string organizationId, CommandOptionsDescriptor<User>? options = null);
Task<ProductTourState> RecordProductTourAsync(string userId, string field, DateTime recordedUtc);
}
35 changes: 35 additions & 0 deletions src/Exceptionless.Core/Repositories/UserRepository.cs
Original file line number Diff line number Diff line change
@@ -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;
Expand All @@ -10,6 +12,18 @@ namespace Exceptionless.Core.Repositories;

public class UserRepository : RepositoryBase<User>, IUserRepository
{
private const string RecordProductTourScript = """
if (ctx._source.product_tours == null) {
ctx._source.product_tours = new HashMap();
}

if (ctx._source.product_tours[params.field] == null) {
ctx._source.product_tours[params.field] = params.recordedUtc;
} else {
ctx.op = 'none';
}
""";

public UserRepository(ExceptionlessElasticConfiguration configuration, MiniValidationValidator validator, AppOptions options)
: base(configuration.Users, validator, options)
{
Expand Down Expand Up @@ -78,6 +92,27 @@ public Task<FindResults<User>> GetByOrganizationIdAsync(string organizationId, C
return FindAsync(q => q.FieldEquals(u => u.OrganizationIds, organizationId).SortAscending(u => u.EmailAddress), o => commandOptions);
}

public async Task<ProductTourState> RecordProductTourAsync(string userId, string field, DateTime recordedUtc)
{
await PatchAsync(userId, new ScriptPatch(RecordProductTourScript)
{
Params = new Dictionary<string, object>
{
["field"] = field,
["recordedUtc"] = recordedUtc
}
});

// An in-flight read can repopulate stale cache entries after patch invalidation.
var user = await GetByIdAsync(userId, options => options.ImmediateConsistency().Cache(false));
if (user is null)
throw new DocumentNotFoundException(userId);

// Refresh both ID and email caches from the authoritative read.
await AddDocumentsToCacheAsync(user, ConfigureOptions(new CommandOptions<User>().Cache()), isDirtyRead: false);
return user.ProductTours;
}

protected override async Task AddDocumentsToCacheAsync(ICollection<FindHit<User>> findHits, ICommandOptions options, bool isDirtyRead)
{
await base.AddDocumentsToCacheAsync(findHits, options, isDirtyRead);
Expand Down
1 change: 1 addition & 0 deletions src/Exceptionless.Web/Api/Endpoints/AdminEndpoints.cs
Original file line number Diff line number Diff line change
Expand Up @@ -146,4 +146,5 @@ private static EventSubmissionSettings CreateEventSubmissionSettings(bool? enabl
bool configuredEnabled = !appOptions.EventSubmissionDisabled;
return new EventSubmissionSettings(enabledOverride ?? configuredEnabled, configuredEnabled, enabledOverride.HasValue);
}

}
17 changes: 17 additions & 0 deletions src/Exceptionless.Web/Api/Endpoints/UserEndpoints.cs
Original file line number Diff line number Diff line change
@@ -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;
Expand Down Expand Up @@ -37,6 +38,22 @@ public static IEndpointRouteBuilder MapUserEndpoints(this IEndpointRouteBuilder
}
});

group.MapPut("users/me/product-tours/{tourName:minlength(1):maxlength(64)}/record", async (string tourName, IMediator mediator, IMediatorResultMapper<HttpIResult> resultMapper)
=> (await mediator.InvokeAsync<Result<RecordProductTourResult>>(new UserMessages.RecordCurrentUserProductTour(tourName))).ToHttpResult(resultMapper))
.Produces<RecordProductTourResult>()
.ProducesProblem(StatusCodes.Status422UnprocessableEntity)
.ProducesProblem(StatusCodes.Status404NotFound)
.WithSummary("Record current user product tour")
.WithMetadata(new EndpointDocumentation {
ParameterDescriptions = new() {
["tourName"] = "The allowlisted product tour identifier.",
},
ResponseDescriptions = new() {
["422"] = "The product tour name is invalid.",
["404"] = "The current user could not be found.",
}
});

group.MapGet("users/me/oauth-grants", async (IMediator mediator, IMediatorResultMapper<HttpIResult> resultMapper)
=> (await mediator.InvokeAsync<Result<IReadOnlyCollection<ViewOAuthGrant>>>(new UserMessages.GetCurrentUserOAuthGrants())).ToHttpResult(resultMapper))
.Produces<IReadOnlyCollection<ViewOAuthGrant>>()
Expand Down
3 changes: 3 additions & 0 deletions src/Exceptionless.Web/Api/Handlers/AdminHandler.cs
Original file line number Diff line number Diff line change
Expand Up @@ -4,10 +4,12 @@
using Exceptionless.Core.Messaging.Models;
using Exceptionless.Core.Migrations;
using Exceptionless.Core.Models;
using Exceptionless.Core.Models.Data;
using Exceptionless.Core.Models.WorkItems;
using Exceptionless.Core.Queues.Models;
using Exceptionless.Core.Repositories;
using Exceptionless.Core.Repositories.Configuration;
using Exceptionless.Core.Services;
using Exceptionless.Core.Utility;
using Exceptionless.DateTimeExtensions;
using Exceptionless.Web.Api.Messages;
Expand All @@ -19,6 +21,7 @@
using Foundatio.Queues;
using Foundatio.Repositories;
using Foundatio.Repositories.Migrations;
using Foundatio.Repositories.Models;
using Foundatio.Storage;
using Foundatio.Mediator;

Expand Down
62 changes: 61 additions & 1 deletion src/Exceptionless.Web/Api/Handlers/UserHandler.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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.Exceptions;

namespace Exceptionless.Web.Api.Handlers;

Expand Down Expand Up @@ -49,6 +51,64 @@ public async Task<Result<ViewCurrentUser>> Handle(GetCurrentUser message)
};
}

private static readonly string[] ProductTourNames = [
ProductTours.AppOverview,
ProductTours.ExieOverview,
ProductTours.EventInvestigate,
ProductTours.ProjectConfigure,
ProductTours.SavedViewCreate,
ProductTours.AppWelcome,
ProductTours.ExieAnnouncement
];

public async Task<Result<RecordProductTourResult>> Handle(RecordCurrentUserProductTour message)
{
if (!ProductTourNames.Contains(message.TourName, StringComparer.Ordinal))
return Result.Invalid(ValidationError.Create("tour_name", "The product tour name is not supported."));

string currentUserId = GetCurrentUserId();
DateTime utcNow = timeProvider.GetUtcNow().UtcDateTime;

ProductTourState state;
try
{
state = await repository.RecordProductTourAsync(currentUserId, GetProductTourField(message.TourName), utcNow);
}
catch (DocumentNotFoundException)
{
return Result.NotFound("User not found.");
}

DateTime? recordedUtc = GetProductTourDate(state, message.TourName);
return recordedUtc.HasValue
? new RecordProductTourResult(recordedUtc.Value)
: Result.Error("Unable to record product tour.");
}

private static DateTime? GetProductTourDate(ProductTourState state, string tourName) => tourName switch
{
ProductTours.AppOverview => state.AppOverview,
ProductTours.ExieOverview => state.ExieOverview,
ProductTours.EventInvestigate => state.EventInvestigate,
ProductTours.ProjectConfigure => state.ProjectConfigure,
ProductTours.SavedViewCreate => state.SavedViewCreate,
ProductTours.AppWelcome => state.AppWelcome,
ProductTours.ExieAnnouncement => state.ExieAnnouncement,
_ => null
};

private static string GetProductTourField(string tourName) => tourName switch
{
ProductTours.AppOverview => "app_overview",
ProductTours.ExieOverview => "exie_overview",
ProductTours.EventInvestigate => "event_investigate",
ProductTours.ProjectConfigure => "project_configure",
ProductTours.SavedViewCreate => "saved_view_create",
ProductTours.AppWelcome => "app_welcome",
ProductTours.ExieAnnouncement => "exie_announcement",
_ => throw new InvalidOperationException("Unknown product tour name.")
};

public async Task<Result<IReadOnlyCollection<ViewOAuthGrant>>> Handle(GetCurrentUserOAuthGrants message)
{
var tokens = new List<OAuthToken>();
Expand Down
1 change: 1 addition & 0 deletions src/Exceptionless.Web/Api/Messages/UserMessages.cs
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ namespace Exceptionless.Web.Api.Messages;
public record GetCurrentUser;
public record GetCurrentUserOAuthGrants;
public record RevokeCurrentUserOAuthGrant(string Id);
public record RecordCurrentUserProductTour(string TourName);
public record GetUserById(string Id);
public record GetUsersByOrganization(string OrganizationId, int Page, int Limit);
public record UpdateUserMessage(string Id, Delta<UpdateUser> Changes);
Expand Down
1 change: 1 addition & 0 deletions src/Exceptionless.Web/ClientApp/.gitignore
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
test-results
playwright-report
node_modules

# Output
Expand Down
Loading
Loading