forked from stripe/vscode-stripe
-
Notifications
You must be signed in to change notification settings - Fork 0
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
Start and stop logs streaming in UI (stripe#172)
- Loading branch information
1 parent
1f2ff88
commit 1acdb5c
Showing
9 changed files
with
306 additions
and
14 deletions.
There are no files selected for viewing
Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.
Oops, something went wrong.
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -1,15 +1,212 @@ | ||
import {Resource} from './resources'; | ||
import {CLICommand, StripeClient} from './stripeClient'; | ||
import {ThemeIcon, window} from 'vscode'; | ||
import {LineStream} from 'byline'; | ||
import {StripeTreeItem} from './stripeTreeItem'; | ||
import {StripeTreeViewDataProvider} from './stripeTreeViewDataProvider'; | ||
import {debounce} from './utils'; | ||
import stream from 'stream'; | ||
|
||
enum ViewState { | ||
Idle, | ||
Loading, | ||
Streaming, | ||
} | ||
|
||
type LogObject = { | ||
status: number; | ||
method: string; | ||
url: string; | ||
// eslint-disable-next-line camelcase | ||
request_id: string; | ||
}; | ||
|
||
export const isLogObject = (object: any): object is LogObject => { | ||
if (!object || typeof object !== 'object') { | ||
return false; | ||
} | ||
const possibleLogObject = object as LogObject; | ||
return ( | ||
typeof possibleLogObject.status === 'number' && | ||
typeof possibleLogObject.method === 'string' && | ||
typeof possibleLogObject.url === 'string' && | ||
typeof possibleLogObject.request_id === 'string' | ||
); | ||
}; | ||
|
||
export class StripeLogsDataProvider extends StripeTreeViewDataProvider { | ||
private static readonly REFRESH_DEBOUNCE_MILLIS = 1000; | ||
|
||
private stripeClient: StripeClient; | ||
private logTreeItems: StripeTreeItem[]; | ||
private viewState: ViewState; | ||
private logsStdoutStream: stream.Writable | null; | ||
private logsStderrStream: stream.Writable | null; | ||
|
||
constructor(stripeClient: StripeClient) { | ||
super(); | ||
this.stripeClient = stripeClient; | ||
this.logTreeItems = []; | ||
this.logsStdoutStream = null; | ||
this.logsStderrStream = null; | ||
this.viewState = ViewState.Idle; | ||
} | ||
|
||
startLogsStreaming = async () => { | ||
if (this.viewState === ViewState.Idle) { | ||
this.setViewState(ViewState.Loading); | ||
try { | ||
await this.setupStreams(); | ||
} catch (e) { | ||
window.showErrorMessage(e.message); | ||
this.stopLogsStreaming(); | ||
} | ||
} | ||
}; | ||
|
||
stopLogsStreaming = () => { | ||
this.cleanupStreams(); | ||
this.setViewState(ViewState.Idle); | ||
}; | ||
|
||
buildTree(): Promise<StripeTreeItem[]> { | ||
const logStreamItem = new StripeTreeItem('Start API logs streaming', 'openLogsStreaming'); | ||
logStreamItem.setIcon({ | ||
dark: Resource.ICONS.dark.terminal, | ||
light: Resource.ICONS.light.terminal, | ||
}); | ||
const streamingControlItemArgs = (() => { | ||
switch (this.viewState) { | ||
case ViewState.Idle: | ||
return { | ||
label: 'Start streaming API logs', | ||
command: 'startLogsStreaming', | ||
iconId: 'play-circle', | ||
}; | ||
case ViewState.Loading: | ||
return { | ||
label: 'Starting streaming API logs...', | ||
command: 'stopLogsStreaming', | ||
iconId: 'loading', | ||
}; | ||
case ViewState.Streaming: | ||
return { | ||
label: 'Stop streaming API logs', | ||
command: 'stopLogsStreaming', | ||
iconId: 'stop-circle', | ||
}; | ||
} | ||
})(); | ||
|
||
const streamingControlItem = this.createItemWithCommand(streamingControlItemArgs); | ||
|
||
const treeItems = [streamingControlItem]; | ||
|
||
if (this.logTreeItems.length > 0) { | ||
const logsStreamRootItem = new StripeTreeItem('Recent logs'); | ||
logsStreamRootItem.children = this.logTreeItems; | ||
logsStreamRootItem.expand(); | ||
treeItems.push(logsStreamRootItem); | ||
} | ||
|
||
return Promise.resolve(treeItems); | ||
} | ||
|
||
private createItemWithCommand({ | ||
label, | ||
command, | ||
iconId, | ||
}: { | ||
label: string; | ||
command?: string; | ||
iconId?: string; | ||
}) { | ||
const item = new StripeTreeItem(label, command); | ||
if (iconId) { | ||
item.iconPath = new ThemeIcon(iconId); | ||
} | ||
return item; | ||
} | ||
|
||
private async setupStreams() { | ||
const stripeLogsTailProcess = await this.stripeClient.getOrCreateCLIProcess( | ||
CLICommand.LogsTail, | ||
['--format', 'JSON'], | ||
); | ||
if (!stripeLogsTailProcess) { | ||
throw new Error('Failed to start `stripe logs tail` process'); | ||
} | ||
|
||
stripeLogsTailProcess.on('exit', this.stopLogsStreaming); | ||
|
||
/** | ||
* The CLI lets you know that streaming is ready via stderr. In the happy path: | ||
* | ||
* $ stripe logs tail | ||
* Getting ready... | ||
* Ready! You're now waiting to receive API request logs (^C to quit) | ||
* | ||
* We interpret any other message as an error. | ||
*/ | ||
if (!this.logsStderrStream) { | ||
this.logsStderrStream = new stream.Writable({ | ||
write: (chunk, _, callback) => { | ||
if (chunk.includes('Ready!')) { | ||
this.setViewState(ViewState.Streaming); | ||
} else if (!chunk.includes('Getting ready')) { | ||
window.showErrorMessage(chunk); | ||
this.stopLogsStreaming(); | ||
} | ||
callback(); | ||
}, | ||
decodeStrings: false, | ||
}); | ||
stripeLogsTailProcess.stderr | ||
.setEncoding('utf8') | ||
.pipe(new LineStream()) | ||
.pipe(this.logsStderrStream); | ||
} | ||
|
||
if (!this.logsStdoutStream) { | ||
this.logsStdoutStream = new stream.Writable({ | ||
write: (chunk, _, callback) => { | ||
try { | ||
const object = JSON.parse(chunk); | ||
if (isLogObject(object)) { | ||
const label = `[${object.status}] ${object.method} ${object.url} [${object.request_id}]`; | ||
const logTreeItem = new StripeTreeItem(label); | ||
this.insertLog(logTreeItem); | ||
} | ||
} catch {} | ||
callback(); | ||
}, | ||
decodeStrings: false, | ||
}); | ||
stripeLogsTailProcess.stdout | ||
.setEncoding('utf8') | ||
.pipe(new LineStream()) | ||
.pipe(this.logsStdoutStream); | ||
} | ||
} | ||
|
||
private cleanupStreams = () => { | ||
if (this.logsStdoutStream) { | ||
this.logsStdoutStream.destroy(); | ||
this.logsStdoutStream = null; | ||
} | ||
if (this.logsStderrStream) { | ||
this.logsStderrStream.destroy(); | ||
this.logsStderrStream = null; | ||
} | ||
this.stripeClient.endCLIProcess(CLICommand.LogsTail); | ||
}; | ||
|
||
private debouncedRefresh = debounce( | ||
this.refresh.bind(this), | ||
StripeLogsDataProvider.REFRESH_DEBOUNCE_MILLIS, | ||
); | ||
|
||
private insertLog = (logTreeItem: StripeTreeItem) => { | ||
this.logTreeItems.unshift(logTreeItem); | ||
this.debouncedRefresh(); | ||
}; | ||
|
||
return Promise.resolve([logStreamItem]); | ||
private setViewState(viewState: ViewState) { | ||
this.viewState = viewState; | ||
this.refresh(); | ||
} | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,50 @@ | ||
import * as assert from 'assert'; | ||
import {isLogObject} from '../../stripeLogsView'; | ||
|
||
suite('stripeLogsView', () => { | ||
suite('isLogObject', () => { | ||
test('returns true when all fields exist and are the correct type', () => { | ||
const object: any = { | ||
status: 200, | ||
method: 'POST', | ||
url: '/v1/checkout', | ||
request_id: 'req_123', | ||
}; | ||
assert.strictEqual(isLogObject(object), true); | ||
}); | ||
|
||
test('returns false when required field is undefined', () => { | ||
const object: any = { | ||
status: 200, | ||
method: 'POST', | ||
url: '/v1/checkout', | ||
}; | ||
assert.strictEqual(isLogObject(object), false); | ||
}); | ||
|
||
test('returns false when required field is null', () => { | ||
const object: any = { | ||
status: 200, | ||
method: 'POST', | ||
url: '/v1/checkout', | ||
request_id: null, | ||
}; | ||
assert.strictEqual(isLogObject(object), false); | ||
}); | ||
|
||
test('returns false when a field is the wrong type', () => { | ||
const object: any = { | ||
status: '200', | ||
method: 'POST', | ||
url: '/v1/checkout', | ||
request_id: 'req_123', | ||
}; | ||
assert.strictEqual(isLogObject(object), false); | ||
}); | ||
|
||
test('returns false when not an object', () => { | ||
const object: any = 'a string'; | ||
assert.strictEqual(isLogObject(object), false); | ||
}); | ||
}); | ||
}); |
Oops, something went wrong.