jdaugherty commented on code in PR #16631:
URL: https://github.com/apache/grails-core/pull/16631#discussion_r4367062421


##########
grails-datamapping-core/src/main/groovy/org/apache/grails/data/core/querytail/QueryTailRequestAttribution.groovy:
##########
@@ -0,0 +1,315 @@
+/*
+ *  Licensed to the Apache Software Foundation (ASF) under one
+ *  or more contributor license agreements.  See the NOTICE file
+ *  distributed with this work for additional information
+ *  regarding copyright ownership.  The ASF licenses this file
+ *  to you under the Apache License, Version 2.0 (the
+ *  "License"); you may not use this file except in compliance
+ *  with the License.  You may obtain a copy of the License at
+ *
+ *    https://www.apache.org/licenses/LICENSE-2.0
+ *
+ *  Unless required by applicable law or agreed to in writing,
+ *  software distributed under the License is distributed on an
+ *  "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ *  KIND, either express or implied.  See the License for the
+ *  specific language governing permissions and limitations
+ *  under the License.
+ */
+package org.apache.grails.data.core.querytail
+
+import java.lang.reflect.Method
+import java.util.concurrent.atomic.AtomicLong
+import java.util.regex.Pattern
+
+import groovy.transform.CompileStatic
+
+import org.springframework.util.ClassUtils
+import org.springframework.web.context.request.RequestAttributes
+import org.springframework.web.context.request.RequestContextHolder
+import org.springframework.web.context.request.ServletRequestAttributes
+
+/**
+ * Attributes a captured statement to the web request on the current thread. 
Grails keeps the
+ * request, and the controller and action it resolved, on the thread that 
serves it, so a statement
+ * run by an interceptor, the action or the view all land on the same page. 
The first statement of
+ * a request gives it an id that every later statement of that request 
repeats, which is how the
+ * {@code query-tail} command counts statements per request. Work on any other 
thread is attributed to
+ * the thread by name.
+ *
+ * <p>The request is recorded as its method and path, with the values in the 
path replaced by
+ * {@code ?}: the values of the parameters the URL mapping took from it and 
those of its query, and
+ * any segment that is a number, a UUID or a long hexadecimal token, so that 
an id, a token or a key
+ * carried in a path never reaches the capture file.</p>
+ *
+ * <p>Spring Web is optional: without it on the classpath every statement is 
attributed to its
+ * thread.</p>
+ *
+ * @since 8.1
+ */
+@CompileStatic
+class QueryTailRequestAttribution {
+
+    /** The request attribute holding the id this attribution gave a request. 
*/
+    public static final String REQUEST_ID_ATTRIBUTE = 
'org.apache.grails.data.core.querytail.REQUEST_ID'
+
+    /** The request attribute Grails sets the controller name in. */
+    public static final String CONTROLLER_NAME_ATTRIBUTE = 
'org.grails.CONTROLLER_NAME_ATTRIBUTE'
+
+    /** The request attribute Grails sets the controller namespace in. */
+    public static final String CONTROLLER_NAMESPACE_ATTRIBUTE = 
'org.grails.CONTROLLER_NAMESPACE_ATTRIBUTE'
+
+    /** The request attribute Grails sets the action name in. */
+    public static final String ACTION_NAME_ATTRIBUTE = 
'org.grails.ACTION_NAME_ATTRIBUTE'
+
+    private static final boolean SPRING_WEB_PRESENT = ClassUtils.isPresent(
+            'org.springframework.web.context.request.RequestContextHolder', 
QueryTailRequestAttribution.classLoader)
+
+    private final AtomicLong requestCounter = new AtomicLong()
+    private final String requestIdPrefix
+
+    /**
+     * An attribution whose request ids are {@code r1}, {@code r2} and so on.
+     */
+    QueryTailRequestAttribution() {
+        this('')
+    }
+
+    /**
+     * @param requestIdPrefix what every request id starts with, which tells 
the requests of one JVM
+     * from those of another writing the same capture
+     */
+    QueryTailRequestAttribution(String requestIdPrefix) {
+        this.requestIdPrefix = requestIdPrefix ?: ''
+    }
+
+    /**
+     * Fills in the request id, page and request of a record from the current 
thread.
+     *
+     * @param record the record of a statement that just ran on this thread
+     * @return the same record
+     */
+    QueryTailRecord attribute(QueryTailRecord record) {
+        if (SPRING_WEB_PRESENT && WebRequestLookup.attribute(record, 
requestCounter, requestIdPrefix)) {
+            return record
+        }
+        record.requestId = null
+        record.request = null
+        record.page = QueryTailRecord.THREAD_PAGE_PREFIX + 
Thread.currentThread().name
+        record
+    }
+
+    /**
+     * Keeps every reference to Spring Web, in a signature as well as in a 
body, in a class that is only
+     * loaded once Spring Web is known to be present. The outer class can then 
be loaded, and reflected
+     * on, without Spring Web on the classpath.
+     */
+    @CompileStatic
+    private static class WebRequestLookup {
+
+        private static final String REQUEST_ID_PREFIX = 'r'
+
+        private static final String PLACEHOLDER = '?'
+
+        /** The parameters a URL mapping sets that name code rather than carry 
a value. */
+        private static final Set<String> MAPPING_PARAMETERS = ['controller', 
'action', 'namespace', 'plugin', 'format'] as Set<String>
+
+        /** A segment that is a number, a UUID or a long hexadecimal token is 
a value whatever the mapping says. */
+        private static final Pattern VALUE_SHAPED = Pattern.compile(
+                
'(?i)[0-9]+|[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}|[0-9a-f]{32,}')
+
+        private static final boolean SERVLET_PRESENT = ClassUtils.isPresent(
+                'jakarta.servlet.http.HttpServletRequest', 
WebRequestLookup.classLoader)
+
+        /** The {@code getParams()} of a Grails web request, which holds the 
parameters its URL mapping took, by class. */
+        private static final ClassValue<Method> PARAMETERS = new 
ClassValue<Method>() {
+            @Override
+            protected Method computeValue(Class<?> type) {
+                try {
+                    return type.getMethod('getParams')
+                }
+                catch (NoSuchMethodException ignored) {
+                    return null
+                }
+            }
+        }
+
+        /**
+         * Fills in a record from the request on the current thread.
+         *
+         * @param record the record to fill in
+         * @param requestCounter the counter the next request id is taken from
+         * @param requestIdPrefix what every request id starts with
+         * @return whether there is a request on the current thread
+         */
+        static boolean attribute(QueryTailRecord record, AtomicLong 
requestCounter, String requestIdPrefix) {
+            RequestAttributes attributes = 
RequestContextHolder.getRequestAttributes()
+            if (attributes == null) {
+                return false
+            }
+            record.requestId = requestId(attributes, requestCounter, 
requestIdPrefix)
+            record.page = page(attributes)
+            record.request = SERVLET_PRESENT ? 
ServletRequestLookup.describe(attributes) : null
+            true
+        }
+
+        /**
+         * Builds the request line: the method and the path, with the values 
in the path, the parameters
+         * the URL mapping took from it among them, replaced.
+         *
+         * @param method the HTTP method
+         * @param path the request path
+         * @param attributes the request, whose mapped parameters are read 
when it is a Grails web request
+         * @param parameters the parameters of the servlet request, by name, 
each a {@code String[]}
+         */
+        static String describe(String method, String path, RequestAttributes 
attributes, Map<?, ?> parameters) {
+            Set<String> values = new HashSet<>()
+            addValues(values, mappedParameters(attributes))
+            addValues(values, parameters)
+            "${method} ${redactPath(path, values)}".toString()
+        }
+
+        private static String requestId(RequestAttributes attributes, 
AtomicLong requestCounter, String requestIdPrefix) {
+            synchronized (attributes) {
+                Object existing = 
attributes.getAttribute(REQUEST_ID_ATTRIBUTE, RequestAttributes.SCOPE_REQUEST)
+                if (existing != null) {
+                    return existing.toString()
+                }
+                String id = requestIdPrefix + REQUEST_ID_PREFIX + 
requestCounter.incrementAndGet()
+                attributes.setAttribute(REQUEST_ID_ATTRIBUTE, id, 
RequestAttributes.SCOPE_REQUEST)
+                return id
+            }
+        }
+
+        private static String page(RequestAttributes attributes) {
+            Object controller = 
attributes.getAttribute(CONTROLLER_NAME_ATTRIBUTE, 
RequestAttributes.SCOPE_REQUEST)
+            if (controller == null) {
+                return null
+            }
+            Object namespace = 
attributes.getAttribute(CONTROLLER_NAMESPACE_ATTRIBUTE, 
RequestAttributes.SCOPE_REQUEST)
+            Object action = attributes.getAttribute(ACTION_NAME_ATTRIBUTE, 
RequestAttributes.SCOPE_REQUEST)
+            StringBuilder page = new StringBuilder()
+            if (namespace != null) {
+                page.append(namespace).append(':')
+            }
+            page.append(controller)
+            if (action != null) {
+                page.append('/').append(action)
+            }
+            page.toString()
+        }
+
+        /**
+         * @return the parameters of a Grails web request, which include those 
its URL mapping took from
+         * the path, or null for a request that is not one
+         */
+        private static Map<?, ?> mappedParameters(RequestAttributes 
attributes) {
+            Method parameters = PARAMETERS.get(attributes.getClass())
+            if (parameters == null) {
+                return null
+            }
+            try {
+                Object value = parameters.invoke(attributes)
+                return value instanceof Map ? (Map<?, ?>) value : null
+            }
+            catch (ReflectiveOperationException | RuntimeException ignored) {
+                return null
+            }
+        }
+
+        private static void addValues(Set<String> values, Map<?, ?> 
parameters) {
+            if (parameters == null) {
+                return
+            }
+            for (Map.Entry<?, ?> entry in parameters.entrySet()) {
+                if (MAPPING_PARAMETERS.contains(String.valueOf(entry.key))) {
+                    continue
+                }
+                Object value = entry.value
+                if (value instanceof CharSequence) {
+                    values.add(value.toString())
+                }
+                else if (value instanceof Object[]) {
+                    for (Object element in (Object[]) value) {
+                        if (element instanceof CharSequence) {
+                            values.add(element.toString())
+                        }
+                    }
+                }
+            }
+        }
+
+        /**
+         * Replaces the values in a path: a segment that is one of the values, 
with or without an
+         * extension, a run of segments that spells a value holding slashes, 
and any segment shaped
+         * like a value.
+         */
+        private static String redactPath(String path, Set<String> values) {
+            String[] segments = path.split('/', -1)

Review Comment:
   Fixed in 5e5feb5e4d (folded into the feature commit). `redactPath` now 
percent-decodes every segment (UTF-8; a `+` stays literal; a segment that is 
not valid percent-encoding is matched as sent) and matches both the raw and the 
decoded form against the mapped and query parameters, so 
`/search/secret%40example.com` with `[email protected]` is written as `GET 
/search/?`. A value holding slashes is also found when the client encoded its 
slashes into one segment (`/files/reports%2F2026%2Fq3.pdf` → `GET /files/?`) or 
only some of them: the decoded segments are re-split before the run is matched. 
The path itself is still written as sent, with the matched segments replaced. 
Covered in `QueryTailRequestAttributionSpec` (three table rows plus the `%40` 
and the malformed-encoding cases) and through the capture API in 
`QueryTailCaptureSpec`, which asserts that neither `secret%40example.com` nor 
`[email protected]` reaches the file; the guide says the values are found 
whether or not the
  client encoded them.



##########
grails-datamapping-core/src/cli/groovy/org/apache/grails/data/core/cli/querytail/QueryTailRunner.groovy:
##########
@@ -0,0 +1,181 @@
+/*
+ *  Licensed to the Apache Software Foundation (ASF) under one
+ *  or more contributor license agreements.  See the NOTICE file
+ *  distributed with this work for additional information
+ *  regarding copyright ownership.  The ASF licenses this file
+ *  to you under the Apache License, Version 2.0 (the
+ *  "License"); you may not use this file except in compliance
+ *  with the License.  You may obtain a copy of the License at
+ *
+ *    https://www.apache.org/licenses/LICENSE-2.0
+ *
+ *  Unless required by applicable law or agreed to in writing,
+ *  software distributed under the License is distributed on an
+ *  "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ *  KIND, either express or implied.  See the License for the
+ *  specific language governing permissions and limitations
+ *  under the License.
+ */
+package org.apache.grails.data.core.cli.querytail
+
+import groovy.transform.CompileStatic
+
+import org.apache.grails.data.core.querytail.QueryTailRecord
+
+/**
+ * Runs the {@code query-tail} command: waits for the capture file, follows 
it, and prints the
+ * summary or the stream until stopped. The command script hands it the 
console and stops it when
+ * the shell cancels the command.
+ *
+ * @since 8.1
+ */
+@CompileStatic
+class QueryTailRunner {
+
+    /** How to get an application to write the capture file. */
+    public static final String START_HINT =
+            "Start the application with 'grails run-app --query-tail' or 
'./gradlew bootRun -Pgrails.gorm.queryTail.enabled' " +
+            'to capture the statements it runs.'
+
+    final QueryTailOptions options
+    final QueryTailRenderer renderer
+
+    /** How long to wait between looks at the file, in milliseconds. */
+    long pollMillis = 250
+
+    /** How long a summary stays on screen before it is printed again, in 
milliseconds. */
+    long refreshMillis = 1000
+
+    /** How much of the capture one look reads at most, in bytes; what is left 
is read without waiting. */
+    int chunk = QueryTailFileReader.CHUNK
+
+    private volatile boolean stopped
+
+    QueryTailRunner(QueryTailOptions options, PrintStream out, boolean ansi) {
+        this.options = options
+        this.renderer = new QueryTailRenderer(out, ansi)
+        this.renderer.width = options.width
+    }
+
+    /**
+     * Ends the run; the summary on screen stays.
+     */
+    void stop() {
+        stopped = true
+    }
+
+    /**
+     * @return whether the run was stopped
+     */
+    boolean isStopped() {
+        stopped
+    }
+
+    /**
+     * Runs until stopped, or until the summary has been printed once with 
{@link QueryTailOptions#once}.
+     *
+     * @return 0 when it ran, 1 when there was nothing to read
+     */
+    int run() {
+        File file = options.file
+        boolean waited = !file.exists()
+        if (waited && !awaitFile(file)) {
+            return stopped ? 0 : 1
+        }
+        // a capture that appeared while waiting is a run that has just 
started: read it from its first statement
+        QueryTailFileReader reader = new QueryTailFileReader(file, waited || 
options.all || options.once, chunk)
+        try {
+            follow(reader)
+        }
+        finally {
+            reader.close()
+        }
+        0
+    }
+
+    private boolean awaitFile(File file) {
+        if (options.once) {
+            renderer.out.println("No query-tail capture at ${file}.")
+            renderer.out.println(START_HINT)
+            return false
+        }
+        renderer.out.println("Waiting for ${file}. ${START_HINT}")
+        while (!file.exists()) {
+            if (stopped || !pause(pollMillis)) {
+                return false
+            }
+        }
+        true
+    }
+
+    private void follow(QueryTailFileReader reader) {
+        QueryTailSummary summary = new QueryTailSummary()
+        QueryTailRecord previous = null
+        long lastRender = 0
+        boolean changed = true
+        while (true) {
+            List<QueryTailRecord> records = reader.poll()
+            if (reader.restarted) {
+                summary = new QueryTailSummary()
+                renderer.reset()
+                previous = null
+                changed = true
+            }
+            for (QueryTailRecord record : records) {
+                if (options.mode == QueryTailMode.STREAM) {

Review Comment:
   Fixed in 5e5feb5e4d. `follow` applies 
`QueryTailSummary.matchesPage(pageOf(record), options.page)` before rendering a 
streamed record, with the same rule the summary uses (`controller/action`, a 
controller name, or a thread page), and only rendered records become the 
`previous` for the request headers. `QueryTailRunnerSpec` streams four requests 
over three pages with `--page=book/show` and gets the two `book/show` requests, 
headers and footers included, and with `--page=book` all three book requests 
and nothing from `author`; the reference page says `--page` filters the stream 
when `--stream` is given.



-- 
This is an automated message from the Apache Git Service.
To respond to the message, please log on to GitHub and use the
URL above to go to the specific comment.

To unsubscribe, e-mail: [email protected]

For queries about this service, please contact Infrastructure at:
[email protected]

Reply via email to