znlgis 博客

GIS开发与技术分享 — GDAL · GeoServer · PostGIS · QGIS · OpenLayers · Cesium · FreeCAD · NPOI

第十章:Dashboards 与 Discover 数据分析

[目录]


10.1 Discover 概述:Sentry 的查询引擎

10.1.1 Discover 的定位与设计哲学

Sentry Discover 是 Sentry 生态中最为核心的自助分析工具。它并非一个简单的”搜索栏”——它是一套完整的查询引擎,允许用户对 Sentry 采集的所有事件数据(错误、事务、会话重放等)执行任意的结构化查询,并支持聚合、分组、时序分析以及可视化展示。

在代码层面,Discover 的本质是一个高层抽象层,它接收用户友好的字段名(如 transaction.durationcount_unique(user))和查询语法,转换为 Snuba 底层查询语言(SnQL),并通过 Snuba 执行引擎获取结果。这个架构设计遵循了关注点分离原则:用户不需要知道 Snuba 的物理表结构、ClickHouse 的数据引擎细节,只需要使用一套统一的、语义化的字段和函数即可完成复杂的数据分析。

Discover 的入口模块位于 src/sentry/snuba/discover.py,该文件导出了以下核心公共 API:

# src/sentry/snuba/discover.py:52-62
__all__ = (
    "PaginationResult",
    "InvalidSearchQuery",
    "query",              # 主事件查询
    "timeseries_query",   # 时序查询
    "top_events_timeseries",  # Top-N 时序查询
    "get_facets",         # 维度分布查询
    "zerofill",           # 时间桶填充工具
    "histogram_query",    # 直方图查询
    "check_multihistogram_fields",
)

这七个函数覆盖了 90% 以上的数据分析场景:query 负责获取原始事件列表或聚合结果,timeseries_query 负责生成时间序列图表数据,top_events_timeseries 同时获取 Top-N 分组及其各自的时间序列,get_facets 生成维度分布用于过滤面板,histogram_query 生成数值指标的分布直方图。

10.1.2 Discover 与 Snuba 的关系

Snuba 是 Sentry 自建的、基于 ClickHouse 的事件数据存储与查询服务。每个 Sentry 事件(无论错误还是事务)在摄入时都会被写入 ClickHouse 的多个物化视图(Materialized View)中。Snuba 对外暴露了一套类似 SQL 的查询语言 SnQL,支持过滤、聚合、分组、排序等标准操作。

Discover 相当于 Snuba 的”翻译层”,负责:

  1. 字段解析:将用户输入的字段名(如 user.emailtransaction.durationstack.function)解析为 Snuba 实际的列名(如 tags[sentry:user]durationexception_stacks.function)。

  2. 函数映射:将用户级的聚合函数(如 p50(transaction.duration))映射为 ClickHouse 的聚合表达式(如 quantile(0.5)(duration))。

  3. 查询构建:通过 QueryBuilder 体系(DiscoverQueryBuilderTimeseriesQueryBuilderTopEventsQueryBuilder)构建完整的 SnQL 查询。

  4. 结果后处理:包括时间格式化(Unix 时间戳转换)、空值填零(zerofill)、Top-N 结果分组等。

从架构角度看,Discover 查询的完整数据流如下:

用户查询 → Discover API 端点 → discover.py 入口函数
    → QueryBuilder 构造 SnQL → bulk_snuba_queries → Snuba 服务
    → ClickHouse 查询 → 原始结果集 → builder.process_results
    → 字段反解析 + 格式化 → 最终 JSON 响应

值得注意的是,src/sentry/snuba/discover.py 中同时支持 MQL(Metrics Query Language)和 SnQL 两条路径,这意味着 Discover 可以在原始的索引事件数据和预聚合的指标数据之间无缝切换,具体由 allow_metric_aggregateshas_metrics 参数控制。

10.1.3 数据集体系:Events、Transactions 与 Discover

Sentry 的数据按用途分为多个逻辑数据集(Dataset),定义在 src/sentry/snuba/dataset.py

# src/sentry/snuba/dataset.py:5-55
class Dataset(Enum):
    Events = "events"              # 所有摄入的错误事件
    Transactions = "transactions"  # 所有摄入的事务(性能)事件
    Discover = "discover"          # Events + Transactions 的联合视图
    Outcomes = "outcomes"          # 使用量统计
    Replays = "replays"            # 会话重放
    Profiles = "profiles"          # 性能剖析
    IssuePlatform = "search_issues"# 通用 Issue 平台
    Functions = "functions"        # 函数级剖析
    SpansIndexed = "spans"         # 索引 Span 数据
    PerformanceMetrics = "generic_metrics"  # 通用指标平台
    EventsAnalyticsPlatform = "events_analytics_platform"  # EAP

在 Discover 中,最常用的是 Dataset.Discover(联合视图)和 Dataset.Transactions(事务专用)。Dataset.Discover 本质上将错误事件和事务事件合并到一个统一的命名空间下查询,这意味着你可以在同一次查询中同时看到错误的 count() 和事务的 p95(transaction.duration)

然而,这种联合查询架构也存在性能问题:由于两个数据集的物理表和字段结构不同,联合查询需要遍历更多的存储分区。Sentry 在近期版本中对 Discover 数据集进行了拆分(详见第 10.7 节),将原本模糊的 DISCOVER 类型拆分为明确的 ERROR_EVENTSTRANSACTION_LIKE 两个子类型。

10.1.4 Discover 查询的核心入口函数

query 函数是 Discover 最核心的入口,它几乎被所有上层功能调用——包括 Dashboard Widget 渲染、Saved Query 执行、API 端点等。其签名揭示了 Discover 的全部能力:

# src/sentry/snuba/discover.py:162-264
def query(
    selected_columns: list[str],     # 选择的字段和聚合函数
    query: str,                      # 过滤条件查询字符串
    snuba_params: SnubaParams,       # 时间范围、项目、环境等参数
    equations: list[str] | None = None,  # 算术表达式
    orderby: list[str] | None = None,    # 排序字段
    offset: int | None = None,           # 分页偏移
    limit: int = 50,                     # 返回行数
    auto_fields: bool = False,           # 自动添加 project + eventid
    auto_aggregations: bool = False,     # 自动为条件中的聚合函数添加字段
    include_equation_fields: bool = False,
    allow_metric_aggregates: bool = False,
    use_aggregate_conditions: bool = False,
    conditions: list[Condition] | None = None,  # 直接透传给 Snuba 的条件
    functions_acl: list[str] | None = None,     # 函数白名单
    transform_alias_to_input_format: bool = False,
    sample: float | None = None,      # 采样率
    has_metrics: bool = False,        # 是否使用指标增强
    skip_tag_resolution: bool = False,
    extra_columns: list[Column] | None = None,
    on_demand_metrics_enabled: bool = False,
    on_demand_metrics_type: MetricSpecType | None = None,
    dataset: Dataset = Dataset.Discover,  # 数据集选择
    fallback_to_transactions: bool = False,
    query_source: QuerySource | None = None,
    *,
    referrer: str,                    # 调用来源标识(必填,用于追踪)
) -> EventsResponse:

这个函数首先对参数进行校验(selected_columns 不能为空,dataset 必须是 DiscoverTransactions),然后构造 DiscoverQueryBuilder 实例:

# src/sentry/snuba/discover.py:231-253
builder = DiscoverQueryBuilder(
    dataset,
    params={},
    snuba_params=snuba_params,
    query=query,
    selected_columns=selected_columns,
    equations=equations,
    orderby=orderby,
    limit=limit,
    offset=offset,
    sample_rate=sample,
    config=QueryBuilderConfig(
        auto_fields=auto_fields,
        auto_aggregations=auto_aggregations,
        use_aggregate_conditions=use_aggregate_conditions,
        functions_acl=functions_acl,
        equation_config={"auto_add": include_equation_fields},
        has_metrics=has_metrics,
        transform_alias_to_input_format=transform_alias_to_input_format,
        skip_tag_resolution=skip_tag_resolution,
    ),
)

timeseries_query 的流程稍有不同 —— 它首先通过 categorize_columns 将选中的列拆分为方程和普通列,然后使用 TimeseriesQueryBuilder 构建查询。该函数还支持 comparison_delta 参数,可以在一次调用中获取当前时间段和对比时间段的两组时序数据,自动计算变化率:

# src/sentry/snuba/discover.py:335-353
if comparison_delta:
    if len(base_builder.aggregates) != 1:
        raise InvalidSearchQuery("Only one column can be selected for comparison queries")
    comp_query_params = snuba_params.copy()
    comp_query_params.start -= comparison_delta
    comp_query_params.end -= comparison_delta
    comparison_builder = TimeseriesQueryBuilder(...)
    query_list.append(comparison_builder)

然后在结果处理阶段,将两组数据合并计算百分比变化:

# src/sentry/snuba/discover.py:383-385
for row, compared_row in zip(results[0]["data"], results[1]["data"]):
    compared_value = compared_row.get(col_name, 0)
    row["comparisonCount"] = compared_value

10.2 Discover Query 语法

10.2.1 字段选择与基本过滤

Discover 查询的核心由两部分组成:字段选择selected_columns)和过滤条件query 字符串)。字段选择是一个字符串数组,每个元素可以是一个简单的字段名、一个标签引用、或者一个聚合函数调用。

简单字段直接引用事件的属性:

transaction
transaction.duration
release
environment
user.email
timestamp
message

标签引用使用方括号语法,对应事件上附着的任意标签:

tags[os.name]
tags[device.family]
tags[geo.country_code]

在代码层面,标签中的字段通过 resolve_column 函数解析:

# src/sentry/snuba/discover.py:75
resolve_discover_column = resolve_column(Dataset.Discover)

过滤条件遵循 Sentry 的搜索查询语法,支持多种操作符:

操作符 含义 示例
: 等于 environment:production
!= 不等于 release:!null
> / < 大于/小于 transaction.duration:>3000
>= / <= 大于等于/小于等于 user_count:>=10
! 不包含 !transaction:"/api/health"
is: 状态检查 is:unresolved
has: 存在性检查 has:user

这些条件最终通过 QueryBuilder 被转换为 Snuba 的 Condition 对象列表,或者作为原始 SnQL 片段直接传递:

# 直接透传给 Snuba 的条件(跳过解析层)
conditions: list[Condition] | None = None

10.2.2 聚合函数详解

Discover 提供了丰富的聚合函数集,函数映射定义在 src/sentry/search/events/fields.pysrc/sentry/search/events/datasets/discover.py 中。每个函数在一个或多个数据集上可用,具体取决于数据集的物理结构。

通用聚合函数(Events + Transactions)

函数 说明 SnQL 映射 返回类型
count() 事件计数 count() integer
count_unique(column) 去重计数 uniq(column) integer
avg(column) 平均值 avg(column) duration
max(column) 最大值 max(column) duration
min(column) 最小值 min(column) duration
sum(column) 求和 sum(column) duration

事务专用聚合函数(仅在 Transactions/TRANSACTION_LIKE 数据集上可用):

函数 说明 代码出处
p50(transaction.duration) 中位数(P50) fields.py:1585
p75(transaction.duration) 75 分位数 fields.py:1593
p95(transaction.duration) 95 分位数 fields.py:1609
p99(transaction.duration) 99 分位数 fields.py:1617
failure_rate() 事务失败率 fields.py:1756
failure_count() 失败事务计数 基于 transaction.status
apdex(threshold) Apdex 满意度得分 阈值通常为 300ms
eps() 每秒事件数(Event Per Second) fields.py:1633
epm() 每分钟事件数(Event Per Minute) fields.py:1639
count_miserable(user, threshold) 受影响用户计数 阈值倍率 4x
user_misery(threshold) 用户痛苦指数 0-1 范围
count_web_vitals(measurement, threshold) Web Vitals 通过/不通过计数 LCP/FCP/FID/CLS
percentile(column, n) 任意分位数 通用版 percentile

这些函数在 fields.py 中通过字典注册。以 p95 为例,其定义如下(在 discover.py 数据集层):

# src/sentry/search/events/datasets/discover.py:356-366
"p95": SnQLFunction(
    "p95",
    required_args=[NumberRange(1, 1, NumberTypes.DURATION)],
    snql_aggregate=lambda args, alias: ...
    result_type_fn=...,
    default_result_type="duration",
),

每个 SnQLFunction 包含参数验证(required_args)、SnQL 转换函数、结果类型推导等逻辑。epsepm 的实现更为复杂,需要在 count() 的基础上除以时间窗口的大小:

# src/sentry/search/events/datasets/function_aliases.py:337-376
def resolve_eps(args, alias):
    # EPS = count() / (end - start).seconds
    ...

def resolve_epm(args, alias):
    # EPM = count() * 60 / (end - start).seconds
    ...

failure_rate 的定义返回一个百分比:

# src/sentry/search/events/fields.py:1756
"failure_rate", transform="failure_rate()", default_result_type="percentage"

10.2.3 算术表达式(Equation)

Discover 支持在查询中使用算术表达式,允许用户将多个聚合函数组合在一起进行计算。表达式以 equation| 为前缀,使用 plus/minus/multiply/divide 四种操作符:

# 计算缓存命中率
equation|(count_if(transaction.op,cache.get))/(count_if(transaction.op,cache.get)+count_if(transaction.op,db.query))

# 计算平均事务耗时 = 总耗时 / 次数
equation|(sum(transaction.duration))/count()

算术表达式的解析由 src/sentry/discover/arithmetic.py 完成,它使用 Parsimonious 解析器(PEG 语法)构建表达式树:

# src/sentry/discover/arithmetic.py:15-17
EQUATION_PREFIX = "equation|"
EQUATION_ALIAS_REGEX = re.compile(r"^equation\[\d*\]$")
SUPPORTED_OPERATORS = {"plus", "minus", "multiply", "divide"}

核心解析语法如下:

# src/sentry/discover/arithmetic.py:95-100
arithmetic_grammar = Grammar(
    r"""
term                 = maybe_factor remaining_adds
remaining_adds       = add_sub*
add_sub              = add_sub_operator maybe_factor
maybe_factor         = spaces (factor / primary) spaces
...

解析后的 Operation 树可以转换为 Snuba 的 JSON 表达式:

# src/sentry/discover/arithmetic.py:62-72
def to_snuba_json(self, alias: str | None = None) -> JsonQueryType:
    lhs = self.lhs.to_snuba_json() if isinstance(self.lhs, Operation) else self.lhs
    if isinstance(lhs, str):
        lhs = ["toFloat64", [lhs]]
    rhs = self.rhs.to_snuba_json() if isinstance(self.rhs, Operation) else self.rhs
    result: JsonQueryType = [self.operator, [lhs, rhs]]
    if alias:
        result.append(alias)
    return result

算术表达式在执行时会被分离出来:categorize_columns 函数将 selected_columns 中的普通列和表达式列分开,分别传递给 QueryBuilder 的不同参数。

10.2.4 Order By、Limit 与 Offset

Discover 支持标准的排序和分页机制。orderby 参数是一个字符串列表,使用前置的 - 表示降序:

# 按错误数量降序排序,每页 50 条
query(
    selected_columns=["count()", "message"],
    query="",
    orderby=["-count()"],
    limit=50,
    offset=0,
    ...
)

limit 默认值为 50,offset 默认值为 None(表示从 0 开始)。分页信息通过 PaginationResult 具名元组返回:

# src/sentry/snuba/discover.py:70
PaginationResult = namedtuple("PaginationResult", ["next", "previous", "oldest", "latest"])

10.2.5 数据集拆分对查询的影响

Sentry 将 Discover 数据集拆分为 ERROR_EVENTSTRANSACTION_LIKE 后,某些字段和函数只能在特定数据集上使用。dataset_split.py 中定义了事务专属的聚合函数和字段:

# src/sentry/discover/dataset_split.py:51-80
TRANSACTION_ONLY_AGGREGATES = [
    "failure_rate", "failure_count", "apdex", "count_miserable",
    "user_misery", "count_web_vitals", "percentile",
    "p50", "p75", "p90", "p95", "p99", "p100",
]

TRANSACTION_ONLY_FIELDS = [
    "duration", "transaction_op", "transaction_status",
    "measurements[lcp]", "measurements[cls]", "measurements[fcp]",
    "measurements[fid]", "measurements[inp]", "measurements[ttfb]",
    "measurements[app_start_cold]", "measurements[app_start_warm]",
    "measurements[frames_total]", "measurements[frames_slow]",
    ...
]

当你编写一个既包含 count_unique(user)(适用于错误事件)又包含 p95(transaction.duration)(仅适用于事务)的查询时,查询会被路由到 Discover 联合数据集。如果 discover_widget_split 字段被设置,则系统会根据查询的特征自动将其路由到 ERROR_EVENTSTRANSACTION_LIKE 数据集。


10.3 Discover 查询构建器

10.3.1 可视化查询界面

Sentry 在前端提供了完整的可视化 Discover 查询构建器。用户通过交互式界面(而非手写查询字符串)选择:

  • 数据集:Errors、Transactions 或两者的合并视图
  • 字段列:从字段列表中勾选需要展示的列,包括事件属性、标签、聚合结果
  • 过滤条件:通过下拉菜单选择字段、操作符和值,支持多条件组合(AND/OR 逻辑)
  • 分组:选择 Group By 字段,将结果分组展示
  • 排序:选择排序字段和方向
  • 图表类型:表格、柱状图、折线图、面积图等
  • 时间范围:通过内置的日期选择器设定查询的时间窗口

这种可视化构建方式的底层调用仍然是 discover.py 中的 querytimeseries_query 函数,前端只是将其图形化呈现而非暴露原始查询字符串。

10.3.2 保存查询与 DiscoverSavedQuery 模型

用户在 Discover 界面构建好查询后,可以选择”保存”以便后续复用。保存的查询通过 DiscoverSavedQuery 模型持久化到数据库中:

# src/sentry/discover/models.py:81-127
class DiscoverSavedQuery(Model):
    projects = models.ManyToManyField("sentry.Project", through=DiscoverSavedQueryProject)
    organization = FlexibleForeignKey("sentry.Organization")
    created_by_id = HybridCloudForeignKey("sentry.User", null=True, on_delete="SET_NULL")
    name = models.CharField(max_length=255)
    query = models.JSONField()          # 查询配置的 JSON 结构
    version = models.IntegerField(null=True)
    date_created = models.DateTimeField(auto_now_add=True)
    date_updated = models.DateTimeField(auto_now=True)
    visits = BoundedBigIntegerField(null=True, default=1)
    last_visited = models.DateTimeField(null=True, default=timezone.now)
    is_homepage = models.BooleanField(null=True, blank=True)
    dataset = BoundedPositiveIntegerField(       # 数据集类型
        choices=DiscoverSavedQueryTypes.as_choices(),
        default=DiscoverSavedQueryTypes.DISCOVER,
    )
    dataset_source = BoundedPositiveIntegerField(  # 数据集的确定来源
        choices=DatasetSourcesTypes.as_choices(),
        default=DatasetSourcesTypes.UNKNOWN.value,
    )

其中 DiscoverSavedQueryTypes 枚举定义了三种查询类型:

# src/sentry/discover/models.py:24-40
class DiscoverSavedQueryTypes(TypesClass):
    DISCOVER = 0        # 原始联合查询
    ERROR_EVENTS = 1    # 错误事件查询
    TRANSACTION_LIKE = 2 # 事务类查询

DatasetSourcesTypes 则记录了数据集是如何被确定的:

# src/sentry/discover/models.py:43-61
class DatasetSourcesTypes(Enum):
    UNKNOWN = 0    # 未分类(待处理)
    INFERRED = 1   # 通过运行查询或启发式推断
    USER = 2       # 用户显式指定
    FORCED = 3     # 模糊查询被强制分割(系统选择默认值)

保存查询的 CRUD 操作通过专门的端点实现:

  • discover_saved_queries.py:创建和列表查询
  • discover_saved_query_detail.py:详情、更新和删除

查询配置以 JSON 格式存储在 query 字段中,包含 fieldsaggregatesconditionsorderby 等完整信息。

10.3.3 主页查询与收藏

每个用户可以为自己的组织设置一个主页查询(Homepage Query),该查询会在用户打开 Discover 页面时自动加载。这个特性通过 is_homepage 字段实现,并配有数据库级唯一约束:

# src/sentry/discover/models.py:120-126
class Meta:
    constraints = [
        UniqueConstraint(
            fields=["organization", "created_by_id", "is_homepage"],
            condition=Q(is_homepage=True),
            name="unique_user_homepage_query",
        )
    ]

这确保每个用户在每个组织内只能设置一个主页查询。相关的设置和获取通过 discover_homepage_query.py 端点实现。


10.4 常用查询场景

10.4.1 错误分布分析

按错误类型分组统计 — 查看各种错误类型的发生频率:

query(
    selected_columns=["count()", "error.type", "error.value"],
    query="has:error.type",
    orderby=["-count()"],
    limit=20,
    ...
)

按时间序列的错误率 — 配合 timeseries_query 生成时序图:

timeseries_query(
    selected_columns=["count()"],
    query="has:error.type",
    rollup=3600,  # 每小时一个数据点
    ...
)

按环境分布 — 区分生产环境和测试环境的错误分布:

query(
    selected_columns=["environment", "count()", "count_unique(user)"],
    query="",
    orderby=["-count()"],
    ...
)

10.4.2 性能分析

事务耗时分布 — 分析哪些事务最慢:

query(
    selected_columns=["transaction", "count()", "p95(transaction.duration)"],
    query="",
    orderby=["-p95(transaction.duration)"],
    limit=20,
    ...
)

事务失败率监控 — 使用 failure_rate() 聚合函数:

query(
    selected_columns=["transaction", "failure_rate()", "count()"],
    query="",
    orderby=["-failure_rate()"],
    ...
)

页面加载性能 — 分析 Web Vitals 指标:

query(
    selected_columns=[
        "transaction",
        "p75(measurements.lcp)",
        "p75(measurements.fcp)",
        "p75(measurements.cls)",
    ],
    query="transaction.op:pageload",
    orderby=["-p75(measurements.lcp)"],
    ...
)

每秒/每分钟吞吐量 — 使用 eps()epm() 函数:

timeseries_query(
    selected_columns=["epm()"],
    query="transaction:/api/*",
    rollup=60,
    ...
)

10.4.3 用户影响范围分析

受影响用户数 — 使用 count_unique(user)count_unique(user.email)

query(
    selected_columns=["error.type", "count()", "count_unique(user)"],
    query="",
    orderby=["-count_unique(user)"],
    limit=20,
    ...
)

用户痛苦指数user_misery() 综合考量失败率和影响用户数:

query(
    selected_columns=["transaction", "user_misery(300)", "count_unique(user)"],
    query="",
    orderby=["-user_misery(300)"],
    limit=10,
    ...
)

10.4.4 多维度组合分析

通过结合多个 Group By 字段,可以实现多维度的下钻分析。例如,同时按项目、环境和事务名分组:

query(
    selected_columns=["project", "environment", "transaction", "p95(transaction.duration)", "count()"],
    query="transaction.op:http.server",
    orderby=["-count()"],
    limit=50,
    ...
)

配合算术表达式,可以计算多个指标的组合比率:

query(
    selected_columns=["count()", "count_if(transaction.status,ok)"],
    equations=["equation|(count_if(transaction.status,ok))/count()"],
    query="",
    orderby=["-equation[0]"],
    include_equation_fields=True,
    ...
)

10.5 Dashboards 仪表盘

10.5.1 Dashboard 数据模型

仪表盘是多个 Widget 的容器,提供统一的筛选器和查看视角。Dashboard 的数据库模型定义在 src/sentry/models/dashboard.py 中:

# src/sentry/models/dashboard.py:336-353
class Dashboard(Model):
    title = models.CharField(max_length=255)
    created_by_id = HybridCloudForeignKey("sentry.User", null=True, on_delete="CASCADE")
    organization = FlexibleForeignKey("sentry.Organization")
    date_added = models.DateTimeField(default=timezone.now)
    visits = BoundedBigIntegerField(null=True, default=1)
    last_visited = models.DateTimeField(null=True, default=timezone.now)
    projects = models.ManyToManyField("sentry.Project", through=DashboardProject)
    filters = JSONField(null=True)
    prebuilt_id = BoundedPositiveIntegerField(null=True, db_default=None)

    MAX_WIDGETS = 30

关键特性:

  • 项目关联:每个 Dashboard 通过多对多关系关联一个或多个项目(通过 DashboardProject 中间表,project + dashboard 唯一约束)
  • 过滤器filters JSON 字段存储仪表盘级别的全局过滤条件,如时间范围、环境选择等。当设置 all_projects: true 时,会使用 ALL_ACCESS_PROJECT_ID(值为 -1)作为项目占位符
  • 数量限制MAX_WIDGETS = 30 限制每个仪表盘最多 30 个小部件
  • 预置标记prebuilt_id 标识预置模板,非空表示该仪表盘为系统预置

此外,Dashboard 还维护了使用热度visits 记录访问次数,last_visited 记录最后访问时间。用户自定义的仪表盘还需要保证同组织内标题唯一(预置模板不受此限制):

# src/sentry/models/dashboard.py:358-375
class Meta:
    constraints = [
        UniqueConstraint(
            fields=["organization", "title"],
            condition=Q(prebuilt_id__isnull=True),
            name="sentry_dashboard_organization_title_uniq",
        ),
        UniqueConstraint(
            fields=["organization", "prebuilt_id"],
            condition=Q(prebuilt_id__isnull=False),
            name="sentry_dashboard_organization_prebuilt_id_uniq",
        ),
        CheckConstraint(
            condition=Q(prebuilt_id__isnull=True) | Q(created_by_id__isnull=True),
            name="sentry_dashboard_prebuilt_null_created_by",
        ),
    ]

注意 created_by_id 的约束:预置仪表盘的 created_by_id 必须为 NULL,因为系统仪表盘不属于任何用户。

10.5.2 预置模板(Prebuilt Dashboards)

Sentry 内置了 29 个预置仪表盘模板(截至当前代码版本),覆盖了最常见的监控场景。这些模板定义在 src/sentry/dashboards/endpoints/organization_dashboards.py 中:

# src/sentry/dashboards/endpoints/organization_dashboards.py:65-237
class PrebuiltDashboardId(IntEnum):
    FRONTEND_SESSION_HEALTH = 1
    BACKEND_QUERIES = 2
    BACKEND_QUERIES_SUMMARY = 3
    HTTP = 4
    HTTP_DOMAIN_SUMMARY = 5
    WEB_VITALS = 6
    WEB_VITALS_SUMMARY = 7
    MOBILE_VITALS = 8
    MOBILE_VITALS_APP_STARTS = 9
    MOBILE_VITALS_SCREEN_LOADS = 10
    MOBILE_VITALS_SCREEN_RENDERING = 11
    BACKEND_OVERVIEW = 12
    MOBILE_SESSION_HEALTH = 13
    FRONTEND_OVERVIEW = 14
    NEXTJS_FRONTEND_OVERVIEW = 15
    AI_AGENTS_OVERVIEW = 16
    AI_AGENTS_MODELS = 17
    AI_AGENTS_TOOLS = 18
    MCP_OVERVIEW = 19
    MCP_TOOLS = 20
    MCP_RESOURCES = 21
    MCP_PROMPTS = 22
    LARAVEL_OVERVIEW = 23
    FRONTEND_ASSETS = 24
    FRONTEND_ASSETS_SUMMARY = 25
    BACKEND_QUEUES = 26
    BACKEND_QUEUE_SUMMARY = 27
    BACKEND_CACHES = 28
    NODE_RUNTIME_METRICS = 29

每个预置仪表盘包含 prebuilt_id(必填)和 title(必填),可选 hidden(隐藏)、pre_favorited(默认收藏):

PREBUILT_DASHBOARDS: list[PrebuiltDashboard] = [
    {
        "prebuilt_id": PrebuiltDashboardId.WEB_VITALS,
        "title": "Web Vitals",
        "pre_favorited": True,  # 对新用户自动收藏
    },
    {
        "prebuilt_id": PrebuiltDashboardId.BACKEND_QUERIES_SUMMARY,
        "title": "Query Details",
        "hidden": True,  # 不在常规列表中展示
    },
    ...
]

值得注意的是,预置仪表盘的实际内容(Widget 配置)并不存储在数据库中,而是由前端代码根据 prebuilt_id 硬编码渲染。数据库中的预置仪表盘记录仅用于追踪收藏状态、最后访问时间等元数据。这意味着:

  • 后端只负责同步预置仪表盘的存在性(sync_prebuilt_dashboards 函数会在组织创建后自动在数据库中创建对应的记录)
  • 前端负责根据 prebuilt_id 加载实际的 Widget 布局和数据
  • 用户无法编辑或删除预置仪表盘(organization_dashboard_details.py 第 153 行明确拒绝删除预置仪表盘的请求)
# src/sentry/dashboards/endpoints/organization_dashboards.py:264-309
def sync_prebuilt_dashboards(organization: Organization) -> None:
    enabled_prebuilt_dashboards = get_enabled_prebuilt_dashboards(organization)
    saved_prebuilt_dashboards = Dashboard.objects.filter(
        organization=organization,
        prebuilt_id__isnull=False,
    )
    # 为不存在的预置仪表盘创建记录,更新已存在的标题
    ...
    # 删除不再需要的预置仪表盘
    ...

同步机制通过 Celery 任务异步执行,使用 Redis 缓存防止重复运行(dashboards:sync_prebuilt_dashboards:{org_id} 作为缓存键)。

10.5.3 自定义仪表盘

自定义仪表盘是用户手动创建的仪表盘,prebuilt_idNULL。创建自定义仪表盘的 API 端点位于 organization_dashboards.py,核心流程如下:

  1. 创建 Dashboard 记录:提供 titleprojectsfilters 等参数,创建一个 prebuilt_idNULL 的 Dashboard。
  2. 添加 Widget:通过 organization_dashboard_details.py 端点向 Dashboard 中添加 Widget,每个 Widget 包含一组查询(DashboardWidgetQuery)。
  3. 配置权限:通过 DashboardPermissions 控制编辑权限(详见 10.8 节)。

自定义仪表盘的标题在同组织内唯一,如果重名会返回 409 错误。Dashboard 模型还提供了 incremental_title 类方法用于复制仪表盘时自动生成新标题:

# src/sentry/models/dashboard.py:429-452
@classmethod
def incremental_title(cls, organization, name):
    base_name = re.sub(r" ?copy ?(\d+)?$", "", name)
    matching_dashboards = cls.objects.filter(
        organization=organization, title__regex=rf"^{re.escape(base_name)} ?(copy)? ?(\d+)?$"
    ).values("title")
    if not matching_dashboards:
        return name
    next_copy_number = 0
    for dashboard in matching_dashboards:
        match = re.search(r" copy ?(\d+)?", dashboard["title"])
        if match:
            copy_number = int(match.group(1) or 0)
            next_copy_number = max(next_copy_number, copy_number + 1)
    if next_copy_number == 0:
        return f"{base_name} copy"
    return f"{base_name} copy {next_copy_number}"

10.5.4 仪表盘版本管理与回滚

Sentry 为每个 Dashboard 维护了版本历史,通过 DashboardRevision 模型实现:

# src/sentry/models/dashboard.py:474-498
class DashboardRevision(DefaultFieldsModel):
    SNAPSHOT_SCHEMA_VERSION = 1
    RETENTION_LIMIT = 10

    created_by_id = HybridCloudForeignKey("sentry.User", ...)
    title = models.CharField(max_length=255)
    source = models.CharField(max_length=32, default="edit")
    snapshot = JSONField(default=dict)
    snapshot_schema_version = models.IntegerField()
    dashboard = FlexibleForeignKey("sentry.Dashboard", on_delete=models.CASCADE)

每当日志盘内容发生变化(编辑 Widget、修改筛选器等),系统会在一个事务中:

  1. 创建一个新的 DashboardRevision 记录,将当前 Widget 配置、筛选器等完整状态序列化为 JSON 快照
  2. 根据 RETENTION_LIMIT = 10 修剪旧版本(只保留最近 10 个版本)
  3. 如果超出保留限制,删除最旧的额外版本

版本快照的 source 字段记录了变更来源:"edit"(手动编辑)、"restore"(从快照恢复)等。

恢复功能通过 organization_dashboard_revision_restore.py 端点实现,允许用户从任意历史版本恢复仪表盘的完整状态。


10.6 Widget 小部件类型

10.6.1 Widget 数据模型

Widget 是仪表盘的核心组成单元。每个 Widget 包含一个或多个查询(DashboardWidgetQuery),以及显示类型、标题、布局参数等。数据模型定义在 src/sentry/models/dashboard_widget.py 中:

# src/sentry/models/dashboard_widget.py:346-386
class DashboardWidget(Model):
    dashboard = FlexibleForeignKey("sentry.Dashboard")
    order = BoundedPositiveIntegerField(null=True)    # 排列顺序
    title = models.CharField(max_length=255)
    description = models.TextField(null=True, blank=True)
    thresholds = JSONField(null=True)                 # 阈值配置
    interval = models.CharField(max_length=10, null=True)
    display_type = BoundedPositiveIntegerField(       # 显示类型
        choices=DashboardWidgetDisplayTypes.as_choices()
    )
    date_added = models.DateTimeField(default=timezone.now)
    widget_type = BoundedPositiveIntegerField(        # 数据源类型
        choices=DashboardWidgetTypes.as_choices(), null=True
    )
    limit = models.IntegerField(null=True)            # 返回数据行数限制
    detail = JSONField(null=True)                     # 展示细节配置
    discover_widget_split = BoundedPositiveIntegerField(
        choices=DashboardWidgetTypes.as_choices(), null=True
    )
    dataset_source = BoundedPositiveIntegerField(     # 数据集选取来源
        choices=DatasetSourcesTypes.as_choices(),
        default=DatasetSourcesTypes.UNKNOWN.value,
    )
    widget_snapshot = models.JSONField(null=True)     # 迁移前快照
    changed_reason = models.JSONField(null=True)      # 迁移字段丢弃原因

每个 Widget 关联的查询通过 DashboardWidgetQuery 模型表示:

# src/sentry/models/dashboard_widget.py:221-258
class DashboardWidgetQuery(Model):
    widget = FlexibleForeignKey("sentry.DashboardWidget")
    name = models.CharField(max_length=255)        # 查询名称(在图例中显示)
    fields = ArrayField(models.TextField(), default=list)       # 字段列表
    conditions = models.TextField()                # 过滤条件
    aggregates = ArrayField(models.TextField(), null=True)      # 聚合函数
    columns = ArrayField(models.TextField(), null=True)         # 非聚合列
    field_aliases = ArrayField(models.TextField(), null=True)   # 字段别名
    orderby = models.TextField(default="")         # 排序条件
    order = BoundedPositiveIntegerField()          # 查询在 Widget 中的顺序
    is_hidden = models.BooleanField(default=False) # 是否隐藏(用于指标部件后台查询)
    selected_aggregate = models.IntegerField(null=True)  # Big Number 选中的聚合索引

一个 Widget 可以有多个 DashboardWidgetQuery,不同查询的结果以不同颜色的曲线或柱状图叠加展示。例如,一条”API 请求数”的折线可以同时显示 count()p95(transaction.duration),尽管它们的值域不同,但可以在相同的 X 轴(时间)上对比趋势。

10.6.2 图表类部件

Sentry 支持的图表显示类型通过 DashboardWidgetDisplayTypes 枚举定义:

# src/sentry/models/dashboard_widget.py:177-205
class DashboardWidgetDisplayTypes(TypesClass):
    LINE_CHART = 0           # 折线图
    AREA_CHART = 1           # 面积图
    BAR_CHART = 3            # 柱状图
    TABLE = 4                # 表格
    BIG_NUMBER = 6           # 大数字
    DETAILS = 8              # 详情列表
    CATEGORICAL_BAR_CHART = 9  # 分类柱状图
    WHEEL = 10               # 圆环/饼图(wheel)
    RAGE_AND_DEAD_CLICKS = 11  # 愤怒点击与死点击
    SERVER_TREE = 12         # 服务调用树
    TEXT = 13                # 文本部件
    AGENTS_TRACES_TABLE = 14 # AI Agent Trace 表格
    HEATMAP = 15             # 热力图

每种显示类型有其默认的返回行数限制(DEFAULT_MAX_WIDGET_LIMIT = 10),但部分类型例外:

# src/sentry/models/dashboard_widget.py:210-213
MAX_WIDGET_LIMIT_BY_DISPLAY_TYPE: dict[int, int] = {
    DashboardWidgetDisplayTypes.CATEGORICAL_BAR_CHART: 25,
    DashboardWidgetDisplayTypes.TABLE: 20,
}

折线图(LINE_CHART):最常用的时序图类型,适用于展示随时间变化的趋势,如每分钟错误数、P95 延迟变化。底层通过 timeseries_query 函数获取数据,自动处理 zerofill 填充空时间桶。

面积图(AREA_CHART):与折线图类似,但用填充的面积表示数据量级,适合展示累积效果或总体占比趋势。

柱状图(BAR_CHART):标准柱状图,适合多维度对比(如不同环境的错误数对比)。当需要对数据进行分类而不是连续时间序列时尤为适用。

分类柱状图(CATEGORICAL_BAR_CHART):特殊的柱状图变体,最高支持 25 条数据,适合展示 Top-N 分类排名,如”按国家分布的错误数”。

热力图(HEATMAP):使用颜色深浅表示数值大小,适合展现二维分布(如按时间和事务名的错误密度)。

10.6.3 表格类部件

表格(TABLE):以行列表格形式展示数据,每行对应一个查询结果,每列对应一个字段或聚合函数。表格部件最多显示 20 行(MAX_WIDGET_LIMIT_BY_DISPLAY_TYPE[TABLE] = 20)。表格支持 field_aliases 字段为列头设置自定义别名。

详情列表(DETAILS):一种特殊的表格变体,通常用于展示事件的详细列表而非聚合结果。每个行可以展开查看事件的全部属性。

AI Agent Trace 表格(AGENTS_TRACES_TABLE = 14):专门为 AI Agent 场景设计的 Trace 展示表格,用于可视化 AI 工具调用链。

10.6.4 大数字部件

大数字(BIG_NUMBER,值为 6):以醒目的数字展示单个聚合结果,如”本周总错误数”、”当前 P95 延迟”。其底层通过 DashboardWidgetQuery.selected_aggregate 字段指定要显示哪个聚合函数的结果。

与其他部件不同,Big Number 部件通常不需要时间序列数据,只需一个单一的聚合值。DashboardWidgetQuery 上的 is_hidden 字段在这里发挥作用 —— 当 Big Number 需要展示相比上一周期的变化百分比时,前端的 Widget 渲染会触发一个”隐藏查询”来获取对比数据,这个查询对于用户是透明的。

10.6.5 其他特殊部件

服务调用树(SERVER_TREE):用于展示服务间的调用拓扑,通常用于微服务架构的场景,直观展示请求的上下游依赖关系。

愤怒点击与死点击(RAGE_AND_DEAD_CLICKS):专门用于前端用户体验监控的部件,展示用户反复点击无响应元素(”愤怒点击”)或点击无效元素(”死点击”)的情况。

文本部件(TEXT):一个纯文本展示部件,可以用来在仪表盘中添加说明、备注或 Markdown 格式的文档。


10.7 Dashboard 数据源

10.7.1 Errors & Transactions 数据集拆分

在早期的 Discover 中,所有的 Widget 查询都针对统一的 Dataset.Discover(联合数据集)。这一设计虽然灵活,但由于底层存储结构的不同(错误事件和事务事件使用不同的 ClickHouse 表),查询效率有限。Sentry 随后引入了数据集拆分(Dataset Split)策略,将 Widget 的数据源明确区分为错误和事务两类。

拆分策略实现在 src/sentry/discover/dashboard_widget_split.pysrc/sentry/discover/dataset_split.py 中。其核心思想是:

  1. 分析 Widget 的 fieldsaggregatescolumnsconditions 中包含的字段和函数
  2. 如果查询只包含错误相关字段(如 error.typeerror.value),则将其分配给 ERROR_EVENTS 数据集
  3. 如果查询包含事务相关字段(如 transaction.durationp50(transaction.duration)),则将其分配给 TRANSACTION_LIKE 数据集
  4. 如果查询既包含错误字段又包含事务字段,则使用完整的 DISCOVER 联合数据集

Widget 类型枚举中反映了这一拆分:

# src/sentry/models/dashboard_widget.py:50-93
class DashboardWidgetTypes(TypesClass):
    DISCOVER = 0        # @deprecated 旧版联合查询
    ISSUE = 1           # Issue 查询
    RELEASE_HEALTH = 2  # 发布健康
    METRICS = 3         # 指标查询
    ERROR_EVENTS = 100  # 拆分后的错误事件
    TRANSACTION_LIKE = 101  # 拆分后的事务类数据
    SPANS = 102         # Span 数据
    LOGS = 103          # 日志
    TRACEMETRICS = 104  # Trace 指标
    PREPROD_APP_SIZE = 105  # 预发布应用尺寸

discover_widget_split 字段记录了拆分的目标类型,dataset_source 记录了其被确定的方式(推断、用户选择、强制等,可能的值多达 12 种,包括 SPAN_MIGRATION 的多个版本)。

10.7.2 Widget 层面的数据集拆分

dashboard_widget_split.py 模块负责在 Widget 级别执行数据集拆分。它的输入是一个 Widget 的完整配置(包括所有 DashboardWidgetQuery),输出是根据字段特征分配的 DashboardWidgetTypes 值。

拆分过程中的关键决策树如下:

  1. 扫描所有查询的 fieldsconditions,提取引用的字段名和函数名
  2. TRANSACTION_ONLY_FIELDSTRANSACTION_ONLY_AGGREGATES 列表进行比较:
    • 如果命中了事务专属字段/函数 → 分配为 TRANSACTION_LIKE
    • 如果命中了错误专属内容 → 分配为 ERROR_EVENTS
    • 如果两者皆有 → 保持 DISCOVER(联合)
    • 如果无法确定 → 标记为 UNKNOWN,由系统选择默认行为

10.7.3 On-Demand Metrics 提取

对于频繁使用的 Dashboard Widget 查询,Sentry 提供了 On-Demand Metrics 机制:将 Widget 的查询条件注册为 Relay 端(事件摄入层)的预聚合规则,在事件到达时就提取出 Widget 所需的指标,而非每次渲染时实时查询 Snuba。这大幅降低了查询延迟。

On-Demand Metrics 的状态通过 DashboardWidgetQueryOnDemand 模型管理:

# src/sentry/models/dashboard_widget.py:277-343
class DashboardWidgetQueryOnDemand(Model):
    dashboard_widget_query = FlexibleForeignKey("sentry.DashboardWidgetQuery")
    spec_hashes = ArrayField(models.TextField(), default=list)
    spec_version = models.IntegerField(null=True)
    extraction_state = models.CharField(
        max_length=30,
        choices=OnDemandExtractionState.choices  # 多个启用/禁用状态
    )

extraction_state 的取值反映了复杂的生命周期管理:

  • disabled:not-applicable:该 Widget 不适用 On-Demand(数据集不支持)
  • disabled:pre-rollout:预填充状态,用于评估负载
  • disabled:manual:用户手动禁用
  • disabled:spec-limit:组织达到 spec 限制,自动禁用
  • disabled:high-cardinality:高基数检查时发现列基数过高,自动禁用
  • enabled:enrolled:在 AM1 迁移时自动启用
  • enabled:creation:在创建 Widget 时主动选择启用
  • enabled:manual:用户手动启用

某些状态转换可以被自动任务覆盖(如 disabled:pre-rollout 可以由定时任务改为 enabled:enrolled),而另一些则不可自动恢复(如 disabled:manualdisabled:high-cardinality 必须由用户手动重新启用):

def can_extraction_be_auto_overridden(self):
    if self.extraction_state in [
        self.OnDemandExtractionState.DISABLED_MANUAL,
        self.OnDemandExtractionState.DISABLED_HIGH_CARDINALITY,
        self.OnDemandExtractionState.DISABLED_SPEC_LIMIT,
    ]:
        return False
    return True

10.8 Dashboard 管理与分享

10.8.1 权限控制

Dashboard 的编辑权限通过 DashboardPermissions 模型管理:

# src/sentry/models/dashboard_permissions.py:23-53
class DashboardPermissions(Model):
    is_editable_by_everyone = models.BooleanField(default=True, db_default=True)
    teams_with_edit_access = models.ManyToManyField(
        "sentry.Team", through=DashboardPermissionsTeam, blank=True
    )
    dashboard = models.OneToOneField(
        "sentry.Dashboard", on_delete=models.CASCADE, related_name="permissions"
    )

    def has_edit_permissions(self, user_id):
        if self.is_editable_by_everyone:
            return True
        if user_id == self.dashboard.created_by_id:
            return True  # 创建者永远有编辑权限
        return self.teams_with_edit_access.filter(
            organizationmemberteam__organizationmember__user_id=user_id
        ).exists()

权限控制有两种模式:

  1. 所有人可编辑(默认)is_editable_by_everyone = True,组织中所有成员都可以修改仪表盘。
  2. 仅限特定团队is_editable_by_everyone = False,需要显式指定有编辑权限的团队列表。Dashboard 创建者始终拥有编辑权限(即使没有加入授权团队)。

权限检查通过 organization_dashboard_details.py 端点在每次编辑操作前执行。当一个用户尝试删除仪表盘时,系统会检查 has_edit_permissions,如果用户没有权限则返回 403。

10.8.2 收藏与排序

用户可以收藏仪表盘以便快速访问。收藏功能通过 DashboardFavoriteUser 模型实现:

# src/sentry/models/dashboard.py:286-294
class DashboardFavoriteUser(DefaultFieldsModel):
    user_id = HybridCloudForeignKey("sentry.User", on_delete="CASCADE")
    organization = FlexibleForeignKey("sentry.Organization")
    dashboard = FlexibleForeignKey("sentry.Dashboard", on_delete=models.CASCADE)
    position = models.PositiveSmallIntegerField(null=True)
    favorited = models.BooleanField(db_default=True)

核心 Manager 方法包括:

  • get_favorite_dashboards(org, user_id):获取用户收藏的所有仪表盘,按 position 排序
  • insert_favorite_dashboard(org, user_id, dashboard):收藏一个仪表盘(添加到列表末尾)
  • insert_favorite_dashboard_alphabetically(org, user_id, dashboard):按字母顺序插入预置仪表盘(用于新用户自动收藏)
  • unfavorite_dashboard(org, user_id, dashboard):取消收藏,自动调整后续项的位置
  • reorder_favorite_dashboards(org, user_id, dashboard_ids):拖拽排序,更新 position

排序通过原子操作实现,在取消收藏时会自动将后续位置的 position 值递减:

self.filter(
    organization=organization,
    user_id=user_id,
    favorited=True,
    position__gt=deleted_position,
).update(position=models.F("position") - 1)

10.8.3 嵌入分享

Sentry Dashboard 支持通过共享链接或嵌入 iframe 的方式与外部用户分享(包括那些没有 Sentry 账户的成员)。共享功能允许设置只读访问权限,外部查看者可以看到仪表盘的实况数据,但无法进行任何修改操作。

嵌入分享的 URL 通常格式为:

https://{organization}.sentry.io/share/dashboard/{dashboard_id}/

此功能需要组织层面启用 organizations:dashboards-share 特性标志(Feature Flag)。


10.9 Insights 模块

Insights 模块是基于 Dashboard 和 Discover 之上构建的高级分析功能,提供了特定领域(如后端性能、前端性能、移动端性能、AI Agent 追踪)的预构建视图和深入洞察能力。Insights 模块的仪表盘通过预置模板机制在后台同步:

# src/sentry/dashboards/endpoints/organization_dashboards.py:448-452
if features.has("organizations:dashboards-prebuilt-insights-dashboards", organization):
    # 同步预置仪表盘到数据库
    ...
    sync_prebuilt_dashboards(organization)

相比普通的预置模板,Insights 仪表盘提供了更深度的上下文信息:

  • Backend Overview(后端概览):展示 API 端点性能、数据库查询耗时、缓存命中率等
  • Frontend Overview(前端概览):展示 Web Vitals 指标、页面加载性能、用户交互指标
  • Mobile Vitals(移动端性能):展示应用程序启动时间、屏幕加载、帧率等移动端专属指标
  • Web Vitals:Core Web Vitals(LCP、INP、CLS)的详细监控
  • Queue/Cache(队列与缓存):后端基础设施的性能洞察
  • AI Agents:AI 应用的模型调用、工具使用、MCP 协议等可观测性
  • Laravel/NextJS Overview:框架专属的性能仪表盘

10.10 查询性能优化与最佳实践

10.10.1 查询构建器配置

Discover 的查询构建器(DiscoverQueryBuilder)支持多项性能优化配置,通过 QueryBuilderConfig 传入:

  1. auto_fields:当设为 False 时,不会自动添加 projecteventid 字段,减少查询的列数。
  2. auto_aggregations:当过滤条件中引用了聚合函数时,自动在 SELECT 中添加对应的聚合字段。在 Dashboard 渲染场景下应设为 True,避免字段缺失导致的二次查询。
  3. skip_tag_resolution:跳过标签解析步骤,在已知字段已正确解析的情况下跳过可以节省开销。top_events_timeseries 中默认启用此选项。
  4. functions_acl:函数访问控制列表,限制可用的聚合函数。通过白名单机制防止查询使用了不支持或不高效的函数。
  5. has_metrics:当设为 True 时,查询将尝试使用预聚合的指标数据而非原始事件数据,可以显著降低查询延迟(牺牲一点精度)。

10.10.2 采样与 Turbo 模式

在查询大量项目或长时间范围的数据时,Discover 自动启用采样策略以减少 Snuba 的负载:

  • Turbo 模式:当请求的项目数超过 2 个时(len(project_ids) > 2),系统启用 turbo 模式。Turbo 模式下 Snuba 不会应用 FINAL 修饰符,即不等待数据去重,在写入并发的场景下可以大幅降低查询延迟,代价是结果可能包含微小偏差。
  • 显式采样率:通过 sample 参数可以指定采样率(0.0 到 1.0 之间)。采样后的结果会按 1 / sample_rate 的倍率进行缩放。
  • Facet 查询的采样get_facets 函数在获取标签分布时,如果高频标签的总计数超过 10,000(key_names["data"][0]["count"] > 10000),则自动应用 0.1 的采样率以减少数据量。

10.10.3 Zerofill 与时间桶填充

zerofill 函数是确保时序图连续性的关键工具。当 Snuba 返回的时序数据中存在空时间桶(即该时间段内没有匹配的事件)时,zerofill 会自动插入值为零的数据点:

# src/sentry/snuba/discover.py:113-151
def zerofill(
    data: SnubaData,
    start_param: datetime,
    end_param: datetime,
    rollup: int,
    orderby: list[str],
    time_col_name: str | None = None,
) -> SnubaData:
    return_value: SnubaData = []
    start = int(to_naive_timestamp(naiveify_datetime(start_param)) / rollup) * rollup
    end = (int(to_naive_timestamp(naiveify_datetime(end_param)) / rollup) * rollup) + rollup
    data_by_time: dict[int, SnubaData] = {}
    ...
    for key in range(start, end, rollup):
        if key in data_by_time and len(data_by_time[key]) > 0:
            return_value.extend(data_by_time[key])
        else:
            return_value.append({"time": key})  # 空桶:只包含 timestamp,聚合字段为 0
    ...

通过 zerofill_results 参数(默认 True)可以控制是否启用。在 timeseries_querytop_events_timeseries 中,zerofill 都是默认启用的,因为时序图如果不展示空桶会导致视觉上的间隔和误导。

10.10.4 Facet 查询的优化策略

Facet 查询(get_facets 函数)用于生成”维度分布”面板 —— 即展示高频标签及其值。这是一个多步骤的复杂查询,优化策略包括:

  1. 先获取高频标签键:首先查询 tags_keycount(),获取出现频率最高的标签键(如 environmentreleaseos.name)。
  2. 分离查询与聚合:对于频率最高的标签键(如 environment),逐个发起单独的查询获取其 Top-N 值;对于频率较低的标签键,使用 tags_key IN 条件批量聚合查询,减少查询轮次。
  3. 项目特殊处理:在多项目场景下,将项目信息单独查询并放置在结果列表最前面,避免被分页截断。
  4. 结果重排序:最终按 (tag_key, -count, tag_value) 三元组排序,确保相同标签键的 Top 值聚集在一起。

10.10.5 批量查询与缓存

对于需要多个独立查询的场景(如 Dashboard 中多个 Widget 同时渲染),Sentry 使用 bulk_snuba_queries 批量执行,将多个 SnQL 查询打包为一次网络往返,减少连接开销:

# src/sentry/snuba/discover.py:354-356
query_results = bulk_snuba_queries(
    [query.get_snql_query() for query in query_list], referrer, query_source=query_source
)

top_events_timeseries 中,当需要同时获取 Top-N 事件和”其他”事件(Other)时,也会使用 bulk_snuba_queries 并发执行两个查询。

对于预置仪表盘的同步操作,系统使用 Redis 缓存(键格式 dashboards:sync_prebuilt_dashboards:{organization.id})通过 Redis Lock 机制避免在短时间内重复执行相同的同步任务。

10.10.6 最佳实践清单

以下是基于 Sentry 代码实现总结的 Discover / Dashboard 使用最佳实践:

查询编写

  1. 尽量在单个查询中完成需求 —— 减少 Snuba 的网络往返次数。如果确实需要多个独立查询,利用 bulk_snuba_queries 批次执行。
  2. 使用精确的过滤条件缩小数据范围 —— conditionsquery 字符串处理更高效(前者直接透传给 Snuba 而不经过额外的解析层)。
  3. 在不需要精确计数时启用采样 —— sample=0.1 可以让结果快 10 倍,适合大数据量的探索性分析。
  4. 对于聚合类查询(非 Detail 视图),将 auto_fields 设为 False —— 不自动添加 projecteventid 列可以减少 IO。
  5. 对于 Top-N 分析,合理设置 limit 不要超过实际需要 —— 每个额外的 Top 条目都会产生一条独立的时序查询。

仪表盘设计

  1. 一个仪表盘的 Widget 数量控制在 30 以内(系统硬限制),但实际建议不超过 10-15 个以保证加载速度。
  2. 利用预置仪表盘作为起点 —— Sentry 官方提供的模板(Web Vitals、Backend Queries、HTTP 等)覆盖了最常见的监控场景,直接使用可以避免重复造轮子。
  3. 对于高频使用的 Widget,启用 On-Demand Metrics —— 将查询条件注册为 Relay 端的预聚合规则,可以让 Dashboard 渲染从”实时查询”变为”读取预计算指标”,延迟降低 10 倍以上。
  4. 利用 Dashboard 的全局过滤器(filters JSON 字段)而非在每个 Widget 中重复设置相同的过滤条件 —— 一次性修改时间范围或环境选择可以影响整个仪表盘。

性能优化

  1. 使用 referrer 追踪查询来源 —— 每个 Discover 查询都应带上明确的 referrer 值,以便在 Snuba 的慢查询日志中定位瓶颈。
  2. 时序查询合理设置 rollup 值 —— rollup 越小,时间桶越多,产生更多的 ClickHouse 分区扫描。对于 7 天以上的时间范围,建议 rollup >= 1h
  3. 利用 transform_alias_to_input_format=True 时注意开销 —— 该选项会对每个聚合函数进行反向格式转换,仅在需要时开启。