diff --git a/packages/app/src/server/mcp-server.ts b/packages/app/src/server/mcp-server.ts index 9479501..372a1bb 100644 --- a/packages/app/src/server/mcp-server.ts +++ b/packages/app/src/server/mcp-server.ts @@ -157,7 +157,7 @@ export const createServerFactory = (_webServerInstance: WebServer, sharedApiClie options?: QueryLoggerOptions ) => void; - type BaseQueryLoggerOptions = Omit; + type BaseQueryLoggerOptions = Omit; interface QueryLoggingConfig { methodName: string; @@ -175,17 +175,24 @@ export const createServerFactory = (_webServerInstance: WebServer, sharedApiClie const start = performance.now(); try { const result = await work(); - const durationMs = performance.now() - start; + const durationMs = Math.round(performance.now() - start); const successOptions = config.successOptions?.(result) ?? {}; + const { success: successOverride, ...restSuccessOptions } = successOptions; + const resultHasError = + typeof result === 'object' && + result !== null && + 'isError' in result && + Boolean((result as { isError?: boolean }).isError); + const successFlag = successOverride ?? !resultHasError; logFn(config.methodName, config.query, config.parameters, { ...config.baseOptions, - ...successOptions, + ...restSuccessOptions, durationMs, - success: true, + success: successFlag, }); return result; } catch (error) { - const durationMs = performance.now() - start; + const durationMs = Math.round(performance.now() - start); logFn(config.methodName, config.query, config.parameters, { ...config.baseOptions, durationMs, diff --git a/packages/app/src/server/transport/base-transport.ts b/packages/app/src/server/transport/base-transport.ts index 360ba2e..23396c4 100644 --- a/packages/app/src/server/transport/base-transport.ts +++ b/packages/app/src/server/transport/base-transport.ts @@ -60,6 +60,7 @@ export interface SessionMetadata { pingFailures?: number; lastPingAttempt?: Date; ipAddress?: string; + authToken?: string; } /** @@ -172,6 +173,20 @@ export abstract class BaseTransport { this.metrics.trackIpAddress(ipAddress); } + /** + * Track an IP address for a specific client + */ + protected trackClientIpAddress(ipAddress: string | undefined, clientInfo?: { name: string; version: string }): void { + this.metrics.trackClientIpAddress(ipAddress, clientInfo); + } + + /** + * Track auth status for a specific client + */ + protected trackClientAuth(authToken: string | undefined, clientInfo?: { name: string; version: string }): void { + this.metrics.trackClientAuth(authToken, clientInfo); + } + /** * Extract IP address from request headers * Handles x-forwarded-for, x-real-ip, and direct IP @@ -454,6 +469,14 @@ export abstract class StatefulTransport { const cleanup = this.createCleanupFunction(sessionId); // Store connection with metadata + const authToken = headers['authorization']?.replace(/^Bearer\s+/i, ''); const connection: SSEConnection = { transport, server, @@ -130,6 +131,7 @@ export class SseTransport extends StatefulTransport { isAuthenticated: authResult.shouldContinue && !!headers['authorization'], capabilities: {}, ipAddress, + authToken, }, cleaningUp: false, }; diff --git a/packages/app/src/server/transport/stateless-http-transport.ts b/packages/app/src/server/transport/stateless-http-transport.ts index 0617b7f..80ff11d 100644 --- a/packages/app/src/server/transport/stateless-http-transport.ts +++ b/packages/app/src/server/transport/stateless-http-transport.ts @@ -269,6 +269,13 @@ export class StatelessHttpTransport extends BaseTransport { this.associateSessionWithClient(extractedClientInfo); this.updateClientActivity(extractedClientInfo); + // Track IP address for this client + this.trackClientIpAddress(ipAddress, extractedClientInfo); + + // Track auth status for this client + const authToken = headers['authorization']?.replace(/^Bearer\s+/i, ''); + this.trackClientAuth(authToken, extractedClientInfo); + // Update analytics session with client info if (this.analyticsMode && sessionId) { this.updateAnalyticsSessionClientInfo(sessionId, extractedClientInfo); diff --git a/packages/app/src/server/transport/streamable-http-transport.ts b/packages/app/src/server/transport/streamable-http-transport.ts index 85ab6d4..12f79db 100644 --- a/packages/app/src/server/transport/streamable-http-transport.ts +++ b/packages/app/src/server/transport/streamable-http-transport.ts @@ -136,7 +136,7 @@ export class StreamableHttpTransport extends StatefulTransport { return; } - transport = await this.createSession(headers); + transport = await this.createSession(headers, req); } else if (!sessionId) { // No session ID and not an initialization request this.trackError(400); @@ -220,12 +220,16 @@ export class StreamableHttpTransport extends StatefulTransport { await this.removeSession(sessionId); } - private async createSession(requestHeaders?: Record): Promise { + private async createSession(requestHeaders?: Record, req?: Request): Promise { // Create server instance using factory with request headers // Note: Auth validation is now done in handlePostRequest before calling this method const result = await this.serverFactory(requestHeaders || null); const server = result.server; + // Extract IP address and auth token for tracking + const ipAddress = req ? this.extractIpAddress(req.headers as Record, req.ip) : undefined; + const authToken = requestHeaders?.['authorization']?.replace(/^Bearer\s+/i, ''); + const transport = new StreamableHTTPServerTransport({ sessionIdGenerator: () => randomUUID(), onsessioninitialized: (sessionId: string) => { @@ -235,6 +239,7 @@ export class StreamableHttpTransport extends StatefulTransport { logSystemEvent('initialize', sessionId, { clientSessionId: sessionId, isAuthenticated: !!requestHeaders?.['authorization'], + ipAddress, }); // Create session object and store it immediately @@ -248,6 +253,8 @@ export class StreamableHttpTransport extends StatefulTransport { requestCount: 0, isAuthenticated: !!requestHeaders?.['authorization'], capabilities: {}, + ipAddress, + authToken, }, cleaningUp: false, }; diff --git a/packages/app/src/server/utils/query-logger.ts b/packages/app/src/server/utils/query-logger.ts index 7fc0b44..e4dd0ad 100644 --- a/packages/app/src/server/utils/query-logger.ts +++ b/packages/app/src/server/utils/query-logger.ts @@ -208,6 +208,8 @@ export function logSearchQuery( ): void { // Use a stable mcpServerSessionId per process/transport instance const mcpServerSessionId = getMcpServerSessionId(); + const normalizedDurationMs = + options?.durationMs !== undefined ? Math.round(options.durationMs) : undefined; const serializedParameters = JSON.stringify(data); const requestPayload = { methodName, @@ -230,7 +232,7 @@ export function logSearchQuery( totalResults: options?.totalResults, resultsShared: options?.resultsShared, responseCharCount: options?.responseCharCount, - durationMs: options?.durationMs, + durationMs: normalizedDurationMs, success: options?.success ?? true, errorMessage: normalizedError, }); @@ -247,6 +249,8 @@ export function logPromptQuery( ): void { // Use a stable mcpServerSessionId per process/transport instance const mcpServerSessionId = getMcpServerSessionId(); + const normalizedDurationMs = + options?.durationMs !== undefined ? Math.round(options.durationMs) : undefined; const serializedParameters = JSON.stringify(data); const requestPayload = { methodName, @@ -269,7 +273,7 @@ export function logPromptQuery( totalResults: options?.totalResults, resultsShared: options?.resultsShared, responseCharCount: options?.responseCharCount, - durationMs: options?.durationMs, + durationMs: normalizedDurationMs, success: options?.success ?? true, errorMessage: normalizedError, }); diff --git a/packages/app/src/shared/transport-metrics.ts b/packages/app/src/shared/transport-metrics.ts index 7c61966..66f608a 100644 --- a/packages/app/src/shared/transport-metrics.ts +++ b/packages/app/src/shared/transport-metrics.ts @@ -1,3 +1,4 @@ +import { createHash } from 'node:crypto'; import type { TransportType } from './constants.js'; /** @@ -77,6 +78,9 @@ export interface ClientMetrics { activeConnections: number; totalConnections: number; toolCallCount: number; + newIpCount: number; + anonCount: number; + uniqueAuthCount: number; } /** @@ -228,6 +232,9 @@ export interface TransportMetricsResponse { activeConnections: number; totalConnections: number; toolCallCount: number; + newIpCount: number; + anonCount: number; + uniqueAuthCount: number; }>; sessions: SessionData[]; @@ -409,6 +416,13 @@ class RollingWindowCounter { } } +/** + * Hash auth tokens before counting them to avoid storing raw secrets. + */ +function hashToken(token: string): string { + return createHash('sha256').update(token).digest('hex'); +} + /** * Centralized metrics counter for transport operations */ @@ -418,6 +432,8 @@ export class MetricsCounter { private rollingHour: RollingWindowCounter; private rolling3Hours: RollingWindowCounter; private uniqueIps: Set; + private clientIps: Map>; // Map of clientKey -> Set of IPs + private clientAuthHashes: Map>; // Map of clientKey -> Set of auth token hashes constructor() { this.metrics = createEmptyMetrics(); @@ -425,6 +441,8 @@ export class MetricsCounter { this.rollingHour = new RollingWindowCounter(60); this.rolling3Hours = new RollingWindowCounter(180); this.uniqueIps = new Set(); + this.clientIps = new Map(); + this.clientAuthHashes = new Map(); } /** @@ -509,6 +527,70 @@ export class MetricsCounter { } } + /** + * Track an IP address for a specific client + */ + trackClientIpAddress(ipAddress: string | undefined, clientInfo?: { name: string; version: string }): void { + // Always track globally + this.trackIpAddress(ipAddress); + + // Track per-client if client info is available + if (ipAddress && clientInfo) { + const clientKey = getClientKey(clientInfo.name, clientInfo.version); + const clientMetrics = this.metrics.clients.get(clientKey); + + if (clientMetrics) { + // Get or create the IP set for this client + let clientIpSet = this.clientIps.get(clientKey); + if (!clientIpSet) { + clientIpSet = new Set(); + this.clientIps.set(clientKey, clientIpSet); + } + + // Check if this is a new IP for this client + const isNewIp = !clientIpSet.has(ipAddress); + if (isNewIp) { + clientIpSet.add(ipAddress); + clientMetrics.newIpCount++; + } + } + } + } + + /** + * Track auth status for a specific client + */ + trackClientAuth(authToken: string | undefined, clientInfo?: { name: string; version: string }): void { + if (!clientInfo) return; + + const clientKey = getClientKey(clientInfo.name, clientInfo.version); + const clientMetrics = this.metrics.clients.get(clientKey); + + if (clientMetrics) { + if (!authToken) { + // Anonymous request + clientMetrics.anonCount++; + } else { + // Authenticated request - hash the token for privacy + const tokenHash = hashToken(authToken); + + // Get or create the auth hash set for this client + let clientAuthSet = this.clientAuthHashes.get(clientKey); + if (!clientAuthSet) { + clientAuthSet = new Set(); + this.clientAuthHashes.set(clientKey, clientAuthSet); + } + + // Check if this is a new auth token for this client + const isNewAuth = !clientAuthSet.has(tokenHash); + if (isNewAuth) { + clientAuthSet.add(tokenHash); + clientMetrics.uniqueAuthCount++; + } + } + } + } + /** * Update active connection count */ @@ -544,6 +626,9 @@ export class MetricsCounter { activeConnections: 1, totalConnections: 1, toolCallCount: 0, + newIpCount: 0, + anonCount: 0, + uniqueAuthCount: 0, }; this.metrics.clients.set(clientKey, clientMetrics); } else { diff --git a/packages/app/src/web/components/StatefulTransportMetrics.tsx b/packages/app/src/web/components/StatefulTransportMetrics.tsx index f46553d..50b5627 100644 --- a/packages/app/src/web/components/StatefulTransportMetrics.tsx +++ b/packages/app/src/web/components/StatefulTransportMetrics.tsx @@ -3,7 +3,7 @@ import { Card, CardContent, CardDescription, CardHeader, CardTitle } from './ui/ import { Badge } from './ui/badge'; import { Separator } from './ui/separator'; import { Table, TableBody, TableCell, TableRow } from './ui/table'; -import { Wifi, WifiOff, AlertTriangle } from 'lucide-react'; +import { Wifi, WifiOff, AlertTriangle, Activity, Clock } from 'lucide-react'; import { DataTable } from './data-table'; import { createSortableHeader } from './data-table-utils'; import { useSessionCache } from '../hooks/useSessionCache'; @@ -25,6 +25,21 @@ type SessionData = { ipAddress?: string; }; +type ClientData = { + name: string; + version: string; + requestCount: number; + activeConnections: number; + totalConnections: number; + isConnected: boolean; + lastSeen: string; + firstSeen: string; + toolCallCount: number; + newIpCount: number; + anonCount: number; + uniqueAuthCount: number; +}; + /** * Format relative time (e.g., "5m ago", "2h ago", "just now") */ @@ -84,6 +99,17 @@ function truncateClientName(name: string): string { return `${name.slice(0, 35)}.....${name.slice(-5)}`; } +/** + * Check if a client was recently active (within last 5 minutes) + */ +function isRecentlyActive(lastSeen: string): boolean { + const now = new Date(); + const lastSeenTime = new Date(lastSeen); + const diffMs = now.getTime() - lastSeenTime.getTime(); + const diffMinutes = Math.floor(diffMs / 60000); + return diffMinutes < 5; +} + interface StatefulTransportMetricsProps { metrics: TransportMetricsResponse; } @@ -183,6 +209,84 @@ export function StatefulTransportMetrics({ metrics }: StatefulTransportMetricsPr }, ]; + // Define columns for the client identities table + const createClientColumns = (): ColumnDef[] => [ + { + accessorKey: 'name', + header: createSortableHeader('Client'), + cell: ({ row }) => { + const client = row.original; + const clientDisplay = `${truncateClientName(client.name)}@${client.version}`; + return ( +
+

+ {clientDisplay} +

+

First seen {formatRelativeTime(client.firstSeen)}

+
+ ); + }, + }, + { + accessorKey: 'requestCount', + header: createSortableHeader('Initializations', 'right'), + cell: ({ row }) =>
{row.getValue('requestCount')}
, + }, + { + accessorKey: 'toolCallCount', + header: createSortableHeader('Tool Calls', 'right'), + cell: ({ row }) =>
{row.getValue('toolCallCount')}
, + }, + { + accessorKey: 'newIpCount', + header: createSortableHeader('New IPs', 'right'), + cell: ({ row }) =>
{row.getValue('newIpCount')}
, + }, + { + accessorKey: 'anonCount', + header: createSortableHeader('Anon/Auth', 'right'), + cell: ({ row }) => { + const client = row.original; + return ( +
+ {client.anonCount}/{client.uniqueAuthCount} +
+ ); + }, + }, + { + accessorKey: 'isConnected', + header: createSortableHeader('Status'), + cell: ({ row }) => { + const client = row.original; + return ( +
+ {isRecentlyActive(client.lastSeen) ? ( + + + Recent + + ) : ( + + + Idle + + )} +
+ ); + }, + }, + { + accessorKey: 'lastSeen', + header: createSortableHeader('Last Seen', 'right'), + cell: ({ row }) => ( +
{formatRelativeTime(row.getValue('lastSeen'))}
+ ), + }, + ]; + + const clientData = metrics.clients; + return ( @@ -339,6 +443,22 @@ export function StatefulTransportMetrics({ metrics }: StatefulTransportMetricsPr /> + + {/* Client Identities */} + <> + +
+

Client Identities

+ +
+
); diff --git a/packages/app/src/web/components/StatelessTransportMetrics.tsx b/packages/app/src/web/components/StatelessTransportMetrics.tsx index 4a2ee05..dbb78a0 100644 --- a/packages/app/src/web/components/StatelessTransportMetrics.tsx +++ b/packages/app/src/web/components/StatelessTransportMetrics.tsx @@ -18,6 +18,9 @@ type ClientData = { lastSeen: string; firstSeen: string; toolCallCount: number; + newIpCount: number; + anonCount: number; + uniqueAuthCount: number; }; /** @@ -108,6 +111,23 @@ export function StatelessTransportMetrics({ metrics }: StatelessTransportMetrics header: createSortableHeader('Tool Calls', 'right'), cell: ({ row }) =>
{row.getValue('toolCallCount')}
, }, + { + accessorKey: 'newIpCount', + header: createSortableHeader('New IPs', 'right'), + cell: ({ row }) =>
{row.getValue('newIpCount')}
, + }, + { + accessorKey: 'anonCount', + header: createSortableHeader('Anon/Auth', 'right'), + cell: ({ row }) => { + const client = row.original; + return ( +
+ {client.anonCount}/{client.uniqueAuthCount} +
+ ); + }, + }, { accessorKey: 'isConnected', header: createSortableHeader('Status'),