diff --git a/extensions/ql-vscode/src/pure/interface-types.ts b/extensions/ql-vscode/src/pure/interface-types.ts index daa6b3eac26..dabf31369fb 100644 --- a/extensions/ql-vscode/src/pure/interface-types.ts +++ b/extensions/ql-vscode/src/pure/interface-types.ts @@ -413,7 +413,8 @@ export type FromRemoteQueriesMessage = | RemoteQueryDownloadAnalysisResultsMessage | RemoteQueryDownloadAllAnalysesResultsMessage | RemoteQueryExportResultsMessage - | CopyRepoListMessage; + | CopyRepoListMessage + | TelemetryMessage; export type ToRemoteQueriesMessage = | SetRemoteQueryResultMessage @@ -504,6 +505,11 @@ export interface CancelVariantAnalysisMessage { t: "cancelVariantAnalysis"; } +export interface TelemetryMessage { + t: "telemetry"; + action: string; +} + export type ToVariantAnalysisMessage = | SetVariantAnalysisMessage | SetRepoResultsMessage @@ -517,4 +523,5 @@ export type FromVariantAnalysisMessage = | CopyRepositoryListMessage | ExportResultsMessage | OpenLogsMessage - | CancelVariantAnalysisMessage; + | CancelVariantAnalysisMessage + | TelemetryMessage; diff --git a/extensions/ql-vscode/src/remote-queries/remote-queries-view.ts b/extensions/ql-vscode/src/remote-queries/remote-queries-view.ts index 92142b45797..9a42622eacb 100644 --- a/extensions/ql-vscode/src/remote-queries/remote-queries-view.ts +++ b/extensions/ql-vscode/src/remote-queries/remote-queries-view.ts @@ -33,6 +33,7 @@ import { AnalysesResultsManager } from "./analyses-results-manager"; import { AnalysisResults } from "./shared/analysis-result"; import { humanizeUnit } from "../pure/time"; import { AbstractWebview, WebviewPanelConfig } from "../abstract-webview"; +import { telemetryListener } from "../telemetry"; export class RemoteQueriesView extends AbstractWebview< ToRemoteQueriesMessage, @@ -167,6 +168,9 @@ export class RemoteQueriesView extends AbstractWebview< msg.queryId, ); break; + case "telemetry": + telemetryListener?.sendUIInteraction(msg.action); + break; default: assertNever(msg); } diff --git a/extensions/ql-vscode/src/remote-queries/variant-analysis-view.ts b/extensions/ql-vscode/src/remote-queries/variant-analysis-view.ts index 24c1589c468..048faa94d33 100644 --- a/extensions/ql-vscode/src/remote-queries/variant-analysis-view.ts +++ b/extensions/ql-vscode/src/remote-queries/variant-analysis-view.ts @@ -16,6 +16,7 @@ import { VariantAnalysisViewManager, } from "./variant-analysis-view-manager"; import { showAndLogWarningMessage } from "../helpers"; +import { telemetryListener } from "../telemetry"; export class VariantAnalysisView extends AbstractWebview @@ -151,6 +152,9 @@ export class VariantAnalysisView this.variantAnalysisId, ); break; + case "telemetry": + telemetryListener?.sendUIInteraction(msg.action); + break; default: assertNever(msg); } diff --git a/extensions/ql-vscode/src/telemetry.ts b/extensions/ql-vscode/src/telemetry.ts index b145a3985dd..948b13c90ff 100644 --- a/extensions/ql-vscode/src/telemetry.ts +++ b/extensions/ql-vscode/src/telemetry.ts @@ -12,6 +12,7 @@ import { GLOBAL_ENABLE_TELEMETRY, LOG_TELEMETRY, isIntegrationTestMode, + isCanary, } from "./config"; import * as appInsights from "applicationinsights"; import { extLogger } from "./common"; @@ -155,19 +156,32 @@ export class TelemetryListener extends ConfigListener { ? CommandCompletion.Cancelled : CommandCompletion.Failed; - const isCanary = (!!CANARY_FEATURES.getValue()).toString(); - this.reporter.sendTelemetryEvent( "command-usage", { name, status, - isCanary, + isCanary: isCanary().toString(), }, { executionTime }, ); } + sendUIInteraction(name: string) { + if (!this.reporter) { + return; + } + + this.reporter.sendTelemetryEvent( + "ui-interaction", + { + name, + isCanary: isCanary().toString(), + }, + {}, + ); + } + /** * Displays a popup asking the user if they want to enable telemetry * for this extension. diff --git a/extensions/ql-vscode/src/view/common/CodePaths/CodePaths.tsx b/extensions/ql-vscode/src/view/common/CodePaths/CodePaths.tsx index 6b61f50ee8c..717cde2f8e6 100644 --- a/extensions/ql-vscode/src/view/common/CodePaths/CodePaths.tsx +++ b/extensions/ql-vscode/src/view/common/CodePaths/CodePaths.tsx @@ -11,6 +11,7 @@ import { ResultSeverity, } from "../../../remote-queries/shared/analysis-result"; import { CodePathsOverlay } from "./CodePathsOverlay"; +import { useTelemetryOnChange } from "../telemetry"; const ShowPathsLink = styled(VSCodeLink)` cursor: pointer; @@ -23,6 +24,8 @@ export type CodePathsProps = { severity: ResultSeverity; }; +const filterIsOpenTelemetry = (v: boolean) => v; + export const CodePaths = ({ codeFlows, ruleDescription, @@ -30,6 +33,9 @@ export const CodePaths = ({ severity, }: CodePathsProps) => { const [isOpen, setIsOpen] = useState(false); + useTelemetryOnChange(isOpen, "code-path-is-open", { + filterTelemetryOnValue: filterIsOpenTelemetry, + }); const linkRef = useRef(null); diff --git a/extensions/ql-vscode/src/view/common/CodePaths/CodePathsOverlay.tsx b/extensions/ql-vscode/src/view/common/CodePaths/CodePathsOverlay.tsx index a291574fdbd..9672e8fe171 100644 --- a/extensions/ql-vscode/src/view/common/CodePaths/CodePathsOverlay.tsx +++ b/extensions/ql-vscode/src/view/common/CodePaths/CodePathsOverlay.tsx @@ -7,6 +7,7 @@ import { CodeFlow, ResultSeverity, } from "../../../remote-queries/shared/analysis-result"; +import { useTelemetryOnChange } from "../telemetry"; import { SectionTitle } from "../SectionTitle"; import { VerticalSpace } from "../VerticalSpace"; import { CodeFlowsDropdown } from "./CodeFlowsDropdown"; @@ -77,6 +78,7 @@ export const CodePathsOverlay = ({ onClose, }: CodePathsOverlayProps) => { const [selectedCodeFlow, setSelectedCodeFlow] = useState(codeFlows[0]); + useTelemetryOnChange(selectedCodeFlow, "code-flow-selected"); return ( diff --git a/extensions/ql-vscode/src/view/common/FileCodeSnippet/CodeSnippetMessage.tsx b/extensions/ql-vscode/src/view/common/FileCodeSnippet/CodeSnippetMessage.tsx index 64e6e1340c6..dade5e03653 100644 --- a/extensions/ql-vscode/src/view/common/FileCodeSnippet/CodeSnippetMessage.tsx +++ b/extensions/ql-vscode/src/view/common/FileCodeSnippet/CodeSnippetMessage.tsx @@ -8,6 +8,7 @@ import { } from "../../../remote-queries/shared/analysis-result"; import { createRemoteFileRef } from "../../../pure/location-link-utils"; import { VerticalSpace } from "../VerticalSpace"; +import { sendTelemetry } from "../telemetry"; const getSeverityColor = (severity: ResultSeverity) => { switch (severity) { @@ -49,6 +50,8 @@ type CodeSnippetMessageProps = { children: React.ReactNode; }; +const sendAlertMessageLinkTelemetry = () => sendTelemetry("alert-message-link"); + export const CodeSnippetMessage = ({ message, severity, @@ -65,6 +68,7 @@ export const CodeSnippetMessage = ({ return ( + sendTelemetry("file-code-snippet-title-link"); + export const FileCodeSnippet = ({ fileLink, codeSnippet, @@ -67,7 +71,12 @@ export const FileCodeSnippet = ({ return ( - {fileLink.filePath} + + {fileLink.filePath} + {message && severity && ( @@ -83,7 +92,12 @@ export const FileCodeSnippet = ({ return ( - {fileLink.filePath} + + {fileLink.filePath} + {code.map((line, index) => ( diff --git a/extensions/ql-vscode/src/view/common/telemetry.ts b/extensions/ql-vscode/src/view/common/telemetry.ts new file mode 100644 index 00000000000..272dc074196 --- /dev/null +++ b/extensions/ql-vscode/src/view/common/telemetry.ts @@ -0,0 +1,61 @@ +import { useEffect, useMemo, useRef } from "react"; +import { vscode } from "../vscode-api"; + +/** + * A react effect that outputs telemetry events whenever the value changes. + * + * @param value Default value to pass to React.useState + * @param telemetryAction Name of the telemetry event to output + * @param options Extra optional arguments, including: + * filterTelemetryOnValue: If provided, only output telemetry events when the + * predicate returns true. If not provided always outputs telemetry. + * debounceTimeout: If provided, will not output telemetry events for every change + * but will wait until specified timeout happens with no new events ocurring. + */ +export function useTelemetryOnChange( + value: S, + telemetryAction: string, + { + filterTelemetryOnValue, + debounceTimeoutMillis, + }: { + filterTelemetryOnValue?: (value: S) => boolean; + debounceTimeoutMillis?: number; + } = {}, +) { + const previousValue = useRef(value); + + const sendTelemetryFunc = useMemo<() => void>(() => { + if (debounceTimeoutMillis === undefined) { + return () => sendTelemetry(telemetryAction); + } else { + let timer: NodeJS.Timeout; + return () => { + clearTimeout(timer); + timer = setTimeout(() => { + sendTelemetry(telemetryAction); + }, debounceTimeoutMillis); + }; + } + }, [telemetryAction, debounceTimeoutMillis]); + + useEffect(() => { + if (value === previousValue.current) { + return; + } + previousValue.current = value; + + if (filterTelemetryOnValue && !filterTelemetryOnValue(value)) { + return; + } + + sendTelemetryFunc(); + }, [sendTelemetryFunc, filterTelemetryOnValue, value, previousValue]); +} + +export function sendTelemetry(telemetryAction: string) { + vscode.postMessage({ + t: "telemetry", + action: telemetryAction, + }); +} diff --git a/extensions/ql-vscode/src/view/remote-queries/RawResultsTable.tsx b/extensions/ql-vscode/src/view/remote-queries/RawResultsTable.tsx index 2d88a5c2314..6ad78c29952 100644 --- a/extensions/ql-vscode/src/view/remote-queries/RawResultsTable.tsx +++ b/extensions/ql-vscode/src/view/remote-queries/RawResultsTable.tsx @@ -10,6 +10,7 @@ import { import { tryGetRemoteLocation } from "../../pure/bqrs-utils"; import TextButton from "./TextButton"; import { convertNonPrintableChars } from "../../text-utils"; +import { sendTelemetry, useTelemetryOnChange } from "../common/telemetry"; const numOfResultsInContractedMode = 5; @@ -45,6 +46,8 @@ type CellProps = { sourceLocationPrefix: string; }; +const sendRawResultsLinkTelemetry = () => sendTelemetry("raw-results-link"); + const Cell = ({ value, fileLinkPrefix, sourceLocationPrefix }: CellProps) => { switch (typeof value) { case "string": @@ -59,7 +62,11 @@ const Cell = ({ value, fileLinkPrefix, sourceLocationPrefix }: CellProps) => { ); const safeLabel = convertNonPrintableChars(value.label); if (url) { - return {safeLabel}; + return ( + + {safeLabel} + + ); } else { return {safeLabel}; } @@ -94,6 +101,8 @@ type RawResultsTableProps = { sourceLocationPrefix: string; }; +const filterTableExpandedTelemetry = (v: boolean) => v; + const RawResultsTable = ({ schema, results, @@ -101,6 +110,9 @@ const RawResultsTable = ({ sourceLocationPrefix, }: RawResultsTableProps) => { const [tableExpanded, setTableExpanded] = useState(false); + useTelemetryOnChange(tableExpanded, "raw-results-table-expanded", { + filterTelemetryOnValue: filterTableExpandedTelemetry, + }); const numOfResultsToShow = tableExpanded ? results.rows.length : numOfResultsInContractedMode; diff --git a/extensions/ql-vscode/src/view/remote-queries/RemoteQueries.tsx b/extensions/ql-vscode/src/view/remote-queries/RemoteQueries.tsx index c1a0dc4a34b..69c8292a687 100644 --- a/extensions/ql-vscode/src/view/remote-queries/RemoteQueries.tsx +++ b/extensions/ql-vscode/src/view/remote-queries/RemoteQueries.tsx @@ -433,7 +433,7 @@ const AnalysesResults = ({ sort: Sort; }) => { const totalAnalysesResults = sumAnalysesResults(analysesResults); - const [filterValue, setFilterValue] = React.useState(""); + const [filterValue, setFilterValue] = useState(""); if (totalResults === 0) { return <>; diff --git a/extensions/ql-vscode/src/view/variant-analysis/RepoRow.tsx b/extensions/ql-vscode/src/view/variant-analysis/RepoRow.tsx index 91bb97fc4ef..a1aa2790fda 100644 --- a/extensions/ql-vscode/src/view/variant-analysis/RepoRow.tsx +++ b/extensions/ql-vscode/src/view/variant-analysis/RepoRow.tsx @@ -24,6 +24,7 @@ import { vscode } from "../vscode-api"; import { AnalyzedRepoItemContent } from "./AnalyzedRepoItemContent"; import StarCount from "../common/StarCount"; import { LastUpdated } from "../common/LastUpdated"; +import { useTelemetryOnChange } from "../common/telemetry"; // This will ensure that these icons have a className which we can use in the TitleContainer const ExpandCollapseCodicon = styled(Codicon)``; @@ -157,6 +158,8 @@ const isExpandableContentLoaded = ( return resultsLoaded; }; +const filterRepoRowExpandedTelemetry = (v: boolean) => v; + export const RepoRow = ({ repository, status, @@ -168,6 +171,9 @@ export const RepoRow = ({ onSelectedChange, }: RepoRowProps) => { const [isExpanded, setExpanded] = useState(false); + useTelemetryOnChange(isExpanded, "variant-analysis-repo-row-expanded", { + filterTelemetryOnValue: filterRepoRowExpandedTelemetry, + }); const resultsLoaded = !!interpretedResults || !!rawResults; const [resultsLoading, setResultsLoading] = useState(false); @@ -198,6 +204,7 @@ export const RepoRow = ({ repository.fullName, status, downloadStatus, + setExpanded, ]); useEffect(() => { @@ -205,7 +212,7 @@ export const RepoRow = ({ setResultsLoading(false); setExpanded(true); } - }, [resultsLoaded, resultsLoading]); + }, [resultsLoaded, resultsLoading, setExpanded]); const onClickCheckbox = useCallback((e: React.MouseEvent) => { // Prevent calling the onClick event of the container, which would toggle the expanded state diff --git a/extensions/ql-vscode/src/view/variant-analysis/VariantAnalysis.tsx b/extensions/ql-vscode/src/view/variant-analysis/VariantAnalysis.tsx index 023f2d80a83..55c564d1007 100644 --- a/extensions/ql-vscode/src/view/variant-analysis/VariantAnalysis.tsx +++ b/extensions/ql-vscode/src/view/variant-analysis/VariantAnalysis.tsx @@ -12,10 +12,8 @@ import { VariantAnalysisOutcomePanels } from "./VariantAnalysisOutcomePanels"; import { VariantAnalysisLoading } from "./VariantAnalysisLoading"; import { ToVariantAnalysisMessage } from "../../pure/interface-types"; import { vscode } from "../vscode-api"; -import { - defaultFilterSortState, - RepositoriesFilterSortState, -} from "../../pure/variant-analysis-filter-sort"; +import { defaultFilterSortState } from "../../pure/variant-analysis-filter-sort"; +import { useTelemetryOnChange } from "../common/telemetry"; export type VariantAnalysisProps = { variantAnalysis?: VariantAnalysisDomainModel; @@ -63,8 +61,19 @@ export function VariantAnalysis({ const [selectedRepositoryIds, setSelectedRepositoryIds] = useState( [], ); - const [filterSortState, setFilterSortState] = - useState(defaultFilterSortState); + useTelemetryOnChange( + selectedRepositoryIds, + "variant-analysis-selected-repository-ids", + { + debounceTimeoutMillis: 1000, + }, + ); + const [filterSortState, setFilterSortState] = useState( + defaultFilterSortState, + ); + useTelemetryOnChange(filterSortState, "variant-analysis-filter-sort-state", { + debounceTimeoutMillis: 1000, + }); useEffect(() => { const listener = (evt: MessageEvent) => {