Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: core extension command log #5856

Draft
wants to merge 4 commits into
base: develop
Choose a base branch
from
Draft

feat: core extension command log #5856

wants to merge 4 commits into from

Conversation

jgellin-sf
Copy link
Contributor

What does this PR do?

This PR logs commands, including exit codes and errors from CLI command executions, in the workspaceState Memento of the core extension. Additional arbitrary data can also be logged with the commands, but this is not currently used. It also allows dependent extensions to query the command log from the API.

Command events are streamed using the new CommandEventStream class. A START event, including the commandId signals the beginning of logging for a given command, and an END event signals the end, at which point the command log entry is written to the log which includes duration information as well as any data, error, or exit code events logged between the START and END. For some long-running events, there is a chance of overlapping streams from multiple commands, so it is possible that data/errors/exit codes from one event are written to multiple events. The risk of incorrect data for overlapping commands is there, but a fully traced command log would be difficult to achieve without a major refactor. This solution attempts to achieve as much as possible as a general solution.

What issues does this PR fix or reference?

@W-16486962@

Functionality Before

<insert gif and/or summary>

Functionality After

<insert gif and/or summary>


export class CommandEventStream {
private static instance: CommandEventStream;
private readonly eventEmitter = new vscode.EventEmitter<CommandEvent>();
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

should this be assigned in the constructor for testability?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

TS complains somewhere about the member not being initialized if I do that, when I write the tests I'll see if I can refactor to make everything happy.

import * as vscode from 'vscode';

export const registerCommand = (commandId: string, callback: (...args: any[]) => any, thisArg?: any): vscode.Disposable => {
return vscode.commands.registerCommand(commandId, wrapCommandCallback(commandId, callback), thisArg);
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Just to verify. B/c we're all arrow functions here the thisArg will still be applied to the eventual run of the callback b/c it's basically going to passthrough to the func returned by wrapCommandCallback. right?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Correct

public readonly onCommandEvent: vscode.Event<CommandEvent> = this.eventEmitter.event;

public post(event: CommandEvent) {
this.eventEmitter.fire(event);
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Should we wrap this in a try/catch and log, but not fail on the error if on occurs? It'd be good to avoid failing command execution due to monitoring code.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

yes. good call

const wrapCommandCallback = (commandId: string, callback: (...args: any[]) => any): (...args: any[]) => any => {
return async (...args: any[]) => {
CommandEventStream.getInstance().post({ type: CommandEventType.START, commandId });
await callback(...args);
Copy link
Contributor

@gbockus-sf gbockus-sf Sep 23, 2024

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think we need to return the result of this. One thing I'm not clear about...what if the caller doesn't want to wait on the promise to complete.

Should this be something like:

...
return Promise.resolve(callback(...args)).then((result) => {
  CommandEventStream.getInstance().post({ type: CommandEventType.END, commandId });
  return result;
});

That way we always get the END event recorded, but the caller has control on awaiting the promise, and access to the result of the call when desired.

Having a .catch is a bit stickier b/c you'll then be intercepting all errors from command calls, but I guess you could and then record the error happened and rethrow...now sure how that will impact stack traces and such

Copy link
Contributor Author

@jgellin-sf jgellin-sf Sep 23, 2024

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Something like:

try {
return await callback(...args);
} finally {
CommandEventStream.getInstance().post({ type: CommandEventType.END, commandId });
}

would work. The END event always gets issued and the exception doesn't have to be caught and rethrown.

}
},
getCommandLog,
getLastCommandLogEntry
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

These seems they they should be wrapped in a service vs just exported as standalone function to following the existing shared services pattern. CommandService on like 679 instead?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good idea I'll give that a whorl.

'sf.folder.diff',
sourceFolderDiff
);

const forceRefreshSObjectsCmd = vscode.commands.registerCommand(
const forceRefreshSObjectsCmd = registerCommand(
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

How would you feel about only doing this for sobject refresh for the initial release, but expanding to other services after we've had a couple of week to ensure there are no surprises with just wrapping sobjectRefresh?

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is a great idea @gbockus-sf

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

👍

}

public async initialize(extensionContext: vscode.ExtensionContext) {
await WorkspaceContextUtil.getInstance().initialize(extensionContext);
this.workspaceStore = extensionContext.workspaceState;
CommandEventStream.getInstance().initialize(extensionContext);
CommandLog.getInstance().initialize();
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Registering with another service doesn't seem like a business WorkspaceContext should be in.
Move to the initializeProject function?

const initializeProject = async (extensionContext: vscode.ExtensionContext) => {

duration: number;
exitCode?: number;
error?: string;
data?: any;
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

unknown would be better for consuming the data in a type safe way.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

👍

return CommandLog.getInstance().getCommandLog(commandIdFilter, exitCodeFilter);
};

export const getLastCommandLogEntry = (commandIdFilter?: string, exitCodeFilter?: number): CommandLogEntry | undefined => {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Any reason this doesn't go in utils.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Also what's the use case for getting the last commandLogEntry? Seems error prone as there can always be a bunch of command running in parallel

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yeah it's really not necessary. It's easy enough for a client of this to find the last one. I'm thinking about just having getCommandLog and an options parameter that has the commandIdFilter and exitCodeFilter and maybe a timestamp sort order as well. Overkill?

import { CommandEvent, CommandEventStream, CommandEventType } from '@salesforce/salesforcedx-utils-vscode';
import { WorkspaceContext } from '../../context';
import { CommandLogEntry } from './commandLogEntry';

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The stand alone methods...should they be static methods on CommandLog?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

My goal was to hide the CommandLog implementation from the API. If I move a new service to the API, I can get rid of the standalone methods.

await this.updateCommandLog();
}

public getCommandLog(commandIdFilter?: string, exitCodeFilter?: number): CommandLogEntry[] {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

is commandIdFilter purposely optional?
I guess it comes with the benefit that I could get all command with -1 exit code, but I'm more concerned with code that would accidentally pass undefined as the commandId then react as if they got back command that's weren't expected.

let startCmd = undefined;
let failedStartCommands = getCommandLog(startCmd, -1) 
console.log('All failed start commands', failedStartCommands);

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yeah I wanted to leave open the option for clients to get multiple types of commands. But parameter order makes it seem like it shouldn't be undefined. It seems to me like an argument for:

Yeah it's really not necessary. It's easy enough for a client of this to find the last one. I'm thinking about just having getCommandLog and an options parameter that has the commandIdFilter and exitCodeFilter and maybe a timestamp sort order as well. Overkill?


private commandLogEntries: CommandLogEntry[] = [];
private inProgressCommands: Map<string, number> = new Map();
private inProgressData: any = {};
Copy link
Contributor

@gbockus-sf gbockus-sf Sep 23, 2024

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Instead of tracking this via instance var why not have two functions for recording a log. That that doesn't take progressData for logs that don't need it (Start) and another that will take progressData (stop). That way we don't have to worry about the async parallel nature of commands.

Any call to record data would need to return the progressData.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Not sure I understand the question completely. But the reason things are set up like this is because of where the code knows what the commandId is and where the code does not. The code only knows the commandId at command registration. But for other information that can get logged (errors, exit codes, misc data), that data is coming from implementation code where we don't have the commandId. The inProgressCommands keeps track for commands for which a START event has been issued but no END yet. The inProgressData keeps track of any EXIT_CODE, DATA, ERROR` events that are issued in between those events.

If the log simply logged the raw events, it would be harder to pull the data like EXIT_CODE and associate it with a given command.

this.exitSubject.next(0);
}

public failureExit(e?: {}) {
if (e) {
// eslint-disable-next-line @typescript-eslint/no-base-to-string, @typescript-eslint/restrict-template-expressions
CommandEventStream.getInstance().post({ type: CommandEventType.ERROR, error: `${e}` });
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is there a safer way to do this without all the eslint bypasses?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'll see what I can do.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

Successfully merging this pull request may close these issues.

3 participants