From 9a522bb75f2314bb3f99b103ad5555b3c0c8e95a Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 19 Nov 2025 16:52:03 +0000 Subject: [PATCH 1/5] Add new IP address tracking per client in Client Identities table This commit adds tracking of new unique IP addresses per client, displaying the count in the Client Identities table similar to tool call counts. Changes: - Added newIpCount field to ClientMetrics interface - Implemented per-client IP tracking in MetricsCounter class - Added trackClientIpAddress method to track IPs per client - Updated StatelessTransportMetrics to display New IPs column - Added Client Identities section to StatefulTransportMetrics - Integrated IP tracking when client info becomes available When a new IP address is seen for a specific client, the newIpCount is incremented, allowing operators to monitor client connection patterns across different IP addresses. --- .../src/server/transport/base-transport.ts | 12 ++ .../transport/stateless-http-transport.ts | 3 + packages/app/src/shared/transport-metrics.ts | 35 ++++++ .../components/StatefulTransportMetrics.tsx | 108 +++++++++++++++++- .../components/StatelessTransportMetrics.tsx | 6 + 5 files changed, 163 insertions(+), 1 deletion(-) diff --git a/packages/app/src/server/transport/base-transport.ts b/packages/app/src/server/transport/base-transport.ts index 360ba2e..3338994 100644 --- a/packages/app/src/server/transport/base-transport.ts +++ b/packages/app/src/server/transport/base-transport.ts @@ -172,6 +172,13 @@ 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); + } + /** * Extract IP address from request headers * Handles x-forwarded-for, x-real-ip, and direct IP @@ -454,6 +461,11 @@ export abstract class StatefulTransport; sessions: SessionData[]; @@ -418,6 +420,7 @@ export class MetricsCounter { private rollingHour: RollingWindowCounter; private rolling3Hours: RollingWindowCounter; private uniqueIps: Set; + private clientIps: Map>; // Map of clientKey -> Set of IPs constructor() { this.metrics = createEmptyMetrics(); @@ -425,6 +428,7 @@ export class MetricsCounter { this.rollingHour = new RollingWindowCounter(60); this.rolling3Hours = new RollingWindowCounter(180); this.uniqueIps = new Set(); + this.clientIps = new Map(); } /** @@ -509,6 +513,36 @@ 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++; + } + } + } + } + /** * Update active connection count */ @@ -544,6 +578,7 @@ export class MetricsCounter { activeConnections: 1, totalConnections: 1, toolCallCount: 0, + newIpCount: 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..b5b72fb 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,19 @@ 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; +}; + /** * Format relative time (e.g., "5m ago", "2h ago", "just now") */ @@ -84,6 +97,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 +207,72 @@ 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: '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 +429,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..6fa8255 100644 --- a/packages/app/src/web/components/StatelessTransportMetrics.tsx +++ b/packages/app/src/web/components/StatelessTransportMetrics.tsx @@ -18,6 +18,7 @@ type ClientData = { lastSeen: string; firstSeen: string; toolCallCount: number; + newIpCount: number; }; /** @@ -108,6 +109,11 @@ 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: 'isConnected', header: createSortableHeader('Status'), From 8db524bd233c5444c373e5536fbf57694aeaba27 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 20 Nov 2025 06:04:37 +0000 Subject: [PATCH 2/5] Add anonymous/authenticated request tracking per client This commit adds tracking of unique authorization tokens (hashed for privacy) per client, displaying anonymous and unique auth counts in the Client Identities table. Changes: - Added anonCount and uniqueAuthCount fields to ClientMetrics - Implemented hashToken function for secure token hashing - Added trackClientAuth method in MetricsCounter to track auth status - Updated SessionMetadata to store authToken - Integrated auth tracking in SSE and stateless transports - Added "Anon/Auth" column to both transport metrics UIs The Anon/Auth column shows "anonCount/uniqueAuthCount" format, allowing operators to monitor authentication patterns per client. --- .../src/server/transport/base-transport.ts | 11 ++++ .../app/src/server/transport/sse-transport.ts | 2 + .../transport/stateless-http-transport.ts | 4 ++ packages/app/src/shared/transport-metrics.ts | 55 +++++++++++++++++++ .../components/StatefulTransportMetrics.tsx | 14 +++++ .../components/StatelessTransportMetrics.tsx | 14 +++++ 6 files changed, 100 insertions(+) diff --git a/packages/app/src/server/transport/base-transport.ts b/packages/app/src/server/transport/base-transport.ts index 3338994..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; } /** @@ -179,6 +180,13 @@ export abstract class BaseTransport { 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 @@ -466,6 +474,9 @@ 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 54d82de..80ff11d 100644 --- a/packages/app/src/server/transport/stateless-http-transport.ts +++ b/packages/app/src/server/transport/stateless-http-transport.ts @@ -272,6 +272,10 @@ export class StatelessHttpTransport extends BaseTransport { // 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/shared/transport-metrics.ts b/packages/app/src/shared/transport-metrics.ts index 189820a..032ffcd 100644 --- a/packages/app/src/shared/transport-metrics.ts +++ b/packages/app/src/shared/transport-metrics.ts @@ -78,6 +78,8 @@ export interface ClientMetrics { totalConnections: number; toolCallCount: number; newIpCount: number; + anonCount: number; + uniqueAuthCount: number; } /** @@ -230,6 +232,8 @@ export interface TransportMetricsResponse { totalConnections: number; toolCallCount: number; newIpCount: number; + anonCount: number; + uniqueAuthCount: number; }>; sessions: SessionData[]; @@ -411,6 +415,19 @@ class RollingWindowCounter { } } +/** + * Simple hash function for auth tokens (for privacy) + */ +function hashToken(token: string): string { + let hash = 0; + for (let i = 0; i < token.length; i++) { + const char = token.charCodeAt(i); + hash = ((hash << 5) - hash) + char; + hash = hash & hash; // Convert to 32bit integer + } + return hash.toString(16); +} + /** * Centralized metrics counter for transport operations */ @@ -421,6 +438,7 @@ export class MetricsCounter { 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(); @@ -429,6 +447,7 @@ export class MetricsCounter { this.rolling3Hours = new RollingWindowCounter(180); this.uniqueIps = new Set(); this.clientIps = new Map(); + this.clientAuthHashes = new Map(); } /** @@ -543,6 +562,40 @@ export class MetricsCounter { } } + /** + * 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 */ @@ -579,6 +632,8 @@ export class MetricsCounter { 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 b5b72fb..50b5627 100644 --- a/packages/app/src/web/components/StatefulTransportMetrics.tsx +++ b/packages/app/src/web/components/StatefulTransportMetrics.tsx @@ -36,6 +36,8 @@ type ClientData = { firstSeen: string; toolCallCount: number; newIpCount: number; + anonCount: number; + uniqueAuthCount: number; }; /** @@ -240,6 +242,18 @@ export function StatefulTransportMetrics({ metrics }: StatefulTransportMetricsPr 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'), diff --git a/packages/app/src/web/components/StatelessTransportMetrics.tsx b/packages/app/src/web/components/StatelessTransportMetrics.tsx index 6fa8255..dbb78a0 100644 --- a/packages/app/src/web/components/StatelessTransportMetrics.tsx +++ b/packages/app/src/web/components/StatelessTransportMetrics.tsx @@ -19,6 +19,8 @@ type ClientData = { firstSeen: string; toolCallCount: number; newIpCount: number; + anonCount: number; + uniqueAuthCount: number; }; /** @@ -114,6 +116,18 @@ export function StatelessTransportMetrics({ metrics }: StatelessTransportMetrics 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'), From 10e43e5a7f6fdb770e60a4ea3fb1394f0eff8aea Mon Sep 17 00:00:00 2001 From: evalstate <1936278+evalstate@users.noreply.github.com> Date: Thu, 20 Nov 2025 07:22:15 +0100 Subject: [PATCH 3/5] hash token properly --- packages/app/src/shared/transport-metrics.ts | 11 +++-------- 1 file changed, 3 insertions(+), 8 deletions(-) diff --git a/packages/app/src/shared/transport-metrics.ts b/packages/app/src/shared/transport-metrics.ts index 032ffcd..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'; /** @@ -416,16 +417,10 @@ class RollingWindowCounter { } /** - * Simple hash function for auth tokens (for privacy) + * Hash auth tokens before counting them to avoid storing raw secrets. */ function hashToken(token: string): string { - let hash = 0; - for (let i = 0; i < token.length; i++) { - const char = token.charCodeAt(i); - hash = ((hash << 5) - hash) + char; - hash = hash & hash; // Convert to 32bit integer - } - return hash.toString(16); + return createHash('sha256').update(token).digest('hex'); } /** From 932e6f7c67910b4cdec88fc64421d90b0f09d5bc Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 20 Nov 2025 06:25:47 +0000 Subject: [PATCH 4/5] Add IP and auth tracking to streamable-http-transport Updated createSession to extract and store ipAddress and authToken in session metadata, enabling proper tracking when client info becomes available via createClientInfoCapture. --- .../src/server/transport/streamable-http-transport.ts | 11 +++++++++-- 1 file changed, 9 insertions(+), 2 deletions(-) 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, }; From 22b0cebd6ae828f0b2ca993f02447b3fd1308268 Mon Sep 17 00:00:00 2001 From: evalstate <1936278+evalstate@users.noreply.github.com> Date: Thu, 20 Nov 2025 07:36:48 +0100 Subject: [PATCH 5/5] improve error capture --- packages/app/src/server/mcp-server.ts | 17 ++++++++++++----- packages/app/src/server/utils/query-logger.ts | 8 ++++++-- 2 files changed, 18 insertions(+), 7 deletions(-) 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/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, });