Skip to content

Commit

Permalink
feat: send plugin installed event on startup [IDE-736] (#556)
Browse files Browse the repository at this point in the history
### Description

- add analytics sending on startup 

### Checklist

- [x] Tests added and all succeed
- [x] Linted
- [x] CHANGELOG.md updated
- [ ] README.md updated, if user-facing

### Screenshots / GIFs

_Visuals that may help the reviewer. Please add screenshots for any UI
change. GIFs are most welcome!_
  • Loading branch information
bastiandoetsch authored Nov 11, 2024
2 parents d6ddc07 + 32038b8 commit 0d56250
Show file tree
Hide file tree
Showing 24 changed files with 205 additions and 64 deletions.
3 changes: 2 additions & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,9 +1,10 @@
# Snyk Security Changelog

## [2.20.0]
- disable hovers over issues
- reduce hover verbosity to only title and description
- If $/snyk.hasAuthenticated transmits an API URL, this is saved in the settings.
- Delete sentry reporting.
- send analytics event "plugin installed" the first time the extension is started

## [2.19.2]
- Update download endpoint to downloads.snyk.io.
Expand Down
75 changes: 75 additions & 0 deletions src/snyk/common/analytics/AnalyticsEvent.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,75 @@
import { AbstractAnalyticsEvent } from './AnalyticsSender';

export class AnalyticsEvent implements AbstractAnalyticsEvent {
private readonly interactionType: string;
private readonly category: string[];
private readonly status: string;
private readonly targetId: string;
private readonly timestampMs: number;
private readonly durationMs: number;
private readonly results: Map<string, unknown>;
private readonly errors: unknown[];
private readonly extension: Map<string, unknown>;

constructor(
deviceId: string,
interactionType: string,
category: string[],
status: string = 'success',
targetId: string = 'pkg:filesystem/scrubbed',
timestampMs: number = Date.now(),
durationMs: number = 0,
results: Map<string, unknown> = new Map<string, unknown>(),
errors: unknown[] = [],
extension: Map<string, unknown> = new Map<string, unknown>(),
) {
this.interactionType = interactionType;
this.category = category;
this.status = status ?? 'success';
this.targetId = targetId ?? 'pkg:filesystem/scrubbed';
this.timestampMs = timestampMs ?? Date.now();
this.durationMs = durationMs ?? 0;
this.results = results ?? new Map<string, unknown>();
this.errors = errors ?? [];
this.extension = extension ?? new Map<string, unknown>();
if (deviceId && deviceId.length > 0) {
this.extension.set('device_id', deviceId);
}
}

public getInteractionType(): string {
return this.interactionType;
}

public getCategory(): string[] {
return this.category;
}

public getStatus(): string {
return this.status;
}

public getTargetId(): string {
return this.targetId;
}

public getTimestampMs(): number {
return this.timestampMs;
}

public getDurationMs(): number {
return this.durationMs;
}

public getResults(): Map<string, unknown> {
return this.results;
}

public getErrors(): unknown[] {
return this.errors;
}

public getExtension(): Map<string, unknown> {
return this.extension;
}
}
84 changes: 84 additions & 0 deletions src/snyk/common/analytics/AnalyticsSender.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,84 @@
// noinspection InfiniteLoopJS

import { ILog } from '../logger/interfaces';
import { IConfiguration } from '../configuration/configuration';
import { sleep } from '@amplitude/experiment-node-server/dist/src/util/time';
import { IVSCodeCommands } from '../vscode/commands';
import { SNYK_REPORT_ANALYTICS } from '../constants/commands';
import { IContextService } from '../services/contextService';
import { SNYK_CONTEXT } from '../constants/views';

interface EventPair {
event: AbstractAnalyticsEvent;
callback: (value: void) => void;
}

// This is just a marker interface, to ensure type security when sending events
export interface AbstractAnalyticsEvent {}

export class AnalyticsSender {
private static instance: AnalyticsSender;
private eventQueue: EventPair[] = [];

constructor(
private logger: ILog,
private configuration: IConfiguration,
private commandExecutor: IVSCodeCommands,
private contextService: IContextService,
) {
void this.start();
}

public static getInstance(
logger: ILog,
configuration: IConfiguration,
commandExecutor: IVSCodeCommands,
contextService: IContextService,
): AnalyticsSender {
if (!AnalyticsSender.instance) {
AnalyticsSender.instance = new AnalyticsSender(logger, configuration, commandExecutor, contextService);
}
return AnalyticsSender.instance;
}

private async start(): Promise<void> {
// eslint-disable-next-line no-constant-condition
while (true) {
// eslint-disable-next-line no-await-in-loop
const authToken = await this.configuration.getToken();
const initialized: boolean = (this.contextService.viewContext[SNYK_CONTEXT.INITIALIZED] as boolean) ?? false;
const hasEvents = this.eventQueue.length > 0;
const authenticated = authToken && authToken.trim() !== '';
const iAmTired = !(initialized && authenticated && hasEvents);

if (iAmTired) {
// eslint-disable-next-line no-await-in-loop
await sleep(5000);
continue;
}

const copyForSending = [...this.eventQueue];
for (let i = 0; i < copyForSending.length; i++) {
const eventPair = copyForSending[i];
try {
// eslint-disable-next-line no-await-in-loop
await this.commandExecutor.executeCommand(SNYK_REPORT_ANALYTICS, JSON.stringify(eventPair.event));
eventPair.callback();
} catch (error) {
// eslint-disable-next-line @typescript-eslint/no-base-to-string
this.logger.error(`could not send ${eventPair.event} ${error}`);
} finally {
// let's not rely on indexes in the eventQueue array not having changed
const index = this.eventQueue.indexOf(eventPair);
if (index > -1) {
this.eventQueue.splice(index, 1);
}
}
}
}
}

public logEvent(event: AbstractAnalyticsEvent, callback: (value: void) => void): void {
this.eventQueue.push({ event, callback });
}
}
2 changes: 0 additions & 2 deletions src/snyk/common/configuration/configuration.ts
Original file line number Diff line number Diff line change
Expand Up @@ -143,8 +143,6 @@ export interface IConfiguration {
}

export class Configuration implements IConfiguration {
// These attributes are used in tests
private readonly defaultSnykCodeBaseURL = 'https://deeproxy.snyk.io';
private readonly defaultAuthHost = 'https://app.snyk.io';
private readonly defaultApiEndpoint = 'https://api.snyk.io';

Expand Down
1 change: 1 addition & 0 deletions src/snyk/common/constants/commands.ts
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@ export const SNYK_FEATURE_FLAG_COMMAND = 'snyk.getFeatureFlagStatus';
export const SNYK_CLEAR_CACHE_COMMAND = 'snyk.clearCache';
export const SNYK_CLEAR_PERSISTED_CACHE_COMMAND = 'snyk.clearPersistedCache';
export const SNYK_GENERATE_ISSUE_DESCRIPTION = 'snyk.generateIssueDescription';
export const SNYK_REPORT_ANALYTICS = 'snyk.reportAnalytics';

// custom Snyk constants used in commands
export const SNYK_CONTEXT_PREFIX = 'snyk:';
1 change: 1 addition & 0 deletions src/snyk/common/constants/globalState.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,3 +2,4 @@ export const MEMENTO_ANONYMOUS_ID = 'snyk.anonymousId';
export const MEMENTO_LS_LAST_UPDATE_DATE = 'snyk.lsLastUpdateDate';
export const MEMENTO_LS_PROTOCOL_VERSION = 'snyk.lsProtocolVersion';
export const MEMENTO_LS_CHECKSUM = 'snyk.lsChecksum';
export const MEMENTO_ANALYTICS_PLUGIN_INSTALLED_SENT = 'snyk.pluginInstalledSent';
2 changes: 1 addition & 1 deletion src/snyk/common/constants/languageServer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@
// Language Server name, used e.g. for the output channel
export const SNYK_LANGUAGE_SERVER_NAME = 'Snyk Language Server';
// The internal language server protocol version for custom messages and configuration
export const PROTOCOL_VERSION = 16;
export const PROTOCOL_VERSION = 17;

// LS protocol methods (needed for not having to rely on vscode dependencies in testing)
export const DID_CHANGE_CONFIGURATION_METHOD = 'workspace/didChangeConfiguration';
Expand Down
2 changes: 1 addition & 1 deletion src/snyk/common/languageServer/settings.ts
Original file line number Diff line number Diff line change
Expand Up @@ -87,7 +87,7 @@ export class LanguageServerSettings {
requiredProtocolVersion: `${PROTOCOL_VERSION}`,
folderConfigs: configuration.getFolderConfigs(),
enableSnykOSSQuickFixCodeActions: `${configuration.getPreviewFeatures().ossQuickfixes}`,
hoverVerbosity: 0,
hoverVerbosity: 1,
};
}
}
4 changes: 2 additions & 2 deletions src/snyk/common/views/issueTreeProvider.ts
Original file line number Diff line number Diff line change
@@ -1,10 +1,10 @@
import _, { flatten } from 'lodash';
import * as vscode from 'vscode'; // todo: invert dependency
import { IConfiguration, IssueViewOptions } from '../../common/configuration/configuration';
import { Issue, IssueSeverity, ScanProduct, LsErrorMessage } from '../../common/languageServer/types';
import { Issue, IssueSeverity, LsErrorMessage } from '../../common/languageServer/types';
import { messages as commonMessages } from '../../common/messages/analysisMessages';
import { IContextService } from '../../common/services/contextService';
import { IProductService, ProductService } from '../../common/services/productService';
import { IProductService } from '../../common/services/productService';
import { AnalysisTreeNodeProvider } from '../../common/views/analysisTreeNodeProvider';
import { INodeIcon, InternalType, NODE_ICONS, TreeNode } from '../../common/views/treeNode';
import { IVSCodeLanguages } from '../../common/vscode/languages';
Expand Down
34 changes: 27 additions & 7 deletions src/snyk/extension.ts
Original file line number Diff line number Diff line change
Expand Up @@ -39,7 +39,6 @@ import {
} from './common/constants/views';
import { ErrorHandler } from './common/error/errorHandler';
import { ExperimentService } from './common/experiment/services/experimentService';
import { LanguageServer } from './common/languageServer/languageServer';
import { StaticLsApi } from './common/languageServer/staticLsApi';
import { Logger } from './common/logger/logger';
import { DownloadService } from './common/services/downloadService';
Expand All @@ -48,7 +47,6 @@ import { NotificationService } from './common/services/notificationService';
import { User } from './common/user';
import { CodeActionAdapter } from './common/vscode/codeAction';
import { vsCodeCommands } from './common/vscode/commands';
import { vsCodeEnv } from './common/vscode/env';
import { extensionContext } from './common/vscode/extensionContext';
import { LanguageClientAdapter } from './common/vscode/languageClient';
import { vsCodeLanguages } from './common/vscode/languages';
Expand Down Expand Up @@ -81,6 +79,10 @@ import { CodeIssueData, IacIssueData, OssIssueData } from './common/languageServ
import { ClearCacheService } from './common/services/CacheService';
import { InMemory, Persisted } from './common/constants/general';
import { GitAPI, GitExtension, Repository } from './common/git';
import { AnalyticsSender } from './common/analytics/AnalyticsSender';
import { MEMENTO_ANALYTICS_PLUGIN_INSTALLED_SENT } from './common/constants/globalState';
import { AnalyticsEvent } from './common/analytics/AnalyticsEvent';
import { LanguageServer } from './common/languageServer/languageServer';

class SnykExtension extends SnykLib implements IExtension {
public async activate(vscodeContext: vscode.ExtensionContext): Promise<void> {
Expand Down Expand Up @@ -381,7 +383,7 @@ class SnykExtension extends SnykLib implements IExtension {
e.removed.forEach(folder => {
this.snykCode.resetResult(folder.uri.fsPath);
});
this.runScan(false);
this.runScan();
});

this.editorsWatcher.activate(this);
Expand Down Expand Up @@ -420,19 +422,37 @@ class SnykExtension extends SnykLib implements IExtension {
// The codeEnabled context depends on an LS command
await this.languageServer.start();

// initialize contexts
await this.contextService.setContext(SNYK_CONTEXT.INITIALIZED, true);

// Fetch feature flag to determine whether to use the new LSP-based rendering.
// feature flags depend on the language server
this.featureFlagService = new FeatureFlagService(vsCodeCommands);
await this.setupFeatureFlags();

// Fetch feature flag to determine whether to use the new LSP-based rendering.

// initialize contexts
await this.contextService.setContext(SNYK_CONTEXT.INITIALIZED, true);
this.sendPluginInstalledEvent();

// Actually start analysis
this.runScan();
}

private sendPluginInstalledEvent() {
// start analytics sender and send plugin installed event
const analyticsSender = AnalyticsSender.getInstance(Logger, configuration, vsCodeCommands, this.contextService);

const pluginInstalledSent =
extensionContext.getGlobalStateValue<boolean>(MEMENTO_ANALYTICS_PLUGIN_INSTALLED_SENT) ?? false;

if (!pluginInstalledSent) {
const category = [];
category.push('install');
const pluginInstalleEvent = new AnalyticsEvent(this.user.anonymousId, 'plugin installed', category);
analyticsSender.logEvent(pluginInstalleEvent, () => {
void extensionContext.updateGlobalStateValue(MEMENTO_ANALYTICS_PLUGIN_INSTALLED_SENT, true);
});
}
}

public async deactivate(): Promise<void> {
this.ossVulnerabilityCountService.dispose();
await this.languageServer.stop();
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -93,8 +93,6 @@ export class CodeSuggestionWebviewProvider
}

async showPanel(issue: Issue<CodeIssueData>): Promise<void> {
const isIgnoresEnabled = configuration.getFeatureFlag(FEATURE_FLAGS.consistentIgnores);

try {
await this.focusSecondEditorGroup();
if (this.panel) {
Expand All @@ -120,7 +118,7 @@ export class CodeSuggestionWebviewProvider
'snyk-code.svg',
);
// TODO: delete this when SNYK_GENERATE_ISSUE_DESCRIPTION command is in stable CLI.
let html: string = '';
let html: string;
if (issue.additionalData.details) {
html = issue.additionalData.details;
} else {
Expand Down
4 changes: 1 addition & 3 deletions src/snyk/snykOss/providers/ossDetailPanelProvider.ts
Original file line number Diff line number Diff line change
Expand Up @@ -62,8 +62,7 @@ export class OssDetailPanelProvider
);
this.registerListeners();
}

const images: Record<string, string> = [
[
['icon-code', 'svg'],
['dark-critical-severity', 'svg'],
['dark-high-severity', 'svg'],
Expand All @@ -76,7 +75,6 @@ export class OssDetailPanelProvider
accumulator[name] = uri.toString();
return accumulator;
}, {});

let html: string = '';
// TODO: delete this when SNYK_GENERATE_ISSUE_DESCRIPTION command is in stable CLI.
if (issue.additionalData.details) {
Expand Down
4 changes: 2 additions & 2 deletions src/snyk/snykOss/providers/ossVulnerabilityTreeProvider.ts
Original file line number Diff line number Diff line change
Expand Up @@ -53,7 +53,7 @@ export default class OssIssueTreeProvider extends ProductIssueTreeProvider<OssIs

let folderVulnCount = 0;

if (folderResult instanceof Error && folderResult.message === LsErrorMessage.repositoryInvalidError) {
if (folderResult instanceof Error && folderResult.message === LsErrorMessage.repositoryInvalidError.toString()) {
nodes.push(this.getFaultyRepositoryErrorTreeNode(folderName, folderResult.toString()));
continue;
}
Expand Down Expand Up @@ -173,7 +173,7 @@ export default class OssIssueTreeProvider extends ProductIssueTreeProvider<OssIs

filterIssues(issues: Issue<OssIssueData>[]): Issue<OssIssueData>[] {
return issues.filter(vuln => {
switch (vuln.severity.toLowerCase()) {
switch (vuln.severity) {
case IssueSeverity.Critical:
return this.configuration.severityFilter.critical;
case IssueSeverity.High:
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,18 +2,13 @@ import EventEmitter from 'events';
import { ImportedModule, ModuleVulnerabilityCount } from './importedModule';

export enum VulnerabilityCountEvents {
PackageJsonFound = 'packageJsonFound',
Start = 'start',
Scanned = 'scanned',
Done = 'done',
Error = 'error',
}

export class VulnerabilityCountEmitter extends EventEmitter {
packageJsonFound(fileName: string): void {
this.emit(VulnerabilityCountEvents.PackageJsonFound, fileName);
}

startScanning(importedModules: ImportedModule[]): void {
this.emit(VulnerabilityCountEvents.Start, importedModules);
}
Expand All @@ -26,6 +21,7 @@ export class VulnerabilityCountEmitter extends EventEmitter {
this.emit(VulnerabilityCountEvents.Done, moduleInfos);
}

// eslint-disable-next-line @typescript-eslint/no-redundant-type-constituents
error(error: Error | unknown): void {
this.emit(VulnerabilityCountEvents.Error, error);
}
Expand Down
1 change: 0 additions & 1 deletion src/test/integration/configuration.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,6 @@ import { FeaturesConfiguration } from '../../snyk/common/configuration/configura
import { configuration } from '../../snyk/common/configuration/instance';
import vscode from 'vscode';
import { ADVANCED_CUSTOM_ENDPOINT } from '../../snyk/common/constants/settings';
import { extensionContext } from '../../snyk/common/vscode/extensionContext';

suite('Configuration', () => {
test('settings change is reflected', async () => {
Expand Down
Loading

0 comments on commit 0d56250

Please sign in to comment.