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 +22 -0
- package/README.md +33 -31
- package/bin/llmflow.js +3 -8
- package/db.js +99 -13
- package/logger.js +5 -0
- package/otlp-export.js +0 -3
- package/package.json +8 -2
- package/public/app.js +67 -33
- package/public/index.html +88 -88
- package/public/style.css +23 -2
- package/server.js +74 -59
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
|
+

|
|
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:
|
|
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
|
|
66
|
-
|
|
67
|
-
| **Cost Tracking**
|
|
68
|
-
| **Request Logging** | See every request/response with latency
|
|
69
|
-
| **Multi-Provider**
|
|
70
|
-
| **OpenTelemetry**
|
|
71
|
-
| **Zero Config**
|
|
72
|
-
| **Local Storage**
|
|
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
|
|
81
|
-
|
|
82
|
-
| OpenAI
|
|
83
|
-
| Anthropic
|
|
84
|
-
| Gemini
|
|
85
|
-
| Ollama
|
|
86
|
-
| Groq
|
|
87
|
-
| Mistral
|
|
88
|
-
| Azure OpenAI | `http://localhost:8080/azure/v1`
|
|
89
|
-
| Cohere
|
|
90
|
-
| Together
|
|
91
|
-
| OpenRouter
|
|
92
|
-
| Perplexity
|
|
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
|
|
111
|
+
import { OTLPTraceExporter } from "@opentelemetry/exporter-trace-otlp-http";
|
|
110
112
|
|
|
111
|
-
new OTLPTraceExporter({ url:
|
|
113
|
+
new OTLPTraceExporter({ url: "http://localhost:3000/v1/traces" });
|
|
112
114
|
```
|
|
113
115
|
|
|
114
116
|
---
|
|
115
117
|
|
|
116
118
|
## Configuration
|
|
117
119
|
|
|
118
|
-
| Variable
|
|
119
|
-
|
|
120
|
-
| `PROXY_PORT`
|
|
121
|
-
| `DASHBOARD_PORT` | `3000`
|
|
122
|
-
| `DATA_DIR`
|
|
123
|
-
| `MAX_TRACES`
|
|
124
|
-
| `VERBOSE`
|
|
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
|
|
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
|
-
|
|
749
|
-
|
|
750
|
-
|
|
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
|
|
856
|
+
const now = Date.now();
|
|
857
|
+
const fromTs = now - (days * 24 * 60 * 60 * 1000);
|
|
805
858
|
const bucketSize = 24 * 60 * 60 * 1000;
|
|
806
|
-
|
|
807
|
-
|
|
808
|
-
|
|
809
|
-
|
|
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.
|
|
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
|
-
|
|
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.
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
918
|
-
|
|
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
|
|
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:
|
|
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
|
|
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
|
|
354
|
+
let parsedResponse = null;
|
|
344
355
|
|
|
345
356
|
try {
|
|
346
|
-
|
|
357
|
+
parsedResponse = JSON.parse(responseBody);
|
|
347
358
|
} catch (e) {
|
|
348
|
-
|
|
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(
|
|
353
|
-
const model = handler.defaultIdentifyModel(req.body,
|
|
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:
|
|
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
|
|
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
|
|
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
|
|
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:
|
|
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:
|
|
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
|
-
|
|
1127
|
-
|
|
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
|
-
|
|
1209
|
-
|
|
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
|
});
|