diff --git a/package-lock.json b/package-lock.json index 187dc191cd..13a397db57 100644 --- a/package-lock.json +++ b/package-lock.json @@ -63,6 +63,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", @@ -9683,6 +9684,17 @@ "node": ">=4" } }, + "node_modules/axios": { + "version": "1.14.0", + "resolved": "https://registry.npmjs.org/axios/-/axios-1.14.0.tgz", + "integrity": "sha512-3Y8yrqLSwjuzpXuZ0oIYZ/XGgLwUIBU3uLvbcpb0pidD9ctpShJd43KSlEEkVQg6DS0G9NKyzOvBfUtDKEyHvQ==", + "license": "MIT", + "dependencies": { + "follow-redirects": "^1.15.11", + "form-data": "^4.0.5", + "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", @@ -15590,6 +15602,26 @@ "dev": true, "license": "ISC" }, + "node_modules/follow-redirects": { + "version": "1.15.11", + "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.15.11.tgz", + "integrity": "sha512-deG2P0JfjrTxl50XGCDyfI97ZGVCxIpfKYmfyrQ54n5FO/0gfIES8C/Psl6kWVDolizcaaxZJnTS0QSMxvnsBQ==", + "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", @@ -15668,9 +15700,9 @@ } }, "node_modules/form-data": { - "version": "4.0.4", - "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.4.tgz", - "integrity": "sha512-KrGhL9Q4zjj0kiUt5OO4Mr/A/jlI2jDYs5eHBpYHPcBEVSiipAvn2Ko2HnPe20rmcuuvMHNdZFp+4IlGTMF0Ow==", + "version": "4.0.5", + "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.5.tgz", + "integrity": "sha512-8RipRLol37bNs2bhoV67fiTEvdTrbMUYcFTiy3+wuuOnUog2QBHCZWXDRijWQfAkhBj2Uf5UnVaiWwA5vdd82w==", "license": "MIT", "dependencies": { "asynckit": "^0.4.0", @@ -25016,6 +25048,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", @@ -25305,6 +25349,15 @@ "node": ">= 8" } }, + "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", @@ -38348,6 +38401,16 @@ "integrity": "sha512-/dlp0fxyM3R8YW7MFzaHWXrf4zzbr0vaYb23VBFCl83R7nWNPg/yaQw2Dc8jzCMmDVLhSdzH8MjrsuIUuvX+6g==", "dev": true }, + "axios": { + "version": "1.14.0", + "resolved": "https://registry.npmjs.org/axios/-/axios-1.14.0.tgz", + "integrity": "sha512-3Y8yrqLSwjuzpXuZ0oIYZ/XGgLwUIBU3uLvbcpb0pidD9ctpShJd43KSlEEkVQg6DS0G9NKyzOvBfUtDKEyHvQ==", + "requires": { + "follow-redirects": "^1.15.11", + "form-data": "^4.0.5", + "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", @@ -42674,6 +42737,11 @@ "integrity": "sha512-PjDse7RzhcPkIJwy5t7KPWQSZ9cAbzQXcafsetQoD7sOJRQlGikNbx7yZp2OotDnJyrDcbyRq3Ttb18iYOqkxA==", "dev": true }, + "follow-redirects": { + "version": "1.15.11", + "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.15.11.tgz", + "integrity": "sha512-deG2P0JfjrTxl50XGCDyfI97ZGVCxIpfKYmfyrQ54n5FO/0gfIES8C/Psl6kWVDolizcaaxZJnTS0QSMxvnsBQ==" + }, "font-awesome": { "version": "4.7.0", "resolved": "https://registry.npmjs.org/font-awesome/-/font-awesome-4.7.0.tgz", @@ -42732,9 +42800,9 @@ } }, "form-data": { - "version": "4.0.4", - "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.4.tgz", - "integrity": "sha512-KrGhL9Q4zjj0kiUt5OO4Mr/A/jlI2jDYs5eHBpYHPcBEVSiipAvn2Ko2HnPe20rmcuuvMHNdZFp+4IlGTMF0Ow==", + "version": "4.0.5", + "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.5.tgz", + "integrity": "sha512-8RipRLol37bNs2bhoV67fiTEvdTrbMUYcFTiy3+wuuOnUog2QBHCZWXDRijWQfAkhBj2Uf5UnVaiWwA5vdd82w==", "requires": { "asynckit": "^0.4.0", "combined-stream": "^1.0.8", @@ -49237,6 +49305,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", @@ -49448,6 +49524,11 @@ "integrity": "sha512-vGrhOavPSTz4QVNuBNdcNXePNdNMaO1xj9yBeH1ScQPjk/rhg9sSlCXPhMkFuaNNW/syTvYqsnbIJxMBfRbbag==", "dev": true }, + "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 90a4c6df6e..c9cdb45a55 100644 --- a/package.json +++ b/package.json @@ -1650,6 +1650,12 @@ "description": "Disable SSL certificate verification (for development only)", "scope": "application" }, + "deepnote.telemetry.enabled": { + "type": "boolean", + "default": true, + "description": "Enable anonymous usage telemetry to help improve Deepnote for VS Code.", + "scope": "application" + }, "deepnote.snapshots.enabled": { "type": "boolean", "default": true, @@ -2725,6 +2731,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/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 4d6c3ec7fa..1e30d67465 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'; @@ -52,7 +53,8 @@ export class DeepnoteEnvironmentsView implements Disposable { @inject(IDeepnoteNotebookEnvironmentMapper) private readonly notebookEnvironmentMapper: IDeepnoteNotebookEnvironmentMapper, @inject(IKernelProvider) private readonly kernelProvider: IKernelProvider, - @inject(IOutputChannel) @named(STANDARD_OUTPUT_CHANNEL) private readonly outputChannel: IOutputChannel + @inject(IOutputChannel) @named(STANDARD_OUTPUT_CHANNEL) private readonly outputChannel: IOutputChannel, + @inject(ITelemetryService) private readonly analytics: ITelemetryService ) { // Create tree data provider @@ -193,6 +195,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) ); @@ -314,6 +324,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); @@ -483,6 +494,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) { diff --git a/src/kernels/deepnote/environments/deepnoteEnvironmentsView.unit.test.ts b/src/kernels/deepnote/environments/deepnoteEnvironmentsView.unit.test.ts index 0407420162..5b76a0dc2c 100644 --- a/src/kernels/deepnote/environments/deepnoteEnvironmentsView.unit.test.ts +++ b/src/kernels/deepnote/environments/deepnoteEnvironmentsView.unit.test.ts @@ -5,6 +5,7 @@ import { CancellationToken, Disposable, NotebookDocument, ProgressOptions, Uri } import { DeepnoteEnvironmentsView } from './deepnoteEnvironmentsView.node'; import { IDeepnoteEnvironmentManager, IDeepnoteKernelAutoSelector, IDeepnoteNotebookEnvironmentMapper } 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'; @@ -25,6 +26,7 @@ suite('DeepnoteEnvironmentsView', () => { let mockNotebookEnvironmentMapper: IDeepnoteNotebookEnvironmentMapper; let mockKernelProvider: IKernelProvider; let mockOutputChannel: IOutputChannel; + let mockTelemetryService: ITelemetryService; let disposables: Disposable[] = []; let pythonEnvironments: PythonExtension['environments']; @@ -43,6 +45,7 @@ suite('DeepnoteEnvironmentsView', () => { mockNotebookEnvironmentMapper = mock(); mockKernelProvider = mock(); mockOutputChannel = mock(); + mockTelemetryService = mock(); // Mock onDidChangeEnvironments to return a disposable event when(mockConfigManager.onDidChangeEnvironments).thenReturn((_listener: () => void) => { @@ -61,7 +64,8 @@ suite('DeepnoteEnvironmentsView', () => { instance(mockKernelAutoSelector), instance(mockNotebookEnvironmentMapper), instance(mockKernelProvider), - instance(mockOutputChannel) + instance(mockOutputChannel), + instance(mockTelemetryService) ); }); diff --git a/src/notebooks/deepnote/deepnoteActivationService.ts b/src/notebooks/deepnote/deepnoteActivationService.ts index 96c35288cd..cd9e571a21 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 { IDeepnoteNotebookManager } from '../types'; @@ -34,6 +35,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 ) { this.integrationManager = integrationManager; @@ -45,7 +47,12 @@ export class DeepnoteActivationService implements IExtensionSyncActivationServic */ public activate() { this.serializer = new DeepnoteNotebookSerializer(this.notebookManager, this.snapshotService); - this.explorerView = new DeepnoteExplorerView(this.extensionContext, this.notebookManager, this.logger); + this.explorerView = new DeepnoteExplorerView( + this.extensionContext, + this.notebookManager, + this.logger, + this.analytics + ); this.editProtection = new DeepnoteInputBlockEditProtection(this.logger); this.snapshotsEnabled = this.isSnapshotsEnabled(); 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..c2246b6369 --- /dev/null +++ b/src/notebooks/deepnote/deepnoteCellExecutionAnalytics.ts @@ -0,0 +1,59 @@ +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; + + if (projectId) { + const project = this.notebookManager.getOriginalProject(projectId); + 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 33599da2f0..c34801ac70 100644 --- a/src/notebooks/deepnote/deepnoteExplorerView.ts +++ b/src/notebooks/deepnote/deepnoteExplorerView.ts @@ -3,6 +3,7 @@ import { commands, window, workspace, type TreeView, Uri, l10n } from 'vscode'; import { serializeDeepnoteFile, type DeepnoteBlock, type DeepnoteFile } from '@deepnote/blocks'; import { convertDeepnoteToJupyterNotebooks, convertIpynbFilesToDeepnoteFile } from '@deepnote/convert'; +import { ITelemetryService } from '../../platform/analytics/types'; import { IExtensionContext } from '../../platform/common/types'; import { IDeepnoteNotebookManager } from '../types'; import { DeepnoteTreeDataProvider } from './deepnoteTreeDataProvider'; @@ -25,7 +26,8 @@ export class DeepnoteExplorerView { constructor( @inject(IExtensionContext) private readonly extensionContext: IExtensionContext, @inject(IDeepnoteNotebookManager) private readonly manager: IDeepnoteNotebookManager, - @inject(ILogger) logger: ILogger + @inject(ILogger) logger: ILogger, + private readonly analytics: ITelemetryService ) { this.treeDataProvider = new DeepnoteTreeDataProvider(logger); } @@ -142,9 +144,9 @@ export class DeepnoteExplorerView { } } - public async deleteNotebook(treeItem: DeepnoteTreeItem): Promise { + public async deleteNotebook(treeItem: DeepnoteTreeItem): Promise { if (treeItem.type !== DeepnoteTreeItemType.Notebook) { - return; + return false; } const notebook = treeItem.data as DeepnoteNotebook; @@ -157,7 +159,7 @@ export class DeepnoteExplorerView { ); if (confirmation !== l10n.t('Delete')) { - return; + return false; } try { @@ -166,7 +168,7 @@ export class DeepnoteExplorerView { if (!projectData?.project?.notebooks) { await window.showErrorMessage(l10n.t('Invalid Deepnote file format')); - return; + return false; } projectData.project.notebooks = projectData.project.notebooks.filter( @@ -184,9 +186,13 @@ export class DeepnoteExplorerView { await 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; } } @@ -332,9 +338,10 @@ export class DeepnoteExplorerView { ); this.extensionContext.subscriptions.push( - commands.registerCommand(Commands.OpenDeepnoteNotebook, (context: DeepnoteTreeItemContext) => - this.openNotebook(context) - ) + commands.registerCommand(Commands.OpenDeepnoteNotebook, async (context: DeepnoteTreeItemContext) => { + await this.openNotebook(context); + this.analytics.trackEvent({ eventName: 'open_notebook' }); + }) ); this.extensionContext.subscriptions.push( @@ -346,19 +353,31 @@ 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 () => { + await this.newNotebook(); + this.analytics.trackEvent({ eventName: 'create_notebook' }); + }) ); // Context menu commands for tree items @@ -369,9 +388,10 @@ export class DeepnoteExplorerView { ); this.extensionContext.subscriptions.push( - commands.registerCommand(Commands.DeleteProject, (treeItem: DeepnoteTreeItem) => - this.deleteProject(treeItem) - ) + commands.registerCommand(Commands.DeleteProject, async (treeItem: DeepnoteTreeItem) => { + const completed = await this.deleteProject(treeItem); + this.analytics.trackEvent({ eventName: 'delete_project', properties: { completed } }); + }) ); this.extensionContext.subscriptions.push( @@ -381,15 +401,17 @@ export class DeepnoteExplorerView { ); 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) => { + await this.duplicateNotebook(treeItem); + this.analytics.trackEvent({ eventName: 'duplicate_notebook' }); + }) ); this.extensionContext.subscriptions.push( @@ -399,15 +421,23 @@ export class DeepnoteExplorerView { ); this.extensionContext.subscriptions.push( - commands.registerCommand(Commands.ExportProject, (treeItem: DeepnoteTreeItem) => - this.exportProject(treeItem) - ) + commands.registerCommand(Commands.ExportProject, async (treeItem: DeepnoteTreeItem) => { + const completed = await this.exportProject(treeItem); + this.analytics.trackEvent({ + eventName: 'export_notebook', + properties: { completed, format: 'project' } + }); + }) ); 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: 'notebook' } + }); + }) ); } @@ -613,7 +643,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?'), @@ -625,7 +655,7 @@ export class DeepnoteExplorerView { await commands.executeCommand('vscode.openFolder'); } - return; + return false; } const projectName = await window.showInputBox({ @@ -641,7 +671,7 @@ export class DeepnoteExplorerView { }); if (!projectName) { - return; + return false; } try { @@ -653,7 +683,7 @@ export class DeepnoteExplorerView { try { 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 } @@ -710,10 +740,14 @@ 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; } } @@ -745,7 +779,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?'), @@ -757,7 +791,7 @@ export class DeepnoteExplorerView { await commands.executeCommand('vscode.openFolder'); } - return; + return false; } const fileUris = await window.showOpenDialog({ @@ -771,7 +805,7 @@ export class DeepnoteExplorerView { }); if (!fileUris || fileUris.length === 0) { - return; + return false; } try { @@ -790,7 +824,7 @@ export class DeepnoteExplorerView { await window.showErrorMessage( l10n.t('A file named "{0}" already exists in this workspace.', fileName) ); - return; + return false; } catch { // File doesn't exist, continue } @@ -808,7 +842,7 @@ export class DeepnoteExplorerView { await window.showErrorMessage( l10n.t('A file named "{0}" already exists in this workspace.', outputFileName) ); - return; + return false; } catch { // File doesn't exist, continue } @@ -849,14 +883,18 @@ export class DeepnoteExplorerView { } this.treeDataProvider.refresh(); + + return true; } 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?'), @@ -868,7 +906,7 @@ export class DeepnoteExplorerView { await commands.executeCommand('vscode.openFolder'); } - return; + return false; } const fileUris = await window.showOpenDialog({ @@ -882,7 +920,7 @@ export class DeepnoteExplorerView { }); if (!fileUris || fileUris.length === 0) { - return; + return false; } try { @@ -901,7 +939,7 @@ export class DeepnoteExplorerView { await window.showErrorMessage( l10n.t('A file named "{0}" already exists in this workspace.', outputFileName) ); - return; + return false; } catch { // File doesn't exist, continue } @@ -922,16 +960,20 @@ export class DeepnoteExplorerView { } this.treeDataProvider.refresh(); + + return true; } 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 deleteProject(treeItem: DeepnoteTreeItem): Promise { + private async deleteProject(treeItem: DeepnoteTreeItem): Promise { if (treeItem.type !== DeepnoteTreeItemType.ProjectFile) { - return; + return false; } const project = treeItem.data as DeepnoteFile; @@ -944,7 +986,7 @@ export class DeepnoteExplorerView { ); if (confirmation !== l10n.t('Delete')) { - return; + return false; } try { @@ -952,9 +994,13 @@ export class DeepnoteExplorerView { await workspace.fs.delete(fileUri); this.treeDataProvider.refresh(); await window.showInformationMessage(l10n.t('Project deleted: {0}', projectName)); + + return true; } catch (error) { const errorMessage = error instanceof Error ? error.message : 'Unknown error'; await window.showErrorMessage(l10n.t('Failed to delete project: {0}', errorMessage)); + + return false; } } @@ -982,9 +1028,9 @@ export class DeepnoteExplorerView { * Exports all notebooks from a Deepnote project to Jupyter format * @param treeItem The tree item representing a project */ - private async exportProject(treeItem: DeepnoteTreeItem): Promise { + private async exportProject(treeItem: DeepnoteTreeItem): Promise { if (treeItem.type !== DeepnoteTreeItemType.ProjectFile) { - return; + return false; } try { @@ -993,7 +1039,7 @@ export class DeepnoteExplorerView { }); if (!format) { - return; + return false; } const fileUri = Uri.file(treeItem.context.filePath); @@ -1002,7 +1048,7 @@ export class DeepnoteExplorerView { if (!projectData?.project) { await window.showErrorMessage(l10n.t('Invalid Deepnote file format')); - return; + return false; } const outputFolder = await window.showOpenDialog({ @@ -1014,7 +1060,7 @@ export class DeepnoteExplorerView { }); if (!outputFolder?.length) { - return; + return false; } const jupyterNotebooks = convertDeepnoteToJupyterNotebooks(projectData); @@ -1041,7 +1087,7 @@ export class DeepnoteExplorerView { ); if (result !== overwrite) { - return; + return false; } } @@ -1058,9 +1104,13 @@ export class DeepnoteExplorerView { : l10n.t('Exported {0} notebooks successfully', count); await window.showInformationMessage(message); + + 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; } } @@ -1068,9 +1118,9 @@ export class DeepnoteExplorerView { * Exports a single notebook from a Deepnote project to Jupyter format * @param treeItem The tree item representing a notebook */ - private async exportNotebook(treeItem: DeepnoteTreeItem): Promise { + private async exportNotebook(treeItem: DeepnoteTreeItem): Promise { if (treeItem.type !== DeepnoteTreeItemType.Notebook) { - return; + return false; } try { @@ -1079,7 +1129,7 @@ export class DeepnoteExplorerView { }); if (!format) { - return; + return false; } const fileUri = Uri.file(treeItem.context.filePath); @@ -1088,7 +1138,7 @@ export class DeepnoteExplorerView { if (!projectData?.project) { await window.showErrorMessage(l10n.t('Invalid Deepnote file format')); - return; + return false; } const outputFolder = await window.showOpenDialog({ @@ -1100,7 +1150,7 @@ export class DeepnoteExplorerView { }); if (!outputFolder?.length) { - return; + return false; } const targetNotebook = projectData.project.notebooks.find((nb) => nb.id === treeItem.context.notebookId); @@ -1108,7 +1158,7 @@ export class DeepnoteExplorerView { if (!targetNotebook) { await window.showErrorMessage(l10n.t('Notebook not found')); - return; + return false; } const filteredProject = { @@ -1142,7 +1192,7 @@ export class DeepnoteExplorerView { ); if (result !== overwrite) { - return; + return false; } } @@ -1152,9 +1202,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 5e5e2de826..512a53567d 100644 --- a/src/notebooks/deepnote/deepnoteExplorerView.unit.test.ts +++ b/src/notebooks/deepnote/deepnoteExplorerView.unit.test.ts @@ -8,6 +8,7 @@ import { stringify as yamlStringify } from 'yaml'; import { DeepnoteExplorerView } from './deepnoteExplorerView'; import { DeepnoteNotebookManager } from './deepnoteNotebookManager'; import { DeepnoteTreeItem, DeepnoteTreeItemType, type DeepnoteTreeItemContext } from './deepnoteTreeItem'; +import { ITelemetryService } from '../../platform/analytics/types'; import type { IExtensionContext } from '../../platform/common/types'; import type { DeepnoteNotebook } from '../../platform/deepnote/deepnoteTypes'; import { mockedVSCodeNamespaces, resetVSCodeMocks } from '../../test/vscode-mock'; @@ -44,6 +45,7 @@ suite('DeepnoteExplorerView', () => { let mockExtensionContext: IExtensionContext; let manager: DeepnoteNotebookManager; let mockLogger: ILogger; + let mockAnalytics: ITelemetryService; setup(() => { mockExtensionContext = { @@ -52,7 +54,8 @@ suite('DeepnoteExplorerView', () => { manager = new DeepnoteNotebookManager(); mockLogger = createMockLogger(); - explorerView = new DeepnoteExplorerView(mockExtensionContext, manager, mockLogger); + mockAnalytics = instance(mock()); + explorerView = new DeepnoteExplorerView(mockExtensionContext, manager, mockLogger, mockAnalytics); }); suite('constructor', () => { @@ -190,8 +193,8 @@ suite('DeepnoteExplorerView', () => { const manager2 = new DeepnoteNotebookManager(); const logger1 = createMockLogger(); const logger2 = createMockLogger(); - const view1 = new DeepnoteExplorerView(context1, manager1, logger1); - const view2 = new DeepnoteExplorerView(context2, manager2, logger2); + const view1 = new DeepnoteExplorerView(context1, manager1, logger1, mockAnalytics); + const view2 = new DeepnoteExplorerView(context2, manager2, logger2, mockAnalytics); // Verify each view has its own context assert.strictEqual((view1 as any).extensionContext, context1); @@ -222,6 +225,7 @@ suite('DeepnoteExplorerView - Empty State Commands', () => { let mockManager: DeepnoteNotebookManager; let sandbox: sinon.SinonSandbox; let uuidStubs: sinon.SinonStub[] = []; + let mockAnalytics: ITelemetryService; setup(() => { sandbox = sinon.createSandbox(); @@ -234,7 +238,8 @@ suite('DeepnoteExplorerView - Empty State Commands', () => { mockManager = new DeepnoteNotebookManager(); const mockLogger = createMockLogger(); - explorerView = new DeepnoteExplorerView(mockContext, mockManager, mockLogger); + mockAnalytics = instance(mock()); + explorerView = new DeepnoteExplorerView(mockContext, mockManager, mockLogger, mockAnalytics); }); teardown(() => { diff --git a/src/notebooks/deepnote/deepnoteNotebookCommandListener.ts b/src/notebooks/deepnote/deepnoteNotebookCommandListener.ts index d91608c15a..c410bd9a83 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.analytics.trackEvent({ eventName: 'add_block', properties: { blockType: '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.analytics.trackEvent({ eventName: 'add_block', properties: { blockType: '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.analytics.trackEvent({ eventName: 'add_block', properties: { blockType: '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.analytics.trackEvent({ eventName: 'add_block', properties: { 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.analytics.trackEvent({ eventName: 'add_block', properties: { blockType: 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,6 +582,7 @@ 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.')); 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 3daf4dc479..1fc5c9c184 100644 --- a/src/notebooks/deepnote/integrations/integrationWebview.ts +++ b/src/notebooks/deepnote/integrations/integrationWebview.ts @@ -1,6 +1,7 @@ import { inject, injectable } from 'inversify'; import { Disposable, l10n, Uri, ViewColumn, WebviewPanel, window } from 'vscode'; +import { ITelemetryService } from '../../../platform/analytics/types'; import { IExtensionContext } from '../../../platform/common/types'; import * as localize from '../../../platform/common/utils/localize'; import { logger } from '../../../platform/logging'; @@ -29,7 +30,8 @@ export class IntegrationWebviewProvider implements IIntegrationWebviewProvider { constructor( @inject(IExtensionContext) private readonly extensionContext: IExtensionContext, @inject(IIntegrationStorage) private readonly integrationStorage: IIntegrationStorage, - @inject(IDeepnoteNotebookManager) private readonly notebookManager: IDeepnoteNotebookManager + @inject(IDeepnoteNotebookManager) private readonly notebookManager: IDeepnoteNotebookManager, + @inject(ITelemetryService) private readonly analytics: ITelemetryService ) {} /** @@ -434,21 +436,28 @@ export class IntegrationWebviewProvider implements IIntegrationWebviewProvider { switch (message.type) { case 'configure': if (message.integrationId) { + this.analytics.trackEvent({ eventName: 'configure_integration' }); await this.showConfigurationForm(message.integrationId); } break; case 'save': if (message.integrationId && message.config) { + this.analytics.trackEvent({ + eventName: 'save_integration', + properties: { integrationType: message.config.type ?? 'unknown' } + }); await this.saveConfiguration(message.integrationId, message.config); } break; case 'reset': if (message.integrationId) { + this.analytics.trackEvent({ eventName: 'reset_integration' }); await this.resetConfiguration(message.integrationId); } break; case 'delete': if (message.integrationId) { + this.analytics.trackEvent({ eventName: 'delete_integration' }); await this.deleteConfiguration(message.integrationId); } break; diff --git a/src/notebooks/deepnote/openInDeepnoteHandler.node.ts b/src/notebooks/deepnote/openInDeepnoteHandler.node.ts index 4a62c634d7..4251bb865c 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,11 +14,17 @@ 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 () => { + await this.handleOpenInDeepnote(); + this.analytics.trackEvent({ eventName: 'open_in_deepnote' }); + }) ); } 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/notebookCommandListener.ts b/src/notebooks/notebookCommandListener.ts index 04907cb1ca..d9bb97e37e 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 { @@ -114,6 +116,7 @@ export class NotebookCommandListener implements INotebookCommandHandler, IExtens private runAllCells() { if (window.activeNotebookEditor) { + this.analytics.trackEvent({ eventName: 'execute_notebook' }); commands.executeCommand('notebook.execute').then(noop, noop); } } @@ -141,6 +144,7 @@ export class NotebookCommandListener implements INotebookCommandHandler, IExtens private addCellBelow() { if (window.activeNotebookEditor) { + 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 cbd8b860fe..0a893faffa 100644 --- a/src/notebooks/serviceRegistry.node.ts +++ b/src/notebooks/serviceRegistry.node.ts @@ -83,6 +83,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'; @@ -173,6 +174,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..acbefc5b66 --- /dev/null +++ b/src/platform/analytics/constants.ts @@ -0,0 +1,2 @@ +export const POSTHOG_API_KEY = '__POSTHOG_API_KEY__'; +export const POSTHOG_HOST = 'https://us.i.posthog.com'; 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..eff82300a3 --- /dev/null +++ b/src/platform/analytics/telemetryService.ts @@ -0,0 +1,119 @@ +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 { 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.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 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..bddcef0294 --- /dev/null +++ b/src/platform/analytics/telemetryService.unit.test.ts @@ -0,0 +1,177 @@ +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; + } + + 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); + + await analyticsService.activate(); + + const client = getPostHogClient(analyticsService); + + assert.isDefined(client, 'PostHog client should be initialized'); + }); + + 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); + + 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); + + 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); + + 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..97b8b8b04f --- /dev/null +++ b/src/platform/analytics/types.ts @@ -0,0 +1,34 @@ +import { IAsyncDisposable } from '../common/types'; + +export type TelemetryEventName = + | 'add_block' + | 'configure_integration' + | 'create_environment' + | 'create_notebook' + | 'create_project' + | 'delete_environment' + | 'delete_integration' + | 'delete_notebook' + | 'delete_project' + | 'duplicate_notebook' + | 'execute_cell' + | 'execute_notebook' + | 'export_notebook' + | 'import_notebook' + | 'open_in_deepnote' + | 'open_notebook' + | 'reset_integration' + | 'save_integration' + | 'select_environment' + | 'toggle_snapshots'; + +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);