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
4 changes: 4 additions & 0 deletions scripts/collect_coder_analytics.py
Original file line number Diff line number Diff line change
Expand Up @@ -330,6 +330,10 @@ def fetch_workspaces(
workspace["total_usage_hours"] = round(total_usage_hours, 2)

# Add active hours from activity insights
# NOTE: active_hours is per-USER (not per-workspace) and includes
# ALL TIME activity. Users with multiple workspaces will have the
# same value on each workspace. The frontend aggregation logic
# handles this correctly by counting each user once.
owner_name = workspace.get("owner_name", "").lower()
workspace["active_hours"] = activity_map.get(owner_name, 0.0)

Expand Down
44 changes: 40 additions & 4 deletions services/analytics/lib/metrics.ts
Original file line number Diff line number Diff line change
Expand Up @@ -96,6 +96,42 @@ function getWorkspaceActiveHours(workspace: CoderWorkspace): number {
return 0;
}

/**
* Calculate total active hours for a group of workspaces, ensuring logical consistency
*
* Active hours from Insights API are per-user across all time (including deleted workspaces).
* To ensure active hours ≤ total hours, we cap each user's active hours at their total workspace hours.
*
* @param workspaces - Array of workspace metrics to aggregate
* @returns Total active hours across all unique users, capped at their workspace totals
*/
function calculateTotalActiveHours(workspaces: WorkspaceMetrics[]): number {
// Track active hours and total hours per unique user
const userActiveHours = new Map<string, number>();
const userTotalHours = new Map<string, number>();

workspaces.forEach((workspace) => {
const user = workspace.owner_github_handle;

// Active hours are per-user (same across all their workspaces), so take max
const existingActive = userActiveHours.get(user) || 0;
userActiveHours.set(user, Math.max(existingActive, workspace.active_hours));

// Total hours are per-workspace, so sum them up
const existingTotal = userTotalHours.get(user) || 0;
userTotalHours.set(user, existingTotal + workspace.workspace_hours);
});

// Cap each user's active hours at their total hours (handles deleted workspaces)
let totalActive = 0;
userActiveHours.forEach((activeHours, user) => {
const totalHours = userTotalHours.get(user) || 0;
totalActive += Math.min(activeHours, totalHours);
});

return totalActive;
}

/**
* Classify activity status based on days since last active
*/
Expand Down Expand Up @@ -278,8 +314,8 @@ 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);
// Total active hours (aggregated correctly per-user, capped at their workspace totals)
const totalActiveHours = calculateTotalActiveHours(teamWorkspaces);

// Average workspace hours
const avgWorkspaceHours =
Expand Down Expand Up @@ -432,8 +468,8 @@ 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);
// Total active hours (aggregated correctly per-user, capped at their workspace totals)
const totalActiveHours = calculateTotalActiveHours(templateWorkspaces);

// Average workspace hours
const avgWorkspaceHours =
Expand Down