@redthreadlabs/tracelog 1.10.0 → 1.11.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.
@@ -18,6 +18,15 @@ const {
18
18
  PutObjectCommand,
19
19
  DeleteObjectCommand,
20
20
  } = require('@aws-sdk/client-s3');
21
+ // The S3 key layout, the sidecar shape, and the histogram-deriving
22
+ // MetaAccumulator are the shared contract — owned by tracelog-schema so the
23
+ // agent (writer) and the viewer (reader) can never drift.
24
+ const {
25
+ buildKey,
26
+ normalizeHost,
27
+ MetaAccumulator,
28
+ sidecarKey,
29
+ } = require('@redthreadlabs/tracelog-schema');
21
30
 
22
31
  // S3 key layout is FIXED (not configurable): it is the contract between
23
32
  // tracelog and the in-browser log viewer, which scans the bucket with
@@ -47,22 +56,6 @@ const {
47
56
  // not a truthful description of its contents. The viewer reads these into its
48
57
  // size ledger for deterministic memory/cache accounting and factual rollups,
49
58
  // and falls back to estimation for files written before sidecars existed.
50
- const SIDECAR_VERSION = 1;
51
- const SIDECAR_SUFFIX = '.meta.json';
52
-
53
- function _pad2(n) {
54
- return String(n).padStart(2, '0');
55
- }
56
-
57
- /** epoch-ms → UTC hour-bucket label 'YYYY-MM-DDTHH' (matches the viewer). */
58
- function _hourBucket(ms) {
59
- const d = new Date(ms);
60
- return (
61
- `${d.getUTCFullYear()}-${_pad2(d.getUTCMonth() + 1)}-${_pad2(d.getUTCDate())}` +
62
- `T${_pad2(d.getUTCHours())}`
63
- );
64
- }
65
-
66
59
  function _safeSize(p) {
67
60
  try {
68
61
  return fs.statSync(p).size;
@@ -71,107 +64,6 @@ function _safeSize(p) {
71
64
  }
72
65
  }
73
66
 
74
- /**
75
- * Derives a log file's sidecar histogram by parsing its NDJSON lines. The file
76
- * on disk is the source of truth: counts come from the exact bytes being
77
- * uploaded, so they cannot drift from the object, and a restart (which wipes
78
- * any in-memory write-time counters) or an orphaned file from a crashed run is
79
- * handled for free — we just re-derive from the file.
80
- *
81
- * Tolerant by design: an unparseable line is skipped (not a record); a record
82
- * with a missing/garbage timestamp is counted as `malformed` rather than
83
- * forced into an interval. Append-only safe: addChunk may be fed successive
84
- * tails of a growing current file, since every line is newline-terminated so
85
- * chunk/offset boundaries always land between lines.
86
- */
87
- class MetaAccumulator {
88
- constructor() {
89
- this.offset = 0; // bytes consumed so far (for incremental current parsing)
90
- this.records = 0;
91
- this.malformed = 0;
92
- this.intervals = Object.create(null); // { 'YYYY-MM-DDTHH': { kind: count } }
93
- this._partial = '';
94
- }
95
-
96
- addChunk(text) {
97
- if (!text) return;
98
- const s = this._partial + text;
99
- let start = 0;
100
- let nl;
101
- while ((nl = s.indexOf('\n', start)) !== -1) {
102
- this._addLine(s.slice(start, nl));
103
- start = nl + 1;
104
- }
105
- this._partial = s.slice(start);
106
- }
107
-
108
- flushPartial() {
109
- if (this._partial) {
110
- this._addLine(this._partial);
111
- this._partial = '';
112
- }
113
- }
114
-
115
- _addLine(line) {
116
- const t = line.trim();
117
- if (!t) return;
118
- let obj;
119
- try {
120
- obj = JSON.parse(t);
121
- } catch (e) {
122
- return; // corrupt line — not a countable record (the viewer skips it too)
123
- }
124
- if (!obj || typeof obj !== 'object') return;
125
- const kind = Object.keys(obj)[0];
126
- if (!kind || kind === 'metadata') return; // the file's metadata line
127
- this.records++;
128
- const body = obj[kind];
129
- const tsUs =
130
- body &&
131
- typeof body.timestamp === 'number' &&
132
- isFinite(body.timestamp) &&
133
- body.timestamp > 0
134
- ? body.timestamp
135
- : 0;
136
- if (!tsUs) {
137
- this.malformed++;
138
- return;
139
- }
140
- const bucket = _hourBucket(tsUs / 1000); // serialized timestamps are epoch-µs
141
- const byKind =
142
- this.intervals[bucket] || (this.intervals[bucket] = Object.create(null));
143
- byKind[kind] = (byKind[kind] || 0) + 1;
144
- }
145
-
146
- /**
147
- * The sidecar object for this file. records === malformed + Σ(intervals).
148
- *
149
- * Keys are emitted in a fixed, sorted order at every level — top-level fields
150
- * in schema order, interval buckets and their kinds sorted lexically — so the
151
- * same contents always serialize to byte-identical JSON regardless of the
152
- * order records arrived in. That makes a sidecar's ETag a reliable
153
- * sameness check.
154
- */
155
- toMeta(interval, bytes, compressed) {
156
- const intervals = Object.create(null);
157
- for (const hour of Object.keys(this.intervals).sort()) {
158
- const src = this.intervals[hour];
159
- const sorted = Object.create(null);
160
- for (const kind of Object.keys(src).sort()) sorted[kind] = src[kind];
161
- intervals[hour] = sorted;
162
- }
163
- return {
164
- v: SIDECAR_VERSION,
165
- interval,
166
- bytes,
167
- compressed,
168
- records: this.records,
169
- malformed: this.malformed,
170
- intervals,
171
- };
172
- }
173
- }
174
-
175
67
  class S3Uploader {
176
68
  /**
177
69
  * @param {Object} opts
@@ -378,7 +270,7 @@ class S3Uploader {
378
270
  this._currentAccs.delete(key);
379
271
 
380
272
  // Delete the snapshot object and its sidecar (best-effort, both).
381
- for (const k of [key, key + SIDECAR_SUFFIX]) {
273
+ for (const k of [key, sidecarKey(key)]) {
382
274
  this._pendingUploads++;
383
275
  this._s3
384
276
  .send(new DeleteObjectCommand({ Bucket: this._bucket, Key: k }))
@@ -538,13 +430,13 @@ class S3Uploader {
538
430
  this._s3
539
431
  .send(new PutObjectCommand({
540
432
  Bucket: this._bucket,
541
- Key: objectKey + SIDECAR_SUFFIX,
433
+ Key: sidecarKey(objectKey),
542
434
  Body: body,
543
435
  ContentType: 'application/json',
544
436
  }))
545
437
  .then(() => {
546
438
  if (this._log) {
547
- this._log.debug('Uploaded sidecar s3://%s/%s%s', this._bucket, objectKey, SIDECAR_SUFFIX);
439
+ this._log.debug('Uploaded sidecar s3://%s/%s', this._bucket, sidecarKey(objectKey));
548
440
  }
549
441
  })
550
442
  .catch((err) => {
@@ -567,14 +459,8 @@ class S3Uploader {
567
459
  * - {boolean} [current] - True for the live (incomplete) file snapshot
568
460
  */
569
461
  _buildKey(vars) {
570
- let basename = this._host;
571
- if (vars.seq > 0) {
572
- basename += `_${vars.seq}`;
573
- }
574
- if (vars.current) {
575
- basename += '_current';
576
- }
577
- return `${vars.channel}/${vars.interval}/${basename}.jsonl`;
462
+ // the layout is the shared contract; host comes from this uploader
463
+ return buildKey({ ...vars, host: this._host });
578
464
  }
579
465
 
580
466
  _logError(fmt, ...args) {
@@ -584,18 +470,6 @@ class S3Uploader {
584
470
  }
585
471
  }
586
472
 
587
- /**
588
- * Normalize a hostname into the host label used in S3 keys. EC2 internal
589
- * hostnames (ip-A-B-C-D or ip-A-B-C-D.ec2.internal etc.) become the dotted
590
- * IP address, which avoids embedding hyphens in the basename; any other
591
- * hostname is used as-is.
592
- */
593
- function normalizeHost(hostname) {
594
- const m = /^ip-(\d{1,3})-(\d{1,3})-(\d{1,3})-(\d{1,3})(\..*)?$/.exec(hostname);
595
- if (m) {
596
- return `${m[1]}.${m[2]}.${m[3]}.${m[4]}`;
597
- }
598
- return hostname;
599
- }
600
-
473
+ // normalizeHost + MetaAccumulator are re-exported from tracelog-schema (the
474
+ // shared contract) so existing importers keep working.
601
475
  module.exports = { S3Uploader, normalizeHost, MetaAccumulator };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@redthreadlabs/tracelog",
3
- "version": "1.10.0",
3
+ "version": "1.11.0",
4
4
  "description": "Node.js APM instrumentation that writes traces to JSONL files",
5
5
  "publishConfig": {
6
6
  "access": "public"
@@ -50,6 +50,7 @@
50
50
  "dependencies": {
51
51
  "@aws-sdk/client-s3": "^3.0.0",
52
52
  "@elastic/ecs-pino-format": "^1.5.0",
53
+ "@redthreadlabs/tracelog-schema": "^0.1.0",
53
54
  "after-all-results": "^2.0.0",
54
55
  "async-value-promise": "^1.1.1",
55
56
  "basic-auth": "^2.0.1",