llmflow 0.3.1 → 0.3.2

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/LICENSE ADDED
@@ -0,0 +1,22 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2025 Helge Sverre
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
22
+ x
package/README.md CHANGED
@@ -10,6 +10,8 @@ npx llmflow
10
10
 
11
11
  Dashboard: [localhost:3000](http://localhost:3000) · Proxy: [localhost:8080](http://localhost:8080)
12
12
 
13
+ ![LLMFlow Dashboard](art/screenshot-dark.png)
14
+
13
15
  ---
14
16
 
15
17
  ## Quick Start
@@ -38,7 +40,7 @@ client = OpenAI(base_url="http://localhost:8080/v1")
38
40
 
39
41
  ```javascript
40
42
  // JavaScript
41
- const client = new OpenAI({ baseURL: 'http://localhost:8080/v1' });
43
+ const client = new OpenAI({ baseURL: "http://localhost:8080/v1" });
42
44
  ```
43
45
 
44
46
  ```php
@@ -62,14 +64,14 @@ Open [localhost:3000](http://localhost:3000) to see your traces, costs, and toke
62
64
 
63
65
  ## Features
64
66
 
65
- | Feature | Description |
66
- |---------|-------------|
67
- | **Cost Tracking** | Real-time pricing for 2000+ models |
68
- | **Request Logging** | See every request/response with latency |
69
- | **Multi-Provider** | OpenAI, Anthropic, Gemini, Ollama, Groq, Mistral, and more |
70
- | **OpenTelemetry** | Accept traces from LangChain, LlamaIndex, etc. |
71
- | **Zero Config** | Just run it, point your SDK, done |
72
- | **Local Storage** | SQLite database, no external services |
67
+ | Feature | Description |
68
+ | ------------------- | ---------------------------------------------------------- |
69
+ | **Cost Tracking** | Real-time pricing for 2000+ models |
70
+ | **Request Logging** | See every request/response with latency |
71
+ | **Multi-Provider** | OpenAI, Anthropic, Gemini, Ollama, Groq, Mistral, and more |
72
+ | **OpenTelemetry** | Accept traces from LangChain, LlamaIndex, etc. |
73
+ | **Zero Config** | Just run it, point your SDK, done |
74
+ | **Local Storage** | SQLite database, no external services |
73
75
 
74
76
  ---
75
77
 
@@ -77,19 +79,19 @@ Open [localhost:3000](http://localhost:3000) to see your traces, costs, and toke
77
79
 
78
80
  Use path prefixes or the `X-LLMFlow-Provider` header:
79
81
 
80
- | Provider | URL |
81
- |----------|-----|
82
- | OpenAI | `http://localhost:8080/v1` (default) |
83
- | Anthropic | `http://localhost:8080/anthropic/v1` |
84
- | Gemini | `http://localhost:8080/gemini/v1` |
85
- | Ollama | `http://localhost:8080/ollama/v1` |
86
- | Groq | `http://localhost:8080/groq/v1` |
87
- | Mistral | `http://localhost:8080/mistral/v1` |
88
- | Azure OpenAI | `http://localhost:8080/azure/v1` |
89
- | Cohere | `http://localhost:8080/cohere/v1` |
90
- | Together | `http://localhost:8080/together/v1` |
91
- | OpenRouter | `http://localhost:8080/openrouter/v1` |
92
- | Perplexity | `http://localhost:8080/perplexity/v1` |
82
+ | Provider | URL |
83
+ | ------------ | ------------------------------------- |
84
+ | OpenAI | `http://localhost:8080/v1` (default) |
85
+ | Anthropic | `http://localhost:8080/anthropic/v1` |
86
+ | Gemini | `http://localhost:8080/gemini/v1` |
87
+ | Ollama | `http://localhost:8080/ollama/v1` |
88
+ | Groq | `http://localhost:8080/groq/v1` |
89
+ | Mistral | `http://localhost:8080/mistral/v1` |
90
+ | Azure OpenAI | `http://localhost:8080/azure/v1` |
91
+ | Cohere | `http://localhost:8080/cohere/v1` |
92
+ | Together | `http://localhost:8080/together/v1` |
93
+ | OpenRouter | `http://localhost:8080/openrouter/v1` |
94
+ | Perplexity | `http://localhost:8080/perplexity/v1` |
93
95
 
94
96
  ---
95
97
 
@@ -106,22 +108,22 @@ exporter = OTLPSpanExporter(endpoint="http://localhost:3000/v1/traces")
106
108
 
107
109
  ```javascript
108
110
  // JavaScript
109
- import { OTLPTraceExporter } from '@opentelemetry/exporter-trace-otlp-http';
111
+ import { OTLPTraceExporter } from "@opentelemetry/exporter-trace-otlp-http";
110
112
 
111
- new OTLPTraceExporter({ url: 'http://localhost:3000/v1/traces' });
113
+ new OTLPTraceExporter({ url: "http://localhost:3000/v1/traces" });
112
114
  ```
113
115
 
114
116
  ---
115
117
 
116
118
  ## Configuration
117
119
 
118
- | Variable | Default | Description |
119
- |----------|---------|-------------|
120
- | `PROXY_PORT` | `8080` | Proxy port |
121
- | `DASHBOARD_PORT` | `3000` | Dashboard port |
122
- | `DATA_DIR` | `~/.llmflow` | Data directory |
123
- | `MAX_TRACES` | `10000` | Max traces to retain |
124
- | `VERBOSE` | `0` | Enable verbose logging |
120
+ | Variable | Default | Description |
121
+ | ---------------- | ------------ | ---------------------- |
122
+ | `PROXY_PORT` | `8080` | Proxy port |
123
+ | `DASHBOARD_PORT` | `3000` | Dashboard port |
124
+ | `DATA_DIR` | `~/.llmflow` | Data directory |
125
+ | `MAX_TRACES` | `10000` | Max traces to retain |
126
+ | `VERBOSE` | `0` | Enable verbose logging |
125
127
 
126
128
  Set provider API keys as environment variables (`OPENAI_API_KEY`, `ANTHROPIC_API_KEY`, etc.) if you want the proxy to forward requests.
127
129
 
package/bin/llmflow.js CHANGED
@@ -64,15 +64,10 @@ if (!fs.existsSync(serverPath)) {
64
64
 
65
65
  // Print startup banner
66
66
  const pkg = require('../package.json');
67
- console.log(`
68
- ╔═══════════════════════════════════════════════╗
69
- ║ LLMFlow ║
70
- ║ Local LLM Observability v${pkg.version.padEnd(13)}║
71
- ╚═══════════════════════════════════════════════╝
72
- `);
67
+ console.log(`\n\x1b[34mLLMFlow\x1b[0m - Local LLM observability v${pkg.version}\n`);
73
68
 
74
- // Start the server
75
- const server = spawn(process.execPath, [serverPath], {
69
+ // Start the server (pass args like --verbose)
70
+ const server = spawn(process.execPath, [serverPath, ...args], {
76
71
  stdio: 'inherit',
77
72
  env: process.env
78
73
  });
package/db.js CHANGED
@@ -344,6 +344,16 @@ function getTraces({ limit = 50, offset = 0, filters = {} } = {}) {
344
344
  params.span_type = filters.span_type;
345
345
  }
346
346
 
347
+ if (filters.provider) {
348
+ where.push('provider = @provider');
349
+ params.provider = filters.provider;
350
+ }
351
+
352
+ if (filters.tag) {
353
+ where.push('tags LIKE @tag');
354
+ params.tag = `%${filters.tag}%`;
355
+ }
356
+
347
357
  const whereSql = where.length ? `WHERE ${where.join(' AND ')}` : '';
348
358
  const stmt = db.prepare(`
349
359
  SELECT
@@ -727,12 +737,25 @@ function getDistinctMetricServices() {
727
737
 
728
738
  // ==================== Analytics Functions ====================
729
739
 
740
+ function formatDateLabel(timestamp, interval) {
741
+ const date = new Date(timestamp);
742
+ const year = date.getUTCFullYear();
743
+ const month = String(date.getUTCMonth() + 1).padStart(2, '0');
744
+ const day = String(date.getUTCDate()).padStart(2, '0');
745
+ if (interval === 'day') {
746
+ return `${year}-${month}-${day}`;
747
+ }
748
+ const hour = String(date.getUTCHours()).padStart(2, '0');
749
+ return `${year}-${month}-${day} ${hour}:00`;
750
+ }
751
+
730
752
  function getTokenTrends({ interval = 'hour', days = 7 } = {}) {
731
- const fromTs = Date.now() - (days * 24 * 60 * 60 * 1000);
732
-
753
+ const now = Date.now();
754
+ const fromTs = now - (days * 24 * 60 * 60 * 1000);
755
+
733
756
  let bucketSize;
734
757
  let dateFormat;
735
-
758
+
736
759
  switch (interval) {
737
760
  case 'day':
738
761
  bucketSize = 24 * 60 * 60 * 1000;
@@ -744,10 +767,12 @@ function getTokenTrends({ interval = 'hour', days = 7 } = {}) {
744
767
  dateFormat = '%Y-%m-%d %H:00';
745
768
  break;
746
769
  }
747
-
748
- return db.prepare(`
749
- SELECT
750
- (timestamp / @bucketSize) * @bucketSize as bucket,
770
+
771
+ // Get actual data from database
772
+ // Use CAST to ensure integer division for proper bucket alignment
773
+ const data = db.prepare(`
774
+ SELECT
775
+ CAST(timestamp / @bucketSize AS INTEGER) * @bucketSize as bucket,
751
776
  strftime(@dateFormat, timestamp / 1000, 'unixepoch') as label,
752
777
  SUM(prompt_tokens) as prompt_tokens,
753
778
  SUM(completion_tokens) as completion_tokens,
@@ -759,6 +784,33 @@ function getTokenTrends({ interval = 'hour', days = 7 } = {}) {
759
784
  GROUP BY bucket
760
785
  ORDER BY bucket ASC
761
786
  `).all({ bucketSize, dateFormat, fromTs });
787
+
788
+ // Create a map for quick lookup
789
+ const dataMap = new Map(data.map(d => [d.bucket, d]));
790
+
791
+ // Generate all buckets and fill gaps with zeros
792
+ const result = [];
793
+ const startBucket = Math.floor(fromTs / bucketSize) * bucketSize;
794
+ const endBucket = Math.floor(now / bucketSize) * bucketSize;
795
+
796
+ for (let bucket = startBucket; bucket <= endBucket; bucket += bucketSize) {
797
+ const existing = dataMap.get(bucket);
798
+ if (existing) {
799
+ result.push(existing);
800
+ } else {
801
+ result.push({
802
+ bucket,
803
+ label: formatDateLabel(bucket, interval),
804
+ prompt_tokens: 0,
805
+ completion_tokens: 0,
806
+ total_tokens: 0,
807
+ total_cost: 0,
808
+ request_count: 0
809
+ });
810
+ }
811
+ }
812
+
813
+ return result;
762
814
  }
763
815
 
764
816
  function getCostByTool({ days = 30 } = {}) {
@@ -801,12 +853,15 @@ function getCostByModel({ days = 30 } = {}) {
801
853
  }
802
854
 
803
855
  function getDailyStats({ days = 30 } = {}) {
804
- const fromTs = Date.now() - (days * 24 * 60 * 60 * 1000);
856
+ const now = Date.now();
857
+ const fromTs = now - (days * 24 * 60 * 60 * 1000);
805
858
  const bucketSize = 24 * 60 * 60 * 1000;
806
-
807
- return db.prepare(`
808
- SELECT
809
- (timestamp / @bucketSize) * @bucketSize as bucket,
859
+
860
+ // Get actual data from database
861
+ // Use CAST to ensure integer division for proper bucket alignment
862
+ const data = db.prepare(`
863
+ SELECT
864
+ CAST(timestamp / @bucketSize AS INTEGER) * @bucketSize as bucket,
810
865
  strftime('%Y-%m-%d', timestamp / 1000, 'unixepoch') as date,
811
866
  SUM(total_tokens) as tokens,
812
867
  SUM(estimated_cost) as cost,
@@ -816,6 +871,35 @@ function getDailyStats({ days = 30 } = {}) {
816
871
  GROUP BY bucket
817
872
  ORDER BY bucket ASC
818
873
  `).all({ bucketSize, fromTs });
874
+
875
+ // Create a map for quick lookup
876
+ const dataMap = new Map(data.map(d => [d.bucket, d]));
877
+
878
+ // Generate all buckets and fill gaps with zeros
879
+ const result = [];
880
+ const startBucket = Math.floor(fromTs / bucketSize) * bucketSize;
881
+ const endBucket = Math.floor(now / bucketSize) * bucketSize;
882
+
883
+ for (let bucket = startBucket; bucket <= endBucket; bucket += bucketSize) {
884
+ const existing = dataMap.get(bucket);
885
+ if (existing) {
886
+ result.push(existing);
887
+ } else {
888
+ result.push({
889
+ bucket,
890
+ date: formatDateLabel(bucket, 'day'),
891
+ tokens: 0,
892
+ cost: 0,
893
+ requests: 0
894
+ });
895
+ }
896
+ }
897
+
898
+ return result;
899
+ }
900
+
901
+ function close() {
902
+ db.close();
819
903
  }
820
904
 
821
905
  module.exports = {
@@ -853,5 +937,7 @@ module.exports = {
853
937
  getDailyStats,
854
938
  // Constants
855
939
  DB_PATH,
856
- DATA_DIR
940
+ DATA_DIR,
941
+ // Lifecycle
942
+ close
857
943
  };
package/logger.js CHANGED
@@ -38,6 +38,11 @@ const logger = {
38
38
  console.log(`${c.cyan}[llmflow]${c.reset} ${message}`);
39
39
  },
40
40
 
41
+ // URL highlighting (yellow)
42
+ url(urlString) {
43
+ return `${c.yellow}${urlString}${c.reset}`;
44
+ },
45
+
41
46
  info(message) {
42
47
  console.log(`${c.dim}[llmflow]${c.reset} ${message}`);
43
48
  },
package/otlp-export.js CHANGED
@@ -515,12 +515,9 @@ function getConfig() {
515
515
  */
516
516
  function initExportHooks(db) {
517
517
  if (!EXPORT_ENABLED) {
518
- log.info('OTLP export disabled');
519
518
  return;
520
519
  }
521
520
 
522
- log.info(`OTLP export enabled: traces=${!!EXPORT_ENDPOINTS.traces}, logs=${!!EXPORT_ENDPOINTS.logs}, metrics=${!!EXPORT_ENDPOINTS.metrics}`);
523
-
524
521
  if (EXPORT_ENDPOINTS.traces) {
525
522
  db.setInsertTraceHook((trace) => {
526
523
  queueTrace(trace);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "llmflow",
3
- "version": "0.3.1",
3
+ "version": "0.3.2",
4
4
  "description": "See what your LLM calls cost. One command. No signup.",
5
5
  "main": "server.js",
6
6
  "bin": {
@@ -17,7 +17,10 @@
17
17
  "test:logs": "node test/run-tests.js otlp-logs-e2e.js",
18
18
  "test:metrics": "node test/run-tests.js otlp-metrics-e2e.js",
19
19
  "test:providers": "node test/run-tests.js providers.js",
20
- "test:providers-e2e": "node test/run-tests.js providers-e2e.js"
20
+ "test:providers-e2e": "node test/run-tests.js providers-e2e.js",
21
+ "test:e2e": "npx playwright test",
22
+ "test:e2e:headed": "npx playwright test --headed",
23
+ "test:e2e:ui": "npx playwright test --ui"
21
24
  },
22
25
  "dependencies": {
23
26
  "better-sqlite3": "^11.0.0",
@@ -58,5 +61,8 @@
58
61
  },
59
62
  "engines": {
60
63
  "node": ">=18"
64
+ },
65
+ "devDependencies": {
66
+ "@playwright/test": "^1.57.0"
61
67
  }
62
68
  }
package/public/app.js CHANGED
@@ -1,4 +1,5 @@
1
1
  // State
2
+ const validTabs = ['timeline', 'traces', 'logs', 'metrics', 'models', 'analytics'];
2
3
  let currentTab = 'timeline';
3
4
  let traces = [];
4
5
  let logs = [];
@@ -41,6 +42,21 @@ let ws = null;
41
42
  let wsRetryDelay = 1000;
42
43
  const WS_MAX_RETRY = 30000;
43
44
 
45
+ // URL hash for tab persistence
46
+ function getTabFromHash() {
47
+ const hash = window.location.hash.slice(1);
48
+ return validTabs.includes(hash) ? hash : null;
49
+ }
50
+
51
+ function setTabHash(tab) {
52
+ if (validTabs.includes(tab)) {
53
+ // Use pushState to create history entries for back/forward navigation
54
+ if (window.location.hash !== '#' + tab) {
55
+ history.pushState(null, '', '#' + tab);
56
+ }
57
+ }
58
+ }
59
+
44
60
  // Theme
45
61
  function initTheme() {
46
62
  const savedTheme = localStorage.getItem('llmflow-theme');
@@ -74,20 +90,32 @@ function init() {
74
90
  setupLogFilters();
75
91
  setupMetricFilters();
76
92
  setupTimelineFilters();
93
+ setupAnalyticsFilters();
77
94
  setupKeyboardShortcuts();
78
95
  loadModels();
79
96
  loadStats();
80
- loadTimeline();
81
97
  loadLogFilterOptions();
82
98
  loadMetricFilterOptions();
83
99
  initWebSocket();
84
100
 
101
+ // Load initial tab from hash or default to timeline
102
+ currentTab = getTabFromHash() || 'timeline';
103
+ showTab(currentTab);
104
+
85
105
  // Polling as fallback (less frequent since we have WebSocket)
86
106
  setInterval(loadStats, 30000);
87
107
  setInterval(() => {
88
108
  if (currentTab === 'timeline') loadTimeline();
89
109
  else if (currentTab === 'traces') loadTraces();
90
110
  }, 30000);
111
+
112
+ // Handle hash changes (back/forward navigation)
113
+ window.addEventListener('hashchange', () => {
114
+ const tab = getTabFromHash();
115
+ if (tab && tab !== currentTab) {
116
+ showTab(tab);
117
+ }
118
+ });
91
119
  }
92
120
 
93
121
  if (document.readyState === 'loading') {
@@ -195,8 +223,9 @@ function clearFilters() {
195
223
  // Tab switching
196
224
  function showTab(tab) {
197
225
  currentTab = tab;
226
+ setTabHash(tab);
198
227
  document.querySelectorAll('.tab').forEach(t => t.classList.remove('active'));
199
- event.target.classList.add('active');
228
+ document.querySelector(`.tab[onclick*="'${tab}'"]`)?.classList.add('active');
200
229
  document.querySelectorAll('.tab-content').forEach(t => t.classList.remove('active'));
201
230
 
202
231
  if (tab === 'timeline') {
@@ -277,15 +306,15 @@ async function loadTraces() {
277
306
  }
278
307
 
279
308
  tbody.innerHTML = traces.map(t => `
280
- <tr class="trace-row ${t.id === selectedTraceId ? 'selected' : ''}" onclick="selectTrace('${t.id}', this)">
281
- <td>${formatTime(t.timestamp)}</td>
282
- <td><span class="span-badge span-${t.span_type || 'llm'}">${t.span_type || 'llm'}</span></td>
283
- <td>${escapeHtml(t.span_name || '-')}</td>
284
- <td>${t.model ? `<span class="model-badge">${escapeHtml(t.model)}</span>` : '-'}</td>
285
- <td>${formatNumber(t.total_tokens || 0)}</td>
286
- <td>$${(t.estimated_cost || 0).toFixed(4)}</td>
287
- <td>${t.duration_ms || 0}ms</td>
288
- <td class="${(t.status || 200) < 400 ? 'status-success' : 'status-error'}">${t.status || 200}</td>
309
+ <tr class="trace-row ${t.id === selectedTraceId ? 'selected' : ''}" data-testid="trace-row" data-trace-id="${t.id}" onclick="selectTrace('${t.id}', this)">
310
+ <td data-testid="trace-time">${formatTime(t.timestamp)}</td>
311
+ <td data-testid="trace-type"><span class="span-badge span-${t.span_type || 'llm'}">${t.span_type || 'llm'}</span></td>
312
+ <td data-testid="trace-name">${escapeHtml(t.span_name || '-')}</td>
313
+ <td data-testid="trace-model">${t.model ? `<span class="model-badge">${escapeHtml(t.model)}</span>` : '-'}</td>
314
+ <td data-testid="trace-tokens">${formatNumber(t.total_tokens || 0)}</td>
315
+ <td data-testid="trace-cost">$${(t.estimated_cost || 0).toFixed(4)}</td>
316
+ <td data-testid="trace-latency">${t.duration_ms || 0}ms</td>
317
+ <td data-testid="trace-status" class="${(t.status || 200) < 400 ? 'status-success' : 'status-error'}">${t.status || 200}</td>
289
318
  </tr>
290
319
  `).join('');
291
320
  } catch (e) {
@@ -519,12 +548,12 @@ function renderLogsTable() {
519
548
  }
520
549
 
521
550
  tbody.innerHTML = logs.map(l => `
522
- <tr class="trace-row ${l.id === selectedLogId ? 'selected' : ''}" onclick="selectLog('${l.id}', this)">
523
- <td>${formatTime(l.timestamp)}</td>
524
- <td><span class="severity-badge severity-${getSeverityClass(l.severity_text)}">${l.severity_text || 'INFO'}</span></td>
525
- <td>${l.service_name ? `<span class="service-badge">${escapeHtml(l.service_name)}</span>` : '-'}</td>
526
- <td>${l.event_name ? `<span class="event-badge">${escapeHtml(l.event_name)}</span>` : '-'}</td>
527
- <td><span class="log-body-preview">${escapeHtml(l.body || '-')}</span></td>
551
+ <tr class="trace-row ${l.id === selectedLogId ? 'selected' : ''}" data-testid="log-row" data-log-id="${l.id}" onclick="selectLog('${l.id}', this)">
552
+ <td data-testid="log-time">${formatTime(l.timestamp)}</td>
553
+ <td data-testid="log-severity"><span class="severity-badge severity-${getSeverityClass(l.severity_text)}">${l.severity_text || 'INFO'}</span></td>
554
+ <td data-testid="log-service">${l.service_name ? `<span class="service-badge">${escapeHtml(l.service_name)}</span>` : '-'}</td>
555
+ <td data-testid="log-event">${l.event_name ? `<span class="event-badge">${escapeHtml(l.event_name)}</span>` : '-'}</td>
556
+ <td data-testid="log-body-preview"><span class="log-body-preview">${escapeHtml(l.body || '-')}</span></td>
528
557
  </tr>
529
558
  `).join('');
530
559
  }
@@ -668,10 +697,12 @@ async function loadMetricsSummary() {
668
697
 
669
698
  const container = document.getElementById('metricsSummary');
670
699
  if (!metricsSummary || metricsSummary.length === 0) {
671
- container.innerHTML = '<p class="empty-state">No metrics yet. Send OTLP metrics to /v1/metrics</p>';
700
+ container.style.display = 'none';
701
+ container.innerHTML = '';
672
702
  return;
673
703
  }
674
704
 
705
+ container.style.display = '';
675
706
  container.innerHTML = metricsSummary.slice(0, 8).map(m => `
676
707
  <div class="metric-card">
677
708
  <div class="metric-card-header">
@@ -1262,15 +1293,15 @@ function renderTracesTable() {
1262
1293
  }
1263
1294
 
1264
1295
  tbody.innerHTML = traces.map(t => `
1265
- <tr class="trace-row ${t.id === selectedTraceId ? 'selected' : ''}" onclick="selectTrace('${t.id}', this)">
1266
- <td>${formatTime(t.timestamp)}</td>
1267
- <td><span class="span-badge span-${t.span_type || 'llm'}">${t.span_type || 'llm'}</span></td>
1268
- <td>${escapeHtml(t.span_name || '-')}</td>
1269
- <td>${t.model ? `<span class="model-badge">${escapeHtml(t.model)}</span>` : '-'}</td>
1270
- <td>${formatNumber(t.total_tokens || 0)}</td>
1271
- <td>$${(t.estimated_cost || 0).toFixed(4)}</td>
1272
- <td>${t.duration_ms || 0}ms</td>
1273
- <td class="${(t.status || 200) < 400 ? 'status-success' : 'status-error'}">${t.status || 200}</td>
1296
+ <tr class="trace-row ${t.id === selectedTraceId ? 'selected' : ''}" data-testid="trace-row" data-trace-id="${t.id}" onclick="selectTrace('${t.id}', this)">
1297
+ <td data-testid="trace-time">${formatTime(t.timestamp)}</td>
1298
+ <td data-testid="trace-type"><span class="span-badge span-${t.span_type || 'llm'}">${t.span_type || 'llm'}</span></td>
1299
+ <td data-testid="trace-name">${escapeHtml(t.span_name || '-')}</td>
1300
+ <td data-testid="trace-model">${t.model ? `<span class="model-badge">${escapeHtml(t.model)}</span>` : '-'}</td>
1301
+ <td data-testid="trace-tokens">${formatNumber(t.total_tokens || 0)}</td>
1302
+ <td data-testid="trace-cost">$${(t.estimated_cost || 0).toFixed(4)}</td>
1303
+ <td data-testid="trace-latency">${t.duration_ms || 0}ms</td>
1304
+ <td data-testid="trace-status" class="${(t.status || 200) < 400 ? 'status-success' : 'status-error'}">${t.status || 200}</td>
1274
1305
  </tr>
1275
1306
  `).join('');
1276
1307
  }
@@ -1312,6 +1343,14 @@ async function loadAnalytics() {
1312
1343
  renderDailySummary(dailyData.daily || []);
1313
1344
  } catch (e) {
1314
1345
  console.error('Failed to load analytics:', e);
1346
+ const setError = (id) => {
1347
+ const el = document.getElementById(id);
1348
+ if (el) el.innerHTML = '<p class="empty-state">Failed to load data.</p>';
1349
+ };
1350
+ setError('tokenTrendsChart');
1351
+ setError('costByToolChart');
1352
+ setError('costByModelChart');
1353
+ setError('dailySummaryTable');
1315
1354
  }
1316
1355
  }
1317
1356
 
@@ -1476,9 +1515,4 @@ function getToolClass(provider, serviceName) {
1476
1515
  return 'tool-proxy';
1477
1516
  }
1478
1517
 
1479
- // Initialize analytics filters when DOM is ready
1480
- if (document.readyState === 'loading') {
1481
- document.addEventListener('DOMContentLoaded', setupAnalyticsFilters);
1482
- } else {
1483
- setupAnalyticsFilters();
1484
- }
1518
+
package/public/index.html CHANGED
@@ -4,25 +4,25 @@
4
4
  <meta charset="UTF-8" />
5
5
  <meta name="viewport" content="width=device-width, initial-scale=1.0" />
6
6
  <title>LLMFlow</title>
7
- <link rel="icon" href="data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 100 100'><text y='.9em' font-size='90'>🔍</text></svg>">
7
+ <link rel="icon" href="data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='%233B82F6' stroke-width='2'><path d='M12 2L2 7l10 5 10-5-10-5z'/><path d='M2 17l10 5 10-5'/><path d='M2 12l10 5 10-5'/></svg>">
8
8
  <link rel="stylesheet" href="style.css" />
9
9
  <script defer src="app.js"></script>
10
10
  </head>
11
11
  <body>
12
12
  <div class="container">
13
- <header>
13
+ <header data-testid="header">
14
14
  <div class="header-row">
15
15
  <div class="header-left">
16
- <h1 class="logo">
16
+ <h1 class="logo" data-testid="logo">
17
17
  <svg class="logo-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
18
18
  <path d="M12 2L2 7l10 5 10-5-10-5z"/>
19
19
  <path d="M2 17l10 5 10-5"/>
20
20
  <path d="M2 12l10 5 10-5"/>
21
21
  </svg>
22
22
  LLMFlow
23
- <span id="connectionStatus" class="status-dot" title="Connecting..."></span>
23
+ <span id="connectionStatus" class="status-dot" data-testid="connection-status" title="Connecting..."></span>
24
24
  </h1>
25
- <button class="theme-toggle" onclick="toggleTheme()" title="Toggle dark mode">
25
+ <button class="theme-toggle" data-testid="theme-toggle" onclick="toggleTheme()" title="Toggle dark mode">
26
26
  <svg class="icon-moon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
27
27
  <path d="M21 12.79A9 9 0 1 1 11.21 3 7 7 0 0 0 21 12.79z"/>
28
28
  </svg>
@@ -39,21 +39,21 @@
39
39
  </svg>
40
40
  </button>
41
41
  </div>
42
- <div class="stats-bar">
43
- <div class="stat">
44
- <span class="stat-value" id="totalRequests">-</span>
42
+ <div class="stats-bar" data-testid="stats-bar">
43
+ <div class="stat" data-testid="stat-traces">
44
+ <span class="stat-value" id="totalRequests" data-testid="total-requests">-</span>
45
45
  <span class="stat-label">Traces</span>
46
46
  </div>
47
- <div class="stat">
48
- <span class="stat-value" id="totalTokens">-</span>
47
+ <div class="stat" data-testid="stat-tokens">
48
+ <span class="stat-value" id="totalTokens" data-testid="total-tokens">-</span>
49
49
  <span class="stat-label">Tokens</span>
50
50
  </div>
51
- <div class="stat">
52
- <span class="stat-value" id="totalCost">-</span>
51
+ <div class="stat" data-testid="stat-cost">
52
+ <span class="stat-value" id="totalCost" data-testid="total-cost">-</span>
53
53
  <span class="stat-label">Cost</span>
54
54
  </div>
55
- <div class="stat">
56
- <span class="stat-value" id="avgLatency">-</span>
55
+ <div class="stat" data-testid="stat-latency">
56
+ <span class="stat-value" id="avgLatency" data-testid="avg-latency">-</span>
57
57
  <span class="stat-label">Avg Latency</span>
58
58
  </div>
59
59
  </div>
@@ -61,19 +61,19 @@
61
61
  </header>
62
62
 
63
63
  <main>
64
- <div class="tabs">
65
- <button class="tab active" onclick="showTab('timeline')">Timeline</button>
66
- <button class="tab" onclick="showTab('traces')">Traces</button>
67
- <button class="tab" onclick="showTab('logs')">Logs</button>
68
- <button class="tab" onclick="showTab('metrics')">Metrics</button>
69
- <button class="tab" onclick="showTab('models')">Models</button>
70
- <button class="tab" onclick="showTab('analytics')">Analytics</button>
64
+ <div class="tabs" data-testid="tabs">
65
+ <button class="tab active" data-testid="tab-timeline" onclick="showTab('timeline')">Timeline</button>
66
+ <button class="tab" data-testid="tab-traces" onclick="showTab('traces')">Traces</button>
67
+ <button class="tab" data-testid="tab-logs" onclick="showTab('logs')">Logs</button>
68
+ <button class="tab" data-testid="tab-metrics" onclick="showTab('metrics')">Metrics</button>
69
+ <button class="tab" data-testid="tab-models" onclick="showTab('models')">Models</button>
70
+ <button class="tab" data-testid="tab-analytics" onclick="showTab('analytics')">Analytics</button>
71
71
  </div>
72
72
 
73
- <div id="timelineTab" class="tab-content active">
74
- <div class="filter-bar">
75
- <input type="text" id="timelineSearchInput" placeholder="Search timeline... (press /)" />
76
- <select id="toolFilter">
73
+ <div id="timelineTab" class="tab-content active" data-testid="timeline-tab">
74
+ <div class="filter-bar" data-testid="timeline-filters">
75
+ <input type="text" id="timelineSearchInput" data-testid="timeline-search" placeholder="Search timeline... (press /)" />
76
+ <select id="toolFilter" data-testid="timeline-tool-filter">
77
77
  <option value="">All Tools</option>
78
78
  <option value="claude-code">Claude Code</option>
79
79
  <option value="codex-cli">Codex CLI</option>
@@ -81,69 +81,69 @@
81
81
  <option value="aider">Aider</option>
82
82
  <option value="proxy">Proxy</option>
83
83
  </select>
84
- <select id="timelineTypeFilter">
84
+ <select id="timelineTypeFilter" data-testid="timeline-type-filter">
85
85
  <option value="">All Types</option>
86
86
  <option value="trace">Traces</option>
87
87
  <option value="log">Logs</option>
88
88
  <option value="metric">Metrics</option>
89
89
  </select>
90
- <select id="timelineDateFilter">
90
+ <select id="timelineDateFilter" data-testid="timeline-date-filter">
91
91
  <option value="">All Time</option>
92
92
  <option value="1h">Last Hour</option>
93
93
  <option value="24h">Last 24h</option>
94
94
  <option value="7d">Last 7d</option>
95
95
  </select>
96
- <button id="clearTimelineFilters" class="btn-secondary">Clear</button>
96
+ <button id="clearTimelineFilters" class="btn-secondary" data-testid="timeline-clear-filters">Clear</button>
97
97
  </div>
98
98
 
99
99
  <div class="split-layout">
100
100
  <div class="panel-left">
101
- <div id="timelineList" class="timeline-list">
101
+ <div id="timelineList" class="timeline-list" data-testid="timeline-list">
102
102
  <div class="empty-state">Loading timeline...</div>
103
103
  </div>
104
104
  </div>
105
105
 
106
- <div class="panel-right" id="timelineDetailPanel">
106
+ <div class="panel-right" id="timelineDetailPanel" data-testid="timeline-detail-panel">
107
107
  <div class="detail-header">
108
- <h2 id="timelineDetailTitle">Select an item</h2>
109
- <span id="timelineDetailMeta" class="detail-meta"></span>
108
+ <h2 id="timelineDetailTitle" data-testid="timeline-detail-title">Select an item</h2>
109
+ <span id="timelineDetailMeta" class="detail-meta" data-testid="timeline-detail-meta"></span>
110
110
  </div>
111
111
  <div class="detail-body">
112
112
  <div class="detail-section" id="timelineDetailContent">
113
- <pre id="timelineDetailData">{}</pre>
113
+ <pre id="timelineDetailData" data-testid="timeline-detail-data">{}</pre>
114
114
  </div>
115
115
  <div class="detail-section" id="relatedLogsSection" style="display: none;">
116
116
  <h3>Related Logs</h3>
117
- <div id="relatedLogs"></div>
117
+ <div id="relatedLogs" data-testid="related-logs"></div>
118
118
  </div>
119
119
  </div>
120
120
  </div>
121
121
  </div>
122
122
  </div>
123
123
 
124
- <div id="tracesTab" class="tab-content">
125
- <div class="filter-bar">
126
- <input type="text" id="searchInput" placeholder="Search... (press /)" />
127
- <select id="modelFilter">
124
+ <div id="tracesTab" class="tab-content" data-testid="traces-tab">
125
+ <div class="filter-bar" data-testid="traces-filters">
126
+ <input type="text" id="searchInput" data-testid="traces-search" placeholder="Search... (press /)" />
127
+ <select id="modelFilter" data-testid="traces-model-filter">
128
128
  <option value="">All Models</option>
129
129
  </select>
130
- <select id="statusFilter">
130
+ <select id="statusFilter" data-testid="traces-status-filter">
131
131
  <option value="">All Status</option>
132
132
  <option value="success">Success</option>
133
133
  <option value="error">Error</option>
134
134
  </select>
135
- <select id="dateFilter">
135
+ <select id="dateFilter" data-testid="traces-date-filter">
136
136
  <option value="">All Time</option>
137
137
  <option value="1h">Last Hour</option>
138
138
  <option value="24h">Last 24h</option>
139
139
  <option value="7d">Last 7d</option>
140
140
  </select>
141
- <button id="clearFilters" class="btn-secondary">Clear</button>
141
+ <button id="clearFilters" class="btn-secondary" data-testid="traces-clear-filters">Clear</button>
142
142
  </div>
143
143
 
144
144
  <div class="split-layout">
145
145
  <div class="panel-left">
146
- <table id="tracesTable">
146
+ <table id="tracesTable" data-testid="traces-table">
147
147
  <thead>
148
148
  <tr>
149
149
  <th>Time</th>
@@ -156,7 +156,7 @@
156
156
  <th>Status</th>
157
157
  </tr>
158
158
  </thead>
159
- <tbody id="tracesBody">
159
+ <tbody id="tracesBody" data-testid="traces-body">
160
160
  <tr>
161
161
  <td colspan="8" class="empty-state">Loading...</td>
162
162
  </tr>
@@ -164,53 +164,53 @@
164
164
  </table>
165
165
  </div>
166
166
 
167
- <div class="panel-right" id="detailPanel">
167
+ <div class="panel-right" id="detailPanel" data-testid="traces-detail-panel">
168
168
  <div class="detail-header">
169
- <h2 id="detailTitle">Select a trace</h2>
170
- <span id="detailMeta" class="detail-meta"></span>
169
+ <h2 id="detailTitle" data-testid="trace-detail-title">Select a trace</h2>
170
+ <span id="detailMeta" class="detail-meta" data-testid="trace-detail-meta"></span>
171
171
  </div>
172
172
  <div class="detail-body">
173
173
  <div class="detail-section">
174
174
  <h3>Info</h3>
175
- <pre id="traceInfo">{}</pre>
175
+ <pre id="traceInfo" data-testid="trace-info">{}</pre>
176
176
  </div>
177
177
  <div class="detail-section">
178
178
  <h3>Spans</h3>
179
- <div id="spanTree" class="span-tree">
179
+ <div id="spanTree" class="span-tree" data-testid="span-tree">
180
180
  <span class="empty-state">Click a trace to view spans</span>
181
181
  </div>
182
182
  </div>
183
183
  <div class="detail-section">
184
184
  <h3>Input / Output</h3>
185
- <pre id="traceIO">{}</pre>
185
+ <pre id="traceIO" data-testid="trace-io">{}</pre>
186
186
  </div>
187
187
  </div>
188
188
  </div>
189
189
  </div>
190
190
  </div>
191
191
 
192
- <div id="logsTab" class="tab-content">
193
- <div class="filter-bar">
194
- <input type="text" id="logSearchInput" placeholder="Search logs... (press /)" />
195
- <select id="logServiceFilter">
192
+ <div id="logsTab" class="tab-content" data-testid="logs-tab">
193
+ <div class="filter-bar" data-testid="logs-filters">
194
+ <input type="text" id="logSearchInput" data-testid="logs-search" placeholder="Search logs... (press /)" />
195
+ <select id="logServiceFilter" data-testid="logs-service-filter">
196
196
  <option value="">All Services</option>
197
197
  </select>
198
- <select id="logEventFilter">
198
+ <select id="logEventFilter" data-testid="logs-event-filter">
199
199
  <option value="">All Events</option>
200
200
  </select>
201
- <select id="logSeverityFilter">
201
+ <select id="logSeverityFilter" data-testid="logs-severity-filter">
202
202
  <option value="">All Severity</option>
203
203
  <option value="17">Error+</option>
204
204
  <option value="13">Warn+</option>
205
205
  <option value="9">Info+</option>
206
206
  <option value="5">Debug+</option>
207
207
  </select>
208
- <button id="clearLogFilters" class="btn-secondary">Clear</button>
208
+ <button id="clearLogFilters" class="btn-secondary" data-testid="logs-clear-filters">Clear</button>
209
209
  </div>
210
210
 
211
211
  <div class="split-layout">
212
212
  <div class="panel-left">
213
- <table id="logsTable">
213
+ <table id="logsTable" data-testid="logs-table">
214
214
  <thead>
215
215
  <tr>
216
216
  <th>Time</th>
@@ -220,7 +220,7 @@
220
220
  <th>Body</th>
221
221
  </tr>
222
222
  </thead>
223
- <tbody id="logsBody">
223
+ <tbody id="logsBody" data-testid="logs-body">
224
224
  <tr>
225
225
  <td colspan="5" class="empty-state">Loading...</td>
226
226
  </tr>
@@ -228,53 +228,53 @@
228
228
  </table>
229
229
  </div>
230
230
 
231
- <div class="panel-right" id="logDetailPanel">
231
+ <div class="panel-right" id="logDetailPanel" data-testid="logs-detail-panel">
232
232
  <div class="detail-header">
233
- <h2 id="logDetailTitle">Select a log</h2>
234
- <span id="logDetailMeta" class="detail-meta"></span>
233
+ <h2 id="logDetailTitle" data-testid="log-detail-title">Select a log</h2>
234
+ <span id="logDetailMeta" class="detail-meta" data-testid="log-detail-meta"></span>
235
235
  </div>
236
236
  <div class="detail-body">
237
237
  <div class="detail-section">
238
238
  <h3>Body</h3>
239
- <pre id="logBody">-</pre>
239
+ <pre id="logBody" data-testid="log-body">-</pre>
240
240
  </div>
241
241
  <div class="detail-section">
242
242
  <h3>Attributes</h3>
243
- <pre id="logAttributes">{}</pre>
243
+ <pre id="logAttributes" data-testid="log-attributes">{}</pre>
244
244
  </div>
245
245
  <div class="detail-section">
246
246
  <h3>Resource</h3>
247
- <pre id="logResource">{}</pre>
247
+ <pre id="logResource" data-testid="log-resource">{}</pre>
248
248
  </div>
249
249
  </div>
250
250
  </div>
251
251
  </div>
252
252
  </div>
253
253
 
254
- <div id="metricsTab" class="tab-content">
255
- <div class="filter-bar">
256
- <select id="metricNameFilter">
254
+ <div id="metricsTab" class="tab-content" data-testid="metrics-tab">
255
+ <div class="filter-bar" data-testid="metrics-filters">
256
+ <select id="metricNameFilter" data-testid="metrics-name-filter">
257
257
  <option value="">All Metrics</option>
258
258
  </select>
259
- <select id="metricServiceFilter">
259
+ <select id="metricServiceFilter" data-testid="metrics-service-filter">
260
260
  <option value="">All Services</option>
261
261
  </select>
262
- <select id="metricTypeFilter">
262
+ <select id="metricTypeFilter" data-testid="metrics-type-filter">
263
263
  <option value="">All Types</option>
264
264
  <option value="sum">Sum (Counter)</option>
265
265
  <option value="gauge">Gauge</option>
266
266
  <option value="histogram">Histogram</option>
267
267
  </select>
268
- <button id="clearMetricFilters" class="btn-secondary">Clear</button>
268
+ <button id="clearMetricFilters" class="btn-secondary" data-testid="metrics-clear-filters">Clear</button>
269
269
  </div>
270
270
 
271
271
  <div class="metrics-layout">
272
- <div class="metrics-summary" id="metricsSummary">
272
+ <div class="metrics-summary" id="metricsSummary" data-testid="metrics-summary">
273
273
  <p class="empty-state">Loading metrics summary...</p>
274
274
  </div>
275
275
 
276
276
  <div class="metrics-table-container">
277
- <table id="metricsTable">
277
+ <table id="metricsTable" data-testid="metrics-table">
278
278
  <thead>
279
279
  <tr>
280
280
  <th>Time</th>
@@ -284,7 +284,7 @@
284
284
  <th>Service</th>
285
285
  </tr>
286
286
  </thead>
287
- <tbody id="metricsBody">
287
+ <tbody id="metricsBody" data-testid="metrics-body">
288
288
  <tr>
289
289
  <td colspan="5" class="empty-state">Loading...</td>
290
290
  </tr>
@@ -294,67 +294,67 @@
294
294
  </div>
295
295
  </div>
296
296
 
297
- <div id="modelsTab" class="tab-content">
298
- <div id="modelStats" class="model-grid">
297
+ <div id="modelsTab" class="tab-content" data-testid="models-tab">
298
+ <div id="modelStats" class="model-grid" data-testid="model-stats">
299
299
  <p class="empty-state">Loading...</p>
300
300
  </div>
301
301
  </div>
302
302
 
303
- <div id="analyticsTab" class="tab-content">
304
- <div class="analytics-controls">
305
- <select id="analyticsDaysFilter">
303
+ <div id="analyticsTab" class="tab-content" data-testid="analytics-tab">
304
+ <div class="analytics-controls" data-testid="analytics-controls">
305
+ <select id="analyticsDaysFilter" data-testid="analytics-days-filter">
306
306
  <option value="7">Last 7 days</option>
307
307
  <option value="14">Last 14 days</option>
308
308
  <option value="30" selected>Last 30 days</option>
309
309
  <option value="90">Last 90 days</option>
310
310
  </select>
311
- <button id="refreshAnalytics" class="btn-secondary">Refresh</button>
311
+ <button id="refreshAnalytics" class="btn-secondary" data-testid="analytics-refresh">Refresh</button>
312
312
  </div>
313
313
 
314
- <div class="analytics-grid">
315
- <div class="analytics-card analytics-card-wide">
314
+ <div class="analytics-grid" data-testid="analytics-grid">
315
+ <div class="analytics-card analytics-card-wide" data-testid="token-trends-card">
316
316
  <div class="analytics-card-header">
317
317
  <h3>Token Usage Trends</h3>
318
318
  <span class="analytics-subtitle">Daily token consumption</span>
319
319
  </div>
320
320
  <div class="analytics-card-body">
321
- <div id="tokenTrendsChart" class="chart-container">
321
+ <div id="tokenTrendsChart" class="chart-container" data-testid="token-trends-chart">
322
322
  <p class="empty-state">Loading chart...</p>
323
323
  </div>
324
324
  </div>
325
325
  </div>
326
326
 
327
- <div class="analytics-card">
327
+ <div class="analytics-card" data-testid="cost-by-tool-card">
328
328
  <div class="analytics-card-header">
329
329
  <h3>Cost by Tool</h3>
330
330
  <span class="analytics-subtitle">Total spend per AI tool</span>
331
331
  </div>
332
332
  <div class="analytics-card-body">
333
- <div id="costByToolChart" class="chart-container">
333
+ <div id="costByToolChart" class="chart-container" data-testid="cost-by-tool-chart">
334
334
  <p class="empty-state">Loading chart...</p>
335
335
  </div>
336
336
  </div>
337
337
  </div>
338
338
 
339
- <div class="analytics-card">
339
+ <div class="analytics-card" data-testid="cost-by-model-card">
340
340
  <div class="analytics-card-header">
341
341
  <h3>Cost by Model</h3>
342
342
  <span class="analytics-subtitle">Total spend per model</span>
343
343
  </div>
344
344
  <div class="analytics-card-body">
345
- <div id="costByModelChart" class="chart-container">
345
+ <div id="costByModelChart" class="chart-container" data-testid="cost-by-model-chart">
346
346
  <p class="empty-state">Loading chart...</p>
347
347
  </div>
348
348
  </div>
349
349
  </div>
350
350
 
351
- <div class="analytics-card analytics-card-wide">
351
+ <div class="analytics-card analytics-card-wide" data-testid="daily-summary-card">
352
352
  <div class="analytics-card-header">
353
353
  <h3>Daily Summary</h3>
354
354
  <span class="analytics-subtitle">Requests, tokens, and costs per day</span>
355
355
  </div>
356
356
  <div class="analytics-card-body">
357
- <div id="dailySummaryTable" class="daily-summary-table">
357
+ <div id="dailySummaryTable" class="daily-summary-table" data-testid="daily-summary-table">
358
358
  <p class="empty-state">Loading data...</p>
359
359
  </div>
360
360
  </div>
package/public/style.css CHANGED
@@ -914,14 +914,35 @@ pre {
914
914
 
915
915
  .analytics-controls {
916
916
  display: flex;
917
- gap: 12px;
918
- padding: 12px;
917
+ align-items: center;
918
+ gap: 8px;
919
+ padding: 8px;
919
920
  background: var(--bg-secondary);
920
921
  border: 1px solid var(--border-primary);
921
922
  border-radius: 4px;
922
923
  margin-bottom: 12px;
923
924
  }
924
925
 
926
+ .analytics-controls select {
927
+ padding: 6px 10px;
928
+ border: 1px solid var(--border-secondary);
929
+ border-radius: 4px;
930
+ font-size: 12px;
931
+ background: var(--bg-secondary);
932
+ color: var(--text-primary);
933
+ cursor: pointer;
934
+ transition: border-color 0.2s;
935
+ }
936
+
937
+ .analytics-controls select:focus {
938
+ outline: none;
939
+ border-color: var(--accent-primary);
940
+ }
941
+
942
+ .analytics-controls select:hover {
943
+ border-color: var(--text-muted);
944
+ }
945
+
925
946
  .analytics-grid {
926
947
  display: grid;
927
948
  grid-template-columns: repeat(2, 1fr);
package/server.js CHANGED
@@ -228,24 +228,23 @@ function createProxyHandler() {
228
228
  res.setHeader(key, value);
229
229
  });
230
230
 
231
- let fullContent = '';
232
- let finalUsage = null;
231
+ let streamBuffer = ''; // Buffer full stream for proper parsing
233
232
  let chunkCount = 0;
234
233
 
235
234
  upstreamRes.on('data', (chunk) => {
236
- const text = chunk.toString('utf8');
237
235
  chunkCount++;
238
-
236
+ streamBuffer += chunk.toString('utf8');
239
237
  res.write(chunk);
240
-
241
- // Parse chunks through provider
242
- const parsed = provider.parseStreamChunk(text);
243
- if (parsed.content) fullContent += parsed.content;
244
- if (parsed.usage) finalUsage = parsed.usage;
245
238
  });
246
239
 
247
240
  upstreamRes.on('end', () => {
248
241
  const duration = Date.now() - startTime;
242
+
243
+ // Parse once over the complete SSE stream for accurate extraction
244
+ const parsed = provider.parseStreamChunk(streamBuffer);
245
+ const fullContent = parsed.content || '';
246
+ const finalUsage = parsed.usage;
247
+
249
248
  const usage = finalUsage || { prompt_tokens: 0, completion_tokens: 0, total_tokens: 0 };
250
249
  const cost = calculateCost(req.body?.model || 'unknown', usage.prompt_tokens, usage.completion_tokens);
251
250
 
@@ -269,7 +268,7 @@ function createProxyHandler() {
269
268
  status: upstreamRes.statusCode,
270
269
  headers: upstreamRes.headers,
271
270
  data: assembledResponse,
272
- usage: finalUsage,
271
+ usage: usage,
273
272
  model: req.body?.model
274
273
  }, duration, null, provider.name);
275
274
 
@@ -331,26 +330,39 @@ function createPassthroughHandler(handler) {
331
330
 
332
331
  const upstreamReq = httpModule.request(options, (upstreamRes) => {
333
332
  if (!isStreaming) {
334
- // Non-streaming: buffer entire response
333
+ // Non-streaming: buffer for logging while forwarding raw bytes
335
334
  let responseBody = '';
336
335
 
336
+ // Set response status and headers immediately for passthrough
337
+ res.status(upstreamRes.statusCode);
338
+ Object.entries(upstreamRes.headers).forEach(([key, value]) => {
339
+ if (key.toLowerCase() !== 'content-length' &&
340
+ key.toLowerCase() !== 'transfer-encoding') {
341
+ res.setHeader(key, value);
342
+ }
343
+ });
344
+
337
345
  upstreamRes.on('data', (chunk) => {
338
346
  responseBody += chunk;
347
+ res.write(chunk); // Forward raw bytes immediately (passthrough)
339
348
  });
340
349
 
341
350
  upstreamRes.on('end', () => {
351
+ res.end(); // Complete the response first
352
+
342
353
  const duration = Date.now() - startTime;
343
- let rawResponse;
354
+ let parsedResponse = null;
344
355
 
345
356
  try {
346
- rawResponse = JSON.parse(responseBody);
357
+ parsedResponse = JSON.parse(responseBody);
347
358
  } catch (e) {
348
- rawResponse = { error: 'Invalid JSON response', body: responseBody };
359
+ // Failed to parse - still log the raw body for debugging
360
+ parsedResponse = null;
349
361
  }
350
362
 
351
- // Extract usage from native response format
352
- const usage = handler.defaultExtractUsage(rawResponse);
353
- const model = handler.defaultIdentifyModel(req.body, rawResponse);
363
+ // Extract usage from native response format (for logging only)
364
+ const usage = parsedResponse ? handler.defaultExtractUsage(parsedResponse) : { prompt_tokens: 0, completion_tokens: 0, total_tokens: 0 };
365
+ const model = handler.defaultIdentifyModel(req.body, parsedResponse);
354
366
  const cost = calculateCost(model, usage.prompt_tokens, usage.completion_tokens);
355
367
 
356
368
  log.proxy({
@@ -366,48 +378,36 @@ function createPassthroughHandler(handler) {
366
378
  logInteraction(traceId, req, {
367
379
  status: upstreamRes.statusCode,
368
380
  headers: upstreamRes.headers,
369
- data: rawResponse,
381
+ data: parsedResponse || { _raw: responseBody.substring(0, 10000) },
370
382
  usage: usage,
371
383
  model: model
372
384
  }, duration, null, handler.name);
373
-
374
- // Return original response as-is (no normalization)
375
- res.status(upstreamRes.statusCode);
376
- Object.entries(upstreamRes.headers).forEach(([key, value]) => {
377
- if (key.toLowerCase() !== 'content-length' &&
378
- key.toLowerCase() !== 'transfer-encoding') {
379
- res.setHeader(key, value);
380
- }
381
- });
382
- res.json(rawResponse);
383
385
  });
384
386
  } else {
385
- // Streaming: forward chunks while extracting usage
387
+ // Streaming: forward chunks while buffering for usage extraction
386
388
  res.status(upstreamRes.statusCode);
387
389
  Object.entries(upstreamRes.headers).forEach(([key, value]) => {
388
390
  res.setHeader(key, value);
389
391
  });
390
392
 
391
- let fullContent = '';
392
- let finalUsage = null;
393
+ let streamBuffer = ''; // Buffer full stream for proper parsing
393
394
  let chunkCount = 0;
394
395
 
395
396
  upstreamRes.on('data', (chunk) => {
396
- const text = chunk.toString('utf8');
397
397
  chunkCount++;
398
-
399
- // Forward chunk immediately (passthrough)
400
- res.write(chunk);
401
-
402
- // Parse chunk for usage extraction
403
- const parsed = handler.defaultParseStreamChunk(text);
404
- if (parsed.content) fullContent += parsed.content;
405
- if (parsed.usage) finalUsage = parsed.usage;
398
+ streamBuffer += chunk.toString('utf8');
399
+ res.write(chunk); // Forward immediately (passthrough)
406
400
  });
407
401
 
408
402
  upstreamRes.on('end', () => {
409
403
  const duration = Date.now() - startTime;
410
404
  const model = handler.defaultIdentifyModel(req.body, {});
405
+
406
+ // Parse once over the complete SSE stream for accurate extraction
407
+ const parsed = handler.defaultParseStreamChunk(streamBuffer);
408
+ const fullContent = parsed.content || '';
409
+ const finalUsage = parsed.usage;
410
+
411
411
  const usage = finalUsage || { prompt_tokens: 0, completion_tokens: 0, total_tokens: 0 };
412
412
  const cost = calculateCost(model, usage.prompt_tokens, usage.completion_tokens);
413
413
 
@@ -427,7 +427,7 @@ function createPassthroughHandler(handler) {
427
427
  id: traceId,
428
428
  model: model,
429
429
  content: fullContent,
430
- usage: finalUsage,
430
+ usage: usage,
431
431
  _streaming: true,
432
432
  _chunks: chunkCount,
433
433
  _passthrough: true
@@ -437,7 +437,7 @@ function createPassthroughHandler(handler) {
437
437
  status: upstreamRes.statusCode,
438
438
  headers: upstreamRes.headers,
439
439
  data: assembledResponse,
440
- usage: finalUsage,
440
+ usage: usage,
441
441
  model: model
442
442
  }, duration, null, handler.name);
443
443
 
@@ -692,6 +692,8 @@ dashboardApp.get('/api/traces', (req, res) => {
692
692
  if (req.query.date_to) filters.date_to = parseInt(req.query.date_to, 10);
693
693
  if (req.query.cost_min) filters.cost_min = parseFloat(req.query.cost_min);
694
694
  if (req.query.cost_max) filters.cost_max = parseFloat(req.query.cost_max);
695
+ if (req.query.provider) filters.provider = req.query.provider;
696
+ if (req.query.tag) filters.tag = req.query.tag;
695
697
 
696
698
  const traces = db.getTraces({ limit, offset, filters });
697
699
  log.dashboard('GET', '/api/traces', Date.now() - start);
@@ -776,6 +778,7 @@ dashboardApp.get('/api/traces/export', (req, res) => {
776
778
  if (req.query.date_from) filters.date_from = parseInt(req.query.date_from, 10);
777
779
  if (req.query.date_to) filters.date_to = parseInt(req.query.date_to, 10);
778
780
  if (req.query.tag) filters.tag = req.query.tag;
781
+ if (req.query.provider) filters.provider = req.query.provider;
779
782
 
780
783
  const traces = db.getTraces({ limit, offset: 0, filters });
781
784
 
@@ -1121,10 +1124,34 @@ dashboardApp.get('/api/traces/:id/tree', (req, res) => {
1121
1124
  }
1122
1125
  });
1123
1126
 
1124
- // Start servers
1127
+ // Start servers - coordinate startup messages
1128
+ let proxyReady = false;
1129
+ let dashboardReady = false;
1130
+
1131
+ function printStartupIfReady() {
1132
+ if (!proxyReady || !dashboardReady) return;
1133
+
1134
+ // Always show URLs (yellow colored, dashboard first)
1135
+ log.startup(`Dashboard: ${log.url(`http://localhost:${DASHBOARD_PORT}`)}`);
1136
+ log.startup(`Proxy: ${log.url(`http://localhost:${PROXY_PORT}`)}`);
1137
+
1138
+ // Verbose only
1139
+ log.debug(`Set base_url to http://localhost:${PROXY_PORT}/v1`);
1140
+ log.debug(`Database: ${db.DB_PATH}`);
1141
+ log.debug(`Traces: ${db.getTraceCount()}, Logs: ${db.getLogCount()}, Metrics: ${db.getMetricCount()}`);
1142
+ log.debug(`WebSocket: ws://localhost:${DASHBOARD_PORT}/ws`);
1143
+
1144
+ const exportConfig = getExportConfig();
1145
+ if (exportConfig.enabled) {
1146
+ log.debug(`OTLP Export: ${exportConfig.endpoints.traces || 'disabled'}`);
1147
+ }
1148
+
1149
+ console.log('');
1150
+ }
1151
+
1125
1152
  proxyApp.listen(PROXY_PORT, () => {
1126
- log.startup(`Proxy running on http://localhost:${PROXY_PORT}`);
1127
- log.info(`Set base_url to http://localhost:${PROXY_PORT}/v1`);
1153
+ proxyReady = true;
1154
+ printStartupIfReady();
1128
1155
  });
1129
1156
 
1130
1157
  // Create HTTP server for dashboard (needed for WebSocket)
@@ -1205,18 +1232,6 @@ db.setInsertMetricHook((metricSummary) => {
1205
1232
  initExportHooks(db);
1206
1233
 
1207
1234
  dashboardServer.listen(DASHBOARD_PORT, () => {
1208
- log.startup(`Dashboard running on http://localhost:${DASHBOARD_PORT}`);
1209
- log.info(`Database: ${db.DB_PATH}`);
1210
- log.info(`Traces: ${db.getTraceCount()}, Logs: ${db.getLogCount()}, Metrics: ${db.getMetricCount()}`);
1211
- log.info(`WebSocket: ws://localhost:${DASHBOARD_PORT}/ws`);
1212
-
1213
- const exportConfig = getExportConfig();
1214
- if (exportConfig.enabled) {
1215
- log.info(`OTLP Export: ${exportConfig.endpoints.traces || 'disabled'}`);
1216
- }
1217
-
1218
- if (log.isVerbose()) {
1219
- log.info('Verbose logging enabled');
1220
- }
1221
- console.log('');
1235
+ dashboardReady = true;
1236
+ printStartupIfReady();
1222
1237
  });