@funcmaticjs/funcmatic
v0.0.10
Published
Middleware framework for AWS Lambda using ES2017 async functions (inspired by Koa.js)
Downloads
7
Readme
Contents
Introduction
Funcmatic helps you develop more complex serverless functions that respond to web requests. What Express is for building Node.js web servers, Funcmatic is for building Node.js web functions with AWS API Gateway and Lambda.
Key Features
- Organize function logic into distinct lifecycle handlers.
- Create and reuse middleware across functions.
Lightweight Approach
- The core framework is a single file less than 400 lines.
- Vanilla Javascript and does not use any Node specific modules (e.g. net/http, os, fs).
- No additional packages or dependencies!
Funcmatic is able to be so lightweight because it:
- Only supports a single AWS Lambda Runtime: Node 8.10 (async/await).
- Does not alter, wrap, or abstract the raw AWS event and context objects.
- Does not dictate the response format.
- Does not help with packaging, deployment, provisioning, configuration.
- Has no aspirations to support “multi-cloud” environments (e.g. Azure Functions, Google Cloud Functions).
Compatibility with Other Frameworks
Because Funcmatic only focuses on helping you organize the internal logic of your function, it works great with other serverless frameworks that help with packaging, configuration, and deployment (e.g. Serverless Framework, AWS SAM CLI).
Installation
Funcmatic requires node v8.10 or higher.
$ npm install @funcmaticjs/funcmatic
Hello World
const func = require('@funcmaticjs/funcmatic')
func.request(async ctx => {
// Response formatted according to API Gateway's Lambda Proxy Integration
ctx.response = {
statusCode: 200,
headers: {
"Content-Type": "application/json; charset=utf-8"
},
body: JSON.stringify({ hello: "world" })
}
})
module.exports = {
lambdaHandler: func.handler() // async (event, context) => { ... }
}
Getting started
Checkout some of the commented examples below to get a feel for what Funcmatic functions look like:
- Hello World
- More examples coming soon ...
Lifecycle Handlers
AWS Lambda gives a single entrypoint to execute all of our function's logic.
// Standard AWS Lambda Handler
module.exports.lambdaHandler = async function(event, context) {
// ... all your function code
return "some success message"
// or
// throw new Error("some error type");
}
When creating more complex functions we often want particular logic to be executed only in particular cirumstances. For example, we might want to fetch environment variables or create a database connection only when a function is cold started.
Funcmatic gives you multiple entrypoints so that you can predictably trigger specific logic for different stages of your function's lifecycle.
1. env handler
The purpose of the env handler is to fetch all necessary configuration values and set them in the ctx.env
object. The ctx.env
object will persist in memory across subsequent invocations of this function.
The env handler is the first of the four handlers to be invoked. It is only invoked during a cold start.
Logic that might be executed by your env handler:
- Decrypt encrypted AWS Lambda environment variables.
- Download and parse a config file stored in AWS S3.
- Fetch environment variables stored in AWS Parameter Store.
Sample code:
// The async function you pass to "func.env" will be
// called during the "env" lifecycle stage.
func.env(async (ctx) => {
// A typical thing to do in our env handler is to
// fetch environment variables.
let vars = await fetchEnvVarsFromSomewhere()
// It's Funcmatic convention to set these
// values in the ctx.env object which persists
// over invocations of this function.
// We can access ctx.env.DB_CONNECTION_URI on
// subsequent invocations of this function.
ctx.env.DB_CONNECTION_URI = vars.DB_CONNECTION_URI
// Once all the config values our function
// needs is set in ctx.env we can return from
// this handler.
return
})
2. start handler
The purpose of the start handler is to perform all the necessary initialization of your function before core business logic can be executed. Oftentimes, this initialization is expensive and we want it to run only once when the function is cold started. A common use case of the start handler is connecting to a database.
The start handler is executed immediately after the env handler. As such, it has access to all the configuration values stored in ctx.env
by the env handler. Like the env handler it only is invoked during a cold start.
Logic that might be executed by your start handler:
- Open and cache database connections
- Fetch and parse a CSV data file
- Set default values of your http request library (e.g. Base URLs of API endpoints, Authorization tokens, )
Sample code:
// The async function you pass to "func.start" will be
// called during the "start" lifecycle stage.
func.start(async (ctx) => {
// Our env handler set DB_CONNECTION_URI in ctx.env
// so we just read it out here.
let uri = ctx.env.DB_CONNECTION_URI
// We use this value to create a database connection
// which takes a lot of time.
// Since we want this db connection to stay
// open across multiple invocations ("cached")
// we set it in "ctx.env"
ctx.env.db = await connectToSomeDB(uri)
// This is all the initialization our function needs
// so we return
return
})
3. request handler
The purpose of the request handler is to to perform the majority of our function's business logic and return the response to the client. Ideally, all configuration and initialization were completed by our env* and start handlers and our request handler can just focus on the logic that makes this function unique and valuable.
The request handler will often make use of the ctx.event
and ctx.context
objects which store the unadulterated namesake objects provided by AWS Lambda.
Unlike the env and start handlers which are only executed on cold start, the request handler is executed every time the function is invoked. During a cold start it will run immediately after the env and start handlers. During a warm start it will be the first handler to be executed.
Logic that might be executed by your request handler:
- Perform database operations (i.e. read/writes/deletes)
- Make API requests to other services
- Authorize and authenticate requests (e.g. validate JWT tokens)
- Format the client response
Sample code:
// The async function you pass to "func.request"
// will be called during the "request" lifecycle stage.
func.request(async (ctx) => {
// We fetch the HTTP query param "name" from the
// AWS API Gateway Lambda Proxy Integration event
let name = ctx.event.queryStringParams.name || '*'
// We can use the open db connection that was
// "cached" in ctx.env by our start handler to
// execute a query based on the "name" param.
let db = ctx.env.db
let data = await db.query({ name })
// We return the data to client by setting
// "ctx.response" to be an object with structure
// expected by API Gateway's Lambda Proxy
// Integration.
ctx.response = {
statusCode: 200,
headers: {
"Content-Type": "application/json; charset=utf-8"
},
body: JSON.stringify(data),
isBase64Encoded: false
}
// Note that we don't actually return the response
// in the request handler. "ctx.response" is what
// the client will receive.
return
})
4. error handler
The purpose of the error handler is to deal with uncaught errors that interrupted the execution of our env, start, or request handler. In other words, it is the error handler of last resort in our function. ctx.error
is where you can access the uncaught error object.
Our error handler will not be executed if there are no uncaught errors.
Logic that might be executed by your error handler:
- Log error to the console (i.e. Cloudwatch Logs)
- Call an error notification service (e.g. PageDuty, Airbrake)
- Return a standard error response to the user
- Clean up any initialized resources
Sample code:
// The async function you pass to "func.error"
// will be called during the "error" lifecycle stage.
func.error(async (ctx) => {
// The uncaught error is available in ctx.error
let error = ctx.error
// Let's log the error the console so it
// shows up in Cloudwatch Logs.
// "ctx.logger" is the Funcmatic default logger
ctx.logger.error(error)
// We return an response to the user with our error message
let message = `Sorry there was an error! (${ctx.error.message})`
ctx.respose = {
statusCode: 500, // Internal server error
headers: {
"Content-Type": "application/json; charset=utf-8"
},
body: JSON.stringify({ message }),
isBase64Encoded: false
}
return
})
5. teardown handler
The teardown handler is a pseudo handler because it is never called when our function is invoked by AWS Lambda.
So why do we have a teardown handler?
It helps when unit testing our function to clean up any resources that our function might be hanging on to after invocation. For example, if we are caching a database connection, our unit test needs close the connection before ending the test and moving on to another.
Logic that might be executed by your teardown handler:
- Close open database connections and files.
- Let go (i.e. "null out") any large data objects held in memory.
- Reset your function back to an uninitialized state.
Sample code:
// The async function you pass to "func.teardown"
// will be called when you manually invoke
// the teardown lifecycle handler
// i.e. calling "func.invokeTeardown()"
func.teardown(async (ctx) => {
// If we have a db connection, close it
if (ctx.env.db) {
await ctx.env.db.close()
}
// Reset all configuration
ctx.env = { }
// We have nothing more to cleanup so we return
return
})
Middleware
One of the primary benefits of using Funcmatic is being able to package common logic into middleware and reuse it our functions.
Funcmatic's middleware design is based on Koa.js. Since each lifecycle handler has its own entrypoint of execution (i.e. env, start, request, error), this means that each lifecycle handler can be configured with its own middleware stack.
Middleware Stack and Functions
A middleware stack is series of nested Javascript async functions.
Flow of Execution In a Middleware Stack
The first (i.e. topmost) function is invoked by the Funcmatic framework directly. It is the first middleware function's responsibility to pass control to the second middleware function by calling await next()
. next
is a special callback async function created by Funcmatic and available all middleware functions.
The second function invokes the third, third invokes the fourth, and so on until we reach the last function in the stack which is typically where your function-specific logic lives.
Since this last function is at the bottom of the stack, it does not need to call next()
since there is no next function to pass control off to. It can simply return
to end its own execution.
Now execution flows back up the stack in the reverse direction. The N-1 function was waiting on the last function to complete execution await next()
.
The N-1 function can now execute its own logic and then end its own execution and pass control to the N-2 function by calling return
and so on until the second function returns and passes control back to the first function which was waiting for it via await next()
.
Middleware Function Definition
Middleware functions are just async javascript functions that take two arguments: ctx
and next
.
ctx
: The context object which many middleware functions with read data from and also write data to. Since middleware functions don't directly pass arguments or return data directly to each other,ctx
is the only way for as a side effect. See ctx documentation below for more details.next
: an async function created and passed in by Funcmatic. All middleware functions must callnext
once and only once so that execution can continue to the next middleware function in the stack.
Code Structure of a Middleware Function
A middleware function has the following structure:
async (ctx, next) => {
// All "downstream" middleware logic higher in the stack will have been executed
// before this middleware
/* This middleware's "downstream" logic ... */
await next() // invoke the next middleware function in the stack
/* This middleware's "upstream" logic ... */
return // any return value will be ignored
// The next "upsteram" middleware logic higher in the stack will be invoked
// after this middleware finishes execution
}
Since the last function (often our function's specific logic) does not have to invoke await next()
it can have the structure below:
async (ctx) => { // Note we don't need to accept the "next" parameter
// All "downstream" middleware logic has executed by this time
/* This end-user function's logic ... */
return // any return value will be ignored
// All "upstream" middleware logic will now begin execution
}
There are two useful terms to describe what happens in a specific middleware function:
- Downstream Logic: This is the code that executes in a middleware function BEFORE it calls
await next()
and pass control to the lower function in the stack. - Upstream Logic: This is the code that executes in a middleware function AFTER it calls
awaits next()
and control returns as a result of the lower function completing execution viareturn
.
Downstream and Upstream are relative terms. In Funcmatic we think of the user initiating a request to our API being the topmost and our function-specific logic being bottommost. Therefore execution first makes its way downstream from the user, through our middleware stack, to our function specific logic. And then back upstream through our middleware stack and ultimately return to the user.
Examples of Middleware Functions
Here are some simple examples of middleware functions.
1. AWS Event queryStringParameters
normalizer
One annoying thing about AWS's event
object is that if the user's HTTP request has no query parameters the event.queryStringParameters
object will be null
instead of {}
. This means that everywhere in our code we have to check if event.queryStringParameters
is first null
before we check if our.
Instead of putting these checks everywhere, let's write a middleware function that will do it once and set
// 1. define our middleware function
const queryStringParametersNormalizer = async (ctx, next) => {
// Because we want all "downstream" logic to not
// have to check "event.queryStringParameters == null"
// we put this logic BEFORE "await next()"
let event = ctx.event
if (!event.queryStringParameters) {
event.queryStringParameters = { }
}
await next()
return
}
// 2. add it to the request middleware stack
// when the "request" lifecycle handler is invoked
// by Funcmatic
func.request(queryStringParametersNormalizer)
2. CORS Headers
It's always CORS! To be honest, I still do not understand how to properly configure API Gateway's built in CORS support. Rather than leave it up to AWS, let's not leave things to chance and just set the CORS header Access-Control-Allow-Origin
to *
ourselves. This will allow our API can be called from any website domain.
// We can define and add our middleware function
// at the same time.
func.request(async (ctx, next) => {
await next()
// Note that this logic happens "upstream" when control
// is flowing back up the middleware stack to the user.
// We assume that some downstream logic has put the
// response to be returned to the user in the
// context object i.e. "ctx.response".
// This middleware is just adding
// and additional header key-value to it.
let response = ctx.response
if (!response.headers["Access-Control-Allow-Origin"]) {
response.headers["Access-Control-Allow-Origin"] = "*"
}
return
})
3. Elapsed Time Logger
Given the distributed nature of serverless, logging and monitoring are common ways to apply middleware.
If the middleware function below is the topmost function of the request middleware stack it will log the elapsed execution time of the entire request middleware stack.
// Ideally, this middleware function will the first
// function added to the 'request' middleware stack
// so that we account for all of the nested
// middleware functions in the stack.
func.request(async (ctx, next) => {
// We have to capture the initial request time
// in the "downstream" logic otherwise we won't
// account for the execution that happens downstream.
let t = Date.now()
let id = ctx.context.awsRequestId
await next()
// This logic happens "upstream" because elapsed
// time needs to account for all downstream AND upstream logic
//
// We use Funcmatic's built in JSON-formatted logger
// "ctx.logger" to log the
// * id: AWS Lambda request id
// * t: The time of the request in ms since epoch
// * elapsed: How long the request took in ms
ctx.logger.info({ id, t, elapsed: (Date.now() - t) })
return
})
Middleware Plugins
Middleware Plugins are Javascript classes that define one or more lifecycle methods.
class MyMiddlewarePlugin {
// Lifecycle middleware methods must be use
// the exact names below to be recognized
// by Funcmatic
env(ctx, next) {
/* Downstream logic ... */
await next()
/* Upstream logic ... */
return
}
start(ctx, next) { }
request(ctx, next) { }
error(ctx, next) { }
teardown(ctx, next) { }
}
// Adds the individual lifecycle methods
// defined above to the appropriate
// lifecycle middleware stacks
func.use(new MyMiddlewarePlugin())
It is recommended that you create and use middleware as plugins rather than as individual functions.
Why use plugins rather than individual functions?
- Plugins are easier to understand since the all related functionality is in one place and functions are named after the middleware stack that they will be added to.
- Plugins are easier to use since a single call to
func.use(...)
will add multiple methods to their correct lifecycle middleware stack.
Example: Using the response-plugin
The response-plugin creates a response
object and sets it in the ctx.response
. This response object makes it format HTTP responses according to AWS's Lambda Proxy Integration format.
1. Install the plugin
$> npm install -save '@funcmaticjs/response-plugin'
2. Add the plugin to your function
There are three steps you must take in your code:
- Import the
ResponsePlugin
class viarequire
- Create an instance of the
ResponsePlugin
- Call
func.use
with the instance
Here is the code below:
let func = require('@funcmaticjs/func')
let ResponsePlugin = require('@funcmaticjs/response-plugin')
func.use(new ResponsePlugin())
/* ... */
Available Middleware Plugins
There are already some handy middleware plugins that have been created and ready to use in your functions:
Environment Variables and Config
- ProcessEnvPlugin: Automatically bring all
process.env
variables toctx.env
- StageVarsPlugin: Set API Gateway Stage Variables in
ctx.env
- ParameterStorePlugin: Fetch environment variables from AWS Parameter Store and set them in
ctx.env
AWS Event and Context
- EventPlugin: Makes working with AWS API Gateway's Lambda Proxy Integration event a little more friendly.
- BodyParserPlugin: Parse common types of event.body content (e.g. application/json, application/x-www-form-urlencoded, multipart/form-data).
Authentication and Authorization
- Auth0Plugin: Verifies an Auth0 JWT token in the 'Authorization' header and puts the decoded token in 'ctx.state.auth'
Datastores
- MemoryCachePlugin: Implements a simple in-memory based cache
- DynamoDBCachePlugin: Creates a simple async cache interface (get, set, del) around DynamoDB.
- MongoDBPlugin: Creates and manages a MongoDB connection
Response
- ResponsePlugin: Express-like HTTP response methods (e.g. res.json(), res.sendFile()) to be used in AWS Lambda Node functions connected to API Gateway using AWS Lambda Proxy Integration.
Logging and Monitoring
- CorrelationPlugin: Sets 'x-correlation-id' in 'ctx.logger' so that log messages can be correlated across different functions and services.
- LogLevelPlugin: Uses the 'X-Log-Level' or 'X-Correlation-Log-Level' headers to dynamically set the log level of ctx.logger.
- ContextLoggerPlugin: Add bound fields to the logger from the AWS context event.
- AccessLogPlugin: Log a JSON line at the end of a request using NGINX access_log format.
The Context Object (ctx
)
The context object (ctx
) is the shared state between AWS Lambda, the Funcmatic framework, middleware, and your function's unique code. It is the interface in which information is passed between each of these layers.
ctx.event
Initialized to be the event created by AWS Lambda when your function is invoked. Funcmatic is primarily designed to be build HTTP APIs, this will most likely be an event in API Gateway Lambda Proxy Integration event format.
Here is an example of what the ctx.event
object could look like.
Note that some middleware, such as EventPlugin, may alter the original AWS event.
Some notable properties of the ctx.event
object are:
- ``:
ctx.context
Initialized to be the AWS Lambda Context object created by AWS Lambda when your function is invoked.
Unlike the AWS event above, the format of this context object remains consistent and independent of what service invoked your function (e.g. API Gateway, S3).
Some notable properties of the ctx.context
object are:
awsRequestId
- Unique string for every invocation of your function (e.g. )
functionName
- The name you gave your function when creating it in AWS Lambda
functionVersion
- The version of your function that is being invoked (e.g. )
invokedFunctionArn
- The full ARN. If your function was invoked using an alias this is the only way to figure that out.
callbackWaitsForEmptyEventLoop
- Funcmatic sets this value to
false
.- This means that when the
request
handler completes, AWS Lambda will immediately return the value inctx.response
even if the Node.js event loop is not empty.
- This means that when the
- If you want to change the default behavior your can set this value to
true
. Which means that AWS Lambda will wait for the Node.js event loop to be empty before returning the response.ctx.context.callbackWaitsForEmptyEventLoop = true
- Funcmatic sets this value to
ctx.response
This is what needs to be set by your function before it completes execution. This is the value that will be returned to AWS Lambda and ultimately back to the requesting client.
Assumming that you are using API Gateway and it's Lambda Proxy Integration then the response must be an object with the following structure:
{
"statusCode": httpStatusCode, /* e.g. 200 */
"headers": {
"headerName": "headerValue",
...
},
"multiValueHeaders": {
"headerName": ["headerValue", "headerValue2" ]
},
"body": "...",
"isBase64Encoded": true|false
}
If your function is returning a JSON object:
{
"statusCode": 200, // Internal Server Error
"headers": {
"Content-Type": "application/json; charset=utf-8"
},
"body": "{\"hello\":\"world\"}",
"isBase64Encoded": false
}
If your function is returning binary data (e.g. an jpeg image):
{
"statusCode": 200, // Internal Server Error
"headers": {
"Content-Type": "image/jpeg"
},
"body": "/9j/2wBDAAMCAgICAgMCAgIDAw...", // Base64 encoded image data
"isBase64Encoded": true
}
If you are returning an HTTP error:
{
"statusCode": 500, // Internal Server Error
"headers": {
"Content-Type": "application/json; charset=utf-8"
},
"body": "{\"errorMessage\":\"My error message\"}",
"isBase64Encoded": false
}
The ResponsePlugin is intended to help abstract the specifics of API Lambda Proxy format. We can produce the equivalent response in the examples above by:
async (ctx) => {
ctx.response.json({ hello: 'world'})
ctx.response.blob("image.jpeg")
ctx.response.httperror(500, 'My error message')
}
ctx.env
This is intended to contain all the configuration values that your function needs. When your function is first cold started, Funcmatic will initialize ctx.env
to an empty object {}
. But unlike event
, context
, and state
, the values you choose to store in ctx.env
will be preserved across invocations.
The single responsibility of your function's env
handler is to fetch configuration values and populate them in ctx.env
. The benefits of this is that it isolates the complexity of where your config is stored (e.g. process.env
, AWS Parameter Store, API Gateway Stage Variables), to a single handler (env
) and beyond that point the rest of your function's logic only needs to interact with the ctx.env
object.
ctx.state
This is initialized to an empty object {}
upon every invocation of your function whether it is a cold or warm start. It borrows its purpose from Express as the recommended place to pass data between middleware and your function's logic. The Funcmatic framework does not do anything directly with this object except initialize it to {}
.
For example, if you use the MongoDBPlugin, it will create a connection to a MongoDB server and sets the connection in ctx.state.mongodb
.
ctx.state.mongodb = await connectToMongoDB()
Then your own function's code can access the connection:
async (ctx) => {
let userid = ctx.event.queryStringParameters['userid']
let db = ctx.state.mongodb
let user = await db.findOne({ userid })
/* ... */
}
ctx.coldstart
This is a literal boolean value (true
or false
).
true
: This current invocation of your function is a cold start meaning that theevent
andstart
handlers will be invoked as part of this invocation.false
: This current invocation is NOT a cold start (i.e. a warm start) and therefore theevent
andstart
handlers will not be invoked as part of this invocation.
ctx.logger
Funcmatic provides a default JSON logger ctx.logger
. See Logging using ctx.logger
section for more detailed info.
ctx.func
A reference of this currently executing Funcmatic function. Most middleware and your function will not need to reference this.
Logging using the Default Logger
Funcmatic helps you use structured JSON logging in your function. Yan Cui has a great blog post explaining why it's better use structured logs (i.e. JSON) rather than lines of text with console.log
(You need to use structured logging with AWS Lambda).
By default, Funcmatic is configured to set ctx.logger
to its own very simple JSON logger (i.e. instance of ConsoleLogger
). It is recommended to use ctx.logger
rather than console.log
in your function and any custom middleware. Of course, console.log
will still work.
Logging Messages
The default logger supports all the standard Log4J Log Levels: trace
(10), debug
(20), info
(30), warn
(40), error
(50), fatal
(60), off
(70).
There are three ways you can log messages using this
1. Logging a simple string message
If you were to log the following:
ctx.logger.info("hello")
It would log the following JSON to console
:
{
msg: "hello",
time: 1560116027570, // Epoch ms
level: 30, // Level as a number
level_name: "info" // Level as a string
}
Above you can see that the log fields time
, level
, and level_name
are automatically added by Funcmatic. There are other automatically added fields which you can see in the section below.
2. Logging a single JSON object
If you were to log the following:
ctx.logger.debug({ hello: "world", req: { /* JSON object */ } })
It would log the following JSON console
:
{
hello: "world",
req: { /* JSON object */ },
time: 1560116027570,
level: 20,
level_name: "debug"
}
Note that the default logger uses JSON.stringify
so you can log nested objects as long as they are serializable into JSON.
3. Logging an error object
There is a special case of logging a Javascript Error
object:
try {
/* Some logic that throws */
} catch (err) {
ctx.logger.error(err)
}
This would log the following JSON:
{
msg: "The error message" // err.message
err: "The stack trace" // err.stack,
time: 1560116027570,
level: 50,
level_name: "error"
}
Which follows the same convention that Bunyan uses to log Error
objects.
4. Logging a object with a string message
It is also possible to log a JSON object followed by a simple string:
ctx.logger.trace({ hello: "world" }, "hello world")
This would log the following JSON:
{
msg: "hello world",
hello: "world",
time: 1560116027570,
level: 10,
level_name: "trace"
}
Note that you could accomplish the same log line by logging a single object:
ctx.logger.trace({ msg: "hello world", hello: "world" })
Log Fields Automatically Included by Funcmatic
In the examples above we saw that the fields time
, level
, and level_name
where automatically included by Funcmatic. Below are all the fields automatically included:
time
- Number of milliseconds elapsed since January 1, 1970 00:00:00 UTC i.e.
Date.now()
- Number of milliseconds elapsed since January 1, 1970 00:00:00 UTC i.e.
level
- Number that represents the level the line was logged at.
- 10 (trace), 20 (debug), 30 (info), 40 (warn), 50 (error), 60 (fatal), 70 (off)
level_name
- String that represents the level the line was logged at.
- trace, debug, info, warn, error, fatal, off
src
- The middleware plugin or function that logged the line using
ctx.logger
- Helpful when debugging errors to know which middleware or function.
- Will have the following format based on the log source:
AsyncFunction:[anonymous]
: Logged from an anonymous function. e.g.func.request(async (ctx { /* ... */ })
AsyncFunction:myHandler
: Logged from a function with the namemyHandler
.MyPlugin:request
: Logged from a middleware plugin of classMyPlugin
and method namedrequest
.funcmatic
: Logged from the core Funcmatic framework i.e.func.js
.
- The middleware plugin or function that logged the line using
lifecycle
- The lifecycle handler that is currently being executed:
env
,start
,request
,error
,teardown
,system
- Will have value
system
if being logged from Funcmatic framework when it is executing logic that lives "in between" handlers.
Setting the Log Level
By default, the log level of ctx.logger
is set to info
. This means that all log levels equal or higher in severity (info
, warn
, error
, fatal
) will actually get logged to the console. Log lines with a lower level of severity (trace
, debug
) will be silienced.
Changing the Log Level Manually
You can set the log level by calling ctx.logger.level
:
ctx.logger.level("debug")
Now all log lines at a debug
level and higher will be output to the console.
You can get the current level of ctx.logger
by calling ctx.logger.level()
with no arguments:
ctx.logger.level()
// returns the level name: "info"
Changing the Default Log Level
You can adjust the default log level that ctx.logger
is initialized with in a couple ways.
1. LOG_LEVEL environment variable
When an Funcmatic instance is initialized it will first see if the LOG_LEVEL
environment variable is set. If so, it will initialize ctx.logger
with the log level found from process.env["LOG_LEVEL"]
.
For example, if you use dotenv, your .env
file might look like:
# .env file
LOG_LEVEL=debug
2. Passing in LOG_LEVEL option when creating an instance of Func
If you don't have control over the environment variables
const { Func } = require("@funcmaticjs/funcmatic")
let func = new Func({ LOG_LEVEL: 'debug' })
Setting Bound Fields
You can add properties to the logger which will be included in all following log lines. For example, if a user is authenticated, you might want to set a bound field of user
so that all subsequent lines include user: [id]
without having to manually log it yourself with every call to ctx.logger
.
Adding Bound Fields
To add bound fields you can call ctx.logger.state
and pass in the fields (and values) you want to always be logged:
ctx.logger.state({ user: 123, email: "[email protected]" })
Now, whenever you log a message for the life of this specific invocation, you will see these fields. For example,
ctx.logger.info("hello world")
Will log the following line:
{
msg: "hello world",
user: 123,
email: "[email protected]"
/* ... Funcmatic default fields */
}
Getting Bound Fields
To see the current fields and values that are bound you can call ctx.logger.state
with no arguments:
// Previously: ctx.logger.state({ user: 123, email: "[email protected]" })
let bound = ctx.logger.state()
/*
bound = {
user: 123,
email: "[email protected]"
}
*/
Clearing Bound Fields
If you want clear all the bound fields in the logger, you can call ctx.logger.state
with the boolean value false
.
ctx.logger.state(false) // clears all bound fields
let bound = ctx.logger.state()
/*
bound = { }
*/
Replacing Bound Fields
You can also replace the bound fields wholesale by calling ctx.logger.state
and passing in options of { replace: true }
:
// Previously: ctx.logger.state({ user: 123, email: "[email protected]" })
ctx.logger.state({ completely: "new" }, { replace: true })
let bound = ctx.logger.state()
/*
bound = {
completely: "new"
}
Calling ctx.logger.state({ completely: "new" }) would
have just done an update:
bound = {
completely: "new",
user: 123,
email: "[email protected]"
}
*/
Note that some middleware (e.g. CorrelationPlugin) will add bound fields for you.
Pretty Logs
The one downside aboutstructured logging is that it is more difficult for humans to read it output. When you are actively developing and testing your function, it
We wrote a simple log prettifier that can be used with Funcmatic to turn logs that would like
[ Image of regular JSON output ]
into this ...
[ Image of prettified logs ]
const { Func } = require("@funcmaticjs/funcmatic")
const prettify = require("@funcmaticjs/pretty-logs")
const func = new Func({ prettify })
Custom Prettify Function
You can create your own prettify
function. It ultimately needs the following interface:
function (obj) { // obj is the JSON log line
/* ... do some prettifying here */
return "someBeautifulString"
}
Check out @funcmaticjs/pretty-logs for an example.
Unit Testing
Funcmatic was designed to support unit testing. Examples below will be in jest.
Basic Test Structure
describe('Func', () => {
let func = null
let ctx = { }
beforeEach(async () => {
func = require("../lib/func.js")
ctx = func.initCtx(ctx)
})
afterEach(async () => {
func.teardown()
})
it ("should test a cold start invocation", async () => {
ctx.event.httpMethod = "POST"
ctx.event.headers[""] = "test"
ctx.event.body = JSON.stringify({hello: "world"})
await func.invoke(ctx)
expect(ctx.response).toMatchObject({
statusCode: ,
headers: {
}
})
let body = JSON.parse(ctx.response.body)
expect(body).toMatchObject({
})
})
it ("should test a warm start invocation", async () => {
await func.invoke(ctx) // cold start
await func.invoke(ctx)
})
it ("should test env hander", async () => {
await func.invokeEnv(ctx)
expect(ctx.env).toMatchObject({
})
})
it ("should test the start handler", async () => {
await func.invokeStart(ctx)
expect(ctx.state).toMatchObject({
})
})
it ("should test the request handler", async () => [
])
it ("should test the error handler", async () => {
await func.invokeError(ctx)
})
})
Running Tests
Install jest
$> npm install --save-dev jest
Update package.json
Run
npm test
See code coverage
Use the following to set the LOG_LEVEL and pretty logging
LOG_LEVEL=debug LOG_PRETTY=true
Trace Level Logs
Alternatives
If Funcmatic doesn't quite suit your project (or your tastes) here are some other projects that might be useful:
- serverless-http: Use your existing middleware framework (e.g. Express, Koa) in AWS Lambda.
- serverless-compose: A lightweight middleware framework for AWS lambda.
- Middy: The stylish Node.js middleware engine for AWS Lambda.
- Lambcycle: Lambcycle is a declarative lambda middleware. Its main purpose is to let you focus on the specifics of your application by providing a configuration cycle.
- Lambda API: Lightweight web framework for your serverless applications.
Contributing
- Contributor Covenant Code of Conduct
- Contributing Guidelines
- Raising Issues
- Submit Pull Requests
- Current Contributors
License
Funcmatic is licensed under the the MIT License. Copyright © 2019 Funcmatic Inc.
All files located in the node_modules and external directories are externally maintained libraries used by this software which have their own licenses; we recommend you read them, as their terms may differ from the terms in the MIT License.