packages/fxa-auth-server/lib/log.js (209 lines of code) (raw):
/* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at http://mozilla.org/MPL/2.0/. */
'use strict';
const EventEmitter = require('events').EventEmitter;
const util = require('util');
const mozlog = require('mozlog');
const { config } = require('../config');
const logConfig = config.get('log');
const amplitudeConfig = config.get('amplitude');
const validateAmplitudeEvent = require('fxa-shared').metrics.amplitude.validate;
let statsd;
const Sentry = require('@sentry/node');
const notifier = require('./notifier');
const validators = require('./oauth/validators');
const { reportSentryMessage } = require('./sentry');
const ISSUER = config.get('domain') || '';
const CLIENT_ID_TO_SERVICE_NAMES = config.get('oauth.clientIds') || {};
// Keeps track of how many times a logger name has been used.
const _registered = {};
function Lug(options) {
const name = options.name || 'fxa-auth-server';
EventEmitter.call(this);
// Ensure that names are unique. If two loggers are created with the same
// name, a double log scenario occurs.
if (_registered[name] === undefined) {
this.name = name;
} else {
let i = 1;
while (_registered[`${name}-${i}`] !== undefined) {
i++;
}
this.name = `${name}-${i}`;
}
this.logger = mozlog({
app: this.name,
level: options.level,
stream: options.stderr || process.stderr,
fmt: options.fmt,
})();
_registered[this.name] = 1;
this.stdout = options.stdout || process.stdout;
this.nodeTracer = options.nodeTracer;
this.notifier = notifier(this, statsd);
// Encourage avoiding scenarios where loggers names have to be incremented.
if (this.name !== name) {
const msg = `Logger with name of ${name} already registered. Adjusting name to ${this.name} to prevent double log scenario.`;
this.logger.warn('init', { msg });
}
}
util.inherits(Lug, EventEmitter);
Lug.prototype.close = function () {};
Lug.prototype.getTraceId = function (data) {
let result = {};
if (this.nodeTracer) {
try {
result = { traceId: this.nodeTracer.getTraceId() };
} catch {
// don't let a tracing issue break logging
this.debug('log', { msg: 'could not get trace id' });
}
}
return result;
};
// Polyfill to make this compatible with nestjs logger which always supports a 'log' method.
Lug.prototype.log = function (op, data) {
this.logger.info(op, Object.assign({}, data, this.getTraceId()));
};
// Expose the standard error/warn/info/debug/etc log methods.
Lug.prototype.trace = function (op, data) {
this.logger.debug(op, Object.assign({}, data, this.getTraceId()));
};
Lug.prototype.debug = function (op, data) {
this.logger.debug(op, Object.assign({}, data, this.getTraceId()));
};
Lug.prototype.error = function (op, data) {
// If the error object contains an email address,
// lift it into top-level fields so that our
// PII-scrubbing tool is able to find it.
if (data.err && data.err.email) {
if (!data.email) {
data.email = data.err.email;
}
data.err.email = null;
}
this.logger.error(op, Object.assign({}, data, this.getTraceId()));
};
Lug.prototype.fatal = function (op, data) {
this.logger.critical(op, Object.assign({}, data, this.getTraceId()));
};
Lug.prototype.warn = function (op, data) {
this.logger.warn(op, Object.assign({}, data, this.getTraceId()));
};
Lug.prototype.info = function (op, data) {
this.logger.info(op, Object.assign({}, data, this.getTraceId()));
};
Lug.prototype.begin = function (op, request) {
this.logger.debug(op);
};
Lug.prototype.stat = function (stats) {
this.logger.info('stat', stats);
};
// Log a request summary line.
// This gets called once for each completed request.
// See https://mana.mozilla.org/wiki/display/CLOUDSERVICES/Logging+Standard
// for a discussion of this format and why it's used.
Lug.prototype.summary = function (request, response) {
if (request.method === 'options') {
return;
}
request.emitRouteFlowEvent(response);
const payload = request.payload || {};
const query = request.query || {};
const credentials = (request.auth && request.auth.credentials) || {};
const responseBody = (response && response.source) || {};
const line = {
status: response.isBoom ? response.output.statusCode : response.statusCode,
errno: response.errno || (response.source && response.source.errno) || 0,
rid: request.id,
path: request.path,
lang: request.app.acceptLanguage,
agent: request.headers['user-agent'],
remoteAddressChain: request.app.remoteAddressChain,
accountRecreated: request.app.accountRecreated,
t: Date.now() - request.info.received,
uid:
credentials.uid ||
payload.uid ||
query.uid ||
response.uid ||
responseBody.uid ||
'00',
service: payload.service || query.service,
reason: payload.reason || query.reason,
redirectTo: payload.redirectTo || query.redirectTo,
keys: !!query.keys,
// Additional data used by the DataFlow fraud detection pipeline.
// Logging PII for the fraud detection pipeline has been given
// the green light so that the fraud detection logic can
// handle much of the logic the customs server currently does.
method: request.method,
email: credentials.email || payload.email || query.email,
phoneNumber: responseBody.formattedPhoneNumber,
};
// TODO: Remove after debugging mysterious empty response body reported in FXA-6573
// is complete.
if (
config.env !== 'prod' &&
line.status === 400 &&
line.path === '/v1/session/verify_code'
) {
try {
const body = JSON.stringify(responseBody);
this.info('request.summary.debug', {
body,
bodySize: body.length,
});
} catch (error) {
this.info('request.summary.debug', {
bodySize: -1,
error,
});
}
}
if (line.status >= 500) {
line.trace = request.app.traced;
line.stack = response.stack;
this.error('request.summary', line);
} else {
this.info('request.summary', line);
}
};
// Broadcast an event to attached services, such as sync.
// In production, these events are broadcast to relying services over SNS/SQS.
Lug.prototype.notifyAttachedServices = async function (name, request, data) {
let metricsContextData = {};
if (request.gatherMetricsContext) {
metricsContextData = await request.gatherMetricsContext({});
}
// Add a timestamp that this event occurred to help attached services resolve any
// potential timing issues
const now = Date.now();
data.timestamp = data.timestamp || now; // Leave as milliseconds
data.ts = data.ts || now / 1000; // Convert to float seconds
// Tag all events with the issuing service.
data.iss = ISSUER;
// convert an oauth client-id to a human readable format, if a name is available.
// If no name is available, continue to use the client_id. Note that oauth client
// IDs are always hex strings. So values like sync or none aren't valid...
if (data.service && validators.HEX_STRING.test(data.service)) {
data.clientId = data.service;
data.service = CLIENT_ID_TO_SERVICE_NAMES[data.service] || data.service;
}
const e = {
event: name,
data,
};
e.data.metricsContext = metricsContextData;
this.debug('notify.attached', e);
this.notifier.send(e);
};
// Log an activity metrics event.
// These events indicate key points at which a particular
// user has interacted with the service.
Lug.prototype.activityEvent = function (data) {
if (!data || !data.event || !data.uid) {
this.error('log.activityEvent', { data });
return;
}
this.logger.info('activityEvent', data);
};
// Log a flow metrics event.
// These events help understand the user's sign-in or sign-up journey.
Lug.prototype.flowEvent = function (data) {
if (!data || !data.event || !data.flow_id || !data.flow_time || !data.time) {
return;
}
this.logger.info('flowEvent', data);
};
Lug.prototype.amplitudeEvent = function (data) {
// @TODO We can remove this guard once we return early after a schema
// validation failure.
if (!data || !data.event_type || (!data.device_id && !data.user_id)) {
this.error('amplitude.missingData', { data });
return;
}
if (amplitudeConfig.schemaValidation) {
try {
validateAmplitudeEvent(data);
} catch (err) {
this.error('amplitude.validationError', { err, amplitudeEvent: data });
// Since we are adding a schema retroactively, let's be conservative:
// temporarily capture any validation "errors" with Sentry to ensure
// that the schema is not too strict against existing events. We'll
// update the schema accordingly. And allow the events in the
// meantime.
Sentry.withScope((scope) => {
scope.setContext('amplitude.validationError', {
event_type: data.event_type,
flow_id: data.user_properties.flow_id,
error: err.message,
});
reportSentryMessage('Amplitude event failed validation', 'error');
});
}
}
this.logger.info('amplitudeEvent', data);
};
module.exports = function (level, name, options = {}) {
if (arguments.length === 1 && typeof level === 'object') {
options = level;
level = options.level;
name = options.name;
}
options.name = name;
options.level = level;
options.fmt = logConfig.fmt;
const log = new Lug(options);
log.stdout.on('error', (err) => {
if (err.code === 'EPIPE') {
log.emit('error', err);
}
});
if (options.statsd) {
statsd = options.statsd;
}
return log;
};