mobile-debug-mcp 0.21.1 → 0.21.3

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,18 @@
1
+ export function parsePngSize(buf) {
2
+ try {
3
+ if (!buf || buf.length < 24)
4
+ return { width: 0, height: 0 };
5
+ // PNG signature + IHDR checks
6
+ if (buf.readUInt32BE(0) !== 0x89504e47 || buf.readUInt32BE(4) !== 0x0d0a1a0a)
7
+ return { width: 0, height: 0 };
8
+ const ihdr = buf.toString('ascii', 12, 16);
9
+ if (ihdr !== 'IHDR')
10
+ return { width: 0, height: 0 };
11
+ const width = buf.readUInt32BE(16);
12
+ const height = buf.readUInt32BE(20);
13
+ return { width, height };
14
+ }
15
+ catch {
16
+ return { width: 0, height: 0 };
17
+ }
18
+ }
package/docs/CHANGELOG.md CHANGED
@@ -2,6 +2,12 @@
2
2
 
3
3
  All notable changes to the **Mobile Debug MCP** project will be documented in this file.
4
4
 
5
+ ## [0.21.3]
6
+ - Added structured logs
7
+
8
+ ## [0.21.2]
9
+ - Fixed screenshots not working, imnproved tool
10
+
5
11
  ## [0.21.1]
6
12
  - Removed wait_for_element and renamed observe_until to wait_for_ui (obsolete references removed)
7
13
 
@@ -3,27 +3,32 @@
3
3
  Tools that retrieve device state, logs, screenshots and UI hierarchies.
4
4
 
5
5
  ## get_logs
6
- Fetch recent logs from the app or device.
7
6
 
8
- Input:
7
+ Fetch recent logs as structured entries optimized for AI agents.
8
+
9
+ Input (example):
9
10
 
10
11
  ```json
11
- { "platform": "android", "appId": "com.example.app", "deviceId": "emulator-5554", "lines": 200 }
12
+ { "platform": "android|ios", "appId": "com.example.app", "deviceId": "emulator-5554", "pid": 1234, "tag": "MyTag", "level": "ERROR", "contains": "timeout", "since_seconds": 60, "limit": 50 }
12
13
  ```
13
14
 
14
- Response (metadata):
15
+ Defaults:
16
+
17
+ - No filters → return the most recent 50 log entries (app-scoped if appId provided), across all levels.
18
+
19
+ Response (structured):
15
20
 
16
21
  ```json
17
- { "entries": 200, "crash_summary": { "crash_detected": false } }
22
+ { "device": { "platform": "android", "id": "emulator-5554" }, "logs": [ { "timestamp": "2026-03-30T16:00:00.000Z", "level": "ERROR", "tag": "MyTag", "pid": 1234, "message": "Something failed" } ], "count": 1, "filtered": true }
18
23
  ```
19
24
 
20
- Followed by a raw log plain text block.
21
-
22
25
  Notes:
23
- - Android log parsing includes basic crash detection (searching for "FATAL EXCEPTION" and exception names).
24
- - Use `lines` to control how many log lines are returned from `adb logcat`.
25
26
 
26
- ---
27
+ - Each log entry: timestamp (ISO), level (VERBOSE|DEBUG|INFO|WARN|ERROR), tag (string), pid (number|null), message (string).
28
+ - Logs ordered oldest → newest. count equals number of entries returned. filtered is true if any filter was applied.
29
+ - Supported filters: pid, tag, level, contains, since_seconds, limit.
30
+ - Platform behaviour: Android uses `adb logcat` with source-side filters where possible; iOS uses unified logging (`log show`/simctl) and maps subsystem/category → tag.
31
+ - Errors are returned as structured objects with `error.code` and `error.message`. Possible codes: LOGS_UNAVAILABLE, INVALID_FILTER, PLATFORM_NOT_SUPPORTED, INTERNAL_ERROR.
27
32
 
28
33
  ## capture_screenshot
29
34
  Capture screen. Returns JSON metadata then an image/png block with base64 PNG data.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "mobile-debug-mcp",
3
- "version": "0.21.1",
3
+ "version": "0.21.3",
4
4
  "description": "MCP server for mobile app debugging (Android + iOS), with focus on security and reliability",
5
5
  "type": "module",
6
6
  "bin": {
@@ -27,7 +27,8 @@
27
27
  "dependencies": {
28
28
  "@modelcontextprotocol/sdk": "^1.0.0",
29
29
  "fast-xml-parser": "^5.5.1",
30
- "zod": "^3.22.4"
30
+ "zod": "^3.22.4",
31
+ "sharp": "^0.32.1"
31
32
  },
32
33
  "devDependencies": {
33
34
  "@types/node": "^25.4.0",
@@ -223,6 +223,16 @@ export class ToolsInteract {
223
223
  return await ToolsInteract.waitForUICore({ type, query, timeoutMs, pollIntervalMs, includeSnapshotOnFailure, match, stability_ms, observationDelayMs, platform, deviceId })
224
224
  }
225
225
 
226
+ // Helper: normalize various log objects into plain message strings for comparison
227
+ private static _logsToMessages(logsArr: any[]): string[] {
228
+ if (!Array.isArray(logsArr)) return []
229
+ return logsArr.map((l: any) => {
230
+ if (typeof l === 'string') return l
231
+ if (l && (l.message || l.msg)) return l.message || l.msg
232
+ try { return JSON.stringify(l) } catch { return String(l) }
233
+ })
234
+ }
235
+
226
236
  static async waitForScreenChangeHandler({ platform, previousFingerprint, timeoutMs = 5000, pollIntervalMs = 300, deviceId }: { platform?: 'android' | 'ios', previousFingerprint: string, timeoutMs?: number, pollIntervalMs?: number, deviceId?: string }) {
227
237
  const start = Date.now()
228
238
  let lastFingerprint: string | null = null
@@ -279,7 +289,9 @@ export class ToolsInteract {
279
289
  if (fpRes && typeof fpRes === 'object') initialFingerprint = (fpRes as ScreenFingerprintResponse).fingerprint ?? null
280
290
  if (gl) {
281
291
  const logsArr = Array.isArray((gl as any).logs) ? (gl as any).logs : []
282
- baselineLastLine = logsArr.length ? logsArr[logsArr.length - 1] : null
292
+ // Normalize to last message string for baseline comparison
293
+ const msgs = ToolsInteract._logsToMessages(logsArr)
294
+ baselineLastLine = msgs.length ? msgs[msgs.length - 1] : null
283
295
  }
284
296
  } catch (err) {
285
297
  try { console.warn('waitForUI: failed to get baseline data (non-fatal):', err instanceof Error ? err.message : String(err)) } catch { }
@@ -348,13 +360,15 @@ export class ToolsInteract {
348
360
 
349
361
  const gl = await ToolsObserve.getLogsHandler({ platform, deviceId, lines: 200 }) as any
350
362
  const logsArr = Array.isArray(gl && gl.logs) ? gl.logs : []
363
+ // Normalize to messages for comparison
364
+ const msgs = ToolsInteract._logsToMessages(logsArr)
351
365
  let startIndex = 0
352
366
  if (baselineLastLine) {
353
- const idx = logsArr.lastIndexOf(baselineLastLine)
367
+ const idx = msgs.lastIndexOf(baselineLastLine)
354
368
  startIndex = idx >= 0 ? idx + 1 : 0
355
369
  }
356
- for (let i = startIndex; i < logsArr.length; i++) {
357
- const line = logsArr[i]
370
+ for (let i = startIndex; i < msgs.length; i++) {
371
+ const line = msgs[i]
358
372
  if (q && String(line).includes(q)) {
359
373
  const now2 = Date.now()
360
374
  return { success: true, condition: 'present', query: q, poll_count: pollCount, duration_ms: now2 - start, stable_duration_ms: 0, matchedLog: { message: line }, matchSource: 'log-snapshot', timestamp: now2, type: 'log', observed_state: true }
@@ -6,6 +6,7 @@ import { createWriteStream } from "fs"
6
6
  import { promises as fsPromises } from "fs"
7
7
  import path from "path"
8
8
  import { computeScreenFingerprint } from "../utils/ui/index.js"
9
+ import { parsePngSize } from "../utils/image.js"
9
10
 
10
11
  const activeLogStreams: Map<string, { proc: any, file: string }> = new Map()
11
12
 
@@ -92,26 +93,97 @@ export class AndroidObserve {
92
93
  }
93
94
  }
94
95
 
95
- async getLogs(appId?: string, lines = 200, deviceId?: string): Promise<GetLogsResponse> {
96
+ async getLogs(filters: { appId?: string, deviceId?: string, pid?: number, tag?: string, level?: string, contains?: string, since_seconds?: number, limit?: number } = {}): Promise<GetLogsResponse> {
97
+ const { appId, deviceId, pid, tag, level, contains, since_seconds, limit } = filters
96
98
  const metadata = await getAndroidDeviceMetadata(appId || "", deviceId)
97
99
  const deviceInfo = getDeviceInfo(deviceId || 'default', metadata)
98
100
 
99
101
  try {
100
- const stdout = await execAdb(['logcat', '-d', '-t', lines.toString(), '-v', 'threadtime'], deviceId)
101
- const allLogs = stdout.split('\n')
102
-
103
- let filteredLogs = allLogs
102
+ const effectiveLimit = typeof limit === 'number' && limit > 0 ? limit : 50
103
+
104
+ // If appId provided, try to get pid to filter at source
105
+ let pidArg: string | null = null
104
106
  if (appId) {
105
- const matchingLogs = allLogs.filter(line => line.includes(appId))
106
-
107
- if (matchingLogs.length > 0) {
108
- filteredLogs = matchingLogs
109
- } else {
110
- filteredLogs = allLogs
111
- }
107
+ try {
108
+ const pidOut = await execAdb(['shell', 'pidof', appId], deviceId).catch(() => '')
109
+ const pidTrim = (pidOut || '').trim()
110
+ if (pidTrim) pidArg = pidTrim.split('\n')[0]
111
+ } catch (err) {
112
+ // Log a warning so failures to detect PID are visible during debugging
113
+ try { console.warn(`getLogs: pid detection failed for appId=${appId}:`, err instanceof Error ? err.message : String(err)) } catch { }
114
+ }
112
115
  }
113
-
114
- return { device: deviceInfo, logs: filteredLogs, logCount: filteredLogs.length }
116
+
117
+ const args = ['logcat', '-d', '-v', 'threadtime']
118
+ // Apply pid filter via --pid if available
119
+ if (pidArg) args.push(`--pid=${pidArg}`)
120
+ else if (pid) args.push(`--pid=${pid}`)
121
+
122
+ // Apply tag/level filter if provided
123
+ if (tag && level) {
124
+ // Map verbose/debug/info/warn/error to single-letter levels
125
+ const levelMap: Record<string, string> = { 'VERBOSE': 'V', 'DEBUG': 'D', 'INFO': 'I', 'WARN': 'W', 'ERROR': 'E' }
126
+ const L = (levelMap[(level || '').toUpperCase()] || 'V')
127
+ args.push(`${tag}:${L}`)
128
+ } else {
129
+ // Default: show all levels
130
+ args.push('*:V')
131
+ }
132
+
133
+ // Use -t to limit lines (apply early)
134
+ args.push('-t', effectiveLimit.toString())
135
+
136
+ const stdout = await execAdb(args, deviceId)
137
+ const allLines = stdout ? stdout.split(/\r?\n/).filter(Boolean) : []
138
+
139
+ // Parse lines into structured entries
140
+ const parsed = allLines.map(l => {
141
+ const entry = parseLogLine(l)
142
+ // normalize level
143
+ const levelChar = (entry.level || '').toUpperCase()
144
+ const levelMapChar: Record<string,string> = { 'V':'VERBOSE','D':'DEBUG','I':'INFO','W':'WARN','E':'ERROR' }
145
+ const normLevel = levelMapChar[levelChar] || (entry.level && String(entry.level).toUpperCase()) || 'INFO'
146
+ const iso = entry._iso || (() => {
147
+ const d = new Date(entry.timestamp || '')
148
+ if (!isNaN(d.getTime())) return d.toISOString()
149
+ return null
150
+ })()
151
+ let pidNum: number | null = null
152
+ if (entry.pid) pidNum = Number(entry.pid)
153
+ else if (pidArg) pidNum = Number(pidArg)
154
+ const pidVal = (pidNum !== null && !isNaN(pidNum)) ? pidNum : null
155
+ return { timestamp: iso, level: normLevel, tag: entry.tag || '', pid: pidVal, message: entry.message || '' }
156
+ })
157
+
158
+ // Apply client-side filters: contains, since_seconds
159
+ let filtered = parsed
160
+ if (contains) filtered = filtered.filter(e => e.message && e.message.includes(contains))
161
+
162
+ if (since_seconds) {
163
+ const sinceMs = Date.now() - (since_seconds * 1000)
164
+ filtered = filtered.filter(e => {
165
+ if (!e.timestamp) return false
166
+ const t = new Date(e.timestamp).getTime()
167
+ return t >= sinceMs
168
+ })
169
+ }
170
+
171
+ // If appId provided and no pidArg, try to filter by appId substring in message/tag
172
+ if (appId && !pidArg) {
173
+ const matched = filtered.filter(e => (e.message && e.message.includes(appId)) || (e.tag && e.tag.includes(appId)))
174
+ if (matched.length > 0) filtered = matched
175
+ }
176
+
177
+ // Ensure ordering oldest -> newest (by timestamp when available)
178
+ filtered.sort((a,b) => {
179
+ const ta = a.timestamp ? new Date(a.timestamp).getTime() : 0
180
+ const tb = b.timestamp ? new Date(b.timestamp).getTime() : 0
181
+ return ta - tb
182
+ })
183
+
184
+ const limited = filtered.slice(-Math.max(0, effectiveLimit))
185
+
186
+ return { device: deviceInfo, logs: limited, logCount: limited.length }
115
187
  } catch (e) {
116
188
  console.error("Error fetching logs:", e)
117
189
  return { device: deviceInfo, logs: [], logCount: 0 }
@@ -142,7 +214,7 @@ export class AndroidObserve {
142
214
  reject(new Error(`ADB screencap timed out after 10s`))
143
215
  }, 10000)
144
216
 
145
- child.on('close', (code) => {
217
+ child.on('close', async (code) => {
146
218
  clearTimeout(timeout)
147
219
  if (code !== 0) {
148
220
  reject(new Error(stderr.trim() || `Screencap failed with code ${code}`))
@@ -152,28 +224,91 @@ export class AndroidObserve {
152
224
  const screenshotBuffer = Buffer.concat(chunks)
153
225
  const screenshotBase64 = screenshotBuffer.toString('base64')
154
226
 
155
- execAdb(['shell', 'wm', 'size'], deviceId)
156
- .then(sizeStdout => {
157
- let width = 0
158
- let height = 0
159
- const match = sizeStdout.match(/Physical size: (\d+)x(\d+)/)
160
- if (match) {
161
- width = parseInt(match[1], 10)
162
- height = parseInt(match[2], 10)
227
+ const parsed = parsePngSize(screenshotBuffer)
228
+ if (parsed.width > 0 && parsed.height > 0) {
229
+ // Attempt to convert to WebP (preferred) and provide JPEG fallback (awaited to avoid race)
230
+ try {
231
+ const sharpModule = await import('sharp'); const sharp = sharpModule && (sharpModule as any).default ? (sharpModule as any).default : sharpModule;
232
+ const buf = screenshotBuffer;
233
+ const img = sharp(buf);
234
+ const meta = await img.metadata().catch((err: any) => { console.error('sharp.metadata failed (Android):', err); return {} as any });
235
+ const hasAlpha = !!meta.hasAlpha || (meta.channels && meta.channels > 3);
236
+
237
+ let webpBuf: Buffer | null = null;
238
+ let jpegBuf: Buffer | null = null;
239
+ try {
240
+ webpBuf = await img.webp({ quality: 80 }).toBuffer();
241
+ } catch (err) {
242
+ console.error('WebP conversion failed (Android):', err instanceof Error ? err.message : String(err));
243
+ webpBuf = null;
244
+ }
245
+ try {
246
+ jpegBuf = await img.jpeg({ quality: 80 }).toBuffer();
247
+ } catch (err) {
248
+ console.error('JPEG conversion failed (Android):', err instanceof Error ? err.message : String(err));
249
+ jpegBuf = null;
250
+ }
251
+
252
+ if (hasAlpha) {
253
+ if (webpBuf) {
254
+ const webpB64 = webpBuf.toString('base64')
255
+ const jpegB64 = jpegBuf ? jpegBuf.toString('base64') : null
256
+ resolve({ device: deviceInfo, screenshot: webpB64, screenshot_mime: 'image/webp', screenshot_fallback: jpegB64, screenshot_fallback_mime: jpegB64 ? 'image/jpeg' : undefined, resolution: { width: parsed.width, height: parsed.height } } as any)
257
+ return
258
+ }
259
+ const pngB64 = buf.toString('base64')
260
+ resolve({ device: deviceInfo, screenshot: pngB64, screenshot_mime: 'image/png', resolution: { width: parsed.width, height: parsed.height } })
261
+ return
163
262
  }
164
- resolve({
165
- device: deviceInfo,
166
- screenshot: screenshotBase64,
167
- resolution: { width, height }
263
+
264
+ if (webpBuf) {
265
+ const webpB64 = webpBuf.toString('base64')
266
+ const jpegB64 = jpegBuf ? jpegBuf.toString('base64') : null
267
+ resolve({ device: deviceInfo, screenshot: webpB64, screenshot_mime: 'image/webp', screenshot_fallback: jpegB64, screenshot_fallback_mime: jpegB64 ? 'image/jpeg' : undefined, resolution: { width: parsed.width, height: parsed.height } } as any)
268
+ return
269
+ }
270
+
271
+ if (jpegBuf) {
272
+ resolve({ device: deviceInfo, screenshot: jpegBuf.toString('base64'), screenshot_mime: 'image/jpeg', resolution: { width: parsed.width, height: parsed.height } })
273
+ return
274
+ }
275
+
276
+ // No conversions succeeded; return original PNG
277
+ resolve({ device: deviceInfo, screenshot: screenshotBase64, screenshot_mime: 'image/png', resolution: { width: parsed.width, height: parsed.height } })
278
+ return
279
+ } catch (err) {
280
+ console.error('Screenshot conversion pipeline failed (Android):', err instanceof Error ? err.message : String(err));
281
+ // Conversion failed - fall back to original PNG with parsed resolution
282
+ resolve({ device: deviceInfo, screenshot: screenshotBase64, screenshot_mime: 'image/png', resolution: { width: parsed.width, height: parsed.height } })
283
+ return
284
+ }
285
+ } else {
286
+ // Fallback to querying wm size if parsing failed
287
+ execAdb(['shell', 'wm', 'size'], deviceId)
288
+ .then(sizeStdout => {
289
+ let width = 0
290
+ let height = 0
291
+ const match = sizeStdout.match(/Physical size: (\d+)x(\d+)/)
292
+ if (match) {
293
+ width = parseInt(match[1], 10)
294
+ height = parseInt(match[2], 10)
295
+ }
296
+ resolve({
297
+ device: deviceInfo,
298
+ screenshot: screenshotBase64,
299
+ screenshot_mime: 'image/png',
300
+ resolution: { width, height }
301
+ })
168
302
  })
169
- })
170
- .catch(() => {
171
- resolve({
172
- device: deviceInfo,
173
- screenshot: screenshotBase64,
174
- resolution: { width: 0, height: 0 }
303
+ .catch(() => {
304
+ resolve({
305
+ device: deviceInfo,
306
+ screenshot: screenshotBase64,
307
+ screenshot_mime: 'image/png',
308
+ resolution: { width: 0, height: 0 }
309
+ })
175
310
  })
176
- })
311
+ }
177
312
  })
178
313
 
179
314
  child.on('error', (err) => {
@@ -38,18 +38,29 @@ export class ToolsObserve {
38
38
  return await (observe as AndroidObserve).getCurrentScreen(resolved.id)
39
39
  }
40
40
 
41
- static async getLogsHandler({ platform, appId, deviceId, lines }: { platform?: 'android' | 'ios', appId?: string, deviceId?: string, lines?: number }) {
41
+ static async getLogsHandler({ platform, appId, deviceId, pid, tag, level, contains, since_seconds, limit, lines }: { platform?: 'android' | 'ios', appId?: string, deviceId?: string, pid?: number, tag?: string, level?: string, contains?: string, since_seconds?: number, limit?: number, lines?: number }) {
42
42
  const { observe, resolved } = await ToolsObserve.resolveObserve(platform, deviceId, appId)
43
+ const filters = { appId, deviceId: resolved.id, pid, tag, level, contains, since_seconds, limit: limit ?? lines }
44
+
45
+ // Validate filters
46
+ if (level && !['VERBOSE','DEBUG','INFO','WARN','ERROR'].includes(level.toString().toUpperCase())) {
47
+ return { device: resolved, logs: [], crashLines: [], logCount: 0, error: { code: 'INVALID_FILTER', message: `Unsupported level filter: ${level}` } } as any
48
+ }
49
+
43
50
  if (observe instanceof AndroidObserve) {
44
- const response = await observe.getLogs(appId, lines ?? 200, resolved.id)
51
+ const response = await observe.getLogs(filters)
45
52
  const logs = Array.isArray(response.logs) ? response.logs : []
46
- const crashLines = logs.filter(line => line.includes('FATAL EXCEPTION'))
47
- return { device: response.device, logs, crashLines }
53
+ const crashLines = logs.filter(entry => /FATAL EXCEPTION/i.test(entry.message))
54
+ const anyFilterApplied = !!(appId || pid || tag || level || contains || since_seconds)
55
+ if (anyFilterApplied && logs.length === 0) return { device: response.device, logs: [], crashLines: [], logCount: 0, error: { code: 'LOGS_UNAVAILABLE', message: 'No logs match filters' } } as any
56
+ return { device: response.device, logs, crashLines, logCount: response.logCount }
48
57
  } else {
49
- const resp = await (observe as iOSObserve).getLogs(appId, resolved.id)
58
+ const resp = await (observe as iOSObserve).getLogs(filters)
50
59
  const logs = Array.isArray(resp.logs) ? resp.logs : []
51
- const crashLines = logs.filter(l => l.includes('FATAL EXCEPTION'))
52
- return { device: resp.device, logs, crashLines }
60
+ const crashLines = logs.filter(entry => /FATAL EXCEPTION/i.test(entry.message))
61
+ const anyFilterApplied = !!(appId || pid || tag || level || contains || since_seconds)
62
+ if (anyFilterApplied && logs.length === 0) return { device: resp.device, logs: [], crashLines: [], logCount: 0, error: { code: 'LOGS_UNAVAILABLE', message: 'No logs match filters' } } as any
63
+ return { device: resp.device, logs, crashLines, logCount: resp.logCount }
53
64
  }
54
65
  }
55
66
 
@@ -96,7 +107,7 @@ export class ToolsObserve {
96
107
 
97
108
  // Parallel fetches for performance: screenshot, current screen, fingerprint, ui tree, and log stream/get logs
98
109
  const sid = sessionId || 'default'
99
- const tasks = {
110
+ const tasks: Record<string, Promise<any>> = {
100
111
  screenshot: ToolsObserve.captureScreenshotHandler({ platform, deviceId }),
101
112
  currentScreen: (!platform || platform === 'android') ? ToolsObserve.getCurrentScreenHandler({ deviceId }) : Promise.resolve(null),
102
113
  fingerprint: ToolsObserve.getScreenFingerprintHandler({ platform, deviceId }),
@@ -145,10 +156,20 @@ export class ToolsObserve {
145
156
  let entries: any[] = Array.isArray(out._streamEntries) ? out._streamEntries : []
146
157
  if (!entries || entries.length === 0) {
147
158
  const gl = await ToolsObserve.getLogsHandler({ platform, appId, deviceId, lines: logLines })
148
- const raw: string[] = (gl && (gl as any).logs) ? (gl as any).logs : []
149
- entries = raw.slice(-Math.max(0, logLines)).map(line => {
150
- const level = /\b(FATAL EXCEPTION|ERROR| E )\b/i.test(line) ? 'ERROR' : /\b(WARN| W )\b/i.test(line) ? 'WARN' : 'INFO'
151
- return { timestamp: null, level, message: line }
159
+ const raw: any[] = (gl && (gl as any).logs) ? (gl as any).logs : []
160
+ // raw may be structured entries or strings
161
+ entries = raw.slice(-Math.max(0, logLines)).map(item => {
162
+ if (!item) return { timestamp: null, level: 'INFO', message: '' }
163
+ if (typeof item === 'string') {
164
+ const level = /\b(FATAL EXCEPTION|ERROR| E )\b/i.test(item) ? 'ERROR' : /\b(WARN| W )\b/i.test(item) ? 'WARN' : 'INFO'
165
+ return { timestamp: null, level, message: item }
166
+ }
167
+ const msg = item.message || item.msg || JSON.stringify(item)
168
+ const levelRaw = item.level || item.levelName || item._level || ''
169
+ const level = (levelRaw && String(levelRaw)).toUpperCase() || (/\bERROR\b/i.test(msg) ? 'ERROR' : /\bWARN\b/i.test(msg) ? 'WARN' : 'INFO')
170
+ const ts = item.timestamp || item._iso || null
171
+ const tsNum = (ts && typeof ts === 'string') ? (isNaN(new Date(ts).getTime()) ? null : new Date(ts).getTime()) : (typeof ts === 'number' ? ts : null)
172
+ return { timestamp: tsNum, level, message: msg }
152
173
  })
153
174
  } else {
154
175
  entries = entries.map(ent => {