diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md index 0e5e2ade5..aa476f65f 100644 --- a/.github/copilot-instructions.md +++ b/.github/copilot-instructions.md @@ -371,6 +371,152 @@ export async function yourCommand(context: IActionContext, targetItem: SomeItem) } ``` +### Wizard Back Navigation and Context Persistence + +When users navigate back in a wizard (via `GoBackError`), the `AzureWizard` framework resets context properties. Understanding this behavior is critical for proper wizard implementation. + +#### How AzureWizard Handles Back Navigation + +When a step throws `GoBackError`, the wizard: + +1. Pops steps from the finished stack until finding the previous prompted step +2. **Resets context properties** to what existed before that step's `prompt()` ran +3. Re-runs the step's `prompt()` method + +**Critical Implementation Detail**: Before each step's `prompt()` runs, the wizard captures `propertiesBeforePrompt`: + +```javascript +// From AzureWizard.js - this runs for EACH step before prompt() +step.propertiesBeforePrompt = Object.keys(this._context).filter((k) => !isNullOrUndefined(this._context[k])); // Only non-null/undefined values! +``` + +When going back, properties NOT in `propertiesBeforePrompt` are set to `undefined`: + +```javascript +// From AzureWizard.js goBack() method +for (const key of Object.keys(this._context)) { + if (!step.propertiesBeforePrompt.find((p) => p === key)) { + this._context[key] = undefined; // Property gets cleared! + } +} +``` + +#### Making Context Properties Survive Back Navigation + +To ensure a context property survives when users navigate back, you must initialize it with a **non-null, non-undefined value** in the wizard context creation: + +```typescript +// ❌ Bad - Property will be cleared on back navigation +const wizardContext: MyWizardContext = { + ...context, + cachedData: undefined, // undefined is filtered out of propertiesBeforePrompt! +}; + +// ❌ Bad - Property not initialized, same problem +const wizardContext: MyWizardContext = { + ...context, + // cachedData not set - will be undefined +}; + +// ✅ Good - Property will survive back navigation (using empty array) +const wizardContext: MyWizardContext = { + ...context, + cachedData: [], // Empty array is not null/undefined, captured in propertiesBeforePrompt +}; + +// ✅ Good - Property will survive back navigation (using empty object) +const wizardContext: MyWizardContext = { + ...context, + cachedConfig: {}, // Empty object is not null/undefined +}; + +// ✅ Good - Property will survive back navigation (using empty string) +const wizardContext: MyWizardContext = { + ...context, + cachedId: '', // Empty string is not null/undefined +}; + +// ✅ Good - Property will survive back navigation (using zero) +const wizardContext: MyWizardContext = { + ...context, + retryCount: 0, // Zero is not null/undefined +}; + +// ✅ Good - Property will survive back navigation (using false) +const wizardContext: MyWizardContext = { + ...context, + hasBeenValidated: false, // false is not null/undefined +}; +``` + +#### Pattern for Cached Data with Back Navigation Support + +When you need to cache expensive data (like API calls) that should survive back navigation: + +1. **Context Interface**: Make the property required with a non-nullable type + +```typescript +export interface MyWizardContext extends IActionContext { + // Required - initialized with non-null/undefined value to survive back navigation + cachedItems: CachedItem[]; + + // Optional - user selections that may be cleared + selectedItem?: SomeItem; +} +``` + +2. **Wizard Initialization**: Initialize with a non-null/undefined value + +```typescript +const wizardContext: MyWizardContext = { + ...context, + cachedItems: [], // Any non-null/undefined value survives back navigation +}; +``` + +3. **Step Implementation**: Check appropriately for the initial value + +```typescript +public async prompt(context: MyWizardContext): Promise { + const getQuickPickItems = async () => { + // Check for initial empty value (array uses .length, string uses === '', etc.) + if (context.cachedItems.length === 0) { + context.cachedItems = await this.fetchExpensiveData(); + } + return context.cachedItems.map(item => ({ label: item.name })); + }; + + await context.ui.showQuickPick(getQuickPickItems(), { /* options */ }); +} +``` + +4. **Clearing Cache**: Reset to the initial non-null/undefined value + +```typescript +// When you need to invalidate the cache (e.g., after a mutation) +context.cachedItems = []; // Reset to initial value, not undefined! +``` + +#### Using GoBackError in Steps + +To navigate back programmatically from a step: + +```typescript +import { GoBackError } from '@microsoft/vscode-azext-utils'; + +public async prompt(context: MyWizardContext): Promise { + const result = await context.ui.showQuickPick(items, options); + + if (result.isBackOption) { + // Clear step-specific selections before going back + context.selectedItem = undefined; + throw new GoBackError(); + } + + // Process selection... +} +``` + ### Tree View Architecture - Use proper data providers that implement `vscode.TreeDataProvider`. diff --git a/docs/user-manual/managing-azure-discovery.md b/docs/user-manual/managing-azure-discovery.md index 5a62d71c8..e1b342832 100644 --- a/docs/user-manual/managing-azure-discovery.md +++ b/docs/user-manual/managing-azure-discovery.md @@ -14,40 +14,65 @@ For a general overview of service discovery, see the [Service Discovery](./servi --- -## Managing Azure Accounts +## Managing Azure Accounts and Tenants -The **Manage Credentials** feature allows you to view and manage which Azure accounts are being used for service discovery within the extension. +The **Manage Credentials** feature allows you to view your Azure accounts, sign in to specific tenants, and add new accounts for service discovery. ### How to Access You can access the credential management feature in two ways: 1. **From the context menu**: Right-click on an Azure service discovery provider and select `Manage Credentials...` -2. **From the Service Discovery panel**: Click the `key icon` next to the service discovery provider name +2. **From the Service Discovery panel**: Click the `key icon` next to the service discovery provider name. -### Available Actions +### Account and Tenant Management Flow -When you open the credential management wizard, you can: +The wizard provides options to manage your Azure authentication state. -1. **View signed-in accounts**: See all Azure accounts currently authenticated in VS Code and available for service discovery -2. **Sign in with a different account**: Add additional Azure accounts for accessing more resources -3. **View active account details**: See which account is currently being used for a specific service discovery provider -4. **Exit without changes**: Close the wizard without making modifications +#### Step 1: Select an Account -### Account Selection +First, you'll see a list of all Azure accounts currently authenticated in VS Code. For each account, you can see how many tenants are available and how many you are currently signed in to. + +You can: + +- Select an existing account to manage its tenants. +- Choose `Sign in with a different account…` to add a new Azure account. + +``` +┌───────────────────────────────────────────────────────────┐ +│ Azure accounts used for service discovery │ +├───────────────────────────────────────────────────────────┤ +│ 👤 user@contoso.com │ +│ 2 tenants available (1 signed in) │ +│ 👤 user@fabrikam.com │ +│ 1 tenant available (1 signed in) │ +├───────────────────────────────────────────────────────────┤ +│ 🔐 Sign in with a different account… │ +│ ✖️ Exit │ +└───────────────────────────────────────────────────────────┘ +``` + +#### Step 2: Manage Tenants for the Selected Account + +After selecting an account, you will see a list of all tenants associated with that account, along with their sign-in status. ``` -┌────────────────────────────────────────────┐ -│ Azure accounts used for service discovery │ -├────────────────────────────────────────────┤ -│ 👤 user1@contoso.com │ -│ 👤 user2@fabrikam.com │ -├────────────────────────────────────────────┤ -│ 🔐 Sign in with a different account… │ -│ ✖️ Exit without making changes │ -└────────────────────────────────────────────┘ +┌───────────────────────────────────────────────────────────┐ +│ Tenants for "user@contoso.com" │ +├───────────────────────────────────────────────────────────┤ +│ Experiments │ +│ ✅ Signed in │ +│ Production │ +│ 🔐 Select to sign in │ +├───────────────────────────────────────────────────────────┤ +│ ⬅️ Back to account selection │ +│ ✖️ Exit │ +└───────────────────────────────────────────────────────────┘ ``` +- **Sign in to a tenant**: Select any tenant marked with `$(sign-in) Select to sign in`. The extension will authenticate you for that specific tenant, making its subscriptions available for discovery. +- **Already signed-in tenants**: Selecting a tenant that is already signed in will simply confirm your status and allow you to return to the list. + ### Signing Out from an Azure Account The credential management wizard does **not** provide a sign-out option. If you need to sign out from an Azure account: @@ -62,7 +87,7 @@ The credential management wizard does **not** provide a sign-out option. If you ## Filtering Azure Resources -The **Filter** feature allows you to control which Azure resources are displayed in the Service Discovery panel by selecting specific tenants and subscriptions. +The **Filter** feature allows you to control which Azure resources are displayed in the Service Discovery panel by selecting from your **currently signed-in tenants** and their corresponding subscriptions. ### How to Access @@ -70,56 +95,55 @@ You can access the filtering feature by clicking the **funnel icon** next to the ### Filtering Flow -The filtering wizard guides you through selecting which Azure resources to display: +The filtering wizard guides you through selecting which Azure resources to display. The flow adapts based on your Azure environment. #### Single-Tenant Scenario -If you have access to only one Azure tenant, the wizard will skip tenant selection and proceed directly to subscription filtering: +If you have access to only one Azure tenant (or are only signed in to one), the wizard will skip tenant selection and proceed directly to subscription filtering: ``` -┌────────────────────────────────────────────┐ -│ Select subscriptions to include in │ -│ service discovery │ -├────────────────────────────────────────────┤ -│ ☑️ Production Subscription │ -│ (sub-id-123) (Contoso) │ -│ ☑️ Development Subscription │ -│ (sub-id-456) (Contoso) │ -│ ☐ Test Subscription │ -│ (sub-id-789) (Contoso) │ -└────────────────────────────────────────────┘ +┌───────────────────────────────────────────────────────────┐ +│ Select subscriptions to include in service discovery │ +├───────────────────────────────────────────────────────────┤ +│ ☑️ Demos (Experiments) │ +│ (sub-id-123) │ +│ ☑️ TestRuns (Experiments) │ +│ (sub-id-456) │ +└───────────────────────────────────────────────────────────┘ ``` #### Multi-Tenant Scenario If you have access to multiple Azure tenants, the wizard will first ask you to select tenants, then filter subscriptions based on your tenant selection: +**Step 1: Select Tenants** + +The wizard first asks you to select from the tenants you are currently signed in to. Only tenants authenticated via the "Manage Credentials" flow will appear here. + +``` +┌───────────────────────────────────────────────────────────┐ +│ Select tenants (manage accounts to see more) │ +├───────────────────────────────────────────────────────────┤ +│ ☑️ Experiments │ +│ (tenant-id-123) │ +│ ☑️ Production │ +│ (tenant-id-456) │ +└───────────────────────────────────────────────────────────┘ +``` + +**Step 2: Select Subscriptions** + +Next, you'll see a list of subscriptions belonging to the tenants you selected in the previous step. + ``` -Step 1: Select Tenants -┌────────────────────────────────────────────┐ -│ Select tenants to include in subscription │ -│ discovery │ -├────────────────────────────────────────────┤ -│ ☑️ Contoso │ -│ (tenant-id-123) contoso.onmicrosoft.com │ -│ ☑️ Fabrikam │ -│ (tenant-id-456) fabrikam.onmicrosoft.com │ -│ ☐ Adventure Works │ -│ (tenant-id-789) adventureworks.com │ -└────────────────────────────────────────────┘ - -Step 2: Select Subscriptions (filtered by selected tenants) -┌────────────────────────────────────────────┐ -│ Select subscriptions to include in │ -│ service discovery │ -├────────────────────────────────────────────┤ -│ ☑️ Contoso Production │ -│ (sub-id-123) (Contoso) │ -│ ☑️ Contoso Development │ -│ (sub-id-456) (Contoso) │ -│ ☑️ Fabrikam Production │ -│ (sub-id-789) (Fabrikam) │ -└────────────────────────────────────────────┘ +┌───────────────────────────────────────────────────────────┐ +│ Select subscriptions to include in service discovery │ +├───────────────────────────────────────────────────────────┤ +│ ☑️ Demos (Experiments) │ +│ (sub-id-123) │ +│ ☑️ Portal (Production) │ +│ (sub-id-789) │ +└───────────────────────────────────────────────────────────┘ ``` ### Filter Persistence @@ -134,22 +158,21 @@ The filtering behavior differs depending on how you access service discovery: When working within the **Service Discovery** panel in the sidebar: -- Your filter selections (tenants and subscriptions) are **applied automatically** -- Only resources from selected tenants and subscriptions are displayed -- The filter persists until you change it +- Your filter selections (tenants and subscriptions) are **applied automatically**. +- Only resources from selected tenants and subscriptions are displayed. +- The filter persists until you change it. #### From the "Add New Connection" Wizard When adding a new connection via the **"Add New Connection"** wizard: -- **No filtering is applied** by default -- You will see **all subscriptions from all tenants** you have access to -- You must select one subscription to continue, but the full list is available -- This ensures you can always access any resource when explicitly adding a connection +- **No filtering is applied** by default. +- You will see **all subscriptions from all tenants** you have access to, regardless of your filter settings or sign-in status for each tenant. +- This ensures you can always access any resource when explicitly adding a connection. ## Related Documentation - [Service Discovery Overview](./service-discovery) -- [Azure CosmosDB for MongoDB (RU) Service Discovery](./service-discovery-azure-cosmosdb-for-mongodb-ru) +- [Azure Cosmos DB for MongoDB (RU) Service Discovery](./service-discovery-azure-cosmosdb-for-mongodb-ru) - [Azure DocumentDB Service Discovery](./service-discovery-azure-cosmosdb-for-mongodb-vcore) - [Azure VMs (DocumentDB) Service Discovery](./service-discovery-azure-vms) diff --git a/l10n/bundle.l10n.json b/l10n/bundle.l10n.json index 7e7e9b43d..c7c2e2815 100644 --- a/l10n/bundle.l10n.json +++ b/l10n/bundle.l10n.json @@ -52,7 +52,7 @@ "[Query Insights Stage 3] AI service completed in {ms}ms (requestKey: {key})": "[Query Insights Stage 3] AI service completed in {ms}ms (requestKey: {key})", "[Query Insights Stage 3] Completed: {count} improvement cards generated (requestKey: {key})": "[Query Insights Stage 3] Completed: {count} improvement cards generated (requestKey: {key})", "[Query Insights Stage 3] Started for {db}.{collection} (requestKey: {key})": "[Query Insights Stage 3] Started for {db}.{collection} (requestKey: {key})", - "{0} is currently being used for Azure service discovery": "{0} is currently being used for Azure service discovery", + "{0} tenants available ({1} signed in)": "{0} tenants available ({1} signed in)", "{countMany} documents have been deleted.": "{countMany} documents have been deleted.", "{countOne} document has been deleted.": "{countOne} document has been deleted.", "{documentCount} documents exported…": "{documentCount} documents exported…", @@ -68,12 +68,16 @@ "$(add) Create...": "$(add) Create...", "$(info) Some storage accounts were filtered because of their sku. Learn more...": "$(info) Some storage accounts were filtered because of their sku. Learn more...", "$(keyboard) Manually enter error": "$(keyboard) Manually enter error", + "$(pass) Signed in": "$(pass) Signed in", "$(plus) Create new {0}...": "$(plus) Create new {0}...", "$(plus) Create new resource group": "$(plus) Create new resource group", "$(plus) Create new storage account": "$(plus) Create new storage account", "$(plus) Create new user assigned identity": "$(plus) Create new user assigned identity", + "$(sign-in) Select to sign in": "$(sign-in) Select to sign in", "$(warning) Only storage accounts in the region \"{0}\" are shown.": "$(warning) Only storage accounts in the region \"{0}\" are shown.", "$(warning) Some storage accounts were filtered because of their network configurations.": "$(warning) Some storage accounts were filtered because of their network configurations.", + "1 tenant available (0 signed in)": "1 tenant available (0 signed in)", + "1 tenant available (1 signed in)": "1 tenant available (1 signed in)", "1. Locating the one you'd like from the DocumentDB side panel,": "1. Locating the one you'd like from the DocumentDB side panel,", "2. Selecting a database or a collection,": "2. Selecting a database or a collection,", "3. Right-clicking and then choosing the \"Mongo Scrapbook\" submenu,": "3. Right-clicking and then choosing the \"Mongo Scrapbook\" submenu,", @@ -88,6 +92,7 @@ "Action failed": "Action failed", "Add new document": "Add new document", "Additional write and storage overhead for maintaining a new index.": "Additional write and storage overhead for maintaining a new index.", + "Adjust Filters": "Adjust Filters", "Advanced": "Advanced", "AI is analyzing...": "AI is analyzing...", "AI Performance Insights": "AI Performance Insights", @@ -139,6 +144,7 @@ "Azure VMs (DocumentDB)": "Azure VMs (DocumentDB)", "Back": "Back", "Back to account selection": "Back to account selection", + "Back to tenant selection": "Back to tenant selection", "Browse to {mongoExecutableFileName}": "Browse to {mongoExecutableFileName}", "Cancel": "Cancel", "Change page size": "Change page size", @@ -156,7 +162,6 @@ "Click here to retry": "Click here to retry", "Click here to update credentials": "Click here to update credentials", "Click to view resource": "Click to view resource", - "Close the account management wizard": "Close the account management wizard", "Cluster metadata not initialized. Client may not be properly connected.": "Cluster metadata not initialized. Client may not be properly connected.", "Cluster support unknown $(info)": "Cluster support unknown $(info)", "Collection name cannot begin with the system. prefix (Reserved for internal use).": "Collection name cannot begin with the system. prefix (Reserved for internal use).", @@ -299,7 +304,6 @@ "Execution timed out": "Execution timed out", "Execution timed out.": "Execution timed out.", "Exit": "Exit", - "Exit without making changes": "Exit without making changes", "Expected a file name \"{0}\", but the selected filename is \"{1}\"": "Expected a file name \"{0}\", but the selected filename is \"{1}\"", "Expecting parentheses or quotes at \"{text}\"": "Expecting parentheses or quotes at \"{text}\"", "Explain(aggregate) completed [{durationMs}ms]": "Explain(aggregate) completed [{durationMs}ms]", @@ -356,6 +360,7 @@ "Failed to retrieve Azure accounts: {0}": "Failed to retrieve Azure accounts: {0}", "Failed to save credentials for \"{cluster}\".": "Failed to save credentials for \"{cluster}\".", "Failed to save credentials.": "Failed to save credentials.", + "Failed to sign in to tenant {0}: {1}": "Failed to sign in to tenant {0}: {1}", "Failed to store secrets for key {0}:": "Failed to store secrets for key {0}:", "Failed to unhide index.": "Failed to unhide index.", "Failed to update the connection.": "Failed to update the connection.", @@ -477,12 +482,15 @@ "Loading Subscriptions…": "Loading Subscriptions…", "Loading Tenant Filter Options…": "Loading Tenant Filter Options…", "Loading Tenants and Subscription Data…": "Loading Tenants and Subscription Data…", + "Loading tenants…": "Loading tenants…", "Loading Tenants…": "Loading Tenants…", "Loading Virtual Machines…": "Loading Virtual Machines…", "Loading...": "Loading...", "Location": "Location", "LOW PRIORITY": "LOW PRIORITY", + "Manage Accounts": "Manage Accounts", "Manage Azure Accounts": "Manage Azure Accounts", + "Manage Azure Accounts…": "Manage Azure Accounts…", "Manually enter a custom tenant ID": "Manually enter a custom tenant ID", "MEDIUM PRIORITY": "MEDIUM PRIORITY", "Microsoft will process the feedback data you submit on behalf of your organization in accordance with the Data Protection Addendum between your organization and Microsoft.": "Microsoft will process the feedback data you submit on behalf of your organization in accordance with the Data Protection Addendum between your organization and Microsoft.", @@ -504,6 +512,7 @@ "New Local Connection…": "New Local Connection…", "No": "No", "No Action": "No Action", + "No authenticated tenants found. Use \"Manage Azure Accounts\" in the Discovery View to sign in to tenants.": "No authenticated tenants found. Use \"Manage Azure Accounts\" in the Discovery View to sign in to tenants.", "No authentication method selected.": "No authentication method selected.", "No authentication methods available for \"{cluster}\".": "No authentication methods available for \"{cluster}\".", "No Azure subscription found for this tree item.": "No Azure subscription found for this tree item.", @@ -526,8 +535,9 @@ "No subscriptions found": "No subscriptions found", "No subscriptions found for the selected tenants. Please adjust your tenant selection or check your Azure permissions.": "No subscriptions found for the selected tenants. Please adjust your tenant selection or check your Azure permissions.", "No suitable language model found. Please ensure GitHub Copilot is installed and you have an active subscription.": "No suitable language model found. Please ensure GitHub Copilot is installed and you have an active subscription.", - "No tenants found. Please try signing in again or check your Azure permissions.": "No tenants found. Please try signing in again or check your Azure permissions.", - "No tenants selected. Azure discovery will be filtered to exclude all tenant results.": "No tenants selected. Azure discovery will be filtered to exclude all tenant results.", + "No tenants available": "No tenants available", + "No tenants available for this account": "No tenants available for this account", + "No tenants selected. Tenant filtering disabled (all tenants will be shown).": "No tenants selected. Tenant filtering disabled (all tenants will be shown).", "None": "None", "Not connected to any MongoDB database.": "Not connected to any MongoDB database.", "Note: This confirmation type can be configured in the extension settings.": "Note: This confirmation type can be configured in the extension settings.", @@ -601,7 +611,6 @@ "Report an issue": "Report an issue", "Resource group \"{0}\" already exists in subscription \"{1}\".": "Resource group \"{0}\" already exists in subscription \"{1}\".", "Retry": "Retry", - "Return to the account list": "Return to the account list", "Reusing active connection for \"{cluster}\".": "Reusing active connection for \"{cluster}\".", "Revisit connection details and try again.": "Revisit connection details and try again.", "Role assignment \"{0}\" created for the {2} resource \"{1}\".": "Role assignment \"{0}\" created for the {2} resource \"{1}\".", @@ -626,7 +635,7 @@ "Select subscription": "Select subscription", "Select subscriptions to include in service discovery": "Select subscriptions to include in service discovery", "Select Subscriptions...": "Select Subscriptions...", - "Select tenants to include in subscription discovery": "Select tenants to include in subscription discovery", + "Select tenants (manage accounts to see more)": "Select tenants (manage accounts to see more)", "Select the error you would like to report": "Select the error you would like to report", "Select the local connection type…": "Select the local connection type…", "Selected subscriptions: {0}": "Selected subscriptions: {0}", @@ -638,11 +647,14 @@ "SHARD_MERGE · {0} shards · {1} docs · {2}ms": "SHARD_MERGE · {0} shards · {1} docs · {2}ms", "Shard: {0}": "Shard: {0}", "Show Stage Details": "Show Stage Details", + "Sign in to additional accounts or authenticate with other tenants to see more options.": "Sign in to additional accounts or authenticate with other tenants to see more options.", + "Sign in to additional accounts or authenticate with other tenants to see more subscriptions.": "Sign in to additional accounts or authenticate with other tenants to see more subscriptions.", "Sign in to Azure to continue…": "Sign in to Azure to continue…", "Sign in to Azure...": "Sign in to Azure...", - "Sign in to other Azure accounts to access more subscriptions": "Sign in to other Azure accounts to access more subscriptions", "Sign in to other Azure accounts to access more tenants": "Sign in to other Azure accounts to access more tenants", "Sign in with a different account…": "Sign in with a different account…", + "Sign-in to tenant was cancelled or failed: {0}": "Sign-in to tenant was cancelled or failed: {0}", + "Signed in to tenant \"{0}\"": "Signed in to tenant \"{0}\"", "Signing out programmatically is not supported. You must sign out by selecting the account in the Accounts menu and choosing Sign Out.": "Signing out programmatically is not supported. You must sign out by selecting the account in the Accounts menu and choosing Sign Out.", "Skip": "Skip", "Skip for now": "Skip for now", @@ -655,6 +667,7 @@ "Starting Azure account management wizard": "Starting Azure account management wizard", "Starting Azure sign-in process…": "Starting Azure sign-in process…", "Starting executable: \"{command}\"": "Starting executable: \"{command}\"", + "Starting sign-in to tenant: {0}": "Starting sign-in to tenant: {0}", "Starts with mongodb:// or mongodb+srv://": "Starts with mongodb:// or mongodb+srv://", "Submit": "Submit", "Submit Feedback": "Submit Feedback", @@ -666,6 +679,8 @@ "Successfully created resource group \"{0}\".": "Successfully created resource group \"{0}\".", "Successfully created storage account \"{0}\".": "Successfully created storage account \"{0}\".", "Successfully created user assigned identity \"{0}\".": "Successfully created user assigned identity \"{0}\".", + "Successfully signed in to {0}": "Successfully signed in to {0}", + "Successfully signed in to tenant: {0}": "Successfully signed in to tenant: {0}", "Suggest a Feature": "Suggest a Feature", "Sure!": "Sure!", "Switch to the new \"Connections View\"…": "Switch to the new \"Connections View\"…", @@ -675,9 +690,11 @@ "Tag cannot be longer than 256 characters.": "Tag cannot be longer than 256 characters.", "Template file is empty: {path}": "Template file is empty: {path}", "Template file not found: {path}": "Template file not found: {path}", + "Tenant {0} has been automatically included in subscription discovery": "Tenant {0} has been automatically included in subscription discovery", "Tenant ID cannot be empty": "Tenant ID cannot be empty", "Tenant ID: {0}": "Tenant ID: {0}", "Tenant Name: {0}": "Tenant Name: {0}", + "Tenants for \"{0}\"": "Tenants for \"{0}\"", "Thank you for helping us improve!": "Thank you for helping us improve!", "Thank you for your feedback!": "Thank you for your feedback!", "The \"_id_\" index cannot be deleted.": "The \"_id_\" index cannot be deleted.", @@ -734,6 +751,7 @@ "This will allow the query planner to use this index again.": "This will allow the query planner to use this index again.", "This will prevent the query planner from using this index.": "This will prevent the query planner from using this index.", "Timed out trying to execute the Mongo script. To use a longer timeout, modify the VS Code 'mongo.shell.timeout' setting.": "Timed out trying to execute the Mongo script. To use a longer timeout, modify the VS Code 'mongo.shell.timeout' setting.", + "To connect to Azure resources, you need to sign in to Azure accounts.": "To connect to Azure resources, you need to sign in to Azure accounts.", "TODO: Share the steps needed to reliably reproduce the problem. Please include actual and expected results.": "TODO: Share the steps needed to reliably reproduce the problem. Please include actual and expected results.", "Too many arguments. Expecting 0 or 1 argument(s) to {constructorCall}": "Too many arguments. Expecting 0 or 1 argument(s) to {constructorCall}", "Total time taken to execute the query on the server": "Total time taken to execute the query on the server", @@ -805,10 +823,10 @@ "Write error: {0}": "Write error: {0}", "Yes": "Yes", "Yes, continue": "Yes, continue", - "Yes, Manage Accounts": "Yes, Manage Accounts", "Yes, open Collection View": "Yes, open Collection View", "Yes, open connection": "Yes, open connection", "Yes, save my credentials": "Yes, save my credentials", + "You are already signed in to tenant \"{0}\"": "You are already signed in to tenant \"{0}\"", "You are not signed in to an Azure account. Please sign in.": "You are not signed in to an Azure account. Please sign in.", "You are not signed in to the MongoDB Cluster. Please sign in (by expanding the node \"{0}\") and try again.": "You are not signed in to the MongoDB Cluster. Please sign in (by expanding the node \"{0}\") and try again.", "You can connect to a different DocumentDB by:": "You can connect to a different DocumentDB by:", diff --git a/src/commands/addConnectionFromRegistry/addConnectionFromRegistry.ts b/src/commands/addConnectionFromRegistry/addConnectionFromRegistry.ts index 8ae66db20..7eeb999fa 100644 --- a/src/commands/addConnectionFromRegistry/addConnectionFromRegistry.ts +++ b/src/commands/addConnectionFromRegistry/addConnectionFromRegistry.ts @@ -26,6 +26,12 @@ export async function addConnectionFromRegistry(context: IActionContext, node: C throw new Error(l10n.t('No node selected.')); } + // Include journey correlation ID in telemetry for funnel analysis + // This is for statistics only - does not influence functionality + if (node.journeyCorrelationId) { + context.telemetry.properties.journeyCorrelationId = node.journeyCorrelationId; + } + // FYI: As of Sept 2025 this command is used in two views: the discovery view and the azure resources view const sourceViewId = node.contextValue.includes('documentDbBranch') || node.contextValue.includes('ruBranch') diff --git a/src/commands/newConnection/PromptTenantStep.ts b/src/commands/newConnection/PromptTenantStep.ts index 903538f60..853f82b0e 100644 --- a/src/commands/newConnection/PromptTenantStep.ts +++ b/src/commands/newConnection/PromptTenantStep.ts @@ -35,7 +35,10 @@ export class PromptTenantStep extends AzureWizardPromptStep { - return refreshView(context, Views.ConnectionsView); - }); + registerCommand( + 'vscode-documentdb.command.connectionsView.refresh', + withCommandCorrelation((context: IActionContext) => { + return refreshView(context, Views.ConnectionsView); + }), + ); registerCommandWithTreeNodeUnwrapping( 'vscode-documentdb.command.chooseDataMigrationExtension', - chooseDataMigrationExtension, + withTreeNodeCommandCorrelation(chooseDataMigrationExtension), ); //// Registry Commands: - registerCommand('vscode-documentdb.command.discoveryView.addRegistry', addDiscoveryRegistry); + registerCommand( + 'vscode-documentdb.command.discoveryView.addRegistry', + withCommandCorrelation(addDiscoveryRegistry), + ); registerCommandWithTreeNodeUnwrapping( 'vscode-documentdb.command.discoveryView.removeRegistry', - removeDiscoveryRegistry, + withTreeNodeCommandCorrelation(removeDiscoveryRegistry), ); registerCommandWithTreeNodeUnwrapping( 'vscode-documentdb.command.discoveryView.filterProviderContent', - filterProviderContent, + withTreeNodeCommandCorrelation(filterProviderContent), ); registerCommandWithTreeNodeUnwrapping( 'vscode-documentdb.command.discoveryView.manageCredentials', - manageCredentials, + withTreeNodeCommandCorrelation(manageCredentials), ); registerCommandWithTreeNodeUnwrapping( 'vscode-documentdb.command.discoveryView.learnMoreAboutProvider', - learnMoreAboutServiceProvider, + withTreeNodeCommandCorrelation(learnMoreAboutServiceProvider), ); registerCommandWithTreeNodeUnwrappingAndModalErrors( 'vscode-documentdb.command.discoveryView.addConnectionToConnectionsView', - addConnectionFromRegistry, + withTreeNodeCommandCorrelation(addConnectionFromRegistry), ); registerCommandWithTreeNodeUnwrappingAndModalErrors( 'vscode-documentdb.command.azureResourcesView.addConnectionToConnectionsView', - addConnectionFromRegistry, + withTreeNodeCommandCorrelation(addConnectionFromRegistry), ); - registerCommand('vscode-documentdb.command.discoveryView.refresh', (context: IActionContext) => { - return refreshView(context, Views.DiscoveryView); - }); + registerCommand( + 'vscode-documentdb.command.discoveryView.refresh', + withCommandCorrelation((context: IActionContext) => { + return refreshView(context, Views.DiscoveryView); + }), + ); registerCommandWithTreeNodeUnwrapping( 'vscode-documentdb.command.connectionsView.removeConnection', - removeConnection, + withTreeNodeCommandCorrelation(removeConnection), ); registerCommandWithTreeNodeUnwrapping( 'vscode-documentdb.command.connectionsView.renameConnection', - renameConnection, + withTreeNodeCommandCorrelation(renameConnection), ); // using registerCommand instead of vscode.commands.registerCommand for better telemetry: @@ -269,33 +285,75 @@ export class ClustersExtension implements vscode.Disposable { * It was possible to merge the two commands into one, but it would result in code that is * harder to understand and maintain. */ - registerCommand('vscode-documentdb.command.internal.containerView.open', openCollectionViewInternal); + registerCommand( + 'vscode-documentdb.command.internal.containerView.open', + withCommandCorrelation(openCollectionViewInternal), + ); registerCommandWithTreeNodeUnwrapping( 'vscode-documentdb.command.containerView.open', - openCollectionView, + withTreeNodeCommandCorrelation(openCollectionView), ); - registerCommand('vscode-documentdb.command.internal.documentView.open', openDocumentView); + registerCommand( + 'vscode-documentdb.command.internal.documentView.open', + withCommandCorrelation(openDocumentView), + ); - registerCommand('vscode-documentdb.command.internal.helpAndFeedback.openUrl', openHelpAndFeedbackUrl); + registerCommand( + 'vscode-documentdb.command.internal.helpAndFeedback.openUrl', + withCommandCorrelation(openHelpAndFeedbackUrl), + ); - registerCommandWithTreeNodeUnwrapping('vscode-documentdb.command.internal.retry', retryAuthentication); - registerCommandWithTreeNodeUnwrapping('vscode-documentdb.command.internal.revealView', revealView); + registerCommandWithTreeNodeUnwrapping( + 'vscode-documentdb.command.internal.retry', + withTreeNodeCommandCorrelation(retryAuthentication), + ); + registerCommandWithTreeNodeUnwrapping( + 'vscode-documentdb.command.internal.revealView', + withTreeNodeCommandCorrelation(revealView), + ); - registerCommandWithTreeNodeUnwrapping('vscode-documentdb.command.launchShell', launchShell); + registerCommandWithTreeNodeUnwrapping( + 'vscode-documentdb.command.launchShell', + withTreeNodeCommandCorrelation(launchShell), + ); - registerCommandWithTreeNodeUnwrapping('vscode-documentdb.command.dropCollection', deleteCollection); - registerCommandWithTreeNodeUnwrapping('vscode-documentdb.command.dropDatabase', deleteAzureDatabase); + registerCommandWithTreeNodeUnwrapping( + 'vscode-documentdb.command.dropCollection', + withTreeNodeCommandCorrelation(deleteCollection), + ); + registerCommandWithTreeNodeUnwrapping( + 'vscode-documentdb.command.dropDatabase', + withTreeNodeCommandCorrelation(deleteAzureDatabase), + ); - registerCommandWithTreeNodeUnwrapping('vscode-documentdb.command.hideIndex', hideIndex); - registerCommandWithTreeNodeUnwrapping('vscode-documentdb.command.unhideIndex', unhideIndex); - registerCommandWithTreeNodeUnwrapping('vscode-documentdb.command.dropIndex', dropIndex); + registerCommandWithTreeNodeUnwrapping( + 'vscode-documentdb.command.hideIndex', + withTreeNodeCommandCorrelation(hideIndex), + ); + registerCommandWithTreeNodeUnwrapping( + 'vscode-documentdb.command.unhideIndex', + withTreeNodeCommandCorrelation(unhideIndex), + ); + registerCommandWithTreeNodeUnwrapping( + 'vscode-documentdb.command.dropIndex', + withTreeNodeCommandCorrelation(dropIndex), + ); - registerCommandWithTreeNodeUnwrapping('vscode-documentdb.command.createCollection', createCollection); + registerCommandWithTreeNodeUnwrapping( + 'vscode-documentdb.command.createCollection', + withTreeNodeCommandCorrelation(createCollection), + ); - registerCommandWithTreeNodeUnwrapping('vscode-documentdb.command.createDocument', createMongoDocument); + registerCommandWithTreeNodeUnwrapping( + 'vscode-documentdb.command.createDocument', + withTreeNodeCommandCorrelation(createMongoDocument), + ); - registerCommandWithTreeNodeUnwrapping('vscode-documentdb.command.importDocuments', importDocuments); + registerCommandWithTreeNodeUnwrapping( + 'vscode-documentdb.command.importDocuments', + withTreeNodeCommandCorrelation(importDocuments), + ); registerScrapbookCommands(); @@ -309,10 +367,13 @@ export class ClustersExtension implements vscode.Disposable { * It was possible to merge the two commands into one, but it would result in code that is * harder to understand and maintain. */ - registerCommand('vscode-documentdb.command.internal.exportDocuments', exportQueryResults); + registerCommand( + 'vscode-documentdb.command.internal.exportDocuments', + withCommandCorrelation(exportQueryResults), + ); registerCommandWithTreeNodeUnwrapping( 'vscode-documentdb.command.exportDocuments', - exportEntireCollection, + withTreeNodeCommandCorrelation(exportEntireCollection), ); // This is an optional task - if it fails, we don't want to break extension activation, // but we should log the error for diagnostics diff --git a/src/documentdb/scrapbook/registerScrapbookCommands.ts b/src/documentdb/scrapbook/registerScrapbookCommands.ts index 45c3af905..c985fa817 100644 --- a/src/documentdb/scrapbook/registerScrapbookCommands.ts +++ b/src/documentdb/scrapbook/registerScrapbookCommands.ts @@ -17,6 +17,7 @@ import { createScrapbook } from '../../commands/scrapbook-commands/createScrapbo import { executeAllCommand } from '../../commands/scrapbook-commands/executeAllCommand'; import { executeCommand } from '../../commands/scrapbook-commands/executeCommand'; import { ext } from '../../extensionVariables'; +import { withTreeNodeCommandCorrelation } from '../../utils/commandTelemetry'; import { MongoConnectError } from './connectToClient'; import { MongoDBLanguageClient } from './languageClient'; import { getAllErrorsFromTextDocument } from './ScrapbookHelpers'; @@ -37,13 +38,25 @@ export function registerScrapbookCommands(): void { setUpErrorReporting(); - registerCommandWithTreeNodeUnwrapping('vscode-documentdb.command.scrapbook.new', createScrapbook); - registerCommandWithTreeNodeUnwrapping('vscode-documentdb.command.scrapbook.executeCommand', executeCommand); - registerCommandWithTreeNodeUnwrapping('vscode-documentdb.command.scrapbook.executeAllCommands', executeAllCommand); + registerCommandWithTreeNodeUnwrapping( + 'vscode-documentdb.command.scrapbook.new', + withTreeNodeCommandCorrelation(createScrapbook), + ); + registerCommandWithTreeNodeUnwrapping( + 'vscode-documentdb.command.scrapbook.executeCommand', + withTreeNodeCommandCorrelation(executeCommand), + ); + registerCommandWithTreeNodeUnwrapping( + 'vscode-documentdb.command.scrapbook.executeAllCommands', + withTreeNodeCommandCorrelation(executeAllCommand), + ); // #region Database command - registerCommandWithTreeNodeUnwrapping('vscode-documentdb.command.scrapbook.connect', connectCluster); + registerCommandWithTreeNodeUnwrapping( + 'vscode-documentdb.command.scrapbook.connect', + withTreeNodeCommandCorrelation(connectCluster), + ); // #endregion } diff --git a/src/plugins/api-shared/azure/askToConfigureCredentials.ts b/src/plugins/api-shared/azure/askToConfigureCredentials.ts index ad23fb4e1..850a788e9 100644 --- a/src/plugins/api-shared/azure/askToConfigureCredentials.ts +++ b/src/plugins/api-shared/azure/askToConfigureCredentials.ts @@ -6,26 +6,52 @@ import * as l10n from '@vscode/l10n'; import { window } from 'vscode'; +interface AskToConfigureCredentialsOptions { + /** + * Whether to show the "Adjust Filters" button. + * Set to false when already in the filtering wizard to avoid circular flow. + * @default true + */ + showFilterOption?: boolean; +} + /** - * Shows a modal dialog asking the user if they want to configure/manage their Azure credentials. + * Shows a modal dialog asking the user if they want to configure/manage their Azure credentials or adjust filters. * Used when no Azure subscriptions are found or when user is not signed in. * - * @returns Promise that resolves to 'configure' if user wants to manage accounts, 'cancel' otherwise + * @param options Configuration options for the dialog + * @returns Promise that resolves to 'configure' if user wants to manage accounts, 'filter' if user wants to adjust filters, 'cancel' otherwise */ -export async function askToConfigureCredentials(): Promise<'configure' | 'cancel'> { - const configure = l10n.t('Yes, Manage Accounts'); +export async function askToConfigureCredentials( + options: AskToConfigureCredentialsOptions = {}, +): Promise<'configure' | 'filter' | 'cancel'> { + const { showFilterOption = true } = options; + + const configure = l10n.t('Manage Accounts'); + const filter = l10n.t('Adjust Filters'); + + const buttons = showFilterOption ? [{ title: configure }, { title: filter }] : [{ title: configure }]; + + const detailMessage = showFilterOption + ? l10n.t( + 'To connect to Azure resources, you need to sign in to Azure accounts.\n\n' + + 'If you are already signed in, your subscription or tenant filters may be hiding results.', + ) + : l10n.t('To connect to Azure resources, you need to sign in to Azure accounts.'); const result = await window.showInformationMessage( l10n.t('No Azure Subscriptions Found'), { modal: true, - detail: l10n.t( - 'To connect to Azure resources, you need to sign in to Azure accounts.\n\n' + - 'Would you like to manage your Azure accounts now?', - ), + detail: detailMessage, }, - { title: configure }, + ...buttons, ); - return result?.title === configure ? 'configure' : 'cancel'; + if (result?.title === configure) { + return 'configure'; + } else if (result?.title === filter) { + return 'filter'; + } + return 'cancel'; } diff --git a/src/plugins/api-shared/azure/credentialsManagement/AccountTenantsStep.ts b/src/plugins/api-shared/azure/credentialsManagement/AccountTenantsStep.ts new file mode 100644 index 000000000..6ad884fbd --- /dev/null +++ b/src/plugins/api-shared/azure/credentialsManagement/AccountTenantsStep.ts @@ -0,0 +1,169 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { type AzureTenant } from '@microsoft/vscode-azext-azureauth'; +import { AzureWizardPromptStep, GoBackError, UserCancelledError } from '@microsoft/vscode-azext-utils'; +import * as l10n from '@vscode/l10n'; +import * as vscode from 'vscode'; +import { ext } from '../../../../extensionVariables'; +import { nonNullProp, nonNullValue } from '../../../../utils/nonNull'; +import { removeUnselectedTenant } from '../subscriptionFiltering/subscriptionFilteringHelpers'; +import { type CredentialsManagementWizardContext } from './CredentialsManagementWizardContext'; + +interface TenantQuickPickItem extends vscode.QuickPickItem { + tenant?: AzureTenant; + isSignedIn?: boolean; + isBackOption?: boolean; + isExitOption?: boolean; +} + +export class AccountTenantsStep extends AzureWizardPromptStep { + public async prompt(context: CredentialsManagementWizardContext): Promise { + const selectedAccount = nonNullValue( + context.selectedAccount, + 'context.selectedAccount', + 'AccountTenantsStep.ts', + ); + + // Get tenants for the selected account from cached data (fetched in SelectAccountStep) + const getTenantQuickPickItems = (): TenantQuickPickItem[] => { + const accountInfo = context.allAccountsWithTenantInfo?.find( + (info) => info.account.id === selectedAccount.id, + ); + const tenantsWithStatus = accountInfo?.tenantsWithStatus ?? []; + + // Add telemetry + const unauthenticatedCount = tenantsWithStatus.filter((t) => !t.isSignedIn).length; + context.telemetry.measurements.totalTenantCount = tenantsWithStatus.length; + context.telemetry.measurements.unauthenticatedTenantCount = unauthenticatedCount; + + if (tenantsWithStatus.length === 0) { + context.telemetry.properties.noTenantsAvailable = 'true'; + return [ + { + label: l10n.t('No tenants available for this account'), + kind: vscode.QuickPickItemKind.Separator, + }, + { + label: l10n.t('Back to account selection'), + iconPath: new vscode.ThemeIcon('arrow-left'), + isBackOption: true, + }, + { label: '', kind: vscode.QuickPickItemKind.Separator }, + { + label: l10n.t('Exit'), + iconPath: new vscode.ThemeIcon('close'), + isExitOption: true, + }, + ]; + } + + // Build tenant items with sign-in status, sorted by name + const sortedTenants = [...tenantsWithStatus].sort((a, b) => { + const aName = a.tenant.displayName ?? a.tenant.tenantId ?? ''; + const bName = b.tenant.displayName ?? b.tenant.tenantId ?? ''; + return aName.localeCompare(bName); + }); + + const tenantItems: TenantQuickPickItem[] = sortedTenants.map(({ tenant, isSignedIn }) => ({ + label: tenant.displayName ?? tenant.tenantId ?? l10n.t('Unknown tenant'), + description: tenant.tenantId ?? '', + detail: isSignedIn ? l10n.t('$(pass) Signed in') : l10n.t('$(sign-in) Select to sign in'), + tenant, + isSignedIn, + })); + + return [ + ...tenantItems, + { label: '', kind: vscode.QuickPickItemKind.Separator }, + { + label: l10n.t('Back to account selection'), + iconPath: new vscode.ThemeIcon('arrow-left'), + isBackOption: true, + }, + { + label: l10n.t('Exit'), + iconPath: new vscode.ThemeIcon('close'), + isExitOption: true, + }, + ]; + }; + + const selectedItem = await context.ui.showQuickPick(getTenantQuickPickItems(), { + stepName: 'selectTenant', + placeHolder: l10n.t('Tenants for "{0}"', selectedAccount.label), + matchOnDescription: true, + suppressPersistence: true, + loadingPlaceHolder: l10n.t('Loading tenants…'), + }); + + // Handle navigation options + if (selectedItem.isBackOption) { + // Clear the selected account to go back to selection (keep cache for fast navigation) + context.selectedAccount = undefined; + context.selectedTenant = undefined; + context.telemetry.properties.tenantAction = 'back'; + + throw new GoBackError(); + } else if (selectedItem.isExitOption) { + context.telemetry.properties.tenantAction = 'exit'; + throw new UserCancelledError('exitAccountManagement'); + } + + // User selected a tenant + const selectedTenant = nonNullValue(selectedItem.tenant, 'selectedItem.tenant', 'AccountTenantsStep.ts'); + + if (selectedItem.isSignedIn) { + // Already signed in - set as selected and go to action step for back/exit options + context.selectedTenant = selectedTenant; + context.telemetry.properties.tenantAction = 'selectSignedInTenant'; + } else { + // Not signed in - start sign-in directly (no extra step) + context.telemetry.properties.tenantAction = 'signIn'; + await this.handleSignIn(context, selectedTenant); + // Clear cache to refresh sign-in status after sign-in attempt + context.allAccountsWithTenantInfo = []; + // After sign-in attempt, go back to account selection to re-fetch all data + context.selectedAccount = undefined; + throw new GoBackError(); + } + } + + private async handleSignIn(context: CredentialsManagementWizardContext, tenant: AzureTenant): Promise { + const tenantId = nonNullProp(tenant, 'tenantId', 'tenant.tenantId', 'AccountTenantsStep.ts'); + const tenantName = tenant.displayName ?? tenantId; + const accountId = tenant.account.id; + + try { + ext.outputChannel.appendLine(l10n.t('Starting sign-in to tenant: {0}', tenantName)); + + // Sign in to the specific tenant + const success = await context.azureSubscriptionProvider.signIn(tenantId, tenant.account); + + if (success) { + ext.outputChannel.appendLine(l10n.t('Successfully signed in to tenant: {0}', tenantName)); + void vscode.window.showInformationMessage(l10n.t('Successfully signed in to {0}', tenantName)); + + // Auto-select the newly authenticated tenant by removing it from the unselected list + // This ensures the tenant's subscriptions will appear in the Discovery View + await removeUnselectedTenant(tenantId, accountId); + ext.outputChannel.appendLine( + l10n.t('Tenant {0} has been automatically included in subscription discovery', tenantName), + ); + } else { + ext.outputChannel.appendLine(l10n.t('Sign-in to tenant was cancelled or failed: {0}', tenantName)); + } + } catch (error) { + const errorMessage = error instanceof Error ? error.message : String(error); + ext.outputChannel.appendLine(l10n.t('Failed to sign in to tenant {0}: {1}', tenantName, errorMessage)); + throw error; + } + } + + public shouldPrompt(context: CredentialsManagementWizardContext): boolean { + // Only show this step if we have a selected account but no selected tenant + return !!context.selectedAccount && !context.selectedTenant; + } +} diff --git a/src/plugins/api-shared/azure/credentialsManagement/CredentialsManagementWizardContext.ts b/src/plugins/api-shared/azure/credentialsManagement/CredentialsManagementWizardContext.ts index 514ff68ba..4149ad88f 100644 --- a/src/plugins/api-shared/azure/credentialsManagement/CredentialsManagementWizardContext.ts +++ b/src/plugins/api-shared/azure/credentialsManagement/CredentialsManagementWizardContext.ts @@ -3,10 +3,21 @@ * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ +import { type AzureTenant } from '@microsoft/vscode-azext-azureauth'; import { type IActionContext } from '@microsoft/vscode-azext-utils'; import type * as vscode from 'vscode'; import { type AzureSubscriptionProviderWithFilters } from '../AzureSubscriptionProviderWithFilters'; +export interface TenantWithSignInStatus { + tenant: AzureTenant; + isSignedIn: boolean; +} + +export interface AccountWithTenantInfo { + account: vscode.AuthenticationSessionAccountInformation; + tenantsWithStatus: TenantWithSignInStatus[]; +} + export interface CredentialsManagementWizardContext extends IActionContext { // Required context azureSubscriptionProvider: AzureSubscriptionProviderWithFilters; @@ -14,6 +25,10 @@ export interface CredentialsManagementWizardContext extends IActionContext { // Selected account information selectedAccount?: vscode.AuthenticationSessionAccountInformation; - // Available options - availableAccounts?: vscode.AuthenticationSessionAccountInformation[]; + // All accounts with their tenant info (fetched once in SelectAccountStep) + // Initialized with [] so it's captured in propertiesBeforePrompt and survives back navigation + allAccountsWithTenantInfo: AccountWithTenantInfo[]; + + // Selected tenant + selectedTenant?: AzureTenant; } diff --git a/src/plugins/api-shared/azure/credentialsManagement/SelectAccountStep.ts b/src/plugins/api-shared/azure/credentialsManagement/SelectAccountStep.ts index 8f7134a0a..377093bad 100644 --- a/src/plugins/api-shared/azure/credentialsManagement/SelectAccountStep.ts +++ b/src/plugins/api-shared/azure/credentialsManagement/SelectAccountStep.ts @@ -7,8 +7,12 @@ import { AzureWizardPromptStep, UserCancelledError } from '@microsoft/vscode-aze import * as l10n from '@vscode/l10n'; import * as vscode from 'vscode'; import { ext } from '../../../../extensionVariables'; -import { nonNullValue } from '../../../../utils/nonNull'; -import { type CredentialsManagementWizardContext } from './CredentialsManagementWizardContext'; +import { nonNullProp, nonNullValue } from '../../../../utils/nonNull'; +import { + type AccountWithTenantInfo, + type CredentialsManagementWizardContext, + type TenantWithSignInStatus, +} from './CredentialsManagementWizardContext'; interface AccountQuickPickItem extends vscode.QuickPickItem { account?: vscode.AuthenticationSessionAccountInformation; @@ -19,22 +23,48 @@ interface AccountQuickPickItem extends vscode.QuickPickItem { export class SelectAccountStep extends AzureWizardPromptStep { public async prompt(context: CredentialsManagementWizardContext): Promise { - // Create async function to provide better loading UX and debugging experience + // Create async function to provide loading UX const getAccountQuickPickItems = async (): Promise => { - const loadStartTime = Date.now(); - - const accounts = await this.getAvailableAccounts(context); - context.availableAccounts = accounts; - - // Add telemetry for account availability - context.telemetry.measurements.initialAccountCount = accounts.length; - context.telemetry.measurements.accountsLoadingTimeMs = Date.now() - loadStartTime; + // Use cached data when navigating back, otherwise fetch + // Note: allAccountsWithTenantInfo is initialized with [] in wizard context creation + // so it's captured in propertiesBeforePrompt and survives back navigation + // (AzureWizard filters out null/undefined values when capturing propertiesBeforePrompt) + if (context.allAccountsWithTenantInfo.length === 0) { + const loadStartTime = Date.now(); + context.allAccountsWithTenantInfo = await this.getAccountsWithTenantInfo(context); + context.telemetry.measurements.accountsLoadingTimeMs = Date.now() - loadStartTime; + } - const accountItems: AccountQuickPickItem[] = accounts.map((account) => ({ - label: account.label, - iconPath: new vscode.ThemeIcon('account'), - account, - })); + const accountsWithInfo = context.allAccountsWithTenantInfo; + context.telemetry.measurements.initialAccountCount = accountsWithInfo.length; + + const accountItems: AccountQuickPickItem[] = accountsWithInfo.map((info) => { + const totalTenants = info.tenantsWithStatus.length; + const signedInCount = info.tenantsWithStatus.filter((t) => t.isSignedIn).length; + + let detail: string; + if (totalTenants === 0) { + detail = l10n.t('No tenants available'); + } else if (totalTenants === 1) { + detail = + signedInCount === 1 + ? l10n.t('1 tenant available (1 signed in)') + : l10n.t('1 tenant available (0 signed in)'); + } else { + detail = l10n.t( + '{0} tenants available ({1} signed in)', + totalTenants.toString(), + signedInCount.toString(), + ); + } + + return { + label: info.account.label, + detail, + iconPath: new vscode.ThemeIcon('account'), + account: info.account, + }; + }); // Handle empty accounts case if (accountItems.length === 0) { @@ -48,7 +78,7 @@ export class SelectAccountStep extends AzureWizardPromptStep { + ): Promise { try { // Get all tenants which include the accounts const tenants = await context.azureSubscriptionProvider.getTenants(); - // Extract unique accounts from tenants - const accounts = tenants.map((tenant) => tenant.account); - const uniqueAccounts = accounts.filter( - (account, index, self) => index === self.findIndex((a) => a.id === account.id), + // Check sign-in status for all tenants in parallel + const knownTenantsWithStatus: TenantWithSignInStatus[] = await Promise.all( + tenants.map(async (tenant) => { + const tenantId = nonNullProp(tenant, 'tenantId', 'tenant.tenantId', 'SelectAccountStep.ts'); + const isSignedIn = await context.azureSubscriptionProvider.isSignedIn(tenantId, tenant.account); + return { tenant, isSignedIn }; + }), ); - return uniqueAccounts.sort((a, b) => a.label.localeCompare(b.label)); + // Group tenants by account + const accountMap = new Map(); + + for (const tenantWithStatus of knownTenantsWithStatus) { + const accountId = tenantWithStatus.tenant.account.id; + if (!accountMap.has(accountId)) { + accountMap.set(accountId, { + account: tenantWithStatus.tenant.account, + tenantsWithStatus: [], + }); + } + const info = accountMap.get(accountId)!; + info.tenantsWithStatus.push(tenantWithStatus); + } + + return Array.from(accountMap.values()).sort((a, b) => a.account.label.localeCompare(b.account.label)); } catch (error) { - ext.outputChannel.appendLine( + ext.outputChannel.error( l10n.t( 'Failed to retrieve Azure accounts: {0}', error instanceof Error ? error.message : String(error), @@ -132,16 +180,16 @@ export class SelectAccountStep extends AzureWizardPromptStep { try { - ext.outputChannel.appendLine(l10n.t('Starting Azure sign-in process…')); + ext.outputChannel.info(l10n.t('Starting Azure sign-in process…')); const success = await context.azureSubscriptionProvider.signIn(); if (success) { - ext.outputChannel.appendLine(l10n.t('Azure sign-in completed successfully')); + ext.outputChannel.info(l10n.t('Azure sign-in completed successfully')); } else { - ext.outputChannel.appendLine(l10n.t('Azure sign-in was cancelled or failed')); + ext.outputChannel.warn(l10n.t('Azure sign-in was cancelled or failed')); } } catch (error) { const errorMessage = error instanceof Error ? error.message : String(error); - ext.outputChannel.appendLine(l10n.t('Azure sign-in failed: {0}', errorMessage)); + ext.outputChannel.error(l10n.t('Azure sign-in failed: {0}', errorMessage)); throw error; } } diff --git a/src/plugins/api-shared/azure/credentialsManagement/AccountActionsStep.ts b/src/plugins/api-shared/azure/credentialsManagement/TenantActionStep.ts similarity index 50% rename from src/plugins/api-shared/azure/credentialsManagement/AccountActionsStep.ts rename to src/plugins/api-shared/azure/credentialsManagement/TenantActionStep.ts index d78ab4622..774c49d91 100644 --- a/src/plugins/api-shared/azure/credentialsManagement/AccountActionsStep.ts +++ b/src/plugins/api-shared/azure/credentialsManagement/TenantActionStep.ts @@ -6,62 +6,57 @@ import { AzureWizardPromptStep, GoBackError, UserCancelledError } from '@microsoft/vscode-azext-utils'; import * as l10n from '@vscode/l10n'; import * as vscode from 'vscode'; -import { nonNullValue } from '../../../../utils/nonNull'; +import { nonNullProp, nonNullValue } from '../../../../utils/nonNull'; import { type CredentialsManagementWizardContext } from './CredentialsManagementWizardContext'; -interface AccountActionQuickPickItem extends vscode.QuickPickItem { +interface TenantActionQuickPickItem extends vscode.QuickPickItem { action?: 'back' | 'exit'; } -export class AccountActionsStep extends AzureWizardPromptStep { +/** + * This step is shown when a user selects a tenant that is already signed in. + * It provides navigation options (back/exit) since there's no action to take. + */ +export class TenantActionStep extends AzureWizardPromptStep { public async prompt(context: CredentialsManagementWizardContext): Promise { - const selectedAccount = nonNullValue( - context.selectedAccount, - 'context.selectedAccount', - 'AccountActionsStep.ts', - ); + const selectedTenant = nonNullValue(context.selectedTenant, 'context.selectedTenant', 'TenantActionStep.ts'); + const tenantId = nonNullProp(selectedTenant, 'tenantId', 'selectedTenant.tenantId', 'TenantActionStep.ts'); + const tenantName = selectedTenant.displayName ?? tenantId; - // Create action items for the selected account - const actionItems: AccountActionQuickPickItem[] = [ + // Tenant is already signed in - show info and allow navigation + const actionItems: TenantActionQuickPickItem[] = [ { - label: l10n.t('Back to account selection'), - detail: l10n.t('Return to the account list'), + label: l10n.t('Back to tenant selection'), + detail: l10n.t('You are already signed in to tenant "{0}"', tenantName), iconPath: new vscode.ThemeIcon('arrow-left'), action: 'back', }, { label: '', kind: vscode.QuickPickItemKind.Separator }, { label: l10n.t('Exit'), - detail: l10n.t('Close the account management wizard'), iconPath: new vscode.ThemeIcon('close'), action: 'exit', }, ]; const selectedAction = await context.ui.showQuickPick(actionItems, { - stepName: 'accountActions', - placeHolder: l10n.t('{0} is currently being used for Azure service discovery', selectedAccount.label), + stepName: 'tenantAction', + placeHolder: l10n.t('Signed in to tenant "{0}"', tenantName), suppressPersistence: true, }); - // Handle the selected action if (selectedAction.action === 'back') { - // Clear the selected account to go back to selection - context.selectedAccount = undefined; - context.telemetry.properties.accountAction = 'back'; - - // Use GoBackError to navigate back to the previous step + context.telemetry.properties.tenantSignInAction = 'back'; + context.selectedTenant = undefined; throw new GoBackError(); - } else if (selectedAction.action === 'exit') { - context.telemetry.properties.accountAction = 'exit'; - - // User chose to exit - throw UserCancelledError to gracefully exit wizard + } else { + context.telemetry.properties.tenantSignInAction = 'exit'; throw new UserCancelledError('exitAccountManagement'); } } public shouldPrompt(context: CredentialsManagementWizardContext): boolean { - // Only show this step if we have a selected account - return !!context.selectedAccount; + // Only show this step if we have a selected tenant (which means it's signed in) + return !!context.selectedTenant; } } diff --git a/src/plugins/api-shared/azure/credentialsManagement/configureAzureCredentials.ts b/src/plugins/api-shared/azure/credentialsManagement/configureAzureCredentials.ts index 6202eff29..2de1ad6e4 100644 --- a/src/plugins/api-shared/azure/credentialsManagement/configureAzureCredentials.ts +++ b/src/plugins/api-shared/azure/credentialsManagement/configureAzureCredentials.ts @@ -13,10 +13,11 @@ import * as l10n from '@vscode/l10n'; import { ext } from '../../../../extensionVariables'; import { isTreeElementWithContextValue } from '../../../../tree/TreeElementWithContextValue'; import { type AzureSubscriptionProviderWithFilters } from '../AzureSubscriptionProviderWithFilters'; -import { AccountActionsStep } from './AccountActionsStep'; +import { AccountTenantsStep } from './AccountTenantsStep'; import { type CredentialsManagementWizardContext } from './CredentialsManagementWizardContext'; import { ExecuteStep } from './ExecuteStep'; import { SelectAccountStep } from './SelectAccountStep'; +import { TenantActionStep } from './TenantActionStep'; /** * Internal implementation of Azure account management. @@ -32,16 +33,19 @@ async function configureAzureCredentialsInternal( ext.outputChannel.appendLine(l10n.t('Starting Azure account management wizard')); // Create wizard context + // Note: allAccountsWithTenantInfo is initialized with [] so it exists in propertiesBeforePrompt + // (AzureWizard filters out null/undefined values) and survives back navigation const wizardContext: CredentialsManagementWizardContext = { ...context, selectedAccount: undefined, + allAccountsWithTenantInfo: [], azureSubscriptionProvider, }; // Create and configure the wizard const wizard = new AzureWizard(wizardContext, { title: l10n.t('Manage Azure Accounts'), - promptSteps: [new SelectAccountStep(), new AccountActionsStep()], + promptSteps: [new SelectAccountStep(), new AccountTenantsStep(), new TenantActionStep()], executeSteps: [new ExecuteStep()], }); diff --git a/src/plugins/api-shared/azure/credentialsManagement/index.ts b/src/plugins/api-shared/azure/credentialsManagement/index.ts index 862b190e2..b93dfa4d7 100644 --- a/src/plugins/api-shared/azure/credentialsManagement/index.ts +++ b/src/plugins/api-shared/azure/credentialsManagement/index.ts @@ -3,8 +3,9 @@ * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ -export { AccountActionsStep } from './AccountActionsStep'; +export { AccountTenantsStep } from './AccountTenantsStep'; export { configureAzureCredentials } from './configureAzureCredentials'; export type { CredentialsManagementWizardContext } from './CredentialsManagementWizardContext'; export { ExecuteStep } from './ExecuteStep'; export { SelectAccountStep } from './SelectAccountStep'; +export { TenantActionStep } from './TenantActionStep'; diff --git a/src/plugins/api-shared/azure/subscriptionFiltering/ExecuteStep.ts b/src/plugins/api-shared/azure/subscriptionFiltering/ExecuteStep.ts index c6779959e..7e91b8e62 100644 --- a/src/plugins/api-shared/azure/subscriptionFiltering/ExecuteStep.ts +++ b/src/plugins/api-shared/azure/subscriptionFiltering/ExecuteStep.ts @@ -54,13 +54,29 @@ export class ExecuteStep extends AzureWizardExecuteStep } } - const selectedTenantIds = new Set(selectedTenants.map((tenant) => tenant.tenantId || '')); - // Add telemetry for tenant filtering context.telemetry.measurements.tenantFilteringCount = allTenants.length; context.telemetry.measurements.selectedFinalTenantsCount = selectedTenants.length; context.telemetry.properties.filteringActionType = 'tenantFiltering'; + // If no tenants selected, clear all tenant filtering (show all tenants) + // This is analogous to subscription filtering where empty selection means "no filter" + if (selectedTenants.length === 0) { + for (const accountId of accountIds) { + for (const tenant of allTenants) { + const tenantId = tenant.tenantId || ''; + await removeUnselectedTenant(tenantId, accountId); + } + } + + ext.outputChannel.appendLine( + l10n.t('No tenants selected. Tenant filtering disabled (all tenants will be shown).'), + ); + return; + } + + const selectedTenantIds = new Set(selectedTenants.map((tenant) => tenant.tenantId || '')); + // Apply tenant filtering for each account for (const accountId of accountIds) { // Process each tenant - add to unselected if not selected, remove from unselected if selected @@ -80,16 +96,10 @@ export class ExecuteStep extends AzureWizardExecuteStep l10n.t('Successfully configured tenant filtering. Selected {0} tenant(s)', selectedTenants.length), ); - if (selectedTenants.length > 0) { - const tenantNames = selectedTenants.map( - (tenant) => tenant.displayName || tenant.tenantId || l10n.t('Unknown tenant'), - ); - ext.outputChannel.appendLine(l10n.t('Selected tenants: {0}', tenantNames.join(', '))); - } else { - ext.outputChannel.appendLine( - l10n.t('No tenants selected. Azure discovery will be filtered to exclude all tenant results.'), - ); - } + const tenantNames = selectedTenants.map( + (tenant) => tenant.displayName || tenant.tenantId || l10n.t('Unknown tenant'), + ); + ext.outputChannel.appendLine(l10n.t('Selected tenants: {0}', tenantNames.join(', '))); } private async applySubscriptionFiltering(context: FilteringWizardContext): Promise { diff --git a/src/plugins/api-shared/azure/subscriptionFiltering/FilterTenantSubStep.ts b/src/plugins/api-shared/azure/subscriptionFiltering/FilterTenantSubStep.ts index ccf644cce..faacb854f 100644 --- a/src/plugins/api-shared/azure/subscriptionFiltering/FilterTenantSubStep.ts +++ b/src/plugins/api-shared/azure/subscriptionFiltering/FilterTenantSubStep.ts @@ -16,6 +16,7 @@ interface TenantQuickPickItem extends vscode.QuickPickItem { export class FilterTenantSubStep extends AzureWizardPromptStep { public async prompt(context: FilteringWizardContext): Promise { + // availableTenants only contains authenticated tenants (filtered in InitializeFilteringStep) const tenants = context.availableTenants || []; // Add telemetry for tenant filtering @@ -23,7 +24,9 @@ export class FilterTenantSubStep extends AzureWizardPromptStep { - // Sort by display name if available, otherwise by tenant ID - const aName = a.displayName || a.tenantId || ''; - const bName = b.displayName || b.tenantId || ''; - return aName.localeCompare(bName); - }); + const allTenants = await azureSubscriptionProvider.getTenants(); + + // Filter to only show authenticated tenants + // Check sign-in status for all tenants in parallel + const tenantsWithSignInStatus = await Promise.all( + allTenants.map(async (tenant) => { + if (!tenant.tenantId) { + return { tenant, isSignedIn: false }; + } + const isSignedIn = await azureSubscriptionProvider.isSignedIn(tenant.tenantId, tenant.account); + return { tenant, isSignedIn }; + }), + ); + + // Only include authenticated tenants in the available list + context.availableTenants = tenantsWithSignInStatus + .filter(({ isSignedIn }) => isSignedIn) + .map(({ tenant }) => tenant) + .sort((a, b) => { + // Sort by display name if available, otherwise by tenant ID + const aName = a.displayName || a.tenantId || ''; + const bName = b.displayName || b.tenantId || ''; + return aName.localeCompare(bName); + }); context.telemetry.measurements.tenantLoadTimeMs = Date.now() - tenantLoadStartTime; - context.telemetry.measurements.tenantsCount = context.availableTenants.length; + context.telemetry.measurements.tenantsCount = allTenants.length; + context.telemetry.measurements.authenticatedTenantsCount = context.availableTenants.length; const subscriptionLoadStartTime = Date.now(); context.allSubscriptions = await azureSubscriptionProvider.getSubscriptions(false); context.telemetry.measurements.subscriptionLoadTimeMs = Date.now() - subscriptionLoadStartTime; context.telemetry.measurements.allSubscriptionsCount = context.allSubscriptions.length; - // Check if there are any tenant-filtered subscriptions available - const filteredSubscriptions = getTenantFilteredSubscriptions(context.allSubscriptions); - if (!filteredSubscriptions || filteredSubscriptions.length === 0) { - // Show modal dialog for empty state - const configureResult = await askToConfigureCredentials(); + // Check if there are any subscriptions available at all (before filtering) + // Only show the credentials dialog if there are truly no subscriptions + // If subscriptions exist but are filtered out, proceed with the wizard to let user adjust filters + if (!context.allSubscriptions || context.allSubscriptions.length === 0) { + // No subscriptions at all - user needs to sign in or configure accounts + // Don't show filter option since we're already in the filtering wizard + const configureResult = await askToConfigureCredentials({ showFilterOption: false }); if (configureResult === 'configure') { await this.configureCredentialsFromWizard(context, azureSubscriptionProvider); throw new UserCancelledError('User chose to configure Azure credentials'); } - // User chose not to configure - also cancel the wizard since there's nothing to filter + // User chose not to configure - cancel the wizard since there's nothing to filter throw new UserCancelledError('No subscriptions available for filtering'); } diff --git a/src/plugins/api-shared/azure/subscriptionFiltering/configureAzureSubscriptionFilter.ts b/src/plugins/api-shared/azure/subscriptionFiltering/configureAzureSubscriptionFilter.ts index f9938ab16..ab18cc72b 100644 --- a/src/plugins/api-shared/azure/subscriptionFiltering/configureAzureSubscriptionFilter.ts +++ b/src/plugins/api-shared/azure/subscriptionFiltering/configureAzureSubscriptionFilter.ts @@ -11,6 +11,7 @@ import { type IActionContext, } from '@microsoft/vscode-azext-utils'; import * as l10n from '@vscode/l10n'; +import { ext } from '../../../../extensionVariables'; import { ExecuteStep } from './ExecuteStep'; import { type FilteringWizardContext } from './FilteringWizardContext'; import { InitializeFilteringStep } from './InitializeFilteringStep'; @@ -81,6 +82,8 @@ export async function configureAzureSubscriptionFilter< context.telemetry.properties.subscriptionFilteringResult = 'Failed'; context.telemetry.properties.subscriptionFilteringError = error instanceof Error ? error.message : String(error); - throw error; + ext.outputChannel.error( + `Error during subscription filtering: ${error instanceof Error ? error.message : String(error)}`, + ); } } diff --git a/src/plugins/api-shared/azure/subscriptionFiltering/subscriptionFilteringHelpers.ts b/src/plugins/api-shared/azure/subscriptionFiltering/subscriptionFilteringHelpers.ts index 05470d8b2..e19a8c4f3 100644 --- a/src/plugins/api-shared/azure/subscriptionFiltering/subscriptionFilteringHelpers.ts +++ b/src/plugins/api-shared/azure/subscriptionFiltering/subscriptionFilteringHelpers.ts @@ -101,12 +101,7 @@ export function isTenantFilteredOut(tenantId: string, accountId: string): boolea * @returns Filtered subscriptions from selected tenants only */ export function getTenantFilteredSubscriptions(subscriptions: AzureSubscription[]): AzureSubscription[] { - const filteredSubscriptions = subscriptions.filter( - (subscription) => !isTenantFilteredOut(subscription.tenantId, subscription.account.id), - ); - - // If filtering would result in an empty list, return all subscriptions as a fallback - return filteredSubscriptions.length > 0 ? filteredSubscriptions : subscriptions; + return subscriptions.filter((subscription) => !isTenantFilteredOut(subscription.tenantId, subscription.account.id)); } /** diff --git a/src/plugins/api-shared/azure/wizard/SelectSubscriptionStep.ts b/src/plugins/api-shared/azure/wizard/SelectSubscriptionStep.ts index 60f259e00..49beabd5e 100644 --- a/src/plugins/api-shared/azure/wizard/SelectSubscriptionStep.ts +++ b/src/plugins/api-shared/azure/wizard/SelectSubscriptionStep.ts @@ -68,13 +68,24 @@ export class SelectSubscriptionStep extends AzureWizardPromptStep { + // Add telemetry for filter configuration activation + context.telemetry.properties.filterConfigActivated = 'true'; + context.telemetry.properties.nodeProvided = 'false'; + context.telemetry.properties.initiatedFrom = 'newConnectionWizard'; + if (context.discoveryProviderId) { + context.telemetry.properties.discoveryProviderId = context.discoveryProviderId; + } + + // Call the subscription filter configuration directly using the subscription provider from context + const { configureAzureSubscriptionFilter } = await import('../subscriptionFiltering'); + await configureAzureSubscriptionFilter(context, subscriptionProvider); + } + private async showRetryInstructions(): Promise { await window.showInformationMessage( l10n.t('Account Management Completed'), diff --git a/src/plugins/service-azure-mongo-ru/AzureMongoRUDiscoveryProvider.ts b/src/plugins/service-azure-mongo-ru/AzureMongoRUDiscoveryProvider.ts index f785cd7a8..aed9fabe1 100644 --- a/src/plugins/service-azure-mongo-ru/AzureMongoRUDiscoveryProvider.ts +++ b/src/plugins/service-azure-mongo-ru/AzureMongoRUDiscoveryProvider.ts @@ -4,7 +4,7 @@ *--------------------------------------------------------------------------------------------*/ import { type IActionContext, type IWizardOptions } from '@microsoft/vscode-azext-utils'; -import { Disposable, l10n, ThemeIcon } from 'vscode'; +import { Disposable } from 'vscode'; import { type NewConnectionWizardContext } from '../../commands/newConnection/NewConnectionWizardContext'; import { ext } from '../../extensionVariables'; import { type DiscoveryProvider } from '../../services/discoveryServices'; @@ -13,15 +13,16 @@ import { AzureSubscriptionProviderWithFilters } from '../api-shared/azure/AzureS import { configureAzureSubscriptionFilter } from '../api-shared/azure/subscriptionFiltering/configureAzureSubscriptionFilter'; import { AzureContextProperties } from '../api-shared/azure/wizard/AzureContextProperties'; import { SelectSubscriptionStep } from '../api-shared/azure/wizard/SelectSubscriptionStep'; +import { DESCRIPTION, DISCOVERY_PROVIDER_ID, ICON_PATH, LABEL, WIZARD_TITLE } from './config'; import { AzureMongoRUServiceRootItem } from './discovery-tree/AzureMongoRUServiceRootItem'; import { AzureMongoRUExecuteStep } from './discovery-wizard/AzureMongoRUExecuteStep'; import { SelectRUClusterStep } from './discovery-wizard/SelectRUClusterStep'; export class AzureMongoRUDiscoveryProvider extends Disposable implements DiscoveryProvider { - id = 'azure-mongo-ru-discovery'; - label = l10n.t('Azure Cosmos DB for MongoDB (RU)'); - description = l10n.t('Azure Service Discovery for MongoDB RU'); - iconPath = new ThemeIcon('azure'); + id = DISCOVERY_PROVIDER_ID; + label = LABEL; + description = DESCRIPTION; + iconPath = ICON_PATH; azureSubscriptionProvider: AzureSubscriptionProviderWithFilters; @@ -41,7 +42,7 @@ export class AzureMongoRUDiscoveryProvider extends Disposable implements Discove context.properties[AzureContextProperties.AzureSubscriptionProvider] = this.azureSubscriptionProvider; return { - title: l10n.t('Azure Service Discovery'), + title: WIZARD_TITLE, promptSteps: [new SelectSubscriptionStep(), new SelectRUClusterStep()], executeSteps: [new AzureMongoRUExecuteStep()], showLoadingPrompt: true, @@ -62,7 +63,7 @@ export class AzureMongoRUDiscoveryProvider extends Disposable implements Discove async configureCredentials(context: IActionContext, node?: TreeElement): Promise { // Add telemetry for credential configuration activation context.telemetry.properties.credentialConfigActivated = 'true'; - context.telemetry.properties.discoveryProviderId = this.id; + context.telemetry.properties.discoveryProviderId = DISCOVERY_PROVIDER_ID; context.telemetry.properties.nodeProvided = node ? 'true' : 'false'; if (!node || node instanceof AzureMongoRUServiceRootItem) { diff --git a/src/plugins/service-azure-mongo-ru/config.ts b/src/plugins/service-azure-mongo-ru/config.ts new file mode 100644 index 000000000..0f2c9eb19 --- /dev/null +++ b/src/plugins/service-azure-mongo-ru/config.ts @@ -0,0 +1,28 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { l10n, ThemeIcon } from 'vscode'; + +/** + * Configuration constants for the Azure Cosmos DB for MongoDB (RU) discovery provider. + */ + +/** Unique identifier for this discovery provider */ +export const DISCOVERY_PROVIDER_ID = 'azure-mongo-ru-discovery'; + +/** Resource type identifier for telemetry */ +export const RESOURCE_TYPE = 'mongoRU'; + +/** Display label for the discovery provider */ +export const LABEL = l10n.t('Azure Cosmos DB for MongoDB (RU)'); + +/** Description shown in the discovery provider list */ +export const DESCRIPTION = l10n.t('Azure Service Discovery for MongoDB RU'); + +/** Icon for the discovery provider */ +export const ICON_PATH = new ThemeIcon('azure'); + +/** Title shown in the discovery wizard */ +export const WIZARD_TITLE = l10n.t('Azure Service Discovery'); diff --git a/src/plugins/service-azure-mongo-ru/discovery-tree/AzureMongoRUServiceRootItem.ts b/src/plugins/service-azure-mongo-ru/discovery-tree/AzureMongoRUServiceRootItem.ts index 78b0352e6..7a38c8884 100644 --- a/src/plugins/service-azure-mongo-ru/discovery-tree/AzureMongoRUServiceRootItem.ts +++ b/src/plugins/service-azure-mongo-ru/discovery-tree/AzureMongoRUServiceRootItem.ts @@ -5,6 +5,7 @@ import { type AzureTenant, type VSCodeAzureSubscriptionProvider } from '@microsoft/vscode-azext-azureauth'; import * as l10n from '@vscode/l10n'; +import { randomUUID } from 'crypto'; import * as vscode from 'vscode'; import { createGenericElementWithContext } from '../../../tree/api/createGenericElementWithContext'; import { type ExtTreeElementBase, type TreeElement } from '../../../tree/TreeElement'; @@ -32,16 +33,24 @@ export class AzureMongoRUServiceRootItem } async getChildren(): Promise { + // Generate a journey correlation ID for funnel telemetry tracking + const journeyCorrelationId = randomUUID(); + const allSubscriptions = await this.azureSubscriptionProvider.getSubscriptions(true); const subscriptions = getTenantFilteredSubscriptions(allSubscriptions); if (!subscriptions || subscriptions.length === 0) { // Show modal dialog for empty state const configureResult = await askToConfigureCredentials(); + // Note to future maintainers: 'void' is important here so that the return below returns the error node. + // Otherwise, the /retry node might be duplicated as we're inside of tree node with a loading state (the node items are being swapped etc.) if (configureResult === 'configure') { - // Note to future maintainers: 'void' is important here so that the return below returns the error node. - // Otherwise, the /retry node might be duplicated as we're inside of tree node with a loading state (the node items are being swapped etc.) void vscode.commands.executeCommand('vscode-documentdb.command.discoveryView.manageCredentials', this); + } else if (configureResult === 'filter') { + void vscode.commands.executeCommand( + 'vscode-documentdb.command.discoveryView.filterProviderContent', + this, + ); } return [ @@ -79,12 +88,16 @@ export class AzureMongoRUServiceRootItem .sort((a, b) => a.name.localeCompare(b.name)) // map to AzureMongoRUSubscriptionItem .map((sub) => { - return new AzureMongoRUSubscriptionItem(this.id, { - subscription: sub, - subscriptionName: sub.name, - subscriptionId: sub.subscriptionId, - tenant: tenantMap.get(sub.tenantId), - }); + return new AzureMongoRUSubscriptionItem( + this.id, + { + subscription: sub, + subscriptionName: sub.name, + subscriptionId: sub.subscriptionId, + tenant: tenantMap.get(sub.tenantId), + }, + journeyCorrelationId, + ); }) ); } diff --git a/src/plugins/service-azure-mongo-ru/discovery-tree/AzureMongoRUSubscriptionItem.ts b/src/plugins/service-azure-mongo-ru/discovery-tree/AzureMongoRUSubscriptionItem.ts index a2268772a..dbc50893d 100644 --- a/src/plugins/service-azure-mongo-ru/discovery-tree/AzureMongoRUSubscriptionItem.ts +++ b/src/plugins/service-azure-mongo-ru/discovery-tree/AzureMongoRUSubscriptionItem.ts @@ -15,6 +15,7 @@ import { type TreeElementWithContextValue } from '../../../tree/TreeElementWithC import { type ClusterModel } from '../../../tree/documentdb/ClusterModel'; import { createCosmosDBManagementClient } from '../../../utils/azureClients'; import { nonNullProp } from '../../../utils/nonNull'; +import { DISCOVERY_PROVIDER_ID } from '../config'; import { MongoRUResourceItem } from './documentdb/MongoRUResourceItem'; export interface AzureSubscriptionModel { @@ -31,6 +32,7 @@ export class AzureMongoRUSubscriptionItem implements TreeElement, TreeElementWit constructor( public readonly parentId: string, public readonly subscription: AzureSubscriptionModel, + private readonly journeyCorrelationId: string, ) { this.id = `${parentId}/${subscription.subscriptionId}`; } @@ -40,7 +42,8 @@ export class AzureMongoRUSubscriptionItem implements TreeElement, TreeElementWit 'azure-mongo-ru-discovery.getChildren', async (context: IActionContext) => { const startTime = Date.now(); - context.telemetry.properties.discoveryProvider = 'azure-mongo-ru-discovery'; + context.telemetry.properties.discoveryProviderId = DISCOVERY_PROVIDER_ID; + context.telemetry.properties.journeyCorrelationId = this.journeyCorrelationId; const managementClient = await createCosmosDBManagementClient(context, this.subscription.subscription); const allAccounts = await uiUtils.listAllIterator(managementClient.databaseAccounts.list()); @@ -61,7 +64,11 @@ export class AzureMongoRUSubscriptionItem implements TreeElement, TreeElementWit dbExperience: CosmosDBMongoRUExperience, } as ClusterModel; - return new MongoRUResourceItem(this.subscription.subscription, clusterInfo); + return new MongoRUResourceItem( + this.journeyCorrelationId, + this.subscription.subscription, + clusterInfo, + ); }); }, ); diff --git a/src/plugins/service-azure-mongo-ru/discovery-tree/documentdb/MongoRUResourceItem.ts b/src/plugins/service-azure-mongo-ru/discovery-tree/documentdb/MongoRUResourceItem.ts index 359058014..098be5b20 100644 --- a/src/plugins/service-azure-mongo-ru/discovery-tree/documentdb/MongoRUResourceItem.ts +++ b/src/plugins/service-azure-mongo-ru/discovery-tree/documentdb/MongoRUResourceItem.ts @@ -13,6 +13,7 @@ import { Views } from '../../../../documentdb/Views'; import { ext } from '../../../../extensionVariables'; import { ClusterItemBase, type EphemeralClusterCredentials } from '../../../../tree/documentdb/ClusterItemBase'; import { type ClusterModel } from '../../../../tree/documentdb/ClusterModel'; +import { DISCOVERY_PROVIDER_ID, RESOURCE_TYPE } from '../../config'; import { extractCredentialsFromRUAccount } from '../../utils/ruClusterHelpers'; export class MongoRUResourceItem extends ClusterItemBase { @@ -28,17 +29,26 @@ export class MongoRUResourceItem extends ClusterItemBase { ); constructor( + /** + * Correlation ID for telemetry funnel analysis. + * For statistics only - does not influence functionality. + */ + journeyCorrelationId: string, readonly subscription: AzureSubscription, cluster: ClusterModel, ) { super(cluster); + this.journeyCorrelationId = journeyCorrelationId; } public async getCredentials(): Promise { return callWithTelemetryAndErrorHandling('getCredentials', async (context: IActionContext) => { context.telemetry.properties.view = Views.DiscoveryView; - context.telemetry.properties.discoveryProvider = 'azure-mongo-ru-discovery'; - context.telemetry.properties.resourceType = 'mongoRU'; + context.telemetry.properties.discoveryProviderId = DISCOVERY_PROVIDER_ID; + context.telemetry.properties.resourceType = RESOURCE_TYPE; + if (this.journeyCorrelationId) { + context.telemetry.properties.journeyCorrelationId = this.journeyCorrelationId; + } const credentials = await extractCredentialsFromRUAccount( context, @@ -59,9 +69,12 @@ export class MongoRUResourceItem extends ClusterItemBase { const result = await callWithTelemetryAndErrorHandling('connect', async (context: IActionContext) => { const connectionStartTime = Date.now(); context.telemetry.properties.view = Views.DiscoveryView; - context.telemetry.properties.discoveryProvider = 'azure-mongo-ru-discovery'; + context.telemetry.properties.discoveryProviderId = DISCOVERY_PROVIDER_ID; context.telemetry.properties.connectionInitiatedFrom = 'discoveryView'; - context.telemetry.properties.resourceType = 'mongoRU'; + context.telemetry.properties.resourceType = RESOURCE_TYPE; + if (this.journeyCorrelationId) { + context.telemetry.properties.journeyCorrelationId = this.journeyCorrelationId; + } ext.outputChannel.appendLine( l10n.t('Attempting to authenticate with "{cluster}"…', { diff --git a/src/plugins/service-azure-mongo-ru/discovery-wizard/AzureMongoRUExecuteStep.ts b/src/plugins/service-azure-mongo-ru/discovery-wizard/AzureMongoRUExecuteStep.ts index 13870172d..ba5bc9d32 100644 --- a/src/plugins/service-azure-mongo-ru/discovery-wizard/AzureMongoRUExecuteStep.ts +++ b/src/plugins/service-azure-mongo-ru/discovery-wizard/AzureMongoRUExecuteStep.ts @@ -10,6 +10,7 @@ import { type NewConnectionWizardContext } from '../../../commands/newConnection import { type GenericResource } from '@azure/arm-resources'; import { type AzureSubscription } from '@microsoft/vscode-azext-azureauth'; import { AzureContextProperties } from '../../api-shared/azure/wizard/AzureContextProperties'; +import { DISCOVERY_PROVIDER_ID } from '../config'; import { extractCredentialsFromRUAccount } from '../utils/ruClusterHelpers'; export class AzureMongoRUExecuteStep extends AzureWizardExecuteStep { @@ -23,7 +24,7 @@ export class AzureMongoRUExecuteStep extends AzureWizardExecuteStep { // Add telemetry for credential configuration activation context.telemetry.properties.credentialConfigActivated = 'true'; - context.telemetry.properties.discoveryProviderId = this.id; + context.telemetry.properties.discoveryProviderId = DISCOVERY_PROVIDER_ID; context.telemetry.properties.nodeProvided = node ? 'true' : 'false'; if (!node || node instanceof AzureServiceRootItem) { diff --git a/src/plugins/service-azure-mongo-vcore/config.ts b/src/plugins/service-azure-mongo-vcore/config.ts new file mode 100644 index 000000000..8c3c6b570 --- /dev/null +++ b/src/plugins/service-azure-mongo-vcore/config.ts @@ -0,0 +1,28 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { l10n, ThemeIcon } from 'vscode'; + +/** + * Configuration constants for the Azure Cosmos DB for MongoDB (vCore) discovery provider. + */ + +/** Unique identifier for this discovery provider */ +export const DISCOVERY_PROVIDER_ID = 'azure-mongo-vcore-discovery'; + +/** Resource type identifier for telemetry */ +export const RESOURCE_TYPE = 'mongoVCore'; + +/** Display label for the discovery provider */ +export const LABEL = l10n.t('Azure DocumentDB'); + +/** Description shown in the discovery provider list */ +export const DESCRIPTION = l10n.t('Azure Service Discovery for Azure DocumentDB'); + +/** Icon for the discovery provider */ +export const ICON_PATH = new ThemeIcon('azure'); + +/** Title shown in the discovery wizard */ +export const WIZARD_TITLE = l10n.t('Azure Service Discovery'); diff --git a/src/plugins/service-azure-mongo-vcore/discovery-tree/AzureServiceRootItem.ts b/src/plugins/service-azure-mongo-vcore/discovery-tree/AzureServiceRootItem.ts index 580fa0907..a84bc9af6 100644 --- a/src/plugins/service-azure-mongo-vcore/discovery-tree/AzureServiceRootItem.ts +++ b/src/plugins/service-azure-mongo-vcore/discovery-tree/AzureServiceRootItem.ts @@ -5,6 +5,7 @@ import { type AzureTenant, type VSCodeAzureSubscriptionProvider } from '@microsoft/vscode-azext-azureauth'; import * as l10n from '@vscode/l10n'; +import { randomUUID } from 'crypto'; import * as vscode from 'vscode'; import { createGenericElementWithContext } from '../../../tree/api/createGenericElementWithContext'; import { type ExtTreeElementBase, type TreeElement } from '../../../tree/TreeElement'; @@ -30,16 +31,25 @@ export class AzureServiceRootItem implements TreeElement, TreeElementWithContext } async getChildren(): Promise { + // Generate a new journey correlation ID for telemetry funnel analysis + // This ID is passed to all child items and included in their telemetry events + const journeyCorrelationId = randomUUID(); + const allSubscriptions = await this.azureSubscriptionProvider.getSubscriptions(true); const subscriptions = getTenantFilteredSubscriptions(allSubscriptions); if (!subscriptions || subscriptions.length === 0) { // Show modal dialog for empty state const configureResult = await askToConfigureCredentials(); + // Note to future maintainers: 'void' is important here so that the return below returns the error node. + // Otherwise, the /retry node might be duplicated as we're inside of tree node with a loading state (the node items are being swapped etc.) if (configureResult === 'configure') { - // Note to future maintainers: 'void' is important here so that the return below returns the error node. - // Otherwise, the /retry node might be duplicated as we're inside of tree node with a loading state (the node items are being swapped etc.) void vscode.commands.executeCommand('vscode-documentdb.command.discoveryView.manageCredentials', this); + } else if (configureResult === 'filter') { + void vscode.commands.executeCommand( + 'vscode-documentdb.command.discoveryView.filterProviderContent', + this, + ); } return [ @@ -77,12 +87,16 @@ export class AzureServiceRootItem implements TreeElement, TreeElementWithContext .sort((a, b) => a.name.localeCompare(b.name)) // map to AzureSubscriptionItem .map((sub) => { - return new AzureSubscriptionItem(this.id, { - subscription: sub, - subscriptionName: sub.name, - subscriptionId: sub.subscriptionId, - tenant: tenantMap.get(sub.tenantId), - }); + return new AzureSubscriptionItem( + this.id, + { + subscription: sub, + subscriptionName: sub.name, + subscriptionId: sub.subscriptionId, + tenant: tenantMap.get(sub.tenantId), + }, + journeyCorrelationId, + ); }) ); } diff --git a/src/plugins/service-azure-mongo-vcore/discovery-tree/AzureSubscriptionItem.ts b/src/plugins/service-azure-mongo-vcore/discovery-tree/AzureSubscriptionItem.ts index 1a0bc27d7..e520c027c 100644 --- a/src/plugins/service-azure-mongo-vcore/discovery-tree/AzureSubscriptionItem.ts +++ b/src/plugins/service-azure-mongo-vcore/discovery-tree/AzureSubscriptionItem.ts @@ -15,6 +15,7 @@ import { type TreeElementWithContextValue } from '../../../tree/TreeElementWithC import { type ClusterModel } from '../../../tree/documentdb/ClusterModel'; import { createResourceManagementClient } from '../../../utils/azureClients'; import { nonNullProp } from '../../../utils/nonNull'; +import { DISCOVERY_PROVIDER_ID } from '../config'; import { DocumentDBResourceItem } from './documentdb/DocumentDBResourceItem'; export interface AzureSubscriptionModel { @@ -31,6 +32,7 @@ export class AzureSubscriptionItem implements TreeElement, TreeElementWithContex constructor( public readonly parentId: string, public readonly subscription: AzureSubscriptionModel, + private readonly journeyCorrelationId: string, ) { this.id = `${parentId}/${subscription.subscriptionId}`; } @@ -39,12 +41,20 @@ export class AzureSubscriptionItem implements TreeElement, TreeElementWithContex return await callWithTelemetryAndErrorHandling( 'azure-discovery.getChildren', async (context: IActionContext) => { + const startTime = Date.now(); + context.telemetry.properties.discoveryProviderId = DISCOVERY_PROVIDER_ID; + context.telemetry.properties.journeyCorrelationId = this.journeyCorrelationId; + const client = await createResourceManagementClient(context, this.subscription.subscription); const accounts = await uiUtils.listAllIterator( client.resources.list({ filter: "resourceType eq 'Microsoft.DocumentDB/mongoClusters'" }), ); + // Add enhanced telemetry for discovery + context.telemetry.measurements.discoveryResourcesCount = accounts.length; + context.telemetry.measurements.discoveryLoadTimeMs = Date.now() - startTime; + return accounts .sort((a, b) => (a.name || '').localeCompare(b.name || '')) .map((account) => { @@ -56,7 +66,11 @@ export class AzureSubscriptionItem implements TreeElement, TreeElementWithContex dbExperience: DocumentDBExperience, } as ClusterModel; - return new DocumentDBResourceItem(this.subscription.subscription, clusterInfo); + return new DocumentDBResourceItem( + this.journeyCorrelationId, + this.subscription.subscription, + clusterInfo, + ); }); }, ); diff --git a/src/plugins/service-azure-mongo-vcore/discovery-tree/documentdb/DocumentDBResourceItem.ts b/src/plugins/service-azure-mongo-vcore/discovery-tree/documentdb/DocumentDBResourceItem.ts index 966437126..a788fd39b 100644 --- a/src/plugins/service-azure-mongo-vcore/discovery-tree/documentdb/DocumentDBResourceItem.ts +++ b/src/plugins/service-azure-mongo-vcore/discovery-tree/documentdb/DocumentDBResourceItem.ts @@ -26,23 +26,33 @@ import { ClusterItemBase, type EphemeralClusterCredentials } from '../../../../t import { type ClusterModel } from '../../../../tree/documentdb/ClusterModel'; import { getThemeAgnosticIconPath } from '../../../../utils/icons'; import { nonNullValue } from '../../../../utils/nonNull'; +import { DISCOVERY_PROVIDER_ID, RESOURCE_TYPE } from '../../config'; import { extractCredentialsFromCluster, getClusterInformationFromAzure } from '../../utils/clusterHelpers'; export class DocumentDBResourceItem extends ClusterItemBase { iconPath = getThemeAgnosticIconPath('AzureDocumentDb.svg'); constructor( + /** + * Correlation ID for telemetry funnel analysis. + * For statistics only - does not influence functionality. + */ + journeyCorrelationId: string, readonly subscription: AzureSubscription, cluster: ClusterModel, ) { super(cluster); + this.journeyCorrelationId = journeyCorrelationId; } public async getCredentials(): Promise { return callWithTelemetryAndErrorHandling('getCredentials', async (context: IActionContext) => { context.telemetry.properties.view = Views.DiscoveryView; - context.telemetry.properties.discoveryProvider = 'azure-mongo-vcore-discovery'; - context.telemetry.properties.resourceType = 'mongoVCore'; + context.telemetry.properties.discoveryProviderId = DISCOVERY_PROVIDER_ID; + context.telemetry.properties.resourceType = RESOURCE_TYPE; + if (this.journeyCorrelationId) { + context.telemetry.properties.journeyCorrelationId = this.journeyCorrelationId; + } // Retrieve and validate cluster information (throws if invalid) const clusterInformation = await getClusterInformationFromAzure( @@ -77,9 +87,12 @@ export class DocumentDBResourceItem extends ClusterItemBase { const result = await callWithTelemetryAndErrorHandling('connect', async (context: IActionContext) => { const connectionStartTime = Date.now(); context.telemetry.properties.view = Views.DiscoveryView; - context.telemetry.properties.discoveryProvider = 'azure-mongo-vcore-discovery'; + context.telemetry.properties.discoveryProviderId = DISCOVERY_PROVIDER_ID; context.telemetry.properties.connectionInitiatedFrom = 'discoveryView'; - context.telemetry.properties.resourceType = 'mongoVCore'; + context.telemetry.properties.resourceType = RESOURCE_TYPE; + if (this.journeyCorrelationId) { + context.telemetry.properties.journeyCorrelationId = this.journeyCorrelationId; + } ext.outputChannel.appendLine( l10n.t('Attempting to authenticate with "{cluster}"…', { @@ -200,7 +213,7 @@ export class DocumentDBResourceItem extends ClusterItemBase { // Prompt the user for credentials await callWithTelemetryAndErrorHandling('connect.promptForCredentials', async (context: IActionContext) => { context.telemetry.properties.view = Views.DiscoveryView; - context.telemetry.properties.discoveryProvider = 'azure-mongo-vcore-discovery'; + context.telemetry.properties.discoveryProviderId = DISCOVERY_PROVIDER_ID; context.telemetry.properties.credentialsRequired = 'true'; context.telemetry.properties.credentialPromptReason = 'firstTime'; diff --git a/src/plugins/service-azure-vm/AzureVMDiscoveryProvider.ts b/src/plugins/service-azure-vm/AzureVMDiscoveryProvider.ts index 43e50c2c7..025c3a686 100644 --- a/src/plugins/service-azure-vm/AzureVMDiscoveryProvider.ts +++ b/src/plugins/service-azure-vm/AzureVMDiscoveryProvider.ts @@ -4,13 +4,14 @@ *--------------------------------------------------------------------------------------------*/ import { type IActionContext, type IWizardOptions } from '@microsoft/vscode-azext-utils'; -import { Disposable, l10n, ThemeIcon } from 'vscode'; +import { Disposable } from 'vscode'; import { type NewConnectionWizardContext } from '../../commands/newConnection/NewConnectionWizardContext'; import { ext } from '../../extensionVariables'; import { type DiscoveryProvider } from '../../services/discoveryServices'; import { type TreeElement } from '../../tree/TreeElement'; import { AzureSubscriptionProviderWithFilters } from '../api-shared/azure/AzureSubscriptionProviderWithFilters'; import { SelectSubscriptionStep } from '../api-shared/azure/wizard/SelectSubscriptionStep'; +import { DESCRIPTION, DISCOVERY_PROVIDER_ID, ICON_PATH, LABEL, WIZARD_TITLE } from './config'; import { AzureServiceRootItem } from './discovery-tree/AzureServiceRootItem'; import { configureVmFilter } from './discovery-tree/configureVmFilterWizard'; import { AzureVMExecuteStep } from './discovery-wizard/AzureVMExecuteStep'; @@ -28,10 +29,10 @@ export enum AzureVMContextProperties { } export class AzureVMDiscoveryProvider extends Disposable implements DiscoveryProvider { - id = 'azure-vm-discovery'; - label = l10n.t('Azure VMs (DocumentDB)'); - description = l10n.t('Azure VM Service Discovery'); - iconPath = new ThemeIcon('vm'); // Using a generic VM icon + id = DISCOVERY_PROVIDER_ID; + label = LABEL; + description = DESCRIPTION; + iconPath = ICON_PATH; azureSubscriptionProvider: AzureSubscriptionProviderWithFilters; @@ -53,7 +54,7 @@ export class AzureVMDiscoveryProvider extends Disposable implements DiscoveryPro context.properties[AzureVMContextProperties.AzureSubscriptionProvider] = this.azureSubscriptionProvider; return { - title: l10n.t('Azure VM Service Discovery'), + title: WIZARD_TITLE, promptSteps: [new SelectSubscriptionStep(), new SelectTagStep(), new SelectVMStep(), new SelectPortStep()], executeSteps: [new AzureVMExecuteStep()], showLoadingPrompt: true, @@ -74,7 +75,7 @@ export class AzureVMDiscoveryProvider extends Disposable implements DiscoveryPro async configureCredentials(context: IActionContext, node?: TreeElement): Promise { // Add telemetry for credential configuration activation context.telemetry.properties.credentialConfigActivated = 'true'; - context.telemetry.properties.discoveryProviderId = this.id; + context.telemetry.properties.discoveryProviderId = DISCOVERY_PROVIDER_ID; context.telemetry.properties.nodeProvided = node ? 'true' : 'false'; if (!node || node instanceof AzureServiceRootItem) { diff --git a/src/plugins/service-azure-vm/config.ts b/src/plugins/service-azure-vm/config.ts new file mode 100644 index 000000000..eb7945638 --- /dev/null +++ b/src/plugins/service-azure-vm/config.ts @@ -0,0 +1,28 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { l10n, ThemeIcon } from 'vscode'; + +/** + * Configuration constants for the Azure VM discovery provider. + */ + +/** Unique identifier for this discovery provider */ +export const DISCOVERY_PROVIDER_ID = 'azure-vm-discovery'; + +/** Resource type identifier for telemetry */ +export const RESOURCE_TYPE = 'azureVM'; + +/** Display label for the discovery provider */ +export const LABEL = l10n.t('Azure VMs (DocumentDB)'); + +/** Description shown in the discovery provider list */ +export const DESCRIPTION = l10n.t('Azure VM Service Discovery'); + +/** Icon for the discovery provider */ +export const ICON_PATH = new ThemeIcon('vm'); + +/** Title shown in the discovery wizard */ +export const WIZARD_TITLE = l10n.t('Azure VM Service Discovery'); diff --git a/src/plugins/service-azure-vm/discovery-tree/AzureServiceRootItem.ts b/src/plugins/service-azure-vm/discovery-tree/AzureServiceRootItem.ts index c70f7398d..db8aad92b 100644 --- a/src/plugins/service-azure-vm/discovery-tree/AzureServiceRootItem.ts +++ b/src/plugins/service-azure-vm/discovery-tree/AzureServiceRootItem.ts @@ -5,6 +5,7 @@ import { type AzureTenant, type VSCodeAzureSubscriptionProvider } from '@microsoft/vscode-azext-azureauth'; import * as l10n from '@vscode/l10n'; +import { randomUUID } from 'crypto'; import * as vscode from 'vscode'; import { createGenericElementWithContext } from '../../../tree/api/createGenericElementWithContext'; import { type ExtTreeElementBase, type TreeElement } from '../../../tree/TreeElement'; @@ -30,16 +31,24 @@ export class AzureServiceRootItem implements TreeElement, TreeElementWithContext } async getChildren(): Promise { + // Generate a journey correlation ID for funnel telemetry tracking + const journeyCorrelationId = randomUUID(); + const allSubscriptions = await this.azureSubscriptionProvider.getSubscriptions(true); const subscriptions = getTenantFilteredSubscriptions(allSubscriptions); if (!subscriptions || subscriptions.length === 0) { // Show modal dialog for empty state const configureResult = await askToConfigureCredentials(); + // Note to future maintainers: 'void' is important here so that the return below returns the error node. + // Otherwise, the /retry node might be duplicated as we're inside of tree node with a loading state (the node items are being swapped etc.) if (configureResult === 'configure') { - // Note to future maintainers: 'void' is important here so that the return below returns the error node. - // Otherwise, the /retry node might be duplicated as we're inside of tree node with a loading state (the node items are being swapped etc.) void vscode.commands.executeCommand('vscode-documentdb.command.discoveryView.manageCredentials', this); + } else if (configureResult === 'filter') { + void vscode.commands.executeCommand( + 'vscode-documentdb.command.discoveryView.filterProviderContent', + this, + ); } return [ @@ -77,12 +86,16 @@ export class AzureServiceRootItem implements TreeElement, TreeElementWithContext .sort((a, b) => a.name.localeCompare(b.name)) // map to AzureSubscriptionItem .map((sub) => { - return new AzureSubscriptionItem(this.id, { - subscription: sub, - subscriptionName: sub.name, - subscriptionId: sub.subscriptionId, - tenant: tenantMap.get(sub.tenantId), - }); + return new AzureSubscriptionItem( + this.id, + { + subscription: sub, + subscriptionName: sub.name, + subscriptionId: sub.subscriptionId, + tenant: tenantMap.get(sub.tenantId), + }, + journeyCorrelationId, + ); }) ); } diff --git a/src/plugins/service-azure-vm/discovery-tree/AzureSubscriptionItem.ts b/src/plugins/service-azure-vm/discovery-tree/AzureSubscriptionItem.ts index 57a037572..42bb50fdd 100644 --- a/src/plugins/service-azure-vm/discovery-tree/AzureSubscriptionItem.ts +++ b/src/plugins/service-azure-vm/discovery-tree/AzureSubscriptionItem.ts @@ -15,6 +15,7 @@ import { ext } from '../../../extensionVariables'; import { type TreeElement } from '../../../tree/TreeElement'; import { type TreeElementWithContextValue } from '../../../tree/TreeElementWithContextValue'; import { createComputeManagementClient, createNetworkManagementClient } from '../../../utils/azureClients'; +import { DISCOVERY_PROVIDER_ID } from '../config'; import { AzureVMResourceItem, type VirtualMachineModel } from './vm/AzureVMResourceItem'; export interface AzureSubscriptionModel { @@ -31,6 +32,7 @@ export class AzureSubscriptionItem implements TreeElement, TreeElementWithContex constructor( public readonly parentId: string, public readonly subscription: AzureSubscriptionModel, + private readonly journeyCorrelationId: string, ) { this.id = `${parentId}/${subscription.subscriptionId}`; } @@ -39,7 +41,10 @@ export class AzureSubscriptionItem implements TreeElement, TreeElementWithContex return await callWithTelemetryAndErrorHandling( 'azure-vm-discovery.getChildren', async (context: IActionContext) => { + const startTime = Date.now(); + context.telemetry.properties.discoveryProviderId = DISCOVERY_PROVIDER_ID; context.telemetry.properties.view = Views.DiscoveryView; + context.telemetry.properties.journeyCorrelationId = this.journeyCorrelationId; const computeClient = await createComputeManagementClient(context, this.subscription.subscription); // For listing VMs const networkClient = await createNetworkManagementClient(context, this.subscription.subscription); // For fetching IP addresses @@ -109,10 +114,16 @@ export class AzureSubscriptionItem implements TreeElement, TreeElementWithContex fqdn: fqdn, dbExperience: DocumentDBExperience, }; - vmItems.push(new AzureVMResourceItem(this.subscription.subscription, vmInfo)); + vmItems.push( + new AzureVMResourceItem(this.journeyCorrelationId, this.subscription.subscription, vmInfo), + ); } } + // Add enhanced telemetry for discovery + context.telemetry.measurements.discoveryResourcesCount = vmItems.length; + context.telemetry.measurements.discoveryLoadTimeMs = Date.now() - startTime; + return vmItems.sort((a, b) => a.cluster.name.localeCompare(b.cluster.name)); }, ); diff --git a/src/plugins/service-azure-vm/discovery-tree/vm/AzureVMResourceItem.ts b/src/plugins/service-azure-vm/discovery-tree/vm/AzureVMResourceItem.ts index e388c6c0f..15ba40776 100644 --- a/src/plugins/service-azure-vm/discovery-tree/vm/AzureVMResourceItem.ts +++ b/src/plugins/service-azure-vm/discovery-tree/vm/AzureVMResourceItem.ts @@ -25,6 +25,7 @@ import { ext } from '../../../../extensionVariables'; import { ClusterItemBase, type EphemeralClusterCredentials } from '../../../../tree/documentdb/ClusterItemBase'; import { type ClusterModel } from '../../../../tree/documentdb/ClusterModel'; import { nonNullProp, nonNullValue } from '../../../../utils/nonNull'; +import { DISCOVERY_PROVIDER_ID } from '../../config'; // Define a model for VM, similar to ClusterModel but for VM properties export interface VirtualMachineModel extends ClusterModel { @@ -39,11 +40,16 @@ export class AzureVMResourceItem extends ClusterItemBase { iconPath = new vscode.ThemeIcon('server-environment'); constructor( - readonly subscription: AzureSubscription, // Retained from original - readonly cluster: VirtualMachineModel, // Using the new VM model - // connectionInfo: any, // Passed from the wizard execution step, containing vmId, name, connectionStringTemplate + /** + * Correlation ID for telemetry funnel analysis. + * For statistics only - does not influence functionality. + */ + journeyCorrelationId: string, + readonly subscription: AzureSubscription, + readonly cluster: VirtualMachineModel, ) { - super(cluster); // label, id + super(cluster); + this.journeyCorrelationId = journeyCorrelationId; // Construct tooltip and description const tooltipParts: string[] = [`**Name:** ${cluster.name}`, `**ID:** ${cluster.id}`]; @@ -67,8 +73,11 @@ export class AzureVMResourceItem extends ClusterItemBase { public async getCredentials(): Promise { return callWithTelemetryAndErrorHandling('connect', async (context: IActionContext) => { - context.telemetry.properties.discoveryProvider = 'azure-vm-discovery'; + context.telemetry.properties.discoveryProviderId = DISCOVERY_PROVIDER_ID; context.telemetry.properties.view = Views.DiscoveryView; + if (this.journeyCorrelationId) { + context.telemetry.properties.journeyCorrelationId = this.journeyCorrelationId; + } const newPort = await context.ui.showInputBox({ prompt: l10n.t('Enter the port number your DocumentDB uses. The default port: {defaultPort}.', { @@ -158,8 +167,11 @@ export class AzureVMResourceItem extends ClusterItemBase { */ protected async authenticateAndConnect(): Promise { const result = await callWithTelemetryAndErrorHandling('connect', async (context: IActionContext) => { - context.telemetry.properties.discoveryProvider = 'azure-vm-discovery'; + context.telemetry.properties.discoveryProviderId = DISCOVERY_PROVIDER_ID; context.telemetry.properties.view = Views.DiscoveryView; + if (this.journeyCorrelationId) { + context.telemetry.properties.journeyCorrelationId = this.journeyCorrelationId; + } ext.outputChannel.appendLine( l10n.t('Azure VM: Attempting to authenticate with "{vmName}"…', { @@ -241,6 +253,7 @@ export class AzureVMResourceItem extends ClusterItemBase { username: wizardContext.selectedUserName ?? '', }), ); + return clustersClient; }); return result ?? null; diff --git a/src/tree/documentdb/ClusterItemBase.ts b/src/tree/documentdb/ClusterItemBase.ts index 25506109b..6af4468fe 100644 --- a/src/tree/documentdb/ClusterItemBase.ts +++ b/src/tree/documentdb/ClusterItemBase.ts @@ -56,6 +56,13 @@ export abstract class ClusterItemBase public readonly experience: Experience; public contextValue: string = 'treeItem_documentdbcluster'; + /** + * Correlation ID used for telemetry funnel analysis. + * This is for statistics only and does not influence functionality. + * It tracks the user's journey through the discovery flow. + */ + public journeyCorrelationId?: string; + protected descriptionOverride?: string; protected tooltipOverride?: string | vscode.MarkdownString; diff --git a/src/utils/commandTelemetry.ts b/src/utils/commandTelemetry.ts new file mode 100644 index 000000000..7a2d34398 --- /dev/null +++ b/src/utils/commandTelemetry.ts @@ -0,0 +1,49 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { type CommandCallback, type IActionContext, type TreeNodeCommandCallback } from '@microsoft/vscode-azext-utils'; + +interface HasJourneyCorrelationId { + journeyCorrelationId?: string; +} + +function tryExtractJourneyCorrelationId(maybeNode: unknown): string | undefined { + if (maybeNode && typeof maybeNode === 'object') { + const value = (maybeNode as HasJourneyCorrelationId).journeyCorrelationId; + if (value) { + return value; + } + } + return undefined; +} + +export function trackJourneyCorrelationId(context: IActionContext, ...args: unknown[]): void { + for (const arg of args) { + const correlationId = tryExtractJourneyCorrelationId(arg); + if (correlationId) { + context.telemetry.properties.journeyCorrelationId = correlationId; + return; + } + } +} + +export function withCommandCorrelation(callback: T): T { + const wrapper = (context: IActionContext, ...args: unknown[]) => { + trackJourneyCorrelationId(context, ...args); + // CommandCallback returns 'any', which we pass through unchanged, the exception below is required. + // eslint-disable-next-line @typescript-eslint/no-unsafe-return + return callback(context, ...args); + }; + return wrapper as T; +} + +export function withTreeNodeCommandCorrelation>(callback: T): T { + const wrapper = (context: IActionContext, ...args: unknown[]) => { + trackJourneyCorrelationId(context, ...args); + // TreeNodeCommandCallback returns 'unknown', which we pass through unchanged, no need or eslint-exception here. + return callback(context, ...args); + }; + return wrapper as T; +}