Skip to content

Commit aa295bd

Browse files
committed
Add active hours metric
1 parent 5645860 commit aa295bd

File tree

5 files changed

+142
-6
lines changed

5 files changed

+142
-6
lines changed

scripts/collect_coder_analytics.py

Lines changed: 87 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -166,6 +166,56 @@ def calculate_workspace_total_usage(builds: list[dict[str, Any]]) -> float:
166166
return total_hours
167167

168168

169+
def fetch_user_activity_insights(
170+
api_url: str, session_token: str, start_time: str, end_time: str
171+
) -> dict[str, float]:
172+
"""Fetch user activity insights from Coder API.
173+
174+
Parameters
175+
----------
176+
api_url : str
177+
The Coder API base URL
178+
session_token : str
179+
The Coder session token for authentication
180+
start_time : str
181+
Start time in ISO 8601 format (e.g., "2025-11-01T00:00:00Z")
182+
end_time : str
183+
End time in ISO 8601 format (e.g., "2025-12-10T00:00:00Z")
184+
185+
Returns
186+
-------
187+
dict[str, float]
188+
Mapping of username (lowercase) -> active_hours
189+
"""
190+
url = f"{api_url}/api/v2/insights/user-activity"
191+
headers = {"Coder-Session-Token": session_token}
192+
params = {"start_time": start_time, "end_time": end_time}
193+
194+
try:
195+
response = requests.get(url, headers=headers, params=params, timeout=30)
196+
response.raise_for_status()
197+
data = response.json()
198+
199+
# Create mapping of username -> active hours
200+
activity_map = {}
201+
users = data.get("report", {}).get("users", [])
202+
for user in users:
203+
username = user.get("username", "").lower()
204+
seconds = user.get("seconds", 0)
205+
hours = round(seconds / 3600.0, 2)
206+
activity_map[username] = hours
207+
208+
return activity_map
209+
except requests.RequestException as e:
210+
print(f"Warning: Failed to fetch user activity insights: {e}")
211+
try:
212+
error_details = response.json() if response else {}
213+
print(f"Error details: {error_details}")
214+
except Exception:
215+
pass
216+
return {}
217+
218+
169219
def get_team_mappings() -> dict[str, str]:
170220
"""Get team mappings from Firestore.
171221
@@ -198,7 +248,7 @@ def get_team_mappings() -> dict[str, str]:
198248
def fetch_workspaces(
199249
team_mappings: dict[str, str], api_url: str, session_token: str
200250
) -> list[dict[str, Any]]:
201-
"""Fetch all workspaces using Coder CLI and enrich with build history.
251+
"""Fetch workspaces using Coder CLI and enrich with build data.
202252
203253
Parameters
204254
----------
@@ -212,7 +262,7 @@ def fetch_workspaces(
212262
Returns
213263
-------
214264
list[dict[str, Any]]
215-
List of workspace objects with builds and usage hours
265+
List of workspace objects with builds, usage hours, and active hours
216266
"""
217267
print("Fetching workspaces from Coder...")
218268
workspaces = run_command(["coder", "list", "-a", "-o", "json"])
@@ -239,8 +289,35 @@ def fetch_workspaces(
239289

240290
print(f"✓ Fetched {len(filtered_workspaces)} workspaces")
241291

292+
# Fetch user activity insights (active hours)
293+
# Use a wide time range to capture all activity
294+
# Find earliest workspace creation date
295+
print("Fetching user activity insights...")
296+
earliest_created = min(
297+
(
298+
datetime.fromisoformat(ws.get("created_at", "").replace("Z", "+00:00"))
299+
for ws in filtered_workspaces
300+
if ws.get("created_at")
301+
),
302+
default=datetime.now(UTC),
303+
)
304+
# Normalize to midnight (00:00:00) as required by the API
305+
start_time = earliest_created.replace(
306+
hour=0, minute=0, second=0, microsecond=0
307+
).strftime("%Y-%m-%dT%H:%M:%SZ")
308+
# Normalize end time to the start of the current hour (required by API)
309+
now = datetime.now(UTC)
310+
end_time = now.replace(minute=0, second=0, microsecond=0).strftime(
311+
"%Y-%m-%dT%H:%M:%SZ"
312+
)
313+
314+
activity_map = fetch_user_activity_insights(
315+
api_url, session_token, start_time, end_time
316+
)
317+
print(f"✓ Fetched activity data for {len(activity_map)} users")
318+
242319
# Enrich workspaces with full build history and usage hours
243-
print("Enriching workspaces with build history...")
320+
print("Enriching workspaces with build history and active hours...")
244321
for i, workspace in enumerate(filtered_workspaces, 1):
245322
workspace_id = workspace.get("id")
246323
if workspace_id:
@@ -252,11 +329,17 @@ def fetch_workspaces(
252329
total_usage_hours = calculate_workspace_total_usage(builds)
253330
workspace["total_usage_hours"] = round(total_usage_hours, 2)
254331

332+
# Add active hours from activity insights
333+
owner_name = workspace.get("owner_name", "").lower()
334+
workspace["active_hours"] = activity_map.get(owner_name, 0.0)
335+
255336
# Progress indicator
256337
if i % 10 == 0:
257338
print(f" Processed {i}/{len(filtered_workspaces)} workspaces...")
258339

259-
print(f"✓ Enriched {len(filtered_workspaces)} workspaces with build history")
340+
print(
341+
f"✓ Enriched {len(filtered_workspaces)} workspaces with build history and active hours"
342+
)
260343
return filtered_workspaces
261344

262345

services/analytics/app/components/templates-table.tsx

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -52,10 +52,15 @@ export function TemplatesTable({ templates }: TemplatesTableProps) {
5252
</Tooltip>
5353
</th>
5454
<th className="px-6 py-4 text-right text-xs font-semibold text-slate-700 dark:text-slate-300 uppercase tracking-wider">
55-
<Tooltip content="Sum of workspace usage hours (time from first connection to last connection) for this template" position="right">
55+
<Tooltip content="Sum of workspace usage hours (time from first connection to last connection) for this template">
5656
Total Hours
5757
</Tooltip>
5858
</th>
59+
<th className="px-6 py-4 text-right text-xs font-semibold text-slate-700 dark:text-slate-300 uppercase tracking-wider">
60+
<Tooltip content="Sum of actual active interaction hours based on agent activity heartbeats (excludes idle time)" position="right">
61+
Active Hours
62+
</Tooltip>
63+
</th>
5964
<th className="px-6 py-4 text-right text-xs font-semibold text-slate-700 dark:text-slate-300 uppercase tracking-wider">
6065
{/* Actions */}
6166
</th>
@@ -103,6 +108,11 @@ export function TemplatesTable({ templates }: TemplatesTableProps) {
103108
<td className="px-6 py-4 text-right text-sm text-slate-700 dark:text-slate-300">
104109
{template.total_workspace_hours.toLocaleString()}h
105110
</td>
111+
<td className="px-6 py-4 text-right text-sm">
112+
<span className="inline-flex items-center px-3 py-1 rounded-full text-xs font-medium bg-emerald-50 text-emerald-700 dark:bg-emerald-900/30 dark:text-emerald-400 border border-emerald-200 dark:border-emerald-800">
113+
{template.total_active_hours.toLocaleString()}h
114+
</span>
115+
</td>
106116
<td className="px-6 py-4 text-right">
107117
<Link
108118
href={`/template/${encodeURIComponent(template.template_name)}`}

services/analytics/app/template/[templateName]/template-teams-content.tsx

Lines changed: 17 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@ import type { User } from '@vector-institute/aieng-auth-core';
77
import type { AnalyticsSnapshot, TeamMetrics } from '@/lib/types';
88
import { Tooltip } from '@/app/components/tooltip';
99

10-
type SortColumn = 'team_name' | 'workspaces_for_template' | 'unique_active_users' | 'total_workspace_hours' | 'active_days';
10+
type SortColumn = 'team_name' | 'workspaces_for_template' | 'unique_active_users' | 'total_workspace_hours' | 'total_active_hours' | 'active_days';
1111
type SortDirection = 'asc' | 'desc';
1212

1313
interface TemplateTeamsContentProps {
@@ -299,6 +299,17 @@ export default function TemplateTeamsContent({ user, templateName }: TemplateTea
299299
</div>
300300
</Tooltip>
301301
</th>
302+
<th
303+
className="px-6 py-4 text-right text-xs font-semibold text-slate-700 dark:text-slate-300 uppercase tracking-wider cursor-pointer hover:bg-slate-200 dark:hover:bg-slate-700 transition-colors"
304+
onClick={() => handleSort('total_active_hours')}
305+
>
306+
<Tooltip content="Sum of actual active interaction hours based on agent activity heartbeats (excludes idle time)">
307+
<div className="flex items-center justify-end gap-2">
308+
Active Hours
309+
{getSortIcon('total_active_hours')}
310+
</div>
311+
</Tooltip>
312+
</th>
302313
<th
303314
className="px-6 py-4 text-right text-xs font-semibold text-slate-700 dark:text-slate-300 uppercase tracking-wider cursor-pointer hover:bg-slate-200 dark:hover:bg-slate-700 transition-colors"
304315
onClick={() => handleSort('active_days')}
@@ -334,6 +345,11 @@ export default function TemplateTeamsContent({ user, templateName }: TemplateTea
334345
<td className="px-6 py-4 text-right text-sm text-slate-700 dark:text-slate-300">
335346
{team.total_workspace_hours.toLocaleString()}h
336347
</td>
348+
<td className="px-6 py-4 text-right text-sm">
349+
<span className="inline-flex items-center px-3 py-1 rounded-full text-xs font-medium bg-emerald-50 text-emerald-700 dark:bg-emerald-900/30 dark:text-emerald-400 border border-emerald-200 dark:border-emerald-800">
350+
{team.total_active_hours.toLocaleString()}h
351+
</span>
352+
</td>
337353
<td className="px-6 py-4 text-right text-sm text-vector-turquoise">
338354
{team.active_days}
339355
</td>

services/analytics/lib/metrics.ts

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -83,6 +83,19 @@ function getWorkspaceUsageHours(workspace: CoderWorkspace): number {
8383
}
8484
}
8585

86+
/**
87+
* Get workspace active hours from pre-calculated field
88+
* The collection script fetches this from Coder Insights API
89+
*/
90+
function getWorkspaceActiveHours(workspace: CoderWorkspace): number {
91+
// Use pre-calculated active_hours from collection script
92+
if (workspace.active_hours !== undefined && workspace.active_hours !== null) {
93+
return workspace.active_hours;
94+
}
95+
96+
return 0;
97+
}
98+
8699
/**
87100
* Classify activity status based on days since last active
88101
*/
@@ -207,6 +220,7 @@ export function enrichWorkspaceData(
207220
const daysSinceActive = daysBetween(new Date(lastActive), now);
208221
const daysSinceCreated = daysBetween(new Date(workspace.created_at), now);
209222
const workspaceHours = getWorkspaceUsageHours(workspace);
223+
const activeHours = getWorkspaceActiveHours(workspace);
210224

211225
// Determine full name
212226
let ownerName = workspace.owner_name;
@@ -231,6 +245,7 @@ export function enrichWorkspaceData(
231245
days_since_created: daysSinceCreated,
232246
days_since_active: daysSinceActive,
233247
workspace_hours: workspaceHours,
248+
active_hours: activeHours,
234249
total_builds: workspace.latest_build.build_number,
235250
last_build_status: workspace.latest_build.job?.status || 'unknown',
236251
activity_status: classifyActivityStatus(daysSinceActive),
@@ -263,6 +278,9 @@ export function aggregateByTeam(workspaces: WorkspaceMetrics[]): TeamMetrics[] {
263278
// Total workspace hours (sum of all workspace lifetime hours)
264279
const totalWorkspaceHours = teamWorkspaces.reduce((sum, w) => sum + w.workspace_hours, 0);
265280

281+
// Total active hours (sum of actual interaction hours from Insights API)
282+
const totalActiveHours = teamWorkspaces.reduce((sum, w) => sum + w.active_hours, 0);
283+
266284
// Average workspace hours
267285
const avgWorkspaceHours =
268286
teamWorkspaces.length > 0 ? totalWorkspaceHours / teamWorkspaces.length : 0;
@@ -319,6 +337,7 @@ export function aggregateByTeam(workspaces: WorkspaceMetrics[]): TeamMetrics[] {
319337
total_workspaces: teamWorkspaces.length,
320338
unique_active_users: activeUsers.size,
321339
total_workspace_hours: Math.round(totalWorkspaceHours),
340+
total_active_hours: Math.round(totalActiveHours),
322341
avg_workspace_hours: Math.round(avgWorkspaceHours * 10) / 10,
323342
active_days: activeDates.size,
324343
template_distribution: templateDistribution,
@@ -413,6 +432,9 @@ export function calculateTemplateMetrics(
413432
// Total workspace hours (sum of all workspace lifetime hours)
414433
const totalWorkspaceHours = templateWorkspaces.reduce((sum, w) => sum + w.workspace_hours, 0);
415434

435+
// Total active hours (sum of actual interaction hours from Insights API)
436+
const totalActiveHours = templateWorkspaces.reduce((sum, w) => sum + w.active_hours, 0);
437+
416438
// Average workspace hours
417439
const avgWorkspaceHours =
418440
templateWorkspaces.length > 0 ? totalWorkspaceHours / templateWorkspaces.length : 0;
@@ -438,6 +460,7 @@ export function calculateTemplateMetrics(
438460
active_workspaces: activeWorkspaces.length,
439461
unique_active_users: activeUsers.size,
440462
total_workspace_hours: Math.round(totalWorkspaceHours),
463+
total_active_hours: Math.round(totalActiveHours),
441464
avg_workspace_hours: Math.round(avgWorkspaceHours * 10) / 10,
442465
team_distribution: teamDistribution,
443466
};

services/analytics/lib/types.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ export interface CoderWorkspace {
1515
template_icon?: string;
1616
name?: string;
1717
total_usage_hours?: number; // Total usage hours across all builds (added by collection script)
18+
active_hours?: number; // Active interaction hours from Insights API (added by collection script)
1819
all_builds?: any[]; // Full build history (added by collection script)
1920
latest_build: {
2021
id: string;
@@ -126,6 +127,7 @@ export interface WorkspaceMetrics {
126127
days_since_created: number;
127128
days_since_active: number;
128129
workspace_hours: number; // Total usage hours (from first connection to last connection)
130+
active_hours: number; // Actual active interaction hours from Insights API
129131

130132
// Build metrics
131133
total_builds: number;
@@ -144,6 +146,7 @@ export interface TeamMetrics {
144146

145147
// Time-based metrics
146148
total_workspace_hours: number; // Sum of all workspace usage hours (first to last connection)
149+
total_active_hours: number; // Sum of actual active interaction hours from Insights API
147150
avg_workspace_hours: number; // Average workspace usage hours (first to last connection)
148151
active_days: number; // Number of unique days with workspace activity
149152

@@ -192,6 +195,7 @@ export interface TemplateMetrics {
192195
active_workspaces: number;
193196
unique_active_users: number; // Number of unique users with activity in last 7 days
194197
total_workspace_hours: number; // Sum of workspace usage hours (first to last connection) for this template
198+
total_active_hours: number; // Sum of actual active interaction hours from Insights API
195199
avg_workspace_hours: number; // Average workspace usage hours (first to last connection)
196200
team_distribution: Record<string, number>;
197201
}

0 commit comments

Comments
 (0)