@jsreport/jsreport-core
Version:
javascript based business reporting
617 lines (507 loc) • 20.7 kB
JavaScript
const EventEmitter = require('events')
const Transport = require('winston-transport')
const extend = require('node.extend.without.arrays')
const generateRequestId = require('../shared/generateRequestId')
const fs = require('fs/promises')
const { SPLAT } = require('triple-beam')
const promisify = require('util').promisify
const stringifyAsync = promisify(require('yieldable-json').stringifyAsync)
module.exports = (reporter) => {
reporter.documentStore.registerEntityType('ProfileType', {
templateShortid: { type: 'Edm.String', referenceTo: 'templates' },
timestamp: { type: 'Edm.DateTimeOffset', schema: { type: 'null' } },
finishedOn: { type: 'Edm.DateTimeOffset', schema: { type: 'null' } },
state: { type: 'Edm.String', schema: { enum: ['running', 'success', 'queued', 'error', 'canceling'] }, index: true, length: 255 },
error: { type: 'Edm.String' },
mode: { type: 'Edm.String', schema: { enum: ['full', 'standard', 'disabled'] } },
blobName: { type: 'Edm.String' },
timeout: { type: 'Edm.Int32' }
})
reporter.documentStore.registerEntitySet('profiles', {
entityType: 'jsreport.ProfileType',
exportable: false
})
const profilersMap = new Map()
const profilerOperationsChainsMap = new Map()
const profilerRequestMap = new Map()
function runInProfilerChain (fnOrOptions, req) {
if (req.context.profiling == null || req.context.profiling.mode === 'disabled') {
return
}
let fn
let cleanFn
if (typeof fnOrOptions === 'function') {
fn = fnOrOptions
} else {
fn = fnOrOptions.fn
cleanFn = fnOrOptions.cleanFn
}
// this only happens when rendering remote delegated requests on docker workers
// there won't be operations chain because the request started from another server
if (!profilerOperationsChainsMap.has(req.context.rootId)) {
return
}
profilerOperationsChainsMap.set(req.context.rootId, profilerOperationsChainsMap.get(req.context.rootId).then(async () => {
if (cleanFn) {
cleanFn()
}
if (req.context.profiling.chainFailed) {
return
}
try {
if (fn) {
await fn()
}
} catch (e) {
reporter.logger.warn('Failed persist profile', e)
req.context.profiling.chainFailed = true
}
}))
}
function createProfileMessage (m, req) {
m.timestamp = new Date().getTime()
m.id = generateRequestId()
m.previousOperationId = m.previousOperationId || null
if (m.type !== 'log') {
m.operationId = m.operationId || generateRequestId()
req.context.profiling.lastOperationId = m.operationId
req.context.profiling.lastEventId = m.id
}
return m
}
function emitProfiles ({ events, log = true }, req) {
if (events.length === 0) {
return
}
let lastOperation
for (const m of events) {
if (m.type === 'log') {
if (log) {
reporter.logger[m.level](m.message, { ...req, ...m.meta, timestamp: m.timestamp, logged: true })
}
} else {
lastOperation = m
}
if (profilersMap.has(req.context.rootId)) {
profilersMap.get(req.context.rootId).emit('profile', m)
}
}
if (lastOperation != null) {
req.context.profiling.lastOperation = lastOperation
}
runInProfilerChain(async () => {
const stringifiedMessages = req.context.mode === 'full'
? await Promise.all(events.map(m => stringifyAsync(m)))
: events.map(m => JSON.stringify(m))
await fs.appendFile(req.context.profiling.logFilePath, Buffer.from(stringifiedMessages.join('\n') + '\n'))
}, req)
}
reporter.registerMainAction('profile', async (eventsOrOptions, _req) => {
let req = _req
// if there is request stored here then take it, this is needed
// for docker workers remote requests, so the emitProfile can work
// with the real render request object
if (profilerRequestMap.has(req.context.rootId) && req.__isJsreportRequest__ == null) {
req = profilerRequestMap.get(req.context.rootId)
}
let events
let log
if (Array.isArray(eventsOrOptions)) {
events = eventsOrOptions
} else {
events = eventsOrOptions.events
log = eventsOrOptions.log
}
const params = { events }
if (log != null) {
params.log = log
}
return emitProfiles(params, req)
})
reporter.attachProfiler = (req, profileMode) => {
req.context = req.context || {}
req.context.rootId = reporter.generateRequestId()
req.context.profiling = {
mode: profileMode == null ? 'full' : profileMode
}
const profiler = new EventEmitter()
profilersMap.set(req.context.rootId, profiler)
return profiler
}
reporter.beforeRenderWorkerAllocatedListeners.add('profiler', async (req) => {
req.context.profiling = req.context.profiling || {}
if (req.context.profiling.enabled === false) {
return
}
if (req.context.profiling.mode == null) {
const profilerSettings = await reporter.settings.findValue('profiler', req)
const defaultMode = reporter.options.profiler.defaultMode || 'standard'
req.context.profiling.mode = (profilerSettings != null && profilerSettings.mode != null) ? profilerSettings.mode : defaultMode
}
profilerOperationsChainsMap.set(req.context.rootId, Promise.resolve())
req.context.profiling.lastOperation = null
const profile = {
_id: reporter.documentStore.generateId(),
timestamp: new Date(),
state: 'queued',
mode: req.context.profiling.mode
}
const { pathToFile } = await reporter.writeTempFile((uuid) => `${uuid}.log`, '')
req.context.profiling.logFilePath = pathToFile
runInProfilerChain(async () => {
req.context.skipValidationFor = profile
await reporter.documentStore.collection('profiles').insert(profile, req)
}, req)
req.context.profiling.entity = profile
const profileStartOperation = createProfileMessage({
type: 'operationStart',
subtype: 'profile',
data: profile,
doDiffs: false
}, req)
req.context.profiling.profileStartOperationId = profileStartOperation.operationId
emitProfiles({ events: [profileStartOperation] }, req)
emitProfiles({
events: [createProfileMessage({
type: 'log',
level: 'info',
message: `Render request ${req.context.reportCounter} queued for execution and waiting for available worker`,
previousOperationId: profileStartOperation.operationId
}, req)]
}, req)
})
reporter.beforeRenderListeners.add('profiler', async (req, res) => {
const update = {
state: 'running',
// the timeout needs to be calculated later here, because the req.options.timeout isnt yet parsed in beforeRenderWorkerAllocatedListeners
timeout: reporter.options.enableRequestReportTimeout && req.options.timeout ? req.options.timeout : reporter.options.reportTimeout
}
// we set the request here because this listener will container the req which
// the .render() starts
profilerRequestMap.set(req.context.rootId, req)
const template = await reporter.templates.resolveTemplate(req)
if (template && template._id) {
req.context.resolvedTemplate = extend(true, {}, template)
update.templateShortid = template.shortid
}
runInProfilerChain(() => {
req.context.skipValidationFor = update
return reporter.documentStore.collection('profiles').update({
_id: req.context.profiling.entity._id
}, {
$set: update
}, req)
}, req)
Object.assign(req.context.profiling.entity, update)
})
reporter.afterRenderListeners.add('profiler', async (req, res) => {
emitProfiles({
events: [createProfileMessage({
type: 'operationEnd',
doDiffs: false,
previousEventId: req.context.profiling.lastEventId,
previousOperationId: req.context.profiling.lastOperationId,
operationId: req.context.profiling.profileStartOperationId
}, req)]
}, req)
res.meta.profileId = req.context.profiling?.entity?._id
runInProfilerChain(async () => {
let blobName = `profiles/${req.context.rootId}.log`
if (req.context.resolvedTemplate) {
const templatePath = await reporter.folders.resolveEntityPath(req.context.resolvedTemplate, 'templates', req)
blobName = `profiles/${templatePath.substring(1)}/${req.context.rootId}.log`
}
const content = await fs.readFile(req.context.profiling.logFilePath)
blobName = await reporter.blobStorage.write(blobName, content, req)
await fs.unlink(req.context.profiling.logFilePath)
const update = {
state: 'success',
finishedOn: new Date(),
blobName
}
req.context.skipValidationFor = update
await reporter.documentStore.collection('profiles').update({
_id: req.context.profiling.entity._id
}, {
$set: update
}, req)
}, req)
// we don't clean the profiler maps here, we do it later in main reporter .render,
// because the renderErrorListeners can be invoked if the afterRenderListener fails
})
reporter.renderErrorListeners.add('profiler', async (req, res, e) => {
res.meta.profileId = req.context.profiling?.entity?._id
if (req.context.profiling?.entity != null) {
emitProfiles({
events: [{
type: 'error',
timestamp: new Date().getTime(),
...e,
id: generateRequestId(),
stack: e.stack,
message: e.message
}]
}, req)
runInProfilerChain(async () => {
const update = {
state: 'error',
finishedOn: new Date(),
error: e.toString()
}
if (req.context.profiling.logFilePath != null) {
let blobName = `profiles/${req.context.rootId}.log`
if (req.context.resolvedTemplate) {
const templatePath = await reporter.folders.resolveEntityPath(req.context.resolvedTemplate, 'templates', req)
blobName = `profiles/${templatePath.substring(1)}/${req.context.rootId}.log`
}
const content = await fs.readFile(req.context.profiling.logFilePath)
blobName = await reporter.blobStorage.write(blobName, content, req)
await fs.unlink(req.context.profiling.logFilePath)
update.blobName = blobName
}
req.context.skipValidationFor = update
await reporter.documentStore.collection('profiles').update({
_id: req.context.profiling.entity._id
}, {
$set: update
}, req)
}, req)
// we don't clean the profiler maps here, we do it later in main reporter .render,
// we do this to ensure a single and clear order
}
})
// we want to add to profiles also log messages from the main
const configuredPreviously = reporter.logger.__profilerConfigured__ === true
if (!configuredPreviously) {
// we emit from winston transport, so winston formatters can still format message
class EmittingProfilesTransport extends Transport {
log (info, callback) {
setImmediate(() => {
this.emit('logged', info)
})
if (info[SPLAT]) {
const [req] = info[SPLAT]
if (req && req.context && req.logged !== true) {
emitProfiles({
events: [createProfileMessage({
type: 'log',
level: info.level,
message: info.message,
previousOperationId: req.context.profiling?.lastOperationId
}, req)],
log: false
}, req)
}
}
callback()
}
}
reporter.logger.add(new EmittingProfilesTransport({
format: reporter.logger.format,
level: 'debug'
}))
reporter.logger.__profilerConfigured__ = true
}
let profilesCleanupInterval
let fullModeDurationCheckInterval
let profilesCancelingCheckInterval
reporter.initializeListeners.add('profiler', async () => {
reporter.documentStore.collection('profiles').beforeRemoveListeners.add('profiles', async (query, req) => {
const profiles = await reporter.documentStore.collection('profiles').find(query, req)
for (const profile of profiles) {
if (profile.blobName != null) {
await reporter.blobStorage.remove(profile.blobName)
}
}
})
// exposing it to jo for override
function profilesCleanupExec () {
return reporter._profilesCleanup()
}
function fullModeDurationCheckExec () {
return reporter._profilesFullModeDurationCheck()
}
let _profilesCancelingCheckExecRunning = false
async function profilesCancelingCheckExec () {
if (_profilesCancelingCheckExecRunning) {
return
}
_profilesCancelingCheckExecRunning = true
try {
const cancelingProfiles = await reporter.documentStore.collection('profiles').find({
state: 'canceling'
})
for (const profile of cancelingProfiles) {
const runningReq = [...reporter.runningRequests.map.values()].find(v => v.req.context.profiling?.entity?._id === profile._id)
if (runningReq) {
runningReq.options.abortEmitter.emit('abort')
}
}
} catch (e) {
reporter.logger.warn('Failed to process cancelling profiles. No worry, it will retry next time.', e)
} finally {
_profilesCancelingCheckExecRunning = false
}
}
profilesCleanupInterval = setInterval(profilesCleanupExec, reporter.options.profiler.cleanupInterval)
profilesCleanupInterval.unref()
fullModeDurationCheckInterval = setInterval(fullModeDurationCheckExec, reporter.options.profiler.fullModeDurationCheckInterval)
fullModeDurationCheckInterval.unref()
profilesCancelingCheckInterval = setInterval(profilesCancelingCheckExec, reporter.options.profiler.cancelingCheckInterval)
profilesCancelingCheckInterval.unref()
await reporter._profilesCleanup()
})
reporter.closeListeners.add('profiler', async () => {
if (profilesCleanupInterval) {
clearInterval(profilesCleanupInterval)
}
if (fullModeDurationCheckInterval) {
clearInterval(fullModeDurationCheckInterval)
}
if (profilesCancelingCheckInterval) {
clearInterval(profilesCancelingCheckInterval)
}
try {
const runningRequests = [...reporter.runningRequests.map.values()]
await reporter.documentStore.collection('profiles').update({
_id: {
$in: runningRequests.map(r => r.req.context.profiling?.entity?._id)
}
}, {
$set: {
state: 'error',
finishedOn: new Date(),
error: 'The server unexpectedly stopped during the report rendering.'
}
})
} catch (e) {
reporter.logger.warn('Failed to set error state to the running requests when closing.', e)
}
for (const key of profilerOperationsChainsMap.keys()) {
const profileAppendPromise = profilerOperationsChainsMap.get(key)
if (profileAppendPromise) {
await profileAppendPromise
}
}
profilersMap.clear()
profilerOperationsChainsMap.clear()
profilerRequestMap.clear()
})
let profilesCleanupRunning = false
reporter._profilesCleanup = async function profilesCleanup () {
if (profilesCleanupRunning) {
return
}
profilesCleanupRunning = true
let lastError
try {
const profilesToRemove = await reporter.documentStore.collection('profiles')
.find({}, { _id: 1 }).sort({ timestamp: -1 })
.skip(reporter.options.profiler.maxProfilesHistory)
.toArray()
for (const profile of profilesToRemove) {
if (reporter.closed || reporter.closing) {
return
}
try {
await reporter.documentStore.collection('profiles').remove({
_id: profile._id
})
} catch (e) {
lastError = e
}
}
const notFinishedProfiles = await reporter.documentStore.collection('profiles')
.find({ $or: [{ state: 'running' }, { state: 'queued' }, { state: 'canceling' }] }, { _id: 1, timeout: 1, timestamp: 1 })
.toArray()
for (const profile of notFinishedProfiles) {
if (reporter.closed || reporter.closing) {
return
}
if (!profile.timeout) {
// we can calculate profile timeout only after worker parses request and req.options.timeout is calculated
// if the timeout isnt calculated we error orphans that hangs for very long time before worker gets allocated and parses req
if ((profile.timestamp.getTime() + reporter.options.profiler.maxUnallocatedProfileAge) < new Date().getTime()) {
try {
await reporter.documentStore.collection('profiles').update({
_id: profile._id
}, {
$set: {
state: 'error',
finishedOn: new Date(),
error: `The request wasn't parsed before ${reporter.options.profiler.maxUnallocatedProfileAge}ms. This can happen when the server is unexpectedly stopped.`
}
})
} catch (e) {
lastError = e
}
}
continue
}
const whenShouldBeFinished = profile.timestamp.getTime() + profile.timeout + reporter.options.reportTimeoutMargin * 2
if (whenShouldBeFinished > new Date().getTime()) {
continue
}
try {
await reporter.documentStore.collection('profiles').update({
_id: profile._id
}, {
$set: {
state: 'error',
finishedOn: new Date(),
error: 'The server did not update the report profile before its timeout. This can happen when the server is unexpectedly stopped.'
}
})
} catch (e) {
lastError = e
}
}
} catch (e) {
reporter.logger.warn('Profile cleanup failed', e)
} finally {
profilesCleanupRunning = false
}
if (lastError) {
reporter.logger.warn('Profile cleanup failed for some entities, last error:', lastError)
}
}
reporter._profilesFullModeDurationCheck = async function () {
try {
if (reporter.options.profiler.defaultMode === 'full') {
return
}
const profiler = await reporter.documentStore.collection('settings').findOne({ key: 'profiler' })
if (profiler == null || (profiler.modificationDate.getTime() + reporter.options.profiler.fullModeDuration) > new Date().getTime()) {
return
}
const profilerValue = JSON.parse(profiler.value)
if (profilerValue.mode !== 'full') {
return
}
reporter.logger.info('Switching full mode profiling back to standard to avoid performance degradation.')
await reporter.settings.addOrSet('profiler', { mode: 'standard' })
} catch (e) {
reporter.logger.warn('Failed to change profiling mode', e)
}
}
return function cleanProfileInRequest (req) {
// - req.context.profiling is empty only on an early error
// that happens before setting the profiler.
// - when profiling.mode is "disabled" there is no profiler chain to append
// in both cases we want the clean code to happen immediately
if (req.context.profiling?.entity == null || req.context.profiling?.mode === 'disabled') {
profilersMap.delete(req.context.rootId)
profilerOperationsChainsMap.delete(req.context.rootId)
profilerRequestMap.delete(req.context.rootId)
return
}
// this will get executed always even if some fn in the chain fails
runInProfilerChain({
cleanFn: () => {
profilersMap.delete(req.context.rootId)
profilerOperationsChainsMap.delete(req.context.rootId)
profilerRequestMap.delete(req.context.rootId)
}
}, req)
}
}