llmflow 0.3.1
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 +142 -0
- package/bin/llmflow.js +91 -0
- package/db.js +857 -0
- package/logger.js +122 -0
- package/otlp-export.js +564 -0
- package/otlp-logs.js +238 -0
- package/otlp-metrics.js +300 -0
- package/otlp.js +398 -0
- package/package.json +62 -0
- package/pricing.fallback.json +58 -0
- package/pricing.js +154 -0
- package/providers/anthropic.js +195 -0
- package/providers/azure.js +159 -0
- package/providers/base.js +145 -0
- package/providers/cohere.js +225 -0
- package/providers/gemini.js +278 -0
- package/providers/index.js +130 -0
- package/providers/ollama.js +36 -0
- package/providers/openai-compatible.js +77 -0
- package/providers/openai.js +217 -0
- package/providers/passthrough.js +573 -0
- package/public/app.js +1484 -0
- package/public/index.html +367 -0
- package/public/style.css +1152 -0
- package/server.js +1222 -0
package/otlp-logs.js
ADDED
|
@@ -0,0 +1,238 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* OTLP Logs Endpoint for LLMFlow
|
|
3
|
+
*
|
|
4
|
+
* Accepts OTLP/HTTP JSON logs and stores them for AI CLI tool observability.
|
|
5
|
+
* Supports log events from Claude Code, Codex CLI, Gemini CLI, etc.
|
|
6
|
+
*
|
|
7
|
+
* Supports:
|
|
8
|
+
* - OTLP/HTTP JSON format (Content-Type: application/json)
|
|
9
|
+
* - event.name extraction for AI CLI tools
|
|
10
|
+
* - Correlation with traces via trace_id/span_id
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
const { v4: uuidv4 } = require('uuid');
|
|
14
|
+
const db = require('./db');
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Extract attributes from OTLP KeyValue array format
|
|
18
|
+
* OTLP attributes are: [{ key: "foo", value: { stringValue: "bar" } }, ...]
|
|
19
|
+
*/
|
|
20
|
+
function extractAttributes(attrs) {
|
|
21
|
+
if (!attrs || !Array.isArray(attrs)) return {};
|
|
22
|
+
|
|
23
|
+
const result = {};
|
|
24
|
+
for (const attr of attrs) {
|
|
25
|
+
const key = attr.key;
|
|
26
|
+
const val = attr.value;
|
|
27
|
+
if (!val) continue;
|
|
28
|
+
|
|
29
|
+
if (val.stringValue !== undefined) result[key] = val.stringValue;
|
|
30
|
+
else if (val.intValue !== undefined) result[key] = parseInt(val.intValue, 10);
|
|
31
|
+
else if (val.doubleValue !== undefined) result[key] = val.doubleValue;
|
|
32
|
+
else if (val.boolValue !== undefined) result[key] = val.boolValue;
|
|
33
|
+
else if (val.arrayValue?.values) {
|
|
34
|
+
result[key] = val.arrayValue.values.map(v =>
|
|
35
|
+
v.stringValue ?? v.intValue ?? v.doubleValue ?? v.boolValue ?? null
|
|
36
|
+
);
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
return result;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* Convert hex string to normalized format
|
|
44
|
+
*/
|
|
45
|
+
function normalizeId(hexId) {
|
|
46
|
+
if (!hexId) return null;
|
|
47
|
+
return hexId.replace(/-/g, '').toLowerCase();
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* Convert nanoseconds timestamp to milliseconds
|
|
52
|
+
*/
|
|
53
|
+
function nanoToMs(nanoStr) {
|
|
54
|
+
if (!nanoStr) return Date.now();
|
|
55
|
+
const nano = BigInt(nanoStr);
|
|
56
|
+
return Number(nano / BigInt(1000000));
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* Extract log body from OTLP AnyValue format
|
|
61
|
+
*/
|
|
62
|
+
function extractBody(body) {
|
|
63
|
+
if (!body) return null;
|
|
64
|
+
|
|
65
|
+
if (body.stringValue !== undefined) return body.stringValue;
|
|
66
|
+
if (body.intValue !== undefined) return String(body.intValue);
|
|
67
|
+
if (body.doubleValue !== undefined) return String(body.doubleValue);
|
|
68
|
+
if (body.boolValue !== undefined) return String(body.boolValue);
|
|
69
|
+
if (body.arrayValue?.values) {
|
|
70
|
+
return JSON.stringify(body.arrayValue.values.map(v =>
|
|
71
|
+
v.stringValue ?? v.intValue ?? v.doubleValue ?? v.boolValue ?? null
|
|
72
|
+
));
|
|
73
|
+
}
|
|
74
|
+
if (body.kvlistValue?.values) {
|
|
75
|
+
const obj = {};
|
|
76
|
+
for (const kv of body.kvlistValue.values) {
|
|
77
|
+
obj[kv.key] = kv.value?.stringValue ?? kv.value?.intValue ?? kv.value?.doubleValue ?? null;
|
|
78
|
+
}
|
|
79
|
+
return JSON.stringify(obj);
|
|
80
|
+
}
|
|
81
|
+
if (body.bytesValue) {
|
|
82
|
+
return `[binary: ${body.bytesValue.length} bytes]`;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
return JSON.stringify(body);
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* Extract event name from log attributes
|
|
90
|
+
* Common patterns for AI CLI tools
|
|
91
|
+
*/
|
|
92
|
+
function extractLogEventName(attrs) {
|
|
93
|
+
return attrs['event.name']
|
|
94
|
+
|| attrs['log.event.name']
|
|
95
|
+
|| attrs['name']
|
|
96
|
+
|| attrs['event_name']
|
|
97
|
+
|| null;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/**
|
|
101
|
+
* Map OTLP severity number to text if not provided
|
|
102
|
+
* Per OTEL spec: https://opentelemetry.io/docs/specs/otel/logs/data-model/#field-severitynumber
|
|
103
|
+
*/
|
|
104
|
+
function getSeverityText(severityNumber, providedText) {
|
|
105
|
+
if (providedText) return providedText;
|
|
106
|
+
if (!severityNumber) return null;
|
|
107
|
+
|
|
108
|
+
const severityMap = {
|
|
109
|
+
1: 'TRACE', 2: 'TRACE', 3: 'TRACE', 4: 'TRACE',
|
|
110
|
+
5: 'DEBUG', 6: 'DEBUG', 7: 'DEBUG', 8: 'DEBUG',
|
|
111
|
+
9: 'INFO', 10: 'INFO', 11: 'INFO', 12: 'INFO',
|
|
112
|
+
13: 'WARN', 14: 'WARN', 15: 'WARN', 16: 'WARN',
|
|
113
|
+
17: 'ERROR', 18: 'ERROR', 19: 'ERROR', 20: 'ERROR',
|
|
114
|
+
21: 'FATAL', 22: 'FATAL', 23: 'FATAL', 24: 'FATAL'
|
|
115
|
+
};
|
|
116
|
+
|
|
117
|
+
return severityMap[severityNumber] || 'UNSPECIFIED';
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/**
|
|
121
|
+
* Process OTLP/HTTP JSON logs request
|
|
122
|
+
*
|
|
123
|
+
* Expected format (OTLP/HTTP JSON):
|
|
124
|
+
* {
|
|
125
|
+
* "resourceLogs": [
|
|
126
|
+
* {
|
|
127
|
+
* "resource": { "attributes": [...] },
|
|
128
|
+
* "scopeLogs": [
|
|
129
|
+
* {
|
|
130
|
+
* "scope": { "name": "...", "version": "..." },
|
|
131
|
+
* "logRecords": [
|
|
132
|
+
* {
|
|
133
|
+
* "timeUnixNano": "...",
|
|
134
|
+
* "observedTimeUnixNano": "...",
|
|
135
|
+
* "severityNumber": 9,
|
|
136
|
+
* "severityText": "INFO",
|
|
137
|
+
* "body": { "stringValue": "..." },
|
|
138
|
+
* "attributes": [...],
|
|
139
|
+
* "traceId": "hex",
|
|
140
|
+
* "spanId": "hex"
|
|
141
|
+
* }
|
|
142
|
+
* ]
|
|
143
|
+
* }
|
|
144
|
+
* ]
|
|
145
|
+
* }
|
|
146
|
+
* ]
|
|
147
|
+
* }
|
|
148
|
+
*/
|
|
149
|
+
function processOtlpLogs(body) {
|
|
150
|
+
const results = {
|
|
151
|
+
accepted: 0,
|
|
152
|
+
rejected: 0,
|
|
153
|
+
errors: []
|
|
154
|
+
};
|
|
155
|
+
|
|
156
|
+
if (!body || !body.resourceLogs) {
|
|
157
|
+
return results;
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
for (const resourceLog of body.resourceLogs) {
|
|
161
|
+
const resourceAttrs = extractAttributes(resourceLog.resource?.attributes);
|
|
162
|
+
|
|
163
|
+
for (const scopeLog of (resourceLog.scopeLogs || [])) {
|
|
164
|
+
const scopeInfo = scopeLog.scope || {};
|
|
165
|
+
|
|
166
|
+
for (const logRecord of (scopeLog.logRecords || [])) {
|
|
167
|
+
try {
|
|
168
|
+
const attrs = extractAttributes(logRecord.attributes);
|
|
169
|
+
const logId = uuidv4();
|
|
170
|
+
|
|
171
|
+
db.insertLog({
|
|
172
|
+
id: logId,
|
|
173
|
+
timestamp: nanoToMs(logRecord.timeUnixNano),
|
|
174
|
+
observed_timestamp: nanoToMs(logRecord.observedTimeUnixNano),
|
|
175
|
+
severity_number: logRecord.severityNumber || null,
|
|
176
|
+
severity_text: getSeverityText(logRecord.severityNumber, logRecord.severityText),
|
|
177
|
+
body: extractBody(logRecord.body),
|
|
178
|
+
trace_id: normalizeId(logRecord.traceId),
|
|
179
|
+
span_id: normalizeId(logRecord.spanId),
|
|
180
|
+
event_name: extractLogEventName(attrs),
|
|
181
|
+
service_name: resourceAttrs['service.name'] || 'unknown',
|
|
182
|
+
scope_name: scopeInfo.name || null,
|
|
183
|
+
attributes: attrs,
|
|
184
|
+
resource_attributes: resourceAttrs
|
|
185
|
+
});
|
|
186
|
+
results.accepted++;
|
|
187
|
+
} catch (err) {
|
|
188
|
+
results.rejected++;
|
|
189
|
+
results.errors.push(err.message);
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
return results;
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
/**
|
|
199
|
+
* Express middleware for OTLP logs endpoint
|
|
200
|
+
*/
|
|
201
|
+
function createLogsHandler() {
|
|
202
|
+
return (req, res) => {
|
|
203
|
+
const contentType = req.headers['content-type'] || '';
|
|
204
|
+
|
|
205
|
+
if (!contentType.includes('application/json')) {
|
|
206
|
+
return res.status(415).json({
|
|
207
|
+
error: 'Unsupported Media Type',
|
|
208
|
+
message: 'Only application/json is supported. Use OTLP/HTTP JSON format.'
|
|
209
|
+
});
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
try {
|
|
213
|
+
const results = processOtlpLogs(req.body);
|
|
214
|
+
|
|
215
|
+
res.status(200).json({
|
|
216
|
+
partialSuccess: results.rejected > 0 ? {
|
|
217
|
+
rejectedLogRecords: results.rejected,
|
|
218
|
+
errorMessage: results.errors.slice(0, 5).join('; ')
|
|
219
|
+
} : undefined
|
|
220
|
+
});
|
|
221
|
+
} catch (err) {
|
|
222
|
+
res.status(500).json({
|
|
223
|
+
error: 'Internal Server Error',
|
|
224
|
+
message: err.message
|
|
225
|
+
});
|
|
226
|
+
}
|
|
227
|
+
};
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
module.exports = {
|
|
231
|
+
processOtlpLogs,
|
|
232
|
+
createLogsHandler,
|
|
233
|
+
extractAttributes,
|
|
234
|
+
extractBody,
|
|
235
|
+
extractLogEventName,
|
|
236
|
+
normalizeId,
|
|
237
|
+
nanoToMs
|
|
238
|
+
};
|
package/otlp-metrics.js
ADDED
|
@@ -0,0 +1,300 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* OTLP Metrics Endpoint for LLMFlow
|
|
3
|
+
*
|
|
4
|
+
* Accepts OTLP/HTTP JSON metrics and stores them for AI CLI tool observability.
|
|
5
|
+
* Supports metrics from Claude Code, Gemini CLI, etc.
|
|
6
|
+
*
|
|
7
|
+
* Supports:
|
|
8
|
+
* - OTLP/HTTP JSON format (Content-Type: application/json)
|
|
9
|
+
* - Sum (Counter), Gauge, Histogram metric types
|
|
10
|
+
* - Token usage and cost metrics extraction
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
const { v4: uuidv4 } = require('uuid');
|
|
14
|
+
const db = require('./db');
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Extract attributes from OTLP KeyValue array format
|
|
18
|
+
*/
|
|
19
|
+
function extractAttributes(attrs) {
|
|
20
|
+
if (!attrs || !Array.isArray(attrs)) return {};
|
|
21
|
+
|
|
22
|
+
const result = {};
|
|
23
|
+
for (const attr of attrs) {
|
|
24
|
+
const key = attr.key;
|
|
25
|
+
const val = attr.value;
|
|
26
|
+
if (!val) continue;
|
|
27
|
+
|
|
28
|
+
if (val.stringValue !== undefined) result[key] = val.stringValue;
|
|
29
|
+
else if (val.intValue !== undefined) result[key] = parseInt(val.intValue, 10);
|
|
30
|
+
else if (val.doubleValue !== undefined) result[key] = val.doubleValue;
|
|
31
|
+
else if (val.boolValue !== undefined) result[key] = val.boolValue;
|
|
32
|
+
else if (val.arrayValue?.values) {
|
|
33
|
+
result[key] = val.arrayValue.values.map(v =>
|
|
34
|
+
v.stringValue ?? v.intValue ?? v.doubleValue ?? v.boolValue ?? null
|
|
35
|
+
);
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
return result;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* Convert nanoseconds timestamp to milliseconds
|
|
43
|
+
*/
|
|
44
|
+
function nanoToMs(nanoStr) {
|
|
45
|
+
if (!nanoStr) return Date.now();
|
|
46
|
+
const nano = BigInt(nanoStr);
|
|
47
|
+
return Number(nano / BigInt(1000000));
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* Extract value from data point
|
|
52
|
+
* Handles asInt, asDouble, and various OTLP value formats
|
|
53
|
+
*/
|
|
54
|
+
function extractValue(dataPoint) {
|
|
55
|
+
if (dataPoint.asInt !== undefined) {
|
|
56
|
+
return { value_int: parseInt(dataPoint.asInt, 10), value_double: null };
|
|
57
|
+
}
|
|
58
|
+
if (dataPoint.asDouble !== undefined) {
|
|
59
|
+
return { value_int: null, value_double: dataPoint.asDouble };
|
|
60
|
+
}
|
|
61
|
+
if (dataPoint.value !== undefined) {
|
|
62
|
+
if (typeof dataPoint.value === 'number') {
|
|
63
|
+
if (Number.isInteger(dataPoint.value)) {
|
|
64
|
+
return { value_int: dataPoint.value, value_double: null };
|
|
65
|
+
}
|
|
66
|
+
return { value_int: null, value_double: dataPoint.value };
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
return { value_int: null, value_double: null };
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* Process Sum metric (Counter)
|
|
74
|
+
* Sum metrics have dataPoints with aggregationTemporality
|
|
75
|
+
*/
|
|
76
|
+
function processSum(metric, resourceAttrs, scopeInfo) {
|
|
77
|
+
const results = [];
|
|
78
|
+
const sum = metric.sum;
|
|
79
|
+
if (!sum || !sum.dataPoints) return results;
|
|
80
|
+
|
|
81
|
+
for (const dp of sum.dataPoints) {
|
|
82
|
+
const attrs = extractAttributes(dp.attributes);
|
|
83
|
+
const { value_int, value_double } = extractValue(dp);
|
|
84
|
+
|
|
85
|
+
results.push({
|
|
86
|
+
id: uuidv4(),
|
|
87
|
+
timestamp: nanoToMs(dp.timeUnixNano),
|
|
88
|
+
name: metric.name,
|
|
89
|
+
description: metric.description || null,
|
|
90
|
+
unit: metric.unit || null,
|
|
91
|
+
metric_type: 'sum',
|
|
92
|
+
value_int,
|
|
93
|
+
value_double,
|
|
94
|
+
histogram_data: null,
|
|
95
|
+
service_name: resourceAttrs['service.name'] || 'unknown',
|
|
96
|
+
scope_name: scopeInfo.name || null,
|
|
97
|
+
attributes: attrs,
|
|
98
|
+
resource_attributes: resourceAttrs
|
|
99
|
+
});
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
return results;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/**
|
|
106
|
+
* Process Gauge metric
|
|
107
|
+
* Gauge metrics represent point-in-time values
|
|
108
|
+
*/
|
|
109
|
+
function processGauge(metric, resourceAttrs, scopeInfo) {
|
|
110
|
+
const results = [];
|
|
111
|
+
const gauge = metric.gauge;
|
|
112
|
+
if (!gauge || !gauge.dataPoints) return results;
|
|
113
|
+
|
|
114
|
+
for (const dp of gauge.dataPoints) {
|
|
115
|
+
const attrs = extractAttributes(dp.attributes);
|
|
116
|
+
const { value_int, value_double } = extractValue(dp);
|
|
117
|
+
|
|
118
|
+
results.push({
|
|
119
|
+
id: uuidv4(),
|
|
120
|
+
timestamp: nanoToMs(dp.timeUnixNano),
|
|
121
|
+
name: metric.name,
|
|
122
|
+
description: metric.description || null,
|
|
123
|
+
unit: metric.unit || null,
|
|
124
|
+
metric_type: 'gauge',
|
|
125
|
+
value_int,
|
|
126
|
+
value_double,
|
|
127
|
+
histogram_data: null,
|
|
128
|
+
service_name: resourceAttrs['service.name'] || 'unknown',
|
|
129
|
+
scope_name: scopeInfo.name || null,
|
|
130
|
+
attributes: attrs,
|
|
131
|
+
resource_attributes: resourceAttrs
|
|
132
|
+
});
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
return results;
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
/**
|
|
139
|
+
* Process Histogram metric
|
|
140
|
+
* Histogram metrics have buckets and bounds
|
|
141
|
+
*/
|
|
142
|
+
function processHistogram(metric, resourceAttrs, scopeInfo) {
|
|
143
|
+
const results = [];
|
|
144
|
+
const histogram = metric.histogram;
|
|
145
|
+
if (!histogram || !histogram.dataPoints) return results;
|
|
146
|
+
|
|
147
|
+
for (const dp of histogram.dataPoints) {
|
|
148
|
+
const attrs = extractAttributes(dp.attributes);
|
|
149
|
+
|
|
150
|
+
const histogramData = {
|
|
151
|
+
count: parseInt(dp.count || 0, 10),
|
|
152
|
+
sum: dp.sum || 0,
|
|
153
|
+
min: dp.min,
|
|
154
|
+
max: dp.max,
|
|
155
|
+
bucketCounts: dp.bucketCounts?.map(c => parseInt(c, 10)) || [],
|
|
156
|
+
explicitBounds: dp.explicitBounds || []
|
|
157
|
+
};
|
|
158
|
+
|
|
159
|
+
results.push({
|
|
160
|
+
id: uuidv4(),
|
|
161
|
+
timestamp: nanoToMs(dp.timeUnixNano),
|
|
162
|
+
name: metric.name,
|
|
163
|
+
description: metric.description || null,
|
|
164
|
+
unit: metric.unit || null,
|
|
165
|
+
metric_type: 'histogram',
|
|
166
|
+
value_int: histogramData.count,
|
|
167
|
+
value_double: histogramData.sum,
|
|
168
|
+
histogram_data: histogramData,
|
|
169
|
+
service_name: resourceAttrs['service.name'] || 'unknown',
|
|
170
|
+
scope_name: scopeInfo.name || null,
|
|
171
|
+
attributes: attrs,
|
|
172
|
+
resource_attributes: resourceAttrs
|
|
173
|
+
});
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
return results;
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
/**
|
|
180
|
+
* Process a single metric based on its type
|
|
181
|
+
*/
|
|
182
|
+
function processMetric(metric, resourceAttrs, scopeInfo) {
|
|
183
|
+
if (metric.sum) {
|
|
184
|
+
return processSum(metric, resourceAttrs, scopeInfo);
|
|
185
|
+
}
|
|
186
|
+
if (metric.gauge) {
|
|
187
|
+
return processGauge(metric, resourceAttrs, scopeInfo);
|
|
188
|
+
}
|
|
189
|
+
if (metric.histogram) {
|
|
190
|
+
return processHistogram(metric, resourceAttrs, scopeInfo);
|
|
191
|
+
}
|
|
192
|
+
// Summary and other types - store as gauge with raw data
|
|
193
|
+
return [];
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
/**
|
|
197
|
+
* Process OTLP/HTTP JSON metrics request
|
|
198
|
+
*
|
|
199
|
+
* Expected format (OTLP/HTTP JSON):
|
|
200
|
+
* {
|
|
201
|
+
* "resourceMetrics": [
|
|
202
|
+
* {
|
|
203
|
+
* "resource": { "attributes": [...] },
|
|
204
|
+
* "scopeMetrics": [
|
|
205
|
+
* {
|
|
206
|
+
* "scope": { "name": "...", "version": "..." },
|
|
207
|
+
* "metrics": [
|
|
208
|
+
* {
|
|
209
|
+
* "name": "metric.name",
|
|
210
|
+
* "description": "...",
|
|
211
|
+
* "unit": "...",
|
|
212
|
+
* "sum": { "dataPoints": [...] }
|
|
213
|
+
* // or "gauge": { "dataPoints": [...] }
|
|
214
|
+
* // or "histogram": { "dataPoints": [...] }
|
|
215
|
+
* }
|
|
216
|
+
* ]
|
|
217
|
+
* }
|
|
218
|
+
* ]
|
|
219
|
+
* }
|
|
220
|
+
* ]
|
|
221
|
+
* }
|
|
222
|
+
*/
|
|
223
|
+
function processOtlpMetrics(body) {
|
|
224
|
+
const results = {
|
|
225
|
+
accepted: 0,
|
|
226
|
+
rejected: 0,
|
|
227
|
+
errors: []
|
|
228
|
+
};
|
|
229
|
+
|
|
230
|
+
if (!body || !body.resourceMetrics) {
|
|
231
|
+
return results;
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
for (const resourceMetric of body.resourceMetrics) {
|
|
235
|
+
const resourceAttrs = extractAttributes(resourceMetric.resource?.attributes);
|
|
236
|
+
|
|
237
|
+
for (const scopeMetric of (resourceMetric.scopeMetrics || [])) {
|
|
238
|
+
const scopeInfo = scopeMetric.scope || {};
|
|
239
|
+
|
|
240
|
+
for (const metric of (scopeMetric.metrics || [])) {
|
|
241
|
+
try {
|
|
242
|
+
const dataPoints = processMetric(metric, resourceAttrs, scopeInfo);
|
|
243
|
+
|
|
244
|
+
for (const dp of dataPoints) {
|
|
245
|
+
db.insertMetric(dp);
|
|
246
|
+
results.accepted++;
|
|
247
|
+
}
|
|
248
|
+
} catch (err) {
|
|
249
|
+
results.rejected++;
|
|
250
|
+
results.errors.push(err.message);
|
|
251
|
+
}
|
|
252
|
+
}
|
|
253
|
+
}
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
return results;
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
/**
|
|
260
|
+
* Express middleware for OTLP metrics endpoint
|
|
261
|
+
*/
|
|
262
|
+
function createMetricsHandler() {
|
|
263
|
+
return (req, res) => {
|
|
264
|
+
const contentType = req.headers['content-type'] || '';
|
|
265
|
+
|
|
266
|
+
if (!contentType.includes('application/json')) {
|
|
267
|
+
return res.status(415).json({
|
|
268
|
+
error: 'Unsupported Media Type',
|
|
269
|
+
message: 'Only application/json is supported. Use OTLP/HTTP JSON format.'
|
|
270
|
+
});
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
try {
|
|
274
|
+
const results = processOtlpMetrics(req.body);
|
|
275
|
+
|
|
276
|
+
res.status(200).json({
|
|
277
|
+
partialSuccess: results.rejected > 0 ? {
|
|
278
|
+
rejectedDataPoints: results.rejected,
|
|
279
|
+
errorMessage: results.errors.slice(0, 5).join('; ')
|
|
280
|
+
} : undefined
|
|
281
|
+
});
|
|
282
|
+
} catch (err) {
|
|
283
|
+
res.status(500).json({
|
|
284
|
+
error: 'Internal Server Error',
|
|
285
|
+
message: err.message
|
|
286
|
+
});
|
|
287
|
+
}
|
|
288
|
+
};
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
module.exports = {
|
|
292
|
+
processOtlpMetrics,
|
|
293
|
+
createMetricsHandler,
|
|
294
|
+
extractAttributes,
|
|
295
|
+
extractValue,
|
|
296
|
+
processSum,
|
|
297
|
+
processGauge,
|
|
298
|
+
processHistogram,
|
|
299
|
+
nanoToMs
|
|
300
|
+
};
|