navis.js 1.0.0 → 3.0.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 +149 -20
- package/bin/generators/service.js +167 -0
- package/bin/navis.js +56 -2
- package/examples/v2-features-demo.js +190 -0
- package/examples/v3-features-demo.js +226 -0
- package/package.json +1 -1
- package/src/index.js +50 -12
- package/src/messaging/base-messaging.js +82 -0
- package/src/messaging/kafka-adapter.js +152 -0
- package/src/messaging/nats-adapter.js +141 -0
- package/src/messaging/sqs-adapter.js +175 -0
- package/src/observability/logger.js +141 -0
- package/src/observability/metrics.js +193 -0
- package/src/observability/tracer.js +205 -0
- package/src/utils/circuit-breaker.js +107 -0
- package/src/utils/retry.js +88 -0
- package/src/utils/service-client.js +234 -104
- package/src/utils/service-config.js +88 -0
- package/src/utils/service-discovery.js +184 -0
|
@@ -0,0 +1,175 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* AWS SQS Messaging Adapter
|
|
3
|
+
* v3: SQS integration for async messaging
|
|
4
|
+
*
|
|
5
|
+
* Note: Requires @aws-sdk/client-sqs to be installed
|
|
6
|
+
* npm install @aws-sdk/client-sqs
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
const BaseMessaging = require('./base-messaging');
|
|
10
|
+
|
|
11
|
+
class SQSMessaging extends BaseMessaging {
|
|
12
|
+
constructor(options = {}) {
|
|
13
|
+
super(options);
|
|
14
|
+
this.region = options.region || process.env.AWS_REGION || 'us-east-1';
|
|
15
|
+
this.queueUrl = options.queueUrl;
|
|
16
|
+
this.sqsClient = null;
|
|
17
|
+
|
|
18
|
+
// Try to load AWS SDK (optional dependency)
|
|
19
|
+
try {
|
|
20
|
+
const { SQSClient, SendMessageCommand, ReceiveMessageCommand, DeleteMessageCommand } = require('@aws-sdk/client-sqs');
|
|
21
|
+
this.SQSClient = SQSClient;
|
|
22
|
+
this.SendMessageCommand = SendMessageCommand;
|
|
23
|
+
this.ReceiveMessageCommand = ReceiveMessageCommand;
|
|
24
|
+
this.DeleteMessageCommand = DeleteMessageCommand;
|
|
25
|
+
} catch (err) {
|
|
26
|
+
// AWS SDK not installed - will throw error on connect
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* Connect to SQS
|
|
32
|
+
*/
|
|
33
|
+
async connect() {
|
|
34
|
+
if (!this.SQSClient) {
|
|
35
|
+
throw new Error('@aws-sdk/client-sqs is required. Install it with: npm install @aws-sdk/client-sqs');
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
this.sqsClient = new this.SQSClient({
|
|
39
|
+
region: this.region,
|
|
40
|
+
...this.options.awsConfig,
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
this.isConnected = true;
|
|
44
|
+
return this;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* Disconnect from SQS
|
|
49
|
+
*/
|
|
50
|
+
async disconnect() {
|
|
51
|
+
this.isConnected = false;
|
|
52
|
+
if (this.sqsClient) {
|
|
53
|
+
// SQS client doesn't need explicit disconnect
|
|
54
|
+
this.sqsClient = null;
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* Publish message to SQS queue
|
|
60
|
+
* @param {string} queueUrl - SQS queue URL
|
|
61
|
+
* @param {Object} message - Message payload
|
|
62
|
+
* @param {Object} options - Publishing options
|
|
63
|
+
*/
|
|
64
|
+
async publish(queueUrl, message, options = {}) {
|
|
65
|
+
if (!this.isConnected) {
|
|
66
|
+
await this.connect();
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
const command = new this.SendMessageCommand({
|
|
70
|
+
QueueUrl: queueUrl || this.queueUrl,
|
|
71
|
+
MessageBody: JSON.stringify(message),
|
|
72
|
+
MessageAttributes: options.attributes || {},
|
|
73
|
+
DelaySeconds: options.delaySeconds || 0,
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
const response = await this.sqsClient.send(command);
|
|
77
|
+
return {
|
|
78
|
+
messageId: response.MessageId,
|
|
79
|
+
md5OfBody: response.MD5OfMessageBody,
|
|
80
|
+
};
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* Subscribe to SQS queue (long polling)
|
|
85
|
+
* @param {string} queueUrl - SQS queue URL
|
|
86
|
+
* @param {Function} handler - Message handler function
|
|
87
|
+
* @param {Object} options - Subscription options
|
|
88
|
+
*/
|
|
89
|
+
async subscribe(queueUrl, handler, options = {}) {
|
|
90
|
+
if (!this.isConnected) {
|
|
91
|
+
await this.connect();
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
const targetQueueUrl = queueUrl || this.queueUrl;
|
|
95
|
+
this._registerHandler(targetQueueUrl, handler);
|
|
96
|
+
|
|
97
|
+
// Start polling
|
|
98
|
+
const poll = async () => {
|
|
99
|
+
if (!this.isConnected) {
|
|
100
|
+
return;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
try {
|
|
104
|
+
const command = new this.ReceiveMessageCommand({
|
|
105
|
+
QueueUrl: targetQueueUrl,
|
|
106
|
+
MaxNumberOfMessages: options.maxMessages || 1,
|
|
107
|
+
WaitTimeSeconds: options.waitTimeSeconds || 20, // Long polling
|
|
108
|
+
VisibilityTimeout: options.visibilityTimeout || 30,
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
const response = await this.sqsClient.send(command);
|
|
112
|
+
|
|
113
|
+
if (response.Messages && response.Messages.length > 0) {
|
|
114
|
+
for (const message of response.Messages) {
|
|
115
|
+
try {
|
|
116
|
+
const body = JSON.parse(message.Body);
|
|
117
|
+
const handlers = this._getHandlers(targetQueueUrl);
|
|
118
|
+
|
|
119
|
+
for (const h of handlers) {
|
|
120
|
+
await h(body, {
|
|
121
|
+
messageId: message.MessageId,
|
|
122
|
+
receiptHandle: message.ReceiptHandle,
|
|
123
|
+
attributes: message.Attributes,
|
|
124
|
+
});
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
// Delete message after successful processing
|
|
128
|
+
if (options.autoDelete !== false) {
|
|
129
|
+
await this.sqsClient.send(new this.DeleteMessageCommand({
|
|
130
|
+
QueueUrl: targetQueueUrl,
|
|
131
|
+
ReceiptHandle: message.ReceiptHandle,
|
|
132
|
+
}));
|
|
133
|
+
}
|
|
134
|
+
} catch (error) {
|
|
135
|
+
console.error('Error processing SQS message:', error);
|
|
136
|
+
// Message will become visible again after visibility timeout
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
} catch (error) {
|
|
141
|
+
console.error('Error receiving SQS messages:', error);
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
// Continue polling
|
|
145
|
+
if (this.isConnected) {
|
|
146
|
+
setTimeout(poll, options.pollInterval || 0);
|
|
147
|
+
}
|
|
148
|
+
};
|
|
149
|
+
|
|
150
|
+
// Start polling
|
|
151
|
+
poll();
|
|
152
|
+
|
|
153
|
+
return { queueUrl: targetQueueUrl, handler };
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
/**
|
|
157
|
+
* Unsubscribe from queue
|
|
158
|
+
*/
|
|
159
|
+
async unsubscribe(queueUrl, handler = null) {
|
|
160
|
+
const targetQueueUrl = queueUrl || this.queueUrl;
|
|
161
|
+
|
|
162
|
+
if (handler) {
|
|
163
|
+
const handlers = this._getHandlers(targetQueueUrl);
|
|
164
|
+
const index = handlers.indexOf(handler);
|
|
165
|
+
if (index > -1) {
|
|
166
|
+
handlers.splice(index, 1);
|
|
167
|
+
}
|
|
168
|
+
} else {
|
|
169
|
+
this.subscribers.delete(targetQueueUrl);
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
module.exports = SQSMessaging;
|
|
175
|
+
|
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Structured Logger
|
|
3
|
+
* v3: Logging with levels and structured output
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
const LOG_LEVELS = {
|
|
7
|
+
DEBUG: 0,
|
|
8
|
+
INFO: 1,
|
|
9
|
+
WARN: 2,
|
|
10
|
+
ERROR: 3,
|
|
11
|
+
FATAL: 4,
|
|
12
|
+
};
|
|
13
|
+
|
|
14
|
+
class Logger {
|
|
15
|
+
constructor(options = {}) {
|
|
16
|
+
const level = options.level || process.env.LOG_LEVEL || 'INFO';
|
|
17
|
+
this.level = level;
|
|
18
|
+
this.levelValue = LOG_LEVELS[level.toUpperCase()] || LOG_LEVELS.INFO;
|
|
19
|
+
this.format = options.format || 'json'; // 'json' or 'text'
|
|
20
|
+
this.enableColors = options.enableColors !== false;
|
|
21
|
+
this.context = options.context || {};
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Log a message
|
|
26
|
+
* @private
|
|
27
|
+
*/
|
|
28
|
+
_log(level, message, data = {}) {
|
|
29
|
+
const levelValue = LOG_LEVELS[level];
|
|
30
|
+
if (levelValue < this.levelValue) {
|
|
31
|
+
return;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
const logEntry = {
|
|
35
|
+
timestamp: new Date().toISOString(),
|
|
36
|
+
level,
|
|
37
|
+
message,
|
|
38
|
+
...this.context,
|
|
39
|
+
...data,
|
|
40
|
+
};
|
|
41
|
+
|
|
42
|
+
if (this.format === 'json') {
|
|
43
|
+
console.log(JSON.stringify(logEntry));
|
|
44
|
+
} else {
|
|
45
|
+
const color = this._getColor(level);
|
|
46
|
+
const reset = this.enableColors ? '\x1b[0m' : '';
|
|
47
|
+
const coloredLevel = this.enableColors ? `${color}${level}${reset}` : level;
|
|
48
|
+
console.log(`[${logEntry.timestamp}] ${coloredLevel}: ${message}`, data);
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* Get color for log level
|
|
54
|
+
* @private
|
|
55
|
+
*/
|
|
56
|
+
_getColor(level) {
|
|
57
|
+
if (!this.enableColors) return '';
|
|
58
|
+
|
|
59
|
+
const colors = {
|
|
60
|
+
DEBUG: '\x1b[36m', // Cyan
|
|
61
|
+
INFO: '\x1b[32m', // Green
|
|
62
|
+
WARN: '\x1b[33m', // Yellow
|
|
63
|
+
ERROR: '\x1b[31m', // Red
|
|
64
|
+
FATAL: '\x1b[35m', // Magenta
|
|
65
|
+
};
|
|
66
|
+
return colors[level] || '';
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* Debug log
|
|
71
|
+
*/
|
|
72
|
+
debug(message, data = {}) {
|
|
73
|
+
this._log('DEBUG', message, data);
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* Info log
|
|
78
|
+
*/
|
|
79
|
+
info(message, data = {}) {
|
|
80
|
+
this._log('INFO', message, data);
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* Warning log
|
|
85
|
+
*/
|
|
86
|
+
warn(message, data = {}) {
|
|
87
|
+
this._log('WARN', message, data);
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/**
|
|
91
|
+
* Error log
|
|
92
|
+
*/
|
|
93
|
+
error(message, error = null, data = {}) {
|
|
94
|
+
const errorData = error ? {
|
|
95
|
+
error: {
|
|
96
|
+
message: error.message,
|
|
97
|
+
stack: error.stack,
|
|
98
|
+
name: error.name,
|
|
99
|
+
},
|
|
100
|
+
} : {};
|
|
101
|
+
this._log('ERROR', message, { ...errorData, ...data });
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/**
|
|
105
|
+
* Fatal log
|
|
106
|
+
*/
|
|
107
|
+
fatal(message, error = null, data = {}) {
|
|
108
|
+
const errorData = error ? {
|
|
109
|
+
error: {
|
|
110
|
+
message: error.message,
|
|
111
|
+
stack: error.stack,
|
|
112
|
+
name: error.name,
|
|
113
|
+
},
|
|
114
|
+
} : {};
|
|
115
|
+
this._log('FATAL', message, { ...errorData, ...data });
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/**
|
|
119
|
+
* Create child logger with additional context
|
|
120
|
+
*/
|
|
121
|
+
child(context) {
|
|
122
|
+
return new Logger({
|
|
123
|
+
level: this.level,
|
|
124
|
+
format: this.format,
|
|
125
|
+
enableColors: this.enableColors,
|
|
126
|
+
context: { ...this.context, ...context },
|
|
127
|
+
});
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
/**
|
|
131
|
+
* Set log level
|
|
132
|
+
*/
|
|
133
|
+
setLevel(level) {
|
|
134
|
+
const upperLevel = level.toUpperCase();
|
|
135
|
+
this.level = upperLevel;
|
|
136
|
+
this.levelValue = LOG_LEVELS[upperLevel] !== undefined ? LOG_LEVELS[upperLevel] : LOG_LEVELS.INFO;
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
module.exports = Logger;
|
|
141
|
+
|
|
@@ -0,0 +1,193 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Metrics Collector
|
|
3
|
+
* v3: Collect and expose application metrics
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
class Metrics {
|
|
7
|
+
constructor(options = {}) {
|
|
8
|
+
this.metrics = {
|
|
9
|
+
counters: new Map(),
|
|
10
|
+
gauges: new Map(),
|
|
11
|
+
histograms: new Map(),
|
|
12
|
+
};
|
|
13
|
+
this.enabled = options.enabled !== false;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Increment a counter
|
|
18
|
+
* @param {string} name - Metric name
|
|
19
|
+
* @param {number} value - Increment value (default: 1)
|
|
20
|
+
* @param {Object} labels - Metric labels
|
|
21
|
+
*/
|
|
22
|
+
increment(name, value = 1, labels = {}) {
|
|
23
|
+
if (!this.enabled) return;
|
|
24
|
+
|
|
25
|
+
const key = this._getKey(name, labels);
|
|
26
|
+
const current = this.metrics.counters.get(key) || 0;
|
|
27
|
+
this.metrics.counters.set(key, current + value);
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* Decrement a counter
|
|
32
|
+
* @param {string} name - Metric name
|
|
33
|
+
* @param {number} value - Decrement value (default: 1)
|
|
34
|
+
* @param {Object} labels - Metric labels
|
|
35
|
+
*/
|
|
36
|
+
decrement(name, value = 1, labels = {}) {
|
|
37
|
+
this.increment(name, -value, labels);
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* Set a gauge value
|
|
42
|
+
* @param {string} name - Metric name
|
|
43
|
+
* @param {number} value - Gauge value
|
|
44
|
+
* @param {Object} labels - Metric labels
|
|
45
|
+
*/
|
|
46
|
+
gauge(name, value, labels = {}) {
|
|
47
|
+
if (!this.enabled) return;
|
|
48
|
+
|
|
49
|
+
const key = this._getKey(name, labels);
|
|
50
|
+
this.metrics.gauges.set(key, value);
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* Record a histogram value
|
|
55
|
+
* @param {string} name - Metric name
|
|
56
|
+
* @param {number} value - Value to record
|
|
57
|
+
* @param {Object} labels - Metric labels
|
|
58
|
+
*/
|
|
59
|
+
histogram(name, value, labels = {}) {
|
|
60
|
+
if (!this.enabled) return;
|
|
61
|
+
|
|
62
|
+
const key = this._getKey(name, labels);
|
|
63
|
+
if (!this.metrics.histograms.has(key)) {
|
|
64
|
+
this.metrics.histograms.set(key, []);
|
|
65
|
+
}
|
|
66
|
+
this.metrics.histograms.get(key).push({
|
|
67
|
+
value,
|
|
68
|
+
timestamp: Date.now(),
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
// Keep only last 1000 values per histogram
|
|
72
|
+
const values = this.metrics.histograms.get(key);
|
|
73
|
+
if (values.length > 1000) {
|
|
74
|
+
values.shift();
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* Record request duration
|
|
80
|
+
* @param {string} method - HTTP method
|
|
81
|
+
* @param {string} path - Request path
|
|
82
|
+
* @param {number} duration - Duration in milliseconds
|
|
83
|
+
* @param {number} statusCode - HTTP status code
|
|
84
|
+
*/
|
|
85
|
+
recordRequest(method, path, duration, statusCode) {
|
|
86
|
+
this.histogram('http_request_duration_ms', duration, {
|
|
87
|
+
method,
|
|
88
|
+
path,
|
|
89
|
+
status: statusCode,
|
|
90
|
+
});
|
|
91
|
+
this.increment('http_requests_total', 1, {
|
|
92
|
+
method,
|
|
93
|
+
path,
|
|
94
|
+
status: statusCode,
|
|
95
|
+
});
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/**
|
|
99
|
+
* Get all metrics
|
|
100
|
+
* @returns {Object} - All collected metrics
|
|
101
|
+
*/
|
|
102
|
+
getAll() {
|
|
103
|
+
return {
|
|
104
|
+
counters: Object.fromEntries(this.metrics.counters),
|
|
105
|
+
gauges: Object.fromEntries(this.metrics.gauges),
|
|
106
|
+
histograms: Object.fromEntries(
|
|
107
|
+
Array.from(this.metrics.histograms.entries()).map(([key, values]) => [
|
|
108
|
+
key,
|
|
109
|
+
{
|
|
110
|
+
count: values.length,
|
|
111
|
+
sum: values.reduce((sum, v) => sum + v.value, 0),
|
|
112
|
+
avg: values.length > 0 ? values.reduce((sum, v) => sum + v.value, 0) / values.length : 0,
|
|
113
|
+
min: values.length > 0 ? Math.min(...values.map(v => v.value)) : 0,
|
|
114
|
+
max: values.length > 0 ? Math.max(...values.map(v => v.value)) : 0,
|
|
115
|
+
values: values.slice(-100), // Last 100 values
|
|
116
|
+
},
|
|
117
|
+
])
|
|
118
|
+
),
|
|
119
|
+
};
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/**
|
|
123
|
+
* Get metrics in Prometheus format
|
|
124
|
+
* @returns {string} - Prometheus metrics format
|
|
125
|
+
*/
|
|
126
|
+
toPrometheus() {
|
|
127
|
+
const lines = [];
|
|
128
|
+
|
|
129
|
+
// Counters
|
|
130
|
+
for (const [key, value] of this.metrics.counters.entries()) {
|
|
131
|
+
const { name, labels } = this._parseKey(key);
|
|
132
|
+
const labelStr = Object.entries(labels)
|
|
133
|
+
.map(([k, v]) => `${k}="${v}"`)
|
|
134
|
+
.join(',');
|
|
135
|
+
lines.push(`${name}{${labelStr}} ${value}`);
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
// Gauges
|
|
139
|
+
for (const [key, value] of this.metrics.gauges.entries()) {
|
|
140
|
+
const { name, labels } = this._parseKey(key);
|
|
141
|
+
const labelStr = Object.entries(labels)
|
|
142
|
+
.map(([k, v]) => `${k}="${v}"`)
|
|
143
|
+
.join(',');
|
|
144
|
+
lines.push(`${name}{${labelStr}} ${value}`);
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
// Histograms
|
|
148
|
+
for (const [key, histogram] of this.metrics.histograms.entries()) {
|
|
149
|
+
const { name, labels } = this._parseKey(key);
|
|
150
|
+
const labelStr = Object.entries(labels)
|
|
151
|
+
.map(([k, v]) => `${k}="${v}"`)
|
|
152
|
+
.join(',');
|
|
153
|
+
lines.push(`${name}_count{${labelStr}} ${histogram.length}`);
|
|
154
|
+
lines.push(`${name}_sum{${labelStr}} ${histogram.reduce((sum, v) => sum + v.value, 0)}`);
|
|
155
|
+
lines.push(`${name}_avg{${labelStr}} ${histogram.length > 0 ? histogram.reduce((sum, v) => sum + v.value, 0) / histogram.length : 0}`);
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
return lines.join('\n');
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
/**
|
|
162
|
+
* Reset all metrics
|
|
163
|
+
*/
|
|
164
|
+
reset() {
|
|
165
|
+
this.metrics.counters.clear();
|
|
166
|
+
this.metrics.gauges.clear();
|
|
167
|
+
this.metrics.histograms.clear();
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
/**
|
|
171
|
+
* Get key for metric storage
|
|
172
|
+
* @private
|
|
173
|
+
*/
|
|
174
|
+
_getKey(name, labels) {
|
|
175
|
+
const labelStr = JSON.stringify(labels);
|
|
176
|
+
return `${name}::${labelStr}`;
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
/**
|
|
180
|
+
* Parse key back to name and labels
|
|
181
|
+
* @private
|
|
182
|
+
*/
|
|
183
|
+
_parseKey(key) {
|
|
184
|
+
const [name, labelStr] = key.split('::');
|
|
185
|
+
return {
|
|
186
|
+
name,
|
|
187
|
+
labels: JSON.parse(labelStr || '{}'),
|
|
188
|
+
};
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
module.exports = Metrics;
|
|
193
|
+
|
|
@@ -0,0 +1,205 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Distributed Tracer
|
|
3
|
+
* v3: Basic distributed tracing support
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
class Tracer {
|
|
7
|
+
constructor(options = {}) {
|
|
8
|
+
this.serviceName = options.serviceName || 'navis-service';
|
|
9
|
+
this.enabled = options.enabled !== false;
|
|
10
|
+
this.spans = new Map();
|
|
11
|
+
this.traceId = null;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* Start a new trace
|
|
16
|
+
* @param {string} operationName - Operation name
|
|
17
|
+
* @param {Object} context - Trace context
|
|
18
|
+
* @returns {string} - Trace ID
|
|
19
|
+
*/
|
|
20
|
+
startTrace(operationName, context = {}) {
|
|
21
|
+
if (!this.enabled) return null;
|
|
22
|
+
|
|
23
|
+
const traceId = this._generateId();
|
|
24
|
+
const spanId = this._generateId();
|
|
25
|
+
|
|
26
|
+
this.traceId = traceId;
|
|
27
|
+
|
|
28
|
+
const span = {
|
|
29
|
+
traceId,
|
|
30
|
+
spanId,
|
|
31
|
+
operationName,
|
|
32
|
+
startTime: Date.now(),
|
|
33
|
+
tags: context.tags || {},
|
|
34
|
+
logs: [],
|
|
35
|
+
childSpans: [],
|
|
36
|
+
};
|
|
37
|
+
|
|
38
|
+
this.spans.set(spanId, span);
|
|
39
|
+
return traceId;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* Start a new span
|
|
44
|
+
* @param {string} operationName - Operation name
|
|
45
|
+
* @param {Object} options - Span options
|
|
46
|
+
* @returns {string} - Span ID
|
|
47
|
+
*/
|
|
48
|
+
startSpan(operationName, options = {}) {
|
|
49
|
+
if (!this.enabled) return null;
|
|
50
|
+
|
|
51
|
+
const spanId = this._generateId();
|
|
52
|
+
const parentSpanId = options.parentSpanId || this._getCurrentSpanId();
|
|
53
|
+
const traceId = options.traceId || this.traceId || this.startTrace(operationName);
|
|
54
|
+
|
|
55
|
+
const span = {
|
|
56
|
+
traceId,
|
|
57
|
+
spanId,
|
|
58
|
+
parentSpanId,
|
|
59
|
+
operationName,
|
|
60
|
+
startTime: Date.now(),
|
|
61
|
+
tags: options.tags || {},
|
|
62
|
+
logs: [],
|
|
63
|
+
childSpans: [],
|
|
64
|
+
};
|
|
65
|
+
|
|
66
|
+
this.spans.set(spanId, span);
|
|
67
|
+
|
|
68
|
+
// Add as child span if parent exists
|
|
69
|
+
if (parentSpanId) {
|
|
70
|
+
const parentSpan = this.spans.get(parentSpanId);
|
|
71
|
+
if (parentSpan) {
|
|
72
|
+
parentSpan.childSpans.push(spanId);
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
return spanId;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* Finish a span
|
|
81
|
+
* @param {string} spanId - Span ID
|
|
82
|
+
* @param {Object} options - Finish options
|
|
83
|
+
*/
|
|
84
|
+
finishSpan(spanId, options = {}) {
|
|
85
|
+
if (!this.enabled || !spanId) return;
|
|
86
|
+
|
|
87
|
+
const span = this.spans.get(spanId);
|
|
88
|
+
if (!span) return;
|
|
89
|
+
|
|
90
|
+
span.endTime = Date.now();
|
|
91
|
+
span.duration = span.endTime - span.startTime;
|
|
92
|
+
span.status = options.status || 'ok';
|
|
93
|
+
span.error = options.error || null;
|
|
94
|
+
|
|
95
|
+
if (options.tags) {
|
|
96
|
+
span.tags = { ...span.tags, ...options.tags };
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/**
|
|
101
|
+
* Add tag to span
|
|
102
|
+
* @param {string} spanId - Span ID
|
|
103
|
+
* @param {string} key - Tag key
|
|
104
|
+
* @param {*} value - Tag value
|
|
105
|
+
*/
|
|
106
|
+
addTag(spanId, key, value) {
|
|
107
|
+
if (!this.enabled || !spanId) return;
|
|
108
|
+
|
|
109
|
+
const span = this.spans.get(spanId);
|
|
110
|
+
if (span) {
|
|
111
|
+
span.tags[key] = value;
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/**
|
|
116
|
+
* Add log to span
|
|
117
|
+
* @param {string} spanId - Span ID
|
|
118
|
+
* @param {string} message - Log message
|
|
119
|
+
* @param {Object} fields - Log fields
|
|
120
|
+
*/
|
|
121
|
+
addLog(spanId, message, fields = {}) {
|
|
122
|
+
if (!this.enabled || !spanId) return;
|
|
123
|
+
|
|
124
|
+
const span = this.spans.get(spanId);
|
|
125
|
+
if (span) {
|
|
126
|
+
span.logs.push({
|
|
127
|
+
timestamp: Date.now(),
|
|
128
|
+
message,
|
|
129
|
+
fields,
|
|
130
|
+
});
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
/**
|
|
135
|
+
* Get trace data
|
|
136
|
+
* @param {string} traceId - Trace ID
|
|
137
|
+
* @returns {Object} - Trace data
|
|
138
|
+
*/
|
|
139
|
+
getTrace(traceId) {
|
|
140
|
+
const traceSpans = Array.from(this.spans.values()).filter(
|
|
141
|
+
span => span.traceId === traceId
|
|
142
|
+
);
|
|
143
|
+
|
|
144
|
+
return {
|
|
145
|
+
traceId,
|
|
146
|
+
spans: traceSpans,
|
|
147
|
+
duration: traceSpans.length > 0
|
|
148
|
+
? Math.max(...traceSpans.map(s => s.endTime || Date.now())) -
|
|
149
|
+
Math.min(...traceSpans.map(s => s.startTime))
|
|
150
|
+
: 0,
|
|
151
|
+
};
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
/**
|
|
155
|
+
* Get all traces
|
|
156
|
+
* @returns {Array} - All traces
|
|
157
|
+
*/
|
|
158
|
+
getAllTraces() {
|
|
159
|
+
const traceIds = new Set();
|
|
160
|
+
for (const span of this.spans.values()) {
|
|
161
|
+
traceIds.add(span.traceId);
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
return Array.from(traceIds).map(traceId => this.getTrace(traceId));
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
/**
|
|
168
|
+
* Clear old spans (keep last N)
|
|
169
|
+
* @param {number} keepCount - Number of spans to keep
|
|
170
|
+
*/
|
|
171
|
+
clearOldSpans(keepCount = 1000) {
|
|
172
|
+
const spans = Array.from(this.spans.entries());
|
|
173
|
+
if (spans.length > keepCount) {
|
|
174
|
+
// Sort by start time and keep most recent
|
|
175
|
+
spans.sort((a, b) => b[1].startTime - a[1].startTime);
|
|
176
|
+
const toKeep = spans.slice(0, keepCount);
|
|
177
|
+
this.spans.clear();
|
|
178
|
+
for (const [id, span] of toKeep) {
|
|
179
|
+
this.spans.set(id, span);
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
/**
|
|
185
|
+
* Get current span ID
|
|
186
|
+
* @private
|
|
187
|
+
*/
|
|
188
|
+
_getCurrentSpanId() {
|
|
189
|
+
// Return the most recently created span
|
|
190
|
+
const spans = Array.from(this.spans.values());
|
|
191
|
+
if (spans.length === 0) return null;
|
|
192
|
+
return spans[spans.length - 1].spanId;
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
/**
|
|
196
|
+
* Generate unique ID
|
|
197
|
+
* @private
|
|
198
|
+
*/
|
|
199
|
+
_generateId() {
|
|
200
|
+
return `${Date.now()}-${Math.random().toString(36).substr(2, 9)}`;
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
module.exports = Tracer;
|
|
205
|
+
|