Skip to content

Add adaptive guided tours to the new UI - #2506

Draft
niemyjski wants to merge 36 commits into
mainfrom
feature/ui-guided-tours-review
Draft

Add adaptive guided tours to the new UI#2506
niemyjski wants to merge 36 commits into
mainfrom
feature/ui-guided-tours-review

Conversation

@niemyjski

@niemyjski niemyjski commented Aug 20, 2026

Copy link
Copy Markdown
Member

Context

Replacement for #2458, which was merged prematurely and reverted by #2505. Keep this PR in draft and do not merge until Blake explicitly approves it.

What changed

  • adds adaptive guided tours to the Svelte UI using Driver.js 1.8.0
  • scopes stable tour identifiers by resource: app-*, project-*, event-*, saved-view-*, and exie-*
  • persists only numeric status and version per tour; legacy users default to an empty map
  • adds a self-only progress endpoint with known-tour/version validation, stale-version protection, and completed precedence
  • records privacy-safe feature-usage telemetry and exposes a global-admin monthly/all-time outcomes dashboard
  • reports real prompt-to-start conversion, completion/dismissal rates, and the manual share of starts without manufacturing shown impressions for direct launches
  • keeps catalog, command palette, help, responsive resume, identity changes, reduced motion, and focus restoration coordinated

Usage query design

  • uses the Foundatio repository DSL with inferred fields and the exact known source set
  • performs one terms/sum/max aggregation plus one bounded recent-events query in parallel
  • supports a bounded UTC month or explicit all-time query without an N+1 count loop
  • contains no raw Elasticsearch client hooks, prefix query, refresh-for-consistency call, or hard-coded keyword field

Safety and compatibility

  • Svelte UI only; legacy Angular remains unchanged
  • no AppHost, shared CSS, API-key, MIME-type, or unrelated project-loading behavior changes
  • no automatic destructive project, saved-view, stack, tab, Exie prompt, or provider action
  • existing public API behavior remains backwards compatible

Verification

  • dotnet build src/Exceptionless.Web/Exceptionless.Web.csproj --no-restore: passed with 0 warnings and 0 errors
  • focused reporting repository, admin API, and OpenAPI tests: 19 passed, 1 explicit performance-test skip
  • OpenAPI snapshot regenerated and generated frontend contracts refreshed
  • npm run check: 0 Svelte errors and 0 warnings
  • targeted Prettier and ESLint checks: passed
  • npm run build: passed
  • populated month/all-time dashboard dogfood: desktop and mobile, no page-level overflow; mobile table rate columns remain horizontally reachable
  • exact-head CI passed: API, client lint/check/build/tests, Playwright E2E, and Docker build

Breaking changes

None.

@niemyjski
niemyjski marked this pull request as draft August 20, 2026 14:45
@niemyjski niemyjski self-assigned this Aug 25, 2026
@niemyjski
niemyjski force-pushed the feature/ui-guided-tours-review branch 7 times, most recently from b22a86a to db17534 Compare September 3, 2026 03:45
@niemyjski
niemyjski force-pushed the feature/ui-guided-tours-review branch from c3d3fb5 to 9546c7f Compare September 4, 2026 19:33

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?

{
public const string StepTagPrefix = "product-tour-step:";

public static FrozenDictionary<string, string[]> Steps { get; } = new Dictionary<string, string[]>(StringComparer.Ordinal)

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.

I don't love having this backend know about all the steps and or tour experiences, feels really tightly coupled. why do we need all the step names? It's not the end of the world but doesn't sit well.

Comment thread src/Exceptionless.Core/Models/User.cs Outdated
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);
public bool ProductTourAnalyticsEnabled { get; set; } = true;

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.

do we really need this flag?

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, ProductTourUsageInterval? usageInterval = null);

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.

interval should be auto computed by elastic with min number of data points, we can infer this from start and end times.

Comment on lines +105 to +113
if (interval is ProductTourUsageInterval.Auto && !utcStart.HasValue)
interval = ProductTourUsageInterval.Month;
string proximity = interval switch
{
ProductTourUsageInterval.Day => "~1d",
ProductTourUsageInterval.Month => "~1M",
ProductTourUsageInterval.Auto => String.Empty,
_ => throw new ArgumentOutOfRangeException(nameof(usageInterval))
};

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.

can't elastic just do this and infer this, feels like we shouldn't do this.

ProductTourUsageInterval Interval);

[JsonConverter(typeof(JsonStringEnumConverter))]
public enum ProductTourUsageInterval

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.

we don't need this.

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?

.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.

private static async Task<HttpIResult> GetProductTourUsageAsync(
IMediator mediator,
IMediatorResultMapper<HttpIResult> resultMapper,
DateTime? month = null,

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.

this should uust be a start and end time no, and everything else is figured out...

=> (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.

@niemyjski
niemyjski force-pushed the feature/ui-guided-tours-review branch from 1233eba to 97a1a43 Compare September 5, 2026 16:19
.ProducesProblem(StatusCodes.Status400BadRequest)
.ProducesProblem(StatusCodes.Status413PayloadTooLarge)
.ProducesProblem(StatusCodes.Status422UnprocessableEntity)
.ProducesProblem(StatusCodes.Status404NotFound)

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.

413? why would it be too big?

Comment on lines +151 to +158
DateTime utcEnd = timeProvider.GetUtcNow().UtcDateTime;
DateTime monthStart = (message.Month ?? utcEnd).ToUniversalTime().StartOfMonth();
if (monthStart >= DateTime.MaxValue.StartOfMonth())
return Result.Invalid(ValidationError.Create("month", "The month must have a representable end date."));

DateTime? utcStart = message.History
? appOptions.MaximumRetentionDays > 0
? utcEnd.SubtractDays(appOptions.MaximumRetentionDays)

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.

greatly simplify this... we don't need half of this date logic..

}

// Re-read the preference so a stale browser or authentication cache cannot bypass opt-out.
var user = await userRepository.GetByIdAsync(HttpContext.Request.GetUser().Id, options => options.Cache(false));

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.

why woudn't we get this from cache, we control consistency level.

return Result.Unavailable("Guided-tour activity storage is unavailable.");
}

var ev = new Event

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.

I don't like this, why wouldn't the client just submit an event from the client itself. this is bad. Just have the client submit an event to the event endpoint we have the exceptionless client.

return Result.Unavailable("Guided-tour activity storage is unavailable.");
}

var ev = new Event

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.

also we already have the user progress for tour do we really need this activity? unless it helps us with sessions feels like we would only care about start, dismissed and finished

Comment on lines +1 to +11
<script lang="ts" module>
import type { KeyboardShortcut } from '$features/shared/keyboard-shortcuts';

export interface ProductTourShortcut {
label: string;
shortcut: KeyboardShortcut;
}
</script>

<script lang="ts">
import type { Snippet } from 'svelte';

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 are we doing here, we can have models folder or something here.... this feels very hacky.

{label}
<Kbd.Root>{formatKeyboardShortcut(shortcut.keys)}</Kbd.Root>
</span>
{/each}

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.

again typegraphy...

<Button disabled={busy} onclick={onBrowse} size="sm" variant="ghost">Browse guides</Button>
</div>
<ProductTourPrivacyLink />
</Alert.Root>

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.

should alerts be in an alerts folder e.g., like dialogs, please think on this more..

Comment on lines +94 to +97
onSettled: () =>
queryClient.invalidateQueries({
queryKey: userQueryKeys.me()
})

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.

why do we need this

}

function requireError(context: ProductTourContext) {
if (!context.organizationId) return { available: false, reason: 'Create an organization and project first.' };

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.

this is a lint error, we should always have {} and multiline why is this not breaking linting, we have stylistic rules for this.

@niemyjski
niemyjski force-pushed the feature/ui-guided-tours-review branch 2 times, most recently from 97c915f to 81599b4 Compare September 5, 2026 18:42
@niemyjski

niemyjski commented Sep 5, 2026

Copy link
Copy Markdown
Member Author

PR #2506 review dispositions

Implementation: 57cb57748, based on main 5bf7c3a14. All 31 inline comments were refreshed and accounted for. Inline replies are blocked by the account's existing pending review; that review has not been changed or submitted. The PR remains a draft and has not been merged.

Review comments Disposition
3941185452 Started is lifecycle activity and a resumable tab checkpoint, not dismissal. Account progress remains terminal status plus version. Invitations persist their own accepted/dismissed state to prevent repeated prompting.
3941189179, 3941219200 Removed backend step catalog, step collection, and step aggregation/UI. Keep guide Started/Completed/Dismissed and invitation Shown/Accepted/Dismissed.
3941193330, 3941215855, 3941216461, 3941216608, 3941245354 Removed the custom collector, preference flag/endpoints/UI, size-limit contract, and redundant cache plumbing. Use the existing Exceptionless client/helper. Progress works independently of telemetry. Standard application telemetry context/settings now apply; this is not the previous anonymous server collector.
3941194838, 3941197221, 3941197874, 3941202175, 3941216165, 3941221170 Removed interval enum and month/history/days API modes. Accept optional start/end; existing repository date-aggregation context selects the interval. History uses one min-date lookup before the source/date aggregation to bound real retained history, not a per-guide query or invented epoch. This is the canonical parser's date histogram, not native auto_date_histogram.
3941199591 Retained atomic patch to preserve concurrent guide keys/membership and prevent older/dismissed progress overwriting completed progress. Integration coverage includes all of those cases, missing users, and legacy records.
3941200564, 3941202592 Semantic validation returns 422. Missing required body or malformed date binding can return ASP.NET 400 before handler validation; keep an accurate contract.
3941222681 Canonical /stack/all avoids testing chart identity across an alias-route remount. Refresh identity assertions remain intact and pass.
3941224599 Renamed metrics component to product-tour-activity-popover.
3941226744, 3941229283, 3941229335 Use existing ChartConfig/Container/LineChart/Tooltip components. Keep only lifecycle series, integer counts, axis spacing, and keyboard inspection specific to this chart. No custom interval formatting modes or shared theme changes.
3941229349 Reused shared date formatter. Browser inspection exposed its pre-existing timezone/midnight bug; reproduced with failing tests, fixed separately on main b8d8a55 using the existing date library, then rebased so it is not part of this PR diff.
3941232981 Reused firstDetailCheckpoint for initial selection and Back boundary.
3941235449 Investigation components/tests grouped under events/components/tours.
3941236564 Investigation handlers extracted from markup into named functions.
3941238499, 3941240193 Dedicated tour components use Typography and Kbd. Raw h1-h6/p audit of those components found none. Existing unrelated markup was not mass-reformatted.
3941239543 Description models live in product-tours/models.ts, not component module exports.
3941243806 Invitation components grouped under components/alerts.
3941246534 Curly rule moved out of the Svelte-only override so ordinary TypeScript is covered; affected branches corrected.

Verified

  • Full backend after removing the obsolete collector test: 2,958 passed, 3 intentional skips, no failures.
  • Full frontend after date correction: 831 passed across 108 files.
  • Svelte: no errors/warnings; ESLint and Prettier passed.
  • OpenAPI snapshot, endpoint manifest, generated contracts and HTTP samples updated.
  • All guided-tour browser scenarios passed with one worker, including keyboard/mobile/reduced-motion, SDK selection, identity isolation, actual domain completion, telemetry failure and session-storage denial.
  • Real dashboard totals and synthetic light/dark/narrow chart checks passed after date correction. Synthetic screenshots are fixtures, not genuine user engagement.

Remaining verification

  • Full local browser run with eight workers: 44/57 passed. Multiple shared-account/fixture failures.
  • Saved-view readiness race reproduced and fixed separately on main 6f2403863; three consecutive focused passes and the subsequent full run passed that case. Shared date-formatting regression fixtures were made timezone-portable on main f8fa3d58c.
  • Previous one-worker full run: 55/57 passed. Trace-enabled reproduction caught both intermittent stack-test failures. The loose Fixed selector matched the submit button and reloaded before any mark-fixed POST; exact status matching passed three repetitions. The chaos scenario now targets the canonical All view and waits for its actual heading; all three repetitions passed without weakening dialog, paging, request-count, or WebSocket assertions. These six test-only line corrections were pushed separately to main 5bf7c3a14, not added to the tour diff.
  • All 57 local browser tests and all hosted checks passed on 36742c81d.
  • A further lifecycle audit reproduced an uncovered interaction: launching a manual guide while the welcome invitation was pending left both surfaces visible. 57cb57748 clears the automatic invitation surface when starting or resuming a guide. No new flags, stored fields, or invitation outcomes were added. Its regression verifies that the invitation stays hidden during and after the manual guide and that no acceptance/dismissal is falsely persisted; three consecutive browser runs passed.
  • Full frontend tests (831) and formatting/type/lint checks passed again after that fix. The complete 58-test browser suite and exact-head hosted checks are running.

Regression evidence

  • Failing trace: /private/tmp/tour-stack-diagnosis/tests-stack-triage.e2e.ts--06e64-d-from-event-details-signup-chromium-repeat2/trace.zip. The stack GET remains Open and no mark-fixed POST was sent before reload; this was not evidence of a persisted update being lost.
  • Fixed status test: three consecutive passes in /private/tmp/tour-stack-readiness-fixed.
  • Fixed chaos test: three consecutive passes in /private/tmp/tour-stack-canonical-fixed.
  • Final full-suite artifacts: /private/tmp/tour-final-release-gate.
  • Earlier passing unit/CI runs did not expose these timing-sensitive browser-test assumptions. Repeated traces and exact control/readiness assertions were needed; no application consistency settings or timeouts were relaxed.
  • Manual-guide/welcome failure: /private/tmp/tour-manual-welcome-repro2; three passing repetitions: /private/tmp/tour-manual-welcome-fixed. Earlier scenarios accepted or dismissed the invitation before starting a guide, so they never exercised both states together.
  • Latest full-suite evidence: /private/tmp/tour-invitation-release-gate.

@niemyjski
niemyjski force-pushed the feature/ui-guided-tours-review branch 2 times, most recently from fadd495 to 0f3e496 Compare September 5, 2026 19:03
@niemyjski
niemyjski force-pushed the feature/ui-guided-tours-review branch from 0f3e496 to 36742c8 Compare September 5, 2026 19:15
@github-actions

github-actions Bot commented Sep 5, 2026

Copy link
Copy Markdown

Code Coverage

Package Line Rate Branch Rate Complexity Health
Exceptionless.Insulation 37% 35% 286
Exceptionless.Web 85% 70% 8178
Exceptionless.Core 76% 68% 10495
Exceptionless.AppHost 38% 41% 147
Summary 79% (26294 / 33280) 68% (12284 / 18074) 19106

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant