fix(metrics): stash metrics context during the account reset flow

We are planning to deprecate metrics context payloads on endpoints where
it is not strictly required. This means only endpoints that start a flow
or endpoints that occur outside of any flow will accept metrics context
payloads in the future.

The motivation for making this change is to reduce the likelihood of
metrics weirdness arising from conflicting metrics context data being
set mid-flow.

The account reset flow includes three different token types. By stashing
metrics context as each new token is created, we can stop sending it to
the subsequent endpoints in some future content server change without
interrupting metrics.
This commit is contained in:
Phil Booth 2018-09-26 16:24:25 +01:00
Родитель f408af456a
Коммит 804907a32b
Не найден ключ, соответствующий данной подписи
Идентификатор ключа GPG: 36FBB106F9C32516
6 изменённых файлов: 355 добавлений и 244 удалений

Просмотреть файл

@ -35,6 +35,7 @@ module.exports = function (log, config) {
return {
stash,
gather,
propagate,
clear,
validate,
setFlowCompleteSignal
@ -148,6 +149,36 @@ module.exports = function (log, config) {
}
}
/**
* Propagates metrics context metadata from one token-derived key to
* another. Asynchronous, returns a promise.
*
* @name propagateMetricsContext
* @this request
* @param oldToken token to gather the metadata from
* @param newToken token to stash the metadata against
*/
function propagate (oldToken, newToken) {
const oldKey = getKey(oldToken)
return cache.get(oldKey)
.then(metadata => {
if (metadata) {
return cache.add(getKey(newToken), metadata)
.catch(err => log.warn({ op: 'metricsContext.propagate.add', err }))
}
})
.catch(err => log.error({
op: 'metricsContext.propagate',
err,
hasOldToken: !! oldToken,
hasOldTokenId: !! (oldToken && oldToken.id),
hasOldTokenUid: !! (oldToken && oldToken.uid),
hasNewToken: !! newToken,
hasNewTokenId: !! (newToken && newToken.id),
hasNewTokenUid: !! (newToken && newToken.uid),
}))
}
/**
* Attempt to clear previously-stashed metrics context metadata.
*

Просмотреть файл

@ -433,6 +433,14 @@ module.exports = function (
request.validateMetricsContext()
let flowCompleteSignal
if (requestHelper.wantsKeys(request)) {
flowCompleteSignal = 'account.signed'
} else {
flowCompleteSignal = 'account.reset'
}
request.setMetricsFlowCompleteSignal(flowCompleteSignal)
// Store flowId and flowBeginTime to send in email
let flowId, flowBeginTime
if (request.payload.metricsContext) {
@ -440,78 +448,66 @@ module.exports = function (
flowBeginTime = request.payload.metricsContext.flowBeginTime
}
let passwordForgotToken
return P.all([
request.emitMetricsEvent('password.forgot.send_code.start'),
customs.check(request, email, 'passwordForgotSendCode')
])
.then(db.accountRecord.bind(db, email))
.then(
function (accountRecord) {
if (accountRecord.primaryEmail.normalizedEmail !== email.toLowerCase()) {
throw error.cannotResetPasswordWithSecondaryEmail()
}
// The token constructor sets createdAt from its argument.
// Clobber the timestamp to prevent prematurely expired tokens.
accountRecord.createdAt = undefined
return db.createPasswordForgotToken(accountRecord)
.then(accountRecord => {
if (accountRecord.primaryEmail.normalizedEmail !== email.toLowerCase()) {
throw error.cannotResetPasswordWithSecondaryEmail()
}
)
.then(
function (passwordForgotToken) {
return db.accountEmails(passwordForgotToken.uid)
.then(emails => {
const geoData = request.app.geo
const {
browser: uaBrowser,
browserVersion: uaBrowserVersion,
os: uaOS,
osVersion: uaOSVersion,
deviceType: uaDeviceType
} = request.app.ua
// The token constructor sets createdAt from its argument.
// Clobber the timestamp to prevent prematurely expired tokens.
accountRecord.createdAt = undefined
return db.createPasswordForgotToken(accountRecord)
})
.then(result => {
passwordForgotToken = result
return P.all([
request.stashMetricsContext(passwordForgotToken),
db.accountEmails(passwordForgotToken.uid)
])
})
.then(([_, emails]) => {
const geoData = request.app.geo
const {
browser: uaBrowser,
browserVersion: uaBrowserVersion,
os: uaOS,
osVersion: uaOSVersion,
deviceType: uaDeviceType
} = request.app.ua
return mailer.sendRecoveryCode(emails, passwordForgotToken, {
token: passwordForgotToken,
code: passwordForgotToken.passCode,
service: service,
redirectTo: request.payload.redirectTo,
resume: request.payload.resume,
acceptLanguage: request.app.acceptLanguage,
flowId: flowId,
flowBeginTime: flowBeginTime,
ip: ip,
location: geoData.location,
timeZone: geoData.timeZone,
uaBrowser,
uaBrowserVersion,
uaOS,
uaOSVersion,
uaDeviceType,
uid: passwordForgotToken.uid
})
})
.then(
function () {
return request.emitMetricsEvent('password.forgot.send_code.completed')
}
)
.then(
function () {
return passwordForgotToken
}
)
}
)
.then(
function (passwordForgotToken) {
return {
passwordForgotToken: passwordForgotToken.data,
ttl: passwordForgotToken.ttl(),
codeLength: passwordForgotToken.passCode.length,
tries: passwordForgotToken.tries
}
},
)
return mailer.sendRecoveryCode(emails, passwordForgotToken, {
token: passwordForgotToken,
code: passwordForgotToken.passCode,
service: service,
redirectTo: request.payload.redirectTo,
resume: request.payload.resume,
acceptLanguage: request.app.acceptLanguage,
flowId: flowId,
flowBeginTime: flowBeginTime,
ip: ip,
location: geoData.location,
timeZone: geoData.timeZone,
uaBrowser,
uaBrowserVersion,
uaOS,
uaOSVersion,
uaDeviceType,
uid: passwordForgotToken.uid
})
})
.then(() => request.emitMetricsEvent('password.forgot.send_code.completed'))
.then(() => ({
passwordForgotToken: passwordForgotToken.data,
ttl: passwordForgotToken.ttl(),
codeLength: passwordForgotToken.passCode.length,
tries: passwordForgotToken.tries
}))
}
},
{
@ -577,13 +573,13 @@ module.exports = function (
return mailer.sendRecoveryCode(emails, passwordForgotToken, {
code: passwordForgotToken.passCode,
token: passwordForgotToken,
service: service,
service,
redirectTo: request.payload.redirectTo,
resume: request.payload.resume,
acceptLanguage: request.app.acceptLanguage,
flowId: flowId,
flowBeginTime: flowBeginTime,
ip: ip,
flowId,
flowBeginTime,
ip,
location: geoData.location,
timeZone: geoData.timeZone,
uaBrowser,
@ -648,72 +644,56 @@ module.exports = function (
flowBeginTime = request.payload.metricsContext.flowBeginTime
}
let accountResetToken
return P.all([
request.emitMetricsEvent('password.forgot.verify_code.start'),
customs.check(request, passwordForgotToken.email, 'passwordForgotVerifyCode')
])
.then(
function () {
if (butil.buffersAreEqual(passwordForgotToken.passCode, code) &&
passwordForgotToken.ttl() > 0) {
return db.forgotPasswordVerified(passwordForgotToken)
.then(
function (accountResetToken) {
return db.accountEmails(passwordForgotToken.uid)
.then((emails) => {
if (accountResetWithRecoveryKey) {
// To prevent multiple password change emails being sent to a user,
// we check for a flag to see if this is a reset using an account recovery key.
// If it is, then the notification email will be sent in `/account/reset`
return P.resolve()
}
return mailer.sendPasswordResetNotification(
emails,
passwordForgotToken,
{
code: code,
acceptLanguage: request.app.acceptLanguage,
flowId: flowId,
flowBeginTime: flowBeginTime,
uid: passwordForgotToken.uid
}
)
})
.then(
function () {
return request.emitMetricsEvent('password.forgot.verify_code.completed')
}
)
.then(
function () {
return accountResetToken
}
)
}
)
.then(
function (accountResetToken) {
return {
accountResetToken: accountResetToken.data
}
}
)
}
else {
return failVerifyAttempt(passwordForgotToken)
.then(
function () {
throw error.invalidVerificationCode({
tries: passwordForgotToken.tries,
ttl: passwordForgotToken.ttl()
})
}
)
}
.then(() => {
if (butil.buffersAreEqual(passwordForgotToken.passCode, code) && passwordForgotToken.ttl() > 0) {
return db.forgotPasswordVerified(passwordForgotToken)
}
)
return failVerifyAttempt(passwordForgotToken)
.then(() => {
throw error.invalidVerificationCode({
tries: passwordForgotToken.tries,
ttl: passwordForgotToken.ttl()
})
})
})
.then(result => {
accountResetToken = result
return P.all([
request.propagateMetricsContext(passwordForgotToken, accountResetToken),
db.accountEmails(passwordForgotToken.uid)
])
})
.then(([_, emails]) => {
if (accountResetWithRecoveryKey) {
// To prevent multiple password change emails being sent to a user,
// we check for a flag to see if this is a reset using an account recovery key.
// If it is, then the notification email will be sent in `/account/reset`
return P.resolve()
}
return mailer.sendPasswordResetNotification(
emails,
passwordForgotToken,
{
code,
acceptLanguage: request.app.acceptLanguage,
flowId,
flowBeginTime,
uid: passwordForgotToken.uid
}
)
})
.then(() => request.emitMetricsEvent('password.forgot.verify_code.completed'))
.then(() => ({
accountResetToken: accountResetToken.data
}))
}
},
{

Просмотреть файл

@ -263,6 +263,7 @@ async function create (log, error, config, routes, db, translator) {
const metricsContext = require('./metrics/context')(log, config)
server.decorate('request', 'stashMetricsContext', metricsContext.stash)
server.decorate('request', 'gatherMetricsContext', metricsContext.gather)
server.decorate('request', 'propagateMetricsContext', metricsContext.propagate)
server.decorate('request', 'clearMetricsContext', metricsContext.clear)
server.decorate('request', 'validateMetricsContext', metricsContext.validate)
server.decorate('request', 'setMetricsFlowCompleteSignal', metricsContext.setFlowCompleteSignal)

Просмотреть файл

@ -53,7 +53,7 @@ describe('metricsContext', () => {
assert.equal(typeof metricsContext, 'object', 'metricsContext is object')
assert.notEqual(metricsContext, null, 'metricsContext is not null')
assert.equal(Object.keys(metricsContext).length, 5, 'metricsContext has 5 properties')
assert.lengthOf(Object.keys(metricsContext), 6)
assert.equal(typeof metricsContext.stash, 'function', 'metricsContext.stash is function')
assert.equal(metricsContext.stash.length, 1, 'metricsContext.stash expects 1 argument')
@ -61,6 +61,9 @@ describe('metricsContext', () => {
assert.equal(typeof metricsContext.gather, 'function', 'metricsContext.gather is function')
assert.equal(metricsContext.gather.length, 1, 'metricsContext.gather expects 1 argument')
assert.isFunction(metricsContext.propagate)
assert.lengthOf(metricsContext.propagate, 2)
assert.equal(typeof metricsContext.clear, 'function', 'metricsContext.clear is function')
assert.equal(metricsContext.clear.length, 0, 'metricsContext.gather expects no arguments')
@ -512,6 +515,78 @@ describe('metricsContext', () => {
}
)
it('metricsContext.propagate', () => {
results.get = P.resolve('wibble')
results.add = P.resolve()
const oldToken = {
uid: Array(64).fill('c').join(''),
id: 'foo'
}
const newToken = {
uid: Array(64).fill('d').join(''),
id: 'bar'
}
return metricsContext.propagate(oldToken, newToken)
.then(() => {
assert.equal(cache.get.callCount, 1)
let args = cache.get.args[0]
assert.lengthOf(args, 1)
assert.equal(args[0], hashToken(oldToken))
assert.equal(cache.add.callCount, 1)
args = cache.add.args[0]
assert.lengthOf(args, 2)
assert.equal(args[0], hashToken(newToken))
assert.equal(args[1], 'wibble')
assert.equal(cache.del.callCount, 0)
assert.equal(log.warn.callCount, 0)
assert.equal(log.error.callCount, 0)
})
})
it('metricsContext.propagate with clashing data', () => {
results.get = P.resolve('wibble')
results.add = P.reject('blee')
const oldToken = {
uid: Array(64).fill('c').join(''),
id: 'foo'
}
const newToken = {
uid: Array(64).fill('d').join(''),
id: 'bar'
}
return metricsContext.propagate(oldToken, newToken)
.then(() => {
assert.equal(cache.get.callCount, 1)
assert.equal(cache.add.callCount, 1)
assert.equal(log.warn.callCount, 1)
assert.equal(cache.del.callCount, 0)
assert.equal(log.error.callCount, 0)
})
})
it('metricsContext.propagate with get error', () => {
results.get = P.reject('wibble')
results.add = P.resolve()
const oldToken = {
uid: Array(64).fill('c').join(''),
id: 'foo'
}
const newToken = {
uid: Array(64).fill('d').join(''),
id: 'bar'
}
return metricsContext.propagate(oldToken, newToken)
.then(() => {
assert.equal(cache.get.callCount, 1)
assert.equal(log.error.callCount, 1)
assert.equal(cache.add.callCount, 0)
assert.equal(log.warn.callCount, 0)
assert.equal(cache.del.callCount, 0)
})
})
it(
'metricsContext.clear with token',
() => {

Просмотреть файл

@ -49,75 +49,86 @@ function runRoute(routes, name, request) {
}
describe('/password', () => {
it(
'/forgot/send_code',
() => {
var mockCustoms = mocks.mockCustoms()
var uid = uuid.v4('binary').toString('hex')
var mockDB = mocks.mockDB({
email: TEST_EMAIL,
passCode: 'foo',
passwordForgotTokenId: crypto.randomBytes(16).toString('hex'),
uid: uid
})
var mockMailer = mocks.mockMailer()
var mockMetricsContext = mocks.mockMetricsContext()
var mockLog = log('ERROR', 'test', {
stdout: {
on: sinon.spy(),
write: sinon.spy()
},
stderr: {
on: sinon.spy(),
write: sinon.spy()
}
})
mockLog.flowEvent = sinon.spy(() => {
return P.resolve()
})
var passwordRoutes = makeRoutes({
customs: mockCustoms,
db: mockDB,
mailer : mockMailer,
metricsContext: mockMetricsContext,
log: mockLog
})
it('/forgot/send_code', () => {
const mockCustoms = mocks.mockCustoms()
const uid = uuid.v4('binary').toString('hex')
const passwordForgotTokenId = crypto.randomBytes(16).toString('hex')
const mockDB = mocks.mockDB({
email: TEST_EMAIL,
passCode: 'foo',
passwordForgotTokenId,
uid
})
const mockMailer = mocks.mockMailer()
const mockMetricsContext = mocks.mockMetricsContext()
const mockLog = log('ERROR', 'test', {
stdout: {
on: sinon.spy(),
write: sinon.spy()
},
stderr: {
on: sinon.spy(),
write: sinon.spy()
}
})
mockLog.flowEvent = sinon.spy(() => {
return P.resolve()
})
const passwordRoutes = makeRoutes({
customs: mockCustoms,
db: mockDB,
mailer : mockMailer,
metricsContext: mockMetricsContext,
log: mockLog
})
var mockRequest = mocks.mockRequest({
log: mockLog,
payload: {
email: TEST_EMAIL,
metricsContext: {
flowId: 'F1031DF1031DF1031DF1031DF1031DF1031DF1031DF1031DF1031DF1031DF103',
flowBeginTime: Date.now() - 1
}
},
query: {},
metricsContext: mockMetricsContext
})
return runRoute(passwordRoutes, '/password/forgot/send_code', mockRequest)
.then(function(response) {
const mockRequest = mocks.mockRequest({
log: mockLog,
payload: {
email: TEST_EMAIL,
metricsContext: {
flowId: 'F1031DF1031DF1031DF1031DF1031DF1031DF1031DF1031DF1031DF1031DF103',
flowBeginTime: Date.now() - 1
}
},
query: {},
metricsContext: mockMetricsContext
})
return runRoute(passwordRoutes, '/password/forgot/send_code', mockRequest)
.then(response => {
assert.equal(mockDB.accountRecord.callCount, 1, 'db.emailRecord was called once')
assert.equal(mockDB.createPasswordForgotToken.callCount, 1, 'db.createPasswordForgotToken was called once')
var args = mockDB.createPasswordForgotToken.args[0]
let args = mockDB.createPasswordForgotToken.args[0]
assert.equal(args.length, 1, 'db.createPasswordForgotToken was passed one argument')
assert.deepEqual(args[0].uid, uid, 'db.createPasswordForgotToken was passed the correct uid')
assert.equal(args[0].createdAt, undefined, 'db.createPasswordForgotToken was not passed a createdAt timestamp')
assert.equal(mockRequest.validateMetricsContext.callCount, 1, 'validateMetricsContext was called')
assert.equal(mockMetricsContext.setFlowCompleteSignal.callCount, 1)
args = mockMetricsContext.setFlowCompleteSignal.args[0]
assert.lengthOf(args, 1)
assert.equal(args[0], 'account.reset')
assert.equal(mockMetricsContext.stash.callCount, 1)
args = mockMetricsContext.stash.args[0]
assert.lengthOf(args, 1)
assert.equal(args[0].id, passwordForgotTokenId)
assert.equal(args[0].uid, uid)
assert.equal(mockLog.flowEvent.callCount, 2, 'log.flowEvent was called twice')
assert.equal(mockLog.flowEvent.args[0][0].event, 'password.forgot.send_code.start', 'password.forgot.send_code.start event was logged')
assert.equal(mockLog.flowEvent.args[1][0].event, 'password.forgot.send_code.completed', 'password.forgot.send_code.completed event was logged')
assert.equal(mockMailer.sendRecoveryCode.callCount, 1, 'mailer.sendRecoveryCode was called once')
assert.equal(mockMailer.sendRecoveryCode.getCall(0).args[2].location.city, 'Mountain View')
assert.equal(mockMailer.sendRecoveryCode.getCall(0).args[2].location.country, 'United States')
assert.equal(mockMailer.sendRecoveryCode.getCall(0).args[2].timeZone, 'America/Los_Angeles')
assert.equal(mockMailer.sendRecoveryCode.getCall(0).args[2].uid, uid)
args = mockMailer.sendRecoveryCode.args[0]
assert.equal(args[2].location.city, 'Mountain View')
assert.equal(args[2].location.country, 'United States')
assert.equal(args[2].timeZone, 'America/Los_Angeles')
assert.equal(args[2].uid, uid)
})
}
)
})
it(
'/forgot/resend_code',
@ -157,6 +168,7 @@ describe('/password', () => {
uid: uid
},
log: mockLog,
metricsContext: mockMetricsContext,
payload: {
email: TEST_EMAIL,
metricsContext: {
@ -164,8 +176,7 @@ describe('/password', () => {
flowBeginTime: Date.now() - 1
}
},
query: {},
metricsContext: mockMetricsContext
query: {}
})
return runRoute(passwordRoutes, '/password/forgot/resend_code', mockRequest)
.then(function(response) {
@ -180,69 +191,72 @@ describe('/password', () => {
}
)
it(
'/forgot/verify_code',
() => {
var mockCustoms = mocks.mockCustoms()
var uid = uuid.v4('binary').toString('hex')
var accountResetToken = {
data: crypto.randomBytes(16).toString('hex')
it('/forgot/verify_code', () => {
const mockCustoms = mocks.mockCustoms()
const uid = uuid.v4('binary').toString('hex')
const accountResetToken = {
data: crypto.randomBytes(16).toString('hex'),
id: crypto.randomBytes(16).toString('hex'),
uid
}
const passwordForgotTokenId = crypto.randomBytes(16).toString('hex')
const mockDB = mocks.mockDB({
accountResetToken: accountResetToken,
email: TEST_EMAIL,
passCode: 'abcdef',
passwordForgotTokenId,
uid
})
const mockMailer = mocks.mockMailer()
const mockMetricsContext = mocks.mockMetricsContext()
const mockLog = log('ERROR', 'test', {
stdout: {
on: sinon.spy(),
write: sinon.spy()
},
stderr: {
on: sinon.spy(),
write: sinon.spy()
}
var mockDB = mocks.mockDB({
accountResetToken: accountResetToken,
email: TEST_EMAIL,
passCode: 'abcdef',
passwordForgotTokenId: crypto.randomBytes(16).toString('hex'),
uid: uid
})
var mockMailer = mocks.mockMailer()
var mockMetricsContext = mocks.mockMetricsContext()
var mockLog = log('ERROR', 'test', {
stdout: {
on: sinon.spy(),
write: sinon.spy()
},
stderr: {
on: sinon.spy(),
write: sinon.spy()
}
})
mockLog.flowEvent = sinon.spy(() => {
return P.resolve()
})
var passwordRoutes = makeRoutes({
customs: mockCustoms,
db: mockDB,
mailer: mockMailer,
metricsContext: mockMetricsContext
})
})
mockLog.flowEvent = sinon.spy(() => {
return P.resolve()
})
const passwordRoutes = makeRoutes({
customs: mockCustoms,
db: mockDB,
mailer: mockMailer,
metricsContext: mockMetricsContext
})
var mockRequest = mocks.mockRequest({
log: mockLog,
credentials: {
email: TEST_EMAIL,
passCode: Buffer.from('abcdef', 'hex'),
ttl: function () { return 17 },
uid: uid
},
payload: {
code: 'abcdef',
metricsContext: {
flowId: 'F1031DF1031DF1031DF1031DF1031DF1031DF1031DF1031DF1031DF1031DF103',
flowBeginTime: Date.now() - 1
}
},
query: {}
})
return runRoute(passwordRoutes, '/password/forgot/verify_code', mockRequest)
.then(function(response) {
const mockRequest = mocks.mockRequest({
log: mockLog,
credentials: {
email: TEST_EMAIL,
id: passwordForgotTokenId,
passCode: Buffer.from('abcdef', 'hex'),
ttl: function () { return 17 },
uid
},
metricsContext: mockMetricsContext,
payload: {
code: 'abcdef',
metricsContext: {
flowId: 'F1031DF1031DF1031DF1031DF1031DF1031DF1031DF1031DF1031DF1031DF103',
flowBeginTime: Date.now() - 1
}
},
query: {}
})
return runRoute(passwordRoutes, '/password/forgot/verify_code', mockRequest)
.then(response => {
assert.deepEqual(Object.keys(response), ['accountResetToken'], 'an accountResetToken was returned')
assert.equal(response.accountResetToken, accountResetToken.data.toString('hex'), 'correct accountResetToken was returned')
assert.equal(mockCustoms.check.callCount, 1, 'customs.check was called once')
assert.equal(mockDB.forgotPasswordVerified.callCount, 1, 'db.passwordForgotVerified was called once')
var args = mockDB.forgotPasswordVerified.args[0]
let args = mockDB.forgotPasswordVerified.args[0]
assert.equal(args.length, 1, 'db.passwordForgotVerified was passed one argument')
assert.deepEqual(args[0].uid, uid, 'db.forgotPasswordVerified was passed the correct token')
@ -251,6 +265,14 @@ describe('/password', () => {
assert.equal(mockLog.flowEvent.args[0][0].event, 'password.forgot.verify_code.start', 'password.forgot.verify_code.start event was logged')
assert.equal(mockLog.flowEvent.args[1][0].event, 'password.forgot.verify_code.completed', 'password.forgot.verify_code.completed event was logged')
assert.equal(mockMetricsContext.propagate.callCount, 1)
args = mockMetricsContext.propagate.args[0]
assert.lengthOf(args, 2)
assert.equal(args[0].id, passwordForgotTokenId)
assert.equal(args[0].uid, uid)
assert.equal(args[1].id, accountResetToken.id)
assert.equal(args[1].uid, uid)
assert.equal(mockMailer.sendPasswordResetNotification.callCount, 1, 'mailer.sendPasswordResetNotification was called once')
assert.equal(mockMailer.sendPasswordResetNotification.args[0][2].uid, uid, 'mailer.sendPasswordResetNotification was passed uid')
})

Просмотреть файл

@ -121,6 +121,7 @@ const MAILER_METHOD_NAMES = [
const METRICS_CONTEXT_METHOD_NAMES = [
'clear',
'gather',
'propagate',
'setFlowCompleteSignal',
'stash',
'validate'
@ -566,10 +567,11 @@ function mockRequest (data, errors) {
info: {
received: data.received || Date.now() - 1
},
path: data.path,
params: data.params || {},
method: data.method || undefined,
params: data.params || {},
path: data.path,
payload: data.payload || {},
propagateMetricsContext: metricsContext.propagate,
query: data.query || {},
setMetricsFlowCompleteSignal: metricsContext.setFlowCompleteSignal,
stashMetricsContext: metricsContext.stash,