feat(observability): 抓取会话逐次尝试与升级路径记成 event

重试链路的关键信息是「升级路径」,而属性表达不了过程:同名属性后写覆盖先写,
三次尝试跑完只剩最后一次的状态码,前两次为什么失败、升到哪一级全被盖掉。

- site_session / rakuma_session:每次尝试各记一条 scrape.attempt event,带
  attempt / outcome / status_code / 截断后的错误串;outcome 区分 http_error、
  bad_status、challenge、validate_failed
- site_session 的每次升级各记一条 scrape.escalate:rewarm_on_home、浏览器兜底
  recovered / failed。兜底失败也要记——不然链路里只剩「最终失败」,看不出浏览器
  这一级试过没有,而「没装 playwright」和「装了也被挡」是两个查法(reason 取
  BrowserFallback.unavailable_reason,为 None 时由 add_event 跳过)
- 三处 span.record_exception 换成 record_error,失败的错误码与可重试位跟着落下来
- 失败页面快照带上 url 与 profile,省得回头对是哪条通道的哪个地址

测试:test_scrape_telemetry.py 补一条会话层用例,用 MockTransport 让所有请求都回
挑战页,断言三次尝试各留一条 event(而不是只剩最后一次)、升级路径依次是
rewarm_on_home → browser,以及浏览器那一级的 outcome=failed 与 reason。

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-08-28 16:18:25 +08:00
co-authored by Claude Opus 5
parent fd7d89ab0a
commit 865bbe3724
3 changed files with 166 additions and 11 deletions
+26 -5
View File
@@ -29,10 +29,14 @@ from app.shared.errors import (
UpstreamBlockedError,
UpstreamRequestError,
)
from app.shared.telemetry import snapshot
from app.shared.telemetry import add_event, record_error, snapshot
logger = logging.getLogger(__name__)
# 逐次尝试 event 里错误串的截断长度:httpx 的异常消息可能很长(带完整 URL 与
# 底层 socket 错误),而这里只是用来区分「这次是怎么失败的」,前半句就够
_ERROR_MAX_CHARS = 200
class RakumaSession:
"""ラクマ 站点抓取会话,管理并发限流与失败重试"""
@@ -108,7 +112,7 @@ class RakumaSession:
)
except TimeoutError as exc:
err = ResourceBusyError()
span.record_exception(err)
record_error(span, err)
span.set_attribute("scrape.fail_reason", "ResourceBusyError")
raise err from exc
@@ -119,6 +123,13 @@ class RakumaSession:
response = await client.get(url)
except httpx.HTTPError as exc:
last_error = f"{type(exc).__name__}: {exc}"
# 每次尝试各记一条 event:属性同名后写覆盖先写,三次尝试
# 跑完只剩最后一次的状态,中间那两次为什么失败全被盖掉
add_event(span, "scrape.attempt", {
"attempt": attempt,
"outcome": "http_error",
"error": last_error[:_ERROR_MAX_CHARS],
})
logger.warning(
"ラクマ 抓取请求异常:url=%s attempt=%s/%s err=%s",
url, attempt, max_attempts, last_error,
@@ -127,7 +138,7 @@ class RakumaSession:
if response.status_code == 404:
err = ItemNotFoundError(f"Page not found: {url}")
span.record_exception(err)
record_error(span, err)
span.set_attribute("scrape.fail_reason", "ItemNotFoundError")
raise err
@@ -145,6 +156,12 @@ class RakumaSession:
last_text = response.text
span.set_attribute("scrape.last_status_code", response.status_code)
span.set_attribute("scrape.html_bytes", len(response.text))
add_event(span, "scrape.attempt", {
"attempt": attempt,
"outcome": "bad_status",
"status_code": response.status_code,
"error": last_error[:_ERROR_MAX_CHARS],
})
logger.warning(
"ラクマ 抓取结果异常:url=%s attempt=%s/%s %s",
url, attempt, max_attempts, last_error,
@@ -154,9 +171,13 @@ class RakumaSession:
err = UpstreamRequestError(f"Upstream request failed: {last_error}")
else:
err = UpstreamBlockedError(f"Failed to fetch {url}: {last_error}")
span.record_exception(err)
record_error(span, err)
span.set_attribute("scrape.fail_reason", type(err).__name__)
snapshot(span, "scrape.failed_html", last_text, self._settings.otel_snapshot_max_bytes)
snapshot(
span, "scrape.failed_html", last_text,
self._settings.otel_snapshot_max_bytes,
extra={"scrape.url": url, "scrape.profile": "rakuma"},
)
raise err
finally:
self._semaphore.release()