Skip to content

Commit 82bc6d0

Browse files
authored
Merge pull request #16 from serpapi/debug-option
Add --debug option
2 parents 993b281 + 8e98b0c commit 82bc6d0

8 files changed

Lines changed: 1093 additions & 11 deletions

File tree

‎README.md‎

Lines changed: 25 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -111,14 +111,36 @@ Prints the CLI version, as `--version` does.
111111
- `--jq <expr>`: [jq](https://jqlang.github.io/jq/manual/) filter applied to the JSON result, like `gh --jq`; strings print unquoted and `null` as an empty line, and results stream as they are produced. A filter that prints only `null` gets a warning listing the response's top-level keys. jq is built in, so nothing else needs installing. In bash and zsh, quote the expression with single quotes.
112112
- `--fields <expr>`: search only; see [search](#search).
113113
- `--api-key <key>`: API key. Prefer `SERPAPI_KEY`, since flags show up in `ps` and shell history.
114-
- `--timeout <seconds>`: HTTP request timeout in seconds, such as `120`, or a duration, such as `2m` (default `60`; env `SERPAPI_TIMEOUT`). `0` waits indefinitely. Slow engines such as `google_ai_mode`, and searches with `no_cache=true`, can take longer than the default; raise it when a `network_error` says the request timed out. A timed-out search may still finish on SerpApi's side, count against your searches and be available in your search archive.
114+
- `--timeout <seconds>`: HTTP request timeout in seconds, such as `120`, or a duration, such as `2m` (default `60`; env `SERPAPI_TIMEOUT`). `0` waits indefinitely. Slow engines such as `google_ai_mode`, and searches with `no_cache=true`, can take longer than the default; raise it when a `network_error` says the request timed out, and add `--debug` to see which step stalled. A timed-out search may still finish on SerpApi's side, count against your searches and be available in your search archive.
115115
```bash
116116
# Give an AI Mode query up to two minutes
117117
serpapi search --timeout 120 engine=google_ai_mode q="..."
118118

119119
# Or set it once for the shell session
120120
export SERPAPI_TIMEOUT=120
121121
```
122+
- `--debug`: trace each request on stderr (env `SERPAPI_DEBUG=1`): DNS, TCP connect (local and remote address), TLS handshake, time to first byte, response status and headers (including `Serpapi-Search-Id` and `X-Request-Id`), body size, and, for searches, the server's `search_metadata` timeline correlated with the client's, so you can see whether a long wait happened on the network path before SerpApi received the request, during processing, or while the response travelled back. Each line carries a UTC timestamp and the seconds elapsed since the request started; the API key is redacted; stdout is untouched, so `--jq` and piping still work. On a terminal the trace is plain text:
123+
```
124+
[debug] 2026-10-09T11:50:54.272Z + 0.000s GET https://serpapi.com/search.json?api_key=[REDACTED]&engine=google&q=coffee
125+
[debug] 2026-10-09T11:50:54.272Z + 0.000s Timeout: 60s
126+
[debug] 2026-10-09T11:50:54.300Z + 0.028s DNS resolved: 162.159.142.21, 172.66.2.17
127+
[debug] 2026-10-09T11:50:54.346Z + 0.074s TLS handshake done: TLS 1.3, ALPN="h2"
128+
[debug] 2026-10-09T11:50:54.413Z + 0.141s Using connection: 192.168.1.20:62494 -> 162.159.142.21:443 (new)
129+
[debug] 2026-10-09T11:50:54.414Z + 0.142s Request sent, waiting for response headers
130+
[debug] 2026-10-09T11:50:55.227Z + 0.955s First response byte received
131+
[debug] 2026-10-09T11:50:55.227Z + 0.955s Response: HTTP/2.0 200 OK
132+
[debug] 2026-10-09T11:50:55.227Z + 0.955s Serpapi-Search-Id: 6ac8d51eaf5fdcac5434ae91
133+
[debug] 2026-10-09T11:50:55.234Z + 0.962s Body received: 57483 bytes
134+
[debug] 2026-10-09T11:50:55.234Z + 0.962s Server search: id=6ac8d51eaf5fdcac5434ae91 status=Success total_time_taken=0.5s
135+
[debug] 2026-10-09T11:50:55.234Z + 0.962s Server timeline: created_at=11:50:54Z (+0.0s after request sent), processed_at=11:50:54Z (+0.0s processing), headers received +1.2s after processed_at
136+
```
137+
Piped or redirected, stderr stays one JSON object per line, so scripts that parse it keep working; headers and the timeline travel as structured `data`:
138+
```
139+
{"debug":{"at":"2026-10-09T11:50:54.414Z","elapsed":0.142,"event":"Request sent, waiting for response headers"}}
140+
{"debug":{"at":"2026-10-09T11:50:55.227Z","elapsed":0.955,"event":"Response: HTTP/2.0 200 OK","data":{"status":200,"proto":"HTTP/2.0","headers":{"Serpapi-Search-Id":"6ac8d51e..."}}}}
141+
{"debug":{"at":"2026-10-09T11:50:55.234Z","elapsed":0.962,"event":"Server timeline: ...","data":{"sent_to_created_s":0.0,"processing_s":0.0,"processed_to_headers_s":1.2}}}
142+
```
143+
Server timestamps have one-second resolution, so sub-second deltas in the timeline are noise. When the client waited noticeably longer than `total_time_taken`, a `Note:` event points at the phase that dominated, if this machine's clock agrees with SerpApi's: it says so, and names no phase when the timestamps contradict each other. To send a trace along with a support request: `SERPAPI_DEBUG=1 serpapi search ... > result.json 2> trace.jsonl`.
122144

123145
## Configuration
124146

@@ -145,10 +167,11 @@ A config file that can't be read or isn't valid TOML is reported by path and lin
145167

146168
- **stdout carries only results**: JSON indented by two spaces, colored on a terminal. Piped, it is indented too, so `head`, `grep` and file viewers show whole lines; `head` cuts the JSON short, so take parts with `--jq`. A terminal gets control characters from results escaped, so a page can't drive it; piped output keeps them. Shrink them on the server with `--fields`, then locally with `--jq`, or ask for `output=md`.
147169
- **Unfamiliar engine?** Responses are often 20–300 KB, and one key can be large too (Maps `place_results` holds reviews and photos). Piped without `--jq`, a result over 16 KB comes with a warning on stderr that lists its top-level keys by size, unless stderr goes into the same pipe (`2>&1`), where it would break a JSON parser. Print a sample with one item per array, `--jq 'walk(if type == "array" then .[:1] else . end)'`; nested arrays are cut too, so it shows the shape, not the answer. Then take what you need from the stored search for free: `serpapi archive SEARCH_METADATA_ID --jq '...'`.
148-
- **Errors and warnings go to stderr**, one JSON object per line:
170+
- **Errors and warnings go to stderr**, one JSON object per line; so does the `--debug` trace, as `{"debug":{...}}` lines, when stderr is not a terminal:
149171
```
150172
{"error":{"code":"api_error","message":"Invalid API key. ...","status":401}}
151173
{"warning":{"message":"..."}}
174+
{"debug":{"at":"2026-10-09T11:50:54.414Z","elapsed":0.142,"event":"Request sent, waiting for response headers"}}
152175
```
153176
- **The exit code says what to do next**:
154177

‎pkg/api/client.go‎

Lines changed: 43 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ import (
99
"io"
1010
"net"
1111
"net/http"
12+
"net/http/httptrace"
1213
"net/url"
1314
"strconv"
1415
"strings"
@@ -36,6 +37,10 @@ type Client struct {
3637
apiKey string
3738
baseURL string
3839
http *http.Client
40+
41+
// debug, if set, receives a trace of each request; see WithDebug.
42+
debug io.Writer
43+
debugJSON bool
3944
}
4045

4146
// Option configures a Client.
@@ -82,6 +87,12 @@ func userAgent() string {
8287

8388
// get performs a GET request and returns the body of a 200 response verbatim.
8489
func (c *Client) get(ctx context.Context, endpoint string, params map[string]string) ([]byte, error) {
90+
return c.getTraced(ctx, endpoint, params, c.newTracer())
91+
}
92+
93+
// getTraced is get with a caller-supplied tracer, so Search can keep logging
94+
// against the same timeline after the body is read.
95+
func (c *Client) getTraced(ctx context.Context, endpoint string, params map[string]string, tr *tracer) ([]byte, error) {
8596
u, err := url.Parse(c.baseURL + endpoint)
8697
if err != nil {
8798
return nil, &clierrors.NetworkError{Message: "Invalid URL: " + err.Error(), Cause: err}
@@ -93,22 +104,31 @@ func (c *Client) get(ctx context.Context, endpoint string, params map[string]str
93104
}
94105
u.RawQuery = q.Encode()
95106

107+
if tr != nil {
108+
ctx = httptrace.WithClientTrace(ctx, tr.clientTrace())
109+
}
110+
96111
req, err := http.NewRequestWithContext(ctx, http.MethodGet, u.String(), nil)
97112
if err != nil {
98113
return nil, &clierrors.NetworkError{Message: "Invalid request: " + endpoint, Cause: err}
99114
}
100115
req.Header.Set("User-Agent", userAgent())
116+
tr.logRequest(req, c.http.Timeout)
101117

102118
resp, err := c.http.Do(req)
103119
if err != nil {
120+
tr.logf("Request failed: %v", err)
104121
return nil, c.networkError(endpoint, err)
105122
}
106123
defer resp.Body.Close()
124+
tr.logResponse(resp)
107125

108126
body, err := io.ReadAll(io.LimitReader(resp.Body, int64(maxResponseBytes)+1))
109127
if err != nil {
128+
tr.logf("Read body failed: %v", err)
110129
return nil, c.networkError(endpoint, err)
111130
}
131+
tr.logf("Body received: %d bytes", len(body))
112132

113133
if resp.StatusCode != http.StatusOK {
114134
message := fmt.Sprintf("HTTP %d: %s", resp.StatusCode, http.StatusText(resp.StatusCode))
@@ -132,11 +152,15 @@ func (c *Client) networkError(endpoint string, err error) error {
132152
}
133153
var netErr net.Error
134154
if c.http.Timeout > 0 && (errors.Is(err, context.DeadlineExceeded) || (errors.As(err, &netErr) && netErr.Timeout())) {
155+
debugHint := ""
156+
if c.debug == nil {
157+
debugHint = "; --debug shows which step stalled"
158+
}
135159
return &clierrors.NetworkError{
136160
Message: fmt.Sprintf("Request to %s timed out after %s. Slow engines, such as google_ai_mode, and no_cache=true can take longer: "+
137-
"retry with a higher --timeout, such as --timeout %d, or 0 to wait indefinitely. "+
161+
"retry with a higher --timeout, such as --timeout %d, or 0 to wait indefinitely%s. "+
138162
"The search may still finish on SerpApi's side, count against your searches and be available in your search archive",
139-
endpoint, seconds(c.http.Timeout), suggestedTimeout(c.http.Timeout)),
163+
endpoint, seconds(c.http.Timeout), suggestedTimeout(c.http.Timeout), debugHint),
140164
Cause: cause,
141165
}
142166
}
@@ -177,7 +201,11 @@ func errorMessage(body []byte) string {
177201
}
178202

179203
func (c *Client) getJSON(ctx context.Context, endpoint string, params map[string]string) (json.RawMessage, error) {
180-
body, err := c.get(ctx, endpoint, params)
204+
return c.getJSONTraced(ctx, endpoint, params, c.newTracer())
205+
}
206+
207+
func (c *Client) getJSONTraced(ctx context.Context, endpoint string, params map[string]string, tr *tracer) (json.RawMessage, error) {
208+
body, err := c.getTraced(ctx, endpoint, params, tr)
181209
if err != nil {
182210
return nil, err
183211
}
@@ -203,13 +231,23 @@ func (c *Client) searchParams(params map[string]string) map[string]string {
203231

204232
// Search performs a search request. If the client has an API key it is added to params.
205233
func (c *Client) Search(ctx context.Context, params map[string]string) (json.RawMessage, error) {
206-
return c.getObject(ctx, "/search.json", c.searchParams(params))
234+
tr := c.newTracer()
235+
body, err := c.getObjectTraced(ctx, "/search.json", c.searchParams(params), tr)
236+
if err != nil {
237+
return nil, err
238+
}
239+
tr.logSearchMetadata(body)
240+
return body, nil
207241
}
208242

209243
// getObject is getJSON for endpoints that answer with a JSON object, so a
210244
// stray null or array is an error rather than an empty-looking success.
211245
func (c *Client) getObject(ctx context.Context, endpoint string, params map[string]string) (json.RawMessage, error) {
212-
body, err := c.getJSON(ctx, endpoint, params)
246+
return c.getObjectTraced(ctx, endpoint, params, c.newTracer())
247+
}
248+
249+
func (c *Client) getObjectTraced(ctx context.Context, endpoint string, params map[string]string, tr *tracer) (json.RawMessage, error) {
250+
body, err := c.getJSONTraced(ctx, endpoint, params, tr)
213251
if err != nil {
214252
return nil, err
215253
}

‎pkg/api/client_test.go‎

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ package api
33
import (
44
"context"
55
"errors"
6+
"io"
67
"net/http"
78
"net/http/httptest"
89
"net/url"
@@ -424,3 +425,20 @@ func TestTimeoutAdviceRaisesTheLimit(t *testing.T) {
424425
}
425426
}
426427
}
428+
429+
// The timeout advice points to --debug only while the trace is off.
430+
func TestTimeoutAdvicePointsToDebugWhenItIsOff(t *testing.T) {
431+
for _, debug := range []bool{false, true} {
432+
c := New("secret", WithTimeout(time.Second))
433+
if debug {
434+
WithDebug(io.Discard, false)(c)
435+
}
436+
var netErr *clierrors.NetworkError
437+
if err := c.networkError("/search.json", osTimeout{}); !errors.As(err, &netErr) {
438+
t.Fatalf("debug %v: got %v, want a NetworkError", debug, err)
439+
}
440+
if suggests := strings.Contains(netErr.Message, "--debug shows which step stalled"); suggests == debug {
441+
t.Errorf("debug %v: message %q", debug, netErr.Message)
442+
}
443+
}
444+
}

0 commit comments

Comments
 (0)