mirror of
https://github.com/jhd3197/ServerKit.git
synced 2026-10-26 21:17:13 +09:00
Centralize backup schedule calculations and shared request/form flows, encode query values, and move repository finalization into its service. Add browser CI coverage, repair stale test fixtures, and provide opt-in request and build measurement tools.
206 lines
9.1 KiB
Python
206 lines
9.1 KiB
Python
#!/usr/bin/env python3
|
|
"""Measure real GET requests; use only against an authorized local/staging panel.
|
|
|
|
JSON schema version 1: endpoints contain warmup and samples arrays plus summary.
|
|
Every attempt records elapsed_ms, status, response_bytes, app_ms, sql_ms,
|
|
sql_queries and error (null on success). Missing Server-Timing data stays null.
|
|
Summary distributions contain count/min/p50/p95/max, with linearly interpolated
|
|
percentiles. Summaries exclude warmup; request_count includes all attempts.
|
|
Body contents, credentials and query values are never included in reports.
|
|
"""
|
|
|
|
import argparse
|
|
from datetime import datetime, timezone
|
|
import ipaddress
|
|
from http.client import HTTPException
|
|
import json
|
|
import math
|
|
import os
|
|
from pathlib import Path
|
|
import re
|
|
import time
|
|
from urllib.error import HTTPError, URLError
|
|
from urllib.parse import parse_qsl, unquote, urlencode, urlsplit, urlunsplit
|
|
from urllib.request import HTTPRedirectHandler, ProxyHandler, Request, build_opener
|
|
|
|
|
|
class NoRedirects(HTTPRedirectHandler):
|
|
def redirect_request(self, req, fp, code, msg, headers, newurl):
|
|
return None
|
|
|
|
|
|
def percentile(values, fraction):
|
|
if not values:
|
|
return None
|
|
ordered = sorted(values)
|
|
position = (len(ordered) - 1) * fraction
|
|
lower = math.floor(position)
|
|
upper = math.ceil(position)
|
|
return ordered[lower] + (ordered[upper] - ordered[lower]) * (position - lower)
|
|
|
|
|
|
def distribution(values):
|
|
present = [value for value in values if value is not None]
|
|
return {
|
|
'count': len(present), 'min': min(present) if present else None,
|
|
'p50': percentile(present, .5), 'p95': percentile(present, .95),
|
|
'max': max(present) if present else None,
|
|
}
|
|
|
|
|
|
def redact_path(path):
|
|
parsed = urlsplit(path)
|
|
query = urlencode([(key, '[redacted]') for key, _ in parse_qsl(parsed.query, keep_blank_values=True)])
|
|
return urlunsplit(('', '', parsed.path, query, ''))
|
|
|
|
|
|
def validate_target(base_url, paths, authenticated):
|
|
base = urlsplit(base_url)
|
|
if (base.scheme not in ('http', 'https') or not base.hostname or base.username is not None
|
|
or base.password is not None or base.query or base.fragment):
|
|
raise ValueError('base-url must be an HTTP(S) URL without credentials, query or fragment')
|
|
try:
|
|
base.port
|
|
except ValueError:
|
|
raise ValueError('base-url has an invalid port') from None
|
|
loopback = base.hostname.lower() == 'localhost'
|
|
try:
|
|
loopback = loopback or ipaddress.ip_address(base.hostname).is_loopback
|
|
except ValueError:
|
|
pass
|
|
if authenticated and base.scheme == 'http' and not loopback:
|
|
raise ValueError('credentials require HTTPS except for a loopback host')
|
|
if any(ord(char) < 32 for char in base_url):
|
|
raise ValueError('base-url contains control characters')
|
|
for path in paths:
|
|
parsed = urlsplit(path)
|
|
decoded = unquote(parsed.path)
|
|
if (parsed.scheme or parsed.netloc or parsed.fragment or not decoded.startswith('/api/')
|
|
or '\\' in decoded or any(part in ('.', '..') for part in decoded.split('/'))
|
|
or any(ord(char) < 32 for char in path + decoded)):
|
|
raise ValueError('each path must be a relative /api/ URL without traversal or fragments')
|
|
return base_url.rstrip('/')
|
|
|
|
|
|
def parse_server_timing(header):
|
|
result = {'app_ms': None, 'sql_ms': None, 'sql_queries': None}
|
|
# The panel emits unquoted numeric durations and a quoted query count.
|
|
# Ignore unrelated metrics or malformed values without inventing zeros.
|
|
for match in re.finditer(r'(?:^|,)\s*(app|db)\s*((?:;[^,]*)?)', header or ''):
|
|
name, params = match.groups()
|
|
duration = re.search(r'(?:^|;)\s*dur\s*=\s*([0-9]+(?:\.[0-9]+)?)\s*(?:;|$)', params)
|
|
if duration:
|
|
value = float(duration.group(1))
|
|
if math.isfinite(value):
|
|
result['app_ms' if name == 'app' else 'sql_ms'] = value
|
|
if name == 'db':
|
|
queries = re.search(r'(?:^|;)\s*desc\s*=\s*"([0-9]+) queries"', params)
|
|
if queries:
|
|
result['sql_queries'] = int(queries.group(1))
|
|
return result
|
|
|
|
|
|
def sample_get(opener, url, headers, timeout):
|
|
started = time.perf_counter()
|
|
sample = {'elapsed_ms': None, 'status': None, 'response_bytes': None,
|
|
'app_ms': None, 'sql_ms': None, 'sql_queries': None, 'error': None}
|
|
response = None
|
|
try:
|
|
try:
|
|
response = opener.open(Request(url, headers=headers, method='GET'), timeout=timeout)
|
|
except HTTPError as exc:
|
|
response = exc
|
|
sample['status'] = response.status
|
|
sample.update(parse_server_timing(', '.join(response.headers.get_all('Server-Timing', []))))
|
|
if 300 <= response.status < 400:
|
|
sample['error'] = 'redirect_refused'
|
|
elif not 200 <= response.status < 300:
|
|
sample['error'] = 'http_error'
|
|
size = 0
|
|
while True:
|
|
chunk = response.read(65536)
|
|
if not chunk:
|
|
break
|
|
size += len(chunk)
|
|
sample['response_bytes'] = size
|
|
except (OSError, URLError, ValueError, HTTPException) as exc:
|
|
# Exception messages may contain the URL/query or proxy credentials.
|
|
sample['error'] = f'transport_error:{type(exc).__name__}'
|
|
finally:
|
|
if response is not None:
|
|
response.close()
|
|
sample['elapsed_ms'] = (time.perf_counter() - started) * 1000
|
|
return sample
|
|
|
|
|
|
def summarize(samples):
|
|
statuses = {}
|
|
for sample in samples:
|
|
key = str(sample['status']) if sample['status'] is not None else 'unavailable'
|
|
statuses[key] = statuses.get(key, 0) + 1
|
|
return {
|
|
'request_count': len(samples), 'failed_count': sum(bool(sample['error']) for sample in samples),
|
|
'statuses': statuses,
|
|
**{field: distribution([sample[field] for sample in samples])
|
|
for field in ('elapsed_ms', 'response_bytes', 'app_ms', 'sql_ms', 'sql_queries')},
|
|
}
|
|
|
|
|
|
def profile(base_url, paths, *, samples=5, warmup=1, timeout=30, headers=None):
|
|
headers = headers or {}
|
|
base_url = validate_target(base_url, paths, bool(headers))
|
|
# Direct requests avoid implicit proxy authentication/routing from the shell.
|
|
opener = build_opener(ProxyHandler({}), NoRedirects())
|
|
report = {'schema_version': 1, 'generated_at': datetime.now(timezone.utc).isoformat(),
|
|
'base_url': base_url, 'method': 'GET', 'request_count': 0,
|
|
'failed_count': 0, 'endpoints': []}
|
|
for path in paths:
|
|
endpoint = {'path': redact_path(path), 'warmup': [], 'samples': []}
|
|
for phase, count in (('warmup', warmup), ('samples', samples)):
|
|
for _ in range(count):
|
|
sample = sample_get(opener, base_url + path, headers, timeout)
|
|
endpoint[phase].append(sample)
|
|
report['request_count'] += 1
|
|
report['failed_count'] += bool(sample['error'])
|
|
endpoint['summary'] = summarize(endpoint['samples'])
|
|
report['endpoints'].append(endpoint)
|
|
return report
|
|
|
|
|
|
def main(argv=None):
|
|
parser = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
|
|
parser.add_argument('--base-url', default='http://127.0.0.1:47927')
|
|
parser.add_argument('--path', action='append', help='repeatable relative /api/ URL; default /api/v1/system/health')
|
|
parser.add_argument('--samples', type=int, default=5)
|
|
parser.add_argument('--warmup', type=int, default=1)
|
|
parser.add_argument('--timeout', type=float, default=30, help='request timeout in seconds (default 30)')
|
|
parser.add_argument('--output', type=Path, help='write JSON to this file instead of stdout')
|
|
parser.epilog = ('Optional authentication: SERVERKIT_PROFILE_TOKEN or SERVERKIT_PROFILE_API_KEY environment '
|
|
'variable (mutually exclusive). Redirects are refused; credentials require HTTPS except '
|
|
'on loopback. Exit 1 means one or more attempts failed, including warmup.')
|
|
args = parser.parse_args(argv)
|
|
if args.samples < 1 or args.warmup < 0 or not math.isfinite(args.timeout) or args.timeout <= 0:
|
|
parser.error('samples must be positive, warmup nonnegative, and timeout finite and positive')
|
|
token = os.environ.get('SERVERKIT_PROFILE_TOKEN')
|
|
api_key = os.environ.get('SERVERKIT_PROFILE_API_KEY')
|
|
if token and api_key:
|
|
parser.error('set only one of SERVERKIT_PROFILE_TOKEN and SERVERKIT_PROFILE_API_KEY')
|
|
headers = {'Authorization': f'Bearer {token}'} if token else {'X-API-Key': api_key} if api_key else {}
|
|
if any('\r' in value or '\n' in value for value in headers.values()):
|
|
parser.error('authentication value contains a newline')
|
|
try:
|
|
report = profile(args.base_url, args.path or ['/api/v1/system/health'], samples=args.samples,
|
|
warmup=args.warmup, timeout=args.timeout, headers=headers)
|
|
except ValueError as exc:
|
|
parser.error(str(exc))
|
|
output = json.dumps(report, indent=2, allow_nan=False) + '\n'
|
|
if args.output:
|
|
args.output.write_text(output, encoding='utf-8')
|
|
else:
|
|
print(output, end='')
|
|
return 1 if report['failed_count'] else 0
|
|
|
|
|
|
if __name__ == '__main__':
|
|
raise SystemExit(main())
|