@redthreadlabs/tracelog 1.5.1 → 1.6.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;
@@ -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,8 +38,6 @@ 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
  }
@@ -60,6 +55,7 @@ function createApmClient(config, agent) {
60
55
  // JSONL file options
61
56
  logDir: config.logDir,
62
57
  logFilePrefix: config.logFilePrefix,
58
+ defaultChannel: config.defaultChannel,
63
59
  maxFileSize: config.logMaxFileSize,
64
60
  flushIntervalMs: config.logFlushIntervalMs,
65
61
  rotationSchedule: config.logRotationSchedule,
@@ -170,7 +170,11 @@ class ChannelWriter {
170
170
 
171
171
  uploadCurrent() {
172
172
  if (this._s3Uploader) {
173
- this._s3Uploader.uploadCurrent(this._currentFilePath, { channel: this._channel });
173
+ this._s3Uploader.uploadCurrent(this._currentFilePath, {
174
+ channel: this._channel,
175
+ interval: this._currentPeriodLabel,
176
+ seq: this._currentSeqNum,
177
+ });
174
178
  }
175
179
  }
176
180
 
@@ -217,7 +221,11 @@ class ChannelWriter {
217
221
  this._wroteMetadata = false;
218
222
 
219
223
  if (this._s3Uploader && fs.existsSync(completedFilePath)) {
220
- this._s3Uploader.uploadCompleted(completedFilePath, { channel: this._channel, interval: this._currentPeriodLabel });
224
+ this._s3Uploader.uploadCompleted(completedFilePath, {
225
+ channel: this._channel,
226
+ interval: this._currentPeriodLabel,
227
+ seq: this._currentSeqNum,
228
+ });
221
229
  }
222
230
 
223
231
  if (this._maxLocalRetentionDays > 0) {
@@ -317,10 +325,15 @@ class ChannelWriter {
317
325
 
318
326
  const withoutPrefix = file.slice(prefix.length);
319
327
  const withoutExt = withoutPrefix.slice(0, withoutPrefix.length - this._ext.length);
328
+ const seqMatch = /\.(\d+)$/.exec(withoutExt);
320
329
  const periodLabel = withoutExt.replace(/\.\d+$/, '');
321
330
 
322
331
  if (periodLabel < this._currentPeriodLabel) {
323
- this._s3Uploader.uploadCompleted(filePath, { channel: this._channel, interval: periodLabel });
332
+ this._s3Uploader.uploadCompleted(filePath, {
333
+ channel: this._channel,
334
+ interval: periodLabel,
335
+ seq: seqMatch ? parseInt(seqMatch[1], 10) : 0,
336
+ });
324
337
  }
325
338
  }
326
339
  } catch (e) { /* ignore */ }
@@ -20,12 +20,13 @@ const DEFAULT_ROTATION_SCHEDULE = 'daily';
20
20
  const DEFAULT_MAX_LOCAL_RETENTION_DAYS = 0;
21
21
  const DEFAULT_MAX_BUFFER_SIZE = 10000;
22
22
 
23
- const DEFAULT_CHANNEL = 'default';
23
+ const DEFAULT_CHANNEL = 'default'; // used when opts.defaultChannel is not set
24
24
 
25
25
  /**
26
26
  * A channel-aware JSONL file client. Routes records to ChannelWriter instances
27
27
  * based on channel name. Each channel gets its own file, buffer, and rotation
28
- * lifecycle. The default channel is 'default'.
28
+ * lifecycle. The default channel is named 'default' unless overridden via
29
+ * opts.defaultChannel.
29
30
  */
30
31
  class JsonlFileClient extends EventEmitter {
31
32
  constructor(opts) {
@@ -33,6 +34,7 @@ class JsonlFileClient extends EventEmitter {
33
34
 
34
35
  this._baseDir = opts.logDir || process.cwd();
35
36
  this._baseName = opts.logFilePrefix || 'tracelog';
37
+ this._defaultChannel = opts.defaultChannel || DEFAULT_CHANNEL;
36
38
  this._clock = opts.clock || (() => new Date());
37
39
  this._log = opts.logger || null;
38
40
  this._destroyed = false;
@@ -105,7 +107,7 @@ class JsonlFileClient extends EventEmitter {
105
107
  }
106
108
 
107
109
  // Create the default channel eagerly.
108
- this._getWriter(DEFAULT_CHANNEL);
110
+ this._getWriter(this._defaultChannel);
109
111
 
110
112
  // Start periodic flush of all channels.
111
113
  const flushIntervalMs = opts.flushIntervalMs || DEFAULT_FLUSH_INTERVAL_MS;
@@ -173,23 +175,23 @@ class JsonlFileClient extends EventEmitter {
173
175
  lambdaRegisterTransaction(trans, awsRequestId) {}
174
176
 
175
177
  sendTransaction(transaction, cb) {
176
- this._getWriter(DEFAULT_CHANNEL).send('transaction', transaction, cb);
178
+ this._getWriter(this._defaultChannel).send('transaction', transaction, cb);
177
179
  }
178
180
 
179
181
  sendSpan(span, cb) {
180
- this._getWriter(DEFAULT_CHANNEL).send('span', span, cb);
182
+ this._getWriter(this._defaultChannel).send('span', span, cb);
181
183
  }
182
184
 
183
185
  sendError(error, cb) {
184
- this._getWriter(DEFAULT_CHANNEL).send('error', error, cb);
186
+ this._getWriter(this._defaultChannel).send('error', error, cb);
185
187
  }
186
188
 
187
189
  sendMetricSet(metricset, cb) {
188
- this._getWriter(DEFAULT_CHANNEL).send('metricset', metricset, cb);
190
+ this._getWriter(this._defaultChannel).send('metricset', metricset, cb);
189
191
  }
190
192
 
191
193
  sendEvent(event, cb) {
192
- this._getWriter(DEFAULT_CHANNEL).send('event', event, cb);
194
+ this._getWriter(this._defaultChannel).send('event', event, cb);
193
195
  }
194
196
 
195
197
  // --- Channel-routed API ---
@@ -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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@redthreadlabs/tracelog",
3
- "version": "1.5.1",
3
+ "version": "1.6.0",
4
4
  "description": "Node.js APM instrumentation that writes traces to JSONL files",
5
5
  "publishConfig": {
6
6
  "access": "public"