@cryptiklemur/lattice 1.5.0 → 1.7.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/client/src/components/analytics/AnalyticsView.tsx +87 -0
- package/client/src/components/analytics/charts/ActivityCalendar.tsx +185 -0
- package/client/src/components/analytics/charts/CacheEfficiencyChart.tsx +60 -0
- package/client/src/components/analytics/charts/ContextUtilizationChart.tsx +110 -0
- package/client/src/components/analytics/charts/DailySummaryCards.tsx +83 -0
- package/client/src/components/analytics/charts/HourlyHeatmap.tsx +129 -0
- package/client/src/components/analytics/charts/ResponseTimeScatter.tsx +101 -0
- package/client/src/components/analytics/charts/SessionTimeline.tsx +112 -0
- package/client/src/components/analytics/charts/TokenFlowChart.tsx +82 -0
- package/client/src/components/analytics/charts/TokenSankeyChart.tsx +89 -0
- package/package.json +1 -1
- package/server/src/analytics/engine.ts +237 -0
- package/shared/src/analytics.ts +9 -0
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
import {
|
|
2
|
+
ScatterChart,
|
|
3
|
+
Scatter,
|
|
4
|
+
XAxis,
|
|
5
|
+
YAxis,
|
|
6
|
+
CartesianGrid,
|
|
7
|
+
Tooltip,
|
|
8
|
+
ResponsiveContainer,
|
|
9
|
+
} from "recharts";
|
|
10
|
+
|
|
11
|
+
var TICK_STYLE = {
|
|
12
|
+
fontSize: 10,
|
|
13
|
+
fontFamily: "var(--font-mono)",
|
|
14
|
+
fill: "oklch(0.9 0.02 280 / 0.3)",
|
|
15
|
+
};
|
|
16
|
+
|
|
17
|
+
var GRID_COLOR = "oklch(0.9 0.02 280 / 0.06)";
|
|
18
|
+
|
|
19
|
+
var MODEL_COLORS: Record<string, string> = {
|
|
20
|
+
opus: "#a855f7",
|
|
21
|
+
sonnet: "oklch(55% 0.25 280)",
|
|
22
|
+
haiku: "#22c55e",
|
|
23
|
+
other: "#f59e0b",
|
|
24
|
+
};
|
|
25
|
+
|
|
26
|
+
interface ResponseTimeDatum {
|
|
27
|
+
tokens: number;
|
|
28
|
+
duration: number;
|
|
29
|
+
model: string;
|
|
30
|
+
sessionId: string;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
interface ResponseTimeScatterProps {
|
|
34
|
+
data: ResponseTimeDatum[];
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function CustomTooltip({ active, payload }: { active?: boolean; payload?: Array<{ payload: { tokens: number; durationSec: number; model: string } }> }) {
|
|
38
|
+
if (!active || !payload || payload.length === 0) return null;
|
|
39
|
+
var d = payload[0].payload;
|
|
40
|
+
return (
|
|
41
|
+
<div className="rounded-lg border border-base-content/8 bg-base-200 px-3 py-2 shadow-lg">
|
|
42
|
+
<div className="text-[11px] font-mono text-base-content/70 space-y-0.5">
|
|
43
|
+
<p><span className="text-base-content/40">tokens </span>{d.tokens.toLocaleString()}</p>
|
|
44
|
+
<p><span className="text-base-content/40">duration </span>{d.durationSec.toFixed(1)}s</p>
|
|
45
|
+
<p><span className="text-base-content/40">model </span>{d.model}</p>
|
|
46
|
+
</div>
|
|
47
|
+
</div>
|
|
48
|
+
);
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export function ResponseTimeScatter({ data }: ResponseTimeScatterProps) {
|
|
52
|
+
var models = Array.from(new Set(data.map(function (d) { return d.model; })));
|
|
53
|
+
|
|
54
|
+
var byModel = models.map(function (model) {
|
|
55
|
+
return {
|
|
56
|
+
model: model,
|
|
57
|
+
color: MODEL_COLORS[model] || "#f59e0b",
|
|
58
|
+
points: data
|
|
59
|
+
.filter(function (d) { return d.model === model; })
|
|
60
|
+
.map(function (d) { return { tokens: d.tokens, durationSec: d.duration / 1000, model: d.model }; }),
|
|
61
|
+
};
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
return (
|
|
65
|
+
<ResponsiveContainer width="100%" height={200}>
|
|
66
|
+
<ScatterChart margin={{ top: 4, right: 4, left: -20, bottom: 0 }}>
|
|
67
|
+
<CartesianGrid strokeDasharray="3 3" stroke={GRID_COLOR} />
|
|
68
|
+
<XAxis
|
|
69
|
+
dataKey="tokens"
|
|
70
|
+
type="number"
|
|
71
|
+
tick={TICK_STYLE}
|
|
72
|
+
axisLine={false}
|
|
73
|
+
tickLine={false}
|
|
74
|
+
tickFormatter={function (v) { return v >= 1000 ? (v / 1000).toFixed(0) + "k" : String(v); }}
|
|
75
|
+
name="tokens"
|
|
76
|
+
/>
|
|
77
|
+
<YAxis
|
|
78
|
+
dataKey="durationSec"
|
|
79
|
+
type="number"
|
|
80
|
+
tick={TICK_STYLE}
|
|
81
|
+
axisLine={false}
|
|
82
|
+
tickLine={false}
|
|
83
|
+
tickFormatter={function (v) { return v.toFixed(0) + "s"; }}
|
|
84
|
+
name="duration"
|
|
85
|
+
/>
|
|
86
|
+
<Tooltip content={<CustomTooltip />} cursor={{ strokeDasharray: "3 3", stroke: GRID_COLOR }} />
|
|
87
|
+
{byModel.map(function (group) {
|
|
88
|
+
return (
|
|
89
|
+
<Scatter
|
|
90
|
+
key={group.model}
|
|
91
|
+
name={group.model}
|
|
92
|
+
data={group.points}
|
|
93
|
+
fill={group.color}
|
|
94
|
+
fillOpacity={0.7}
|
|
95
|
+
/>
|
|
96
|
+
);
|
|
97
|
+
})}
|
|
98
|
+
</ScatterChart>
|
|
99
|
+
</ResponsiveContainer>
|
|
100
|
+
);
|
|
101
|
+
}
|
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
import { useState } from "react";
|
|
2
|
+
|
|
3
|
+
interface TimelineDatum {
|
|
4
|
+
id: string;
|
|
5
|
+
title: string;
|
|
6
|
+
project: string;
|
|
7
|
+
start: number;
|
|
8
|
+
end: number;
|
|
9
|
+
cost: number;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
interface SessionTimelineProps {
|
|
13
|
+
data: TimelineDatum[];
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
var ROW_HEIGHT = 16;
|
|
17
|
+
var ROW_GAP = 2;
|
|
18
|
+
var ROW_STEP = ROW_HEIGHT + ROW_GAP;
|
|
19
|
+
var LEFT_MARGIN = 8;
|
|
20
|
+
var RIGHT_MARGIN = 8;
|
|
21
|
+
|
|
22
|
+
var PROJECT_PALETTE = [
|
|
23
|
+
"oklch(55% 0.25 280)",
|
|
24
|
+
"#a855f7",
|
|
25
|
+
"#22c55e",
|
|
26
|
+
"#f59e0b",
|
|
27
|
+
"oklch(65% 0.2 240)",
|
|
28
|
+
"oklch(65% 0.25 25)",
|
|
29
|
+
"oklch(65% 0.25 150)",
|
|
30
|
+
"oklch(70% 0.2 60)",
|
|
31
|
+
];
|
|
32
|
+
|
|
33
|
+
function formatTime(ts: number): string {
|
|
34
|
+
var d = new Date(ts);
|
|
35
|
+
return (d.getMonth() + 1) + "/" + d.getDate() + " " + String(d.getHours()).padStart(2, "0") + ":" + String(d.getMinutes()).padStart(2, "0");
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export function SessionTimeline({ data }: SessionTimelineProps) {
|
|
39
|
+
var [hover, setHover] = useState<{ x: number; y: number; datum: TimelineDatum } | null>(null);
|
|
40
|
+
|
|
41
|
+
if (!data || data.length === 0) {
|
|
42
|
+
return (
|
|
43
|
+
<div className="flex items-center justify-center h-[200px] text-base-content/25 font-mono text-[11px]">
|
|
44
|
+
No data
|
|
45
|
+
</div>
|
|
46
|
+
);
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
var projects = Array.from(new Set(data.map(function (d) { return d.project; })));
|
|
50
|
+
function getColor(project: string): string {
|
|
51
|
+
var idx = projects.indexOf(project);
|
|
52
|
+
return PROJECT_PALETTE[idx % PROJECT_PALETTE.length];
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
var minTime = Infinity;
|
|
56
|
+
var maxTime = -Infinity;
|
|
57
|
+
for (var i = 0; i < data.length; i++) {
|
|
58
|
+
if (data[i].start < minTime) minTime = data[i].start;
|
|
59
|
+
if (data[i].end > maxTime) maxTime = data[i].end;
|
|
60
|
+
}
|
|
61
|
+
var timeRange = maxTime - minTime || 1;
|
|
62
|
+
|
|
63
|
+
var svgHeight = data.length * ROW_STEP;
|
|
64
|
+
var maxHeight = 300;
|
|
65
|
+
|
|
66
|
+
return (
|
|
67
|
+
<div className="relative overflow-y-auto overflow-x-hidden" style={{ maxHeight: maxHeight }}>
|
|
68
|
+
<svg width="100%" height={svgHeight} className="block" viewBox={"0 0 600 " + svgHeight} preserveAspectRatio="none">
|
|
69
|
+
{data.map(function (d, idx) {
|
|
70
|
+
var barWidth = 600 - LEFT_MARGIN - RIGHT_MARGIN;
|
|
71
|
+
var x1 = LEFT_MARGIN + ((d.start - minTime) / timeRange) * barWidth;
|
|
72
|
+
var x2 = LEFT_MARGIN + ((d.end - minTime) / timeRange) * barWidth;
|
|
73
|
+
var w = Math.max(x2 - x1, 3);
|
|
74
|
+
var y = idx * ROW_STEP;
|
|
75
|
+
|
|
76
|
+
return (
|
|
77
|
+
<rect
|
|
78
|
+
key={d.id}
|
|
79
|
+
x={x1}
|
|
80
|
+
y={y}
|
|
81
|
+
width={w}
|
|
82
|
+
height={ROW_HEIGHT}
|
|
83
|
+
rx={3}
|
|
84
|
+
ry={3}
|
|
85
|
+
fill={getColor(d.project)}
|
|
86
|
+
opacity={0.7}
|
|
87
|
+
onMouseEnter={function (e) {
|
|
88
|
+
setHover({ x: e.clientX, y: e.clientY, datum: d });
|
|
89
|
+
}}
|
|
90
|
+
onMouseLeave={function () { setHover(null); }}
|
|
91
|
+
/>
|
|
92
|
+
);
|
|
93
|
+
})}
|
|
94
|
+
</svg>
|
|
95
|
+
|
|
96
|
+
{hover && (
|
|
97
|
+
<div
|
|
98
|
+
className="fixed z-50 rounded-lg border border-base-content/8 bg-base-200 px-3 py-2 shadow-lg pointer-events-none max-w-[200px]"
|
|
99
|
+
style={{ left: hover.x + 12, top: hover.y - 50 }}
|
|
100
|
+
>
|
|
101
|
+
<p className="text-[10px] font-mono text-base-content/50 mb-1 truncate">{hover.datum.title}</p>
|
|
102
|
+
<div className="text-[11px] font-mono text-base-content/70 space-y-0.5">
|
|
103
|
+
<p><span className="text-base-content/40">project </span>{hover.datum.project}</p>
|
|
104
|
+
<p><span className="text-base-content/40">start </span>{formatTime(hover.datum.start)}</p>
|
|
105
|
+
<p><span className="text-base-content/40">end </span>{formatTime(hover.datum.end)}</p>
|
|
106
|
+
<p><span className="text-base-content/40">cost </span>${hover.datum.cost.toFixed(4)}</p>
|
|
107
|
+
</div>
|
|
108
|
+
</div>
|
|
109
|
+
)}
|
|
110
|
+
</div>
|
|
111
|
+
);
|
|
112
|
+
}
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
import {
|
|
2
|
+
AreaChart,
|
|
3
|
+
Area,
|
|
4
|
+
XAxis,
|
|
5
|
+
YAxis,
|
|
6
|
+
CartesianGrid,
|
|
7
|
+
Tooltip,
|
|
8
|
+
ResponsiveContainer,
|
|
9
|
+
} from "recharts";
|
|
10
|
+
|
|
11
|
+
var TICK_STYLE = {
|
|
12
|
+
fontSize: 10,
|
|
13
|
+
fontFamily: "var(--font-mono)",
|
|
14
|
+
fill: "oklch(0.9 0.02 280 / 0.3)",
|
|
15
|
+
};
|
|
16
|
+
|
|
17
|
+
var GRID_COLOR = "oklch(0.9 0.02 280 / 0.06)";
|
|
18
|
+
|
|
19
|
+
interface TokenFlowDatum {
|
|
20
|
+
date: string;
|
|
21
|
+
input: number;
|
|
22
|
+
output: number;
|
|
23
|
+
cacheRead: number;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
interface TokenFlowChartProps {
|
|
27
|
+
data: TokenFlowDatum[];
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function CustomTooltip({ active, payload, label }: { active?: boolean; payload?: Array<{ name: string; value: number; color: string }>; label?: string }) {
|
|
31
|
+
if (!active || !payload || payload.length === 0) return null;
|
|
32
|
+
return (
|
|
33
|
+
<div className="rounded-lg border border-base-content/8 bg-base-200 px-3 py-2 shadow-lg">
|
|
34
|
+
<p className="text-[10px] font-mono text-base-content/50 mb-1">{label}</p>
|
|
35
|
+
{payload.map(function (entry) {
|
|
36
|
+
return (
|
|
37
|
+
<div key={entry.name} className="flex items-center gap-2 text-[11px] font-mono">
|
|
38
|
+
<span className="inline-block w-2 h-2 rounded-full" style={{ background: entry.color }} />
|
|
39
|
+
<span className="text-base-content/60 capitalize">{entry.name}</span>
|
|
40
|
+
<span className="text-base-content ml-auto pl-4">{entry.value.toLocaleString()}</span>
|
|
41
|
+
</div>
|
|
42
|
+
);
|
|
43
|
+
})}
|
|
44
|
+
</div>
|
|
45
|
+
);
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function formatTokens(v: number): string {
|
|
49
|
+
if (v >= 1000000) return (v / 1000000).toFixed(1) + "M";
|
|
50
|
+
if (v >= 1000) return (v / 1000).toFixed(0) + "k";
|
|
51
|
+
return String(v);
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export function TokenFlowChart({ data }: TokenFlowChartProps) {
|
|
55
|
+
return (
|
|
56
|
+
<ResponsiveContainer width="100%" height={200}>
|
|
57
|
+
<AreaChart data={data} margin={{ top: 4, right: 4, left: -20, bottom: 0 }}>
|
|
58
|
+
<defs>
|
|
59
|
+
<linearGradient id="inputGrad" x1="0" y1="0" x2="0" y2="1">
|
|
60
|
+
<stop offset="5%" stopColor="oklch(55% 0.25 280)" stopOpacity={0.4} />
|
|
61
|
+
<stop offset="95%" stopColor="oklch(55% 0.25 280)" stopOpacity={0.05} />
|
|
62
|
+
</linearGradient>
|
|
63
|
+
<linearGradient id="outputGrad" x1="0" y1="0" x2="0" y2="1">
|
|
64
|
+
<stop offset="5%" stopColor="#22c55e" stopOpacity={0.4} />
|
|
65
|
+
<stop offset="95%" stopColor="#22c55e" stopOpacity={0.05} />
|
|
66
|
+
</linearGradient>
|
|
67
|
+
<linearGradient id="cacheReadGrad" x1="0" y1="0" x2="0" y2="1">
|
|
68
|
+
<stop offset="5%" stopColor="#f59e0b" stopOpacity={0.4} />
|
|
69
|
+
<stop offset="95%" stopColor="#f59e0b" stopOpacity={0.05} />
|
|
70
|
+
</linearGradient>
|
|
71
|
+
</defs>
|
|
72
|
+
<CartesianGrid strokeDasharray="3 3" stroke={GRID_COLOR} vertical={false} />
|
|
73
|
+
<XAxis dataKey="date" tick={TICK_STYLE} axisLine={false} tickLine={false} />
|
|
74
|
+
<YAxis tick={TICK_STYLE} axisLine={false} tickLine={false} tickFormatter={formatTokens} />
|
|
75
|
+
<Tooltip content={<CustomTooltip />} />
|
|
76
|
+
<Area type="monotone" dataKey="input" stackId="1" stroke="oklch(55% 0.25 280)" fill="url(#inputGrad)" strokeWidth={1.5} />
|
|
77
|
+
<Area type="monotone" dataKey="output" stackId="1" stroke="#22c55e" fill="url(#outputGrad)" strokeWidth={1.5} />
|
|
78
|
+
<Area type="monotone" dataKey="cacheRead" stackId="1" stroke="#f59e0b" fill="url(#cacheReadGrad)" strokeWidth={1.5} />
|
|
79
|
+
</AreaChart>
|
|
80
|
+
</ResponsiveContainer>
|
|
81
|
+
);
|
|
82
|
+
}
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
import { Sankey, Tooltip, ResponsiveContainer } from "recharts";
|
|
2
|
+
|
|
3
|
+
var NODE_COLORS: Record<string, string> = {
|
|
4
|
+
"Input Tokens": "oklch(55% 0.25 280)",
|
|
5
|
+
"Cache Read": "#f59e0b",
|
|
6
|
+
"Cache Creation": "oklch(65% 0.2 240)",
|
|
7
|
+
"Opus": "#a855f7",
|
|
8
|
+
"Sonnet": "oklch(55% 0.25 280)",
|
|
9
|
+
"Haiku": "#22c55e",
|
|
10
|
+
"Other": "#f59e0b",
|
|
11
|
+
"Output Tokens": "#22c55e",
|
|
12
|
+
};
|
|
13
|
+
|
|
14
|
+
interface SankeyData {
|
|
15
|
+
nodes: Array<{ name: string }>;
|
|
16
|
+
links: Array<{ source: number; target: number; value: number }>;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
interface TokenSankeyChartProps {
|
|
20
|
+
data: SankeyData;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
function CustomTooltip({ active, payload }: { active?: boolean; payload?: Array<{ payload: { source?: { name: string }; target?: { name: string }; value?: number; name?: string } }> }) {
|
|
24
|
+
if (!active || !payload || payload.length === 0) return null;
|
|
25
|
+
var d = payload[0].payload;
|
|
26
|
+
if (d.source && d.target) {
|
|
27
|
+
return (
|
|
28
|
+
<div className="rounded-lg border border-base-content/8 bg-base-200 px-3 py-2 shadow-lg">
|
|
29
|
+
<p className="text-[11px] font-mono text-base-content">
|
|
30
|
+
{d.source.name} → {d.target.name}: {(d.value || 0).toLocaleString()}
|
|
31
|
+
</p>
|
|
32
|
+
</div>
|
|
33
|
+
);
|
|
34
|
+
}
|
|
35
|
+
if (d.name) {
|
|
36
|
+
return (
|
|
37
|
+
<div className="rounded-lg border border-base-content/8 bg-base-200 px-3 py-2 shadow-lg">
|
|
38
|
+
<p className="text-[11px] font-mono text-base-content">{d.name}</p>
|
|
39
|
+
</div>
|
|
40
|
+
);
|
|
41
|
+
}
|
|
42
|
+
return null;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function SankeyNode({ x, y, width, height, index, payload }: { x: number; y: number; width: number; height: number; index: number; payload: { name: string } }) {
|
|
46
|
+
var color = NODE_COLORS[payload.name] || "oklch(0.5 0.1 280)";
|
|
47
|
+
return (
|
|
48
|
+
<g>
|
|
49
|
+
<rect x={x} y={y} width={width} height={height} fill={color} fillOpacity={0.85} rx={2} />
|
|
50
|
+
{height > 14 && (
|
|
51
|
+
<text
|
|
52
|
+
x={x + width + 6}
|
|
53
|
+
y={y + height / 2}
|
|
54
|
+
dy={4}
|
|
55
|
+
fill="oklch(0.9 0.02 280 / 0.5)"
|
|
56
|
+
fontSize={9}
|
|
57
|
+
fontFamily="var(--font-mono)"
|
|
58
|
+
>
|
|
59
|
+
{payload.name}
|
|
60
|
+
</text>
|
|
61
|
+
)}
|
|
62
|
+
</g>
|
|
63
|
+
);
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
export function TokenSankeyChart({ data }: TokenSankeyChartProps) {
|
|
67
|
+
if (!data.links || data.links.length === 0) {
|
|
68
|
+
return (
|
|
69
|
+
<div className="flex items-center justify-center h-[250px] text-base-content/30 font-mono text-[12px]">
|
|
70
|
+
No token flow data
|
|
71
|
+
</div>
|
|
72
|
+
);
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
return (
|
|
76
|
+
<ResponsiveContainer width="100%" height={250}>
|
|
77
|
+
<Sankey
|
|
78
|
+
data={data}
|
|
79
|
+
node={<SankeyNode x={0} y={0} width={0} height={0} index={0} payload={{ name: "" }} />}
|
|
80
|
+
link={{ stroke: "oklch(0.9 0.02 280 / 0.1)" }}
|
|
81
|
+
margin={{ top: 10, right: 100, left: 10, bottom: 10 }}
|
|
82
|
+
nodeWidth={12}
|
|
83
|
+
nodePadding={14}
|
|
84
|
+
>
|
|
85
|
+
<Tooltip content={<CustomTooltip />} />
|
|
86
|
+
</Sankey>
|
|
87
|
+
</ResponsiveContainer>
|
|
88
|
+
);
|
|
89
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@cryptiklemur/lattice",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.7.0",
|
|
4
4
|
"description": "Multi-machine agentic dashboard for Claude Code. Monitor sessions, manage MCP servers and skills, orchestrate across mesh-networked nodes.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"author": "Aaron Scherer <me@aaronscherer.me>",
|
|
@@ -5,6 +5,18 @@ import type { AnalyticsPayload, AnalyticsPeriod, AnalyticsScope } from "@lattice
|
|
|
5
5
|
import { estimateCost, projectPathToHash } from "../project/session";
|
|
6
6
|
import { loadConfig } from "../config";
|
|
7
7
|
|
|
8
|
+
interface ResponseTimeDatum {
|
|
9
|
+
tokens: number;
|
|
10
|
+
duration: number;
|
|
11
|
+
model: string;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
interface ContextMessage {
|
|
15
|
+
messageIndex: number;
|
|
16
|
+
inputTokens: number;
|
|
17
|
+
model: string;
|
|
18
|
+
}
|
|
19
|
+
|
|
8
20
|
interface SessionData {
|
|
9
21
|
id: string;
|
|
10
22
|
title: string;
|
|
@@ -18,6 +30,8 @@ interface SessionData {
|
|
|
18
30
|
tools: Map<string, number>;
|
|
19
31
|
startTime: number;
|
|
20
32
|
endTime: number;
|
|
33
|
+
responseTimePoints: ResponseTimeDatum[];
|
|
34
|
+
contextMessages: ContextMessage[];
|
|
21
35
|
}
|
|
22
36
|
|
|
23
37
|
interface CacheEntry {
|
|
@@ -83,8 +97,13 @@ function parseSessionFile(filePath: string, sessionId: string, projectSlug: stri
|
|
|
83
97
|
tools: new Map(),
|
|
84
98
|
startTime: 0,
|
|
85
99
|
endTime: 0,
|
|
100
|
+
responseTimePoints: [],
|
|
101
|
+
contextMessages: [],
|
|
86
102
|
};
|
|
87
103
|
|
|
104
|
+
var lastUserTimestamp = 0;
|
|
105
|
+
var assistantIndex = 0;
|
|
106
|
+
|
|
88
107
|
for (var i = 0; i < lines.length; i++) {
|
|
89
108
|
var line = lines[i].trim();
|
|
90
109
|
if (!line) continue;
|
|
@@ -136,6 +155,16 @@ function parseSessionFile(filePath: string, sessionId: string, projectSlug: stri
|
|
|
136
155
|
} else {
|
|
137
156
|
data.models.set(bucket, { cost: cost, tokens: inTok + outTok });
|
|
138
157
|
}
|
|
158
|
+
|
|
159
|
+
if (outTok > 0 && timestamp > 0 && lastUserTimestamp > 0) {
|
|
160
|
+
var dur = timestamp - lastUserTimestamp;
|
|
161
|
+
if (dur > 0 && dur < 600000) {
|
|
162
|
+
data.responseTimePoints.push({ tokens: outTok, duration: dur, model: bucket });
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
data.contextMessages.push({ messageIndex: assistantIndex, inputTokens: inTok + cacheRead + cacheCreation, model: bucket });
|
|
167
|
+
assistantIndex++;
|
|
139
168
|
}
|
|
140
169
|
|
|
141
170
|
if (!data.title && message.content) {
|
|
@@ -144,6 +173,7 @@ function parseSessionFile(filePath: string, sessionId: string, projectSlug: stri
|
|
|
144
173
|
}
|
|
145
174
|
}
|
|
146
175
|
} else if (parsed.type === "user") {
|
|
176
|
+
if (timestamp > 0) lastUserTimestamp = timestamp;
|
|
147
177
|
var userMsg = parsed.message as Record<string, unknown> | undefined;
|
|
148
178
|
if (!userMsg || !Array.isArray(userMsg.content)) continue;
|
|
149
179
|
|
|
@@ -408,6 +438,206 @@ function aggregate(sessions: SessionData[], period: AnalyticsPeriod): AnalyticsP
|
|
|
408
438
|
});
|
|
409
439
|
toolUsage.sort(function (a, b) { return b.count - a.count; });
|
|
410
440
|
|
|
441
|
+
var responseTimeData: AnalyticsPayload["responseTimeData"] = [];
|
|
442
|
+
for (var rti = 0; rti < filtered.length; rti++) {
|
|
443
|
+
var rtSess = filtered[rti];
|
|
444
|
+
for (var rtj = 0; rtj < rtSess.responseTimePoints.length; rtj++) {
|
|
445
|
+
var rtp = rtSess.responseTimePoints[rtj];
|
|
446
|
+
responseTimeData.push({ tokens: rtp.tokens, duration: rtp.duration, model: rtp.model, sessionId: rtSess.id });
|
|
447
|
+
}
|
|
448
|
+
if (responseTimeData.length >= 200) break;
|
|
449
|
+
}
|
|
450
|
+
if (responseTimeData.length > 200) responseTimeData.length = 200;
|
|
451
|
+
|
|
452
|
+
var contextWindowSizes: Record<string, number> = { opus: 200000, sonnet: 200000, haiku: 200000, other: 200000 };
|
|
453
|
+
var contextUtilization: AnalyticsPayload["contextUtilization"] = [];
|
|
454
|
+
var recentSessions = sorted.slice(0, 5);
|
|
455
|
+
for (var cui = 0; cui < recentSessions.length; cui++) {
|
|
456
|
+
var cuSess = recentSessions[cui];
|
|
457
|
+
var runningTokens = 0;
|
|
458
|
+
var primaryModel = "other";
|
|
459
|
+
var maxModelTokens = 0;
|
|
460
|
+
cuSess.models.forEach(function (val, key) {
|
|
461
|
+
if (val.tokens > maxModelTokens) {
|
|
462
|
+
maxModelTokens = val.tokens;
|
|
463
|
+
primaryModel = key;
|
|
464
|
+
}
|
|
465
|
+
});
|
|
466
|
+
var windowSize = contextWindowSizes[primaryModel] || 200000;
|
|
467
|
+
for (var cmj = 0; cmj < cuSess.contextMessages.length; cmj++) {
|
|
468
|
+
var cm = cuSess.contextMessages[cmj];
|
|
469
|
+
runningTokens += cm.inputTokens;
|
|
470
|
+
contextUtilization.push({
|
|
471
|
+
messageIndex: cm.messageIndex,
|
|
472
|
+
contextPercent: Math.min((runningTokens / windowSize) * 100, 100),
|
|
473
|
+
sessionId: cuSess.id,
|
|
474
|
+
title: cuSess.title,
|
|
475
|
+
});
|
|
476
|
+
}
|
|
477
|
+
}
|
|
478
|
+
|
|
479
|
+
var sankeyNodes = [
|
|
480
|
+
{ name: "Input Tokens" },
|
|
481
|
+
{ name: "Cache Read" },
|
|
482
|
+
{ name: "Cache Creation" },
|
|
483
|
+
{ name: "Opus" },
|
|
484
|
+
{ name: "Sonnet" },
|
|
485
|
+
{ name: "Haiku" },
|
|
486
|
+
{ name: "Other" },
|
|
487
|
+
{ name: "Output Tokens" },
|
|
488
|
+
];
|
|
489
|
+
var modelNodeMap: Record<string, number> = { opus: 3, sonnet: 4, haiku: 5, other: 6 };
|
|
490
|
+
var sankeyLinks: Array<{ source: number; target: number; value: number }> = [];
|
|
491
|
+
var modelInputTotals = new Map<string, number>();
|
|
492
|
+
var modelCacheTotals = new Map<string, number>();
|
|
493
|
+
var modelCacheCreationTotals = new Map<string, number>();
|
|
494
|
+
var modelOutputTotals = new Map<string, number>();
|
|
495
|
+
|
|
496
|
+
for (var ski = 0; ski < filtered.length; ski++) {
|
|
497
|
+
var skSess = filtered[ski];
|
|
498
|
+
var skTotal = skSess.inputTokens + skSess.cacheReadTokens + skSess.cacheCreationTokens;
|
|
499
|
+
if (skTotal === 0) continue;
|
|
500
|
+
skSess.models.forEach(function (val, key) {
|
|
501
|
+
var proportion = val.tokens / (skTotal + skSess.outputTokens || 1);
|
|
502
|
+
modelInputTotals.set(key, (modelInputTotals.get(key) || 0) + skSess.inputTokens * proportion);
|
|
503
|
+
modelCacheTotals.set(key, (modelCacheTotals.get(key) || 0) + skSess.cacheReadTokens * proportion);
|
|
504
|
+
modelCacheCreationTotals.set(key, (modelCacheCreationTotals.get(key) || 0) + skSess.cacheCreationTokens * proportion);
|
|
505
|
+
modelOutputTotals.set(key, (modelOutputTotals.get(key) || 0) + skSess.outputTokens * proportion);
|
|
506
|
+
});
|
|
507
|
+
}
|
|
508
|
+
|
|
509
|
+
["opus", "sonnet", "haiku", "other"].forEach(function (model) {
|
|
510
|
+
var nodeIdx = modelNodeMap[model];
|
|
511
|
+
var inputVal = Math.round(modelInputTotals.get(model) || 0);
|
|
512
|
+
var cacheVal = Math.round(modelCacheTotals.get(model) || 0);
|
|
513
|
+
var cacheCreationVal = Math.round(modelCacheCreationTotals.get(model) || 0);
|
|
514
|
+
var outputVal = Math.round(modelOutputTotals.get(model) || 0);
|
|
515
|
+
if (inputVal > 0) sankeyLinks.push({ source: 0, target: nodeIdx, value: inputVal });
|
|
516
|
+
if (cacheVal > 0) sankeyLinks.push({ source: 1, target: nodeIdx, value: cacheVal });
|
|
517
|
+
if (cacheCreationVal > 0) sankeyLinks.push({ source: 2, target: nodeIdx, value: cacheCreationVal });
|
|
518
|
+
if (outputVal > 0) sankeyLinks.push({ source: nodeIdx, target: 7, value: outputVal });
|
|
519
|
+
});
|
|
520
|
+
|
|
521
|
+
var tokenFlowSankey: AnalyticsPayload["tokenFlowSankey"] = { nodes: sankeyNodes, links: sankeyLinks };
|
|
522
|
+
|
|
523
|
+
var activityCalendarMap = new Map<string, { count: number; tokens: number; cost: number }>();
|
|
524
|
+
for (var aci = 0; aci < filtered.length; aci++) {
|
|
525
|
+
var acSess = filtered[aci];
|
|
526
|
+
var acDate = formatDate(acSess.endTime > 0 ? acSess.endTime : acSess.startTime);
|
|
527
|
+
var acEntry = activityCalendarMap.get(acDate);
|
|
528
|
+
if (!acEntry) {
|
|
529
|
+
acEntry = { count: 0, tokens: 0, cost: 0 };
|
|
530
|
+
activityCalendarMap.set(acDate, acEntry);
|
|
531
|
+
}
|
|
532
|
+
acEntry.count++;
|
|
533
|
+
acEntry.tokens += acSess.inputTokens + acSess.outputTokens;
|
|
534
|
+
acEntry.cost += acSess.cost;
|
|
535
|
+
}
|
|
536
|
+
|
|
537
|
+
var activityCalendar: AnalyticsPayload["activityCalendar"] = [];
|
|
538
|
+
if (dates.length > 0) {
|
|
539
|
+
var calStart = new Date(dates[0]);
|
|
540
|
+
var calEnd = new Date(dates[dates.length - 1]);
|
|
541
|
+
var calCursor = new Date(calStart);
|
|
542
|
+
while (calCursor <= calEnd) {
|
|
543
|
+
var calKey = formatDate(calCursor.getTime());
|
|
544
|
+
var calData = activityCalendarMap.get(calKey);
|
|
545
|
+
activityCalendar.push({
|
|
546
|
+
date: calKey,
|
|
547
|
+
count: calData ? calData.count : 0,
|
|
548
|
+
tokens: calData ? calData.tokens : 0,
|
|
549
|
+
cost: calData ? calData.cost : 0,
|
|
550
|
+
});
|
|
551
|
+
calCursor.setDate(calCursor.getDate() + 1);
|
|
552
|
+
}
|
|
553
|
+
}
|
|
554
|
+
|
|
555
|
+
var hourlyHeatmap: AnalyticsPayload["hourlyHeatmap"] = [];
|
|
556
|
+
var heatmapGrid = new Map<string, number>();
|
|
557
|
+
for (var hmi = 0; hmi < filtered.length; hmi++) {
|
|
558
|
+
var hmSess = filtered[hmi];
|
|
559
|
+
if (hmSess.startTime <= 0) continue;
|
|
560
|
+
var hmDate = new Date(hmSess.startTime);
|
|
561
|
+
var hmDay = hmDate.getDay();
|
|
562
|
+
var hmHour = hmDate.getHours();
|
|
563
|
+
var hmKey = hmDay + ":" + hmHour;
|
|
564
|
+
heatmapGrid.set(hmKey, (heatmapGrid.get(hmKey) || 0) + 1);
|
|
565
|
+
}
|
|
566
|
+
for (var hd = 0; hd < 7; hd++) {
|
|
567
|
+
for (var hh = 0; hh < 24; hh++) {
|
|
568
|
+
var hhKey = hd + ":" + hh;
|
|
569
|
+
hourlyHeatmap.push({ day: hd, hour: hh, count: heatmapGrid.get(hhKey) || 0 });
|
|
570
|
+
}
|
|
571
|
+
}
|
|
572
|
+
|
|
573
|
+
var sessionTimeline: AnalyticsPayload["sessionTimeline"] = [];
|
|
574
|
+
var tlSorted = filtered
|
|
575
|
+
.filter(function (s) { return s.startTime > 0 && s.endTime > 0; })
|
|
576
|
+
.sort(function (a, b) { return b.startTime - a.startTime; });
|
|
577
|
+
var tlCap = Math.min(tlSorted.length, 50);
|
|
578
|
+
for (var tli = 0; tli < tlCap; tli++) {
|
|
579
|
+
var tlSess = tlSorted[tli];
|
|
580
|
+
sessionTimeline.push({
|
|
581
|
+
id: tlSess.id,
|
|
582
|
+
title: tlSess.title,
|
|
583
|
+
project: tlSess.project,
|
|
584
|
+
start: tlSess.startTime,
|
|
585
|
+
end: tlSess.endTime,
|
|
586
|
+
cost: tlSess.cost,
|
|
587
|
+
});
|
|
588
|
+
}
|
|
589
|
+
|
|
590
|
+
var dailySummaryMap = new Map<string, { sessions: number; cost: number; tokens: number; tools: Map<string, number>; models: Map<string, number> }>();
|
|
591
|
+
for (var dsi = 0; dsi < filtered.length; dsi++) {
|
|
592
|
+
var dsSess = filtered[dsi];
|
|
593
|
+
var dsDate = formatDate(dsSess.endTime > 0 ? dsSess.endTime : dsSess.startTime);
|
|
594
|
+
var dsEntry = dailySummaryMap.get(dsDate);
|
|
595
|
+
if (!dsEntry) {
|
|
596
|
+
dsEntry = { sessions: 0, cost: 0, tokens: 0, tools: new Map(), models: new Map() };
|
|
597
|
+
dailySummaryMap.set(dsDate, dsEntry);
|
|
598
|
+
}
|
|
599
|
+
dsEntry.sessions++;
|
|
600
|
+
dsEntry.cost += dsSess.cost;
|
|
601
|
+
dsEntry.tokens += dsSess.inputTokens + dsSess.outputTokens;
|
|
602
|
+
dsSess.tools.forEach(function (count, tool) {
|
|
603
|
+
dsEntry!.tools.set(tool, (dsEntry!.tools.get(tool) || 0) + count);
|
|
604
|
+
});
|
|
605
|
+
dsSess.models.forEach(function (val, key) {
|
|
606
|
+
dsEntry!.models.set(key, (dsEntry!.models.get(key) || 0) + val.cost);
|
|
607
|
+
});
|
|
608
|
+
}
|
|
609
|
+
|
|
610
|
+
var dailySummaries: AnalyticsPayload["dailySummaries"] = [];
|
|
611
|
+
var dsSortedDates = Array.from(dailySummaryMap.keys()).sort();
|
|
612
|
+
for (var dsdi = 0; dsdi < dsSortedDates.length; dsdi++) {
|
|
613
|
+
var dsd = dsSortedDates[dsdi];
|
|
614
|
+
var dsData = dailySummaryMap.get(dsd)!;
|
|
615
|
+
var topTool = "";
|
|
616
|
+
var topToolCount = 0;
|
|
617
|
+
dsData.tools.forEach(function (count, tool) {
|
|
618
|
+
if (count > topToolCount) {
|
|
619
|
+
topToolCount = count;
|
|
620
|
+
topTool = tool;
|
|
621
|
+
}
|
|
622
|
+
});
|
|
623
|
+
var modelMix: Record<string, number> = {};
|
|
624
|
+
var modelTotal = 0;
|
|
625
|
+
dsData.models.forEach(function (cost) { modelTotal += cost; });
|
|
626
|
+
if (modelTotal > 0) {
|
|
627
|
+
dsData.models.forEach(function (cost, model) {
|
|
628
|
+
modelMix[model] = Math.round((cost / modelTotal) * 100) / 100;
|
|
629
|
+
});
|
|
630
|
+
}
|
|
631
|
+
dailySummaries.push({
|
|
632
|
+
date: dsd,
|
|
633
|
+
sessions: dsData.sessions,
|
|
634
|
+
cost: dsData.cost,
|
|
635
|
+
tokens: dsData.tokens,
|
|
636
|
+
topTool: topTool,
|
|
637
|
+
modelMix: modelMix,
|
|
638
|
+
});
|
|
639
|
+
}
|
|
640
|
+
|
|
411
641
|
return {
|
|
412
642
|
totalCost: totalCost,
|
|
413
643
|
totalSessions: filtered.length,
|
|
@@ -430,6 +660,13 @@ function aggregate(sessions: SessionData[], period: AnalyticsPeriod): AnalyticsP
|
|
|
430
660
|
modelUsage: modelUsage,
|
|
431
661
|
projectBreakdown: projectBreakdown,
|
|
432
662
|
toolUsage: toolUsage,
|
|
663
|
+
responseTimeData: responseTimeData,
|
|
664
|
+
contextUtilization: contextUtilization,
|
|
665
|
+
tokenFlowSankey: tokenFlowSankey,
|
|
666
|
+
activityCalendar: activityCalendar,
|
|
667
|
+
hourlyHeatmap: hourlyHeatmap,
|
|
668
|
+
sessionTimeline: sessionTimeline,
|
|
669
|
+
dailySummaries: dailySummaries,
|
|
433
670
|
};
|
|
434
671
|
}
|
|
435
672
|
|