diff --git a/src/AzureDBExperiences.ts b/src/AzureDBExperiences.ts index 454e2bdf..eed43a81 100644 --- a/src/AzureDBExperiences.ts +++ b/src/AzureDBExperiences.ts @@ -15,6 +15,7 @@ export enum API { Core = 'Core', // Now called NoSQL PostgresSingle = 'PostgresSingle', PostgresFlexible = 'PostgresFlexible', + Common = 'Common', // In case we're reporting a common event and still need to provide the value of the API } export enum DBAccountKind { diff --git a/src/commands/importDocuments.ts b/src/commands/importDocuments.ts index baa18f8a..94fd9395 100644 --- a/src/commands/importDocuments.ts +++ b/src/commands/importDocuments.ts @@ -4,14 +4,16 @@ *--------------------------------------------------------------------------------------------*/ import { type ItemDefinition } from '@azure/cosmos'; -import { parseError, type IActionContext } from '@microsoft/vscode-azext-utils'; +import { callWithTelemetryAndErrorHandling, parseError, type IActionContext } from '@microsoft/vscode-azext-utils'; import { EJSON } from 'bson'; import * as fse from 'fs-extra'; +import { type InsertManyResult } from 'mongodb'; import * as vscode from 'vscode'; import { cosmosMongoFilter, sqlFilter } from '../constants'; import { DocDBCollectionTreeItem } from '../docdb/tree/DocDBCollectionTreeItem'; import { ext } from '../extensionVariables'; import { MongoCollectionTreeItem } from '../mongo/tree/MongoCollectionTreeItem'; +import { type InsertDocumentsResult } from '../mongoClusters/MongoClustersClient'; import { CollectionItem } from '../mongoClusters/tree/CollectionItem'; import { nonNullProp, nonNullValue } from '../utils/nonNull'; import { getRootPath } from '../utils/workspacUtils'; @@ -191,9 +193,16 @@ async function insertDocumentsIntoDocdb( // eslint-disable-next-line @typescript-eslint/no-explicit-any async function insertDocumentsIntoMongo(node: MongoCollectionTreeItem, documents: any[]): Promise { let output = ''; - // eslint-disable-next-line @typescript-eslint/no-unsafe-argument - const parsed = await node.collection.insertMany(documents); - if (parsed.acknowledged) { + + let parsed: InsertManyResult | undefined; + await callWithTelemetryAndErrorHandling('cosmosDB.mongo.importDocumets', async (actionContext) => { + // eslint-disable-next-line @typescript-eslint/no-unsafe-argument + parsed = await node.collection.insertMany(documents); + + actionContext.telemetry.measurements.documentCount = parsed?.insertedCount; + }); + + if (parsed?.acknowledged) { output = `Import into mongo successful. Inserted ${parsed.insertedCount} document(s). See output for more details.`; for (const inserted of Object.values(parsed.insertedIds)) { ext.outputChannel.appendLog(`Inserted document: ${inserted}`); @@ -207,10 +216,15 @@ async function insertDocumentsIntoMongoCluster( node: CollectionItem, documents: unknown[], ): Promise { - const result = await node.insertDocuments(context, documents as Document[]); + let result: InsertDocumentsResult | undefined; + await callWithTelemetryAndErrorHandling('cosmosDB.mongoClusters.importDocumets', async (actionContext) => { + result = await node.insertDocuments(context, documents as Document[]); + + actionContext.telemetry.measurements.documentCount = result?.insertedCount; + }); let message: string; - if (result.acknowledged) { + if (result?.acknowledged) { message = `Import successful. Inserted ${result.insertedCount} document(s).`; } else { message = `Import failed. The operation was not acknowledged by the database.`; diff --git a/src/docdb/tree/DocDBAccountTreeItemBase.ts b/src/docdb/tree/DocDBAccountTreeItemBase.ts index 3c80426c..716da8e4 100644 --- a/src/docdb/tree/DocDBAccountTreeItemBase.ts +++ b/src/docdb/tree/DocDBAccountTreeItemBase.ts @@ -104,7 +104,7 @@ export abstract class DocDBAccountTreeItemBase extends DocDBTreeItemBase => { - context.telemetry.properties.parentContext = this.contextValue; + context.telemetry.properties.parentNodeContext = this.contextValue; // move this to a shared file, currently it's defined in DocDBAccountTreeItem so I can't reference it here if (this.contextValue.includes('cosmosDBDocumentServer')) { diff --git a/src/mongo/connectToMongoClient.ts b/src/mongo/connectToMongoClient.ts index b33d266d..70227f97 100644 --- a/src/mongo/connectToMongoClient.ts +++ b/src/mongo/connectToMongoClient.ts @@ -10,7 +10,7 @@ export async function connectToMongoClient(connectionString: string, appName: st // appname appears to be the correct equivalent to user-agent for mongo const options: MongoClientOptions = { // appName should be wrapped in '@'s when trying to connect to a Mongo account, this doesn't effect the appendUserAgent string - appName: `@${appName}@`, + appName: `${appName}[RU]`, // https://github.com/lmammino/mongo-uri-builder/issues/2 useNewUrlParser: true, useUnifiedTopology: true, diff --git a/src/mongo/tree/MongoAccountTreeItem.ts b/src/mongo/tree/MongoAccountTreeItem.ts index ba45b9a3..3834b37d 100644 --- a/src/mongo/tree/MongoAccountTreeItem.ts +++ b/src/mongo/tree/MongoAccountTreeItem.ts @@ -70,7 +70,7 @@ export class MongoAccountTreeItem extends AzExtParentTreeItem { 'getChildren', async (context: IActionContext): Promise => { context.telemetry.properties.experience = API.MongoDB; - context.telemetry.properties.parentContext = this.contextValue; + context.telemetry.properties.parentNodeContext = this.contextValue; let mongoClient: MongoClient | undefined; try { diff --git a/src/mongoClusters/MongoClustersClient.ts b/src/mongoClusters/MongoClustersClient.ts index 727fbaf2..139fde23 100644 --- a/src/mongoClusters/MongoClustersClient.ts +++ b/src/mongoClusters/MongoClustersClient.ts @@ -9,6 +9,7 @@ * singletone on a client with a getter from a connection pool.. */ +import { appendExtensionUserAgent, callWithTelemetryAndErrorHandling } from '@microsoft/vscode-azext-utils'; import { EJSON } from 'bson'; import { MongoClient, @@ -22,6 +23,8 @@ import { type WithoutId, } from 'mongodb'; import { CredentialCache } from './CredentialCache'; +import { areMongoDBAzure, getHostsFromConnectionString } from './utils/connectionStringHelpers'; +import { getMongoClusterMetadata, type MongoClusterMetadata } from './utils/getMongoClusterMetadata'; import { toFilterQueryObj } from './utils/toFilterQuery'; export interface DatabaseItemModel { @@ -73,9 +76,31 @@ export class MongoClustersClient { } this._credentialId = credentialId; + + // check if it's an azure connection, and do some special handling + let userAgentString: string | undefined = undefined; + { + const cString = CredentialCache.getCredentials(credentialId)?.connectionString as string; + const hosts = getHostsFromConnectionString(cString); + if (areMongoDBAzure(hosts)) { + userAgentString = appendExtensionUserAgent(); + } + } + const cStringPassword = CredentialCache.getConnectionStringWithPassword(credentialId); - this._mongoClient = await MongoClient.connect(cStringPassword as string); + this._mongoClient = await MongoClient.connect(cStringPassword as string, { + appName: userAgentString, + }); + + void callWithTelemetryAndErrorHandling('cosmosDB.mongoClusters.connect.getmetadata', async (context) => { + const metadata: MongoClusterMetadata = await getMongoClusterMetadata(this._mongoClient); + + context.telemetry.properties = { + ...context.telemetry.properties, + ...metadata, + }; + }); } public static async getClient(credentialId: string): Promise { diff --git a/src/mongoClusters/MongoClustersExtension.ts b/src/mongoClusters/MongoClustersExtension.ts index d67cb186..3156e909 100644 --- a/src/mongoClusters/MongoClustersExtension.ts +++ b/src/mongoClusters/MongoClustersExtension.ts @@ -86,9 +86,6 @@ export class MongoClustersExtension implements vscode.Disposable { registerCommand('command.internal.mongoClusters.containerView.open', openCollectionView); registerCommand('command.internal.mongoClusters.documentView.open', openDocumentView); - registerCommand('command.internal.mongoClusters.importDocuments', mongoClustersImportDocuments); - registerCommand('command.internal.mongoClusters.exportDocuments', mongoClustersExportQueryResults); - registerCommandWithTreeNodeUnwrapping('command.mongoClusters.launchShell', launchShell); registerCommandWithTreeNodeUnwrapping('command.mongoClusters.dropCollection', dropCollection); @@ -101,6 +98,18 @@ export class MongoClustersExtension implements vscode.Disposable { 'command.mongoClusters.importDocuments', mongoClustersImportDocuments, ); + + /** + * Here, exporting documents is done in two ways: one is accessible from the tree view + * via a context menu, and the other is accessible programmatically. Both of them + * use the same underlying function to export documents. + * + * mongoClustersExportEntireCollection calls mongoClustersExportQueryResults with no queryText. + * + * It was possible to merge the two commands into one, but it would result in code that is + * harder to understand and maintain. + */ + registerCommand('command.internal.mongoClusters.exportDocuments', mongoClustersExportQueryResults); registerCommandWithTreeNodeUnwrapping( 'command.mongoClusters.exportDocuments', mongoClustersExportEntireCollection, diff --git a/src/mongoClusters/commands/addWorkspaceConnection.ts b/src/mongoClusters/commands/addWorkspaceConnection.ts index 31ac2ce5..a6ff01ad 100644 --- a/src/mongoClusters/commands/addWorkspaceConnection.ts +++ b/src/mongoClusters/commands/addWorkspaceConnection.ts @@ -12,6 +12,7 @@ import { WorkspaceResourceType } from '../../tree/workspace/sharedWorkspaceResou import { SharedWorkspaceStorage } from '../../tree/workspace/sharedWorkspaceStorage'; import { showConfirmationAsInSettings } from '../../utils/dialogs/showConfirmation'; import { localize } from '../../utils/localize'; +import { areMongoDBRU } from '../utils/connectionStringHelpers'; import { type AddWorkspaceConnectionContext } from '../wizards/addWorkspaceConnection/AddWorkspaceConnectionContext'; import { ConnectionStringStep } from '../wizards/addWorkspaceConnection/ConnectionStringStep'; import { PasswordStep } from '../wizards/addWorkspaceConnection/PasswordStep'; @@ -55,12 +56,8 @@ export async function addWorkspaceConnection(context: IActionContext): Promise { - if (isMongoDBRU(host)) { - isRU = true; - } - }); + const isRU = areMongoDBRU(connectionString.hosts); + if (isRU) { try { await vscode.window.showInformationMessage( @@ -104,15 +101,3 @@ export async function addWorkspaceConnection(context: IActionContext): Promise { // node ??= ... pick a node if not provided if (!node) { throw new Error('No collection selected.'); } - const targetUri = await askForTargetFile(_context); + context.telemetry.properties.calledFrom = props?.source ?? 'contextMenu'; + + const targetUri = await askForTargetFile(context); if (!targetUri) { return; @@ -39,7 +41,7 @@ export async function mongoClustersExportQueryResults( node.databaseInfo.name, node.collectionInfo.name, docStreamAbortController.signal, - queryText, + props?.queryText, ); const filePath = targetUri.fsPath; // Convert `vscode.Uri` to a regular file path @@ -48,14 +50,20 @@ export async function mongoClustersExportQueryResults( let documentCount = 0; // Wrap the export process inside a progress reporting function - await runExportWithProgressAndDescription(node.id, async (progress, cancellationToken) => { - documentCount = await exportDocumentsToFile( - docStream, - filePath, - progress, - cancellationToken, - docStreamAbortController, - ); + await callWithTelemetryAndErrorHandling('cosmosDB.mongoClusters.exportDocuments', async (actionContext) => { + await runExportWithProgressAndDescription(node.id, async (progress, cancellationToken) => { + documentCount = await exportDocumentsToFile( + docStream, + filePath, + progress, + cancellationToken, + docStreamAbortController, + ); + }); + + actionContext.telemetry.properties.source = props?.source; + actionContext.telemetry.measurements.queryLength = props?.queryText?.length; + actionContext.telemetry.measurements.documentCount = documentCount; }); ext.outputChannel.appendLog(`MongoDB Clusters: Exported document count: ${documentCount}`); diff --git a/src/mongoClusters/commands/importDocuments.ts b/src/mongoClusters/commands/importDocuments.ts index c6fc33a3..4a4b3cdb 100644 --- a/src/mongoClusters/commands/importDocuments.ts +++ b/src/mongoClusters/commands/importDocuments.ts @@ -10,6 +10,11 @@ import { type CollectionItem } from '../tree/CollectionItem'; export async function mongoClustersImportDocuments( context: IActionContext, collectionNode?: CollectionItem, + _collectionNodes?: CollectionItem[], // required by the TreeNodeCommandCallback, but not used + ...args: unknown[] ): Promise { + const source = (args[0] as { source?: string })?.source ?? 'contextMenu'; + context.telemetry.properties.calledFrom = source; + return importDocuments(context, undefined, collectionNode); } diff --git a/src/mongoClusters/tree/MongoClusterResourceItem.ts b/src/mongoClusters/tree/MongoClusterResourceItem.ts index 09bbfec9..7a45db81 100644 --- a/src/mongoClusters/tree/MongoClusterResourceItem.ts +++ b/src/mongoClusters/tree/MongoClusterResourceItem.ts @@ -42,7 +42,7 @@ export class MongoClusterResourceItem extends MongoClusterItemBase { */ protected async authenticateAndConnect(): Promise { const result = await callWithTelemetryAndErrorHandling( - 'cosmosDB.mongoClusters.authenticate', + 'cosmosDB.mongoClusters.connect', async (context: IActionContext) => { ext.outputChannel.appendLine( `MongoDB Clusters: Attempting to authenticate with "${this.mongoCluster.name}"...`, diff --git a/src/mongoClusters/tree/MongoClustersBranchDataProvider.ts b/src/mongoClusters/tree/MongoClustersBranchDataProvider.ts index 2d91a768..70ed8c1c 100644 --- a/src/mongoClusters/tree/MongoClustersBranchDataProvider.ts +++ b/src/mongoClusters/tree/MongoClustersBranchDataProvider.ts @@ -52,7 +52,7 @@ export class MongoClustersBranchDataProvider */ return await callWithTelemetryAndErrorHandling('getChildren', async (context: IActionContext) => { context.telemetry.properties.experience = API.MongoClusters; - context.telemetry.properties.parentContext = (await element.getTreeItem()).contextValue ?? 'unknown'; + context.telemetry.properties.parentNodeContext = (await element.getTreeItem()).contextValue ?? 'unknown'; return (await element.getChildren?.())?.map((child) => { if (child.id) { diff --git a/src/mongoClusters/tree/workspace/MongoClusterWorkspaceItem.ts b/src/mongoClusters/tree/workspace/MongoClusterWorkspaceItem.ts index 8b368ac7..dc49b41f 100644 --- a/src/mongoClusters/tree/workspace/MongoClusterWorkspaceItem.ts +++ b/src/mongoClusters/tree/workspace/MongoClusterWorkspaceItem.ts @@ -37,7 +37,7 @@ export class MongoClusterWorkspaceItem extends MongoClusterItemBase { */ protected async authenticateAndConnect(): Promise { const result = await callWithTelemetryAndErrorHandling( - 'cosmosDB.mongoClusters.authenticate', + 'cosmosDB.mongoClusters.connect', async (context: IActionContext) => { context.telemetry.properties.view = 'workspace'; @@ -127,7 +127,7 @@ export class MongoClusterWorkspaceItem extends MongoClusterItemBase { // Prompt the user for credentials await callWithTelemetryAndErrorHandling( - 'cosmosDB.mongoClusters.authenticate.promptForCredentials', + 'cosmosDB.mongoClusters.connect.promptForCredentials', async (context: IActionContext) => { context.telemetry.properties.view = 'workspace'; diff --git a/src/mongoClusters/tree/workspace/MongoClustersWorkbenchBranchDataProvider.ts b/src/mongoClusters/tree/workspace/MongoClustersWorkbenchBranchDataProvider.ts index 39634e06..35fa6509 100644 --- a/src/mongoClusters/tree/workspace/MongoClustersWorkbenchBranchDataProvider.ts +++ b/src/mongoClusters/tree/workspace/MongoClustersWorkbenchBranchDataProvider.ts @@ -35,7 +35,7 @@ export class MongoClustersWorkspaceBranchDataProvider return await callWithTelemetryAndErrorHandling('getChildren', async (context: IActionContext) => { context.telemetry.properties.experience = API.MongoClusters; context.telemetry.properties.view = 'workspace'; - context.telemetry.properties.parentContext = (await element.getTreeItem()).contextValue ?? 'unknown'; + context.telemetry.properties.parentNodeContext = (await element.getTreeItem()).contextValue ?? 'unknown'; return (await element.getChildren?.())?.map((child) => { if (child.id) { diff --git a/src/mongoClusters/utils/connectionStringHelpers.ts b/src/mongoClusters/utils/connectionStringHelpers.ts index 68ccecff..8083d0f0 100644 --- a/src/mongoClusters/utils/connectionStringHelpers.ts +++ b/src/mongoClusters/utils/connectionStringHelpers.ts @@ -30,8 +30,41 @@ export const getPasswordFromConnectionString = (connectionString: string): strin return new ConnectionString(connectionString).password; }; +export const getHostsFromConnectionString = (connectionString: string): string[] => { + return new ConnectionString(connectionString).hosts; +}; + export const addDatabasePathToConnectionString = (connectionString: string, databaseName: string): string => { const connectionStringOb = new ConnectionString(connectionString); connectionStringOb.pathname = databaseName; return connectionStringOb.toString(); }; + +/** + * Checks if any of the given hosts end with any of the provided suffixes. + * + * @param hosts - An array of host strings to check. + * @param suffixes - An array of suffixes to check against the hosts. + * @returns True if any host ends with any of the suffixes, false otherwise. + */ +function hostsEndWithAny(hosts: string[], suffixes: string[]): boolean { + return hosts.some((host) => { + const hostWithoutPort = host.split(':')[0].toLowerCase(); + return suffixes.some((suffix) => hostWithoutPort.endsWith(suffix)); + }); +} + +export function areMongoDBRU(hosts: string[]): boolean { + const knownSuffixes = ['mongo.cosmos.azure.com']; + return hostsEndWithAny(hosts, knownSuffixes); +} + +export function areMongoDBvCore(hosts: string[]): boolean { + const knownSuffixes = ['mongocluster.cosmos.azure.com']; + return hostsEndWithAny(hosts, knownSuffixes); +} + +export function areMongoDBAzure(hosts: string[]): boolean { + const knownSuffixes = ['azure.com']; + return hostsEndWithAny(hosts, knownSuffixes); +} diff --git a/src/mongoClusters/utils/getMongoClusterMetadata.ts b/src/mongoClusters/utils/getMongoClusterMetadata.ts new file mode 100644 index 00000000..964b9bd3 --- /dev/null +++ b/src/mongoClusters/utils/getMongoClusterMetadata.ts @@ -0,0 +1,107 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +/* eslint-disable @typescript-eslint/no-unsafe-call */ +/* eslint-disable @typescript-eslint/no-unsafe-member-access */ +/* eslint-disable @typescript-eslint/no-unsafe-assignment */ + +import { type MongoClient } from 'mongodb'; + +/** + * Interface to define the structure of MongoDB cluster metadata. + * The data structure is flat with dot notation in field names to meet requirements in the telemetry section. + * + * The fields are optional to allow for partial data collection in case of errors. + */ +export interface MongoClusterMetadata { + 'serverInfo.version'?: string; // MongoDB server version (non-sensitive) + 'serverInfo.gitVersion'?: string; // Git version of the MongoDB server (non-sensitive) + 'serverInfo.opensslVersion'?: string; // OpenSSL version used by the server (non-sensitive) + 'serverInfo.platform'?: string; // Server platform information (non-sensitive) + 'serverInfo.storageEngines'?: string; // Storage engine used by the server (non-sensitive) + 'serverInfo.modules'?: string; // List of modules loaded by the server (non-sensitive) + 'serverInfo.error'?: string; // Error message if fetching server info fails + + 'topology.type'?: string; // Type of topology (e.g., replica set, sharded cluster) + 'topology.numberOfServers'?: string; // Number of servers + 'topology.minWireVersion'?: string; // Minimum wire protocol version supported + 'topology.maxWireVersion'?: string; // Maximum wire protocol version supported + 'topology.error'?: string; // Error message if fetching topology info fails + + 'serverStatus.uptime'?: string; // Server uptime in seconds (non-sensitive) + 'serverStatus.connections.current'?: string; // Current number of connections (non-sensitive) + 'serverStatus.connections.available'?: string; // Available connections (non-sensitive) + 'serverStatus.memory.resident'?: string; // Resident memory usage in MB (non-sensitive) + 'serverStatus.memory.virtual'?: string; // Virtual memory usage in MB (non-sensitive) + 'serverStatus.error'?: string; // Error message if fetching server status fails + + 'hostInfo.json'?: string; // JSON stringified host information + 'hostInfo.error'?: string; // Error message if fetching host info fails +} + +/** + * Retrieves metadata information about a MongoDB cluster. + * This data helps improve diagnostics and user experience. + * No internal server addresses or sensitive information are read. + * + * @param client - The MongoClient instance connected to the MongoDB cluster. + * @returns A promise that resolves to an object containing various metadata about the MongoDB cluster. + * + */ +export async function getMongoClusterMetadata(client: MongoClient): Promise { + const result: MongoClusterMetadata = {}; + + const adminDb = client.db().admin(); + + // Fetch build info (server version, git version, etc.) + // This information is non-sensitive and aids in diagnostics. + try { + const buildInfo = await adminDb.command({ buildInfo: 1 }); + result['serverInfo.version'] = buildInfo.version; + result['serverInfo.gitVersion'] = buildInfo.gitVersion; + result['serverInfo.opensslVersion'] = buildInfo.opensslVersion; + result['serverInfo.platform'] = buildInfo.platform; + result['serverInfo.storageEngines'] = (buildInfo.storageEngines as string[])?.join(';'); + result['serverInfo.modules'] = (buildInfo.modules as string[])?.join(';'); + } catch (error) { + result['serverInfo.error'] = (error as Error).message; + } + + // Fetch server status information. + // Includes non-sensitive data like uptime and connection metrics. + try { + const serverStatus = await adminDb.command({ serverStatus: 1 }); + result['serverStatus.uptime'] = serverStatus.uptime.toString(); + result['serverStatus.connections.current'] = serverStatus.connections?.current.toString(); + result['serverStatus.connections.available'] = serverStatus.connections?.available.toString(); + result['serverStatus.memory.resident'] = serverStatus.mem?.resident.toString(); + result['serverStatus.memory.virtual'] = serverStatus.mem?.virtual.toString(); + } catch (error) { + result['serverStatus.error'] = (error as Error).message; + } + + // Fetch topology information using the 'hello' command. + // Internal server addresses are not collected to ensure privacy. + try { + const helloInfo = await adminDb.command({ hello: 1 }); + result['topology.type'] = helloInfo.msg || 'unknown'; + result['topology.numberOfServers'] = (helloInfo.hosts?.length || 0).toString(); + result['topology.minWireVersion'] = helloInfo.minWireVersion.toString(); + result['topology.maxWireVersion'] = helloInfo.maxWireVersion.toString(); + } catch (error) { + result['topology.error'] = (error as Error).message; + } + + // Fetch host information + try { + const hostInfo = await adminDb.command({ hostInfo: 1 }); + result['hostInfo.json'] = JSON.stringify(hostInfo); + } catch (error) { + result['hostInfo.error'] = (error as Error).message; + } + + // Return the collected metadata. + return result; +} diff --git a/src/table/tree/TableAccountTreeItem.ts b/src/table/tree/TableAccountTreeItem.ts index abac5dac..d387ffe4 100644 --- a/src/table/tree/TableAccountTreeItem.ts +++ b/src/table/tree/TableAccountTreeItem.ts @@ -31,7 +31,7 @@ export class TableAccountTreeItem extends DocDBAccountTreeItemBase { 'getChildren', (context: IActionContext): AzExtTreeItem[] => { context.telemetry.properties.experience = API.Table; - context.telemetry.properties.parentContext = this.contextValue; + context.telemetry.properties.parentNodeContext = this.contextValue; const tableNotFoundTreeItem: AzExtTreeItem = new GenericTreeItem(this, { contextValue: 'tableNotSupported', diff --git a/src/webviews/api/configuration/appRouter.ts b/src/webviews/api/configuration/appRouter.ts index b0c1846d..1a904845 100644 --- a/src/webviews/api/configuration/appRouter.ts +++ b/src/webviews/api/configuration/appRouter.ts @@ -6,7 +6,9 @@ /** * This a minimal tRPC server */ +import { callWithTelemetryAndErrorHandling } from '@microsoft/vscode-azext-utils'; import { z } from 'zod'; +import { type API } from '../../../AzureDBExperiences'; import { collectionsViewRouter as collectionViewRouter } from '../../mongoClusters/collectionView/collectionViewRouter'; import { documentsViewRouter as documentViewRouter } from '../../mongoClusters/documentView/documentsViewRouter'; import { publicProcedure, router } from '../extension-server/trpc'; @@ -23,7 +25,69 @@ import { publicProcedure, router } from '../extension-server/trpc'; * There is one router called 'commonRouter'. It has procedures that are shared across all webviews. */ +export type BaseRouterContext = { + dbExperience: API; + webviewName: string; +}; + +/** + * eventName: string, + properties?: Record, + measurements?: Record + */ const commonRouter = router({ + reportEvent: publicProcedure + // This is the input schema of your procedure, two parameters, both strings + .input( + z.object({ + eventName: z.string(), + properties: z.optional(z.record(z.string())), //By default, the keys of a JavaScript object are always strings (or symbols). Even if you use a number as an object key, JavaScript will convert it to a string internally. + measurements: z.optional(z.record(z.number())), //By default, the keys of a JavaScript object are always strings (or symbols). Even if you use a number as an object key, JavaScript will convert it to a string internally. + }), + ) + // Here the procedure (query or mutation) + .mutation(({ input, ctx }) => { + const myCtx = ctx as BaseRouterContext; + + void callWithTelemetryAndErrorHandling( + `cosmosDB.${myCtx.dbExperience}.webview.event.${myCtx.webviewName}.${input.eventName}`, + (context) => { + context.errorHandling.suppressDisplay = true; + context.telemetry.properties.experience = myCtx.dbExperience; + Object.assign(context.telemetry.properties, input.properties ?? {}); + Object.assign(context.telemetry.measurements, input.measurements ?? {}); + }, + ); + }), + reportError: publicProcedure + // This is the input schema of your procedure, two parameters, both strings + .input( + z.object({ + message: z.string(), + stack: z.string(), + componentStack: z.optional(z.string()), + properties: z.optional(z.record(z.string())), //By default, the keys of a JavaScript object are always strings (or symbols). Even if you use a number as an object key, JavaScript will convert it to a string internally. + }), + ) + // Here the procedure (query or mutation) + .mutation(({ input, ctx }) => { + const myCtx = ctx as BaseRouterContext; + + void callWithTelemetryAndErrorHandling( + `cosmosDB.${myCtx.dbExperience}.webview.error.${myCtx.webviewName}`, + (context) => { + context.errorHandling.suppressDisplay = true; + context.telemetry.properties.experience = myCtx.dbExperience; + + Object.assign(context.telemetry.properties, input.properties ?? {}); + + const newError = new Error(input.message); + // If it's a rendering error in the webview, swap the stack with the componentStack which is more helpful + newError.stack = input.componentStack ?? input.stack; + throw newError; + }, + ); + }), hello: publicProcedure // This is the input schema of your procedure, no parameters .query(async () => { @@ -37,8 +101,6 @@ const commonRouter = router({ .input(z.string()) // Here the procedure (query or mutation) .query(async ({ input }) => { - await new Promise((resolve) => setTimeout(resolve, 3000)); - // This is what you're returning to your client return { text: `Hello ${input}!` }; }), diff --git a/src/webviews/api/extension-server/WebviewController.ts b/src/webviews/api/extension-server/WebviewController.ts index 3c244a3a..38fac622 100644 --- a/src/webviews/api/extension-server/WebviewController.ts +++ b/src/webviews/api/extension-server/WebviewController.ts @@ -4,7 +4,8 @@ *--------------------------------------------------------------------------------------------*/ import * as vscode from 'vscode'; -import { appRouter } from '../configuration/appRouter'; +import { type API } from '../../../AzureDBExperiences'; +import { appRouter, type BaseRouterContext } from '../configuration/appRouter'; import { type VsCodeLinkNotification, type VsCodeLinkRequestMessage } from '../webview-client/vscodeLink'; import { WebviewBaseController } from './WebviewBaseController'; import { createCallerFactory } from './trpc'; @@ -21,7 +22,7 @@ export class WebviewController extends WebviewBaseController extends WebviewBaseController extends WebviewBaseController extends WebviewBaseController { + const result = await callWithTelemetryAndErrorHandling>( + `cosmosDB.rpc.${type}.${path}`, + async (context) => { + context.errorHandling.suppressDisplay = true; + + const result = await next(); + + if (!result.ok) { + context.telemetry.properties.result = 'Failed'; + context.telemetry.properties.error = result.error.message; + + /** + * we're not any error here as we just want to log it here and let the + * caller of the RPC call handle the error there. + */ + } + + return result; + }, + ); + + return result as MiddlewareResult; +}); diff --git a/src/webviews/mongoClusters/collectionView/CollectionView.tsx b/src/webviews/mongoClusters/collectionView/CollectionView.tsx index b5c96814..867f32b8 100644 --- a/src/webviews/mongoClusters/collectionView/CollectionView.tsx +++ b/src/webviews/mongoClusters/collectionView/CollectionView.tsx @@ -138,6 +138,17 @@ export const CollectionView = (): JSX.Element => { break; } + trpcClient.common.reportEvent + .mutate({ + eventName: 'viewChanged', + properties: { + view: selection, + }, + }) + .catch((_error) => { + console.log('error'); + }); + setCurrentContext((prev) => ({ ...prev, currentView: selection })); getDataForView(selection); }; @@ -307,12 +318,28 @@ export const CollectionView = (): JSX.Element => { return; } + const newPath = [...(currentContext.currentViewState?.currentPath ?? []), activeColumn]; + setCurrentContext((prev) => ({ ...prev, currentViewState: { - currentPath: [...(currentContext.currentViewState?.currentPath ?? []), activeColumn], + currentPath: newPath, }, })); + + trpcClient.common.reportEvent + .mutate({ + eventName: 'stepIn', + properties: { + source: 'step-in-button', + }, + measurements: { + depth: newPath.length ?? 0, + }, + }) + .catch((_error) => { + console.log('error'); + }); } return ( @@ -325,12 +352,26 @@ export const CollectionView = (): JSX.Element => { + onExecuteRequest={(q: string) => { setCurrentContext((prev) => ({ ...prev, currrentQueryDefinition: { ...prev.currrentQueryDefinition, queryText: q, pageNumber: 1 }, - })) - } + })); + + trpcClient.common.reportEvent + .mutate({ + eventName: 'executeQuery', + properties: { + ui: 'shortcut', + }, + measurements: { + queryLenth: q.length, + }, + }) + .catch((error) => { + console.error('Failed to report query event:', error); + }); + }} /> diff --git a/src/webviews/mongoClusters/collectionView/collectionViewController.ts b/src/webviews/mongoClusters/collectionView/collectionViewController.ts index a3f63ba6..ea7324e8 100644 --- a/src/webviews/mongoClusters/collectionView/collectionViewController.ts +++ b/src/webviews/mongoClusters/collectionView/collectionViewController.ts @@ -3,6 +3,7 @@ * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ +import { API } from '../../../AzureDBExperiences'; import { ext } from '../../../extensionVariables'; import { type CollectionItem } from '../../../mongoClusters/tree/CollectionItem'; import { WebviewController } from '../../api/extension-server/WebviewController'; @@ -24,9 +25,11 @@ export class CollectionViewController extends WebviewController { + getInfo: publicProcedure.use(trpcToTelemetry).query(({ ctx }) => { const myCtx = ctx as RouterContext; return 'Info from the webview: ' + JSON.stringify(myCtx); }), runQuery: publicProcedure + .use(trpcToTelemetry) // parameters .input( z.object({ @@ -58,6 +60,7 @@ export const collectionsViewRouter = router({ return { documentCount: size }; }), getAutocompletionSchema: publicProcedure + .use(trpcToTelemetry) // procedure type .query(({ ctx }) => { const myCtx = ctx as RouterContext; @@ -79,6 +82,7 @@ export const collectionsViewRouter = router({ return querySchema; }), getCurrentPageAsTable: publicProcedure + .use(trpcToTelemetry) //parameters .input(z.array(z.string())) // procedure type @@ -91,6 +95,7 @@ export const collectionsViewRouter = router({ return tableData; }), getCurrentPageAsTree: publicProcedure + .use(trpcToTelemetry) // procedure type .query(({ ctx }) => { const myCtx = ctx as RouterContext; @@ -101,6 +106,7 @@ export const collectionsViewRouter = router({ return treeData; }), getCurrentPageAsJson: publicProcedure + .use(trpcToTelemetry) // procedure type .query(({ ctx }) => { const myCtx = ctx as RouterContext; @@ -111,6 +117,7 @@ export const collectionsViewRouter = router({ return jsonData; }), addDocument: publicProcedure + .use(trpcToTelemetry) // procedure type .mutation(({ ctx }) => { const myCtx = ctx as RouterContext; @@ -123,6 +130,7 @@ export const collectionsViewRouter = router({ }); }), viewDocumentById: publicProcedure + .use(trpcToTelemetry) // parameters .input(z.string()) // procedure type @@ -138,6 +146,7 @@ export const collectionsViewRouter = router({ }); }), editDocumentById: publicProcedure + .use(trpcToTelemetry) // parameters .input(z.string()) // procedure type @@ -153,6 +162,7 @@ export const collectionsViewRouter = router({ }); }), deleteDocumentsById: publicProcedure + .use(trpcToTelemetry) // parameteres .input(z.array(z.string())) // stands for string[] // procedure type @@ -198,21 +208,23 @@ export const collectionsViewRouter = router({ return acknowledged; }), exportDocuments: publicProcedure + .use(trpcToTelemetry) // parameters .input(z.object({ query: z.string() })) //procedure type - .query(async ({ input, ctx }) => { + .query(({ input, ctx }) => { const myCtx = ctx as RouterContext; - vscode.commands.executeCommand( - 'command.internal.mongoClusters.exportDocuments', - myCtx.collectionTreeItem, - input.query, - ); + vscode.commands.executeCommand('command.internal.mongoClusters.exportDocuments', myCtx.collectionTreeItem, { + queryText: input.query, + source: 'webview;collectionView', + }); }), - importDocuments: publicProcedure.query(async ({ ctx }) => { + importDocuments: publicProcedure.use(trpcToTelemetry).query(({ ctx }) => { const myCtx = ctx as RouterContext; - vscode.commands.executeCommand('command.internal.mongoClusters.importDocuments', myCtx.collectionTreeItem); + vscode.commands.executeCommand('command.mongoClusters.importDocuments', myCtx.collectionTreeItem, null, { + source: 'webview;collectionView', + }); }), }); diff --git a/src/webviews/mongoClusters/collectionView/components/toolbar/ToolbarMainView.tsx b/src/webviews/mongoClusters/collectionView/components/toolbar/ToolbarMainView.tsx index f9221a53..cdfa3608 100644 --- a/src/webviews/mongoClusters/collectionView/components/toolbar/ToolbarMainView.tsx +++ b/src/webviews/mongoClusters/collectionView/components/toolbar/ToolbarMainView.tsx @@ -20,6 +20,12 @@ export const ToolbarMainView = (): JSX.Element => { }; const ToolbarQueryOperations = (): JSX.Element => { + /** + * Use the `useTrpcClient` hook to get the tRPC client and an event target + * for handling notifications from the extension. + */ + const { trpcClient /** , vscodeEventTarget */ } = useTrpcClient(); + const [currentContext, setCurrentContext] = useContext(CollectionViewContext); const handleExecuteQuery = () => { @@ -38,6 +44,20 @@ const ToolbarQueryOperations = (): JSX.Element => { ...prev, currrentQueryDefinition: { ...prev.currrentQueryDefinition, queryText: queryContent, pageNumber: 1 }, })); + + trpcClient.common.reportEvent + .mutate({ + eventName: 'executeQuery', + properties: { + ui: 'button', + }, + measurements: { + queryLenth: queryContent.length, + }, + }) + .catch((error) => { + console.error('Failed to report query event:', error); + }); }; const handleRefreshResults = () => { @@ -46,6 +66,23 @@ const ToolbarQueryOperations = (): JSX.Element => { ...prev, currrentQueryDefinition: { ...prev.currrentQueryDefinition }, })); + + trpcClient.common.reportEvent + .mutate({ + eventName: 'refreshResults', + properties: { + ui: 'button', + view: currentContext.currentView, + }, + measurements: { + page: currentContext.currrentQueryDefinition.pageNumber, + pageSize: currentContext.currrentQueryDefinition.pageSize, + queryLength: currentContext.currrentQueryDefinition.queryText.length, + }, + }) + .catch((error) => { + console.error('Failed to report query event:', error); + }); }; return ( diff --git a/src/webviews/mongoClusters/collectionView/components/toolbar/ToolbarTableNavigation.tsx b/src/webviews/mongoClusters/collectionView/components/toolbar/ToolbarTableNavigation.tsx index 93a6eb6d..bdf74ab8 100644 --- a/src/webviews/mongoClusters/collectionView/components/toolbar/ToolbarTableNavigation.tsx +++ b/src/webviews/mongoClusters/collectionView/components/toolbar/ToolbarTableNavigation.tsx @@ -16,29 +16,68 @@ import { } from '@fluentui/react-components'; import { ArrowUp16Filled } from '@fluentui/react-icons'; import { useContext } from 'react'; +import { useTrpcClient } from '../../../../api/webview-client/useTrpcClient'; import { CollectionViewContext, Views } from '../../collectionViewContext'; export const ToolbarTableNavigation = (): JSX.Element => { + /** + * Use the `useTrpcClient` hook to get the tRPC client and an event target + * for handling notifications from the extension. + */ + const { trpcClient /** , vscodeEventTarget */ } = useTrpcClient(); + const [currentContext, setCurrentContext] = useContext(CollectionViewContext); function levelUp() { + const newPath = currentContext.currentViewState?.currentPath.slice(0, -1) ?? []; + setCurrentContext({ ...currentContext, currentViewState: { ...currentContext.currentViewState, - currentPath: currentContext.currentViewState?.currentPath.slice(0, -1) ?? [], + currentPath: newPath, }, }); + + trpcClient.common.reportEvent + .mutate({ + eventName: 'stepIn', + properties: { + source: 'step-out-button', + }, + measurements: { + depth: newPath.length ?? 0, + }, + }) + .catch((_error) => { + console.log('error'); + }); } function jumpToLevel(level: number) { + const newPath = currentContext.currentViewState?.currentPath.slice(0, level) ?? []; + setCurrentContext({ ...currentContext, currentViewState: { ...currentContext.currentViewState, - currentPath: currentContext.currentViewState?.currentPath.slice(0, level) ?? [], + currentPath: newPath, }, }); + + trpcClient.common.reportEvent + .mutate({ + eventName: 'stepIn', + properties: { + source: 'breadcrumb', + }, + measurements: { + depth: newPath.length ?? 0, + }, + }) + .catch((_error) => { + console.log('error'); + }); } type Item = { diff --git a/src/webviews/mongoClusters/collectionView/components/toolbar/ToolbarViewNavigation.tsx b/src/webviews/mongoClusters/collectionView/components/toolbar/ToolbarViewNavigation.tsx index 5b9dc836..eda0032e 100644 --- a/src/webviews/mongoClusters/collectionView/components/toolbar/ToolbarViewNavigation.tsx +++ b/src/webviews/mongoClusters/collectionView/components/toolbar/ToolbarViewNavigation.tsx @@ -6,30 +6,75 @@ import { Dropdown, Label, Option, Toolbar, ToolbarButton, Tooltip } from '@fluentui/react-components'; import { ArrowLeftFilled, ArrowPreviousFilled, ArrowRightFilled } from '@fluentui/react-icons'; import { useContext } from 'react'; +import { useTrpcClient } from '../../../../api/webview-client/useTrpcClient'; import { CollectionViewContext } from '../../collectionViewContext'; import { ToolbarDividerTransparent } from './ToolbarDividerTransparent'; export const ToolbarViewNavigation = (): JSX.Element => { + /** + * Use the `useTrpcClient` hook to get the tRPC client and an event target + * for handling notifications from the extension. + */ + const { trpcClient /** , vscodeEventTarget */ } = useTrpcClient(); + const [currentContext, setCurrentContext] = useContext(CollectionViewContext); function goToNextPage() { + const newPage = currentContext.currrentQueryDefinition.pageNumber + 1; + setCurrentContext({ ...currentContext, currrentQueryDefinition: { ...currentContext.currrentQueryDefinition, - pageNumber: currentContext.currrentQueryDefinition.pageNumber + 1, + pageNumber: newPage, }, }); + + trpcClient.common.reportEvent + .mutate({ + eventName: 'pagination', + properties: { + source: 'next-page', + ui: 'button', + view: currentContext.currentView, + }, + measurements: { + page: newPage, + pageSize: currentContext.currrentQueryDefinition.pageSize, + }, + }) + .catch((error) => { + console.error('Failed to report pagination event:', error); + }); } function goToPreviousPage() { + const newPage = Math.max(1, currentContext.currrentQueryDefinition.pageNumber - 1); + setCurrentContext({ ...currentContext, currrentQueryDefinition: { ...currentContext.currrentQueryDefinition, - pageNumber: Math.max(1, currentContext.currrentQueryDefinition.pageNumber - 1), + pageNumber: newPage, }, }); + + trpcClient.common.reportEvent + .mutate({ + eventName: 'pagination', + properties: { + source: 'prev-page', + ui: 'button', + view: currentContext.currentView, + }, + measurements: { + page: newPage, + pageSize: currentContext.currrentQueryDefinition.pageSize, + }, + }) + .catch((error) => { + console.error('Failed to report pagination event:', error); + }); } function goToFirstPage() { @@ -37,6 +82,23 @@ export const ToolbarViewNavigation = (): JSX.Element => { ...currentContext, currrentQueryDefinition: { ...currentContext.currrentQueryDefinition, pageNumber: 1 }, }); + + trpcClient.common.reportEvent + .mutate({ + eventName: 'pagination', + properties: { + source: 'first-page', + ui: 'button', + view: currentContext.currentView, + }, + measurements: { + page: 1, + pageSize: currentContext.currrentQueryDefinition.pageSize, + }, + }) + .catch((error) => { + console.error('Failed to report pagination event:', error); + }); } function setPageSize(pageSize: number) { @@ -48,6 +110,23 @@ export const ToolbarViewNavigation = (): JSX.Element => { pageNumber: 1, }, }); + + trpcClient.common.reportEvent + .mutate({ + eventName: 'pagination', + properties: { + source: 'page-size', + ui: 'button', + view: currentContext.currentView, + }, + measurements: { + page: currentContext.currrentQueryDefinition.pageNumber, + pageSize: pageSize, + }, + }) + .catch((error) => { + console.error('Failed to report pagination event:', error); + }); } return ( diff --git a/src/webviews/mongoClusters/collectionView/components/toolbar/toolbarPaging.tsx b/src/webviews/mongoClusters/collectionView/components/toolbar/toolbarPaging.tsx deleted file mode 100644 index 16f55633..00000000 --- a/src/webviews/mongoClusters/collectionView/components/toolbar/toolbarPaging.tsx +++ /dev/null @@ -1,138 +0,0 @@ -/*--------------------------------------------------------------------------------------------- - * Copyright (c) Microsoft Corporation. All rights reserved. - * Licensed under the MIT License. See License.txt in the project root for license information. - *--------------------------------------------------------------------------------------------*/ - -import { Dropdown, Label, Option, Toolbar, ToolbarButton, ToolbarDivider, Tooltip } from '@fluentui/react-components'; -import { ArrowLeftFilled, ArrowPreviousFilled, ArrowRightFilled, ArrowUp16Filled } from '@fluentui/react-icons'; -import { useContext } from 'react'; -import { CollectionViewContext, Views } from '../../collectionViewContext'; -import { ToolbarDividerTransparent } from './ToolbarDividerTransparent'; - -export const ToolbarPaging = (): JSX.Element => { - const [currentContext, setCurrentContext] = useContext(CollectionViewContext); - - function goToNextPage() { - setCurrentContext({ - ...currentContext, - currrentQueryDefinition: { - ...currentContext.currrentQueryDefinition, - pageNumber: currentContext.currrentQueryDefinition.pageNumber + 1, - }, - }); - } - - function goToPreviousPage() { - setCurrentContext({ - ...currentContext, - currrentQueryDefinition: { - ...currentContext.currrentQueryDefinition, - pageNumber: Math.max(1, currentContext.currrentQueryDefinition.pageNumber - 1), - }, - }); - } - - function goToFirstPage() { - setCurrentContext({ - ...currentContext, - currrentQueryDefinition: { ...currentContext.currrentQueryDefinition, pageNumber: 1 }, - }); - } - - function setPageSize(pageSize: number) { - setCurrentContext({ - ...currentContext, - currrentQueryDefinition: { - ...currentContext.currrentQueryDefinition, - pageSize: pageSize, - pageNumber: 1, - }, - }); - } - - function levelUp() { - setCurrentContext({ - ...currentContext, - currentViewState: { - ...currentContext.currentViewState, - currentPath: currentContext.currentViewState?.currentPath.slice(0, -1) ?? [], - }, - }); - } - - // function refresh() { - // setCurrentContext({ - // ...currentContext - // }); - // } - - return ( - - - } - disabled={ - currentContext.currentView !== Views.TABLE || - currentContext.currentViewState?.currentPath.length === 0 - } - /> - - - - - - } - disabled={currentContext.isLoading} - /> - - - - } - disabled={currentContext.isLoading} - /> - - - - } - disabled={currentContext.isLoading} - /> - - - - - - { - setPageSize(parseInt(data.optionText ?? '10')); - }} - style={{ minWidth: '100px', maxWidth: '100px' }} - defaultValue="10" - defaultSelectedOptions={['10']} - > - - - - - - - - - - - - ); -}; diff --git a/src/webviews/mongoClusters/documentView/documentView.tsx b/src/webviews/mongoClusters/documentView/documentView.tsx index 3df958f1..39ed974b 100644 --- a/src/webviews/mongoClusters/documentView/documentView.tsx +++ b/src/webviews/mongoClusters/documentView/documentView.tsx @@ -170,10 +170,28 @@ export const DocumentView = (): JSX.Element => { setIsLoading(true); + let documentLength = 0; + void trpcClient.mongoClusters.documentView.getDocumentById.query(documentId).then((response) => { + documentLength = response.length ?? 0; + setContent(response); setIsLoading(false); }); + + trpcClient.common.reportEvent + .mutate({ + eventName: 'refreshDocument', + properties: { + ui: 'button', + }, + measurements: { + documentLength: documentLength, + }, + }) + .catch((error) => { + console.error('Failed to report event:', error); + }); } function handleOnSaveRequest(): void { @@ -203,6 +221,20 @@ export const DocumentView = (): JSX.Element => { .finally(() => { setIsLoading(false); }); + + trpcClient.common.reportEvent + .mutate({ + eventName: 'saveDocument', + properties: { + ui: 'button', + }, + measurements: { + documentLength: editorContent.length, + }, + }) + .catch((error) => { + console.error('Failed to report event:', error); + }); } function handleOnValidateRequest(): void {} diff --git a/src/webviews/mongoClusters/documentView/documentsViewController.ts b/src/webviews/mongoClusters/documentView/documentsViewController.ts index 7b1b4b5a..74f1d1e6 100644 --- a/src/webviews/mongoClusters/documentView/documentsViewController.ts +++ b/src/webviews/mongoClusters/documentView/documentsViewController.ts @@ -4,6 +4,7 @@ *--------------------------------------------------------------------------------------------*/ import { ViewColumn } from 'vscode'; +import { API } from '../../../AzureDBExperiences'; import { ext } from '../../../extensionVariables'; import { WebviewController } from '../../api/extension-server/WebviewController'; import { type RouterContext } from './documentsViewRouter'; @@ -33,9 +34,11 @@ export class DocumentsViewController extends WebviewController