Files
deerflow-code/diagnose_deerflow_latency.py
T

4783 lines
207 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/usr/bin/env python3
"""Standalone evidence-first latency diagnostics for DeerFlow HTTP deployments.
The script is intentionally standard-library-only so it can be copied to a
Linux deployment host and run with the system Python. It combines:
* fresh-connection HTTP timing (DNS, TCP, TLS, response wait, first body byte,
body read, total), including bounded concurrency and direct-backend
comparisons;
* Linux host, process, socket/backlog, file-descriptor, pressure and Docker
snapshots;
* read-only Nginx capacity/config inspection and log-pattern aggregation;
* optional read-only MySQL status/process/lock checks through the ``mysql``
command-line client; and
* optional DeerFlow administrator concurrency endpoints.
Security invariants:
* URL query values and userinfo are always redacted from console/JSON output.
* Request/response bodies and secret header values are never printed or saved.
* Successful login and agent POSTs can mutate session/run state, so unsafe
methods are never replayed unless ``--allow-unsafe-replay`` is explicit.
* TLS verification is enabled by default. ``--insecure`` is explicit and is
recorded as a high-severity caveat in the report.
This tool reports evidence and cautious localization. A high response wait is
not, by itself, proof of a slow database, Nginx, or an LLM provider.
"""
from __future__ import annotations
import argparse
import base64
import concurrent.futures
import contextlib
import datetime as dt
import getpass
import glob
import hashlib
import http.client
import io
import ipaddress
import json
import math
import os
import platform
import re
import shutil
import socket
import ssl
import statistics
import subprocess
import sys
import tempfile
import threading
import time
import urllib.error
import urllib.parse
import urllib.request
from collections import Counter, defaultdict
from collections.abc import Iterable, Mapping, Sequence
from dataclasses import dataclass, field
from pathlib import Path
from typing import Any
REPORT_VERSION = 1
USER_AGENT = "deerflow-latency-diagnosis/1"
SAFE_METHODS = {"GET", "HEAD", "OPTIONS"}
SECRET_HEADER_RE = re.compile(
r"(?:authorization|proxy-authorization|cookie|set-cookie|token|api[-_]?key|secret|password|csrf|session|credential)",
re.IGNORECASE,
)
SECRET_QUERY_RE = re.compile(
r"(?:token|auth|password|passwd|secret|key|ticket|session|cookie|credential)",
re.IGNORECASE,
)
MAX_HEADER_BYTES = 1024 * 1024
MAX_REQUEST_HEADER_BYTES = 64 * 1024
MAX_COMMAND_OUTPUT = 4 * 1024 * 1024
UTC = getattr(dt, "UTC", dt.timezone.utc) # noqa: UP017 - keep the standalone tool usable on Python 3.9
HEADER_NAME_RE = re.compile(r"^[!#$%&'*+.^_`|~0-9A-Za-z-]+$")
@dataclass
class ProbeOptions:
method: str = "GET"
headers: dict[str, str] = field(default_factory=dict)
body: bytes = b""
connect_timeout: float = 5.0
first_byte_timeout: float = 30.0
read_timeout: float = 5.0
total_timeout: float = 120.0
max_response_bytes: int = 1024 * 1024
read_mode: str = "auto"
insecure: bool = False
ca_file: str | None = None
follow_redirects: bool = False
max_redirects: int = 3
proxy: str | None = None
use_env_proxy: bool = False
ip_family: str = "auto"
class ProbeFailure(RuntimeError):
"""Transport failure with the phase that failed."""
def __init__(self, phase: str, message: str):
super().__init__(message)
self.phase = phase
class _NoRedirectHandler(urllib.request.HTTPRedirectHandler):
"""Return 30x to the caller instead of forwarding credentials implicitly."""
def redirect_request(
self,
req: urllib.request.Request,
fp: Any,
code: int,
msg: str,
headers: Any,
newurl: str,
) -> None:
return None
class _SocketBodyReader:
"""Bounded socket reader used by HTTP body framing parsers."""
def __init__(
self,
sock: socket.socket,
initial: bytes,
*,
idle_timeout: float,
deadline: float,
idle_timeout_phase: str = "body_idle_timeout",
):
self.sock = sock
self.buffer = bytearray(initial)
self.idle_timeout = max(0.1, idle_timeout)
self.deadline = deadline
self.idle_timeout_phase = idle_timeout_phase
self.eof = False
def _fill(self) -> None:
if self.eof:
return
remaining = self.deadline - time.perf_counter()
if remaining <= 0:
raise ProbeFailure("body_total_timeout", "overall request timeout expired while reading response body")
self.sock.settimeout(min(self.idle_timeout, remaining))
try:
chunk = self.sock.recv(65536)
except (TimeoutError, socket.timeout) as exc: # noqa: UP041 - Python 3.9/Windows may not alias these
phase = "body_total_timeout" if time.perf_counter() >= self.deadline else self.idle_timeout_phase
raise ProbeFailure(phase, f"response body made no progress for {min(self.idle_timeout, remaining):.3f}s") from exc
except OSError as exc:
raise ProbeFailure("body_read", _safe_error(exc)) from exc
if not chunk:
self.eof = True
return
self.buffer.extend(chunk)
def read_some(self, maximum: int) -> bytes:
while not self.buffer and not self.eof:
self._fill()
if not self.buffer:
return b""
size = min(maximum, len(self.buffer))
output = bytes(self.buffer[:size])
del self.buffer[:size]
return output
def read_exact(self, size: int) -> bytes:
while len(self.buffer) < size and not self.eof:
self._fill()
if len(self.buffer) < size:
raise ProbeFailure("body_framing", "response ended before the declared body framing completed")
output = bytes(self.buffer[:size])
del self.buffer[:size]
return output
def read_line(self, limit: int = 8192) -> bytes:
while True:
marker = self.buffer.find(b"\r\n")
if marker >= 0:
if marker > limit:
raise ProbeFailure("body_framing", "HTTP chunk framing line exceeded 8192 bytes")
output = bytes(self.buffer[:marker])
del self.buffer[: marker + 2]
return output
if len(self.buffer) > limit:
raise ProbeFailure("body_framing", "HTTP chunk framing line exceeded 8192 bytes")
if self.eof:
raise ProbeFailure("body_framing", "response ended inside HTTP chunk framing")
self._fill()
def utc_now_iso() -> str:
return dt.datetime.now(UTC).isoformat()
def monotonic_ms(start: float, end: float | None = None) -> float:
return round(((end if end is not None else time.perf_counter()) - start) * 1000.0, 3)
def _safe_error(exc: BaseException, limit: int = 300) -> str:
"""Return a bounded error string with obvious credentials redacted."""
text = f"{type(exc).__name__}: {exc}"
text = re.sub(
r"https?://[^\s\]\[(){}<>'\"]+",
lambda match: sanitize_url(match.group(0)),
text,
flags=re.IGNORECASE,
)
text = re.sub(
r"(?i)(authorization|cookie|token|password|secret|api[-_]?key|csrf|session|credential)\s*[:=]\s*[^\s,;]+",
r"\1=<redacted>",
text,
)
text = re.sub(r"(?i)://[^/@\s]+@", "://<redacted>@", text)
return text[:limit]
def sanitize_url(url: str) -> str:
"""Redact userinfo and every query value while retaining routing shape."""
try:
parsed = urllib.parse.urlsplit(url)
except ValueError:
return "<invalid-url>"
if not parsed.scheme or not parsed.netloc:
return "<invalid-url>"
hostname = parsed.hostname or ""
try:
ip = ipaddress.ip_address(hostname)
rendered_host = f"[{ip}]" if ip.version == 6 else str(ip)
except ValueError:
rendered_host = hostname
try:
parsed_port = parsed.port
except ValueError:
return "<invalid-url>"
if parsed_port is not None:
rendered_host = f"{rendered_host}:{parsed_port}"
safe_query: list[tuple[str, str]] = []
for index, query_field in enumerate(parsed.query.split("&")):
if not query_field:
continue
raw_key, separator, _raw_value = query_field.partition("=")
key = urllib.parse.unquote_plus(raw_key)
key_is_safe = bool(separator and len(key) <= 64 and re.fullmatch(r"[A-Za-z0-9_.~-]+", key))
safe_key = key if key_is_safe else f"param_{index + 1}"
marker = "<secret>" if SECRET_QUERY_RE.search(key) else "<redacted>"
safe_query.append((safe_key, marker))
query = urllib.parse.urlencode(safe_query)
return urllib.parse.urlunsplit((parsed.scheme, rendered_host, parsed.path or "/", query, ""))
def sanitize_origin(url: str) -> str:
try:
parsed = urllib.parse.urlsplit(url)
parsed_port = parsed.port
except ValueError:
return "<invalid-origin>"
hostname = parsed.hostname or ""
try:
ip = ipaddress.ip_address(hostname)
rendered_host = f"[{ip}]" if ip.version == 6 else str(ip)
except ValueError:
rendered_host = hostname
default_port = 443 if parsed.scheme == "https" else 80
if parsed_port and parsed_port != default_port:
rendered_host = f"{rendered_host}:{parsed_port}"
return f"{parsed.scheme}://{rendered_host}"
def _origin_tuple(url: str) -> tuple[str, str, int]:
parsed = urllib.parse.urlsplit(url)
scheme = parsed.scheme.lower()
if scheme not in {"http", "https"} or not parsed.hostname:
raise ValueError("URL must use http:// or https:// and include a host")
return scheme, parsed.hostname.lower(), parsed.port or (443 if scheme == "https" else 80)
def _host_is_loopback(hostname: str) -> bool:
normalized = hostname.rstrip(".").lower()
if normalized == "localhost":
return True
try:
return ipaddress.ip_address(normalized).is_loopback
except ValueError:
return False
def _url_has_secret_query(url: str) -> bool:
return any(
bool(value) and bool(SECRET_QUERY_RE.search(key))
for key, value in urllib.parse.parse_qsl(
urllib.parse.urlsplit(url).query,
keep_blank_values=True,
)
)
def _header_is_secret(name: str) -> bool:
return bool(SECRET_HEADER_RE.search(name))
def _validate_header(name: str, value: str) -> None:
if not HEADER_NAME_RE.fullmatch(name):
raise ValueError(f"Invalid header name: {name!r}")
if any((ord(ch) < 32 and ch != "\t") or ord(ch) == 127 or ord(ch) > 255 for ch in value):
raise ValueError(f"Header {name!r} contains an illegal control or non-Latin-1 character")
if len(value.encode("iso-8859-1")) > 16 * 1024:
raise ValueError(f"Header {name!r} exceeds 16 KiB")
def parse_header_argument(raw: str) -> tuple[str, str]:
name, separator, value = raw.partition(":")
if not separator:
raise ValueError("Header must use NAME:VALUE")
name = name.strip()
value = value.strip()
_validate_header(name, value)
return name, value
def parse_header_env_argument(raw: str) -> tuple[str, str]:
name, separator, env_name = raw.partition("=")
if not separator or not name.strip() or not env_name.strip():
raise ValueError("Environment header must use HEADER=ENV_NAME")
value = os.environ.get(env_name.strip())
if value is None:
raise ValueError(f"Environment variable {env_name.strip()} is not set")
name = name.strip()
_validate_header(name, value)
return name, value
def parse_server_timing(raw: str | None) -> list[dict[str, Any]]:
"""Parse only metric name/duration; deliberately discard descriptions."""
if not raw:
return []
metrics: list[dict[str, Any]] = []
for part in raw.split(","):
segments = [segment.strip() for segment in part.split(";") if segment.strip()]
if not segments:
continue
name = re.sub(r"[^A-Za-z0-9_.:-]", "", segments[0])[:80]
if not name:
continue
item: dict[str, Any] = {"name": name}
for segment in segments[1:]:
key, separator, value = segment.partition("=")
if separator and key.strip().lower() == "dur":
try:
duration = float(value.strip().strip('"'))
except ValueError:
continue
if math.isfinite(duration) and duration >= 0:
item["duration_ms"] = round(duration, 3)
metrics.append(item)
return metrics
def percentile(values: Sequence[float], percentile_value: float) -> float | None:
if not values:
return None
ordered = sorted(float(value) for value in values)
if len(ordered) == 1:
return round(ordered[0], 3)
rank = (len(ordered) - 1) * percentile_value
lower = math.floor(rank)
upper = math.ceil(rank)
if lower == upper:
return round(ordered[lower], 3)
weight = rank - lower
return round(ordered[lower] * (1.0 - weight) + ordered[upper] * weight, 3)
def summarize_numbers(values: Iterable[float | int | None]) -> dict[str, float | int | None]:
clean = [float(value) for value in values if value is not None and math.isfinite(float(value))]
if not clean:
return {"samples": 0, "min": None, "p50": None, "p95": None, "max": None, "avg": None}
return {
"samples": len(clean),
"min": round(min(clean), 3),
"p50": percentile(clean, 0.50),
"p95": percentile(clean, 0.95),
"max": round(max(clean), 3),
"avg": round(statistics.fmean(clean), 3),
}
def _select_proxy(url: str, options: ProbeOptions) -> str | None:
if options.proxy:
return options.proxy
if not options.use_env_proxy:
return None
parsed = urllib.parse.urlsplit(url)
hostname = parsed.hostname or ""
bypass_targets = [hostname] if hostname else []
if parsed.port is not None:
try:
rendered_bypass_host = f"[{hostname}]" if ipaddress.ip_address(hostname).version == 6 else hostname
except ValueError:
rendered_bypass_host = hostname
bypass_targets.append(f"{rendered_bypass_host}:{parsed.port}")
if any(target and urllib.request.proxy_bypass(target) for target in dict.fromkeys(bypass_targets)):
return None
proxies = urllib.request.getproxies()
return proxies.get(parsed.scheme.lower())
def _address_family(value: str) -> int:
if value == "ipv4":
return socket.AF_INET
if value == "ipv6":
return socket.AF_INET6
return socket.AF_UNSPEC
def _connect_socket(
host: str,
port: int,
*,
timeout: float,
family: str,
) -> tuple[socket.socket, dict[str, Any]]:
deadline = time.perf_counter() + timeout
dns_started = time.perf_counter()
resolution: dict[str, Any] = {}
resolved = threading.Event()
def resolve() -> None:
try:
resolution["infos"] = socket.getaddrinfo(
host,
port,
family=_address_family(family),
type=socket.SOCK_STREAM,
)
except Exception as exc: # captured and re-raised in the caller thread
resolution["error"] = exc
finally:
resolved.set()
resolver = threading.Thread(target=resolve, name="deerflow-dns-probe", daemon=True)
resolver.start()
if not resolved.wait(timeout):
raise ProbeFailure("dns", f"timed out after {timeout:.3f}s")
error = resolution.get("error")
if error is not None:
if isinstance(error, socket.gaierror):
raise ProbeFailure("dns", _safe_error(error)) from error
raise ProbeFailure("dns", _safe_error(error))
infos = resolution.get("infos") or []
dns_ms = monotonic_ms(dns_started)
if not infos:
raise ProbeFailure("dns", "DNS returned no usable address")
attempts: list[dict[str, Any]] = []
last_error: BaseException | None = None
tcp_started = time.perf_counter()
for af, socktype, protocol, _canonname, sockaddr in infos:
remaining = deadline - time.perf_counter()
if remaining <= 0:
raise ProbeFailure("connect", f"DNS and TCP connection exceeded {timeout:.3f}s")
sock = socket.socket(af, socktype, protocol)
sock.settimeout(remaining)
started = time.perf_counter()
try:
sock.connect(sockaddr)
except OSError as exc:
last_error = exc
attempts.append(
{
"family": "ipv6" if af == socket.AF_INET6 else "ipv4",
"address": str(sockaddr[0]),
"connect_ms": monotonic_ms(started),
"outcome": type(exc).__name__,
}
)
sock.close()
continue
connect_ms = monotonic_ms(started)
attempts.append(
{
"family": "ipv6" if af == socket.AF_INET6 else "ipv4",
"address": str(sockaddr[0]),
"connect_ms": connect_ms,
"outcome": "connected",
}
)
return sock, {
"dns_ms": dns_ms,
"connect_ms": monotonic_ms(tcp_started),
"chosen_address_connect_ms": connect_ms,
"peer_ip": str(sockaddr[0]),
"connect_attempts": attempts,
}
raise ProbeFailure("connect", _safe_error(last_error or OSError("all addresses failed")))
def _recv_header_block(
sock: socket.socket,
timeout: float,
*,
initial: bytes = b"",
) -> tuple[bytes, bytes, float]:
deadline = time.perf_counter() + timeout
buffer = bytearray(initial)
first_byte_at: float | None = time.perf_counter() if initial else None
while b"\r\n\r\n" not in buffer:
remaining = deadline - time.perf_counter()
if remaining <= 0:
raise ProbeFailure("first_byte", f"response headers exceeded {timeout:.3f}s total")
sock.settimeout(remaining)
try:
chunk = sock.recv(65536)
except (TimeoutError, socket.timeout) as exc: # noqa: UP041 - standalone Python compatibility
raise ProbeFailure("first_byte", f"timed out after {timeout:.3f}s") from exc
except OSError as exc:
raise ProbeFailure("first_byte", _safe_error(exc)) from exc
if not chunk:
raise ProbeFailure("http_headers", "connection closed before response headers completed")
if first_byte_at is None:
first_byte_at = time.perf_counter()
buffer.extend(chunk)
if len(buffer) > MAX_HEADER_BYTES and b"\r\n\r\n" not in buffer:
raise ProbeFailure("http_headers", "response headers exceeded 1 MiB")
head, body = bytes(buffer).split(b"\r\n\r\n", 1)
if len(head) > MAX_HEADER_BYTES:
raise ProbeFailure("http_headers", "response headers exceeded 1 MiB")
return head, body, first_byte_at or time.perf_counter()
def _parse_header_block(head: bytes) -> tuple[int, str, dict[str, str]]:
first_line, separator, remainder = head.partition(b"\r\n")
if not separator:
raise ProbeFailure("http_headers", "malformed HTTP status line")
try:
status_text = first_line.decode("iso-8859-1")
status_parts = status_text.split(" ", 2)
if len(status_parts) < 2:
raise ValueError("invalid status line")
version, status_raw = status_parts[:2]
reason = status_parts[2] if len(status_parts) == 3 else ""
if not re.fullmatch(r"HTTP/\d+\.\d+", version):
raise ValueError("invalid protocol")
if not re.fullmatch(r"\d{3}", status_raw):
raise ValueError("invalid status code")
status = int(status_raw)
parsed_headers = http.client.parse_headers(io.BytesIO(remainder + b"\r\n\r\n"))
except (ValueError, http.client.HTTPException) as exc:
raise ProbeFailure("http_headers", _safe_error(exc)) from exc
headers: dict[str, str] = {}
for name in parsed_headers.keys():
values = parsed_headers.get_all(name) or []
headers[name.lower()] = ", ".join(values)
return status, reason[:100], headers
def _safe_response_headers(headers: Mapping[str, str]) -> dict[str, str]:
allowed = {
"server",
"via",
"content-type",
"content-length",
"transfer-encoding",
"connection",
"x-accel-buffering",
"x-cache",
"retry-after",
}
return {name: str(value)[:300] for name, value in headers.items() if name.lower() in allowed and not _header_is_secret(name)}
def _proxy_authorization(parsed_proxy: urllib.parse.SplitResult) -> str | None:
if parsed_proxy.username is None:
return None
username = urllib.parse.unquote(parsed_proxy.username)
password = urllib.parse.unquote(parsed_proxy.password or "")
encoded = base64.b64encode(f"{username}:{password}".encode()).decode("ascii")
return f"Basic {encoded}"
def _host_header(parsed: urllib.parse.SplitResult) -> str:
hostname = parsed.hostname or ""
try:
if ipaddress.ip_address(hostname).version == 6:
hostname = f"[{hostname}]"
except ValueError:
hostname = hostname.encode("idna").decode("ascii")
default = 443 if parsed.scheme == "https" else 80
return hostname if (parsed.port or default) == default else f"{hostname}:{parsed.port}"
def _build_ssl_context(options: ProbeOptions) -> ssl.SSLContext:
if options.insecure:
context = ssl._create_unverified_context() # noqa: SLF001 - explicit diagnostic flag
else:
context = ssl.create_default_context(cafile=options.ca_file)
with contextlib.suppress(NotImplementedError):
context.set_alpn_protocols(["http/1.1"])
return context
def _do_proxy_connect(
sock: socket.socket,
target_host: str,
target_port: int,
proxy_auth: str | None,
timeout: float,
) -> float:
try:
rendered_host = f"[{target_host}]" if ipaddress.ip_address(target_host).version == 6 else target_host
except ValueError:
rendered_host = target_host.encode("idna").decode("ascii")
authority = f"{rendered_host}:{target_port}"
lines = [
f"CONNECT {authority} HTTP/1.1",
f"Host: {authority}",
"Proxy-Connection: keep-alive",
]
if proxy_auth:
lines.append(f"Proxy-Authorization: {proxy_auth}")
request = ("\r\n".join(lines) + "\r\n\r\n").encode("iso-8859-1")
started = time.perf_counter()
try:
sock.sendall(request)
head, _body, _first = _recv_header_block(sock, timeout)
status, _reason, _headers = _parse_header_block(head)
except ProbeFailure as exc:
raise ProbeFailure("proxy_connect", f"{exc.phase}: {exc}") from exc
except OSError as exc:
raise ProbeFailure("proxy_connect", _safe_error(exc)) from exc
if not 200 <= status < 300:
raise ProbeFailure("proxy_connect", f"HTTP proxy CONNECT returned {status}")
return monotonic_ms(started)
def _request_target(parsed: urllib.parse.SplitResult, use_http_proxy: bool) -> str:
safe_path = urllib.parse.quote(parsed.path or "/", safe="/%:@-._~!$&'()*+,;=")
safe_query = urllib.parse.quote(parsed.query, safe="=&%/:@-._~!$'()*+,;?")
path = urllib.parse.urlunsplit(("", "", safe_path, safe_query, ""))
if not use_http_proxy:
return path
netloc = _host_header(parsed)
return urllib.parse.urlunsplit((parsed.scheme, netloc, safe_path, safe_query, ""))
def _content_length(headers: Mapping[str, str], *, transfer_encoded: bool) -> int | None:
raw = headers.get("content-length")
if raw is None or transfer_encoded:
return None
values = [part.strip() for part in raw.split(",")]
if not values or any(not re.fullmatch(r"\d+", value) for value in values) or len(set(values)) != 1:
raise ProbeFailure("body_framing", "invalid or conflicting Content-Length response header")
return int(values[0])
def _next_chunk_size(reader: _SocketBodyReader) -> int:
raw = reader.read_line()
size_text = raw.split(b";", 1)[0].strip()
if not size_text or not re.fullmatch(rb"[0-9A-Fa-f]+", size_text):
raise ProbeFailure("body_framing", "invalid HTTP chunk size")
size = int(size_text, 16)
if size > 2**63 - 1:
raise ProbeFailure("body_framing", "HTTP chunk size is unreasonably large")
return size
def _finish_chunk_trailers(reader: _SocketBodyReader) -> None:
total = 0
for _index in range(100):
line = reader.read_line()
total += len(line) + 2
if total > 65536:
raise ProbeFailure("body_framing", "HTTP chunk trailers exceeded 64 KiB")
if not line:
return
raise ProbeFailure("body_framing", "HTTP chunk trailers exceeded 100 fields")
def _read_response_body(
sock: socket.socket,
initial: bytes,
headers: Mapping[str, str],
*,
method_has_no_body: bool,
read_mode: str,
max_response_bytes: int,
idle_timeout: float,
first_body_timeout: float,
deadline: float,
) -> dict[str, Any]:
"""Read a response entity with Content-Length/chunked framing and hard bounds."""
transfer_tokens = [item.strip().lower() for item in headers.get("transfer-encoding", "").split(",") if item.strip()]
chunked = bool(transfer_tokens and transfer_tokens[-1] == "chunked")
content_length = _content_length(headers, transfer_encoded=bool(transfer_tokens))
waiting_for_first_entity_byte = read_mode == "first-byte"
reader = _SocketBodyReader(
sock,
initial,
idle_timeout=first_body_timeout if waiting_for_first_entity_byte else idle_timeout,
deadline=deadline,
idle_timeout_phase="first_body" if waiting_for_first_entity_byte else "body_idle_timeout",
)
result: dict[str, Any] = {
"body_bytes_read": 0,
"body_complete": False,
"body_truncated_reason": None,
"first_body_at": None,
"error_phase": None,
"error": None,
}
if transfer_tokens and not chunked:
result["framing_note"] = "non_chunked_transfer_encoding_delimited_by_connection_close"
elif transfer_tokens and headers.get("content-length") is not None:
result["framing_note"] = "transfer_encoding_overrides_content_length"
if method_has_no_body or content_length == 0:
result["body_complete"] = True
return result
if read_mode == "headers":
result["body_truncated_reason"] = "headers_only"
return result
try:
if chunked:
if read_mode == "first-byte":
while True:
size = _next_chunk_size(reader)
if size == 0:
_finish_chunk_trailers(reader)
result["body_complete"] = True
return result
first = reader.read_exact(1)
if first:
result["body_bytes_read"] = 1
result["first_body_at"] = time.perf_counter()
result["body_truncated_reason"] = "first_byte_mode"
return result
while True:
size = _next_chunk_size(reader)
if size == 0:
_finish_chunk_trailers(reader)
result["body_complete"] = True
return result
remaining_chunk = size
while remaining_chunk:
remaining_cap = max_response_bytes - int(result["body_bytes_read"])
if remaining_cap <= 0:
result["body_truncated_reason"] = "max_response_bytes"
return result
part = reader.read_some(min(65536, remaining_chunk, remaining_cap))
if not part:
raise ProbeFailure("body_framing", "response ended inside an HTTP chunk")
if result["first_body_at"] is None:
result["first_body_at"] = time.perf_counter()
result["body_bytes_read"] += len(part)
remaining_chunk -= len(part)
if reader.read_exact(2) != b"\r\n":
raise ProbeFailure("body_framing", "HTTP chunk data was not followed by CRLF")
if int(result["body_bytes_read"]) >= max_response_bytes:
# Do not wait for another chunk-size line once the configured
# entity-byte cap is reached. A streaming server is allowed
# to pause forever here; treating that pause as an idle
# timeout would falsely turn an intentional truncation into a
# transport failure. We can still recognize the common
# already-buffered empty-trailer terminator without blocking.
if reader.buffer.startswith(b"0\r\n\r\n"):
del reader.buffer[:5]
result["body_complete"] = True
else:
result["body_truncated_reason"] = "max_response_bytes"
return result
result["body_truncated_reason"] = "max_response_bytes"
return result
if read_mode == "first-byte":
first = reader.read_some(1)
if first:
result["body_bytes_read"] = 1
result["first_body_at"] = time.perf_counter()
if content_length == 1:
result["body_complete"] = True
else:
result["body_truncated_reason"] = "first_byte_mode"
elif content_length is None:
result["body_complete"] = True
else:
raise ProbeFailure("body_framing", "response ended before Content-Length bytes arrived")
return result
expected = content_length
while result["body_bytes_read"] < max_response_bytes:
if expected is not None:
remaining = expected - int(result["body_bytes_read"])
if remaining <= 0:
result["body_complete"] = True
return result
part = reader.read_some(min(65536, remaining, max_response_bytes - int(result["body_bytes_read"])))
if not part:
raise ProbeFailure("body_framing", "response ended before Content-Length bytes arrived")
else:
part = reader.read_some(min(65536, max_response_bytes - int(result["body_bytes_read"])))
if not part:
result["body_complete"] = True
return result
if result["first_body_at"] is None:
result["first_body_at"] = time.perf_counter()
result["body_bytes_read"] += len(part)
if expected is not None and result["body_bytes_read"] >= expected:
result["body_complete"] = True
else:
result["body_truncated_reason"] = "max_response_bytes"
except ProbeFailure as exc:
result["body_truncated_reason"] = exc.phase
result["error_phase"] = exc.phase
result["error"] = str(exc)[:300]
return result
def _probe_hop(url: str, options: ProbeOptions) -> tuple[dict[str, Any], str | None]:
hop_started = time.perf_counter()
hop_deadline = hop_started + options.total_timeout
parsed = urllib.parse.urlsplit(url)
scheme, target_host, target_port = _origin_tuple(url)
proxy_url: str | None = None
proxy_parsed: urllib.parse.SplitResult | None = None
connect_host, connect_port = target_host, target_port
proxy_auth: str | None = None
record: dict[str, Any] = {
"url": sanitize_url(url),
"method": options.method,
"proxy_used": False,
"proxy_origin": None,
"success": False,
"status_code": None,
"error_phase": None,
"error": None,
"timings_ms": {},
"response": {},
}
sock: socket.socket | ssl.SSLSocket | None = None
location: str | None = None
try:
proxy_url = _select_proxy(url, options)
if proxy_url:
proxy_parsed = urllib.parse.urlsplit(proxy_url)
if proxy_parsed.scheme.lower() != "http" or not proxy_parsed.hostname:
raise ProbeFailure("proxy", "only an http:// forward proxy is supported")
connect_host = proxy_parsed.hostname
connect_port = proxy_parsed.port or 80
proxy_auth = _proxy_authorization(proxy_parsed)
if proxy_auth is None:
proxy_auth = next(
(value for name, value in options.headers.items() if name.lower() == "proxy-authorization"),
None,
)
record["proxy_used"] = True
record["proxy_origin"] = sanitize_origin(proxy_url)
remaining = hop_deadline - time.perf_counter()
if remaining <= 0:
raise ProbeFailure("total_timeout", "overall request timeout expired before DNS")
sock, connect_details = _connect_socket(
connect_host,
connect_port,
timeout=min(options.connect_timeout, remaining),
family=options.ip_family,
)
record.update(connect_details)
record["dns_subject"] = "proxy" if proxy_parsed else "origin"
timings = record["timings_ms"]
timings["dns"] = connect_details["dns_ms"]
timings["tcp_connect"] = connect_details["connect_ms"]
if proxy_parsed and scheme == "https":
remaining = hop_deadline - time.perf_counter()
if remaining <= 0:
raise ProbeFailure("total_timeout", "overall request timeout expired before proxy CONNECT")
timings["proxy_connect"] = _do_proxy_connect(
sock,
target_host,
target_port,
proxy_auth,
min(options.first_byte_timeout, remaining),
)
if scheme == "https":
tls_started = time.perf_counter()
try:
remaining = hop_deadline - time.perf_counter()
if remaining <= 0:
raise ProbeFailure("total_timeout", "overall request timeout expired before TLS")
sock.settimeout(min(options.connect_timeout, remaining))
sock = _build_ssl_context(options).wrap_socket(sock, server_hostname=target_host)
except ssl.SSLCertVerificationError as exc:
raise ProbeFailure("tls_verify", _safe_error(exc)) from exc
except (TimeoutError, ssl.SSLError, OSError) as exc:
raise ProbeFailure("tls_handshake", _safe_error(exc)) from exc
timings["tls_handshake"] = monotonic_ms(tls_started)
cipher = sock.cipher()
cert = sock.getpeercert() or {}
record["tls"] = {
"verified": not options.insecure,
"version": sock.version(),
"cipher": cipher[0] if cipher else None,
"alpn": sock.selected_alpn_protocol(),
"certificate_not_after": cert.get("notAfter"),
}
request_headers = {name: value for name, value in options.headers.items() if name.lower() != "proxy-authorization"}
lower_names: dict[str, str] = {}
for name in request_headers:
lowered = name.lower()
if lowered in lower_names:
raise ProbeFailure(
"request_validation",
f"duplicate request header names differ only by case: {lower_names[lowered]} and {name}",
)
lower_names[lowered] = name
for forbidden in ("transfer-encoding", "expect"):
if forbidden in lower_names:
raise ProbeFailure("request_validation", f"request header {forbidden} is not supported")
supplied_length: int | None = None
if "content-length" in lower_names:
raw_length = request_headers.pop(lower_names["content-length"])
if not re.fullmatch(r"\d+", raw_length.strip()):
raise ProbeFailure("request_validation", "Content-Length must be a non-negative integer")
supplied_length = int(raw_length)
if supplied_length != len(options.body):
raise ProbeFailure("request_validation", "Content-Length does not match the request body")
if "connection" in lower_names:
request_headers.pop(lower_names["connection"])
defaults = {
"Host": _host_header(parsed),
"User-Agent": USER_AGENT,
"Accept": "*/*",
"Accept-Encoding": "identity",
"Connection": "close",
}
lower_names = {name.lower(): name for name in request_headers}
for name, value in defaults.items():
if name.lower() not in lower_names:
request_headers[name] = value
if options.body or supplied_length is not None or options.method not in SAFE_METHODS:
request_headers["Content-Length"] = str(len(options.body))
if proxy_parsed and scheme == "http" and proxy_auth:
request_headers["Proxy-Authorization"] = proxy_auth
for name, value in request_headers.items():
try:
_validate_header(name, value)
except ValueError as exc:
raise ProbeFailure("request_validation", str(exc)) from exc
target = _request_target(parsed, bool(proxy_parsed and scheme == "http"))
request_lines = [f"{options.method} {target} HTTP/1.1"]
request_lines.extend(f"{name}: {value}" for name, value in request_headers.items())
header_bytes = ("\r\n".join(request_lines) + "\r\n\r\n").encode("iso-8859-1")
if len(header_bytes) > MAX_REQUEST_HEADER_BYTES:
raise ProbeFailure("request_validation", "request headers exceed 64 KiB")
request_bytes = header_bytes + options.body
upload_started = time.perf_counter()
try:
remaining = hop_deadline - time.perf_counter()
if remaining <= 0:
raise ProbeFailure("total_timeout", "overall request timeout expired before upload")
sock.settimeout(min(options.connect_timeout, remaining))
sock.sendall(request_bytes)
except (TimeoutError, OSError) as exc:
raise ProbeFailure("request_upload", _safe_error(exc)) from exc
uploaded_at = time.perf_counter()
timings["request_upload"] = monotonic_ms(upload_started, uploaded_at)
remaining = hop_deadline - time.perf_counter()
if remaining <= 0:
raise ProbeFailure("total_timeout", "overall request timeout expired before response")
head, initial_body, first_response_at = _recv_header_block(
sock,
min(options.first_byte_timeout, remaining),
)
status, _reason, response_headers = _parse_header_block(head)
interim_statuses: list[int] = []
while 100 <= status < 200 and status != 101:
interim_statuses.append(status)
remaining = hop_deadline - time.perf_counter()
if remaining <= 0:
raise ProbeFailure("first_byte", "overall request timeout expired after interim response")
head, initial_body, _interim_first = _recv_header_block(
sock,
min(options.first_byte_timeout, remaining),
initial=initial_body,
)
status, _reason, response_headers = _parse_header_block(head)
headers_complete_at = time.perf_counter()
timings["response_wait"] = monotonic_ms(uploaded_at, first_response_at)
timings["headers_complete_after_upload"] = monotonic_ms(uploaded_at, headers_complete_at)
record["status_code"] = status
if interim_statuses:
record["interim_status_codes"] = interim_statuses
record["response"] = {
"headers": _safe_response_headers(response_headers),
"server_timing": parse_server_timing(response_headers.get("server-timing")),
"body_bytes_read": 0,
"body_complete": False,
"body_truncated_reason": None,
}
location = response_headers.get("location")
if location:
record["response"]["redirect_location"] = sanitize_url(urllib.parse.urljoin(url, location))
method_has_no_body = options.method == "HEAD" or status in {204, 304} or 100 <= status < 200
content_type = response_headers.get("content-type", "").lower()
read_mode = options.read_mode
if read_mode == "auto":
read_mode = "first-byte" if "text/event-stream" in content_type else "full"
body_started = time.perf_counter()
body_result = _read_response_body(
sock,
initial_body,
response_headers,
method_has_no_body=method_has_no_body,
read_mode=read_mode,
max_response_bytes=options.max_response_bytes,
idle_timeout=options.read_timeout,
first_body_timeout=options.first_byte_timeout,
deadline=hop_deadline,
)
body_finished_at = time.perf_counter()
first_body_at = body_result.pop("first_body_at")
body_error_phase = body_result.pop("error_phase")
body_error = body_result.pop("error")
record["response"].update(body_result)
if first_body_at is not None:
timings["first_body_after_upload"] = monotonic_ms(uploaded_at, first_body_at)
else:
timings["first_body_after_upload"] = None
timings["body_read"] = monotonic_ms(body_started, body_finished_at)
timings["total"] = monotonic_ms(hop_started, body_finished_at)
if body_error_phase:
record["error_phase"] = body_error_phase
record["error"] = body_error
else:
record["success"] = True
except ProbeFailure as exc:
record["error_phase"] = exc.phase
record["error"] = str(exc)[:300]
record["timings_ms"]["total"] = monotonic_ms(hop_started)
except Exception as exc: # pragma: no cover - last-resort containment
record["error_phase"] = "unexpected"
record["error"] = _safe_error(exc)
record["timings_ms"]["total"] = monotonic_ms(hop_started)
finally:
if sock is not None:
with contextlib.suppress(OSError):
sock.close()
return record, location
def probe_url(url: str, options: ProbeOptions) -> dict[str, Any]:
"""Probe one logical request, following redirects only when requested."""
started = time.perf_counter()
current_url = url
current_method = options.method
current_body = options.body
current_headers = dict(options.headers)
hops: list[dict[str, Any]] = []
visited: set[str] = set()
for hop_index in range(options.max_redirects + 1):
remaining_total = options.total_timeout - (time.perf_counter() - started)
if remaining_total <= 0:
hops.append(
{
"url": sanitize_url(current_url),
"method": current_method,
"success": False,
"status_code": None,
"error_phase": "total_timeout",
"error": "overall logical request timeout expired",
"timings_ms": {"total": monotonic_ms(started)},
"response": {},
"hop": hop_index,
}
)
break
hop_options = ProbeOptions(
method=current_method,
headers=current_headers,
body=current_body,
connect_timeout=options.connect_timeout,
first_byte_timeout=options.first_byte_timeout,
read_timeout=options.read_timeout,
total_timeout=remaining_total,
max_response_bytes=options.max_response_bytes,
read_mode=options.read_mode,
insecure=options.insecure,
ca_file=options.ca_file,
follow_redirects=options.follow_redirects,
max_redirects=options.max_redirects,
proxy=options.proxy,
use_env_proxy=options.use_env_proxy,
ip_family=options.ip_family,
)
hop, raw_location = _probe_hop(current_url, hop_options)
hop["hop"] = hop_index
hops.append(hop)
status = hop.get("status_code")
if not hop.get("success") or not options.follow_redirects or status not in {301, 302, 303, 307, 308}:
break
if not raw_location:
break
next_url = urllib.parse.urljoin(current_url, raw_location)
try:
next_origin = _origin_tuple(next_url)
except ValueError as exc:
hop["success"] = False
hop["error_phase"] = "redirect"
hop["error"] = f"invalid redirect target: {_safe_error(exc)}"
break
if next_url in visited:
hop["success"] = False
hop["error_phase"] = "redirect"
hop["error"] = "redirect loop detected"
break
visited.add(current_url)
if hop_index >= options.max_redirects:
hop["success"] = False
hop["error_phase"] = "redirect"
hop["error"] = "maximum redirects exceeded"
break
if _origin_tuple(current_url) != next_origin:
hop["success"] = False
hop["error_phase"] = "redirect"
hop["error"] = "cross-origin redirect refused to prevent credential or request-body disclosure"
break
if status == 303 or (status in {301, 302} and current_method == "POST"):
current_method = "GET"
current_body = b""
current_headers = {name: value for name, value in current_headers.items() if name.lower() not in {"content-length", "content-type"}}
current_url = next_url
final = hops[-1]
aggregate_timings: dict[str, float | None] = {}
for key in {
"dns",
"tcp_connect",
"proxy_connect",
"tls_handshake",
"request_upload",
"response_wait",
"headers_complete_after_upload",
"body_read",
}:
values = [hop.get("timings_ms", {}).get(key) for hop in hops]
clean = [float(value) for value in values if value is not None]
aggregate_timings[key] = round(sum(clean), 3) if clean else None
aggregate_timings["total"] = monotonic_ms(started)
return {
"url": sanitize_url(url),
"success": bool(final.get("success")),
"status_code": final.get("status_code"),
"error_phase": final.get("error_phase"),
"error": final.get("error"),
"timings_ms": aggregate_timings,
"hops": hops,
}
def summarize_probe_results(results: Sequence[Mapping[str, Any]]) -> dict[str, Any]:
successful = [result for result in results if result.get("success")]
status_counts = Counter(str(result.get("status_code")) for result in successful)
error_counts = Counter(str(result.get("error_phase") or "unknown") for result in results if not result.get("success"))
timing_keys = [
"dns",
"tcp_connect",
"proxy_connect",
"tls_handshake",
"request_upload",
"response_wait",
"headers_complete_after_upload",
"body_read",
"total",
]
timings = {key: summarize_numbers(result.get("timings_ms", {}).get(key) for result in successful) for key in timing_keys}
return {
"requests": len(results),
"successful_transport": len(successful),
"transport_errors": len(results) - len(successful),
"status_counts": dict(status_counts),
"error_phase_counts": dict(error_counts),
"timings_ms": timings,
}
def run_probe_batch(
url: str,
options: ProbeOptions,
*,
count: int,
concurrency: int,
interval: float = 0.0,
) -> tuple[list[dict[str, Any]], float]:
"""Run a bounded number of fresh-connection probes."""
count = max(1, int(count))
concurrency = max(1, min(int(concurrency), count))
started = time.perf_counter()
results: list[dict[str, Any]] = []
if concurrency == 1:
for index in range(count):
results.append(probe_url(url, options))
if interval > 0 and index + 1 < count:
time.sleep(interval)
else:
with concurrent.futures.ThreadPoolExecutor(max_workers=concurrency) as executor:
futures = [executor.submit(probe_url, url, options) for _ in range(count)]
for future in concurrent.futures.as_completed(futures):
results.append(future.result())
return results, monotonic_ms(started)
def validate_replay_safety(
*,
method: str,
url_count: int,
count: int,
concurrency: int,
follow_redirects: bool,
allow_unsafe_replay: bool,
) -> None:
method = method.upper()
if method in SAFE_METHODS:
return
total = max(1, url_count) * max(1, count)
replayed = total > 1 or concurrency > 1 or follow_redirects
if replayed and not allow_unsafe_replay:
raise ValueError(
"Unsafe method would be replayed. Login can invalidate an old session and failed login "
"can trigger IP lockout; agent POSTs can create duplicate runs. Use exactly one URL, "
"--count 1, --concurrency 1 and no redirect following, or explicitly add "
"--allow-unsafe-replay after reviewing the impact."
)
def _parse_scalar(value: str) -> Any:
raw = value.strip().strip("\"'")
lowered = raw.lower()
if lowered in {"true", "yes", "on"}:
return True
if lowered in {"false", "no", "off"}:
return False
if lowered in {"null", "none", "~", ""}:
return None
try:
return int(raw)
except ValueError:
try:
return float(raw)
except ValueError:
return raw
def read_safe_config(path: Path) -> dict[str, Any]:
"""Read a small allowlist of non-secret scalar YAML settings."""
wanted = {
"gateway.port",
"database.backend",
"database.pool_size",
"database.max_overflow",
"database.pool_timeout",
"database.pool_recycle",
"database.sqlite_dir",
"runtime_performance.skill_view_reuse_enabled",
"runtime_performance.skill_view_cache_enabled",
"runtime_performance.model_timing_nonblocking_enabled",
"runtime_performance.asyncio_thread_pool_enabled",
"runtime_performance.asyncio_thread_pool_size",
"concurrency.redis.enabled",
"concurrency.redis.socket_connect_timeout_ms",
"concurrency.redis.socket_timeout_ms",
"concurrency.redis.acquire_timeout_ms",
"concurrency.redis.fail_open",
}
output: dict[str, Any] = {}
stack: list[tuple[int, str]] = []
try:
lines = path.read_text(encoding="utf-8").splitlines()
except OSError as exc:
return {"path": str(path), "error": _safe_error(exc)}
for raw_line in lines:
line = raw_line.split("#", 1)[0].rstrip()
if not line.strip() or line.lstrip().startswith("-") or ":" not in line:
continue
indent = len(line) - len(line.lstrip(" \t"))
key, _separator, value = line.strip().partition(":")
while stack and stack[-1][0] >= indent:
stack.pop()
dotted = ".".join([item[1] for item in stack] + [key])
if value.strip():
if dotted in wanted:
output[dotted] = _parse_scalar(value)
else:
stack.append((indent, key))
output["path"] = str(path)
return output
def find_config_path(explicit: str | None = None) -> Path | None:
candidates: list[Path] = []
if explicit:
candidates.append(Path(explicit))
for env_name in ("DEER_FLOW_CONFIG_PATH", "DEER_FLOW_CONFIG", "DEERFLOW_CONFIG"):
if os.environ.get(env_name):
candidates.append(Path(os.environ[env_name]))
script_backend = Path(__file__).resolve().parent.parent
candidates.extend(
[
Path.cwd() / "config.yaml",
script_backend / "config.yaml",
Path("/app/backend/config.yaml"),
]
)
seen: set[Path] = set()
for candidate in candidates:
with contextlib.suppress(OSError):
resolved = candidate.expanduser().resolve()
if resolved in seen:
continue
seen.add(resolved)
if resolved.is_file():
return resolved
return None
def _runtime_home_candidates(config_path: Path | None) -> list[Path]:
candidates: list[Path] = []
if os.environ.get("DEER_FLOW_HOME"):
candidates.append(Path(os.environ["DEER_FLOW_HOME"]))
if config_path:
candidates.append(config_path.parent / ".deer-flow")
candidates.extend([Path.cwd() / ".deer-flow", Path("/app/backend/.deer-flow")])
return candidates
def read_safe_system_settings(config_path: Path | None) -> dict[str, Any]:
path = next(
(candidate / "system_settings.json" for candidate in _runtime_home_candidates(config_path) if (candidate / "system_settings.json").is_file()),
None,
)
if path is None:
return {"found": False}
try:
raw = json.loads(path.read_text(encoding="utf-8"))
except (OSError, json.JSONDecodeError) as exc:
return {"found": True, "path": str(path), "error": _safe_error(exc)}
model = raw.get("model_concurrency") if isinstance(raw, dict) else {}
monitor = raw.get("concurrency_monitor") if isinstance(raw, dict) else {}
if not isinstance(model, dict):
model = {}
if not isinstance(monitor, dict):
monitor = {}
safe_limits = {str(name)[:120]: int(value) for name, value in (model.get("model_limits") or {}).items() if isinstance(value, int) and value > 0}
safe_user_limits = {str(name)[:120]: int(value) for name, value in (model.get("user_model_limits") or {}).items() if isinstance(value, int) and value > 0}
return {
"found": True,
"path": str(path),
"concurrency_monitor_enabled": bool(monitor.get("enabled", False)),
"model_concurrency": {
"enabled": bool(model.get("enabled", False)),
"default_limit": model.get("default_limit"),
"user_default_limit": model.get("user_default_limit"),
"model_limits": safe_limits,
"user_model_limits": safe_user_limits,
},
}
def _run_command(
args: Sequence[str],
*,
timeout: float = 8.0,
env: Mapping[str, str] | None = None,
) -> dict[str, Any]:
started = time.perf_counter()
try:
completed = subprocess.run(
list(args),
check=False,
capture_output=True,
text=False,
timeout=timeout,
env=dict(env) if env is not None else None,
)
except FileNotFoundError:
return {"available": False, "returncode": None, "elapsed_ms": monotonic_ms(started), "output": b""}
except subprocess.TimeoutExpired as exc:
output = (exc.stdout or b"") + (exc.stderr or b"")
return {
"available": True,
"returncode": None,
"timed_out": True,
"elapsed_ms": monotonic_ms(started),
"output": output[:MAX_COMMAND_OUTPUT],
}
return {
"available": True,
"returncode": completed.returncode,
"timed_out": False,
"elapsed_ms": monotonic_ms(started),
"output": (completed.stdout + completed.stderr)[:MAX_COMMAND_OUTPUT],
}
def _read_proc_stat() -> dict[str, int]:
try:
first = Path("/proc/stat").read_text(encoding="ascii").splitlines()[0].split()
except (OSError, IndexError):
return {}
if not first or first[0] != "cpu":
return {}
names = ["user", "nice", "system", "idle", "iowait", "irq", "softirq", "steal", "guest", "guest_nice"]
values: dict[str, int] = {}
for name, raw in zip(names, first[1:]):
with contextlib.suppress(ValueError):
values[name] = int(raw)
return values
def _cpu_delta(before: Mapping[str, int], after: Mapping[str, int]) -> dict[str, float | None]:
keys = set(before) | set(after)
deltas = {key: max(0, int(after.get(key, 0)) - int(before.get(key, 0))) for key in keys}
total = sum(deltas.values()) - deltas.get("guest", 0) - deltas.get("guest_nice", 0)
if total <= 0:
return {"busy_pct": None, "iowait_pct": None, "steal_pct": None}
idle = deltas.get("idle", 0)
return {
"busy_pct": round(100.0 * (total - idle) / total, 2),
"iowait_pct": round(100.0 * deltas.get("iowait", 0) / total, 2),
"steal_pct": round(100.0 * deltas.get("steal", 0) / total, 2),
}
def _read_meminfo() -> dict[str, int]:
values: dict[str, int] = {}
try:
lines = Path("/proc/meminfo").read_text(encoding="ascii").splitlines()
except OSError:
return values
for line in lines:
key, separator, rest = line.partition(":")
if not separator:
continue
match = re.search(r"\d+", rest)
if match:
values[key] = int(match.group()) * 1024
return values
def _read_vmstat() -> dict[str, int]:
output: dict[str, int] = {}
try:
lines = Path("/proc/vmstat").read_text(encoding="ascii").splitlines()
except OSError:
return output
wanted = {"pswpin", "pswpout", "pgmajfault", "oom_kill"}
for line in lines:
key, _separator, raw = line.partition(" ")
if key not in wanted:
continue
with contextlib.suppress(ValueError):
output[key] = int(raw.strip())
return output
def _read_pressure() -> dict[str, Any]:
output: dict[str, Any] = {}
for resource in ("cpu", "memory", "io"):
path = Path("/proc/pressure") / resource
if not path.is_file():
continue
resource_values: dict[str, dict[str, float | int]] = {}
with contextlib.suppress(OSError):
for line in path.read_text(encoding="ascii").splitlines():
parts = line.split()
if not parts:
continue
metrics: dict[str, float | int] = {}
for part in parts[1:]:
key, separator, raw = part.partition("=")
if not separator:
continue
try:
metrics[key] = int(raw) if key == "total" else float(raw)
except ValueError:
pass
resource_values[parts[0]] = metrics
if resource_values:
output[resource] = resource_values
return output
def _read_diskstats() -> dict[str, dict[str, int]]:
output: dict[str, dict[str, int]] = {}
try:
lines = Path("/proc/diskstats").read_text(encoding="ascii").splitlines()
except OSError:
return output
for line in lines:
parts = line.split()
if len(parts) < 14:
continue
name = parts[2]
if not (Path("/sys/block") / name).exists():
continue
try:
output[name] = {
"reads": int(parts[3]),
"sectors_read": int(parts[5]),
"writes": int(parts[7]),
"sectors_written": int(parts[9]),
"io_ms": int(parts[12]),
}
except ValueError:
continue
return output
def _disk_delta(
before: Mapping[str, Mapping[str, int]],
after: Mapping[str, Mapping[str, int]],
elapsed_seconds: float,
) -> dict[str, Any]:
devices: list[dict[str, Any]] = []
for name in sorted(set(before) & set(after)):
old = before[name]
new = after[name]
read_bytes = max(0, new["sectors_read"] - old["sectors_read"]) * 512
write_bytes = max(0, new["sectors_written"] - old["sectors_written"]) * 512
io_ms = max(0, new["io_ms"] - old["io_ms"])
devices.append(
{
"device": name,
"read_bytes_per_sec": round(read_bytes / max(elapsed_seconds, 0.001), 1),
"write_bytes_per_sec": round(write_bytes / max(elapsed_seconds, 0.001), 1),
"util_pct": round(min(100.0, io_ms / (elapsed_seconds * 10.0)), 2),
"reads": max(0, new["reads"] - old["reads"]),
"writes": max(0, new["writes"] - old["writes"]),
}
)
devices.sort(key=lambda item: item["util_pct"], reverse=True)
return {
"devices": devices[:12],
"max_util_pct": devices[0]["util_pct"] if devices else None,
"busiest_device": devices[0]["device"] if devices else None,
}
def _read_netdev() -> dict[str, dict[str, int]]:
output: dict[str, dict[str, int]] = {}
try:
lines = Path("/proc/net/dev").read_text(encoding="ascii").splitlines()[2:]
except OSError:
return output
for line in lines:
name, separator, raw = line.partition(":")
if not separator:
continue
interface = name.strip()
fields = raw.split()
if len(fields) < 16:
continue
with contextlib.suppress(ValueError):
output[interface] = {
"rx_bytes": int(fields[0]),
"rx_errors": int(fields[2]),
"rx_dropped": int(fields[3]),
"tx_bytes": int(fields[8]),
"tx_errors": int(fields[10]),
"tx_dropped": int(fields[11]),
}
return output
def _net_delta(
before: Mapping[str, Mapping[str, int]],
after: Mapping[str, Mapping[str, int]],
elapsed_seconds: float,
) -> dict[str, Any]:
totals = Counter()
for name in set(before) & set(after):
if name == "lo":
continue
for key in before[name]:
totals[key] += max(0, int(after[name].get(key, 0)) - int(before[name].get(key, 0)))
return {
"rx_bytes_per_sec": round(totals["rx_bytes"] / max(elapsed_seconds, 0.001), 1),
"tx_bytes_per_sec": round(totals["tx_bytes"] / max(elapsed_seconds, 0.001), 1),
"rx_errors_delta": totals["rx_errors"],
"tx_errors_delta": totals["tx_errors"],
"rx_dropped_delta": totals["rx_dropped"],
"tx_dropped_delta": totals["tx_dropped"],
}
def _read_cgroup_number(path: str) -> int | None:
try:
raw = Path(path).read_text(encoding="ascii").strip()
except OSError:
return None
if raw == "max":
return None
with contextlib.suppress(ValueError):
return int(raw)
return None
def _read_cgroup_limits() -> dict[str, Any]:
output: dict[str, Any] = {}
if Path("/sys/fs/cgroup/cgroup.controllers").exists():
memory_current = _read_cgroup_number("/sys/fs/cgroup/memory.current")
memory_max = _read_cgroup_number("/sys/fs/cgroup/memory.max")
pids_current = _read_cgroup_number("/sys/fs/cgroup/pids.current")
pids_max = _read_cgroup_number("/sys/fs/cgroup/pids.max")
cpu_quota: float | None = None
with contextlib.suppress(OSError, ValueError):
quota_raw, period_raw = Path("/sys/fs/cgroup/cpu.max").read_text(encoding="ascii").split()[:2]
if quota_raw != "max":
cpu_quota = round(int(quota_raw) / int(period_raw), 3)
output = {
"version": 2,
"memory_current_bytes": memory_current,
"memory_limit_bytes": memory_max,
"memory_usage_pct": (round(100.0 * memory_current / memory_max, 2) if memory_current is not None and memory_max and memory_max > 0 else None),
"cpu_limit_cores": cpu_quota,
"pids_current": pids_current,
"pids_limit": pids_max,
}
return output
def _read_process_stat(pid: int) -> tuple[int, int] | None:
try:
raw = (Path("/proc") / str(pid) / "stat").read_text(encoding="ascii")
after_name = raw.rsplit(")", 1)[1].split()
return int(after_name[11]), int(after_name[12])
except (OSError, IndexError, ValueError):
return None
def _classify_process(cmdline: str, name: str) -> str | None:
lowered = f"{name} {cmdline}".lower()
if "nginx" in lowered:
return "nginx"
if any(token in lowered for token in ("mysqld", "mariadbd")):
return "mysql"
if any(token in lowered for token in ("uvicorn", "gunicorn", "deerflow", "app.gateway")):
return "deerflow_gateway"
return None
def _scan_relevant_processes() -> dict[int, dict[str, Any]]:
output: dict[int, dict[str, Any]] = {}
for path in Path("/proc").iterdir():
if not path.name.isdigit():
continue
pid = int(path.name)
try:
cmdline = (path / "cmdline").read_bytes().replace(b"\x00", b" ").decode(errors="replace")
status_lines = (path / "status").read_text(encoding="utf-8", errors="replace").splitlines()
except OSError:
continue
status: dict[str, str] = {}
for line in status_lines:
key, separator, value = line.partition(":")
if separator:
status[key] = value.strip()
role = _classify_process(cmdline, status.get("Name", ""))
if role is None:
continue
fd_count: int | None = None
with contextlib.suppress(OSError):
fd_count = len(list((path / "fd").iterdir()))
fd_limit: int | None = None
with contextlib.suppress(OSError):
for line in (path / "limits").read_text(encoding="utf-8").splitlines():
if line.startswith("Max open files"):
fields = line.split()
if len(fields) >= 4 and fields[3].isdigit():
fd_limit = int(fields[3])
rss_kib = 0
match = re.search(r"\d+", status.get("VmRSS", ""))
if match:
rss_kib = int(match.group())
output[pid] = {
"pid": pid,
"role": role,
"rss_bytes": rss_kib * 1024,
"threads": int(status.get("Threads", "0") or 0),
"fd_count": fd_count,
"fd_limit": fd_limit,
"cpu_ticks": _read_process_stat(pid),
}
return output
def _process_delta(
before: Mapping[int, Mapping[str, Any]],
after: Mapping[int, Mapping[str, Any]],
elapsed_seconds: float,
) -> dict[str, Any]:
tick_rate = os.sysconf("SC_CLK_TCK") if hasattr(os, "sysconf") else 100
grouped: dict[str, dict[str, Any]] = defaultdict(
lambda: {
"processes": 0,
"rss_bytes": 0,
"threads": 0,
"fd_count": 0,
"fd_limit_min": None,
"cpu_pct_sum": 0.0,
"max_fd_usage_pct": None,
}
)
for pid, item in after.items():
role = str(item["role"])
group = grouped[role]
group["processes"] += 1
group["rss_bytes"] += int(item.get("rss_bytes") or 0)
group["threads"] += int(item.get("threads") or 0)
fd_count = item.get("fd_count")
fd_limit = item.get("fd_limit")
if isinstance(fd_count, int):
group["fd_count"] += fd_count
if isinstance(fd_limit, int) and fd_limit > 0:
current_min = group["fd_limit_min"]
group["fd_limit_min"] = fd_limit if current_min is None else min(current_min, fd_limit)
if isinstance(fd_count, int):
usage = round(100.0 * fd_count / fd_limit, 2)
current_usage = group["max_fd_usage_pct"]
group["max_fd_usage_pct"] = usage if current_usage is None else max(current_usage, usage)
old = before.get(pid, {}).get("cpu_ticks")
new = item.get("cpu_ticks")
if old and new:
ticks = max(0, (new[0] + new[1]) - (old[0] + old[1]))
group["cpu_pct_sum"] += 100.0 * ticks / max(tick_rate * elapsed_seconds, 0.001)
for group in grouped.values():
group["cpu_pct_sum"] = round(group["cpu_pct_sum"], 2)
return dict(grouped)
def collect_linux_snapshot(sample_seconds: float = 1.0) -> dict[str, Any]:
if platform.system() != "Linux" or not Path("/proc").is_dir():
return {"supported": False, "reason": "server checks require Linux /proc"}
sample_seconds = max(0.25, min(float(sample_seconds), 5.0))
cpu_before = _read_proc_stat()
disk_before = _read_diskstats()
net_before = _read_netdev()
vm_before = _read_vmstat()
process_before = _scan_relevant_processes()
started = time.perf_counter()
time.sleep(sample_seconds)
elapsed = time.perf_counter() - started
cpu_after = _read_proc_stat()
disk_after = _read_diskstats()
net_after = _read_netdev()
vm_after = _read_vmstat()
process_after = _scan_relevant_processes()
mem = _read_meminfo()
total_memory = mem.get("MemTotal", 0)
available_memory = mem.get("MemAvailable", 0)
swap_total = mem.get("SwapTotal", 0)
swap_free = mem.get("SwapFree", 0)
load_values: tuple[float, float, float] | tuple[()] = ()
with contextlib.suppress(OSError):
load_values = os.getloadavg()
file_nr: list[int] = []
with contextlib.suppress(OSError, ValueError):
file_nr = [int(item) for item in Path("/proc/sys/fs/file-nr").read_text().split()]
file_max = _read_cgroup_number("/proc/sys/fs/file-max")
disk_paths: dict[str, Any] = {}
for path in (Path("/"), Path.cwd()):
try:
usage = shutil.disk_usage(path)
except OSError:
continue
disk_paths[str(path)] = {
"total_bytes": usage.total,
"free_bytes": usage.free,
"free_pct": round(100.0 * usage.free / usage.total, 2) if usage.total else None,
}
return {
"supported": True,
"sample_seconds": round(elapsed, 3),
"hostname_hash": hashlib.sha256(socket.gethostname().encode()).hexdigest()[:12],
"kernel": platform.release(),
"cpu_count": os.cpu_count(),
"load_average": list(load_values),
"cpu": _cpu_delta(cpu_before, cpu_after),
"memory": {
"total_bytes": total_memory,
"available_bytes": available_memory,
"available_pct": round(100.0 * available_memory / total_memory, 2) if total_memory else None,
"swap_total_bytes": swap_total,
"swap_used_bytes": max(0, swap_total - swap_free),
"swap_used_pct": round(100.0 * (swap_total - swap_free) / swap_total, 2) if swap_total else 0.0,
"dirty_bytes": mem.get("Dirty", 0),
},
"vm_delta": {key: max(0, vm_after.get(key, 0) - vm_before.get(key, 0)) for key in set(vm_before) | set(vm_after)},
"pressure": _read_pressure(),
"disk_io": _disk_delta(disk_before, disk_after, elapsed),
"disk_space": disk_paths,
"network": _net_delta(net_before, net_after, elapsed),
"processes": _process_delta(process_before, process_after, elapsed),
"cgroup": _read_cgroup_limits(),
"file_handles": {
"allocated": file_nr[0] if file_nr else None,
"unused": file_nr[1] if len(file_nr) > 1 else None,
"system_limit": file_max or (file_nr[2] if len(file_nr) > 2 else None),
},
}
def _port_from_address(value: str) -> int | None:
if value.startswith("[") and "]:" in value:
raw = value.rsplit(":", 1)[-1]
elif ":" in value:
raw = value.rsplit(":", 1)[-1]
else:
return None
with contextlib.suppress(ValueError):
return int(raw)
return None
def collect_socket_snapshot(relevant_ports: set[int]) -> dict[str, Any]:
if shutil.which("ss") is None:
return {"available": False, "reason": "ss command not found"}
listeners_result = _run_command(["ss", "-H", "-lnt"], timeout=5)
connections_result = _run_command(["ss", "-H", "-tan"], timeout=8)
listeners: list[dict[str, Any]] = []
if listeners_result.get("returncode") == 0:
for raw_line in listeners_result["output"].decode(errors="replace").splitlines():
parts = raw_line.split()
if len(parts) < 5:
continue
port = _port_from_address(parts[3])
if port is None or (relevant_ports and port not in relevant_ports):
continue
try:
receive_queue = int(parts[1])
backlog = int(parts[2])
except ValueError:
continue
listeners.append(
{
"port": port,
"receive_queue": receive_queue,
"backlog": backlog,
"backlog_usage_pct": round(100.0 * receive_queue / backlog, 2) if backlog else None,
}
)
global_states: Counter[str] = Counter()
by_port: dict[int, Counter[str]] = defaultdict(Counter)
if connections_result.get("returncode") == 0:
for raw_line in connections_result["output"].decode(errors="replace").splitlines():
parts = raw_line.split()
if len(parts) < 5:
continue
state = parts[0].upper()
global_states[state] += 1
local_port = _port_from_address(parts[3])
peer_port = _port_from_address(parts[4])
for port in relevant_ports:
if port in {local_port, peer_port}:
by_port[port][state] += 1
return {
"available": bool(listeners_result.get("available") and connections_result.get("available")),
"listeners": listeners,
"global_states": dict(global_states),
"states_by_relevant_port": {str(port): dict(states) for port, states in by_port.items()},
}
def _parse_nginx_config(text: str, *, source: str) -> dict[str, Any]:
def _last_value(pattern: str) -> str | None:
matches = re.findall(pattern, text, flags=re.IGNORECASE | re.MULTILINE)
return matches[-1] if matches else None
worker_processes_raw = _last_value(r"^\s*worker_processes\s+([^;\s]+)\s*;")
worker_connections_raw = _last_value(r"^\s*worker_connections\s+(\d+)\s*;")
worker_rlimit_raw = _last_value(r"^\s*worker_rlimit_nofile\s+(\d+)\s*;")
keepalive_timeout_raw = _last_value(r"^\s*keepalive_timeout\s+([^;]+)\s*;")
multi_accept_raw = _last_value(r"^\s*multi_accept\s+([^;\s]+)\s*;")
workers: int | None
if worker_processes_raw == "auto":
workers = os.cpu_count()
else:
try:
workers = int(worker_processes_raw) if worker_processes_raw else None
except ValueError:
workers = None
worker_connections = int(worker_connections_raw) if worker_connections_raw else 512
theoretical = workers * worker_connections if workers else None
return {
"source": source,
"worker_processes_config": worker_processes_raw,
"worker_processes_resolved": workers,
"worker_connections": worker_connections,
"worker_rlimit_nofile": int(worker_rlimit_raw) if worker_rlimit_raw else None,
"theoretical_connection_slots": theoretical,
"reverse_proxy_estimated_client_ceiling": theoretical // 2 if theoretical else None,
"multi_accept": multi_accept_raw,
"keepalive_timeout": keepalive_timeout_raw[:80] if keepalive_timeout_raw else None,
"limit_conn_directives": len(re.findall(r"\blimit_conn\s+", text)),
"upstream_keepalive_directives": len(re.findall(r"\bkeepalive\s+\d+\s*;", text)),
"proxy_connect_timeout": _last_value(r"^\s*proxy_connect_timeout\s+([^;]+)\s*;"),
"proxy_read_timeout": _last_value(r"^\s*proxy_read_timeout\s+([^;]+)\s*;"),
"proxy_send_timeout": _last_value(r"^\s*proxy_send_timeout\s+([^;]+)\s*;"),
}
def collect_nginx_config(container_ids: Sequence[str] = ()) -> dict[str, Any]:
candidates: list[dict[str, Any]] = []
if shutil.which("nginx"):
result = _run_command(["nginx", "-T"], timeout=8)
if result.get("returncode") == 0:
candidates.append(_parse_nginx_config(result["output"].decode(errors="replace"), source="host:nginx -T"))
if shutil.which("docker"):
for container_id in list(container_ids)[:5]:
result = _run_command(["docker", "exec", container_id, "nginx", "-T"], timeout=8)
if result.get("returncode") == 0:
candidates.append(
_parse_nginx_config(
result["output"].decode(errors="replace"),
source=f"container:{container_id[:12]}",
)
)
if not candidates:
return {"available": False, "reason": "no readable running Nginx configuration"}
return {"available": True, "instances": candidates}
def collect_docker_snapshot() -> dict[str, Any]:
if shutil.which("docker") is None:
return {"available": False, "reason": "docker command not found"}
listing = _run_command(
["docker", "ps", "--format", "{{.ID}}\t{{.Names}}\t{{.Image}}\t{{.Status}}"],
timeout=8,
)
if listing.get("returncode") != 0:
return {"available": False, "reason": "docker ps failed"}
containers: list[dict[str, Any]] = []
relevant_ids: list[str] = []
nginx_ids: list[str] = []
for line in listing["output"].decode(errors="replace").splitlines():
parts = line.split("\t", 3)
if len(parts) != 4:
continue
container_id, name, image, status = parts
lowered = f"{name} {image}".lower()
if not any(token in lowered for token in ("deerflow", "gateway", "backend", "nginx", "frontend", "mysql", "mariadb")):
continue
role = "other"
if any(token in lowered for token in ("mysql", "mariadb")):
role = "mysql"
elif "nginx" in lowered or "frontend" in lowered:
role = "nginx_or_frontend"
nginx_ids.append(container_id)
elif any(token in lowered for token in ("deerflow", "gateway", "backend")):
role = "deerflow_gateway"
item = {
"id": container_id[:12],
"name": name[:120],
"image": image[:180],
"role": role,
"status": status[:160],
}
containers.append(item)
relevant_ids.append(container_id)
stats_by_name: dict[str, dict[str, Any]] = {}
if relevant_ids:
stats = _run_command(
[
"docker",
"stats",
"--no-stream",
"--format",
"{{.Name}}\t{{.CPUPerc}}\t{{.MemUsage}}\t{{.MemPerc}}\t{{.PIDs}}\t{{.NetIO}}\t{{.BlockIO}}",
*relevant_ids,
],
timeout=15,
)
if stats.get("returncode") == 0:
for line in stats["output"].decode(errors="replace").splitlines():
fields = line.split("\t")
if len(fields) != 7:
continue
name, cpu, memory, memory_pct, pids, network, block = fields
stats_by_name[name] = {
"cpu_pct": _parse_percent(cpu),
"memory_usage": memory[:100],
"memory_pct": _parse_percent(memory_pct),
"pids": int(pids) if pids.isdigit() else None,
"network_io": network[:100],
"block_io": block[:100],
}
for container, container_id in zip(containers, relevant_ids):
container["stats"] = stats_by_name.get(container["name"], {})
inspect = _run_command(
[
"docker",
"inspect",
"--format",
"{{.HostConfig.Memory}}\t{{.HostConfig.NanoCpus}}\t{{.HostConfig.PidsLimit}}\t{{.State.OOMKilled}}\t{{.RestartCount}}",
container_id,
],
timeout=5,
)
if inspect.get("returncode") == 0:
fields = inspect["output"].decode(errors="replace").strip().split("\t")
if len(fields) == 5:
memory_limit, nano_cpus, pids_limit, oom_killed, restart_count = fields
container["limits"] = {
"memory_bytes": int(memory_limit) if memory_limit.isdigit() and int(memory_limit) > 0 else None,
"cpu_cores": round(int(nano_cpus) / 1_000_000_000, 3) if nano_cpus.isdigit() and int(nano_cpus) > 0 else None,
"pids": int(pids_limit) if re.fullmatch(r"-?\d+", pids_limit) and int(pids_limit) > 0 else None,
"oom_killed": oom_killed.lower() == "true",
"restart_count": int(restart_count) if restart_count.isdigit() else None,
}
return {"available": True, "containers": containers, "nginx_container_ids": nginx_ids}
def _parse_percent(raw: str) -> float | None:
try:
return round(float(raw.strip().rstrip("%")), 2)
except ValueError:
return None
LOG_PATTERNS: dict[str, re.Pattern[str]] = {
"nginx_connection_capacity": re.compile(r"worker_connections are not enough", re.IGNORECASE),
"too_many_open_files": re.compile(r"too many open files", re.IGNORECASE),
"nginx_upstream_timeout": re.compile(r"upstream timed out", re.IGNORECASE),
"nginx_upstream_connect_failure": re.compile(r"connect\(\) failed|no live upstreams|upstream.*connection refused", re.IGNORECASE),
"db_pool_timeout": re.compile(r"QueuePool limit|connection pool.*(?:exhaust|timeout)|pool timeout", re.IGNORECASE),
"db_lock_or_deadlock": re.compile(r"lock wait timeout|deadlock found|database is locked", re.IGNORECASE),
"db_disconnect": re.compile(r"server has gone away|lost connection.*mysql|connection.*closed|connection reset", re.IGNORECASE),
"deerflow_concurrency_reject": re.compile(r"reached the concurrency limit|selected model is busy|ModelConcurrencyLimit", re.IGNORECASE),
"resource_exhaustion": re.compile(r"cannot allocate memory|out of memory|oomkilled|resource temporarily unavailable", re.IGNORECASE),
"gateway_timeout": re.compile(r"504 Gateway Time-out|Gateway Timeout", re.IGNORECASE),
"skill_view_timing": re.compile(r"SKILL_VIEW_CACHE", re.IGNORECASE),
"start_run_timing": re.compile(r"\[start-run-timing\]", re.IGNORECASE),
"operation_timing": re.compile(r"GATEWAY_OPERATION_TIMING", re.IGNORECASE),
"llm_request_timing": re.compile(r"LLM_REQUEST_(?:DONE|FAILED)", re.IGNORECASE),
}
def _tail_bytes(path: Path, maximum: int) -> bytes:
with path.open("rb") as handle:
handle.seek(0, os.SEEK_END)
size = handle.tell()
handle.seek(max(0, size - maximum))
return handle.read(maximum)
def discover_log_paths(explicit: Sequence[str], config_path: Path | None) -> list[Path]:
candidates = [Path(path) for path in explicit]
roots = [Path.cwd(), Path(__file__).resolve().parents[2]]
if config_path:
roots.append(config_path.parent)
for root in roots:
candidates.extend(
[
root / ".runtime/logs/backend.log",
root / ".runtime/logs/gateway.log",
root / "logs/backend.log",
root / "logs/gateway.log",
]
)
for pattern in ("/var/log/nginx/*.log", "/var/log/mysql/*.log", "/var/log/mariadb/*.log"):
candidates.extend(Path(item) for item in glob.glob(pattern))
output: list[Path] = []
seen: set[Path] = set()
for candidate in candidates:
with contextlib.suppress(OSError):
resolved = candidate.expanduser().resolve()
if resolved not in seen and resolved.is_file():
seen.add(resolved)
output.append(resolved)
return output
def scan_logs(paths: Sequence[Path], tail_bytes: int = 4 * 1024 * 1024) -> dict[str, Any]:
files: list[dict[str, Any]] = []
totals: Counter[str] = Counter()
timing_max: dict[str, float] = {}
for path in paths:
try:
text = _tail_bytes(path, tail_bytes).decode("utf-8", errors="replace")
stat = path.stat()
except OSError as exc:
files.append({"path": str(path), "error": _safe_error(exc)})
continue
counts: dict[str, int] = {}
for name, pattern in LOG_PATTERNS.items():
count = len(pattern.findall(text))
if count:
counts[name] = count
totals[name] += count
numeric_patterns = {
"skill_view_total_ms": r"SKILL_VIEW_CACHE[^\n]*\btotal_ms=([0-9.]+)",
"agent_build_elapsed_ms": r"AGENT_BUILD_TIMING[^\n]*\belapsed_ms=([0-9.]+)",
"llm_total_ms": r"LLM_REQUEST_(?:DONE|FAILED)[^\n]*\btotal_ms=([0-9.]+)",
"start_run_total_seconds": r"\[start-run-timing\][^\n]*\btotal=([0-9.]+)s",
}
for name, pattern in numeric_patterns.items():
values = []
for raw in re.findall(pattern, text, flags=re.IGNORECASE):
with contextlib.suppress(ValueError):
values.append(float(raw))
if values:
timing_max[name] = max(timing_max.get(name, 0.0), max(values))
files.append(
{
"path": str(path),
"bytes_scanned": min(stat.st_size, tail_bytes),
"modified_at": dt.datetime.fromtimestamp(stat.st_mtime, tz=UTC).isoformat(),
"pattern_counts": counts,
}
)
return {"files": files, "pattern_counts": dict(totals), "timing_max": timing_max}
def collect_catalogue_scan(config_path: Path | None) -> dict[str, Any]:
if config_path is None:
return {"available": False, "reason": "config.yaml not found"}
runtime_home = next((path for path in _runtime_home_candidates(config_path) if path.exists()), config_path.parent / ".deer-flow")
skills_root = Path(os.environ.get("DEER_FLOW_SKILLS_PATH", str(config_path.parent / "skills")))
agents_root = Path(os.environ.get("DEER_FLOW_AGENTS_PATH", str(runtime_home / "agents")))
output: dict[str, Any] = {"available": True}
for label, root, names in (
("skills", skills_root, {"SKILL.md", "config.yaml"}),
("agents", agents_root, {"config.yaml", "SOUL.md"}),
):
started = time.perf_counter()
directories = 0
files = 0
bytes_total = 0
errors = 0
cap_reason: str | None = None
deadline = time.perf_counter() + 3.0
if root.is_dir():
try:
for current_root, dirnames, filenames in os.walk(root):
if time.perf_counter() >= deadline:
dirnames.clear()
cap_reason = "3s_deadline"
break
directories += len(dirnames)
if directories >= 20_000:
dirnames.clear()
cap_reason = "20000_directories"
break
for filename in filenames:
if filename not in names and not (label == "skills" and filename.endswith((".md", ".py"))):
continue
files += 1
with contextlib.suppress(OSError):
bytes_total += (Path(current_root) / filename).stat().st_size
if files >= 100_000:
cap_reason = "100000_files"
break
if files >= 100_000:
break
except OSError:
errors += 1
output[label] = {
"root": str(root),
"exists": root.is_dir(),
"directories": directories,
"matching_files": files,
"bytes": bytes_total,
"metadata_walk_ms": monotonic_ms(started),
"errors": errors,
"capped": cap_reason is not None,
"cap_reason": cap_reason,
}
return output
def _escape_mysql_option(value: str) -> str:
if "\n" in value or "\r" in value:
raise ValueError("MySQL option contains a newline")
return value.replace("\\", "\\\\").replace('"', '\\"')
def _mysql_url_parts(url: str) -> dict[str, Any]:
normalized = re.sub(r"^mysql(?:\+[A-Za-z0-9_]+)?://", "mysql://", url, count=1)
parsed = urllib.parse.urlsplit(normalized)
if parsed.scheme != "mysql" or not parsed.hostname:
raise ValueError("MySQL URL must use mysql:// or mysql+driver://")
if parsed.fragment:
raise ValueError("MySQL URL fragments are unsupported; use --mysql-defaults-file")
query = urllib.parse.parse_qs(parsed.query, keep_blank_values=True, strict_parsing=True)
normalized_query: dict[str, list[str]] = {}
for key, values in query.items():
normalized_key = key.lower().replace("_", "-")
if normalized_key in normalized_query:
raise ValueError("MySQL URL has duplicate normalized options; use --mysql-defaults-file")
normalized_query[normalized_key] = values
allowed = {"ssl-mode", "ssl-ca", "ssl-cert", "ssl-key"}
unknown = set(normalized_query) - allowed
if unknown or any(len(values) != 1 for values in normalized_query.values()):
raise ValueError("MySQL URL has unsupported or repeated options; use --mysql-defaults-file")
ssl_mode_values = normalized_query.get("ssl-mode") or []
ssl_mode = ssl_mode_values[0].upper() if ssl_mode_values else None
if ssl_mode not in {None, "REQUIRED", "VERIFY_CA", "VERIFY_IDENTITY"}:
raise ValueError("MySQL URL ssl-mode must be REQUIRED, VERIFY_CA, or VERIFY_IDENTITY")
output = {
"host": parsed.hostname,
"port": parsed.port or 3306,
"user": urllib.parse.unquote(parsed.username or ""),
"password": urllib.parse.unquote(parsed.password or ""),
"database": urllib.parse.unquote(parsed.path.lstrip("/")),
"ssl-mode": ssl_mode,
}
for name in ("ssl-ca", "ssl-cert", "ssl-key"):
values = normalized_query.get(name)
if values:
# parse_qs() already percent-decodes query values. Decoding a
# second time could change a literal "%2F" in a file name.
output[name] = values[0]
output["protocol"] = "TCP"
return output
MYSQL_SAFE_OPTIONS = {
"host",
"port",
"user",
"password",
"database",
"protocol",
"socket",
"ssl-mode",
"ssl-ca",
"ssl-capath",
"ssl-cert",
"ssl-key",
"ssl-cipher",
"tls-version",
"ssl-crl",
"ssl-crlpath",
}
def _decode_mysql_option_value(raw: str) -> str:
value = raw.strip()
if value[:1] in {'"', "'"}:
if len(value) < 2 or value[-1] != value[0]:
raise ValueError("unterminated quote in MySQL defaults file")
value = value[1:-1]
output: list[str] = []
index = 0
escapes = {"b": "\b", "t": "\t", "n": "\n", "r": "\r", "s": " ", "\\": "\\", '"': '"', "'": "'"}
while index < len(value):
if value[index] == "\\" and index + 1 < len(value):
index += 1
escaped = value[index]
output.append(escapes.get(escaped, f"\\{escaped}"))
else:
output.append(value[index])
index += 1
decoded = "".join(output)
if "\x00" in decoded or "\r" in decoded or "\n" in decoded:
raise ValueError("MySQL option contains a forbidden control character")
return decoded
def _read_safe_mysql_defaults(path: Path) -> dict[str, str]:
options: dict[str, str] = {}
section = ""
for line_number, raw_line in enumerate(path.read_text(encoding="utf-8").splitlines(), start=1):
stripped = raw_line.strip()
if not stripped or stripped.startswith(("#", ";")):
continue
if stripped.lower().startswith("!include"):
raise ValueError("MySQL defaults file include directives are not allowed")
if stripped.startswith("[") and stripped.endswith("]"):
section = stripped[1:-1].strip().lower()
continue
if section != "client":
continue
key, separator, raw_value = stripped.partition("=")
if not separator:
raise ValueError(f"MySQL [client] option on line {line_number} must use key=value")
normalized_key = key.strip().lower().replace("_", "-")
if normalized_key not in MYSQL_SAFE_OPTIONS:
raise ValueError(f"unsafe or unsupported MySQL [client] option: {normalized_key}")
if normalized_key in options:
raise ValueError(f"duplicate MySQL [client] option: {normalized_key}")
options[normalized_key] = _decode_mysql_option_value(raw_value)
if not options:
raise ValueError("MySQL defaults file has no supported [client] options")
if options.get("port"):
try:
port = int(options["port"])
except ValueError as exc:
raise ValueError("MySQL port must be an integer") from exc
if not 1 <= port <= 65535:
raise ValueError("MySQL port must be between 1 and 65535")
options["port"] = str(port)
if options.get("protocol") and options["protocol"].upper() not in {"TCP", "SOCKET"}:
raise ValueError("MySQL protocol must be TCP or SOCKET")
if options.get("protocol"):
options["protocol"] = options["protocol"].upper()
if options.get("ssl-mode"):
ssl_mode = options["ssl-mode"].upper()
if ssl_mode not in {"REQUIRED", "VERIFY_CA", "VERIFY_IDENTITY"}:
raise ValueError("MySQL ssl-mode must be REQUIRED, VERIFY_CA, or VERIFY_IDENTITY")
options["ssl-mode"] = ssl_mode
return options
@contextlib.contextmanager
def _mysql_defaults_file(
*,
explicit_file: str | None,
mysql_url_env: str | None,
mysql_host: str | None,
mysql_port: int,
) -> Iterable[tuple[str | None, dict[str, Any] | None, str | None]]:
env_name = mysql_url_env or "MYSQL_DATABASE_URL"
connection: dict[str, Any]
options: dict[str, Any]
if explicit_file:
path = Path(explicit_file).expanduser()
if not path.is_file():
yield None, None, f"MySQL defaults file not found: {path}"
return
try:
options = _read_safe_mysql_defaults(path)
except (OSError, UnicodeError, ValueError) as exc:
yield None, None, f"could not safely parse MySQL defaults file: {exc}"
return
connection = {
"source": "sanitized_defaults_file",
"host": options.get("host") or mysql_host,
"port": int(options.get("port") or mysql_port),
"database": options.get("database"),
"ssl-mode": options.get("ssl-mode"),
"protocol": options.get("protocol"),
}
if os.name == "posix":
with contextlib.suppress(OSError):
mode = path.stat().st_mode & 0o777
if mode & 0o077:
connection["permissions_warning"] = f"source defaults file mode {mode:o} is group/world accessible"
protocol = str(options.get("protocol") or "").upper()
local_socket = bool(protocol == "SOCKET" or (os.name == "posix" and protocol != "TCP" and (options.get("socket") or str(options.get("host") or "").lower() in {"", "localhost"})))
connection["transport"] = "unix_socket" if local_socket else "tcp"
if not local_socket and not options.get("ssl-mode"):
yield (
None,
connection,
"MySQL TCP authentication requires ssl-mode=REQUIRED (or VERIFY_CA/VERIFY_IDENTITY) in the sanitized [client] file. Only TCP timing was collected.",
)
return
else:
raw_url = os.environ.get(env_name)
if not raw_url:
if mysql_host:
yield None, {"source": "host_only", "host": mysql_host, "port": mysql_port}, None
else:
yield None, None, f"{env_name} is not set and --mysql-defaults-file was not provided"
return
try:
options = _mysql_url_parts(raw_url)
except ValueError as exc:
yield None, None, str(exc)
return
connection = {key: value for key, value in options.items() if key in {"host", "port", "database", "ssl-mode"} and value not in (None, "")}
connection["source"] = f"env:{env_name}"
if not options.get("ssl-mode"):
yield (
None,
connection,
"MySQL URL authentication requires ?ssl-mode=REQUIRED (or VERIFY_CA/VERIFY_IDENTITY); otherwise use --mysql-defaults-file. Only TCP timing was collected.",
)
return
content = "[client]\n"
for key in sorted(MYSQL_SAFE_OPTIONS):
value = options.get(key)
if value in (None, ""):
continue
content += f'{key}="{_escape_mysql_option(str(value))}"\n'
file_path: str | None = None
try:
with tempfile.NamedTemporaryFile(
mode="w",
encoding="utf-8",
prefix="deerflow-mysql-",
suffix=".cnf",
delete=False,
) as handle:
file_path = handle.name
handle.write(content)
handle.flush()
os.fsync(handle.fileno())
os.chmod(file_path, 0o600)
if os.name == "posix" and (Path(file_path).stat().st_mode & 0o077):
yield None, None, "could not enforce 0600 permissions on temporary MySQL credentials"
return
yield file_path, connection, None
finally:
if file_path:
with contextlib.suppress(OSError):
Path(file_path).unlink()
def _clean_mysql_environment(secret_env_name: str | None) -> dict[str, str]:
clean_env = dict(os.environ)
for name in (
"MYSQL_PWD",
"MYSQL_HOST",
"MYSQL_TCP_PORT",
"MYSQL_UNIX_PORT",
"MYSQL_TEST_LOGIN_FILE",
"MYSQL_GROUP_SUFFIX",
secret_env_name,
):
if name:
clean_env.pop(name, None)
return clean_env
def _mysql_supports_no_login_paths(secret_env_name: str | None) -> bool:
result = _run_command(
["mysql", "--no-defaults", "--help"],
timeout=5.0,
env=_clean_mysql_environment(secret_env_name),
)
return b"--no-login-paths" in result.get("output", b"")
def _mysql_command(
defaults_file: str,
sql: str,
timeout: float = 8.0,
*,
secret_env_name: str | None = None,
no_login_paths_supported: bool,
) -> dict[str, Any]:
clean_env = _clean_mysql_environment(secret_env_name)
arguments = ["mysql"]
if no_login_paths_supported:
arguments.append("--no-login-paths")
else:
clean_env["MYSQL_TEST_LOGIN_FILE"] = f"{defaults_file}.disabled-login-path"
arguments.append(f"--defaults-file={defaults_file}")
arguments.extend(
[
"--batch",
"--raw",
"--skip-column-names",
"--connect-timeout=5",
"--execute",
sql,
]
)
return _run_command(
arguments,
timeout=timeout,
env=clean_env,
)
def _parse_key_value_tsv(raw: bytes) -> dict[str, Any]:
output: dict[str, Any] = {}
for line in raw.decode(errors="replace").splitlines():
key, separator, value = line.partition("\t")
if not separator:
continue
value = value.strip()
try:
parsed: Any = int(value)
except ValueError:
try:
parsed = float(value)
except ValueError:
parsed = value[:200]
output[key.strip()] = parsed
return output
def _tcp_connect_timing(host: str, port: int, timeout: float = 3.0) -> dict[str, Any]:
started = time.perf_counter()
try:
sock, details = _connect_socket(host, port, timeout=timeout, family="auto")
except ProbeFailure as exc:
return {"success": False, "error_phase": exc.phase, "error": str(exc), "total_ms": monotonic_ms(started)}
with contextlib.suppress(OSError):
sock.close()
return {
"success": True,
"dns_ms": details["dns_ms"],
"connect_ms": details["connect_ms"],
"peer_ip": details["peer_ip"],
"total_ms": monotonic_ms(started),
}
def collect_mysql_diagnostics(
*,
enabled: bool,
mysql_url_env: str | None,
defaults_file: str | None,
mysql_host: str | None,
mysql_port: int,
probes: int = 3,
) -> dict[str, Any]:
if not enabled:
return {"enabled": False}
with _mysql_defaults_file(
explicit_file=defaults_file,
mysql_url_env=mysql_url_env,
mysql_host=mysql_host,
mysql_port=mysql_port,
) as (resolved_file, connection, config_error):
output: dict[str, Any] = {"enabled": True, "connection": connection}
if connection and connection.get("host"):
output["tcp"] = _tcp_connect_timing(str(connection["host"]), int(connection.get("port") or mysql_port))
if config_error:
output["available"] = False
output["reason"] = config_error
return output
if shutil.which("mysql") is None:
output["available"] = False
output["reason"] = "mysql command-line client not found; TCP timing may still be present"
return output
if resolved_file is None:
output["available"] = False
output["reason"] = "credentials were not provided"
return output
no_login_paths_supported = _mysql_supports_no_login_paths(mysql_url_env)
output["client_option_isolation"] = {
"defaults_file_sanitized": True,
"login_paths_disabled": no_login_paths_supported,
"login_file_redirected_when_unsupported": not no_login_paths_supported,
"ambient_mysql_environment_removed": True,
}
def mysql_query(sql: str, timeout: float = 8.0) -> dict[str, Any]:
return _mysql_command(
resolved_file,
sql,
timeout=timeout,
secret_env_name=mysql_url_env,
no_login_paths_supported=no_login_paths_supported,
)
ping_ms: list[float] = []
ping_errors: list[str] = []
for _index in range(max(1, min(probes, 10))):
result = mysql_query("SELECT 1;", timeout=8)
if result.get("returncode") == 0:
ping_ms.append(float(result["elapsed_ms"]))
else:
ping_errors.append("timeout" if result.get("timed_out") else "query_failed")
output["select_1_ms"] = summarize_numbers(ping_ms)
output["select_1_errors"] = dict(Counter(ping_errors))
output["available"] = bool(ping_ms)
if not ping_ms:
output["reason"] = "all authenticated SELECT 1 probes failed; subsequent status queries were skipped"
return output
variables_sql = "SHOW GLOBAL VARIABLES WHERE Variable_name IN ('max_connections','wait_timeout','interactive_timeout','slow_query_log','long_query_time');"
status_sql = (
"SHOW GLOBAL STATUS WHERE Variable_name IN "
"('Threads_connected','Threads_running','Threads_created','Connections','Aborted_connects',"
"'Max_used_connections','Slow_queries','Questions','Uptime','Innodb_row_lock_current_waits',"
"'Innodb_row_lock_time','Innodb_row_lock_waits','Created_tmp_disk_tables','Created_tmp_tables',"
"'Innodb_buffer_pool_reads','Innodb_buffer_pool_read_requests');"
)
variables_result = mysql_query(variables_sql)
status_result = mysql_query(status_sql)
if variables_result.get("returncode") == 0:
output["variables"] = _parse_key_value_tsv(variables_result["output"])
else:
output["variables_error"] = "query_failed"
if status_result.get("returncode") == 0:
output["status"] = _parse_key_value_tsv(status_result["output"])
else:
output["status_error"] = "query_failed"
process_sql = (
"SELECT COUNT(*),"
"COALESCE(SUM(COMMAND='Sleep'),0),"
"COALESCE(SUM(COMMAND<>'Sleep'),0),"
"COALESCE(SUM(COMMAND<>'Sleep' AND TIME>=5),0),"
"COALESCE(SUM(COMMAND<>'Sleep' AND TIME>=30),0),"
"COALESCE(SUM(LOWER(COALESCE(STATE,'')) LIKE '%lock%'),0),"
"COALESCE(MAX(CASE WHEN COMMAND<>'Sleep' THEN TIME ELSE 0 END),0) "
"FROM information_schema.PROCESSLIST;"
)
process_result = mysql_query(process_sql)
if process_result.get("returncode") == 0:
fields = process_result["output"].decode(errors="replace").strip().split("\t")
if len(fields) == 7:
names = [
"total",
"sleeping",
"active",
"active_at_least_5s",
"active_at_least_30s",
"lock_state_count",
"max_active_seconds",
]
with contextlib.suppress(ValueError):
output["processlist"] = {name: int(value) for name, value in zip(names, fields)}
else:
output["processlist_error"] = "query_failed_or_permission_denied"
lock_queries = {
"data_lock_waits": "SELECT COUNT(*) FROM performance_schema.data_lock_waits;",
"pending_metadata_locks": ("SELECT COUNT(*) FROM performance_schema.metadata_locks WHERE LOCK_STATUS='PENDING';"),
}
locks: dict[str, Any] = {}
for name, sql in lock_queries.items():
result = mysql_query(sql)
if result.get("returncode") == 0:
raw = result["output"].decode(errors="replace").strip()
with contextlib.suppress(ValueError):
locks[name] = int(raw)
else:
locks[f"{name}_error"] = "unavailable_or_permission_denied"
output["locks"] = locks
digest_sql = "SELECT COUNT(*),COALESCE(ROUND(MAX(AVG_TIMER_WAIT)/1000000000,3),0),COALESCE(ROUND(MAX(MAX_TIMER_WAIT)/1000000000,3),0) FROM performance_schema.events_statements_summary_by_digest WHERE COUNT_STAR>0;"
digest_result = mysql_query(digest_sql)
if digest_result.get("returncode") == 0:
fields = digest_result["output"].decode(errors="replace").strip().split("\t")
if len(fields) == 3:
with contextlib.suppress(ValueError):
output["statement_digest_summary"] = {
"digests": int(fields[0]),
"max_average_ms": float(fields[1]),
"max_single_ms": float(fields[2]),
}
else:
output["statement_digest_error"] = "unavailable_or_permission_denied"
return output
def collect_sqlite_diagnostics(config_path: Path | None, config: Mapping[str, Any]) -> dict[str, Any]:
if str(config.get("database.backend") or "").lower() != "sqlite" or config_path is None:
return {"enabled": False}
import sqlite3
sqlite_dir = str(config.get("database.sqlite_dir") or ".deer-flow/data")
base = Path(sqlite_dir)
if not base.is_absolute():
base = config_path.parent / base
candidates = [base / "deerflow.db", base / "deerflow.sqlite", base / "deerflow.sqlite3"]
db_path = next((item for item in candidates if item.is_file()), None)
if db_path is None:
return {"enabled": True, "available": False, "reason": "SQLite database file not found"}
started = time.perf_counter()
try:
uri = f"file:{urllib.parse.quote(str(db_path))}?mode=ro"
connection = sqlite3.connect(uri, uri=True, timeout=0.5)
try:
connection.execute("SELECT 1").fetchone()
journal_mode = connection.execute("PRAGMA journal_mode").fetchone()[0]
page_count = int(connection.execute("PRAGMA page_count").fetchone()[0])
page_size = int(connection.execute("PRAGMA page_size").fetchone()[0])
finally:
connection.close()
except sqlite3.Error as exc:
return {
"enabled": True,
"available": False,
"path": str(db_path),
"error": _safe_error(exc),
"probe_ms": monotonic_ms(started),
}
return {
"enabled": True,
"available": True,
"path": str(db_path),
"probe_ms": monotonic_ms(started),
"journal_mode": journal_mode,
"page_count": page_count,
"page_size": page_size,
"database_bytes": db_path.stat().st_size,
"wal_bytes": db_path.with_name(db_path.name + "-wal").stat().st_size if db_path.with_name(db_path.name + "-wal").is_file() else 0,
"shm_bytes": db_path.with_name(db_path.name + "-shm").stat().st_size if db_path.with_name(db_path.name + "-shm").is_file() else 0,
}
def _json_request(
url: str,
*,
headers: Mapping[str, str],
timeout: float,
insecure: bool,
ca_file: str | None,
) -> tuple[int | None, float, Any | None, str | None]:
started = time.perf_counter()
try:
request = urllib.request.Request(url, headers=dict(headers), method="GET")
handlers: list[Any] = [urllib.request.ProxyHandler({}), _NoRedirectHandler()]
if urllib.parse.urlsplit(url).scheme == "https":
context = ssl._create_unverified_context() if insecure else ssl.create_default_context(cafile=ca_file) # noqa: SLF001
handlers.append(urllib.request.HTTPSHandler(context=context))
opener = urllib.request.build_opener(*handlers)
with opener.open(request, timeout=timeout) as response:
status = int(response.status)
body = response.read(4 * 1024 * 1024)
except urllib.error.HTTPError as exc:
return int(exc.code), monotonic_ms(started), None, f"HTTP {exc.code}"
except (urllib.error.URLError, TimeoutError, OSError, ValueError) as exc:
return None, monotonic_ms(started), None, _safe_error(exc)
try:
payload = json.loads(body)
except (json.JSONDecodeError, UnicodeDecodeError):
return status, monotonic_ms(started), None, "response was not JSON"
return status, monotonic_ms(started), payload, None
def collect_admin_concurrency(
origin: str | None,
*,
token_env: str | None,
timeout: float,
insecure: bool,
ca_file: str | None,
) -> dict[str, Any]:
if not token_env:
return {"enabled": False, "reason": "--admin-token-env not supplied"}
token = os.environ.get(token_env)
if not token:
return {"enabled": True, "available": False, "reason": f"environment variable {token_env} is not set"}
if not origin:
return {"enabled": True, "available": False, "reason": "admin origin is unavailable"}
headers = {"Authorization": token if token.lower().startswith("bearer ") else f"Bearer {token}"}
paths = {
"active": "/api/admin/active-runs",
"concurrency": "/api/admin/active-runs/concurrency?granularity=minute&hours=1",
"token_speed": "/api/admin/active-runs/token-speed?granularity=minute&hours=1",
"limits": "/api/system-settings/model-concurrency",
}
output: dict[str, Any] = {
"enabled": True,
"available": False,
"origin": sanitize_origin(origin),
"proxy_mode": "direct",
}
successful_endpoints = 0
failed_endpoints: list[str] = []
required_keys = {
"active": {"count", "runs"},
"concurrency": {"enabled", "points"},
"token_speed": {"points"},
"limits": {"enabled", "default_limit", "user_default_limit", "model_limits", "user_model_limits"},
}
def safe_int(value: Any, default: int = 0) -> int:
try:
parsed = int(value)
except (TypeError, ValueError, OverflowError):
return default
return parsed
def safe_float(value: Any, default: float = 0.0) -> float:
try:
parsed = float(value)
except (TypeError, ValueError, OverflowError):
return default
return parsed if math.isfinite(parsed) else default
def payload_schema_ok(name: str, payload: Any) -> bool:
if not isinstance(payload, dict) or not required_keys[name].issubset(payload):
return False
if name == "active":
return isinstance(payload.get("count"), int) and isinstance(payload.get("runs"), list)
if name == "concurrency":
return isinstance(payload.get("enabled"), bool) and isinstance(payload.get("points"), list)
if name == "token_speed":
return isinstance(payload.get("points"), list)
return (
isinstance(payload.get("enabled"), bool)
and isinstance(payload.get("default_limit"), int)
and isinstance(payload.get("user_default_limit"), int)
and isinstance(payload.get("model_limits"), dict)
and isinstance(payload.get("user_model_limits"), dict)
)
for name, path in paths.items():
url = urllib.parse.urljoin(origin.rstrip("/") + "/", path.lstrip("/"))
status, elapsed_ms, payload, error = _json_request(
url,
headers=headers,
timeout=timeout,
insecure=insecure,
ca_file=ca_file,
)
entry: dict[str, Any] = {"status_code": status, "elapsed_ms": elapsed_ms}
if error:
entry["error"] = error
endpoint_ok = status is not None and 200 <= status < 300 and payload_schema_ok(name, payload)
if endpoint_ok:
successful_endpoints += 1
else:
failed_endpoints.append(name)
if not error and status is not None and 200 <= status < 300:
entry["error"] = "unexpected JSON schema"
entry["available"] = endpoint_ok
if name == "active" and endpoint_ok:
runs = payload.get("runs") if isinstance(payload.get("runs"), list) else []
entry.update(
{
"count": safe_int(payload.get("count"), len(runs)),
"by_model": dict(Counter(str(run.get("model_name") or "unknown") for run in runs if isinstance(run, dict))),
"by_kind": dict(Counter(str(run.get("kind") or "unknown") for run in runs if isinstance(run, dict))),
"max_elapsed_seconds": max([safe_int(run.get("elapsed_seconds")) for run in runs if isinstance(run, dict)] or [0]),
}
)
elif name == "concurrency" and endpoint_ok:
points = payload.get("points") if isinstance(payload.get("points"), list) else []
entry.update(
{
"monitor_enabled": bool(payload.get("enabled")),
"points": len(points),
"peak": max([safe_int(point.get("peak")) for point in points if isinstance(point, dict)] or [0]),
"max_average": max([safe_float(point.get("avg")) for point in points if isinstance(point, dict)] or [0.0]),
}
)
elif name == "token_speed" and endpoint_ok:
points = payload.get("points") if isinstance(payload.get("points"), list) else []
speeds = [safe_float(point.get("avg_tokens_per_sec")) for point in points if isinstance(point, dict)]
entry.update(
{
"points": len(points),
"min_bucket_average_tokens_per_sec": min(speeds) if speeds else None,
"max_bucket_average_tokens_per_sec": max(speeds) if speeds else None,
}
)
elif name == "limits" and endpoint_ok:
safe_payload: dict[str, Any] = {}
for key in ("enabled", "default_limit", "user_default_limit", "model_limits", "user_model_limits"):
value = payload.get(key)
if key in {"model_limits", "user_model_limits"} and isinstance(value, dict):
safe_payload[key] = {str(model)[:120]: safe_int(limit) for model, limit in list(value.items())[:500] if safe_int(limit) > 0}
elif key == "enabled":
safe_payload[key] = bool(value)
elif key in {"default_limit", "user_default_limit"}:
parsed_limit = safe_int(value, -1)
safe_payload[key] = parsed_limit if parsed_limit >= 0 else None
entry["settings"] = safe_payload
output[name] = entry
output["available"] = successful_endpoints > 0
output["successful_endpoints"] = successful_endpoints
output["failed_endpoints"] = failed_endpoints
output["partial"] = 0 < successful_endpoints < len(paths)
if successful_endpoints == 0 and all(output[name].get("status_code") in {401, 403} for name in paths):
output["reason"] = "admin token was rejected"
elif successful_endpoints == 0:
output["reason"] = "no admin endpoint returned a 2xx JSON object"
return output
def run_deerflow_suite(origin: str, options: ProbeOptions, *, authenticated: bool) -> dict[str, Any]:
paths = [
("health", "/api/health", False),
("auth_options", "/api/v1/auth/options", False),
]
if authenticated:
paths.extend(
[
("auth_me", "/api/v1/auth/me", True),
("agents_selector", "/api/agents/selector", True),
(
"agents_mine",
"/api/agents?scope=mine&page=1&page_size=20&sort=time_desc",
True,
),
(
"agents_square",
"/api/agents?scope=square&page=1&page_size=20&sort=time_desc",
True,
),
("skills", "/api/skills?sort=time_desc", True),
]
)
output: dict[str, Any] = {}
public_headers: dict[str, str] = {}
for name, path, use_auth in paths:
suite_options = ProbeOptions(
method="GET",
headers=dict(options.headers if use_auth else public_headers),
connect_timeout=options.connect_timeout,
first_byte_timeout=options.first_byte_timeout,
read_timeout=options.read_timeout,
total_timeout=options.total_timeout,
max_response_bytes=options.max_response_bytes,
read_mode="full",
insecure=options.insecure,
ca_file=options.ca_file,
follow_redirects=False,
proxy=options.proxy,
use_env_proxy=options.use_env_proxy,
ip_family=options.ip_family,
)
url = urllib.parse.urljoin(origin.rstrip("/") + "/", path.lstrip("/"))
result = probe_url(url, suite_options)
output[name] = {
"url": result["url"],
"success": result["success"],
"status_code": result["status_code"],
"error_phase": result["error_phase"],
"timings_ms": result["timings_ms"],
"body_bytes_read": result["hops"][-1].get("response", {}).get("body_bytes_read", 0),
}
return output
def run_health_baselines(origins: Sequence[str], options: ProbeOptions) -> dict[str, Any]:
paths = ("/health", "/api/health", "/api/langgraph/health")
output: dict[str, Any] = {}
public_headers: dict[str, str] = {}
health_options = ProbeOptions(
method="GET",
headers=public_headers,
connect_timeout=options.connect_timeout,
first_byte_timeout=min(options.first_byte_timeout, 10.0),
read_timeout=min(options.read_timeout, 3.0),
total_timeout=min(options.total_timeout, 15.0),
max_response_bytes=65536,
read_mode="full",
insecure=options.insecure,
ca_file=options.ca_file,
follow_redirects=False,
proxy=options.proxy,
use_env_proxy=options.use_env_proxy,
ip_family=options.ip_family,
)
for origin in dict.fromkeys(origins):
origin_output: dict[str, Any] = {}
for path in paths:
url = urllib.parse.urljoin(origin.rstrip("/") + "/", path.lstrip("/"))
result = probe_url(url, health_options)
origin_output[path] = {
"status_code": result["status_code"],
"success": result["success"],
"error_phase": result["error_phase"],
"timings_ms": result["timings_ms"],
}
output[sanitize_origin(origin)] = origin_output
return output
def run_interference_probe(
target_url: str,
sentinel_url: str,
target_options: ProbeOptions,
*,
interval: float,
max_sentinel_probes: int,
) -> dict[str, Any]:
"""Probe health repeatedly while one target request is in flight."""
stop = threading.Event()
sentinel_results: list[dict[str, Any]] = []
public_headers: dict[str, str] = {}
sentinel_options = ProbeOptions(
method="GET",
headers=public_headers,
connect_timeout=min(target_options.connect_timeout, 3.0),
first_byte_timeout=min(target_options.first_byte_timeout, 5.0),
read_timeout=min(target_options.read_timeout, 2.0),
total_timeout=min(target_options.total_timeout, 10.0),
max_response_bytes=65536,
read_mode="full",
insecure=target_options.insecure,
ca_file=target_options.ca_file,
proxy=target_options.proxy,
use_env_proxy=target_options.use_env_proxy,
ip_family=target_options.ip_family,
)
def sentinel_loop() -> None:
while not stop.is_set() and len(sentinel_results) < max_sentinel_probes:
sentinel_results.append(probe_url(sentinel_url, sentinel_options))
stop.wait(max(0.1, interval))
thread = threading.Thread(target=sentinel_loop, name="deerflow-health-sentinel", daemon=True)
started = time.perf_counter()
thread.start()
target_result = probe_url(target_url, target_options)
stop.set()
thread.join(timeout=max(1.0, target_options.read_timeout + 1.0))
return {
"target_url": sanitize_url(target_url),
"sentinel_url": sanitize_url(sentinel_url),
"wall_ms": monotonic_ms(started),
"target": target_result,
"sentinel_results": sentinel_results,
"sentinel_summary": summarize_probe_results(sentinel_results),
"caveat": "Multiple Gateway workers or load balancers can route sentinel requests around a blocked worker.",
}
def probe_nginx_stub_status(url: str | None, options: ProbeOptions) -> dict[str, Any]:
if not url:
return {"enabled": False}
request = urllib.request.Request(url, method="GET", headers={"User-Agent": USER_AGENT})
handlers: list[Any] = [urllib.request.ProxyHandler({}), _NoRedirectHandler()]
if urllib.parse.urlsplit(url).scheme == "https":
context = (
ssl._create_unverified_context() # noqa: SLF001
if options.insecure
else ssl.create_default_context(cafile=options.ca_file)
)
handlers.append(urllib.request.HTTPSHandler(context=context))
started = time.perf_counter()
try:
with urllib.request.build_opener(*handlers).open(request, timeout=options.first_byte_timeout) as response:
status = response.status
text = response.read(65536).decode(errors="replace")
except (urllib.error.URLError, urllib.error.HTTPError, TimeoutError, OSError) as exc:
return {
"enabled": True,
"available": False,
"proxy_mode": "direct",
"url": sanitize_url(url),
"elapsed_ms": monotonic_ms(started),
"error": _safe_error(exc),
}
active_match = re.search(r"Active connections:\s*(\d+)", text)
state_match = re.search(r"Reading:\s*(\d+)\s+Writing:\s*(\d+)\s+Waiting:\s*(\d+)", text)
accepts_match = re.search(r"server accepts handled requests\s*\n\s*(\d+)\s+(\d+)\s+(\d+)", text)
return {
"enabled": True,
"available": bool(active_match and state_match),
"proxy_mode": "direct",
"url": sanitize_url(url),
"status_code": int(status),
"elapsed_ms": monotonic_ms(started),
"active_connections": int(active_match.group(1)) if active_match else None,
"reading": int(state_match.group(1)) if state_match else None,
"writing": int(state_match.group(2)) if state_match else None,
"waiting": int(state_match.group(3)) if state_match else None,
"accepted": int(accepts_match.group(1)) if accepts_match else None,
"handled": int(accepts_match.group(2)) if accepts_match else None,
"requests": int(accepts_match.group(3)) if accepts_match else None,
}
SEVERITY_ORDER = {"critical": 0, "warning": 1, "info": 2, "ok": 3}
def _add_finding(
findings: list[dict[str, Any]],
severity: str,
area: str,
code: str,
title: str,
evidence: Sequence[str],
interpretation: str,
next_step: str,
) -> None:
findings.append(
{
"severity": severity,
"area": area,
"code": code,
"title": title,
"evidence": list(evidence),
"interpretation": interpretation,
"next_step": next_step,
}
)
def _summary_p50(summary: Mapping[str, Any], key: str) -> float | None:
value = summary.get("timings_ms", {}).get(key, {}).get("p50")
return float(value) if value is not None else None
def _target_response_signatures(target: Mapping[str, Any]) -> tuple[tuple[int, str], ...]:
signatures: set[tuple[int, str]] = set()
for sample in target.get("samples", []):
if not sample.get("success"):
continue
hops = sample.get("hops") or []
if not hops:
continue
hop = hops[-1]
status = hop.get("status_code")
if not isinstance(status, int):
continue
content_type = str(hop.get("response", {}).get("headers", {}).get("content-type") or "")
media_type = content_type.split(";", 1)[0].strip().lower()
signatures.add((status, media_type))
return tuple(sorted(signatures))
def _suite_total(suite: Mapping[str, Any], key: str) -> float | None:
value = suite.get(key, {}).get("timings_ms", {}).get("total")
return float(value) if value is not None else None
def _health_total_for_origin(health: Mapping[str, Any], origin: str, path: str = "/api/health") -> float | None:
value = health.get(sanitize_origin(origin), {}).get(path, {}).get("timings_ms", {}).get("total")
return float(value) if value is not None else None
def build_findings(report: Mapping[str, Any]) -> list[dict[str, Any]]:
findings: list[dict[str, Any]] = []
http_section = report.get("http", {})
targets = http_section.get("targets", [])
for target in targets:
label = str(target.get("label") or "target")
summary = target.get("summary", {})
errors = int(summary.get("transport_errors") or 0)
requests = int(summary.get("requests") or 0)
if errors:
_add_finding(
findings,
"critical" if errors == requests else "warning",
"network",
"transport_errors",
f"{label} 存在传输层失败",
[f"{errors}/{requests} 次失败", f"失败阶段={summary.get('error_phase_counts', {})}"],
"失败阶段可以先把问题定位到 DNS、TCP、TLS、首响应等待或正文读取;HTTP 4xx/5xx 不计为传输失败。",
"按报告中的 error_phase 处理;如果是 first_byte,继续对照直连 Gateway 与 /api/health。",
)
status_counts = summary.get("status_counts", {})
bad_gateway = sum(int(status_counts.get(str(code), 0)) for code in (502, 503, 504))
if bad_gateway:
_add_finding(
findings,
"warning",
"nginx",
"upstream_http_errors",
f"{label} 返回网关/上游错误",
[f"502/503/504 共 {bad_gateway} 次", f"状态分布={status_counts}"],
"代理成功接到请求,但上游连接、上游处理或代理超时失败。",
"对照 Nginx error log 的 upstream timed out/connect failed,并检查直连 Gateway 是否正常。",
)
if int(status_counts.get("429", 0)):
_add_finding(
findings,
"warning",
"deerflow",
"http_429",
f"{label} 触发限流或并发上限",
[f"429 次数={status_counts.get('429')}"],
"可能是 DeerFlow 模型并发限制、上游配额或其他限流;仅凭状态码不能区分。",
"查看管理员 active-runs/model-concurrency 数据和 Gateway 同时段日志。",
)
total = _summary_p50(summary, "total")
dns = _summary_p50(summary, "dns") or 0.0
tcp = _summary_p50(summary, "tcp_connect") or 0.0
tls = _summary_p50(summary, "tls_handshake") or 0.0
response_wait = _summary_p50(summary, "response_wait") or 0.0
body_read = _summary_p50(summary, "body_read") or 0.0
if dns >= 200:
_add_finding(
findings,
"warning",
"network",
"slow_dns",
f"{label} DNS 解析慢",
[f"DNS p50={dns:.1f}ms"],
"连接尚未到达 Nginx 或 Gateway,先查 DNS/解析链路。",
"在客户端和服务器分别检查 resolv.conf、DNS 服务器延迟与缓存。",
)
if tcp >= 300:
_add_finding(
findings,
"warning",
"network",
"slow_tcp_connect",
f"{label} TCP 建连慢",
[f"TCP p50={tcp:.1f}ms"],
"延迟发生在应用收到 HTTP 请求前,可能涉及网络、SYN 队列、防火墙或连接耗尽。",
"查看 ss 监听队列/SYN-RECV,并从同网段及服务器本机分别复测。",
)
if tls >= 500:
_add_finding(
findings,
"warning",
"network",
"slow_tls",
f"{label} TLS 握手慢",
[f"TLS p50={tls:.1f}ms"],
"延迟主要在证书/加密握手或中间代理,尚未进入业务处理。",
"检查 TLS 终止层 CPU、证书链、OCSP/中间安全设备,并与 HTTP 直连作对照。",
)
if total and response_wait >= 800 and response_wait / max(total, 1.0) >= 0.55:
_add_finding(
findings,
"warning",
"application",
"response_wait_dominates",
f"{label} 主要卡在请求发完到首响应之间",
[f"总耗时 p50={total:.1f}ms", f"response_wait p50={response_wait:.1f}ms"],
"请求已建连并上传,等待可能来自 Nginx 上游排队、Gateway 事件循环、数据库、文件扫描或下游服务;这不是单独证明 MySQL 慢。",
"结合直连、并行 health、MySQL processlist/locks 和日志中的分阶段计时继续定位。",
)
if body_read >= 1000 and response_wait < body_read:
_add_finding(
findings,
"warning",
"network",
"slow_body_transfer",
f"{label} 首响应后正文读取更慢",
[f"body_read p50={body_read:.1f}ms", f"response_wait p50={response_wait:.1f}ms"],
"更像大响应、慢流式输出、网络吞吐或代理缓冲,而不是请求开始前排队。",
"检查响应字节数、Content-Type、Nginx buffering 与客户端渲染;SSE 用 --read-mode first-byte。",
)
if len(targets) >= 2:
public = targets[0]
public_p50 = _summary_p50(public.get("summary", {}), "total")
public_signature = _target_response_signatures(public)
for compare in targets[1:]:
direct_p50 = _summary_p50(compare.get("summary", {}), "total")
compare_signature = _target_response_signatures(compare)
gap_is_large = bool(public_p50 and direct_p50 and public_p50 >= direct_p50 * 1.5 and public_p50 - direct_p50 >= 200)
responses_are_comparable = bool(
len(public_signature) == 1
and public_signature == compare_signature
and 200 <= public_signature[0][0] < 300
)
if gap_is_large and responses_are_comparable:
_add_finding(
findings,
"warning",
"nginx",
"public_direct_gap",
"公网/代理路径明显慢于对照地址",
[
f"{public.get('label')} p50={public_p50:.1f}ms",
f"{compare.get('label')} p50={direct_p50:.1f}ms",
],
"差值位于两条路径不同的网络、TLS、负载均衡或 Nginx 层;前提是两个 URL 实际执行同一业务。",
"确认 Host/路径/请求体一致,再检查 Nginx 连接、错误日志和上游 keepalive。",
)
elif gap_is_large and not responses_are_comparable:
_add_finding(
findings,
"info",
"nginx",
"public_direct_not_comparable",
"公网与对照地址响应不一致,不能据此归因 Nginx",
[f"公网响应签名={public_signature}", f"对照响应签名={compare_signature}"],
"两条路径的状态码或 Content-Type 不同,快速的 401/404/HTML 页面不能作为同一业务的直连基线。",
"先确认 Host、路由、鉴权和路径一致,再进行耗时对比。",
)
local_gateway = http_section.get("local_gateway_health", {})
target_host_confirmed = bool(report.get("server", {}).get("scope", {}).get("target_host_confirmed"))
if target_host_confirmed and targets and local_gateway.get("success") and local_gateway.get("status_code") == 200:
primary_origin = _origin_for_request(str(targets[0].get("url") or ""))
public_health = http_section.get("health_baselines", {}).get(sanitize_origin(primary_origin), {}).get("/api/health", {})
public_health_ms = public_health.get("timings_ms", {}).get("total")
local_health_ms = local_gateway.get("timings_ms", {}).get("total")
if (
public_health.get("success")
and public_health.get("status_code") == 200
and public_health_ms is not None
and local_health_ms is not None
and float(public_health_ms) >= float(local_health_ms) * 1.5
and float(public_health_ms) - float(local_health_ms) >= 200
):
_add_finding(
findings,
"warning",
"nginx",
"public_local_health_gap",
"公网 health 明显慢于本机 DeerFlow Gateway health",
[f"公网 /api/health={float(public_health_ms):.1f}ms", f"本机 /api/health={float(local_health_ms):.1f}ms"],
"两个 200 health 响应执行同一轻量路径,差值更偏向入口网络、TLS、负载均衡或 Nginx。",
"结合 Nginx 活跃连接、监听队列、FD 和错误日志判断是否连接容量或代理层问题。",
)
suite = http_section.get("deerflow_suite", {})
if suite:
health_ms = _suite_total(suite, "health")
options_ms = _suite_total(suite, "auth_options")
me_ms = _suite_total(suite, "auth_me")
agents_ms = max([_suite_total(suite, name) or 0.0 for name in ("agents_selector", "agents_mine", "agents_square")] or [0.0])
skills_ms = _suite_total(suite, "skills")
db_paths = [value for value in (options_ms, me_ms, agents_ms) if value is not None and value > 0]
if health_ms is not None and health_ms < 500 and db_paths and sum(value >= 1500 for value in db_paths) >= 2:
_add_finding(
findings,
"warning",
"mysql",
"health_fast_db_paths_slow",
"静态 health 快,但多条数据库相关接口慢",
[
f"health={health_ms:.1f}ms",
f"auth/options={options_ms}ms",
f"auth/me={me_ms}ms",
f"agents最大={agents_ms:.1f}ms",
],
"这是业务数据库、连接池、锁或数据库磁盘的强关联证据;auth/options、auth/me、agents 都会查业务库。",
"立即在卡顿时抓 MySQL Threads/PROCESSLIST/data_lock_waits/metadata_locks,并核对池上限。",
)
if health_ms is not None and health_ms < 500 and skills_ms and skills_ms >= 1500:
others = [value for value in (options_ms, me_ms, agents_ms) if value is not None and value > 0]
if not others or max(others) < skills_ms * 0.6:
_add_finding(
findings,
"warning",
"deerflow",
"skills_path_specific",
"技能列表明显慢于健康和其他业务接口",
[f"health={health_ms:.1f}ms", f"skills={skills_ms:.1f}ms"],
"当前实现每次 /api/skills 都同步扫描并解析技能目录,再查所有权/收藏/标签;慢磁盘或技能很多会放大。",
"运行 interference 探针;若 health 同时尖峰,优先查同步文件扫描阻塞单 worker 事件循环。",
)
if health_ms is not None and health_ms < 500 and agents_ms >= 1500:
_add_finding(
findings,
"info",
"deerflow",
"agents_path_slow",
"Agents 接口慢但静态 health 正常",
[f"health={health_ms:.1f}ms", f"agents最大={agents_ms:.1f}ms"],
"可能是首次/每小时 legacy agent 目录同步或 agents 多次串行 DB 查询;需看同一时段日志。",
"grep [start-run-timing] 和 agents 同步日志,并在第二次立即复测判断是否只有冷启动/TTL 首次慢。",
)
interference = http_section.get("interference")
if isinstance(interference, dict):
target_total = interference.get("target", {}).get("timings_ms", {}).get("total")
sentinel_p95 = interference.get("sentinel_summary", {}).get("timings_ms", {}).get("total", {}).get("p95")
sentinel_count = interference.get("sentinel_summary", {}).get("successful_transport", 0)
if target_total and float(target_total) >= 1000 and sentinel_count and sentinel_p95 is not None:
if float(sentinel_p95) >= 500:
_add_finding(
findings,
"warning",
"deerflow",
"health_stalls_with_target",
"慢接口执行期间 health 也出现明显延迟",
[f"目标总耗时={float(target_total):.1f}ms", f"并行 health p95={float(sentinel_p95):.1f}ms"],
"强烈指向共享 Gateway worker/事件循环、CPU 或宿主 IO 阻塞;单 worker 下同步技能目录扫描尤其可疑。多 worker/负载均衡可能掩盖该现象。",
"对照进程 CPU/IO PSI、技能目录遍历时间和 Gateway 日志;不要先盲目扩大 MySQL 连接。",
)
elif float(sentinel_p95) < 250:
_add_finding(
findings,
"info",
"application",
"health_fast_during_target",
"慢接口执行期间 health 保持快速",
[f"目标总耗时={float(target_total):.1f}ms", f"并行 health p95={float(sentinel_p95):.1f}ms"],
"更偏向该请求自己的数据库/下游服务/模型等待,而不是整个 Gateway 事件循环被卡死。",
"继续对照 auth/me、MySQL 和该接口专属下游;多 worker 环境下此结论只能算辅助证据。",
)
server = report.get("server", {})
easy_mode = bool(report.get("request", {}).get("easy_mode"))
target_host_confirmed = bool(server.get("scope", {}).get("target_host_confirmed"))
if easy_mode and not target_host_confirmed:
_add_finding(
findings,
"info",
"resources",
"execution_host_not_confirmed",
"未确认脚本执行机就是目标 DeerFlow 服务器",
["本机未发现经过身份校验的 loopback DeerFlow Gateway"],
"已采集的 CPU、内存、Docker、Nginx 等数据属于脚本执行机,不能直接用于判断目标服务器配置不足。",
"把脚本复制到实际部署服务器运行;网络接口计时结果仍然有效。",
)
server = {}
socket_info = server.get("sockets", {})
for listener in socket_info.get("listeners", []):
usage = listener.get("backlog_usage_pct")
if usage is not None and float(usage) >= 70:
_add_finding(
findings,
"critical" if float(usage) >= 95 else "warning",
"nginx",
"listen_backlog_pressure",
f"端口 {listener.get('port')} 监听队列接近上限",
[
f"Recv-Q={listener.get('receive_queue')}",
f"backlog={listener.get('backlog')}",
f"占用={float(usage):.1f}%",
],
"新 TCP 连接可能在内核/Nginx/Gateway 接收前排队或丢弃。",
"核对 worker 是否处理不过来、somaxconn/tcp_max_syn_backlog、CPU 和文件句柄;先解决处理能力而非只放大 backlog。",
)
global_states = socket_info.get("global_states", {})
if int(global_states.get("SYN-RECV", 0)) >= 100:
_add_finding(
findings,
"warning",
"network",
"many_syn_recv",
"存在大量半连接",
[f"SYN-RECV={global_states.get('SYN-RECV')}"],
"可能是连接洪峰、backlog 压力或异常客户端。",
"按监听端口查看 ss -ant state syn-recv,并检查负载均衡、SYN cookies 与网络来源。",
)
nginx_status = server.get("nginx_status", {})
nginx_config = server.get("nginx", {})
if nginx_status.get("available"):
capacities = [int(item.get("theoretical_connection_slots")) for item in nginx_config.get("instances", []) if item.get("theoretical_connection_slots")]
capacity = min(capacities) if capacities else None
active = nginx_status.get("active_connections")
if capacity and active is not None and int(active) >= capacity * 0.7:
_add_finding(
findings,
"critical" if int(active) >= capacity * 0.9 else "warning",
"nginx",
"nginx_connection_slots",
"Nginx 活跃连接接近理论 worker_connections 上限",
[f"active={active}", f"理论槽位={capacity}"],
"反向代理通常每个客户端请求还占一个上游连接,实际可承载客户端数低于理论槽位。",
"先确认连接是否被长 SSE/keepalive 占用,再评估 worker_connections、worker_rlimit_nofile 和 worker 数。",
)
logs = server.get("logs", {})
pattern_counts = logs.get("pattern_counts", {})
log_finding_map = {
"nginx_connection_capacity": ("critical", "nginx", "Nginx 日志明确出现 worker_connections 不足"),
"too_many_open_files": ("critical", "resources", "日志出现文件句柄耗尽"),
"nginx_upstream_timeout": ("warning", "nginx", "Nginx 记录上游超时"),
"db_pool_timeout": ("critical", "mysql", "日志记录数据库连接池等待超时"),
"db_lock_or_deadlock": ("warning", "mysql", "日志记录数据库锁等待或死锁"),
"deerflow_concurrency_reject": ("warning", "deerflow", "日志记录 DeerFlow 并发拒绝"),
"resource_exhaustion": ("critical", "resources", "日志记录系统资源耗尽"),
}
for key, (severity, area, title) in log_finding_map.items():
count = int(pattern_counts.get(key, 0))
if count:
_add_finding(
findings,
severity,
area,
f"log_{key}",
title,
[f"最近日志尾部命中 {count} 次"],
"这是实际日志证据,但需按日志时间确认是否与本次卡顿同一时段。",
"打开原日志核对时间、请求和完整堆栈;本报告为防泄密只保存模式计数,不保存原行。",
)
mysql = server.get("mysql", {})
if mysql.get("available"):
ping_p50 = mysql.get("select_1_ms", {}).get("p50")
if ping_p50 is not None and float(ping_p50) >= 100:
_add_finding(
findings,
"warning",
"mysql",
"mysql_ping_slow",
"MySQL 新连接 + SELECT 1 延迟偏高",
[f"p50={float(ping_p50):.1f}ms"],
"包含 mysql 客户端启动、DNS、TCP、认证和查询,说明 DB 路径值得优先调查,但不等同于业务 SQL 本身慢。",
"在应用连接池内复测并抓 PROCESSLIST/慢查询;同时看 TCP timing 区分网络。",
)
mysql_status = mysql.get("status", {})
variables = mysql.get("variables", {})
connected = int(mysql_status.get("Threads_connected") or 0)
max_connections = int(variables.get("max_connections") or 0)
if max_connections and connected >= max_connections * 0.8:
_add_finding(
findings,
"critical" if connected >= max_connections * 0.95 else "warning",
"mysql",
"mysql_connections_high",
"MySQL 当前连接数接近 max_connections",
[f"Threads_connected={connected}", f"max_connections={max_connections}"],
"新请求可能在应用池或 MySQL 握手阶段等待/失败。",
"检查各 Gateway worker 的池总上限、Sleep 连接、泄漏和其他应用连接;不要只盲目调大 max_connections。",
)
processlist = mysql.get("processlist", {})
if int(processlist.get("lock_state_count") or 0) or int(processlist.get("active_at_least_5s") or 0):
_add_finding(
findings,
"critical" if int(processlist.get("active_at_least_30s") or 0) else "warning",
"mysql",
"mysql_active_waits",
"MySQL 当前存在长执行或锁状态会话",
[
f">=5s active={processlist.get('active_at_least_5s', 0)}",
f">=30s active={processlist.get('active_at_least_30s', 0)}",
f"lock state={processlist.get('lock_state_count', 0)}",
f"max active={processlist.get('max_active_seconds', 0)}s",
],
"这是卡顿时的实时数据库证据,可能直接拖慢 login/agents/skills 的串行查询。",
"由 DBA 查看对应 PROCESSLIST 详情、执行计划和事务持锁者;本脚本不保存 SQL 正文。",
)
locks = mysql.get("locks", {})
if int(locks.get("data_lock_waits") or 0) or int(locks.get("pending_metadata_locks") or 0):
_add_finding(
findings,
"critical",
"mysql",
"mysql_lock_waits",
"MySQL performance_schema 显示锁等待",
[
f"data_lock_waits={locks.get('data_lock_waits', 0)}",
f"pending_metadata_locks={locks.get('pending_metadata_locks', 0)}",
],
"数据锁或 metadata lock 可让相关查询成批等待;skills 路径的建表 checkfirst 尤其应对照 metadata lock。",
"立即保留锁等待链、持锁事务和表名证据,再由 DBA 处理;避免无证据重启掩盖现场。",
)
config = report.get("deerflow", {}).get("config", {})
backend = str(config.get("database.backend") or "").lower()
if backend == "mysql" and mysql.get("available"):
pool_size = int(config.get("database.pool_size") or 0)
overflow = int(config.get("database.max_overflow") or 0)
gateway_workers = int(server.get("host", {}).get("processes", {}).get("deerflow_gateway", {}).get("processes") or 1)
max_connections = int(mysql.get("variables", {}).get("max_connections") or 0)
theoretical_pool = (pool_size + overflow) * max(1, gateway_workers)
if max_connections and theoretical_pool >= max_connections * 0.8:
_add_finding(
findings,
"warning",
"mysql",
"pool_budget_risk",
"Gateway 理论连接池总量接近 MySQL 上限",
[
f"(pool_size {pool_size} + overflow {overflow}) × worker {gateway_workers} = {theoretical_pool}",
f"max_connections={max_connections}",
],
"还未计入其他应用、运维和 MySQL 保留连接,高峰时可能先在池或数据库侧排队。",
"为所有客户端预留余量,结合真实 Max_used_connections/Threads_connected 调整;先排查泄漏。",
)
host = server.get("host", {})
if host.get("supported"):
cpu_count = int(host.get("cpu_count") or 1)
cpu = host.get("cpu", {})
busy = cpu.get("busy_pct")
iowait = cpu.get("iowait_pct")
load = host.get("load_average") or []
if busy is not None and float(busy) >= 90:
_add_finding(
findings,
"critical",
"resources",
"cpu_saturated",
"服务器 CPU 已接近饱和",
[f"CPU busy={float(busy):.1f}%", f"CPU cores={cpu_count}", f"load={load}"],
"Gateway、TLS、Python 同步扫描和数据库客户端都可能排队。",
"用 pidstat/top 定位进程和线程;若是技能扫描先修热点,不要只扩机器。",
)
elif load and float(load[0]) >= cpu_count * 1.2:
_add_finding(
findings,
"warning",
"resources",
"load_high",
"系统 1 分钟负载高于 CPU 容量",
[f"load1={float(load[0]):.2f}", f"CPU cores={cpu_count}"],
"可运行任务或不可中断 IO 等待过多,接口可能在调度层排队。",
"结合 CPU busy、iowait、PSI 和进程 CPU 判断是算力还是 IO。",
)
if iowait is not None and float(iowait) >= 15:
_add_finding(
findings,
"warning",
"resources",
"cpu_iowait_high",
"CPU iowait 偏高",
[f"iowait={float(iowait):.1f}%"],
"磁盘或网络块设备等待可能拖慢 MySQL、SQLite、技能目录扫描与日志。",
"用 iostat -xz 1 10 对准 MySQL/技能所在盘确认 await 和 util。",
)
memory = host.get("memory", {})
available_pct = memory.get("available_pct")
if available_pct is not None and float(available_pct) < 10:
_add_finding(
findings,
"critical",
"resources",
"memory_low",
"可用内存不足",
[f"MemAvailable={float(available_pct):.1f}%", f"Swap used={memory.get('swap_used_pct')}%"],
"回收、换页或 OOM 会造成所有接口抖动,重启后短暂变快也与此相符。",
"定位 RSS 最大进程、容器内存限制和 OOM 记录;为模型/数据库/文件缓存保留余量。",
)
disk_util = host.get("disk_io", {}).get("max_util_pct")
if disk_util is not None and float(disk_util) >= 85:
_add_finding(
findings,
"warning",
"resources",
"disk_busy",
"块设备采样期间接近满负载",
[
f"device={host.get('disk_io', {}).get('busiest_device')}",
f"util={float(disk_util):.1f}%",
],
"可能拖慢数据库和技能/Agent 文件扫描;一次 1 秒采样只代表当前窗口。",
"卡顿持续期间运行 iostat -xz 1 10,确认 r_await/w_await 与具体挂载点。",
)
for path, usage in host.get("disk_space", {}).items():
free_pct = usage.get("free_pct")
if free_pct is not None and float(free_pct) < 10:
_add_finding(
findings,
"warning",
"resources",
"disk_space_low",
f"文件系统 {path} 剩余空间不足",
[f"free={float(free_pct):.1f}%"],
"数据库临时表、WAL、日志和容器写层可能受影响。",
"清理前先确认数据归属和备份;不要直接删除数据库/卷。",
)
for role, process in host.get("processes", {}).items():
fd_usage = process.get("max_fd_usage_pct")
if fd_usage is not None and float(fd_usage) >= 80:
_add_finding(
findings,
"critical" if float(fd_usage) >= 95 else "warning",
"resources",
"process_fd_high",
f"{role} 文件句柄接近进程上限",
[f"最大 FD 占用={float(fd_usage):.1f}%", f"进程数={process.get('processes')}"],
"新 socket、日志或文件打开会失败,表现为连接卡顿/502。",
"查 /proc/<pid>/fd 类型与泄漏,结合 limits/worker_connections 调整。",
)
if cpu_count <= 2:
_add_finding(
findings,
"info",
"resources",
"small_cpu_capacity",
"服务器 CPU 核数较少",
[f"CPU cores={cpu_count}"],
"这不是当前卡顿的直接证据,但单 worker Gateway、Nginx、数据库或容器共享时余量有限。",
"先用实测 CPU/PSI/并发退化证明容量瓶颈,再决定扩容。",
)
docker = server.get("docker", {})
for container in docker.get("containers", []):
limits = container.get("limits", {})
stats = container.get("stats", {})
if limits.get("oom_killed"):
_add_finding(
findings,
"critical",
"resources",
"container_oom",
f"容器 {container.get('name')} 曾被 OOM kill",
[f"restart_count={limits.get('restart_count')}"],
"内存限制或宿主内存不足已经造成服务中断。",
"查 docker events/kernel OOM 时间和容器 RSS,调整限制或修复内存增长。",
)
if stats.get("memory_pct") is not None and float(stats["memory_pct"]) >= 90:
_add_finding(
findings,
"critical",
"resources",
"container_memory_high",
f"容器 {container.get('name')} 内存接近限制",
[f"memory={stats.get('memory_usage')}", f"memory_pct={stats.get('memory_pct')}%"],
"容器回收/OOM 风险可拖慢或重启服务。",
"结合宿主内存和容器内进程 RSS判断是否扩限或修复增长。",
)
catalogue = server.get("catalogue_scan", {})
skills = catalogue.get("skills", {})
if skills.get("exists") and float(skills.get("metadata_walk_ms") or 0) >= 500:
_add_finding(
findings,
"warning",
"deerflow",
"skill_directory_walk_slow",
"技能目录仅元数据遍历就很慢",
[
f"目录={skills.get('directories')}",
f"文件={skills.get('matching_files')}",
f"walk={float(skills.get('metadata_walk_ms')):.1f}ms",
],
"/api/skills 会进一步读取和 YAML 解析每个技能文件,实际阻塞可能更长。",
"检查技能目录是否位于慢盘/NFS、是否残留过多文件,并用 interference 探针确认事件循环影响。",
)
elif int(skills.get("matching_files") or 0) >= 500:
_add_finding(
findings,
"info",
"deerflow",
"large_skill_catalogue",
"技能目录文件量较大",
[f"匹配文件={skills.get('matching_files')}", f"metadata walk={skills.get('metadata_walk_ms')}ms"],
"当前 /api/skills 每请求全量扫描,前端重复刷新会放大成本;文件量本身不是卡顿证明。",
"对照访问日志中的 /api/skills 请求簇与 SKILL_VIEW_CACHE/health 干扰数据。",
)
admin = report.get("deerflow", {}).get("admin_concurrency", {})
if admin.get("available"):
active = admin.get("active", {})
count = int(active.get("count") or 0)
max_elapsed = int(active.get("max_elapsed_seconds") or 0)
limits = admin.get("limits", {}).get("settings", {})
default_limit = int(limits.get("default_limit") or 0)
if count and default_limit and count >= default_limit:
_add_finding(
findings,
"warning",
"deerflow",
"active_runs_at_limit",
"活动运行数已达到默认模型并发限制",
[f"active={count}", f"default_limit={default_limit}", f"max_elapsed={max_elapsed}s"],
"新 Agent 请求可能收到 429;这不是 Nginx 排队,而是应用明确限流。",
"按模型查看 active-runs,处理异常长运行或评估上游模型真实配额后再调限制。",
)
elif max_elapsed >= 300:
_add_finding(
findings,
"warning",
"deerflow",
"long_active_run",
"存在持续很久的活动 Agent 运行",
[f"active={count}", f"max_elapsed={max_elapsed}s"],
"长 SSE/模型/工具任务会持续占用模型配额、连接和内存,但不一定异常。",
"在管理员并发页核对对应任务是否仍在产出;仅在确认卡死后中断。",
)
if not findings:
_add_finding(
findings,
"info",
"overall",
"no_decisive_signal",
"本次快照没有抓到决定性异常",
["没有命中脚本的高置信阈值"],
"间歇性卡顿恢复后采样只能说明恢复后的状态,不能排除曾发生过连接池、锁或资源尖峰。",
"在卡顿正在发生时重新运行,并启用 --deerflow-suite 与 --interference。",
)
findings.sort(key=lambda item: (SEVERITY_ORDER.get(str(item["severity"]), 99), str(item["area"]), str(item["code"])))
return findings
def build_quick_verdict(findings: Sequence[Mapping[str, Any]]) -> dict[str, Any]:
labels = {
"nginx": "Nginx/入口代理",
"mysql": "MySQL/数据库",
"deerflow": "DeerFlow 并发或热点路径",
"application": "接口自身或下游服务",
"resources": "服务器资源",
"network": "网络/DNS/TLS",
"overall": "暂未定位",
}
decisive = [item for item in findings if item.get("severity") in {"critical", "warning"}]
if not decisive:
return {
"headline": "本次没有抓到决定性异常;建议在卡顿正在发生时再次运行",
"areas": [],
"confidence": "low",
"evidence_titles": [str(item.get("title")) for item in findings[:3]],
}
areas: list[str] = []
for item in decisive:
label = labels.get(str(item.get("area")), str(item.get("area")))
if label not in areas:
areas.append(label)
if len(areas) >= 3:
break
primary_severity = str(decisive[0].get("severity"))
return {
"headline": "当前证据优先指向:" + ";同时关注:".join(areas),
"areas": areas,
"confidence": "high" if primary_severity == "critical" else "medium",
"evidence_titles": [str(item.get("title")) for item in decisive[:3]],
"note": "这是基于当前快照的定位方向,不是对根因的绝对证明。",
}
def _origin_for_request(url: str) -> str:
scheme, hostname, port = _origin_tuple(url)
try:
rendered_host = f"[{hostname}]" if ipaddress.ip_address(hostname).version == 6 else hostname
except ValueError:
rendered_host = hostname
default_port = 443 if scheme == "https" else 80
if port != default_port:
rendered_host = f"{rendered_host}:{port}"
return f"{scheme}://{rendered_host}"
def _report_output_path(raw: str | None) -> Path:
if raw:
requested = Path(raw).expanduser()
parent = requested.parent.resolve()
output = parent / requested.name
if output.is_symlink():
raise ValueError(f"refusing to replace symlink output path: {output}")
if output.exists() and not output.is_file():
raise ValueError(f"output path exists and is not a regular file: {output}")
return output
stamp = dt.datetime.now().strftime("%Y%m%d-%H%M%S")
return Path.cwd().resolve() / f"deerflow-latency-report-{stamp}.json"
def write_private_json(path: Path, payload: Mapping[str, Any]) -> None:
path.parent.mkdir(parents=True, exist_ok=True)
descriptor, temporary_name = tempfile.mkstemp(prefix=f".{path.name}.", suffix=".tmp", dir=path.parent)
temporary_path = Path(temporary_name)
try:
with os.fdopen(descriptor, "w", encoding="utf-8") as handle:
json.dump(payload, handle, ensure_ascii=False, indent=2)
handle.write("\n")
handle.flush()
os.fsync(handle.fileno())
with contextlib.suppress(OSError):
os.chmod(temporary_path, 0o600)
os.replace(temporary_path, path)
except Exception:
with contextlib.suppress(OSError):
os.close(descriptor)
with contextlib.suppress(OSError):
temporary_path.unlink()
raise
with contextlib.suppress(OSError):
os.chmod(path, 0o600)
def _format_ms(value: Any) -> str:
if value is None:
return "-"
try:
return f"{float(value):.1f}ms"
except (TypeError, ValueError):
return "-"
def print_console_summary(report: Mapping[str, Any], output_path: Path | None) -> None:
print("\n=== DeerFlow 接口卡顿诊断 ===")
print(f"时间: {report.get('started_at')}")
print("说明: HTTP/1.1 新连接测量;请求体、响应体、Token、Cookie 和查询参数值未写入报告。")
print("注意: 报告仍含路径、IP、容器名等基础设施元数据,请勿公开发布。")
warnings = report.get("security", {}).get("warnings", [])
if warnings:
print("安全警告:")
for warning in warnings:
print(f" - {warning}")
print("\n接口测量:")
for target in report.get("http", {}).get("targets", []):
summary = target.get("summary", {})
timings = summary.get("timings_ms", {})
print(
f" [{target.get('label')}] {target.get('url')} "
f"成功 {summary.get('successful_transport')}/{summary.get('requests')} "
f"总耗时p50={_format_ms(timings.get('total', {}).get('p50'))} "
f"等待首响应p50={_format_ms(timings.get('response_wait', {}).get('p50'))} "
f"DNS/TCP/TLS={_format_ms(timings.get('dns', {}).get('p50'))}/"
f"{_format_ms(timings.get('tcp_connect', {}).get('p50'))}/"
f"{_format_ms(timings.get('tls_handshake', {}).get('p50'))} "
f"状态={summary.get('status_counts')}"
)
host = report.get("server", {}).get("host", {})
if host.get("supported"):
print("\n服务器快照:")
print(f" CPU核数={host.get('cpu_count')} busy={host.get('cpu', {}).get('busy_pct')}% iowait={host.get('cpu', {}).get('iowait_pct')}% load={host.get('load_average')}")
print(f" 可用内存={host.get('memory', {}).get('available_pct')}% Swap使用={host.get('memory', {}).get('swap_used_pct')}% 最忙磁盘={host.get('disk_io', {}).get('busiest_device')} util={host.get('disk_io', {}).get('max_util_pct')}%")
verdict = report.get("quick_verdict", {})
if verdict:
print("\n>>> 一键判断:")
print(f" {verdict.get('headline')}")
for title in verdict.get("evidence_titles", []):
print(f" - {title}")
if verdict.get("note"):
print(f" 说明: {verdict.get('note')}")
print("\n优先结论:")
for index, finding in enumerate(report.get("findings", []), start=1):
severity = {"critical": "严重", "warning": "警告", "info": "提示", "ok": "正常"}.get(str(finding.get("severity")), str(finding.get("severity")))
print(f" {index}. [{severity}/{finding.get('area')}] {finding.get('title')}")
for evidence in finding.get("evidence", []):
print(f" 证据: {evidence}")
print(f" 判断: {finding.get('interpretation')}")
print(f" 下一步: {finding.get('next_step')}")
missing = report.get("coverage", {}).get("unavailable", [])
if missing:
print("\n未完成检查:")
for item in missing:
print(f" - {item}")
if output_path:
print(f"\n凭据脱敏 JSON 报告: {output_path}")
def _normalize_easy_url(raw: str) -> str:
value = raw.strip()
if len(value) >= 2 and value[0] == value[-1] and value[0] in {"'", '"'}:
value = value[1:-1].strip()
if not value:
raise ValueError("接口地址不能为空")
if "://" not in value:
value = "https://" + value
return value
def _option_present(arguments: Sequence[str], *names: str) -> bool:
return any(argument in names or any(argument.startswith(name + "=") for name in names) for argument in arguments)
def _verified_local_gateway_origin() -> str | None:
"""Return a loopback Gateway origin only after checking its public identity."""
ports: list[int] = [8001]
config_path = find_config_path(None)
if config_path:
config = read_safe_config(config_path)
configured_port = config.get("gateway.port")
if isinstance(configured_port, int) and 1 <= configured_port <= 65535:
ports.insert(0, configured_port)
for port in dict.fromkeys(ports):
origin = f"http://127.0.0.1:{port}"
request = urllib.request.Request(
origin + "/api/health",
method="GET",
headers={"User-Agent": USER_AGENT, "Accept": "application/json"},
)
opener = urllib.request.build_opener(urllib.request.ProxyHandler({}), _NoRedirectHandler())
try:
with opener.open(request, timeout=1.5) as response:
if response.status != 200:
continue
payload = json.loads(response.read(4096).decode("utf-8"))
except (OSError, TimeoutError, UnicodeError, ValueError, urllib.error.URLError, urllib.error.HTTPError):
continue
if isinstance(payload, dict) and payload.get("status") == "healthy" and payload.get("service") == "deer-flow-gateway":
return origin
return None
def _easy_optional_environment_args() -> list[str]:
arguments: list[str] = []
config_path = os.environ.get("DEERFLOW_DIAG_CONFIG")
mysql_file = os.environ.get("DEERFLOW_DIAG_MYSQL_CNF")
mysql_url = os.environ.get("DEERFLOW_DIAG_MYSQL_URL")
nginx_status = os.environ.get("DEERFLOW_DIAG_NGINX_STATUS_URL")
admin_token = os.environ.get("DEERFLOW_DIAG_ADMIN_TOKEN")
if config_path:
arguments.extend(["--config", config_path])
if mysql_file:
arguments.extend(["--mysql-defaults-file", mysql_file])
elif mysql_url:
arguments.extend(["--mysql", "--mysql-url-env", "DEERFLOW_DIAG_MYSQL_URL"])
if nginx_status:
arguments.extend(["--nginx-status-url", nginx_status])
if admin_token:
arguments.extend(["--admin-token-env", "DEERFLOW_DIAG_ADMIN_TOKEN"])
return arguments
def _easy_auth_from_environment() -> list[str]:
if os.environ.get("DEERFLOW_DIAG_TOKEN"):
return ["--bearer-env", "DEERFLOW_DIAG_TOKEN"]
if os.environ.get("DEERFLOW_DIAG_COOKIE"):
return ["--cookie-env", "DEERFLOW_DIAG_COOKIE"]
return []
def _store_easy_secret(kind: str, value: str) -> tuple[list[str], str | None]:
if not value:
return [], None
env_name = f"_DEERFLOW_DIAG_EASY_{kind.upper()}_{os.getpid()}"
os.environ[env_name] = value
option = "--bearer-env" if kind == "token" else "--cookie-env"
return [option, env_name], env_name
def _interactive_easy_arguments() -> tuple[list[str], list[str]]:
print("\n=== DeerFlow 一键卡顿诊断 ===")
print("只需选择接口类型并粘贴地址;不会修改、重启或替换后端。")
target_url = _normalize_easy_url(input("\n请粘贴卡顿接口 URL: "))
print("\n接口类型:")
print(" 1. 普通只读 GET / Skills(默认,深度检查,会请求目标 4 次)")
print(" 2. 登录 POST(只请求 1 次)")
print(" 3. Agent SSE POST(只请求 1 次,只等待首个响应实体字节)")
mode = input("请选择 [1/2/3,默认 1]: ").strip() or "1"
if mode not in {"1", "2", "3"}:
raise ValueError("接口类型只能选择 1、2 或 3")
arguments = [
"--easy-mode",
"--url",
target_url,
"--server-checks",
"--deerflow-suite",
"--concurrency",
"1",
"--connect-timeout",
"5",
]
cleanup_env_names: list[str] = []
if mode == "1":
arguments.extend(
[
"--count",
"3",
"--interference",
"--first-byte-timeout",
"30",
"--total-timeout",
"60",
]
)
else:
body_path = input("请求 JSON 文件路径: ").strip()
if not body_path:
raise ValueError("POST 诊断必须提供请求 JSON 文件")
resolved_body_path = Path(body_path).expanduser().resolve()
if not resolved_body_path.is_file():
raise ValueError(f"请求文件不存在: {resolved_body_path}")
arguments.extend(
[
"--method",
"POST",
"--header",
"Content-Type: application/json",
"--data-file",
str(resolved_body_path),
"--count",
"1",
]
)
if mode == "2":
arguments.extend(["--read-mode", "full", "--first-byte-timeout", "60", "--total-timeout", "90"])
else:
arguments.extend(
[
"--header",
"Accept: text/event-stream",
"--read-mode",
"first-byte",
"--first-byte-timeout",
"180",
"--total-timeout",
"240",
]
)
environment_auth = _easy_auth_from_environment()
if environment_auth:
env_name = environment_auth[-1]
print(f"检测到 {env_name},将用于用户输入的目标地址;值不会写入报告。")
arguments.extend(environment_auth)
else:
default_auth = "1" if mode == "3" else "0"
auth_choice = input(f"鉴权方式 [0=无, 1=Bearer Token, 2=Cookie,默认 {default_auth}]: ").strip() or default_auth
if auth_choice not in {"0", "1", "2"}:
raise ValueError("鉴权方式只能选择 0、1 或 2")
if auth_choice == "1":
secret_args, env_name = _store_easy_secret("token", getpass.getpass("Bearer Token(输入不显示): ").strip())
arguments.extend(secret_args)
if env_name:
cleanup_env_names.append(env_name)
elif auth_choice == "2":
secret_args, env_name = _store_easy_secret("cookie", getpass.getpass("Cookie 值(输入不显示,不含 Cookie: 前缀): ").strip())
arguments.extend(secret_args)
if env_name:
cleanup_env_names.append(env_name)
local_origin = _verified_local_gateway_origin()
if local_origin:
arguments.extend(["--easy-local-origin", local_origin])
print(f"已确认本机 DeerFlow Gateway:{local_origin};仅对比公开 health,不会向它复制目标查询参数、凭据或请求体。")
on_server = input("当前脚本是否运行在这个 DeerFlow 的实际部署服务器上?[Y/n]: ").strip().lower()
if on_server in {"", "y", "yes", "是"}:
arguments.append("--easy-target-host-confirmed")
elif on_server not in {"n", "no", "否"}:
raise ValueError("请回答 y 或 n")
arguments.extend(_easy_optional_environment_args())
return arguments, cleanup_env_names
def _positional_easy_arguments(raw_arguments: Sequence[str]) -> list[str]:
target_url = _normalize_easy_url(str(raw_arguments[0]))
rest = list(raw_arguments[1:])
arguments = [
"--easy-mode",
"--url",
target_url,
"--count",
"1",
"--concurrency",
"1",
"--deerflow-suite",
"--server-checks",
"--connect-timeout",
"5",
"--first-byte-timeout",
"30",
"--total-timeout",
"60",
]
if not _option_present(rest, "--bearer-env", "--cookie-env", "--header-env", "--header"):
arguments.extend(_easy_auth_from_environment())
local_origin = _verified_local_gateway_origin()
if local_origin:
arguments.extend(["--easy-local-origin", local_origin])
if os.environ.get("DEERFLOW_DIAG_ON_SERVER", "").strip().lower() in {"1", "true", "yes", "y"}:
arguments.append("--easy-target-host-confirmed")
arguments.extend(_easy_optional_environment_args())
arguments.extend(rest)
return arguments
def prepare_cli_arguments(raw_arguments: Sequence[str]) -> tuple[list[str], list[str]]:
arguments = list(raw_arguments)
if not arguments:
if not sys.stdin.isatty():
raise ValueError("无参数模式需要交互终端;非交互执行请使用: python3 diagnose_deerflow_latency.py 'https://地址'")
return _interactive_easy_arguments()
if not arguments[0].startswith("-"):
return _positional_easy_arguments(arguments), []
return arguments, []
def build_argument_parser() -> argparse.ArgumentParser:
parser = argparse.ArgumentParser(description=("Diagnose DeerFlow latency across HTTP/Nginx/database/concurrency/host layers. Run it while the slowdown is happening for the strongest evidence."))
parser.add_argument("--url", action="append", default=[], help="Target URL; may be repeated")
parser.add_argument(
"--url-env",
action="append",
default=[],
metavar="ENV_NAME",
help="Read a target URL from an environment variable; may be repeated",
)
parser.add_argument(
"--compare-url",
"--direct-url",
action="append",
default=[],
dest="compare_url",
help="Same request through another path, e.g. http://127.0.0.1:8001/...",
)
parser.add_argument(
"--allow-cross-origin-compare",
action="store_true",
help="Allow credentials/request body to reach an explicitly supplied comparison origin",
)
parser.add_argument("--method", default="GET", help="HTTP method (default: GET)")
parser.add_argument(
"--query-env",
action="append",
default=[],
metavar="KEY=ENV_NAME",
help="Append/replace a query value from an environment variable on every target URL",
)
parser.add_argument("--header", action="append", default=[], help="Request header NAME:VALUE; secrets should use --header-env")
parser.add_argument(
"--header-env",
action="append",
default=[],
metavar="HEADER=ENV_NAME",
help="Read a header value from an environment variable",
)
parser.add_argument("--bearer-env", help="Environment variable containing a Bearer token")
parser.add_argument("--cookie-env", help="Environment variable containing a Cookie header value")
parser.add_argument("--data-file", help="Request body file; '-' reads stdin. Body content is never reported")
parser.add_argument("--count", type=int, help="Total fresh-connection samples (safe methods default 5; unsafe default 1)")
parser.add_argument("--concurrency", type=int, default=1, help="Concurrent samples; keep low on production (default 1)")
parser.add_argument("--interval", type=float, default=0.2, help="Seconds between sequential samples")
parser.add_argument(
"--allow-unsafe-replay",
action="store_true",
help="Explicitly allow repeating/concurrently sending POST/PUT/PATCH/DELETE requests",
)
parser.add_argument(
"--read-mode",
choices=("auto", "headers", "first-byte", "full"),
default="auto",
help="auto uses first-byte for SSE and full for other responses",
)
parser.add_argument("--connect-timeout", type=float, default=5.0)
parser.add_argument("--first-byte-timeout", type=float, default=30.0)
parser.add_argument("--read-timeout", type=float, default=5.0)
parser.add_argument("--total-timeout", type=float, default=120.0, help="Hard deadline per HTTP request")
parser.add_argument("--max-response-bytes", type=int, default=1024 * 1024)
parser.add_argument("--follow-redirects", action="store_true", help="Follow at most --max-redirects redirects")
parser.add_argument("--max-redirects", type=int, default=3)
parser.add_argument("--proxy", help="Explicit http:// forward proxy; credentials are never reported")
parser.add_argument("--proxy-env", help="Environment variable holding an explicit http:// proxy URL")
parser.add_argument("--use-env-proxy", action="store_true", help="Use HTTP(S)_PROXY/NO_PROXY environment settings")
parser.add_argument("--ip-family", choices=("auto", "ipv4", "ipv6"), default="auto")
parser.add_argument("--ca-file", help="Custom CA bundle for HTTPS")
parser.add_argument("--insecure", action="store_true", help="Disable TLS certificate verification (recorded as unsafe)")
parser.add_argument(
"--allow-insecure-http",
action="store_true",
help="Allow credentials, environment-backed query values, or a request body over non-loopback plaintext HTTP",
)
parser.add_argument(
"--health-baselines",
action=argparse.BooleanOptionalAction,
default=True,
help="Probe /health, /api/health and /api/langgraph/health once per origin",
)
parser.add_argument(
"--deerflow-suite",
action="store_true",
help="Probe safe DeerFlow baselines (auth/options and, with auth, me/agents/skills)",
)
parser.add_argument(
"--interference",
action="store_true",
help="Send one extra target request while repeatedly probing /api/health",
)
parser.add_argument("--interference-url", help="Custom sentinel URL (default: target origin /api/health)")
parser.add_argument("--sentinel-interval", type=float, default=0.25)
parser.add_argument("--max-sentinel-probes", type=int, default=120)
parser.add_argument(
"--server-checks",
action=argparse.BooleanOptionalAction,
default=None,
help="Collect Linux/Nginx/Docker/log/database checks (auto-enabled on Linux)",
)
parser.add_argument("--resource-sample-seconds", type=float, default=1.0)
parser.add_argument("--config", help="DeerFlow config.yaml path")
parser.add_argument("--log", action="append", default=[], help="Additional log file to scan; may be repeated")
parser.add_argument("--log-tail-bytes", type=int, default=4 * 1024 * 1024)
parser.add_argument(
"--mysql",
action=argparse.BooleanOptionalAction,
default=None,
help="Run read-only MySQL checks (requires this flag, --mysql-defaults-file, or --mysql-host)",
)
parser.add_argument("--mysql-url-env", default="MYSQL_DATABASE_URL", help="Environment variable holding MySQL URL")
parser.add_argument("--mysql-defaults-file", help="Existing mysql client option file (recommended alternative to URL env)")
parser.add_argument("--mysql-host", help="Host-only TCP test when credentials are unavailable")
parser.add_argument("--mysql-port", type=int, default=3306)
parser.add_argument("--mysql-probes", type=int, default=3)
parser.add_argument("--nginx-status-url", help="Optional Nginx stub_status URL")
parser.add_argument("--admin-origin", help="Gateway origin for admin read-only concurrency APIs")
parser.add_argument("--admin-token-env", help="Environment variable holding an admin JWT")
parser.add_argument(
"--allow-cross-origin-admin",
action="store_true",
help="Allow the admin JWT to be sent to an explicitly supplied origin different from the primary target",
)
parser.add_argument("--output", help="JSON report path (default: ./deerflow-latency-report-<time>.json)")
parser.add_argument("--no-output", action="store_true", help="Do not write a JSON report")
parser.add_argument("--json-stdout", action="store_true", help="Print the full JSON report instead of the Chinese summary")
parser.add_argument("--easy-mode", action="store_true", help=argparse.SUPPRESS)
parser.add_argument("--easy-local-origin", help=argparse.SUPPRESS)
parser.add_argument("--easy-target-host-confirmed", action="store_true", help=argparse.SUPPRESS)
return parser
def _apply_query_env(url: str, specifications: Sequence[str]) -> str:
parsed = urllib.parse.urlsplit(url)
pairs = urllib.parse.parse_qsl(parsed.query, keep_blank_values=True)
for specification in specifications:
key, separator, env_name = specification.partition("=")
key = key.strip()
env_name = env_name.strip()
if not separator or not re.fullmatch(r"[A-Za-z0-9_.~-]{1,64}", key) or not env_name:
raise ValueError("--query-env must use a safe KEY=ENV_NAME")
value = os.environ.get(env_name)
if value is None:
raise ValueError(f"environment variable {env_name} is not set")
pairs = [(old_key, old_value) for old_key, old_value in pairs if old_key != key]
pairs.append((key, value))
query = urllib.parse.urlencode(pairs)
return urllib.parse.urlunsplit((parsed.scheme, parsed.netloc, parsed.path, query, parsed.fragment))
def _load_request_headers(args: argparse.Namespace) -> tuple[dict[str, str], list[str]]:
headers: dict[str, str] = {}
names_by_lower: dict[str, str] = {}
warnings: list[str] = []
def add_header(name: str, value: str) -> None:
lowered = name.lower()
if lowered in names_by_lower:
raise ValueError(f"duplicate request header names differ only by case: {names_by_lower[lowered]} and {name}")
names_by_lower[lowered] = name
headers[name] = value
for raw in args.header:
name, value = parse_header_argument(raw)
add_header(name, value)
if _header_is_secret(name):
warnings.append(f"Secret-like header {name} was supplied on the command line; use --header-env next time")
for raw in args.header_env:
name, value = parse_header_env_argument(raw)
add_header(name, value)
if args.bearer_env:
token = os.environ.get(args.bearer_env)
if token is None:
raise ValueError(f"Environment variable {args.bearer_env} is not set")
add_header("Authorization", token if token.lower().startswith("bearer ") else f"Bearer {token}")
if args.cookie_env:
cookie = os.environ.get(args.cookie_env)
if cookie is None:
raise ValueError(f"Environment variable {args.cookie_env} is not set")
add_header("Cookie", cookie)
return headers, warnings
def _load_request_body(path: str | None) -> bytes:
if not path:
return b""
limit = 16 * 1024 * 1024
if path == "-":
data = sys.stdin.buffer.read(limit + 1)
else:
body_path = Path(path).expanduser()
with body_path.open("rb") as handle:
data = handle.read(limit + 1)
if len(data) > limit:
raise ValueError("Request body exceeds the 16 MiB diagnostic safety limit")
return data
def _unavailable_coverage(report: Mapping[str, Any]) -> list[str]:
unavailable: list[str] = []
server = report.get("server", {})
if server:
for key, label in (
("host", "Linux /proc resource snapshot"),
("sockets", "socket/listen queue snapshot"),
("docker", "Docker snapshot"),
("nginx", "Nginx effective configuration"),
("mysql", "MySQL authenticated diagnostics"),
):
entry = server.get(key, {})
explicitly_unavailable = entry.get("available") is False or (key == "host" and entry.get("supported") is False)
if entry and explicitly_unavailable:
unavailable.append(f"{label}: {entry.get('reason') or entry.get('error') or 'unavailable'}")
admin = report.get("deerflow", {}).get("admin_concurrency", {})
if admin.get("enabled") and admin.get("available") is False:
unavailable.append(f"DeerFlow admin concurrency APIs: {admin.get('reason', 'unavailable')}")
elif admin.get("partial"):
unavailable.append("DeerFlow admin concurrency APIs (partial): " + ", ".join(str(name) for name in admin.get("failed_endpoints", [])))
if report.get("request", {}).get("easy_mode"):
mysql = server.get("mysql", {})
if mysql.get("enabled") is False:
unavailable.append("MySQL authenticated diagnostics: 未提供 DEERFLOW_DIAG_MYSQL_CNF/DEERFLOW_DIAG_MYSQL_URL,未执行认证检查")
if admin.get("enabled") is False:
unavailable.append("DeerFlow admin concurrency APIs: 未提供 DEERFLOW_DIAG_ADMIN_TOKEN,未核验活动运行数和模型并发上限")
if server.get("scope", {}).get("target_host_confirmed") is False:
unavailable.append("Server resource attribution: 未确认脚本执行机就是目标 DeerFlow 服务器")
return unavailable
def main(argv: Sequence[str] | None = None) -> int:
parser = build_argument_parser()
args = parser.parse_args(argv)
try:
if args.proxy and args.proxy_env:
raise ValueError("use only one of --proxy and --proxy-env")
if args.proxy_env:
args.proxy = os.environ.get(args.proxy_env)
if not args.proxy:
raise ValueError(f"environment variable {args.proxy_env} is not set")
method = args.method.upper().strip()
if not re.fullmatch(r"[A-Z]+", method):
raise ValueError("HTTP method must contain only letters")
target_urls = list(args.url)
for env_name in args.url_env:
env_url = os.environ.get(env_name)
if not env_url:
raise ValueError(f"environment variable {env_name} is not set")
target_urls.append(env_url)
if not target_urls:
raise ValueError("at least one --url or --url-env is required")
urls = [_apply_query_env(url, args.query_env) for url in target_urls + list(args.compare_url)]
for url in urls:
parsed_url = urllib.parse.urlsplit(url)
_origin_tuple(url)
if parsed_url.username is not None or parsed_url.password is not None:
raise ValueError("target URL userinfo is not allowed; pass credentials through an environment-backed header")
if any(ord(character) < 32 or ord(character) == 127 for character in url):
raise ValueError("target URL contains a control character")
if args.proxy:
parsed_proxy = urllib.parse.urlsplit(args.proxy)
if parsed_proxy.scheme.lower() != "http" or not parsed_proxy.hostname:
raise ValueError("--proxy/--proxy-env must contain a valid http:// proxy URL")
_ = parsed_proxy.port
for label, auxiliary_url in (
("--admin-origin", args.admin_origin),
("--interference-url", args.interference_url),
("--nginx-status-url", args.nginx_status_url),
("--easy-local-origin", args.easy_local_origin),
):
if not auxiliary_url:
continue
parsed_auxiliary = urllib.parse.urlsplit(auxiliary_url)
_origin_tuple(auxiliary_url)
if parsed_auxiliary.username is not None or parsed_auxiliary.password is not None:
raise ValueError(f"{label} must not contain URL userinfo")
if any(ord(character) < 32 or ord(character) == 127 for character in auxiliary_url):
raise ValueError(f"{label} contains a control character")
if args.easy_local_origin:
easy_scheme, easy_host, _easy_port = _origin_tuple(args.easy_local_origin)
if easy_scheme != "http" or not _host_is_loopback(easy_host):
raise ValueError("--easy-local-origin must be a loopback http:// origin")
if args.ca_file:
resolved_ca_file = Path(args.ca_file).expanduser().resolve()
if not resolved_ca_file.is_file():
raise ValueError(f"CA file not found: {args.ca_file}")
args.ca_file = str(resolved_ca_file)
headers, input_warnings = _load_request_headers(args)
for raw_url in list(args.url) + list(args.compare_url):
for query_key, query_value in urllib.parse.parse_qsl(
urllib.parse.urlsplit(raw_url).query,
keep_blank_values=True,
):
if query_value and SECRET_QUERY_RE.search(query_key):
input_warnings.append(f"Secret-like query parameter {query_key} was supplied on the command line; use --query-env")
if args.proxy and urllib.parse.urlsplit(args.proxy).username is not None and not args.proxy_env:
input_warnings.append("Proxy credentials were supplied on the command line; use --proxy-env to keep them out of shell history")
proxy_has_credentials = bool(args.proxy and (urllib.parse.urlsplit(args.proxy).username is not None or any(name.lower() == "proxy-authorization" for name in headers)))
if proxy_has_credentials:
input_warnings.append("The supported proxy scheme is http://; proxy authentication is exposed to the proxy link before any target TLS tunnel")
if args.allow_cross_origin_compare:
input_warnings.append("Cross-origin comparison was enabled; supplied headers, environment-backed query values, and request body may be sent to comparison hosts")
body = _load_request_body(args.data_file)
primary_origin = _origin_tuple(urls[0])
if args.admin_token_env and args.admin_origin:
admin_origin_tuple = _origin_tuple(args.admin_origin)
if admin_origin_tuple != primary_origin and not args.allow_cross_origin_admin:
raise ValueError("--admin-origin differs from the primary target and would receive an admin JWT; review it and add --allow-cross-origin-admin")
if admin_origin_tuple != primary_origin:
input_warnings.append("Cross-origin admin diagnostics were enabled; the admin JWT will be sent to the supplied admin origin")
sensitive_request = bool(body) or bool(args.query_env) or any(_header_is_secret(name) for name in headers) or any(_url_has_secret_query(url) for url in urls)
plaintext_sensitive_origins: set[str] = set()
proxy_selection_options = ProbeOptions(
proxy=args.proxy,
use_env_proxy=args.use_env_proxy,
)
if sensitive_request:
for url in urls:
scheme, host, _port = _origin_tuple(url)
if scheme != "http":
continue
selected_proxy = _select_proxy(url, proxy_selection_options)
if not _host_is_loopback(host) or selected_proxy:
plaintext_sensitive_origins.add(sanitize_origin(url))
admin_origin_for_transport = args.admin_origin or _origin_for_request(urls[0])
admin_token_present = bool(args.admin_token_env and os.environ.get(args.admin_token_env))
admin_scheme, admin_host, _admin_port = _origin_tuple(admin_origin_for_transport)
if admin_token_present and admin_scheme == "http" and not _host_is_loopback(admin_host):
plaintext_sensitive_origins.add(sanitize_origin(admin_origin_for_transport))
if plaintext_sensitive_origins and not args.allow_insecure_http:
rendered_origins = ", ".join(sorted(plaintext_sensitive_origins))
raise ValueError(f"credentials/environment-backed query values/request body would be sent over non-loopback plaintext HTTP to {rendered_origins}; use HTTPS or review the risk and add --allow-insecure-http")
if plaintext_sensitive_origins:
input_warnings.append("Plaintext HTTP was explicitly allowed for sensitive request material: " + ", ".join(sorted(plaintext_sensitive_origins)))
if not args.allow_cross_origin_compare:
primary_has_query = bool(urllib.parse.urlsplit(urls[0]).query)
for compare_url in urls[1:]:
compare_has_query = bool(urllib.parse.urlsplit(compare_url).query)
if _origin_tuple(compare_url) != primary_origin and (
headers
or body
or args.query_env
or primary_has_query
or compare_has_query
or method not in SAFE_METHODS
):
raise ValueError("a comparison URL has a different origin and would receive headers/body/query values; review it and add --allow-cross-origin-compare")
count = args.count if args.count is not None else (5 if method in SAFE_METHODS else 1)
if count < 1 or count > 1000:
raise ValueError("--count must be between 1 and 1000")
if args.concurrency < 1 or args.concurrency > 100:
raise ValueError("--concurrency must be between 1 and 100")
for name, value, minimum, maximum in (
("--connect-timeout", args.connect_timeout, 0.1, 300.0),
("--first-byte-timeout", args.first_byte_timeout, 0.1, 3600.0),
("--read-timeout", args.read_timeout, 0.1, 3600.0),
("--total-timeout", args.total_timeout, 0.1, 7200.0),
("--interval", args.interval, 0.0, 3600.0),
("--sentinel-interval", args.sentinel_interval, 0.05, 60.0),
("--resource-sample-seconds", args.resource_sample_seconds, 0.1, 30.0),
):
if not math.isfinite(value) or value < minimum or value > maximum:
raise ValueError(f"{name} must be a finite number between {minimum} and {maximum}")
if args.mysql_port < 1 or args.mysql_port > 65535:
raise ValueError("--mysql-port must be between 1 and 65535")
replay_url_count = len(urls) + (1 if args.interference else 0)
validate_replay_safety(
method=method,
url_count=replay_url_count,
count=count,
concurrency=args.concurrency,
follow_redirects=args.follow_redirects,
allow_unsafe_replay=args.allow_unsafe_replay,
)
if args.max_response_bytes < 1 or args.max_response_bytes > 64 * 1024 * 1024:
raise ValueError("--max-response-bytes must be between 1 and 67108864")
if args.interference and args.max_sentinel_probes < 1:
raise ValueError("--max-sentinel-probes must be positive")
planned_output_path = None if args.no_output else _report_output_path(args.output)
except (OSError, ValueError) as exc:
parser.error(str(exc))
options = ProbeOptions(
method=method,
headers=headers,
body=body,
connect_timeout=max(0.1, args.connect_timeout),
first_byte_timeout=max(0.1, args.first_byte_timeout),
read_timeout=max(0.1, args.read_timeout),
total_timeout=max(0.1, args.total_timeout),
max_response_bytes=args.max_response_bytes,
read_mode=args.read_mode,
insecure=args.insecure,
ca_file=args.ca_file,
follow_redirects=args.follow_redirects,
max_redirects=max(0, min(args.max_redirects, 10)),
proxy=args.proxy,
use_env_proxy=args.use_env_proxy,
ip_family=args.ip_family,
)
config_path = find_config_path(args.config)
config = read_safe_config(config_path) if config_path else {"found": False}
system_settings = read_safe_system_settings(config_path)
started_at = utc_now_iso()
report: dict[str, Any] = {
"report_version": REPORT_VERSION,
"started_at": started_at,
"finished_at": None,
"request": {
"method": method,
"easy_mode": bool(args.easy_mode),
"target_count": len(urls),
"sample_count_per_target": count,
"concurrency": args.concurrency,
"read_mode": args.read_mode,
"body_bytes": len(body),
"header_names": sorted(headers),
"tls_verification": not args.insecure,
"proxy_mode": "explicit" if args.proxy else ("environment" if args.use_env_proxy else "direct"),
},
"security": {
"request_body_saved": False,
"response_body_saved": False,
"secret_header_values_saved": False,
"url_query_values_saved": False,
"warnings": input_warnings
+ (
["TLS certificate verification was disabled for one or more HTTPS probes"]
if args.insecure and any(urllib.parse.urlsplit(candidate).scheme.lower() == "https" for candidate in urls + [value for value in (args.admin_origin, args.interference_url, args.nginx_status_url) if value])
else []
),
},
"deerflow": {"config": config, "system_settings": system_settings},
"http": {"targets": []},
"server": {},
"findings": [],
"coverage": {"unavailable": []},
"caveats": [
"Fresh HTTP/1.1 connections are used; browser HTTP/2 multiplexing and keepalive reuse are not reproduced.",
"A single snapshot cannot disprove an intermittent problem; rerun while the slowdown is happening.",
"High response_wait localizes the delay after upload and before first response, but does not alone prove DB, model, Nginx, or disk root cause.",
"Health-sentinel evidence can be hidden by multiple workers or load-balancer routing.",
"The report removes credential values but contains infrastructure metadata such as paths, IPs, container names, database hosts and model names; do not publish it publicly.",
],
}
server_checks = args.server_checks if args.server_checks is not None else platform.system() == "Linux"
resource_executor: concurrent.futures.ThreadPoolExecutor | None = None
resource_future: concurrent.futures.Future[dict[str, Any]] | None = None
if server_checks:
resource_executor = concurrent.futures.ThreadPoolExecutor(max_workers=1)
resource_future = resource_executor.submit(collect_linux_snapshot, args.resource_sample_seconds)
try:
if args.easy_mode and not args.json_stdout:
print("\n[1/5] 正在测量目标接口……", flush=True)
for index, url in enumerate(urls):
label = "target" if index == 0 else f"compare-{index}"
results, wall_ms = run_probe_batch(
url,
options,
count=count,
concurrency=args.concurrency,
interval=max(0.0, args.interval),
)
report["http"]["targets"].append(
{
"label": label,
"url": sanitize_url(url),
"wall_ms": wall_ms,
"observed_rate_including_interval_requests_per_sec": round(
count / max(wall_ms / 1000.0, 0.001),
3,
),
"summary": summarize_probe_results(results),
"samples": results,
}
)
origins = [_origin_for_request(url) for url in urls]
if args.easy_mode and not args.json_stdout:
print("[2/5] 正在检查公网 health 与本机 Gateway……", flush=True)
if args.health_baselines:
report["http"]["health_baselines"] = run_health_baselines(origins, options)
if args.easy_local_origin:
local_health_options = ProbeOptions(
method="GET",
headers={},
connect_timeout=min(options.connect_timeout, 3.0),
first_byte_timeout=min(options.first_byte_timeout, 5.0),
read_timeout=min(options.read_timeout, 2.0),
total_timeout=min(options.total_timeout, 10.0),
max_response_bytes=65536,
read_mode="full",
insecure=False,
follow_redirects=False,
proxy=None,
use_env_proxy=False,
ip_family="ipv4",
)
report["http"]["local_gateway_health"] = probe_url(
urllib.parse.urljoin(args.easy_local_origin.rstrip("/") + "/", "api/health"),
local_health_options,
)
if args.easy_mode and not args.json_stdout:
print("[3/5] 正在检查 DeerFlow 基线与请求排队迹象……", flush=True)
if args.deerflow_suite:
authenticated = any(_header_is_secret(name) for name in headers)
report["http"]["deerflow_suite"] = run_deerflow_suite(origins[0], options, authenticated=authenticated)
if args.interference:
sentinel_url = args.interference_url or urllib.parse.urljoin(origins[0] + "/", "api/health")
report["http"]["interference"] = run_interference_probe(
urls[0],
sentinel_url,
options,
interval=max(0.1, args.sentinel_interval),
max_sentinel_probes=min(max(args.max_sentinel_probes, 1), 1000),
)
admin_origin = args.admin_origin or origins[0]
report["deerflow"]["admin_concurrency"] = collect_admin_concurrency(
admin_origin,
token_env=args.admin_token_env,
timeout=min(max(args.first_byte_timeout, 1.0), 30.0),
insecure=args.insecure,
ca_file=args.ca_file,
)
if server_checks:
if args.easy_mode and not args.json_stdout:
print("[4/5] 正在检查 Nginx、连接数、Docker 和服务器资源……", flush=True)
if resource_future is not None:
try:
report["server"]["host"] = resource_future.result(timeout=max(10.0, args.resource_sample_seconds + 5.0))
except Exception as exc: # diagnostic coverage must degrade, not abort the report
report["server"]["host"] = {"available": False, "error": _safe_error(exc)}
try:
docker = collect_docker_snapshot()
except Exception as exc:
docker = {"available": False, "error": _safe_error(exc)}
nginx_ids = docker.pop("nginx_container_ids", []) if isinstance(docker, dict) else []
report["server"]["docker"] = docker
relevant_ports = {80, 443, 2026, 8001, args.mysql_port}
for url in urls:
_scheme, _host, port = _origin_tuple(url)
relevant_ports.add(port)
try:
report["server"]["sockets"] = collect_socket_snapshot(relevant_ports)
except Exception as exc:
report["server"]["sockets"] = {"available": False, "error": _safe_error(exc)}
try:
report["server"]["nginx"] = collect_nginx_config(nginx_ids)
except Exception as exc:
report["server"]["nginx"] = {"available": False, "error": _safe_error(exc)}
try:
report["server"]["nginx_status"] = probe_nginx_stub_status(args.nginx_status_url, options)
except Exception as exc:
report["server"]["nginx_status"] = {"available": False, "error": _safe_error(exc)}
log_paths = discover_log_paths(args.log, config_path)
try:
report["server"]["logs"] = scan_logs(
log_paths,
tail_bytes=max(65536, min(args.log_tail_bytes, 64 * 1024 * 1024)),
)
except Exception as exc:
report["server"]["logs"] = {"available": False, "error": _safe_error(exc)}
try:
report["server"]["catalogue_scan"] = collect_catalogue_scan(config_path)
except Exception as exc:
report["server"]["catalogue_scan"] = {"available": False, "error": _safe_error(exc)}
try:
report["server"]["sqlite"] = collect_sqlite_diagnostics(config_path, config)
except Exception as exc:
report["server"]["sqlite"] = {"enabled": True, "available": False, "error": _safe_error(exc)}
mysql_explicit = bool(args.mysql_defaults_file) or bool(args.mysql_host)
mysql_enabled = args.mysql if args.mysql is not None else mysql_explicit
try:
report["server"]["mysql"] = collect_mysql_diagnostics(
enabled=mysql_enabled,
mysql_url_env=args.mysql_url_env,
defaults_file=args.mysql_defaults_file,
mysql_host=args.mysql_host,
mysql_port=args.mysql_port,
probes=max(1, min(args.mysql_probes, 10)),
)
except Exception as exc:
report["server"]["mysql"] = {"enabled": mysql_enabled, "available": False, "error": _safe_error(exc)}
finally:
if resource_executor is not None:
resource_executor.shutdown(wait=False, cancel_futures=True)
local_health = report.get("http", {}).get("local_gateway_health", {})
local_gateway_verified = bool(local_health.get("success") and local_health.get("status_code") == 200)
target_host_confirmed = bool(args.easy_target_host_confirmed)
if args.easy_mode and urls:
_target_scheme, target_host, _target_port = _origin_tuple(urls[0])
target_host_confirmed = target_host_confirmed or _host_is_loopback(target_host)
report["server"]["scope"] = {
"execution_host_only": True,
"local_gateway_verified": local_gateway_verified,
"target_host_confirmed": target_host_confirmed if args.easy_mode else None,
"confirmation": "operator confirmed execution on target host"
if target_host_confirmed
else ("not confirmed in easy mode" if args.easy_mode else "advanced mode; operator supplied scope"),
}
if args.easy_mode and not args.json_stdout:
print("[5/5] 正在汇总最可能的问题方向……", flush=True)
report["findings"] = build_findings(report)
report["quick_verdict"] = build_quick_verdict(report["findings"])
report["coverage"]["unavailable"] = _unavailable_coverage(report)
report["finished_at"] = utc_now_iso()
output_path: Path | None = planned_output_path
if output_path is not None:
write_private_json(output_path, report)
if args.json_stdout:
print(json.dumps(report, ensure_ascii=False, indent=2))
else:
print_console_summary(report, output_path)
return 0
def cli_entry() -> int:
cleanup_env_names: list[str] = []
try:
arguments, cleanup_env_names = prepare_cli_arguments(sys.argv[1:])
return main(arguments)
except KeyboardInterrupt:
print("\n诊断已取消。", file=sys.stderr)
return 130
except (EOFError, ValueError) as exc:
print(f"一键诊断无法启动: {exc}", file=sys.stderr)
return 2
finally:
for env_name in cleanup_env_names:
os.environ.pop(env_name, None)
if __name__ == "__main__":
raise SystemExit(cli_entry())