Skip to content

RUM 分层统一查询 —— 实施方案 ​

0x01 方案结论 ​

a. 目标调用链 ​

text
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. 总体结构 ​

mermaid
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。

python
@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)
mermaid
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
查询层级结果表来源
SpanTraceDatasourceTarget.table_id
ViewTraceDatasourceTarget.levels 中 name = "view" 的 table_ids
SessionTraceDatasourceTarget.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 查询适配 ​

mermaid
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 补查保留期内的汇总。

mermaid
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 分别计数;Session rotate 不纳入 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. 代码落点 ​

text
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 ​

python
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] BaseQuerybkmonitor/data_source/utils/query.py
[Change] SpanQuerypackages/rum_web/handlers/query/span.py
[Add] ViewQuerypackages/rum_web/handlers/query/view.py
[Add] SessionQuerypackages/rum_web/handlers/query/session.py
[Change] APM BaseQueryapm/core/handlers/query/base.py
[Change] TraceQuery / OriginTraceQuery / SpanQueryapm/core/handlers/query/

查询原语:

text
query_list
query_total
query_field_topk
query_option_values
query_graph_config
query_field_aggregated_value
query_detail
query_fields

d. Level 与 Query 交互 ​

mermaid
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_query
python
from 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 交互 ​

python
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 的免实例鉴权规则。

请求序列化器按「应用上下文 → 时间范围 → 检索条件」单链继承,接口参数只在叶子声明:

text
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 接口 ​

URLResourceLevel 方法
GET /rum/search/view_config/RumViewConfigResourceview_config
POST /rum/search/generate_query_string/RumGenerateQueryStringResourcegenerate_query_string
POST /rum/search/field_topk/RumFieldTopKResourcefield_topk
POST /rum/search/field_statistics_info/RumFieldStatisticsInfoResourcefield_statistics_info
POST /rum/search/field_statistics_graph/RumFieldStatisticsGraphResourcefield_statistics_graph
POST /rum/search/download_topk/RumDownloadTopKResourcedownload_topk
POST /rum/search/get_fields_option_values/RumFieldsOptionValuesResourceget_fields_option_values
POST /rum/search/list_records/RumRecordsResourcelist_records
POST /rum/search/record_detail/RumRecordDetailResourcerecord_detail

g. semconv ​

原子字段只定义一次,再由 Attributes、Events 等复合字段组成 Trace 结构。SpanSpec 组织整棵字段树,并支持按完整路径查找。

1)代码结构 ​

text
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/                         # 消费 SpanSpec

2)核心模型 ​

python
@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。

python
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 组装协议。

text
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/,接口骨架:

python
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=sessionstart / 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_stateViewSpanBuilder._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 组装协议。

text
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_idattributes.session.id = {session_id}
view_idattributes.view.id = {view_id} AND attributes.span_type != session

packages/rum_web/handlers/builder/timeline.py 接口骨架:

python
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_idstart_timeattributes.session.phase IN (start, rotate)min(start_time)
session_idend_timeattributes.session.phase = endmax(end_time)
view_idstart_timeattributes.span_type = viewmin(attributes.view.started_at) × 1000
view_idend_timeattributes.span_type = view AND attributes.view.phase = endmax(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) 的快照
ActionViewattributes.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),只是入参来源不同。

入口spanrelated_spans
record_detailquery_detail(record_id)_query_related_spans(span) 回查存储
timeline查询范围内的 Span同一结果按 attributes.view.id 切分,并把同 session.id 的 Session 生命周期 Span 一并传入 View 的 related_spans

0x04 核心协议 ​

a. view_config ​

json
{
  "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 时整段省略,不混用两套字段。

mermaid
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_durationattributes.resource.download.start + attributes.resource.download.duration
preparedns.start − redirect.start
connectTLS 字段有效时 connect.duration − ssl.duration,否则用完整 connect.duration;缺少 TLS 字段不能视为未发生握手

1)XHR & Fetch ​

json
{
  "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 ​

json
{
  "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) ​

json
{
  "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额外过滤条件统计
触发请求数statisticsattributes.span_type: resourcecount(attributes.span_type)
错误数statisticsattributes.span_type: errorcount(attributes.span_type)
LongTasks 数statisticsattributes.span_type: long_taskcount(attributes.span_type)
  • [1] 统一过滤条件:attributes.action.id: {当前 Action ID}。
  • [2] 查询时间范围:[end_time - 1d, end_time + 1d]。

d. RecordDetail(LongTask) ​

json
{
  "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过滤条件
关联 Actionlist_recordsattributes.span_type: action AND attributes.action.id: xxx

e. RecordDetail(Error) ​

json
{
  "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聚合额外
影响用户数statisticsdistinct(attributes.user.id)time_shifts=["0s", "{end - start}s"](计算环比)
发生次数statisticscount(attributes.span_type)
影响会话数statisticsdistinct(attributes.session.id)
24 小时趋势statisticscount(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) ​

json
{
  "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_durationattributes.view.loading_time
prepare.start0
prepare.durationattributes.vital.ttfb.waiting_duration
dns.startconnect.start − dns.duration
dns.durationattributes.vital.ttfb.dns_duration
connect.startfirst_byte.start − connect.duration
connect.durationattributes.vital.ttfb.connection_duration
first_byte.startattributes.vital.value − first_byte.duration
first_byte.durationattributes.vital.ttfb.request_duration
dom_processing.startattributes.view.first_byte
dom_processing.durationattributes.view.dom_content_loaded − attributes.view.first_byte [1]
resource_load.startattributes.view.dom_content_loaded
resource_load.durationattributes.view.load_event − attributes.view.dom_content_loaded [1]
page_stable.startattributes.view.load_event
page_stable.durationmax(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_completeattributes.view.dom_complete
milestones.load_eventattributes.view.load_event
milestones.page_stableattributes.view.loading_time
  • [1] 内容传输与 DOM 处理包含 HTML 接收、解析、阻塞脚本和 DOMContentLoaded 事件处理,剩余资源加载包含其后至 Load 完成的资源等待、脚本及事件处理,均为加载进程区间,不是纯下载或解析耗时。
  • [2] 自动计时的 Loading Time 不早于 Load,max(0, …) 只消除浮点误差产生的微小负值。
json
{
  "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额外
触发请求数statisticsattributes.span_type: resource AND attributes.resource.type: (xhr OR fetch)
错误数statisticsattributes.span_type: error
Span 数statisticsNOT attributes.span_type: (view OR vital OR session)
动作数statisticsattributes.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 Vitalsattributes.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 元数据:

json
{
  "hover": {
    "title": "LCP(最大内容绘制)",
    "description": "从打开页面到视窗内最大的图片、文本块或视频画面渲染完成的时间。",
    "user_experience": "页面主要内容已经出现,用户能够判断页面内容。",
    "source": {
      "label": "web.dev · Largest Contentful Paint",
      "url": "https://web.dev/articles/lcp"
    }
  }
}
字段类型必填说明
hover.titlestring是Hover 标题。
hover.descriptionstring是专业解释,不包含计算方式、公式或原始字段表达式。
hover.user_experiencestring否用户可感知的页面表现。
hover.sourceobject否参考来源,见 source 子结构。
hover.source.labelstring是来源名称。
hover.source.urlstring是官方定义或规范链接。

覆盖范围:

对象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[].hoverTTFB、FCP、LCP 与 web_vitals 使用同一指标定义。
View 累计里程碑sections.loading_timing.data.milestones[].hover覆盖 DOM Complete、Load Event 和页面稳定。
Web Vitalsections.web_vitals.data.<metric>.hover、sections.vital_rating.data.hoverTTFB、FCP、LCP、INP 和 CLS 共用定义。
Web Vital 评级display.rating_config[].hovergood、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 startemitViewEvent(view, 'start', startedAt)Span 起止同一时刻,停留派生为 0
关页view.end_reason=pagehidepagehide 结束当前 View,并 flush
往返缓存view.end_reason=bfcachepagehide.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,优先于无活动
关页后的 SessionlocalStorage 仍为 active关页不发 session.phase=end。15 min 内回来续同一个 Session,超时后再发 rotate

过期原因 inactivity / maxLifetime 只存在于 SDK 内部 notifySessionLifecycle,不上报为 Span Attribute。产品用 Session 起止间隔回推:达到 4 h 判满生命周期,其余 session_expired 判无活动。

mermaid
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"]
text
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 h

display.view.close_state 取值:

值条件页面文案
page_closed最新快照 phase=end 且 end_reason=pagehide页面关闭
bf_cacheend_reason=bfcache进入往返缓存
route_changedphase=end,且 end_reason 不是 pagehide、bfcache、session_expired、shutdown路由切换
session_idleend_reason=session_expired,且 lifetime < MAX - slack无活动超时
session_max_lifetimeend_reason=session_expired,且 lifetime >= MAX - slack会话已满 4 小时
shutdownend_reason=shutdownSDK 关闭
open无 phase=end,且 clock - last_snapshot < IDLE进行中
unobserved_end无 phase=end,且已超出 IDLE未见结束上报
json
{
  "key": "key_info",
  "type": "summary_cards",
  "data": {
    "duration": {
      "display.view.duration": 0,
      "display.view.close_state": "page_closed"
    }
  }
}
字段类型必填说明
display.view.durationnumber否[a] 最新快照 end_time 减 started_at,单位 ms
[b] 展示层把负值收成 0,不改快照时间
display.view.close_stateenum是[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] 保留期窗口与秒毫秒转换

测试门禁:

bash
pytest semconv/rum/tests -q
pytest packages/rum_web/tests/query -q
pytest bkmonitor/data_source/tests/test_utils_query.py -q

2026-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:00Session / View 列表采用原始 Span 折叠与当前页批量补查,分析按生命周期阶段降级,保留层级表切换能力;本地 589 项定向测试通过,挫败行为字段 key 和口径待确认。
2026-10-19 14:00View 关联信息保留 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:00FieldSpec 与 view_config 增加 Web Vitals 评级阈值,阈值沿用字段单位并按包含性上界依次匹配
2026-08-28 00:00FieldSpec 与 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 参考 ​

0x08 版本锚点 ​

状态分支里程碑PR
🔄feat/rum-session-view-query/#1010158081139526191Session / 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/#1010158081137033151
feat/rum_view_config_add_span_type_display_fields/#1010158081137385339
feat/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/#1010158081137728855
feat/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/#1010158081139035316RUM 详情支持 Hover 解释协议(TAPD #1010158081139035316)TencentBlueKing/bk-monitor #12784
✅feat/rum_resource_timing/#1010158081139063287Resource 加载时序重构(TAPD #1010158081139063287)TencentBlueKing/bk-monitor #12798
🔄feat/rum-timeline-action-children/#1010158081139124184会话时间线把匹配 Action 的成员挂为子节点TencentBlueKing/bk-monitor #12820
🔄feat/rum-action-filter-docs/#1010158081139157253RUM 接口文档补充动作数过滤条件及其他 Span 类型的统计说明TencentBlueKing/bk-monitor #12826
  • [1] RUM 接口里程碑实施期间,随接口落地补充 rum_web/docs/api/search.md。
  • [2] Mock 数据为每种 Span 类型分配固定 span_id;详情请求根据 record_id 返回对应类型的 Mock 数据。