Skip to content

Add adaptive guided tours to the new UI - #2506

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

Add adaptive guided tours to the new UI#2506
niemyjski wants to merge 43 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: dae155a1a, based on main 5121e734b. 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.

Filter initialization follow-up

  • At the user's request, the shared filter fix is included here, not pushed to main. dae155a1a keeps ID-based matching, reuses an unambiguous logical-key match when hydration regenerates an ID, and keys rendered controls by their reused facet object. Multiple raw filters with the same key remain distinct. No stored filter model, query value, or backend change.
  • The component regression failed before the fix because hydration replaced the trigger node. It now passes, along with removal/re-addition and newly-added-filter coverage.
  • The unchanged saved-view and project-scoping browser tests each passed three consecutive traced repetitions (6/6). Evidence: /private/tmp/filter-hydration-browser.log and /private/tmp/filter-hydration-browser.
  • The first fix (0fbc67ef7) passed the original regressions but caused a duplicate-key error when a stream draft combined local and server raw filters. Full browser QA caught it (58/59); three traced failures and a passing comparison against the old component established causality. The correction restores ID matching and limits logical-key fallback to unambiguous cases. Added duplicate/raw-filter replacement coverage. Evidence: /private/tmp/filter-hydration-stream-repeat and /private/tmp/filter-hydration-stream-baseline.
  • Corrected verification: all three affected browser workflows passed three repetitions each (9/9), in /private/tmp/filter-hydration-distinct-browser. Full frontend validation: 838 tests in 109 files passed; Svelte, ESLint, and Prettier clean. The full local browser suite passed 59/59 in 7.6 minutes on dae155a1a; evidence: /private/tmp/filter-hydration-final-browser.log and /private/tmp/filter-hydration-final-browser. Hosted frontend and Docker checks passed; hosted API/E2E remain pending at this update. No merge or deployment was performed.

Previous simplification pass and verification history

  • Removed the guide's ability to block normal saved-view submission before its final checkpoint. Private remains the guide default, not a forced restriction.
  • Collapsed invitation state and impression bookkeeping; guide resume rules now live beside their catalog definitions and use route identity instead of path slicing.
  • Error availability queries are enabled only while the catalog is open. Optional activity submission no longer holds navigation or successful progress completion open.
  • Added early form-submit browser coverage, non-settling telemetry completion coverage, and route-resume unit coverage.
  • Current frontend validation passed: 834 tests across 108 files, Svelte zero errors/warnings, ESLint and Prettier. All 11 focused tour browser tests passed twice (focused and full-suite run), as did the three dashboard browser tests.
  • Not release-cleared: the full local browser suite passed 56/59. Invitation signup organization membership, immediate-reload project-filter interaction, and the saved-view date-picker interaction failed. Traced repetitions are under investigation; passing retries do not close these findings. Evidence: /private/tmp/tour-simplification-all-browser.log and /private/tmp/tour-simplification-failure-traces.
  • Hosted checks for 0b67631f1 are still running. The older successful checks below apply to their stated heads, not this latest commit.
  • Traced follow-up: invitation signup and project scoping each passed 3/3 repeats; saved-view date selection failed 3/3. The trace contains the renamed view in the successful saved-views API response. Its later not-found error occurs during cleanup, not the initial failure. Filter initialization replaces generated filter IDs, remounting the open picker; this shared code is also on main and needs a separate reproduction/fix, not a relaxed test timeout. Artifacts: /private/tmp/tour-simplification-failure-traces.log.
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

  • Final clean-runtime browser suite: 58/58 passed in 6.2 minutes on bf2c3ea8f, with API based on main 5121e734b. Evidence: /private/tmp/tour-clean-runtime-final.log and /private/tmp/tour-clean-runtime-final.
  • All exact-head hosted checks passed: API, client, E2E, Docker build, version, and CLA. Run: https://github.com/exceptionless/Exceptionless/actions/runs/34007959247. Deployment/preview jobs remain intentionally skipped for this draft PR.
  • 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.

Verification history and fixes

  • 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. All hosted checks passed on 57cb57748.
  • A fresh-runtime full browser run passed 57/58 tests. Invitation signup still failed; the earlier project deletion cleanup failure did not recur. Nine focused invitation/project repetitions had passed, demonstrating why one passing retry was not enough.
  • The existing invitation endpoint saved its token without waiting for search visibility, although signup immediately searches by that token. Extending its endpoint test with the actual token lookup failed with null. Main-only fix 5121e734b waits for search visibility on that one save before sending mail; the regression and all 104 organization endpoint tests passed. No global consistency settings or test timeouts changed. The tour branch was rebased without changing its file contents.
  • bf2c3ea8f corrects the Accepted explanation to include browsing guides and opening Exie. All three popover tests and full formatting/type/lint validation passed. The complete 58-test local browser suite and hosted checks subsequently passed on this exact head.
  • An intermediate run passed 56/58 with invitation signup and an investigation scenario failing. A diagnostic screenshot exposed Vite requesting the obsolete pre-rebase feature-announcement component path. Restarting only the frontend cleared that stale module graph; invitation signup and the full domain-tour workflow then each passed twice, followed by the complete 58/58 run. Temporary diagnostics were removed. No code edits or rebases occurred during the final run.

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.
  • Fresh-runtime invitation failure: /private/tmp/tour-fresh-runtime-release. The prior endpoint test read the organization by ID, which did not exercise the search-backed invite lookup; the new assertion covers that missing boundary.
  • Fixed organization suite: /private/tmp/tour-invite-organization-suite.log. Final local browser evidence: /private/tmp/tour-invitation-fixed-release.

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

github-actions Bot commented Sep 6, 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% (26293 / 33280) 68% (12283 / 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