@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 +5 -1
- package/index.d.ts +8 -2
- package/lib/agent.js +105 -42
- package/lib/apm-client/apm-client.js +2 -5
- package/lib/apm-client/channel-writer.js +39 -5
- package/lib/apm-client/jsonl-file-client.js +23 -9
- package/lib/apm-client/s3-uploader.js +94 -43
- package/lib/config/schema.js +9 -8
- package/lib/errors.js +1 -1
- package/lib/instrumentation/transaction.js +43 -14
- package/lib/metrics/registry.js +3 -1
- package/package.json +1 -1
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
|
-
|
|
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
|
-
|
|
645
|
-
|
|
646
|
-
|
|
647
|
-
|
|
648
|
-
|
|
649
|
-
|
|
650
|
-
|
|
651
|
-
|
|
652
|
-
|
|
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|
|
|
738
|
-
//
|
|
739
|
-
//
|
|
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 =
|
|
768
|
-
|
|
769
|
-
|
|
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 =
|
|
885
|
-
|
|
886
|
-
|
|
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 (
|
|
890
|
-
this._agent.
|
|
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
|
-
|
|
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
|
-
|
|
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, {
|
|
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
|
-
|
|
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, {
|
|
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, {
|
|
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
|
-
|
|
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(
|
|
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(
|
|
187
|
+
this._getWriter(this._defaultChannel).send('transaction', transaction, cb);
|
|
177
188
|
}
|
|
178
189
|
|
|
179
190
|
sendSpan(span, cb) {
|
|
180
|
-
this._getWriter(
|
|
191
|
+
this._getWriter(this._defaultChannel).send('span', span, cb);
|
|
181
192
|
}
|
|
182
193
|
|
|
183
194
|
sendError(error, cb) {
|
|
184
|
-
this._getWriter(
|
|
195
|
+
this._getWriter(this._defaultChannel).send('error', error, cb);
|
|
185
196
|
}
|
|
186
197
|
|
|
187
198
|
sendMetricSet(metricset, cb) {
|
|
188
|
-
this._getWriter(
|
|
199
|
+
this._getWriter(this._defaultChannel).send('metricset', metricset, cb);
|
|
189
200
|
}
|
|
190
201
|
|
|
191
202
|
sendEvent(event, cb) {
|
|
192
|
-
this._getWriter(
|
|
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 {
|
|
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.
|
|
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 -
|
|
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.
|
|
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.
|
|
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
|
-
*
|
|
169
|
-
*
|
|
170
|
-
*
|
|
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
|
-
*
|
|
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 -
|
|
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,
|
|
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.
|
|
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.
|
|
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
|
-
*
|
|
319
|
+
* Build the S3 key for a log file (see the layout contract above).
|
|
275
320
|
*
|
|
276
|
-
* @param {Object} vars -
|
|
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')
|
|
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
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
return
|
|
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
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
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 };
|
package/lib/config/schema.js
CHANGED
|
@@ -509,7 +509,15 @@ const CONFIG_SCHEMA = [
|
|
|
509
509
|
defaultValue: 10000,
|
|
510
510
|
envVar: 'TRACELOG_MAX_BUFFER_SIZE',
|
|
511
511
|
},
|
|
512
|
-
//
|
|
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
|
@@ -288,18 +288,31 @@ Transaction.prototype.toJSON = function () {
|
|
|
288
288
|
};
|
|
289
289
|
|
|
290
290
|
if (this.sampled) {
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
),
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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();
|
package/lib/metrics/registry.js
CHANGED
|
@@ -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
|
-
|
|
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
|
};
|