From c62859aabd07a0f27ee1862c2b097de4a2c38c78 Mon Sep 17 00:00:00 2001 From: WilliamK112 <164879897+WilliamK112@users.noreply.github.com> Date: Wed, 12 Aug 2026 19:37:08 +0800 Subject: [PATCH] feat: add duration percentiles to analytics Signed-off-by: WilliamK112 <164879897+WilliamK112@users.noreply.github.com> --- src/__tests__/sessions.test.ts | 30 ++++++++++++++++++++++++++++++ src/sessions.ts | 34 +++++++++++++++++++++++++++++----- 2 files changed, 59 insertions(+), 5 deletions(-) diff --git a/src/__tests__/sessions.test.ts b/src/__tests__/sessions.test.ts index b1fb0fd..e2eec5a 100644 --- a/src/__tests__/sessions.test.ts +++ b/src/__tests__/sessions.test.ts @@ -2,6 +2,9 @@ import { describe, it, expect, beforeEach, afterEach } from "vitest"; import * as fs from "fs"; import * as path from "path"; import * as os from "os"; +import { _testing } from "../sessions"; + +const { durationPercentile, summarizeDurations } = _testing; // Test sessions.ts functions that don't depend on the real ~/.copilot directory // by creating temporary fixture directories @@ -83,6 +86,33 @@ summary_count: 3 }); describe("analytics aggregation logic", () => { + it("returns zero percentiles when no sessions have duration data", () => { + expect(summarizeDurations([])).toEqual({ + avgDuration: 0, + minDuration: 0, + maxDuration: 0, + p50Duration: 0, + p90Duration: 0, + p99Duration: 0, + totalDuration: 0, + }); + }); + + it("uses the single duration for every percentile", () => { + expect(durationPercentile([42_000], 0.5)).toBe(42_000); + expect(durationPercentile([42_000], 0.9)).toBe(42_000); + expect(durationPercentile([42_000], 0.99)).toBe(42_000); + }); + + it("calculates nearest-rank duration percentiles without mutating input", () => { + const durations = [100, 10, 90, 20, 80, 30, 70, 40, 60, 50]; + + expect(durationPercentile(durations, 0.5)).toBe(50); + expect(durationPercentile(durations, 0.9)).toBe(90); + expect(durationPercentile(durations, 0.99)).toBe(100); + expect(durations).toEqual([100, 10, 90, 20, 80, 30, 70, 40, 60, 50]); + }); + it("counts tool usage correctly", () => { const toolUsage: Record = {}; const events = [ diff --git a/src/sessions.ts b/src/sessions.ts index ab8f2f0..a23b24a 100644 --- a/src/sessions.ts +++ b/src/sessions.ts @@ -48,6 +48,9 @@ export interface AnalyticsData { avgDuration: number; minDuration: number; maxDuration: number; + p50Duration: number; + p90Duration: number; + p99Duration: number; totalDuration: number; toolUsage: Record; topDirectories: Record; @@ -295,6 +298,28 @@ export function getAnalytics(source: AnalyticsSourceFilter = "all"): AnalyticsDa return cachedCall(`getAnalytics:${source}`, CACHE_TTL, () => _computeAnalytics(source)); } +/** Return the nearest-rank percentile without changing the caller's samples. */ +function durationPercentile(durations: number[], percentile: number): number { + if (durations.length === 0) return 0; + + const sorted = [...durations].sort((a, b) => a - b); + const index = Math.ceil(percentile * sorted.length) - 1; + return sorted[Math.max(0, Math.min(index, sorted.length - 1))]; +} + +function summarizeDurations(durations: number[]) { + const totalDuration = durations.reduce((a, b) => a + b, 0); + return { + avgDuration: durations.length ? totalDuration / durations.length : 0, + minDuration: durations.length ? Math.min(...durations) : 0, + maxDuration: durations.length ? Math.max(...durations) : 0, + p50Duration: durationPercentile(durations, 0.5), + p90Duration: durationPercentile(durations, 0.9), + p99Duration: durationPercentile(durations, 0.99), + totalDuration, + }; +} + function _computeAnalytics(source: AnalyticsSourceFilter = "all"): AnalyticsData { const allSessions = listSessions(); const sessions = source === "all" ? allSessions : allSessions.filter((s) => s.source === source); @@ -453,15 +478,12 @@ function _computeAnalytics(source: AnalyticsSourceFilter = "all"): AnalyticsData } catch {} } - const totalDuration = durations.reduce((a, b) => a + b, 0); + const durationSummary = summarizeDurations(durations); return { totalSessions: sessions.length, sessionsPerDay, - avgDuration: durations.length ? totalDuration / durations.length : 0, - minDuration: durations.length ? Math.min(...durations) : 0, - maxDuration: durations.length ? Math.max(...durations) : 0, - totalDuration, + ...durationSummary, toolUsage, topDirectories, branchTime, @@ -918,3 +940,5 @@ export function getVSCodeScore(): RepoScore { tips, }; } + +export const _testing = { durationPercentile, summarizeDurations };