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
58 changes: 57 additions & 1 deletion backend/app/api/analytics.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@
from fastapi import APIRouter, Query
from tortoise import connections
from app.models import LogLevel
from app.schemas import VolumeResponse, VolumeBucket, TopResponse, TopItem, HeatmapResponse, HeatmapCell
from app.schemas import VolumeResponse, VolumeBucket, TopResponse, TopItem, HeatmapResponse, HeatmapCell, TopUsersVolumeResponse, TopUsersVolumeBucket
from app.api.deps import get_team_member, CurrentUser

router = APIRouter()
Expand Down Expand Up @@ -137,6 +137,62 @@ async def analytics_top(
return TopResponse(items=[TopItem(value=r["value"], count=r["count"]) for r in rows])


@router.get("/{team_id}/analytics/top-users-volume", response_model=TopUsersVolumeResponse)
async def analytics_top_users_volume(
team_id: UUID,
user: CurrentUser,
bucket: Literal["hour", "day", "week"] = "hour",
limit: int = Query(10, ge=1, le=50),
from_time: datetime | None = Query(None, alias="from"),
to_time: datetime | None = Query(None, alias="to"),
):
team, _ = await get_team_member(team_id, user)
start, end = _default_range(from_time, to_time)
conn = connections.get("default")

trunc = BUCKET_SQL[bucket]

# Step 1: find top N user_ids by total count
top_rows = await conn.execute_query_dict(
"""
SELECT user_id, count(*) AS count
FROM logs
WHERE team_id = $1 AND timestamp >= $2 AND timestamp <= $3
AND user_id IS NOT NULL
GROUP BY user_id
ORDER BY count DESC
LIMIT $4
""",
[str(team.id), start, end, limit],
)

user_ids = [r["user_id"] for r in top_rows]

if not user_ids:
return TopUsersVolumeResponse(users=[], buckets=[])

# Step 2: bucket counts for only those user_ids
placeholders = ", ".join(f"${i}" for i in range(4, 4 + len(user_ids)))
rows = await conn.execute_query_dict(
f"""
SELECT {trunc} AS bucket, user_id, count(*) AS count
FROM logs
WHERE team_id = $1 AND timestamp >= $2 AND timestamp <= $3
AND user_id IN ({placeholders})
GROUP BY bucket, user_id
ORDER BY bucket, user_id
""",
[str(team.id), start, end, *user_ids],
)

buckets = [
TopUsersVolumeBucket(bucket=str(r["bucket"]), user_id=r["user_id"], count=r["count"])
for r in rows
]

return TopUsersVolumeResponse(users=user_ids, buckets=buckets)


@router.get("/{team_id}/analytics/heatmap", response_model=HeatmapResponse)
async def analytics_heatmap(
team_id: UUID,
Expand Down
3 changes: 2 additions & 1 deletion backend/app/schemas/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@
from app.schemas.user import UserCreate, UserUpdate, UserResponse
from app.schemas.team import TeamCreate, TeamUpdate, TeamResponse, TeamWithKey, MembershipCreate, MembershipResponse
from app.schemas.log import LogCreate, LogBatchCreate, LogResponse, LogSearchParams, UserIdBackfillRequest, UserIdBackfillResponse
from app.schemas.analytics import VolumeResponse, VolumeBucket, TopResponse, TopItem, HeatmapResponse, HeatmapCell
from app.schemas.analytics import VolumeResponse, VolumeBucket, TopResponse, TopItem, HeatmapResponse, HeatmapCell, TopUsersVolumeResponse, TopUsersVolumeBucket

__all__ = [
"Token", "TokenPayload", "LoginRequest", "RefreshRequest",
Expand All @@ -11,4 +11,5 @@
"LogCreate", "LogBatchCreate", "LogResponse", "LogSearchParams",
"UserIdBackfillRequest", "UserIdBackfillResponse",
"VolumeResponse", "VolumeBucket", "TopResponse", "TopItem", "HeatmapResponse", "HeatmapCell",
"TopUsersVolumeResponse", "TopUsersVolumeBucket",
]
11 changes: 11 additions & 0 deletions backend/app/schemas/analytics.py
Original file line number Diff line number Diff line change
Expand Up @@ -32,3 +32,14 @@ class HeatmapResponse(BaseModel):
sources: list[str]
levels: list[str]
data: list[HeatmapCell]


class TopUsersVolumeBucket(BaseModel):
bucket: str
user_id: str
count: int


class TopUsersVolumeResponse(BaseModel):
users: list[str]
buckets: list[TopUsersVolumeBucket]
11 changes: 11 additions & 0 deletions frontend/src/api/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -139,3 +139,14 @@ export interface HeatmapResponse {
levels: string[]
data: HeatmapCell[]
}

export interface TopUsersVolumeBucket {
bucket: string
user_id: string
count: number
}

export interface TopUsersVolumeResponse {
users: string[]
buckets: TopUsersVolumeBucket[]
}
9 changes: 8 additions & 1 deletion frontend/src/composables/useAnalytics.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import api, {
type VolumeResponse,
type TopResponse,
type HeatmapResponse,
type TopUsersVolumeResponse,
} from '@/api/client'

export interface TimeRange {
Expand All @@ -15,6 +16,7 @@ export function useAnalytics(teamId: string) {
const topSources = ref<TopResponse | null>(null) as Ref<TopResponse | null>
const topErrors = ref<TopResponse | null>(null) as Ref<TopResponse | null>
const topUsers = ref<TopResponse | null>(null) as Ref<TopResponse | null>
const topUsersVolume = ref<TopUsersVolumeResponse | null>(null) as Ref<TopUsersVolumeResponse | null>
const heatmap = ref<HeatmapResponse | null>(null) as Ref<HeatmapResponse | null>
const loading = ref(false)

Expand All @@ -26,7 +28,7 @@ export function useAnalytics(teamId: string) {
loading.value = true
try {
const params = rangeParams(range)
const [volRes, srcRes, errRes, usrRes, hmRes] = await Promise.all([
const [volRes, srcRes, errRes, usrRes, tuvRes, hmRes] = await Promise.all([
api.get(`/teams/${teamId}/analytics/volume`, {
params: { ...params, bucket, split_by: 'level' },
}),
Expand All @@ -39,6 +41,9 @@ export function useAnalytics(teamId: string) {
api.get(`/teams/${teamId}/analytics/top`, {
params: { ...params, field: 'user_id' },
}),
api.get(`/teams/${teamId}/analytics/top-users-volume`, {
params: { ...params, bucket },
}),
api.get(`/teams/${teamId}/analytics/heatmap`, {
params,
}),
Expand All @@ -47,6 +52,7 @@ export function useAnalytics(teamId: string) {
topSources.value = srcRes.data
topErrors.value = errRes.data
topUsers.value = usrRes.data
topUsersVolume.value = tuvRes.data
heatmap.value = hmRes.data
} catch (e) {
console.error('Failed to fetch analytics:', e)
Expand All @@ -60,6 +66,7 @@ export function useAnalytics(teamId: string) {
topSources,
topErrors,
topUsers,
topUsersVolume,
heatmap,
loading,
fetchAll,
Expand Down
41 changes: 40 additions & 1 deletion frontend/src/composables/useChartOptions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import type {
VolumeResponse,
TopResponse,
HeatmapResponse,
TopUsersVolumeResponse,
} from '@/api/client'

const LEVEL_COLORS: Record<string, string> = {
Expand All @@ -23,11 +24,17 @@ function formatBucket(iso: string): string {
return `${month}/${day} ${hour}:00`
}

const USER_COLORS = [
'#5470c6', '#91cc75', '#fac858', '#ee6666', '#73c0de',
'#3ba272', '#fc8452', '#9a60b4', '#ea7ccc', '#48b8d0',
]

export function useChartOptions(
volume: Ref<VolumeResponse | null>,
topSources: Ref<TopResponse | null>,
topErrors: Ref<TopResponse | null>,
topUsers: Ref<TopResponse | null>,
topUsersVolume: Ref<TopUsersVolumeResponse | null>,
heatmap: Ref<HeatmapResponse | null>,
) {

Expand Down Expand Up @@ -182,7 +189,38 @@ export function useChartOptions(
}
})

// Chart 7: Source x Level heatmap
// Chart 7: Top users volume over time (multi-series line)
const topUsersVolumeOption = computed(() => {
const data = topUsersVolume.value
if (!data || data.buckets.length === 0) return null

const bucketSet = [...new Set(data.buckets.map(b => b.bucket))].sort()
const xLabels = bucketSet.map(formatBucket)

const series = data.users.map((userId, idx) => {
const counts = bucketSet.map(bucket => {
const entry = data.buckets.find(b => b.bucket === bucket && b.user_id === userId)
return entry?.count ?? 0
})
return {
name: userId,
type: 'line' as const,
data: counts,
itemStyle: { color: USER_COLORS[idx % USER_COLORS.length] },
}
})

return {
tooltip: { trigger: 'axis' },
legend: { data: data.users, top: 0, type: 'scroll' },
grid: { left: 50, right: 20, bottom: 40, top: 40 },
xAxis: { type: 'category', data: xLabels },
yAxis: { type: 'value' },
series,
}
})

// Chart 8: Source x Level heatmap
const heatmapOption = computed(() => {
const data = heatmap.value
if (!data || data.data.length === 0) return null
Expand Down Expand Up @@ -236,6 +274,7 @@ export function useChartOptions(
topSourcesOption,
topErrorsData,
topUsersOption,
topUsersVolumeOption,
heatmapOption,
}
}
Loading