Skip to content

Commit

Permalink
Start and stop logs streaming in UI (stripe#172)
Browse files Browse the repository at this point in the history
  • Loading branch information
vcheung-stripe authored Feb 12, 2021
1 parent 1f2ff88 commit 1acdb5c
Show file tree
Hide file tree
Showing 9 changed files with 306 additions and 14 deletions.
14 changes: 14 additions & 0 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 2 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -296,6 +296,7 @@
"test": "node ./out/test/runTest.js"
},
"devDependencies": {
"@types/byline": "^4.2.32",
"@types/glob": "^7.1.3",
"@types/mocha": "^8.0.3",
"@types/node": "^10.14.17",
Expand All @@ -315,6 +316,7 @@
},
"dependencies": {
"@types/universal-analytics": "^0.4.4",
"byline": "^5.0.0",
"execa": "^4.0.0",
"moment": "^2.24.0",
"os-name": "^3.1.0",
Expand Down
12 changes: 9 additions & 3 deletions src/commands.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import {getExtensionInfo, showQuickPickWithItems, showQuickPickWithValues} from
import {getRecentEvents, recordEvent} from './stripeWorkspaceState';
import osName = require('os-name');
import {StripeEventsDataProvider} from './stripeEventsView';
import {StripeLogsDataProvider} from './stripeLogsView';
import {StripeTerminal} from './stripeTerminal';
import {StripeTreeItem} from './stripeTreeItem';
import {Telemetry} from './telemetry';
Expand Down Expand Up @@ -121,9 +122,14 @@ export class Commands {
this.terminal.execute('listen', [...forwardToFlag, ...forwardConnectToFlag, ...eventsFlag]);
};

openLogsStreaming = () => {
this.telemetry.sendEvent('openLogsStreaming');
this.terminal.execute('logs', ['tail']);
startLogsStreaming = (stripeLogsDataProvider: StripeLogsDataProvider) => {
this.telemetry.sendEvent('startLogsStreaming');
stripeLogsDataProvider.startLogsStreaming();
};

stopLogsStreaming = (stripeLogsDataProvider: StripeLogsDataProvider) => {
this.telemetry.sendEvent('stopLogsStreaming');
stripeLogsDataProvider.stopLogsStreaming();
};

startLogin = () => {
Expand Down
16 changes: 14 additions & 2 deletions src/extension.ts
Original file line number Diff line number Diff line change
Expand Up @@ -56,7 +56,7 @@ export function activate(this: any, context: ExtensionContext) {
showCollapseAll: true,
});

const stripeLogsViewProvider = new StripeLogsDataProvider();
const stripeLogsViewProvider = new StripeLogsDataProvider(stripeClient);
window.createTreeView('stripeLogsView', {
treeDataProvider: stripeLogsViewProvider,
showCollapseAll: true,
Expand Down Expand Up @@ -102,6 +102,16 @@ export function activate(this: any, context: ExtensionContext) {
stripeEventsViewProvider,
);

const boundStartLogsStreaming = stripeCommands.startLogsStreaming.bind(
this,
stripeLogsViewProvider,
);

const boundStopLogsStreaming = stripeCommands.stopLogsStreaming.bind(
this,
stripeLogsViewProvider,
);

context.subscriptions.push(commands.registerCommand('stripe.openCLI', stripeCommands.openCLI));

context.subscriptions.push(commands.registerCommand('stripe.login', stripeCommands.startLogin));
Expand All @@ -111,9 +121,11 @@ export function activate(this: any, context: ExtensionContext) {
);

subscriptions.push(
commands.registerCommand('stripe.openLogsStreaming', stripeCommands.openLogsStreaming),
commands.registerCommand('stripe.startLogsStreaming', boundStartLogsStreaming),
);

subscriptions.push(commands.registerCommand('stripe.stopLogsStreaming', boundStopLogsStreaming));

subscriptions.push(
commands.registerCommand('stripe.openDashboardEvents', stripeCommands.openDashboardEvents),
);
Expand Down
2 changes: 1 addition & 1 deletion src/stripeClient.ts
Original file line number Diff line number Diff line change
Expand Up @@ -151,7 +151,7 @@ export class StripeClient {
const newCLIProcess = spawn(cliPath, [...commandArgs, ...allFlags]);
this.cliProcesses.set(cliCommand, newCLIProcess);

newCLIProcess.on('close', () => this.cleanupCLIProcess(cliCommand));
newCLIProcess.on('exit', () => this.cleanupCLIProcess(cliCommand));
newCLIProcess.on('error', () => this.cleanupCLIProcess(cliCommand));

return newCLIProcess;
Expand Down
211 changes: 204 additions & 7 deletions src/stripeLogsView.ts
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();
}
}
2 changes: 1 addition & 1 deletion src/test/suite/stripeClient.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -200,7 +200,7 @@ suite('stripeClient', () => {
});

suite('on child process events', () => {
['close', 'error'].forEach((event) => {
['exit', 'error'].forEach((event) => {
test(`on ${event}, removes child process`, async () => {
const stripeClient = new StripeClient(new NoOpTelemetry());
sandbox.stub(stripeClient, 'getCLIPath').resolves('path/to/stripe');
Expand Down
50 changes: 50 additions & 0 deletions src/test/suite/stripeLogsView.test.ts
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);
});
});
});
Loading

0 comments on commit 1acdb5c

Please sign in to comment.