observability-toolkit 1.4.0 → 1.5.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.
Files changed (36) hide show
  1. package/dist/backends/index.d.ts +93 -1
  2. package/dist/backends/index.d.ts.map +1 -1
  3. package/dist/backends/local-jsonl-boolean-search.test.d.ts +2 -0
  4. package/dist/backends/local-jsonl-boolean-search.test.d.ts.map +1 -0
  5. package/dist/backends/local-jsonl-boolean-search.test.js +154 -0
  6. package/dist/backends/local-jsonl-boolean-search.test.js.map +1 -0
  7. package/dist/backends/local-jsonl.d.ts +47 -3
  8. package/dist/backends/local-jsonl.d.ts.map +1 -1
  9. package/dist/backends/local-jsonl.js +635 -142
  10. package/dist/backends/local-jsonl.js.map +1 -1
  11. package/dist/backends/local-jsonl.test.js +2108 -133
  12. package/dist/backends/local-jsonl.test.js.map +1 -1
  13. package/dist/backends/signoz-api.d.ts +13 -1
  14. package/dist/backends/signoz-api.d.ts.map +1 -1
  15. package/dist/backends/signoz-api.js +326 -0
  16. package/dist/backends/signoz-api.js.map +1 -1
  17. package/dist/backends/signoz-api.test.js +320 -0
  18. package/dist/backends/signoz-api.test.js.map +1 -1
  19. package/dist/lib/cache.d.ts +20 -0
  20. package/dist/lib/cache.d.ts.map +1 -0
  21. package/dist/lib/cache.js +63 -0
  22. package/dist/lib/cache.js.map +1 -0
  23. package/dist/lib/indexer.d.ts +78 -0
  24. package/dist/lib/indexer.d.ts.map +1 -0
  25. package/dist/lib/indexer.js +277 -0
  26. package/dist/lib/indexer.js.map +1 -0
  27. package/dist/lib/indexer.test.d.ts +2 -0
  28. package/dist/lib/indexer.test.d.ts.map +1 -0
  29. package/dist/lib/indexer.test.js +392 -0
  30. package/dist/lib/indexer.test.js.map +1 -0
  31. package/dist/lib/otlp-export.d.ts +178 -0
  32. package/dist/lib/otlp-export.d.ts.map +1 -0
  33. package/dist/lib/otlp-export.js +382 -0
  34. package/dist/lib/otlp-export.js.map +1 -0
  35. package/dist/tools/query-logs.d.ts +4 -4
  36. package/package.json +1 -1
@@ -5,8 +5,11 @@
5
5
  * span or log record, not the batched OpenTelemetry export format.
6
6
  */
7
7
  import { join } from 'path';
8
+ import { convertToOTLPTraces, convertToOTLPLogs, convertToOTLPMetrics, } from '../lib/otlp-export.js';
8
9
  import { TELEMETRY_DIR, getTelemetryDirectories, getSpanKind, getStatusCodeName } from '../lib/constants.js';
9
10
  import { listFiles, streamJsonl, parseDateFromFilename, getDateString, paginateResults, hasReachedLimit, } from '../lib/file-utils.js';
11
+ import { QueryCache, makeCacheKey } from '../lib/cache.js';
12
+ import { getIndexPath, readIndex, isIndexStale, queryIndex, readLinesByNumber, } from '../lib/indexer.js';
10
13
  import { existsSync } from 'fs';
11
14
  /**
12
15
  * Insert item into a sorted array, maintaining sort order and max size.
@@ -36,6 +39,107 @@ function insertSortedBounded(arr, item, maxSize, compareFn) {
36
39
  arr.pop();
37
40
  }
38
41
  }
42
+ /**
43
+ * Apply numeric filter conditions to an attributes object.
44
+ * Returns true if all conditions pass, false otherwise.
45
+ */
46
+ function applyNumericFilters(attributes, filters) {
47
+ for (const filter of filters) {
48
+ const attrValue = attributes?.[filter.attribute];
49
+ // Skip if attribute doesn't exist or isn't a number
50
+ if (typeof attrValue !== 'number') {
51
+ return false;
52
+ }
53
+ // Apply the comparison
54
+ switch (filter.operator) {
55
+ case 'gt':
56
+ if (!(attrValue > filter.value))
57
+ return false;
58
+ break;
59
+ case 'gte':
60
+ if (!(attrValue >= filter.value))
61
+ return false;
62
+ break;
63
+ case 'lt':
64
+ if (!(attrValue < filter.value))
65
+ return false;
66
+ break;
67
+ case 'lte':
68
+ if (!(attrValue <= filter.value))
69
+ return false;
70
+ break;
71
+ case 'eq':
72
+ if (!(attrValue === filter.value))
73
+ return false;
74
+ break;
75
+ }
76
+ }
77
+ return true;
78
+ }
79
+ /**
80
+ * Parse time bucket string to milliseconds
81
+ * Supports: '1m', '5m', '1h', '1d', etc.
82
+ */
83
+ function parseTimeBucket(bucket) {
84
+ const match = bucket.match(/^(\d+)(m|h|d)$/);
85
+ if (!match)
86
+ return null;
87
+ const value = parseInt(match[1], 10);
88
+ const unit = match[2];
89
+ switch (unit) {
90
+ case 'm':
91
+ return value * 60 * 1000; // minutes to ms
92
+ case 'h':
93
+ return value * 60 * 60 * 1000; // hours to ms
94
+ case 'd':
95
+ return value * 24 * 60 * 60 * 1000; // days to ms
96
+ default:
97
+ return null;
98
+ }
99
+ }
100
+ /**
101
+ * Floor timestamp to bucket boundary
102
+ */
103
+ function floorToBucket(timestamp, bucketMs) {
104
+ const ts = new Date(timestamp).getTime();
105
+ return Math.floor(ts / bucketMs) * bucketMs;
106
+ }
107
+ /**
108
+ * Extract a field from an object using dot notation path.
109
+ * Returns undefined if the path doesn't exist or the object is not traversable.
110
+ */
111
+ function extractField(obj, path) {
112
+ const parts = path.split('.');
113
+ let current = obj;
114
+ for (const part of parts) {
115
+ if (current == null || typeof current !== 'object')
116
+ return undefined;
117
+ current = current[part];
118
+ }
119
+ return current;
120
+ }
121
+ /**
122
+ * Extract multiple fields from a JSON string body.
123
+ * Returns undefined if body is not valid JSON or doesn't start with '{'.
124
+ */
125
+ function extractFieldsFromBody(body, fields) {
126
+ if (!body.startsWith('{'))
127
+ return undefined;
128
+ try {
129
+ const parsed = JSON.parse(body);
130
+ const extracted = {};
131
+ for (const field of fields) {
132
+ const value = extractField(parsed, field);
133
+ if (value !== undefined) {
134
+ extracted[field] = value;
135
+ }
136
+ }
137
+ return Object.keys(extracted).length > 0 ? extracted : undefined;
138
+ }
139
+ catch {
140
+ return undefined;
141
+ }
142
+ }
39
143
  /**
40
144
  * OTel-compliant severity number mapping
41
145
  * https://opentelemetry.io/docs/specs/otel/logs/data-model/#field-severitynumber
@@ -77,6 +181,21 @@ function normalizeSpan(raw) {
77
181
  if (raw.resource?.serviceVersion) {
78
182
  attributes['service.version'] = raw.resource.serviceVersion;
79
183
  }
184
+ // Normalize span links
185
+ let links;
186
+ if (raw.links && raw.links.length > 0) {
187
+ links = raw.links
188
+ .filter(link => link.context?.traceId && link.context?.spanId)
189
+ .map(link => ({
190
+ traceId: link.context.traceId,
191
+ spanId: link.context.spanId,
192
+ attributes: link.attributes,
193
+ }));
194
+ // Only include if we have valid links
195
+ if (links.length === 0) {
196
+ links = undefined;
197
+ }
198
+ }
80
199
  return {
81
200
  traceId: raw.traceId,
82
201
  spanId: raw.spanId,
@@ -89,12 +208,14 @@ function normalizeSpan(raw) {
89
208
  status: raw.status?.code !== undefined ? { code: raw.status.code, message: raw.status.message } : undefined,
90
209
  statusCode: getStatusCodeName(raw.status?.code),
91
210
  attributes,
211
+ links,
212
+ instrumentationScope: raw.instrumentationScope,
92
213
  };
93
214
  }
94
215
  /**
95
216
  * Convert flat log to normalized LogRecord
96
217
  */
97
- function normalizeLog(raw) {
218
+ function normalizeLog(raw, extractFields) {
98
219
  const attributes = { ...raw.attributes };
99
220
  if (raw.resource?.serviceName) {
100
221
  attributes['service.name'] = raw.resource.serviceName;
@@ -110,16 +231,63 @@ function normalizeLog(raw) {
110
231
  }
111
232
  const severity = raw.severityText || raw.severity || 'INFO';
112
233
  const severityNumber = SEVERITY_MAP[severity.toUpperCase()];
234
+ const body = raw.body || '';
235
+ // Extract fields from JSON body if requested
236
+ let extractedFields;
237
+ if (extractFields && extractFields.length > 0) {
238
+ extractedFields = extractFieldsFromBody(body, extractFields);
239
+ }
113
240
  return {
114
241
  timestamp,
115
242
  severity,
116
243
  severityNumber,
117
- body: raw.body || '',
244
+ body,
118
245
  traceId: raw.traceId,
119
246
  spanId: raw.spanId,
120
247
  attributes,
248
+ instrumentationScope: raw.instrumentationScope,
249
+ extractedFields,
121
250
  };
122
251
  }
252
+ /**
253
+ * Convert OTel aggregation temporality to string representation
254
+ * OTel spec: 0=UNSPECIFIED, 1=DELTA, 2=CUMULATIVE
255
+ * https://opentelemetry.io/docs/specs/otel/metrics/data-model/#temporality
256
+ */
257
+ function normalizeAggregationTemporality(value) {
258
+ if (value === undefined)
259
+ return undefined;
260
+ // Handle string values
261
+ if (typeof value === 'string') {
262
+ const upper = value.toUpperCase();
263
+ if (upper === 'DELTA' || upper === 'CUMULATIVE' || upper === 'UNSPECIFIED') {
264
+ return upper;
265
+ }
266
+ return 'UNSPECIFIED';
267
+ }
268
+ // Handle numeric values (OTel spec)
269
+ switch (value) {
270
+ case 1:
271
+ return 'DELTA';
272
+ case 2:
273
+ return 'CUMULATIVE';
274
+ case 0:
275
+ default:
276
+ return 'UNSPECIFIED';
277
+ }
278
+ }
279
+ /**
280
+ * Normalize a flat exemplar timestamp to ISO string
281
+ */
282
+ function normalizeExemplarTimestamp(ts, metricTimestamp) {
283
+ if (!ts)
284
+ return metricTimestamp;
285
+ if (typeof ts === 'string')
286
+ return ts;
287
+ // Convert [seconds, nanoseconds] to ISO string
288
+ const ms = ts[0] * 1000 + Math.floor(ts[1] / 1_000_000);
289
+ return new Date(ms).toISOString();
290
+ }
123
291
  /**
124
292
  * Convert flat metric to normalized MetricDataPoint
125
293
  */
@@ -128,12 +296,40 @@ function normalizeMetric(raw) {
128
296
  if (raw.resource?.serviceName) {
129
297
  attributes['service.name'] = raw.resource.serviceName;
130
298
  }
299
+ // Build histogram data if present
300
+ let histogram;
301
+ if (raw.histogram && raw.type === 'histogram') {
302
+ histogram = {
303
+ buckets: raw.histogram.buckets.map(b => ({
304
+ le: b.le,
305
+ count: b.count,
306
+ })),
307
+ sum: raw.histogram.sum,
308
+ count: raw.histogram.count,
309
+ };
310
+ }
311
+ // Normalize exemplars if present
312
+ let exemplars;
313
+ if (raw.exemplars && raw.exemplars.length > 0) {
314
+ exemplars = raw.exemplars.map(e => ({
315
+ timestamp: normalizeExemplarTimestamp(e.timestamp, raw.timestamp),
316
+ value: e.value,
317
+ traceId: e.traceId,
318
+ spanId: e.spanId,
319
+ attributes: e.attributes,
320
+ }));
321
+ }
322
+ // Normalize aggregation temporality
323
+ const aggregationTemporality = normalizeAggregationTemporality(raw.aggregationTemporality);
131
324
  return {
132
325
  timestamp: raw.timestamp,
133
326
  name: raw.name,
134
327
  value: raw.value,
135
328
  unit: raw.unit,
136
329
  attributes,
330
+ histogram,
331
+ exemplars,
332
+ aggregationTemporality,
137
333
  };
138
334
  }
139
335
  /**
@@ -163,163 +359,325 @@ function getFilesInRange(dir, pattern, startDate, endDate) {
163
359
  export class LocalJsonlBackend {
164
360
  name = 'local-jsonl';
165
361
  telemetryDir;
166
- constructor(telemetryDir) {
362
+ traceCache = new QueryCache();
363
+ logCache = new QueryCache();
364
+ metricCache = new QueryCache();
365
+ llmEventCache = new QueryCache();
366
+ useIndexes;
367
+ constructor(telemetryDir, useIndexes = true) {
167
368
  this.telemetryDir = telemetryDir || TELEMETRY_DIR;
369
+ this.useIndexes = useIndexes;
370
+ }
371
+ /**
372
+ * Clear all query caches
373
+ */
374
+ clearCache() {
375
+ this.traceCache.clear();
376
+ this.logCache.clear();
377
+ this.metricCache.clear();
378
+ this.llmEventCache.clear();
379
+ }
380
+ /**
381
+ * Try to use an index for a file, returning matching line numbers or null if full scan needed
382
+ */
383
+ tryUseIndex(file, _type, indexOptions) {
384
+ if (!this.useIndexes)
385
+ return null;
386
+ const idxPath = getIndexPath(file);
387
+ const index = readIndex(idxPath);
388
+ if (!index)
389
+ return null;
390
+ if (isIndexStale(index, file))
391
+ return null;
392
+ return queryIndex(index, indexOptions);
168
393
  }
169
394
  async queryTraces(options) {
395
+ // Check cache first
396
+ const cacheKey = makeCacheKey('traces', options);
397
+ const cached = this.traceCache.get(cacheKey);
398
+ if (cached)
399
+ return cached;
170
400
  const files = getFilesInRange(this.telemetryDir, /traces-\d{4}-\d{2}-\d{2}\.jsonl$/, options.startDate, options.endDate);
171
401
  const results = [];
172
402
  const limit = options.limit || 100;
173
403
  const offset = options.offset || 0;
404
+ // Compile regex once outside the loop, handling invalid patterns gracefully
405
+ let spanNameRegex = null;
406
+ if (options.spanNameRegex) {
407
+ try {
408
+ spanNameRegex = new RegExp(options.spanNameRegex);
409
+ }
410
+ catch {
411
+ console.warn(`Invalid spanNameRegex pattern: ${options.spanNameRegex}`);
412
+ }
413
+ }
414
+ // Build index query options for indexable filters
415
+ const indexOptions = {
416
+ traceId: options.traceId,
417
+ spanName: options.spanName,
418
+ serviceName: options.serviceName,
419
+ };
420
+ // Helper to apply non-indexable filters to a span
421
+ const applyFilters = (span) => {
422
+ // Regex filter (not indexable)
423
+ if (spanNameRegex && !spanNameRegex.test(span.name))
424
+ return false;
425
+ if (options.excludeSpanName && span.name.includes(options.excludeSpanName))
426
+ return false;
427
+ if (options.minDurationMs && (span.durationMs || 0) < options.minDurationMs)
428
+ return false;
429
+ if (options.maxDurationMs && (span.durationMs || Infinity) > options.maxDurationMs)
430
+ return false;
431
+ // Apply attribute filter
432
+ if (options.attributeFilter) {
433
+ for (const [key, value] of Object.entries(options.attributeFilter)) {
434
+ if (span.attributes?.[key] !== value)
435
+ return false;
436
+ }
437
+ }
438
+ // Apply attributeExists filter
439
+ if (options.attributeExists) {
440
+ for (const key of options.attributeExists) {
441
+ if (span.attributes?.[key] === undefined)
442
+ return false;
443
+ }
444
+ }
445
+ // Apply attributeNotExists filter
446
+ if (options.attributeNotExists) {
447
+ for (const key of options.attributeNotExists) {
448
+ if (span.attributes?.[key] !== undefined)
449
+ return false;
450
+ }
451
+ }
452
+ // Apply numeric filter conditions
453
+ if (options.numericFilter && options.numericFilter.length > 0) {
454
+ if (!applyNumericFilters(span.attributes, options.numericFilter))
455
+ return false;
456
+ }
457
+ return true;
458
+ };
174
459
  for (const file of files) {
175
- // Stream flat span records (one per line) to avoid loading entire file into memory
176
- for await (const raw of streamJsonl(file)) {
177
- const span = normalizeSpan(raw);
178
- if (!span)
179
- continue;
180
- // Apply filters
181
- if (options.traceId && span.traceId !== options.traceId)
182
- continue;
183
- if (options.spanName && !span.name.includes(options.spanName))
184
- continue;
185
- if (options.excludeSpanName && span.name.includes(options.excludeSpanName))
186
- continue;
187
- if (options.minDurationMs && (span.durationMs || 0) < options.minDurationMs)
188
- continue;
189
- if (options.maxDurationMs && (span.durationMs || Infinity) > options.maxDurationMs)
190
- continue;
191
- if (options.serviceName) {
192
- const svc = span.attributes?.['service.name'];
193
- if (svc !== options.serviceName)
460
+ // Try to use index for pre-filtering
461
+ const matchingLines = this.tryUseIndex(file, 'traces', indexOptions);
462
+ if (matchingLines !== null) {
463
+ // Use indexed query - read only matching lines
464
+ const rawRecords = await readLinesByNumber(file, matchingLines);
465
+ for (const raw of rawRecords) {
466
+ const span = normalizeSpan(raw);
467
+ if (!span)
194
468
  continue;
195
- }
196
- // Apply attribute filter
197
- if (options.attributeFilter) {
198
- let matches = true;
199
- for (const [key, value] of Object.entries(options.attributeFilter)) {
200
- if (span.attributes?.[key] !== value) {
201
- matches = false;
202
- break;
203
- }
204
- }
205
- if (!matches)
469
+ if (!applyFilters(span))
206
470
  continue;
207
- }
208
- // Apply attributeExists filter - all specified attributes must exist
209
- if (options.attributeExists) {
210
- let allExist = true;
211
- for (const key of options.attributeExists) {
212
- if (span.attributes?.[key] === undefined) {
213
- allExist = false;
214
- break;
215
- }
471
+ results.push(span);
472
+ if (hasReachedLimit(results.length, offset, limit)) {
473
+ const paginated = paginateResults(results, offset, limit);
474
+ this.traceCache.set(cacheKey, paginated);
475
+ return paginated;
216
476
  }
217
- if (!allExist)
218
- continue;
219
477
  }
220
- // Apply attributeNotExists filter - exclude if any specified attribute exists
221
- if (options.attributeNotExists) {
222
- let anyExist = false;
223
- for (const key of options.attributeNotExists) {
224
- if (span.attributes?.[key] !== undefined) {
225
- anyExist = true;
226
- break;
227
- }
478
+ }
479
+ else {
480
+ // Fall back to full file scan
481
+ for await (const raw of streamJsonl(file)) {
482
+ const span = normalizeSpan(raw);
483
+ if (!span)
484
+ continue;
485
+ // Apply indexable filters (since no index was used)
486
+ if (options.traceId && span.traceId !== options.traceId)
487
+ continue;
488
+ if (options.spanName && !span.name.includes(options.spanName))
489
+ continue;
490
+ if (options.serviceName) {
491
+ const svc = span.attributes?.['service.name'];
492
+ if (svc !== options.serviceName)
493
+ continue;
228
494
  }
229
- if (anyExist)
495
+ if (!applyFilters(span))
230
496
  continue;
231
- }
232
- results.push(span);
233
- if (hasReachedLimit(results.length, offset, limit)) {
234
- return paginateResults(results, offset, limit);
497
+ results.push(span);
498
+ if (hasReachedLimit(results.length, offset, limit)) {
499
+ const paginated = paginateResults(results, offset, limit);
500
+ this.traceCache.set(cacheKey, paginated);
501
+ return paginated;
502
+ }
235
503
  }
236
504
  }
237
505
  }
238
- return paginateResults(results, offset, limit);
506
+ const paginated = paginateResults(results, offset, limit);
507
+ this.traceCache.set(cacheKey, paginated);
508
+ return paginated;
239
509
  }
240
510
  async queryLogs(options) {
511
+ // Check cache first
512
+ const cacheKey = makeCacheKey('logs', options);
513
+ const cached = this.logCache.get(cacheKey);
514
+ if (cached)
515
+ return cached;
241
516
  const files = getFilesInRange(this.telemetryDir, /logs-\d{4}-\d{2}-\d{2}\.jsonl$/, options.startDate, options.endDate);
242
517
  const results = [];
243
518
  const limit = options.limit || 100;
244
519
  const offset = options.offset || 0;
520
+ // Build index query options for indexable filters
521
+ const indexOptions = {
522
+ traceId: options.traceId,
523
+ severity: options.severity,
524
+ };
525
+ // Helper to apply non-indexable filters to a log
526
+ const applyFilters = (log) => {
527
+ if (options.search && !log.body.toLowerCase().includes(options.search.toLowerCase()))
528
+ return false;
529
+ // Apply boolean search with multiple terms
530
+ if (options.searchTerms && options.searchTerms.length > 0) {
531
+ const bodyLower = log.body.toLowerCase();
532
+ const operator = options.searchOperator || 'AND';
533
+ if (operator === 'AND') {
534
+ const allMatch = options.searchTerms.every(term => bodyLower.includes(term.toLowerCase()));
535
+ if (!allMatch)
536
+ return false;
537
+ }
538
+ else {
539
+ const anyMatch = options.searchTerms.some(term => bodyLower.includes(term.toLowerCase()));
540
+ if (!anyMatch)
541
+ return false;
542
+ }
543
+ }
544
+ if (options.excludeSearch && log.body.toLowerCase().includes(options.excludeSearch.toLowerCase()))
545
+ return false;
546
+ // Apply attributeExists filter
547
+ if (options.attributeExists) {
548
+ for (const key of options.attributeExists) {
549
+ if (log.attributes?.[key] === undefined)
550
+ return false;
551
+ }
552
+ }
553
+ // Apply attributeNotExists filter
554
+ if (options.attributeNotExists) {
555
+ for (const key of options.attributeNotExists) {
556
+ if (log.attributes?.[key] !== undefined)
557
+ return false;
558
+ }
559
+ }
560
+ // Apply numeric filter conditions
561
+ if (options.numericFilter && options.numericFilter.length > 0) {
562
+ if (!applyNumericFilters(log.attributes, options.numericFilter))
563
+ return false;
564
+ }
565
+ return true;
566
+ };
245
567
  for (const file of files) {
246
- // Stream flat log records (one per line) to avoid loading entire file into memory
247
- for await (const raw of streamJsonl(file)) {
248
- const log = normalizeLog(raw);
249
- if (!log)
250
- continue;
251
- // Apply filters
252
- if (options.severity && log.severity.toUpperCase() !== options.severity.toUpperCase())
253
- continue;
254
- if (options.traceId && log.traceId !== options.traceId)
255
- continue;
256
- if (options.search && !log.body.toLowerCase().includes(options.search.toLowerCase()))
257
- continue;
258
- if (options.excludeSearch && log.body.toLowerCase().includes(options.excludeSearch.toLowerCase()))
259
- continue;
260
- // Apply attributeExists filter - all specified attributes must exist
261
- if (options.attributeExists) {
262
- let allExist = true;
263
- for (const key of options.attributeExists) {
264
- if (log.attributes?.[key] === undefined) {
265
- allExist = false;
266
- break;
267
- }
268
- }
269
- if (!allExist)
568
+ // Try to use index for pre-filtering
569
+ const matchingLines = this.tryUseIndex(file, 'logs', indexOptions);
570
+ if (matchingLines !== null) {
571
+ // Use indexed query - read only matching lines
572
+ const rawRecords = await readLinesByNumber(file, matchingLines);
573
+ for (const raw of rawRecords) {
574
+ const log = normalizeLog(raw, options.extractFields);
575
+ if (!log)
270
576
  continue;
271
- }
272
- // Apply attributeNotExists filter - exclude if any specified attribute exists
273
- if (options.attributeNotExists) {
274
- let anyExist = false;
275
- for (const key of options.attributeNotExists) {
276
- if (log.attributes?.[key] !== undefined) {
277
- anyExist = true;
278
- break;
279
- }
280
- }
281
- if (anyExist)
577
+ if (!applyFilters(log))
282
578
  continue;
579
+ results.push(log);
580
+ if (hasReachedLimit(results.length, offset, limit)) {
581
+ const paginated = paginateResults(results, offset, limit);
582
+ this.logCache.set(cacheKey, paginated);
583
+ return paginated;
584
+ }
283
585
  }
284
- results.push(log);
285
- if (hasReachedLimit(results.length, offset, limit)) {
286
- return paginateResults(results, offset, limit);
586
+ }
587
+ else {
588
+ // Fall back to full file scan
589
+ for await (const raw of streamJsonl(file)) {
590
+ const log = normalizeLog(raw, options.extractFields);
591
+ if (!log)
592
+ continue;
593
+ // Apply indexable filters (since no index was used)
594
+ if (options.severity && log.severity.toUpperCase() !== options.severity.toUpperCase())
595
+ continue;
596
+ if (options.traceId && log.traceId !== options.traceId)
597
+ continue;
598
+ if (!applyFilters(log))
599
+ continue;
600
+ results.push(log);
601
+ if (hasReachedLimit(results.length, offset, limit)) {
602
+ const paginated = paginateResults(results, offset, limit);
603
+ this.logCache.set(cacheKey, paginated);
604
+ return paginated;
605
+ }
287
606
  }
288
607
  }
289
608
  }
290
- return paginateResults(results, offset, limit);
609
+ const paginated = paginateResults(results, offset, limit);
610
+ this.logCache.set(cacheKey, paginated);
611
+ return paginated;
291
612
  }
292
613
  async queryMetrics(options) {
614
+ // Check cache first
615
+ const cacheKey = makeCacheKey('metrics', options);
616
+ const cached = this.metricCache.get(cacheKey);
617
+ if (cached)
618
+ return cached;
293
619
  const files = getFilesInRange(this.telemetryDir, /metrics-\d{4}-\d{2}-\d{2}\.jsonl$/, options.startDate, options.endDate);
294
620
  const results = [];
295
621
  const limit = options.limit || 100;
296
622
  const offset = options.offset || 0;
623
+ // Build index query options for indexable filters
624
+ const indexOptions = {
625
+ metricName: options.metricName,
626
+ };
297
627
  outer: for (const file of files) {
298
- // Stream flat metric records (one per line) to avoid loading entire file into memory
299
- for await (const raw of streamJsonl(file)) {
300
- const point = normalizeMetric(raw);
301
- if (!point)
302
- continue;
303
- // Apply filters
304
- if (options.metricName && !point.name.includes(options.metricName))
305
- continue;
306
- results.push(point);
307
- if (hasReachedLimit(results.length, offset, limit)) {
308
- break outer;
628
+ // Try to use index for pre-filtering
629
+ const matchingLines = this.tryUseIndex(file, 'metrics', indexOptions);
630
+ if (matchingLines !== null) {
631
+ // Use indexed query - read only matching lines
632
+ const rawRecords = await readLinesByNumber(file, matchingLines);
633
+ for (const raw of rawRecords) {
634
+ const point = normalizeMetric(raw);
635
+ if (!point)
636
+ continue;
637
+ results.push(point);
638
+ if (hasReachedLimit(results.length, offset, limit)) {
639
+ break outer;
640
+ }
641
+ }
642
+ }
643
+ else {
644
+ // Fall back to full file scan
645
+ for await (const raw of streamJsonl(file)) {
646
+ const point = normalizeMetric(raw);
647
+ if (!point)
648
+ continue;
649
+ // Apply filters (since no index was used)
650
+ if (options.metricName && !point.name.includes(options.metricName))
651
+ continue;
652
+ results.push(point);
653
+ if (hasReachedLimit(results.length, offset, limit)) {
654
+ break outer;
655
+ }
309
656
  }
310
657
  }
311
658
  }
312
659
  // Apply aggregation if requested
313
660
  if (options.aggregation && results.length > 0) {
314
- return this.aggregate(results, options.aggregation, options.groupBy);
661
+ const aggregated = this.aggregate(results, options.aggregation, options.groupBy, options.timeBucket);
662
+ this.metricCache.set(cacheKey, aggregated);
663
+ return aggregated;
315
664
  }
316
- return paginateResults(results, offset, limit);
665
+ const paginated = paginateResults(results, offset, limit);
666
+ this.metricCache.set(cacheKey, paginated);
667
+ return paginated;
317
668
  }
318
- aggregate(points, aggregation, groupBy) {
319
- // Group by metric name and optional attributes
669
+ aggregate(points, aggregation, groupBy, timeBucket) {
670
+ // Parse time bucket if provided
671
+ const bucketMs = timeBucket ? parseTimeBucket(timeBucket) : null;
672
+ // Group by metric name, optional attributes, and time bucket
320
673
  const groups = new Map();
321
674
  for (const point of points) {
322
675
  let key = point.name;
676
+ // Add time bucket to key if specified
677
+ if (bucketMs) {
678
+ const bucketTimestamp = floorToBucket(point.timestamp, bucketMs);
679
+ key += `|_bucket=${bucketTimestamp}`;
680
+ }
323
681
  if (groupBy) {
324
682
  for (const attr of groupBy) {
325
683
  const val = point.attributes?.[attr];
@@ -352,57 +710,150 @@ export class LocalJsonlBackend {
352
710
  case 'count':
353
711
  value = values.length;
354
712
  break;
713
+ case 'p50':
714
+ value = this.calculatePercentile(values, 50);
715
+ break;
716
+ case 'p95':
717
+ value = this.calculatePercentile(values, 95);
718
+ break;
719
+ case 'p99':
720
+ value = this.calculatePercentile(values, 99);
721
+ break;
722
+ case 'rate':
723
+ value = this.calculateRate(group);
724
+ break;
725
+ }
726
+ // Use bucket timestamp if time bucketing is enabled
727
+ let timestamp = group[group.length - 1].timestamp;
728
+ if (bucketMs) {
729
+ const bucketTimestamp = floorToBucket(group[0].timestamp, bucketMs);
730
+ timestamp = new Date(bucketTimestamp).toISOString();
355
731
  }
356
732
  results.push({
357
- timestamp: group[group.length - 1].timestamp,
733
+ timestamp,
358
734
  name: group[0].name,
359
735
  value,
360
736
  unit: group[0].unit,
361
737
  attributes: group[0].attributes,
362
738
  });
363
739
  }
740
+ // Sort results by timestamp when using time buckets
741
+ if (bucketMs) {
742
+ results.sort((a, b) => new Date(a.timestamp).getTime() - new Date(b.timestamp).getTime());
743
+ }
364
744
  return results;
365
745
  }
746
+ calculatePercentile(values, percentile) {
747
+ if (values.length === 0)
748
+ return 0;
749
+ const sorted = [...values].sort((a, b) => a - b);
750
+ const index = Math.ceil((percentile / 100) * sorted.length) - 1;
751
+ return sorted[Math.max(0, index)];
752
+ }
753
+ /**
754
+ * Calculate rate of change per second.
755
+ * Rate = (last_value - first_value) / duration_in_seconds
756
+ * Edge cases: single value returns 0, same timestamp returns 0
757
+ */
758
+ calculateRate(group) {
759
+ if (group.length < 2)
760
+ return 0;
761
+ // Sort by timestamp
762
+ const sorted = [...group].sort((a, b) => new Date(a.timestamp).getTime() - new Date(b.timestamp).getTime());
763
+ const firstPoint = sorted[0];
764
+ const lastPoint = sorted[sorted.length - 1];
765
+ const firstTime = new Date(firstPoint.timestamp).getTime();
766
+ const lastTime = new Date(lastPoint.timestamp).getTime();
767
+ const durationMs = lastTime - firstTime;
768
+ // Avoid division by zero when timestamps are the same
769
+ if (durationMs === 0)
770
+ return 0;
771
+ const durationSeconds = durationMs / 1000;
772
+ return (lastPoint.value - firstPoint.value) / durationSeconds;
773
+ }
366
774
  async queryLLMEvents(options) {
775
+ // Check cache first
776
+ const cacheKey = makeCacheKey('llm-events', options);
777
+ const cached = this.llmEventCache.get(cacheKey);
778
+ if (cached)
779
+ return cached;
367
780
  const files = getFilesInRange(this.telemetryDir, /llm-events-\d{4}-\d{2}-\d{2}\.jsonl$/, options.startDate, options.endDate);
368
781
  const results = [];
369
782
  const limit = options.limit || 100;
370
783
  const offset = options.offset || 0;
784
+ // Build index query options - eventName maps to spanName in index
785
+ const indexOptions = {
786
+ spanName: options.eventName,
787
+ };
788
+ // Helper to apply non-indexable filters to an event
789
+ const applyFilters = (event) => {
790
+ if (options.model) {
791
+ const model = event.attributes?.['gen_ai.request.model'] || event.attributes?.['model'];
792
+ if (model !== options.model)
793
+ return false;
794
+ }
795
+ if (options.provider) {
796
+ const provider = event.attributes?.['gen_ai.system'] || event.attributes?.['provider'];
797
+ if (provider !== options.provider)
798
+ return false;
799
+ }
800
+ if (options.search) {
801
+ const searchLower = options.search.toLowerCase();
802
+ const attrStr = JSON.stringify(event.attributes).toLowerCase();
803
+ if (!attrStr.includes(searchLower) && !event.name.toLowerCase().includes(searchLower))
804
+ return false;
805
+ }
806
+ return true;
807
+ };
371
808
  for (const file of files) {
372
- // Stream LLM event records (one per line) to avoid loading entire file into memory
373
- for await (const event of streamJsonl(file)) {
374
- if (!event.timestamp || !event.name)
375
- continue;
376
- // Apply filters
377
- if (options.eventName && !event.name.includes(options.eventName))
378
- continue;
379
- if (options.model) {
380
- const model = event.attributes?.['gen_ai.request.model'] || event.attributes?.['model'];
381
- if (model !== options.model)
809
+ // Try to use index for pre-filtering
810
+ const matchingLines = this.tryUseIndex(file, 'llm-events', indexOptions);
811
+ if (matchingLines !== null) {
812
+ // Use indexed query - read only matching lines
813
+ const rawRecords = await readLinesByNumber(file, matchingLines);
814
+ for (const event of rawRecords) {
815
+ if (!event.timestamp || !event.name)
382
816
  continue;
383
- }
384
- if (options.provider) {
385
- const provider = event.attributes?.['gen_ai.system'] || event.attributes?.['provider'];
386
- if (provider !== options.provider)
817
+ if (!applyFilters(event))
387
818
  continue;
819
+ results.push({
820
+ timestamp: event.timestamp,
821
+ name: event.name,
822
+ attributes: event.attributes,
823
+ });
824
+ if (hasReachedLimit(results.length, offset, limit)) {
825
+ const paginated = paginateResults(results, offset, limit);
826
+ this.llmEventCache.set(cacheKey, paginated);
827
+ return paginated;
828
+ }
388
829
  }
389
- if (options.search) {
390
- const searchLower = options.search.toLowerCase();
391
- const attrStr = JSON.stringify(event.attributes).toLowerCase();
392
- if (!attrStr.includes(searchLower) && !event.name.toLowerCase().includes(searchLower))
830
+ }
831
+ else {
832
+ // Fall back to full file scan
833
+ for await (const event of streamJsonl(file)) {
834
+ if (!event.timestamp || !event.name)
393
835
  continue;
394
- }
395
- results.push({
396
- timestamp: event.timestamp,
397
- name: event.name,
398
- attributes: event.attributes,
399
- });
400
- if (hasReachedLimit(results.length, offset, limit)) {
401
- return paginateResults(results, offset, limit);
836
+ // Apply indexable filters (since no index was used)
837
+ if (options.eventName && !event.name.includes(options.eventName))
838
+ continue;
839
+ if (!applyFilters(event))
840
+ continue;
841
+ results.push({
842
+ timestamp: event.timestamp,
843
+ name: event.name,
844
+ attributes: event.attributes,
845
+ });
846
+ if (hasReachedLimit(results.length, offset, limit)) {
847
+ const paginated = paginateResults(results, offset, limit);
848
+ this.llmEventCache.set(cacheKey, paginated);
849
+ return paginated;
850
+ }
402
851
  }
403
852
  }
404
853
  }
405
- return paginateResults(results, offset, limit);
854
+ const paginated = paginateResults(results, offset, limit);
855
+ this.llmEventCache.set(cacheKey, paginated);
856
+ return paginated;
406
857
  }
407
858
  async healthCheck() {
408
859
  if (!existsSync(this.telemetryDir)) {
@@ -428,6 +879,27 @@ export class LocalJsonlBackend {
428
879
  message: `Found: ${found} for ${today}`,
429
880
  };
430
881
  }
882
+ /**
883
+ * Export traces in OTLP JSON format
884
+ */
885
+ async exportTracesOTLP(options) {
886
+ const traces = await this.queryTraces(options);
887
+ return convertToOTLPTraces(traces);
888
+ }
889
+ /**
890
+ * Export logs in OTLP JSON format
891
+ */
892
+ async exportLogsOTLP(options) {
893
+ const logs = await this.queryLogs(options);
894
+ return convertToOTLPLogs(logs);
895
+ }
896
+ /**
897
+ * Export metrics in OTLP JSON format
898
+ */
899
+ async exportMetricsOTLP(options) {
900
+ const metrics = await this.queryMetrics(options);
901
+ return convertToOTLPMetrics(metrics);
902
+ }
431
903
  }
432
904
  /**
433
905
  * Multi-directory backend that queries all telemetry directories
@@ -437,9 +909,9 @@ export class MultiDirectoryBackend {
437
909
  name = 'multi-directory';
438
910
  backends;
439
911
  directories;
440
- constructor(cwd) {
912
+ constructor(cwd, useIndexes = true) {
441
913
  this.directories = getTelemetryDirectories(cwd);
442
- this.backends = this.directories.map(d => new LocalJsonlBackend(d.path));
914
+ this.backends = this.directories.map(d => new LocalJsonlBackend(d.path, useIndexes));
443
915
  }
444
916
  getDirectories() {
445
917
  return this.directories;
@@ -509,5 +981,26 @@ export class MultiDirectoryBackend {
509
981
  directories: dirStatuses,
510
982
  };
511
983
  }
984
+ /**
985
+ * Export traces in OTLP JSON format
986
+ */
987
+ async exportTracesOTLP(options) {
988
+ const traces = await this.queryTraces(options);
989
+ return convertToOTLPTraces(traces);
990
+ }
991
+ /**
992
+ * Export logs in OTLP JSON format
993
+ */
994
+ async exportLogsOTLP(options) {
995
+ const logs = await this.queryLogs(options);
996
+ return convertToOTLPLogs(logs);
997
+ }
998
+ /**
999
+ * Export metrics in OTLP JSON format
1000
+ */
1001
+ async exportMetricsOTLP(options) {
1002
+ const metrics = await this.queryMetrics(options);
1003
+ return convertToOTLPMetrics(metrics);
1004
+ }
512
1005
  }
513
1006
  //# sourceMappingURL=local-jsonl.js.map