@privateclaw/privateclaw-relay 0.1.6 → 0.1.8

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,952 @@
1
+ import { Redis } from "ioredis";
2
+ const ADMIN_PREFIX = "privateclaw:admin:v1";
3
+ const SESSION_INDEX_KEY = `${ADMIN_PREFIX}:sessions:index`;
4
+ const INSTANCE_INDEX_KEY = `${ADMIN_PREFIX}:instances:index`;
5
+ const GLOBAL_STATS_KEY = `${ADMIN_PREFIX}:stats:global`;
6
+ const ERROR_CODE_COUNTS_KEY = `${ADMIN_PREFIX}:stats:error-codes`;
7
+ const REQUEST_TYPE_COUNTS_KEY_PREFIX = `${ADMIN_PREFIX}:stats:request-types`;
8
+ const INSTANCE_TTL_MS = 60_000;
9
+ function sessionKey(sessionId) {
10
+ return `${ADMIN_PREFIX}:session:${sessionId}`;
11
+ }
12
+ function sessionParticipantsKey(sessionId) {
13
+ return `${ADMIN_PREFIX}:session:${sessionId}:participants`;
14
+ }
15
+ function sessionParticipantKey(sessionId, appId) {
16
+ return `${ADMIN_PREFIX}:session:${sessionId}:participant:${appId}`;
17
+ }
18
+ function instanceKey(instanceId) {
19
+ return `${ADMIN_PREFIX}:instance:${instanceId}`;
20
+ }
21
+ function requestTypeCountsKey(actor) {
22
+ return `${REQUEST_TYPE_COUNTS_KEY_PREFIX}:${actor}`;
23
+ }
24
+ function parseInteger(value) {
25
+ if (!value) {
26
+ return 0;
27
+ }
28
+ const parsed = Number.parseInt(value, 10);
29
+ return Number.isFinite(parsed) ? parsed : 0;
30
+ }
31
+ function parseOptionalInteger(value) {
32
+ if (!value) {
33
+ return undefined;
34
+ }
35
+ const parsed = Number.parseInt(value, 10);
36
+ return Number.isFinite(parsed) ? parsed : undefined;
37
+ }
38
+ function parseBooleanFlag(value) {
39
+ return value === "1";
40
+ }
41
+ function normalizeSessionStatus(session, now) {
42
+ if (typeof session.closedAt === "number") {
43
+ return "closed";
44
+ }
45
+ if (session.expiresAt <= now) {
46
+ return "expired";
47
+ }
48
+ return "active";
49
+ }
50
+ function dedupeStrings(values) {
51
+ return [...new Set(values.filter((value) => value.trim() !== ""))].sort();
52
+ }
53
+ function dedupeBindings(bindings) {
54
+ const seen = new Set();
55
+ const normalized = [];
56
+ for (const binding of bindings) {
57
+ const sessionId = binding.sessionId.trim();
58
+ const appId = binding.appId.trim();
59
+ if (!sessionId || !appId) {
60
+ continue;
61
+ }
62
+ const key = `${sessionId}:${appId}`;
63
+ if (seen.has(key)) {
64
+ continue;
65
+ }
66
+ seen.add(key);
67
+ normalized.push({ sessionId, appId });
68
+ }
69
+ normalized.sort((left, right) => left.sessionId.localeCompare(right.sessionId) || left.appId.localeCompare(right.appId));
70
+ return normalized;
71
+ }
72
+ function toSerializableMemoryUsage(usage) {
73
+ return {
74
+ rss: usage.rss,
75
+ heapTotal: usage.heapTotal,
76
+ heapUsed: usage.heapUsed,
77
+ external: usage.external,
78
+ arrayBuffers: usage.arrayBuffers,
79
+ };
80
+ }
81
+ function createEmptyRequestStats() {
82
+ return {
83
+ appRequests: 0,
84
+ providerRequests: 0,
85
+ appSuccesses: 0,
86
+ providerSuccesses: 0,
87
+ appErrors: 0,
88
+ providerErrors: 0,
89
+ appFrames: 0,
90
+ providerFrames: 0,
91
+ errorCodes: [],
92
+ requestTypes: [],
93
+ };
94
+ }
95
+ function createLiveState(instances) {
96
+ const providerIds = new Set();
97
+ const participantKeys = new Set();
98
+ const activeParticipantCountBySession = new Map();
99
+ for (const instance of instances) {
100
+ for (const providerId of instance.providerIds) {
101
+ providerIds.add(providerId);
102
+ }
103
+ for (const binding of instance.participantBindings) {
104
+ const key = `${binding.sessionId}:${binding.appId}`;
105
+ if (participantKeys.has(key)) {
106
+ continue;
107
+ }
108
+ participantKeys.add(key);
109
+ activeParticipantCountBySession.set(binding.sessionId, (activeParticipantCountBySession.get(binding.sessionId) ?? 0) + 1);
110
+ }
111
+ }
112
+ return {
113
+ providerIds,
114
+ participantKeys,
115
+ activeParticipantCountBySession,
116
+ };
117
+ }
118
+ function toSessionSummary(params) {
119
+ const { session, liveState, now } = params;
120
+ return {
121
+ sessionId: session.sessionId,
122
+ providerId: session.providerId,
123
+ groupMode: session.groupMode,
124
+ createdAt: session.createdAt,
125
+ updatedAt: session.updatedAt,
126
+ expiresAt: session.expiresAt,
127
+ ...(typeof session.closedAt === "number" ? { closedAt: session.closedAt } : {}),
128
+ ...(session.closeReason ? { closeReason: session.closeReason } : {}),
129
+ status: normalizeSessionStatus(session, now),
130
+ appMessageCount: session.appMessageCount,
131
+ providerMessageCount: session.providerMessageCount,
132
+ distinctParticipantCount: session.distinctParticipantCount,
133
+ activeParticipantCount: liveState.activeParticipantCountBySession.get(session.sessionId) ?? 0,
134
+ providerOnline: liveState.providerIds.has(session.providerId),
135
+ ...(typeof session.lastAppMessageAt === "number"
136
+ ? { lastAppMessageAt: session.lastAppMessageAt }
137
+ : {}),
138
+ ...(typeof session.lastProviderMessageAt === "number"
139
+ ? { lastProviderMessageAt: session.lastProviderMessageAt }
140
+ : {}),
141
+ };
142
+ }
143
+ function toParticipantSummary(params) {
144
+ const { sessionId, participant, liveState, now } = params;
145
+ const participantKeyValue = `${sessionId}:${participant.appId}`;
146
+ const isOnline = liveState.participantKeys.has(participantKeyValue);
147
+ const currentlyConnectedSince = participant.lastConnectedAt;
148
+ const disconnectedAt = participant.lastDisconnectedAt;
149
+ let currentConnectedMs = participant.totalConnectedMs;
150
+ if (typeof currentlyConnectedSince === "number") {
151
+ if (isOnline) {
152
+ currentConnectedMs += Math.max(now - currentlyConnectedSince, 0);
153
+ }
154
+ else if (typeof disconnectedAt !== "number" ||
155
+ disconnectedAt < currentlyConnectedSince) {
156
+ currentConnectedMs += Math.max(Math.max(participant.lastSeenAt, currentlyConnectedSince) - currentlyConnectedSince, 0);
157
+ }
158
+ }
159
+ return {
160
+ appId: participant.appId,
161
+ firstSeenAt: participant.firstSeenAt,
162
+ lastSeenAt: participant.lastSeenAt,
163
+ ...(typeof participant.lastConnectedAt === "number"
164
+ ? { lastConnectedAt: participant.lastConnectedAt }
165
+ : {}),
166
+ ...(typeof participant.lastDisconnectedAt === "number"
167
+ ? { lastDisconnectedAt: participant.lastDisconnectedAt }
168
+ : {}),
169
+ ...(participant.lastDisconnectReason
170
+ ? { lastDisconnectReason: participant.lastDisconnectReason }
171
+ : {}),
172
+ connectionCount: participant.connectionCount,
173
+ messageCount: participant.messageCount,
174
+ totalConnectedMs: participant.totalConnectedMs,
175
+ currentConnectedMs,
176
+ isOnline,
177
+ };
178
+ }
179
+ function sortParticipants(left, right) {
180
+ if (left.isOnline !== right.isOnline) {
181
+ return left.isOnline ? -1 : 1;
182
+ }
183
+ return right.lastSeenAt - left.lastSeenAt || left.appId.localeCompare(right.appId);
184
+ }
185
+ function normalizePage(page) {
186
+ return Number.isInteger(page) && page && page > 0 ? page : 1;
187
+ }
188
+ function normalizePageSize(pageSize) {
189
+ if (!Number.isInteger(pageSize) || !pageSize || pageSize <= 0) {
190
+ return 50;
191
+ }
192
+ return Math.min(pageSize, 200);
193
+ }
194
+ function matchesSessionQuery(session, query) {
195
+ if (!query) {
196
+ return true;
197
+ }
198
+ const normalizedQuery = query.trim().toLowerCase();
199
+ if (!normalizedQuery) {
200
+ return true;
201
+ }
202
+ return (session.sessionId.toLowerCase().includes(normalizedQuery) ||
203
+ session.providerId.toLowerCase().includes(normalizedQuery));
204
+ }
205
+ function collectOverviewTotals(sessions, instances) {
206
+ let activeSessions = 0;
207
+ let closedSessions = 0;
208
+ let expiredSessions = 0;
209
+ let knownParticipants = 0;
210
+ let activeParticipants = 0;
211
+ for (const session of sessions) {
212
+ if (session.status === "active") {
213
+ activeSessions += 1;
214
+ }
215
+ else if (session.status === "closed") {
216
+ closedSessions += 1;
217
+ }
218
+ else {
219
+ expiredSessions += 1;
220
+ }
221
+ knownParticipants += session.distinctParticipantCount;
222
+ activeParticipants += session.activeParticipantCount;
223
+ }
224
+ return {
225
+ sessions: sessions.length,
226
+ activeSessions,
227
+ closedSessions,
228
+ expiredSessions,
229
+ knownParticipants,
230
+ activeParticipants,
231
+ instances: instances.length,
232
+ };
233
+ }
234
+ function decodeRequestTypeCounts(actor, raw) {
235
+ const counts = new Map();
236
+ for (const [field, value] of Object.entries(raw)) {
237
+ const separatorIndex = field.lastIndexOf(":");
238
+ if (separatorIndex <= 0) {
239
+ continue;
240
+ }
241
+ const type = field.slice(0, separatorIndex);
242
+ const status = field.slice(separatorIndex + 1);
243
+ const entry = counts.get(type) ?? { actor, type, ok: 0, error: 0 };
244
+ if (status === "ok") {
245
+ entry.ok = parseInteger(value);
246
+ }
247
+ else if (status === "error") {
248
+ entry.error = parseInteger(value);
249
+ }
250
+ counts.set(type, entry);
251
+ }
252
+ return [...counts.values()].sort((left, right) => right.ok + right.error - (left.ok + left.error) || left.type.localeCompare(right.type));
253
+ }
254
+ function decodeErrorCodeCounts(raw) {
255
+ return Object.entries(raw)
256
+ .map(([code, value]) => ({ code, count: parseInteger(value) }))
257
+ .filter((entry) => entry.count > 0)
258
+ .sort((left, right) => right.count - left.count || left.code.localeCompare(right.code));
259
+ }
260
+ function decodeSessionHash(sessionId, hash) {
261
+ if (Object.keys(hash).length === 0) {
262
+ return undefined;
263
+ }
264
+ return {
265
+ sessionId,
266
+ providerId: hash.providerId ?? "unknown-provider",
267
+ groupMode: parseBooleanFlag(hash.groupMode),
268
+ createdAt: parseInteger(hash.createdAt),
269
+ updatedAt: parseInteger(hash.updatedAt),
270
+ expiresAt: parseInteger(hash.expiresAt),
271
+ ...(hash.closedAt ? { closedAt: parseInteger(hash.closedAt) } : {}),
272
+ ...(hash.closeReason ? { closeReason: hash.closeReason } : {}),
273
+ appMessageCount: parseInteger(hash.appMessageCount),
274
+ providerMessageCount: parseInteger(hash.providerMessageCount),
275
+ distinctParticipantCount: parseInteger(hash.distinctParticipantCount),
276
+ ...(hash.lastAppMessageAt
277
+ ? { lastAppMessageAt: parseInteger(hash.lastAppMessageAt) }
278
+ : {}),
279
+ ...(hash.lastProviderMessageAt
280
+ ? { lastProviderMessageAt: parseInteger(hash.lastProviderMessageAt) }
281
+ : {}),
282
+ };
283
+ }
284
+ function decodeParticipantHash(appId, hash) {
285
+ if (Object.keys(hash).length === 0) {
286
+ return undefined;
287
+ }
288
+ return {
289
+ appId,
290
+ firstSeenAt: parseInteger(hash.firstSeenAt),
291
+ lastSeenAt: parseInteger(hash.lastSeenAt),
292
+ ...(hash.lastConnectedAt
293
+ ? { lastConnectedAt: parseInteger(hash.lastConnectedAt) }
294
+ : {}),
295
+ ...(hash.lastDisconnectedAt
296
+ ? { lastDisconnectedAt: parseInteger(hash.lastDisconnectedAt) }
297
+ : {}),
298
+ ...(hash.lastDisconnectReason
299
+ ? { lastDisconnectReason: hash.lastDisconnectReason }
300
+ : {}),
301
+ connectionCount: parseInteger(hash.connectionCount),
302
+ messageCount: parseInteger(hash.messageCount),
303
+ totalConnectedMs: parseInteger(hash.totalConnectedMs),
304
+ };
305
+ }
306
+ function decodeInstanceHash(instanceId, hash) {
307
+ if (Object.keys(hash).length === 0) {
308
+ return undefined;
309
+ }
310
+ return {
311
+ instanceId,
312
+ startedAt: parseInteger(hash.startedAt),
313
+ lastSeenAt: parseInteger(hash.lastSeenAt),
314
+ activeProviders: parseInteger(hash.activeProviders),
315
+ activeApps: parseInteger(hash.activeApps),
316
+ localSessions: parseInteger(hash.localSessions),
317
+ providerIds: hash.providerIdsJson
318
+ ? JSON.parse(hash.providerIdsJson)
319
+ : [],
320
+ sessionIds: hash.sessionIdsJson
321
+ ? JSON.parse(hash.sessionIdsJson)
322
+ : [],
323
+ participantBindings: hash.participantBindingsJson
324
+ ? JSON.parse(hash.participantBindingsJson)
325
+ : [],
326
+ memoryUsage: hash.memoryUsageJson
327
+ ? JSON.parse(hash.memoryUsageJson)
328
+ : {
329
+ rss: 0,
330
+ heapTotal: 0,
331
+ heapUsed: 0,
332
+ external: 0,
333
+ arrayBuffers: 0,
334
+ },
335
+ };
336
+ }
337
+ class BaseRelayAdminMetricsStore {
338
+ buildOverview(params) {
339
+ return {
340
+ generatedAt: params.generatedAt,
341
+ totals: collectOverviewTotals(params.sessions, params.instances),
342
+ requestStats: params.requestStats,
343
+ };
344
+ }
345
+ }
346
+ export class InMemoryRelayAdminMetricsStore extends BaseRelayAdminMetricsStore {
347
+ persistent = false;
348
+ sessions = new Map();
349
+ participants = new Map();
350
+ instances = new Map();
351
+ errorCodeCounts = new Map();
352
+ requestTypeCounts = {
353
+ app: new Map(),
354
+ provider: new Map(),
355
+ };
356
+ globalStats = {
357
+ appRequests: 0,
358
+ providerRequests: 0,
359
+ appSuccesses: 0,
360
+ providerSuccesses: 0,
361
+ appErrors: 0,
362
+ providerErrors: 0,
363
+ appFrames: 0,
364
+ providerFrames: 0,
365
+ };
366
+ ensureSession(session, recordedAt) {
367
+ const existing = this.sessions.get(session.sessionId);
368
+ if (existing) {
369
+ existing.providerId = session.providerId;
370
+ existing.groupMode = session.groupMode;
371
+ existing.expiresAt = session.expiresAt;
372
+ existing.updatedAt = Math.max(existing.updatedAt, recordedAt);
373
+ return existing;
374
+ }
375
+ const created = {
376
+ sessionId: session.sessionId,
377
+ providerId: session.providerId,
378
+ groupMode: session.groupMode,
379
+ createdAt: recordedAt,
380
+ updatedAt: recordedAt,
381
+ expiresAt: session.expiresAt,
382
+ appMessageCount: 0,
383
+ providerMessageCount: 0,
384
+ distinctParticipantCount: 0,
385
+ };
386
+ this.sessions.set(session.sessionId, created);
387
+ return created;
388
+ }
389
+ ensureParticipant(sessionId, appId, recordedAt) {
390
+ const sessionParticipants = this.participants.get(sessionId) ?? new Map();
391
+ this.participants.set(sessionId, sessionParticipants);
392
+ const existing = sessionParticipants.get(appId);
393
+ if (existing) {
394
+ return { participant: existing, isNew: false };
395
+ }
396
+ const created = {
397
+ appId,
398
+ firstSeenAt: recordedAt,
399
+ lastSeenAt: recordedAt,
400
+ connectionCount: 0,
401
+ messageCount: 0,
402
+ totalConnectedMs: 0,
403
+ };
404
+ sessionParticipants.set(appId, created);
405
+ return { participant: created, isNew: true };
406
+ }
407
+ activeInstances(now) {
408
+ const cutoff = now - INSTANCE_TTL_MS;
409
+ for (const [instanceId, instance] of this.instances.entries()) {
410
+ if (instance.lastSeenAt <= cutoff) {
411
+ this.instances.delete(instanceId);
412
+ }
413
+ }
414
+ return [...this.instances.values()].sort((left, right) => right.lastSeenAt - left.lastSeenAt || left.instanceId.localeCompare(right.instanceId));
415
+ }
416
+ liveState(now) {
417
+ return createLiveState(this.activeInstances(now));
418
+ }
419
+ requestStats() {
420
+ return {
421
+ ...this.globalStats,
422
+ errorCodes: [...this.errorCodeCounts.entries()]
423
+ .map(([code, count]) => ({ code, count }))
424
+ .sort((left, right) => right.count - left.count || left.code.localeCompare(right.code)),
425
+ requestTypes: [
426
+ ...this.requestTypeCounts.app.values(),
427
+ ...this.requestTypeCounts.provider.values(),
428
+ ].sort((left, right) => right.ok + right.error - (left.ok + left.error) || left.type.localeCompare(right.type)),
429
+ };
430
+ }
431
+ async recordSessionCreated(session, recordedAt) {
432
+ this.ensureSession(session, recordedAt);
433
+ }
434
+ async recordSessionRenewed(session, recordedAt) {
435
+ this.ensureSession(session, recordedAt);
436
+ }
437
+ async recordSessionClosed(sessionId, reason, recordedAt) {
438
+ const session = this.sessions.get(sessionId);
439
+ if (!session) {
440
+ return;
441
+ }
442
+ session.closedAt = recordedAt;
443
+ session.closeReason = reason;
444
+ session.updatedAt = recordedAt;
445
+ }
446
+ async recordAppAttached(session, appId, recordedAt) {
447
+ const sessionRecord = this.ensureSession(session, recordedAt);
448
+ const { participant, isNew } = this.ensureParticipant(session.sessionId, appId, recordedAt);
449
+ participant.lastSeenAt = recordedAt;
450
+ if (typeof participant.lastConnectedAt !== "number" ||
451
+ (typeof participant.lastDisconnectedAt === "number" &&
452
+ participant.lastDisconnectedAt >= participant.lastConnectedAt)) {
453
+ participant.connectionCount += 1;
454
+ participant.lastConnectedAt = recordedAt;
455
+ }
456
+ if (isNew) {
457
+ sessionRecord.distinctParticipantCount += 1;
458
+ }
459
+ sessionRecord.updatedAt = recordedAt;
460
+ }
461
+ async recordAppDetached(sessionId, appId, recordedAt, reason) {
462
+ const participant = this.participants.get(sessionId)?.get(appId);
463
+ if (!participant) {
464
+ return;
465
+ }
466
+ if (typeof participant.lastConnectedAt === "number" &&
467
+ (typeof participant.lastDisconnectedAt !== "number" ||
468
+ participant.lastDisconnectedAt < participant.lastConnectedAt)) {
469
+ participant.totalConnectedMs += Math.max(recordedAt - participant.lastConnectedAt, 0);
470
+ }
471
+ participant.lastDisconnectedAt = recordedAt;
472
+ participant.lastDisconnectReason = reason;
473
+ participant.lastSeenAt = recordedAt;
474
+ const session = this.sessions.get(sessionId);
475
+ if (session) {
476
+ session.updatedAt = recordedAt;
477
+ }
478
+ }
479
+ async recordAppFrame(sessionId, appId, recordedAt) {
480
+ const session = this.sessions.get(sessionId);
481
+ if (session) {
482
+ session.appMessageCount += 1;
483
+ session.lastAppMessageAt = recordedAt;
484
+ session.updatedAt = recordedAt;
485
+ }
486
+ const participant = this.participants.get(sessionId)?.get(appId);
487
+ if (participant) {
488
+ participant.messageCount += 1;
489
+ participant.lastSeenAt = recordedAt;
490
+ }
491
+ this.globalStats.appFrames += 1;
492
+ }
493
+ async recordProviderFrame(sessionId, recordedAt) {
494
+ const session = this.sessions.get(sessionId);
495
+ if (!session) {
496
+ return;
497
+ }
498
+ session.providerMessageCount += 1;
499
+ session.lastProviderMessageAt = recordedAt;
500
+ session.updatedAt = recordedAt;
501
+ this.globalStats.providerFrames += 1;
502
+ }
503
+ async recordRequest(params) {
504
+ const actorStatsField = params.actor === "app" ? "app" : "provider";
505
+ this.globalStats[`${actorStatsField}Requests`] += 1;
506
+ if (params.ok) {
507
+ this.globalStats[`${actorStatsField}Successes`] += 1;
508
+ }
509
+ else {
510
+ this.globalStats[`${actorStatsField}Errors`] += 1;
511
+ if (params.errorCode) {
512
+ this.errorCodeCounts.set(params.errorCode, (this.errorCodeCounts.get(params.errorCode) ?? 0) + 1);
513
+ }
514
+ }
515
+ const requestTypeCounts = this.requestTypeCounts[params.actor];
516
+ const entry = requestTypeCounts.get(params.type) ?? {
517
+ actor: params.actor,
518
+ type: params.type,
519
+ ok: 0,
520
+ error: 0,
521
+ };
522
+ if (params.ok) {
523
+ entry.ok += 1;
524
+ }
525
+ else {
526
+ entry.error += 1;
527
+ }
528
+ requestTypeCounts.set(params.type, entry);
529
+ }
530
+ async recordInstanceHeartbeat(heartbeat) {
531
+ for (const binding of heartbeat.snapshot.participantBindings) {
532
+ const participant = this.participants.get(binding.sessionId)?.get(binding.appId);
533
+ if (participant) {
534
+ participant.lastSeenAt = heartbeat.recordedAt;
535
+ }
536
+ }
537
+ this.instances.set(heartbeat.instanceId, {
538
+ instanceId: heartbeat.instanceId,
539
+ startedAt: heartbeat.startedAt,
540
+ lastSeenAt: heartbeat.recordedAt,
541
+ activeProviders: heartbeat.snapshot.activeProviders,
542
+ activeApps: heartbeat.snapshot.activeApps,
543
+ localSessions: heartbeat.snapshot.localSessions,
544
+ providerIds: dedupeStrings(heartbeat.snapshot.providerIds),
545
+ sessionIds: dedupeStrings(heartbeat.snapshot.sessionIds),
546
+ participantBindings: dedupeBindings(heartbeat.snapshot.participantBindings),
547
+ memoryUsage: toSerializableMemoryUsage(heartbeat.snapshot.memoryUsage),
548
+ });
549
+ }
550
+ async unregisterInstance(instanceId) {
551
+ this.instances.delete(instanceId);
552
+ }
553
+ async listSessions(options = {}) {
554
+ const now = options.now ?? Date.now();
555
+ const liveState = this.liveState(now);
556
+ const page = normalizePage(options.page);
557
+ const pageSize = normalizePageSize(options.pageSize);
558
+ const allSessions = [...this.sessions.values()]
559
+ .map((session) => toSessionSummary({ session, liveState, now }))
560
+ .filter((session) => options.status && options.status !== "all"
561
+ ? session.status === options.status
562
+ : true)
563
+ .filter((session) => matchesSessionQuery(session, options.query))
564
+ .sort((left, right) => right.createdAt - left.createdAt || left.sessionId.localeCompare(right.sessionId));
565
+ const startIndex = (page - 1) * pageSize;
566
+ return {
567
+ total: allSessions.length,
568
+ page,
569
+ pageSize,
570
+ sessions: allSessions.slice(startIndex, startIndex + pageSize),
571
+ };
572
+ }
573
+ async getSessionDetail(sessionId, now = Date.now()) {
574
+ const session = this.sessions.get(sessionId);
575
+ if (!session) {
576
+ return undefined;
577
+ }
578
+ const liveState = this.liveState(now);
579
+ const sessionSummary = toSessionSummary({ session, liveState, now });
580
+ const participants = [
581
+ ...(this.participants.get(sessionId)?.values() ?? []),
582
+ ]
583
+ .map((participant) => toParticipantSummary({
584
+ sessionId,
585
+ participant,
586
+ liveState,
587
+ now,
588
+ }))
589
+ .sort(sortParticipants);
590
+ return {
591
+ session: sessionSummary,
592
+ participants,
593
+ };
594
+ }
595
+ async getOverview(now = Date.now()) {
596
+ const instances = this.activeInstances(now);
597
+ const liveState = createLiveState(instances);
598
+ const sessions = [...this.sessions.values()]
599
+ .map((session) => toSessionSummary({ session, liveState, now }))
600
+ .sort((left, right) => right.createdAt - left.createdAt || left.sessionId.localeCompare(right.sessionId));
601
+ return this.buildOverview({
602
+ sessions,
603
+ instances,
604
+ requestStats: this.requestStats(),
605
+ generatedAt: now,
606
+ });
607
+ }
608
+ async listInstances(now = Date.now()) {
609
+ return this.activeInstances(now);
610
+ }
611
+ async close() {
612
+ this.sessions.clear();
613
+ this.participants.clear();
614
+ this.instances.clear();
615
+ this.errorCodeCounts.clear();
616
+ this.requestTypeCounts.app.clear();
617
+ this.requestTypeCounts.provider.clear();
618
+ }
619
+ }
620
+ export class RedisRelayAdminMetricsStore extends BaseRelayAdminMetricsStore {
621
+ persistent = true;
622
+ redis;
623
+ constructor(redisUrl) {
624
+ super();
625
+ this.redis = new Redis(redisUrl, {
626
+ lazyConnect: false,
627
+ maxRetriesPerRequest: 1,
628
+ });
629
+ }
630
+ async ensureSession(session, recordedAt) {
631
+ const key = sessionKey(session.sessionId);
632
+ const existingCreatedAt = await this.redis.hget(key, "createdAt");
633
+ await this.redis
634
+ .multi()
635
+ .hset(key, {
636
+ sessionId: session.sessionId,
637
+ providerId: session.providerId,
638
+ groupMode: session.groupMode ? "1" : "0",
639
+ createdAt: existingCreatedAt ?? String(recordedAt),
640
+ updatedAt: String(recordedAt),
641
+ expiresAt: String(session.expiresAt),
642
+ })
643
+ .hsetnx(key, "appMessageCount", "0")
644
+ .hsetnx(key, "providerMessageCount", "0")
645
+ .hsetnx(key, "distinctParticipantCount", "0")
646
+ .zadd(SESSION_INDEX_KEY, String(existingCreatedAt ?? recordedAt), session.sessionId)
647
+ .exec();
648
+ }
649
+ async loadSession(sessionId) {
650
+ return decodeSessionHash(sessionId, await this.redis.hgetall(sessionKey(sessionId)));
651
+ }
652
+ async loadParticipant(sessionId, appId) {
653
+ return decodeParticipantHash(appId, await this.redis.hgetall(sessionParticipantKey(sessionId, appId)));
654
+ }
655
+ async pruneStaleInstances(now) {
656
+ const cutoff = now - INSTANCE_TTL_MS;
657
+ const staleIds = await this.redis.zrangebyscore(INSTANCE_INDEX_KEY, 0, cutoff);
658
+ if (staleIds.length === 0) {
659
+ return;
660
+ }
661
+ const pipeline = this.redis.multi();
662
+ pipeline.zrem(INSTANCE_INDEX_KEY, ...staleIds);
663
+ for (const staleId of staleIds) {
664
+ pipeline.del(instanceKey(staleId));
665
+ }
666
+ await pipeline.exec();
667
+ }
668
+ async loadLiveState(now) {
669
+ return createLiveState(await this.listInstances(now));
670
+ }
671
+ async loadRequestStats() {
672
+ const [globalRaw, errorCodeRaw, appRequestTypesRaw, providerRequestTypesRaw] = await Promise.all([
673
+ this.redis.hgetall(GLOBAL_STATS_KEY),
674
+ this.redis.hgetall(ERROR_CODE_COUNTS_KEY),
675
+ this.redis.hgetall(requestTypeCountsKey("app")),
676
+ this.redis.hgetall(requestTypeCountsKey("provider")),
677
+ ]);
678
+ return {
679
+ appRequests: parseInteger(globalRaw.appRequests),
680
+ providerRequests: parseInteger(globalRaw.providerRequests),
681
+ appSuccesses: parseInteger(globalRaw.appSuccesses),
682
+ providerSuccesses: parseInteger(globalRaw.providerSuccesses),
683
+ appErrors: parseInteger(globalRaw.appErrors),
684
+ providerErrors: parseInteger(globalRaw.providerErrors),
685
+ appFrames: parseInteger(globalRaw.appFrames),
686
+ providerFrames: parseInteger(globalRaw.providerFrames),
687
+ errorCodes: decodeErrorCodeCounts(errorCodeRaw),
688
+ requestTypes: [
689
+ ...decodeRequestTypeCounts("app", appRequestTypesRaw),
690
+ ...decodeRequestTypeCounts("provider", providerRequestTypesRaw),
691
+ ],
692
+ };
693
+ }
694
+ async recordSessionCreated(session, recordedAt) {
695
+ await this.ensureSession(session, recordedAt);
696
+ }
697
+ async recordSessionRenewed(session, recordedAt) {
698
+ await this.ensureSession(session, recordedAt);
699
+ }
700
+ async recordSessionClosed(sessionId, reason, recordedAt) {
701
+ await this.redis.hset(sessionKey(sessionId), {
702
+ updatedAt: String(recordedAt),
703
+ closedAt: String(recordedAt),
704
+ closeReason: reason,
705
+ });
706
+ }
707
+ async recordAppAttached(session, appId, recordedAt) {
708
+ await this.ensureSession(session, recordedAt);
709
+ const participantKey = sessionParticipantKey(session.sessionId, appId);
710
+ const participant = await this.loadParticipant(session.sessionId, appId);
711
+ const isNewParticipant = (await this.redis.sadd(sessionParticipantsKey(session.sessionId), appId)) === 1;
712
+ const pipeline = this.redis.multi();
713
+ pipeline.hset(participantKey, {
714
+ appId,
715
+ firstSeenAt: participant?.firstSeenAt !== undefined
716
+ ? String(participant.firstSeenAt)
717
+ : String(recordedAt),
718
+ lastSeenAt: String(recordedAt),
719
+ connectionCount: typeof participant?.lastConnectedAt === "number" &&
720
+ (typeof participant.lastDisconnectedAt !== "number" ||
721
+ participant.lastDisconnectedAt < participant.lastConnectedAt)
722
+ ? String(participant.connectionCount)
723
+ : String((participant?.connectionCount ?? 0) + 1),
724
+ lastConnectedAt: typeof participant?.lastConnectedAt === "number" &&
725
+ (typeof participant.lastDisconnectedAt !== "number" ||
726
+ participant.lastDisconnectedAt < participant.lastConnectedAt)
727
+ ? String(participant.lastConnectedAt)
728
+ : String(recordedAt),
729
+ totalConnectedMs: String(participant?.totalConnectedMs ?? 0),
730
+ messageCount: String(participant?.messageCount ?? 0),
731
+ ...(participant?.lastDisconnectedAt !== undefined
732
+ ? { lastDisconnectedAt: String(participant.lastDisconnectedAt) }
733
+ : {}),
734
+ ...(participant?.lastDisconnectReason
735
+ ? { lastDisconnectReason: participant.lastDisconnectReason }
736
+ : {}),
737
+ });
738
+ pipeline.hset(sessionKey(session.sessionId), { updatedAt: String(recordedAt) });
739
+ if (isNewParticipant) {
740
+ pipeline.hincrby(sessionKey(session.sessionId), "distinctParticipantCount", 1);
741
+ }
742
+ await pipeline.exec();
743
+ }
744
+ async recordAppDetached(sessionId, appId, recordedAt, reason) {
745
+ const participant = await this.loadParticipant(sessionId, appId);
746
+ if (!participant) {
747
+ return;
748
+ }
749
+ let totalConnectedMs = participant.totalConnectedMs;
750
+ if (typeof participant.lastConnectedAt === "number" &&
751
+ (typeof participant.lastDisconnectedAt !== "number" ||
752
+ participant.lastDisconnectedAt < participant.lastConnectedAt)) {
753
+ totalConnectedMs += Math.max(recordedAt - participant.lastConnectedAt, 0);
754
+ }
755
+ await this.redis
756
+ .multi()
757
+ .hset(sessionParticipantKey(sessionId, appId), {
758
+ lastSeenAt: String(recordedAt),
759
+ lastDisconnectedAt: String(recordedAt),
760
+ lastDisconnectReason: reason,
761
+ totalConnectedMs: String(totalConnectedMs),
762
+ })
763
+ .hset(sessionKey(sessionId), { updatedAt: String(recordedAt) })
764
+ .exec();
765
+ }
766
+ async recordAppFrame(sessionId, appId, recordedAt) {
767
+ await this.redis
768
+ .multi()
769
+ .hincrby(sessionKey(sessionId), "appMessageCount", 1)
770
+ .hset(sessionKey(sessionId), {
771
+ updatedAt: String(recordedAt),
772
+ lastAppMessageAt: String(recordedAt),
773
+ })
774
+ .hincrby(sessionParticipantKey(sessionId, appId), "messageCount", 1)
775
+ .hset(sessionParticipantKey(sessionId, appId), {
776
+ appId,
777
+ lastSeenAt: String(recordedAt),
778
+ })
779
+ .hincrby(GLOBAL_STATS_KEY, "appFrames", 1)
780
+ .exec();
781
+ }
782
+ async recordProviderFrame(sessionId, recordedAt) {
783
+ await this.redis
784
+ .multi()
785
+ .hincrby(sessionKey(sessionId), "providerMessageCount", 1)
786
+ .hset(sessionKey(sessionId), {
787
+ updatedAt: String(recordedAt),
788
+ lastProviderMessageAt: String(recordedAt),
789
+ })
790
+ .hincrby(GLOBAL_STATS_KEY, "providerFrames", 1)
791
+ .exec();
792
+ }
793
+ async recordRequest(params) {
794
+ const pipeline = this.redis.multi();
795
+ pipeline.hincrby(GLOBAL_STATS_KEY, params.actor === "app" ? "appRequests" : "providerRequests", 1);
796
+ pipeline.hincrby(GLOBAL_STATS_KEY, params.actor === "app"
797
+ ? params.ok
798
+ ? "appSuccesses"
799
+ : "appErrors"
800
+ : params.ok
801
+ ? "providerSuccesses"
802
+ : "providerErrors", 1);
803
+ pipeline.hincrby(requestTypeCountsKey(params.actor), `${params.type}:${params.ok ? "ok" : "error"}`, 1);
804
+ if (!params.ok && params.errorCode) {
805
+ pipeline.hincrby(ERROR_CODE_COUNTS_KEY, params.errorCode, 1);
806
+ }
807
+ await pipeline.exec();
808
+ }
809
+ async recordInstanceHeartbeat(heartbeat) {
810
+ const key = instanceKey(heartbeat.instanceId);
811
+ const providerIds = dedupeStrings(heartbeat.snapshot.providerIds);
812
+ const sessionIds = dedupeStrings(heartbeat.snapshot.sessionIds);
813
+ const participantBindings = dedupeBindings(heartbeat.snapshot.participantBindings);
814
+ const pipeline = this.redis.multi();
815
+ pipeline.hset(key, {
816
+ instanceId: heartbeat.instanceId,
817
+ startedAt: String(heartbeat.startedAt),
818
+ lastSeenAt: String(heartbeat.recordedAt),
819
+ activeProviders: String(heartbeat.snapshot.activeProviders),
820
+ activeApps: String(heartbeat.snapshot.activeApps),
821
+ localSessions: String(heartbeat.snapshot.localSessions),
822
+ providerIdsJson: JSON.stringify(providerIds),
823
+ sessionIdsJson: JSON.stringify(sessionIds),
824
+ participantBindingsJson: JSON.stringify(participantBindings),
825
+ memoryUsageJson: JSON.stringify(toSerializableMemoryUsage(heartbeat.snapshot.memoryUsage)),
826
+ });
827
+ pipeline.zadd(INSTANCE_INDEX_KEY, String(heartbeat.recordedAt), heartbeat.instanceId);
828
+ pipeline.pexpire(key, INSTANCE_TTL_MS * 2);
829
+ for (const binding of participantBindings) {
830
+ pipeline.hset(sessionParticipantKey(binding.sessionId, binding.appId), {
831
+ appId: binding.appId,
832
+ lastSeenAt: String(heartbeat.recordedAt),
833
+ });
834
+ }
835
+ await pipeline.exec();
836
+ }
837
+ async unregisterInstance(instanceId) {
838
+ await this.redis.multi().zrem(INSTANCE_INDEX_KEY, instanceId).del(instanceKey(instanceId)).exec();
839
+ }
840
+ async listSessions(options = {}) {
841
+ const now = options.now ?? Date.now();
842
+ await this.pruneStaleInstances(now);
843
+ const sessionIds = await this.redis.zrevrange(SESSION_INDEX_KEY, 0, -1);
844
+ const page = normalizePage(options.page);
845
+ const pageSize = normalizePageSize(options.pageSize);
846
+ if (sessionIds.length === 0) {
847
+ return {
848
+ total: 0,
849
+ page,
850
+ pageSize,
851
+ sessions: [],
852
+ };
853
+ }
854
+ const pipeline = this.redis.multi();
855
+ for (const sessionId of sessionIds) {
856
+ pipeline.hgetall(sessionKey(sessionId));
857
+ }
858
+ const results = await pipeline.exec();
859
+ const liveState = await this.loadLiveState(now);
860
+ const sessions = sessionIds
861
+ .map((sessionId, index) => decodeSessionHash(sessionId, results?.[index]?.[1] ?? {}))
862
+ .filter((session) => !!session)
863
+ .map((session) => toSessionSummary({ session, liveState, now }))
864
+ .filter((session) => options.status && options.status !== "all"
865
+ ? session.status === options.status
866
+ : true)
867
+ .filter((session) => matchesSessionQuery(session, options.query));
868
+ const startIndex = (page - 1) * pageSize;
869
+ return {
870
+ total: sessions.length,
871
+ page,
872
+ pageSize,
873
+ sessions: sessions.slice(startIndex, startIndex + pageSize),
874
+ };
875
+ }
876
+ async getSessionDetail(sessionId, now = Date.now()) {
877
+ await this.pruneStaleInstances(now);
878
+ const session = await this.loadSession(sessionId);
879
+ if (!session) {
880
+ return undefined;
881
+ }
882
+ const participantIds = await this.redis.smembers(sessionParticipantsKey(sessionId));
883
+ const pipeline = this.redis.multi();
884
+ for (const appId of participantIds) {
885
+ pipeline.hgetall(sessionParticipantKey(sessionId, appId));
886
+ }
887
+ const participantHashes = await pipeline.exec();
888
+ const liveState = await this.loadLiveState(now);
889
+ const participants = participantIds
890
+ .map((appId, index) => decodeParticipantHash(appId, participantHashes?.[index]?.[1] ?? {}))
891
+ .filter((participant) => !!participant)
892
+ .map((participant) => toParticipantSummary({ sessionId, participant, liveState, now }))
893
+ .sort(sortParticipants);
894
+ return {
895
+ session: toSessionSummary({ session, liveState, now }),
896
+ participants,
897
+ };
898
+ }
899
+ async getOverview(now = Date.now()) {
900
+ await this.pruneStaleInstances(now);
901
+ const [sessionIds, instances, requestStats] = await Promise.all([
902
+ this.redis.zrevrange(SESSION_INDEX_KEY, 0, -1),
903
+ this.listInstances(now),
904
+ this.loadRequestStats(),
905
+ ]);
906
+ let sessions = [];
907
+ if (sessionIds.length > 0) {
908
+ const pipeline = this.redis.multi();
909
+ for (const sessionId of sessionIds) {
910
+ pipeline.hgetall(sessionKey(sessionId));
911
+ }
912
+ const results = await pipeline.exec();
913
+ const liveState = createLiveState(instances);
914
+ sessions = sessionIds
915
+ .map((sessionId, index) => decodeSessionHash(sessionId, results?.[index]?.[1] ?? {}))
916
+ .filter((session) => !!session)
917
+ .map((session) => toSessionSummary({ session, liveState, now }));
918
+ }
919
+ return this.buildOverview({
920
+ sessions,
921
+ instances,
922
+ requestStats,
923
+ generatedAt: now,
924
+ });
925
+ }
926
+ async listInstances(now = Date.now()) {
927
+ await this.pruneStaleInstances(now);
928
+ const instanceIds = await this.redis.zrevrange(INSTANCE_INDEX_KEY, 0, -1);
929
+ if (instanceIds.length === 0) {
930
+ return [];
931
+ }
932
+ const pipeline = this.redis.multi();
933
+ for (const instanceId of instanceIds) {
934
+ pipeline.hgetall(instanceKey(instanceId));
935
+ }
936
+ const results = await pipeline.exec();
937
+ return instanceIds
938
+ .map((instanceId, index) => decodeInstanceHash(instanceId, results?.[index]?.[1] ?? {}))
939
+ .filter((instance) => !!instance)
940
+ .sort((left, right) => right.lastSeenAt - left.lastSeenAt || left.instanceId.localeCompare(right.instanceId));
941
+ }
942
+ async close() {
943
+ await this.redis.quit();
944
+ }
945
+ }
946
+ export function createRelayAdminMetricsStore(params) {
947
+ if (params.redisUrl) {
948
+ return new RedisRelayAdminMetricsStore(params.redisUrl);
949
+ }
950
+ return new InMemoryRelayAdminMetricsStore();
951
+ }
952
+ //# sourceMappingURL=admin-metrics-store.js.map