Skip to content

Commit

Permalink
Use new telemetry API
Browse files Browse the repository at this point in the history
  • Loading branch information
vinistock committed Jul 5, 2024
1 parent 8fe7f1d commit 00193ab
Show file tree
Hide file tree
Showing 10 changed files with 195 additions and 410 deletions.
60 changes: 34 additions & 26 deletions vscode/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -389,39 +389,47 @@ Please note that only Docker is officially supported as a backend by the Dev Con

## Telemetry

On its own, the Ruby LSP does not collect any telemetry by default, but it does support hooking up to a private metrics
service if desired.
The Ruby LSP does not collect any telemetry by default, but it supports hooking up to a private metrics service if
desired. This can be useful if you'd like to understand adoption, performance or catch errors of the Ruby LSP within
your team or company.

In order to receive metrics requests, a private plugin must export the `ruby-lsp.getPrivateTelemetryApi` command, which
should return an object that implements the `TelemetryApi` interface defined
[here](https://github.com/Shopify/ruby-lsp/blob/main/vscode/src/telemetry.ts).
To collect metrics, another VS Code extension (typically a private one) should define the command
`getTelemetrySenderObject`. This command should return an object that implements the
[vscode.TelemetrySender](https://code.visualstudio.com/api/references/vscode-api#TelemetrySender) interface, thus
defining where data and error reports should be sent to. For example:

Fields included by default are defined in `TelemetryEvent`
[here](https://github.com/Shopify/ruby-lsp/blob/main/vscode/src/telemetry.ts). The exported API object can add any
other data of interest and publish it to a private service.
```typescript
// Your private VS Code extension

For example,
class Telemetry implements vscode.TelemetrySender {
constructor() {
// Initialize some API service or whatever is needed to collect metrics
}

```typescript
// Create the API class in a private plugin
class MyApi implements TelemetryApi {
sendEvent(event: TelemetryEvent): Promise<void> {
// Add timestamp to collected metrics
const payload = {
timestamp: Date.now(),
...event,
};

// Send metrics to a private service
myFavouriteHttpClient.post("private-metrics-url", payload);
sendEventData(eventName: string, data: EventData): void {
// Send events to some API or accumulate them to be sent in batch when `flush` is invoked by VS Code
}

sendErrorData(error: Error, data?: Record<string, any> | undefined): void {
// Send errors to some API or accumulate them to be sent in batch when `flush` is invoked by VS Code
}

async flush() {
// Optional function to flush accumulated events and errors
}
}

// Register the command to return an object of the API
vscode.commands.registerCommand(
"ruby-lsp.getPrivateTelemetryApi",
() => new MyApi(),
);
export async function activate(context: vscode.ExtensionContext) {
const telemetry = new Telemetry();
await telemetry.activate();

// Register the command that the Ruby LSP will search for to hook into
context.subscriptions.push(
vscode.commands.registerCommand("getTelemetrySenderObject", () => {
return telemetry;
}),
);
}
```

## Formatting
Expand Down
108 changes: 35 additions & 73 deletions vscode/src/client.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import path from "path";
import os from "os";
import { performance as Perf } from "perf_hooks";

import * as vscode from "vscode";
Expand All @@ -19,10 +20,10 @@ import {
Message,
ErrorAction,
CloseAction,
State,
} from "vscode-languageclient/node";

import { LSP_NAME, ClientInterface, Addon } from "./common";
import { Telemetry, RequestEvent } from "./telemetry";
import { Ruby } from "./ruby";
import { WorkspaceChannel } from "./workspaceChannel";

Expand Down Expand Up @@ -211,18 +212,17 @@ export default class Client extends LanguageClient implements ClientInterface {
public serverVersion?: string;
public addons?: Addon[];
private readonly workingDirectory: string;
private readonly telemetry: Telemetry;
private readonly telemetry: vscode.TelemetryLogger;
private readonly createTestItems: (response: CodeLens[]) => void;
private readonly baseFolder;
private requestId = 0;
private readonly workspaceOutputChannel: WorkspaceChannel;

#context: vscode.ExtensionContext;
#formatter: string;

constructor(
context: vscode.ExtensionContext,
telemetry: Telemetry,
telemetry: vscode.TelemetryLogger,
ruby: Ruby,
createTestItems: (response: CodeLens[]) => void,
workspaceFolder: vscode.WorkspaceFolder,
Expand Down Expand Up @@ -298,93 +298,42 @@ export default class Client extends LanguageClient implements ClientInterface {

private async benchmarkMiddleware<T>(
type: string | MessageSignature,
params: any,
_params: any,
runRequest: () => Promise<T>,
): Promise<T> {
// Because of the custom bundle logic in the server, we can only fetch the server version after launching it. That
// means some requests may be received before the computed the version. For those, we cannot send telemetry
if (this.serverVersion === undefined) {
if (this.state !== State.Running) {
return runRequest();
}

const request = typeof type === "string" ? type : type.method;

// The first few requests are not representative for telemetry. Their response time is much higher than the rest
// because they are inflate by the time we spend indexing and by regular "warming up" of the server (like
// autoloading constants or running signature blocks).
if (this.requestId < 50) {
this.requestId++;
return runRequest();
}

const telemetryData: RequestEvent = {
request,
rubyVersion: this.ruby.rubyVersion!,
yjitEnabled: this.ruby.yjitEnabled!,
lspVersion: this.serverVersion,
requestTime: 0,
};

// If there are parameters in the request, include those
if (params) {
const castParam = { ...params } as { textDocument?: { uri: string } };

if ("textDocument" in castParam) {
const uri = castParam.textDocument?.uri.replace(
// eslint-disable-next-line no-process-env
process.env.HOME!,
"~",
);

delete castParam.textDocument;
telemetryData.uri = uri;
}

telemetryData.params = JSON.stringify(castParam);
}

let result: T | undefined;
let errorResult;
const benchmarkId = this.requestId++;

// Execute the request measuring the time it takes to receive the response
Perf.mark(`${benchmarkId}.start`);
try {
result = await runRequest();
// Execute the request measuring the time it takes to receive the response
Perf.mark(`${request}.start`);
const result = await runRequest();
Perf.mark(`${request}.end`);

const bench = Perf.measure(
"benchmarks",
`${request}.start`,
`${request}.end`,
);

this.logResponseTime(bench.duration, request);
return result;
} catch (error: any) {
// If any errors occurred in the request, we'll receive these from the LSP server
telemetryData.errorClass = error.data.errorClass;
telemetryData.errorMessage = error.data.errorMessage;
telemetryData.backtrace = error.data.backtrace;
errorResult = error;
}
Perf.mark(`${benchmarkId}.end`);

// Insert benchmarked response time into telemetry data
const bench = Perf.measure(
"benchmarks",
`${benchmarkId}.start`,
`${benchmarkId}.end`,
);
telemetryData.requestTime = bench.duration;
await this.telemetry.sendEvent(telemetryData);

// If there has been an error, we must throw it again. Otherwise we can return the result
if (errorResult) {
if (
this.baseFolder === "ruby-lsp" ||
this.baseFolder === "ruby-lsp-rails"
) {
await vscode.window.showErrorMessage(
`Ruby LSP error ${errorResult.data.errorClass}: ${errorResult.data.errorMessage}\n\n
${errorResult.data.backtrace}`,
`Ruby LSP error ${error.data.errorClass}: ${error.data.errorMessage}\n\n${error.data.backtrace}`,
);
}

throw errorResult;
this.telemetry.logError(error, { ...error.data });
throw error;
}

return result!;
}

// Register the middleware in the client options
Expand Down Expand Up @@ -497,4 +446,17 @@ export default class Client extends LanguageClient implements ClientInterface {
},
};
}

private logResponseTime(duration: number, label: string) {
this.telemetry.logUsage("ruby_lsp.response_time", {
type: "histogram",
value: duration,
attributes: {
message: new vscode.TelemetryTrustedValue(label),
lspVersion: this.serverVersion,
rubyVersion: this.ruby.rubyVersion,
environment: os.platform(),
},
});
}
}
69 changes: 68 additions & 1 deletion vscode/src/extension.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import * as vscode from "vscode";

import { RubyLsp } from "./rubyLsp";
import { LOG_CHANNEL } from "./common";

let extension: RubyLsp;

Expand All @@ -24,7 +25,8 @@ export async function activate(context: vscode.ExtensionContext) {
return;
}

extension = new RubyLsp(context);
const logger = await createLogger(context);
extension = new RubyLsp(context, logger);
await extension.activate();
}

Expand Down Expand Up @@ -63,3 +65,68 @@ async function migrateManagerConfigurations() {
}
}
}

async function createLogger(context: vscode.ExtensionContext) {
let sender;

switch (context.extensionMode) {
case vscode.ExtensionMode.Development:
sender = {
sendEventData: (eventName: string, data?: Record<string, any>) => {
LOG_CHANNEL.debug(eventName, data);
},
sendErrorData: (error: Error, data?: Record<string, any>) => {
LOG_CHANNEL.error(error, data);
},
};
break;
case vscode.ExtensionMode.Test:
sender = {
sendEventData: (_eventName: string, _data?: Record<string, any>) => {},
sendErrorData: (_error: Error, _data?: Record<string, any>) => {},
};
break;
default:
try {
let counter = 0;

// If the extension that implements the getTelemetrySenderObject is not activated yet, the first invocation to
// the command will activate it, but it might actually return `null` rather than the sender object. Here we try
// a few times to receive a non `null` object back because we know that the getTelemetrySenderObject command
// exists (otherwise, we end up in the catch clause)
while (!sender && counter < 5) {
await vscode.commands.executeCommand("getTelemetrySenderObject");

sender =
await vscode.commands.executeCommand<vscode.TelemetrySender | null>(
"getTelemetrySenderObject",
);

counter++;
}
} catch (error: any) {
sender = {
sendEventData: (
_eventName: string,
_data?: Record<string, any>,
) => {},
sendErrorData: (_error: Error, _data?: Record<string, any>) => {},
};
}
break;
}

if (!sender) {
sender = {
sendEventData: (_eventName: string, _data?: Record<string, any>) => {},
sendErrorData: (_error: Error, _data?: Record<string, any>) => {},
};
}

return vscode.env.createTelemetryLogger(sender, {
ignoreBuiltInCommonProperties: true,
ignoreUnhandledErrors:
context.extensionMode === vscode.ExtensionMode.Test ||
context.extensionMode === vscode.ExtensionMode.Development,
});
}
12 changes: 6 additions & 6 deletions vscode/src/rubyLsp.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,6 @@
import * as vscode from "vscode";
import { Range } from "vscode-languageclient/node";

import { Telemetry } from "./telemetry";
import DocumentProvider from "./documentProvider";
import { Workspace } from "./workspace";
import { Command, LOG_CHANNEL, STATUS_EMITTER } from "./common";
Expand All @@ -16,15 +15,18 @@ import { DependenciesTree } from "./dependenciesTree";
// commands
export class RubyLsp {
private readonly workspaces: Map<string, Workspace> = new Map();
private readonly telemetry: Telemetry;
private readonly context: vscode.ExtensionContext;
private readonly statusItems: StatusItems;
private readonly testController: TestController;
private readonly debug: Debugger;
private readonly telemetry: vscode.TelemetryLogger;

constructor(context: vscode.ExtensionContext) {
constructor(
context: vscode.ExtensionContext,
telemetry: vscode.TelemetryLogger,
) {
this.context = context;
this.telemetry = new Telemetry(context);
this.telemetry = telemetry;
this.testController = new TestController(
context,
this.telemetry,
Expand Down Expand Up @@ -56,7 +58,6 @@ export class RubyLsp {
}
}
}),

// Lazily activate workspaces that do not contain a lockfile
vscode.workspace.onDidOpenTextDocument(async (document) => {
if (document.languageId !== "ruby") {
Expand Down Expand Up @@ -86,7 +87,6 @@ export class RubyLsp {
// all language servers for each existing workspace
async activate() {
await vscode.commands.executeCommand("testing.clearTestResults");
await this.telemetry.sendConfigurationEvents();

const firstWorkspace = vscode.workspace.workspaceFolders?.[0];

Expand Down
Loading

0 comments on commit 00193ab

Please sign in to comment.