RUM 分层统一查询 —— 实施方案
0x01 方案结论
a. 目标调用链
Resource
└── RumLevelHandlerFactory.create(mode, data_sources)
└── Span / View / Session LevelHandler(data_sources)
├── self.query → Span / View / Session Query → BaseQuery
└── self.span_query → SpanQuery → BaseQuery(可选兜底)| 决策点 | 结论 |
|---|---|
| HTTP 入口 | Resource 自行实现 perform_request(),不设公共基类 |
| Level 分派 | RumLevelHandlerFactory 只维护 mode → LevelHandler 映射 |
| 初始化参数 | Factory、Level、Query 均接收 list[TraceDatasourceTarget] |
| 结果表承载 | table_id 存原始表,levels 存 RUM 层级表和 APM 预计算表 |
| 查询职责 | Query 提供原子查询,Level 组合一个或多个 Query |
| 字段语义 | semconv/rum 定义原子字段,Trace 命名空间声明结构前缀,SpanSpec 统一访问 |
| APM 适配 | QueryProxy 统一构造 Target,3 类 Query 复用通用查询原语 |
b. 方案边界
- 除
view_config、Span 核心详情与异步分析约定外的接口细节和错误码。 - View、Session 的预计算链路与数据生产方式。
- View、Session 的记录详情、层级时间线和综合统计接口。
- OTLP 编解码和 RUM 上报协议变更。
0x02 架构设计
a. 总体结构
flowchart LR
Client["RUM 调用方"] --> Resource["独立 Resource<br/>9 个统一入口"]
Resource -- "mode + data_sources" --> Factory["RumLevelHandlerFactory"]
Factory -- "span" --> SpanLevel["SpanLevelHandler"]
Factory -- "view" --> ViewLevel["ViewLevelHandler"]
Factory -- "session" --> SessionLevel["SessionLevelHandler"]
SpanLevel --> SpanQuery["SpanQuery"]
ViewLevel --> ViewQuery["ViewQuery"]
ViewLevel -. "Span 兜底" .-> SpanQuery
SessionLevel --> SessionQuery["SessionQuery"]
SessionLevel -. "Span 兜底" .-> SpanQuery
SpanLevel -. "view_config 字段语义" .-> SpanSpec["SpanSpec<br/>FieldRegistry"]
SpanQuery --> BaseQuery["BaseQuery<br/>存储查询原语"]
ViewQuery --> BaseQuery
SessionQuery --> BaseQuery
BaseQuery --> UQ["UnifyQuery"]
Target["list[TraceDatasourceTarget]<br/>table_id · app · levels"] -. "Resource 构造" .-> Resource
classDef api fill:#FFF4E5,stroke:#C77700,color:#333;
classDef level fill:#E8F3FF,stroke:#3A84FF,color:#333;
classDef query fill:#EAF7EE,stroke:#3E9B59,color:#333;
classDef target fill:#F2EDFF,stroke:#8064D6,color:#333;
class Resource,Factory api;
class SpanLevel,ViewLevel,SessionLevel level;
class SpanQuery,ViewQuery,SessionQuery,BaseQuery query;
class SpanSpec target;
class Target target;| 层 | 负责 | 不负责 |
|---|---|---|
| Resource | 权限、参数校验、构造 data_sources、调用 Level | 选择具体 Level 类或执行存储查询 |
| Factory | 校验 mode、构造对应 Level | 解析结果表或实现接口能力 |
| Level | 组合一个或多个 Query,组装接口能力 | 处理 HTTP 或直接访问 UnifyQuery |
| Query | 从 Target 选择层级表、构造并执行存储查询 | 页面配置和 HTTP 返回组装 |
| Semconv | 定义字段语义、声明 Trace 命名空间、按完整路径查找 | 查询存储、决定字段是否实际存在 |
b. 数据源目标
代码位置:bkmonitor/data_source/utils/apm.py。
@dataclass(frozen=True)
class LevelTarget:
name: str
table_ids: list[str]
@dataclass(frozen=True)
class TraceDatasourceTarget:
table_id: str
app: APMAppTarget
retention: int | None = None
levels: list[LevelTarget] = field(default_factory=list)classDiagram
direction LR
class TraceDatasourceTarget {
+str table_id
+APMAppTarget app
+Optional~int~ retention
+list~LevelTarget~ levels
}
class LevelTarget {
+str name
+list~str~ table_ids
}
TraceDatasourceTarget "1" o-- "0..*" LevelTarget : levels| 查询层级 | 结果表来源 |
|---|---|
| Span | TraceDatasourceTarget.table_id |
| View | TraceDatasourceTarget.levels 中 name = "view" 的 table_ids |
| Session | TraceDatasourceTarget.levels 中 name = "session" 的 table_ids |
| APM Trace 预计算 | TraceDatasourceTarget.levels 中 name = "trace" 的 table_ids |
补充约束:
retention和levels为可选字段,仅提供table_id的调用方无需调整。QueryProxy从 Trace 数据源填充retention,APM Query 只消费 Target。LevelTarget.name标识预计算层级,不与RumQueryMode绑定。
c. APM 查询适配
flowchart LR
Proxy["QueryProxy"] --> Target["TraceDatasourceTarget"]
Target -- "table_id" --> Span["SpanQuery"]
Target -- "table_id" --> Origin["OriginTraceQuery"]
Target -- "levels: trace" --> Trace["TraceQuery"]
Span --> APMBase["APM BaseQuery<br/>retention · Scope · filter"]
Origin --> APMBase
Trace --> APMBase
APMBase --> DataSourceBase["DataSourceBaseQuery<br/>aggregation · list"]
DataSourceBase --> UQ["UnifyQuery"]SpanQuery、OriginTraceQuery 和 TraceQuery 构造查询配置并返回结果列表,QueryProxy 统一包装响应协议。
d. Session 与 View 降级列表
层级表是否齐备由 Query 判断,语义字段目录独立于表结构。列表先折叠检索窗口内的匹配 Span,再以当前页实体 ID 补查保留期内的汇总。
flowchart LR
Level["SessionLevelHandler / ViewLevelHandler"] --> Query["SessionQuery / ViewQuery"]
Level --> LevelBase["BaseRumLevelHandler"]
Query --> FallbackBase["BaseFallbackRumQuery"]
FallbackBase --> QueryBase["BaseRumQuery"]
Query --> Ready{"levels 齐备"}
Ready -- yes --> Table["SpanQuery<br/>层级表"]
Ready -- no --> Fold["_query_group_list()<br/>原始 Span 表"]
Fold --> Summary["query_summaries()<br/>当前页 ID"]
Summary --> Build["build_records()"]
Spec["SessionSpec / ViewSpec"] --> Level| 能力 | 降级协议 | 层级表就绪 |
|---|---|---|
| 字段目录 | [a] SessionSpec 包含 31 个字段[b] ViewSpec 包含 40 个字段[c] 按保留表及已确认的 frustration_count 定义维护,不调用 UQ 字段发现 | 使用同一语义目录 |
| 原始字段分析 | [a] Session:span_type=session AND session.phase=start[b] View: span_type=view AND view.phase=update | 分析实体记录 |
| 计算字段能力 | [a] is_agg=false[b] is_searchable=false[c] 消费端关闭计算字段操作入口 [1] | 按字段类型开放 |
| 列表候选 | 按有效的 attributes.session.id 或 attributes.view.id 折叠全部匹配 Span | 直接分页查询层级表 |
| 当前页补查 | [a] 按 ID 批量查询计数、最早开始、最晚结束和生命周期 [b] Session 补入口、出口及 View 去重数 [c] View 按指标取最新 Vital | 不补查 |
| 关闭状态 | [a] 只由最新生命周期事件判断 is_active[b] end 返回 normal 与结束时间[c] 缺失状态与指标返回 null | 使用层级表结果 |
- [1]
start_time允许按匹配 Span 的时间排序;补查后的实体start_time取min(start_time),保持折叠分页顺序,不进行页内伪排序。 - [2] 分析统计是事件口径,多个 View
update分别计数;Sessionrotate不纳入start分析。 - [3] 补查只保留实体 ID 和授权数据源,不继承检索条件,使用数据保留期内的全部可观测数据。
- [4] 多目标必须全部具有对应层级表才切换,避免混合实体统计与事件统计。
- [5] 分页参数由序列化器统一校验,查询层不另设条数上限;补查最多
6个并发任务,聚合查询沿用基类的异常处理。
close_reason=timeout 依赖预计算窗口,降级查询不推断超时。
「挫败行为数」采用已确认的 frustration_count。bk-rum 的 packages/open-telemetry/src/plugins/action.ts 与 Action 协议 将 attributes.action.frustration.type 定义为数组,包含 rage_click、dead_click、error_click,同一 Action 可命中多种类型。查询按当前页实体 ID 与 span_type=action 限定范围,再匹配任一挫败类型并对记录计数;不按类型拆桶或求和,同一条 Action 只计一次。该字段归入数量统计分组,缺少匹配记录时为 0,降级模式下只供列表展示。
Span、Session、View 的字段配置共用 BaseRumQuery.query_fields。字段目录始终以各层级 semconv 为兜底,UQ 失败不丢失语义字段;Span 及已有层级表时再合并存储元数据。服务端移除 validate_fields、validate_search 和 _validate_analysis,消费端依据能力元数据关闭未开放的操作。表格补查的数值聚合复用 _statistics,沿用基类现有的异常处理,不引入严格错误模式。
0x03 开发方案
a. 代码落点
bkmonitor/data_source/utils/
├── query.py # [Keep] BaseQuery
└── apm.py # [Change] LevelTarget、TraceDatasourceTarget.retention / levels
apm/core/handlers/query/
├── base.py # [Change] 继承 APMQueryFilterMixin、DataSourceBaseQuery
├── proxy.py # [Change] 统一构造 TraceDatasourceTarget
├── span_query.py # [Change] 使用原始表
├── origin_trace_query.py # [Change] 使用原始表
└── trace_query.py # [Change] 使用 trace 层级预计算表
packages/rum_web/
├── constants.py # [Change] RumQueryMode
├── query/
│ ├── resources.py # [Add] 9 个独立 Resource
│ ├── serializers.py # [Add] 请求序列化器分层
│ ├── views.py # [Add] SearchViewSet
│ └── urls.py # [Add] ResourceRouter,生成 search/
├── urls.py # [Change] 在根路径挂载 rum_web.query.urls
└── handlers/
├── builder/
│ ├── span/ # [Add] Span Builder
│ └── timeline.py # [Add] 会话时间线 Builder
├── query/
│ ├── span.py # [Change] SpanQuery
│ ├── view.py # [Add] ViewQuery
│ └── session.py # [Add] SessionQuery
└── level/
├── base.py # [Add] BaseRumLevelHandler
├── factory.py # [Add] RumLevelHandlerFactory
├── span.py # [Add] SpanLevelHandler
├── view.py # [Add] ViewLevelHandler
└── session.py # [Add] SessionLevelHandler聚合层实现落点:
| 对象 | 职责 |
|---|---|
semconv/rum/session、semconv/rum/view | 分别声明各层级字段树,公共字段定义复用 semconv/rum/common.py |
handlers/query/base.py | 平移 Span 通用原子查询和字段分析,统一语义目录兜底 |
handlers/query/fallback.py | 专用父类复用折叠、补查与回填,通过 QueryConfigBuilder 复用当前页 ID 与过滤条件,聚合直接调用基类 _statistics,任务将结果回填为 ID → 字段值 |
handlers/query/session.py、handlers/query/view.py | 独立声明语义目录、分析阶段和快照排序,通过 _add_summary_tasks 追加各自补查任务,过滤和排序由 Builder 声明,聚合字段、方法及分组使用基类统计接口 |
handlers/level/base.py | 提供通用列表、字段配置、字段分析及统计响应协议 |
handlers/level/session.py、handlers/level/view.py | 各自声明层级 Query、语义目录及默认字段,不引入中间继承层 |
constants.py | 沿用分组名称、别名和 field_names 协议 |
b. mode 与 Factory
class RumQueryMode(CachedEnum):
SPAN = "span"
VIEW = "view"
SESSION = "session"
@classmethod
def choices(cls) -> list[tuple[str, str]]:
"""枚举形态承接 choices 生成与取值容错,无需逐个维护选项列表。"""
class RumLevelHandlerFactory:
HANDLERS = {
RumQueryMode.SPAN.value: SpanLevelHandler,
RumQueryMode.VIEW.value: ViewLevelHandler,
RumQueryMode.SESSION.value: SessionLevelHandler,
}
@classmethod
def create(
cls,
mode: str,
data_sources: list[TraceDatasourceTarget],
) -> BaseRumLevelHandler:
if mode not in cls.HANDLERS:
raise UnsupportedRumQueryMode(mode)
return cls.HANDLERS[mode](data_sources)c. 基础查询层
| 变更点 | 代码位置 |
|---|---|
[Keep] BaseQuery | bkmonitor/data_source/utils/query.py |
[Change] SpanQuery | packages/rum_web/handlers/query/span.py |
[Add] ViewQuery | packages/rum_web/handlers/query/view.py |
[Add] SessionQuery | packages/rum_web/handlers/query/session.py |
[Change] APM BaseQuery | apm/core/handlers/query/base.py |
[Change] TraceQuery / OriginTraceQuery / SpanQuery | apm/core/handlers/query/ |
查询原语:
query_list
query_total
query_field_topk
query_option_values
query_graph_config
query_field_aggregated_value
query_detail
query_fieldsd. Level 与 Query 交互
classDiagram
class BaseRumLevelHandler {
<<abstract>>
+list~TraceDatasourceTarget~ data_sources
+__init__(data_sources)
}
class SpanLevelHandler
class ViewLevelHandler
class SessionLevelHandler
class BaseQuery
class SpanQuery
class ViewQuery
class SessionQuery
BaseRumLevelHandler <|-- SpanLevelHandler
BaseRumLevelHandler <|-- ViewLevelHandler
BaseRumLevelHandler <|-- SessionLevelHandler
BaseQuery <|-- SpanQuery
BaseQuery <|-- ViewQuery
BaseQuery <|-- SessionQuery
SpanLevelHandler --> SpanQuery : query
ViewLevelHandler --> ViewQuery : query
ViewLevelHandler --> SpanQuery : span_query
SessionLevelHandler --> SessionQuery : query
SessionLevelHandler ..> SpanQuery : optional span_queryfrom abc import ABC, abstractmethod
from typing import Any
from bkmonitor.data_source.utils import types
class BaseRumLevelHandler(ABC):
def __init__(self, data_sources: list[TraceDatasourceTarget]):
self.data_sources = data_sources
@abstractmethod
def view_config(
self,
extra_config: dict[str, Any] | None = None,
) -> dict[str, Any]:
...
@abstractmethod
def generate_query_string(
self,
filters: list[types.Filter],
extra_config: dict[str, Any] | None = None,
) -> str:
...
@abstractmethod
def field_topk(
self,
start_time: int,
end_time: int,
field: str,
limit: int = 5,
filters: list[types.Filter] | None = None,
query_string: str = "",
extra_config: dict[str, Any] | None = None,
) -> list[dict[str, Any]]:
...
@abstractmethod
def field_statistics_info(
self,
start_time: int,
end_time: int,
field: dict[str, Any],
filters: list[types.Filter] | None = None,
query_string: str = "",
extra_config: dict[str, Any] | None = None,
) -> dict[str, Any]:
...
@abstractmethod
def field_statistics_graph(
self,
start_time: int,
end_time: int,
field: dict[str, Any],
filters: list[types.Filter] | None = None,
query_string: str = "",
extra_config: dict[str, Any] | None = None,
) -> dict[str, Any]:
...
@abstractmethod
def download_topk(
self,
start_time: int,
end_time: int,
field: str,
limit: int = 5,
filters: list[types.Filter] | None = None,
query_string: str = "",
extra_config: dict[str, Any] | None = None,
) -> bytes:
...
@abstractmethod
def get_fields_option_values(
self,
start_time: int,
end_time: int,
fields: list[str],
limit: int = 10,
filters: list[types.Filter] | None = None,
query_string: str = "",
extra_config: dict[str, Any] | None = None,
) -> dict[str, list[str]]:
...
@abstractmethod
def list_records(
self,
start_time: int,
end_time: int,
offset: int = 0,
limit: int = 10,
filters: list[types.Filter] | None = None,
query_string: str = "",
sort: list[str] | None = None,
extra_config: dict[str, Any] | None = None,
) -> list[dict[str, Any]]:
...
@abstractmethod
def record_detail(
self,
record_id: str,
extra_config: dict[str, Any] | None = None,
) -> dict[str, Any]:
...
class ViewLevelHandler(BaseRumLevelHandler):
def __init__(self, data_sources: list[TraceDatasourceTarget]):
super().__init__(data_sources)
self.query = ViewQuery(data_sources)
self.span_query = SpanQuery(data_sources)- [1]
extra_config仅承载服务端 Level 差异化配置。 - [2] Resource 按
mode构造配置,请求序列化器不对外声明。 - [3] Level 只读取白名单字段,并将解析值按参数传给 Query。
- [4]
extra_config不能覆盖显式参数或data_sources。
e. Resource 与 Level 交互
class RumFieldTopKResource(Resource):
RequestSerializer = RumFieldTopKRequestSerializer
def perform_request(self, data):
mode = data.pop("mode")
application = self.get_authorized_application(data)
data_sources = [
TraceDatasourceTarget.build(
bk_biz_id=application.bk_biz_id,
app_name=application.app_name,
table_id=application.span_result_table_id,
)
]
handler = RumLevelHandlerFactory.create(mode, data_sources)
return handler.field_topk(
start_time=data["start_time"],
end_time=data["end_time"],
field=data["field"],
limit=data["limit"],
filters=data["filters"],
query_string=data["query_string"],
)- [1] Resource 使用已鉴权的
Application构造data_sources,请求参数不能指定结果表。 - [2] 除
generate_query_string外,所有 Resource 均按应用实例鉴权。 - [3]
generate_query_string只转换过滤条件,不查询业务数据,因此沿用 APM 的免实例鉴权规则。
请求序列化器按「应用上下文 → 时间范围 → 检索条件」单链继承,接口参数只在叶子声明:
FilterSerializer # 存储查询侧,value 收敛为字符串
└── QueryStringFilterSerializer # 查询串渲染侧,value 保留 JSON 原类型
BaseRumRequestSerializer # bk_biz_id、app_name、mode
├── RumGenerateQueryStringRequestSerializer
└── BaseRumTimeRangeSerializer # start_time、end_time
├── RumViewConfigRequestSerializer
└── BaseRumSearchSerializer # filters、query_string
└── 列表类与分析类接口叶子 # offset / limit / sort / fields / field- [4]
FilterSerializer将value元素转换为字符串,满足 UnifyQuery condition 协议。 - [5]
QueryStringFilterSerializer保留 JSON 数值和布尔类型,用于渲染查询串。 - [6] 分页数量、TopK 数量和枚举值数量语义不同,
limit只在叶子声明。 - [7]
record_detail不需要时间范围,直接继承BaseRumRequestSerializer。
f. Resource 接口
| URL | Resource | Level 方法 |
|---|---|---|
GET /rum/search/view_config/ | RumViewConfigResource | view_config |
POST /rum/search/generate_query_string/ | RumGenerateQueryStringResource | generate_query_string |
POST /rum/search/field_topk/ | RumFieldTopKResource | field_topk |
POST /rum/search/field_statistics_info/ | RumFieldStatisticsInfoResource | field_statistics_info |
POST /rum/search/field_statistics_graph/ | RumFieldStatisticsGraphResource | field_statistics_graph |
POST /rum/search/download_topk/ | RumDownloadTopKResource | download_topk |
POST /rum/search/get_fields_option_values/ | RumFieldsOptionValuesResource | get_fields_option_values |
POST /rum/search/list_records/ | RumRecordsResource | list_records |
POST /rum/search/record_detail/ | RumRecordDetailResource | record_detail |
g. semconv
原子字段只定义一次,再由 Attributes、Events 等复合字段组成 Trace 结构。SpanSpec 组织整棵字段树,并支持按完整路径查找。
1)代码结构
bkmonitor/
├── constants/
│ └── apm.py # CachedEnum
├── semconv/
│ ├── constants.py # 通用字段展示、单位、Span Kind 和状态枚举
│ └── rum/
│ ├── constants.py # RUM 字段枚举和 Span 类型默认列
│ ├── field.py # FieldSpec
│ ├── registry.py # FieldRegistry
│ ├── attributes/
│ │ ├── span_attributes.py # Span 根字段及 Span 语义字段
│ │ ├── action_attributes.py
│ │ └── *_attributes.py # 按 view、network 等语义分段
│ └── trace/
│ ├── __init__.py # SpanSpec
│ ├── resource.py
│ ├── status.py
│ ├── events.py
│ ├── attributes.py
│ └── links.py
└── packages/
└── rum_web/ # 消费 SpanSpec2)核心模型
@dataclass(frozen=True, slots=True)
class FieldSpec:
field_name: str
field_alias: str = ""
field_unit: str | None = None
field_type: str | None = None
field_display_type: str | None = None
is_real: bool = True
option_values: type[HasChoicesCachedEnum] | None = None
_full_field_name: str = field(default="", init=False, repr=False, compare=False)
def children(self) -> Iterator["FieldSpec"]:
return (
candidate
for name, candidate in vars(type(self)).items()
if name.isupper() and isinstance(candidate, FieldSpec)
)
def get_full_field_name(self) -> str:
return self._full_field_name or self.field_name
def bind(self, full_field_name: str) -> "FieldSpec":
bound = replace(self)
object.__setattr__(bound, "_full_field_name", full_field_name)
return bound
# semconv/rum/trace/events.py
class EventAttributes(FieldSpec):
CODE_LINENO = code_attributes.CODE_LINENO
class Events(FieldSpec):
NAME = event_attributes.NAME
ATTRIBUTES = EventAttributes(field_name="attributes")
# semconv/rum/registry.py
class FieldRegistry:
def __init__(self, root: FieldSpec) -> None:
self.originals: dict[str, FieldSpec] = {}
self.bound_fields: dict[str, FieldSpec] = {}
self._collect(root, parent_path="")
def _collect(self, field: FieldSpec, parent_path: str) -> None:
full_path = f"{parent_path}.{field.field_name}" if parent_path else field.field_name
if full_path:
if full_path in self.originals:
raise ValueError(f"字段路径重复注册: {full_path}")
self.originals[full_path] = field
self.bound_fields[full_path] = field.bind(full_path)
for child in field.children():
self._collect(child, parent_path=full_path)
def from_field(self, field_name: str) -> FieldSpec:
spec = self.bound_fields.get(field_name)
return spec if spec is not None else FieldSpec(field_name)
def fields(self) -> list[FieldSpec]:
return list(self.bound_fields.values())
# semconv/rum/trace/__init__.py
class SpanSpec(FieldSpec):
KIND = span_attributes.KIND
END_TIME = span_attributes.END_TIME
RESOURCE = Resource(field_name="resource")
STATUS = Status(field_name="status")
EVENTS = Events(field_name="events")
ATTRIBUTES = Attributes(field_name="attributes")
LINKS = Links(field_name="links")
@classmethod
def from_field(cls, field_name: str) -> FieldSpec:
return _SPAN_FIELDS.from_field(field_name)
@classmethod
def fields(cls) -> list[FieldSpec]:
return _SPAN_FIELDS.fields()
_SPAN_FIELDS = FieldRegistry(SpanSpec(field_name=""))FieldRegistry 为每条完整路径创建绑定副本,并保留原始 FieldSpec 引用。同一原始字段可以复用到多条路径,绑定副本通过 get_full_field_name() 返回当前路径。from_field() 返回绑定副本;未注册字段返回仅含输入字段名的 FieldSpec。is_real 默认取 True,只有虚拟字段显式设置为 False。
assert SpanSpec.from_field("kind").get_full_field_name() == "kind"
assert SpanSpec.from_field("events.name").get_full_field_name() == "events.name"3)消费边界
[1] bkmonitor/data_source/utils/query.py:
- 不再承担别名、候选值、单位的渲染。
- 移除 FIELD_ALIAS_MAP_LIST、FIELD_UNITS、ENUM_FIELD_OPTION_VALUES 及相应的消费逻辑。
[2] rum_web.handlers.query.span.SpanQuery.query_fields():
- 真实字段通过
SpanSpec.from_field(field_name)补充别名、单位、展示类型和枚举候选值。 - 虚拟字段从
SpanSpec.fields()统一读取,只注入is_real = False的字段。 - 字段名、映射键和
origin_field基于get_full_field_name()生成,不再维护独立的VIRTUAL_FIELDS。
h. Span Builder
建议收敛成 Handler 准备数据,Builder 组装协议。
SpanLevelHandler.record_detail(record_id)
├─ self.query.query_list(...) # 查询主 Span
├─ self._query_related_spans(span) # View 才补查生命周期和 Vital
└─ span_builder.build(span, related_spans)Builder 全部放在 packages/rum_web/handlers/builder/span/,接口骨架:
class SpanBuilder:
@classmethod
def process(
cls,
span: dict[str, Any],
related_spans: Sequence[dict[str, Any]] = (),
) -> dict[str, Any]:
... # 默认返回公共头部和空 sections
# 各类型继承 SpanBuilder,实现 process。
BUILDERS = {
"resource": ResourceSpanBuilder,
"action": ActionSpanBuilder,
"long_task": LongTaskSpanBuilder,
"error": ErrorSpanBuilder,
"vital": VitalSpanBuilder,
"view": ViewSpanBuilder,
}
def build(
span: dict[str, Any],
related_spans: Sequence[dict[str, Any]] = (),
) -> dict[str, Any]:
builder = BUILDERS.get(
get_value(span, "attributes.span_type"), SpanBuilder
)
return builder.process(span, related_spans)| 位置 | 职责 |
|---|---|
SpanLevelHandler.record_detail | 查询主 Span,处理记录不存在,调用 build |
SpanLevelHandler._query_related_spans | 使用 self.query.query_list 查询关联记录,返回平铺的 Span 列表 |
SpanBuilder.process | 组装 origin_data、span_id、overview、sections |
| 各类型 Builder | 按 PLAN.md 构造对应区块、评级配置及加载时序 |
_query_related_spans 仅对 View 执行:
| 关联记录 | 过滤条件 | 选取 |
|---|---|---|
| View 生命周期 | 同 View ID、span_type=view | 按 view.version 降序取最新一条 |
| Web Vitals | 同 View ID、span_type=vital | 每个指标按 end_time 降序取最新一条 |
| Session 生命周期 | 同 Session ID、span_type=session | start / rotate / end 全部保留,供结束判定 |
- [1] View 和 Vital 按全局唯一的 View ID 定位,不追加 Session 条件。Session 生命周期按主记录的
attributes.session.id另查。 - [2] 关联查询不指定时间边界,由查询层按保留期补齐窗口;其他类型返回空列表。
- [3]
ViewSpanBuilder用最新快照构造展示数据,origin_data、span_id保留主记录。 - [4] Builder 不持有 Query,不引入 Context;计数和影响面仍由
statistics获取。字段访问复用轻量取值函数,协议与计算公式直接遵循PLAN.md,不在草稿重复维护。 - [5] Session 关联结果只参与
display.view.close_state,不覆盖 View 快照字段,也不生成 Session 节点。
结束判定落点:
| 变更点 | 目标 |
|---|---|
[Add] display.view.close_state | ViewSpanBuilder._prepare_flatten_data 按 View 结束判定协议写入 |
[Add] semconv 虚拟字段 | display.view.close_state 进入字段目录,声明枚举别名 |
[Add] key_info.duration 副文案 | 前端按 close_state 映射「页面关闭」等标签,负停留展示为 0 |
[Keep] 时间线 end_time | 无 phase=end 时仍为 null,不用 15 min / 4 h 补造结束时刻 |
i. Timeline Builder
会话时间线沿用 Handler 准备数据,Builder 组装协议。
SpanLevelHandler.timeline(session_id | view_id)
├─ self.query.query_list(...) # 按查询范围查询全部 Span
└─ timeline_builder.build(spans, view_id)
└─ span_builder.build(span, related_spans) # 每个节点构造一条 record查询范围:
| 请求参数 | 过滤条件 |
|---|---|
session_id | attributes.session.id = {session_id} |
view_id | attributes.view.id = {view_id} AND attributes.span_type != session |
packages/rum_web/handlers/builder/timeline.py 接口骨架:
from rum_web.handlers.builder.span import build as build_span
@dataclass
class ViewGroup:
snapshots: list[dict[str, Any]] # 同 View ID 的 View 快照
vitals: list[dict[str, Any]] # 同 View ID 的 Vital
members: list[dict[str, Any]] # 同 View ID 的其余 Span,作为子节点候选
def build(spans: Sequence[dict[str, Any]], view_id: str | None = None) -> dict[str, Any]:
sessions, groups = _partition(spans)
tree, records = [], []
for group in sorted(groups, key=_started_at):
view = min(group.snapshots, key=_version)
records.append(build_span(view, [*group.snapshots, *group.vitals]))
records.extend(build_span(span) for span in group.members)
tree.append(_node(view, _attach(group.members)))
start_time, end_time = _view_period(groups) if view_id else _session_period(sessions)
return {"start_time": start_time, "end_time": end_time, "tree": tree, "records": records}起止时间:
| 查询范围 | 字段 | 过滤条件 | 取值 |
|---|---|---|---|
session_id | start_time | attributes.session.phase IN (start, rotate) | min(start_time) |
session_id | end_time | attributes.session.phase = end | max(end_time) |
view_id | start_time | attributes.span_type = view | min(attributes.view.started_at) × 1000 |
view_id | end_time | attributes.span_type = view AND attributes.view.phase = end | max(end_time) |
- [1] 无匹配的 Span 时返回
null。 - [2]
start、rotate的上报时刻晚于首个 View 的attributes.view.started_at约0.6 s~1.7 s,待 SDK 优化。
父子层级:
| 节点 | 父节点 | 判定规则 |
|---|---|---|
| View | -- | 按 attributes.view.id 去重,取 min(attributes.view.version) 的快照 |
| Action | View | attributes.view.id 相等,不因 attributes.action.id 挂到其他 Action |
| 非 Action 成员 | Action、View | 同一 View 内 attributes.action.id 命中 Action 时挂到该 Action,否则挂到 View |
- [1] View 按
attributes.view.started_at升序,子节点按start_time升序。 - [2] Vital、Session 不生成节点。
- [3]
attributes.view.id为空或所属 View 无快照的 Span 丢弃。
复用 Span Builder:两个入口调用同一个 build(span, related_spans),只是入参来源不同。
| 入口 | span | related_spans |
|---|---|---|
record_detail | query_detail(record_id) | _query_related_spans(span) 回查存储 |
timeline | 查询范围内的 Span | 同一结果按 attributes.view.id 切分,并把同 session.id 的 Session 生命周期 Span 一并传入 View 的 related_spans |
0x04 核心协议
a. view_config
{
"default_sort": ["-end_time"],
"span_type_display_fields": {"{span_type}": []},
"fields": [
{
"field_name": "span_name",
"field_alias": "Span 名称",
"field_type": "keyword",
"origin_field": "span_name",
"is_real": true,
"is_searchable": true,
"is_agg": true,
"is_list": true,
"supported_operations": []
},
{
"field_name": "end_time",
"field_alias": "结束时间",
"field_type": "long",
"field_unit": "us",
"field_display_type": "datetime",
"origin_field": "end_time",
"is_real": true,
"is_searchable": true,
"is_agg": true,
"is_list": true,
"supported_operations": []
},
{
"field_name": "elapsed_time",
"field_alias": "耗时",
"field_type": "long",
"field_unit": "us",
"field_display_type": "duration",
"origin_field": "elapsed_time",
"is_real": true,
"is_searchable": true,
"is_agg": true,
"is_list": true,
"supported_operations": []
},
{
"field_name": "attributes.span_type",
"field_alias": "Span 类型",
"field_type": "keyword",
"origin_field": "attributes",
"is_real": true,
"is_searchable": true,
"is_agg": true,
"is_list": true,
"supported_operations": [],
"option_values": [
{"value": "view", "alias": "视图"},
{"value": "resource", "alias": "资源加载"}
]
},
{
"field_name": "resource.user_agent.name",
"field_alias": "代理名称",
"field_type": "keyword",
"origin_field": "resource",
"is_real": true,
"is_searchable": true,
"is_agg": true,
"is_list": true,
"supported_operations": []
},
{
"field_name": "LCP",
"field_alias": "最大内容绘制",
"field_type": "double",
"field_unit": "ms",
"field_display_type": "duration",
"origin_field": "LCP",
"is_real": false,
"is_searchable": true,
"is_agg": true,
"is_list": false,
"supported_operations": [],
"rating_config": [
{"rating": "good", "value": 2500},
{"rating": "needs_improvement", "value": 4000},
{"rating": "poor"}
]
}
],
"groups": [
{
"name": "DEVICE_BROWSER",
"alias": "终端 & 浏览器",
"supported_span_types": ["resource", "action"],
"field_names": [
"resource.user_agent.name"
]
},
{
"name": "WEB_VITALS",
"alias": "网页指标(Web Vitals)",
"supported_span_types": ["vital"],
"field_names": [
"LCP"
]
}
],
"display_fields": [
"span_name",
"attributes.span_type",
"end_time",
"elapsed_time",
"status.code",
"attributes.view.url_template",
"attributes.user.id"
]
}- [1]
origin_field标识字段在原始记录中的来源,打平字段使用所属顶层对象名。 - [2]
is_real区分原始字段与计算、虚拟字段。 - [3]
field_unit是原始字段的可选上报单位,如us、ms、s。 - [4]
field_display_type是可选展示类型,支持datetime和duration;消费方根据field_unit换算原始值。 - [5]
groups[].supported_span_types与span_type_display_fields只在 Span 视图下返回。 - [6]
is_list = false的字段只用于分析和检索,不进入列表字段。 - [7]
option_values存在别名时,字段分析和候选值使用{alias}({value})展示,列表使用{alias}。 - [8]
rating_config按数组顺序匹配。value是包含性上界,单位沿用字段的field_unit;未设置value的末项承接剩余值。
b. RecordDetail(Resource)
0)通用
Resource loading_timing 优先用 attributes.resource.timing.*(相对 timeOrigin 的时间点)按 A→E 匹配瀑布图,阶段起点再换成相对 start_time 的偏移。start_time 或 response_end 缺失时,回退到旧 attributes.resource.*.start / duration(相对 entry.startTime 的偏移);旧偏移不能送进 TimingLayout。新区间 end <= start 时整段省略,不混用两套字段。
flowchart TD
G{"timing.start_time / response_end"}
G -- "缺任一" --> Legacy["_legacy_waterfall()"]
G -- "end <= start" --> Omit["omit loading_timing"]
G -- "有效" --> M["LAYOUTS.matches()"]
M --> A["CrossOriginLayout"]
M --> B["ServiceWorkerLayout"]
M --> C["LocalLayout"]
M --> D["ReusedLayout"]
M --> E["NetworkLayout"]
M -- "未命中" --> A| 类型 | 判断 | 输出 |
|---|---|---|
| A 跨域明细不可见 | request_start = 0 且 response_start = 0 且 decoded_body_size = 0 | 只输出 total |
| B Service Worker 响应 | worker_start > 0,connect_end ∈ {0, fetch_start},worker_matched_router_source ∈ {空, fetch-event},worker_final_router_source ≠ cache | 排队、SW 阶段、等待首字节、内容下载 |
| C 非网络响应 | connect_end ∈ {0, fetch_start},且缓存命中、推断缓存或 SW 静态路由缓存三者之一 | 发起前等待、等待首字节、内容下载 |
| D 连接复用 | domain_lookup_start = domain_lookup_end = connect_start = connect_end ≠ 0 | 与 E 同图;DNS / 连接建立 / TLS 以 0 耗时落在 connect_end |
| E 网络请求 | 未匹配 A–D,且 connect_start > 0 且 connect_end > 0 | 重定向、发起前等待、DNS、连接、TLS、等待首字节、内容下载 |
- [1] 匹配按 A→E 顺序。
ReusedLayout继承NetworkLayout,必须排在 E 之前。未命中任何类型时只展示总耗时。 - [2]
total_duration = response_end − start_time。阶段duration <= 0或乱序不输出;D 的三根 0 耗时条除外,供前端合并为连接复用。 - [3]
secure_connection_start落在[connect_start, connect_end]之外(含被钳到fetch_start)时不画 TLS,连接建立计算到connect_end。 - [4] 发起前等待:C 画到
fetch_start;D / E 画到domain_lookup_start,把 fetch 之后、DNS 之前的空隙算进这一段。 - [5] 有重定向时,B / C / D / E 都先输出
redirect,后一段从redirect_end起算;无重定向时从start_time起算。 - [6] B 在
response_start > 0时,respondWith与「等待首字节」同起止。
旧 SDK 回退仍用阶段字段:
| 指标 | 计算方式 |
|---|---|
total_duration | attributes.resource.download.start + attributes.resource.download.duration |
prepare | dns.start − redirect.start |
connect | TLS 字段有效时 connect.duration − ssl.duration,否则用完整 connect.duration;缺少 TLS 字段不能视为未发生握手 |
1)XHR & Fetch
{
"origin_data": {},
"span_id": "7d2f09b6f8bc31aa",
"overview": {
"title": "POST /api/orders",
"badges": [
{"field_name": "elapsed_time", "value": 128000},
{"field_name": "attributes.resource.type", "value": "fetch"},
{"field_name": "attributes.http.response.status_code", "value": 200}
],
"items": [
{
"field_name": "display.span_type",
"field_alias": "类型",
"value": "Resource(fetch)",
"alias": "Resource(fetch)"
},
{"field_name": "app_name", "value": "bkop"},
{"field_name": "attributes.view.url_template", "value": "/order/submit"},
{"field_name": "attributes.session.id", "value": "sess-001"},
{"field_name": "attributes.view.id", "value": "view-001"},
{"field_name": "start_time", "value": 1788451565200000},
{"field_name": "end_time", "value": 1788451565328000},
{"field_name": "attributes.user.id", "value": "user-001"},
{"field_name": "resource.deployment.environment.name", "value": "prod"}
]
},
"sections": [
{
"key": "key_info",
"type": "summary_cards",
"data": {
"request": {
"attributes.http.request.method": "POST",
"attributes.url.template": "/api/orders",
"attributes.url.full": "https://example.com/api/orders",
"attributes.server.address": "example.com"
},
"duration": {"elapsed_time": 128000},
"http_result": {
"attributes.http.response.status_code": 200,
"attributes.outcome.type": "success"
},
"transfer": {
"display.compression_ratio": 0.6295,
"attributes.resource.transfer_size": 3260,
"attributes.resource.encoded_body_size": 3120,
"attributes.resource.decoded_body_size": 8420,
}
}
},
{
"key": "loading_timing",
"type": "waterfall",
"data": {
"unit": "ms",
"total_duration": 100,
"phases": [
{"key": "redirect", "alias": "重定向", "start": 0, "duration": 10},
{"key": "prepare", "alias": "发起前等待", "start": 10, "duration": 5},
{"key": "dns", "alias": "DNS", "start": 15, "duration": 5},
{"key": "connect", "alias": "连接建立", "start": 20, "duration": 8},
{"key": "tls", "alias": "TLS", "start": 28, "duration": 7},
{"key": "first_byte", "alias": "等待首字节", "start": 35, "duration": 45},
{"key": "download", "alias": "内容下载", "start": 80, "duration": 20}
]
}
}
]
}2)others
{
"origin_data": {},
"span_id": "8e301ac709bd42bb",
"overview": {
"title": "db.svg",
"badges": [
{"field_name": "elapsed_time", "value": 60300},
{"field_name": "attributes.resource.type", "value": "img"},
{"field_name": "attributes.http.response.status_code", "value": 200}
],
// 和 1)XHR & Fetch 一致,省略。
"items": []
},
"sections": [
{
"key": "key_info",
"type": "summary_cards",
"data": {
"http_result": {
"attributes.http.response.status_code": 200,
"attributes.outcome.type": "success"
},
"duration": {"elapsed_time": 60300},
"transfer": {
"display.compression_ratio": 0.689,
"attributes.resource.transfer_size": 0,
"attributes.resource.encoded_body_size": 280833,
"attributes.resource.decoded_body_size": 903000
},
"delivery": {
"attributes.resource.delivery_type": "cache",
"attributes.resource.cache.hit": true
},
"blocking": {
"attributes.resource.render_blocking_status": "non-blocking"
}
}
},
{
"key": "resource_info",
"type": "summary_cards",
"items": [
{"field_name": "attributes.resource.type", "value": "img"},
{"field_name": "attributes.url.template", "value": "/static/img/db.svg"},
{
"field_name": "attributes.server.address",
"value": "rum.example.com"
},
{"field_name": "attributes.http.request.method", "value": "GET"},
{"field_name": "attributes.resource.protocol", "value": "h2"}
]
},
// 同 1)XHR & Fetch,不复述。
{"key": "loading_timing", "type": "waterfall", "data": {}}
]
}c. RecordDetail(Action)
{
"origin_data": {},
"span_id": "e121536e5ae785a0",
"overview": {
"title": "click .submit-btn",
"badges": [
{"field_name": "elapsed_time", "value": 432000},
{"field_name": "attributes.action.type", "value": "click"},
{"field_name": "attributes.outcome.type", "value": "warning"}
],
// 省略,同 Resource。
"items": []
},
"sections": [
{
"key": "key_info",
"type": "summary_cards",
"data": {
"interaction": {"attributes.action.type": "click"},
"target": {
"attributes.action.target.name": ".submit-btn",
"attributes.action.target.tag": "button"
}
}
}
]
}需异步拉取的「关键信息」:
| 关联信息 | API | 额外过滤条件 | 统计 |
|---|---|---|---|
| 触发请求数 | statistics | attributes.span_type: resource | count(attributes.span_type) |
| 错误数 | statistics | attributes.span_type: error | count(attributes.span_type) |
| LongTasks 数 | statistics | attributes.span_type: long_task | count(attributes.span_type) |
- [1] 统一过滤条件:
attributes.action.id: {当前 Action ID}。 - [2] 查询时间范围:[end_time - 1d, end_time + 1d]。
d. RecordDetail(LongTask)
{
"origin_data": {},
"span_id": "c49ddfc2ac5214d7",
"overview": {
"title": "longTask",
"badges": [
{"field_name": "elapsed_time", "value": 123500},
{"field_name": "attributes.outcome.type", "value": "warning"}
],
// 省略,同 Resource。
"items": []
},
"sections": [
{
"key": "key_info",
"type": "summary_cards",
"data": {
"duration": {
"elapsed_time": 123500,
"attributes.long_task.blocking_duration": 42.5
},
"action": {"attributes.action.id": "xxxxxx"},
"attribution": {
"attributes.long_task.entry_type": "long-animation-frame",
"attributes.long_task.name": "long-animation-frame",
}
}
}
]
}[1] 根据 attributes.action.id 异步补齐名称和时间。
异步拉取的「关键信息」:
| 关联信息 | API | 过滤条件 |
|---|---|---|
| 关联 Action | list_records | attributes.span_type: action AND attributes.action.id: xxx |
e. RecordDetail(Error)
{
"origin_data": {},
"span_id": "9d199175096474e4",
"overview": {
"title": "TypeError: Cannot read properties of undefined (reading 'name')",
"badges": [
{"field_name": "attributes.outcome.type", "value": "error"}
],
// 省略,同 Resource。
"items": []
},
"sections": [
{
"key": "key_info",
"type": "summary_cards",
"data": {
"error_type": {"events.attributes.exception.type": "TypeError"},
"source": {
"attributes.code.filepath": "https://example.com/static/js/app.js",
"attributes.code.lineno": 9,
"attributes.code.column": 654249
}
}
}
]
}[1] Error Span 是瞬时事件,elapsed_time 恒为 0,概览不展示耗时徽标。
异步拉取的「关键信息」「影响面统计」:
| 关联信息 | API | 聚合 | 额外 |
|---|---|---|---|
| 影响用户数 | statistics | distinct(attributes.user.id) | time_shifts=["0s", "{end - start}s"](计算环比) |
| 发生次数 | statistics | count(attributes.span_type) | |
| 影响会话数 | statistics | distinct(attributes.session.id) | |
| 24 小时趋势 | statistics | count(attributes.span_type) | interval=3600(按 1h 分桶) |
- [1] 统一过滤条件:
attributes.code.lineno: "xx" AND attributes.code.filepath: "xxx" AND attributes.code.column: "xxx" AND events.attributes.exception.type: "xxx" AND attributes.span_type: "error"。 - [2] 影响面统计查询时间范围:[end_time - 1d, end_time + 1d]。
- [3] 24 小时趋势查询时间范围:[end_time - 1d, end_time]。
f. RecordDetail(Vital)
{
"origin_data": {},
"span_id": "ea1ae6490e17fd9d",
"overview": {
"title": "LCP",
"badges": [
{"field_name": "attributes.vital.value", "value": 2840},
{
"field_name": "display.rating_level",
"value": "needs_improvement",
"alias": "需改进"
}
],
// 省略,同 Resource。
"items": []
},
"sections": [
{
"key": "vital_rating",
"type": "rating_bar",
"data": {
"attributes.vital.metric": "lcp",
"attributes.vital.value": 2840,
"display.rating_config": [
{"rating": "good", "value": 2500, "alias": "良好"},
{"rating": "needs_improvement", "value": 4000, "alias": "需改进"},
{"rating": "poor", "alias": "差"}
]
}
},
]
}[1] Vital Span 是瞬时快照,elapsed_time 恒为 0,概览徽标使用指标值 attributes.vital.value。
g. RecordDetail(View)
加载时序计算方式:
| 指标 | 计算方式 |
|---|---|
total_duration | attributes.view.loading_time |
prepare.start | 0 |
prepare.duration | attributes.vital.ttfb.waiting_duration |
dns.start | connect.start − dns.duration |
dns.duration | attributes.vital.ttfb.dns_duration |
connect.start | first_byte.start − connect.duration |
connect.duration | attributes.vital.ttfb.connection_duration |
first_byte.start | attributes.vital.value − first_byte.duration |
first_byte.duration | attributes.vital.ttfb.request_duration |
dom_processing.start | attributes.view.first_byte |
dom_processing.duration | attributes.view.dom_content_loaded − attributes.view.first_byte [1] |
resource_load.start | attributes.view.dom_content_loaded |
resource_load.duration | attributes.view.load_event − attributes.view.dom_content_loaded [1] |
page_stable.start | attributes.view.load_event |
page_stable.duration | max(0, attributes.view.loading_time − attributes.view.load_event) [2] |
markers[].value | 同名 Vital 的 attributes.vital.value |
markers[].display.rating_config | 同名虚拟字段的 rating_config,与 web_vitals 卡片一致 |
milestones.dom_complete | attributes.view.dom_complete |
milestones.load_event | attributes.view.load_event |
milestones.page_stable | attributes.view.loading_time |
- [1] 内容传输与 DOM 处理包含 HTML 接收、解析、阻塞脚本和 DOMContentLoaded 事件处理,剩余资源加载包含其后至 Load 完成的资源等待、脚本及事件处理,均为加载进程区间,不是纯下载或解析耗时。
- [2] 自动计时的 Loading Time 不早于 Load,
max(0, …)只消除浮点误差产生的微小负值。
{
"origin_data": {},
"span_id": "34e3b6ff1943346c",
"overview": {
"title": "view-001",
"badges": [{"field_name": "display.view.duration", "field_unit": "ms", "value": 60300}],
"items": [
{
"field_name": "display.span_type",
"field_alias": "类型",
"value": "View",
"alias": "View"
},
{"field_name": "app_name", "value": "bkop"},
{"field_name": "attributes.view.url_template", "value": "/order/submit"},
{
"field_name": "attributes.view.previous_url_template",
"value": "/product/:id/"
},
{"field_name": "attributes.session.id", "value": "sess-001"},
{"field_name": "attributes.view.id", "value": "view-001"},
{"field_name": "start_time", "value": 1788451565200000},
{"field_name": "end_time", "value": 1788451625500000},
{"field_name": "attributes.user.id", "value": "user-001"},
{"field_name": "resource.deployment.environment.name", "value": "prod"}
]
},
"sections": [
{
"key": "key_info",
"type": "summary_cards",
"data": {
"duration": {"display.view.duration": 60300},
"loading": {
"attributes.view.loading_time": 200,
"attributes.view.loading_time_source": "auto",
"attributes.view.loading_type": "initial_load",
"attributes.view.first_byte": 101.7,
"attributes.view.dom_content_loaded": 140,
"attributes.view.load_event": 175
}
}
},
{
"key": "web_vitals",
"type": "summary_cards",
"data": {
"ttfb": {
"attributes.vital.metric": "ttfb",
"attributes.vital.value": 101.7,
"attributes.vital.ttfb.waiting_duration": 1.2,
"attributes.vital.ttfb.dns_duration": 3.8,
"attributes.vital.ttfb.connection_duration": 10,
"attributes.vital.ttfb.request_duration": 83.7,
"display.rating_config": [
{"rating": "good", "value": 800, "alias": "良好"},
{"rating": "needs_improvement", "value": 1800, "alias": "需改进"},
{"rating": "poor", "alias": "差"}
]
},
"fcp": {
"attributes.vital.metric": "fcp",
"attributes.vital.value": 120,
"display.rating_config": [
{"rating": "good", "value": 1800, "alias": "良好"},
{"rating": "needs_improvement", "value": 3000, "alias": "需改进"},
{"rating": "poor", "alias": "差"}
]
},
"lcp": {
"attributes.vital.metric": "lcp",
"attributes.vital.value": 250,
"display.rating_config": [
{"rating": "good", "value": 2500, "alias": "良好"},
{"rating": "needs_improvement", "value": 4000, "alias": "需改进"},
{"rating": "poor", "alias": "差"}
]
},
"inp": {
"attributes.vital.metric": "inp",
"attributes.vital.value": 86,
"display.rating_config": [
{"rating": "good", "value": 200, "alias": "良好"},
{"rating": "needs_improvement", "value": 500, "alias": "需改进"},
{"rating": "poor", "alias": "差"}
]
},
"cls": {
"attributes.vital.metric": "cls",
"attributes.vital.value": 0.023,
"display.rating_config": [
{"rating": "good", "value": 0.1, "alias": "良好"},
{"rating": "needs_improvement", "value": 0.25, "alias": "需改进"},
{"rating": "poor", "alias": "差"}
]
}
}
},
{
"key": "loading_timing",
"type": "waterfall",
"data": {
"unit": "ms",
"total_duration": 200,
"phases": [
{
"key": "prepare",
"alias": "浏览器准备",
"start": 0,
"duration": 1.2
},
{
"key": "dns",
"alias": "DNS 查询",
"start": 4.2,
"duration": 3.8
},
{
"key": "connect",
"alias": "网络连接建立",
"start": 8,
"duration": 10
},
{
"key": "first_byte",
"alias": "等待首字节",
"start": 18,
"duration": 83.7
},
{
"key": "dom_processing",
"alias": "内容传输与 DOM 处理",
"start": 101.7,
"duration": 38.3
},
{
"key": "resource_load",
"alias": "剩余资源加载",
"start": 140,
"duration": 35
},
{
"key": "page_stable",
"alias": "页面趋于稳定",
"start": 175,
"duration": 25
}
],
"markers": [
{
"key": "TTFB",
"field_name": "TTFB",
"value": 101.7,
"display.rating_config": [
{"rating": "good", "value": 800, "alias": "良好"},
{"rating": "needs_improvement", "value": 1800, "alias": "需改进"},
{"rating": "poor", "alias": "差"}
]
},
{
"key": "FCP",
"field_name": "FCP",
"value": 120,
"display.rating_config": [
{"rating": "good", "value": 1800, "alias": "良好"},
{"rating": "needs_improvement", "value": 3000, "alias": "需改进"},
{"rating": "poor", "alias": "差"}
]
},
{
"key": "LCP",
"field_name": "LCP",
"value": 250,
"display.rating_config": [
{"rating": "good", "value": 2500, "alias": "良好"},
{"rating": "needs_improvement", "value": 4000, "alias": "需改进"},
{"rating": "poor", "alias": "差"}
]
}
],
"milestones": [
{"key": "dom_complete", "field_name": "attributes.view.dom_complete", "value": 170},
{"key": "load_event", "field_name": "attributes.view.load_event", "value": 175},
{"key": "page_stable", "field_name": "attributes.view.loading_time", "value": 200}
]
}
}
]
}- [1]
loading_timing只在首次加载(attributes.view.loading_type = initial_load)时返回,路由切换等非首次加载没有导航计时,不返回该区块。 - [2]
phases、markers和milestones在字段缺失或非法时省略对应项,不用相邻时间点补造。 - [3]
phases和milestones中的page_stable只在attributes.view.loading_time_source = auto时返回,业务手动上报的 Loading Time 可能早于 Load,不代表页面稳定。
异步拉取的「关键信息」:
| 关联信息 | API | 额外 |
|---|---|---|
| 触发请求数 | statistics | attributes.span_type: resource AND attributes.resource.type: (xhr OR fetch) |
| 错误数 | statistics | attributes.span_type: error |
| Span 数 | statistics | NOT attributes.span_type: (view OR vital OR session) |
| 动作数 | statistics | attributes.span_type: action |
- [1] 统一过滤条件:
attributes.view.id: {当前 View ID};应用及 Session 与主记录一致。 - [2] 统一聚合方式:
count(attributes.span_type)。 - [3] 查询时间范围:[end_time - 1d, end_time + 1d]。
- [4] 除
view、session外,其他 Span 类型均可通过attributes.span_type等值过滤,统计当前 View 下对应类型的数量。
View Span 关联信息获取(后端):
| 补齐信息 | 额外过滤条件 | 补齐字段 |
|---|---|---|
| View 生命周期 | attributes.span_type: view AND attributes.view.id: {当前 View ID} | [a] 头部时间:end_time 和 display.view.duration[b] 加载字段: attributes.view 下的 loading_time、loading_time_source、loading_type、first_byte、dom_content_loaded、dom_complete 和 load_event |
| Web Vitals | attributes.span_type: vital AND attributes.view.id: {当前 View ID} | TTFB 四段耗时,以及 FCP、LCP、INP 和 CLS 的 attributes.vital.value |
| Session 生命周期 | attributes.span_type: session AND attributes.session.id: {当前 Session ID} | display.view.close_state 所需的 start / rotate / end 时刻 |
- [1]
origin_data和span_id保留主记录,补齐字段只覆盖展示值。 - [2] View 生命周期 Span 是瞬时快照,
start_time等于上报时刻,elapsed_time恒为0,因此概览徽标使用display.view.duration,通过field_unit声明毫秒,由前端格式化。 - [3] 概览
start_time取attributes.view.started_at(毫秒)换算的微秒值,display.view.duration为最新快照end_time与导航开始的差值,未取得phase=end时仅表示已观测时长。结束语义见 View 结束判定,不从停留时长反推。
h. Hover
record_detail 在加载阶段、里程碑、Web Vitals 和评级对象中返回统一的 hover 元数据:
{
"hover": {
"title": "LCP(最大内容绘制)",
"description": "从打开页面到视窗内最大的图片、文本块或视频画面渲染完成的时间。",
"user_experience": "页面主要内容已经出现,用户能够判断页面内容。",
"source": {
"label": "web.dev · Largest Contentful Paint",
"url": "https://web.dev/articles/lcp"
}
}
}| 字段 | 类型 | 必填 | 说明 |
|---|---|---|---|
hover.title | string | 是 | Hover 标题。 |
hover.description | string | 是 | 专业解释,不包含计算方式、公式或原始字段表达式。 |
hover.user_experience | string | 否 | 用户可感知的页面表现。 |
hover.source | object | 否 | 参考来源,见 source 子结构。 |
hover.source.label | string | 是 | 来源名称。 |
hover.source.url | string | 是 | 官方定义或规范链接。 |
覆盖范围:
| 对象 | Hover 落点 | 约束 |
|---|---|---|
| Resource 加载阶段 | sections.loading_timing.data.phases[].hover | [a] 按语义键取值 [b] prepare 是 C / D / E 共用文案,用「可能包含」覆盖缓存、连接复用等场景,不按单条色条改写 |
| View 加载阶段 | sections.loading_timing.data.phases[].hover | 阶段缺失时随阶段一起省略。 |
| View 指标标记 | sections.loading_timing.data.markers[].hover | TTFB、FCP、LCP 与 web_vitals 使用同一指标定义。 |
| View 累计里程碑 | sections.loading_timing.data.milestones[].hover | 覆盖 DOM Complete、Load Event 和页面稳定。 |
| Web Vital | sections.web_vitals.data.<metric>.hover、sections.vital_rating.data.hover | TTFB、FCP、LCP、INP 和 CLS 共用定义。 |
| Web Vital 评级 | display.rating_config[].hover | good、needs_improvement 和 poor 共用评级定义。 |
Builder 在 packages/rum_web/handlers/builder/hover.py 维护静态定义。Resource、View、Vital Builder 只按语义键取值, 不在各 Builder 复制文案。Timeline 继续调用 span_builder.build();View record 直接复用 sections.web_vitals 和 sections.loading_timing,不重算 Hover。
i. View 结束判定
页面关闭只认 view.end_reason=pagehide。View 结束和 Session 过期是两条生命周期,产品用 display.view.close_state 同时表达关页、超时和未见结束。
SDK 把同一 view.id 拆成 start / update / end 瞬时快照。start 的上报时刻等于 view.started_at,display.view.duration 就是 0(毫秒进微秒时还可能得到 -0.001 ms)。关页、无活动超时和满生命周期轮转都会让这条 View 停下,不能靠停留时长区分。
| 机制 | SDK 常量 / 字段 | 行为 |
|---|---|---|
View start | emitViewEvent(view, 'start', startedAt) | Span 起止同一时刻,停留派生为 0 |
| 关页 | view.end_reason=pagehide | pagehide 结束当前 View,并 flush |
| 往返缓存 | view.end_reason=bfcache | pagehide.persisted=true 时结束当前 View |
| 路由切换 | view.end_reason 为 pushState、popstate、load、manual 等 | 创建新 View 前结束旧 View |
| 无活动超时 | SESSION_IDLE_TIMEOUT_MS = 15 min | 交互续期,到期发 session.phase=end,并结束 View,end_reason=session_expired |
| 最长生命周期 | SESSION_MAX_DURATION_MS = 4 h | 自 Session 创建起算,到期同样 session_expired,优先于无活动 |
| 关页后的 Session | localStorage 仍为 active | 关页不发 session.phase=end。15 min 内回来续同一个 Session,超时后再发 rotate |
过期原因 inactivity / maxLifetime 只存在于 SDK 内部 notifySessionLifecycle,不上报为 Span Attribute。产品用 Session 起止间隔回推:达到 4 h 判满生命周期,其余 session_expired 判无活动。
flowchart LR
latest["latest_view.phase"]
latest -- "end" --> reason["view.end_reason"]
latest -- "start / update" --> idle{"idle window"}
reason -- "pagehide" --> page_closed["page_closed"]
reason -- "bfcache" --> bf_cache["bf_cache"]
reason -- "shutdown" --> shutdown["shutdown"]
reason -- "session_expired" --> life{"lifetime"}
reason -- "other" --> route_changed["route_changed"]
life -- "ge MAX - slack" --> session_max_lifetime["session_max_lifetime"]
life -- "else" --> session_idle["session_idle"]
idle -- "within IDLE" --> open["open"]
idle -- "else" --> unobserved_end["unobserved_end"]clock = min(request_end_time, server_now)
last_snapshot = max(view / 同 View 成员 Span 的 end_time)
session_start = min(session.phase in {start, rotate}.start_time)
session_end = max(session.phase=end.end_time) 或 view.end 时刻
lifetime = session_end - session_start
slack = 5 s # 与 SDK FUTURE_CLOCK_TOLERANCE_MS 对齐
IDLE = 15 min
MAX = 4 hdisplay.view.close_state 取值:
| 值 | 条件 | 页面文案 |
|---|---|---|
page_closed | 最新快照 phase=end 且 end_reason=pagehide | 页面关闭 |
bf_cache | end_reason=bfcache | 进入往返缓存 |
route_changed | phase=end,且 end_reason 不是 pagehide、bfcache、session_expired、shutdown | 路由切换 |
session_idle | end_reason=session_expired,且 lifetime < MAX - slack | 无活动超时 |
session_max_lifetime | end_reason=session_expired,且 lifetime >= MAX - slack | 会话已满 4 小时 |
shutdown | end_reason=shutdown | SDK 关闭 |
open | 无 phase=end,且 clock - last_snapshot < IDLE | 进行中 |
unobserved_end | 无 phase=end,且已超出 IDLE | 未见结束上报 |
{
"key": "key_info",
"type": "summary_cards",
"data": {
"duration": {
"display.view.duration": 0,
"display.view.close_state": "page_closed"
}
}
}| 字段 | 类型 | 必填 | 说明 |
|---|---|---|---|
display.view.duration | number | 否 | [a] 最新快照 end_time 减 started_at,单位 ms[b] 展示层把负值收成 0,不改快照时间 |
display.view.close_state | enum | 是 | [a] 有 phase=end 时只认 end_reason[b] 缺 Session 起止时, session_expired 回退为 session_idle |
展示落点是停留时长卡片副文案和会话时间线 View 节点右缘。page_closed 且停留为 0 时文案是「页面关闭」,数值仍为 0。时间线无 phase=end 时 end_time 仍为 null,close_state 只改变标签。
关页不等于 Session 结束。同一 Session 里可以先出现 page_closed 的 View,Session 仍是进行中,直到 15 min 无活动或满 4 h。unobserved_end 不写成「页面关闭」:可能是信标丢失,也可能是查询窗口没覆盖到 end 快照。
0x05 验收与验证
查询测试位于 packages/rum_web/tests/query/,通用模块测试跟随 core/ 和 semconv/:
| 测试 | 核心断言 |
|---|---|
test_datasource_target.py | [a] levels 默认空列表[b] 现有 TraceDatasourceTarget.build() 行为不变[c] 可携带多个层级结果表 |
test_layer_query.py | [a] 字段集合与保留表及确认补充一致,目录不访问 UQ [b] 分析阶段和折叠分页正确 [c] 非生命周期事件可命中列表 [d] 当前页批量补查不继承筛选,聚合查询沿用基类异常处理 [e] 层级表切换、布尔值和缺失指标保持协议 [f] Resource 到 UQ 编译链路与共享表应用隔离 [g] 挫败类型数组只用于筛选,按 Action 记录计数,无命中为零 |
test_level_factory.py | [a] 合法 mode 返回对应 Level[b] 未注册模式明确失败 [c] data_sources 原样传入 Level |
test_query.py | [a] Query 复用 BaseQuery[b] 接收 list[TraceDatasourceTarget][c] 具备 8 项原子能力 |
semconv/rum/tests/test_span_spec.py | [a] field_alias 默认空串,is_real 默认 true[b] 同一原始 FieldSpec 可注册到多条路径,重复路径明确失败[c] from_field() 返回携带完整路径的绑定副本[d] fields() 返回全部已注册字段[e] 枚举保留 label 和 choices()[f] Web Vitals 评级阈值使用字段单位,末项省略 value 并兜底[g] 未知路径返回仅含原始字段名的 FieldSpec |
packages/rum_web/tests/query/test_span_query.py | [a] 真实字段从 SpanSpec 补充语义[b] 虚拟字段从 SpanSpec.fields() 注入并使用完整路径[c] 别名按语义配置、数据源值、完整字段名依次回退 [d] 评级末档省略空 value[e] 数据源已有同名字段时不重复注入 |
test_level_handler.py | [a] 基类保存 data_sources 并提供通用查询响应实现[b] 具体 Level 可组合多个 Query [c] TopK 方法只接收单个字段 [d] 9 项公共方法声明参数与返回类型 [e] 未知配置被拒绝,且不能覆盖公共参数或数据源 |
test_query_resources.py | [a] 9 个 URL、HTTP 方法、Resource 和 Level 方法一一对应 [b] Resource 不依赖公共基类 [c] 请求协议不接受 extra_config,客户端无法覆盖 Level 配置[d] view_config 保留 origin_field,顶层维护全量字段,分组通过字段名引用[e] Span 视图返回按类型配置的默认列与分组适用范围 [f] semconv 补充别名、单位、展示类型、枚举选项和评级阈值 |
| Span 详情验收(待实现) | [a] 五类详情复用字段元数据 [b] Long Task 保留上报的阻塞贡献 [c] Vital 与 Error 概览不使用 Span 耗时,Vital 徽标取指标值,CLS 无单位 [d] View 开始时间与停留时长取 attributes.view.started_at,结果不随点开的快照变化[e] 非 View 类型不触发关联查询,记录不存在时明确失败 |
| Resource 加载时序验收(待实现) | [a] 有 resource.timing.start_time 与 response_end 时按 A–E 匹配,缺一则回退旧阶段字段[b] 新区间非法时整段省略,不混用旧偏移 [c] 有重定向时 B/C/D/E 先输出 redirect,发起前等待从 redirect_end 起算[d] D/E 的发起前等待画到 domain_lookup_start;连接复用输出 0 耗时 DNS/连接/TLS[e] total_duration = response_end − start_time,乱序或非正耗时不画条 |
| View 加载时序验收(待实现) | [a] 前四段复用同条 TTFB 耗时,连接不拆 TLS,倒推起点保留空隙 [b] View 三段按首字节、DOMContentLoaded、Load 和自动 Loading Time 的边界计算并返回到 phases[c] markers 仅包含 TTFB、FCP、LCP,评级阈值与 web_vitals 卡片一致,缺失指标不补造[d] 非首次加载不返回 loading_timing,原点不一致、非法数值和倒序边界不组装伪时序,手动 Loading Time 不生成 phases 和 milestones 中的 page_stable,自动计时的微小负耗时按 0 处理[e] total_duration 等于有效的 view.loading_time,缺失或非法时省略,有效零值保留,标记超出总耗时仅扩展横轴[f] milestones 依次取 dom_complete、load_event 和 loading_time,不返回别名,缺失字段直接省略 |
| Hover 协议验收(待实现) | [a] Resource、View、Vital 共用 title、description、user_experience、source 结构[b] 可选字段按内容省略 [c] 响应不包含 formula、calculation 或原始字段表达式[d] 指标标记与 Web Vital 卡片共用定义,View 与 Vital 详情共用评级定义 |
| 异步分析验收(待实现) | [a] 基础详情不等待关联聚合 [b] 未加载、失败与统计为零可区分 [c] 应用、关联 ID 和时间边界一致,记录去重 [d] 区块合并保留基础字段,切换记录后丢弃旧响应 |
| 会话时间线验收(待实现) | [a] 每个 View ID 只生成一个 View 节点,records 与同 span_id 的 record_detail 一致,包含相同 Hover[b] 同一 View 内,非 Action 成员在 action.id 命中 Action 时挂到该 Action,未命中时挂到 View;跨 View 的成员留在自身 View[c] Action 保持 View 的直接子节点 [d] Vital 和 Session 不生成节点,无 phase=end 记录时 end_time 为 null[e] 缺少 View ID 或所属 View 无快照的 Span 不进入时间线 [f] 按 view_id 查询时只返回一个 View 节点,起止时间取该 View,无 view.phase=end 快照时 end_time 为 null |
| View 结束判定验收(待实现) | [a] pagehide 且停留 0 返回 page_closed,负停留展示为 0[b] 无 phase=end 且在 15 min 内为 open,超出为 unobserved_end,时间线 end_time 仍为 null[c] session_expired 按 Session 寿命区分 session_idle 与 session_max_lifetime,缺起止时回退 session_idle[d] page_closed 不把 Session 标成已结束 |
bkmonitor/data_source/tests/test_utils_query.py | [a] 多表字段元数据合并 [b] 字段检索能力判断 [c] 保留期窗口与秒毫秒转换 |
测试门禁:
pytest semconv/rum/tests -q
pytest packages/rum_web/tests/query -q
pytest bkmonitor/data_source/tests/test_utils_query.py -q2026-10-28 扩展回归记录:apm/tests/test_discovery_group_query.py 因缺失迁移父节点 monitor_web.0077_collectorpluginmeta_id 无法初始化测试数据库;apm/tests/test_trace_query_es_batch.py 的既有查询返回 is_es_batch=false,与断言不符。两处实现未由本次变更修改,未计入通过数。
定向验证覆盖 semconv/rum/tests、rum_web/tests/query、rum_web/tests/builder 和 test_utils_query.py,当前共 617 项通过。真实后端联调已验证 Session/View 时间字段、候选值、详情两种 ID 入口一致性和 QueryBuilder 等值过滤简写;浏览器已验证列表展示、ID 首列、类型选择隐藏及候选项加载。
2026-10-29 整体类型复查覆盖本次涉及的 16 个生产文件和 5 个测试文件。新增与功能变更代码无类型诊断,纯平移方法按原文比对,保留以下既有诊断。没有为消除提示改写纯平移方法或关闭检查。
| 诊断归属 | 数量 | 处理 |
|---|---|---|
| 纯平移的原方法 | 21 | 保持原文,Query 9 个、Level 12 个方法逐字比对通过 |
| Resource 原有方法签名 | 10 | 与原签名一致,保留既有基类覆盖类型诊断 |
| 未修改的原测试方法 | 8 | 方法源码与原文一致,保留既有类型诊断 |
| 新增及功能变更代码 | 0 | 补齐类型契约和数据收窄,未使用强转或忽略标记消除诊断 |
分组聚合直接引用指标表达式,避免外层 MIN / MAX 丢失 Session/View ID;编译层与真实 UQ 查询均已核对。补查聚合直接复用 _statistics 的时间窗口、表达式、结果合并和数值格式化,异常也沿用基类现有处理,不增加严格模式或单独的聚合执行层。
0x06 实施进展
| 时间 | 结论性进展 |
|---|---|
2026-10-29 | [a] 复查 Session/View 列表最小适配、常驻过滤、候选值和详情复用,时间线由 Resource 直接调用 Span Handler,基类不再声明占位能力 [b] 整理 import 分组、复用记录 ID 扩展点,消除 Session Builder 对主记录的重复打平, 617 项回归、前端 6 文件 ESLint 与相关详情类型检查通过[c] 确认 PreCI 失败源于任务 worktree 错误启用语言规则,按指定配置仅保留并启用 standard_scc、opensource_sensitive_other,实际扫描本次 29 个文件无问题,全部提交 hook 通过[d] GTM 已提交并推送 ca31392f354be0e849245dc891209d9585a933a5,PR #13018 已更新 |
2026-10-29 | [a] 从最新 upstream/master 创建独立分支并交付 PR #13018,提交 234d979a56a5436c5aff9b583e8b5406c67c8134[b] 收回多字段 TopK 的额外异常传播改动,更新统计异常处理文档 [c] 独立 worktree 查询测试 332 passed,提交时敏感信息扫描、Ruff、格式和提交说明检查通过;真实 UQ 联调未执行,本地 PreCI 未安装,远端检查状态以 PR 为准 |
2026-10-29 | [a] 按用户要求直接复用基类 _statistics,聚合异常沿用现有处理[b] 删除新增的 _query_aggregated_records 及 fallback 重复统计逻辑,Builder 保留过滤与排序,聚合参数遵循基类接口[c] 查询模块 332 项回归、Ruff 和相关类型检查通过 |
2026-10-29 | [a] 按用户确认,将补查的过滤、指标、别名、分组和排序统一为 QueryConfigBuilder,任务只接收完整查询模板,移除无调用方的 query_group_values 包装[b] 保留时间窗口、MIN / MAX、多表查询、应用隔离和数值处理;改造前后 8 组场景的请求(统一内部引用别名后)及回填结果一致[c] 查询模块 332 项回归、Ruff 和 fallback 三个 Query 文件类型检查通过,并为 QueryConfigBuilder 补充实际 QueryConfig 类型声明,避免 IDE 误判 metric 属性[d] 移除补查不再使用的 raise_on_error 参数及执行分支,BaseQuery._statistics 与 HEAD 原方法逐字一致,查询模块 332 项回归通过[e] 从基类聚合方法抽出 _query_aggregated_records,标量聚合、分组聚合与 fallback 共用执行逻辑,移除 fallback 重复配置查询集的代码,332 项回归及 8 组基线对比通过 |
2026-10-29 | [a] 按用户要求将本轮范围收敛为代码规范与重复逻辑清理,撤回统计线程异常、TopK 兜底及区间校验的行为改动 [b] 删除 fallback 分页上限及重复参数校验,复用过滤器已有的 EXISTS 处理,字段语义只应用一次 [c] 合并指标与记录补查的单次转调,分别命名为 _build_metric_task 和 _build_record_task;类型、任务构建方法及变量统一使用 Task / task 命名,内联仅调用一次的生命周期过滤助手,AST 比对确认仅重命名和原表达式内联[d] 查询模块 324 项回归、Ruff 及 fallback 三个 Query 文件类型检查通过 |
2026-10-29 09:44 | [a] 整体复查生产代码和测试,新增及功能变更代码无类型诊断,纯平移方法原文保持一致 [b] fallback 任务直接产出按 ID 组织的回填字段,移除任务名分支和并行字段映射,统一 ID 收窄与结果投影 [c] 修正 MIN / MAX 表达式引用名,避免单窗口补查嵌套线程池, 591 项回归通过 |
2026-10-29 02:11 | [a] 按用户建议新增 Session / View 专用父类 BaseFallbackRumQuery,移除模块函数传入 self、跨模块私有调用和任务闭包[b] 为 Span Level 声明具体 Query 类型;新父类及两类 Query 静态检查无错误,本地 579 项回归通过[c] Span 时间线原有 Optional ID 类型提示仍在,未改动原方法 |
2026-10-29 | [a] 按迁移前源码复核,Query 的 9 个、Level 的 12 个无功能变更方法逐字平移,恢复额外注解、时间线空串改写及遗漏注释[b] 字段目录兜底、层级字段与分组适配、共享表隔离和补查统计错误处理作为功能变更单独保留,不纳入纯平移结论 |
2026-10-29 01:53 | [a] 移除 entity 继承层,Span / Session / View Query 直接继承 BaseRumQuery,通用 Level 实现下沉至 BaseRumLevelHandler[b] Session / View 各自声明语义字段树与差异补查,专用补查助手放在 query/fallback.py,不进入查询基类[c] 三层共用语义目录兜底,移除过渡字段校验;本地 579 项定向回归通过 |
2026-10-28 14:40 | [a] 按用户确认补齐 frustration_count,同一 Action 命中多个挫败类型只计一次,Session / View 目录分别为 31 / 40 个字段[b] 本地 591 项定向测试、Ruff 和新增代码类型检查通过[c] 保留无预计算降级与层级表扩展,代码未提交,真实 UQ 联调未执行 |
2026-10-28 14:00 | Session / View 列表采用原始 Span 折叠与当前页批量补查,分析按生命周期阶段降级,保留层级表切换能力;本地 589 项定向测试通过,挫败行为字段 key 和口径待确认。 |
2026-10-19 14:00 | View 关联信息保留 Span 数,新增 attributes.span_type = action 的动作数过滤条件,其他非 view、session 类型沿用等值过滤,接口文档见 TencentBlueKing/bk-monitor #12826 |
2026-10-17 00:15 | [a] 按 GTM 重新交付会话时间线 Action 子节点,提交 5a1ac99857dfb30c50e2c9c4c7f84ed25261a793[b] 关闭未走 GTM 的 #12819 [c] 新 PR TencentBlueKing/bk-monitor #12820,时间线测试 46 项通过,未跑前端点击验收 |
2026-10-16 20:00 | [a] 会话时间线把同一 View 内匹配 action.id 的非 Action 成员挂到 Action,Action 仍只作为 View 的直接子节点[b] 交付 TencentBlueKing/bk-monitor #12819 |
2026-10-16 16:00 | 设计 View 结束判定:display.view.close_state 区分页面关闭、无活动超时、满 4 h 和未见结束,关页不等于 Session 结束 |
2026-10-16 11:00 | [a] 复查 Resource 加载时序 PR(#12798)并 Approve [b] loading_timing 按 resource.timing.* 匹配 A–E 瀑布,缺新时间点时回退旧阶段字段[c] 有重定向时 B/C/D/E 先输出 redirect;D/E 的发起前等待画到 domain_lookup_start[d] prepare hover 是 C/D/E 共用文案,去掉重定向后保留「可能包含」缓存等场景,不按单条色条改写 |
2026-10-15 00:00 | [a] 完成统一 Hover 协议、后台 Builder 和接口文档,排除计算方式与原始字段表达式 [b] Resource 只采用 bk-rum 新文档的阶段术语,不修改现有算法 [c] Timeline View record 复用 Span Builder 的 Hover [d] Builder 与 Timeline 定向测试 138 项通过,交付 TencentBlueKing/bk-monitor #12784 |
2026-10-12 11:00 | 会话时间线支持按 view_id 查询单个 View,供 Span 详情的链路上下文复用,起止时间取该 View 的开始与 view.phase=end 快照 |
2026-10-10 00:00 | [a] 设计会话时间线 Builder,节点按 View ID 和 Action ID 挂载,详情复用 Span Builder [b] View 停留时长徽标通过 field_unit 声明毫秒,前端负责格式化 |
2026-10-09 15:00 | [a] 完成里程碑 7 第三轮 PR(#12463)review [b] Vital 与 Error Span 为瞬时事件,概览去掉 elapsed_time 徽标,Vital 徽标改用指标值[c] View 关联查询按 View ID 定位,不追加 Session 条件 |
2026-09-29 15:00 | [a] 完成里程碑 7 第二轮 PR(#12463)review [b] View 生命周期 Span 为瞬时快照,导航开始取 attributes.view.started_at,概览徽标改用 display.view.duration |
2026-09-29 11:00 | 补齐 View 加载时序标注:[a] milestones 以 dom_complete、load_event 和 loading_time 标注累计时间点,别名沿用字段元数据,页面稳定仅在自动计时时返回,浮点负差按 0 处理[b] markers 携带与 Web Vitals 卡片一致的评级阈值[c] View 快照补齐字段增加 dom_complete |
2026-09-16 17:00 | 确认 View 加载时序协议:[a] TTFB 四段与 View 三段统一放入 phases,markers 仅保留可定位的 Vital 指标[b] 加载总耗时直接取 Loading Time,与绘图横轴范围分离 [c] loading_timing 返回范围由 loading_type 和 loading_time_source 决定,非首次加载整块不返回[d] 最新 View 快照只补齐头部时间和 View 侧加载字段,Vital 字段由 Web Vitals 关联查询补齐 |
2026-09-04 13:00 | 完成里程碑 5 的语义字段模型复查(#12326):[a] RUM 枚举收归 semconv/rum/constants.py[b] 虚拟字段归入 attributes/virtual_attributes.py[c] FieldRegistry 以绑定副本携带完整路径[d] SpanQuery 通过 SpanSpec.fields() 统一补充虚拟字段 |
2026-08-31 00:00 | FieldSpec 与 view_config 增加 Web Vitals 评级阈值,阈值沿用字段单位并按包含性上界依次匹配 |
2026-08-28 00:00 | FieldSpec 与 view_config 增加 field_display_type,field_unit 只标识原始上报单位 |
2026-08-26 22:00 | 完成里程碑 2 的后续实现并通过 TencentBlueKing/bk-monitor #12094 review:view_config 支持省略时间范围,查询层根据 DataSourceTarget.retention 补齐缺失边界,RUM 与 APM Query 统一接收数据源列表 |
2026-08-24 00:00 | 统一字段语义与 view_config 协议:[a] 原子字段与 Attributes、Events 等复合字段统一使用 FieldSpec,由 SpanSpec 组成字段树[b] FieldRegistry 遍历字段树生成完整路径,未注册字段保留原始字段名[c] CachedEnum 只记录代码复制关系,枚举继续使用现有 label 和 choices() 协议 |
2026-08-18 15:00 | [a] 完成里程碑 2 首轮 PR(#11887)review [b] 明确 extra_config 为 Level 层扩展位,不进入接口协议[c] 确定请求序列化器单链分层:应用上下文 → 时间范围 → 检索条件,过滤条件按存储查询侧与查询串渲染侧拆分两类 |
2026-08-18 10:00 | 确认检索接口返回协议:view_config 维护字段元数据与枚举别名;get_fields_option_values 返回字段路径到原始值列表 |
2026-08-12 09:00 | 里程碑 4 已通过 TencentBlueKing/bk-monitor #11877 合入,统一 APM 查询基类和 TraceDatasourceTarget 协议 |
2026-08-10 22:00 | 统一 APM 查询继承链与 Target 协议:分析查询下沉到 APM 基类,列表查询由具体 Query 直接构造,所有查询配置保持 list[QueryConfigBuilder] 形态 |
2026-08-09 09:00 | 统一 Span 接口命名、里程碑和 /rum/search/{API}/ 路由 |
2026-08-08 16:00 | 统一 Level 方法的参数、返回类型、命名和扩展边界 |
2026-08-07 19:00 | 完成分层设计:Level 以 data_sources 初始化,并可组合主查询与 Span 兜底查询 |
0x07 参考
- <源码> bk-monitor/bkmonitor/data_source/utils
- <源码> bk-monitor/apm/core/handlers/query
- <源码> bk-monitor/constants
- <源码> bk-monitor/packages/rum_web
- <源码> bk-monitor/packages/apm_web/trace
- RUM 数据协议
- bk-rum Session:
packages/open-telemetry/src/plugins/session.ts、session-state.ts - bk-rum View:
packages/open-telemetry/src/plugins/page-view.ts - bk-rum Span 协议 · 资源加载时序:
packages/open-telemetry/wikis/reference/span-protocol.md - web-vitals 5.2.0 TTFB 归因
- HTML 解析结束与加载事件
- OpenTelemetry Tracing API
- OpenTelemetry Semantic Attribute Naming
- OpenTelemetry Semantic Convention Guidelines
0x08 版本锚点
| 状态 | 分支 | 里程碑 | PR |
|---|---|---|---|
| 🔄 | feat/rum-session-view-query/#1010158081139526191 | Session / View 降级列表及字段分析 | TencentBlueKing/bk-monitor #13018 |
| ✅ | feat/rum_base_search_module/#1010158081136933145 | 里程碑 0:提供 RUM 基础检索模块 | TencentBlueKing/bk-monitor #11838 |
| ✅ | feat/rum_base_query_fields/#1010158081136920078 | 里程碑 1:提供 RUM 字段元数据查询(query_fields) | TencentBlueKing/bk-monitor #11840 |
| ✅ | feat/rum_span_list_api/#1010158081137033151feat/rum_view_config_add_span_type_display_fields/#1010158081137385339feat/rum_view_config_api_add_span_type_display_fields/#1010158081137396316 | 里程碑 2:提供 RUM Span 列表类接口(list_records、view_config、get_fields_option_values、generate_query_string) [1] | TencentBlueKing/bk-monitor #11887 TencentBlueKing/bk-monitor #12086 TencentBlueKing/bk-monitor #12094 |
| ✅ | — | 里程碑 3:提供 RUM Span 分析类接口(field_topk、field_statistics_info、field_statistics_graph、download_topk) [1] | — |
| ✅ | feat/apm_trace/#1010158081137031784 | 里程碑 4:统一 APM 查询基类和 TraceDatasourceTarget 协议 | TencentBlueKing/bk-monitor #11877 |
| ✅ | feat/rum_span_semconv_build/#1010158081137728855feat/rum_span_semconv_better/#1010158081137884971 | 里程碑 5:提供 RUM 语义字段目录和 SpanSpec 访问入口 | TencentBlueKing/bk-monitor #12295 TencentBlueKing/bk-monitor #12326 |
| 🔄 | <branch_name> | 里程碑 6:提供 RUM 详情 Mock 接口 [2] | 待创建 |
| 🔄 | feat/rum_span_detail/#1010158081138079323 | 里程碑 7:提供 RUM Span 详情类接口(record_detail) [1] | TencentBlueKing/bk-monitor #12463 |
| 🔄 | <branch_name> | 里程碑 8:提供 RUM 分析接口(statistic) [1] | 待创建 |
| 🔄 | feat/rum-hover-protocol/#1010158081139035316 | RUM 详情支持 Hover 解释协议(TAPD #1010158081139035316) | TencentBlueKing/bk-monitor #12784 |
| ✅ | feat/rum_resource_timing/#1010158081139063287 | Resource 加载时序重构(TAPD #1010158081139063287) | TencentBlueKing/bk-monitor #12798 |
| 🔄 | feat/rum-timeline-action-children/#1010158081139124184 | 会话时间线把匹配 Action 的成员挂为子节点 | TencentBlueKing/bk-monitor #12820 |
| 🔄 | feat/rum-action-filter-docs/#1010158081139157253 | RUM 接口文档补充动作数过滤条件及其他 Span 类型的统计说明 | TencentBlueKing/bk-monitor #12826 |
- [1] RUM 接口里程碑实施期间,随接口落地补充
rum_web/docs/api/search.md。 - [2] Mock 数据为每种 Span 类型分配固定
span_id;详情请求根据record_id返回对应类型的 Mock 数据。