f05dd1cf74
Co-authored-by: guke <guke@autohome.com.cn> Reviewed-on: #187
183 lines
6.7 KiB
Python
183 lines
6.7 KiB
Python
"""客户端运行日志上报:writer 单测 + 端点集成测试。"""
|
|
from __future__ import annotations
|
|
|
|
import json
|
|
from pathlib import Path
|
|
|
|
import pytest
|
|
|
|
from app.core import client_log
|
|
|
|
|
|
@pytest.fixture()
|
|
def client_log_file(tmp_path, monkeypatch):
|
|
"""把客户端日志切到临时文件,并重置 writer 单例使其按当时 env 重建。"""
|
|
p = tmp_path / "app-client.log"
|
|
monkeypatch.setenv("CLIENT_LOG_FILE", str(p))
|
|
client_log.reset_client_logger()
|
|
yield p
|
|
client_log.reset_client_logger()
|
|
|
|
|
|
def _read_lines(p: Path) -> list[dict]:
|
|
text = p.read_text(encoding="utf-8").strip()
|
|
return [json.loads(ln) for ln in text.splitlines() if ln]
|
|
|
|
|
|
# ---------------- writer 层 ----------------
|
|
|
|
def _meta(**kw) -> dict:
|
|
base = {"device_id": "d-1", "user_id": None, "app_ver": None,
|
|
"platform": None, "sent_at": None}
|
|
base.update(kw)
|
|
return base
|
|
|
|
|
|
def test_writer_writes_one_line_per_record(client_log_file):
|
|
recs = [
|
|
{"client_ts": 1737000000000, "level": "info", "msg": "hello"},
|
|
{"client_ts": 1737000000001, "level": "error", "msg": "boom", "tag": "net"},
|
|
]
|
|
received, dropped = client_log.write_records(
|
|
recs, meta=_meta(user_id=42, app_ver="1.2.3", platform="android"),
|
|
client_ip="1.2.3.4",
|
|
)
|
|
assert (received, dropped) == (2, 0)
|
|
lines = _read_lines(client_log_file)
|
|
assert len(lines) == 2
|
|
assert lines[0]["source"] == "client"
|
|
assert lines[0]["service"] == "app-client"
|
|
assert lines[0]["device_id"] == "d-1"
|
|
assert lines[0]["user_id"] == 42
|
|
assert lines[0]["app_ver"] == "1.2.3"
|
|
assert lines[0]["client_ip"] == "1.2.3.4"
|
|
assert lines[0]["level"] == "INFO" # 归一化大写
|
|
assert lines[0]["msg"] == "hello"
|
|
assert lines[0]["client_ts"] == 1737000000000
|
|
assert lines[1]["tag"] == "net"
|
|
|
|
|
|
def test_writer_hoists_trace_id_to_top_level(client_log_file):
|
|
client_log.write_records(
|
|
[{"client_ts": 1, "level": "info", "msg": "x", "trace_id": "abc123"}],
|
|
meta=_meta(), client_ip="",
|
|
)
|
|
assert _read_lines(client_log_file)[0]["trace_id"] == "abc123"
|
|
|
|
|
|
def test_writer_sweeps_unknown_keys_into_data(client_log_file):
|
|
client_log.write_records(
|
|
[{"client_ts": 1, "level": "info", "msg": "x",
|
|
"foo": 123, "data": {"bar": "baz"}}],
|
|
meta=_meta(), client_ip="",
|
|
)
|
|
line = _read_lines(client_log_file)[0]
|
|
assert "foo" not in line # 白名单外不进顶层
|
|
assert line["data"]["foo"] == 123 # 兜底进 data
|
|
assert line["data"]["bar"] == "baz" # 客户端自带 data 合并进来
|
|
|
|
|
|
def test_writer_truncates_oversize_msg(client_log_file, monkeypatch):
|
|
monkeypatch.setenv("APPLOG_MAX_MSG_BYTES", "10")
|
|
received, dropped = client_log.write_records(
|
|
[{"client_ts": 1, "level": "info", "msg": "x" * 100}],
|
|
meta=_meta(), client_ip="",
|
|
)
|
|
assert (received, dropped) == (1, 0) # 截断而非丢弃
|
|
line = _read_lines(client_log_file)[0]
|
|
assert line["msg_truncated"] is True
|
|
assert line["msg"].endswith("…[truncated]")
|
|
body = line["msg"].removesuffix("…[truncated]")
|
|
assert len(body.encode("utf-8")) <= 10 # 截断体不超过字节上限
|
|
|
|
|
|
def test_writer_caps_indexed_field_lengths(client_log_file):
|
|
client_log.write_records(
|
|
[{"client_ts": 1, "level": "info" * 20, "msg": "x",
|
|
"trace_id": "t" * 1000, "tag": "g" * 1000}],
|
|
meta=_meta(), client_ip="",
|
|
)
|
|
line = _read_lines(client_log_file)[0]
|
|
assert len(line["level"]) <= 16 # 索引字段(spec §8)做长度上限
|
|
assert len(line["trace_id"]) <= 256
|
|
assert len(line["tag"]) <= 128
|
|
|
|
|
|
def test_writer_logger_does_not_propagate(client_log_file):
|
|
lg = client_log.get_logger()
|
|
assert lg.name == "shagua.client_log"
|
|
assert lg.propagate is False # 不冒泡到 root → 不写 app-server.log
|
|
|
|
|
|
def test_writer_drops_bad_record_without_raising(client_log_file, monkeypatch):
|
|
def _boom(*a, **kw):
|
|
raise RuntimeError("boom")
|
|
monkeypatch.setattr(client_log, "_build_line", _boom)
|
|
received, dropped = client_log.write_records(
|
|
[{"client_ts": 1, "level": "info", "msg": "x"}],
|
|
meta=_meta(), client_ip="",
|
|
)
|
|
assert (received, dropped) == (0, 1) # 坏条计 dropped,且不抛给上层
|
|
|
|
|
|
# ---------------- 端点层 ----------------
|
|
|
|
def _post(client, body):
|
|
return client.post("/api/v1/applog/batch", json=body)
|
|
|
|
|
|
def test_endpoint_happy_path(client, client_log_file):
|
|
body = {
|
|
"device_id": "d-1", "user_id": 42, "app_ver": "1.2.3", "platform": "android",
|
|
"logs": [
|
|
{"client_ts": 1, "level": "info", "msg": "a", "trace_id": "t1"},
|
|
{"client_ts": 2, "level": "warn", "msg": "b"},
|
|
],
|
|
}
|
|
resp = _post(client, body)
|
|
assert resp.status_code == 200
|
|
assert resp.json() == {"ok": True, "received": 2, "dropped": 0}
|
|
lines = _read_lines(client_log_file)
|
|
assert len(lines) == 2
|
|
assert lines[0]["trace_id"] == "t1" # 端到端:trace_id 落到文件顶层
|
|
assert lines[0]["client_ip"] # 服务端补了 IP
|
|
|
|
|
|
def test_endpoint_rejects_batch_over_max(client, client_log_file):
|
|
body = {"device_id": "d-1",
|
|
"logs": [{"client_ts": i, "level": "info", "msg": str(i)} for i in range(501)]}
|
|
resp = _post(client, body)
|
|
assert resp.status_code == 422 # Pydantic max_length=500
|
|
|
|
|
|
def test_endpoint_rejects_body_over_limit(client, client_log_file, monkeypatch):
|
|
# 只测有 Content-Length 的常规情形(TestClient/requests 恒发该头)。缺该头(chunked)
|
|
# 时依赖 nginx client_max_body_size 兜底,不在应用层测。
|
|
monkeypatch.setenv("APPLOG_MAX_BODY_BYTES", "50")
|
|
body = {"device_id": "d-1", "logs": [{"client_ts": 1, "level": "info", "msg": "x" * 500}]}
|
|
resp = _post(client, body)
|
|
assert resp.status_code == 413 # 依赖查 Content-Length,body 校验前拦截
|
|
|
|
|
|
def test_endpoint_returns_200_on_write_failure(client, client_log_file, monkeypatch):
|
|
class _BoomLogger:
|
|
name = "shagua.client_log"
|
|
propagate = False
|
|
handlers: list = []
|
|
|
|
def info(self, *a, **k):
|
|
raise RuntimeError("disk full")
|
|
|
|
monkeypatch.setattr(client_log, "get_logger", lambda: _BoomLogger())
|
|
body = {"device_id": "d-1", "logs": [
|
|
{"client_ts": 1, "level": "info", "msg": "a"},
|
|
{"client_ts": 2, "level": "info", "msg": "b"}]}
|
|
resp = _post(client, body)
|
|
assert resp.status_code == 200 # fire-and-forget:写失败不 500
|
|
assert resp.json() == {"ok": True, "received": 0, "dropped": 2}
|
|
|
|
|
|
def test_endpoint_missing_device_id_is_422(client, client_log_file):
|
|
resp = _post(client, {"logs": [{"client_ts": 1, "level": "info", "msg": "a"}]})
|
|
assert resp.status_code == 422 # device_id 必填
|