Conversation
Adds a new "live tail" mode that watches Slack's local log directories and incrementally parses new log lines as they appear. Entry points include welcome screen buttons and Electron menu items (macOS only). Key components: - LineParser: extracted incremental parsing state machine from readLogFile - LiveTailWatcher: fs.watch-based file/directory monitoring with byte-offset reads - Renderer live-tail update pipeline with MobX-driven re-renders - Auto-scroll behavior and Live/Stop UI controls in header Also fixes pre-existing MobX strict-mode violations in getSuggestions and LogTimeView onZoomComplete. Co-Authored-By: Claude <svc-devxp-claude@slack-corp.com>
|
|
||
| private watchedDirs: string[] = []; | ||
|
|
||
| private async scanDirectory(): Promise<UnzippedFiles> { |
There was a problem hiding this comment.
see above in ipc.ts line 404
we're recursively scanning files here
| ); | ||
| } | ||
| /** Register IPC handlers for starting, stopping, and cleaning up live tail sessions. */ | ||
| private setupLiveTail() { |
There was a problem hiding this comment.
Blocking: This seems unsafe! We should define a file allow list, otherwise we can freely recurse and stream any file content under a given path.
| private onFileChange(fullPath: string) { | ||
| if (this.stopped) return; | ||
|
|
||
| const watched = this.watchedFiles.get(fullPath); | ||
| if (!watched) return; | ||
|
|
||
| let newSize: number; | ||
| try { | ||
| newSize = fs.statSync(fullPath).size; | ||
| } catch { | ||
| return; | ||
| } | ||
|
|
||
| if (newSize <= watched.byteOffset) { | ||
| if (newSize < watched.byteOffset) { | ||
| d('File %s was truncated/rotated, resetting', fullPath); | ||
| this.resetFile(watched); | ||
| } | ||
| return; | ||
| } | ||
|
|
||
| this.readIncremental(watched, newSize); | ||
| } |
There was a problem hiding this comment.
| private onFileChange(fullPath: string) { | |
| if (this.stopped) return; | |
| const watched = this.watchedFiles.get(fullPath); | |
| if (!watched) return; | |
| let newSize: number; | |
| try { | |
| newSize = fs.statSync(fullPath).size; | |
| } catch { | |
| return; | |
| } | |
| if (newSize <= watched.byteOffset) { | |
| if (newSize < watched.byteOffset) { | |
| d('File %s was truncated/rotated, resetting', fullPath); | |
| this.resetFile(watched); | |
| } | |
| return; | |
| } | |
| this.readIncremental(watched, newSize); | |
| } | |
| private onFileChange(fullPath: string) { | |
| if (this.stopped || !this.watchedFiles.has(fullPath)) return; | |
| this.dirtyFiles.add(fullPath); | |
| if (!this.readTimer) { | |
| this.readTimer = setTimeout(() => this.drainDirty(), 20); | |
| } | |
| } | |
| private drainDirty() { | |
| this.readTimer = null; | |
| if (this.stopped) return; | |
| for (const fullPath of this.dirtyFiles) { | |
| const watched = this.watchedFiles.get(fullPath); | |
| if (!watched) continue; | |
| let newSize: number; | |
| try { | |
| newSize = fs.statSync(fullPath).size; | |
| } catch { | |
| continue; | |
| } | |
| if (newSize <= watched.byteOffset) { | |
| if (newSize < watched.byteOffset) { | |
| d('File %s was truncated/rotated, resetting', fullPath); | |
| this.resetFile(watched); | |
| } | |
| continue; | |
| } | |
| this.readIncremental(watched, newSize); | |
| } | |
| this.dirtyFiles.clear(); | |
| } |
There was a problem hiding this comment.
we can add a 20ms debounce, or even a 50tbh so we aren't creating a new read stream and readline scaffolding on each line, if we're bursting logs we can batch read and write.
| private directoryWatchers: fs.FSWatcher[] = []; | ||
| private watchedFiles = new Map<string, WatchedFile>(); | ||
| private pendingUpdates: LiveTailUpdate[] = []; | ||
| private flushTimer: ReturnType<typeof setTimeout> | null = null; |
There was a problem hiding this comment.
| private flushTimer: ReturnType<typeof setTimeout> | null = null; | |
| private flushTimer: ReturnType<typeof setTimeout> | null = null; | |
| private dirtyFiles = new Set<string>(); | |
| private readTimer: ReturnType<typeof setTimeout> | null = null; | |
| private stopped = false; |
There was a problem hiding this comment.
see comment about batching read and writes across a debounce below
| } | ||
| this.watchedFiles.clear(); | ||
|
|
||
| if (this.flushTimer) { |
There was a problem hiding this comment.
| if (this.flushTimer) { | |
| if (this.flushTimer) { | |
| clearTimeout(this.flushTimer); | |
| this.flushTimer = null; | |
| } | |
| if (this.readTimer) { | |
| clearTimeout(this.readTimer); | |
| this.readTimer = null; | |
| } | |
| this.dirtyFiles.clear(); | |
| this.pendingUpdates = []; |
There was a problem hiding this comment.
cleanup for batching read and writes
| for (const logType of affectedTypes) { | ||
| if (logType === LogType.ALL) continue; | ||
|
|
||
| const existingMerged = state.mergedLogFiles![logType]; |
There was a problem hiding this comment.
nit: I'd like to change this into a guard instead, makes for better error logs
| * Watches a Slack logs directory for changes, incrementally parses new lines, | ||
| * and sends batched updates to the renderer via IPC every `FLUSH_DEBOUNCE_MS`. | ||
| */ | ||
| export class LiveTailWatcher { |
There was a problem hiding this comment.
follow-up: all of our parsing work runs in the main thread, if CE uses these on non-dev machines, i'm not sure if all the regex matching can cause sleuth to lag, I think its a much better architectural decision to treat the file stream like a message queue and move all the reading, decoding and regex matching to a node worker thread instead.
This PR adds a Live Tail mode that peeks at your local
Slack.applog folder. Useful for making your local logs human-readable and filterable, especially when you need to inspect across process types.