@redthreadlabs/tracelog 1.10.0 → 1.12.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
@@ -650,13 +650,13 @@ Agent.prototype.writeError = function (err, opts, cb) {
650
650
  opts.user,
651
651
  );
652
652
  if (Object.keys(errorUser).length > 0) errorContext.user = errorUser;
653
- const errorTags = Object.assign(
653
+ const errorLabels = Object.assign(
654
654
  {},
655
655
  trans && trans._labels,
656
656
  opts.tags,
657
657
  opts.labels,
658
658
  );
659
- if (Object.keys(errorTags).length > 0) errorContext.tags = errorTags;
659
+ if (Object.keys(errorLabels).length > 0) errorContext.labels = errorLabels;
660
660
  const errorCustom = Object.assign({}, trans && trans._custom, opts.custom);
661
661
  if (Object.keys(errorCustom).length > 0) errorContext.custom = errorCustom;
662
662
  if (req) {
@@ -951,6 +951,28 @@ Channel.prototype.writeSpan = function (input) {
951
951
  }
952
952
  };
953
953
 
954
+ // Forward client-originated event records as-is (validated + bounded). Distinct
955
+ // from writeEvents, which builds events from the server's own ms-based API.
956
+ Channel.prototype.writeClientEvents = function (events) {
957
+ if (!this._agent._apmClient || !Array.isArray(events)) return;
958
+ for (const input of events) {
959
+ const event = _validateClientEvent(input);
960
+ if (event) {
961
+ this._agent._apmClient.sendToChannel(this._name, 'event', event);
962
+ }
963
+ }
964
+ };
965
+
966
+ // Write a client's RecordOrigin (service + environment) as a `metadata` record,
967
+ // keyed by its lifetime_id — the in-stream origin that records join to.
968
+ Channel.prototype.writeRecordOrigin = function (origin) {
969
+ if (!this._agent._apmClient) return;
970
+ const validated = _validateRecordOrigin(origin);
971
+ if (validated) {
972
+ this._agent._apmClient.sendToChannel(this._name, 'metadata', validated);
973
+ }
974
+ };
975
+
954
976
  // --- Write transaction/span on the default channel ---
955
977
 
956
978
  // Write a validated transaction record to the default channel.
@@ -1003,6 +1025,7 @@ function _validateTransaction(input) {
1003
1025
  if (typeof input.duration === 'number' && isFinite(input.duration)) transaction.duration = input.duration;
1004
1026
  if (typeof input.result === 'string') transaction.result = input.result.slice(0, 1024);
1005
1027
  if (typeof input.parent_id === 'string' && HEX_16.test(input.parent_id)) transaction.parent_id = input.parent_id;
1028
+ if (typeof input.lifetime_id === 'string' && HEX_16.test(input.lifetime_id)) transaction.lifetime_id = input.lifetime_id;
1006
1029
  if (input.context && typeof input.context === 'object') {
1007
1030
  const ctx = _sanitizeContext(input.context);
1008
1031
  if (ctx) transaction.context = ctx;
@@ -1033,6 +1056,7 @@ function _validateSpan(input) {
1033
1056
  if (typeof input.duration === 'number' && isFinite(input.duration)) span.duration = input.duration;
1034
1057
  if (typeof input.subtype === 'string') span.subtype = input.subtype.slice(0, 1024);
1035
1058
  if (typeof input.action === 'string') span.action = input.action.slice(0, 1024);
1059
+ if (typeof input.lifetime_id === 'string' && HEX_16.test(input.lifetime_id)) span.lifetime_id = input.lifetime_id;
1036
1060
  if (input.context && typeof input.context === 'object') {
1037
1061
  const ctx = _sanitizeContext(input.context);
1038
1062
  if (ctx) span.context = ctx;
@@ -1046,14 +1070,14 @@ function _validateSpan(input) {
1046
1070
  function _sanitizeContext(ctx) {
1047
1071
  const result = {};
1048
1072
 
1049
- if (ctx.tags && typeof ctx.tags === 'object') {
1050
- const tags = {};
1051
- for (const [k, v] of Object.entries(ctx.tags)) {
1073
+ if (ctx.labels && typeof ctx.labels === 'object') {
1074
+ const labels = {};
1075
+ for (const [k, v] of Object.entries(ctx.labels)) {
1052
1076
  if (typeof v === 'string' || typeof v === 'number' || typeof v === 'boolean') {
1053
- tags[k] = v;
1077
+ labels[k] = v;
1054
1078
  }
1055
1079
  }
1056
- if (Object.keys(tags).length > 0) result.tags = tags;
1080
+ if (Object.keys(labels).length > 0) result.labels = labels;
1057
1081
  }
1058
1082
 
1059
1083
  if (ctx.user && typeof ctx.user === 'object') {
@@ -1067,6 +1091,91 @@ function _sanitizeContext(ctx) {
1067
1091
  return Object.keys(result).length > 0 ? result : undefined;
1068
1092
  }
1069
1093
 
1094
+ const VALID_EVENT_LEVELS = ['debug', 'info', 'warn', 'error'];
1095
+
1096
+ // Validate a client-originated event record, forwarded as-is to the channel
1097
+ // (the server has already stamped user/lifetime_id). Untrusted input, so every
1098
+ // string is length-bounded and only known fields survive. Unlike _buildEvent
1099
+ // (the server's own ms API) this carries the record's epoch-µs timestamp as-is.
1100
+ function _validateClientEvent(input) {
1101
+ if (!input || typeof input !== 'object') return null;
1102
+ const event = {
1103
+ type:
1104
+ typeof input.type === 'string' && input.type
1105
+ ? input.type.slice(0, 256)
1106
+ : 'client-log',
1107
+ timestamp:
1108
+ typeof input.timestamp === 'number' && isFinite(input.timestamp)
1109
+ ? Math.floor(input.timestamp)
1110
+ : Date.now() * 1000,
1111
+ level: VALID_EVENT_LEVELS.includes(input.level) ? input.level : 'info',
1112
+ };
1113
+ if (typeof input.message === 'string') event.message = input.message.slice(0, 10000);
1114
+ if (typeof input.locale === 'string' && input.locale) event.locale = input.locale.slice(0, 64);
1115
+ if (typeof input.tz_offset === 'number' && isFinite(input.tz_offset)) event.tz_offset = input.tz_offset;
1116
+ if (typeof input.lifetime_id === 'string' && HEX_16.test(input.lifetime_id)) event.lifetime_id = input.lifetime_id;
1117
+ if (input.context && typeof input.context === 'object') {
1118
+ const ctx = _sanitizeContext(input.context);
1119
+ if (ctx) event.context = ctx;
1120
+ }
1121
+ if (input.error && typeof input.error === 'object') {
1122
+ const err = {};
1123
+ if (typeof input.error.message === 'string') err.message = input.error.message.slice(0, 10000);
1124
+ if (typeof input.error.type === 'string') err.type = input.error.type.slice(0, 1024);
1125
+ if (typeof input.error.code === 'string' || typeof input.error.code === 'number') {
1126
+ err.code = String(input.error.code).slice(0, 1024);
1127
+ }
1128
+ if (typeof input.error.stack === 'string') err.stack = input.error.stack.slice(0, 10000);
1129
+ if (Object.keys(err).length > 0) event.error = err;
1130
+ }
1131
+ return event;
1132
+ }
1133
+
1134
+ // Validate a client RecordOrigin (service + environment) for a `metadata`
1135
+ // record. Untrusted input: bound strings, keep only known fields.
1136
+ function _validateRecordOrigin(input) {
1137
+ if (!input || typeof input !== 'object') return null;
1138
+ const str = (v) => (typeof v === 'string' ? v.slice(0, 256) : undefined);
1139
+ const nameVer = (o) => {
1140
+ if (!o || typeof o !== 'object') return undefined;
1141
+ const r = {};
1142
+ if (str(o.name) !== undefined) r.name = str(o.name);
1143
+ if (str(o.version) !== undefined) r.version = str(o.version);
1144
+ return Object.keys(r).length > 0 ? r : undefined;
1145
+ };
1146
+ const origin = {};
1147
+ if (typeof input.lifetime_id === 'string' && HEX_16.test(input.lifetime_id)) origin.lifetime_id = input.lifetime_id;
1148
+ const service = nameVer(input.service);
1149
+ if (service) origin.service = service;
1150
+ const runtime = nameVer(input.runtime);
1151
+ if (runtime) origin.runtime = runtime;
1152
+ const os = nameVer(input.os);
1153
+ if (os) origin.os = os;
1154
+ if (input.host && typeof input.host === 'object' && str(input.host.name) !== undefined) {
1155
+ origin.host = { name: str(input.host.name) };
1156
+ }
1157
+ if (input.device && typeof input.device === 'object') {
1158
+ const d = {};
1159
+ if (str(input.device.model) !== undefined) d.model = str(input.device.model);
1160
+ if (str(input.device.brand) !== undefined) d.brand = str(input.device.brand);
1161
+ if (str(input.device.type) !== undefined) d.type = str(input.device.type);
1162
+ if (typeof input.device.year_class === 'number' && isFinite(input.device.year_class)) {
1163
+ d.year_class = input.device.year_class;
1164
+ }
1165
+ if (input.device.screen && typeof input.device.screen === 'object') {
1166
+ const sc = {};
1167
+ for (const k of ['width', 'height', 'pixel_ratio']) {
1168
+ if (typeof input.device.screen[k] === 'number' && isFinite(input.device.screen[k])) {
1169
+ sc[k] = input.device.screen[k];
1170
+ }
1171
+ }
1172
+ if (Object.keys(sc).length > 0) d.screen = sc;
1173
+ }
1174
+ if (Object.keys(d).length > 0) origin.device = d;
1175
+ }
1176
+ return Object.keys(origin).length > 0 ? origin : null;
1177
+ }
1178
+
1070
1179
  function _buildEvent(type, opts) {
1071
1180
  const event = {
1072
1181
  type: type || 'custom',
@@ -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 };
@@ -508,7 +508,7 @@ Span.prototype._encode = function (cb) {
508
508
  haveContext = true;
509
509
  }
510
510
  if (self._labels) {
511
- context.tags = self._labels;
511
+ context.labels = self._labels;
512
512
  haveContext = true;
513
513
  }
514
514
  if (haveContext) {
@@ -298,7 +298,7 @@ Transaction.prototype.toJSON = function () {
298
298
  );
299
299
  if (Object.keys(user).length > 0) context.user = user;
300
300
  if (this._labels && Object.keys(this._labels).length > 0) {
301
- context.tags = this._labels;
301
+ context.labels = this._labels;
302
302
  }
303
303
  if (this._custom && Object.keys(this._custom).length > 0) {
304
304
  context.custom = this._custom;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@redthreadlabs/tracelog",
3
- "version": "1.10.0",
3
+ "version": "1.12.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.3.0",
53
54
  "after-all-results": "^2.0.0",
54
55
  "async-value-promise": "^1.1.1",
55
56
  "basic-auth": "^2.0.1",