@xenon-device-management/xenon 2.16.0 → 2.17.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.
Files changed (114) hide show
  1. package/README.md +2 -2
  2. package/lib/package.json +1 -1
  3. package/lib/public/assets/{AnnotationOverlay-2W4JvEyZ.js → AnnotationOverlay-CjmMCS0S.js} +1 -1
  4. package/lib/public/assets/{ApiKeyGate-GmckX_7r.js → ApiKeyGate-B4Ob0v3C.js} +1 -1
  5. package/lib/public/assets/{DeviceMosaicView-DqODzkyo.js → DeviceMosaicView-DTVOB_NO.js} +1 -1
  6. package/lib/public/assets/{EmptyState-7hETKEWb.js → EmptyState-DcEG-oAi.js} +1 -1
  7. package/lib/public/assets/{FieldGroup-GOgssFEP.js → FieldGroup-CtnCDshE.js} +1 -1
  8. package/lib/public/assets/{FilterMenu-Yvf6RGMd.js → FilterMenu-BK0dE9cc.js} +1 -1
  9. package/lib/public/assets/{Menu-KrPnfdsf.js → Menu-eKsmLpKS.js} +1 -1
  10. package/lib/public/assets/{Modal-C6WjZFeI.js → Modal-DPwop3Lz.js} +1 -1
  11. package/lib/public/assets/{RecordingPage-CSRIhZEh.js → RecordingPage-CfgSkZkB.js} +1 -1
  12. package/lib/public/assets/{RecordingsPage-CB8ST3nE.js → RecordingsPage-Bzl9pTUY.js} +1 -1
  13. package/lib/public/assets/{SegmentedControl-C9o90KId.js → SegmentedControl-B_TZTrPm.js} +1 -1
  14. package/lib/public/assets/{SettingCard-CPm3w9l2.js → SettingCard-DlmvZJPM.js} +1 -1
  15. package/lib/public/assets/{Table-DwIgERRt.js → Table-BigPWKXi.js} +1 -1
  16. package/lib/public/assets/{activity-DrLbHifn.js → activity-CvuZdQYu.js} +1 -1
  17. package/lib/public/assets/{ai-settings-DU_Ls06P.js → ai-settings-lI2UJ2n7.js} +1 -1
  18. package/lib/public/assets/{api-keys-CWYdEzJ_.js → api-keys-CPgf9XJM.js} +1 -1
  19. package/lib/public/assets/{apps-BqNVdln7.js → apps-DIzOvtUp.js} +1 -1
  20. package/lib/public/assets/{arrow-left-BtB0_Jg_.js → arrow-left-DiP5ywBT.js} +1 -1
  21. package/lib/public/assets/{arrow-right-C8NhdtyN.js → arrow-right-DtW_8hm5.js} +1 -1
  22. package/lib/public/assets/{arrow-up-right-gMg0ffvv.js → arrow-up-right-C89FbNU0.js} +1 -1
  23. package/lib/public/assets/{auth-shell-CiYZ3N4M.js → auth-shell-B7sUDb--.js} +2 -2
  24. package/lib/public/assets/{builds-page-EaaqDmLN.js → builds-page-BBhvrMfA.js} +1 -1
  25. package/lib/public/assets/{button-DDau4Axv.js → button-DugsdUw1.js} +1 -1
  26. package/lib/public/assets/{calendar-DQ83B1ed.js → calendar-grKd08ZH.js} +1 -1
  27. package/lib/public/assets/{check-K1ovTQam.js → check-ORjyNfnX.js} +1 -1
  28. package/lib/public/assets/{chevron-right-Biq00pMt.js → chevron-right-Ei2FxUXq.js} +1 -1
  29. package/lib/public/assets/{circle-check-CBv88cH7.js → circle-check-Day0HjW4.js} +1 -1
  30. package/lib/public/assets/{circle-x-Bmltailn.js → circle-x-CHxSRLHp.js} +1 -1
  31. package/lib/public/assets/{clock-BPrld-3h.js → clock-BDHewWTx.js} +1 -1
  32. package/lib/public/assets/{copy-t0QB14JI.js → copy-CTwT4I3D.js} +1 -1
  33. package/lib/public/assets/{device-explorer-BgGZ8AvV.js → device-explorer-DlhrKJ-g.js} +1 -1
  34. package/lib/public/assets/{download-BroHxNOh.js → download-D3bjz_vr.js} +1 -1
  35. package/lib/public/assets/{forgot-password-Bjmf49i6.js → forgot-password-BNBLi023.js} +1 -1
  36. package/lib/public/assets/{h264Stream-DeD__6wa.js → h264Stream-C51Au2i_.js} +1 -1
  37. package/lib/public/assets/{index-B0gQkEg0.js → index-Dk5J6HB1.js} +2 -2
  38. package/lib/public/assets/{index-B0JeuZbg.js → index-DncSz5LG.js} +1 -1
  39. package/lib/public/assets/{index-72oF89LJ.js → index-Z7_BY3pJ.js} +1 -1
  40. package/lib/public/assets/{input-DIc4MSC7.js → input-bTXM4l09.js} +1 -1
  41. package/lib/public/assets/{line-chart-BcWm2vW8.js → line-chart-B3YmVYyr.js} +1 -1
  42. package/lib/public/assets/{list-checks-B06IJJad.js → list-checks-BClKEj1-.js} +1 -1
  43. package/lib/public/assets/{lock-pVLvnN30.js → lock-CZsenX3D.js} +1 -1
  44. package/lib/public/assets/{login-CN77vy--.js → login-B6A6tOp9.js} +1 -1
  45. package/lib/public/assets/{maintenance-settings-CSdzQSkY.js → maintenance-settings-CmCJGy7O.js} +1 -1
  46. package/lib/public/assets/{monitor-BtrN5IkI.js → monitor-BdTNEglU.js} +1 -1
  47. package/lib/public/assets/{mouse-pointer-2-CNsQfo6w.js → mouse-pointer-2-BM8CaLtt.js} +1 -1
  48. package/lib/public/assets/{network-BSfA8ZxG.js → network-BS6vUc1x.js} +1 -1
  49. package/lib/public/assets/{overview-BfTebCK5.js → overview-Yfk_Y7g3.js} +1 -1
  50. package/lib/public/assets/{page-header-D8KUX6Qg.js → page-header-DhXCwpml.js} +1 -1
  51. package/lib/public/assets/{play-Cvc8xzic.js → play-D08gP1td.js} +1 -1
  52. package/lib/public/assets/{plus-C9Zj84UF.js → plus-BKboorfa.js} +1 -1
  53. package/lib/public/assets/{profile-page-Bi27STgs.js → profile-page-Cd9NodrA.js} +1 -1
  54. package/lib/public/assets/{reset-password-CPsOuwSu.js → reset-password-acLDyyDW.js} +1 -1
  55. package/lib/public/assets/{runbook-page-DEQ-DBOX.js → runbook-page-CWLkC1Fd.js} +1 -1
  56. package/lib/public/assets/{select-BWpnhWXj.js → select-DZoRKNti.js} +1 -1
  57. package/lib/public/assets/{selector-detail-redirect-BefSCSpe.js → selector-detail-redirect-DS4qrt84.js} +1 -1
  58. package/lib/public/assets/{selector-health-page-MSk96Zz7.js → selector-health-page-TpmN61G3.js} +1 -1
  59. package/lib/public/assets/{session-detail-page-DPyBNef_.js → session-detail-page-C0wYaXqy.js} +1 -1
  60. package/lib/public/assets/{settings-V6x-cXwy.js → settings-CxPGEY2j.js} +1 -1
  61. package/lib/public/assets/{stat-tile-Duex-GZD.js → stat-tile-DUpyYgl-.js} +1 -1
  62. package/lib/public/assets/{tablet-BBcoqBmL.js → tablet-SA02BJNQ.js} +1 -1
  63. package/lib/public/assets/{teams-ts8p5rEh.js → teams-jamD0Kxr.js} +1 -1
  64. package/lib/public/assets/{trash-2-CZ2ew6AZ.js → trash-2-DO8fv6w4.js} +1 -1
  65. package/lib/public/assets/{upload-Dv99y_le.js → upload-CaiGCStb.js} +1 -1
  66. package/lib/public/assets/{use-builds-data-DrYC8JCF.js → use-builds-data-jS-fcvVf.js} +1 -1
  67. package/lib/public/assets/{use-password-reset-mode-C7QkBNkP.js → use-password-reset-mode-BzoenegM.js} +1 -1
  68. package/lib/public/assets/{users-gJ5Y01yo.js → users-Bnw7Vs_B.js} +1 -1
  69. package/lib/public/assets/{users-D7tKMESH.js → users-DHvDrriG.js} +1 -1
  70. package/lib/public/assets/{video-off-CqZVuZGs.js → video-off-FEvy0w5U.js} +1 -1
  71. package/lib/public/assets/{webhook-settings-DI5DSW99.js → webhook-settings-CBVB5lxi.js} +1 -1
  72. package/lib/public/assets/{zap-ClcHFxfo.js → zap-BMrTJO6M.js} +1 -1
  73. package/lib/public/index.html +1 -1
  74. package/lib/schema.json +1 -1
  75. package/lib/src/app/index.js +2 -0
  76. package/lib/src/app/openapi/grid.yaml +102 -0
  77. package/lib/src/dashboard/event-manager.js +5 -8
  78. package/lib/src/device-utils.js +11 -2
  79. package/lib/src/gateway/nodeAsk.js +57 -0
  80. package/lib/src/gateway/nodeSessionDeviceLogs.js +35 -0
  81. package/lib/src/plugin.js +4 -1
  82. package/lib/src/scripts/initialize-database.js +0 -6
  83. package/lib/src/scripts/run-migrations.js +123 -880
  84. package/lib/src/services/SessionLifecycleService.js +20 -5
  85. package/lib/src/services/interceptor/CertManager.js +50 -16
  86. package/lib/src/services/logcat/NodeDeviceLogStore.js +99 -0
  87. package/lib/src/services/logcat/NodeDeviceLogsCollector.js +164 -0
  88. package/lib/src/services/logcat/SessionDeviceLogs.js +199 -44
  89. package/lib/src/services/logcat/nodeDeviceLogs.js +91 -0
  90. package/lib/src/services/metrics/nodeMetrics.js +20 -49
  91. package/lib/src/sessions/RemoteSession.js +11 -2
  92. package/lib/src/sessions/SessionManager.js +28 -1
  93. package/lib/test/helpers/fake-logcat.js +80 -0
  94. package/lib/test/helpers/hub-node-pair.js +227 -0
  95. package/lib/test/unit/dashboard-events-device-logs.spec.js +15 -0
  96. package/lib/test/unit/hub-node-iphone-readiness.spec.js +134 -0
  97. package/lib/test/unit/hub-node-session-device-logs.spec.js +194 -0
  98. package/lib/test/unit/hub-node-session-metrics.spec.js +24 -192
  99. package/lib/test/unit/hub-remote-session-heartbeat.spec.js +8 -1
  100. package/lib/test/unit/interceptor/CertManager.spec.js +89 -0
  101. package/lib/test/unit/node-device-log-store.spec.js +102 -0
  102. package/lib/test/unit/node-device-logs-collector.spec.js +343 -0
  103. package/lib/test/unit/node-gateway-create-appium-server.spec.js +8 -1
  104. package/lib/test/unit/node-hub-session-owner.spec.js +8 -1
  105. package/lib/test/unit/node-session-device-logs-route.spec.js +103 -0
  106. package/lib/test/unit/run-migrations-database.spec.js +57 -424
  107. package/lib/test/unit/run-migrations.test.js +74 -674
  108. package/lib/test/unit/session-device-logs-hub-node.spec.js +340 -0
  109. package/lib/test/unit/session-device-logs-lifecycle.spec.js +137 -0
  110. package/lib/test/unit/session-device-logs.spec.js +32 -94
  111. package/lib/test/unit/session-log-indexes.spec.js +15 -0
  112. package/lib/tsconfig.tsbuildinfo +1 -1
  113. package/package.json +1 -1
  114. package/schema.json +1 -1
@@ -26,6 +26,9 @@ const PluginContext_1 = require("../../PluginContext");
26
26
  const localDeviceHosts_1 = require("../../device-managers/localDeviceHosts");
27
27
  const LogcatStreamService_1 = require("../../device-managers/android/LogcatStreamService");
28
28
  const adbForPhone_1 = require("../network/adbForPhone");
29
+ const NodeDeviceLogStore_1 = require("./NodeDeviceLogStore");
30
+ const NodeDeviceLogsCollector_1 = require("./NodeDeviceLogsCollector");
31
+ const nodeDeviceLogs_1 = require("./nodeDeviceLogs");
29
32
  const deviceLogBook_1 = require("./deviceLogBook");
30
33
  const iosDriverLog_1 = require("./iosDriverLog");
31
34
  /** How often a session's new lines are written. */
@@ -37,6 +40,11 @@ const CLOCK_TIMEOUT_MS = 5000;
37
40
  /** Waits before opening the phone's log stream again: doubling from the first, up to the last. */
38
41
  const RETRY_FIRST_MS = 1000;
39
42
  const RETRY_MAX_MS = 30000;
43
+ /**
44
+ * The newest stored rows a hub reads when its collection resumes after a
45
+ * restart, to find where they end in the node's first answer.
46
+ */
47
+ const RESUME_TAIL = 32;
40
48
  /**
41
49
  * The session page's Device logs for a session on this server's own Android
42
50
  * phone, iPhone or simulator: its lines from start to end, each once, with
@@ -58,6 +66,14 @@ const RETRY_MAX_MS = 30000;
58
66
  * ostrace`): that one has no simulators, needs the phone's go-ios tunnel on
59
67
  * iOS 17+, serves one reader per phone and carries ten times the lines.
60
68
  *
69
+ * A node records the sessions its hub creates the same way, whatever its
70
+ * dashboard setting, but has no Session row for them: it holds the rows in
71
+ * memory (NodeDeviceLogStore) for the hub to collect, and stops recording a
72
+ * session no hub has asked about within UNCLAIMED_MS (a hub with its
73
+ * dashboard off never asks). On a hub, a session on a node's phone gets a
74
+ * NodeDeviceLogsCollector instead, which asks the node for the rows and
75
+ * writes them here, in the order the node kept them.
76
+ *
61
77
  * Until 2.14 each recorded command dumped the last 500 lines (`logcat -d`),
62
78
  * kept the last 100 and skipped as many as the previous dump had given, so
63
79
  * after the first command nothing more was ever saved, each line stamped with
@@ -68,73 +84,184 @@ let SessionDeviceLogs = class SessionDeviceLogs {
68
84
  constructor() {
69
85
  this.log = logger_1.default.scope('DeviceLogs');
70
86
  this.running = new Map();
87
+ /** Stops under way: a second ending waits for the first one's last lines. */
88
+ this.stopping = new Map();
71
89
  }
72
90
  /**
73
91
  * Whether a session on this device is recorded: an Android phone or
74
- * emulator, an iPhone or a simulator, that this server drives.
92
+ * emulator, an iPhone or a simulator, that this server drives, or, on a
93
+ * hub, a node's through its session.
75
94
  */
76
- appliesTo(device) {
95
+ appliesTo(device, source) {
77
96
  var _a;
78
97
  if (!device || device.cloud)
79
98
  return false;
80
99
  if (!['android', 'ios', 'tvos'].includes(String((_a = device.platform) !== null && _a !== void 0 ? _a : '').toLowerCase())) {
81
100
  return false;
82
101
  }
102
+ if (this.ownPhone(device))
103
+ return true;
83
104
  // A node's phone isn't reachable from here (its driver and its adb are
84
- // the node's); the node has the session, and no row for it.
105
+ // the node's): on a hub, its lines are collected from the node. Never a
106
+ // cloud provider's (above).
107
+ return !!source && !this.holdsForHub();
108
+ }
109
+ ownPhone(device) {
85
110
  const ctx = this.context();
86
111
  return (0, localDeviceHosts_1.isOwnDevice)((0, localDeviceHosts_1.localDeviceHosts)(ctx.pluginArgs, ctx.port), ctx.nodeId, device);
87
112
  }
88
113
  /**
89
114
  * Starts recording the session's device log. Resolves once it listens to
90
- * the phone's stream (or is waiting to try again); callers needn't wait.
115
+ * the phone's stream (or is waiting to try again), or asks the node;
116
+ * callers needn't wait.
91
117
  */
92
- start({ sessionId, device, since, driverLog, appUnderTest }) {
93
- var _a, _b;
94
- if (this.running.has(sessionId) || !this.appliesTo(device))
118
+ start({ sessionId, device, since, source, resume, driverLog, appUnderTest, }) {
119
+ var _a, _b, _c, _d;
120
+ if (this.running.has(sessionId) || !this.appliesTo(device, source))
95
121
  return Promise.resolve();
122
+ const fromNode = !!source && !this.ownPhone(device);
96
123
  const android = String(device.platform).toLowerCase() === 'android';
97
- if (!android && !(0, iosDriverLog_1.isDriverLog)(driverLog)) {
124
+ if (!fromNode && !android && !(0, iosDriverLog_1.isDriverLog)(driverLog)) {
98
125
  this.log.info(`[${sessionId}] No device log for ${device.udid}: the driver isn't capturing one ` +
99
126
  '(appium:skipLogCapture?)');
100
127
  return Promise.resolve();
101
128
  }
129
+ // A node has no Session row for the hub's sessions, so it holds the rows
130
+ // for its hub to collect (GET /node/sessions/:id/device-logs).
131
+ const held = this.holdsForHub();
102
132
  const entry = {
103
133
  udid: device.udid,
104
134
  buffer: [],
105
- flushTimer: setInterval(() => void this.write(sessionId, entry), this.flushIntervalMs()),
106
135
  writing: Promise.resolve(),
107
136
  lastCreatedAt: 0,
108
137
  attempts: 0,
109
138
  stopped: false,
139
+ held,
110
140
  };
111
- (_b = (_a = entry.flushTimer).unref) === null || _b === void 0 ? void 0 : _b.call(_a);
141
+ if (!held) {
142
+ entry.flushTimer = setInterval(() => void this.write(sessionId, entry), this.flushIntervalMs());
143
+ (_b = (_a = entry.flushTimer).unref) === null || _b === void 0 ? void 0 : _b.call(_a);
144
+ }
112
145
  this.running.set(sessionId, entry);
146
+ const warn = (err) => { var _a; return this.log.warn(`[${sessionId}] Device log not recorded: ${(_a = err === null || err === void 0 ? void 0 : err.message) !== null && _a !== void 0 ? _a : err}`); };
147
+ if (fromNode) {
148
+ this.log.info(`[${sessionId}] Collecting the device log of ${device.udid} from its node`);
149
+ entry.collecting = this.collect(sessionId, entry, source, resume === true).catch(warn);
150
+ return entry.collecting;
151
+ }
152
+ if (held) {
153
+ this.nodeStore().begin(sessionId);
154
+ entry.unclaimedTimer = setTimeout(() => this.dropUnclaimed(sessionId), this.unclaimedMs());
155
+ (_d = (_c = entry.unclaimedTimer).unref) === null || _d === void 0 ? void 0 : _d.call(_c);
156
+ }
113
157
  this.log.info(`[${sessionId}] Recording the device log of ${device.udid}`);
114
158
  const opened = android
115
159
  ? this.open(sessionId, entry, since !== null && since !== void 0 ? since : Date.now())
116
160
  : Promise.resolve(this.listenToDriver(sessionId, entry, since !== null && since !== void 0 ? since : Date.now(), driverLog, appUnderTest));
117
- return opened.catch((err) => { var _a; return this.log.warn(`[${sessionId}] Device log not recorded: ${(_a = err === null || err === void 0 ? void 0 : err.message) !== null && _a !== void 0 ? _a : err}`); });
161
+ return opened.catch(warn);
118
162
  }
119
- /** Stops listening and writes what is left. Idempotent. */
163
+ /**
164
+ * Stops listening and writes what is left (on a node, holds it for the
165
+ * hub). Idempotent: a stop while one is under way resolves with it, once
166
+ * the last lines are written, since what follows an ending (the failure
167
+ * analysis) reads them.
168
+ */
120
169
  stop(sessionId) {
170
+ const underway = this.stopping.get(sessionId);
171
+ if (underway)
172
+ return underway;
173
+ const entry = this.running.get(sessionId);
174
+ if (!entry)
175
+ return Promise.resolve();
176
+ const done = this.finishStop(sessionId, entry).finally(() => this.stopping.delete(sessionId));
177
+ this.stopping.set(sessionId, done);
178
+ return done;
179
+ }
180
+ finishStop(sessionId, entry) {
121
181
  return __awaiter(this, void 0, void 0, function* () {
122
- var _a;
123
- const entry = this.running.get(sessionId);
124
- if (!entry)
125
- return;
126
- this.running.delete(sessionId);
127
- entry.stopped = true;
128
- clearInterval(entry.flushTimer);
129
- if (entry.retryTimer)
130
- clearTimeout(entry.retryTimer);
131
- (_a = entry.remove) === null || _a === void 0 ? void 0 : _a.call(entry);
132
- entry.remove = undefined;
182
+ this.halt(sessionId, entry);
183
+ // On a hub: the node's last lines. Its session ended first (the hub's
184
+ // DELETE reached it), so they are all there.
185
+ if (entry.collecting)
186
+ yield entry.collecting;
187
+ if (entry.collector)
188
+ yield entry.collector.stop();
133
189
  if (entry.book)
134
- entry.buffer.push(...entry.book.finish(new Date()));
135
- yield this.write(sessionId, entry);
190
+ this.keep(sessionId, entry, entry.book.finish(new Date()));
191
+ if (entry.held)
192
+ this.nodeStore().end(sessionId);
193
+ else
194
+ yield this.write(sessionId, entry);
195
+ });
196
+ }
197
+ /** Stops listening, with nothing more written or held. */
198
+ halt(sessionId, entry) {
199
+ var _a;
200
+ this.running.delete(sessionId);
201
+ entry.stopped = true;
202
+ if (entry.flushTimer)
203
+ clearInterval(entry.flushTimer);
204
+ if (entry.retryTimer)
205
+ clearTimeout(entry.retryTimer);
206
+ if (entry.unclaimedTimer)
207
+ clearTimeout(entry.unclaimedTimer);
208
+ (_a = entry.remove) === null || _a === void 0 ? void 0 : _a.call(entry);
209
+ entry.remove = undefined;
210
+ }
211
+ /**
212
+ * On a node: no hub has asked about the session since it started, so none
213
+ * collects it (a hub with its dashboard off, the default, or an older
214
+ * one). Recording it would run the phone's log stream and hold up to the
215
+ * book's limit for nothing.
216
+ */
217
+ dropUnclaimed(sessionId) {
218
+ const entry = this.running.get(sessionId);
219
+ if (!entry || this.nodeStore().claimed(sessionId))
220
+ return;
221
+ this.halt(sessionId, entry);
222
+ this.nodeStore().forget(sessionId);
223
+ this.log.debug(`[${sessionId}] No hub asked for the device log of ${entry.udid}; stopped recording it`);
224
+ }
225
+ /**
226
+ * On a hub: asks the node for the session's lines and writes each answer's
227
+ * at once. After a restart, new rows go after the newest ones stored.
228
+ */
229
+ collect(sessionId, entry, source, resume) {
230
+ return __awaiter(this, void 0, void 0, function* () {
231
+ var _a;
232
+ let tail = [];
233
+ if (resume) {
234
+ try {
235
+ tail = yield this.storedTail(sessionId);
236
+ }
237
+ catch (err) {
238
+ this.log.warn(`[${sessionId}] Can't read the newest device log lines stored: ${(_a = err === null || err === void 0 ? void 0 : err.message) !== null && _a !== void 0 ? _a : err}`);
239
+ }
240
+ if (tail.length > 0)
241
+ entry.lastCreatedAt = tail[tail.length - 1].createdAt;
242
+ }
243
+ entry.collector = this.collectorFor(source, (rows) => {
244
+ this.keep(sessionId, entry, rows);
245
+ return this.write(sessionId, entry);
246
+ }, tail.map(({ message, timestamp }) => ({ message, timestamp })));
247
+ // Made even when the session ended meanwhile: its stop then asks the
248
+ // node once, for the last lines.
249
+ if (!entry.stopped)
250
+ entry.collector.start();
136
251
  });
137
252
  }
253
+ /** The session's new rows: held for the hub on a node, else written soon. */
254
+ keep(sessionId, entry, rows) {
255
+ if (rows.length === 0)
256
+ return;
257
+ if (entry.held) {
258
+ this.nodeStore().add(sessionId, rows);
259
+ return;
260
+ }
261
+ entry.buffer.push(...rows);
262
+ if (entry.buffer.length >= exports.WRITE_BATCH)
263
+ void this.write(sessionId, entry);
264
+ }
138
265
  /** Whether the session is recorded here. */
139
266
  isRecording(sessionId) {
140
267
  return this.running.has(sessionId);
@@ -207,12 +334,7 @@ let SessionDeviceLogs = class SessionDeviceLogs {
207
334
  delivered = true;
208
335
  entry.attempts = 0;
209
336
  }
210
- const rows = book.add(rec);
211
- if (rows.length === 0)
212
- return;
213
- entry.buffer.push(...rows);
214
- if (entry.buffer.length >= exports.WRITE_BATCH)
215
- void this.write(sessionId, entry);
337
+ this.keep(sessionId, entry, book.add(rec));
216
338
  },
217
339
  // Never refused: what the session keeps is bounded by its book.
218
340
  () => true, () => {
@@ -224,7 +346,7 @@ let SessionDeviceLogs = class SessionDeviceLogs {
224
346
  // A stream that delivered nothing (a phone that's gone) isn't noted
225
347
  // again on every try.
226
348
  if (delivered) {
227
- entry.buffer.push(...note);
349
+ this.keep(sessionId, entry, note);
228
350
  this.log.info(`[${sessionId}] The device log of ${entry.udid} ended; opening it again`);
229
351
  }
230
352
  this.retry(sessionId, entry);
@@ -246,12 +368,14 @@ let SessionDeviceLogs = class SessionDeviceLogs {
246
368
  entry.book = book;
247
369
  const appLines = appUnderTest ? new iosDriverLog_1.IosAppLines(appUnderTest) : undefined;
248
370
  if (appLines) {
249
- entry.buffer.push({
250
- message: `Xenon: Kept here: what the app under test (${appUnderTest}) logs itself, ` +
251
- 'every error inside it, what the phone says about its launch, state, crashes ' +
252
- "and end, and faults. The rest of the phone's log is left out.",
253
- timestamp: new Date(since),
254
- });
371
+ this.keep(sessionId, entry, [
372
+ {
373
+ message: `Xenon: Kept here: what the app under test (${appUnderTest}) logs itself, ` +
374
+ 'every error inside it, what the phone says about its launch, state, crashes ' +
375
+ "and end, and faults. The rest of the phone's log is left out.",
376
+ timestamp: new Date(since),
377
+ },
378
+ ]);
255
379
  }
256
380
  const take = (line) => {
257
381
  const rec = (0, iosDriverLog_1.recordFromDriverLog)(line);
@@ -259,12 +383,7 @@ let SessionDeviceLogs = class SessionDeviceLogs {
259
383
  return;
260
384
  if (appLines && !appLines.keeps(rec.message, rec.level))
261
385
  return;
262
- const rows = book.add(rec);
263
- if (rows.length === 0)
264
- return;
265
- entry.buffer.push(...rows);
266
- if (entry.buffer.length >= exports.WRITE_BATCH)
267
- void this.write(sessionId, entry);
386
+ this.keep(sessionId, entry, book.add(rec));
268
387
  };
269
388
  // The create's lines first teach the filter the app's process, so its
270
389
  // first lines aren't lost to an announcement that comes after them.
@@ -326,6 +445,42 @@ let SessionDeviceLogs = class SessionDeviceLogs {
326
445
  retryFirstMs() {
327
446
  return RETRY_FIRST_MS;
328
447
  }
448
+ /** A node (a server with `hub`) holds the rows for its hub instead of writing them. */
449
+ holdsForHub() {
450
+ var _a;
451
+ return ((_a = this.context().pluginArgs) === null || _a === void 0 ? void 0 : _a.hub) !== undefined;
452
+ }
453
+ nodeStore() {
454
+ return typedi_1.Container.get(NodeDeviceLogStore_1.NodeDeviceLogStore);
455
+ }
456
+ unclaimedMs() {
457
+ return nodeDeviceLogs_1.UNCLAIMED_MS;
458
+ }
459
+ collectorFor(source, onLines, storedTail) {
460
+ return new NodeDeviceLogsCollector_1.NodeDeviceLogsCollector({
461
+ source,
462
+ onLines,
463
+ storedTail,
464
+ support: typedi_1.Container.get(nodeDeviceLogs_1.NodeDeviceLogsSupport),
465
+ logger: this.log,
466
+ });
467
+ }
468
+ /** The session's newest Device logs rows, by `createdAt`, oldest first. */
469
+ storedTail(sessionId) {
470
+ return __awaiter(this, void 0, void 0, function* () {
471
+ const rows = yield prisma_1.prisma.log.findMany({
472
+ where: { session_id: sessionId, log_type: 'DEVICE' },
473
+ orderBy: { createdAt: 'desc' },
474
+ take: RESUME_TAIL,
475
+ select: { message: true, timestamp: true, createdAt: true },
476
+ });
477
+ return rows.reverse().map((row) => ({
478
+ message: row.message,
479
+ timestamp: row.timestamp.getTime(),
480
+ createdAt: row.createdAt.getTime(),
481
+ }));
482
+ });
483
+ }
329
484
  /** The phone's clock against this server's, or null if its answer isn't one. */
330
485
  readClock(udid) {
331
486
  return __awaiter(this, void 0, void 0, function* () {
@@ -0,0 +1,91 @@
1
+ "use strict";
2
+ var __decorate = (this && this.__decorate) || function (decorators, target, key, desc) {
3
+ var c = arguments.length, r = c < 3 ? target : desc === null ? desc = Object.getOwnPropertyDescriptor(target, key) : desc, d;
4
+ if (typeof Reflect === "object" && typeof Reflect.decorate === "function") r = Reflect.decorate(decorators, target, key, desc);
5
+ else for (var i = decorators.length - 1; i >= 0; i--) if (d = decorators[i]) r = (c < 3 ? d(r) : c > 3 ? d(target, key, r) : d(target, key)) || r;
6
+ return c > 3 && r && Object.defineProperty(target, key, r), r;
7
+ };
8
+ var __importDefault = (this && this.__importDefault) || function (mod) {
9
+ return (mod && mod.__esModule) ? mod : { "default": mod };
10
+ };
11
+ Object.defineProperty(exports, "__esModule", { value: true });
12
+ exports.NodeDeviceLogsSupport = exports.UNCLAIMED_MS = exports.NODE_DEVICE_LOGS_KEEP_AFTER_END_MS = exports.NODE_DEVICE_LOG_PAGE = exports.NODE_DEVICE_LOGS_HEADER = exports.NODE_DEVICE_LOGS_ROUTE = void 0;
13
+ exports.readNodeDeviceLogsReply = readNodeDeviceLogsReply;
14
+ exports.nodeDeviceLogsSourceOf = nodeDeviceLogsSourceOf;
15
+ const typedi_1 = require("typedi");
16
+ const logger_1 = __importDefault(require("../../logger"));
17
+ const SessionType_1 = __importDefault(require("../../enums/SessionType"));
18
+ const nodeAsk_1 = require("../../gateway/nodeAsk");
19
+ /** Where a node serves a session's device log lines, under `/xenon/api`. */
20
+ exports.NODE_DEVICE_LOGS_ROUTE = '/node/sessions/:sessionId/device-logs';
21
+ /** On every answer of that route, so a hub can tell a node that has it. */
22
+ exports.NODE_DEVICE_LOGS_HEADER = 'x-xenon-node-device-logs';
23
+ /** Lines in one answer. */
24
+ exports.NODE_DEVICE_LOG_PAGE = 2000;
25
+ /** How long a node keeps an ended session's lines for its hub's last ask. */
26
+ exports.NODE_DEVICE_LOGS_KEEP_AFTER_END_MS = 10 * 60000;
27
+ /**
28
+ * A node stops recording a session no hub has asked about this long after
29
+ * it started: its hub doesn't collect (its dashboard is off, the default) or
30
+ * is older. A hub that collects asks as soon as the session is created.
31
+ */
32
+ exports.UNCLAIMED_MS = 2 * 60000;
33
+ const STATES = ['recording', 'ended', 'off'];
34
+ /**
35
+ * The line, field by field, or null when it isn't one: one malformed row
36
+ * would fail every later write of the session's lines (a failed batch goes
37
+ * back to the front of the buffer).
38
+ */
39
+ function lineOf(v) {
40
+ if (!v || typeof v !== 'object')
41
+ return null;
42
+ if (!Number.isSafeInteger(v.seq) || v.seq < 1)
43
+ return null;
44
+ if (typeof v.message !== 'string')
45
+ return null;
46
+ if (typeof v.timestamp !== 'number' || !Number.isFinite(v.timestamp))
47
+ return null;
48
+ return { seq: v.seq, message: v.message, timestamp: v.timestamp };
49
+ }
50
+ function deviceLogsAnswerOf(value) {
51
+ if (!value || !STATES.includes(value.state) || !Array.isArray(value.lines))
52
+ return null;
53
+ return {
54
+ state: value.state,
55
+ lines: value.lines.map(lineOf).filter((x) => !!x),
56
+ more: value.more === true,
57
+ };
58
+ }
59
+ function readNodeDeviceLogsReply(status, headers, data) {
60
+ return (0, nodeAsk_1.readNodeReply)(exports.NODE_DEVICE_LOGS_HEADER, status, headers, data, deviceLogsAnswerOf);
61
+ }
62
+ /**
63
+ * The session as a NodeDeviceLogsSource, when it runs on a node. A cloud
64
+ * provider's session (CloudSession) and this server's own (LocalSession)
65
+ * inherit the methods from RemoteSession, so the type decides.
66
+ */
67
+ function nodeDeviceLogsSourceOf(session) {
68
+ var _a;
69
+ const s = session;
70
+ return s &&
71
+ ((_a = s.getType) === null || _a === void 0 ? void 0 : _a.call(s)) === SessionType_1.default.REMOTE &&
72
+ typeof s.nodeDeviceLogs === 'function' &&
73
+ typeof s.nodeOrigin === 'function'
74
+ ? s
75
+ : undefined;
76
+ }
77
+ /** Hub side: which nodes lack the route (an older Xenon). Their sessions have no Device logs. */
78
+ let NodeDeviceLogsSupport = class NodeDeviceLogsSupport extends nodeAsk_1.OlderNodes {
79
+ constructor() {
80
+ super(...arguments);
81
+ this.logger = logger_1.default.scope('DeviceLogs');
82
+ }
83
+ warning(origin, status) {
84
+ return (`Node ${origin} has no session device log route (answered ${status}): an older Xenon. ` +
85
+ 'Its sessions have no device logs here. Upgrade the node.');
86
+ }
87
+ };
88
+ exports.NodeDeviceLogsSupport = NodeDeviceLogsSupport;
89
+ exports.NodeDeviceLogsSupport = NodeDeviceLogsSupport = __decorate([
90
+ (0, typedi_1.Service)()
91
+ ], NodeDeviceLogsSupport);
@@ -9,12 +9,13 @@ var __importDefault = (this && this.__importDefault) || function (mod) {
9
9
  return (mod && mod.__esModule) ? mod : { "default": mod };
10
10
  };
11
11
  Object.defineProperty(exports, "__esModule", { value: true });
12
- exports.NodeMetricsSupport = exports.NODE_METRICS_RECHECK_MS = exports.KEEP_AFTER_END_MS = exports.NODE_METRICS_HEADER = exports.NODE_METRICS_ROUTE = void 0;
12
+ exports.NodeMetricsSupport = exports.KEEP_AFTER_END_MS = exports.NODE_METRICS_HEADER = exports.NODE_METRICS_ROUTE = void 0;
13
13
  exports.readNodeMetricsReply = readNodeMetricsReply;
14
14
  exports.nodeMetricsSourceOf = nodeMetricsSourceOf;
15
15
  const typedi_1 = require("typedi");
16
16
  const logger_1 = __importDefault(require("../../logger"));
17
17
  const SessionType_1 = __importDefault(require("../../enums/SessionType"));
18
+ const nodeAsk_1 = require("../../gateway/nodeAsk");
18
19
  /** Where a node serves a session's figures, under `/xenon/api`. */
19
20
  exports.NODE_METRICS_ROUTE = '/node/sessions/:sessionId/metrics';
20
21
  /** On every answer of that route, so a hub can tell a node that has it. */
@@ -41,33 +42,20 @@ function sampleOf(v) {
41
42
  return null;
42
43
  return { at: v.at, deviceCpuPct, deviceMemMb, deviceMemTotalMb, appCpuPct, appMemMb, appId };
43
44
  }
44
- function readNodeMetricsReply(status, headers, data) {
45
+ function metricsAnswerOf(value) {
45
46
  var _a;
46
- if (!(headers === null || headers === void 0 ? void 0 : headers[exports.NODE_METRICS_HEADER])) {
47
- // An older node answers its login's 401, an unknown route's 404, or a
48
- // catch-all's 2xx. Anything else without the header (a proxy's 502 in
49
- // front of a node) is an outage: ask again, never give the node up.
50
- if (status === 401 || status === 404 || (status >= 200 && status < 300)) {
51
- return { kind: 'unsupported', status };
52
- }
53
- return { kind: 'unavailable', reason: `answered ${status}` };
54
- }
55
- if (status === 404)
56
- return { kind: 'refused' };
57
- const value = data === null || data === void 0 ? void 0 : data.value;
58
- if (status === 200 && value && STATES.includes(value.state) && Array.isArray(value.samples)) {
59
- return {
60
- kind: 'answer',
61
- answer: {
62
- platform: String((_a = value.platform) !== null && _a !== void 0 ? _a : ''),
63
- state: value.state,
64
- samples: value.samples
65
- .map(sampleOf)
66
- .filter((x) => x !== null),
67
- },
68
- };
69
- }
70
- return { kind: 'unavailable', reason: `answered ${status}` };
47
+ if (!value || !STATES.includes(value.state) || !Array.isArray(value.samples))
48
+ return null;
49
+ return {
50
+ platform: String((_a = value.platform) !== null && _a !== void 0 ? _a : ''),
51
+ state: value.state,
52
+ samples: value.samples
53
+ .map(sampleOf)
54
+ .filter((x) => x !== null),
55
+ };
56
+ }
57
+ function readNodeMetricsReply(status, headers, data) {
58
+ return (0, nodeAsk_1.readNodeReply)(exports.NODE_METRICS_HEADER, status, headers, data, metricsAnswerOf);
71
59
  }
72
60
  /**
73
61
  * The session as a NodeMetricsSource, when it runs on a node. A cloud
@@ -84,35 +72,18 @@ function nodeMetricsSourceOf(session) {
84
72
  ? s
85
73
  : undefined;
86
74
  }
87
- /** How long the hub leaves a node without the route alone before asking it again. */
88
- exports.NODE_METRICS_RECHECK_MS = 10 * 60000;
89
75
  /**
90
76
  * Hub side: which nodes lack the route (an older Xenon). Their sessions show
91
77
  * no figures; the hub says so once per node, and asks again after
92
- * NODE_METRICS_RECHECK_MS, so a node upgraded in place is picked up.
78
+ * OLDER_NODE_RECHECK_MS, so a node upgraded in place is picked up.
93
79
  */
94
- let NodeMetricsSupport = class NodeMetricsSupport {
80
+ let NodeMetricsSupport = class NodeMetricsSupport extends nodeAsk_1.OlderNodes {
95
81
  constructor() {
82
+ super(...arguments);
96
83
  this.logger = logger_1.default.scope('NodeMetrics');
97
- this.now = () => Date.now();
98
- this.unsupportedUntil = new Map();
99
- this.warned = new Set();
100
- }
101
- shouldAsk(origin) {
102
- const until = this.unsupportedUntil.get(origin);
103
- if (until === undefined)
104
- return true;
105
- if (until > this.now())
106
- return false;
107
- this.unsupportedUntil.delete(origin);
108
- return true;
109
84
  }
110
- unsupported(origin, status) {
111
- this.unsupportedUntil.set(origin, this.now() + exports.NODE_METRICS_RECHECK_MS);
112
- if (this.warned.has(origin))
113
- return;
114
- this.warned.add(origin);
115
- this.logger.warn(`Node ${origin} has no session metrics route (answered ${status}): an older Xenon. ` +
85
+ warning(origin, status) {
86
+ return (`Node ${origin} has no session metrics route (answered ${status}): an older Xenon. ` +
116
87
  'Its sessions show no CPU or memory. Upgrade the node.');
117
88
  }
118
89
  };
@@ -20,6 +20,7 @@ const SessionType_1 = __importDefault(require("../enums/SessionType"));
20
20
  const hubSessionToken_1 = require("../gateway/hubSessionToken");
21
21
  const outboundProxy_1 = require("../helpers/outboundProxy");
22
22
  const nodeMetrics_1 = require("../services/metrics/nodeMetrics");
23
+ const nodeDeviceLogs_1 = require("../services/logcat/nodeDeviceLogs");
23
24
  const nodeSessionStatus_1 = require("../gateway/nodeSessionStatus");
24
25
  const XenonSession_1 = require("./XenonSession");
25
26
  class RemoteSession extends XenonSession_1.XenonSession {
@@ -235,6 +236,14 @@ class RemoteSession extends XenonSession_1.XenonSession {
235
236
  }
236
237
  /** The node's CPU and memory for this session newer than `after` (NodeMetricsCollector). */
237
238
  nodeMetrics(after) {
239
+ return this.askNode('metrics', after, nodeMetrics_1.readNodeMetricsReply);
240
+ }
241
+ /** The node's device log lines for this session after line `after` (NodeDeviceLogsCollector). */
242
+ nodeDeviceLogs(after) {
243
+ return this.askNode('device-logs', after, nodeDeviceLogs_1.readNodeDeviceLogsReply);
244
+ }
245
+ /** One of the node's routes about this session, `/xenon/api/node/sessions/<id>/<what>?after=`. */
246
+ askNode(what, after, read) {
238
247
  return __awaiter(this, void 0, void 0, function* () {
239
248
  var _a, _b, _c;
240
249
  const origin = this.nodeOrigin();
@@ -243,12 +252,12 @@ class RemoteSession extends XenonSession_1.XenonSession {
243
252
  try {
244
253
  const response = yield this.call({
245
254
  method: 'get',
246
- url: `${origin}${nodeSessionStatus_1.NODE_SESSION_STATUS_PATH}/${encodeURIComponent(this.sessionId)}/metrics`,
255
+ url: `${origin}${nodeSessionStatus_1.NODE_SESSION_STATUS_PATH}/${encodeURIComponent(this.sessionId)}/${what}`,
247
256
  params: after === null ? undefined : { after },
248
257
  timeout: 5000,
249
258
  validateStatus: () => true,
250
259
  });
251
- return (0, nodeMetrics_1.readNodeMetricsReply)(response.status, (_a = response.headers) !== null && _a !== void 0 ? _a : {}, response.data);
260
+ return read(response.status, (_a = response.headers) !== null && _a !== void 0 ? _a : {}, response.data);
252
261
  }
253
262
  catch (err) {
254
263
  return { kind: 'unavailable', reason: String((_c = (_b = err === null || err === void 0 ? void 0 : err.code) !== null && _b !== void 0 ? _b : err === null || err === void 0 ? void 0 : err.message) !== null && _c !== void 0 ? _c : err) };
@@ -59,9 +59,11 @@ const logger_1 = __importDefault(require("../logger"));
59
59
  const RemoteSession_1 = require("./RemoteSession");
60
60
  const CloudSession_1 = require("./CloudSession");
61
61
  const device_store_1 = require("../data-service/device-store");
62
+ const PluginContext_1 = require("../PluginContext");
62
63
  const nodeWebDriverUrl_1 = require("../gateway/nodeWebDriverUrl");
63
64
  const XenonCapabilityManager_1 = require("../XenonCapabilityManager");
64
65
  const nodeMetrics_1 = require("../services/metrics/nodeMetrics");
66
+ const nodeDeviceLogs_1 = require("../services/logcat/nodeDeviceLogs");
65
67
  // A session still running at boot that can't be picked up again is filed
66
68
  // under it; the failure analysis also writes it for a shutdown's drain.
67
69
  const failureCategories_1 = require("../dashboard/services/failureCategories");
@@ -121,7 +123,7 @@ let SessionManager = class SessionManager {
121
123
  */
122
124
  recoverActiveSessions(currentNodeId_1, nodeBasePath_1) {
123
125
  return __awaiter(this, arguments, void 0, function* (currentNodeId, nodeBasePath, isLocalHost = () => false) {
124
- var _a, _b;
126
+ var _a, _b, _c;
125
127
  this.log.info('🔄 Attempting to recover active sessions from database...');
126
128
  try {
127
129
  // Find all sessions that are still "running" (not ended)
@@ -225,6 +227,13 @@ let SessionManager = class SessionManager {
225
227
  if (!device.cloud && dbSession.is_profiling_available) {
226
228
  yield this.resumeNodeMetrics(dbSession.id, device, recoveredSession, sessionResponse);
227
229
  }
230
+ // Its device log too, where the hub records sessions (the dashboard
231
+ // on) and the session didn't turn it off, after the newest line stored.
232
+ if (!device.cloud &&
233
+ ((_c = typedi_2.Container.get(PluginContext_1.PluginContext).pluginArgs) === null || _c === void 0 ? void 0 : _c.enableDashboard) &&
234
+ xenonCapabilities[XenonCapabilityManager_1.XENON_CAPABILITIES.SAVE_DEVICE_LOGS] !== false) {
235
+ yield this.resumeNodeDeviceLogs(dbSession.id, device, recoveredSession);
236
+ }
228
237
  recoveredCount++;
229
238
  }
230
239
  catch (sessionErr) {
@@ -266,6 +275,24 @@ let SessionManager = class SessionManager {
266
275
  }
267
276
  });
268
277
  }
278
+ /** Goes on collecting a node session's device log after a hub restart. */
279
+ resumeNodeDeviceLogs(sessionId, device, session) {
280
+ return __awaiter(this, void 0, void 0, function* () {
281
+ try {
282
+ // Loaded here: SessionDeviceLogs imports the device managers.
283
+ const { SessionDeviceLogs } = yield Promise.resolve().then(() => __importStar(require('../services/logcat/SessionDeviceLogs')));
284
+ void typedi_2.Container.get(SessionDeviceLogs).start({
285
+ sessionId,
286
+ device,
287
+ source: (0, nodeDeviceLogs_1.nodeDeviceLogsSourceOf)(session),
288
+ resume: true,
289
+ });
290
+ }
291
+ catch (err) {
292
+ this.log.warn(`Session ${sessionId}: device log not resumed: ${err.message}`);
293
+ }
294
+ });
295
+ }
269
296
  /**
270
297
  * A recovered session is this process's from now on: its row's heartbeat
271
298
  * is stamped with this process, now. The row still carried the heartbeat