@aura-labs-ai/scout 0.2.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,420 @@
1
+ /**
2
+ * Scout Activity Logger
3
+ *
4
+ * Structured observability for a single Scout's (buyer) lifecycle.
5
+ * Every action the Scout takes — registration, session management,
6
+ * offer evaluation, transaction handling — is recorded as a structured event
7
+ * with timestamps, durations, correlation IDs, and outcome metadata.
8
+ *
9
+ * Consumers can:
10
+ * - Subscribe to events in real-time via `on(eventType, handler)`
11
+ * - Query the activity log via `getEvents()` with filters
12
+ * - Get summary stats via `getSummary()`
13
+ * - Plug in a custom logger (pino, winston, console, etc.)
14
+ *
15
+ * @example
16
+ * ```js
17
+ * const scout = createScout({ ... });
18
+ *
19
+ * // Listen to specific events
20
+ * scout.activity.on('offer.committed', (event) => {
21
+ * console.log(`Offer ${event.offerId} committed in ${event.durationMs}ms`);
22
+ * });
23
+ *
24
+ * // Get summary stats
25
+ * const stats = scout.activity.getSummary();
26
+ * console.log(`Sessions: ${stats.sessions.created}, Offers: ${stats.offers.committed}`);
27
+ * ```
28
+ */
29
+
30
+ import { randomUUID } from 'crypto';
31
+
32
+ /**
33
+ * Supported Scout activity event types
34
+ */
35
+ export const ScoutActivityEventTypes = {
36
+ // Lifecycle
37
+ SCOUT_CREATED: 'scout.created',
38
+ SCOUT_READY: 'scout.ready',
39
+ SCOUT_READY_FAILED: 'scout.ready_failed',
40
+ SCOUT_REGISTERED: 'scout.registered',
41
+ SCOUT_REGISTRATION_FAILED: 'scout.registration_failed',
42
+
43
+ // Intent
44
+ INTENT_CREATED: 'intent.created',
45
+ INTENT_FAILED: 'intent.failed',
46
+
47
+ // Sessions
48
+ SESSION_CREATED: 'session.created',
49
+ SESSION_REFRESHED: 'session.refreshed',
50
+ SESSION_RESUMED: 'session.resumed',
51
+ SESSION_RESUME_FAILED: 'session.resume_failed',
52
+ SESSION_CANCELLED: 'session.cancelled',
53
+ SESSION_CANCEL_FAILED: 'session.cancel_failed',
54
+ SESSION_COMMITTED: 'session.committed',
55
+ SESSION_COMMIT_FAILED: 'session.commit_failed',
56
+
57
+ // Offers
58
+ OFFERS_POLLING: 'offers.polling',
59
+ OFFERS_RECEIVED: 'offers.received',
60
+ OFFERS_EVALUATED: 'offers.evaluated',
61
+ OFFER_COMMITTED: 'offer.committed',
62
+ WAIT_FOR_OFFERS: 'offers.wait_complete',
63
+ WAIT_FOR_OFFERS_FAILED: 'offers.wait_failed',
64
+
65
+ // Transactions
66
+ TRANSACTION_CREATED: 'transaction.created',
67
+ TRANSACTION_REFRESHED: 'transaction.refreshed',
68
+ TRANSACTION_FULFILLED: 'transaction.fulfilled',
69
+ TRANSACTION_FULFILLMENT_TIMEOUT: 'transaction.fulfillment_timeout',
70
+ WAIT_FOR_FULFILLMENT: 'transaction.wait_complete',
71
+ WAIT_FOR_FULFILLMENT_FAILED: 'transaction.wait_failed',
72
+
73
+ // Healthcheck
74
+ PING_SUCCESS: 'ping.success',
75
+ PING_FAILED: 'ping.failed',
76
+
77
+ // HTTP
78
+ REQUEST_START: 'request.start',
79
+ REQUEST_COMPLETE: 'request.complete',
80
+ REQUEST_FAILED: 'request.failed',
81
+ };
82
+
83
+ /**
84
+ * Activity event — the unit of observability
85
+ */
86
+ export class ActivityEvent {
87
+ constructor(type, data = {}) {
88
+ this.id = randomUUID();
89
+ this.type = type;
90
+ this.timestamp = new Date().toISOString();
91
+ this.epochMs = Date.now();
92
+ this.durationMs = data.durationMs || null;
93
+ this.correlationId = data.correlationId || null;
94
+ this.sessionId = data.sessionId || null;
95
+ this.transactionId = data.transactionId || null;
96
+ this.success = data.success !== undefined ? data.success : null;
97
+ this.error = data.error || null;
98
+ this.metadata = data.metadata || {};
99
+ }
100
+
101
+ toJSON() {
102
+ const obj = {
103
+ id: this.id,
104
+ type: this.type,
105
+ timestamp: this.timestamp,
106
+ };
107
+ if (this.durationMs !== null) obj.durationMs = this.durationMs;
108
+ if (this.correlationId) obj.correlationId = this.correlationId;
109
+ if (this.sessionId) obj.sessionId = this.sessionId;
110
+ if (this.transactionId) obj.transactionId = this.transactionId;
111
+ if (this.success !== null) obj.success = this.success;
112
+ if (this.error) obj.error = this.error;
113
+ if (Object.keys(this.metadata).length > 0) obj.metadata = this.metadata;
114
+ return obj;
115
+ }
116
+ }
117
+
118
+ /**
119
+ * ScoutActivityLogger — records and emits structured Scout events
120
+ */
121
+ export class ScoutActivityLogger {
122
+ #events = [];
123
+ #listeners = new Map();
124
+ #maxEvents;
125
+ #logger;
126
+ #scoutContext;
127
+ #counters;
128
+
129
+ /**
130
+ * @param {Object} options
131
+ * @param {number} options.maxEvents - Max events to retain in memory (default 5000)
132
+ * @param {Object} options.logger - External logger with info/warn/error methods (optional)
133
+ * @param {Object} options.scoutContext - Scout identity info attached to all events
134
+ */
135
+ constructor(options = {}) {
136
+ this.#maxEvents = options.maxEvents || 5000;
137
+ this.#logger = options.logger || null;
138
+ this.#scoutContext = options.scoutContext || {};
139
+ this.#counters = {
140
+ sessions: { created: 0, committed: 0, cancelled: 0 },
141
+ offers: { received: 0, evaluated: 0, committed: 0 },
142
+ transactions: { created: 0, fulfilled: 0, timedOut: 0 },
143
+ ping: { total: 0, successful: 0, failed: 0 },
144
+ requests: { total: 0, succeeded: 0, failed: 0, totalDurationMs: 0 },
145
+ };
146
+ }
147
+
148
+ /**
149
+ * Record an activity event
150
+ */
151
+ record(type, data = {}) {
152
+ const event = new ActivityEvent(type, data);
153
+
154
+ // Attach scout context
155
+ event.scoutId = this.#scoutContext.scoutId || null;
156
+ event.scoutExternalId = this.#scoutContext.externalId || null;
157
+ event.scoutName = this.#scoutContext.name || null;
158
+
159
+ // Store event
160
+ this.#events.push(event);
161
+
162
+ // Prune if over limit
163
+ if (this.#events.length > this.#maxEvents) {
164
+ this.#events = this.#events.slice(-Math.floor(this.#maxEvents * 0.8));
165
+ }
166
+
167
+ // Update counters
168
+ this.#updateCounters(event);
169
+
170
+ // Emit to listeners
171
+ this.#emit(type, event);
172
+
173
+ // Forward to external logger if configured
174
+ if (this.#logger) {
175
+ const logData = { ...event.toJSON(), scout: this.#scoutContext };
176
+ if (event.error) {
177
+ this.#logger.error?.(logData, `[scout] ${type}`);
178
+ } else {
179
+ this.#logger.info?.(logData, `[scout] ${type}`);
180
+ }
181
+ }
182
+
183
+ return event;
184
+ }
185
+
186
+ /**
187
+ * Create a timer for measuring durations
188
+ * Returns a function that, when called, records the event with duration
189
+ */
190
+ startTimer(type, data = {}) {
191
+ const start = Date.now();
192
+ const correlationId = data.correlationId || randomUUID();
193
+
194
+ return (completionData = {}) => {
195
+ return this.record(type, {
196
+ ...data,
197
+ ...completionData,
198
+ correlationId,
199
+ durationMs: Date.now() - start,
200
+ });
201
+ };
202
+ }
203
+
204
+ /**
205
+ * Subscribe to events by type
206
+ * Supports exact match ('offer.committed') or prefix ('offer.*')
207
+ */
208
+ on(eventType, handler) {
209
+ if (!this.#listeners.has(eventType)) {
210
+ this.#listeners.set(eventType, []);
211
+ }
212
+ this.#listeners.get(eventType).push(handler);
213
+ return this;
214
+ }
215
+
216
+ /**
217
+ * Remove a listener
218
+ */
219
+ off(eventType, handler) {
220
+ const handlers = this.#listeners.get(eventType);
221
+ if (handlers) {
222
+ const idx = handlers.indexOf(handler);
223
+ if (idx !== -1) handlers.splice(idx, 1);
224
+ }
225
+ return this;
226
+ }
227
+
228
+ /**
229
+ * Query events with optional filters
230
+ * @param {Object} filters
231
+ * @param {string} filters.type - Event type (exact or prefix with *)
232
+ * @param {string} filters.sessionId - Filter by session
233
+ * @param {string} filters.correlationId - Filter by correlation
234
+ * @param {string} filters.since - ISO timestamp, events after this time
235
+ * @param {number} filters.limit - Max results (default 100)
236
+ */
237
+ getEvents(filters = {}) {
238
+ let results = this.#events;
239
+
240
+ if (filters.type) {
241
+ if (filters.type.endsWith('*')) {
242
+ const prefix = filters.type.slice(0, -1);
243
+ results = results.filter((e) => e.type.startsWith(prefix));
244
+ } else {
245
+ results = results.filter((e) => e.type === filters.type);
246
+ }
247
+ }
248
+
249
+ if (filters.sessionId) {
250
+ results = results.filter((e) => e.sessionId === filters.sessionId);
251
+ }
252
+
253
+ if (filters.correlationId) {
254
+ results = results.filter((e) => e.correlationId === filters.correlationId);
255
+ }
256
+
257
+ if (filters.since) {
258
+ const sinceMs = new Date(filters.since).getTime();
259
+ results = results.filter((e) => e.epochMs >= sinceMs);
260
+ }
261
+
262
+ if (filters.limit) {
263
+ results = results.slice(-filters.limit);
264
+ }
265
+
266
+ return results.map((e) => e.toJSON());
267
+ }
268
+
269
+ /**
270
+ * Get aggregate summary counters
271
+ */
272
+ getSummary() {
273
+ const avgRequestMs =
274
+ this.#counters.requests.total > 0
275
+ ? Math.round(this.#counters.requests.totalDurationMs / this.#counters.requests.total)
276
+ : 0;
277
+
278
+ return {
279
+ scout: { ...this.#scoutContext },
280
+ sessions: { ...this.#counters.sessions },
281
+ offers: { ...this.#counters.offers },
282
+ transactions: { ...this.#counters.transactions },
283
+ ping: { ...this.#counters.ping },
284
+ requests: {
285
+ total: this.#counters.requests.total,
286
+ succeeded: this.#counters.requests.succeeded,
287
+ failed: this.#counters.requests.failed,
288
+ avgDurationMs: avgRequestMs,
289
+ },
290
+ eventsRecorded: this.#events.length,
291
+ oldestEvent: this.#events[0]?.timestamp || null,
292
+ newestEvent: this.#events[this.#events.length - 1]?.timestamp || null,
293
+ };
294
+ }
295
+
296
+ /**
297
+ * Clear all events (keeps counters)
298
+ */
299
+ clearEvents() {
300
+ this.#events = [];
301
+ }
302
+
303
+ /**
304
+ * Reset everything (events and counters)
305
+ */
306
+ reset() {
307
+ this.#events = [];
308
+ this.#counters = {
309
+ sessions: { created: 0, committed: 0, cancelled: 0 },
310
+ offers: { received: 0, evaluated: 0, committed: 0 },
311
+ transactions: { created: 0, fulfilled: 0, timedOut: 0 },
312
+ ping: { total: 0, successful: 0, failed: 0 },
313
+ requests: { total: 0, succeeded: 0, failed: 0, totalDurationMs: 0 },
314
+ };
315
+ }
316
+
317
+ /**
318
+ * Update scout context (e.g., after registration when scoutId is assigned)
319
+ */
320
+ setScoutContext(context) {
321
+ this.#scoutContext = { ...this.#scoutContext, ...context };
322
+ }
323
+
324
+ /**
325
+ * Generate a new correlation ID
326
+ */
327
+ static correlationId() {
328
+ return randomUUID();
329
+ }
330
+
331
+ // --- Private methods ---
332
+
333
+ #updateCounters(event) {
334
+ switch (event.type) {
335
+ case ScoutActivityEventTypes.SESSION_CREATED:
336
+ this.#counters.sessions.created++;
337
+ break;
338
+ case ScoutActivityEventTypes.SESSION_COMMITTED:
339
+ this.#counters.sessions.committed++;
340
+ break;
341
+ case ScoutActivityEventTypes.SESSION_CANCELLED:
342
+ this.#counters.sessions.cancelled++;
343
+ break;
344
+ case ScoutActivityEventTypes.OFFERS_RECEIVED:
345
+ this.#counters.offers.received++;
346
+ break;
347
+ case ScoutActivityEventTypes.OFFERS_EVALUATED:
348
+ this.#counters.offers.evaluated++;
349
+ break;
350
+ case ScoutActivityEventTypes.OFFER_COMMITTED:
351
+ this.#counters.offers.committed++;
352
+ break;
353
+ case ScoutActivityEventTypes.TRANSACTION_CREATED:
354
+ this.#counters.transactions.created++;
355
+ break;
356
+ case ScoutActivityEventTypes.TRANSACTION_FULFILLED:
357
+ this.#counters.transactions.fulfilled++;
358
+ break;
359
+ case ScoutActivityEventTypes.TRANSACTION_FULFILLMENT_TIMEOUT:
360
+ this.#counters.transactions.timedOut++;
361
+ break;
362
+ case ScoutActivityEventTypes.PING_SUCCESS:
363
+ this.#counters.ping.total++;
364
+ this.#counters.ping.successful++;
365
+ break;
366
+ case ScoutActivityEventTypes.PING_FAILED:
367
+ this.#counters.ping.total++;
368
+ this.#counters.ping.failed++;
369
+ break;
370
+ case ScoutActivityEventTypes.REQUEST_COMPLETE:
371
+ this.#counters.requests.total++;
372
+ this.#counters.requests.succeeded++;
373
+ if (event.durationMs) this.#counters.requests.totalDurationMs += event.durationMs;
374
+ break;
375
+ case ScoutActivityEventTypes.REQUEST_FAILED:
376
+ this.#counters.requests.total++;
377
+ this.#counters.requests.failed++;
378
+ if (event.durationMs) this.#counters.requests.totalDurationMs += event.durationMs;
379
+ break;
380
+ }
381
+ }
382
+
383
+ #emit(type, event) {
384
+ // Exact match listeners
385
+ const exact = this.#listeners.get(type) || [];
386
+ for (const handler of exact) {
387
+ try {
388
+ handler(event.toJSON());
389
+ } catch (_) {
390
+ // Listener errors must not break the Scout
391
+ }
392
+ }
393
+
394
+ // Wildcard listeners (e.g., 'offer.*' matches 'offer.committed')
395
+ for (const [pattern, handlers] of this.#listeners) {
396
+ if (pattern.endsWith('*')) {
397
+ const prefix = pattern.slice(0, -1);
398
+ if (type.startsWith(prefix) && pattern !== type) {
399
+ for (const handler of handlers) {
400
+ try {
401
+ handler(event.toJSON());
402
+ } catch (_) {
403
+ // Listener errors must not break the Scout
404
+ }
405
+ }
406
+ }
407
+ }
408
+ }
409
+
410
+ // Global wildcard
411
+ const global = this.#listeners.get('*') || [];
412
+ for (const handler of global) {
413
+ try {
414
+ handler(event.toJSON());
415
+ } catch (_) {
416
+ // Listener errors must not break the Scout
417
+ }
418
+ }
419
+ }
420
+ }