Skip to content

Commit a1825a7

Browse files
committed
Filter the log by the process that wrote it
Which process wrote a line is not in the line's words, so no search can ask it, but it is the question a reader most often has of a log several applications share. It becomes a filter like the others: the scan counts uids as it already counts tags and levels, the sheet offers them as chips between the levels and the tags, and a chosen one shows as a chip that undoes itself -- with its own avatar, since it sits beside the tag's and says something else. The three read as one question. Within a category the choices are alternatives -- two applications, two levels, and now two tags, which was a single choice before and had no reason to be -- while across categories they are conditions on the same line. The counts follow: each category is counted under the others and never under itself, so choosing an application leaves the sheet listing the tags that application writes, with the counts it wrote them, while the applications beside it stay listed and switchable. Anything the rest of the filter has left with nothing goes, since a chip that selects no line is a choice not worth offering; a chosen one stays regardless, or a filter could not be undone. A uid is not a name, so the host puts one to it through writerLabel, asked once per scan and off the main thread. Both hosts read logs written by installed packages and would answer alike, so the rule is written once, in WriterLabeler: one package holding the uid is named by its label, a uid several share by the platform's name for it. The platform names only what it has a package setting for, and the writers that dominate a privileged daemon's log have none -- root has no setting at all -- so the few fixed assignments a reader actually meets are spelled out. What remains unnamed keeps its number, which still separates one process from another, and a writer outside the first user carries the user it belongs to, since the same application in a second profile is a different writer with the same label. hasVerboseStream, from the same seam: a host reduced to its own process's log has no second stream to unfold into, where both would be the same lines under two names, and says so instead of offering a control that changes nothing.
1 parent 35b510c commit a1825a7

9 files changed

Lines changed: 373 additions & 49 deletions

File tree

Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,54 @@
1+
package org.matrix.vector.ui.logs
2+
3+
/**
4+
* Counts what a log holds while deciding what the current query keeps, in the host's one scanning pass.
5+
*
6+
* A category is counted under the *other* categories, never under itself. Choosing an application and then opening the
7+
* filter should show the tags that application writes, with the counts it wrote them — not the tags of a log the reader
8+
* has already narrowed away, which would offer choices that select nothing, nor the counts of the whole file, which
9+
* would promise lines the filter will not show. Its own category is left out so that the alternatives beside a chosen
10+
* chip stay visible: picking one tag must not make every other tag disappear, or a second one could never be added.
11+
*
12+
* The rule falls out of that: for each facet, every condition applies except the facet's own.
13+
*
14+
* Both hosts scan their own format but ask the same question of it, so this is written once. [add] reports whether the
15+
* row survives the whole query, which is what the caller records as a match.
16+
*/
17+
class LogFacetCounter(private val query: LogQuery) {
18+
19+
private val tags = HashMap<String, Int>()
20+
private val levels = HashMap<LogLevel, Int>()
21+
private val writers = HashMap<Int, Int>()
22+
23+
fun add(row: LogRow): Boolean {
24+
if (row !is LogRow.Entry) return query.matches(row)
25+
26+
val level = query.matchesLevel(row)
27+
val writer = query.matchesWriter(row)
28+
val tag = query.matchesTag(row)
29+
val text = query.matchesText(row)
30+
31+
if (writer && tag && text) levels[row.level] = (levels[row.level] ?: 0) + 1
32+
if (level && tag && text && row.uid >= 0) writers[row.uid] = (writers[row.uid] ?: 0) + 1
33+
if (level && writer && text) tags[row.tag] = (tags[row.tag] ?: 0) + 1
34+
35+
return level && writer && tag && text
36+
}
37+
38+
/**
39+
* Ordered by weight: what a log is mostly made of is what a reader is mostly looking to include or exclude.
40+
*
41+
* Whatever the query already selects is listed even where nothing survives the rest of the filter -- at a count of
42+
* zero, which is the honest number. A chip counted out of existence would be a filter still narrowing the log with
43+
* no way left to switch it off.
44+
*/
45+
fun facets(): LogFacets {
46+
query.tags.forEach { tags.putIfAbsent(it, 0) }
47+
query.uids.forEach { writers.putIfAbsent(it, 0) }
48+
return LogFacets(
49+
tags = tags.entries.sortedByDescending { it.value }.map { it.key to it.value },
50+
levels = levels,
51+
writers = writers.entries.sortedByDescending { it.value }.map { LogWriter(it.key, it.value) },
52+
)
53+
}
54+
}

manager-ui/src/main/kotlin/org/matrix/vector/ui/logs/LogModel.kt

Lines changed: 45 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -63,6 +63,12 @@ sealed interface LogRow {
6363
val date: String,
6464
/** `HH:mm:ss.SSS`. The date is redundant on every row and moves to the separator. */
6565
val time: String,
66+
/**
67+
* Who wrote the line, or -1 where the format carries no uid for it.
68+
*
69+
* Negative rather than zero, because zero is root, and a log written by a privileged daemon is full of its
70+
* lines: a sentinel sharing that value would hide them from a filter that lists writers.
71+
*/
6672
val uid: Int,
6773
val pid: Int,
6874
val tid: Int,
@@ -115,34 +121,62 @@ class LogIndex(val bounds: LongArray, val droppedLeading: Int) {
115121
/** What [LogContent.scan] found: the filtered line numbers, and what the file contains. */
116122
class LogScanResult(val matches: IntArray?, val facets: LogFacets)
117123

118-
/** The tags and levels actually present, with counts, so the filter sheet cannot go stale. */
124+
/**
125+
* A process the log has lines from, and how many it wrote.
126+
*
127+
* Counted in the host's own scanning pass, where the lines already are. Naming it is a separate question, asked of the
128+
* host through [LogSource.writerLabel] only for the few uids a filter actually offers, rather than carried here for
129+
* every one of them.
130+
*/
131+
data class LogWriter(val uid: Int, val count: Int)
132+
133+
/** The tags, levels and writers actually present, with counts, so the filter sheet cannot go stale. */
119134
data class LogFacets(
120135
val tags: List<Pair<String, Int>> = emptyList(),
121136
val levels: Map<LogLevel, Int> = emptyMap(),
137+
val writers: List<LogWriter> = emptyList(),
122138
)
123139

124-
/** Everything that narrows the view. All of it is applied in one pass over the file. */
140+
/**
141+
* Everything that narrows the view. All of it is applied in one pass over the file.
142+
*
143+
* Within a category the choices are alternatives and within a category alone: two levels, two writers or two tags are
144+
* read together, because "these two apps" and "these two tags" are ordinary questions. Across categories they are
145+
* conditions on the same line, so a level, a writer and a tag chosen together mean all three at once.
146+
*/
125147
data class LogQuery(
148+
/** Levels to keep; empty for all of them. */
126149
val levels: Set<LogLevel> = emptySet(),
127-
val tag: String? = null,
150+
/** Writers to keep, by uid; empty for all of them. */
151+
val uids: Set<Int> = emptySet(),
152+
/** Tags to keep; empty for all of them. */
153+
val tags: Set<String> = emptySet(),
128154
val text: String = "",
129155
) {
130156
val isActive: Boolean
131-
get() = levels.isNotEmpty() || tag != null || text.isNotBlank()
157+
get() = levels.isNotEmpty() || uids.isNotEmpty() || tags.isNotEmpty() || text.isNotBlank()
158+
159+
fun matchesLevel(row: LogRow.Entry): Boolean = levels.isEmpty() || row.level in levels
160+
161+
fun matchesWriter(row: LogRow.Entry): Boolean = uids.isEmpty() || row.uid in uids
162+
163+
fun matchesTag(row: LogRow.Entry): Boolean = tags.isEmpty() || row.tag in tags
164+
165+
fun matchesText(row: LogRow.Entry): Boolean =
166+
text.isBlank() ||
167+
row.message.contains(text, ignoreCase = true) ||
168+
row.tag.contains(text, ignoreCase = true)
132169

133170
fun matches(row: LogRow): Boolean =
134171
when (row) {
135172
is LogRow.Entry ->
136-
(levels.isEmpty() || row.level in levels) &&
137-
(tag == null || row.tag == tag) &&
138-
(text.isBlank() ||
139-
row.message.contains(text, ignoreCase = true) ||
140-
row.tag.contains(text, ignoreCase = true))
141-
// A rotation banner has neither level nor tag, so it survives only a plain text
173+
matchesLevel(row) && matchesWriter(row) && matchesTag(row) && matchesText(row)
174+
// A rotation banner has no level, no writer and no tag, so it survives only a plain text
142175
// search. It marks where a writer restarted, which is worth keeping when it can be.
143176
is LogRow.Marker ->
144177
levels.isEmpty() &&
145-
tag == null &&
178+
uids.isEmpty() &&
179+
tags.isEmpty() &&
146180
(text.isBlank() || row.text.contains(text, ignoreCase = true))
147181
is LogRow.DayBreak -> false
148182
}

manager-ui/src/main/kotlin/org/matrix/vector/ui/logs/LogSource.kt

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,15 @@ interface LogSource {
3232
/** The rotated parts of a stream, oldest first; the last is the live one. Empty when a host keeps no parts. */
3333
suspend fun parts(verbose: Boolean): List<String>
3434

35+
/**
36+
* A name for the process behind a uid, or null to leave it as the number.
37+
*
38+
* The log records who wrote a line as a uid, which is not something a reader recognises. Asked only of the uids the
39+
* filter offers, so a host may resolve it however it likes -- LSPatch reads its package list -- without that cost
40+
* falling on the scan.
41+
*/
42+
fun writerLabel(uid: Int): String? = null
43+
3544
/**
3645
* Opens a stream for reading. [part] names a rotated part, or null for the live tail.
3746
*
@@ -42,6 +51,21 @@ interface LogSource {
4251
*/
4352
suspend fun open(verbose: Boolean, part: String?): Result<LogContent?>
4453

54+
/**
55+
* Whether the host actually has a second, verbose stream to unfold into.
56+
*
57+
* A host can be reduced to a single log — one that reads only its own process because the
58+
* privileged backend it normally reads the device through is unavailable — and then the two
59+
* streams would be the same lines under two names. Saying so drops the unfold control instead of
60+
* offering a choice that changes nothing.
61+
*
62+
* Read from composition on every frame that matters, because a backend can come and go while the screen is open and
63+
* the control has to follow it. A host whose answer can change must therefore back it with snapshot state; one
64+
* computing it from a plain field would keep the control on screen after the stream behind it was gone.
65+
*/
66+
val hasVerboseStream: Boolean
67+
get() = true
68+
4569
// --- Verbose-logging preference (distinct from which stream is on screen) ------------------
4670

4771
/** Whether the host has a persistent "write verbose lines at all" preference to toggle. */

0 commit comments

Comments
 (0)