Skip to content

Commit 0e3cc38

Browse files
authored
feat: Per-user Copilot usage & premium request analytics (#316)
1 parent 5dd6b8c commit 0e3cc38

39 files changed

Lines changed: 4464 additions & 316 deletions

.env.example

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -28,7 +28,7 @@ NUXT_PUBLIC_USING_GITHUB_AUTH=false
2828
# ------------------------------------------------------------------------------
2929

3030
# GitHub Personal Access Token for API calls
31-
# Required scopes: copilot, manage_billing:copilot, manage_billing:enterprise, read:enterprise, read:org
31+
# Required permissions: Read access to members, organization copilot metrics, and organization copilot seat management
3232
# NUXT_GITHUB_TOKEN=ghp_your_token_here
3333

3434
# Session password for encrypting user sessions (REQUIRED!)

.github/copilot-instructions.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -87,7 +87,7 @@ Always reference these instructions first and fallback to search or bash command
8787
- Uses sample data for development and testing
8888
- **Real GitHub data**: Requires GitHub Personal Access Token
8989
- `NUXT_GITHUB_TOKEN=<your_token>`
90-
- Token needs scopes: copilot, manage_billing:copilot, manage_billing:enterprise, read:enterprise, read:org
90+
- Token needs permissions: Read access to members, organization copilot metrics, and organization copilot seat management
9191

9292
### Scope Configuration
9393
- **NUXT_PUBLIC_SCOPE**: Sets default scope ('organization', 'enterprise', 'team-organization', 'team-enterprise')

API_MIGRATION_DESIGN.md

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -33,7 +33,7 @@ The application currently operates as a **stateless web application** with the f
3333

3434
4. **Authentication**
3535
- Personal Access Tokens (PAT) or GitHub OAuth
36-
- Token scopes: `copilot`, `manage_billing:copilot`, `read:enterprise`, `read:org`
36+
- Token permissions: Read access to members, organization copilot metrics, and organization copilot seat management
3737

3838
## New API Requirements
3939

@@ -56,7 +56,7 @@ The application currently operates as a **stateless web application** with the f
5656
- Must enable "Copilot usage metrics" policy
5757

5858
4. **Authentication**
59-
- Same token scopes: `manage_billing:copilot` or `read:enterprise`
59+
- Required permissions: Read access to members, organization copilot metrics, and organization copilot seat management
6060
- OAuth apps or fine-grained access tokens supported
6161

6262
## Proposed Architecture

API_QUICK_REFERENCE.md

Lines changed: 5 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -53,19 +53,13 @@ GET https://api.github.com/enterprises/{enterprise}/copilot/metrics/reports/ente
5353

5454
## Authentication
5555

56-
### Required Token Scopes (Same for Both APIs)
57-
58-
**Classic PAT**:
59-
- `copilot` (for basic metrics)
60-
- `manage_billing:copilot` (for billing-related metrics)
61-
- `manage_billing:enterprise` (for enterprise billing)
62-
- `read:enterprise` (for enterprise metrics)
63-
- `read:org` (for organization metrics)
56+
### Required Token Permissions (Same for Both APIs)
6457

6558
**Fine-grained Token**:
6659
- Repository access: Not required
67-
- Organization permissions: "View Copilot usage"
68-
- Enterprise permissions: "View Enterprise Copilot Metrics"
60+
- Read access to members
61+
- Organization copilot metrics
62+
- Organization copilot seat management
6963

7064
## Request/Response Examples
7165

@@ -409,7 +403,7 @@ async function getMetrics(org: string, since: string, until: string) {
409403
**A**: Team metrics are included in organization/enterprise reports, but may require filtering
410404

411405
### Q: Can I still use the same authentication?
412-
**A**: Yes, token scopes are the same
406+
**A**: Yes, the required permissions are the same: Read access to members, organization copilot metrics, and organization copilot seat management
413407

414408
### Q: What about seats data?
415409
**A**: Seats API remains similar (separate endpoint, not affected by metrics API change)

DEPLOYMENT.md

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -284,11 +284,11 @@ or navigate using UI:
284284
2. Provide a home page URL: your company URL or just `http://localhost`.
285285
3. Add a callback URL for `http://localhost:3000/auth/github`. (We'll add the real redirect URL after the application is deployed.)
286286
4. Uncheck the "Webhook -> Active" checkbox.
287-
5. Set the scopes:
287+
5. Set the permissions:
288288
- Select **Organization permissions**.
289-
- Under **GitHub Copilot Business**, select **Access: Read-only**.
290-
- Under **Copilot Metrics**, select **Access: Read-only**. _(Required for v3.0+ — the new Copilot Usage Metrics API)_
291289
- Under **Members**, select **Access: Read-only**.
290+
- Under **Copilot Metrics**, select **Access: Read-only**.
291+
- Under **Copilot Seat Management**, select **Access: Read-only**.
292292
6. Click on 'Create GitHub App' and, in the following page, click on 'Generate a new client secret'.
293293
7. Note the `Client ID` and `Client Secret` (copy it to a secure location). This is required for the application to authenticate with GitHub.
294294
8. Install the app in the organization:

README.md

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -236,10 +236,10 @@ NUXT_PUBLIC_IS_DATA_MOCKED=false
236236

237237
#### NUXT_GITHUB_TOKEN
238238

239-
Specifies the GitHub Personal Access Token utilized for API requests. Generate this token with the following scopes: _copilot_, _manage_billing:copilot_, _manage_billing:enterprise_, _read:enterprise_, _read:org_.
239+
Specifies the GitHub Personal Access Token utilized for API requests. Generate this token with the following permissions: _Read access to members_, _organization copilot metrics_, and _organization copilot seat management_.
240240

241241
> [!IMPORTANT]
242-
> **v3.0 Migration:** The new Copilot Usage Metrics API requires the **"Organization Copilot metrics: Read"** permission on your GitHub App (or the `read:org` scope for classic PATs). Without this, the new API endpoints will return 400/403 errors. See [GitHub App Registration](DEPLOYMENT.md#github-app-registration) for setup details.
242+
> **v3.0 Migration:** The new Copilot Usage Metrics API requires **Read access to members, organization copilot metrics, and organization copilot seat management** permissions. Without this, the new API endpoints will return 400/403 errors. See [GitHub App Registration](DEPLOYMENT.md#github-app-registration) for setup details.
243243
244244
Token is not used in the frontend.
245245

app/components/MainComponent.vue

Lines changed: 114 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -86,7 +86,7 @@
8686

8787
<div v-show="!apiError">
8888
<v-progress-linear v-show="!metricsReady" indeterminate color="indigo" />
89-
<v-window v-show="(metricsReady && metrics.length) || (seatsReady && tab === 'seat analysis')" v-model="tab">
89+
<v-window v-show="(metricsReady && metrics.length) || (seatsReady && tab === 'seat analysis') || (userMetricsReady && tab === 'user metrics')" v-model="tab">
9090
<v-window-item v-for="item in tabItems" :key="item" :value="item">
9191
<v-card flat>
9292
<MetricsViewer v-if="item === getDisplayTabName(itemName)" :metrics="metrics" :date-range-description="dateRangeDescription" />
@@ -103,14 +103,30 @@ v-if="item === 'copilot chat'" :metrics="metrics"
103103
<AgentActivityViewer v-if="item === 'agent activity'" :report-data="reportData" :date-range-description="dateRangeDescription" />
104104
<PullRequestViewer v-if="item === 'pull requests'" :report-data="reportData" :date-range-description="dateRangeDescription" />
105105
<AgentModeViewer v-if="item === 'github.com'" :original-metrics="originalMetrics" :date-range="dateRange" :date-range-description="dateRangeDescription" />
106-
<SeatsAnalysisViewer v-if="item === 'seat analysis'" :seats="seats" />
106+
<SeatsAnalysisViewer
107+
v-if="item === 'seat analysis'"
108+
:seats="seats"
109+
:total-seats-count="seatsTotalCount"
110+
:current-page="seatsCurrentPage"
111+
:total-pages="seatsTotalPages"
112+
:per-page="seatsPerPage"
113+
:seats-history="seatsHistory"
114+
@page-change="handleSeatsPageChange"
115+
/>
116+
<UserMetricsViewer
117+
v-if="item === 'user metrics'"
118+
:user-metrics="userMetrics"
119+
:date-range-description="dateRangeDescription"
120+
:user-metrics-history="userMetricsHistory"
121+
:query-params="seatsQueryParams"
122+
/>
107123
<ApiResponse
108124
v-if="item === 'api response'" :metrics="metrics" :original-metrics="originalMetrics"
109125
:seats="seats" />
110126
</v-card>
111127
</v-window-item>
112128
<v-alert
113-
v-show="(metricsReady && metrics.length == 0 && tab !== 'seat analysis') || (seatsReady && seats.length == 0 && tab === 'seat analysis')"
129+
v-show="(metricsReady && metrics.length == 0 && tab !== 'seat analysis' && tab !== 'user metrics') || (seatsReady && seats.length == 0 && tab === 'seat analysis') || (userMetricsReady && userMetrics.length == 0 && tab === 'user metrics')"
114130
density="compact" text="No data available to display" title="No data" type="warning" />
115131
</v-window>
116132

@@ -123,7 +139,10 @@ import type { Metrics } from '@/model/Metrics';
123139
import type { CopilotMetrics } from '@/model/Copilot_Metrics';
124140
import type { MetricsApiResponse } from '@/types/metricsApiResponse';
125141
import type { Seat } from "@/model/Seat";
126-
import type { ReportDayTotals } from "../../server/services/github-copilot-usage-api";
142+
import type { SeatsApiResponse } from '../server/api/seats';
143+
import type { ReportDayTotals, UserTotals } from "../../server/services/github-copilot-usage-api";
144+
import type { SeatHistoryEntry } from "../../server/storage/seats-storage";
145+
import type { UserMetricsHistoryEntry } from "../../server/storage/user-metrics-storage";
127146
import type { H3Error } from 'h3'
128147
129148
//Components
@@ -137,6 +156,7 @@ import AgentModeViewer from './AgentModeViewer.vue'
137156
import AgentActivityViewer from './AgentActivityViewer.vue'
138157
import PullRequestViewer from './PullRequestViewer.vue'
139158
import DateRangeSelector from './DateRangeSelector.vue'
159+
import UserMetricsViewer from './UserMetricsViewer.vue'
140160
import { Options } from '@/model/Options';
141161
import { useRoute } from 'vue-router';
142162
@@ -152,7 +172,8 @@ export default defineNuxtComponent({
152172
AgentModeViewer,
153173
AgentActivityViewer,
154174
PullRequestViewer,
155-
DateRangeSelector
175+
DateRangeSelector,
176+
UserMetricsViewer
156177
},
157178
methods: {
158179
logout() {
@@ -255,12 +276,45 @@ export default defineNuxtComponent({
255276
break;
256277
}
257278
}
279+
},
280+
/** Handle page navigation from SeatsAnalysisViewer paginator */
281+
async handleSeatsPageChange(page: number) {
282+
const { data: seatsData, error: seatsError, execute } = this.seatsFetch;
283+
// Update setup ref so the reactive query re-evaluates with the new page
284+
(this as any).seatsCurrentPage = page;
285+
await execute();
286+
if (!seatsError.value) {
287+
const resp = seatsData.value as SeatsApiResponse | null;
288+
if (resp) {
289+
this.seats = resp.seats || [];
290+
this.seatsTotalCount = resp.total_seats || 0;
291+
this.seatsCurrentPage = resp.page || page;
292+
this.seatsTotalPages = resp.total_pages || 1;
293+
}
294+
}
295+
},
296+
/** Fetch seats and user-metrics history (DB / historical mode) */
297+
async fetchHistory() {
298+
const options = Options.fromRoute(this.route);
299+
const params = options.toParams();
300+
const qs = new URLSearchParams(params).toString();
301+
302+
try {
303+
const [seatsH, userH] = await Promise.all([
304+
$fetch<SeatHistoryEntry[]>(`/api/seats-history${qs ? '?' + qs : ''}`).catch(() => []),
305+
$fetch<UserMetricsHistoryEntry[]>(`/api/user-metrics-history${qs ? '?' + qs : ''}`).catch(() => []),
306+
]);
307+
this.seatsHistory = seatsH;
308+
this.userMetricsHistory = userH;
309+
} catch {
310+
// history is non-critical — swallow errors
311+
}
258312
}
259313
},
260314
261315
data() {
262316
return {
263-
tabItems: ['languages', 'editors', 'copilot chat', 'agent activity', 'pull requests', 'github.com', 'seat analysis', 'api response'],
317+
tabItems: ['languages', 'editors', 'copilot chat', 'agent activity', 'pull requests', 'github.com', 'seat analysis', 'user metrics', 'api response'],
264318
tab: null,
265319
dateRangeDescription: 'Over the last 28 days',
266320
isLoading: false,
@@ -270,6 +324,14 @@ export default defineNuxtComponent({
270324
reportData: [] as ReportDayTotals[],
271325
seatsReady: false,
272326
seats: [] as Seat[],
327+
seatsTotalCount: 0,
328+
seatsCurrentPage: 1,
329+
seatsTotalPages: 1,
330+
seatsPerPage: 300,
331+
seatsHistory: [] as SeatHistoryEntry[],
332+
userMetricsReady: false,
333+
userMetrics: [] as UserTotals[],
334+
userMetricsHistory: [] as UserMetricsHistoryEntry[],
273335
apiError: undefined as string | undefined,
274336
showMigrationBanner: true,
275337
config: null as ReturnType<typeof useRuntimeConfig> | null,
@@ -295,16 +357,39 @@ export default defineNuxtComponent({
295357
await this.fetchMetrics();
296358
297359
const { data: seatsData, error: seatsError, execute: executeSeats } = this.seatsFetch;
360+
const { data: userMetricsData, error: userMetricsError, execute: executeUserMetrics } = this.userMetricsFetch;
298361
299362
if (!this.signInRequired) {
300363
await executeSeats();
301364
302365
if (seatsError.value) {
303366
this.processError(seatsError.value as H3Error);
304367
} else {
305-
this.seats = (seatsData.value as Seat[]) || [];
368+
const resp = seatsData.value as SeatsApiResponse | null;
369+
if (resp) {
370+
this.seats = resp.seats || [];
371+
this.seatsTotalCount = resp.total_seats || 0;
372+
this.seatsCurrentPage = resp.page || 1;
373+
this.seatsTotalPages = resp.total_pages || 1;
374+
}
306375
this.seatsReady = true;
307376
}
377+
378+
await executeUserMetrics();
379+
380+
if (userMetricsError.value) {
381+
// User metrics errors are non-fatal — log but don't block the UI
382+
console.warn('User metrics fetch failed:', userMetricsError.value);
383+
} else {
384+
this.userMetrics = (userMetricsData.value as UserTotals[]) || [];
385+
this.userMetricsReady = true;
386+
}
387+
388+
// Fetch history data if historical mode is enabled
389+
const config = useRuntimeConfig();
390+
if (config.public.enableHistoricalMode) {
391+
await this.fetchHistory();
392+
}
308393
}
309394
310395
} catch (error) {
@@ -322,6 +407,7 @@ export default defineNuxtComponent({
322407
const dateRange = ref({ since: undefined as string | undefined, until: undefined as string | undefined });
323408
const isLoading = ref(false);
324409
const route = ref(useRoute());
410+
const seatsCurrentPage = ref(1);
325411
326412
const signInRequired = computed(() => {
327413
return config.public.usingGithubAuth && !loggedIn.value;
@@ -330,6 +416,24 @@ export default defineNuxtComponent({
330416
const seatsFetch = useFetch('/api/seats', {
331417
server: true,
332418
immediate: !signInRequired.value,
419+
query: computed(() => {
420+
const options = Options.fromRoute(route.value);
421+
return { ...options.toParams(), page: String(seatsCurrentPage.value), per_page: '300' };
422+
})
423+
});
424+
425+
/** Scope + org/ent params forwarded to history endpoints and user-trend API */
426+
const seatsQueryParams = computed(() => {
427+
const options = Options.fromRoute(route.value);
428+
const p = options.toParams();
429+
// Only forward identity params, not pagination
430+
const { since: _s, until: _u, ...rest } = p;
431+
return rest;
432+
});
433+
434+
const userMetricsFetch = useFetch('/api/user-metrics', {
435+
server: true,
436+
immediate: false,
333437
query: computed(() => {
334438
const options = Options.fromRoute(route.value);
335439
return options.toParams();
@@ -344,9 +448,12 @@ export default defineNuxtComponent({
344448
signInRequired,
345449
user,
346450
seatsFetch,
451+
userMetricsFetch,
347452
dateRange,
348453
isLoading,
349454
route,
455+
seatsCurrentPage,
456+
seatsQueryParams,
350457
};
351458
},
352459
})

0 commit comments

Comments
 (0)