From f8d9dfb86e2b01482fed964f443b59245b828059 Mon Sep 17 00:00:00 2001 From: David Pine <7679720+IEvangelist@users.noreply.github.com> Date: Tue, 25 Aug 2026 12:07:27 -0500 Subject: [PATCH 1/5] feat: add documentation funnel telemetry Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- src/frontend/ec.config.mjs | 2 + src/frontend/public/scripts/analytics/1ds.js | 70 +- .../public/scripts/analytics/track.js | 857 +++++++++++++++++- .../src/components/InstallCliModal.astro | 114 ++- .../src/components/InstallDotNetPackage.astro | 54 +- .../src/components/InstallPackage.astro | 81 +- .../src/components/IntegrationCard.astro | 42 +- .../src/components/Integrations.astro | 86 +- .../src/components/home/HomePage.astro | 9 +- .../src/components/starlight/Header.astro | 8 + .../src/components/starlight/Search.astro | 170 ++++ .../components/starlight/SocialIcons.astro | 4 + src/frontend/src/content/docs/404.mdx | 26 +- .../get-started/add-aspire-existing-app.mdx | 26 +- .../docs/get-started/deploy-first-app.mdx | 24 +- .../content/docs/get-started/first-app.mdx | 12 +- .../content/docs/get-started/install-cli.mdx | 41 +- .../docs/get-started/troubleshooting.mdx | 52 +- .../docs/ja/get-started/deploy-first-app.mdx | 24 +- .../content/docs/ja/get-started/first-app.mdx | 10 +- .../docs/ja/get-started/install-cli.mdx | 36 +- .../docs/ja/get-started/troubleshooting.mdx | 38 +- .../funnel-metadata.mjs | 30 + .../tests/e2e/analytics-scripts.spec.ts | 679 +++++++++++++- .../analytics-script-contracts.vitest.test.ts | 192 +++- .../unit/custom-components.vitest.test.ts | 22 +- .../Telemetry/OneDSTelemetryService.cs | 17 +- .../Telemetry/TelemetryConstants.cs | 56 +- .../OneDSTelemetryServiceTests.cs | 90 ++ 29 files changed, 2666 insertions(+), 206 deletions(-) create mode 100644 src/frontend/src/expressive-code-plugins/funnel-metadata.mjs create mode 100644 tests/StaticHost.Tests/OneDSTelemetryServiceTests.cs diff --git a/src/frontend/ec.config.mjs b/src/frontend/ec.config.mjs index 45358601a..92dcd6a86 100644 --- a/src/frontend/ec.config.mjs +++ b/src/frontend/ec.config.mjs @@ -5,6 +5,7 @@ import { pluginLineNumbers } from '@expressive-code/plugin-line-numbers'; import ecTwoSlash from 'expressive-code-twoslash'; import { tsImport } from 'tsx/esm/api'; import { pluginDisableCopy } from './src/expressive-code-plugins/disable-copy.mjs'; +import { pluginFunnelMetadata } from './src/expressive-code-plugins/funnel-metadata.mjs'; import { TWOSLASH_ENABLED, TWOSLASH_LANGUAGES, @@ -47,6 +48,7 @@ export default { pluginLineNumbers(), pluginIcon(), pluginDisableCopy(), + pluginFunnelMetadata(), ...(TWOSLASH_ENABLED ? [ ecTwoSlash({ diff --git a/src/frontend/public/scripts/analytics/1ds.js b/src/frontend/public/scripts/analytics/1ds.js index 6c1ebc9fc..c94d4decb 100644 --- a/src/frontend/public/scripts/analytics/1ds.js +++ b/src/frontend/public/scripts/analytics/1ds.js @@ -1,6 +1,52 @@ -/* global location, oneDS, window */ +/* global document, location, oneDS, window */ (function () { + function isNotFoundPage() { + return Boolean( + document.querySelector('[data-funnel="not_found_recovery"][data-funnel-view]') + ); + } + + function sanitizeTelemetryUrl(value) { + if (typeof value !== 'string' || !value) return value; + + try { + const url = new URL(value, location.origin); + if ( + isNotFoundPage() && + url.origin === location.origin && + url.pathname === location.pathname + ) { + url.pathname = '/404/'; + } + url.search = ''; + url.hash = ''; + return url.toString(); + } catch (err) { + console.debug('[1ds] Dropping invalid telemetry URL:', err); + return ''; + } + } + + function sanitizeTelemetryUrls(item) { + [item && item.baseData, item && item.data].forEach(function (properties) { + if (!properties) return; + + ['uri', 'targetUri', 'refUri', 'referrerUri'].forEach(function (property) { + if (property in properties) { + properties[property] = sanitizeTelemetryUrl(properties[property]); + } + }); + }); + } + + function getTelemetryPageName() { + if (isNotFoundPage()) return '404'; + + const segments = location.pathname.split('/').filter(Boolean); + return segments.length ? segments[segments.length - 1] : 'Home'; + } + if (typeof location !== 'undefined' && location.origin !== 'https://aspire.dev') { console.debug('[1ds] Skipping load for origin:', location.origin); return; @@ -24,21 +70,35 @@ channelConfiguration: { eventsLimitInMem: 50 }, propertyConfiguration: { env: 'PROD' }, webAnalyticsConfiguration: { - urlCollectQuery: true, + urlCollectQuery: false, + callback: { pageName: getTelemetryPageName }, autoCapture: { scroll: true, - pageView: true, - onLoad: true, + pageView: false, + onLoad: false, onUnload: true, click: true, resize: true, - jsError: true, + jsError: false, }, }, }, [] ); + analytics.addTelemetryInitializer(sanitizeTelemetryUrls); + analytics.capturePageView({ isAuto: true }); + + const captureLoadEvents = function () { + analytics.capturePageViewPerformance({ isAuto: true }); + analytics.captureContentUpdate({ isAuto: true, isDomComplete: true }); + }; + if (document.readyState === 'complete') { + captureLoadEvents(); + } else { + window.addEventListener('load', captureLoadEvents, { once: true }); + } + analytics.__initialized = true; window.analytics = analytics; } catch (err) { diff --git a/src/frontend/public/scripts/analytics/track.js b/src/frontend/public/scripts/analytics/track.js index 2f570a065..1c6d988a2 100644 --- a/src/frontend/public/scripts/analytics/track.js +++ b/src/frontend/public/scripts/analytics/track.js @@ -1,7 +1,153 @@ -/* global document, window */ +/* global document, IntersectionObserver, location, Node, window */ (function () { - if (!window.analytics || !window.analytics.__initialized) { + var FUNNEL_EVENT_NAME = 'aspire.dev/funnel/step'; + var CLI_INSTALL_FUNNEL = 'cli_install'; + var GETTING_STARTED_FUNNEL = 'getting_started'; + var CLI_ENTRY_MARKER_KEY = 'aspire-cli-install-entry'; + var SEARCH_DESTINATION_MARKER_KEY = 'aspire-search-destination'; + var NOT_FOUND_DESTINATION_MARKER_KEY = 'aspire-not-found-destination'; + var CLI_ENTRY_MARKER_TTL_MS = 30 * 1000; + var CONTINUATION_MARKER_TTL_MS = 5 * 60 * 1000; + var lastRoutePath = null; + var observedFunnelSteps = new WeakSet(); + var pendingTroubleshootingReturn = null; + var troubleshootingWasHidden = false; + var inferredIntegrationContext = null; + + var funnelSteps = { + cli_install: { + entry: 1, + options_viewed: 2, + command_copied: 3, + script_requested: 4, + }, + getting_started: { + first_app_viewed: 1, + create_command_copied: 2, + run_command_copied: 3, + next_step_clicked: 4, + }, + search_success: { + search_opened: 1, + results_shown: 2, + no_results: 2, + result_selected: 3, + destination_action: 4, + }, + integration_adoption: { + gallery_viewed: 1, + filter_used: 2, + integration_selected: 3, + install_command_copied: 4, + configuration_copied: 5, + }, + deployment_intent: { + deploy_guide_viewed: 1, + target_selected: 2, + prerequisite_copied: 3, + deploy_command_copied: 4, + verification_or_troubleshooting: 5, + }, + troubleshooting_recovery: { + troubleshooting_viewed: 1, + issue_selected: 2, + remediation_copied: 3, + return_to_task: 4, + file_issue: 4, + }, + not_found_recovery: { + not_found_viewed: 1, + recovery_action: 2, + valid_destination_loaded: 3, + }, + existing_app_adoption: { + guide_viewed: 1, + approach_selected: 2, + setup_command_copied: 3, + run_command_copied: 4, + next_step_clicked: 5, + }, + }; + + var optionalDimensions = { + surface: [ + 'header', + 'site_tools', + 'homepage', + 'install_modal', + 'install_page', + 'first_app_page', + 'site_search', + 'search_destination', + 'integrations_gallery', + 'integration_page', + 'deployment_page', + 'troubleshooting_page', + 'not_found_page', + 'existing_app_page', + ], + entryType: ['cta', 'direct'], + method: [ + 'script', + 'homebrew', + 'npm', + 'nuget', + 'winget', + 'mise', + 'nix', + 'aspire_cli', + 'dotnet_cli', + 'pnpm', + 'yarn', + 'bun', + 'file_directive', + 'package_reference', + ], + platform: ['macos', 'linux', 'windows', 'unix', 'cross_platform'], + channel: ['release', 'staging', 'dev'], + language: ['csharp', 'typescript', 'python', 'javascript'], + destination: [ + 'deploy', + 'testing', + 'verification', + 'troubleshooting', + 'return_to_task', + 'github_issue', + 'executable_resources', + 'vscode_extension', + ], + queryLength: ['one_to_three', 'four_to_ten', 'eleven_to_thirty', 'thirty_plus'], + resultCount: ['zero', 'one_to_five', 'six_to_twenty', 'twenty_plus'], + selectedRank: ['first', 'two_to_five', 'six_plus', 'api_reference'], + searchTarget: ['docs', 'csharp_api', 'typescript_api'], + resultType: ['docs', 'api_reference', 'integration', 'reference', 'other'], + actionKind: ['code_copy', 'internal_navigation', 'external_navigation', 'content_control'], + filterType: ['search', 'official', 'community', 'hosting', 'client', 'tag'], + publisher: ['official', 'community'], + integrationKind: ['hosting', 'client'], + target: ['docker_compose', 'azure_container_apps'], + recoveryAction: ['back', 'homepage', 'docs', 'search', 'suggested_page'], + approach: ['ai_agent', 'manual', 'cli'], + }; + + var boundedDimensions = { + integration: /^[a-z0-9@/._-]{1,100}$/, + issue: /^[a-z0-9][a-z0-9_-]{0,63}$/, + }; + + var languageFunnels = { + getting_started: true, + integration_adoption: true, + deployment_intent: true, + existing_app_adoption: true, + }; + + if ( + !window.analytics || + !window.analytics.__initialized || + typeof window.analytics.trackPageAction !== 'function' + ) { console.debug('[track] Analytics not initialized, skipping event tracking setup.'); return; } @@ -13,40 +159,701 @@ window.analytics.__trackingBound = true; - document.addEventListener('click', function (e) { - var target = e.target.closest('a[data-track], button[data-track]'); - if (!target) return; + function normalizePathname(pathname) { + var segments = pathname.split('/').filter(Boolean); + var locale = (document.documentElement.lang || 'en').toLowerCase(); + var firstSegment = (segments[0] || '').toLowerCase(); - var eventName = target.getAttribute('data-track'); - if (!eventName) return; + if (firstSegment === locale || firstSegment === locale.split('-')[0]) { + segments.shift(); + } - var overrides = { name: eventName }; + return segments.length ? '/' + segments.join('/') + '/' : '/'; + } - for (var i = 0; i < target.attributes.length; i++) { - var attr = target.attributes[i]; - if (attr.name.startsWith('data-track-')) { - var key = attr.name.substring('data-track-'.length).replace(/-([a-z])/g, function (_, c) { - return c.toUpperCase(); - }); - overrides[key] = attr.value; + function normalizePath() { + return normalizePathname(location.pathname); + } + + function getTelemetryPath() { + return document.querySelector('[data-funnel="not_found_recovery"][data-funnel-view]') + ? '/404/' + : normalizePath(); + } + + function clearCliEntryMarker() { + try { + window.sessionStorage.removeItem(CLI_ENTRY_MARKER_KEY); + } catch (err) { + console.debug('[track] Failed to clear CLI entry marker:', err); + } + } + + function rememberCliEntry() { + try { + window.sessionStorage.setItem(CLI_ENTRY_MARKER_KEY, String(Date.now())); + } catch (err) { + console.debug('[track] Failed to persist CLI entry marker:', err); + } + } + + function consumeRecentCliEntry() { + try { + var value = Number(window.sessionStorage.getItem(CLI_ENTRY_MARKER_KEY)); + window.sessionStorage.removeItem(CLI_ENTRY_MARKER_KEY); + return Number.isFinite(value) && Date.now() - value <= CLI_ENTRY_MARKER_TTL_MS; + } catch (err) { + console.debug('[track] Failed to read CLI entry marker:', err); + return false; + } + } + + function getInternalDestinationPath(href) { + if (!href) return null; + + try { + var destination = new URL(href, location.href); + return destination.origin === location.origin ? destination.pathname : null; + } catch (err) { + console.debug('[track] Failed to parse funnel destination:', err); + return null; + } + } + + function writeContinuationMarker(key, destinationPath, dimensions) { + if (!destinationPath) return; + + try { + window.sessionStorage.setItem( + key, + JSON.stringify({ + destinationPath: destinationPath, + expiresAt: Date.now() + CONTINUATION_MARKER_TTL_MS, + dimensions: dimensions || {}, + }) + ); + } catch (err) { + console.debug('[track] Failed to persist funnel continuation:', err); + } + } + + function readContinuationMarker(key, consume) { + try { + var value = window.sessionStorage.getItem(key); + if (!value) return null; + + var marker = JSON.parse(value); + if ( + !marker || + typeof marker.destinationPath !== 'string' || + typeof marker.expiresAt !== 'number' || + marker.expiresAt < Date.now() + ) { + window.sessionStorage.removeItem(key); + return null; } + + if (consume) { + window.sessionStorage.removeItem(key); + } + + return marker; + } catch (err) { + console.debug('[track] Failed to read funnel continuation:', err); + return null; } + } - if (target.tagName === 'A' && target.href) { - overrides.href = target.href; + function clearContinuationMarker(key) { + try { + window.sessionStorage.removeItem(key); + } catch (err) { + console.debug('[track] Failed to clear funnel continuation:', err); } + } + + function copyOptionalDimensions(details, properties) { + Object.keys(optionalDimensions).forEach(function (key) { + var value = details[key]; + if (value === undefined || value === null || value === '') return; + + if (optionalDimensions[key].indexOf(value) === -1) { + console.debug('[track] Ignoring unsupported funnel dimension:', key, value); + return; + } + + properties[key] = value; + }); + + Object.keys(boundedDimensions).forEach(function (key) { + var value = details[key]; + if (value === undefined || value === null || value === '') return; + + if (typeof value !== 'string' || !boundedDimensions[key].test(value)) { + console.debug('[track] Ignoring invalid bounded funnel dimension:', key, value); + return; + } + + properties[key] = value; + }); + } - var text = (target.textContent || '').trim(); - if (text.length > 100) text = text.substring(0, 100); - if (text) overrides.text = text; + function getSelectedLanguage() { + var selected = document.querySelector( + '#pivot-selector-aspire-lang [data-pivot-option].active, #pivot-selector-lang [data-pivot-option].active' + ); + if (selected) return selected.dataset.pivotOption; + + var selectedTab = document.querySelector( + 'starlight-tabs[data-sync-key="aspire-lang"] [role="tab"][aria-selected="true"]' + ); + var label = selectedTab && selectedTab.textContent ? selectedTab.textContent.trim() : ''; + if (label === 'C#') return 'csharp'; + if (label === 'TypeScript') return 'typescript'; + return null; + } + + function trackFunnelStep(details) { + if (!details || !details.funnel || !details.step) { + console.debug('[track] Funnel event requires funnel and step values.'); + return false; + } + + var steps = funnelSteps[details.funnel]; + var stepIndex = steps && steps[details.step]; + + if (!stepIndex) { + console.debug('[track] Ignoring unsupported funnel step:', details.funnel, details.step); + return false; + } + + var telemetryPath = getTelemetryPath(); + var properties = { + schemaVersion: 1, + funnel: details.funnel, + step: details.step, + stepIndex: stepIndex, + locale: document.documentElement.lang || 'en', + path: telemetryPath, + }; + + copyOptionalDimensions(details, properties); + if (languageFunnels[details.funnel] && !properties.language) { + var selectedLanguage = getSelectedLanguage(); + if (optionalDimensions.language.indexOf(selectedLanguage) !== -1) { + properties.language = selectedLanguage; + } + } try { - window.analytics.capturePageAction(target, overrides); - console.debug('[track] Event tracked:', eventName, overrides); + window.analytics.trackPageAction( + { + name: FUNNEL_EVENT_NAME, + uri: location.origin + telemetryPath, + pageName: document.title || properties.path, + actionType: details.actionType === 'CL' ? 'CL' : 'O', + isManual: true, + }, + properties + ); + console.debug('[track] Funnel step tracked:', properties); + return true; } catch (err) { - console.debug('[track] Failed to track event:', err); + console.debug('[track] Failed to track funnel step:', err); + return false; + } + } + + function readDeclarativeStep(stepTarget) { + var root = stepTarget.closest('[data-funnel]'); + if (!root) { + console.debug('[track] Funnel step is missing a data-funnel ancestor.'); + return null; + } + + var details = { + funnel: root.dataset.funnel, + step: stepTarget.dataset.funnelStep || root.dataset.funnelStep, + actionType: 'CL', + }; + + Object.keys(optionalDimensions).forEach(function (key) { + var dataKey = 'funnel' + key.charAt(0).toUpperCase() + key.slice(1); + details[key] = stepTarget.dataset[dataKey] || root.dataset[dataKey]; + }); + + Object.keys(boundedDimensions).forEach(function (key) { + var dataKey = 'funnel' + key.charAt(0).toUpperCase() + key.slice(1); + details[key] = stepTarget.dataset[dataKey] || root.dataset[dataKey]; + }); + + return details; + } + + function findPrecedingContext(selector, element) { + var contexts = Array.from(document.querySelectorAll(selector)); + var selected = null; + + contexts.forEach(function (context) { + if ( + context.contains(element) || + context.compareDocumentPosition(element) & Node.DOCUMENT_POSITION_FOLLOWING + ) { + selected = context; + } + }); + + return selected; + } + + function classifyIntegrationInstall(code) { + if (/^\s*aspire\s+add\b/m.test(code)) return { method: 'aspire_cli' }; + if (/^\s*dotnet\s+add\b.*\bpackage\b/m.test(code)) return { method: 'dotnet_cli' }; + if (/^\s*#:package\s+/m.test(code)) return { method: 'file_directive' }; + if (/