@redthreadlabs/tracelog 1.5.1 → 1.7.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/README.md CHANGED
@@ -19,12 +19,16 @@ require('tracelog').start({
19
19
  serviceName: 'my-api',
20
20
  serviceVersion: '1.0.0',
21
21
  logDir: '/var/log/myapp',
22
+ defaultChannel: 'server',
22
23
  s3Bucket: 'my-traces',
23
24
  s3Region: 'us-east-1',
24
- s3KeyTemplate: '{serviceName}/{environment}/{date}/{hostname}-{pid}-{timestamp}.jsonl',
25
25
  });
26
26
  ```
27
27
 
28
+ S3 keys follow a fixed layout designed for prefix scans and per-channel
29
+ lifecycle rules: `{channel}/{interval}/{host}[_{seq}][_current].jsonl[.gz]`
30
+ (see CONFIG.md).
31
+
28
32
  Or use the auto-start entry point with environment variables:
29
33
 
30
34
  ```bash
package/index.d.ts CHANGED
@@ -318,10 +318,16 @@ declare namespace apm {
318
318
  maxLocalRetentionDays?: number;
319
319
  maxBufferSize?: number;
320
320
 
321
- // Tracelog: S3 upload
321
+ /**
322
+ * Name of the default channel — the first segment of local filenames
323
+ * and S3 keys for records not routed elsewhere. Default: 'default'.
324
+ */
325
+ defaultChannel?: string;
326
+
327
+ // Tracelog: S3 upload. The S3 key layout is fixed:
328
+ // {channel}/{interval}/{host}[_{seq}][_current].jsonl[.gz]
322
329
  s3Bucket?: string;
323
330
  s3Region?: string;
324
- s3KeyTemplate?: string;
325
331
  s3UploadIntervalMs?: number;
326
332
  s3GzipCompleted?: boolean;
327
333
  s3GzipCurrent?: boolean;
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
@@ -31,9 +31,6 @@ function createApmClient(config, agent) {
31
31
  if (config.s3Bucket) {
32
32
  s3Uploader = new S3Uploader({
33
33
  bucket: config.s3Bucket,
34
- keyTemplate:
35
- config.s3KeyTemplate ||
36
- '{serviceName}/{environment}/{hostname}-{channel}-{interval}.jsonl',
37
34
  region: config.s3Region,
38
35
  accessKeyId: config.s3AccessKeyId,
39
36
  secretAccessKey: config.s3SecretAccessKey,
@@ -41,14 +38,13 @@ function createApmClient(config, agent) {
41
38
  s3Client: config.s3Client, // optional: inject a mock for testing
42
39
  gzipCompleted: config.s3GzipCompleted,
43
40
  gzipCurrent: config.s3GzipCurrent,
44
- serviceName: config.serviceName,
45
- environment: config.environment,
46
41
  logger: config.logger,
47
42
  });
48
43
  }
49
44
 
50
45
  const client = new JsonlFileClient({
51
46
  serviceName: config.serviceName,
47
+ serviceNodeName: config.serviceNodeName,
52
48
  serviceVersion: config.serviceVersion,
53
49
  environment: config.environment,
54
50
  globalLabels: maybePairsToObject(config.globalLabels),
@@ -60,6 +56,7 @@ function createApmClient(config, agent) {
60
56
  // JSONL file options
61
57
  logDir: config.logDir,
62
58
  logFilePrefix: config.logFilePrefix,
59
+ defaultChannel: config.defaultChannel,
63
60
  maxFileSize: config.logMaxFileSize,
64
61
  flushIntervalMs: config.logFlushIntervalMs,
65
62
  rotationSchedule: config.logRotationSchedule,
@@ -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
 
@@ -170,14 +189,20 @@ class ChannelWriter {
170
189
 
171
190
  uploadCurrent() {
172
191
  if (this._s3Uploader) {
173
- this._s3Uploader.uploadCurrent(this._currentFilePath, { channel: this._channel });
192
+ this._s3Uploader.uploadCurrent(this._currentFilePath, {
193
+ channel: this._channel,
194
+ interval: this._currentPeriodLabel,
195
+ seq: this._currentSeqNum,
196
+ });
174
197
  }
175
198
  }
176
199
 
177
200
  destroy() {
178
201
  if (this._destroyed) return;
179
202
  this._destroyed = true;
180
- 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 });
181
206
  this.uploadCurrent();
182
207
  }
183
208
 
@@ -217,7 +242,11 @@ class ChannelWriter {
217
242
  this._wroteMetadata = false;
218
243
 
219
244
  if (this._s3Uploader && fs.existsSync(completedFilePath)) {
220
- this._s3Uploader.uploadCompleted(completedFilePath, { channel: this._channel, interval: this._currentPeriodLabel });
245
+ this._s3Uploader.uploadCompleted(completedFilePath, {
246
+ channel: this._channel,
247
+ interval: this._currentPeriodLabel,
248
+ seq: this._currentSeqNum,
249
+ });
221
250
  }
222
251
 
223
252
  if (this._maxLocalRetentionDays > 0) {
@@ -317,10 +346,15 @@ class ChannelWriter {
317
346
 
318
347
  const withoutPrefix = file.slice(prefix.length);
319
348
  const withoutExt = withoutPrefix.slice(0, withoutPrefix.length - this._ext.length);
349
+ const seqMatch = /\.(\d+)$/.exec(withoutExt);
320
350
  const periodLabel = withoutExt.replace(/\.\d+$/, '');
321
351
 
322
352
  if (periodLabel < this._currentPeriodLabel) {
323
- this._s3Uploader.uploadCompleted(filePath, { channel: this._channel, interval: periodLabel });
353
+ this._s3Uploader.uploadCompleted(filePath, {
354
+ channel: this._channel,
355
+ interval: periodLabel,
356
+ seq: seqMatch ? parseInt(seqMatch[1], 10) : 0,
357
+ });
324
358
  }
325
359
  }
326
360
  } catch (e) { /* ignore */ }
@@ -14,18 +14,20 @@ 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';
20
21
  const DEFAULT_MAX_LOCAL_RETENTION_DAYS = 0;
21
22
  const DEFAULT_MAX_BUFFER_SIZE = 10000;
22
23
 
23
- const DEFAULT_CHANNEL = 'default';
24
+ const DEFAULT_CHANNEL = 'default'; // used when opts.defaultChannel is not set
24
25
 
25
26
  /**
26
27
  * A channel-aware JSONL file client. Routes records to ChannelWriter instances
27
28
  * based on channel name. Each channel gets its own file, buffer, and rotation
28
- * lifecycle. The default channel is 'default'.
29
+ * lifecycle. The default channel is named 'default' unless overridden via
30
+ * opts.defaultChannel.
29
31
  */
30
32
  class JsonlFileClient extends EventEmitter {
31
33
  constructor(opts) {
@@ -33,6 +35,7 @@ class JsonlFileClient extends EventEmitter {
33
35
 
34
36
  this._baseDir = opts.logDir || process.cwd();
35
37
  this._baseName = opts.logFilePrefix || 'tracelog';
38
+ this._defaultChannel = opts.defaultChannel || DEFAULT_CHANNEL;
36
39
  this._clock = opts.clock || (() => new Date());
37
40
  this._log = opts.logger || null;
38
41
  this._destroyed = false;
@@ -59,6 +62,9 @@ class JsonlFileClient extends EventEmitter {
59
62
  name: opts.serviceName || 'unknown',
60
63
  version: opts.serviceVersion || undefined,
61
64
  environment: opts.environment || undefined,
65
+ ...(opts.serviceNodeName && {
66
+ node: { configured_name: opts.serviceNodeName },
67
+ }),
62
68
  agent: { name: 'tracelog', version: require('../../package').version },
63
69
  },
64
70
  process: {
@@ -67,13 +73,18 @@ class JsonlFileClient extends EventEmitter {
67
73
  argv: process.argv,
68
74
  },
69
75
  system: {
70
- 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()),
71
79
  architecture: os.arch(),
72
80
  platform: os.platform(),
73
81
  },
74
82
  ...(opts.globalLabels && { labels: opts.globalLabels }),
75
83
  },
76
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,
77
88
  maxFileSize: opts.maxFileSize,
78
89
  maxBufferSize: opts.maxBufferSize || DEFAULT_MAX_BUFFER_SIZE,
79
90
  rotationSchedule: opts.rotationSchedule || DEFAULT_ROTATION_SCHEDULE,
@@ -105,7 +116,7 @@ class JsonlFileClient extends EventEmitter {
105
116
  }
106
117
 
107
118
  // Create the default channel eagerly.
108
- this._getWriter(DEFAULT_CHANNEL);
119
+ this._getWriter(this._defaultChannel);
109
120
 
110
121
  // Start periodic flush of all channels.
111
122
  const flushIntervalMs = opts.flushIntervalMs || DEFAULT_FLUSH_INTERVAL_MS;
@@ -173,23 +184,23 @@ class JsonlFileClient extends EventEmitter {
173
184
  lambdaRegisterTransaction(trans, awsRequestId) {}
174
185
 
175
186
  sendTransaction(transaction, cb) {
176
- this._getWriter(DEFAULT_CHANNEL).send('transaction', transaction, cb);
187
+ this._getWriter(this._defaultChannel).send('transaction', transaction, cb);
177
188
  }
178
189
 
179
190
  sendSpan(span, cb) {
180
- this._getWriter(DEFAULT_CHANNEL).send('span', span, cb);
191
+ this._getWriter(this._defaultChannel).send('span', span, cb);
181
192
  }
182
193
 
183
194
  sendError(error, cb) {
184
- this._getWriter(DEFAULT_CHANNEL).send('error', error, cb);
195
+ this._getWriter(this._defaultChannel).send('error', error, cb);
185
196
  }
186
197
 
187
198
  sendMetricSet(metricset, cb) {
188
- this._getWriter(DEFAULT_CHANNEL).send('metricset', metricset, cb);
199
+ this._getWriter(this._defaultChannel).send('metricset', metricset, cb);
189
200
  }
190
201
 
191
202
  sendEvent(event, cb) {
192
- this._getWriter(DEFAULT_CHANNEL).send('event', event, cb);
203
+ this._getWriter(this._defaultChannel).send('event', event, cb);
193
204
  }
194
205
 
195
206
  // --- Channel-routed API ---
@@ -209,6 +220,9 @@ class JsonlFileClient extends EventEmitter {
209
220
  }
210
221
 
211
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.
212
226
  writer.flush();
213
227
  }
214
228
 
@@ -12,31 +12,52 @@ const path = require('path');
12
12
  const zlib = require('zlib');
13
13
  const { createGzip } = require('zlib');
14
14
  const { pipeline } = require('stream');
15
- const { S3Client, PutObjectCommand } = require('@aws-sdk/client-s3');
15
+ const {
16
+ S3Client,
17
+ PutObjectCommand,
18
+ DeleteObjectCommand,
19
+ } = require('@aws-sdk/client-s3');
20
+
21
+ // S3 key layout is FIXED (not configurable): it is the contract between
22
+ // tracelog and the in-browser log viewer, which scans the bucket with
23
+ // prefix listings. Channel comes before interval so that prefix-scoped
24
+ // lifecycle rules work and a date-range scan within a channel is a single
25
+ // lexicographically-ordered listing:
26
+ //
27
+ // {channel}/{interval}/{host}[_{seq}][_current].jsonl[.gz]
28
+ //
29
+ // server/2026-06-11/172.31.27.225.jsonl.gz
30
+ // server/2026-06-11/172.31.27.225_current.jsonl.gz
31
+ // server/2026-06-11/172.31.27.225_1.jsonl.gz
32
+ //
33
+ // There is no serviceName segment: buckets are per-service/per-env, channel
34
+ // names are the top-level namespace (the default channel's name is set via
35
+ // the `defaultChannel` config), and the service name is recorded in every
36
+ // file's metadata line. The basename is underscore-delimited (hostnames
37
+ // cannot contain underscores): host, then a numeric size-rotation seq when
38
+ // > 0, then the literal 'current' for the live file. A host that died
39
+ // mid-interval leaves its final '_current' upload in place, interval intact.
16
40
 
17
41
  class S3Uploader {
18
42
  /**
19
43
  * @param {Object} opts
20
44
  * @param {string} opts.bucket - S3 bucket name
21
- * @param {string} opts.keyTemplate - S3 key template with {variable} placeholders
22
45
  * @param {string} [opts.region] - AWS region
23
46
  * @param {string} [opts.accessKeyId] - AWS access key ID
24
47
  * @param {string} [opts.secretAccessKey] - AWS secret access key
25
48
  * @param {string} [opts.sessionToken] - AWS session token
26
- * @param {string} opts.serviceName - Service name for template variables
27
- * @param {string} [opts.environment] - Environment for template variables
28
49
  * @param {boolean} [opts.gzipCompleted=true] - Gzip completed files before upload
29
50
  * @param {boolean} [opts.gzipCurrent=true] - Gzip current files before upload
30
51
  * @param {Object} [opts.logger] - Logger instance
31
52
  * @param {Object} [opts.s3Client] - S3 client instance (must have a send() method).
32
53
  * Defaults to a real S3Client from @aws-sdk/client-s3. Inject a mock for testing.
33
54
  * @param {Function} [opts.clock] - Clock provider for testability. Returns a Date.
55
+ * @param {string} [opts.host] - Host label override (for testing). Defaults to
56
+ * the normalized os.hostname().
34
57
  */
35
58
  constructor(opts) {
36
59
  this._bucket = opts.bucket;
37
- this._keyTemplate = opts.keyTemplate;
38
- this._serviceName = opts.serviceName || 'unknown';
39
- this._environment = opts.environment || 'development';
60
+ this._host = opts.host || normalizeHost(os.hostname());
40
61
  this._gzipCompleted = opts.gzipCompleted !== false;
41
62
  this._gzipCurrent = opts.gzipCurrent !== false;
42
63
  this._log = opts.logger || null;
@@ -68,9 +89,11 @@ class S3Uploader {
68
89
  }
69
90
 
70
91
  /**
71
- * Upload a completed (rotated) file, then delete local on success.
92
+ * Upload a completed (rotated) file, then delete local on success. Also
93
+ * deletes the now-superseded '_current' snapshot object for the same
94
+ * interval/seq, so only hosts that died mid-interval leave one behind.
72
95
  * @param {string} filePath - Path to the completed JSONL file
73
- * @param {Object} vars - Template variables (channel, interval)
96
+ * @param {Object} vars - Key variables (channel, interval, seq)
74
97
  */
75
98
  uploadCompleted(filePath, vars) {
76
99
  if (!fs.existsSync(filePath)) return;
@@ -83,7 +106,7 @@ class S3Uploader {
83
106
  }
84
107
 
85
108
  _uploadCompletedGzipped(filePath, vars) {
86
- const key = this._resolveKey(vars) + '.gz';
109
+ const key = this._buildKey(vars) + '.gz';
87
110
  const gzPath = filePath + '.gz';
88
111
 
89
112
  this._pendingUploads++;
@@ -116,6 +139,7 @@ class S3Uploader {
116
139
  }
117
140
  try { fs.unlinkSync(filePath); } catch (e) { /* ignore */ }
118
141
  try { fs.unlinkSync(gzPath); } catch (e) { /* ignore */ }
142
+ this._deleteStaleCurrent(vars);
119
143
  })
120
144
  .catch((uploadErr) => {
121
145
  this._logError(
@@ -132,7 +156,7 @@ class S3Uploader {
132
156
  }
133
157
 
134
158
  _uploadCompletedRaw(filePath, vars) {
135
- const key = this._resolveKey(vars);
159
+ const key = this._buildKey(vars);
136
160
 
137
161
  this._pendingUploads++;
138
162
 
@@ -151,6 +175,7 @@ class S3Uploader {
151
175
  this._log.debug('Uploaded completed log to s3://%s/%s', this._bucket, key);
152
176
  }
153
177
  try { fs.unlinkSync(filePath); } catch (e) { /* ignore */ }
178
+ this._deleteStaleCurrent(vars);
154
179
  })
155
180
  .catch((uploadErr) => {
156
181
  this._logError(
@@ -165,17 +190,37 @@ class S3Uploader {
165
190
  }
166
191
 
167
192
  /**
168
- * Upload the current (incomplete) file without deletion.
169
- * Uses a stable S3 key (based on the filename, not a timestamp) so that
170
- * each upload overwrites the previous snapshot instead of creating duplicates.
171
- * @param {string} filePath - Path to the current JSONL file
172
- * @param {Function} [cb] - Callback when upload completes
193
+ * Delete the '_current' snapshot object superseded by a successful
194
+ * completed upload of the same channel/interval/seq. Best-effort:
195
+ * missing objects and failures are ignored.
173
196
  */
197
+ _deleteStaleCurrent(vars) {
198
+ let key = this._buildKey({ ...vars, current: true });
199
+ if (this._gzipCurrent) {
200
+ key += '.gz';
201
+ }
202
+
203
+ this._pendingUploads++;
204
+ this._s3
205
+ .send(new DeleteObjectCommand({ Bucket: this._bucket, Key: key }))
206
+ .then(() => {
207
+ if (this._log) {
208
+ this._log.debug('Deleted stale current log s3://%s/%s', this._bucket, key);
209
+ }
210
+ })
211
+ .catch(() => { /* best-effort */ })
212
+ .finally(() => {
213
+ this._pendingUploads--;
214
+ });
215
+ }
216
+
174
217
  /**
175
- * Upload the current (incomplete) file without deletion.
176
- * Uses 'current' as the interval so periodic uploads overwrite the same object.
218
+ * Upload the current (incomplete) file without deletion. The key keeps
219
+ * the file's real interval and carries a '_current' basename suffix, so
220
+ * periodic uploads overwrite the same object, and the snapshot sorts
221
+ * beside its finalized siblings.
177
222
  * @param {string} filePath - Path to the current JSONL file
178
- * @param {Object} vars - Template variables (channel); interval is set to 'current'
223
+ * @param {Object} vars - Key variables (channel, interval, seq)
179
224
  * @param {Function} [cb] - Callback when upload completes
180
225
  */
181
226
  uploadCurrent(filePath, vars, cb) {
@@ -188,7 +233,7 @@ class S3Uploader {
188
233
  return;
189
234
  }
190
235
 
191
- const currentVars = { ...vars, interval: 'current' };
236
+ const currentVars = { ...vars, current: true };
192
237
  const rawBody = fs.readFileSync(filePath);
193
238
 
194
239
  if (this._gzipCurrent) {
@@ -199,7 +244,7 @@ class S3Uploader {
199
244
  }
200
245
 
201
246
  _uploadCurrentGzipped(filePath, rawBody, vars, cb) {
202
- const key = this._resolveKey(vars) + '.gz';
247
+ const key = this._buildKey(vars) + '.gz';
203
248
 
204
249
  this._pendingUploads++;
205
250
 
@@ -240,7 +285,7 @@ class S3Uploader {
240
285
  }
241
286
 
242
287
  _uploadCurrentRaw(filePath, rawBody, vars, cb) {
243
- const key = this._resolveKey(vars);
288
+ const key = this._buildKey(vars);
244
289
 
245
290
  this._pendingUploads++;
246
291
 
@@ -271,23 +316,23 @@ class S3Uploader {
271
316
  }
272
317
 
273
318
  /**
274
- * Resolve an S3 key from the template.
319
+ * Build the S3 key for a log file (see the layout contract above).
275
320
  *
276
- * @param {Object} vars - Template variables:
321
+ * @param {Object} vars - Key variables:
277
322
  * - {string} channel - Channel name (e.g. 'server', 'client')
278
- * - {string} interval - Period label (e.g. '2026-03-17') or 'current'
323
+ * - {string} interval - Period label (e.g. '2026-03-17')
324
+ * - {number} [seq] - Size-rotation sequence number within the interval
325
+ * - {boolean} [current] - True for the live (incomplete) file snapshot
279
326
  */
280
- _resolveKey(vars) {
281
- const allVars = {
282
- serviceName: this._serviceName,
283
- environment: this._environment,
284
- hostname: os.hostname(),
285
- ...vars,
286
- };
287
-
288
- return this._keyTemplate.replace(/\{(\w+)\}/g, (match, name) => {
289
- return allVars[name] !== undefined ? allVars[name] : match;
290
- });
327
+ _buildKey(vars) {
328
+ let basename = this._host;
329
+ if (vars.seq > 0) {
330
+ basename += `_${vars.seq}`;
331
+ }
332
+ if (vars.current) {
333
+ basename += '_current';
334
+ }
335
+ return `${vars.channel}/${vars.interval}/${basename}.jsonl`;
291
336
  }
292
337
 
293
338
  _logError(fmt, ...args) {
@@ -297,12 +342,18 @@ class S3Uploader {
297
342
  }
298
343
  }
299
344
 
300
- function _pad2(n) {
301
- return String(n).padStart(2, '0');
302
- }
303
-
304
- function _fmtDate(d) {
305
- return `${d.getFullYear()}-${_pad2(d.getMonth() + 1)}-${_pad2(d.getDate())}`;
345
+ /**
346
+ * Normalize a hostname into the host label used in S3 keys. EC2 internal
347
+ * hostnames (ip-A-B-C-D or ip-A-B-C-D.ec2.internal etc.) become the dotted
348
+ * IP address, which avoids embedding hyphens in the basename; any other
349
+ * hostname is used as-is.
350
+ */
351
+ function normalizeHost(hostname) {
352
+ const m = /^ip-(\d{1,3})-(\d{1,3})-(\d{1,3})-(\d{1,3})(\..*)?$/.exec(hostname);
353
+ if (m) {
354
+ return `${m[1]}.${m[2]}.${m[3]}.${m[4]}`;
355
+ }
356
+ return hostname;
306
357
  }
307
358
 
308
- module.exports = { S3Uploader };
359
+ module.exports = { S3Uploader, normalizeHost };
@@ -509,7 +509,15 @@ const CONFIG_SCHEMA = [
509
509
  defaultValue: 10000,
510
510
  envVar: 'TRACELOG_MAX_BUFFER_SIZE',
511
511
  },
512
- // S3 upload
512
+ // Channels
513
+ {
514
+ name: 'defaultChannel',
515
+ configType: 'string',
516
+ defaultValue: 'default',
517
+ envVar: 'TRACELOG_DEFAULT_CHANNEL',
518
+ },
519
+ // S3 upload. The S3 key layout is fixed (the contract with the log
520
+ // viewer): {channel}/{interval}/{host}[_{seq}][_current].jsonl[.gz]
513
521
  {
514
522
  name: 's3Bucket',
515
523
  configType: 'string',
@@ -522,13 +530,6 @@ const CONFIG_SCHEMA = [
522
530
  defaultValue: undefined,
523
531
  envVar: 'TRACELOG_S3_REGION',
524
532
  },
525
- {
526
- name: 's3KeyTemplate',
527
- configType: 'string',
528
- defaultValue:
529
- '{serviceName}/{environment}/{date}/{hostname}-{pid}-{timestamp}.jsonl',
530
- envVar: 'TRACELOG_S3_KEY_TEMPLATE',
531
- },
532
533
  {
533
534
  name: 's3UploadIntervalMs',
534
535
  configType: 'number',
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
 
@@ -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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@redthreadlabs/tracelog",
3
- "version": "1.5.1",
3
+ "version": "1.7.0",
4
4
  "description": "Node.js APM instrumentation that writes traces to JSONL files",
5
5
  "publishConfig": {
6
6
  "access": "public"