Skip to content

Commit a08f22a

Browse files
author
Aleksander Boruch-Gruszecki
committed
Implement LSP tracing for vscode-dotty
1 parent 164d2ac commit a08f22a

File tree

4 files changed

+341
-4
lines changed

4 files changed

+341
-4
lines changed

vscode-dotty/package.json

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,20 @@
3939
}
4040
],
4141
"contributes": {
42+
"configuration": {
43+
"type": "object",
44+
"title": "Dotty Language Server configuration",
45+
"properties": {
46+
"dotty.tracing.machineId": {
47+
"type": [
48+
"string",
49+
"null"
50+
],
51+
"default": null,
52+
"description": "ID of your machine used when Dotty Language Server telemetry is turned on."
53+
}
54+
}
55+
},
4256
"configurationDefaults": {
4357
"[scala]": {
4458
"editor.tabSize": 2,

vscode-dotty/src/extension.ts

Lines changed: 19 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -10,12 +10,23 @@ import * as vscode from 'vscode';
1010
import { LanguageClient, LanguageClientOptions, RevealOutputChannelOn,
1111
ServerOptions } from 'vscode-languageclient';
1212

13+
import { Tracer } from './tracer';
14+
15+
const extensionName = 'dotty';
16+
const extensionConfig = vscode.workspace.getConfiguration(extensionName);
17+
1318
let extensionContext: ExtensionContext
1419
let outputChannel: vscode.OutputChannel
20+
let tracer: Tracer;
1521

1622
export function activate(context: ExtensionContext) {
1723
extensionContext = context
1824
outputChannel = vscode.window.createOutputChannel('Dotty Language Client');
25+
tracer = new Tracer({
26+
extensionContext,
27+
extensionConfig,
28+
extensionOut: outputChannel,
29+
})
1930

2031
const sbtArtifact = "org.scala-sbt:sbt-launch:1.2.3"
2132
const buildSbtFile = `${vscode.workspace.rootPath}/build.sbt`
@@ -150,6 +161,10 @@ function configureIDE(sbtClasspath: string, languageServerScalaVersion: string,
150161
}
151162

152163
function run(serverOptions: ServerOptions) {
164+
165+
tracer.initializeAsyncWorkspaceDump();
166+
const tracingOutputChannel = tracer.createLspOutputChannel();
167+
153168
const clientOptions: LanguageClientOptions = {
154169
documentSelector: [
155170
{ language: 'scala', scheme: 'file', pattern: '**/*.scala' },
@@ -158,14 +173,14 @@ function run(serverOptions: ServerOptions) {
158173
synchronize: {
159174
configurationSection: 'dotty'
160175
},
161-
revealOutputChannelOn: RevealOutputChannelOn.Never
176+
revealOutputChannelOn: RevealOutputChannelOn.Never,
177+
outputChannel: tracingOutputChannel,
162178
}
163179

164-
outputChannel.dispose()
165-
166-
const client = new LanguageClient('dotty', 'Dotty Language Server', serverOptions, clientOptions);
180+
const client = new LanguageClient(extensionName, 'Dotty Language Server', serverOptions, clientOptions);
167181

168182
// Push the disposable to the context's subscriptions so that the
169183
// client can be deactivated on extension deactivation
170184
extensionContext.subscriptions.push(client.start());
185+
if (tracingOutputChannel) extensionContext.subscriptions.push(tracingOutputChannel);
171186
}

vscode-dotty/src/tracer.ts

Lines changed: 281 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,281 @@
1+
import * as vscode from 'vscode';
2+
3+
import * as fs from 'fs';
4+
import * as path from 'path';
5+
6+
import * as archiver from 'archiver';
7+
import * as WebSocket from 'ws';
8+
import * as request from 'request';
9+
10+
import { TracingConsentCache } from './tracing-consent';
11+
12+
export interface Ctx {
13+
readonly extensionContext: vscode.ExtensionContext,
14+
readonly extensionConfig: vscode.WorkspaceConfiguration,
15+
readonly extensionOut: vscode.OutputChannel
16+
}
17+
18+
export class Tracer {
19+
private readonly ctx: Ctx
20+
21+
private projectId: string
22+
private machineId: string
23+
private sessionId: string
24+
25+
private tracingConsent: TracingConsentCache
26+
27+
private remoteTracingUrl?: string
28+
29+
constructor(ctx: Ctx) {
30+
this.ctx = ctx;
31+
32+
this.tracingConsent = new TracingConsentCache(ctx.extensionContext.workspaceState);
33+
34+
this.machineId = (() => {
35+
const machineIdKey = 'tracing.machineId';
36+
function persisted(value: string): string {
37+
ctx.extensionConfig.update(machineIdKey, value, vscode.ConfigurationTarget.Global)
38+
return value
39+
}
40+
41+
const machineId = ctx.extensionConfig.get<string | null>(machineIdKey)
42+
if (machineId != null) return machineId;
43+
44+
// vscode.env.machineId is a dummy value if telemetry is off - cannot be used
45+
const vscodeMachineId = vscode.workspace.getConfiguration().get<string>('telemetry.machineId')
46+
if (vscodeMachineId !== undefined) return persisted(vscodeMachineId)
47+
48+
function uuidv4() {
49+
// https://stackoverflow.com/a/2117523
50+
return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, function (c) {
51+
var r = Math.random() * 16 | 0, v = c == 'x' ? r : (r & 0x3 | 0x8);
52+
return v.toString(16);
53+
});
54+
}
55+
56+
return persisted(uuidv4())
57+
})();
58+
59+
this.projectId = vscode.workspace.name !== undefined ? vscode.workspace.name : 'no-project';
60+
this.sessionId = new Date().toISOString();
61+
}
62+
63+
initializeAsyncWorkspaceDump() {
64+
const remoteWorkspaceDumpUrl = this.ctx.extensionConfig.get<string>('remoteWorkspaceDumpUrl');
65+
if (remoteWorkspaceDumpUrl === undefined) return;
66+
67+
try {
68+
this.asyncUploadWorkspaceDump(remoteWorkspaceDumpUrl);
69+
} catch (err) {
70+
this.logError('error during workspace dump', safeError(err));
71+
}
72+
}
73+
74+
createLspOutputChannel(): vscode.OutputChannel | undefined {
75+
const remoteTracingUrl = this.ctx.extensionConfig.get<string>('remoteTracingUrl');
76+
if (!remoteTracingUrl) return undefined;
77+
78+
if (this.tracingConsent.get() === 'no-answer') {
79+
vscode.window.showInformationMessage(
80+
'Do you want to help EPFL develop this plugin by uploading your usage data? ' +
81+
'PLEASE BE AWARE that this will upload all of your keystrokes and all of your code, ' +
82+
'among other things.',
83+
'yes', 'no'
84+
).then((value: string | undefined) => {
85+
if (value === 'yes' || value === 'no') this.tracingConsent.set(value);
86+
});
87+
}
88+
89+
const localLspOutputChannel = vscode.window.createOutputChannel('Dotty LSP Communication')
90+
try {
91+
return this.createRemoteLspOutputChannel(remoteTracingUrl, localLspOutputChannel);
92+
} catch (err) {
93+
this.logError('error during remote output channel creation', safeError(err));
94+
return localLspOutputChannel;
95+
}
96+
}
97+
98+
private asyncUploadWorkspaceDump(url: string) {
99+
const storagePath = this.ctx.extensionContext.storagePath;
100+
const rootPath = vscode.workspace.rootPath;
101+
if (storagePath === undefined || rootPath === undefined) {
102+
this.logError('Cannot start workspace dump b/c of workspace state:', { storagePath, rootPath });
103+
return;
104+
}
105+
106+
if (!fs.existsSync(storagePath)) fs.mkdirSync(storagePath);
107+
const outputPath = path.join(storagePath, 'workspace-dump.zip');
108+
if (fs.existsSync(outputPath)) fs.unlinkSync(outputPath);
109+
let output = fs.createWriteStream(outputPath);
110+
output.on('end', () => {
111+
this.ctx.extensionOut.appendLine('zip - data has been drained');
112+
});
113+
114+
const zip = archiver('zip');
115+
zip.on('error', (err) => this.logError('zip error', safeError(err)));
116+
zip.on('warning', (err) => this.logError('zip warning', safeError(err)));
117+
zip.on('entry', (entry) => {
118+
this.ctx.extensionOut.appendLine(`zip - entry: ${entry.name}`);
119+
});
120+
zip.on('finish', () => {
121+
this.ctx.extensionOut.appendLine('zip - finished');
122+
fs.createReadStream(outputPath).pipe(
123+
request.put(url, {
124+
qs: {
125+
client: this.machineId,
126+
project: this.projectId,
127+
session: this.sessionId
128+
}
129+
})
130+
.on('error', (err) => this.logError('zip upload connection error', url, safeError(err)))
131+
.on('complete', (resp) => {
132+
if (!(resp.statusCode >= 200 && resp.statusCode < 300)) {
133+
this.logError('zip upload http error', url, resp.statusCode, resp.body);
134+
} else {
135+
this.ctx.extensionOut.appendLine('zip - http upload finished');
136+
}
137+
})
138+
);
139+
});
140+
zip.pipe(output);
141+
zip.glob('./**/*.{scala,sbt}', { cwd: rootPath });
142+
zip.finalize();
143+
}
144+
145+
private createRemoteLspOutputChannel(
146+
remoteTracingUrl: string,
147+
localOutputChannel: vscode.OutputChannel
148+
): vscode.OutputChannel {
149+
const socketHeaders = {
150+
'X-DLS-Project-ID': this.projectId,
151+
'X-DLS-Client-ID': this.machineId,
152+
'X-DLS-Session-ID': this.sessionId,
153+
};
154+
155+
const socket = new WebSocket(remoteTracingUrl, { headers: socketHeaders });
156+
157+
const timer = setInterval(
158+
() => {
159+
if (socket.readyState === WebSocket.OPEN) {
160+
socket.send('');
161+
} else if (socket.readyState === WebSocket.CLOSED) {
162+
clearInterval(timer);
163+
}
164+
},
165+
10 * 1000 /*ms*/,
166+
)
167+
168+
socket.onerror = (event) => {
169+
this.logErrorWithoutNotifying(
170+
'socket error',
171+
remoteTracingUrl,
172+
new SafeJsonifier(event, (event) => ({
173+
error: safeError(event.error),
174+
message: event.message,
175+
type: event.type
176+
}))
177+
);
178+
vscode.window.showWarningMessage('An error occured in Dotty LSP remote tracing connection.');
179+
}
180+
181+
socket.onclose = (event) => {
182+
this.logErrorWithoutNotifying(
183+
'socket closed',
184+
remoteTracingUrl,
185+
new SafeJsonifier(event, (event) => ({
186+
wasClean: event.wasClean,
187+
code: event.code,
188+
reason: event.reason
189+
}))
190+
);
191+
vscode.window.showWarningMessage('Dotty LSP remote tracing connection was dropped.');
192+
}
193+
194+
let log: string = '';
195+
return {
196+
name: 'websocket',
197+
198+
append: (value: string) => {
199+
localOutputChannel.append(value);
200+
if (this.tracingConsent.get() === 'no') return;
201+
log += value;
202+
},
203+
204+
appendLine: (value: string) => {
205+
localOutputChannel.appendLine(value)
206+
if (this.tracingConsent.get() === 'no') {
207+
log = '';
208+
return;
209+
}
210+
211+
log += value;
212+
log += '\n';
213+
if (this.tracingConsent.get() === 'yes' && socket.readyState === WebSocket.OPEN) {
214+
socket.send(log, (err) => {
215+
if (err) {
216+
this.logError('socket send error', err)
217+
}
218+
});
219+
log = '';
220+
}
221+
},
222+
223+
clear() { },
224+
show() { },
225+
hide() { },
226+
dispose() {
227+
socket.close();
228+
localOutputChannel.dispose();
229+
}
230+
};
231+
}
232+
233+
private silenceErrors: boolean = false;
234+
private logErrorWithoutNotifying(message: string, ...rest: any[]) {
235+
const msg = `[Dotty LSP Tracer] ${message}`;
236+
// unwrap SafeJsonifier, for some reason Electron logs the result
237+
// of .toJSON, unlike browsers
238+
console.error(msg, ...rest.map((a) => a instanceof SafeJsonifier ? a.value : a));
239+
function cautiousStringify(a: any): string {
240+
try {
241+
return JSON.stringify(a, undefined, 4);
242+
} catch (err) {
243+
console.error('cannot stringify', err, a);
244+
return a.toString();
245+
}
246+
}
247+
this.ctx.extensionOut.appendLine([msg].concat(rest.map(cautiousStringify)).join(' '));
248+
}
249+
private logError(message: string, ...rest: any[]) {
250+
this.logErrorWithoutNotifying(message, ...rest);
251+
if (!this.silenceErrors) {
252+
vscode.window.showErrorMessage(
253+
'An error occured which prevents sending usage data to EPFL. ' +
254+
'Please copy the text from "Dotty Language Client" output (View > Output) and send it to your TA.',
255+
'Silence further errors'
256+
).then((result) => {
257+
if (result !== undefined) {
258+
this.silenceErrors = true;
259+
}
260+
})
261+
}
262+
}
263+
}
264+
265+
function safeError(e: Error): SafeJsonifier<Error> {
266+
return new SafeJsonifier(e, (e) => e.toString());
267+
}
268+
269+
class SafeJsonifier<T> {
270+
value: T
271+
valueToObject: (t: T) => {}
272+
273+
constructor(value: T, valueToObject: (t: T) => {}) {
274+
this.value = value;
275+
this.valueToObject = valueToObject;
276+
}
277+
278+
toJSON() {
279+
return this.valueToObject(this.value);
280+
}
281+
}

vscode-dotty/src/tracing-consent.ts

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
import { Memento } from 'vscode';
2+
3+
export type TracingConsent = 'yes' | 'no' | 'no-answer';
4+
5+
export class TracingConsentCache {
6+
private readonly workspaceState: Memento
7+
8+
private cache?: TracingConsent
9+
10+
constructor(workspaceState: Memento) {
11+
this.workspaceState = workspaceState;
12+
}
13+
14+
get(): TracingConsent {
15+
if (this.cache !== undefined) return this.cache;
16+
const setting = this.workspaceState.get('remote-tracing-consent');
17+
this.cache = setting === undefined ? 'no-answer'
18+
: setting ? 'yes'
19+
: 'no';
20+
return this.cache;
21+
}
22+
23+
set(value: 'yes' | 'no'): void {
24+
this.workspaceState.update('remote-tracing-consent', value === 'yes');
25+
this.cache = value;
26+
}
27+
}

0 commit comments

Comments
 (0)