Supabase Studio 日志查询开发指南:SafeLogSqlFragment 品牌化 SQL 与 OTEL/BigQuery 双路径接入规范
本篇指南面向需要为 Supabase Studio 编写分析日志 SQL 的 TypeScript 开发者,系统讲解如何把日志查询写得安全(品牌化防注入)、路由正确(按 PostHog 特性开关选择 ClickHouse OTEL 或 BigQuery 端点)、风格一致(沿用既有 OTEL builder 约定)。读完后,你将掌握 SafeLogSqlFragment 的完整 API 与逃生边界、logsAllEndpointUrl / pickLogsQueryBuilder 的端点选择机制,以及 otelTimestampToMicros 等 JS 归一化层的用法,能够独立完成一条符合团队规范并可通过单测验证的新日志查询。
适用场景:什么时候该遵循这套规范
这套规则约束的是在 apps/studio 中构建或执行日志查询的 TypeScript 代码,而不仅仅是在 Logs Explorer UI 里手敲一条查询。判断标准是:只要你的改动涉及“用代码拼 SQL 并发送请求”,就必须遵循本文全部约定;只是在 UI 中为用户编写查询文本,则适用另外一组规则(用户文本走 untrustedLogSql 标记,见下文“用户手写 SQL 的晋升边界”)。
品牌化 SQL:SafeLogSqlFragment 与防注入模型
为什么 analytics SQL 也有注入风险
分析日志查询(云上 legacy 走 BigQuery,自托管 OTEL 走 ClickHouse)与 Postgres 查询承担同样的注入风险:过滤器 key、value 以及其他 SQL 片段可能来自 URL 参数、UI 输入甚至 LLM 输出,再被拼进代表项目执行的 SQL。因此 apps/studio/data/logs/safe-analytics-sql.ts 采用了一套与 pg-meta safe-SQL 同构的“proven authorship”模型:每个来自外部的值必须先经过净化 helper 再被插值,而线上边界(executeAnalyticsSql)在编译期就拒绝普通字符串。
从源码结构看,该文件与 pg-meta 的 SafeSqlFragment 品牌刻意保持隔离(safe-analytics-sql.ts#L52):Postgres 的转义方式(E'…' 字符串、::jsonb 强制转换、双引号标识符)对 BigQuery/ClickHouse 并不安全——比如 BigQuery 中双引号包裹的是字符串字面量而非标识符。两个品牌互不相通,就杜绝了“Postgres 转义过的片段被误拼进分析查询(或反过来)后静默产出不安全 SQL”的可能。
文件头部注释还说明了两引擎共享的转义约定,这解释了为什么同一套 helper 能同时服务 BigQuery 与 ClickHouse:
- 字符串字面量:ClickHouse 与 BigQuery 约定一致——单引号翻倍(
'')、反斜杠翻倍(\\),外层用普通'…'定界; - 标识符:BigQuery 要求反引号,ClickHouse 两者都接受,实现中统一使用反引号形式(safe-analytics-sql.ts#L181-L196)。两种引擎下引号内的反斜杠都是转义字符,所以代码直接拒绝任何不符合
[A-Za-z_][A-Za-z0-9_]*的输入,而不是尝试转义——列名实际上从不包含特殊字符。
核心导出 API 全解
所有 analytics 日志 SQL 都必须是 SafeLogSqlFragment,且必须用 apps/studio/data/logs/safe-analytics-sql.ts 中的 helper 构建。这一点由 eslint 强制检查,而品牌机制保证被插值的值不会演变成注入。关键导出如下:
| 导出 | 签名与行为 | 源码依据 |
|---|---|---|
safeSql\...`` |
标签模板,仅接受 SafeLogSqlFragment 插值;普通字符串(以及 Postgres 品牌的 SafeSqlFragment)在编译期被拒绝,因此不可能“顺手”塞入一个原始值 |
safe-analytics-sql.ts#L110-L118 |
analyticsLiteral(value) |
将 string/number/boolean 转换为安全转义的字符串字面量片段(单引号与反斜杠均被转义)。所有动态值(尤其 source)都必须走它。数字要求有限值,非有限值直接抛错;布尔转为 true/false 片段 |
safe-analytics-sql.ts#L138-L158 |
joinSqlFragments(fragments, separator) |
用固定的结构性分隔符(限定在 ','、', '、' and '、' AND '、' or '、' OR '、' union '、' UNION '、';\n'、'\n'、' ' 等联合类型内)拼接已安全的片段 |
safe-analytics-sql.ts#L89-L136 |
keyword(value, allowed) |
将输入值与编译期允许清单(一组已品牌化的片段)做大小写不敏感匹配,返回清单内的片段、绝不返回原始输入;匹配失败则抛错。典型用法:keyword(op, [safeSql\AND`, safeSql`OR`])` |
safe-analytics-sql.ts#L170-L179 |
quotedIdent(value) |
对点分标识符路径逐段校验 [A-Za-z_][A-Za-z0-9_]* 后,每段加反引号。例如 quotedIdent('request.method') → `request`.`method`。反引号两种引擎都接受,因此同时服务 BigQuery 与 ClickHouse |
safe-analytics-sql.ts#L181-L196 |
需要强调:这里刻意没有导出任何 “raw” 逃生口。源码中确实存在一个 rawSql 函数(safe-analytics-sql.ts#L126-L128),但它是内部专用的品牌化工具、未导出——外部调用者只能通过 safeSql 加上述净化 helper 组合,绝不能把任意字符串强转为品牌类型。
文档给出的最小可用示例:
import { analyticsLiteral, safeSql } from 'data/logs/safe-analytics-sql'
const source = 'edge_logs'
const sql = safeSql`
select timestamp, event_message
from logs
where source = ${analyticsLiteral(source)}
order by timestamp desc
limit 100
`
真实代码中还可以看到更完整的组合方式。Logs.utils.otel.ts 顶部就定义了两个高频组合器:attr(key) 用 safeSql\log_attributes[${lit(key)}]` 构造带转义的 map 取值,statusAsInt(key)再包一层toInt32OrZero(...)——因为 log_attributes` 里所有值都是字符串,数值字段必须显式转整型才能参与比较(Logs.utils.otel.ts#L18-L19)。
用户手写 SQL 的晋升边界
除了程序生成的 SQL,还有第二类来源:用户在编辑器里写的日志 SQL。类型系统用另一个独立品牌 UntrustedLogSqlFragment 承接它(safe-analytics-sql.ts#L65-L75),并规定唯一的晋升通道 acceptUntrustedLogsSql 只能出现在绑定了明确用户动作的事件处理器里(Run 按钮的 onClick、Cmd+Enter 的 keydown)——绝不能在 render、useEffect 或任何没有用户手势的路径上调用(safe-analytics-sql.ts#L77-L87)。该函数是“安全边界”:未经晋升的不可信 SQL 可以展示、可以存为编辑器工作文本,但永远不可执行。
按特性开关选择端点与 builder
ClickHouse 路径由 PostHog 特性开关 otelLegacyLogs 门控(通过 common 包的 useFlag('otelLegacyLogs') 读取,useLogsSqlExecution.ts 中可见实际用法),且要求开关关闭时 BigQuery 路径继续可用。apps/studio/data/logs/logs-endpoint.ts 里两个 helper 表达了这个分叉(logs-endpoint.ts):
-
logsAllEndpointUrl(useOtel)—useOtel为真时返回logs.all.otel端点,否则返回 legacy 的logs.all端点:export const logsAllEndpointUrl = (useOtel: boolean) => useOtel ? ('/platform/projects/{ref}/analytics/endpoints/logs.all.otel' as const) : ('/platform/projects/{ref}/analytics/endpoints/logs.all' as const) -
pickLogsQueryBuilder(useOtel, otel, bq)— 在 OTEL builder 与 BigQuery builder 之间做选择,并且用泛型T保留输入类型,让调用方维持原有签名:export const pickLogsQueryBuilder = <T>(useOtel: boolean, otel: T, bq: T): T => useOtel ? otel : bq
组合起来的写法:
const useOtel = useFlag('otelLegacyLogs')
const builder = pickLogsQueryBuilder(useOtel, genDefaultQueryOtel, genDefaultQuery)
const endpoint = logsAllEndpointUrl(useOtel)
// 在 React Query key 中包含 { otel: useOtel },让两条路径各自独立缓存
生成的片段最终通过 executeAnalyticsSql(execute-analytics-sql.ts)发往对应端点。它是分析路径的“线上边界”:参数 sql 必须携带 SafeLogSqlFragment 品牌,普通字符串在编译期被拒绝。它的完整参数面比文档描述的更丰富,值得逐条了解(execute-analytics-sql.ts#L23-L47):
projectRef/endpoint— 端点被限定为AnalyticsSqlEndpoint联合类型(当前只有logs.all与logs.all.otel两个成员,后续端点迁移到该模式时扩展这个联合即可);iso_timestamp_start/iso_timestamp_end— ISO 时间范围,随sql一起进 POST body 或 GET query string;method— 默认'post';迁移 legacy GET 调用者时可传'get'以保持线上行为;key— 可选的 query-string 键,仅用于网络工具(DevTools)识别,不属于 OpenAPI schema;signal/headers— 标准的取消与请求头透传。
沿用既有 OTEL builder:查询形状与归一化约定
需要新的查询形状时,应模仿 Logs.utils.otel.ts 中的生成器,而不是另起炉灶。这些生成器已经固化了团队约定,核心导出包括:
- 四个查询 builder:
genDefaultQueryOtel(table, filters, limit = 100)(L262-L273)— 行数据预览:选择真实列,加上按 source 的log_attributes[...]查找并别名到渲染器期望的叶子名;genCountQueryOtel(L275-L278)— 计数;genChartQueryOtel(L291-L311)— 按时间桶(12 小时内按分钟、72 小时内按小时、更长按天,对齐 BigQuery 行为)输出ok_count/error_count/warning_count;genSingleLogQueryOtel(id)(L313-L323)— 按 id 取单条日志,入口先用正则拒绝非 uuid 形态的 id(注入防护)。
- JS 归一化层:
mapOtelPreviewRow、mapOtelSingleLogToLegacy、otelTimestampToMicros。OTEL 行的timestamp是 ISO 字符串,而分页游标与渲染器要求微秒数字,因此必须复用otelTimestampToMicros(实现是parseOtelTimestamp(timestamp).getTime() * 1000,L325-L327),不要自己解析 ISO 字符串。mapOtelSingleLogToLegacy还会用 zod 校验行形状(OtelLogRowSchema,L377-L382),并按查询类型从扁平的log_attributes重建出详情面板期望的嵌套metadata结构。
OTEL_SOURCES 描述符:每表的 source、列与过滤器约定
builder 背后是一张 OTEL_SOURCES: Record<LogsTableName, OtelSourceDescriptor> 表(L126-L219),它声明了每张日志表的:
source值——即WHERE source = ...过滤用的字面量,如 EDGE →'edge_logs'、POSTGRES 与 PG_CRON →'postgres_logs'(pg_cron 额外挂baseCondition子集条件)、FUNCTIONS →'function_logs'、FN_EDGE →'function_edge_logs'、AUTH →'auth_logs'、MULTIGRES →'multigres_logs'、POSTGREST →'postgrest_logs'、SUPAVISOR →'supavisor_logs'、ETL →'etl_replication_logs',以及auth_audit_logs、realtime_logs、storage_logs、pgbouncer_logs、pg_upgrade_logs等;columns— 除id/timestamp/event_message之外按 source 选择的列,统一通过col(key, alias)从log_attributes取出并别名;filterTemplates— 每表可用的过滤器模板(severity、status_code、method、product 等),未列出的 key 落到兜底resolveUnknownOtelClause,即log_attributes[key] = lit(value),对非标量输入直接丢弃该子句;error/warning— 图表的严重度分类条件,各表共享如HTTP_ERROR(toInt32OrZero(log_attributes['response.status_code']) >= 500)、PG_ERROR(parsed.error_severity IN ('ERROR','FATAL','PANIC'))等预定义片段,保证同一行在图表与过滤器中分类一致。
写 WHERE 子句时,genOtelWhere 的模式是标准答案:先放 source = lit(desc.source),再按需追加 baseCondition,然后展开 buildWhereClauses 产出的各过滤子句,最后用 joinSqlFragments(conditions, ' AND ') 拼接(L237-L250)。
规范样例:map-key 发现查询
apps/studio/data/logs/otel-log-keys-query.ts 是“小而正确”的 OTEL 查询的标准范例——写新查询前先读它。fetchOtelLogKeys(otel-log-keys-query.ts#L12-L35)展示了一次完整调用链:
const sql = safeSql`SELECT arrayJoin(mapKeys(log_attributes)) AS key, count() AS n FROM logs WHERE source = ${analyticsLiteral(source)} GROUP BY key ORDER BY n DESC LIMIT 500`
const data = await executeAnalyticsSql({
projectRef,
endpoint: logsAllEndpointUrl(true),
sql,
iso_timestamp_start: start.toISOString(),
iso_timestamp_end: end.toISOString(),
method: 'post',
signal,
})
它同时体现了检查单上的多条要求:动态的 source 走 analyticsLiteral;查询按 source 过滤;带 LIMIT 500;时间窗口固定回看 7 天(LOOKBACK_HOURS = 24 * 7)。此外,otelLogKeysQueryOptions 与 useOtelLogKeysQuery 把查询选项抽成共享工厂,让响应式 hook 与命令式 queryClient.fetchQuery 调用方复用同一份缓存(staleTime 5 分钟,key 走 logsKeys.otelLogKeys(projectRef, source))。
完成前检查单
提交新日志查询前,逐条核对(该检查单继承自 codebase-integration.md):
- [ ] 每个动态值都经过
analyticsLiteral(或其他净化手段),绝不字符串拼接; - [ ] 查询按
source过滤,且包含LIMIT; - [ ] 数值型
log_attributes值都包在toInt32OrZero中(log_attributes值全部是字符串,数值字面量比较会触发 ClickHouse 类型错误); - [ ] 端点与 builder 通过
logsAllEndpointUrl/pickLogsQueryBuilder基于useFlag('otelLegacyLogs')选择; - [ ] React Query key 能区分 OTEL 与 BigQuery 两条路径(例如包含
{ otel: useOtel }); - [ ] 凡是被表格/分页游标消费的行,
timestamp都已归一化为微秒(复用otelTimestampToMicros); - [ ] 存在一个断言“生成 SQL 字符串”的单测——可参考 Logs.utils.otel.test.ts 与 safe-analytics-sql.test.ts 中的既有测试模式。
延伸阅读路径
| 关注点 | 文件 |
|---|---|
| 品牌化 SQL helper 全量实现 | apps/studio/data/logs/safe-analytics-sql.ts |
| 端点选择与 builder picker | apps/studio/data/logs/logs-endpoint.ts |
线上边界 executeAnalyticsSql |
apps/studio/data/logs/execute-analytics-sql.ts |
| OTEL 查询 builder 与归一化层 | apps/studio/components/interfaces/Settings/Logs/Logs.utils.otel.ts |
| 规范小查询范例 | apps/studio/data/logs/otel-log-keys-query.ts |
| 特性开关的实际消费方 | apps/studio/components/interfaces/SQLEditor/useLogsSqlExecution.ts |
| 生成的 SQL 字符串单测 | apps/studio/components/interfaces/Settings/Logs/Logs.utils.otel.test.ts、apps/studio/data/logs/safe-analytics-sql.test.ts |
atomcodeClaude Code 的开源替代方案。连接任意大模型,编辑代码,运行命令,自动验证 — 全自动执行。用 Rust 构建,极致性能。 | An open-source alternative to Claude Code. Connect any LLM, edit code, run commands, and verify changes — autonomously. Built in Rust for speed. Get StartedRust0622
Hy4-previewHy4 preview 是由腾讯混元团队研发的新一代混合专家(MoE)旗舰模型。模型总参数量 770B,每个 token 激活 49B,主干共包含78层,第一层采用标准 FFN,其余 77 层均为 MoE 结构,每层包含 256 个路由专家与 1 个共享专家,每个 token 激活 top-8 路由专家及共享专家。主干之外原生内置 1 层 MTP(总参数量 10B,激活 0.7B)以支持投机解码。Python00
GLM-5.3GLM-5.3 与 GLM-5.2 使用相同的基座模型——所有提升均来自后训练。与 GLM-5.2 相比,它在复杂编程和长程任务上的表现显著提升。Jinja00
GLM-5.3-FlashGLM-5.3-Flash (320B-A18B),是GLM-5系列的首个原生多模态模型。320B总参数,能力超过GLM-5.2Jinja00
Spark-X2.5-4BSpark-X2.5-4B 旨在让强大的 AI 更实用、更高效、更易获得。在广泛日常任务中表现强劲,涵盖对话、写作、翻译、推理、编码、工具调用以及智能体工作流,并在同等规模的开源模型中取得领先成绩。Spark-X2.5 将面向效率的架构与最高 1M tokens 的原生上下文窗口相结合,并支持 200 多种语言。Python00
Spark-X2.5-1.7BSpark-X2.5-1.7B 旨在让强大的 AI 更加实用、高效且易于获取。这些模型在广泛的日常任务中表现出色,涵盖对话、写作、翻译、推理、编程、工具调用和智能体工作流,并在同等规模的开源模型中取得领先结果。Spark-X2.5 将面向效率的架构与最高 1M tokens 的原生上下文窗口相结合,并支持 200 多种语言。Python00