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
43 changes: 43 additions & 0 deletions src/analytics-validation-plugin.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -62,6 +62,49 @@ describe("Analytics Validation Plugin", () => {
expect(validateSessionStartedEvent(validEvent)).toBe(true);
});

it("should accept event with is_reconnection: true", () => {
const validEvent = {
sess_id: "session-123",
process_id: "process-456",
user_unique_id: "user-789",
is_reconnection: true,
};

expect(validateSessionStartedEvent(validEvent)).toBe(true);
});

it("should accept event with is_reconnection: false", () => {
const validEvent = {
sess_id: "session-123",
process_id: "process-456",
user_unique_id: "user-789",
is_reconnection: false,
};

expect(validateSessionStartedEvent(validEvent)).toBe(true);
});

it("should accept event without is_reconnection (optional)", () => {
const validEvent = {
sess_id: "session-123",
process_id: "process-456",
user_unique_id: "user-789",
};

expect(validateSessionStartedEvent(validEvent)).toBe(true);
});

it("should reject event with non-boolean is_reconnection", () => {
const invalidEvent = {
sess_id: "session-123",
process_id: "process-456",
user_unique_id: "user-789",
is_reconnection: "yes",
};

expect(validateSessionStartedEvent(invalidEvent)).toBe(false);
});

it("should reject event with unexpected fields", () => {
const invalidEvent = {
sess_id: "session-123",
Expand Down
4 changes: 3 additions & 1 deletion src/analytics-validation-plugin.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ import {
const EVENT_SCHEMAS = {
mcp_session_started: {
required: ["sess_id", "process_id", "user_unique_id"],
optional: ["user_agent", "mcp_tool_set"],
optional: ["user_agent", "mcp_tool_set", "is_reconnection"],
validators: {
sess_id: (value: any) => typeof value === "string" && value.length > 0,
process_id: (value: any) => typeof value === "string" && value.length > 0,
Expand All @@ -21,6 +21,8 @@ const EVENT_SCHEMAS = {
value === undefined || typeof value === "string",
mcp_tool_set: (value: any) =>
value === undefined || typeof value === "string",
is_reconnection: (value: any) =>
value === undefined || typeof value === "boolean",
},
},
mcp_tool_called: {
Expand Down
98 changes: 98 additions & 0 deletions src/analytics.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -357,6 +357,104 @@ describe("Analytics Service", () => {
});
});

describe("reconnection detection", () => {
beforeEach(() => {
analyticsService.initialize(
"test-key",
() => 0,
"1.0.0",
"test",
false,
);
mockAnalyticsInstance.track.mockClear();
});

it("should set is_reconnection to false for the first session of a token", () => {
analyticsService.trackMcpSessionStarted(
"session-1",
"agent",
"toolset",
"token-A",
);

expect(mockAnalyticsInstance.track).toHaveBeenCalledTimes(1);
const call = mockAnalyticsInstance.track.mock.calls[0][0];
expect(call.properties.is_reconnection).toBe(false);
});

it("should set is_reconnection to true for a second session with the same token", () => {
analyticsService.trackMcpSessionStarted(
"session-1",
"agent",
"toolset",
"token-A",
);
analyticsService.trackMcpSessionStarted(
"session-2",
"agent",
"toolset",
"token-A",
);

expect(mockAnalyticsInstance.track).toHaveBeenCalledTimes(2);
const firstCall = mockAnalyticsInstance.track.mock.calls[0][0];
const secondCall = mockAnalyticsInstance.track.mock.calls[1][0];
expect(firstCall.properties.is_reconnection).toBe(false);
expect(secondCall.properties.is_reconnection).toBe(true);
});

it("should set is_reconnection to false for different tokens", () => {
analyticsService.trackMcpSessionStarted(
"session-1",
"agent",
"toolset",
"token-A",
);
analyticsService.trackMcpSessionStarted(
"session-2",
"agent",
"toolset",
"token-B",
);

expect(mockAnalyticsInstance.track).toHaveBeenCalledTimes(2);
const firstCall = mockAnalyticsInstance.track.mock.calls[0][0];
const secondCall = mockAnalyticsInstance.track.mock.calls[1][0];
expect(firstCall.properties.is_reconnection).toBe(false);
expect(secondCall.properties.is_reconnection).toBe(false);
});

it("should set is_reconnection to false after the reconnection window expires", () => {
// Use vi.useFakeTimers to control time
vi.useFakeTimers();

analyticsService.trackMcpSessionStarted(
"session-1",
"agent",
"toolset",
"token-A",
);

// Advance time past the 1-hour reconnection window
vi.advanceTimersByTime(3_600_001);

analyticsService.trackMcpSessionStarted(
"session-2",
"agent",
"toolset",
"token-A",
);

expect(mockAnalyticsInstance.track).toHaveBeenCalledTimes(2);
const firstCall = mockAnalyticsInstance.track.mock.calls[0][0];
const secondCall = mockAnalyticsInstance.track.mock.calls[1][0];
expect(firstCall.properties.is_reconnection).toBe(false);
expect(secondCall.properties.is_reconnection).toBe(false);

vi.useRealTimers();
});
});

describe("shutdown", () => {
it("should handle shutdown gracefully", async () => {
const getActiveSessions = () => 0;
Expand Down
23 changes: 21 additions & 2 deletions src/analytics.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,8 @@ export class AnalyticsService {
private salt: string;
private startTime: number;
private statusReportInterval: NodeJS.Timeout | null = null;
private recentUsers: Map<string, number> = new Map();
private readonly RECONNECTION_WINDOW = 3_600_000; // 1 hour in ms

constructor() {
// Generate volatile process ID that changes on each restart
Expand Down Expand Up @@ -142,15 +144,23 @@ export class AnalyticsService {
): void {
if (!this.isEnabled) return;
try {
const userUniqueId = this.generateUserUniqueId(userToken);
const now = Date.now();
const lastSeen = this.recentUsers.get(userUniqueId);
const isReconnection =
lastSeen !== undefined && now - lastSeen < this.RECONNECTION_WINDOW;
this.recentUsers.set(userUniqueId, now);
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Depending on the toolset, pne singe user can be connected multiple times. In the situation where the user connect to several toolsets, isReconnect will be true for all but the first connection.

Also, we can have the situation where a user is using two different MCP clients at the same time, eg VSCode and Claude Code.


this.analytics!.track({
event: "mcp_session_started",
anonymousId: this.generateUserUniqueId(userToken),
anonymousId: userUniqueId,
properties: {
sess_id: sessionId,
user_unique_id: this.generateUserUniqueId(userToken),
user_unique_id: userUniqueId,
user_agent: userAgent,
mcp_tool_set: mcpToolSet,
process_id: this.processId,
is_reconnection: isReconnection,
},
});
} catch (error) {
Expand Down Expand Up @@ -250,6 +260,14 @@ export class AnalyticsService {
): NodeJS.Timeout {
const reportStatus = () => {
try {
// Clean up stale entries from recentUsers
const now = Date.now();
for (const [userId, timestamp] of this.recentUsers) {
if (now - timestamp >= this.RECONNECTION_WINDOW) {
this.recentUsers.delete(userId);
}
}

const activeSessions = getActiveSessions();
// For now, we assume healthy status. This could be enhanced to check AAP connectivity
const healthStatus = "healthy" as const;
Expand Down Expand Up @@ -296,6 +314,7 @@ export interface SessionStartedEvent {
mcp_tool_set?: string;
process_id: string;
user_unique_id: string;
is_reconnection?: boolean;
}

export interface ToolCalledEvent {
Expand Down
Loading