-
Notifications
You must be signed in to change notification settings - Fork 407
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
base: develop
Are you sure you want to change the base?
Conversation
|
||
export class CommandEventStream { | ||
private static instance: CommandEventStream; | ||
private readonly eventEmitter = new vscode.EventEmitter<CommandEvent>(); |
There was a problem hiding this comment.
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?
There was a problem hiding this comment.
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); |
There was a problem hiding this comment.
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?
There was a problem hiding this comment.
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); |
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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); |
There was a problem hiding this comment.
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
There was a problem hiding this comment.
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 |
There was a problem hiding this comment.
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?
There was a problem hiding this comment.
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( |
There was a problem hiding this comment.
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?
There was a problem hiding this comment.
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
There was a problem hiding this comment.
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(); |
There was a problem hiding this comment.
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; |
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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 => { |
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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
There was a problem hiding this comment.
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'; | ||
|
There was a problem hiding this comment.
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?
There was a problem hiding this comment.
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[] { |
There was a problem hiding this comment.
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);
There was a problem hiding this comment.
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 = {}; |
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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}` }); |
There was a problem hiding this comment.
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?
There was a problem hiding this comment.
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.
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. ASTART
event, including thecommandId
signals the beginning of logging for a given command, and anEND
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 theSTART
andEND
. 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>