Skip to content

Commit 407373d

Browse files
authored
[Part 2/6] feat(telemetry): add activity detector with user interaction tracking (#8111)
1 parent 6756a8b commit 407373d

File tree

4 files changed

+261
-0
lines changed

4 files changed

+261
-0
lines changed
Lines changed: 164 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,164 @@
1+
/**
2+
* @license
3+
* Copyright 2025 Google LLC
4+
* SPDX-License-Identifier: Apache-2.0
5+
*/
6+
7+
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
8+
import {
9+
ActivityDetector,
10+
getActivityDetector,
11+
recordUserActivity,
12+
isUserActive,
13+
} from './activity-detector.js';
14+
15+
describe('ActivityDetector', () => {
16+
let detector: ActivityDetector;
17+
18+
beforeEach(() => {
19+
detector = new ActivityDetector(1000); // 1 second idle threshold for testing
20+
});
21+
22+
describe('constructor', () => {
23+
it('should initialize with default idle threshold', () => {
24+
const defaultDetector = new ActivityDetector();
25+
expect(defaultDetector).toBeInstanceOf(ActivityDetector);
26+
});
27+
28+
it('should initialize with custom idle threshold', () => {
29+
const customDetector = new ActivityDetector(5000);
30+
expect(customDetector).toBeInstanceOf(ActivityDetector);
31+
});
32+
});
33+
34+
describe('recordActivity', () => {
35+
beforeEach(() => {
36+
vi.useFakeTimers();
37+
});
38+
afterEach(() => {
39+
vi.useRealTimers();
40+
});
41+
it('should update last activity time', () => {
42+
const beforeTime = detector.getLastActivityTime();
43+
vi.advanceTimersByTime(100);
44+
45+
detector.recordActivity();
46+
const afterTime = detector.getLastActivityTime();
47+
48+
expect(afterTime).toBeGreaterThan(beforeTime);
49+
});
50+
});
51+
52+
describe('isUserActive', () => {
53+
beforeEach(() => {
54+
vi.useFakeTimers();
55+
});
56+
afterEach(() => {
57+
vi.useRealTimers();
58+
});
59+
it('should return true immediately after construction', () => {
60+
expect(detector.isUserActive()).toBe(true);
61+
});
62+
63+
it('should return true within idle threshold', () => {
64+
detector.recordActivity();
65+
expect(detector.isUserActive()).toBe(true);
66+
});
67+
68+
it('should return false after idle threshold', () => {
69+
// Advance time beyond idle threshold
70+
vi.advanceTimersByTime(2000); // 2 seconds, threshold is 1 second
71+
72+
expect(detector.isUserActive()).toBe(false);
73+
});
74+
75+
it('should return true again after recording new activity', () => {
76+
// Go idle
77+
vi.advanceTimersByTime(2000);
78+
expect(detector.isUserActive()).toBe(false);
79+
80+
// Record new activity
81+
detector.recordActivity();
82+
expect(detector.isUserActive()).toBe(true);
83+
});
84+
});
85+
86+
describe('getTimeSinceLastActivity', () => {
87+
beforeEach(() => {
88+
vi.useFakeTimers();
89+
});
90+
afterEach(() => {
91+
vi.useRealTimers();
92+
});
93+
it('should return time elapsed since last activity', () => {
94+
detector.recordActivity();
95+
vi.advanceTimersByTime(500);
96+
97+
const timeSince = detector.getTimeSinceLastActivity();
98+
expect(timeSince).toBe(500);
99+
});
100+
});
101+
102+
describe('getLastActivityTime', () => {
103+
it('should return the timestamp of last activity', () => {
104+
const before = Date.now();
105+
detector.recordActivity();
106+
const activityTime = detector.getLastActivityTime();
107+
const after = Date.now();
108+
109+
expect(activityTime).toBeGreaterThanOrEqual(before);
110+
expect(activityTime).toBeLessThanOrEqual(after);
111+
});
112+
});
113+
});
114+
115+
describe('Global Activity Detector Functions', () => {
116+
describe('global instance', () => {
117+
it('should expose a global ActivityDetector via getActivityDetector', () => {
118+
const detector = getActivityDetector();
119+
expect(detector).toBeInstanceOf(ActivityDetector);
120+
});
121+
});
122+
123+
describe('getActivityDetector', () => {
124+
it('should always return the global instance', () => {
125+
const detector = getActivityDetector();
126+
const detectorAgain = getActivityDetector();
127+
expect(detectorAgain).toBe(detector);
128+
});
129+
});
130+
131+
describe('recordUserActivity', () => {
132+
beforeEach(() => {
133+
vi.useFakeTimers();
134+
});
135+
afterEach(() => {
136+
vi.useRealTimers();
137+
});
138+
it('should record activity on existing detector', () => {
139+
const detector = getActivityDetector()!;
140+
const beforeTime = detector.getLastActivityTime();
141+
vi.advanceTimersByTime(100);
142+
143+
recordUserActivity();
144+
145+
const afterTime = detector.getLastActivityTime();
146+
expect(afterTime).toBeGreaterThan(beforeTime);
147+
});
148+
});
149+
150+
describe('isUserActive', () => {
151+
beforeEach(() => {
152+
vi.useFakeTimers();
153+
});
154+
afterEach(() => {
155+
vi.useRealTimers();
156+
});
157+
it('should reflect global detector state', () => {
158+
expect(isUserActive()).toBe(true);
159+
// Default idle threshold is 30s; advance beyond it
160+
vi.advanceTimersByTime(31000);
161+
expect(isUserActive()).toBe(false);
162+
});
163+
});
164+
});
Lines changed: 70 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,70 @@
1+
/**
2+
* @license
3+
* Copyright 2025 Google LLC
4+
* SPDX-License-Identifier: Apache-2.0
5+
*/
6+
7+
/**
8+
* Tracks user activity state to determine when memory monitoring should be active
9+
*/
10+
export class ActivityDetector {
11+
private lastActivityTime: number = Date.now();
12+
private readonly idleThresholdMs: number;
13+
14+
constructor(idleThresholdMs: number = 30000) {
15+
this.idleThresholdMs = idleThresholdMs;
16+
}
17+
18+
/**
19+
* Record user activity (called by CLI when user types, adds messages, etc.)
20+
*/
21+
recordActivity(): void {
22+
this.lastActivityTime = Date.now();
23+
}
24+
25+
/**
26+
* Check if user is currently active (activity within idle threshold)
27+
*/
28+
isUserActive(): boolean {
29+
const timeSinceActivity = Date.now() - this.lastActivityTime;
30+
return timeSinceActivity < this.idleThresholdMs;
31+
}
32+
33+
/**
34+
* Get time since last activity in milliseconds
35+
*/
36+
getTimeSinceLastActivity(): number {
37+
return Date.now() - this.lastActivityTime;
38+
}
39+
40+
/**
41+
* Get last activity timestamp
42+
*/
43+
getLastActivityTime(): number {
44+
return this.lastActivityTime;
45+
}
46+
}
47+
48+
// Global activity detector instance (eagerly created with default threshold)
49+
const globalActivityDetector: ActivityDetector = new ActivityDetector();
50+
51+
/**
52+
* Get global activity detector instance
53+
*/
54+
export function getActivityDetector(): ActivityDetector {
55+
return globalActivityDetector;
56+
}
57+
58+
/**
59+
* Record user activity (convenience function for CLI to call)
60+
*/
61+
export function recordUserActivity(): void {
62+
globalActivityDetector.recordActivity();
63+
}
64+
65+
/**
66+
* Check if user is currently active (convenience function)
67+
*/
68+
export function isUserActive(): boolean {
69+
return globalActivityDetector.isUserActive();
70+
}
Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
/**
2+
* @license
3+
* Copyright 2025 Google LLC
4+
* SPDX-License-Identifier: Apache-2.0
5+
*/
6+
7+
/**
8+
* Types of user activities that can be tracked
9+
*/
10+
export enum ActivityType {
11+
USER_INPUT_START = 'user_input_start',
12+
USER_INPUT_END = 'user_input_end',
13+
MESSAGE_ADDED = 'message_added',
14+
TOOL_CALL_SCHEDULED = 'tool_call_scheduled',
15+
TOOL_CALL_COMPLETED = 'tool_call_completed',
16+
STREAM_START = 'stream_start',
17+
STREAM_END = 'stream_end',
18+
HISTORY_UPDATED = 'history_updated',
19+
MANUAL_TRIGGER = 'manual_trigger',
20+
}

packages/core/src/telemetry/index.ts

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -59,3 +59,10 @@ export { SemanticAttributes } from '@opentelemetry/semantic-conventions';
5959
export * from './uiTelemetry.js';
6060
export { HighWaterMarkTracker } from './high-water-mark-tracker.js';
6161
export { RateLimiter } from './rate-limiter.js';
62+
export { ActivityType } from './activity-types.js';
63+
export {
64+
ActivityDetector,
65+
getActivityDetector,
66+
recordUserActivity,
67+
isUserActive,
68+
} from './activity-detector.js';

0 commit comments

Comments
 (0)