diff --git a/index.js b/index.js index 426442093c..384d31af0b 100644 --- a/index.js +++ b/index.js @@ -5,6 +5,8 @@ 'use strict' +const HealthReporter = require('./lib/health-reporter') + // Record opening times before loading any other files. const preAgentTime = process.uptime() const agentStart = Date.now() @@ -156,6 +158,7 @@ function createAgent(config) { 'New Relic requires that you name this application!\n' + 'Set app_name in your newrelic.js or newrelic.cjs file or set environment variable\n' + 'NEW_RELIC_APP_NAME. Not starting!' + agent.healthReporter.setStatus(HealthReporter.STATUS_MISSING_APP_NAME) throw new Error(message) } @@ -169,6 +172,7 @@ function createAgent(config) { agent.start(function afterStart(error) { if (error) { + agent.healthReporter.setStatus(HealthReporter.STATUS_INTERNAL_UNEXPECTED_ERROR) const errorMessage = 'New Relic for Node.js halted startup due to an error:' logger.error(error, errorMessage) diff --git a/lib/agent.js b/lib/agent.js index 85d06cdcc9..e1aa64c04a 100644 --- a/lib/agent.js +++ b/lib/agent.js @@ -41,6 +41,7 @@ const { const synthetics = require('./synthetics') const Harvester = require('./harvester') const { createFeatureUsageMetrics } = require('./util/application-logging') +const HealthReporter = require('./health-reporter') // Map of valid states to whether or not data collection is valid const STATES = { @@ -159,7 +160,10 @@ const DEFAULT_HARVEST_INTERVAL_MS = 60000 function Agent(config) { EventEmitter.call(this) + this.healthReporter = new HealthReporter() + if (!config) { + this.healthReporter.setStatus(HealthReporter.STATUS_CONFIG_PARSE_FAILURE) throw new Error('Agent must be created with a configuration!') } @@ -350,11 +354,15 @@ Agent.prototype.start = function start(callback) { 'Has a license key been specified in the agent configuration ' + 'file or via the NEW_RELIC_LICENSE_KEY environment variable?' ) + this.healthReporter.setStatus(HealthReporter.STATUS_LICENSE_KEY_MISSING) this.setState('errored') sampler.stop() + const self = this return process.nextTick(function onNextTick() { - callback(new Error('Not starting without license key!')) + self.healthReporter.stop(() => { + callback(new Error('Not starting without license key!')) + }) }) } logger.info('Starting New Relic for Node.js connection process.') @@ -484,23 +492,26 @@ Agent.prototype.stop = function stop(callback) { sampler.stop() - if (this.collector.isConnected()) { - this.collector.shutdown(function onShutdown(error) { - if (error) { - agent.setState('errored') - logger.warn(error, 'Got error shutting down connection to New Relic:') - } else { - agent.setState('stopped') - logger.info('Stopped New Relic for Node.js.') - } - - callback(error) - }) - } else { - logger.trace('Collector was not connected, invoking callback.') + this.healthReporter.setStatus(HealthReporter.STATUS_AGENT_SHUTDOWN) + this.healthReporter.stop(() => { + if (agent.collector.isConnected()) { + agent.collector.shutdown(function onShutdown(error) { + if (error) { + agent.setState('errored') + logger.warn(error, 'Got error shutting down connection to New Relic:') + } else { + agent.setState('stopped') + logger.info('Stopped New Relic for Node.js.') + } + + callback(error) + }) + } else { + logger.trace('Collector was not connected, invoking callback.') - process.nextTick(callback) - } + process.nextTick(callback) + } + }) } /** diff --git a/lib/health-reporter.js b/lib/health-reporter.js new file mode 100644 index 0000000000..918903ff1e --- /dev/null +++ b/lib/health-reporter.js @@ -0,0 +1,205 @@ +/* + * Copyright 2024 New Relic Corporation. All rights reserved. + * SPDX-License-Identifier: Apache-2.0 + */ + +'use strict' + +const fs = require('node:fs') +const crypto = require('node:crypto') +const path = require('node:path') + +const defaultLogger = require('./logger').child({ component: 'HealthReporter' }) + +const VALID_CODES = new Map([ + ['NR-APM-000', 'Healthy.'], + ['NR-APM-001', 'Invalid license key.'], + ['NR-APM-002', 'License key missing.'], + ['NR-APM-003', 'Forced disconnect received from New Relic.'], + ['NR-APM-004', 'HTTP error communicating with New Relic.'], + ['NR-APM-005', 'Missing application name in agent configuration.'], + ['NR-APM-006', 'The maximum number of configured app names is exceeded.'], + ['NR-APM-007', 'HTTP proxy is misconfigured.'], + ['NR-APM-008', 'Agent is disabled via configuration.'], + ['NR-APM-009', 'Failed to connect to the New Relic data collector.'], + ['NR-APM-010', 'Agent config could not be parsed.'], + ['NR-APM-099', 'Agent has shutdown.'], + // Codes 300 through 399 are reserved for the Node.js Agent. + ['NR-APM-300', 'An unexpected error occurred.'] +]) + +function writeStatus({ file, healthy = true, code, msg, startTime, callback } = {}) { + const currentTime = Number(process.hrtime.bigint()) + const yaml = [ + `healthy: ${healthy}`, + `status: '${msg}'`, + `last_error: ${code}`, + `start_time_unix_nano: ${startTime}`, + `status_time_unix_nano: ${currentTime}` + ].join('\n') + fs.writeFile(file, yaml, { encoding: 'utf8' }, callback) +} + +/** + * HealthReporter implements the "super agent" (New Relic Control) health + * check spec. An instance of the reporter will continually write out the + * current status, as set by `reporter.setStatus`, on the interval defined + * by the environment. + */ +class HealthReporter { + #enabled = false + #status = HealthReporter.STATUS_HEALTHY + #interval + #destFile + #logger + #startTime + + static STATUS_HEALTHY = 'NR-APM-000' + static STATUS_INVALID_LICENSE_KEY = 'NR-APM-001' + static STATUS_LICENSE_KEY_MISSING = 'NR-APM-002' + static STATUS_FORCED_DISCONNECT = 'NR-APM-003' + static STATUS_BACKEND_ERROR = 'NR-APM-004' + static STATUS_MISSING_APP_NAME = 'NR-APM-005' + static STATUS_MAXIMUM_APP_NAMES_EXCEEDED = 'NR-APM-006' + static STATUS_HTTP_PROXY_MISCONFIGURED = 'NR-APM-007' + static STATUS_AGENT_DISABLED = 'NR-APM-008' + static STATUS_CONNECT_ERROR = 'NR-APM-009' + static STATUS_CONFIG_PARSE_FAILURE = 'NR-APM-010' + static STATUS_AGENT_SHUTDOWN = 'NR-APM-099' + + // STATUS_INTERNAL errors are the Node.js Agent specific error codes. + static STATUS_INTERNAL_UNEXPECTED_ERROR = 'NR-APM-300' + + constructor({ logger = defaultLogger, setInterval = global.setInterval } = {}) { + const fleetId = process.env.NEW_RELIC_SUPERAGENT_FLEET_ID + const outDir = process.env.NEW_RELIC_SUPERAGENT_HEALTH_DELIVERY_LOCATION + let checkInterval = process.env.NEW_RELIC_SUPERAGENT_HEALTH_FREQUENCY + + this.#logger = logger + + if (!fleetId) { + this.#logger.info('new relic control not present, skipping health reporting') + return + } + + if (outDir === undefined) { + this.#logger.error('health check output directory not provided, skipping health reporting') + return + } + + if (checkInterval === undefined) { + this.#logger.debug('health check interval not available, using default 5 seconds') + checkInterval = 5_000 + } else { + checkInterval = parseInt(checkInterval, 10) * 1_000 + } + + this.#startTime = Number(process.hrtime.bigint()) + + const uuid = crypto.randomUUID().replaceAll('-', '') + this.#destFile = path.join(outDir, `health-${uuid}.yaml`) + + this.#logger.info( + `new relic control is present, writing health on interval ${checkInterval} milliseconds to ${ + this.#destFile + }` + ) + this.#interval = setInterval(this.#healthCheck.bind(this), checkInterval) + this.#interval.unref() + + this.#enabled = true + this.#logger.info('health reporter initialized') + } + + #healthCheck() { + const healthy = this.#status === HealthReporter.STATUS_HEALTHY + writeStatus({ + file: this.#destFile, + healthy, + startTime: this.#startTime, + code: this.#status, + msg: VALID_CODES.get(this.#status), + callback: (error) => { + if (error) { + this.#logger.error(`error when writing out health status: ${error.message}`) + } + } + }) + } + + /** + * Update the known health status. This status will be written to the health + * file on the next interval. If the provided status is not a recognized + * status, a log will be written and the status will not be updated. + * + * @param {string} status Utilize one of the static status fields. + */ + setStatus(status) { + if (this.#enabled === false) { + return + } + + if (VALID_CODES.has(status) === false) { + this.#logger.warn(`invalid health reporter status provided: ${status}`) + return + } + + if ( + status === HealthReporter.STATUS_AGENT_SHUTDOWN && + this.#status !== HealthReporter.STATUS_HEALTHY + ) { + this.#logger.info( + `not setting shutdown health status due to current status code: ${this.#status}` + ) + return + } + + this.#status = status + } + + /** + * This should be invoked on agent shutdown after setting the status + * to the shutdown status. It will stop the ongoing update interval, + * initiate an immediate write of the status file, and then invoke the + * provided callback. + * + * @param {function} done Callback to be invoked after the status file has + * been updated. + */ + stop(done) { + if (this.#enabled === false) { + return done() + } + + clearInterval(this.#interval) + + const healthy = this.#status === HealthReporter.STATUS_HEALTHY + let code = this.#status + let msg = VALID_CODES.get(code) + if (healthy === true) { + // We only update the status on shutdown when the last known state is + // the healthy state. Otherwise, we need to leave the current code in + // place, and just update the report time. + code = HealthReporter.STATUS_AGENT_SHUTDOWN + msg = VALID_CODES.get(code) + } + + writeStatus({ + file: this.#destFile, + startTime: this.#startTime, + healthy, + code, + msg, + callback: (error) => { + if (error) { + this.#logger.error( + `error when writing out health status during shutdown: ${error.message}` + ) + } + done && done() + } + }) + } +} + +module.exports = HealthReporter diff --git a/package.json b/package.json index 69fc90c850..90ddc3474b 100644 --- a/package.json +++ b/package.json @@ -193,6 +193,9 @@ "bin": { "newrelic-naming-rules": "./bin/test-naming-rules.js" }, + "imports": { + "#agentlib/*.js": "./lib/*.js" + }, "dependencies": { "@grpc/grpc-js": "^1.12.2", "@grpc/proto-loader": "^0.7.5", diff --git a/test/unit/agent/agent.test.js b/test/unit/agent/agent.test.js index 8a43178785..0f7b3ce359 100644 --- a/test/unit/agent/agent.test.js +++ b/test/unit/agent/agent.test.js @@ -7,6 +7,7 @@ const test = require('node:test') const assert = require('node:assert') +const tspl = require('@matteo.collina/tspl') const Collector = require('../../lib/test-collector') const sinon = require('sinon') @@ -17,12 +18,51 @@ const Agent = require('../../../lib/agent') const Transaction = require('../../../lib/transaction') const CollectorResponse = require('../../../lib/collector/response') +const fs = require('node:fs') +const os = require('node:os') +const path = require('node:path') +process.env.NEW_RELIC_SUPERAGENT_FLEET_ID = 42 +process.env.NEW_RELIC_SUPERAGENT_HEALTH_DELIVERY_LOCATION = os.tmpdir() +process.env.NEW_RELIC_SUPERAGENT_HEALTH_FREQUENCY = 1 +const HealthReporter = require('../../../lib/health-reporter') +test.after(() => { + const files = fs.readdirSync(process.env.NEW_RELIC_SUPERAGENT_HEALTH_DELIVERY_LOCATION) + for (const file of files) { + if (file.startsWith('health-') !== true) { + continue + } + fs.rmSync(path.join(process.env.NEW_RELIC_SUPERAGENT_HEALTH_DELIVERY_LOCATION, file), { + force: true + }) + } + delete process.env.NEW_RELIC_SUPERAGENT_FLEET_ID + delete process.env.NEW_RELIC_SUPERAGENT_HEALTH_DELIVERY_LOCATION + delete process.env.NEW_RELIC_SUPERAGENT_HEALTH_FREQUENCY +}) + const RUN_ID = 1337 test('should require configuration passed to constructor', () => { assert.throws(() => new Agent()) }) +test('should update health reporter if configuration is bad', (t, end) => { + const setStatus = HealthReporter.prototype.setStatus + t.after(() => { + HealthReporter.prototype.setStatus = setStatus + }) + + HealthReporter.prototype.setStatus = (status) => { + assert.equal(status, HealthReporter.STATUS_CONFIG_PARSE_FAILURE) + end() + } + + try { + const _ = new Agent() + assert.ok(_, 'should not be hit, just satisfying linter') + } catch {} +}) + test('should not throw with valid config', () => { const config = configurator.initialize({ agent_enabled: false }) const agent = new Agent(config) @@ -310,6 +350,29 @@ test('when starting', async (t) => { }) }) + await t.test('should error when no license key is included, and update health', async (t) => { + const plan = tspl(t, { plan: 2 }) + const { agent } = t.nr + const setStatus = HealthReporter.prototype.setStatus + t.after(() => { + HealthReporter.prototype.setStatus = setStatus + }) + + HealthReporter.prototype.setStatus = (status) => { + plan.equal(status, HealthReporter.STATUS_LICENSE_KEY_MISSING) + } + + agent.config.license_key = undefined + agent.collector.connect = function () { + plan.fail('should not be called') + } + agent.start((error) => { + plan.equal(error.message, 'Not starting without license key!') + }) + + await plan.completed + }) + await t.test('should call connect when using proxy', (t, end) => { const { agent } = t.nr agent.config.proxy = 'fake://url' @@ -462,6 +525,26 @@ test('when stopping', async (t) => { assert.equal(sampler.state, 'stopped') }) + await t.test('should stop health reporter', async (t) => { + const plan = tspl(t, { plan: 1 }) + const setStatus = HealthReporter.prototype.setStatus + + t.after(() => { + HealthReporter.prototype.setStatus = setStatus + }) + + HealthReporter.prototype.setStatus = (status) => { + plan.equal(status, HealthReporter.STATUS_AGENT_SHUTDOWN) + } + + const { agent } = t.nr + sampler.start(agent) + agent.collector.shutdown = () => {} + agent.stop(() => {}) + + await plan.completed + }) + await t.test('should change state to "stopping"', (t) => { const { agent } = t.nr sampler.start(agent) diff --git a/test/unit/index.test.js b/test/unit/index.test.js index ca3a4c3199..99a4ecd3d6 100644 --- a/test/unit/index.test.js +++ b/test/unit/index.test.js @@ -7,9 +7,11 @@ const test = require('node:test') const assert = require('node:assert') +const tspl = require('@matteo.collina/tspl') const sinon = require('sinon') +const HealthReporter = require('../../lib/health-reporter') const proxyquire = require('proxyquire').noCallThru() const createLoggerMock = require('./mocks/logger') const createMockAgent = require('./mocks/agent') @@ -322,30 +324,52 @@ test('index tests', async (t) => { ) }) - await t.test('should throw error is app name is not set in config', (t) => { + await t.test('should throw error is app name is not set in config', async (t) => { + const plan = tspl(t, { plan: 3 }) + const setStatus = HealthReporter.prototype.setStatus + HealthReporter.prototype.setStatus = (status) => { + plan.equal(status, HealthReporter.STATUS_MISSING_APP_NAME) + } + t.after(() => { + HealthReporter.prototype.setStatus = setStatus + }) + t.nr.processVersionStub.satisfies.onCall(0).returns(true) t.nr.processVersionStub.satisfies.onCall(1).returns(false) t.nr.mockConfig.applications.returns([]) loadIndex(t) - assert.equal(t.nr.loggerMock.error.callCount, 1, 'should log an error') - assert.match( + plan.equal(t.nr.loggerMock.error.callCount, 1, 'should log an error') + plan.match( t.nr.loggerMock.error.args[0][0].message, /New Relic requires that you name this application!/ ) + + await plan.completed }) - await t.test('should log error if agent startup failed', (t) => { + await t.test('should log error if agent startup failed', async (t) => { + const plan = tspl(t, { plan: 3 }) + const setStatus = HealthReporter.prototype.setStatus + HealthReporter.prototype.setStatus = (status) => { + plan.equal(status, HealthReporter.STATUS_INTERNAL_UNEXPECTED_ERROR) + } + t.after(() => { + HealthReporter.prototype.setStatus = setStatus + }) + t.nr.processVersionStub.satisfies.onCall(0).returns(true) t.nr.processVersionStub.satisfies.onCall(1).returns(false) t.nr.mockConfig.applications.returns(['my-app-name']) const err = new Error('agent start failed') t.nr.MockAgent.prototype.start.yields(err) loadIndex(t) - assert.equal(t.nr.loggerMock.error.callCount, 1, 'should log a startup error') - assert.equal( + plan.equal(t.nr.loggerMock.error.callCount, 1, 'should log a startup error') + plan.equal( t.nr.loggerMock.error.args[0][1], 'New Relic for Node.js halted startup due to an error:' ) + + await plan.completed }) await t.test('should log warning if not in main thread and make a stub api', (t) => { diff --git a/test/unit/lib/health-reporter.test.js b/test/unit/lib/health-reporter.test.js new file mode 100644 index 0000000000..bc473d103f --- /dev/null +++ b/test/unit/lib/health-reporter.test.js @@ -0,0 +1,288 @@ +/* + * Copyright 2024 New Relic Corporation. All rights reserved. + * SPDX-License-Identifier: Apache-2.0 + */ + +'use strict' + +const test = require('node:test') +const assert = require('node:assert') +const os = require('node:os') +const fs = require('node:fs') +const tspl = require('@matteo.collina/tspl') + +const match = require('../../lib/custom-assertions/match') + +// TODO: testing this out. Current eslint config doesn't allow for it. If +// it doesn't cause issues, then I'll investigate how to fix the suppression. +// eslint-disable-next-line node/no-missing-require +const HealthReporter = require('#agentlib/health-reporter.js') + +function simpleInterval(method) { + method.call() + return { + unref() {} + } +} + +test.beforeEach((ctx) => { + ctx.nr = {} + ctx.nr.writeFileOrig = fs.writeFile + ctx.nr.bigintOrig = process.hrtime.bigint + + let count = 0n + process.hrtime.bigint = () => { + count += 1n + return count + } + + const logs = { + info: [], + debug: [], + error: [], + warn: [] + } + ctx.nr.logs = logs + ctx.nr.logger = { + info(...args) { + logs.info.push(args) + }, + debug(...args) { + logs.debug.push(args) + }, + error(...args) { + logs.error.push(args) + }, + warn(...args) { + logs.warn.push(args) + } + } + + process.env.NEW_RELIC_SUPERAGENT_FLEET_ID = 42 + process.env.NEW_RELIC_SUPERAGENT_HEALTH_DELIVERY_LOCATION = os.tmpdir() + process.env.NEW_RELIC_SUPERAGENT_HEALTH_FREQUENCY = 1 +}) + +test.afterEach((ctx) => { + fs.writeFile = ctx.nr.writeFileOrig + process.hrtime.bigint = ctx.nr.bigintOrig + delete process.env.NEW_RELIC_SUPERAGENT_FLEET_ID + delete process.env.NEW_RELIC_SUPERAGENT_HEALTH_DELIVERY_LOCATION + delete process.env.NEW_RELIC_SUPERAGENT_HEALTH_FREQUENCY +}) + +test('requires fleet id to be set', (t) => { + delete process.env.NEW_RELIC_SUPERAGENT_FLEET_ID + + const reporter = new HealthReporter(t.nr) + assert.ok(reporter) + + const { + logs: { info } + } = t.nr + assert.deepStrictEqual(info, [['new relic control not present, skipping health reporting']]) +}) + +test('requires output directory to be set', (t) => { + delete process.env.NEW_RELIC_SUPERAGENT_HEALTH_DELIVERY_LOCATION + + const reporter = new HealthReporter(t.nr) + assert.ok(reporter) + + const { + logs: { info, error } + } = t.nr + assert.equal(info.length, 0, 'should not log any info messages') + assert.deepStrictEqual(error, [ + ['health check output directory not provided, skipping health reporting'] + ]) +}) + +test('sets default interval', (t) => { + delete process.env.NEW_RELIC_SUPERAGENT_HEALTH_FREQUENCY + + const reporter = new HealthReporter(t.nr) + assert.ok(reporter) + + const { + logs: { info, error, debug } + } = t.nr + match(info, [ + [/new relic control is present, writing health on interval 5000 milliseconds to .+/], + ['health reporter initialized'] + ]) + assert.equal(error.length, 0, 'should not log any errors') + assert.deepStrictEqual(debug, [['health check interval not available, using default 5 seconds']]) +}) + +test('initializes and writes to destination', async (t) => { + const plan = tspl(t, { plan: 8 }) + fs.writeFile = (dest, data, options, callback) => { + plan.match(dest, /health-\w{32}\.yaml/) + plan.equal( + data, + [ + 'healthy: true', + `status: 'Healthy.'`, + 'last_error: NR-APM-000', + 'start_time_unix_nano: 1', + 'status_time_unix_nano: 2' + ].join('\n') + ) + plan.deepStrictEqual(options, { encoding: 'utf8' }) + callback() + plan.equal(t.nr.logs.error.length, 0, 'callback should not write error log') + } + + const reporter = new HealthReporter({ ...t.nr, setInterval: localInterval }) + plan.ok(reporter) + + await plan.completed + + function localInterval(method, delay) { + plan.equal(delay, 1_000) + plan.equal(method.name, 'bound #healthCheck') + method.call() + return { + unref() { + plan.ok('invoked unref') + } + } + } +}) + +test('logs error if writing failed', async (t) => { + const plan = tspl(t, { plan: 3 }) + fs.writeFile = (dest, data, options, callback) => { + callback(Error('boom')) + plan.deepStrictEqual(t.nr.logs.error, [['error when writing out health status: boom']]) + } + + const reporter = new HealthReporter({ ...t.nr, setInterval: localInterval }) + plan.ok(reporter) + + await plan.completed + + function localInterval(method) { + method.call() + return { + unref() { + plan.ok('invoked unref') + } + } + } +}) + +test('setStatus and stop do nothing if reporter disabled', (t, end) => { + delete process.env.NEW_RELIC_SUPERAGENT_FLEET_ID + fs.writeFile = () => { + assert.fail('should not be invoked') + } + const reporter = new HealthReporter(t.nr) + reporter.setStatus(HealthReporter.STATUS_AGENT_SHUTDOWN) + reporter.stop(() => { + assert.ok('stopped') + end() + }) +}) + +test('setStatus warns for bad code', (t) => { + const reporter = new HealthReporter(t.nr) + reporter.setStatus('bad-code') + assert.deepStrictEqual(t.nr.logs.warn, [['invalid health reporter status provided: bad-code']]) +}) + +test('setStatus logs info message if shutdown and not healthy', (t) => { + const reporter = new HealthReporter(t.nr) + reporter.setStatus(HealthReporter.STATUS_BACKEND_ERROR) + reporter.setStatus(HealthReporter.STATUS_AGENT_SHUTDOWN) + assert.deepStrictEqual(t.nr.logs.info.pop(), [ + 'not setting shutdown health status due to current status code: NR-APM-004' + ]) +}) + +test('stop leaves last error code in place', async (t) => { + const plan = tspl(t, { plan: 3 }) + let invocation = 0 + fs.writeFile = (dest, data, options, callback) => { + if (invocation === 0) { + invocation += 1 + return callback() + } + + plan.equal( + data, + [ + 'healthy: false', + `status: 'HTTP error communicating with New Relic.'`, + 'last_error: NR-APM-004', + 'start_time_unix_nano: 1', + 'status_time_unix_nano: 3' + ].join('\n') + ) + callback() + } + + const reporter = new HealthReporter({ ...t.nr, setInterval: simpleInterval }) + reporter.setStatus(HealthReporter.STATUS_BACKEND_ERROR) + reporter.stop(() => { + plan.deepStrictEqual(t.nr.logs.error, []) + }) + plan.ok(reporter) + + await plan.completed +}) + +test('stop sets shutdown status', async (t) => { + const plan = tspl(t, { plan: 3 }) + let invocation = 0 + fs.writeFile = (dest, data, options, callback) => { + if (invocation === 0) { + invocation += 1 + return callback() + } + + plan.equal( + data, + [ + 'healthy: true', + `status: 'Agent has shutdown.'`, + 'last_error: NR-APM-099', + 'start_time_unix_nano: 1', + 'status_time_unix_nano: 3' + ].join('\n') + ) + callback() + } + + const reporter = new HealthReporter({ ...t.nr, setInterval: simpleInterval }) + reporter.stop(() => { + plan.deepStrictEqual(t.nr.logs.error, []) + }) + plan.ok(reporter) + + await plan.completed +}) + +test('stop logs writing error', async (t) => { + const plan = tspl(t, { plan: 2 }) + let invocation = 0 + fs.writeFile = (dest, data, options, callback) => { + if (invocation === 0) { + invocation += 1 + return callback() + } + + callback(Error('boom')) + } + + const reporter = new HealthReporter({ ...t.nr, setInterval: simpleInterval }) + reporter.stop(() => { + plan.deepStrictEqual(t.nr.logs.error, [ + ['error when writing out health status during shutdown: boom'] + ]) + }) + plan.ok(reporter) + + await plan.completed +}) diff --git a/test/unit/mocks/agent.js b/test/unit/mocks/agent.js index 31100d18a2..4bb39d447c 100644 --- a/test/unit/mocks/agent.js +++ b/test/unit/mocks/agent.js @@ -4,9 +4,11 @@ */ 'use strict' + const { EventEmitter } = require('events') const util = require('util') const sinon = require('sinon') +const HealthReporter = require('../../../lib/health-reporter') module.exports = (sandbox = sinon, metricsMock) => { function MockAgent(config = {}) { @@ -14,6 +16,7 @@ module.exports = (sandbox = sinon, metricsMock) => { this.config = config this.config.app_name = 'Unit Test App' this.metrics = metricsMock + this.healthReporter = new HealthReporter() } MockAgent.prototype.start = sandbox.stub() MockAgent.prototype.recordSupportability = sandbox.stub()