UNPKG

custom-metrics

Version:
972 lines (971 loc) 35.9 kB
"use strict"; var __importDefault = (this && this.__importDefault) || function (mod) { return (mod && mod.__esModule) ? mod : { "default": mod }; }; Object.defineProperty(exports, "__esModule", { value: true }); exports.CustomMetrics = exports.DefaultSpans = void 0; const process_1 = __importDefault(require("process")); const client_dynamodb_1 = require("@aws-sdk/client-dynamodb"); const util_dynamodb_1 = require("@aws-sdk/util-dynamodb"); const Version = 1; const Assert = true; const Buffering = true; const DefaultResolution = 0; const MaxSeq = Number.MAX_SAFE_INTEGER; const MaxRetries = 10; const MetricListLimit = 10000; exports.DefaultSpans = [ { period: 5 * 60, samples: 10 }, { period: 60 * 60, samples: 12 }, { period: 24 * 60 * 60, samples: 12 }, { period: 7 * 24 * 60 * 60, samples: 14 }, { period: 28 * 24 * 60 * 60, samples: 14 }, { period: 365 * 24 * 60 * 60, samples: 12 }, ]; var Instances = {}; process_1.default.on('SIGTERM', async () => { await CustomMetrics.terminate(); }); class CustomMetrics { constructor(options = {}) { this.consistent = false; this.buffers = null; this.prefix = 'metric'; this.log = new Log(options.log); if (options.ttl && typeof options.ttl != 'number') { throw new Error('Bad type for "ttl" option'); } if (options.spans && (!Array.isArray(options.spans) || options.spans.length == 0)) { throw new Error('The "spans" option must be an non-empty array'); } if (options.source && typeof options.source != 'string') { throw new Error('Non-string "source" option'); } if (options.pResolution != undefined && (options.pResolution < 0 || options.pResolution > 1000)) { throw new Error('Invalid "pResolution" option. Must be between 0 and 1000. Default is 0'); } if (options.consistent != null && typeof options.consistent != 'boolean') { throw new Error('Bad type for "consistent" option'); } if (options.prefix) { this.prefix = options.prefix; } if (options.buffer) { if (typeof options.buffer != 'object') { throw new Error('Bad type for "buffer" option'); } this.buffer = options.buffer; } this.expires = options.expires || 'expires'; this.primaryKey = options.primaryKey || 'pk'; this.sortKey = options.sortKey || 'sk'; this.type = options.type || { _type: 'Metric' }; if (options.client) { this.client = options.client; } else { let params = {}; if (options.creds) { params.credentials = options.creds; params.region = params.credentials.region; } if (options.region) { params.region = options.region; } this.client = new client_dynamodb_1.DynamoDBClient(params); } if (!options.table) { throw new Error('Missing DynamoDB table name property'); } this.table = options.table; this.options = options; this.owner = options.owner || 'default'; this.spans = options.spans || exports.DefaultSpans; this.ttl = options.ttl || this.spans[this.spans.length - 1].period; if (options.consistent != null) { this.consistent = options.consistent; } if (options.source) { this.source = options.source; } this.pResolution = options.pResolution || DefaultResolution; } async emit(namespace, metricName, value, dimensionsList = [{}], options = {}) { if (value == undefined || value == null) { throw new Error('Invalid metric value'); } if (dimensionsList.length == 0) { dimensionsList = [{}]; } value = Number(value); if (isNaN(value)) { throw new Error(`Value to emit is not valid`); } if (!namespace || !metricName) { throw new Error('Missing emit namespace / metric argument'); } if (!Array.isArray(dimensionsList)) { throw new Error('Dimensions must be an array'); } if (dimensionsList.length == 0) { dimensionsList = [{}]; } let point; point = { count: 1, sum: value }; return await this.emitDimensions(namespace, metricName, point, dimensionsList, options); } async emitDimensions(namespace, metricName, point, dimensionsList, options) { let result; for (let dim of dimensionsList) { let dimensions = this.makeDimensionString(dim); let buffer = options.buffer || this.buffer; if (buffer && (buffer.elapsed || buffer.force || buffer.sum || buffer.count) && Buffering) { result = await this.bufferMetric(namespace, metricName, point, dimensions, options); } else { result = await this.emitDimensionedMetric(namespace, metricName, point, dimensions, options); } } return result; } async bufferMetric(namespace, metricName, point, dimensions, options) { let buffer = options.buffer || this.buffer; let key = this.getBufferKey(namespace, metricName, dimensions); let buffers = (this.buffers = this.buffers || {}); let timestamp = Math.floor((options.timestamp || Date.now()) / 1000); let elapsed = buffer.elapsed || this.spans[0].period / this.spans[0].samples; let elt = (buffers[key] = buffers[key] || { count: 0, sum: 0, timestamp: timestamp + elapsed, elapsed: elapsed, namespace: namespace, metric: metricName, dimensions, spans: [{ points: [{ count: 0, sum: 0 }] }], }); let current = elt.spans[0].points.at(-1); if (current) { current.count += point.count; current.sum += point.sum; } elt.count += point.count; elt.sum += point.sum; if (buffer.force || (buffer.sum && elt.sum >= buffer.sum) || (buffer.count && elt.count >= buffer.count) || timestamp >= elt.timestamp) { options = Object.assign({}, options, { timestamp: timestamp * 1000 }); let metric = await this.emitDimensionedMetric(namespace, metricName, elt, dimensions, options); elt.count = elt.sum = 0; elt.spans = metric.spans; elt.timestamp = timestamp + (buffer.elapsed || this.spans[0].period / this.spans[0].samples); return metric; } CustomMetrics.saveInstance({ key }, this); return { spans: elt.spans, metric: metricName, namespace: namespace, owner: options.owner || this.owner, version: Version, }; } async emitDimensionedMetric(namespace, metricName, point, dimensions, options = {}) { let timestamp = Math.floor((options.timestamp || Date.now()) / 1000); let ttl = options.ttl != undefined ? options.ttl : this.ttl; let retries = MaxRetries; let metric; let backoff = 10; let chan = options.log == true ? 'info' : 'trace'; do { let owner = options.owner || this.owner; metric = await this.getMetric(owner, namespace, metricName, dimensions, options.log); if (metric) { if (options.upgrade) { metric = this.upgradeMetric(metric); } } else { metric = this.initMetric(owner, namespace, metricName, dimensions, timestamp); } if (point.timestamp) { let si = metric.spans.findIndex((s) => s.end - s.period <= point.timestamp || s.end <= point.timestamp); if (si >= 0) { this.addValue(metric, point.timestamp, point, si); } else { } } else { this.addValue(metric, timestamp, point, 0); } if (this.source) { metric._source = this.source; } if (ttl) { metric.expires = timestamp + ttl; } if (await this.putMetric(metric, options)) { break; } if (retries == 0) { this.log.error(`Metric update has too many retries`, { namespace, metricName, dimensions }); break; } this.log[chan](`Retry ${MaxRetries - retries} metric update ${metric.namespace} ${metric.metric} ${metric.dimensions}`, { retries, metric, }); backoff = backoff * 2; this.log[chan](`Retry backoff ${backoff} ${this.jitter(backoff)}`); await this.delay(this.jitter(backoff)); } while (retries-- > 0); return metric; } async upgrade(namespace, metricName, dimensionsList = [{}], options = {}) { let owner = options.owner || this.owner; if (dimensionsList.length == 0) { dimensionsList = [{}]; } let metric; for (let dim of dimensionsList) { let dimensions = this.makeDimensionString(dim); let old = await this.getMetric(owner, namespace, metricName, dimensions, options.log); metric = this.upgradeMetric(old); await this.putMetric(metric, options); } return metric; } upgradeMetric(old) { let required = false; if (this.spans.length == old.spans.length) { for (let [index, span] of Object.entries(old.spans)) { if (span.period != this.spans[index].period || span.samples != this.spans[index].samples) { required = true; } } if (!required) { return old; } } let timestamp = old.spans[0].end || Math.floor(Date.now() / 1000); let metric = this.initMetric(old.owner, old.namespace, old.metric, old.dimensions, timestamp); for (let span of old.spans) { let interval = span.period / span.samples; let timestamp = span.end - (span.points.length * interval); let si = metric.spans.findIndex((s) => s.end - s.period <= timestamp || s.end <= timestamp); for (let point of span.points) { this.addValue(metric, timestamp, point, si); timestamp += interval; } } return metric; } static async terminate() { await CustomMetrics.flushAll(); } static async flushAll() { for (let [key, instance] of Object.entries(Instances)) { await instance.flush(); CustomMetrics.freeInstanceByKey(key); } Instances = {}; } async flush() { if (!this.buffers) return; for (let elt of Object.values(this.buffers)) { await this.flushElt(elt); } } async flushElt(elt) { let now = Date.now() / 1000; let timestamp = now; if (timestamp > elt.timestamp) { timestamp = elt.timestamp; } let metric = await this.emitDimensionedMetric(elt.namespace, elt.metric, elt, elt.dimensions, { timestamp: timestamp * 1000, }); elt.count = elt.sum = 0; elt.spans = metric.spans; elt.timestamp = now + (elt.elapsed || this.spans[0].period / this.spans[0].samples); } getBufferKey(namespace, metricName, dimensions) { return `${namespace}|${metricName}|${JSON.stringify(dimensions)}`; } async query(namespace, metricName, dimensions, period, statistic, options = {}) { let owner = options.owner || this.owner; let timestamp = Math.floor((options.timestamp || Date.now()) / 1000); let dimString = this.makeDimensionString(dimensions); if (this.buffers) { let key = this.getBufferKey(namespace, metricName, dimString); if (this.buffers[key]) { await this.flushElt(this.buffers[key]); } } let metric = await this.getMetric(owner, namespace, metricName, dimString, options.log); if (!metric) { return { dimensions, id: options.id, metric: metricName, namespace, period, points: [], owner, samples: 0 }; } let span; let start = options.start; if (start) { start /= 1000; span = metric.spans.find((s) => s.end - s.period <= start); if (!span) { span = metric.spans[metric.spans.length - 1]; period = span.period; } } else { span = metric.spans.find((s) => period <= s.period); if (!span) { span = metric.spans[metric.spans.length - 1]; period = span.period; } } this.addValue(metric, timestamp, { count: 0, sum: 0 }, 0, period); let result; if (metric && span) { if (!start) { start = span.end - period; } let interval = span.period / span.samples; let count = Math.ceil(period / interval); let index = span.points.length - (span.end - start) / interval; if (index < 0) { index = 0; } span.points = span.points.slice(index, index + count); if (options.accumulate) { result = this.accumulateMetric(metric, span, statistic, owner, timestamp); } else { result = this.calculateSeries(metric, span, statistic, owner, timestamp); } } else { result = { dimensions, metric: metricName, namespace, period, points: [], owner, samples: span.samples }; } result.id = options.id; this.log[options.log == true ? 'info' : 'trace'](`Query metrics ${namespace}, ${metricName}`, { dimensions, period, statistic, options, result, }); return result; } accumulateMetric(metric, span, statistic, owner, timestamp) { let value = 0, count = 0, pvalues = []; if (statistic == 'max') { value = Number.NEGATIVE_INFINITY; } else if (statistic == 'min') { value = Infinity; } else if (statistic == 'sum') { value = 0; count = 0; } else if (statistic == 'count') { value = 0; count = 0; } else if (statistic == 'current') { value = 0; count = 0; } else if (statistic.match(/^p[0-9]+/)) { pvalues = []; } else { value = 0; count = 0; } let points = span.points; for (let i = 0; i < points.length; i++) { let point = points[i]; if (statistic == 'max') { if (point.max != undefined) { value = Math.max(value, point.max); } else { value = Math.max(value, point.sum / (point.count || 1)); } } else if (statistic == 'min') { if (point.min != undefined) { value = Math.min(value, point.min); } else { value = Math.min(value, point.sum / (point.count || 1)); } } else if (statistic == 'sum') { value += point.sum; } else if (statistic == 'current') { value = point.sum / (point.count || 1); } else if (statistic == 'count') { value += point.count; } else if (statistic.match(/^p[0-9]+/)) { pvalues = pvalues.concat(point.pvalues); } else { value += point.sum; } count += point.count; } if (statistic.match(/^p[0-9]+/)) { let p = parseInt(statistic.slice(1)); pvalues.sort((a, b) => a - b); let nth = Math.min(Math.round((pvalues.length * p) / 100 + 1), pvalues.length - 1); value = pvalues[nth]; } else if (statistic == 'avg') { value /= Math.max(count, 1); } return { dimensions: this.makeDimensionObject(metric.dimensions), metric: metric.metric, namespace: metric.namespace, owner: owner, period: span.period, points: [{ value, timestamp, count }], samples: span.samples, }; } calculateSeries(metric, span, statistic, owner, timestamp) { let points = []; let interval = span.period / span.samples; let start = span.end - span.points.length * interval; let value = undefined; let i = 0; for (let point of span.points) { if (point.count > 0) { if (statistic == 'max') { if (point.max != undefined) { if (value == undefined) { value = point.max; } else { value = Math.max(value, point.max); } } } else if (statistic == 'min') { if (point.min != undefined) { if (value == undefined) { value = point.min; } else { value = Math.min(value, point.min); } } } else if (statistic == 'sum') { value = point.sum; } else if (statistic == 'count') { value = point.count; } else if (statistic.match(/^p[0-9]+/)) { let p = parseInt(statistic.slice(1)); let pvalues = point.pvalues; pvalues.sort((a, b) => a - b); let nth = Math.min(Math.round((pvalues.length * p) / 100 + 1), pvalues.length - 1); value = pvalues[nth]; } else { value = point.sum / point.count; } } else { value = 0; } start += interval; start = Math.min(start, timestamp); points.push({ value, count: point.count, timestamp: start * 1000 }); i++; } return { dimensions: this.makeDimensionObject(metric.dimensions), metric: metric.metric, namespace: metric.namespace, period: span.period, points: points, owner: owner, samples: span.samples, }; } makeDimensionString(dimensions) { let result = []; let entries = Object.entries(dimensions).sort((a, b) => a[0].localeCompare(b[0])); for (let [name, value] of entries) { result.push(`${name}=${value}`); } return result.join(','); } makeDimensionObject(dimensions) { let result = {}; for (let dimension of dimensions.split(',')) { if (dimension) { let [key, value] = dimension.split('='); result[key] = value; } } return result; } addValue(metric, timestamp, point, si, queryPeriod = 0) { this.assert(metric); this.assert(timestamp); this.assert(0 <= si && si < metric.spans.length); let span = metric.spans[si]; let interval = span.period / span.samples; let points = span.points || []; let queryRecurse = queryPeriod && span.period < queryPeriod && si + 1 < metric.spans.length; while (points.length > span.samples) { points.shift(); } let start = span.end - points.length * interval; let shift = 0; if (points.length) { if (queryRecurse) { shift = points.length; } else if (timestamp >= start) { shift = Math.floor((timestamp - start) / interval) - span.samples; if (!queryRecurse && point.count && timestamp >= span.end) { shift += 1; } } shift = Math.max(0, Math.min(shift, points.length)); this.assert(0 <= shift && shift <= points.length); for (let i = 0; i < shift; i++) { let p = points.shift(); if (p.count && si + 1 < metric.spans.length) { this.addValue(metric, start, p, si + 1, queryPeriod); } start += interval; } } if (queryRecurse) { this.addValue(metric, timestamp, point, si + 1, queryPeriod); return; } if (point.count) { if (points.length == 0) { start = span.end = this.getTimestamp(span, timestamp); } if (timestamp < span.end - span.period) { return; } while (timestamp < start) { points.unshift({ count: 0, sum: 0 }); start -= interval; } while (timestamp >= span.end) { points.push({ count: 0, sum: 0 }); span.end += interval; } let index = Math.floor((timestamp - start) / interval); this.assert(points.length <= span.samples); if (!(0 <= index && index < points.length)) { this.assert(0 <= index && index < points.length); if (index > 0) { index = points.length - 1; } } this.setPoint(span, index, point); } } setPoint(span, index, add) { let points = span.points; this.assert(0 <= index && index < points.length); let point = points[index]; if (!point) { this.log.error(`Metric null point`, { span, index, add }); return; } if (add.count) { let value = add.sum / add.count; if (point.min == undefined) { point.min = value; } else { point.min = Math.min(value, point.min); } if (point.max == undefined) { point.max = value; } else { point.max = Math.max(value, point.max); } } if (this.pResolution) { point.pvalues = point.pvalues || []; if (add.pvalues) { point.pvalues.push(...add.pvalues); } else { point.pvalues.push(add.sum / add.count); } point.pvalues.splice(0, point.pvalues.length - this.pResolution); } point.sum += add.sum; point.count += add.count; } async getMetricList(namespace = undefined, metric = undefined, options = { limit: MetricListLimit }) { let map = {}; let owner = options.owner || this.owner; let next = options.next; let limit = options.limit || MetricListLimit; let chan = options.log == true ? 'info' : 'trace'; let items, command; let count = 0; do { ; ({ command, items, next } = await this.findMetrics(owner, namespace, metric, limit, next)); this.log[chan](`Find metrics ${namespace}, ${metric}`, { command, items }); if (items.length) { for (let item of items) { let ns = (map[item.namespace] = map[item.namespace] || {}); let met = (ns[item.metric] = ns[item.metric] || []); met.push(item.dimensions); } count += items.length; } } while (next && count < limit); let result = { namespaces: Object.keys(map) }; if (namespace && map[namespace]) { result.metrics = Object.keys(map[namespace]); if (metric) { let dimensions = map[namespace][metric]; if (dimensions) { result.dimensions = []; dimensions = dimensions.sort().filter((v, index, self) => self.indexOf(v) === index); for (let dimension of dimensions) { result.dimensions.push(this.makeDimensionObject(dimension)); } } } } return result; } initMetric(owner, namespace, name, dimensions, timestamp) { let metric = { dimensions, metric: name, namespace, owner, spans: [], version: Version, }; for (let sdef of this.spans) { let span = { samples: sdef.samples, period: sdef.period, end: timestamp, points: [], }; span.end = this.getTimestamp(span, timestamp); metric.spans.push(span); } return metric; } async getMetric(owner, namespace, metric, dimensions, log) { let command = new client_dynamodb_1.GetItemCommand({ TableName: this.table, Key: { [this.primaryKey]: { S: `${this.prefix}#${Version}#${owner}` }, [this.sortKey]: { S: `${this.prefix}#${namespace}#${metric}#${dimensions}` }, }, ConsistentRead: this.consistent, }); let data = await this.client.send(command); let result = null; if (data && data.Item) { let item = (0, util_dynamodb_1.unmarshall)(data.Item); result = this.mapItemFromDB(item); } if (log == true) { let chan = log == true ? 'info' : 'trace'; this.log[chan](`GetMetric ${namespace}, ${metric} ${dimensions}`, { cmd: command, result }); } return result; } async findMetrics(owner, namespace, metric, limit, startKey) { let key = [namespace]; if (metric) { key.push(metric); } let start = startKey ? (0, util_dynamodb_1.marshall)(startKey) : undefined; let command = new client_dynamodb_1.QueryCommand({ TableName: this.table, ExpressionAttributeNames: { '#_0': this.primaryKey, '#_1': this.sortKey, }, ExpressionAttributeValues: { ':_0': { S: `${this.prefix}#${Version}#${owner}` }, ':_1': { S: `${this.prefix}#${key.join('#')}` }, }, KeyConditionExpression: '#_0 = :_0 and begins_with(#_1, :_1)', ConsistentRead: this.consistent, Limit: limit, ScanIndexForward: true, ExclusiveStartKey: start, ProjectionExpression: `${this.primaryKey}, ${this.sortKey}`, }); let result = await this.client.send(command); let items = []; if (result.Items) { for (let i = 0; i < result.Items.length; i++) { let item = (0, util_dynamodb_1.unmarshall)(result.Items[i]); items.push(this.mapItemFromDB(item)); } } let next = undefined; if (result.LastEvaluatedKey) { next = (0, util_dynamodb_1.unmarshall)(result.LastEvaluatedKey); } return { items, next, command }; } async putMetric(item, options) { let ConditionExpression, ExpressionAttributeValues; let seq; if (item.seq != undefined) { seq = item.seq = item.seq || 0; if (item.seq++ >= MaxSeq) { item.seq = 0; } ConditionExpression = `seq = :_0`; ExpressionAttributeValues = { ':_0': { N: seq.toString() } }; } else { item.seq = 0; } let mapped = this.mapItemToDB(item); let params = { TableName: this.table, ReturnValues: 'NONE', Item: (0, util_dynamodb_1.marshall)(mapped, { removeUndefinedValues: true }), ConditionExpression, ExpressionAttributeValues, }; let command = new client_dynamodb_1.PutItemCommand(params); let chan = options.log == true ? 'info' : 'trace'; this.log[chan](`Put metric ${item.namespace}, ${item.metric}`, { dimensions: item.dimensions, command, params, item, }); try { await this.client.send(command); return true; } catch (err) { ; (function (err, log) { let code = err.code || err.name; if (code == 'ConditionalCheckFailedException') { log.trace(`Update collision`, { err }); } else if (code == 'ProvisionedThroughputExceededException') { log.info(`Provisioned throughput exceeded: ${err.message}`, { err, cmd: command, item }); } else { log.error(`Emit exception code ${err.name} ${err.code} message ${err.message}`, { err, cmd: command, item, }); throw err; } return false; })(err, this.log); } } mapItemFromDB(data) { let pk = data[this.primaryKey]; let sk = data[this.sortKey]; let owner = pk.split('#').pop(); let [, namespace, metric, dimensions] = sk.split('#'); let spans; if (data.spans) { spans = data.spans.map((s) => { return { end: s.se, period: s.sp, samples: s.ss, points: s.pt.map((p) => { let point = { count: Number(p.c), sum: Number(p.s) }; if (p.x != null) { point.max = Number(p.x); } if (p.m != null) { point.min = Number(p.m); } if (p.v) { point.pvalues = p.v; } return point; }), }; }); } let expires = data[this.expires]; let seq = data.seq; return { dimensions, expires, metric, namespace, owner, seq, spans }; } mapItemToDB(item) { let result = { [this.primaryKey]: `${this.prefix}#${Version}#${item.owner}`, [this.sortKey]: `${this.prefix}#${item.namespace}#${item.metric}#${item.dimensions}`, [this.expires]: item.expires, spans: item.spans.map((i) => { return { se: i.end, sp: i.period, ss: i.samples, pt: i.points.map((point) => { let p = { c: point.count, s: this.round(point.sum) }; if (point.max != null) { p.x = this.round(point.max); } if (point.min != null) { p.m = this.round(point.min); } if (point.pvalues) { p.v = point.pvalues; } return p; }), }; }), seq: item.seq, _source: item._source, }; if (this.type) { let [key, model] = Object.entries(this.type)[0]; result[key] = model; } return result; } static freeInstanceByKey(key) { delete Instances[key]; } static saveInstance(tags, metrics) { let key = JSON.stringify(tags); Instances[key] = metrics; } getTimestamp(span, timestamp) { let interval = span.period / span.samples; return Math.ceil(timestamp / interval) * interval; } assert(c) { if (!c && Assert) { let msg = { stack: '' }; if (typeof Error.captureStackTrace === 'function') { Error.captureStackTrace(msg); } else { msg.stack = new Error('Assert').stack; } this.log.error(`Assertion failed`, { stack: msg.stack }); } } info(message, context = {}) { console.log('INFO: ' + message, context); } error(message, context = {}) { console.log('ERROR: ' + message, context); } trace(message, context = {}) { console.log('TRACE: ' + message, context); } round(n) { if (isNaN(n) || n == null) { return 0; } let places = 16 - n.toFixed(0).length; return Number(n.toFixed(places)) - 0; } jitter(msecs) { return Math.min(10 * 1000, Math.floor(msecs / 2 + msecs * Math.random())); } async delay(time) { return new Promise(function (resolve, reject) { setTimeout(() => resolve(true), time); }); } } exports.CustomMetrics = CustomMetrics; class Log { constructor(dest) { this.senselogs = null; this.logger = null; this.verbose = false; if (dest === true) { this.logger = this.defaultLogger; } else if (dest == 'verbose') { this.logger = this.defaultLogger; this.verbose = true; } else if (dest && typeof dest.info == 'function') { this.senselogs = dest; } } error(message, context) { this.process('error', message, context); } info(message, context) { this.process('info', message, context); } trace(message, context) { this.process('trace', message, context); } process(chan, message, context) { if (this.logger) { this.logger(chan, message, context); } else if (this.senselogs) { this.senselogs[chan](message, context); } } defaultLogger(chan, message, context) { if (chan == 'trace' && !this.verbose) { return; } let tag = chan.toUpperCase(); if (context) { try { console.log(tag, message, JSON.stringify(context, null, 4)); } catch (err) { let buf = ['{']; for (let [key, value] of Object.entries(context)) { try { buf.push(` ${key}: ${JSON.stringify(value, null, 4)}`); } catch (err) { } } buf.push('}'); console.log(tag, message, buf.join('\n')); } } else { console.log(tag, message); } } }