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
13 changes: 13 additions & 0 deletions coder/analytics/aggregate.py
Original file line number Diff line number Diff line change
Expand Up @@ -470,6 +470,16 @@ def compute_template_metrics(
for ws in workspaces:
team_dist[ws["team_name"]] += 1

# Per-team active hours for this template specifically (not team-wide totals)
team_active_hours: dict[str, float] = defaultdict(float)
for r in accumulated_usage.values():
if (
r.get("template_name") == t_name
and r.get("team_name", "Unassigned") not in EXCLUDED_TEAMS
):
team = r.get("team_name", "Unassigned")
team_active_hours[team] += r.get("total_active_hours", 0.0)

result.append(
{
"template_id": t_id,
Expand All @@ -482,6 +492,9 @@ def compute_template_metrics(
"total_active_hours": round(total_active_hours),
"avg_workspace_hours": round(avg_ws_hours * 10) / 10,
"team_distribution": dict(team_dist),
"team_active_hours": {
team: round(hours) for team, hours in team_active_hours.items()
},
}
)

Expand Down
7 changes: 6 additions & 1 deletion coder/analytics/collect.py
Original file line number Diff line number Diff line change
Expand Up @@ -677,7 +677,12 @@ def calculate_accumulated_usage(
"owner_name": data["owner_name"],
"template_name": data["template_name"],
"team_name": team_name,
"total_active_hours": data["current_active"],
# Start at 0 so we don't inherit cross-template hours.
# The Insights API returns a per-user total across all templates,
# so initialising with current_active would incorrectly dump all
# historical activity from other templates into this new record.
# The next collection run will add only the incremental delta.
"total_active_hours": 0.0,
"total_workspace_hours": sum(workspace_hours_map.values()),
"workspace_ids": workspace_ids,
"last_updated": now,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -386,7 +386,7 @@ export default function TemplateTeamsContent({ user, templateName }: TemplateTea
</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
{(template.team_active_hours?.[team.team_name] ?? 0).toLocaleString()}h
</span>
</td>
<td className="px-6 py-4 text-right text-sm text-vector-turquoise">
Expand Down
1 change: 1 addition & 0 deletions services/analytics/lib/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -122,6 +122,7 @@ export interface TemplateMetrics {
total_active_hours: number; // Accumulated active hours from Insights API (includes deleted workspaces)
avg_workspace_hours: number; // Average workspace usage hours (first to last connection)
team_distribution: Record<string, number>;
team_active_hours: Record<string, number>; // Accumulated active hours per team for this template
}

// ===== API Response Types =====
Expand Down
102 changes: 88 additions & 14 deletions tests/coder/analytics/test_collect.py
Original file line number Diff line number Diff line change
Expand Up @@ -213,7 +213,13 @@ class TestCalculateAccumulatedUsage:
"""Tests for calculate_accumulated_usage function."""

def test_creates_new_record_for_new_workspace(self) -> None:
"""Test creating a new accumulated usage record for a new workspace."""
"""Test that a new accumulated usage record starts at 0 active hours.

New records must not be pre-loaded with the Insights API value because
that value is a per-user total across all templates, not template-specific.
The workspace_usage_snapshot still stores the current value so subsequent
runs can compute the correct incremental delta.
"""
current_workspaces = [
{
"id": "ws-1",
Expand All @@ -235,8 +241,11 @@ def test_creates_new_record_for_new_workspace(self) -> None:

key = "user1_python-dev"
assert key in accumulated
assert accumulated[key]["total_active_hours"] == 10.0
# New record starts at 0, not at current_active, to avoid inheriting
# cross-template hours from the Insights API per-user total.
assert accumulated[key]["total_active_hours"] == 0.0
assert accumulated[key]["team_name"] == "team-a"
# Snapshot stores the actual current value for delta computation next run.
assert "ws-1" in snapshots
assert snapshots["ws-1"]["active_hours"] == 10.0

Expand Down Expand Up @@ -332,17 +341,19 @@ def test_preserves_deleted_workspace_hours(self) -> None:
assert "ws-1" not in snapshots

def test_handles_multiple_templates_per_user(self) -> None:
"""Test accumulation across multiple templates for same user.
"""Test that brand-new records for multiple templates both start at 0.

Note: active_hours from Insights API is per-user, so both templates
get the same value (max across all workspaces).
The Insights API returns a per-user total across all templates.
Initialising new records with that value would double-count hours for
users who have workspaces on more than one template. Both records must
start at 0 and accumulate only incremental deltas going forward.
"""
current_workspaces = [
{
"id": "ws-1",
"owner_name": "user1",
"template_name": "python-dev",
"active_hours": 10.0, # Per-user value
"active_hours": 10.0, # Per-user Insights API total
},
{
"id": "ws-2",
Expand All @@ -364,9 +375,71 @@ def test_handles_multiple_templates_per_user(self) -> None:

assert "user1_python-dev" in accumulated
assert "user1_nodejs-dev" in accumulated
# Both should have the same per-user active hours (10.0)
assert accumulated["user1_python-dev"]["total_active_hours"] == 10.0
assert accumulated["user1_nodejs-dev"]["total_active_hours"] == 10.0
# Both new records start at 0 to avoid inheriting cross-template hours.
assert accumulated["user1_python-dev"]["total_active_hours"] == 0.0
assert accumulated["user1_nodejs-dev"]["total_active_hours"] == 0.0

def test_new_template_does_not_inherit_existing_template_hours(self) -> None:
"""Test that a new template record does not inherit hours from other templates.

Regression test for the bug where a user with accumulated hours on
template A would have those hours immediately copied into a brand-new
record for template B when they created their first workspace there.

Scenario: user1 has 45h accumulated on python-dev (recorded across many
runs). The Insights API now returns 50h (a 5h increase since last run).
user1 simultaneously creates a first workspace on nodejs-dev.

Expected:
- python-dev gains the 5h delta → 50h total (correct)
- nodejs-dev starts at 0h, not 50h (the cross-template Insights total)
"""
current_workspaces = [
{
"id": "ws-1",
"owner_name": "user1",
"template_name": "python-dev",
"active_hours": 50.0, # Current Insights API total for user1
},
{
"id": "ws-2", # First workspace on nodejs-dev
"owner_name": "user1",
"template_name": "nodejs-dev",
"active_hours": 50.0, # Same per-user Insights API total
},
]
historical_accumulated = {
"user1_python-dev": {
"owner_name": "user1",
"template_name": "python-dev",
"team_name": "team-a",
"total_active_hours": 45.0,
"last_updated": "2024-01-01T00:00:00Z",
"first_seen": "2024-01-01T00:00:00Z",
}
}
historical_workspace_snapshots = {
"ws-1": {
"active_hours": 45.0, # Previous Insights API value for user1
"owner_name": "user1",
"template_name": "python-dev",
}
}
participant_mappings = {"user1": {"team_name": "team-a"}}

accumulated, snapshots = calculate_accumulated_usage(
current_workspaces,
historical_accumulated,
historical_workspace_snapshots,
participant_mappings,
)

# python-dev: existing record grows by delta (50 - 45 = 5h)
assert accumulated["user1_python-dev"]["total_active_hours"] == 50.0
# nodejs-dev: new record must start at 0, not inherit the 50h total
assert accumulated["user1_nodejs-dev"]["total_active_hours"] == 0.0
# Snapshot for the new workspace records current value for next delta
assert snapshots["ws-2"]["active_hours"] == 50.0

def test_prevents_negative_delta(self) -> None:
"""Test that negative deltas (hours going backwards) are treated as 0."""
Expand Down Expand Up @@ -493,10 +566,11 @@ def test_preserves_historical_team_for_deleted_participant(self) -> None:
assert accumulated[key]["total_active_hours"] == 15.0

def test_handles_multiple_workspaces_same_user_template(self) -> None:
"""Test accumulation when user has multiple workspaces from same template.
"""Test that multiple workspaces under the same user+template start at 0.

Note: active_hours from Insights API is per-user (same across all workspaces),
so we take the max value, not sum.
active_hours from the Insights API is per-user, so both workspaces carry
the same value. The new record still starts at 0; workspace snapshots
record the current per-user value so the next run computes the correct delta.
"""
current_workspaces = [
{
Expand Down Expand Up @@ -524,6 +598,6 @@ def test_handles_multiple_workspaces_same_user_template(self) -> None:
)

key = "user1_python-dev"
# Should be 10 (max of user's active_hours, not sum)
assert accumulated[key]["total_active_hours"] == 10.0
# New record starts at 0 (not the per-user Insights API total).
assert accumulated[key]["total_active_hours"] == 0.0
assert len(snapshots) == 2