diff --git a/.github/workflows/cd.yml b/.github/workflows/cd.yml index 473d733d08..e6fad70eea 100644 --- a/.github/workflows/cd.yml +++ b/.github/workflows/cd.yml @@ -58,6 +58,8 @@ jobs: - name: Package extension run: npm run package + env: + POSTHOG_API_KEY: ${{ secrets.POSTHOG_API_KEY }} - name: Rename VSIX file run: | diff --git a/build/esbuild/build.ts b/build/esbuild/build.ts index c313ce8cf8..a2b65c699e 100644 --- a/build/esbuild/build.ts +++ b/build/esbuild/build.ts @@ -231,6 +231,12 @@ function createConfig( inject.push(path.join(__dirname, isDevbuild ? 'process.development.js' : 'process.production.js')); } } + if (target === 'desktop') { + // Bake the PostHog key in from the CI secret at build time; falls back to the placeholder locally (see constants.ts). + define = { + POSTHOG_API_KEY_BUILD: JSON.stringify(process.env.POSTHOG_API_KEY ?? '') + }; + } if (source.endsWith(path.join('data-explorer', 'index.tsx'))) { inject.push(path.join(__dirname, 'jquery.js')); } diff --git a/package-lock.json b/package-lock.json index f8e9ef939b..22cbeaa779 100644 --- a/package-lock.json +++ b/package-lock.json @@ -64,6 +64,7 @@ "pidtree": "^0.6.0", "plotly.js-dist": "^3.0.1", "portfinder": "^1.0.25", + "posthog-node": "^4.18.0", "re-resizable": "^6.5.5", "react": "^16.5.2", "react-data-grid": "^6.0.2-0", @@ -11009,6 +11010,18 @@ "node": ">=4" } }, + "node_modules/axios": { + "version": "1.18.1", + "resolved": "https://registry.npmjs.org/axios/-/axios-1.18.1.tgz", + "integrity": "sha512-3nTvFlvpn9Zu/RkHUqtc7/+al4UpRW5az71ap5zccp6e8RAYEzhMTecX8Dz1wWDYrPpUoB1HAQEGEAEvUr7S9g==", + "license": "MIT", + "dependencies": { + "follow-redirects": "^1.16.0", + "form-data": "^4.0.5", + "https-proxy-agent": "^5.0.1", + "proxy-from-env": "^2.1.0" + } + }, "node_modules/axobject-query": { "version": "3.2.1", "resolved": "https://registry.npmjs.org/axobject-query/-/axobject-query-3.2.1.tgz", @@ -17771,6 +17784,26 @@ "dev": true, "license": "ISC" }, + "node_modules/follow-redirects": { + "version": "1.16.0", + "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.16.0.tgz", + "integrity": "sha512-y5rN/uOsadFT/JfYwhxRS5R7Qce+g3zG97+JrtFZlC9klX/W5hD7iiLzScI4nZqUS7DNUdhPgw4xI8W2LuXlUw==", + "funding": [ + { + "type": "individual", + "url": "https://github.com/sponsors/RubenVerborgh" + } + ], + "license": "MIT", + "engines": { + "node": ">=4.0" + }, + "peerDependenciesMeta": { + "debug": { + "optional": true + } + } + }, "node_modules/font-awesome": { "version": "4.7.0", "resolved": "https://registry.npmjs.org/font-awesome/-/font-awesome-4.7.0.tgz", @@ -27804,6 +27837,18 @@ "node": ">=0.10.0" } }, + "node_modules/posthog-node": { + "version": "4.18.0", + "resolved": "https://registry.npmjs.org/posthog-node/-/posthog-node-4.18.0.tgz", + "integrity": "sha512-XROs1h+DNatgKh/AlIlCtDxWzwrKdYDb2mOs58n4yN8BkGN9ewqeQwG5ApS4/IzwCb7HPttUkOVulkYatd2PIw==", + "license": "MIT", + "dependencies": { + "axios": "^1.8.2" + }, + "engines": { + "node": ">=15.0.0" + } + }, "node_modules/postinstall-build": { "version": "5.0.3", "resolved": "https://registry.npmjs.org/postinstall-build/-/postinstall-build-5.0.3.tgz", @@ -28135,6 +28180,15 @@ "node": ">= 0.10" } }, + "node_modules/proxy-from-env": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-2.1.0.tgz", + "integrity": "sha512-cJ+oHTW1VAEa8cJslgmUZrc+sjRKgAKl3Zyse6+PV38hZe/V6Z14TbCuXcan9F9ghlz4QrFr2c92TNF82UkYHA==", + "license": "MIT", + "engines": { + "node": ">=10" + } + }, "node_modules/prr": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/prr/-/prr-1.0.1.tgz", @@ -43318,6 +43372,17 @@ "integrity": "sha512-/dlp0fxyM3R8YW7MFzaHWXrf4zzbr0vaYb23VBFCl83R7nWNPg/yaQw2Dc8jzCMmDVLhSdzH8MjrsuIUuvX+6g==", "dev": true }, + "axios": { + "version": "1.18.1", + "resolved": "https://registry.npmjs.org/axios/-/axios-1.18.1.tgz", + "integrity": "sha512-3nTvFlvpn9Zu/RkHUqtc7/+al4UpRW5az71ap5zccp6e8RAYEzhMTecX8Dz1wWDYrPpUoB1HAQEGEAEvUr7S9g==", + "requires": { + "follow-redirects": "^1.16.0", + "form-data": "4.0.6", + "https-proxy-agent": "^5.0.1", + "proxy-from-env": "^2.1.0" + } + }, "axobject-query": { "version": "3.2.1", "resolved": "https://registry.npmjs.org/axobject-query/-/axobject-query-3.2.1.tgz", @@ -48179,6 +48244,11 @@ "integrity": "sha512-PjDse7RzhcPkIJwy5t7KPWQSZ9cAbzQXcafsetQoD7sOJRQlGikNbx7yZp2OotDnJyrDcbyRq3Ttb18iYOqkxA==", "dev": true }, + "follow-redirects": { + "version": "1.16.0", + "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.16.0.tgz", + "integrity": "sha512-y5rN/uOsadFT/JfYwhxRS5R7Qce+g3zG97+JrtFZlC9klX/W5hD7iiLzScI4nZqUS7DNUdhPgw4xI8W2LuXlUw==" + }, "font-awesome": { "version": "4.7.0", "resolved": "https://registry.npmjs.org/font-awesome/-/font-awesome-4.7.0.tgz", @@ -55117,6 +55187,14 @@ "xtend": "^4.0.0" } }, + "posthog-node": { + "version": "4.18.0", + "resolved": "https://registry.npmjs.org/posthog-node/-/posthog-node-4.18.0.tgz", + "integrity": "sha512-XROs1h+DNatgKh/AlIlCtDxWzwrKdYDb2mOs58n4yN8BkGN9ewqeQwG5ApS4/IzwCb7HPttUkOVulkYatd2PIw==", + "requires": { + "axios": "^1.8.2" + } + }, "postinstall-build": { "version": "5.0.3", "resolved": "https://registry.npmjs.org/postinstall-build/-/postinstall-build-5.0.3.tgz", @@ -55352,6 +55430,11 @@ "ipaddr.js": "1.9.1" } }, + "proxy-from-env": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-2.1.0.tgz", + "integrity": "sha512-cJ+oHTW1VAEa8cJslgmUZrc+sjRKgAKl3Zyse6+PV38hZe/V6Z14TbCuXcan9F9ghlz4QrFr2c92TNF82UkYHA==" + }, "prr": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/prr/-/prr-1.0.1.tgz", diff --git a/package.json b/package.json index 8dc22af21d..e44e44ecb5 100644 --- a/package.json +++ b/package.json @@ -1643,6 +1643,12 @@ "description": "Disable SSL certificate verification (for development only)", "scope": "application" }, + "deepnote.telemetry.enabled": { + "type": "boolean", + "default": true, + "description": "%deepnote.configuration.deepnote.telemetry.enabled.description%", + "scope": "application" + }, "deepnote.snapshots.enabled": { "type": "boolean", "default": true, @@ -2726,6 +2732,7 @@ "pidtree": "^0.6.0", "plotly.js-dist": "^3.0.1", "portfinder": "^1.0.25", + "posthog-node": "^4.18.0", "re-resizable": "^6.5.5", "react": "^16.5.2", "react-data-grid": "^6.0.2-0", diff --git a/package.nls.json b/package.nls.json index f3b21dc525..58ea84148d 100644 --- a/package.nls.json +++ b/package.nls.json @@ -122,6 +122,7 @@ "deepnote.debuggers.kernel": "Python Kernel Debug Adapter", "deepnote.debuggers.interactive": "Python Interactive Window", "deepnote.configuration.deepnote.experiments.enabled.description": "Enables/disables A/B tests.", + "deepnote.configuration.deepnote.telemetry.enabled.description": "Enable anonymous usage telemetry to help improve Deepnote for VS Code.", "deepnote.configuration.deepnote.showVariableViewWhenDebugging.description": "Bring up the Variable View when starting a Run by Line session.", "deepnote.configuration.deepnote.logging.level.off": "No messages are logged with this level.", "deepnote.configuration.deepnote.logging.level.trace": "All messages are logged with this level.", diff --git a/src/extension.node.ts b/src/extension.node.ts index 13460ca9ad..bc22ac6484 100644 --- a/src/extension.node.ts +++ b/src/extension.node.ts @@ -134,6 +134,7 @@ export function deactivate(): Thenable { // Make sure to shutdown anybody who needs it. if (activatedServiceContainer) { const registry = activatedServiceContainer.get(IAsyncDisposableRegistry); + if (registry) { return registry.dispose(); } diff --git a/src/kernels/deepnote/environments/deepnoteEnvironmentsView.node.ts b/src/kernels/deepnote/environments/deepnoteEnvironmentsView.node.ts index 0b5ab50948..0684a334ba 100644 --- a/src/kernels/deepnote/environments/deepnoteEnvironmentsView.node.ts +++ b/src/kernels/deepnote/environments/deepnoteEnvironmentsView.node.ts @@ -13,6 +13,7 @@ import { import { IPythonApiProvider } from '../../../platform/api/types'; import { STANDARD_OUTPUT_CHANNEL } from '../../../platform/common/constants'; import { getDisplayPath } from '../../../platform/common/platform/fs-paths.node'; +import { ITelemetryService } from '../../../platform/analytics/types'; import { IDisposableRegistry, IOutputChannel } from '../../../platform/common/types'; import { createDeepnoteServerConfigHandle } from '../../../platform/deepnote/deepnoteServerUtils.node'; import { DeepnoteToolkitMissingError } from '../../../platform/errors/deepnoteKernelErrors'; @@ -54,7 +55,8 @@ export class DeepnoteEnvironmentsView implements Disposable { private readonly notebookEnvironmentMapper: IDeepnoteNotebookEnvironmentMapper, @inject(IKernelProvider) private readonly kernelProvider: IKernelProvider, @inject(IOutputChannel) @named(STANDARD_OUTPUT_CHANNEL) private readonly outputChannel: IOutputChannel, - @inject(IDeepnoteServerStarter) private readonly serverStarter: IDeepnoteServerStarter + @inject(IDeepnoteServerStarter) private readonly serverStarter: IDeepnoteServerStarter, + @inject(ITelemetryService) private readonly analytics: ITelemetryService ) { // Create tree data provider @@ -195,6 +197,14 @@ export class DeepnoteEnvironmentsView implements Disposable { const config = await this.environmentManager.createEnvironment(options, token); logger.info(`Created environment: ${config.id} (${config.name})`); + this.analytics.trackEvent({ + eventName: 'create_environment', + properties: { + hasDescription: !!options.description, + hasPackages: !!options.packages?.length + } + }); + void window.showInformationMessage( l10n.t('Environment "{0}" created successfully!', config.name) ); @@ -328,6 +338,7 @@ export class DeepnoteEnvironmentsView implements Disposable { } ); + this.analytics.trackEvent({ eventName: 'delete_environment' }); void window.showInformationMessage(l10n.t('Environment "{0}" deleted', config.name)); } catch (error) { logger.error('Failed to delete environment', error); @@ -494,6 +505,7 @@ export class DeepnoteEnvironmentsView implements Disposable { } ); + this.analytics.trackEvent({ eventName: 'select_environment' }); void window.showInformationMessage(l10n.t('Environment switched successfully')); } catch (error) { if (error instanceof DeepnoteToolkitMissingError) { @@ -544,6 +556,7 @@ export class DeepnoteEnvironmentsView implements Disposable { logger.info(`Renamed environment ${environmentId} to "${newName}"`); void window.showInformationMessage(l10n.t('Environment renamed to "{0}"', newName)); + this.analytics.trackEvent({ eventName: 'update_environment', properties: { field: 'name' } }); } catch (error) { logger.error('Failed to rename environment', error); void window.showErrorMessage(l10n.t('Failed to rename environment. See output for details.')); @@ -602,6 +615,10 @@ export class DeepnoteEnvironmentsView implements Disposable { ); void window.showInformationMessage(l10n.t('Packages updated for "{0}"', config.name)); + this.analytics.trackEvent({ + eventName: 'update_environment', + properties: { field: 'packages', packageCount: packages.length } + }); } catch (error) { logger.error('Failed to update packages', error); void window.showErrorMessage(l10n.t('Failed to update packages. See output for details.')); diff --git a/src/kernels/deepnote/environments/deepnoteEnvironmentsView.unit.test.ts b/src/kernels/deepnote/environments/deepnoteEnvironmentsView.unit.test.ts index e2a9cf0edc..9d59edc279 100644 --- a/src/kernels/deepnote/environments/deepnoteEnvironmentsView.unit.test.ts +++ b/src/kernels/deepnote/environments/deepnoteEnvironmentsView.unit.test.ts @@ -10,6 +10,7 @@ import { IDeepnoteServerStarter } from '../types'; import { IPythonApiProvider } from '../../../platform/api/types'; +import { ITelemetryService } from '../../../platform/analytics/types'; import { IDisposableRegistry, IOutputChannel } from '../../../platform/common/types'; import { IKernelProvider } from '../../../kernels/types'; import { DeepnoteEnvironment } from './deepnoteEnvironment'; @@ -30,6 +31,7 @@ suite('DeepnoteEnvironmentsView', () => { let mockNotebookEnvironmentMapper: IDeepnoteNotebookEnvironmentMapper; let mockKernelProvider: IKernelProvider; let mockOutputChannel: IOutputChannel; + let mockTelemetryService: ITelemetryService; let mockServerStarter: IDeepnoteServerStarter; let disposables: Disposable[] = []; let pythonEnvironments: PythonExtension['environments']; @@ -49,6 +51,7 @@ suite('DeepnoteEnvironmentsView', () => { mockNotebookEnvironmentMapper = mock(); mockKernelProvider = mock(); mockOutputChannel = mock(); + mockTelemetryService = mock(); mockServerStarter = mock(); // stopServer is a safe no-op when a notebook has no running server @@ -72,7 +75,8 @@ suite('DeepnoteEnvironmentsView', () => { instance(mockNotebookEnvironmentMapper), instance(mockKernelProvider), instance(mockOutputChannel), - instance(mockServerStarter) + instance(mockServerStarter), + instance(mockTelemetryService) ); }); diff --git a/src/notebooks/deepnote/deepnoteActivationService.ts b/src/notebooks/deepnote/deepnoteActivationService.ts index 07d65abdf9..cb2144b9fd 100644 --- a/src/notebooks/deepnote/deepnoteActivationService.ts +++ b/src/notebooks/deepnote/deepnoteActivationService.ts @@ -2,6 +2,7 @@ import { inject, injectable, optional } from 'inversify'; import { commands, l10n, workspace, window, type Disposable, type NotebookDocumentContentOptions } from 'vscode'; import { IExtensionSyncActivationService } from '../../platform/activation/types'; +import { ITelemetryService } from '../../platform/analytics/types'; import { IExtensionContext } from '../../platform/common/types'; import { ILogger } from '../../platform/logging/types'; import { IDeepnoteNotebookEnvironmentMapper } from '../../kernels/deepnote/types'; @@ -40,6 +41,7 @@ export class DeepnoteActivationService implements IExtensionSyncActivationServic @inject(IDeepnoteNotebookManager) private readonly notebookManager: IDeepnoteNotebookManager, @inject(IIntegrationManager) integrationManager: IIntegrationManager, @inject(ILogger) private readonly logger: ILogger, + @inject(ITelemetryService) private readonly analytics: ITelemetryService, @inject(SnapshotService) @optional() private readonly snapshotService?: SnapshotService, @inject(IDeepnoteNotebookEnvironmentMapper) @optional() @@ -57,7 +59,8 @@ export class DeepnoteActivationService implements IExtensionSyncActivationServic this.explorerView = new DeepnoteExplorerView( this.extensionContext, this.logger, - new DeepnoteTreeDataProvider(this.logger) + new DeepnoteTreeDataProvider(this.logger), + this.analytics ); this.editProtection = new DeepnoteInputBlockEditProtection(this.logger); this.snapshotsEnabled = this.isSnapshotsEnabled(); @@ -86,7 +89,8 @@ export class DeepnoteActivationService implements IExtensionSyncActivationServic this.environmentMapper, () => this.explorerView.refresh(), this.logger, - deepnoteFileExists + deepnoteFileExists, + this.analytics ); this.extensionContext.subscriptions.push(...this.multiNotebookSplitter.activate()); this.extensionContext.subscriptions.push(this.multiNotebookSplitter); diff --git a/src/notebooks/deepnote/deepnoteActivationService.unit.test.ts b/src/notebooks/deepnote/deepnoteActivationService.unit.test.ts index a8068d5f64..e4409af9ad 100644 --- a/src/notebooks/deepnote/deepnoteActivationService.unit.test.ts +++ b/src/notebooks/deepnote/deepnoteActivationService.unit.test.ts @@ -1,6 +1,7 @@ import { assert } from 'chai'; -import { anything, verify, when } from 'ts-mockito'; +import { anything, instance, mock, verify, when } from 'ts-mockito'; +import { ITelemetryService } from '../../platform/analytics/types'; import { DeepnoteActivationService } from './deepnoteActivationService'; import { DeepnoteNotebookManager } from './deepnoteNotebookManager'; import { IExtensionContext } from '../../platform/common/types'; @@ -25,6 +26,7 @@ suite('DeepnoteActivationService', () => { let manager: DeepnoteNotebookManager; let mockIntegrationManager: IIntegrationManager; let mockLogger: ILogger; + let mockAnalytics: ITelemetryService; setup(() => { mockExtensionContext = { @@ -38,11 +40,13 @@ suite('DeepnoteActivationService', () => { } }; mockLogger = createMockLogger(); + mockAnalytics = instance(mock()); activationService = new DeepnoteActivationService( mockExtensionContext, manager, mockIntegrationManager, - mockLogger + mockLogger, + mockAnalytics ); }); @@ -103,6 +107,7 @@ suite('DeepnoteActivationService', () => { manager, mockIntegrationManager, mockLogger, + mockAnalytics, mockSnapshotService ); @@ -150,6 +155,7 @@ suite('DeepnoteActivationService', () => { manager, mockIntegrationManager, mockLogger, + mockAnalytics, mockSnapshotService ); @@ -206,8 +212,20 @@ suite('DeepnoteActivationService', () => { }; const mockLogger1 = createMockLogger(); const mockLogger2 = createMockLogger(); - const service1 = new DeepnoteActivationService(context1, manager1, mockIntegrationManager1, mockLogger1); - const service2 = new DeepnoteActivationService(context2, manager2, mockIntegrationManager2, mockLogger2); + const service1 = new DeepnoteActivationService( + context1, + manager1, + mockIntegrationManager1, + mockLogger1, + mockAnalytics + ); + const service2 = new DeepnoteActivationService( + context2, + manager2, + mockIntegrationManager2, + mockLogger2, + mockAnalytics + ); // Verify each service has its own context assert.strictEqual((service1 as any).extensionContext, context1); @@ -244,8 +262,8 @@ suite('DeepnoteActivationService', () => { }; const mockLogger3 = createMockLogger(); const mockLogger4 = createMockLogger(); - new DeepnoteActivationService(context1, manager1, mockIntegrationManager1, mockLogger3); - new DeepnoteActivationService(context2, manager2, mockIntegrationManager2, mockLogger4); + new DeepnoteActivationService(context1, manager1, mockIntegrationManager1, mockLogger3, mockAnalytics); + new DeepnoteActivationService(context2, manager2, mockIntegrationManager2, mockLogger4, mockAnalytics); assert.strictEqual(context1.subscriptions.length, 0); assert.strictEqual(context2.subscriptions.length, 1); diff --git a/src/notebooks/deepnote/deepnoteCellExecutionAnalytics.ts b/src/notebooks/deepnote/deepnoteCellExecutionAnalytics.ts new file mode 100644 index 0000000000..bc2c0cdaef --- /dev/null +++ b/src/notebooks/deepnote/deepnoteCellExecutionAnalytics.ts @@ -0,0 +1,60 @@ +import { inject, injectable } from 'inversify'; +import { Disposable } from 'vscode'; + +import { IExtensionSyncActivationService } from '../../platform/activation/types'; +import { ITelemetryService } from '../../platform/analytics/types'; +import { IDisposableRegistry } from '../../platform/common/types'; +import { NotebookCellExecutionState, notebookCellExecutions } from '../../platform/notebooks/cellExecutionStateService'; +import { IDeepnoteNotebookManager } from '../types'; + +/** + * Tracks cell execution events for telemetry. + */ +@injectable() +export class DeepnoteCellExecutionAnalytics implements IExtensionSyncActivationService { + constructor( + @inject(ITelemetryService) private readonly analytics: ITelemetryService, + @inject(IDeepnoteNotebookManager) private readonly notebookManager: IDeepnoteNotebookManager, + @inject(IDisposableRegistry) private readonly disposables: Disposable[] + ) {} + + public activate(): void { + this.disposables.push( + notebookCellExecutions.onDidChangeNotebookCellExecutionState((e) => { + if (e.state !== NotebookCellExecutionState.Executing) { + return; + } + + if (e.cell.notebook.notebookType !== 'deepnote') { + return; + } + + const languageId = e.cell.document.languageId; + const cellType = languageId === 'sql' ? 'sql' : languageId === 'markdown' ? 'markdown' : 'code'; + + const properties: Record = { cellType }; + + if (cellType === 'sql') { + const integrationId = + e.cell.metadata?.__deepnotePocket?.sql_integration_id ?? e.cell.metadata?.sql_integration_id; + + if (integrationId) { + const projectId = e.cell.notebook.metadata?.deepnoteProjectId; + const notebookId = e.cell.notebook.metadata?.deepnoteNotebookId; + + if (projectId && notebookId) { + const project = this.notebookManager.getProjectForNotebook(projectId, notebookId); + const integration = project?.project.integrations?.find((i) => i.id === integrationId); + + if (integration?.type) { + properties.integrationType = integration.type; + } + } + } + } + + this.analytics.trackEvent({ eventName: 'execute_cell', properties }); + }) + ); + } +} diff --git a/src/notebooks/deepnote/deepnoteExplorerView.ts b/src/notebooks/deepnote/deepnoteExplorerView.ts index c8d082ce46..463b985769 100644 --- a/src/notebooks/deepnote/deepnoteExplorerView.ts +++ b/src/notebooks/deepnote/deepnoteExplorerView.ts @@ -3,6 +3,7 @@ import { commands, window, workspace, type TreeView, RelativePattern, Uri, l10n import { serializeDeepnoteFile, type DeepnoteBlock, type DeepnoteFile } from '@deepnote/blocks'; import { convertDeepnoteToJupyterNotebooks, convertIpynbFileToDeepnoteFile } from '@deepnote/convert'; +import { ITelemetryService } from '../../platform/analytics/types'; import { IExtensionContext } from '../../platform/common/types'; import { DeepnoteTreeDataProvider } from './deepnoteTreeDataProvider'; import { @@ -36,7 +37,8 @@ export class DeepnoteExplorerView { constructor( @inject(IExtensionContext) private readonly extensionContext: IExtensionContext, @inject(ILogger) private readonly logger: ILogger, - private readonly treeDataProvider: DeepnoteTreeDataProvider + private readonly treeDataProvider: DeepnoteTreeDataProvider, + private readonly analytics: ITelemetryService ) {} public activate(): void { @@ -144,9 +146,9 @@ export class DeepnoteExplorerView { return { id: newNotebook.id, name: notebookName }; } - public async renameNotebook(treeItem: DeepnoteTreeItem): Promise { + public async renameNotebook(treeItem: DeepnoteTreeItem): Promise { if (!this.itemIsNotebookScoped(treeItem)) { - return; + return false; } try { @@ -156,7 +158,7 @@ export class DeepnoteExplorerView { if (!projectData?.project?.notebooks) { await window.showErrorMessage(l10n.t('Invalid Deepnote file format')); - return; + return false; } const targetNotebook = this.resolveTargetNotebook(treeItem, projectData); @@ -164,7 +166,7 @@ export class DeepnoteExplorerView { if (!targetNotebook) { await window.showErrorMessage(l10n.t('Notebook not found')); - return; + return false; } const currentName = targetNotebook.name; @@ -173,7 +175,7 @@ export class DeepnoteExplorerView { const newName = await this.promptForNotebookName(currentName, existingNames); if (!newName || newName === currentName) { - return; + return false; } // Flush the open document and re-read before rewriting, so we serialize the user's live cell @@ -183,7 +185,7 @@ export class DeepnoteExplorerView { l10n.t('Could not save "{0}" before renaming. The notebook was left unchanged.', currentName) ); - return; + return false; } const freshData = await readDeepnoteProjectFile(fileUri); @@ -192,7 +194,7 @@ export class DeepnoteExplorerView { if (!freshTarget) { await window.showErrorMessage(l10n.t('Notebook not found')); - return; + return false; } freshTarget.name = newName; @@ -201,15 +203,19 @@ export class DeepnoteExplorerView { this.treeDataProvider.refreshNotebook(treeItem.context.projectId); await window.showInformationMessage(l10n.t('Notebook renamed to: {0}', newName)); + + return true; } catch (error) { const errorMessage = error instanceof Error ? error.message : 'Unknown error'; await window.showErrorMessage(l10n.t('Failed to rename notebook: {0}', errorMessage)); + + return false; } } - public async deleteNotebook(treeItem: DeepnoteTreeItem): Promise { + public async deleteNotebook(treeItem: DeepnoteTreeItem): Promise { if (!this.itemIsNotebookScoped(treeItem)) { - return; + return false; } try { @@ -219,7 +225,7 @@ export class DeepnoteExplorerView { if (!projectData?.project?.notebooks) { await window.showErrorMessage(l10n.t('Invalid Deepnote file format')); - return; + return false; } const targetNotebook = this.resolveTargetNotebook(treeItem, projectData); @@ -227,7 +233,7 @@ export class DeepnoteExplorerView { if (!targetNotebook) { await window.showErrorMessage(l10n.t('Notebook not found')); - return; + return false; } const notebookName = targetNotebook.name; @@ -239,7 +245,7 @@ export class DeepnoteExplorerView { ); if (confirmation !== l10n.t('Delete')) { - return; + return false; } // A single-notebook file's only non-init notebook is the file itself: delete the file. @@ -248,7 +254,7 @@ export class DeepnoteExplorerView { this.treeDataProvider.refresh(); await window.showInformationMessage(l10n.t('Notebook deleted: {0}', notebookName)); - return; + return true; } // Legacy multi-notebook file: remove the notebook from the array. @@ -260,9 +266,13 @@ export class DeepnoteExplorerView { this.treeDataProvider.refreshNotebook(treeItem.context.projectId); await window.showInformationMessage(l10n.t('Notebook deleted: {0}', notebookName)); + + return true; } catch (error) { const errorMessage = error instanceof Error ? error.message : 'Unknown error'; await window.showErrorMessage(l10n.t('Failed to delete notebook: {0}', errorMessage)); + + return false; } } @@ -276,9 +286,9 @@ export class DeepnoteExplorerView { await workspace.fs.delete(fileUri, { useTrash }); } - public async duplicateNotebook(treeItem: DeepnoteTreeItem): Promise { + public async duplicateNotebook(treeItem: DeepnoteTreeItem): Promise { if (!this.itemIsNotebookScoped(treeItem)) { - return; + return false; } try { @@ -288,7 +298,7 @@ export class DeepnoteExplorerView { if (!projectData?.project?.notebooks) { await window.showErrorMessage(l10n.t('Invalid Deepnote file format')); - return; + return false; } const targetNotebook = this.resolveTargetNotebook(treeItem, projectData); @@ -296,7 +306,7 @@ export class DeepnoteExplorerView { if (!targetNotebook) { await window.showErrorMessage(l10n.t('Notebook not found')); - return; + return false; } const existingNames = await this.collectNotebookNamesForProject(treeItem.context.projectId); @@ -317,7 +327,7 @@ export class DeepnoteExplorerView { this.treeDataProvider.refreshNotebook(treeItem.context.projectId); await window.showInformationMessage(l10n.t('Notebook duplicated: {0}', newName)); - return; + return true; } // Legacy multi-notebook file: append the duplicate in place (existing behavior). @@ -334,15 +344,19 @@ export class DeepnoteExplorerView { }); await window.showInformationMessage(l10n.t('Notebook duplicated: {0}', newName)); + + return true; } catch (error) { const errorMessage = error instanceof Error ? error.message : 'Unknown error'; await window.showErrorMessage(l10n.t('Failed to duplicate notebook: {0}', errorMessage)); + + return false; } } - public async renameProject(treeItem: DeepnoteTreeItem): Promise { + public async renameProject(treeItem: DeepnoteTreeItem): Promise { if (treeItem.extra.type !== DeepnoteTreeItemType.ProjectGroup) { - return; + return false; } const group = treeItem.extra.data; @@ -361,7 +375,7 @@ export class DeepnoteExplorerView { }); if (!newName || newName === currentName) { - return; + return false; } try { @@ -378,7 +392,7 @@ export class DeepnoteExplorerView { ) ); - return; + return false; } } @@ -411,9 +425,13 @@ export class DeepnoteExplorerView { } else { await window.showInformationMessage(l10n.t('Project renamed to: {0}', newName)); } + + return failedCount === 0; } catch (error) { const errorMessage = error instanceof Error ? error.message : 'Unknown error'; await window.showErrorMessage(l10n.t('Failed to rename project: {0}', errorMessage)); + + return false; } } @@ -423,9 +441,10 @@ export class DeepnoteExplorerView { ); this.extensionContext.subscriptions.push( - commands.registerCommand(Commands.OpenDeepnoteNotebook, (context: DeepnoteTreeItemContext) => - this.openNotebook(context) - ) + commands.registerCommand(Commands.OpenDeepnoteNotebook, async (context: DeepnoteTreeItemContext) => { + const completed = await this.openNotebook(context); + this.analytics.trackEvent({ eventName: 'open_notebook', properties: { completed } }); + }) ); this.extensionContext.subscriptions.push( @@ -437,56 +456,77 @@ export class DeepnoteExplorerView { ); this.extensionContext.subscriptions.push( - commands.registerCommand(Commands.NewProject, () => this.newProject()) + commands.registerCommand(Commands.NewProject, async () => { + const completed = await this.newProject(); + this.analytics.trackEvent({ eventName: 'create_project', properties: { completed } }); + }) ); this.extensionContext.subscriptions.push( - commands.registerCommand(Commands.ImportNotebook, () => this.importNotebook()) + commands.registerCommand(Commands.ImportNotebook, async () => { + const completed = await this.importNotebook(); + this.analytics.trackEvent({ eventName: 'import_notebook', properties: { completed } }); + }) ); this.extensionContext.subscriptions.push( - commands.registerCommand(Commands.ImportJupyterNotebook, () => this.importJupyterNotebook()) + commands.registerCommand(Commands.ImportJupyterNotebook, async () => { + const completed = await this.importJupyterNotebook(); + this.analytics.trackEvent({ eventName: 'import_notebook', properties: { completed } }); + }) ); this.extensionContext.subscriptions.push( - commands.registerCommand(Commands.NewNotebook, () => this.newNotebook()) + commands.registerCommand(Commands.NewNotebook, async () => { + const completed = await this.newNotebook(); + this.analytics.trackEvent({ eventName: 'create_notebook', properties: { completed } }); + }) ); // Context menu commands for tree items this.extensionContext.subscriptions.push( - commands.registerCommand(Commands.RenameProject, (treeItem: DeepnoteTreeItem) => - this.renameProject(treeItem) - ) + commands.registerCommand(Commands.RenameProject, async (treeItem: DeepnoteTreeItem) => { + const completed = await this.renameProject(treeItem); + this.analytics.trackEvent({ eventName: 'rename_project', properties: { completed } }); + }) ); this.extensionContext.subscriptions.push( - commands.registerCommand(Commands.RenameNotebook, (treeItem: DeepnoteTreeItem) => - this.renameNotebook(treeItem) - ) + commands.registerCommand(Commands.RenameNotebook, async (treeItem: DeepnoteTreeItem) => { + const completed = await this.renameNotebook(treeItem); + this.analytics.trackEvent({ eventName: 'rename_notebook', properties: { completed } }); + }) ); this.extensionContext.subscriptions.push( - commands.registerCommand(Commands.DeleteNotebook, (treeItem: DeepnoteTreeItem) => - this.deleteNotebook(treeItem) - ) + commands.registerCommand(Commands.DeleteNotebook, async (treeItem: DeepnoteTreeItem) => { + const completed = await this.deleteNotebook(treeItem); + this.analytics.trackEvent({ eventName: 'delete_notebook', properties: { completed } }); + }) ); this.extensionContext.subscriptions.push( - commands.registerCommand(Commands.DuplicateNotebook, (treeItem: DeepnoteTreeItem) => - this.duplicateNotebook(treeItem) - ) + commands.registerCommand(Commands.DuplicateNotebook, async (treeItem: DeepnoteTreeItem) => { + const completed = await this.duplicateNotebook(treeItem); + this.analytics.trackEvent({ eventName: 'duplicate_notebook', properties: { completed } }); + }) ); this.extensionContext.subscriptions.push( - commands.registerCommand(Commands.AddNotebookToProject, (treeItem: DeepnoteTreeItem) => - this.addNotebookToProject(treeItem) - ) + commands.registerCommand(Commands.AddNotebookToProject, async (treeItem: DeepnoteTreeItem) => { + const completed = await this.addNotebookToProject(treeItem); + this.analytics.trackEvent({ eventName: 'create_notebook', properties: { completed } }); + }) ); this.extensionContext.subscriptions.push( - commands.registerCommand(Commands.ExportNotebook, (treeItem: DeepnoteTreeItem) => - this.exportNotebook(treeItem) - ) + commands.registerCommand(Commands.ExportNotebook, async (treeItem: DeepnoteTreeItem) => { + const completed = await this.exportNotebook(treeItem); + this.analytics.trackEvent({ + eventName: 'export_notebook', + properties: { completed, format: 'jupyter' } + }); + }) ); } @@ -664,7 +704,7 @@ export class DeepnoteExplorerView { this.treeDataProvider.refresh(); } - private async openNotebook(context: DeepnoteTreeItemContext): Promise { + private async openNotebook(context: DeepnoteTreeItemContext): Promise { try { const fileUri = Uri.file(context.filePath); const document = await workspace.openNotebookDocument(fileUri); @@ -673,10 +713,14 @@ export class DeepnoteExplorerView { preview: false, preserveFocus: false }); + + return true; } catch (error) { const errorMessage = error instanceof Error ? error.message : 'Unknown error'; await window.showErrorMessage(`Failed to open notebook: ${errorMessage}`); + + return false; } } @@ -740,7 +784,7 @@ export class DeepnoteExplorerView { } } - private async newProject(): Promise { + private async newProject(): Promise { if (!workspace.workspaceFolders || workspace.workspaceFolders.length === 0) { const selection = await window.showInformationMessage( l10n.t('No workspace folder is open. Would you like to open a folder?'), @@ -752,7 +796,7 @@ export class DeepnoteExplorerView { await commands.executeCommand('vscode.openFolder'); } - return; + return false; } const projectName = await window.showInputBox({ @@ -768,7 +812,7 @@ export class DeepnoteExplorerView { }); if (!projectName) { - return; + return false; } try { @@ -781,7 +825,7 @@ export class DeepnoteExplorerView { await workspace.fs.stat(fileUri); await window.showErrorMessage(l10n.t('A file named "{0}" already exists in this workspace.', fileName)); - return; + return false; } catch { // File doesn't exist, continue } @@ -835,20 +879,24 @@ export class DeepnoteExplorerView { preserveFocus: false, preview: false }); + + return true; } catch (error) { const errorMessage = error instanceof Error ? error.message : 'Unknown error'; await window.showErrorMessage(l10n.t(`Failed to create project: {0}`, errorMessage)); + + return false; } } - private async newNotebook(): Promise { + private async newNotebook(): Promise { const activeEditor = window.activeNotebookEditor; if (!activeEditor || activeEditor.notebook.notebookType !== 'deepnote') { await window.showErrorMessage(l10n.t('No active Deepnote file opened. Please open a Deepnote file first.')); - return; + return false; } const document = activeEditor.notebook; @@ -870,9 +918,13 @@ export class DeepnoteExplorerView { await window.showInformationMessage(l10n.t('Created new notebook: {0}', result.name)); } + + return result !== null; } catch (error) { const errorMessage = error instanceof Error ? error.message : 'Unknown error'; await window.showErrorMessage(l10n.t('Failed to add notebook: {0}', errorMessage)); + + return false; } } @@ -952,7 +1004,7 @@ export class DeepnoteExplorerView { }; } - private async importNotebook(): Promise { + private async importNotebook(): Promise { if (!workspace.workspaceFolders || workspace.workspaceFolders.length === 0) { const selection = await window.showInformationMessage( l10n.t('No workspace folder is open. Would you like to open a folder?'), @@ -964,7 +1016,7 @@ export class DeepnoteExplorerView { await commands.executeCommand('vscode.openFolder'); } - return; + return false; } const fileUris = await window.showOpenDialog({ @@ -978,7 +1030,7 @@ export class DeepnoteExplorerView { }); if (!fileUris || fileUris.length === 0) { - return; + return false; } try { @@ -998,14 +1050,14 @@ export class DeepnoteExplorerView { l10n.t('A file named "{0}" already exists in this workspace.', fileName) ); - return; + return false; } catch { // File doesn't exist, continue } } if (!(await this.checkJupyterImportTargetsAvailable(jupyterUris, workspaceFolder.uri))) { - return; + return false; } // Import deepnote files @@ -1029,14 +1081,18 @@ export class DeepnoteExplorerView { } this.treeDataProvider.refresh(); + + return numberOfNotebooks > 0; } catch (error) { const errorMessage = error instanceof Error ? error.message : 'Unknown error'; await window.showErrorMessage(`Failed to import notebook: ${errorMessage}`); + + return false; } } - private async importJupyterNotebook(): Promise { + private async importJupyterNotebook(): Promise { if (!workspace.workspaceFolders || workspace.workspaceFolders.length === 0) { const selection = await window.showInformationMessage( l10n.t('No workspace folder is open. Would you like to open a folder?'), @@ -1048,7 +1104,7 @@ export class DeepnoteExplorerView { await commands.executeCommand('vscode.openFolder'); } - return; + return false; } const fileUris = await window.showOpenDialog({ @@ -1062,14 +1118,14 @@ export class DeepnoteExplorerView { }); if (!fileUris || fileUris.length === 0) { - return; + return false; } try { const workspaceFolder = workspace.workspaceFolders[0]; if (!(await this.checkJupyterImportTargetsAvailable(fileUris, workspaceFolder.uri))) { - return; + return false; } const failedCount = await this.convertJupyterUrisToDeepnoteFiles(fileUris, workspaceFolder.uri); @@ -1085,16 +1141,20 @@ export class DeepnoteExplorerView { } this.treeDataProvider.refresh(); + + return numberOfNotebooks > 0; } catch (error) { const errorMessage = error instanceof Error ? error.message : 'Unknown error'; await window.showErrorMessage(l10n.t(`Failed to import Jupyter notebook: {0}`, errorMessage)); + + return false; } } - private async addNotebookToProject(treeItem: DeepnoteTreeItem): Promise { + private async addNotebookToProject(treeItem: DeepnoteTreeItem): Promise { if (treeItem.extra.type !== DeepnoteTreeItemType.ProjectGroup) { - return; + return false; } const group = treeItem.extra.data; @@ -1103,7 +1163,7 @@ export class DeepnoteExplorerView { if (!sourceFile) { await window.showErrorMessage(l10n.t('Invalid Deepnote file format')); - return; + return false; } try { @@ -1116,16 +1176,20 @@ export class DeepnoteExplorerView { this.treeDataProvider.refreshNotebook(treeItem.context.projectId); await window.showInformationMessage(l10n.t('Created new notebook: {0}', result.name)); } + + return result !== null; } catch (error) { const errorMessage = error instanceof Error ? error.message : 'Unknown error'; await window.showErrorMessage(l10n.t('Failed to add notebook: {0}', errorMessage)); + + return false; } } /** Exports a single notebook (single-notebook leaf or legacy in-file notebook) to Jupyter. */ - private async exportNotebook(treeItem: DeepnoteTreeItem): Promise { + private async exportNotebook(treeItem: DeepnoteTreeItem): Promise { if (!this.itemIsNotebookScoped(treeItem)) { - return; + return false; } try { @@ -1134,7 +1198,7 @@ export class DeepnoteExplorerView { }); if (!format) { - return; + return false; } const fileUri = Uri.file(treeItem.context.filePath); @@ -1143,7 +1207,7 @@ export class DeepnoteExplorerView { if (!projectData?.project) { await window.showErrorMessage(l10n.t('Invalid Deepnote file format')); - return; + return false; } const outputFolder = await window.showOpenDialog({ @@ -1155,7 +1219,7 @@ export class DeepnoteExplorerView { }); if (!outputFolder?.length) { - return; + return false; } const targetNotebook = this.resolveTargetNotebook(treeItem, projectData); @@ -1163,7 +1227,7 @@ export class DeepnoteExplorerView { if (!targetNotebook) { await window.showErrorMessage(l10n.t('Notebook not found')); - return; + return false; } const filteredProject = { @@ -1198,7 +1262,7 @@ export class DeepnoteExplorerView { ); if (result !== overwrite) { - return; + return false; } } @@ -1208,9 +1272,13 @@ export class DeepnoteExplorerView { ); await window.showInformationMessage(l10n.t('Exported 1 notebook successfully')); + + return true; } catch (error) { const errorMessage = error instanceof Error ? error.message : 'Unknown error'; await window.showErrorMessage(l10n.t('Failed to export: {0}', errorMessage)); + + return false; } } } diff --git a/src/notebooks/deepnote/deepnoteExplorerView.unit.test.ts b/src/notebooks/deepnote/deepnoteExplorerView.unit.test.ts index 9d0df7b06b..1d20b64a10 100644 --- a/src/notebooks/deepnote/deepnoteExplorerView.unit.test.ts +++ b/src/notebooks/deepnote/deepnoteExplorerView.unit.test.ts @@ -19,6 +19,7 @@ import { DeepnoteExplorerView } from './deepnoteExplorerView'; import { DeepnoteTreeDataProvider } from './deepnoteTreeDataProvider'; import { createWorkspaceFolder } from './deepnoteTestHelpers'; import { DeepnoteTreeItem, DeepnoteTreeItemType, type DeepnoteTreeItemContext } from './deepnoteTreeItem'; +import { ITelemetryService } from '../../platform/analytics/types'; import { Commands } from '../../platform/common/constants'; import type { IExtensionContext } from '../../platform/common/types'; import { mockedVSCodeNamespaces, resetVSCodeMocks } from '../../test/vscode-mock'; @@ -116,6 +117,7 @@ suite('DeepnoteExplorerView', () => { let explorerView: DeepnoteExplorerView; let mockExtensionContext: IExtensionContext; let mockLogger: ILogger; + let mockAnalytics: ITelemetryService; setup(() => { resetVSCodeMocks(); @@ -124,10 +126,12 @@ suite('DeepnoteExplorerView', () => { mockExtensionContext = makeExtensionContext(); mockLogger = createMockLogger(); + mockAnalytics = instance(mock()); explorerView = new DeepnoteExplorerView( mockExtensionContext, mockLogger, - new DeepnoteTreeDataProvider(mockLogger) + new DeepnoteTreeDataProvider(mockLogger), + mockAnalytics ); explorerView.activate(); }); @@ -244,6 +248,7 @@ suite('DeepnoteExplorerView - Empty State Commands', () => { let mockContext: IExtensionContext; let sandbox: sinon.SinonSandbox; let uuidStubs: sinon.SinonStub[] = []; + let mockAnalytics: ITelemetryService; setup(() => { sandbox = sinon.createSandbox(); @@ -254,7 +259,13 @@ suite('DeepnoteExplorerView - Empty State Commands', () => { mockContext = makeExtensionContext(); const mockLogger = createMockLogger(); - explorerView = new DeepnoteExplorerView(mockContext, mockLogger, new DeepnoteTreeDataProvider(mockLogger)); + mockAnalytics = instance(mock()); + explorerView = new DeepnoteExplorerView( + mockContext, + mockLogger, + new DeepnoteTreeDataProvider(mockLogger), + mockAnalytics + ); explorerView.activate(); }); @@ -484,7 +495,8 @@ suite('DeepnoteExplorerView - Empty State Commands', () => { explorerView = new importModule.DeepnoteExplorerView( mockContext, createMockLogger(), - new DeepnoteTreeDataProvider(createMockLogger()) + new DeepnoteTreeDataProvider(createMockLogger()), + instance(mock()) ); explorerView.activate(); }); @@ -674,7 +686,8 @@ suite('DeepnoteExplorerView - Empty State Commands', () => { explorerView = new importModule.DeepnoteExplorerView( mockContext, createMockLogger(), - new DeepnoteTreeDataProvider(createMockLogger()) + new DeepnoteTreeDataProvider(createMockLogger()), + instance(mock()) ); explorerView.activate(); }); @@ -742,7 +755,8 @@ suite('DeepnoteExplorerView - Empty State Commands', () => { const partialExplorer = new failingModule.DeepnoteExplorerView( mockContext, createMockLogger(), - new DeepnoteTreeDataProvider(createMockLogger()) + new DeepnoteTreeDataProvider(createMockLogger()), + instance(mock()) ); partialExplorer.activate(); @@ -797,7 +811,8 @@ suite('DeepnoteExplorerView - Empty State Commands', () => { const failedExplorer = new failingModule.DeepnoteExplorerView( mockContext, createMockLogger(), - new DeepnoteTreeDataProvider(createMockLogger()) + new DeepnoteTreeDataProvider(createMockLogger()), + instance(mock()) ); failedExplorer.activate(); @@ -2383,7 +2398,8 @@ suite('DeepnoteExplorerView - Sibling-file command semantics', () => { explorerView = new DeepnoteExplorerView( mockContext, createMockLogger(), - new DeepnoteTreeDataProvider(createMockLogger()) + new DeepnoteTreeDataProvider(createMockLogger()), + instance(mock()) ); explorerView.activate(); }); @@ -2459,7 +2475,12 @@ suite('DeepnoteExplorerView - Sibling-file command semantics', () => { uuidStubs.push(createUuidMock(['new-nb', 'new-group', 'new-block'])); const mockProvider = mock(); - explorerView = new DeepnoteExplorerView(mockContext, createMockLogger(), instance(mockProvider)); + explorerView = new DeepnoteExplorerView( + mockContext, + createMockLogger(), + instance(mockProvider), + instance(mock()) + ); explorerView.activate(); const treeItem: Partial = { diff --git a/src/notebooks/deepnote/deepnoteKernelAutoSelector.node.ts b/src/notebooks/deepnote/deepnoteKernelAutoSelector.node.ts index 8c0b93cc4a..fd8fe6449d 100644 --- a/src/notebooks/deepnote/deepnoteKernelAutoSelector.node.ts +++ b/src/notebooks/deepnote/deepnoteKernelAutoSelector.node.ts @@ -44,6 +44,7 @@ import { } from '../../kernels/jupyter/types'; import { IJupyterKernelSpec, IKernelProvider } from '../../kernels/types'; import { IExtensionSyncActivationService } from '../../platform/activation/types'; +import { ITelemetryService } from '../../platform/analytics/types'; import { IPythonExtensionChecker } from '../../platform/api/types'; import { Cancellation, isCancellationError } from '../../platform/common/cancellation'; import { JVSC_EXTENSION_ID, STANDARD_OUTPUT_CHANNEL } from '../../platform/common/constants'; @@ -98,7 +99,8 @@ export class DeepnoteKernelAutoSelector implements IDeepnoteKernelAutoSelector, private readonly notebookEnvironmentMapper: IDeepnoteNotebookEnvironmentMapper, @inject(IOutputChannel) @named(STANDARD_OUTPUT_CHANNEL) private readonly outputChannel: IOutputChannel, @inject(IDeepnoteToolkitInstaller) private readonly toolkitInstaller: IDeepnoteToolkitInstaller, - @inject(IServerHandleRegistry) private readonly serverHandleRegistry: IServerHandleRegistry + @inject(IServerHandleRegistry) private readonly serverHandleRegistry: IServerHandleRegistry, + @inject(ITelemetryService) private readonly analytics: ITelemetryService ) {} public activate() { @@ -766,6 +768,7 @@ export class DeepnoteKernelAutoSelector implements IDeepnoteKernelAutoSelector, Cancellation.throwIfCanceled(token); await this.notebookEnvironmentMapper.setEnvironmentForNotebook(notebook.uri, selectedEnvironment.id); + this.analytics.trackEvent({ eventName: 'select_environment' }); const result = await this.setupKernelForEnvironment(notebook, selectedEnvironment, notebookKey, token); diff --git a/src/notebooks/deepnote/deepnoteKernelAutoSelector.node.unit.test.ts b/src/notebooks/deepnote/deepnoteKernelAutoSelector.node.unit.test.ts index d5f71bc77c..7607d20a6c 100644 --- a/src/notebooks/deepnote/deepnoteKernelAutoSelector.node.unit.test.ts +++ b/src/notebooks/deepnote/deepnoteKernelAutoSelector.node.unit.test.ts @@ -2,6 +2,7 @@ import { assert } from 'chai'; import * as sinon from 'sinon'; import { anything, instance, mock, verify, when } from 'ts-mockito'; import { DeepnoteKernelAutoSelector } from './deepnoteKernelAutoSelector.node'; +import { ITelemetryService } from '../../platform/analytics/types'; import { createMockChildProcess } from '../../kernels/deepnote/deepnoteTestHelpers.node'; import { ServerHandleRegistry } from '../../kernels/deepnote/deepnoteServerHandleRegistry.node'; import { @@ -141,7 +142,8 @@ suite('DeepnoteKernelAutoSelector - rebuildController', () => { instance(mockNotebookEnvironmentMapper), instance(mockOutputChannel), instance(mockToolkitInstaller), - registry + registry, + instance(mock()) ); }); diff --git a/src/notebooks/deepnote/deepnoteMultiNotebookSplitter.ts b/src/notebooks/deepnote/deepnoteMultiNotebookSplitter.ts index 49da7ee7c6..0e9cbda899 100644 --- a/src/notebooks/deepnote/deepnoteMultiNotebookSplitter.ts +++ b/src/notebooks/deepnote/deepnoteMultiNotebookSplitter.ts @@ -2,6 +2,7 @@ import { l10n, TabInputNotebook, Uri, window, workspace, type Disposable, type N import { serializeDeepnoteFile } from '@deepnote/blocks'; import { isSingleNotebookDeepnoteFile, splitByNotebooks } from '@deepnote/convert'; +import { ITelemetryService } from '../../platform/analytics/types'; import { ILogger } from '../../platform/logging/types'; import type { IDeepnoteNotebookEnvironmentMapper } from '../../kernels/deepnote/types'; import { DEEPNOTE_NOTEBOOK_TYPE } from '../../kernels/deepnote/types'; @@ -35,16 +36,20 @@ export class DeepnoteMultiNotebookSplitter { private readonly refreshTree: () => void; + private readonly analytics: ITelemetryService; + constructor( envMapper: IDeepnoteNotebookEnvironmentMapper | undefined, refreshTree: () => void, logger: ILogger, - exists: (uri: Uri) => Promise + exists: (uri: Uri) => Promise, + analytics: ITelemetryService ) { this.envMapper = envMapper; this.refreshTree = refreshTree; this.logger = logger; this.exists = exists; + this.analytics = analytics; } public activate(): Disposable[] { @@ -102,14 +107,18 @@ export class DeepnoteMultiNotebookSplitter { ); if (selection === SPLIT_ACTION) { - await this.splitFile(fileUri); + const notebookCount = await this.splitFile(fileUri); + this.analytics.trackEvent({ + eventName: 'split_notebook', + properties: { completed: notebookCount > 0, notebookCount } + }); } } catch (error) { this.logger.error(`Failed to inspect Deepnote file for multi-notebook split: ${fileUri.toString()}`, error); } } - private async splitFile(fileUri: Uri): Promise { + private async splitFile(fileUri: Uri): Promise { // Compensations for each applied step, unwound in reverse on any failure so the split is all-or-nothing. const rollbacks: Array<() => Thenable> = []; let renamed = false; @@ -134,7 +143,7 @@ export class DeepnoteMultiNotebookSplitter { l10n.t('Could not save the file before splitting. The file was left unchanged.') ); - return; + return 0; } } @@ -193,6 +202,8 @@ export class DeepnoteMultiNotebookSplitter { this.refreshTree(); await window.showInformationMessage(l10n.t('Split into {0} files.', newUris.length)); + + return newUris.length; } catch (error) { // Unwind every applied step so the original is left as it was found (or an honest message if it can't be). this.logger.error(`Failed to split Deepnote file: ${fileUri.toString()}`, error); @@ -201,6 +212,8 @@ export class DeepnoteMultiNotebookSplitter { const errorMessage = error instanceof Error ? error.message : 'Unknown error'; await window.showErrorMessage(this.describeSplitFailure({ errorMessage, renamed, restored })); + + return 0; } } diff --git a/src/notebooks/deepnote/deepnoteMultiNotebookSplitter.unit.test.ts b/src/notebooks/deepnote/deepnoteMultiNotebookSplitter.unit.test.ts index dac3395cf0..cc04d13a91 100644 --- a/src/notebooks/deepnote/deepnoteMultiNotebookSplitter.unit.test.ts +++ b/src/notebooks/deepnote/deepnoteMultiNotebookSplitter.unit.test.ts @@ -3,6 +3,7 @@ import { assert } from 'chai'; import { anything, instance, mock, when } from 'ts-mockito'; import { EventEmitter, FileType, NotebookDocument, TabGroups, TabInputNotebook, Uri } from 'vscode'; +import { ITelemetryService } from '../../platform/analytics/types'; import type { IDeepnoteNotebookEnvironmentMapper } from '../../kernels/deepnote/types'; import type { ILogger } from '../../platform/logging/types'; import { mockedVSCodeNamespaces, resetVSCodeMocks } from '../../test/vscode-mock'; @@ -193,7 +194,8 @@ suite('DeepnoteMultiNotebookSplitter', () => { }, logger, // `exists` probe injected directly (mirrors deepnoteFileExists, but synchronous-set-backed). - (uri: Uri) => Promise.resolve(existingOnDisk.has(basename(uri))) + (uri: Uri) => Promise.resolve(existingOnDisk.has(basename(uri))), + instance(mock()) ); splitter.activate(); }); @@ -403,7 +405,8 @@ suite('DeepnoteMultiNotebookSplitter', () => { refreshTreeCount++; }, logger, - (uri: Uri) => Promise.resolve(existingOnDisk.has(basename(uri))) + (uri: Uri) => Promise.resolve(existingOnDisk.has(basename(uri))), + instance(mock()) ); splitterWithEnv.activate(); @@ -582,7 +585,8 @@ suite('DeepnoteMultiNotebookSplitter', () => { refreshTreeCount++; }, logger, - (uri: Uri) => Promise.resolve(existingOnDisk.has(basename(uri))) + (uri: Uri) => Promise.resolve(existingOnDisk.has(basename(uri))), + instance(mock()) ); envSplitter.activate(); diff --git a/src/notebooks/deepnote/deepnoteNotebookCommandListener.ts b/src/notebooks/deepnote/deepnoteNotebookCommandListener.ts index d91608c15a..27865247d0 100644 --- a/src/notebooks/deepnote/deepnoteNotebookCommandListener.ts +++ b/src/notebooks/deepnote/deepnoteNotebookCommandListener.ts @@ -17,6 +17,7 @@ import z from 'zod'; import { logger } from '../../platform/logging'; import { IExtensionSyncActivationService } from '../../platform/activation/types'; +import { ITelemetryService } from '../../platform/analytics/types'; import { IConfigurationService, IDisposableRegistry } from '../../platform/common/types'; import { Commands } from '../../platform/common/constants'; import { notebookUpdaterUtils } from '../../kernels/execution/notebookUpdater'; @@ -151,6 +152,7 @@ export function getNextDeepnoteVariableName(cells: NotebookCell[], prefix: 'df' @injectable() export class DeepnoteNotebookCommandListener implements IExtensionSyncActivationService { constructor( + @inject(ITelemetryService) private readonly analytics: ITelemetryService, @inject(IConfigurationService) private readonly configurationService: IConfigurationService, @inject(IDisposableRegistry) private readonly disposableRegistry: IDisposableRegistry ) {} @@ -264,6 +266,8 @@ export class DeepnoteNotebookCommandListener implements IExtensionSyncActivation throw new Error(l10n.t('Failed to insert SQL block')); } + this.trackAddBlock('sql'); + const notebookRange = new NotebookRange(insertIndex, insertIndex + 1); editor.revealRange(notebookRange, NotebookEditorRevealType.Default); editor.selection = notebookRange; @@ -305,6 +309,8 @@ export class DeepnoteNotebookCommandListener implements IExtensionSyncActivation throw new Error(l10n.t('Failed to insert big number chart block')); } + this.trackAddBlock('big-number'); + const notebookRange = new NotebookRange(insertIndex, insertIndex + 1); editor.revealRange(notebookRange, NotebookEditorRevealType.Default); editor.selection = notebookRange; @@ -359,6 +365,8 @@ export class DeepnoteNotebookCommandListener implements IExtensionSyncActivation throw new WrappedError(l10n.t('Failed to insert chart block')); } + this.trackAddBlock('visualization'); + const notebookRange = new NotebookRange(insertIndex, insertIndex + 1); editor.revealRange(notebookRange, NotebookEditorRevealType.Default); @@ -406,6 +414,8 @@ export class DeepnoteNotebookCommandListener implements IExtensionSyncActivation throw new Error(l10n.t('Failed to insert input block')); } + this.trackAddBlock(blockType); + const notebookRange = new NotebookRange(insertIndex, insertIndex + 1); editor.revealRange(notebookRange, NotebookEditorRevealType.Default); editor.selection = notebookRange; @@ -539,6 +549,8 @@ export class DeepnoteNotebookCommandListener implements IExtensionSyncActivation throw new Error(l10n.t('Failed to insert text block')); } + this.trackAddBlock(textBlockType); + const notebookRange = new NotebookRange(insertIndex, insertIndex + 1); editor.revealRange(notebookRange, NotebookEditorRevealType.Default); editor.selection = notebookRange; @@ -554,6 +566,7 @@ export class DeepnoteNotebookCommandListener implements IExtensionSyncActivation undefined, ConfigurationTarget.Workspace ); + this.analytics.trackEvent({ eventName: 'toggle_snapshots', properties: { enabled: false } }); void window.showInformationMessage(l10n.t('Snapshots disabled for this workspace.')); } catch (error) { logger.error('Failed to disable snapshots', error); @@ -569,9 +582,14 @@ export class DeepnoteNotebookCommandListener implements IExtensionSyncActivation undefined, ConfigurationTarget.Workspace ); + this.analytics.trackEvent({ eventName: 'toggle_snapshots', properties: { enabled: true } }); } catch (error) { logger.error('Failed to enable snapshots', error); void window.showErrorMessage(l10n.t('Failed to enable snapshots.')); } } + + private trackAddBlock(blockType: string): void { + this.analytics.trackEvent({ eventName: 'add_block', properties: { blockType } }); + } } diff --git a/src/notebooks/deepnote/deepnoteNotebookCommandListener.unit.test.ts b/src/notebooks/deepnote/deepnoteNotebookCommandListener.unit.test.ts index ff600a2bf9..dd54a44e5e 100644 --- a/src/notebooks/deepnote/deepnoteNotebookCommandListener.unit.test.ts +++ b/src/notebooks/deepnote/deepnoteNotebookCommandListener.unit.test.ts @@ -1,6 +1,6 @@ import { assert } from 'chai'; import * as sinon from 'sinon'; -import { when, reset, anything } from 'ts-mockito'; +import { when, reset, anything, mock, instance } from 'ts-mockito'; import { NotebookCell, NotebookDocument, @@ -18,6 +18,7 @@ import { InputBlockType } from './deepnoteNotebookCommandListener'; import { formatInputBlockCellContent, getInputBlockLanguage } from './inputBlockContentFormatter'; +import { ITelemetryService } from '../../platform/analytics/types'; import { IConfigurationService, IDisposable } from '../../platform/common/types'; import * as notebookUpdater from '../../kernels/execution/notebookUpdater'; import { createMockedNotebookDocument } from '../../test/datascience/editor-integration/helpers'; @@ -31,6 +32,7 @@ suite('DeepnoteNotebookCommandListener', () => { let disposables: IDisposable[]; let sandbox: sinon.SinonSandbox; let mockConfigService: IConfigurationService; + let mockTelemetryService: ITelemetryService; function createMockConfigService(): IConfigurationService { return { @@ -44,7 +46,12 @@ suite('DeepnoteNotebookCommandListener', () => { sandbox = sinon.createSandbox(); disposables = []; mockConfigService = createMockConfigService(); - commandListener = new DeepnoteNotebookCommandListener(mockConfigService, disposables); + mockTelemetryService = mock(); + commandListener = new DeepnoteNotebookCommandListener( + instance(mockTelemetryService), + mockConfigService, + disposables + ); }); teardown(() => { @@ -89,7 +96,11 @@ suite('DeepnoteNotebookCommandListener', () => { // Create new instance and activate again const disposables2: IDisposable[] = []; - const commandListener2 = new DeepnoteNotebookCommandListener(createMockConfigService(), disposables2); + const commandListener2 = new DeepnoteNotebookCommandListener( + instance(mockTelemetryService), + createMockConfigService(), + disposables2 + ); commandListener2.activate(); // Both should register the same number of commands diff --git a/src/notebooks/deepnote/integrations/integrationWebview.ts b/src/notebooks/deepnote/integrations/integrationWebview.ts index 0ba25f8c5f..15dae3772f 100644 --- a/src/notebooks/deepnote/integrations/integrationWebview.ts +++ b/src/notebooks/deepnote/integrations/integrationWebview.ts @@ -3,6 +3,7 @@ import { commands, Disposable, l10n, Uri, ViewColumn, WebviewPanel, window } fro import { BigQueryAuthMethods } from '@deepnote/database-integrations'; +import { ITelemetryService, TelemetryEventName } from '../../../platform/analytics/types'; import { Commands } from '../../../platform/common/constants'; import { IDisposableRegistry, IExtensionContext } from '../../../platform/common/types'; import * as localize from '../../../platform/common/utils/localize'; @@ -42,6 +43,7 @@ export class IntegrationWebviewProvider implements IIntegrationWebviewProvider { @inject(IExtensionContext) private readonly extensionContext: IExtensionContext, @inject(IIntegrationStorage) private readonly integrationStorage: IIntegrationStorage, @inject(IDeepnoteNotebookManager) private readonly notebookManager: IDeepnoteNotebookManager, + @inject(ITelemetryService) private readonly analytics: ITelemetryService, @inject(IDisposableRegistry) private readonly disposableRegistry: IDisposableRegistry, @inject(IFederatedAuthTokenStorage) @optional() @@ -562,6 +564,10 @@ export class IntegrationWebviewProvider implements IIntegrationWebviewProvider { } } + private trackIntegrationEvent(eventName: TelemetryEventName, integrationType: string | undefined): void { + this.analytics.trackEvent({ eventName, properties: { integrationType: integrationType ?? 'unknown' } }); + } + /** Handle messages from the webview; mirrors the `WebviewOutboundMessage` union in `src/webviews/webview-side/integrations/types.ts`. */ private async handleMessage(message: { type: string; @@ -571,26 +577,45 @@ export class IntegrationWebviewProvider implements IIntegrationWebviewProvider { switch (message.type) { case 'configure': if (message.integrationId) { + const integrationType = this.integrations.get(message.integrationId)?.integrationType; + this.trackIntegrationEvent('configure_integration', integrationType); await this.showConfigurationForm(message.integrationId); } break; case 'save': if (message.integrationId && message.config) { - await this.saveConfiguration(message.integrationId, message.config); + const saved = await this.saveConfiguration(message.integrationId, message.config); + + if (saved) { + this.trackIntegrationEvent('save_integration', message.config.type); + } } break; case 'reset': if (message.integrationId) { - await this.resetConfiguration(message.integrationId); + const integrationType = this.integrations.get(message.integrationId)?.integrationType; + const reset = await this.resetConfiguration(message.integrationId); + + if (reset) { + this.trackIntegrationEvent('reset_integration', integrationType); + } } break; case 'delete': if (message.integrationId) { - await this.deleteConfiguration(message.integrationId); + const integrationType = this.integrations.get(message.integrationId)?.integrationType; + const deleted = await this.deleteConfiguration(message.integrationId); + + if (deleted) { + this.trackIntegrationEvent('delete_integration', integrationType); + } } break; case 'authenticate': if (message.integrationId) { + const integrationType = this.integrations.get(message.integrationId)?.integrationType; + this.trackIntegrationEvent('authenticate_integration', integrationType); + try { await commands.executeCommand(Commands.AuthenticateIntegration, message.integrationId); } catch (error) { @@ -629,7 +654,7 @@ export class IntegrationWebviewProvider implements IIntegrationWebviewProvider { private async saveConfiguration( integrationId: string, config: ConfigurableDatabaseIntegrationConfig - ): Promise { + ): Promise { try { // Invalidate stale federated tokens before saving (fingerprint change or auth-method switch). await this.invalidateStaleFederatedToken(integrationId, config); @@ -665,6 +690,8 @@ export class IntegrationWebviewProvider implements IIntegrationWebviewProvider { type: 'success' }); } + + return persisted; } catch (error) { logger.error('Failed to save integration configuration', error); await this.currentPanel?.webview.postMessage({ @@ -674,13 +701,15 @@ export class IntegrationWebviewProvider implements IIntegrationWebviewProvider { ), type: 'error' }); + + return false; } } /** * Reset the configuration for an integration (clears credentials but keeps the integration entry) */ - private async resetConfiguration(integrationId: string): Promise { + private async resetConfiguration(integrationId: string): Promise { try { await this.integrationStorage.delete(integrationId); await this.tokenStorage?.delete(integrationId).catch((error) => { @@ -705,6 +734,8 @@ export class IntegrationWebviewProvider implements IIntegrationWebviewProvider { type: 'success' }); } + + return persisted; } catch (error) { logger.error('Failed to reset integration configuration', error); await this.currentPanel?.webview.postMessage({ @@ -714,13 +745,15 @@ export class IntegrationWebviewProvider implements IIntegrationWebviewProvider { ), type: 'error' }); + + return false; } } /** * Delete the integration completely (removes credentials and integration entry) */ - private async deleteConfiguration(integrationId: string): Promise { + private async deleteConfiguration(integrationId: string): Promise { try { await this.integrationStorage.delete(integrationId); await this.tokenStorage?.delete(integrationId).catch((error) => { @@ -740,6 +773,8 @@ export class IntegrationWebviewProvider implements IIntegrationWebviewProvider { type: 'success' }); } + + return persisted; } catch (error) { logger.error('Failed to delete integration', error); await this.currentPanel?.webview.postMessage({ @@ -749,6 +784,8 @@ export class IntegrationWebviewProvider implements IIntegrationWebviewProvider { ), type: 'error' }); + + return false; } } diff --git a/src/notebooks/deepnote/integrations/integrationWebview.unit.test.ts b/src/notebooks/deepnote/integrations/integrationWebview.unit.test.ts index 51d2b67bbb..1792b272d9 100644 --- a/src/notebooks/deepnote/integrations/integrationWebview.unit.test.ts +++ b/src/notebooks/deepnote/integrations/integrationWebview.unit.test.ts @@ -3,6 +3,7 @@ import sinon from 'sinon'; import { EventEmitter, Uri } from 'vscode'; import { anyString, anything, instance, mock, reset, verify, when } from 'ts-mockito'; +import { ITelemetryService } from '../../../platform/analytics/types'; import { IExtensionContext, IDisposable } from '../../../platform/common/types'; import { Commands } from '../../../platform/common/constants'; import { IDeepnoteNotebookManager } from '../../types'; @@ -156,6 +157,7 @@ suite('IntegrationWebviewProvider', () => { instance(extensionContext), instance(integrationStorage), instance(notebookManager), + instance(mock()), extensionSubscriptions, opts.tokenStorage ); diff --git a/src/notebooks/deepnote/openInDeepnoteHandler.node.ts b/src/notebooks/deepnote/openInDeepnoteHandler.node.ts index 1d21340507..355209a0ec 100644 --- a/src/notebooks/deepnote/openInDeepnoteHandler.node.ts +++ b/src/notebooks/deepnote/openInDeepnoteHandler.node.ts @@ -4,6 +4,7 @@ import { injectable, inject } from 'inversify'; import { commands, window, Uri, env, l10n } from 'vscode'; import { IExtensionSyncActivationService } from '../../platform/activation/types'; +import { ITelemetryService } from '../../platform/analytics/types'; import { IExtensionContext } from '../../platform/common/types'; import { Commands } from '../../platform/common/constants'; import { logger } from '../../platform/logging'; @@ -13,15 +14,21 @@ import { initImport, uploadFile, getErrorMessage, MAX_FILE_SIZE, getDeepnoteDoma @injectable() export class OpenInDeepnoteHandler implements IExtensionSyncActivationService { - constructor(@inject(IExtensionContext) private readonly extensionContext: IExtensionContext) {} + constructor( + @inject(IExtensionContext) private readonly extensionContext: IExtensionContext, + @inject(ITelemetryService) private readonly analytics: ITelemetryService + ) {} public activate(): void { this.extensionContext.subscriptions.push( - commands.registerCommand(Commands.OpenInDeepnote, () => this.handleOpenInDeepnote()) + commands.registerCommand(Commands.OpenInDeepnote, async () => { + const completed = await this.handleOpenInDeepnote(); + this.analytics.trackEvent({ eventName: 'open_in_deepnote', properties: { completed } }); + }) ); } - private async handleOpenInDeepnote(): Promise { + private async handleOpenInDeepnote(): Promise { try { let fileUri: Uri | undefined; let isNotebook = false; @@ -39,7 +46,7 @@ export class OpenInDeepnoteHandler implements IExtensionSyncActivationService { const activeEditor = window.activeTextEditor; if (!activeEditor) { void window.showErrorMessage('Please open a .deepnote file first'); - return; + return false; } fileUri = activeEditor.document.uri; @@ -47,7 +54,7 @@ export class OpenInDeepnoteHandler implements IExtensionSyncActivationService { if (!fileUri.fsPath.endsWith('.deepnote')) { void window.showErrorMessage('This command only works with .deepnote files'); - return; + return false; } if (isNotebook) { @@ -58,7 +65,7 @@ export class OpenInDeepnoteHandler implements IExtensionSyncActivationService { const saved = await activeEditor.document.save(); if (!saved) { void window.showErrorMessage('Please save the file before opening in Deepnote'); - return; + return false; } } } @@ -71,12 +78,12 @@ export class OpenInDeepnoteHandler implements IExtensionSyncActivationService { const stats = await fs.promises.stat(filePath); if (stats.size > MAX_FILE_SIZE) { void window.showErrorMessage(`File exceeds ${MAX_FILE_SIZE / (1024 * 1024)}MB limit`); - return; + return false; } const fileBuffer = await fs.promises.readFile(filePath); - await window.withProgress( + return await window.withProgress( { location: { viewId: 'workbench.view.extension.deepnoteExplorer' }, title: l10n.t('Opening in Deepnote'), @@ -105,10 +112,14 @@ export class OpenInDeepnoteHandler implements IExtensionSyncActivationService { void window.showInformationMessage('Opening in Deepnote...'); logger.info('Successfully opened file in Deepnote'); + + return true; } catch (error) { logger.error('Failed to open in Deepnote', error); const errorMessage = getErrorMessage(error); void window.showErrorMessage(`Failed to open in Deepnote: ${errorMessage}`); + + return false; } } ); @@ -116,6 +127,8 @@ export class OpenInDeepnoteHandler implements IExtensionSyncActivationService { logger.error('Error in handleOpenInDeepnote', error); const errorMessage = getErrorMessage(error); void window.showErrorMessage(`Failed to open in Deepnote: ${errorMessage}`); + + return false; } } } diff --git a/src/notebooks/deepnote/openInDeepnoteHandler.node.unit.test.ts b/src/notebooks/deepnote/openInDeepnoteHandler.node.unit.test.ts index 8b7d0240d5..c501940389 100644 --- a/src/notebooks/deepnote/openInDeepnoteHandler.node.unit.test.ts +++ b/src/notebooks/deepnote/openInDeepnoteHandler.node.unit.test.ts @@ -6,6 +6,7 @@ import * as fs from 'fs'; import esmock from 'esmock'; import type { OpenInDeepnoteHandler } from './openInDeepnoteHandler.node'; +import { ITelemetryService } from '../../platform/analytics/types'; import { IExtensionContext } from '../../platform/common/types'; import { mockedVSCodeNamespaces, resetVSCodeMocks } from '../../test/vscode-mock'; import { MAX_FILE_SIZE } from './importClient.node'; @@ -49,7 +50,7 @@ suite('OpenInDeepnoteHandler', () => { subscriptions: [] } as any; - handler = new OpenInDeepnoteHandlerClass(mockExtensionContext); + handler = new OpenInDeepnoteHandlerClass(mockExtensionContext, instance(mock())); }); teardown(() => { diff --git a/src/notebooks/deepnote/sqlCellStatusBarProvider.ts b/src/notebooks/deepnote/sqlCellStatusBarProvider.ts index 5bf312977c..2546ded250 100644 --- a/src/notebooks/deepnote/sqlCellStatusBarProvider.ts +++ b/src/notebooks/deepnote/sqlCellStatusBarProvider.ts @@ -19,6 +19,7 @@ import { import { inject, injectable } from 'inversify'; import { IExtensionSyncActivationService } from '../../platform/activation/types'; +import { ITelemetryService } from '../../platform/analytics/types'; import { IDisposableRegistry } from '../../platform/common/types'; import { IIntegrationStorage } from './integrations/types'; import { Commands } from '../../platform/common/constants'; @@ -69,7 +70,8 @@ export class SqlCellStatusBarProvider implements NotebookCellStatusBarItemProvid constructor( @inject(IDisposableRegistry) private readonly disposables: IDisposableRegistry, @inject(IIntegrationStorage) private readonly integrationStorage: IIntegrationStorage, - @inject(IDeepnoteNotebookManager) private readonly notebookManager: IDeepnoteNotebookManager + @inject(IDeepnoteNotebookManager) private readonly notebookManager: IDeepnoteNotebookManager, + @inject(ITelemetryService) private readonly analytics: ITelemetryService ) {} public activate(): void { @@ -459,5 +461,14 @@ export class SqlCellStatusBarProvider implements NotebookCellStatusBarItemProvid // Trigger status bar update this._onDidChangeCellStatusBarItems.fire(); + + const selectedIntegration = projectIntegrations.find((i) => i.id === selectedId); + this.analytics.trackEvent({ + eventName: 'switch_sql_integration', + properties: { + integrationType: + selectedId === DATAFRAME_SQL_INTEGRATION_ID ? 'duckdb' : selectedIntegration?.type ?? 'unknown' + } + }); } } diff --git a/src/notebooks/deepnote/sqlCellStatusBarProvider.unit.test.ts b/src/notebooks/deepnote/sqlCellStatusBarProvider.unit.test.ts index f729453582..b52592940b 100644 --- a/src/notebooks/deepnote/sqlCellStatusBarProvider.unit.test.ts +++ b/src/notebooks/deepnote/sqlCellStatusBarProvider.unit.test.ts @@ -4,6 +4,7 @@ import { CancellationToken, CancellationTokenSource, EventEmitter, NotebookCell import { IDisposableRegistry } from '../../platform/common/types'; import { IIntegrationStorage } from './integrations/types'; +import { ITelemetryService } from '../../platform/analytics/types'; import { SqlCellStatusBarProvider } from './sqlCellStatusBarProvider'; import { DATAFRAME_SQL_INTEGRATION_ID } from '../../platform/notebooks/deepnote/integrationTypes'; import { mockedVSCodeNamespaces, resetVSCodeMocks } from '../../test/vscode-mock'; @@ -23,7 +24,12 @@ suite('SqlCellStatusBarProvider', () => { disposables = []; integrationStorage = mock(); notebookManager = mock(); - provider = new SqlCellStatusBarProvider(disposables, instance(integrationStorage), instance(notebookManager)); + provider = new SqlCellStatusBarProvider( + disposables, + instance(integrationStorage), + instance(notebookManager), + instance(mock()) + ); const tokenSource = new CancellationTokenSource(); cancellationToken = tokenSource.token; @@ -304,7 +310,8 @@ suite('SqlCellStatusBarProvider', () => { activateProvider = new SqlCellStatusBarProvider( activateDisposables, instance(activateIntegrationStorage), - instance(activateNotebookManager) + instance(activateNotebookManager), + instance(mock()) ); }); @@ -540,7 +547,8 @@ suite('SqlCellStatusBarProvider', () => { eventProvider = new SqlCellStatusBarProvider( eventDisposables, instance(eventIntegrationStorage), - instance(eventNotebookManager) + instance(eventNotebookManager), + instance(mock()) ); }); @@ -668,7 +676,8 @@ suite('SqlCellStatusBarProvider', () => { commandProvider = new SqlCellStatusBarProvider( commandDisposables, instance(commandIntegrationStorage), - instance(commandNotebookManager) + instance(commandNotebookManager), + instance(mock()) ); // Capture the command handler @@ -809,7 +818,8 @@ suite('SqlCellStatusBarProvider', () => { commandProvider = new SqlCellStatusBarProvider( commandDisposables, instance(commandIntegrationStorage), - instance(commandNotebookManager) + instance(commandNotebookManager), + instance(mock()) ); // Capture the command handler diff --git a/src/notebooks/notebookCommandListener.ts b/src/notebooks/notebookCommandListener.ts index 04907cb1ca..1a1b8da0ea 100644 --- a/src/notebooks/notebookCommandListener.ts +++ b/src/notebooks/notebookCommandListener.ts @@ -33,6 +33,7 @@ import { getNotebookMetadata } from '../platform/common/utils'; import { KernelConnector } from './controllers/kernelConnector'; import { IControllerRegistration } from './controllers/types'; import { IExtensionSyncActivationService } from '../platform/activation/types'; +import { ITelemetryService } from '../platform/analytics/types'; import { IKernelStatusProvider } from '../kernels/kernelStatusProvider'; export const INotebookCommandHandler = Symbol('INotebookCommandHandler'); @@ -54,7 +55,8 @@ export class NotebookCommandListener implements INotebookCommandHandler, IExtens @inject(IDataScienceErrorHandler) private errorHandler: IDataScienceErrorHandler, @inject(INotebookEditorProvider) private notebookEditorProvider: INotebookEditorProvider, @inject(IServiceContainer) private serviceContainer: IServiceContainer, - @inject(IKernelStatusProvider) private kernelStatusProvider: IKernelStatusProvider + @inject(IKernelStatusProvider) private kernelStatusProvider: IKernelStatusProvider, + @inject(ITelemetryService) private readonly analytics: ITelemetryService ) {} activate(): void { @@ -113,7 +115,11 @@ export class NotebookCommandListener implements INotebookCommandHandler, IExtens } private runAllCells() { - if (window.activeNotebookEditor) { + const editor = window.activeNotebookEditor; + if (editor) { + if (editor.notebook.notebookType === 'deepnote') { + this.analytics.trackEvent({ eventName: 'execute_notebook' }); + } commands.executeCommand('notebook.execute').then(noop, noop); } } @@ -140,7 +146,11 @@ export class NotebookCommandListener implements INotebookCommandHandler, IExtens } private addCellBelow() { - if (window.activeNotebookEditor) { + const editor = window.activeNotebookEditor; + if (editor) { + if (editor.notebook.notebookType === 'deepnote') { + this.analytics.trackEvent({ eventName: 'add_block', properties: { blockType: 'code' } }); + } commands.executeCommand('notebook.cell.insertCodeCellBelow').then(noop, noop); } } diff --git a/src/notebooks/serviceRegistry.node.ts b/src/notebooks/serviceRegistry.node.ts index eb87ce58a3..b429263154 100644 --- a/src/notebooks/serviceRegistry.node.ts +++ b/src/notebooks/serviceRegistry.node.ts @@ -92,6 +92,7 @@ import { DeepnoteEnvironmentsView } from '../kernels/deepnote/environments/deepn import { DeepnoteEnvironmentsActivationService } from '../kernels/deepnote/environments/deepnoteEnvironmentsActivationService'; import { DeepnoteExtensionSidecarWriter } from '../kernels/deepnote/environments/deepnoteExtensionSidecarWriter.node'; import { DeepnoteNotebookEnvironmentMapper } from '../kernels/deepnote/environments/deepnoteNotebookEnvironmentMapper.node'; +import { DeepnoteCellExecutionAnalytics } from './deepnote/deepnoteCellExecutionAnalytics'; import { DeepnoteNotebookCommandListener } from './deepnote/deepnoteNotebookCommandListener'; import { DeepnoteInputBlockCellStatusBarItemProvider } from './deepnote/deepnoteInputBlockCellStatusBarProvider'; import { DeepnoteBigNumberCellStatusBarProvider } from './deepnote/deepnoteBigNumberCellStatusBarProvider'; @@ -183,6 +184,10 @@ export function registerTypes(serviceManager: IServiceManager, isDevMode: boolea IExtensionSyncActivationService, DeepnoteNotebookCommandListener ); + serviceManager.addSingleton( + IExtensionSyncActivationService, + DeepnoteCellExecutionAnalytics + ); serviceManager.addSingleton(IDeepnoteNotebookManager, DeepnoteNotebookManager); // Bind the platform-layer interface to the same implementation serviceManager.addBinding(IDeepnoteNotebookManager, IPlatformDeepnoteNotebookManager); diff --git a/src/platform/analytics/constants.ts b/src/platform/analytics/constants.ts new file mode 100644 index 0000000000..9d7afe74fd --- /dev/null +++ b/src/platform/analytics/constants.ts @@ -0,0 +1,14 @@ +// Substituted at build time from the POSTHOG_API_KEY CI secret (see build/esbuild/build.ts). +// Left undefined in local builds, where telemetry falls back to this inert placeholder. +declare const POSTHOG_API_KEY_BUILD: string | undefined; + +const POSTHOG_API_KEY_PLACEHOLDER = '__POSTHOG_API_KEY__'; + +export const POSTHOG_API_KEY = + typeof POSTHOG_API_KEY_BUILD !== 'undefined' && POSTHOG_API_KEY_BUILD + ? POSTHOG_API_KEY_BUILD + : POSTHOG_API_KEY_PLACEHOLDER; +export const POSTHOG_HOST = 'https://us.i.posthog.com'; + +// Guards against initializing PostHog with the inert placeholder key in local/unconfigured builds. +export const IS_POSTHOG_CONFIGURED = POSTHOG_API_KEY !== POSTHOG_API_KEY_PLACEHOLDER && POSTHOG_API_KEY.length > 0; diff --git a/src/platform/analytics/noOpTelemetryService.ts b/src/platform/analytics/noOpTelemetryService.ts new file mode 100644 index 0000000000..db39a2d7de --- /dev/null +++ b/src/platform/analytics/noOpTelemetryService.ts @@ -0,0 +1,14 @@ +import { ITelemetryService, TelemetryEvent } from './types'; + +/** + * No-op telemetry service for use in tests. + */ +export class NoOpTelemetryService implements ITelemetryService { + public async dispose(): Promise { + // No-op + } + + public trackEvent(_event: TelemetryEvent): void { + // No-op + } +} diff --git a/src/platform/analytics/telemetryService.ts b/src/platform/analytics/telemetryService.ts new file mode 100644 index 0000000000..991764adc6 --- /dev/null +++ b/src/platform/analytics/telemetryService.ts @@ -0,0 +1,123 @@ +import { inject, injectable } from 'inversify'; +import { PostHog } from 'posthog-node'; +import { workspace } from 'vscode'; + +import { IExtensionSyncActivationService } from '../activation/types'; +import { + IAsyncDisposableRegistry, + IDisposableRegistry, + IPersistentState, + IPersistentStateFactory +} from '../common/types'; +import { generateUuid } from '../common/uuid'; +import { logger } from '../logging'; +import { IS_POSTHOG_CONFIGURED, POSTHOG_API_KEY, POSTHOG_HOST } from './constants'; +import { ITelemetryService, TelemetryEvent } from './types'; + +const USER_ID_STORAGE_KEY = 'deepnote-telemetry-anonymous-user-id'; +const POSTHOG_SHUTDOWN_TIMEOUT = 5000; + +@injectable() +export class TelemetryService implements ITelemetryService, IExtensionSyncActivationService { + private client: PostHog | null; + + private userIdState: IPersistentState; + + constructor( + @inject(IDisposableRegistry) private readonly disposables: IDisposableRegistry, + @inject(IPersistentStateFactory) private readonly stateFactory: IPersistentStateFactory, + @inject(IAsyncDisposableRegistry) asyncDisposables: IAsyncDisposableRegistry + ) { + asyncDisposables.push(this); + this.client = null; + this.userIdState = this.stateFactory.createGlobalPersistentState(USER_ID_STORAGE_KEY, generateUuid()); + } + + public async activate(): Promise { + try { + this.createClient(); + } catch (error) { + logger.debug(`TelemetryService activation error: ${error}`); + } + + this.disposables.push( + workspace.onDidChangeConfiguration((e) => { + if (e.affectsConfiguration('telemetry') || e.affectsConfiguration('deepnote.telemetry')) { + this.handleConfigChanged(); + } + }) + ); + } + + public async dispose(): Promise { + await this.destroyClient(); + } + + public trackEvent({ eventName, properties }: TelemetryEvent): void { + try { + if (!this.client || !this.userIdState) { + return; + } + + this.client.capture({ + distinctId: this.userIdState.value, + event: eventName, + properties + }); + } catch (ex) { + logger.debug(`PostHog analytics error: ${ex}`); + } + } + + private createClient(): void { + if (this.client || !this.isPostHogConfigured() || !this.isTelemetryEnabled()) { + return; + } + + this.client = new PostHog(POSTHOG_API_KEY, { + flushAt: 20, + flushInterval: 30000, + host: POSTHOG_HOST + }); + } + + private async destroyClient(): Promise { + const client = this.client; + this.client = null; + + if (!client) { + return; + } + + try { + await client.shutdown(POSTHOG_SHUTDOWN_TIMEOUT); + } catch (ex) { + logger.debug(`PostHog shutdown error: ${ex}`); + } + } + + private isPostHogConfigured(): boolean { + return IS_POSTHOG_CONFIGURED; + } + + private isTelemetryEnabled(): boolean { + const telemetryLevel = workspace.getConfiguration('telemetry').get('telemetryLevel', 'all'); + + if (telemetryLevel !== 'all') { + return false; + } + + return workspace.getConfiguration('deepnote').get('telemetry.enabled', true); + } + + private handleConfigChanged(): void { + if (this.isTelemetryEnabled()) { + this.createClient(); + } else { + this.destroyClient().catch((error) => { + logger.error(`Failed to destroy PostHog client: ${error}`); + this.client = null; + }); + } + } +} diff --git a/src/platform/analytics/telemetryService.unit.test.ts b/src/platform/analytics/telemetryService.unit.test.ts new file mode 100644 index 0000000000..65fac3948e --- /dev/null +++ b/src/platform/analytics/telemetryService.unit.test.ts @@ -0,0 +1,199 @@ +import { assert } from 'chai'; +import * as sinon from 'sinon'; + +import { + IAsyncDisposableRegistry, + IDisposableRegistry, + IPersistentState, + IPersistentStateFactory +} from '../common/types'; +import { TelemetryService } from './telemetryService'; + +suite('TelemetryService', () => { + let analyticsService: TelemetryService; + let mockDisposables: IDisposableRegistry; + let mockStateFactory: IPersistentStateFactory; + let mockAsyncDisposableRegistry: IAsyncDisposableRegistry; + let mockUserIdState: IPersistentState; + + function createMockPersistentState(initialValue: string): IPersistentState { + let storedValue = initialValue; + + return { + get value() { + return storedValue; + }, + updateValue: sinon.stub().callsFake(async (newValue: string) => { + storedValue = newValue; + }) + }; + } + + // eslint-disable-next-line @typescript-eslint/no-explicit-any + function getPostHogClient(service: TelemetryService): any { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + return (service as any).client; + } + + function stubTelemetryEnabled(service: TelemetryService, enabled: boolean): void { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + (service as any).isTelemetryEnabled = () => enabled; + } + + function stubPostHogConfigured(service: TelemetryService, configured: boolean): void { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + (service as any).isPostHogConfigured = () => configured; + } + + setup(() => { + mockUserIdState = createMockPersistentState(''); + mockDisposables = []; + mockStateFactory = { + createGlobalPersistentState: sinon.stub().returns(mockUserIdState), + createWorkspacePersistentState: sinon.stub().returns(mockUserIdState) + } as unknown as IPersistentStateFactory; + mockAsyncDisposableRegistry = { + push: sinon.stub(), + dispose: sinon.stub().resolves() + }; + }); + + test('should create instance without errors', () => { + analyticsService = new TelemetryService(mockDisposables, mockStateFactory, mockAsyncDisposableRegistry); + + assert.isDefined(analyticsService); + }); + + test('activate should not create client when telemetry is disabled', async () => { + analyticsService = new TelemetryService(mockDisposables, mockStateFactory, mockAsyncDisposableRegistry); + stubTelemetryEnabled(analyticsService, false); + + await analyticsService.activate(); + + assert.isNull(getPostHogClient(analyticsService), 'PostHog client should not be created'); + assert.isTrue( + (mockStateFactory.createGlobalPersistentState as sinon.SinonStub).calledOnce, + 'Should still create persistent state during construction' + ); + }); + + test('activate should create client when telemetry is enabled', async () => { + analyticsService = new TelemetryService(mockDisposables, mockStateFactory, mockAsyncDisposableRegistry); + stubTelemetryEnabled(analyticsService, true); + stubPostHogConfigured(analyticsService, true); + + await analyticsService.activate(); + + const client = getPostHogClient(analyticsService); + + assert.isDefined(client, 'PostHog client should be initialized'); + }); + + test('activate should not create client when PostHog is not configured', async () => { + analyticsService = new TelemetryService(mockDisposables, mockStateFactory, mockAsyncDisposableRegistry); + stubTelemetryEnabled(analyticsService, true); + stubPostHogConfigured(analyticsService, false); + + await analyticsService.activate(); + + assert.isNull( + getPostHogClient(analyticsService), + 'PostHog client should not be created with the placeholder key' + ); + }); + + test('should generate user ID and call PostHog capture on first trackEvent', async () => { + (mockStateFactory.createGlobalPersistentState as sinon.SinonStub).callsFake( + (_key: string, defaultValue: string) => createMockPersistentState(defaultValue) + ); + + analyticsService = new TelemetryService(mockDisposables, mockStateFactory, mockAsyncDisposableRegistry); + stubTelemetryEnabled(analyticsService, true); + stubPostHogConfigured(analyticsService, true); + + await analyticsService.activate(); + + const createStateSpy = mockStateFactory.createGlobalPersistentState as sinon.SinonStub; + + assert.isTrue(createStateSpy.calledOnce, 'Should create persistent state'); + + const generatedId = createStateSpy.firstCall.args[1]; + + assert.isString(generatedId); + assert.isNotEmpty(generatedId, 'Generated user ID should not be empty'); + + const client = getPostHogClient(analyticsService); + + assert.isDefined(client, 'PostHog client should be initialized'); + + const captureStub = sinon.stub(); + client.capture = captureStub; + + analyticsService.trackEvent({ eventName: 'execute_notebook' }); + + assert.isTrue(captureStub.calledOnce, 'PostHog capture should be called'); + assert.deepStrictEqual(captureStub.firstCall.args[0], { + distinctId: generatedId, + event: 'execute_notebook', + properties: undefined + }); + }); + + test('should reuse existing user ID', async () => { + mockUserIdState = createMockPersistentState('existing-user-id'); + (mockStateFactory.createGlobalPersistentState as sinon.SinonStub).returns(mockUserIdState); + + analyticsService = new TelemetryService(mockDisposables, mockStateFactory, mockAsyncDisposableRegistry); + stubTelemetryEnabled(analyticsService, true); + + await analyticsService.activate(); + + assert.isFalse( + (mockUserIdState.updateValue as sinon.SinonStub).called, + 'Should not update value when user ID already exists' + ); + }); + + test('settings change should destroy client when telemetry is disabled', async () => { + analyticsService = new TelemetryService(mockDisposables, mockStateFactory, mockAsyncDisposableRegistry); + stubTelemetryEnabled(analyticsService, true); + stubPostHogConfigured(analyticsService, true); + + await analyticsService.activate(); + + const client = getPostHogClient(analyticsService); + + assert.isDefined(client, 'Client should be created initially'); + + const shutdownStub = sinon.stub().resolves(); + client.shutdown = shutdownStub; + + stubTelemetryEnabled(analyticsService, false); + // eslint-disable-next-line @typescript-eslint/no-explicit-any + (analyticsService as any).handleConfigChanged(); + + assert.isNull(getPostHogClient(analyticsService), 'Client should be destroyed when telemetry is disabled'); + }); + + test('settings change should create client when telemetry is enabled', async () => { + analyticsService = new TelemetryService(mockDisposables, mockStateFactory, mockAsyncDisposableRegistry); + stubTelemetryEnabled(analyticsService, false); + stubPostHogConfigured(analyticsService, true); + + await analyticsService.activate(); + + assert.isNull(getPostHogClient(analyticsService), 'Client should not be created initially'); + + stubTelemetryEnabled(analyticsService, true); + // eslint-disable-next-line @typescript-eslint/no-explicit-any + (analyticsService as any).handleConfigChanged(); + + assert.isDefined(getPostHogClient(analyticsService), 'Client should be created when telemetry is enabled'); + }); + + test('dispose should not throw even when client is not initialized', async () => { + analyticsService = new TelemetryService(mockDisposables, mockStateFactory, mockAsyncDisposableRegistry); + + await assert.isFulfilled(analyticsService.dispose()); + }); +}); diff --git a/src/platform/analytics/telemetryWebService.ts b/src/platform/analytics/telemetryWebService.ts new file mode 100644 index 0000000000..9dd7b611d9 --- /dev/null +++ b/src/platform/analytics/telemetryWebService.ts @@ -0,0 +1,14 @@ +import { injectable } from 'inversify'; + +import { ITelemetryService, TelemetryEvent } from './types'; + +@injectable() +export class TelemetryWebService implements ITelemetryService { + public trackEvent(_event: TelemetryEvent): void { + // No-op for web + } + + public async dispose(): Promise { + // No-op for web + } +} diff --git a/src/platform/analytics/types.ts b/src/platform/analytics/types.ts new file mode 100644 index 0000000000..1695978eed --- /dev/null +++ b/src/platform/analytics/types.ts @@ -0,0 +1,39 @@ +import { IAsyncDisposable } from '../common/types'; + +export type TelemetryEventName = + | 'add_block' + | 'authenticate_integration' + | 'configure_integration' + | 'create_environment' + | 'create_notebook' + | 'create_project' + | 'delete_environment' + | 'delete_integration' + | 'delete_notebook' + | 'duplicate_notebook' + | 'execute_cell' + | 'execute_notebook' + | 'export_notebook' + | 'import_notebook' + | 'open_in_deepnote' + | 'open_notebook' + | 'rename_notebook' + | 'rename_project' + | 'reset_integration' + | 'save_integration' + | 'select_environment' + | 'split_notebook' + | 'switch_sql_integration' + | 'toggle_snapshots' + | 'update_environment'; + +export interface TelemetryEvent { + eventName: TelemetryEventName; + properties?: Record; +} + +export const ITelemetryService = Symbol('ITelemetryService'); + +export interface ITelemetryService extends IAsyncDisposable { + trackEvent(event: TelemetryEvent): void; +} diff --git a/src/platform/serviceRegistry.node.ts b/src/platform/serviceRegistry.node.ts index 73599e069a..d9a3346276 100644 --- a/src/platform/serviceRegistry.node.ts +++ b/src/platform/serviceRegistry.node.ts @@ -6,6 +6,8 @@ import { registerTypes as registerApiTypes } from './api/serviceRegistry.node'; import { registerTypes as registerCommonTypes } from './common/serviceRegistry.node'; import { registerTypes as registerTerminalTypes } from './terminals/serviceRegistry.node'; import { registerTypes as registerInterpreterTypes } from './interpreter/serviceRegistry.node'; +import { ITelemetryService } from './analytics/types'; +import { TelemetryService } from './analytics/telemetryService'; import { DataScienceStartupTime } from './common/constants'; import { IExtensionSyncActivationService } from './activation/types'; import { IConfigurationService, IDataScienceCommandListener } from './common/types'; @@ -26,6 +28,8 @@ export function registerTypes(serviceManager: IServiceManager) { serviceManager.addBinding(FileSystem, IFileSystemNode); serviceManager.addBinding(FileSystem, IFileSystem); serviceManager.addSingleton(IWorkspaceService, WorkspaceService); + serviceManager.addSingleton(ITelemetryService, TelemetryService); + serviceManager.addBinding(ITelemetryService, IExtensionSyncActivationService); serviceManager.addSingleton(IConfigurationService, ConfigurationService); registerApiTypes(serviceManager); diff --git a/src/platform/serviceRegistry.web.ts b/src/platform/serviceRegistry.web.ts index 453d73c0e7..9bc3c26dca 100644 --- a/src/platform/serviceRegistry.web.ts +++ b/src/platform/serviceRegistry.web.ts @@ -24,9 +24,12 @@ import { KernelProgressReporter } from './progress/kernelProgressReporter'; import { WebviewPanelProvider } from './webviews/webviewPanelProvider'; import { WebviewViewProvider } from './webviews/webviewViewProvider'; import { WorkspaceInterpreterTracker } from './interpreter/workspaceInterpreterTracker'; +import { ITelemetryService } from './analytics/types'; +import { TelemetryWebService } from './analytics/telemetryWebService'; import { ApplicationEnvironment } from './common/application/applicationEnvironment'; export function registerTypes(serviceManager: IServiceManager) { + serviceManager.addSingleton(ITelemetryService, TelemetryWebService); serviceManager.addSingleton(IFileSystem, FileSystem); serviceManager.addSingleton(IWorkspaceService, WorkspaceService); serviceManager.addSingleton(IApplicationEnvironment, ApplicationEnvironment); diff --git a/test/e2e/suite/projectRename.e2e.test.ts b/test/e2e/suite/projectRename.e2e.test.ts index 4d123285cf..2a2c455672 100644 --- a/test/e2e/suite/projectRename.e2e.test.ts +++ b/test/e2e/suite/projectRename.e2e.test.ts @@ -42,12 +42,29 @@ async function leaveUnsavedCellEdit(): Promise { // Focus the first CODE cell's Monaco editor by clicking its visible source line (markdown cells // render without an editor, so scope to code rows), then type a marker into the focused input. - const line = await driver.wait( - async () => (await driver.findElements(By.css('.notebookOverlay .code-cell-row .view-line')))[0], + // Locate AND click in one waited step, retrying on failure: a freshly opened Deepnote notebook + // re-renders its cells for a beat (status-bar items, kernel wiring), which can invalidate a + // separately-located element reference before the click lands (StaleElementReferenceError). + await driver.wait( + async () => { + const line = (await driver.findElements(By.css('.notebookOverlay .code-cell-row .view-line')))[0]; + + if (!line) { + return false; + } + + try { + await line.click(); + + return true; + } catch { + // Stale reference (the cell re-rendered) or not yet clickable — re-locate and retry. + return false; + } + }, WORKBENCH_TIMEOUT, - 'the notebook code cell did not render' + 'the notebook code cell did not render or settle enough to focus' ); - await line.click(); await driver.sleep(400); await driver.switchTo().activeElement().sendKeys(DIRTY_MARKER);