Skip to content

Commit 48bb4b2

Browse files
authored
Merge pull request #125 from huggingface/claude/track-new-ip-addresses-01FVJDgSc4HFAUVymLhfBL1p
Show new IP addresses in Client Identities table
2 parents 8388245 + 22b0ceb commit 48bb4b2

File tree

9 files changed

+285
-10
lines changed

9 files changed

+285
-10
lines changed

packages/app/src/server/mcp-server.ts

Lines changed: 12 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -157,7 +157,7 @@ export const createServerFactory = (_webServerInstance: WebServer, sharedApiClie
157157
options?: QueryLoggerOptions
158158
) => void;
159159

160-
type BaseQueryLoggerOptions = Omit<QueryLoggerOptions, 'durationMs' | 'success' | 'error'>;
160+
type BaseQueryLoggerOptions = Omit<QueryLoggerOptions, 'durationMs' | 'error'>;
161161

162162
interface QueryLoggingConfig<T> {
163163
methodName: string;
@@ -175,17 +175,24 @@ export const createServerFactory = (_webServerInstance: WebServer, sharedApiClie
175175
const start = performance.now();
176176
try {
177177
const result = await work();
178-
const durationMs = performance.now() - start;
178+
const durationMs = Math.round(performance.now() - start);
179179
const successOptions = config.successOptions?.(result) ?? {};
180+
const { success: successOverride, ...restSuccessOptions } = successOptions;
181+
const resultHasError =
182+
typeof result === 'object' &&
183+
result !== null &&
184+
'isError' in result &&
185+
Boolean((result as { isError?: boolean }).isError);
186+
const successFlag = successOverride ?? !resultHasError;
180187
logFn(config.methodName, config.query, config.parameters, {
181188
...config.baseOptions,
182-
...successOptions,
189+
...restSuccessOptions,
183190
durationMs,
184-
success: true,
191+
success: successFlag,
185192
});
186193
return result;
187194
} catch (error) {
188-
const durationMs = performance.now() - start;
195+
const durationMs = Math.round(performance.now() - start);
189196
logFn(config.methodName, config.query, config.parameters, {
190197
...config.baseOptions,
191198
durationMs,

packages/app/src/server/transport/base-transport.ts

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -60,6 +60,7 @@ export interface SessionMetadata {
6060
pingFailures?: number;
6161
lastPingAttempt?: Date;
6262
ipAddress?: string;
63+
authToken?: string;
6364
}
6465

6566
/**
@@ -172,6 +173,20 @@ export abstract class BaseTransport {
172173
this.metrics.trackIpAddress(ipAddress);
173174
}
174175

176+
/**
177+
* Track an IP address for a specific client
178+
*/
179+
protected trackClientIpAddress(ipAddress: string | undefined, clientInfo?: { name: string; version: string }): void {
180+
this.metrics.trackClientIpAddress(ipAddress, clientInfo);
181+
}
182+
183+
/**
184+
* Track auth status for a specific client
185+
*/
186+
protected trackClientAuth(authToken: string | undefined, clientInfo?: { name: string; version: string }): void {
187+
this.metrics.trackClientAuth(authToken, clientInfo);
188+
}
189+
175190
/**
176191
* Extract IP address from request headers
177192
* Handles x-forwarded-for, x-real-ip, and direct IP
@@ -454,6 +469,14 @@ export abstract class StatefulTransport<TSession extends BaseSession = BaseSessi
454469
session.metadata.clientInfo = clientInfo;
455470
// Associate session with real client for metrics tracking
456471
this.metrics.associateSessionWithClient(clientInfo);
472+
473+
// Track IP address for this client if available
474+
if (session.metadata.ipAddress) {
475+
this.trackClientIpAddress(session.metadata.ipAddress, clientInfo);
476+
}
477+
478+
// Track auth status for this client
479+
this.trackClientAuth(session.metadata.authToken, clientInfo);
457480
}
458481

459482
if (clientCapabilities) {

packages/app/src/server/transport/sse-transport.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -118,6 +118,7 @@ export class SseTransport extends StatefulTransport<SSEConnection> {
118118
const cleanup = this.createCleanupFunction(sessionId);
119119

120120
// Store connection with metadata
121+
const authToken = headers['authorization']?.replace(/^Bearer\s+/i, '');
121122
const connection: SSEConnection = {
122123
transport,
123124
server,
@@ -130,6 +131,7 @@ export class SseTransport extends StatefulTransport<SSEConnection> {
130131
isAuthenticated: authResult.shouldContinue && !!headers['authorization'],
131132
capabilities: {},
132133
ipAddress,
134+
authToken,
133135
},
134136
cleaningUp: false,
135137
};

packages/app/src/server/transport/stateless-http-transport.ts

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -269,6 +269,13 @@ export class StatelessHttpTransport extends BaseTransport {
269269
this.associateSessionWithClient(extractedClientInfo);
270270
this.updateClientActivity(extractedClientInfo);
271271

272+
// Track IP address for this client
273+
this.trackClientIpAddress(ipAddress, extractedClientInfo);
274+
275+
// Track auth status for this client
276+
const authToken = headers['authorization']?.replace(/^Bearer\s+/i, '');
277+
this.trackClientAuth(authToken, extractedClientInfo);
278+
272279
// Update analytics session with client info
273280
if (this.analyticsMode && sessionId) {
274281
this.updateAnalyticsSessionClientInfo(sessionId, extractedClientInfo);

packages/app/src/server/transport/streamable-http-transport.ts

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -136,7 +136,7 @@ export class StreamableHttpTransport extends StatefulTransport<Session> {
136136
return;
137137
}
138138

139-
transport = await this.createSession(headers);
139+
transport = await this.createSession(headers, req);
140140
} else if (!sessionId) {
141141
// No session ID and not an initialization request
142142
this.trackError(400);
@@ -220,12 +220,16 @@ export class StreamableHttpTransport extends StatefulTransport<Session> {
220220
await this.removeSession(sessionId);
221221
}
222222

223-
private async createSession(requestHeaders?: Record<string, string>): Promise<StreamableHTTPServerTransport> {
223+
private async createSession(requestHeaders?: Record<string, string>, req?: Request): Promise<StreamableHTTPServerTransport> {
224224
// Create server instance using factory with request headers
225225
// Note: Auth validation is now done in handlePostRequest before calling this method
226226
const result = await this.serverFactory(requestHeaders || null);
227227
const server = result.server;
228228

229+
// Extract IP address and auth token for tracking
230+
const ipAddress = req ? this.extractIpAddress(req.headers as Record<string, string | string[] | undefined>, req.ip) : undefined;
231+
const authToken = requestHeaders?.['authorization']?.replace(/^Bearer\s+/i, '');
232+
229233
const transport = new StreamableHTTPServerTransport({
230234
sessionIdGenerator: () => randomUUID(),
231235
onsessioninitialized: (sessionId: string) => {
@@ -235,6 +239,7 @@ export class StreamableHttpTransport extends StatefulTransport<Session> {
235239
logSystemEvent('initialize', sessionId, {
236240
clientSessionId: sessionId,
237241
isAuthenticated: !!requestHeaders?.['authorization'],
242+
ipAddress,
238243
});
239244

240245
// Create session object and store it immediately
@@ -248,6 +253,8 @@ export class StreamableHttpTransport extends StatefulTransport<Session> {
248253
requestCount: 0,
249254
isAuthenticated: !!requestHeaders?.['authorization'],
250255
capabilities: {},
256+
ipAddress,
257+
authToken,
251258
},
252259
cleaningUp: false,
253260
};

packages/app/src/server/utils/query-logger.ts

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -208,6 +208,8 @@ export function logSearchQuery(
208208
): void {
209209
// Use a stable mcpServerSessionId per process/transport instance
210210
const mcpServerSessionId = getMcpServerSessionId();
211+
const normalizedDurationMs =
212+
options?.durationMs !== undefined ? Math.round(options.durationMs) : undefined;
211213
const serializedParameters = JSON.stringify(data);
212214
const requestPayload = {
213215
methodName,
@@ -230,7 +232,7 @@ export function logSearchQuery(
230232
totalResults: options?.totalResults,
231233
resultsShared: options?.resultsShared,
232234
responseCharCount: options?.responseCharCount,
233-
durationMs: options?.durationMs,
235+
durationMs: normalizedDurationMs,
234236
success: options?.success ?? true,
235237
errorMessage: normalizedError,
236238
});
@@ -247,6 +249,8 @@ export function logPromptQuery(
247249
): void {
248250
// Use a stable mcpServerSessionId per process/transport instance
249251
const mcpServerSessionId = getMcpServerSessionId();
252+
const normalizedDurationMs =
253+
options?.durationMs !== undefined ? Math.round(options.durationMs) : undefined;
250254
const serializedParameters = JSON.stringify(data);
251255
const requestPayload = {
252256
methodName,
@@ -269,7 +273,7 @@ export function logPromptQuery(
269273
totalResults: options?.totalResults,
270274
resultsShared: options?.resultsShared,
271275
responseCharCount: options?.responseCharCount,
272-
durationMs: options?.durationMs,
276+
durationMs: normalizedDurationMs,
273277
success: options?.success ?? true,
274278
errorMessage: normalizedError,
275279
});

packages/app/src/shared/transport-metrics.ts

Lines changed: 85 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import { createHash } from 'node:crypto';
12
import type { TransportType } from './constants.js';
23

34
/**
@@ -77,6 +78,9 @@ export interface ClientMetrics {
7778
activeConnections: number;
7879
totalConnections: number;
7980
toolCallCount: number;
81+
newIpCount: number;
82+
anonCount: number;
83+
uniqueAuthCount: number;
8084
}
8185

8286
/**
@@ -228,6 +232,9 @@ export interface TransportMetricsResponse {
228232
activeConnections: number;
229233
totalConnections: number;
230234
toolCallCount: number;
235+
newIpCount: number;
236+
anonCount: number;
237+
uniqueAuthCount: number;
231238
}>;
232239

233240
sessions: SessionData[];
@@ -409,6 +416,13 @@ class RollingWindowCounter {
409416
}
410417
}
411418

419+
/**
420+
* Hash auth tokens before counting them to avoid storing raw secrets.
421+
*/
422+
function hashToken(token: string): string {
423+
return createHash('sha256').update(token).digest('hex');
424+
}
425+
412426
/**
413427
* Centralized metrics counter for transport operations
414428
*/
@@ -418,13 +432,17 @@ export class MetricsCounter {
418432
private rollingHour: RollingWindowCounter;
419433
private rolling3Hours: RollingWindowCounter;
420434
private uniqueIps: Set<string>;
435+
private clientIps: Map<string, Set<string>>; // Map of clientKey -> Set of IPs
436+
private clientAuthHashes: Map<string, Set<string>>; // Map of clientKey -> Set of auth token hashes
421437

422438
constructor() {
423439
this.metrics = createEmptyMetrics();
424440
this.rollingMinute = new RollingWindowCounter(1);
425441
this.rollingHour = new RollingWindowCounter(60);
426442
this.rolling3Hours = new RollingWindowCounter(180);
427443
this.uniqueIps = new Set();
444+
this.clientIps = new Map();
445+
this.clientAuthHashes = new Map();
428446
}
429447

430448
/**
@@ -509,6 +527,70 @@ export class MetricsCounter {
509527
}
510528
}
511529

530+
/**
531+
* Track an IP address for a specific client
532+
*/
533+
trackClientIpAddress(ipAddress: string | undefined, clientInfo?: { name: string; version: string }): void {
534+
// Always track globally
535+
this.trackIpAddress(ipAddress);
536+
537+
// Track per-client if client info is available
538+
if (ipAddress && clientInfo) {
539+
const clientKey = getClientKey(clientInfo.name, clientInfo.version);
540+
const clientMetrics = this.metrics.clients.get(clientKey);
541+
542+
if (clientMetrics) {
543+
// Get or create the IP set for this client
544+
let clientIpSet = this.clientIps.get(clientKey);
545+
if (!clientIpSet) {
546+
clientIpSet = new Set();
547+
this.clientIps.set(clientKey, clientIpSet);
548+
}
549+
550+
// Check if this is a new IP for this client
551+
const isNewIp = !clientIpSet.has(ipAddress);
552+
if (isNewIp) {
553+
clientIpSet.add(ipAddress);
554+
clientMetrics.newIpCount++;
555+
}
556+
}
557+
}
558+
}
559+
560+
/**
561+
* Track auth status for a specific client
562+
*/
563+
trackClientAuth(authToken: string | undefined, clientInfo?: { name: string; version: string }): void {
564+
if (!clientInfo) return;
565+
566+
const clientKey = getClientKey(clientInfo.name, clientInfo.version);
567+
const clientMetrics = this.metrics.clients.get(clientKey);
568+
569+
if (clientMetrics) {
570+
if (!authToken) {
571+
// Anonymous request
572+
clientMetrics.anonCount++;
573+
} else {
574+
// Authenticated request - hash the token for privacy
575+
const tokenHash = hashToken(authToken);
576+
577+
// Get or create the auth hash set for this client
578+
let clientAuthSet = this.clientAuthHashes.get(clientKey);
579+
if (!clientAuthSet) {
580+
clientAuthSet = new Set();
581+
this.clientAuthHashes.set(clientKey, clientAuthSet);
582+
}
583+
584+
// Check if this is a new auth token for this client
585+
const isNewAuth = !clientAuthSet.has(tokenHash);
586+
if (isNewAuth) {
587+
clientAuthSet.add(tokenHash);
588+
clientMetrics.uniqueAuthCount++;
589+
}
590+
}
591+
}
592+
}
593+
512594
/**
513595
* Update active connection count
514596
*/
@@ -544,6 +626,9 @@ export class MetricsCounter {
544626
activeConnections: 1,
545627
totalConnections: 1,
546628
toolCallCount: 0,
629+
newIpCount: 0,
630+
anonCount: 0,
631+
uniqueAuthCount: 0,
547632
};
548633
this.metrics.clients.set(clientKey, clientMetrics);
549634
} else {

0 commit comments

Comments
 (0)