serverless-lumigo
Version:
Serverless framework plugin to auto-install the Lumigo tracer
605 lines (526 loc) • 19.2 kB
JavaScript
const _ = require("lodash");
const http = require("axios");
const fs = require("fs-extra");
const BbPromise = require("bluebird");
const childProcess = BbPromise.promisifyAll(require("child_process"));
const path = require("path");
const nodeLayerVersionsUrl =
"https://raw.githubusercontent.com/lumigo-io/lumigo-node/master/layers/LAYERS22x.md";
const pythonLayerVersionsUrl =
"https://raw.githubusercontent.com/lumigo-io/python_tracer/master/layers/LAYERS37.md";
const NodePackageManagers = {
NPM: "npm",
Yarn: "yarn",
PNPM: "pnpm"
};
const LayerArns = {
node: null,
python: null
};
class LumigoPlugin {
constructor(serverless, options) {
this.serverless = serverless;
this.options = options;
this.log = msg => this.serverless.cli.log(`serverless-lumigo: ${msg}`);
this.verboseLog = msg => {
if (process.env.SLS_DEBUG) {
this.log(msg);
}
};
this.folderPath = path.join(this.serverless.config.servicePath, "_lumigo");
this.hooks = {
"after:package:initialize": this.afterPackageInitialize.bind(this),
"after:deploy:function:initialize": this.afterDeployFunctionInitialize.bind(
this
),
"after:package:createDeploymentArtifacts": this.afterCreateDeploymentArtifacts.bind(
this
)
};
this.extendServerlessSchema();
}
extendServerlessSchema() {
if (
this.serverless.configSchemaHandler &&
typeof this.serverless.configSchemaHandler.defineFunctionProperties ===
"function"
) {
this.serverless.configSchemaHandler.defineFunctionProperties("aws", {
type: "object",
properties: {
lumigo: {
type: "object",
properties: {
token: { type: "string" },
enabled: { type: "boolean" },
pinVersion: { type: "string" },
skipInstallNodeTracer: { type: "boolean" },
skipReqCheck: { type: "boolean" },
step_function: { type: "boolean" },
useLayers: { type: "boolean" },
nodePackageManager: { type: "string" },
nodeLayerVersion: { type: "string" },
nodeUseESModule: { type: "boolean" },
nodeModuleFileExtension: { type: "string" },
pythonLayerVersion: { type: "string" }
},
additionalProperties: false
}
}
});
}
}
get nodeModuleFileExtension() {
return _.get(
this.serverless.service,
"custom.lumigo.nodeModuleFileExtension",
"js"
).toLowerCase();
}
get nodeUseESModule() {
return _.get(this.serverless.service, "custom.lumigo.nodeUseESModule", false);
}
get nodePackageManager() {
return _.get(
this.serverless.service,
"custom.lumigo.nodePackageManager",
NodePackageManagers.NPM
).toLowerCase();
}
get useServerlessEsbuild() {
const plugins = _.get(this.serverless.service, "plugins", []);
const modulesPlugins = _.get(this.serverless.service, "plugins.modules", []); // backward compatible
const isServerlessEsbuildInList = list =>
list.find(plugin => plugin === "serverless-esbuild");
return (
(Array.isArray(plugins) && isServerlessEsbuildInList(plugins)) ||
(Array.isArray(modulesPlugins) && isServerlessEsbuildInList(modulesPlugins))
);
}
get useLayers() {
return (
_.get(this.serverless.service, "custom.lumigo.useLayers", false) ||
this.useServerlessEsbuild
);
}
get pinnedNodeLayerVersion() {
return _.get(this.serverless.service, "custom.lumigo.nodeLayerVersion", null);
}
get pinnedPythonLayerVersion() {
return _.get(this.serverless.service, "custom.lumigo.pythonLayerVersion", null);
}
async afterDeployFunctionInitialize() {
await this.wrapFunctions([this.options.function]);
}
async afterPackageInitialize() {
await this.wrapFunctions();
}
async getLatestNodeLayerVersionArn(layerArn) {
const resp = await http.get(nodeLayerVersionsUrl);
const pattern = `${layerArn}:\\d+`;
const regex = new RegExp(pattern, "gm");
const matches = regex.exec(resp.data);
return matches[0];
}
async getLatestPythonLayerVersionArn(layerArn) {
const resp = await http.get(pythonLayerVersionsUrl);
const pattern = `${layerArn}:\\d+`;
const regex = new RegExp(pattern, "gm");
const matches = regex.exec(resp.data);
return matches[0];
}
async getLayerArn(runtime) {
const region = this.serverless.service.provider.region;
if (runtime.startsWith("nodejs")) {
if (this.pinnedNodeLayerVersion) {
return `arn:aws:lambda:${region}:114300393969:layer:lumigo-node-tracer:${this.pinnedNodeLayerVersion}`;
} else if (LayerArns.node) {
return LayerArns.node;
} else {
const nodeLayerArn = `arn:aws:lambda:${region}:114300393969:layer:lumigo-node-tracer`;
LayerArns.node = await this.getLatestNodeLayerVersionArn(nodeLayerArn);
return LayerArns.node;
}
} else if (runtime.startsWith("python")) {
if (this.pinnedPythonLayerVersion) {
return `arn:aws:lambda:${region}:114300393969:layer:lumigo-python-tracer:${this.pinnedPythonLayerVersion}`;
} else if (LayerArns.python) {
return LayerArns.python;
} else {
const pythonLayerArn = `arn:aws:lambda:${region}:114300393969:layer:lumigo-python-tracer`;
LayerArns.python = await this.getLatestPythonLayerVersionArn(
pythonLayerArn
);
return LayerArns.python;
}
}
}
async wrapFunctions(functionNames) {
const { runtime, functions } = this.getFunctionsToWrap(
this.serverless.service,
functionNames
);
this.log(`there are ${functions.length} function(s) to wrap...`);
functions.forEach(fn => this.verboseLog(JSON.stringify(fn)));
if (functions.length === 0) {
return;
}
const token = _.get(this.serverless.service, "custom.lumigo.token");
if (!token) {
throw new this.serverless.classes.Error(
"serverless-lumigo: Unable to find token. Please follow https://github.com/lumigo-io/serverless-lumigo"
);
}
if (!this.useLayers) {
const pinVersion = _.get(this.serverless.service, "custom.lumigo.pinVersion");
const skipInstallNodeTracer = _.get(
this.serverless.service,
"custom.lumigo.skipInstallNodeTracer",
false
);
let skipReqCheck = _.get(
this.serverless.service,
"custom.lumigo.skipReqCheck",
false
);
let parameters = _.get(this.serverless.service, "custom.lumigo", {});
parameters = _.omit(parameters, [
"pinVersion",
"skipReqCheck",
"skipInstallNodeTracer"
]);
if (runtime === "nodejs") {
if (!skipInstallNodeTracer) {
await this.installLumigoNodejs(pinVersion);
}
for (const func of functions) {
const handler = await this.createWrappedNodejsFunction(
func,
token,
parameters
);
// replace the function handler to the wrapped function
this.verboseLog(
`setting [${func.localName}]'s handler to [${handler}]...`
);
this.serverless.service.functions[func.localName].handler = handler;
}
} else if (runtime === "python") {
if (skipReqCheck !== true) {
await this.ensureLumigoPythonIsInstalled();
} else {
this.log("Skipping requirements.txt check");
}
const { isZip } = await this.getPythonPluginConfiguration();
this.verboseLog(`Python plugin zip status ${isZip}`);
for (const func of functions) {
const handler = await this.createWrappedPythonFunction(
func,
token,
parameters,
isZip
);
// replace the function handler to the wrapped function
this.verboseLog(
`setting [${func.localName}]'s handler to [${handler}]...`
);
this.serverless.service.functions[func.localName].handler = handler;
}
}
if (this.serverless.service.package) {
const include = this.serverless.service.package.include || [];
include.push("_lumigo/*");
this.serverless.service.package.include = include;
}
}
}
async afterCreateDeploymentArtifacts() {
if (this.useLayers) {
const token = _.get(this.serverless.service, "custom.lumigo.token");
const { runtime, functions } = this.getFunctionsToWrap(
this.serverless.service
);
for (const func of functions) {
const funcRuntime = func.runtime || runtime;
func.layers = func.layers || [
...(this.serverless.service.provider.layers || [])
];
const layer = await this.getLayerArn(funcRuntime);
func.layers.push(layer);
func.environment = func.environment || {};
func.environment["LUMIGO_ORIGINAL_HANDLER"] = func.handler;
func.environment["LUMIGO_TRACER_TOKEN"] = token;
if (funcRuntime.startsWith("nodejs")) {
func.handler = "lumigo-auto-instrument.handler";
} else if (funcRuntime.startsWith("python")) {
func.handler = "/opt/python/lumigo_tracer._handler";
}
// replace the function handler to the wrapped function
this.verboseLog(`adding Lumigo tracer layer to [${func.localName}]...`);
this.serverless.service.functions[func.localName].handler = func.handler;
this.serverless.service.functions[func.localName].environment =
func.environment;
this.serverless.service.functions[func.localName].layers = func.layers;
}
return;
}
const { runtime, functions } = this.getFunctionsToWrap(this.serverless.service);
if (functions.length === 0) {
return;
}
await this.cleanFolder();
if (runtime === "nodejs") {
const skipInstallNodeTracer = _.get(
this.serverless.service,
"custom.lumigo.skipInstallNodeTracer",
false
);
if (!skipInstallNodeTracer) {
await this.uninstallLumigoNodejs();
}
}
}
getFunctionsToWrap(service, functionNames) {
functionNames = functionNames || this.serverless.service.getAllFunctions();
const functions = service
.getAllFunctions()
.filter(localName => functionNames.includes(localName))
.filter(localName => {
const { lumigo = {} } = this.serverless.service.getFunction(localName);
return lumigo.enabled == undefined || lumigo.enabled === true;
})
.map(localName => {
const x = _.cloneDeep(service.getFunction(localName));
x.localName = localName;
return x;
});
if (service.provider.runtime.startsWith("nodejs")) {
return { runtime: "nodejs", functions };
} else if (service.provider.runtime.startsWith("python3")) {
return { runtime: "python", functions };
} else {
this.log(`unsupported runtime: [${service.provider.runtime}], skipped...`);
return { runtime: "unsupported", functions: [] };
}
}
async installLumigoNodejs(pinVersion) {
const finalVersion = pinVersion || "latest";
this.log(`installing @lumigo/tracer@${finalVersion}...`);
let installCommand;
if (this.nodePackageManager === NodePackageManagers.NPM) {
installCommand = `npm install @lumigo/tracer@${finalVersion}`;
} else if (this.nodePackageManager === NodePackageManagers.Yarn) {
installCommand = `yarn add @lumigo/tracer@${finalVersion}`;
} else if (this.nodePackageManager === NodePackageManagers.PNPM) {
installCommand = `pnpm add @lumigo/tracer@${finalVersion}`;
} else {
throw new this.serverless.classes.Error(
"No Node.js package manager found. Please install either NPM, PNPM or Yarn."
);
}
const installDetails = childProcess.execSync(installCommand, "utf8");
this.verboseLog(installDetails);
}
async uninstallLumigoNodejs() {
this.log("uninstalling @lumigo/tracer...");
let uninstallCommand;
if (this.nodePackageManager === NodePackageManagers.NPM) {
uninstallCommand = "npm uninstall @lumigo/tracer";
} else if (this.nodePackageManager === NodePackageManagers.Yarn) {
uninstallCommand = "yarn remove @lumigo/tracer";
} else if (this.nodePackageManager === NodePackageManagers.PNPM) {
uninstallCommand = "pnpm remove @lumigo/tracer";
} else {
throw new this.serverless.classes.Error(
"No Node.js package manager found. Please install either NPM, PNPM or Yarn."
);
}
const uninstallDetails = childProcess.execSync(uninstallCommand, "utf8");
this.verboseLog(uninstallDetails);
}
async getPythonPluginConfiguration() {
const isZip = _.get(
this.serverless.service,
"custom.pythonRequirements.zip",
false
);
return { isZip };
}
async ensureLumigoPythonIsInstalled() {
this.log("checking if lumigo_tracer is installed...");
const pluginsSection = _.get(this.serverless.service, "plugins", []);
const plugins = Array.isArray(pluginsSection)
? pluginsSection
: pluginsSection.modules;
const slsPythonInstalled = plugins.includes("serverless-python-requirements");
const ensureTracerInstalled = async fileName => {
const requirementsExists = fs.pathExistsSync(fileName);
if (!requirementsExists) {
let errorMessage = `${fileName} is not found.`;
if (!slsPythonInstalled) {
errorMessage += `
Consider using the serverless-python-requirements plugin to help you package Python dependencies.`;
}
throw new this.serverless.classes.Error(errorMessage);
}
const requirements = await fs.readFile(fileName, "utf8");
if (
!requirements.includes("lumigo_tracer") &&
!requirements.includes("lumigo-tracer")
) {
const errorMessage = `lumigo_tracer is not installed. Please check ${fileName}.`;
throw new this.serverless.classes.Error(errorMessage);
}
};
const packageIndividually = _.get(
this.serverless.service,
"package.individually",
false
);
if (packageIndividually) {
this.log(
"functions are packed individually, ensuring each function has a requirement.txt..."
);
const { functions } = this.getFunctionsToWrap(this.serverless.service);
for (const fn of functions) {
// functions/hello.world.handler -> functions
const dir = path.dirname(fn.handler);
// there should be a requirements.txt in each function's folder
// unless there's an override
const defaultRequirementsFilename = path.join(dir, "requirements.txt");
const requirementsFilename = _.get(
this.serverless.service,
"custom.pythonRequirements.fileName",
defaultRequirementsFilename
);
await ensureTracerInstalled(requirementsFilename);
}
} else {
this.log("ensuring there is a requirement.txt or equivalent...");
const requirementsFilename = _.get(
this.serverless.service,
"custom.pythonRequirements.fileName",
"requirements.txt"
);
await ensureTracerInstalled(requirementsFilename);
}
}
getTracerParameters(
token,
options,
equalityToken = ":",
trueValue = "true",
falseValue = "false"
) {
if (token === undefined) {
throw new this.serverless.classes.Error("Lumigo's tracer token is undefined");
}
let configuration = [];
options = _.omit(options, [
"nodePackageManager",
"nodeUseESModule",
"nodeModuleFileExtension"
]);
for (const [key, value] of Object.entries(options)) {
if (String(value).toLowerCase() === "true") {
configuration.push(`${key}${equalityToken}${trueValue}`);
} else if (String(value).toLowerCase() === "false") {
configuration.push(`${key}${equalityToken}${falseValue}`);
} else {
configuration.push(`${key}${equalityToken}'${value}'`);
}
}
return configuration.join(",");
}
getNodeTracerParameters(token, options) {
return this.getTracerParameters(token, options, ":", "true", "false");
}
getPythonTracerParameters(token, options) {
return this.getTracerParameters(token, options, "=", "True", "False");
}
async createWrappedNodejsFunction(func, token, options) {
this.verboseLog(`wrapping [${func.handler}]...`);
const localName = func.localName;
// e.g. functions/hello.world.handler -> hello.world.handler
const handler = path.basename(func.handler);
// e.g. functions/hello.world.handler -> functions/hello.world
const handlerModulePath = func.handler.substr(0, func.handler.lastIndexOf("."));
// e.g. functions/hello.world.handler -> handler
const handlerFuncName = handler.substr(handler.lastIndexOf(".") + 1);
// too shorten the file extension ref for prettier during test:all
const fileExt = this.nodeModuleFileExtension;
const wrappedESMFunction = `
import lumigo from '@lumigo/tracer'
import {${handlerFuncName} as originalHandler} from '../${handlerModulePath}.${fileExt}'
const tracer = lumigo({ ${this.getNodeTracerParameters(token, options)} })
export const ${handlerFuncName} = tracer.trace(originalHandler);`;
const wrappedCJSFunction = `
const tracer = require("@lumigo/tracer")({
${this.getNodeTracerParameters(token, options)}
});
const handler = require('../${handlerModulePath}').${handlerFuncName};
module.exports.${handlerFuncName} = tracer.trace(handler);`;
const wrappedFunction = this.nodeUseESModule
? wrappedESMFunction
: wrappedCJSFunction;
const fileName = localName + ".js";
// e.g. hello.world.js -> /Users/username/source/project/_lumigo/hello.world.js
const filePath = path.join(this.folderPath, fileName);
this.verboseLog(`writing wrapper function to [${filePath}]...`);
await fs.outputFile(filePath, wrappedFunction);
// convert from abs path to relative path, e.g.
// /Users/username/source/project/_lumigo/hello.world.js -> _lumigo/hello.world.js
// Make sure to support windows paths
const newFilePath = path
.relative(this.serverless.config.servicePath, filePath)
.replace("\\", "/");
// e.g. _lumigo/hello.world.js -> _lumigo/hello.world.handler
return newFilePath.substr(0, newFilePath.lastIndexOf(".") + 1) + handlerFuncName;
}
async createWrappedPythonFunction(func, token, options, isZip) {
this.verboseLog(`wrapping [${func.handler}]...`);
const localName = func.localName;
// e.g. functions/hello.world.handler -> hello.world.handler
const handler = path.basename(func.handler);
// e.g. functions/hello.world.handler -> functions.hello.world
const handlerModulePath = func.handler
.substr(0, func.handler.lastIndexOf("."))
.split("/") // replace all occurances of "/""
.join(".");
// e.g. functions/hello.world.handler -> handler
const handlerFuncName = handler.substr(handler.lastIndexOf(".") + 1);
let addZipConstruct = "";
if (isZip) {
addZipConstruct = `
try:
import unzip_requirements
except ImportError:
pass
`;
}
const wrappedFunction = `
${addZipConstruct}
import importlib
from lumigo_tracer import lumigo_tracer
userHandler = getattr(importlib.import_module("${handlerModulePath}"), "${handlerFuncName}")
@lumigo_tracer(${this.getPythonTracerParameters(token, options)})
def ${handlerFuncName}(event, context):
return userHandler(event, context)
`;
const fileName = localName + ".py";
// e.g. hello.world.py -> /Users/username/source/project/_lumigo/hello.world.py
const filePath = path.join(this.folderPath, fileName);
this.verboseLog(`writing wrapper function to [${filePath}]...`);
await fs.outputFile(filePath, wrappedFunction);
// convert from abs path to relative path, e.g.
// /Users/username/source/project/_lumigo/hello.world.py -> _lumigo/hello.world.py
const newFilePath = path.relative(this.serverless.config.servicePath, filePath);
// e.g. _lumigo/hello.world.py -> _lumigo/hello.world.handler
return newFilePath.substr(0, newFilePath.lastIndexOf(".") + 1) + handlerFuncName;
}
async cleanFolder() {
this.verboseLog(`removing the temporary folder [${this.folderPath}]...`);
return fs.remove(this.folderPath);
}
}
module.exports = LumigoPlugin;