Skip to content
Open
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
51 changes: 51 additions & 0 deletions app/components/BillingCreditsViewer.vue
Original file line number Diff line number Diff line change
Expand Up @@ -219,6 +219,42 @@
are hidden until GitHub starts returning attributed data.
</div>
</v-alert>
<v-alert
v-if="unmatchedBillingUsernamesList.length > 0"
type="warning"
variant="tonal"
density="comfortable"
class="mx-3 mt-3 mb-2"
icon="mdi-account-question-outline"
>
<div class="font-weight-medium mb-1">
{{ unmatchedBillingUsernamesList.length }} billing username<span v-if="unmatchedBillingUsernamesList.length !== 1">s</span>
with spend did not match the loaded Metrics API logins
</div>
<div class="text-body-2 mb-2">
This can happen on EMU enterprises when GitHub's metrics and billing feeds use
different handles for the same person. Configure <code>NUXT_BILLING_USER_ALIASES</code>
to map billing usernames to metrics logins.
</div>
<v-expansion-panels variant="accordion">
<v-expansion-panel>
<v-expansion-panel-title class="text-body-2">
Show unmatched billing usernames
</v-expansion-panel-title>
<v-expansion-panel-text>
<v-chip
v-for="username in unmatchedBillingUsernamesList"
:key="username"
size="small"
variant="tonal"
class="ma-1"
>
{{ username }}
</v-chip>
</v-expansion-panel-text>
</v-expansion-panel>
</v-expansion-panels>
</v-alert>
<v-row v-if="perUserRows.length > 0 && !noPerUserAttribution" dense class="px-3 mt-2">
<v-col cols="12" md="6">
<v-card variant="outlined">
Expand Down Expand Up @@ -516,13 +552,15 @@ export default defineComponent({
interface BillingAgg { credits: number; grossAmount: number; netAmount: number; models: Set<string>; display: string }
const billingByLogin = reactive(new Map<string, BillingAgg>());
const loadedLogins = reactive(new Set<string>());
const unmatchedBillingUsernames = reactive(new Set<string>());
const perUserLoading = ref(false);

// When the user switches month or toggles month view, drop cached
// per-user roll-ups so the visible page re-fetches against the new window.
watch([selectedMonth, monthView, () => props.queryParams.since, () => props.queryParams.until], () => {
billingByLogin.clear();
loadedLogins.clear();
unmatchedBillingUsernames.clear();
});

async function loadBillingForLogins(logins: string[]): Promise<void> {
Expand Down Expand Up @@ -552,10 +590,15 @@ export default defineComponent({
const qp: Record<string, string> = { ...parent, logins: chunk.join(',') };
try {
const resp = await $fetch<BillingCreditsResponse>('/api/billing-credits-by-user', { query: qp });
for (const username of resp.unmatchedBillingUsernames ?? []) {
const trimmed = username.trim();
if (trimmed) unmatchedBillingUsernames.add(trimmed);
}
for (const it of resp.usageItems ?? []) {
const u = (it.user || '').trim();
if (!u) continue;
const key = u.toLowerCase();
unmatchedBillingUsernames.delete(u);
const prev = billingByLogin.get(key) || { credits: 0, grossAmount: 0, netAmount: 0, models: new Set<string>(), display: u };
prev.credits += Number.isFinite(it.netQuantity) ? it.netQuantity : 0;
prev.credits += Number.isFinite(it.discountQuantity) ? it.discountQuantity : 0;
Expand Down Expand Up @@ -646,6 +689,9 @@ export default defineComponent({
});
});
const loadedLoginsCount = computed(() => loadedLogins.size);
const unmatchedBillingUsernamesList = computed(() =>
[...unmatchedBillingUsernames].sort((a, b) => a.localeCompare(b))
);

// True when we've loaded at least one page of users AND the aggregate
// totals show non-zero spend AND zero per-user attribution has come back.
Expand Down Expand Up @@ -683,6 +729,10 @@ export default defineComponent({
billingByLogin.set(key, prev);
loadedLogins.add(key);
}
for (const username of data.value?.unmatchedBillingUsernames ?? []) {
const trimmed = username.trim();
if (trimmed) unmatchedBillingUsernames.add(trimmed);
}
});

// Distinguish "our admin gate" 403 from "GitHub billing API" 403 from
Expand Down Expand Up @@ -832,6 +882,7 @@ export default defineComponent({
errorReason, headers,
perUserRows, perUserHeaders,
perUserLoading, loadedLoginsCount, onTableOptions, noPerUserAttribution,
unmatchedBillingUsernamesList,
topSpendersChartData, topSpendersChartOptions,
topTokensChartData, topTokensChartOptions,
dataSourceBadge,
Expand Down
1 change: 1 addition & 0 deletions nuxt.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -125,6 +125,7 @@ export default defineNuxtConfig({
// Server-only admin allowlist for the per-user billing breakdown tab
// (NUXT_USAGE_ADMINS). Closed-by-default: empty list = nobody is admin.
usageAdmins: '',
auditLogEnabled: process.env.NUXT_AUDIT_LOG_ENABLED || 'false',
public: {
isDataMocked: false, // can be overridden by NUXT_PUBLIC_IS_DATA_MOCKED environment variable
scope: 'organization', // can be overridden by NUXT_PUBLIC_SCOPE environment variable
Expand Down
35 changes: 30 additions & 5 deletions server/api/admin/sync.post.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import { clearFailedSyncsForScope, getFailedSyncsForScope } from '../../storage/
import { Options } from '@/model/Options';
import { isMockMode } from '../../services/github-copilot-usage-api-mock';
import { requireUsageAdmin } from '../../utils/usage-admin';
import { emitAuditEvent } from '../../utils/audit';
import {
createBillingCsvJob,
cancelInFlightBillingCsvJobs,
Expand Down Expand Up @@ -55,6 +56,20 @@ export default defineEventHandler(async (event) => {
throw createError({ statusCode: 401, statusMessage: 'Authorization header required' });
}

const identifier = options.githubOrg || options.githubEnt || 'unknown';
await emitAuditEvent('admin.sync.triggered', {
action,
outcome: 'allow',
target: identifier,
detail: {
scope: options.scope,
team: options.githubTeam,
date,
since: options.since,
until: options.until,
},
}, event);

// Handle different sync actions
switch (action) {
case 'sync-date': {
Expand All @@ -66,7 +81,7 @@ export default defineEventHandler(async (event) => {
logger.info(`Syncing metrics for ${date}`);
const result = await syncMetricsForDate({
scope: options.scope!,
identifier: options.githubOrg || options.githubEnt || 'unknown',
identifier,
date,
teamSlug: options.githubTeam,
headers
Expand All @@ -84,7 +99,7 @@ export default defineEventHandler(async (event) => {
logger.info(`Syncing metrics from ${options.since} to ${options.until}`);
const results = await syncMetricsForDateRange(
options.scope!,
options.githubOrg || options.githubEnt || 'unknown',
identifier,
options.since,
options.until,
headers,
Expand Down Expand Up @@ -112,7 +127,7 @@ export default defineEventHandler(async (event) => {
logger.info(`Syncing gaps from ${options.since} to ${options.until}`);
const { results, gapsDetected, outsideWindow } = await syncGaps(
options.scope!,
options.githubOrg || options.githubEnt || 'unknown',
identifier,
options.since,
options.until,
headers,
Expand All @@ -138,7 +153,7 @@ export default defineEventHandler(async (event) => {
logger.info(`Running bulk sync for ${options.scope}:${options.githubOrg || options.githubEnt}`);
const bulkResult = await syncBulk(
options.scope!,
options.githubOrg || options.githubEnt || 'unknown',
identifier,
headers,
options.githubTeam
);
Expand All @@ -151,7 +166,6 @@ export default defineEventHandler(async (event) => {

case 'retry-failed': {
// Re-attempt every sync_status row in 'failed' state for this scope.
const identifier = options.githubOrg || options.githubEnt || 'unknown';
const failed = await getFailedSyncsForScope(options.scope!, identifier, options.githubTeam);

if (failed.length === 0) {
Expand Down Expand Up @@ -240,6 +254,17 @@ export default defineEventHandler(async (event) => {
throw e;
}

await emitAuditEvent('admin.billing_csv.triggered', {
action,
outcome: 'allow',
target: enterprise,
detail: {
jobId: job.id,
startDate,
endDate,
},
}, event);

// Fire-and-forget. The ingester catches all errors and records them
// on the job row; we just need to make sure unhandled rejections
// don't crash the process.
Expand Down
43 changes: 35 additions & 8 deletions server/api/billing-credits-by-user.get.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,13 +28,18 @@

import { Options } from '@/model/Options';
import { requireUsageAdmin } from '../utils/usage-admin';
import { emitAuditEvent } from '../utils/audit';
import { buildBillingApiUrl } from '../utils/billing-url';
import type { BillingCreditsResponse, BillingUsageItem } from './billing-credits.get';
import {
decideSource,
resolveWindow,
aggregateForBillingByUser,
} from '../services/billing-credit-reader';
import {
billingUsernamesForMetricsLogins,
parseBillingUserAliases,
} from '../../shared/utils/billing-user-identity';
// eslint-disable-next-line @typescript-eslint/no-explicit-any
import mockBilling from '../../public/mock-data/billing-credits.json';

Expand Down Expand Up @@ -78,6 +83,23 @@ export default defineEventHandler(async (event): Promise<BillingCreditsResponse>
});
}

await emitAuditEvent('billing.per_user.viewed', {
action: 'view',
outcome: 'allow',
target: options.githubOrg || options.githubEnt || 'unknown',
detail: {
scope: options.scope,
requestedLogins,
requestedCount: requestedLogins.length,
year: query.year,
month: query.month,
day: query.day,
model: query.model,
product: query.product,
costCenterId: query.cost_center_id,
},
}, event);

// ── DB-first read path (Phase B) ───────────────────────────────────────────
// Same coverage check as /api/billing-credits: if a completed CSV ingest
// job covers the requested window, serve the per-user aggregate from
Expand Down Expand Up @@ -189,37 +211,42 @@ export default defineEventHandler(async (event): Promise<BillingCreditsResponse>
});

const tagged: BillingUsageItem[] = [];
const aliases = parseBillingUserAliases(process.env.NUXT_BILLING_USER_ALIASES);
let timePeriod: BillingCreditsResponse['timePeriod'] = { year: 0, month: 0 };
let orgSlug: string | undefined;
let entSlug: string | undefined;
let failures = 0;

async function fetchOne(login: string): Promise<void> {
const params = new URLSearchParams({ ...forwardParams, user: login });
async function fetchOne(metricsLogin: string, billingUsername: string): Promise<void> {
const params = new URLSearchParams({ ...forwardParams, user: billingUsername });
const url = `${apiUrl}?${params.toString()}`;
try {
const resp = await $fetch<BillingCreditsResponse>(url, { headers: billingHeaders });
timePeriod = resp.timePeriod || timePeriod;
orgSlug = resp.organization || orgSlug;
entSlug = resp.enterprise || entSlug;
for (const it of resp.usageItems ?? []) {
tagged.push({ ...it, user: login });
tagged.push({ ...it, user: metricsLogin });
}
} catch (err) {
failures++;
logger.warn(`billing-credits-by-user: ${login} fan-out failed`, err);
logger.warn(`billing-credits-by-user: ${billingUsername} fan-out failed`, err);
}
}

const fetchTargets = requestedLogins.flatMap(login =>
billingUsernamesForMetricsLogins([login], aliases).map(billingUsername => ({ login, billingUsername }))
);

let cursor = 0;
async function worker(): Promise<void> {
while (cursor < requestedLogins.length) {
while (cursor < fetchTargets.length) {
const i = cursor++;
const login = requestedLogins[i];
if (login) await fetchOne(login);
const target = fetchTargets[i];
if (target) await fetchOne(target.login, target.billingUsername);
}
}
await Promise.all(Array.from({ length: Math.min(CONCURRENCY, requestedLogins.length) }, () => worker()));
await Promise.all(Array.from({ length: Math.min(CONCURRENCY, fetchTargets.length) }, () => worker()));

if (failures > 0 && tagged.length === 0) {
throw createError({
Expand Down
17 changes: 17 additions & 0 deletions server/api/billing-credits.get.ts
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@

import { Options } from '@/model/Options';
import { requireUsageAdmin } from '../utils/usage-admin';
import { emitAuditEvent } from '../utils/audit';
import { buildBillingApiUrl } from '../utils/billing-url';
import {
decideSource,
Expand Down Expand Up @@ -64,6 +65,7 @@ export interface BillingCreditsResponse {
organization?: string;
enterprise?: string;
user?: string;
unmatchedBillingUsernames?: string[];
usageItems: BillingUsageItem[];
}

Expand All @@ -87,6 +89,21 @@ export default defineEventHandler(async (event): Promise<BillingCreditsResponse>
// exercise the tab without configuring NUXT_USAGE_ADMINS.
if (!options.isDataMocked) {
await requireUsageAdmin(event);
await emitAuditEvent('billing.viewed', {
action: 'view',
outcome: 'allow',
target: options.githubOrg || options.githubEnt || 'unknown',
detail: {
scope: options.scope,
year: query.year,
month: query.month,
day: query.day,
model: query.model,
product: query.product,
costCenterId: query.cost_center_id,
user: query.user,
},
}, event);
}

// ── Mock mode ──────────────────────────────────────────────────────────────
Expand Down
10 changes: 10 additions & 0 deletions server/api/my-usage.get.ts
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,7 @@ import {
import { buildBillingApiUrl } from '../utils/billing-url';
import { aggregateBillingSpend, type BillingUsageItem } from '../utils/billing-spend-aggregator';
import { isUsageAdminForEvent } from '../utils/usage-admin';
import { emitAuditEvent } from '../utils/audit';
// eslint-disable-next-line @typescript-eslint/no-explicit-any
import mockUsersOrg28Day from '../../public/mock-data/new-api/organization-users-28-day-report.json';
// eslint-disable-next-line @typescript-eslint/no-explicit-any
Expand Down Expand Up @@ -164,6 +165,15 @@ export default defineEventHandler(async (event): Promise<MyUsageResponse> => {
myLogin = requestedLogin;
myEmail = undefined;
viewingAsAdmin = true;
await emitAuditEvent('user_data.viewed', {
action: 'view',
outcome: 'allow',
target: requestedLogin,
detail: {
scope: options.scope,
requestedLogin,
},
}, event);
}
}

Expand Down
9 changes: 9 additions & 0 deletions server/routes/auth/auth0.get.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
import { emitAuditEvent } from '../../utils/audit'

export default defineOAuthAuth0EventHandler({
async onSuccess(event, { user }) {
const email: string = user.email || ''
Expand All @@ -13,6 +15,13 @@ export default defineOAuthAuth0EventHandler({
}
})

await emitAuditEvent('auth.login.success', {
action: 'login',
outcome: 'allow',
target: user.nickname || email,
detail: { provider: 'auth0' },
}, event)

const config = useRuntimeConfig(event)
const defaultOrg = config.public.githubOrg || config.public.githubEnt
return sendRedirect(event, defaultOrg ? getAppBaseURL(event) : appURL('/select-org', event))
Expand Down
9 changes: 9 additions & 0 deletions server/routes/auth/github.get.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
import { emitAuditEvent } from '../../utils/audit';

export default defineOAuthGitHubEventHandler({
config: {
// Default scopes: read:user for profile, read:org for org membership (used by org picker).
Expand Down Expand Up @@ -29,6 +31,13 @@ export default defineOAuthGitHubEventHandler({
}
})

await emitAuditEvent('auth.login.success', {
action: 'login',
outcome: 'allow',
target: user.login,
detail: { provider: 'github' },
}, event)

// If a default org/ent is pinned via env var, go straight to the home page.
const defaultOrg = config.public.githubOrg || config.public.githubEnt
if (defaultOrg) {
Expand Down
Loading
Loading