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

Add ability to retrieve and tail app function logs #490

Merged
merged 9 commits into from
May 7, 2021
3 changes: 0 additions & 3 deletions jest.config.js
Original file line number Diff line number Diff line change
@@ -1,6 +1,3 @@
// Chalk can cause snapshots to with its styling, just disable the color instead
process.env.FORCE_COLOR = 0;

module.exports = {
testEnvironment: 'node',
projects: ['<rootDir>/packages/*'],
Expand Down
25 changes: 24 additions & 1 deletion packages/cli-lib/__tests__/__snapshots__/schema.js.snap
Original file line number Diff line number Diff line change
@@ -1,5 +1,28 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP

exports[`cli-lib/schema cleanSchema() cleans a basic schema 1`] = `
Object {
"associatedObjects": null,
"labels": Object {
"plural": "Schemas",
"singular": "Schema",
},
"name": "schema",
"primaryDisplayProperty": "name",
"properties": Array [
Object {
"description": "Name Field",
"fieldType": "text",
"label": "Name",
"name": "name",
"type": "string",
},
],
"requiredProperties": Array [],
"searchableProperties": Array [],
}
`;

exports[`cli-lib/schema cleanSchema() cleans a full schema 1`] = `
Object {
"associatedObjects": undefined,
Expand Down Expand Up @@ -176,7 +199,7 @@ Array [
exports[`cli-lib/schema logSchemas() logs schemas 1`] = `
"╔════════╤════════╤══════════════╗
║ Label │ Name │ objectTypeId ║
║ Schema │ schema │
║ Schema │ schema │ 2-123
╚════════╧════════╧══════════════╝
"
`;
Expand Down
1 change: 1 addition & 0 deletions packages/cli-lib/__tests__/fixtures/schema/basic.json
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
"requiredProperties": [],
"searchableProperties": [],
"primaryDisplayProperty": "name",
"objectTypeId": "2-123",
"properties": [
{
"name": "name",
Expand Down
10 changes: 9 additions & 1 deletion packages/cli-lib/__tests__/schema.js
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
const fs = require('fs-extra');
const path = require('path');
const chalk = require('chalk');
const { cleanSchema, writeSchemaToDisk, logSchemas } = require('../schema');
const { logger } = require('../logger');
const { getCwd } = require('../path');
Expand All @@ -8,9 +9,16 @@ const full = require('./fixtures/schema/full.json');
const multiple = require('./fixtures/schema/multiple.json');

describe('cli-lib/schema', () => {
const originalChalkLevel = chalk.level;
beforeEach(() => {
chalk.level = 0;
Copy link
Contributor Author

Choose a reason for hiding this comment

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

@drewjenkins due to an upgrade of chalk, you're approach of monkey patching process.env.FORCE_COLOR stopped working. I switched to a more targeted approach using https://github.com/chalk/chalk#chalklevel.

});
afterEach(() => {
chalk.level = originalChalkLevel;
});
describe('cleanSchema()', () => {
it('cleans a basic schema', () => {
expect(cleanSchema(basic)).toEqual(basic);
expect(cleanSchema(basic)).toMatchSnapshot();
});

it('cleans a full schema', () => {
Expand Down
23 changes: 23 additions & 0 deletions packages/cli-lib/api/functions.js
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,30 @@ async function getBuildStatus(portalId, buildId) {
});
}

async function getAppFunctionLogs(
accountId,
functionName,
appPath,
query = {}
) {
const { limit = 5 } = query;

return http.get(accountId, {
uri: `${FUNCTION_API_PATH}/app-function/logs/${functionName}`,
query: { ...query, limit, appPath },
});
}

async function getLatestAppFunctionLogs(accountId, functionName, appPath) {
return http.get(accountId, {
uri: `${FUNCTION_API_PATH}/app-function/logs/${functionName}/latest`,
query: { appPath },
});
}

module.exports = {
getAppFunctionLogs,
getLatestAppFunctionLogs,
buildPackage,
getBuildStatus,
getFunctionByPath,
Expand Down
172 changes: 81 additions & 91 deletions packages/cli/commands/logs.js
Original file line number Diff line number Diff line change
@@ -1,4 +1,3 @@
const readline = require('readline');
const ora = require('ora');
const {
addAccountOptions,
Expand All @@ -20,38 +19,17 @@ const {
ApiErrorContext,
} = require('@hubspot/cli-lib/errorHandlers');
const { outputLogs } = require('@hubspot/cli-lib/lib/logs');
const { getFunctionByPath } = require('@hubspot/cli-lib/api/functions');
const {
getFunctionByPath,
getAppFunctionLogs,
getLatestAppFunctionLogs,
} = require('@hubspot/cli-lib/api/functions');
const {
getFunctionLogs,
getLatestFunctionLog,
} = require('@hubspot/cli-lib/api/results');
const { base64EncodeString } = require('@hubspot/cli-lib/lib/encoding');
const { validateAccount } = require('../lib/validation');

const TAIL_DELAY = 5000;

const makeSpinner = (functionPath, accountId) => {
return ora(
`Waiting for log entries for '${functionPath}' on account '${accountId}'.\n`
);
};

const makeTailCall = (accountId, functionId) => {
return async after => {
const latestLog = await getFunctionLogs(accountId, functionId, { after });
return latestLog;
};
};

const handleKeypressToExit = exit => {
readline.emitKeypressEvents(process.stdin);
process.stdin.setRawMode(true);
process.stdin.on('keypress', (str, key) => {
if (key && ((key.ctrl && key.name == 'c') || key.name === 'escape')) {
exit();
}
});
};
const { tailLogs } = require('../lib/serverlessLogs');

const loadAndValidateOptions = async options => {
setLogLevel(options);
Expand All @@ -65,66 +43,8 @@ const loadAndValidateOptions = async options => {
}
};

const tailLogs = async ({
functionId,
functionPath,
accountId,
accountName,
compact,
}) => {
const tailCall = makeTailCall(accountId, functionId);
const spinner = makeSpinner(functionPath, accountName || accountId);
let initialAfter;

spinner.start();

try {
const latestLog = await getLatestFunctionLog(accountId, functionId);
initialAfter = base64EncodeString(latestLog.id);
} catch (e) {
// A 404 means no latest log exists(never executed)
if (e.statusCode !== 404) {
await logServerlessFunctionApiErrorInstance(
accountId,
e,
new ApiErrorContext({ accountId, functionPath })
);
}
}

const tail = async after => {
const latestLog = await tailCall(after);

if (latestLog.results.length) {
spinner.clear();
outputLogs(latestLog, {
compact,
});
}

setTimeout(() => {
tail(latestLog.paging.next.after);
}, TAIL_DELAY);
};

handleKeypressToExit(() => {
spinner.stop();
process.exit();
});
tail(initialAfter);
};

exports.command = 'logs <endpoint>';
exports.describe = 'get logs for a function';

exports.handler = async options => {
loadAndValidateOptions(options);

const endpointLog = async (accountId, options) => {
const { latest, follow, compact, endpoint: functionPath } = options;
let logsResp;
const accountId = getAccountId(options);

trackCommandUsage('logs', { latest }, accountId);

logger.debug(
`Getting ${
Expand All @@ -142,16 +62,24 @@ exports.handler = async options => {
process.exit();
}
);
const functionId = functionResp.id;

logger.debug(`Retrieving logs for functionId: ${functionResp.id}`);

let logsResp;

if (follow) {
const spinner = ora(
`Waiting for log entries for '${functionPath}' on account '${accountId}'.\n`
);
const tailCall = after => getFunctionLogs(accountId, functionId, { after });
const fetchLatest = () => getLatestFunctionLog(accountId, functionId);
await tailLogs({
functionId: functionResp.id,
functionPath,
accountId,
accountName: options.portal,
Copy link
Contributor

Choose a reason for hiding this comment

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

Do we not need this?

Copy link
Contributor Author

Choose a reason for hiding this comment

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

If we do want to show the account name instead of the id, we do need to incorporate something like this in the new code. That said, I think the way to handle that is to get the account config using the id so that it works consistently even in cases where the defaultPortal is used. We also should be consistent across all commands in terms of how we refer to an account in the output.

compact,
spinner,
tailCall,
fetchLatest,
});
} else if (latest) {
logsResp = await getLatestFunctionLog(accountId, functionResp.id);
Expand All @@ -164,13 +92,74 @@ exports.handler = async options => {
}
};

const appFunctionLog = async (accountId, options) => {
const { latest, follow, compact, functionName, appPath } = options;

let logsResp;

if (follow) {
const spinner = ora(
`Waiting for log entries for "${functionName}" on account "${accountId}".\n`
);
const tailCall = after =>
getAppFunctionLogs(accountId, functionName, appPath, { after });
const fetchLatest = () =>
getLatestAppFunctionLogs(accountId, functionName, appPath);

await tailLogs({
accountId,
compact,
spinner,
tailCall,
fetchLatest,
});
Copy link
Contributor Author

Choose a reason for hiding this comment

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

One thing that I am not sure about is whether the indirection and passing in of functions is easy enough to follow.

Copy link
Contributor

Choose a reason for hiding this comment

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

I think it's clear from the function name.

} else if (latest) {
logsResp = await getLatestAppFunctionLogs(accountId, functionName, appPath);
} else {
logsResp = await getAppFunctionLogs(accountId, functionName, appPath, {});
}

if (logsResp) {
return outputLogs(logsResp, options);
}
};

exports.command = 'logs [endpoint]';
exports.describe = 'get logs for a function';

exports.handler = async options => {
loadAndValidateOptions(options);

const { latest, functionName } = options;

const accountId = getAccountId(options);

trackCommandUsage('logs', { latest }, accountId);
Copy link
Contributor Author

Choose a reason for hiding this comment

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

One thing that I need to figure out is how we want to track the two, which may require a backend change to support additional event attributes.

Copy link
Contributor

Choose a reason for hiding this comment

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

What about adding appPath and functionName to the tracking object along with latest?

Copy link
Contributor Author

Choose a reason for hiding this comment

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

What about adding appPath and functionName to the tracking object along with latest?

I think the backend needs to support any event attributes that we add.


if (functionName) {
appFunctionLog(accountId, options);
} else {
endpointLog(accountId, options);
}
};

exports.builder = yargs => {
yargs.positional('endpoint', {
describe: 'Serverless function endpoint',
type: 'string',
});
yargs
.options({
appPath: {
describe: 'path to the app',
type: 'string',
hidden: true,
},
functionName: {
describe: 'app function name',
type: 'string',
hidden: true,
},
latest: {
alias: 'l',
describe: 'retrieve most recent log only',
Expand All @@ -191,7 +180,8 @@ exports.builder = yargs => {
type: 'number',
},
})
.conflicts('follow', 'limit');
.conflicts('follow', 'limit')
.conflicts('functionName', 'endpoint');

yargs.example([
[
Expand Down
Loading