Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
43 commits
Select commit Hold shift + click to select a range
2d4e4ea
Harden guided tours and usage reporting
niemyjski Aug 28, 2026
5042273
Keep guided tours out of setup flows
niemyjski Aug 28, 2026
fd0af38
Refine product tour usage aggregation
niemyjski Aug 28, 2026
3f89b02
Tighten guided tours implementation
niemyjski Aug 28, 2026
d0b005f
Harden guided tour implementation
niemyjski Aug 28, 2026
160a65b
Minimize guided tour integration surface
niemyjski Aug 29, 2026
c58579c
Harden guided tour architecture
niemyjski Aug 29, 2026
1a0f39c
Clarify guided tour usage reporting
niemyjski Aug 29, 2026
f792d3a
Simplify product tour usage reporting
niemyjski Aug 30, 2026
ee7f1ec
Harden guided tour workflows and reporting
niemyjski Aug 30, 2026
d38f77e
Regenerate guided tour API contracts
niemyjski Aug 30, 2026
abb75e0
Stabilize guided tour end-to-end coverage
niemyjski Aug 30, 2026
87e40fc
Harden guided tour recovery and metrics
niemyjski Sep 1, 2026
d89eade
Refine guided tour accessibility and usage insights
niemyjski Sep 2, 2026
e3b243e
Simplify guided tour usage metrics and chart presentation
niemyjski Sep 2, 2026
d439b0b
Make guided tour analytics a chart-first overview
niemyjski Sep 2, 2026
6fc668e
Simplify tour chart cards and remove detail disclosures
niemyjski Sep 2, 2026
d653198
Refine guided tour chart spacing to match overview
niemyjski Sep 3, 2026
90d9da6
Separate guided tour activity from browser telemetry and refine usage…
niemyjski Sep 3, 2026
9033385
Improve tour chart contrast and verify keyboard tooltips
niemyjski Sep 3, 2026
0437ca5
Condense guided tour chart diagnostics
niemyjski Sep 3, 2026
aa4bf1c
Harden guided tour runtime and browser coverage
niemyjski Sep 3, 2026
d8cc361
Improve guided tour progress and collection reliability
niemyjski Sep 4, 2026
0258980
Guard stale tour actions and aggregation growth
niemyjski Sep 4, 2026
ebaf034
Preserve tour cache updates across account changes
niemyjski Sep 4, 2026
2c61327
Use date-filtered buckets for guided-tour history
niemyjski Sep 4, 2026
55f72a9
Refine guided-tour activity limits and validation
niemyjski Sep 5, 2026
9ec62d2
Remove tour-specific activity throttling
niemyjski Sep 5, 2026
5ecdefc
Improve guided tour failure handling and regression coverage
niemyjski Sep 5, 2026
75bd890
Clarify guided-tour activity preferences
niemyjski Sep 5, 2026
039ca0e
Stabilize browser and dialog test lifecycles
niemyjski Sep 5, 2026
850ef76
Align guided tour components with application conventions
niemyjski Sep 5, 2026
b506eea
Simplify guided tour preference caching
niemyjski Sep 5, 2026
a85bfa5
Simplify guided tour activity and date range queries
niemyjski Sep 5, 2026
335c13a
Remove retired tour collector remnants
niemyjski Sep 5, 2026
b1018d9
Keep welcome invitations out of manually started guides
niemyjski Sep 5, 2026
bf2c3ea
Clarify guided tour invitation activity
niemyjski Sep 6, 2026
0b67631
Simplify guided tour coordination and completion
niemyjski Sep 6, 2026
0fbc67e
Preserve filter controls during initialization
niemyjski Sep 6, 2026
dae155a
Keep duplicate filter instances distinct
niemyjski Sep 6, 2026
6e2df00
Preserve saved-view drafts when a guide ends
niemyjski Sep 6, 2026
164eacf
Preserve newer guided-tour progress in the user cache
niemyjski Sep 6, 2026
cff4741
Format guided-tour cache regressions
niemyjski Sep 6, 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
13 changes: 13 additions & 0 deletions src/Exceptionless.Core/Models/Data/ProductTourProgress.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
namespace Exceptionless.Core.Models.Data;

public record ProductTourProgress
{
public ProductTourStatus Status { get; set; }
public int Version { get; set; }
}

public enum ProductTourStatus
{
Completed = 1,

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

shouldn't we have one for started? or would that be dismissed?

Dismissed = 2
}
103 changes: 103 additions & 0 deletions src/Exceptionless.Core/Models/Data/ProductTours.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,103 @@
using System.Collections.Frozen;
using System.Runtime.Serialization;
using System.Text.Json.Serialization;

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";

public static FrozenDictionary<string, ProductTourDefinition> Definitions { get; } = new[]
{
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) => Definitions.ContainsKey(name);

public static bool IsValid(string name, int version)
{
return Definitions.TryGetValue(name, out var definition) && version > 0 && version <= definition.CurrentVersion;
}

public static string CreateTelemetrySource(
ProductTourTelemetryEvent telemetryEvent,
string tourName,
int version,
ProductTourLaunchSource launchSource)
{
return $"product-tour.{GetTelemetryName(telemetryEvent)}.{tourName}.v{version}.{GetLaunchSourceName(launchSource)}";
}

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);

[JsonConverter(typeof(JsonStringEnumConverter))]
public enum ProductTourKind
{
[JsonStringEnumMemberName("guide")]
[EnumMember(Value = "guide")]
Guide,
[JsonStringEnumMemberName("prompt")]
[EnumMember(Value = "prompt")]
Prompt
}

public enum ProductTourTelemetryEvent
{
Completed,
Dismissed,
Shown,
Started
}

[JsonConverter(typeof(JsonStringEnumConverter))]
public enum ProductTourLaunchSource
{
[JsonStringEnumMemberName("welcome")]
[EnumMember(Value = "welcome")]
Welcome,
[JsonStringEnumMemberName("catalog")]
[EnumMember(Value = "catalog")]
Catalog,
[JsonStringEnumMemberName("command-palette")]
[EnumMember(Value = "command-palette")]
CommandPalette,
[JsonStringEnumMemberName("feature-announcement")]
[EnumMember(Value = "feature-announcement")]
FeatureAnnouncement,
[JsonStringEnumMemberName("help-menu")]
[EnumMember(Value = "help-menu")]
HelpMenu
}
2 changes: 2 additions & 0 deletions src/Exceptionless.Core/Models/User.cs
Original file line number Diff line number Diff line change
@@ -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;
Expand All @@ -25,6 +26,7 @@ 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>();
public IDictionary<string, ProductTourProgress> ProductTours { get; init; } = new Dictionary<string, ProductTourProgress>(StringComparer.Ordinal);

/// <summary>
/// Gets or sets the users Full Name.
Expand Down
81 changes: 81 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 Expand Up @@ -82,6 +84,85 @@ public Task<FindResults<PersistentEvent>> 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<ProductTourUsageResult> GetProductTourUsageAsync(string projectId, DateTime? utcStart, DateTime utcEnd)
{
ArgumentException.ThrowIfNullOrEmpty(projectId);
if (utcStart.HasValue && utcEnd <= utcStart)
throw new ArgumentOutOfRangeException(nameof(utcEnd), "The end date must be later than the start date.");

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);
if (!utcStart.HasValue)
{
DateTime? retainedStart = _options.MaximumRetentionDays > 0
? _timeProvider.GetUtcNow().UtcDateTime.SubtractDays(_options.MaximumRetentionDays)
: null;
var bounds = await CountAsync(query => ApplyProductTourUsageFilter(query, projectId, retainedStart, utcEnd, allSources)
.AggregationsExpression($"min:{dateField}"));
utcStart = bounds.Aggregations.Min<DateTime>($"min_{dateField}")?.Value;
if (!utcStart.HasValue)
{
return new ProductTourUsageResult([]);
}
}

var aggregation = await CountAsync(query => ApplyProductTourUsageFilter(query, projectId, utcStart, utcEnd, allSources)
.AggregationsExpression($"terms:({sourceField}~{allSources.Length} sum:{countField}~1 max:{dateField} date:({dateField} sum:{countField}~1))"));

var sourceBuckets = aggregation.Aggregations.Terms<string>($"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<DateTime>($"max_{dateField}")?.Value,
(bucket.Aggregations.DateHistogram($"date_{dateField}")?.Buckets ?? [])
.Where(period => period.Date < utcEnd)
.Select(period => new ProductTourUsagePeriod(period.Date, Convert.ToInt64(period.Aggregations.Sum($"sum_{countField}")?.Value ?? period.Total.GetValueOrDefault())))
.ToArray())
: null)
.OfType<ProductTourUsageBucket>()
.ToArray();
return new ProductTourUsageResult(usage);
}

private static IRepositoryQuery<PersistentEvent> ApplyProductTourUsageFilter(
IRepositoryQuery<PersistentEvent> query,
string projectId,
DateTime? utcStart,
DateTime utcEnd,
string[] sources)
{
query = query
.Project(projectId)
.FieldEquals(ev => ev.Type, Event.KnownTypes.FeatureUsage)
.FieldEquals(ev => ev.Source, sources)
.FieldLessThan(ev => ev.Date, utcEnd);

if (utcStart.HasValue)
return query.DateRange(utcStart, utcEnd, (PersistentEvent ev) => ev.Date).Index(utcStart, utcEnd);

return query.DateRange(null, utcEnd, (PersistentEvent ev) => ev.Date);
}

private static ProductTourUsageSource[] CreateProductTourSources(string tourName, int currentVersion)
{
return Enumerable.Range(1, currentVersion)
.SelectMany(version => Enum.GetValues<ProductTourTelemetryEvent>().SelectMany(telemetryEvent => Enum.GetValues<ProductTourLaunchSource>().Select(launchSource =>
new ProductTourUsageSource(
ProductTours.CreateTelemetrySource(telemetryEvent, tourName, version, launchSource),
telemetryEvent,
tourName,
version,
launchSource))))
.ToArray();
}

public async Task<PreviousAndNextEventIdResult> GetPreviousAndNextEventIdsAsync(PersistentEvent ev, AppFilter? systemFilter = null, DateTime? utcStart = null, DateTime? utcEnd = null)
{
var previous = GetPreviousEventIdAsync(ev, systemFilter, utcStart, utcEnd);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ public interface IEventRepository : IRepositoryOwnedByOrganizationAndProject<Per
Task<bool> UpdateSessionStartLastActivityAsync(string id, DateTime lastActivityUtc, bool isSessionEnd = false, bool hasError = false, bool sendNotifications = true);
Task<long> RemoveAllAsync(string organizationId, string? clientIpAddress, DateTime? utcStart, DateTime? utcEnd, CommandOptionsDescriptor<PersistentEvent>? options = null);
Task<long> RemoveAllByStackIdsAsync(string[] stackIds);
Task<ProductTourUsageResult> GetProductTourUsageAsync(string projectId, DateTime? utcStart, DateTime utcEnd);
}

public static class EventRepositoryExtensions
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<ProductTourProgress> UpdateProductTourProgressAsync(string userId, string tourName, ProductTourProgress progress);
}
16 changes: 16 additions & 0 deletions src/Exceptionless.Core/Repositories/ProductTourUsageResult.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
using Exceptionless.Core.Models.Data;

namespace Exceptionless.Core.Repositories;

public sealed record ProductTourUsageResult(IReadOnlyCollection<ProductTourUsageBucket> Buckets);

public sealed record ProductTourUsageBucket(ProductTourUsageSource Source, long Count, DateTime? LastUtc, IReadOnlyCollection<ProductTourUsagePeriod> Activity);

public sealed record ProductTourUsagePeriod(DateTime DateUtc, long Count);

public sealed record ProductTourUsageSource(
string Raw,
ProductTourTelemetryEvent Event,
string TourName,
int Version,
ProductTourLaunchSource LaunchSource);
37 changes: 37 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 Down Expand Up @@ -78,6 +80,41 @@ 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<ProductTourProgress> UpdateProductTourProgressAsync(string userId, string tourName, ProductTourProgress progress)

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

is this truely needed for an update, and or do we have great integration test coverage around htis?

{
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.completedStatus || 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<string, object>
{
["completedStatus"] = (int)ProductTourStatus.Completed,
["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<FindHit<User>> findHits, ICommandOptions options, bool isDirtyRead)
{
await base.AddDocumentsToCacheAsync(findHits, options, isDirtyRead);
Expand Down
16 changes: 16 additions & 0 deletions src/Exceptionless.Web/Api/Endpoints/AdminEndpoints.cs
Original file line number Diff line number Diff line change
Expand Up @@ -87,6 +87,15 @@ 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<AutoValidationEndpointFilter>()
.Produces<ProductTourUsageResponse>(StatusCodes.Status200OK)
.ProducesProblem(StatusCodes.Status400BadRequest)

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

what produces a 400, any invalid parameters would be a 422.

.ProducesValidationProblem(StatusCodes.Status422UnprocessableEntity)
.Produces(StatusCodes.Status401Unauthorized)
.Produces(StatusCodes.Status403Forbidden);

group.MapPost("change-plan", async (HttpContext httpContext, IMediator mediator, IMediatorResultMapper<HttpIResult> resultMapper, string organizationId, string planId)
=> (await mediator.InvokeAsync<Result<object>>(new AdminChangePlan(organizationId, planId, httpContext))).ToHttpResult(resultMapper));

Expand Down Expand Up @@ -130,4 +139,11 @@ private static EventSubmissionSettings CreateEventSubmissionSettings(bool? enabl
bool configuredEnabled = !appOptions.EventSubmissionDisabled;
return new EventSubmissionSettings(enabledOverride ?? configuredEnabled, configuredEnabled, enabledOverride.HasValue);
}

private static async Task<HttpIResult> GetProductTourUsageAsync(
IMediator mediator,
IMediatorResultMapper<HttpIResult> resultMapper,
DateTime? start = null,
DateTime? end = null)
=> (await mediator.InvokeAsync<Result<object>>(new GetAdminProductTourUsage(start, end))).ToHttpResult(resultMapper);
}
22 changes: 22 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,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<HttpIResult> resultMapper, [FromBody] UpdateProductTourProgress progress)
=> (await mediator.InvokeAsync<Result<ProductTourProgress>>(new UserMessages.UpdateCurrentUserProductTour(tourName, progress))).ToHttpResult(resultMapper))
.Accepts<UpdateProductTourProgress>(false, "application/json")
.Produces<ProductTourProgress>()
.ProducesProblem(StatusCodes.Status400BadRequest)

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

what produces a 400? 422 is preferred for any validation errors.

.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<HttpIResult> resultMapper)
=> (await mediator.InvokeAsync<Result<IReadOnlyCollection<ViewOAuthGrant>>>(new UserMessages.GetCurrentUserOAuthGrants())).ToHttpResult(resultMapper))
.Produces<IReadOnlyCollection<ViewOAuthGrant>>()
Expand Down
Loading
Loading