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/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
+ };
@@ -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
+ };