stagnant

Measure your slow code, make it _fast_.

Usage no npm install needed!

<script type="module">
  import stagnant from 'https://cdn.skypack.dev/stagnant';
</script>

README

stagnant

Measure your slow code, make it fast.

What

  • A JS instrumentation library for measuring execution time across the stack
  • No inference, 100% explicit instrumentation
  • Easy to use - integrate any 3rd party metrics service
  • Example honeycomb.io integration OOTB
  • Browser = 0 Dependencies
  • Server = 1 Polyfill (node-fetch)
  • Runs in the Browser and the Server

Builds

You can view all the latest builds on unpkg here.

Browser

Stagnant is built to trace performance across the entire stack. You can begin a trace client side, continue tracing within the server and then pick up the trace client side again.

This makes it possible to get far deeper insights into your Time to Interactive (TTI) and Time to Load (TTL).

The browser version of stagnant has no dependencies, it simply uses the native fetch module to call out to honeycomb for each event. The node version uses the same code but relies on the node-fetch polyfill.

Node.js

Depending on your project structure node.js will either import the native ESM module or the CJS build automatically.

  • Native ESM Stagnant Module: import stagnant from 'stagnant'

  • CJS Stagnant Bundle: const stagnant = require('stagnant')

  • Native ESM Stagnant Honeycomb Module: import stagnant from 'stagnant/honeycomb.js'

  • CJS Stagnant Honeycomb Bundle: const stagnant = require('stagnant/honeycomb.cjs')

🤓 If anyone knows how to make require('stagnant/honeycomb') automatically point to the honeycomb.cjs file, please let me know!

Quick Start

  • npm install stagnant
import trace from 'stagnant'

const traceOptions = {
    onevent({ id, name, data, parentId, startTime, endTime }){
        console.log(name, endTime - startTime)
    },

    // Advanced Options:
    //
    // onerror(){},
    // onsuccess(){},
    // onflush(){},
    // generateId(){},
    // traceId,
    // parentId
}

async function main(){
    const span = trace(traceOptions)

    const package = 
        await span( () => fs.promises.readFile('package.json', 'utf8') )

    const files = 
        await span( 'ls', () => fs.promises.readdir('.') )

    for( let file of files ) {
        const filedata = span.sync( 'read file: ' + file,  () =>
            fs.readFileSync(file)
        )
    }
    
    await span.flush()
}

Demonstration


import stagnant from 'stagnant'

async function main(trace){

    await trace(() => sql`
        select expensive_query()
    `)
    
    // trace is async by default to avoid Zalgo
    const file = await trace( () => fs.promises.readFile('package.json', 'utf8') )

    // for sync code, use trace.sync
    trace.sync( () => 2 + 2 )

    // add a meaningful name when the inferred name won't do 
    trace.sync('sequence loop', () => {

        for( let x of sequence() ) {
            someSyncCode(x)
        }
    })

    // create child traces, great for modelling the callstack
    await trace('somethingElse', p => 
        somethingElse(p)
    )


    await trace.flush()
}

async function somethingElse(trace){
    // this trace is a child trace 👆
    // you can use this data to create useful graphs with e.g. honeycomb/open tracing

    await trace( () => sql`
        select another_expensive_query()
    `)

    // by default, names are inferred from the `toString()` of the callback
    // but you can specify a name explicitly as well
    let data = trace('custom-name', async () => {
        return anotherExpensiveCall()
    })

    // you can also attach metadata to the trace (and any child events)
    let data = trace({ name: 'custom-name', x:1, y:2 }, async () => {
        return anotherExpensiveCall()
    })

    // passing an object with no function, will attach that data
    // to all subsequent events
    trace(data)

    return true
}


const events = []
const options = {
    onevent(event){
        // capture all events
        events.push(event)
    },
    async onsuccess({ name, parentId, id, data, startTime, endTime }){
        console.log(startTime, name, 'duration:', endTime - startTime )
    },
    async onerror({ name, error, parentId, id, data, startTime, endTime }){
        console.error(
            startTime, name + ' (failed)', 'duration:', endTime - startTime
            ,'error:', error
        )
    }
}

const trace = stagnant(options)

main(trace).finally( () => {
    // all events ready to inspect
    events

    // we've also been logging our events as they happened
    // we could be sending them to a tracing API if we prefer
})

API

stagnant

stagnant(options: stagnant.Options ) -> Trace

Initialize a trace.

stagnant.call

stagnant.call( trace?, ...args, () -> b ) -> b

Safely invoke a trace callback even if the trace is null. Fairly useful for writing wrappers around 3rd party libraries that may be invoked by code without a trace variable.

async function something(event){

    // instead of:
    // await event.trace( 'normal invocation style', () => db.query('select 1+1') )
    await stagnant.call(event.trace, 'safer invocation style', () => db.query('select 1+1') )
}

stagnant.ensure

Stability: 💀 Unstable

trace = stagnant.ensure( trace? )

Much like stagnant.call, but instead of immediately invoking the trace (if it exists), a mock trace is returned that will execute just fine but will not actually create any events or traces behind the scenes.

async function something(event){
    event.trace = stagnant.ensure(event.trace)

    await event.trace( 'will work even if event.trace is null', () => db.query('select 1+1') )
}

Trace

Trace is often aliased as I (for instrument). Usually you invoke trace with a callback. stagnant will time how long it takes for a promise returned from that callback to settle.

const I = stagnant(options)

let output = await I( () => myAsyncFunction() )

You can also measure a synchronous function

let output = I.sync( () => mySynchronousFunction() )

You can attach data to the current event and any child events via trace( data ).

// all child events will have the url and method property attached to event.data
I({ url, method })

You can also pass in data when setting up a callback to be traced:

I({ url, method }, () => callEndpoint() )

By default stagnant will name the event using the Function::toString() method of your callback. But you can explicitly name an event as well via two approaches.

  1. Passing a string as the first argument
  2. Including a name attribute on the event data object
// string as first arg
I('call the endpoint', { url, method }, () => callEndpoint())

// name attribute on the data object
I({ name: 'call the endpoint', url, method }, () => callEndpoint())

You can create detailed call graphs by taking advantage of the child span constructor passed in to all callbacks:


I( 'outer', I => // rebind I
    I('inner', I => // to create nested traces
        I('core', () => ... )
    )
)

Each event will have a parentId set to the id of the previous event. This is great for explicitly modelling async call stacks.

When you are done measuring your code call trace.flush to signal to stagnant that the trace is over.

await I.flush()

It's best to not use I after calling flush as the total duration of rootEvent will be less than the summed duration of any child events... which would be weird.

Keep in mind, flush doesn't wait for other events to finish that is your responsibility. Generally call flush in a finally that wraps your entrypoint.

That's a high level philisophical distinction in stagnant with other libraries, there's next to no magic, just a nice pure dose of sugar.

stagnant.Options

generateId

() -> string

Control how stagnant generates identifiers, by default Math.random().toString(15).slice(2,8) is used.

onevent

async onevent( event: Event ) -> void

Dispatched whenever a callback settles, whether it throws an exception or returns a value.

onsuccess

async onsuccess( event: Event ) -> void

Dispatched whenever a callback does not throw an exception.

onerror

async onerror( event: ErrorEvent ) -> void

Dispatched whenever a callback throws an exception.

onflush

async onflush( event: RootEvent ) -> void

Dispatched .flush() is called on the root trace. Calling flush implies the end of the parent trace. This is helpful for measuring overall run time of a trace.

Event

{
    // id of the parent event
    parentId: string

    // reference to the parent event
    , parent: Trace

    // id of the current event 
    , id: string

    // event metadata, just a state bag with no schema
    , data: any

    // time is represented as a millisecond unix timestamp
    , startTime: number
    , endTime: number
}

ErrorEvent

Event & { error: Error }

Just like an event, but with an error attached. This will be dispatched to your callback whenever a callback throws an exception.

Advanced

Logging

By default stagnant calls config.console for all logging. These logs functions are just empty functions. You can enable logging by specifying console: console when initializing stagnant.

When initializing honeycomb configuring stagnant options requires an outer key config so { config: { console } }

FAQ

How do I instrument 3rd party libraries if everything is explicit?

Short answer, you don't.

Long answer, instead of directly calling the 3rd party library, call your own function that calls the library and time that.

You can use stagnant.call( trace, () => ...) or stagnant.ensure(trace) instead of trace( () => ... ) to safe guard against not having a trace variable.

If the trace is undefined, stagnant will just invoke the callback without creating a trace. That way you can write code that will behave just fine even if there is no trace variable passed down. This also means you can disable tracing in your codebase without having to restructure your code beyond not passing down a trace at the entry point.

const stagnant = require('stagnant')

function query(query, values, p=null){
    const results = await stagnant.call(p, () => db.query(query,values))
    return results
}

// this will not perform a trace
await query('select * from users where user_id = ? ', [1], null)

// this will perform a span within the active trace
await query('select * from users where user_id = ? ', [1], p)

Why use callbacks for everything

It helps capture absolutely everything safely. Everything that is measured can be wrapped in a try catch, and the execution itself can be deferred, or even skipped if required when you are debugging things.

It is a sensible default.

But won't using anonymous functions introduce signifcantly delays?

All signs point to no.

How do I continue a trace across multiple servers or contexts?

In your onevent callback, pass in your own parentId or traceId from a previous request. When stagnant creates what it thinks is the root trace, it will leave the parentId property null. You can use that fact to conditionally add a different parentId from a previous request.

See the honeycomb implementation for ideas.

Honeycomb integration

A visualization of a call graph as measured by stagnant using the honeycomb integration.

A visualization of a call graph as measured by stagnant using the honeycomb integration.

Stagnant can be used offline and with any 3rd party instrumentation toolkit you prefer to use. I originally wrote this tool as I was continually having issues with the official node.js honeycomb beeline library. I was often getting issues with missing traces, and missing parent spans and I couldn't figure it out. After spending a lot of time on it, I figured it was easier to just write an adpater that is 100% explicit and doesn't rely on Node's async_hooks module.

So here we are. I share with you the same integration just in case it is useful for to you. But stagnant can be used as a standalone library just as easily.

There is a simple honeycomb integration in stagnant/honeycomb.js. Check out the usage script to set it up in your own project.