@crowdin/logs-formatter
v3.1.0
Published
Used in Crowdin Apps to output console logs in JSON format
Keywords
Readme
Crowdin Logs Formatter
Patches the standard console methods to emit JSON records the ELK stack understands, attributes every record to the tenant that produced it, reports critical levels to Sentry, and forwards browser-side logs of Crowdin app UI modules to the server.
Release notes and the 2.x → 3.0.0 migration guide: CHANGELOG.md.
Setup
const logsFormatter = require('@crowdin/logs-formatter');
logsFormatter.setup({ appIdentifier: 'my-app' }); // exactly once, at startup
const app = express();
app.use(logsFormatter.contextResolverMiddleware()); // must come before expressMiddleware()
app.use(express.json({ limit: '50mb' }));
app.use(logsFormatter.expressMiddleware());For fastify:
fastify.addHook('onRequest', logsFormatter.contextResolverHook());
logsFormatter.applyFastifyRoutes(fastify);appIdentifier is the app's identity — it becomes the app_identifier tag on every Sentry event
the process reports, so even a crash outside any request says which app crashed. It is not a field
of the stdout/ELK records.
Console methods modified by setup()
console.log();
console.debug();
console.info();
console.warn();
console.trace();
console.error();
console.fatal();Context
A context exists only inside a scope, and a scope belongs to one unit of work — one request, one
job, one message. Concurrent units cannot see each other's context, including in log calls that
resume after an await, and nothing is left behind to mislabel a later log line.
For Express requests contextResolverMiddleware() opens the scope, for fastify the
contextResolverHook() does — both fill it from the request's token (the ?jwtToken query
parameter or an Authorization: Bearer header):
app.get('/work', async (req, res) => {
await somethingSlow();
console.log('still this request'); // stamped with this request's project/user/organization
});For anything else — cron jobs, queue consumers, workers, scripts — open the scope yourself with
runWithContext(context, fn). Everything logged inside fn, at any depth and after any await,
is stamped with that context:
await logsFormatter.runWithContext(
{ project: { id: 1, identifier: 'newproject', organization_id: 200000001, user_id: 12 } },
async () => {
await syncProject();
console.error('sync failed'); // stamped with the project above
},
);Inside a scope, add to the context with setContext({ ... }) and read it with getContext() (a
read-only snapshot) or the context export. Outside any scope there is nowhere for a context to
live: writes are ignored with a stderr warning naming the call site, and records simply carry no
context fields. The exported Context type describes the accepted shape — worth using, because
setContext() silently ignores a wrong one.
Errors
console.error(err) is enough. message keeps Class: text, the frames go to extra.backtrace, and
whatever the thrower attached (code, apiError, …) goes to extra.attributes. An axios error is
the exception. It keeps its bare Request failed with status code 422, with the class left on the
first backtrace line. error.cause follows as caused by: lines, and several errors in one call get
one record each. In the browser the injected script reports console.*, page errors and unhandled
rejections. There too an error's text, stack and own properties travel as separate fields, so they
land in the same message / extra.backtrace / extra.attributes split a server-side error gets.
console.error(err); // message: "CrowdinError: Language 'null' not found"Sanitizers
The formatter masks nothing on its own. It decides what a record is made of, not what may appear in it. Masking is a sanitizer's job, and whoever uses the package registers the sanitizer.
logsFormatter.registerSanitizer((entry) => ({ ...entry, record: mask(entry.record) }));An app built on @crowdin/app-project-module gets crowdinLogSanitizer registered for it inside
configure(). That one masks by key (authorization, cookie, set-cookie, password, api_key
and the like) and by the shape of a value, catching a JWT, a Bearer token, or credentials inside a
URL. Using this package on its own means registering a sanitizer of your own. Without one, nothing is
masked.
Write a sanitizer as a pure function. It runs once per record, not once per log call, so a call that
carries two errors runs it twice, and the two calls share one rawParams array. A counter, a metric
or an outbound request inside a sanitizer therefore fires as many times as the call produced records,
and mutating what the sanitizer was handed applies that change on top of the previous one.
Sanitizers receive extra.attributes as an object and the formatter serialises the record only after
they run, so a key-based rule reaches it. This matters most for an error carried inside another one
through originalError or cause. The formatter expands that inner error in full, its HTTP config
and response headers included, and the sanitizer is what decides how much of it reaches the log.
ELK attributes
While ELK supports a variety of attributes, note that not all of them are included in the current version of this formatter. Some examples of these attributes are:
"user.id", "user.login", "project.id", "project.identifier", "organization.id", "organization.domain", "client.ip"
"user_agent.original", "http.request.referrer", "url.original", "extra.attributes", "extra.backtrace"