Skip to content

uber/logtron

Repository files navigation

logtron

build status

logger used in realtime

Example

var Logger = require('logtron');

var statsd =  StatsdClient(...)

/*  configure your logger

     - pass in meta data to describe your service
     - pass in your backends of choice
*/
var logger = Logger({
    meta: {
        team: 'my-team',
        project: 'my-project'
    },
    backends: Logger.defaultBackends({
        logFolder: '/var/log/nodejs',
        console: true,
        kafka: { proxyHost: 'localhost', proxyPort: 9093 },
        sentry: { id: '{sentryId}' }
    }, {
        // pass in a statsd client to turn on an airlock prober
        // on the kafka and sentry connection
        statsd: statsd
    })
});

/* now write your app and use your logger */
var http = require('http');

var server = http.createServer(function (req, res) {
    logger.info('got a request', {
        uri: req.url
    });

    res.end('hello world');
});

server.listen(8000, function () {
    var addr = server.address();
    logger.info('server bound', {
        port: addr.port,
        address: addr.address
    });
});

/* maybe some error handling */
server.on("error", function (err) {
    logger.error("unknown server error", err);
});

Docs

Type definitions

See docs.mli for type definitions

var logger = Logger(options)

type Backend := {
    createStream: (meta: Object) => WritableStream
}

type Entry := {
    level: String,
    message: String,
    meta: Object,
    path: String
}

type Logger := {
    trace: (message: String, meta: Object, cb? Callback) => void,
    debug: (message: String, meta: Object, cb? Callback) => void,
    info: (message: String, meta: Object, cb? Callback) => void,
    access?: (message: String, meta: Object, cb? Callback) => void,
    warn: (message: String, meta: Object, cb? Callback) => void,
    error: (message: String, meta: Object, cb? Callback) => void,
    fatal: (message: String, meta: Object, cb? Callback) => void,
    writeEntry: (Entry, cb?: Callback) => void,
    createChild: (path: String, Object<levelName: String>, Object<opts: String>) => Logger
}

type LogtronLogger := EventEmitter & Logger & {
    instrument: (server?: HttpServer, opts?: Object) => void,
    destroy: ({
        createStream: (meta: Object) => WritableStream
    }) => void
}

logtron/logger := ((LoggerOpts) => LogtronLogger) & {
    defaultBackends: (config: {
        logFolder?: String,
        kafka?: {
            proxyHost: String,
            proxyPort: Number
        },
        console?: Boolean,
        sentry?: {
            id: String
        }
    }, clients?: {
        statsd: StatsdClient,
        kafkaClient?: KafkaClient
    }) => {
        disk: Backend | null,
        kafka: Backend | null,
        console: Backend | null,
        sentry: Backend | null
    }
}

Logger takes a set of meta information for the logger, that will be used by each backend to customize the log formatting and a set of backends that you want to be able to write to.

Logger returns a logger object that has some method names in common with console.

options.meta.name

options.meta.name is the name of your application, you should supply a string for this option. Various backends may use this value to configure themselves.

For example the Disk backend uses the name to create a filename for you.

options.meta.team

options.meta.team is the name of the team that this application belongs to. Various backends may use this value to configure themselves.

For example the Disk backend uses the team to create a filename for you.

####options.meta.hostname

options.meta.hostname is the the hostname of the server this application is running on. You can use require('os').hostname() to get the hostname of your process. Various backends may use this value to configure themselves.

For example the Sentry backend uses the hostname as meta data to send to sentry so you can identify which host caused the sentry error in their visual error inspector.

options.meta.pid

options.meta.pid is the pid of your process. You can get the pid of your process by reading process.pid. Various backends may use this value to configure themselves.

For example the Disk backend or Console backend may prepend all log messages or somehow embed the process pid in the log message. This allows you to tail a log and identify which process misbehaves.

options.backends

options.backends is how you specify the backends you want to set for your logger. backends should be an object of key value pairs, where the key is the name of the backend and the value is something matching the Backend interface.

Out of the box, the logger comes with four different backend names it supports, "disk", "console", "kafka" and "sentry".

If you want to disable a backend, for example "console" then you should just not pass in a console backend to the logger.

A valid Backend is an object with a createStream method. createStream gets passed options.meta and must return a WritableStream.

There are a set of functions in logtron/backends that you require to make the specifying of backends easier.

  • require('logtron/backends/disk')
  • require('logtron/backends/console')
  • require('logtron/backends/kafka')
  • require('logtron/backends/sentry')

options.transforms

options.transforms is an optional array of transform functions. The transform functions get called with [levelName, message, metaObject] and must return a tuple of [levelName, message, metaObject].

A transform is a good place to put transformation logic before it get's logged to a backend.

Each funciton in the transforms array will get called in order.

A good use-case for the transforms array is pretty printing certain objects like HttpRequest or HttpResponse. Another good use-case is scrubbing sensitive data

logger

Logger(options) returns a logger object. The logger has a set of logging methods named after the levels for the logger and a destroy() method.

Each level method (info(), warn(), error(), etc.) takes a string and an object of more information. You can also pass in an optional callback as the third parameter.

The string message argument to the level method should be a static string, not a dynamic string. This allows anyone analyzing the logs to quickly find the callsite in the code and anyone looking at the callsite in the code to quickly grep through the logs to find all prints.

The object information argument should be the dynamic information that you want to log at the callsite. Things like an id, an uri, extra information, etc are great things to add here. You should favor placing dynamic information in the information object, not in the message string.

Each level method will write to a different set of backends.

See bunyan level descriptions for more / alternative suggestions around how to use levels.

logger.trace(message, information, callback?)

trace() will write your log message to the ["console"] backends.

Note that due to the high volume nature of trace() it should not be spamming "disk".

trace() is meant to be used to write tracing information to your logger. This is mainly used for high volume performance debugging.

It's expected you change the trace level configuration to basically write nowhere in production and be manually toggled on to write to local disk / stdout if you really want to trace a production process.

logger.debug(message, information, callback?)

debug() will write your log message to the ["disk", "console"] backends.

Note that due to the higher volume nature of debug() it should not be spamming "kafka".

debug() is meant to be used to write debugging information. debugging information is information that is purely about the code and not about the business logic. You might want to print a debug if there is a programmer bug instead of an application / business logic bug.

If your going to add a high volume debug() callsite that will get called a lot or get called in a loop consider using trace() instead.

It's expected that the debug level is enabled in production by default.

logger.info(message, information, callback?)

info() will write your log message to the ["disk", "kafka", "console"] backends.

info() is meant to used when you want to print informational messages that concern application or business logic. These messages should just record that a "useful thing" has happened.

You should use warn() or error() if you want to print that a "strange thing" or "wrong thing" has happened

If your going to print information that does not concern business or application logic consider using debug() instead.

logger.warn(message, information, callback?)

warn() will write your log message to the ["disk", "kafka", "console"] backends.

warn() is meant to be used when you want to print warning messages that concern application or business logic. These messages should just record that an "unusual thing" has happened.

If your in a code path where you cannot recover or continue cleanly you should consider using error() instead. warn() is generally used for code paths that are correct but not normal.

logger.error(message, information, callback?)

error() will write your log message to the ["disk", "kafka", "console", "sentry"] backends.

Note that due to importance of error messages it should be going to "sentry" so we can track all errors for an application using sentry.

error() is meant to be used when you want to print error messages that concern application or business logic. These messages should just record that a "wrong thing" has happened.

You should use error() whenever something incorrect or unhandlable happens.

If your in a code path that is uncommon but still correct consider using warn() instead.

logger.fatal(message, information, callback?)

fatal() will write your log message to the ["disk", "kafka", "console", "sentry"] backends.

fatal() is meant to be used to print a fatal error. A fatal error should happen when something unrecoverable happens, i.e. it is fatal for the currently running node process.

You should use fatal() when something becomes corrupt and it cannot be recovered without a restart or when key part of infrastructure is fatally missing. You should also use fatal() when you interact with an unrecoverable error.

If your error is recoverable or you are not going to shutdown the process you should use error() instead.

It's expected that shutdown the process once you have verified that the fatal() error message has been logged. You can do either a hard or soft shutdown.

logger.createChild({path: String, levels?, opts?})

The createChild method returns a Logger that will create entries at a nested path.

Paths are lower-case and dot.delimited. Child loggers can be nested within other child loggers to construct deeper paths.

Child loggers implement log level methods for every key in the given levels, or the default levels. The levels must be given as an object, and the values are not important for the use of createChild, but true will suffice if there isn't an object laying around with the keys you need.

Opts specifies options for the child logger. The available options are to enable strict mode, and to add metadata to each entry. To enable strict mode pass the strict key in the options with a true value. In strict mode the child logger will ensure that each log level has a corresponding backend in the parent logger. Otherwise the logger will replace any missing parent methods with a no-op function. If you wish to add meta data to each log entry the child set the extendMeta key to true and the meta to an object with your meta data. The metaFilter key takes an array of objects which will create filters that are run at log time. This allows you to automatically add the current value of an object property to the log meta without having to manual add the values at each log site. The format of a filter object is: {'oject': targetObj, 'mappings': {'src': 'dst', 'src2': 'dst2'}}. Each filter has an object key which is the target the data will be taken from. The mappings object contains keys which are the src of the data on the target object as a dot path and the destination it will be placed in on the meta object. A log site can still override this destination though. If you want the child logger to inherit it's parent logger's meta and metaFilter, set mergeParentMeta to true. If there are conflicts, the child meta will win.

logger.createChild("requestHandler", {
    info: true,
    warn: true,
    log: true,
    trace: true
}, {
    extendMeta: true,
    // Each time we log this will include the session key
    meta: {
        sessionKey: 'abc123'
    },
    // Each time we log this will include if the headers
    // have been written to the client yet based on the
    // current value of res.headersSent
    metaFilter: [
        {object: res, mappings: {
            'headersSent' : 'headersSent'
        }
    ],
    mergeParentMeta: true
})

logger.writeEntry(Entry, callback?)

All of the log level methods internally create an Entry and use the writeEntry method to send it into routing. Child loggers use this method directly to forward arbitrary entries to the root level logger.

type Entry := {
    level: String,
    message: String,
    meta: Object,
    path: String
}

var backends = Logger.defaultBackends(options, clients)

type Logger : { ... }

type KafkaClient : Object
type StatsdClient := {
    increment: (String) => void
}

logtron := Logger & {
    defaultBackends: (config: {
        logFolder?: String,
        kafka?: {
            proxyHost: String,
            proxyPort: Number
        },
        console?: Boolean,
        sentry?: {
            id: String
        }
    }, clients?: {
        statsd: StatsdClient,
        kafkaClient?: KafkaClient,
        isKafkaDisabled?: () => Boolean
    }) => {
        disk: Backend | null,
        kafka: Backend | null,
        console: Backend | null,
        sentry: Backend | null
    }
}

Rather then configuring the backends for logtron yourself you can use the defaultBackend function

defaultBackends takes a set of options and returns a hash of backends that you can pass to a logger like

var logger = Logger({
    backends: Logger.defaultBackends(backendConfig)
})

You can also pass defaultBackends a clients argument to pass in a statsd client. The statsd client will then be passed to the backends so that they can be instrumented with statsd.

You can also configure a reusable kafkaClient on the clients object. This must be an instance of uber-nodesol-write.

options.logFolder

options.logFolder is an optional string, if you want the disk backend enabled you should set this to a folder on disk where you want your disk logs written to.

options.kafka

options.kafka is an optional object, if you want the kafka backend enabled you should set this to an object containing a "proxyHost" and "proxyPort" key.

options.kafka.proxyHost should be a string and is the hostname of the kafka REST proxy server to write to.

options.kafka.proxyPort should be a port and is the port of the kafka REST proxy server to write to.

options.console

options.console is an optional boolean, if you want the console backend enabled you should set this to true

options.sentry

options.sentry is an optional object, if you want the sentry backend enabled you should set this to an object containing an "id" key.

options.sentry.id is the dsn uri used to talk to sentry.

clients

clients is an optional object, it contains all the concrete service clients that the backends will use to communicate with external services.

clients.statsd

If you want you backends instrumented with statsd you should pass in a statsd client to clients.statsd. This ensures that we enable airlock monitoring on the kafka and sentry backend

clients.kafkaClient

If you want to re-use a single kafkaClient in your application you can pass in an instance of the uber-nodesol-write module and the logger will re-use this client isntead of creating its own kafka client.

clients.isKafkaDisabled

If you want to be able to disable kafka at run time you can pass an isKafkaDisabled predicate function.

If this function returns true then logtron will stop writing to kafka.

Logging Errors

I want to log errors when I get them in my callbacks

The logger supports passing in an Error instance as the metaObject field.

For example:

fs.readFile(uri, function (err, content) {
    if (err) {
        logger.error('got file error', err);
    }
})

If you want to add extra information you can also make the err one of the keys in the meta object.

For example:

fs.readFile(uri, function (err, content) {
    if (err) {
        logger.error('got file error', {
            error: err,
            uri: uri
        });
    }
})

Custom levels

I want to add my own levels to the logger, how can I tweak the logger to use different levels

By default the logger has the levels as specified above.

However you can pass in your own level definition.

I want to remove a level

You can set a level to null to remove it. For example this is how you would remove the trace() level.

var logger = Logger({
    meta: { ... },
    backends: { ... },
    levels: {
        trace: null
    }
})

I want to add my own levels

You can add a level to a logger by adding a new Level record.

For example this is how you would define an access level

var logger = Logger({
    meta: {},
    backends: {},
    levels: {
        access: {
            level: 25,
            backends: ['disk', 'console']
        }
    }
})

logger.access('got request', {
    uri: '/some-uri'
});

This adds an access() method to your logger that will write to the backend named "disk" and the backend named "console".

I want to change an existing level

You can change an existing backend by just redefining it.

For example this is how you mute the trace level

var logger = Logger({
    meta: {},
    backends: {},
    levels: {
        trace: {
            level: 10,
            backends: []
        }
    }
})

I want to add a level that writes to a custom backend

You can add a level that writes to a new backend name and then add a backend with that name

var logger = Logger({
    meta: {},
    backends: {
        custom: CustomBackend()
    },
    levels: {
        custom: {
            level: 15,
            backends: ["custom"]
        }
    }
})

logger.custom('hello', { foo: "bar" });

As long as your CustomBackend() returns an object with a createStream() method that returns a WritableStream this will work like you want it to.

var backend = Console()

logtron/backends/console := () => {
    createStream: (meta: Object) => WritableStream
}

Console() can be used to create a backend that writes to the console.

The Console backend just writes to stdout.

var backend = Disk(options)

logtron/backends/disk := (options: {
    folder: String
}) => {
    createStream: (meta: Object) => WritableStream
}

Disk(options) can be used to create a backend that writes to rotating files on disk.

The Disk depends on meta.team and meta.project to be defined on the logger and it uses those to create the filename it will write to.

options.folder

options.folder must be specificied as a string and it determines which folder the Disk backend will write to.

var backend = Kafka(options)

logtron/backends/kafka := (options: {
    proxyHost: String,
    proxyPort: Number,
    statsd?: Object,
    isDisabled: () => Boolean
}) => {
    createStream: (meta: Object) => WritableStream
}

Kafka(options) can be used to create a backend that writes to a kafka topic.

The Kafka backend depends on meta.team and meta.project and uses those to define which topic it will write to.

options.proxyHost

Specify the proxyHost which we should use when connecting to kafka REST proxy

options.proxyPort

Specify the proxyPort which we should use when connecting to kafka REST proxy

options.statsd

If you pass a statsd client to the Kafka backend it will use the statsd client to record information about the health of the Kafka backend.

options.kafkaClient

If you pass a kafkaClient to the Kafka backend it will use this to write to kafka instead of creating it's own client. You must ensure this is an instance of the uber-nodesol-write module.

options.isDisabled

If you want to be able to disable this backend at run time you can pass in a predicate function.

When this predicate function returns true the KafkaBackend will stop writing to kafka.

var backend = Sentry(options)

logtron/backends/sentry := (options: {
    dsn: String,
    statsd?: Object
}) => {
    createStream: (meta: Object) => WritableStream
}

Sentry(options) can be used to create a backend that will write to a sentry server.

options.dsn

Specify the dsn host to be used when connection to sentry.

options.statsd

If you pass a statsd client to the Sentry backend it will use the statsd client to record information about the health of the Sentry backend.

Installation

npm install logtron

Tests

npm test

There is a kafka.js that will talk to kafka if it is running and just gets skipped if its not running.

To run the kafka test you have to run zookeeper & kafka with npm run start-zk and npm run start-kafka