Skip to content
Closed
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
23 changes: 19 additions & 4 deletions src/actions/active-sessions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -425,8 +425,13 @@ export async function getAllSessions(
if (cached) {
logger.debug("[SessionCache] All sessions cache hit");

const { SessionTracker } = await import("@/lib/session-tracker");

// 过滤:管理员可查看所有,普通用户只能查看自己的
const filteredCached = isAdmin ? cached : cached.filter((s) => s.userId === currentUserId);
const concurrentCounts = await SessionTracker.getObservedConcurrentCountBatch(
filteredCached.map((s) => s.sessionId)
);

// 分离活跃和非活跃(5 分钟内有请求为活跃)
const now = Date.now();
Expand All @@ -437,6 +442,7 @@ export async function getAllSessions(

for (const s of filteredCached) {
const lastRequestTime = s.lastRequestAt ? new Date(s.lastRequestAt).getTime() : 0;
const concurrentCount = concurrentCounts.get(s.sessionId) ?? 0;
const sessionInfo: ActiveSessionInfo = {
sessionId: s.sessionId,
sessionIdentityKind: s.sessionIdentityKind,
Expand All @@ -460,12 +466,14 @@ export async function getAllSessions(
s.totalCacheCreationTokens +
s.totalCacheReadTokens,
costUsd: s.totalCostUsd,
status: "completed",
status: concurrentCount > 0 ? "in_progress" : "completed",
durationMs: s.totalDurationMs,
requestCount: s.requestCount,
concurrentCount,
};

if (lastRequestTime >= fiveMinutesAgo) {
const isConcurrent = concurrentCount > 0;
if (isConcurrent || lastRequestTime >= fiveMinutesAgo) {
active.push(sessionInfo);
} else {
inactive.push(sessionInfo);
Expand Down Expand Up @@ -520,6 +528,10 @@ export async function getAllSessions(
const { aggregateMultipleSessionStats } = await import("@/repository/message");
const sessionsData = await aggregateMultipleSessionStats(allSessionIds);

const concurrentCounts = await SessionTracker.getObservedConcurrentCountBatch(
sessionsData.map((s) => s.sessionId)
);

// 4. 写入缓存
setActiveSessionsCache(sessionsData, cacheKey);

Expand All @@ -537,6 +549,7 @@ export async function getAllSessions(

for (const s of filteredSessions) {
const lastRequestTime = s.lastRequestAt ? new Date(s.lastRequestAt).getTime() : 0;
const concurrentCount = concurrentCounts.get(s.sessionId) ?? 0;
const sessionInfo: ActiveSessionInfo = {
sessionId: s.sessionId,
sessionIdentityKind: s.sessionIdentityKind,
Expand All @@ -560,12 +573,14 @@ export async function getAllSessions(
s.totalCacheCreationTokens +
s.totalCacheReadTokens,
costUsd: s.totalCostUsd,
status: "completed",
status: concurrentCount > 0 ? "in_progress" : "completed",
durationMs: s.totalDurationMs,
requestCount: s.requestCount,
concurrentCount,
};

if (lastRequestTime >= fiveMinutesAgo) {
const isConcurrent = concurrentCount > 0;
if (isConcurrent || lastRequestTime >= fiveMinutesAgo) {
active.push(sessionInfo);
} else {
inactive.push(sessionInfo);
Expand Down
6 changes: 5 additions & 1 deletion src/actions/usage-logs.ts
Original file line number Diff line number Diff line change
Expand Up @@ -157,7 +157,10 @@ async function buildUsageLogsExport(
progress: Pick<UsageLogsExportStatus, "processedRows" | "totalRows" | "progressPercent">
) => Promise<void> | void
): Promise<string> {
const initialResult = await findUsageLogsWithDetails({ ...filters, page: 1, pageSize: 1 });
const initialResult = await findUsageLogsWithDetails(
{ ...filters, page: 1, pageSize: 1 },
{ includeSourceSessionIds: false }
);
let estimatedTotalRows = initialResult.total;

if (estimatedTotalRows === 0) {
Expand All @@ -178,6 +181,7 @@ async function buildUsageLogsExport(
...filters,
cursor,
limit: USAGE_LOGS_EXPORT_BATCH_SIZE,
includeSourceSessionIds: false,
});

if (batch.logs.length > 0) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,9 @@ vi.mock("@/components/ui/tooltip", () => ({
TooltipProvider: ({ children }: { children?: ReactNode }) => <div>{children}</div>,
Tooltip: ({ children }: { children?: ReactNode }) => <div>{children}</div>,
TooltipTrigger: ({ children }: { children?: ReactNode }) => <div>{children}</div>,
TooltipContent: ({ children }: { children?: ReactNode }) => <div>{children}</div>,
TooltipContent: ({ children }: { children?: ReactNode }) => (
<div data-slot="tooltip-content">{children}</div>
),
}));

vi.mock("@/components/ui/relative-time", () => ({
Expand Down Expand Up @@ -606,6 +608,57 @@ describe("usage-logs-table multiplier badge", () => {
});
container.remove();
});

test("shows the client session ID and keeps the canonical prefix identity in the tooltip", () => {
const html = renderToStaticMarkup(
<UsageLogsTable
logs={[
makeLog({
sessionId: "pfx:scope:fingerprint",
sourceSessionId: "client-session-id",
}),
]}
total={1}
page={1}
pageSize={50}
onPageChange={() => {}}
isPending={false}
/>
);

expect(html).toContain('data-session-id="client-session-id"');
expect(html).toContain("client-session-id");
expect(html).toContain("pfx:scope:fingerprint");
});

test("does not duplicate the canonical identity when it is the source fallback", () => {
const html = renderToStaticMarkup(
<UsageLogsTable
logs={[
makeLog({
sessionId: "same-session-id",
sourceSessionId: "same-session-id",
}),
]}
total={1}
page={1}
pageSize={50}
onPageChange={() => {}}
isPending={false}
/>
);
const container = document.createElement("div");
container.innerHTML = html;
const tooltip = [...container.querySelectorAll('[data-slot="tooltip-content"]')].find((node) =>
node.textContent?.includes("same-session-id")
);

expect(
[...(tooltip?.querySelectorAll("span") ?? [])].filter(
(node) => node.textContent === "same-session-id"
)
).toHaveLength(1);
});
});

describe("usage-logs-table pricing resolution", () => {
Expand Down
19 changes: 16 additions & 3 deletions src/app/[locale]/dashboard/logs/_components/usage-logs-table.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -161,6 +161,9 @@ export function UsageLogsTable({
: Number(actualCostMultiplier);
const hasCostBadge =
multiplier != null && Number.isFinite(multiplier) && multiplier !== 1;
const displayedSourceSessionIds = (
log.sourceSessionIds?.length ? log.sourceSessionIds : [log.sourceSessionId]
).filter((id): id is string => Boolean(id));

return (
<TableRow
Expand All @@ -186,15 +189,25 @@ export function UsageLogsTable({
<button
type="button"
className="w-full text-left truncate cursor-pointer hover:underline"
data-session-id={log.sessionId}
data-session-id={log.sourceSessionId ?? log.sessionId}
onClick={handleCopySessionIdClick}
>
{log.sessionId}
{log.sourceSessionId ?? log.sessionId}
</button>
</TooltipTrigger>
<TooltipContent side="bottom" align="start" className="max-w-[500px]">
<p className="text-xs whitespace-normal break-words font-mono">
{log.sessionId}
{displayedSourceSessionIds.map((id) => (
<span className="block" key={id}>
{id}
</span>
))}
{log.sessionId &&
!displayedSourceSessionIds.includes(log.sessionId) && (
<span className="mt-1 block text-muted-foreground">
{log.sessionId}
</span>
)}
</p>
</TooltipContent>
</Tooltip>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import type { UsageLogRow } from "@/repository/usage-logs";
import type { RoutingTraceV1 } from "@/types/routing-trace";

let mockLogs: UsageLogRow[] = [];
let mockSourceSessionIdsByIdentity: Record<string, string[]> = {};
let mockIsLoading = false;
let mockIsError = false;
let mockError: unknown = null;
Expand All @@ -24,7 +25,16 @@ vi.mock("@tanstack/react-query", () => ({
useInfiniteQuery: (options: unknown) => {
useInfiniteQuerySpy(options);
return {
data: { pages: [{ logs: mockLogs, nextCursor: null, hasMore: false }] },
data: {
pages: [
{
logs: mockLogs,
sourceSessionIdsByIdentity: mockSourceSessionIdsByIdentity,
nextCursor: null,
hasMore: false,
},
],
},
fetchNextPage: vi.fn(),
hasNextPage: mockHasNextPage,
isFetchingNextPage: mockIsFetchingNextPage,
Expand Down Expand Up @@ -192,10 +202,46 @@ function renderTableWithLog(overrides: Partial<UsageLogRow>) {
mockHasNextPage = false;
mockIsFetchingNextPage = false;
mockLogs = [makeLog({ id: 1, ...overrides })];
mockSourceSessionIdsByIdentity = {};

return renderToStaticMarkup(<VirtualizedLogsTable filters={{}} autoRefreshEnabled={false} />);
}

test("shows the client session ID and canonical prefix identity in the virtualized tooltip", () => {
renderTableWithLog({
sessionId: "pfx:scope:fingerprint",
sourceSessionId: "client-session-id",
});
mockSourceSessionIdsByIdentity = {
"pfx:scope:fingerprint": ["client-session-id", "client-session-id-2"],
};
const html = renderToStaticMarkup(
<VirtualizedLogsTable filters={{}} autoRefreshEnabled={false} />
);

expect(html).toContain("client-session-id");
expect(html).toContain("client-session-id-2");
expect(html).toContain("pfx:scope:fingerprint");
});

test("does not duplicate the canonical identity when it is the source fallback", () => {
const html = renderTableWithLog({
sessionId: "same-session-id",
sourceSessionId: "same-session-id",
});
const container = document.createElement("div");
container.innerHTML = html;
const tooltip = [...container.querySelectorAll('[data-slot="tooltip-content"]')].find((node) =>
node.textContent?.includes("same-session-id")
);

expect(
[...(tooltip?.querySelectorAll("span") ?? [])].filter(
(node) => node.textContent === "same-session-id"
)
).toHaveLength(1);
});

function renderCostTooltipWithLog(overrides: Partial<UsageLogRow>) {
const html = renderTableWithLog(overrides);
const container = document.createElement("div");
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -203,6 +203,13 @@ export function VirtualizedLogsTable({
// Flatten all pages into a single array
const pages = data?.pages;
const allLogs = useMemo(() => pages?.flatMap((page) => page.logs) ?? [], [pages]);
const sourceSessionIdsByIdentity = useMemo<Record<string, string[]>>(
() =>
Object.fromEntries(
pages?.flatMap((page) => Object.entries(page.sourceSessionIdsByIdentity ?? {})) ?? []
),
[pages]
);
const filtersResetKey = useMemo(() => JSON.stringify(filters), [filters]);
const previousFiltersResetKeyRef = useRef(filtersResetKey);

Expand Down Expand Up @@ -781,6 +788,16 @@ export function VirtualizedLogsTable({
}

const isNonBilling = isNonBillingEndpoint(log.endpoint);
const mappedSourceSessionIds = log.sessionId
? sourceSessionIdsByIdentity[log.sessionId]
: undefined;
const displayedSourceSessionIds = (
mappedSourceSessionIds?.length
? mappedSourceSessionIds
: log.sourceSessionIds?.length
? log.sourceSessionIds
: [log.sourceSessionId]
).filter((id): id is string => Boolean(id));
const _isWarmupSkipped = log.blockedBy === "warmup";
return (
<div
Expand Down Expand Up @@ -838,15 +855,25 @@ export function VirtualizedLogsTable({
<button
type="button"
className="w-full text-left font-mono text-xs truncate cursor-pointer hover:underline"
data-session-id={log.sessionId}
data-session-id={log.sourceSessionId ?? log.sessionId}
onClick={handleCopySessionIdClick}
>
{log.sessionId}
{log.sourceSessionId ?? log.sessionId}
</button>
</TooltipTrigger>
<TooltipContent side="bottom" align="start" className="max-w-[500px]">
<p className="text-xs whitespace-normal break-words font-mono">
{log.sessionId}
{displayedSourceSessionIds.map((id) => (
<span className="block" key={id}>
{id}
</span>
))}
{log.sessionId &&
!displayedSourceSessionIds.includes(log.sessionId) && (
<span className="mt-1 block text-muted-foreground">
{log.sessionId}
</span>
)}
</p>
</TooltipContent>
</Tooltip>
Expand Down
1 change: 1 addition & 0 deletions src/app/v1/[...route]/route.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import "@/lib/polyfills/file";
import "@/lib/polyfills/worker-threads";

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Load the undici polyfill before legacy action imports

When the Bun/undici>=8 workaround is needed, limiting this side-effect import to /v1//v1beta still leaves the legacy actions route unpatched: src/app/api/actions/[...route]/route.ts imports @/actions/providers at module load, which pulls @/lib/proxy-agent and imports undici before markAsUncloneable is installed. The Docker build runs bun run build, so after /v1 is fixed it can still hit the same webidl.util.markAsUncloneable crash while evaluating /api/actions; move/import the polyfill from a shared server bootstrap or add it ahead of those action imports too.

Useful? React with 👍 / 👎.

import { Hono } from "hono";
import { handle } from "hono/vercel";
import { registerCors } from "@/app/v1/_lib/cors";
Expand Down
1 change: 1 addition & 0 deletions src/app/v1beta/[...route]/route.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import "@/lib/polyfills/file";
import "@/lib/polyfills/worker-threads";
import { Hono } from "hono";
import { handle } from "hono/vercel";
import { registerCors } from "@/app/v1/_lib/cors";
Expand Down
2 changes: 2 additions & 0 deletions src/lib/api-client/v1/actions/usage-logs.ts
Original file line number Diff line number Diff line change
Expand Up @@ -112,6 +112,7 @@ function toLegacyUsageLogsPage(body: unknown): UsageLogsBatchResult {
const page = body as {
logs?: UsageLogsBatchResult["logs"];
items?: UsageLogsBatchResult["logs"];
sourceSessionIdsByIdentity?: UsageLogsBatchResult["sourceSessionIdsByIdentity"];
pageInfo?: {
nextCursor?: UsageLogsBatchResult["nextCursor"] | string;
hasMore?: boolean;
Expand All @@ -129,6 +130,7 @@ function toLegacyUsageLogsPage(body: unknown): UsageLogsBatchResult {
};
return {
logs: page.logs ?? page.items ?? [],
sourceSessionIdsByIdentity: page.sourceSessionIdsByIdentity,
nextCursor: normalizeLegacyCursor(page.nextCursor ?? page.pageInfo?.nextCursor),
hasMore: page.hasMore ?? page.pageInfo?.hasMore ?? false,
...((page.total ?? page.pageInfo?.total !== undefined)
Expand Down
13 changes: 13 additions & 0 deletions src/lib/polyfills/worker-threads.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
import { createRequire } from "node:module";

const nodeRequire = createRequire(import.meta.url);
const workerThreads = nodeRequire("node:worker_threads") as {
markAsUncloneable?: (...args: unknown[]) => void;
};

// undici >= 8 destructures markAsUncloneable from node:worker_threads without
// a fallback. Bun (Docker build stage) does not implement this Node.js 23+ API,
// so next build crashes during page data collection.
if (typeof workerThreads.markAsUncloneable !== "function") {
workerThreads.markAsUncloneable = function markAsUncloneable() {};
}
5 changes: 4 additions & 1 deletion src/repository/_shared/usage-log-filters.ts
Original file line number Diff line number Diff line change
Expand Up @@ -142,7 +142,10 @@ export function buildUsageLogConditions(filters: UsageLogFilterParams): SQL[] {
const trimmedSessionId = filters.sessionId?.trim();
if (trimmedSessionId) {
conditions.push(
sql`COALESCE(${messageRequest.sessionIdentity}, ${messageRequest.sessionId}) = ${trimmedSessionId}`
sql`(
COALESCE(${messageRequest.sessionIdentity}, ${messageRequest.sessionId}) = ${trimmedSessionId}
OR ${messageRequest.sessionId} = ${trimmedSessionId}
)`
);
}

Expand Down
Loading