Add query history telemetry (#20722)

* Add query history telemetry

* Update property name

* Only send on success

* Update name
This commit is contained in:
Charles Gagnon
2022-10-04 15:43:38 -07:00
committed by GitHub
parent f511536a0f
commit 7ebcb21879
5 changed files with 207 additions and 17 deletions

View File

@@ -9,6 +9,7 @@ import { DOUBLE_CLICK_ACTION_CONFIG_SECTION, ITEM_SELECTED_COMMAND_ID, QUERY_HIS
import { QueryHistoryItem } from './queryHistoryItem';
import { QueryHistoryProvider, setLoadingContext } from './queryHistoryProvider';
import { promises as fs } from 'fs';
import { TelemetryActions, TelemetryReporter, TelemetryViews } from './telemetry';
let lastSelectedItem: { item: QueryHistoryItem | undefined, time: number | undefined } = {
item: undefined,
@@ -26,6 +27,7 @@ export async function activate(context: vscode.ExtensionContext): Promise<void>
await fs.mkdir(storageUri.fsPath);
} catch (err) {
if (err.code !== 'EEXIST') {
TelemetryReporter.sendErrorEvent(TelemetryViews.QueryHistory, 'CreatingStorageFolder');
console.error(`Error creating query history global storage folder ${context.globalStorageUri.fsPath}. ${err}`);
}
}
@@ -44,6 +46,7 @@ export async function activate(context: vscode.ExtensionContext): Promise<void>
const clickTime = new Date().getTime();
if (lastSelectedItem.item === selectedItem && lastSelectedItem.time && (clickTime - lastSelectedItem.time) < DOUBLE_CLICK_TIMEOUT_MS) {
const doubleClickAction = vscode.workspace.getConfiguration(QUERY_HISTORY_CONFIG_SECTION).get<string>(DOUBLE_CLICK_ACTION_CONFIG_SECTION);
TelemetryReporter.sendActionEvent(TelemetryViews.QueryHistory, TelemetryActions.DoubleClick, doubleClickAction);
switch (doubleClickAction) {
case 'run':
await runQuery(selectedItem);
@@ -91,21 +94,37 @@ export async function activate(context: vscode.ExtensionContext): Promise<void>
}
async function openQuery(item: QueryHistoryItem): Promise<void> {
await azdata.queryeditor.openQueryDocument(
{
content: item.queryText
}, item.connectionProfile?.providerId);
try {
await azdata.queryeditor.openQueryDocument(
{
content: item.queryText
}, item.connectionProfile?.providerId);
} catch (err) {
TelemetryReporter.sendErrorEvent(TelemetryViews.QueryHistory, 'OpenQuery');
}
}
async function runQuery(item: QueryHistoryItem): Promise<void> {
const doc = await azdata.queryeditor.openQueryDocument(
{
content: item.queryText
}, item.connectionProfile?.providerId);
if (item.connectionProfile) {
await doc.connect(item.connectionProfile);
} else {
await azdata.queryeditor.connect(doc.uri, '');
let step = 'OpenDoc';
try {
const doc = await azdata.queryeditor.openQueryDocument(
{
content: item.queryText
}, item.connectionProfile?.providerId);
if (item.connectionProfile) {
step = 'ConnectWithProfile';
await doc.connect(item.connectionProfile);
} else {
step = 'ConnectWithoutProfile';
await azdata.queryeditor.connect(doc.uri, '');
}
step = 'Run';
azdata.queryeditor.runQuery(doc.uri);
} catch (err) {
TelemetryReporter.createErrorEvent(TelemetryViews.QueryHistory, 'RunQuery')
.withAdditionalProperties({ step })
.send();
}
azdata.queryeditor.runQuery(doc.uri);
}

View File

@@ -12,6 +12,7 @@ import * as fs from 'fs';
import * as path from 'path';
import * as crypto from 'crypto';
import * as loc from './localizedConstants';
import { sendSettingChangedEvent, TelemetryActions, TelemetryReporter, TelemetryViews, TimedAction } from './telemetry';
const STORAGE_IV_KEY = 'queryHistory.storage-iv';
const STORAGE_KEY_KEY = 'queryHistory.storage-key';
@@ -50,7 +51,8 @@ export class QueryHistoryProvider implements vscode.TreeDataProvider<QueryHistor
constructor(private _context: vscode.ExtensionContext, storageUri: vscode.Uri) {
this._historyStorageFile = path.join(storageUri.fsPath, HISTORY_STORAGE_FILE_NAME);
// Kick off initialization but then continue on since that may take a while and we don't want to block extension activation
this._initPromise = this.initialize();
const initializeAction = new TimedAction(TelemetryViews.QueryHistoryProvider, TelemetryActions.Initialize);
this._initPromise = this.initialize().then(() => initializeAction.send());
this._disposables.push(vscode.workspace.onDidChangeConfiguration(async e => {
if (e.affectsConfiguration(QUERY_HISTORY_CONFIG_SECTION) || e.affectsConfiguration(MAX_ENTRIES_CONFIG_SECTION)) {
await this.updateConfigurationValues();
@@ -82,6 +84,7 @@ export class QueryHistoryProvider implements vscode.TreeDataProvider<QueryHistor
// We need to compare URIs, but the event Uri comes in as string so while it should be in the same format as
// the textDocument uri.toString() we parse it into a vscode.Uri first to be absolutely sure.
if (textEditor?.document.uri.toString() !== vscode.Uri.parse(document.uri).toString()) {
TelemetryReporter.sendErrorEvent(TelemetryViews.QueryHistoryProvider, 'UriMismatch');
// If we couldn't find the document then we can't get the text so just log the error and move on
console.error(`Active text editor ${textEditor?.document.uri} does not match URI ${document.uri} for query event`);
return;
@@ -114,6 +117,7 @@ export class QueryHistoryProvider implements vscode.TreeDataProvider<QueryHistor
}
} catch (err) {
console.error(`Error getting persistance storage IV: ${err}`);
TelemetryReporter.sendErrorEvent(TelemetryViews.QueryHistoryProvider, 'InitializingIV');
// An IV is required to read/write the encrypted file so if we can't get it then just fail early
return;
}
@@ -129,21 +133,31 @@ export class QueryHistoryProvider implements vscode.TreeDataProvider<QueryHistor
}
} catch (err) {
console.error(`Error getting persistance storage key: ${err}`);
TelemetryReporter.sendErrorEvent(TelemetryViews.QueryHistoryProvider, 'InitializingKey');
// A key is required to read/write the encrypted file so if we can't get it then just fail early
return;
}
this.writeHistoryFileWorker = (): void => {
if (this._persistHistory) {
try {
// We store the history entries in an encrypted file because they may contain sensitive information
// such as passwords (even in the query text itself)
const cipher = crypto.createCipheriv(STORAGE_ENCRYPTION_ALGORITHM, key!, iv!);
const stringifiedItems = JSON.stringify(this._queryHistoryItems);
const encryptedText = Buffer.concat([cipher.update(Buffer.from(stringifiedItems)), cipher.final()]);
const writeStorageFileAction = new TimedAction(TelemetryViews.QueryHistoryProvider, TelemetryActions.WriteStorageFile,
{},
{
ItemCount: this._queryHistoryItems.length,
ItemLengthChars: stringifiedItems.length
});
// Use sync here so that we can write this out when the object is disposed
fs.writeFileSync(this._historyStorageFile, encryptedText);
writeStorageFileAction.send();
} catch (err) {
TelemetryReporter.sendErrorEvent(TelemetryViews.QueryHistoryProvider, 'WriteStorageFile');
console.error(`Error writing query history to disk: ${err}`);
}
@@ -155,9 +169,12 @@ export class QueryHistoryProvider implements vscode.TreeDataProvider<QueryHistor
return;
}
try {
const readStorageFileAction = new TimedAction(TelemetryViews.QueryHistoryProvider, TelemetryActions.ReadStorageFile);
// Read and decrypt any previous history items
const encryptedItems = await fs.promises.readFile(this._historyStorageFile);
readStorageFileAction.send();
const decipher = crypto.createDecipheriv(STORAGE_ENCRYPTION_ALGORITHM, key, iv);
const result = Buffer.concat([decipher.update(encryptedItems), decipher.final()]).toString();
this._queryHistoryItems = JSON.parse(result);
@@ -165,6 +182,7 @@ export class QueryHistoryProvider implements vscode.TreeDataProvider<QueryHistor
} catch (err) {
// Ignore ENOENT errors, those are expected if the storage file doesn't exist (on first run or if results aren't being persisted)
if (err.code !== 'ENOENT') {
TelemetryReporter.sendErrorEvent(TelemetryViews.QueryHistoryProvider, 'ReadStorageFile');
console.error(`Error deserializing stored history items: ${err}`);
void vscode.window.showWarningMessage(loc.errorLoading(err));
// Rename the file to avoid attempting to load a potentially corrupted or unreadable file every time we start up, we'll make
@@ -173,6 +191,7 @@ export class QueryHistoryProvider implements vscode.TreeDataProvider<QueryHistor
const bakPath = path.join(path.dirname(this._historyStorageFile), `${HISTORY_STORAGE_FILE_NAME}.bak`);
await fs.promises.rename(this._historyStorageFile, bakPath);
} catch (err) {
TelemetryReporter.sendErrorEvent(TelemetryViews.QueryHistoryProvider, 'MovingBadStorageFile');
console.error(`Error moving corrupted history file: ${err}`);
}
}
@@ -231,9 +250,21 @@ export class QueryHistoryProvider implements vscode.TreeDataProvider<QueryHistor
private async updateConfigurationValues(): Promise<void> {
const configSection = vscode.workspace.getConfiguration(QUERY_HISTORY_CONFIG_SECTION);
this._captureEnabled = configSection.get(CAPTURE_ENABLED_CONFIG_SECTION, DEFAULT_CAPTURE_ENABLED);
this._persistHistory = configSection.get(PERSIST_HISTORY_CONFIG_SECTION, DEFAULT_PERSIST_HISTORY);
this._maxEntries = configSection.get(MAX_ENTRIES_CONFIG_SECTION, DEFAULT_MAX_ENTRIES);
const newCaptureEnabled = configSection.get(CAPTURE_ENABLED_CONFIG_SECTION, DEFAULT_CAPTURE_ENABLED);
if (this._captureEnabled !== newCaptureEnabled) {
sendSettingChangedEvent('CaptureEnabled', String(this._captureEnabled), String(newCaptureEnabled));
this._captureEnabled = newCaptureEnabled;
}
const newPersistHistory = configSection.get(PERSIST_HISTORY_CONFIG_SECTION, DEFAULT_PERSIST_HISTORY);
if (this._persistHistory !== newPersistHistory) {
sendSettingChangedEvent('PersistHistory', String(this._persistHistory), String(newPersistHistory));
this._persistHistory = newPersistHistory;
}
const newMaxEntries = configSection.get(MAX_ENTRIES_CONFIG_SECTION, DEFAULT_MAX_ENTRIES);
if (this._maxEntries !== newMaxEntries) {
sendSettingChangedEvent('MaxEntries', String(this._maxEntries), String(newMaxEntries));
this._maxEntries = newMaxEntries;
}
this.trimExtraEntries();
if (!this._persistHistory) {
// We're not persisting history so we can immediately set loading to false to immediately
@@ -246,6 +277,7 @@ export class QueryHistoryProvider implements vscode.TreeDataProvider<QueryHistor
} catch (err) {
// Ignore ENOENT errors, those are expected if the storage file doesn't exist (on first run or if results aren't being persisted)
if (err.code !== 'ENOENT') {
TelemetryReporter.sendErrorEvent(TelemetryViews.QueryHistoryProvider, 'CleaningUpStorageFile');
// Best effort, we don't want other things to fail if we can't delete the file for some reason
console.error(`Error cleaning up query history storage: ${this._historyStorageFile}. ${err}`);
}

View File

@@ -0,0 +1,87 @@
/*---------------------------------------------------------------------------------------------
* Copyright (c) Microsoft Corporation. All rights reserved.
* Licensed under the Source EULA. See License.txt in the project root for license information.
*--------------------------------------------------------------------------------------------*/
import AdsTelemetryReporter, { TelemetryEventMeasures, TelemetryEventProperties } from '@microsoft/ads-extension-telemetry';
export interface PackageInfo {
name: string;
version: string;
aiKey: string;
}
const packageInfo = require('../package.json') as PackageInfo;
export const TelemetryReporter = new AdsTelemetryReporter(packageInfo.name, packageInfo.version, packageInfo.aiKey);
/**
* A helper class to send an Action event with a duration, timer starts on construction and ends when send() is called.
*/
export class TimedAction {
/**
* Additional properties to send along with the final message once send is called.
*/
public readonly additionalProperties: TelemetryEventProperties = {};
/**
* Additional measures to send along with the final message once send is called.
*/
public readonly additionalMeasures: TelemetryEventMeasures = {};
private _start: number = Date.now();
/**
* Creates a new TimedAction and sets the start time to Date.now().
* @param _view The view this action originates from
* @param _action The name of the action
* @param properties Additional properties to send along with the final message once send is called
* @param measures Additional measures to send along with the final message once send is called
*/
constructor(private _view: TelemetryViews, private _action: TelemetryActions, properties: TelemetryEventProperties = {}, measures: TelemetryEventMeasures = {}) {
Object.assign(this.additionalProperties, properties);
Object.assign(this.additionalMeasures, measures);
}
/**
* Sends the event with the duration being the difference between when this TimedAction was created and now.
* @param properties Additional properties to send along with the event
* @param measures Additional measures to send along with the event
*/
public send(properties: TelemetryEventProperties = {}, measures: TelemetryEventMeasures = {}): void {
Object.assign(this.additionalProperties, properties);
Object.assign(this.additionalMeasures, measures);
TelemetryReporter.createActionEvent(this._view, this._action, undefined, undefined, Date.now() - this._start)
.withAdditionalProperties(this.additionalProperties)
.withAdditionalMeasurements(this.additionalMeasures)
.send();
}
}
/**
* Send an event indicating that a setting changed along with the new and old values. Core has a setting changed
* event already, but that doesn't capture the values so we make our own here.
* @param setting The name of the setting
* @param oldValue The original value
* @param newValue The new value
*/
export function sendSettingChangedEvent(setting: string, oldValue: string, newValue: string): void {
TelemetryReporter.createActionEvent(TelemetryViews.QueryHistoryProvider, TelemetryActions.SettingChanged, setting)
.withAdditionalProperties({
oldValue,
newValue
})
.send();
}
export enum TelemetryViews {
QueryHistory = 'QueryHistory',
QueryHistoryProvider = 'QueryHistoryProvider'
}
export enum TelemetryActions {
DoubleClick = 'DoubleClick',
Initialize = 'Initialize',
ReadStorageFile = 'ReadStorageFile',
SettingChanged = 'SettingChanged',
WriteStorageFile = 'WriteStorageFile'
}