Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
91 changes: 87 additions & 4 deletions scripts/collect_coder_analytics.py
Original file line number Diff line number Diff line change
Expand Up @@ -166,6 +166,56 @@ def calculate_workspace_total_usage(builds: list[dict[str, Any]]) -> float:
return total_hours


def fetch_user_activity_insights(
api_url: str, session_token: str, start_time: str, end_time: str
) -> dict[str, float]:
"""Fetch user activity insights from Coder API.

Parameters
----------
api_url : str
The Coder API base URL
session_token : str
The Coder session token for authentication
start_time : str
Start time in ISO 8601 format (e.g., "2025-11-01T00:00:00Z")
end_time : str
End time in ISO 8601 format (e.g., "2025-12-10T00:00:00Z")

Returns
-------
dict[str, float]
Mapping of username (lowercase) -> active_hours
"""
url = f"{api_url}/api/v2/insights/user-activity"
headers = {"Coder-Session-Token": session_token}
params = {"start_time": start_time, "end_time": end_time}

try:
response = requests.get(url, headers=headers, params=params, timeout=30)
response.raise_for_status()
data = response.json()

# Create mapping of username -> active hours
activity_map = {}
users = data.get("report", {}).get("users", [])
for user in users:
username = user.get("username", "").lower()
seconds = user.get("seconds", 0)
hours = round(seconds / 3600.0, 2)
activity_map[username] = hours

return activity_map
except requests.RequestException as e:
print(f"Warning: Failed to fetch user activity insights: {e}")
try:
error_details = response.json() if response else {}
print(f"Error details: {error_details}")
except Exception:
pass
return {}


def get_team_mappings() -> dict[str, str]:
"""Get team mappings from Firestore.

Expand Down Expand Up @@ -198,7 +248,7 @@ def get_team_mappings() -> dict[str, str]:
def fetch_workspaces(
team_mappings: dict[str, str], api_url: str, session_token: str
) -> list[dict[str, Any]]:
"""Fetch all workspaces using Coder CLI and enrich with build history.
"""Fetch workspaces using Coder CLI and enrich with build data.

Parameters
----------
Expand All @@ -212,7 +262,7 @@ def fetch_workspaces(
Returns
-------
list[dict[str, Any]]
List of workspace objects with builds and usage hours
List of workspace objects with builds, usage hours, and active hours
"""
print("Fetching workspaces from Coder...")
workspaces = run_command(["coder", "list", "-a", "-o", "json"])
Expand All @@ -239,8 +289,35 @@ def fetch_workspaces(

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

# Fetch user activity insights (active hours)
# Use a wide time range to capture all activity
# Find earliest workspace creation date
print("Fetching user activity insights...")
earliest_created = min(
(
datetime.fromisoformat(ws.get("created_at", "").replace("Z", "+00:00"))
for ws in filtered_workspaces
if ws.get("created_at")
),
default=datetime.now(UTC),
)
# Normalize to midnight (00:00:00) as required by the API
start_time = earliest_created.replace(
hour=0, minute=0, second=0, microsecond=0
).strftime("%Y-%m-%dT%H:%M:%SZ")
# Normalize end time to the start of the current hour (required by API)
now = datetime.now(UTC)
end_time = now.replace(minute=0, second=0, microsecond=0).strftime(
"%Y-%m-%dT%H:%M:%SZ"
)

activity_map = fetch_user_activity_insights(
api_url, session_token, start_time, end_time
)
print(f"✓ Fetched activity data for {len(activity_map)} users")

# Enrich workspaces with full build history and usage hours
print("Enriching workspaces with build history...")
print("Enriching workspaces with build history and active hours...")
for i, workspace in enumerate(filtered_workspaces, 1):
workspace_id = workspace.get("id")
if workspace_id:
Expand All @@ -252,11 +329,17 @@ def fetch_workspaces(
total_usage_hours = calculate_workspace_total_usage(builds)
workspace["total_usage_hours"] = round(total_usage_hours, 2)

# Add active hours from activity insights
owner_name = workspace.get("owner_name", "").lower()
workspace["active_hours"] = activity_map.get(owner_name, 0.0)

# Progress indicator
if i % 10 == 0:
print(f" Processed {i}/{len(filtered_workspaces)} workspaces...")

print(f"✓ Enriched {len(filtered_workspaces)} workspaces with build history")
print(
f"✓ Enriched {len(filtered_workspaces)} workspaces with build history and active hours"
)
return filtered_workspaces


Expand Down
12 changes: 11 additions & 1 deletion services/analytics/app/components/templates-table.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -52,10 +52,15 @@ export function TemplatesTable({ templates }: TemplatesTableProps) {
</Tooltip>
</th>
<th className="px-6 py-4 text-right text-xs font-semibold text-slate-700 dark:text-slate-300 uppercase tracking-wider">
<Tooltip content="Sum of workspace usage hours (time from first connection to last connection) for this template" position="right">
<Tooltip content="Sum of workspace usage hours (time from first connection to last connection) for this template">
Total Hours
</Tooltip>
</th>
<th className="px-6 py-4 text-right text-xs font-semibold text-slate-700 dark:text-slate-300 uppercase tracking-wider">
<Tooltip content="Sum of actual active interaction hours based on agent activity heartbeats (excludes idle time)" position="right">
Active Hours
</Tooltip>
</th>
<th className="px-6 py-4 text-right text-xs font-semibold text-slate-700 dark:text-slate-300 uppercase tracking-wider">
{/* Actions */}
</th>
Expand Down Expand Up @@ -103,6 +108,11 @@ export function TemplatesTable({ templates }: TemplatesTableProps) {
<td className="px-6 py-4 text-right text-sm text-slate-700 dark:text-slate-300">
{template.total_workspace_hours.toLocaleString()}h
</td>
<td className="px-6 py-4 text-right text-sm">
<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">
{template.total_active_hours.toLocaleString()}h
</span>
</td>
<td className="px-6 py-4 text-right">
<Link
href={`/template/${encodeURIComponent(template.template_name)}`}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ import type { User } from '@vector-institute/aieng-auth-core';
import type { AnalyticsSnapshot, TeamMetrics } from '@/lib/types';
import { Tooltip } from '@/app/components/tooltip';

type SortColumn = 'team_name' | 'workspaces_for_template' | 'unique_active_users' | 'total_workspace_hours' | 'active_days';
type SortColumn = 'team_name' | 'workspaces_for_template' | 'unique_active_users' | 'total_workspace_hours' | 'total_active_hours' | 'active_days';
type SortDirection = 'asc' | 'desc';

interface TemplateTeamsContentProps {
Expand Down Expand Up @@ -299,6 +299,17 @@ export default function TemplateTeamsContent({ user, templateName }: TemplateTea
</div>
</Tooltip>
</th>
<th
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"
onClick={() => handleSort('total_active_hours')}
>
<Tooltip content="Sum of actual active interaction hours based on agent activity heartbeats (excludes idle time)">
<div className="flex items-center justify-end gap-2">
Active Hours
{getSortIcon('total_active_hours')}
</div>
</Tooltip>
</th>
<th
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"
onClick={() => handleSort('active_days')}
Expand Down Expand Up @@ -334,6 +345,11 @@ export default function TemplateTeamsContent({ user, templateName }: TemplateTea
<td className="px-6 py-4 text-right text-sm text-slate-700 dark:text-slate-300">
{team.total_workspace_hours.toLocaleString()}h
</td>
<td className="px-6 py-4 text-right text-sm">
<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">
{team.total_active_hours.toLocaleString()}h
</span>
</td>
<td className="px-6 py-4 text-right text-sm text-vector-turquoise">
{team.active_days}
</td>
Expand Down
23 changes: 23 additions & 0 deletions services/analytics/lib/metrics.ts
Original file line number Diff line number Diff line change
Expand Up @@ -83,6 +83,19 @@ function getWorkspaceUsageHours(workspace: CoderWorkspace): number {
}
}

/**
* Get workspace active hours from pre-calculated field
* The collection script fetches this from Coder Insights API
*/
function getWorkspaceActiveHours(workspace: CoderWorkspace): number {
// Use pre-calculated active_hours from collection script
if (workspace.active_hours !== undefined && workspace.active_hours !== null) {
return workspace.active_hours;
}

return 0;
}

/**
* Classify activity status based on days since last active
*/
Expand Down Expand Up @@ -207,6 +220,7 @@ export function enrichWorkspaceData(
const daysSinceActive = daysBetween(new Date(lastActive), now);
const daysSinceCreated = daysBetween(new Date(workspace.created_at), now);
const workspaceHours = getWorkspaceUsageHours(workspace);
const activeHours = getWorkspaceActiveHours(workspace);

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

// Total active hours (sum of actual interaction hours from Insights API)
const totalActiveHours = teamWorkspaces.reduce((sum, w) => sum + w.active_hours, 0);

// Average workspace hours
const avgWorkspaceHours =
teamWorkspaces.length > 0 ? totalWorkspaceHours / teamWorkspaces.length : 0;
Expand Down Expand Up @@ -319,6 +337,7 @@ export function aggregateByTeam(workspaces: WorkspaceMetrics[]): TeamMetrics[] {
total_workspaces: teamWorkspaces.length,
unique_active_users: activeUsers.size,
total_workspace_hours: Math.round(totalWorkspaceHours),
total_active_hours: Math.round(totalActiveHours),
avg_workspace_hours: Math.round(avgWorkspaceHours * 10) / 10,
active_days: activeDates.size,
template_distribution: templateDistribution,
Expand Down Expand Up @@ -413,6 +432,9 @@ export function calculateTemplateMetrics(
// Total workspace hours (sum of all workspace lifetime hours)
const totalWorkspaceHours = templateWorkspaces.reduce((sum, w) => sum + w.workspace_hours, 0);

// Total active hours (sum of actual interaction hours from Insights API)
const totalActiveHours = templateWorkspaces.reduce((sum, w) => sum + w.active_hours, 0);

// Average workspace hours
const avgWorkspaceHours =
templateWorkspaces.length > 0 ? totalWorkspaceHours / templateWorkspaces.length : 0;
Expand All @@ -438,6 +460,7 @@ export function calculateTemplateMetrics(
active_workspaces: activeWorkspaces.length,
unique_active_users: activeUsers.size,
total_workspace_hours: Math.round(totalWorkspaceHours),
total_active_hours: Math.round(totalActiveHours),
avg_workspace_hours: Math.round(avgWorkspaceHours * 10) / 10,
team_distribution: teamDistribution,
};
Expand Down
4 changes: 4 additions & 0 deletions services/analytics/lib/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ export interface CoderWorkspace {
template_icon?: string;
name?: string;
total_usage_hours?: number; // Total usage hours across all builds (added by collection script)
active_hours?: number; // Active interaction hours from Insights API (added by collection script)
all_builds?: any[]; // Full build history (added by collection script)
latest_build: {
id: string;
Expand Down Expand Up @@ -126,6 +127,7 @@ export interface WorkspaceMetrics {
days_since_created: number;
days_since_active: number;
workspace_hours: number; // Total usage hours (from first connection to last connection)
active_hours: number; // Actual active interaction hours from Insights API

// Build metrics
total_builds: number;
Expand All @@ -144,6 +146,7 @@ export interface TeamMetrics {

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

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