@redthreadlabs/tracelog 1.6.0 → 1.8.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/lib/agent.js CHANGED
@@ -640,17 +640,25 @@ Agent.prototype.writeError = function (err, opts, cb) {
640
640
  // for a response that hasn't yet completed (no headers, unset status_code,
641
641
  // etc.).
642
642
  setImmediate(() => {
643
- // Gather `error.context.*`.
644
- const errorContext = {
645
- user: Object.assign(
646
- {},
647
- req && parsers.getUserContextFromRequest(req),
648
- trans && trans._user,
649
- opts.user,
650
- ),
651
- tags: Object.assign({}, trans && trans._labels, opts.tags, opts.labels),
652
- custom: Object.assign({}, trans && trans._custom, opts.custom),
653
- };
643
+ // Gather `error.context.*`. Members without content are omitted
644
+ // entirely rather than serialized as empty `{}` placeholders.
645
+ const errorContext = {};
646
+ const errorUser = Object.assign(
647
+ {},
648
+ req && parsers.getUserContextFromRequest(req),
649
+ trans && trans._user,
650
+ opts.user,
651
+ );
652
+ if (Object.keys(errorUser).length > 0) errorContext.user = errorUser;
653
+ const errorTags = Object.assign(
654
+ {},
655
+ trans && trans._labels,
656
+ opts.tags,
657
+ opts.labels,
658
+ );
659
+ if (Object.keys(errorTags).length > 0) errorContext.tags = errorTags;
660
+ const errorCustom = Object.assign({}, trans && trans._custom, opts.custom);
661
+ if (Object.keys(errorCustom).length > 0) errorContext.custom = errorCustom;
654
662
  if (req) {
655
663
  errorContext.request = parsers.getContextFromRequest(
656
664
  req,
@@ -733,10 +741,11 @@ Agent.prototype.writeError = function (err, opts, cb) {
733
741
  // - `message` {string} - Human-readable description.
734
742
  // - `level` {string} - Severity: 'debug', 'info', 'warn', 'error', 'fatal'.
735
743
  // - `timestamp` {number} - Unix epoch ms. Defaults to Date.now().
744
+ // (Serialized as epoch-µs, like every other record kind.)
736
745
  // - `duration` {number} - Duration in ms.
737
- // - `error` {Error|any} - Error object. Safely extracts message, type, code,
738
- // and stack trace into the event record, and also emits a full error record
739
- // via captureError.
746
+ // - `error` {Error|string|Object} - Error to attach. Safely extracts
747
+ // message, type, code, and stack into `event.error`, including from
748
+ // error-like plain objects.
740
749
  // - `user` {Object} - End-user identity: { id, email, username }.
741
750
  // - `client` {Object} - Client environment: { name, version, os, device, runtime }.
742
751
  // - `params` {Object} - Arbitrary key-value data.
@@ -764,15 +773,19 @@ Agent.prototype.writeEvent = function (type, opts, cb) {
764
773
  }
765
774
 
766
775
  if (this._apmClient) {
767
- const event = this._eventFilters.process(_buildEvent(type, opts));
768
- if (event) {
769
- this._apmClient.sendEvent(event);
776
+ const event = _buildEvent(type, opts);
777
+ // Correlate with the active trace, when there is one. Batch writes
778
+ // (writeEvents) intentionally do not do this: their events originate
779
+ // on remote clients, not in the transaction that relayed them.
780
+ const trans = this.currentTransaction;
781
+ if (trans) {
782
+ event.trace_id = trans.traceId;
783
+ event.transaction_id = trans.id;
784
+ }
785
+ const filtered = this._eventFilters.process(event);
786
+ if (filtered) {
787
+ this._apmClient.sendEvent(filtered);
770
788
  }
771
- }
772
-
773
- // If an error was provided, also emit a full error record
774
- if (opts.error != null) {
775
- this.writeError(opts.error);
776
789
  }
777
790
 
778
791
  if (cb) {
@@ -881,13 +894,16 @@ Channel.prototype.writeEvent = function (type, opts, cb) {
881
894
  return;
882
895
  }
883
896
 
884
- const event = this._agent._eventFilters.process(_buildEvent(type, opts));
885
- if (event) {
886
- this._agent._apmClient.sendToChannel(this._name, 'event', event);
897
+ const event = _buildEvent(type, opts);
898
+ // Correlate with the active trace, when there is one (see writeEvent).
899
+ const trans = this._agent.currentTransaction;
900
+ if (trans) {
901
+ event.trace_id = trans.traceId;
902
+ event.transaction_id = trans.id;
887
903
  }
888
-
889
- if (opts.error != null) {
890
- this._agent.writeError(opts.error);
904
+ const filtered = this._agent._eventFilters.process(event);
905
+ if (filtered) {
906
+ this._agent._apmClient.sendToChannel(this._name, 'event', filtered);
891
907
  }
892
908
 
893
909
  if (cb) process.nextTick(cb);
@@ -1054,31 +1070,78 @@ function _sanitizeContext(ctx) {
1054
1070
  function _buildEvent(type, opts) {
1055
1071
  const event = {
1056
1072
  type: type || 'custom',
1057
- timestamp: opts.timestamp || Date.now(),
1073
+ // The API accepts epoch-ms (JS-native, e.g. Date.now()); the record
1074
+ // serializes epoch-µs so every record kind shares one timestamp unit.
1075
+ timestamp:
1076
+ (typeof opts.timestamp === 'number' && isFinite(opts.timestamp)
1077
+ ? opts.timestamp
1078
+ : Date.now()) * 1000,
1079
+ level:
1080
+ typeof opts.level === 'string' && opts.level !== ''
1081
+ ? opts.level
1082
+ : 'info',
1058
1083
  };
1059
1084
  if (opts.message != null) event.message = opts.message;
1060
- if (opts.level != null) event.level = opts.level;
1061
1085
  if (opts.duration != null) event.duration = opts.duration;
1062
1086
  if (opts.user != null) event.user = opts.user;
1063
1087
  if (opts.client != null) event.client = opts.client;
1064
1088
  if (opts.params != null) event.params = opts.params;
1065
1089
  if (opts.error != null) {
1066
- const err = opts.error;
1067
- event.error = {};
1068
- if (isError(err)) {
1069
- event.error.message = String(err.message || '');
1070
- if (err.name) event.error.type = String(err.name);
1071
- if (err.code) event.error.code = String(err.code);
1072
- if (err.stack) event.error.stack = String(err.stack);
1073
- } else if (typeof err === 'string') {
1074
- event.error.message = err;
1075
- } else {
1076
- event.error.message = String(err);
1077
- }
1090
+ event.error = _extractEventError(opts.error);
1078
1091
  }
1079
1092
  return event;
1080
1093
  }
1081
1094
 
1095
+ // Build the `event.error` sub-object from an Error instance, a string, or
1096
+ // an error-like plain object. The plain-object case matters: errors that
1097
+ // crossed a process boundary (e.g. client errors relayed through a server
1098
+ // endpoint, ShareDB op errors) arrive as `{message, code, ...}` objects,
1099
+ // and stringifying those yields the useless '[object Object]'.
1100
+ function _extractEventError(err) {
1101
+ const out = {};
1102
+ if (isError(err)) {
1103
+ out.message = String(err.message || '');
1104
+ if (err.name) out.type = String(err.name);
1105
+ if (err.code != null) out.code = String(err.code);
1106
+ if (err.stack) out.stack = String(err.stack);
1107
+ } else if (typeof err === 'string') {
1108
+ out.message = err;
1109
+ } else if (err && typeof err === 'object') {
1110
+ out.message =
1111
+ typeof err.message === 'string' && err.message !== ''
1112
+ ? err.message
1113
+ : _safeJson(err);
1114
+ if (err.name != null || err.type != null) {
1115
+ out.type = String(err.name != null ? err.name : err.type);
1116
+ }
1117
+ if (err.code != null) out.code = String(err.code);
1118
+ if (typeof err.stack === 'string' && err.stack !== '') {
1119
+ out.stack = err.stack;
1120
+ }
1121
+ } else {
1122
+ out.message = String(err);
1123
+ }
1124
+ return out;
1125
+ }
1126
+
1127
+ // Bounded JSON representation for error-like objects without a usable
1128
+ // `message`. Falls back to a key listing when JSON.stringify fails or
1129
+ // produces nothing informative.
1130
+ function _safeJson(obj) {
1131
+ try {
1132
+ const json = JSON.stringify(obj);
1133
+ if (json && json !== '{}') {
1134
+ return json.length > 500 ? json.slice(0, 500) + '…' : json;
1135
+ }
1136
+ } catch (e) {
1137
+ // fall through to the key listing
1138
+ }
1139
+ const keys = Object.keys(obj);
1140
+ return keys.length > 0
1141
+ ? '[object with keys: ' + keys.join(', ') + ']'
1142
+ : Object.prototype.toString.call(obj);
1143
+ }
1144
+
1082
1145
  // The optional callback will be called with the error object after the error
1083
1146
  // have been sent to the intake API. If no callback have been provided we will
1084
1147
  // automatically terminate the process, so if you provide a callback you must
@@ -44,6 +44,7 @@ function createApmClient(config, agent) {
44
44
 
45
45
  const client = new JsonlFileClient({
46
46
  serviceName: config.serviceName,
47
+ serviceNodeName: config.serviceNodeName,
47
48
  serviceVersion: config.serviceVersion,
48
49
  environment: config.environment,
49
50
  globalLabels: maybePairsToObject(config.globalLabels),
@@ -55,10 +55,15 @@ class ChannelWriter {
55
55
  this._metadata = opts.metadata;
56
56
  this._metadataFilters = opts.metadataFilters;
57
57
  this._extraMetadata = opts.extraMetadata || null;
58
+ this._metadataReady = opts.metadataReady || null;
58
59
  this._s3Uploader = opts.s3Uploader || null;
59
60
  this._clock = opts.clock || (() => new Date());
60
61
  this._log = opts.logger || null;
61
62
 
63
+ // How long flush() may hold the first write waiting for asynchronously
64
+ // fetched metadata (e.g. cloud) before proceeding without it.
65
+ this._metadataWaitDeadline = this._clock().getTime() + 10 * 1000;
66
+
62
67
  // Rotation schedule
63
68
  const schedule = opts.rotationSchedule || 'daily';
64
69
  if (typeof schedule === 'string' && ROTATION_SCHEDULES[schedule]) {
@@ -129,9 +134,23 @@ class ChannelWriter {
129
134
  }
130
135
  }
131
136
 
132
- flush() {
137
+ flush(opts) {
133
138
  if (this._buffer.length === 0) return;
134
139
 
140
+ // Hold the first write of a file until asynchronously fetched metadata
141
+ // (cloud) is ready, so the file's metadata line is complete. The wait
142
+ // is bounded twice over: the fetcher's own timeout flips the readiness
143
+ // flag, and the hard deadline covers a callback that never fires.
144
+ if (
145
+ !(opts && opts.force) &&
146
+ this._metadataReady &&
147
+ !this._wroteMetadata &&
148
+ !this._metadataReady() &&
149
+ this._clock().getTime() < this._metadataWaitDeadline
150
+ ) {
151
+ return;
152
+ }
153
+
135
154
  try {
136
155
  this._checkTimeRotation();
137
156
 
@@ -181,7 +200,9 @@ class ChannelWriter {
181
200
  destroy() {
182
201
  if (this._destroyed) return;
183
202
  this._destroyed = true;
184
- this.flush();
203
+ // Force: never hold buffered records back at shutdown, even if
204
+ // asynchronously fetched metadata never became ready.
205
+ this.flush({ force: true });
185
206
  this.uploadCurrent();
186
207
  }
187
208
 
@@ -14,6 +14,7 @@ const os = require('os');
14
14
  const Filters = require('object-filter-sequence');
15
15
 
16
16
  const { ChannelWriter } = require('./channel-writer');
17
+ const { normalizeHost } = require('./s3-uploader');
17
18
 
18
19
  const DEFAULT_FLUSH_INTERVAL_MS = 1000;
19
20
  const DEFAULT_ROTATION_SCHEDULE = 'daily';
@@ -61,6 +62,9 @@ class JsonlFileClient extends EventEmitter {
61
62
  name: opts.serviceName || 'unknown',
62
63
  version: opts.serviceVersion || undefined,
63
64
  environment: opts.environment || undefined,
65
+ ...(opts.serviceNodeName && {
66
+ node: { configured_name: opts.serviceNodeName },
67
+ }),
64
68
  agent: { name: 'tracelog', version: require('../../package').version },
65
69
  },
66
70
  process: {
@@ -69,13 +73,18 @@ class JsonlFileClient extends EventEmitter {
69
73
  argv: process.argv,
70
74
  },
71
75
  system: {
72
- hostname: os.hostname(),
76
+ // Normalized the same way as the host in S3 keys, so a viewer
77
+ // can correlate metadata with key-derived hosts using one rule.
78
+ hostname: normalizeHost(os.hostname()),
73
79
  architecture: os.arch(),
74
80
  platform: os.platform(),
75
81
  },
76
82
  ...(opts.globalLabels && { labels: opts.globalLabels }),
77
83
  },
78
84
  s3Uploader: opts.s3Uploader || null,
85
+ // Lets writers hold their first write (bounded) until the async
86
+ // cloud-metadata fetch resolves, so metadata lines include `cloud`.
87
+ metadataReady: () => this._cloudMetadataReady,
79
88
  maxFileSize: opts.maxFileSize,
80
89
  maxBufferSize: opts.maxBufferSize || DEFAULT_MAX_BUFFER_SIZE,
81
90
  rotationSchedule: opts.rotationSchedule || DEFAULT_ROTATION_SCHEDULE,
@@ -211,6 +220,9 @@ class JsonlFileClient extends EventEmitter {
211
220
  }
212
221
 
213
222
  for (const writer of this._writers.values()) {
223
+ // Not forced: while the (bounded) wait for async cloud metadata is
224
+ // pending, records stay buffered. destroy() is the path that must
225
+ // never hold records back.
214
226
  writer.flush();
215
227
  }
216
228
 
package/lib/errors.js CHANGED
@@ -175,7 +175,7 @@ function createAPMError(args, cb) {
175
175
  sampled: args.trans.sampled,
176
176
  };
177
177
  }
178
- if (args.errorContext) {
178
+ if (args.errorContext && Object.keys(args.errorContext).length > 0) {
179
179
  error.context = args.errorContext;
180
180
  }
181
181
 
@@ -10,6 +10,10 @@ const querystring = require('querystring');
10
10
  const HEADER_FORM_URLENCODED = 'application/x-www-form-urlencoded';
11
11
  const REDACTED = require('../constants').REDACTED;
12
12
 
13
+ // Depth guard for deep JSON redaction. Far deeper than any sane request
14
+ // body; protects against adversarial nesting.
15
+ const MAX_REDACT_DEPTH = 32;
16
+
13
17
  /**
14
18
  * Handles req.body as object or string
15
19
  *
@@ -42,6 +46,80 @@ function redactKeysFromPostedFormVariables(body, requestHeaders, regexes) {
42
46
  return body;
43
47
  }
44
48
 
49
+ /**
50
+ * Redact sensitive fields from a captured request body of any supported
51
+ * content type:
52
+ *
53
+ * - `application/x-www-form-urlencoded` — top-level form fields (the
54
+ * historical Elastic APM behavior).
55
+ * - JSON content types (`application/json`, `application/*+json`, with or
56
+ * without a charset suffix) — **deep** redaction: any key at any depth
57
+ * matching the sanitizeFieldNames patterns is replaced, recursing through
58
+ * nested objects and arrays. Stringified JSON bodies are parsed, redacted,
59
+ * and re-stringified; strings that fail to parse are returned untouched.
60
+ * - anything else — returned as-is.
61
+ *
62
+ * @param {Object | String} body
63
+ * @param {Object} requestHeaders
64
+ * @param {Array<RegExp>} regexes
65
+ * @returns {Object | String} a copy of the body with redacted fields
66
+ */
67
+ function redactKeysFromBody(body, requestHeaders, regexes) {
68
+ const contentType = String(requestHeaders['content-type'] || '');
69
+ if (!isJsonContentType(contentType)) {
70
+ return redactKeysFromPostedFormVariables(body, requestHeaders, regexes);
71
+ }
72
+ if (!Array.isArray(regexes)) {
73
+ return body;
74
+ }
75
+
76
+ if (body !== null && !Buffer.isBuffer(body) && typeof body === 'object') {
77
+ return redactDeep(body, regexes, 0, new WeakSet());
78
+ }
79
+
80
+ if (typeof body === 'string') {
81
+ let parsed;
82
+ try {
83
+ parsed = JSON.parse(body);
84
+ } catch (_err) {
85
+ return body; // claimed JSON but isn't; leave it alone
86
+ }
87
+ return JSON.stringify(redactDeep(parsed, regexes, 0, new WeakSet()));
88
+ }
89
+
90
+ return body;
91
+ }
92
+
93
+ function isJsonContentType(contentType) {
94
+ // 'application/json', 'application/json; charset=utf-8',
95
+ // 'application/vnd.api+json', …
96
+ const mime = contentType.split(';')[0].trim().toLowerCase();
97
+ return mime === 'application/json' || mime.endsWith('+json');
98
+ }
99
+
100
+ function redactDeep(value, regexes, depth, seen) {
101
+ if (value === null || typeof value !== 'object') {
102
+ return value;
103
+ }
104
+ if (depth >= MAX_REDACT_DEPTH || seen.has(value)) {
105
+ return REDACTED; // too deep / circular: redact rather than risk leaking
106
+ }
107
+ seen.add(value);
108
+
109
+ if (Array.isArray(value)) {
110
+ return value.map((item) => redactDeep(item, regexes, depth + 1, seen));
111
+ }
112
+
113
+ const result = {};
114
+ for (const key of Object.keys(value)) {
115
+ const shouldRedact = regexes.some((regex) => regex.test(key));
116
+ result[key] = shouldRedact
117
+ ? REDACTED
118
+ : redactDeep(value[key], regexes, depth + 1, seen);
119
+ }
120
+ return result;
121
+ }
122
+
45
123
  /**
46
124
  * Returns a copy of the provided object. Each entry of the copy will have
47
125
  * its value REDACTEd if the key matches any of the regexes
@@ -66,4 +144,5 @@ function redactKeysFromObject(obj, regexes, redactedStr = REDACTED) {
66
144
  module.exports = {
67
145
  redactKeysFromObject,
68
146
  redactKeysFromPostedFormVariables,
147
+ redactKeysFromBody,
69
148
  };
@@ -288,18 +288,31 @@ Transaction.prototype.toJSON = function () {
288
288
  };
289
289
 
290
290
  if (this.sampled) {
291
- payload.context = {
292
- user: Object.assign(
293
- {},
294
- this.req && parsers.getUserContextFromRequest(this.req),
295
- this._user,
296
- ),
297
- tags: this._labels || {},
298
- custom: this._custom || {},
299
- service: this._service || {},
300
- cloud: this._cloud || {},
301
- message: this._message || {},
302
- };
291
+ // Only include context members that have content — empty placeholder
292
+ // objects are omitted entirely rather than serialized as `{}`.
293
+ const context = {};
294
+ const user = Object.assign(
295
+ {},
296
+ this.req && parsers.getUserContextFromRequest(this.req),
297
+ this._user,
298
+ );
299
+ if (Object.keys(user).length > 0) context.user = user;
300
+ if (this._labels && Object.keys(this._labels).length > 0) {
301
+ context.tags = this._labels;
302
+ }
303
+ if (this._custom && Object.keys(this._custom).length > 0) {
304
+ context.custom = this._custom;
305
+ }
306
+ if (this._service && Object.keys(this._service).length > 0) {
307
+ context.service = this._service;
308
+ }
309
+ if (this._cloud && Object.keys(this._cloud).length > 0) {
310
+ context.cloud = this._cloud;
311
+ }
312
+ if (this._message && Object.keys(this._message).length > 0) {
313
+ context.message = this._message;
314
+ }
315
+
303
316
  // Only include dropped count when spans have been dropped.
304
317
  if (this._droppedSpans > 0) {
305
318
  payload.span_count.dropped = this._droppedSpans;
@@ -307,14 +320,17 @@ Transaction.prototype.toJSON = function () {
307
320
 
308
321
  var conf = this._agent._conf;
309
322
  if (this.req) {
310
- payload.context.request = parsers.getContextFromRequest(
323
+ context.request = parsers.getContextFromRequest(
311
324
  this.req,
312
325
  conf,
313
326
  'transactions',
314
327
  );
315
328
  }
316
329
  if (this.res) {
317
- payload.context.response = parsers.getContextFromResponse(this.res, conf);
330
+ context.response = parsers.getContextFromResponse(this.res, conf);
331
+ }
332
+ if (Object.keys(context).length > 0) {
333
+ payload.context = context;
318
334
  }
319
335
  }
320
336
 
@@ -410,6 +426,19 @@ Transaction.prototype.end = function (result, endTime) {
410
426
 
411
427
  if (result !== undefined && result !== null) {
412
428
  this.result = result;
429
+
430
+ // Manually ended transactions never go through
431
+ // _setOutcomeFromHttpStatusCode, so derive the outcome from an
432
+ // explicitly passed string result rather than serializing 'unknown'
433
+ // alongside result 'success'. (The result *default* of 'success' does
434
+ // not derive: ending without a result means the outcome is unknown.)
435
+ if (this.outcome === constants.OUTCOME_UNKNOWN && !this._isOutcomeFrozen) {
436
+ if (result === 'success') {
437
+ this.outcome = constants.OUTCOME_SUCCESS;
438
+ } else if (result === 'failure' || result === 'error') {
439
+ this.outcome = constants.OUTCOME_FAILURE;
440
+ }
441
+ }
413
442
  }
414
443
 
415
444
  if (!this._defaultName && this.req) this.setDefaultNameFromRequest();
@@ -12,6 +12,7 @@ const { SelfReportingMetricsRegistry } = require('measured-reporting');
12
12
  const DimensionAwareMetricsRegistry = require('measured-reporting/lib/registries/DimensionAwareMetricsRegistry');
13
13
 
14
14
  const MetricsReporter = require('./reporter');
15
+ const { normalizeHost } = require('../apm-client/s3-uploader');
15
16
  const createRuntimeMetrics = require('./runtime');
16
17
  const createSystemMetrics =
17
18
  process.platform === 'linux'
@@ -22,7 +23,8 @@ class MetricsRegistry extends SelfReportingMetricsRegistry {
22
23
  constructor(agent, { reporterOptions, registryOptions = {} } = {}) {
23
24
  const defaultReporterOptions = {
24
25
  defaultDimensions: {
25
- hostname: agent._conf.hostname || os.hostname(),
26
+ // Normalized the same way as the host in S3 keys (see s3-uploader).
27
+ hostname: normalizeHost(agent._conf.hostname || os.hostname()),
26
28
  env: agent._conf.environment || '',
27
29
  },
28
30
  };
package/lib/parsers.js CHANGED
@@ -17,7 +17,7 @@ const stringify = require('fast-safe-stringify');
17
17
  const REDACTED = require('./constants').REDACTED;
18
18
  const {
19
19
  redactKeysFromObject,
20
- redactKeysFromPostedFormVariables,
20
+ redactKeysFromBody,
21
21
  } = require('./filters/sanitize-field-names');
22
22
 
23
23
  // When redacting individual cookie field values, this string is used instead
@@ -105,7 +105,7 @@ function getContextFromRequest(req, conf, type) {
105
105
  if (typeof body === 'string' && req.bodyIsBase64Encoded === true) {
106
106
  body = Buffer.from(body, 'base64').toString('utf8');
107
107
  }
108
- body = redactKeysFromPostedFormVariables(
108
+ body = redactKeysFromBody(
109
109
  body,
110
110
  req.headers,
111
111
  conf.sanitizeFieldNamesRegExp,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@redthreadlabs/tracelog",
3
- "version": "1.6.0",
3
+ "version": "1.8.0",
4
4
  "description": "Node.js APM instrumentation that writes traces to JSONL files",
5
5
  "publishConfig": {
6
6
  "access": "public"