@operor/analytics 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,344 @@
1
+ import { describe, it, expect, beforeEach, vi } from 'vitest';
2
+ import { AnalyticsCollector } from './AnalyticsCollector.js';
3
+ import { InMemoryAnalyticsStore } from './InMemoryAnalyticsStore.js';
4
+
5
+ /** Minimal emitter mock matching the Emitter interface */
6
+ function createEmitter() {
7
+ const handlers = new Map<string, Set<Function>>();
8
+ return {
9
+ on(event: string, handler: Function) {
10
+ if (!handlers.has(event)) handlers.set(event, new Set());
11
+ handlers.get(event)!.add(handler);
12
+ },
13
+ off(event: string, handler: Function) {
14
+ handlers.get(event)?.delete(handler);
15
+ },
16
+ emit(event: string, ...args: any[]) {
17
+ for (const h of handlers.get(event) ?? []) h(...args);
18
+ },
19
+ listenerCount(event: string) {
20
+ return handlers.get(event)?.size ?? 0;
21
+ },
22
+ };
23
+ }
24
+
25
+ function makePayload(overrides: Record<string, any> = {}) {
26
+ return {
27
+ message: {
28
+ id: 'msg_1',
29
+ from: '+1234567890',
30
+ text: 'Where is my order?',
31
+ timestamp: Date.now(),
32
+ channel: 'whatsapp',
33
+ provider: 'whatsapp',
34
+ },
35
+ response: {
36
+ text: 'Your order is on the way!',
37
+ duration: 350,
38
+ cost: 0.003,
39
+ toolCalls: [],
40
+ metadata: {
41
+ kbTopScore: 0.88,
42
+ kbIsFaqMatch: true,
43
+ kbResultCount: 2,
44
+ promptTokens: 150,
45
+ completionTokens: 60,
46
+ },
47
+ },
48
+ agent: 'support',
49
+ intent: { intent: 'order_status', confidence: 0.95 },
50
+ customer: { id: 'cust_1', phone: '+1234567890' },
51
+ duration: 350,
52
+ cost: 0.003,
53
+ ...overrides,
54
+ };
55
+ }
56
+
57
+ describe('AnalyticsCollector', () => {
58
+ let store: InMemoryAnalyticsStore;
59
+ let collector: AnalyticsCollector;
60
+ let emitter: ReturnType<typeof createEmitter>;
61
+
62
+ beforeEach(async () => {
63
+ store = new InMemoryAnalyticsStore();
64
+ await store.initialize();
65
+ collector = new AnalyticsCollector(store);
66
+ emitter = createEmitter();
67
+ });
68
+
69
+ // --- attach / detach ---
70
+
71
+ describe('attach and detach', () => {
72
+ it('registers message:processed and error handlers', () => {
73
+ collector.attach(emitter);
74
+ expect(emitter.listenerCount('message:processed')).toBe(1);
75
+ expect(emitter.listenerCount('error')).toBe(1);
76
+ });
77
+
78
+ it('removes handlers on detach', () => {
79
+ collector.attach(emitter);
80
+ collector.detach(emitter);
81
+ expect(emitter.listenerCount('message:processed')).toBe(0);
82
+ expect(emitter.listenerCount('error')).toBe(0);
83
+ });
84
+
85
+ it('can attach to multiple emitters', () => {
86
+ const emitter2 = createEmitter();
87
+ collector.attach(emitter);
88
+ // Attaching again replaces internal handler refs, but both emitters have listeners
89
+ collector.attach(emitter2);
90
+ expect(emitter2.listenerCount('message:processed')).toBe(1);
91
+ });
92
+ });
93
+
94
+ // --- Event recording ---
95
+
96
+ describe('event recording', () => {
97
+ it('records a message:processed event', async () => {
98
+ collector.attach(emitter);
99
+ emitter.emit('message:processed', makePayload());
100
+ // Wait for async handler
101
+ await new Promise(r => setTimeout(r, 50));
102
+
103
+ const summary = await store.getSummary();
104
+ expect(summary.totalMessages).toBe(1);
105
+ });
106
+
107
+ it('extracts agent name correctly', async () => {
108
+ collector.attach(emitter);
109
+ emitter.emit('message:processed', makePayload({ agent: 'billing' }));
110
+ await new Promise(r => setTimeout(r, 50));
111
+
112
+ const stats = await store.getAgentStats();
113
+ expect(stats[0].agentName).toBe('billing');
114
+ });
115
+
116
+ it('extracts intent correctly', async () => {
117
+ collector.attach(emitter);
118
+ emitter.emit('message:processed', makePayload({
119
+ intent: { intent: 'refund_request', confidence: 0.8 },
120
+ }));
121
+ await new Promise(r => setTimeout(r, 50));
122
+
123
+ const kb = await store.getKbStats();
124
+ expect(kb.totalQueries).toBe(1);
125
+ });
126
+
127
+ it('extracts KB metadata from response.metadata', async () => {
128
+ collector.attach(emitter);
129
+ emitter.emit('message:processed', makePayload({
130
+ response: {
131
+ text: 'Answer',
132
+ duration: 200,
133
+ metadata: { kbTopScore: 0.92, kbIsFaqMatch: true, kbResultCount: 5 },
134
+ },
135
+ }));
136
+ await new Promise(r => setTimeout(r, 50));
137
+
138
+ const kb = await store.getKbStats();
139
+ expect(kb.faqHits).toBe(1);
140
+ });
141
+
142
+ it('handles missing metadata gracefully', async () => {
143
+ collector.attach(emitter);
144
+ emitter.emit('message:processed', makePayload({
145
+ response: { text: 'Answer', duration: 200 },
146
+ }));
147
+ await new Promise(r => setTimeout(r, 50));
148
+
149
+ const summary = await store.getSummary();
150
+ expect(summary.totalMessages).toBe(1);
151
+ });
152
+
153
+ it('extracts tool calls from response.toolCalls', async () => {
154
+ collector.attach(emitter);
155
+ emitter.emit('message:processed', makePayload({
156
+ response: {
157
+ text: 'Done',
158
+ duration: 500,
159
+ toolCalls: [
160
+ { name: 'get_order', params: { id: '123' } },
161
+ { name: 'track_shipment', params: { id: '456' } },
162
+ ],
163
+ metadata: {},
164
+ },
165
+ }));
166
+ await new Promise(r => setTimeout(r, 50));
167
+
168
+ const stats = await store.getAgentStats();
169
+ expect(stats[0].toolCallCount).toBe(2);
170
+ });
171
+
172
+ it('falls back to payload.toolCalls when response.toolCalls is missing', async () => {
173
+ collector.attach(emitter);
174
+ emitter.emit('message:processed', makePayload({
175
+ response: { text: 'Done', duration: 500, metadata: {} },
176
+ toolCalls: [{ name: 'get_order', params: {} }],
177
+ }));
178
+ await new Promise(r => setTimeout(r, 50));
179
+
180
+ const stats = await store.getAgentStats();
181
+ expect(stats[0].toolCallCount).toBe(1);
182
+ });
183
+
184
+ it('extracts message and response lengths', async () => {
185
+ collector.attach(emitter);
186
+ emitter.emit('message:processed', makePayload({
187
+ message: { id: '1', from: '+1', text: 'Hello', timestamp: Date.now(), channel: 'whatsapp', provider: 'whatsapp' },
188
+ response: { text: 'Hi there, how can I help?', duration: 100, metadata: {} },
189
+ }));
190
+ await new Promise(r => setTimeout(r, 50));
191
+
192
+ const summary = await store.getSummary();
193
+ expect(summary.totalMessages).toBe(1);
194
+ });
195
+
196
+ it('creates conversation session for each event', async () => {
197
+ collector.attach(emitter);
198
+ emitter.emit('message:processed', makePayload());
199
+ emitter.emit('message:processed', makePayload());
200
+ await new Promise(r => setTimeout(r, 50));
201
+
202
+ const summary = await store.getSummary();
203
+ expect(summary.totalMessages).toBe(2);
204
+ });
205
+
206
+ it('detects new customer when firstInteraction is very recent', async () => {
207
+ collector.attach(emitter);
208
+ emitter.emit('message:processed', makePayload({
209
+ customer: { id: 'new_cust', phone: '+999', firstInteraction: new Date() },
210
+ }));
211
+ await new Promise(r => setTimeout(r, 50));
212
+
213
+ const summary = await store.getSummary();
214
+ expect(summary.newCustomers).toBe(1);
215
+ });
216
+
217
+ it('does not mark as new customer when firstInteraction is old', async () => {
218
+ collector.attach(emitter);
219
+ const oldDate = new Date(Date.now() - 7 * 24 * 60 * 60 * 1000); // 7 days ago
220
+ emitter.emit('message:processed', makePayload({
221
+ customer: { id: 'old_cust', phone: '+888', firstInteraction: oldDate },
222
+ }));
223
+ await new Promise(r => setTimeout(r, 50));
224
+
225
+ const summary = await store.getSummary();
226
+ expect(summary.newCustomers).toBe(0);
227
+ });
228
+
229
+ it('uses response.duration, falls back to payload.duration', async () => {
230
+ collector.attach(emitter);
231
+ emitter.emit('message:processed', makePayload({
232
+ response: { text: 'Hi', duration: 0, metadata: {} },
233
+ duration: 999,
234
+ }));
235
+ await new Promise(r => setTimeout(r, 50));
236
+
237
+ // Should not crash — duration extracted from one of the sources
238
+ const summary = await store.getSummary();
239
+ expect(summary.totalMessages).toBe(1);
240
+ });
241
+
242
+ it('uses response.cost, falls back to payload.cost', async () => {
243
+ collector.attach(emitter);
244
+ emitter.emit('message:processed', makePayload({
245
+ response: { text: 'Hi', duration: 100, cost: undefined, metadata: {} },
246
+ cost: 0.005,
247
+ }));
248
+ await new Promise(r => setTimeout(r, 50));
249
+
250
+ const summary = await store.getSummary();
251
+ expect(summary.totalMessages).toBe(1);
252
+ });
253
+ });
254
+
255
+ // --- Error resilience ---
256
+
257
+ describe('error resilience', () => {
258
+ it('does not throw when store.recordMessageEvent fails', async () => {
259
+ const failStore = new InMemoryAnalyticsStore();
260
+ await failStore.initialize();
261
+ vi.spyOn(failStore, 'recordMessageEvent').mockRejectedValue(new Error('DB write failed'));
262
+
263
+ const failCollector = new AnalyticsCollector(failStore, { debug: true });
264
+ failCollector.attach(emitter);
265
+
266
+ // Should not throw
267
+ emitter.emit('message:processed', makePayload());
268
+ await new Promise(r => setTimeout(r, 50));
269
+ });
270
+
271
+ it('does not throw when store.getOrCreateConversation fails', async () => {
272
+ const failStore = new InMemoryAnalyticsStore();
273
+ await failStore.initialize();
274
+ vi.spyOn(failStore, 'getOrCreateConversation').mockRejectedValue(new Error('DB read failed'));
275
+
276
+ const failCollector = new AnalyticsCollector(failStore, { debug: true });
277
+ failCollector.attach(emitter);
278
+
279
+ emitter.emit('message:processed', makePayload());
280
+ await new Promise(r => setTimeout(r, 50));
281
+ // No crash
282
+ });
283
+
284
+ it('handles error events without crashing', async () => {
285
+ collector.attach(emitter);
286
+ emitter.emit('error', { error: new Error('Something broke'), message: { text: 'test' } });
287
+ await new Promise(r => setTimeout(r, 50));
288
+ // No crash, no events recorded
289
+ const summary = await store.getSummary();
290
+ expect(summary.totalMessages).toBe(0);
291
+ });
292
+
293
+ it('handles malformed payload gracefully (missing message.text)', async () => {
294
+ collector.attach(emitter);
295
+ emitter.emit('message:processed', makePayload({
296
+ message: { id: '1', from: '+1', text: undefined, timestamp: Date.now(), channel: 'whatsapp', provider: 'whatsapp' },
297
+ }));
298
+ await new Promise(r => setTimeout(r, 50));
299
+
300
+ const summary = await store.getSummary();
301
+ expect(summary.totalMessages).toBe(1);
302
+ });
303
+
304
+ it('handles missing customer.firstInteraction', async () => {
305
+ collector.attach(emitter);
306
+ emitter.emit('message:processed', makePayload({
307
+ customer: { id: 'cust_x', phone: '+1' },
308
+ }));
309
+ await new Promise(r => setTimeout(r, 50));
310
+
311
+ const summary = await store.getSummary();
312
+ expect(summary.totalMessages).toBe(1);
313
+ expect(summary.newCustomers).toBe(0);
314
+ });
315
+ });
316
+
317
+ // --- Debug mode ---
318
+
319
+ describe('debug mode', () => {
320
+ it('logs when debug is enabled', async () => {
321
+ const logSpy = vi.spyOn(console, 'log').mockImplementation(() => {});
322
+ const debugCollector = new AnalyticsCollector(store, { debug: true });
323
+ debugCollector.attach(emitter);
324
+
325
+ emitter.emit('message:processed', makePayload());
326
+ await new Promise(r => setTimeout(r, 50));
327
+
328
+ expect(logSpy).toHaveBeenCalledWith(expect.stringContaining('[Analytics]'));
329
+ logSpy.mockRestore();
330
+ });
331
+
332
+ it('does not log when debug is disabled', async () => {
333
+ const logSpy = vi.spyOn(console, 'log').mockImplementation(() => {});
334
+ collector.attach(emitter);
335
+
336
+ emitter.emit('message:processed', makePayload());
337
+ await new Promise(r => setTimeout(r, 50));
338
+
339
+ const analyticsCalls = logSpy.mock.calls.filter(c => String(c[0]).includes('[Analytics]'));
340
+ expect(analyticsCalls).toHaveLength(0);
341
+ logSpy.mockRestore();
342
+ });
343
+ });
344
+ });
@@ -0,0 +1,148 @@
1
+ import type { AnalyticsStore, AnalyticsEvent } from './types.js';
2
+
3
+ /**
4
+ * Event payload from Operor 'message:processed' event.
5
+ * Kept loose to avoid a hard dependency on @operor/core.
6
+ */
7
+ interface MessageProcessedPayload {
8
+ message: {
9
+ id?: string;
10
+ from: string;
11
+ text: string;
12
+ timestamp: number;
13
+ channel: string;
14
+ provider: string;
15
+ };
16
+ response: {
17
+ text: string;
18
+ duration: number;
19
+ cost?: number;
20
+ toolCalls?: Array<{ name: string; params: any; result?: any; success?: boolean }>;
21
+ metadata?: Record<string, any>;
22
+ };
23
+ agent: string;
24
+ intent: {
25
+ intent: string;
26
+ confidence: number;
27
+ };
28
+ customer: {
29
+ id: string;
30
+ phone?: string;
31
+ firstInteraction?: Date;
32
+ };
33
+ toolCalls?: Array<{ name: string; params: any }>;
34
+ duration: number;
35
+ cost?: number;
36
+ }
37
+
38
+ /** Minimal EventEmitter interface — matches eventemitter3 used by Operor */
39
+ interface Emitter {
40
+ on(event: string, handler: (...args: any[]) => void): void;
41
+ off(event: string, handler: (...args: any[]) => void): void;
42
+ }
43
+
44
+ /**
45
+ * Listens to Operor events and persists analytics data.
46
+ */
47
+ export class AnalyticsCollector {
48
+ private store: AnalyticsStore;
49
+ private debug: boolean;
50
+ private boundHandlers = new Map<string, (...args: any[]) => void>();
51
+
52
+ constructor(store: AnalyticsStore, options?: { debug?: boolean }) {
53
+ this.store = store;
54
+ this.debug = options?.debug ?? false;
55
+ }
56
+
57
+ /**
58
+ * Attach to an Operor instance (or any EventEmitter with the same events).
59
+ */
60
+ attach(os: Emitter): void {
61
+ const onProcessed = (payload: MessageProcessedPayload) => {
62
+ this.handleProcessed(payload).catch(err => {
63
+ if (this.debug) console.warn('[Analytics] Error recording event:', err?.message);
64
+ });
65
+ };
66
+
67
+ const onError = (payload: { error: any; message?: any }) => {
68
+ this.handleError(payload).catch(err => {
69
+ if (this.debug) console.warn('[Analytics] Error recording error event:', err?.message);
70
+ });
71
+ };
72
+
73
+ os.on('message:processed', onProcessed);
74
+ os.on('error', onError);
75
+
76
+ this.boundHandlers.set('message:processed', onProcessed);
77
+ this.boundHandlers.set('error', onError);
78
+ }
79
+
80
+ /**
81
+ * Detach from an Operor instance.
82
+ */
83
+ detach(os: Emitter): void {
84
+ for (const [event, handler] of this.boundHandlers) {
85
+ os.off(event, handler);
86
+ }
87
+ this.boundHandlers.clear();
88
+ }
89
+
90
+ private async handleProcessed(payload: MessageProcessedPayload): Promise<void> {
91
+ const { message, response, agent, intent, customer } = payload;
92
+
93
+ const toolCalls = response.toolCalls || payload.toolCalls || [];
94
+ const meta = response.metadata || {};
95
+
96
+ // Detect if this is a new customer (first interaction is very recent)
97
+ const isNew = customer.firstInteraction
98
+ ? (Date.now() - customer.firstInteraction.getTime()) < 60_000
99
+ : false;
100
+
101
+ // Get or create conversation session
102
+ const conversationId = await this.store.getOrCreateConversation(
103
+ message.from,
104
+ message.channel || message.provider,
105
+ agent,
106
+ );
107
+
108
+ const event: AnalyticsEvent = {
109
+ timestamp: message.timestamp || Date.now(),
110
+ channel: message.channel || message.provider,
111
+ customerPhone: message.from,
112
+ customerId: customer.id,
113
+ agentName: agent,
114
+ intent: intent.intent,
115
+ intentConfidence: intent.confidence,
116
+ responseTimeMs: response.duration || payload.duration || 0,
117
+ llmCost: response.cost ?? payload.cost ?? 0,
118
+ kbTopScore: meta.kbTopScore ?? 0,
119
+ kbIsFaqMatch: meta.kbIsFaqMatch ?? false,
120
+ kbResultCount: meta.kbResultCount ?? 0,
121
+ toolCallsCount: toolCalls.length,
122
+ toolCallsJson: toolCalls.length > 0 ? JSON.stringify(toolCalls) : null,
123
+ messageLength: message.text?.length ?? 0,
124
+ responseLength: response.text?.length ?? 0,
125
+ isNewCustomer: isNew,
126
+ wasEscalated: false, // set externally if escalation detected
127
+ conversationId,
128
+ promptTokens: meta.promptTokens ?? 0,
129
+ completionTokens: meta.completionTokens ?? 0,
130
+ };
131
+
132
+ await this.store.recordMessageEvent(event);
133
+ await this.store.updateConversationStats(conversationId, event);
134
+
135
+ if (this.debug) {
136
+ this.log(`Recorded event: agent=${agent} customer=${message.from} intent=${intent.intent}`);
137
+ }
138
+ }
139
+
140
+ private async handleError(_payload: { error: any; message?: any }): Promise<void> {
141
+ // Error events don't have enough structured data to record as full events.
142
+ // Future: could track error counts in daily_metrics.
143
+ }
144
+
145
+ private log(msg: string): void {
146
+ console.log(`[Analytics] ${msg}`);
147
+ }
148
+ }