diff --git a/Jakefile.js b/Jakefile.js index cda7e2ca882ce..4bb1744a3a6f7 100644 --- a/Jakefile.js +++ b/Jakefile.js @@ -171,6 +171,7 @@ var servicesSources = [ var serverCoreSources = [ "types.d.ts", + "shared.ts", "utilities.ts", "scriptVersionCache.ts", "typingsCache.ts", @@ -193,6 +194,7 @@ var cancellationTokenSources = [ var typingsInstallerSources = [ "../types.d.ts", + "../shared.ts", "typingsInstaller.ts", "nodeTypingsInstaller.ts" ].map(function (f) { diff --git a/src/harness/unittests/tsserverProjectSystem.ts b/src/harness/unittests/tsserverProjectSystem.ts index 91b2092dbe22c..dd7c26ac40fae 100644 --- a/src/harness/unittests/tsserverProjectSystem.ts +++ b/src/harness/unittests/tsserverProjectSystem.ts @@ -51,8 +51,9 @@ namespace ts.projectSystem { throttleLimit: number, installTypingHost: server.ServerHost, readonly typesRegistry = createMap(), + telemetryEnabled?: boolean, log?: TI.Log) { - super(installTypingHost, globalTypingsCacheLocation, safeList.path, throttleLimit, log); + super(installTypingHost, globalTypingsCacheLocation, safeList.path, throttleLimit, telemetryEnabled, log); } safeFileList = safeList.path; diff --git a/src/harness/unittests/typingsInstaller.ts b/src/harness/unittests/typingsInstaller.ts index 7ce4be0571029..7326b619cd0b2 100644 --- a/src/harness/unittests/typingsInstaller.ts +++ b/src/harness/unittests/typingsInstaller.ts @@ -20,12 +20,13 @@ namespace ts.projectSystem { } class Installer extends TestTypingsInstaller { - constructor(host: server.ServerHost, p?: InstallerParams, log?: TI.Log) { + constructor(host: server.ServerHost, p?: InstallerParams, telemetryEnabled?: boolean, log?: TI.Log) { super( (p && p.globalTypingsCacheLocation) || "/a/data", (p && p.throttleLimit) || 5, host, (p && p.typesRegistry), + telemetryEnabled, log); } @@ -35,15 +36,16 @@ namespace ts.projectSystem { } } + function executeCommand(self: Installer, host: TestServerHost, installedTypings: string[], typingFiles: FileOrFolder[], cb: TI.RequestCompletedAction): void { + self.addPostExecAction(installedTypings, success => { + for (const file of typingFiles) { + host.createFileOrFolder(file, /*createParentDirectory*/ true); + } + cb(success); + }); + } + describe("typingsInstaller", () => { - function executeCommand(self: Installer, host: TestServerHost, installedTypings: string[], typingFiles: FileOrFolder[], cb: TI.RequestCompletedAction): void { - self.addPostExecAction(installedTypings, success => { - for (const file of typingFiles) { - host.createFileOrFolder(file, /*createParentDirectory*/ true); - } - cb(success); - }); - } it("configured projects (typings installed) 1", () => { const file1 = { path: "/a/b/app.js", @@ -905,7 +907,7 @@ namespace ts.projectSystem { const host = createServerHost([f1, packageJson]); const installer = new (class extends Installer { constructor() { - super(host, { globalTypingsCacheLocation: "/tmp" }, { isEnabled: () => true, writeLine: msg => messages.push(msg) }); + super(host, { globalTypingsCacheLocation: "/tmp" }, /*telemetryEnabled*/ false, { isEnabled: () => true, writeLine: msg => messages.push(msg) }); } installWorker(_requestId: number, _args: string[], _cwd: string, _cb: server.typingsInstaller.RequestCompletedAction) { assert(false, "runCommand should not be invoked"); @@ -949,4 +951,50 @@ namespace ts.projectSystem { assert.deepEqual(result.newTypingNames, ["bar"]); }); }); + + describe("telemetry events", () => { + it ("should be received", () => { + const f1 = { + path: "/a/app.js", + content: "" + }; + const package = { + path: "/a/package.json", + content: JSON.stringify({ dependencies: { "commander": "1.0.0" } }) + }; + const cachePath = "/a/cache/"; + const commander = { + path: cachePath + "node_modules/@types/commander/index.d.ts", + content: "export let x: number" + }; + const host = createServerHost([f1, package]); + let seenTelemetryEvent = false; + const installer = new (class extends Installer { + constructor() { + super(host, { globalTypingsCacheLocation: cachePath, typesRegistry: createTypesRegistry("commander") }, /*telemetryEnabled*/ true); + } + installWorker(_requestId: number, _args: string[], _cwd: string, cb: server.typingsInstaller.RequestCompletedAction) { + const installedTypings = ["@types/commander"]; + const typingFiles = [commander]; + executeCommand(this, host, installedTypings, typingFiles, cb); + } + sendResponse(response: server.SetTypings | server.InvalidateCachedTypings | server.TypingsInstallEvent) { + if (response.kind === server.EventInstall) { + assert.deepEqual(response.packagesToInstall, ["@types/commander"]); + seenTelemetryEvent = true; + return; + } + super.sendResponse(response); + } + })(); + const projectService = createProjectService(host, { typingsInstaller: installer }); + projectService.openClientFile(f1.path); + + installer.installAll(/*expectedCount*/ 1); + + assert.isTrue(seenTelemetryEvent); + checkNumberOfProjects(projectService, { inferredProjects: 1 }); + checkProjectActualFiles(projectService.inferredProjects[0], [f1.path, commander.path]); + }); + }); } \ No newline at end of file diff --git a/src/server/editorServices.ts b/src/server/editorServices.ts index 6565940c22d08..fa3ee724d0990 100644 --- a/src/server/editorServices.ts +++ b/src/server/editorServices.ts @@ -286,10 +286,10 @@ namespace ts.server { return; } switch (response.kind) { - case "set": + case ActionSet: this.typingsCache.updateTypingsForProject(response.projectName, response.compilerOptions, response.typingOptions, response.unresolvedImports, response.typings); break; - case "invalidate": + case ActionInvalidate: this.typingsCache.deleteTypingsForProject(response.projectName); break; } diff --git a/src/server/protocol.ts b/src/server/protocol.ts index 7da95e49575f5..d13caf7f01b0c 100644 --- a/src/server/protocol.ts +++ b/src/server/protocol.ts @@ -2057,6 +2057,32 @@ namespace ts.server.protocol { childItems?: NavigationTree[]; } + export type TelemetryEventName = "telemetry"; + + export interface TelemetryEvent extends Event { + event: TelemetryEventName; + body: TelemetryEventBody; + } + + export interface TelemetryEventBody { + telemetryEventName: string; + payload: any; + } + + export type TypingsInstalledTelemetryEventName = "typingsInstalled"; + + export interface TypingsInstalledTelemetryEventBody extends TelemetryEventBody { + telemetryEventName: TypingsInstalledTelemetryEventName; + payload: TypingsInstalledTelemetryEventPayload; + } + + export interface TypingsInstalledTelemetryEventPayload { + /** + * Comma separated list of installed typing packages + */ + installedPackages: string; + } + export interface NavBarResponse extends Response { body?: NavigationBarItem[]; } diff --git a/src/server/server.ts b/src/server/server.ts index 4bab628f9a534..e0a608556b159 100644 --- a/src/server/server.ts +++ b/src/server/server.ts @@ -1,4 +1,5 @@ /// +/// /// // used in fs.writeSync /* tslint:disable:no-null-keyword */ @@ -17,7 +18,6 @@ namespace ts.server { homedir(): string } = require("os"); - function getGlobalTypingsCacheLocation() { let basePath: string; switch (process.platform) { @@ -184,8 +184,10 @@ namespace ts.server { private socket: NodeSocket; private projectService: ProjectService; private throttledOperations: ThrottledOperations; + private telemetrySender: EventSender; constructor( + private readonly telemetryEnabled: boolean, private readonly logger: server.Logger, host: ServerHost, eventPort: number, @@ -214,15 +216,22 @@ namespace ts.server { this.socket.write(formatMessage({ seq, type: "event", event, body }, this.logger, Buffer.byteLength, this.newLine), "utf8"); } + setTelemetrySender(telemetrySender: EventSender) { + this.telemetrySender = telemetrySender; + } + attach(projectService: ProjectService) { this.projectService = projectService; if (this.logger.hasLevel(LogLevel.requestTime)) { this.logger.info("Binding..."); } - const args: string[] = ["--globalTypingsCacheLocation", this.globalTypingsCacheLocation]; + const args: string[] = [Arguments.GlobalCacheLocation, this.globalTypingsCacheLocation]; + if (this.telemetryEnabled) { + args.push(Arguments.EnableTelemetry); + } if (this.logger.loggingEnabled() && this.logger.getLogFileName()) { - args.push("--logFile", combinePaths(getDirectoryPath(normalizeSlashes(this.logger.getLogFileName())), `ti-${process.pid}.log`)); + args.push(Arguments.LogFile, combinePaths(getDirectoryPath(normalizeSlashes(this.logger.getLogFileName())), `ti-${process.pid}.log`)); } const execArgv: string[] = []; { @@ -268,12 +277,25 @@ namespace ts.server { }); } - private handleMessage(response: SetTypings | InvalidateCachedTypings) { + private handleMessage(response: SetTypings | InvalidateCachedTypings | TypingsInstallEvent) { if (this.logger.hasLevel(LogLevel.verbose)) { this.logger.info(`Received response: ${JSON.stringify(response)}`); } + if (response.kind === EventInstall) { + if (this.telemetrySender) { + const body: protocol.TypingsInstalledTelemetryEventBody = { + telemetryEventName: "typingsInstalled", + payload: { + installedPackages: response.packagesToInstall.join(",") + } + }; + const eventName: protocol.TelemetryEventName = "telemetry"; + this.telemetrySender.event(body, eventName); + } + return; + } this.projectService.updateTypingsForProject(response); - if (response.kind == "set" && this.socket) { + if (response.kind == ActionSet && this.socket) { this.sendEvent(0, "setTypings", response); } } @@ -288,18 +310,25 @@ namespace ts.server { useSingleInferredProject: boolean, disableAutomaticTypingAcquisition: boolean, globalTypingsCacheLocation: string, + telemetryEnabled: boolean, logger: server.Logger) { - super( - host, - cancellationToken, - useSingleInferredProject, - disableAutomaticTypingAcquisition - ? nullTypingsInstaller - : new NodeTypingsInstaller(logger, host, installerEventPort, globalTypingsCacheLocation, host.newLine), - Buffer.byteLength, - process.hrtime, - logger, - canUseEvents); + const typingsInstaller = disableAutomaticTypingAcquisition + ? undefined + : new NodeTypingsInstaller(telemetryEnabled, logger, host, installerEventPort, globalTypingsCacheLocation, host.newLine); + + super( + host, + cancellationToken, + useSingleInferredProject, + typingsInstaller || nullTypingsInstaller, + Buffer.byteLength, + process.hrtime, + logger, + canUseEvents); + + if (telemetryEnabled && typingsInstaller) { + typingsInstaller.setTelemetrySender(this); + } } exit() { @@ -526,17 +555,17 @@ namespace ts.server { let eventPort: number; { - const index = sys.args.indexOf("--eventPort"); - if (index >= 0 && index < sys.args.length - 1) { - const v = parseInt(sys.args[index + 1]); - if (!isNaN(v)) { - eventPort = v; - } + const str = findArgument("--eventPort"); + const v = str && parseInt(str); + if (!isNaN(v)) { + eventPort = v; } } - const useSingleInferredProject = sys.args.indexOf("--useSingleInferredProject") >= 0; - const disableAutomaticTypingAcquisition = sys.args.indexOf("--disableAutomaticTypingAcquisition") >= 0; + const useSingleInferredProject = hasArgument("--useSingleInferredProject"); + const disableAutomaticTypingAcquisition = hasArgument("--disableAutomaticTypingAcquisition"); + const telemetryEnabled = hasArgument(Arguments.EnableTelemetry); + const ioSession = new IOSession( sys, cancellationToken, @@ -545,6 +574,7 @@ namespace ts.server { useSingleInferredProject, disableAutomaticTypingAcquisition, getGlobalTypingsCacheLocation(), + telemetryEnabled, logger); process.on("uncaughtException", function (err: Error) { ioSession.logError(err, "unknown"); diff --git a/src/server/session.ts b/src/server/session.ts index 545701f1449fa..b250393b7ffdb 100644 --- a/src/server/session.ts +++ b/src/server/session.ts @@ -73,6 +73,10 @@ namespace ts.server { project: Project; } + export interface EventSender { + event(payload: any, eventName: string): void; + } + function allEditsBeforePos(edits: ts.TextChange[], pos: number) { for (const edit of edits) { if (textSpanEnd(edit.span) >= pos) { @@ -165,7 +169,7 @@ namespace ts.server { return `Content-Length: ${1 + len}\r\n\r\n${json}${newLine}`; } - export class Session { + export class Session implements EventSender { private readonly gcTimer: GcTimer; protected projectService: ProjectService; private errorTimer: any; /*NodeJS.Timer | number*/ diff --git a/src/server/shared.ts b/src/server/shared.ts new file mode 100644 index 0000000000000..81a1f7fb55bc2 --- /dev/null +++ b/src/server/shared.ts @@ -0,0 +1,24 @@ +/// + +namespace ts.server { + export const ActionSet: ActionSet = "action::set"; + export const ActionInvalidate: ActionInvalidate = "action::invalidate"; + export const EventInstall: EventInstall = "event::install"; + + export namespace Arguments { + export const GlobalCacheLocation = "--globalTypingsCacheLocation"; + export const LogFile = "--logFile"; + export const EnableTelemetry = "--enableTelemetry"; + } + + export function hasArgument(argumentName: string) { + return sys.args.indexOf(argumentName) >= 0; + } + + export function findArgument(argumentName: string) { + const index = sys.args.indexOf(argumentName); + return index >= 0 && index < sys.args.length - 1 + ? sys.args[index + 1] + : undefined; + } +} \ No newline at end of file diff --git a/src/server/tsconfig.json b/src/server/tsconfig.json index a99994d97c5f9..85c8867916421 100644 --- a/src/server/tsconfig.json +++ b/src/server/tsconfig.json @@ -18,6 +18,7 @@ "files": [ "../services/shims.ts", "../services/utilities.ts", + "shared.ts", "utilities.ts", "scriptVersionCache.ts", "scriptInfo.ts", diff --git a/src/server/tsconfig.library.json b/src/server/tsconfig.library.json index e269629b55898..5483cc8ec289a 100644 --- a/src/server/tsconfig.library.json +++ b/src/server/tsconfig.library.json @@ -15,6 +15,7 @@ "files": [ "../services/shims.ts", "../services/utilities.ts", + "shared.ts", "utilities.ts", "scriptVersionCache.ts", "scriptInfo.ts", diff --git a/src/server/types.d.ts b/src/server/types.d.ts index ec2befe8fa9b6..1fc42f9405a4c 100644 --- a/src/server/types.d.ts +++ b/src/server/types.d.ts @@ -41,23 +41,37 @@ declare namespace ts.server { readonly kind: "closeProject"; } - export type SetRequest = "set"; - export type InvalidateRequest = "invalidate"; + export type ActionSet = "action::set"; + export type ActionInvalidate = "action::invalidate"; + export type EventInstall = "event::install"; + export interface TypingInstallerResponse { + readonly kind: ActionSet | ActionInvalidate | EventInstall; + } + + export interface ProjectResponse extends TypingInstallerResponse { readonly projectName: string; - readonly kind: SetRequest | InvalidateRequest; } - export interface SetTypings extends TypingInstallerResponse { + export interface SetTypings extends ProjectResponse { readonly typingOptions: ts.TypingOptions; readonly compilerOptions: ts.CompilerOptions; readonly typings: string[]; readonly unresolvedImports: SortedReadonlyArray; - readonly kind: SetRequest; + readonly kind: ActionSet; + } + + export interface InvalidateCachedTypings extends ProjectResponse { + readonly kind: ActionInvalidate; + } + + export interface InvalidateCachedTypings extends ProjectResponse { + readonly kind: ActionInvalidate; } - export interface InvalidateCachedTypings extends TypingInstallerResponse { - readonly kind: InvalidateRequest; + export interface TypingsInstallEvent extends TypingInstallerResponse { + readonly packagesToInstall: ReadonlyArray; + readonly kind: EventInstall; } export interface InstallTypingHost extends JsTyping.TypingResolutionHost { diff --git a/src/server/typingsInstaller/nodeTypingsInstaller.ts b/src/server/typingsInstaller/nodeTypingsInstaller.ts index 98a61719c5b4a..21e42606904be 100644 --- a/src/server/typingsInstaller/nodeTypingsInstaller.ts +++ b/src/server/typingsInstaller/nodeTypingsInstaller.ts @@ -75,12 +75,13 @@ namespace ts.server.typingsInstaller { private readonly npmPath: string; readonly typesRegistry: Map; - constructor(globalTypingsCacheLocation: string, throttleLimit: number, log: Log) { + constructor(globalTypingsCacheLocation: string, throttleLimit: number, telemetryEnabled: boolean, log: Log) { super( sys, globalTypingsCacheLocation, toPath("typingSafeList.json", __dirname, createGetCanonicalFileName(sys.useCaseSensitiveFileNames)), throttleLimit, + telemetryEnabled, log); if (this.log.isEnabled()) { this.log.writeLine(`Process id: ${process.pid}`); @@ -145,15 +146,10 @@ namespace ts.server.typingsInstaller { } } - function findArgument(argumentName: string) { - const index = sys.args.indexOf(argumentName); - return index >= 0 && index < sys.args.length - 1 - ? sys.args[index + 1] - : undefined; - } + const logFilePath = findArgument(server.Arguments.LogFile); + const globalTypingsCacheLocation = findArgument(server.Arguments.GlobalCacheLocation); + const telemetryEnabled = hasArgument(server.Arguments.EnableTelemetry); - const logFilePath = findArgument("--logFile"); - const globalTypingsCacheLocation = findArgument("--globalTypingsCacheLocation"); const log = new FileLog(logFilePath); if (log.isEnabled()) { process.on("uncaughtException", (e: Error) => { @@ -166,6 +162,6 @@ namespace ts.server.typingsInstaller { } process.exit(0); }); - const installer = new NodeTypingsInstaller(globalTypingsCacheLocation, /*throttleLimit*/5, log); + const installer = new NodeTypingsInstaller(globalTypingsCacheLocation, /*throttleLimit*/5, telemetryEnabled, log); installer.listen(); } \ No newline at end of file diff --git a/src/server/typingsInstaller/tsconfig.json b/src/server/typingsInstaller/tsconfig.json index c9b4d8f0ad14a..c6031b19aae48 100644 --- a/src/server/typingsInstaller/tsconfig.json +++ b/src/server/typingsInstaller/tsconfig.json @@ -17,6 +17,7 @@ }, "files": [ "../types.d.ts", + "../shared.ts", "typingsInstaller.ts", "nodeTypingsInstaller.ts" ] diff --git a/src/server/typingsInstaller/typingsInstaller.ts b/src/server/typingsInstaller/typingsInstaller.ts index 003f9d76473aa..02c4d71f18640 100644 --- a/src/server/typingsInstaller/typingsInstaller.ts +++ b/src/server/typingsInstaller/typingsInstaller.ts @@ -2,6 +2,7 @@ /// /// /// +/// namespace ts.server.typingsInstaller { interface NpmConfig { @@ -88,6 +89,7 @@ namespace ts.server.typingsInstaller { readonly globalCachePath: string, readonly safeListPath: Path, readonly throttleLimit: number, + readonly telemetryEnabled: boolean, protected readonly log = nullLog) { if (this.log.isEnabled()) { this.log.writeLine(`Global cache location '${globalCachePath}', safe file path '${safeListPath}'`); @@ -298,9 +300,17 @@ namespace ts.server.typingsInstaller { this.installRunCount++; this.installTypingsAsync(requestId, scopedTypings, cachePath, ok => { + if (this.telemetryEnabled) { + this.sendResponse({ + kind: EventInstall, + packagesToInstall: scopedTypings + }); + } + if (!ok) { return; } + // TODO: watch project directory if (this.log.isEnabled()) { this.log.writeLine(`Requested to install typings ${JSON.stringify(scopedTypings)}, installed typings ${JSON.stringify(scopedTypings)}`); @@ -354,7 +364,7 @@ namespace ts.server.typingsInstaller { this.log.writeLine(`Got FS notification for ${f}, handler is already invoked '${isInvoked}'`); } if (!isInvoked) { - this.sendResponse({ projectName: projectName, kind: "invalidate" }); + this.sendResponse({ projectName: projectName, kind: server.ActionInvalidate }); isInvoked = true; } }); @@ -370,7 +380,7 @@ namespace ts.server.typingsInstaller { compilerOptions: request.compilerOptions, typings, unresolvedImports: request.unresolvedImports, - kind: "set" + kind: ActionSet }; } @@ -392,6 +402,6 @@ namespace ts.server.typingsInstaller { } protected abstract installWorker(requestId: number, args: string[], cwd: string, onRequestCompleted: RequestCompletedAction): void; - protected abstract sendResponse(response: SetTypings | InvalidateCachedTypings): void; + protected abstract sendResponse(response: SetTypings | InvalidateCachedTypings | TypingsInstallEvent): void; } } \ No newline at end of file diff --git a/src/server/utilities.ts b/src/server/utilities.ts index 8806b759e3f8d..9a832d22c8d5c 100644 --- a/src/server/utilities.ts +++ b/src/server/utilities.ts @@ -1,4 +1,5 @@ /// +/// namespace ts.server { export enum LogLevel { @@ -10,6 +11,7 @@ namespace ts.server { export const emptyArray: ReadonlyArray = []; + export interface Logger { close(): void; hasLevel(level: LogLevel): boolean;