PostHog Lazy Computation:ClickHouse 聚合查询的预计算、复用与一致性设计

PostHog Lazy Computation:ClickHouse 聚合查询的预计算、复用与一致性设计 PostHog Lazy ComputationClickHouse 聚合查询的预计算、复用与一致性设计【免费下载链接】posthog:hedgehog: PostHog is the leading platform for building self-driving products. Our developer tools – AI observability, analytics, session replay, flags, experiments, error tracking, logs, and more – capture all the context agents need to diagnose problems, uncover opportunities, and ship fixes. Steer it all from Slack, web, desktop, or the MCP.项目地址: https://gitcode.com/GitHub_Trending/po/posthogPostHog 的 lazy computation惰性计算模块把每次查询都全量扫描 events 表变成聚合一次、反复复用它把查询按天切窗预计算成带job_id的中间结果写入 ClickHouse后续同形状查询直接读预聚合表并用 merge 函数合并。本文基于 模块 README 与配套源码完整讲解其两种使用方式自动 HogQL 改写 手动ensure_precomputedAPI、可变 TTL 调度、基于 Redis pubsub 与 Postgres 部分唯一索引的并发协调机制以及分布式 ClickHouse 下写完即读的一致性方案读完你可以在 PostHog 的查询执行器中复用这一整套预计算框架。要解决的问题按 README 的定义lazy computation 通过保存并复用中间计算结果来加速查询与其在每次查询时扫描原始 events 表不如把聚合数据只计算一次供后续同形状查询复用。README 明确了它的设计定位——面向最大客户的最重要查询直接运行在 PostHog 规模极端的 ClickHouse 与 Postgres 集群上因此整个设计把大集群上的分布式、并发、一致性作为一等公民来对待而不只是加一层缓存。整个模块位于products/analytics_platform/backend/lazy_computation/核心文件分工如下lazy_computation_executor.py执行器主体含ensure_precomputed手动 API、LazyComputationExecutor.execute()主循环、TTL 调度与监控计数lazy_computation_transformer.py自动 HogQL AST 改写器computation_notifications.pyRedis pubsub 通知与 ClickHouse 查询存活标记models/preaggregation_job.pyPostgres 侧的PreaggregationJob作业模型CONSISTENCY.mdread-your-own-writes 一致性调研笔记。两种使用方式README 指出该模块有两种工作方式自动改写 HogQL 查询以及供查询执行器query runner使用的手动 API。自动 HogQL 改写自动路径分五步模式检测遍历 AST检查任一 SELECT 子句是否匹配受支持的 pattern例如pageview 的每日去重人数查询哈希基于查询结构、时区等设置计算稳定哈希刻意排除时间范围使同一查询不同时间窗共享同一query_hash查找已有作业在 Postgres 中查询哪些时间范围已有预计算数据补齐缺失范围对缺失的日期区间执行 INSERT 填充 ClickHouse 预聚合表改写查询把原查询重写为从预聚合表读取使用聚合 merge 函数。这个改写对调用方完全透明。README 给出的示例-- 原查询直接扫描 events SELECT uniqExact(person_id) FROM events WHERE event $pageview AND timestamp 2024-01-01 AND timestamp 2024-02-01 GROUP BY toStartOfDay(timestamp)被改写为-- 改写后读预聚合表merge 中间状态 SELECT uniqExactMerge(uniq_exact_state) FROM preaggregation_results WHERE job_id IN (...) AND time_window_start 2024-01-01 AND time_window_start 2024-02-01 GROUP BY time_window_start结合 lazy_computation_transformer.py 源码可以看到当前受支持 pattern 的具体边界。_is_daily_unique_persons_pageviews_query要求查询同时满足聚合列是uniqExact(person_id)或count(DISTINCT person_id)person_id的各种写法如person.id、events.person_id均被识别见_is_person_id_fieldWHERE 含event $pageview过滤_is_pageview_filter同时兼容equals()函数调用形态按toStartOfDay(timestamp)或等价的toStartOfInterval(timestamp, toIntervalDay(1))分组_is_to_start_of_day_timestampFROM 是events表_is_valid_events_from。Transformer是一个CloningVisitorTransformer.visit_select_query命中 pattern 后它把uniqExact(person_id)改写为uniqExactMerge(uniq_exact_state)、把toStartOfDay(timestamp)替换为预聚合表的time_window_start字段、把额外 GROUP BY 列映射到breakdown_value数组下标arrayElement(breakdown_value, N)并将 WHERE 换成time_window_start范围 job_id IN (...)。一个重要的降级行为是如果执行器返回result.ready为 False例如超时或重试耗尽改写器直接返回未改写的原查询——预计算路径失败永远不会让查询报错只会回退到慢查询。query_hash的生成逻辑在 compute_query_hash对归一化后的 ASTrepr()、团队时区、breakdown 字段列表做 JSON 序列化后取 SHA-256。时区参与哈希是因为toStartOfDay依赖团队时区时间范围不参与这正是同一查询不同窗口复用同一身份的关键。手动 APIensure_precomputed如果你正在写一个查询执行器比如 web analytics要预计算某些太复杂、无法自动改写的数据可以把手动 INSERT SELECT 交给执行器由它负责把整个时间范围切成作业并逐个填充。README 的完整示例from datetime import datetime from products.analytics_platform.backend.lazy_computation.lazy_computation_executor import ( ensure_precomputed, LazyComputationTable, ) from posthog.hogql import ast # Ensure that the given query is lazy-computed with variable TTLs result ensure_precomputed( teamself.team, insert_query SELECT toStartOfHour(timestamp) as time_window_start, [] as breakdown_value, uniqExactState(person_id) as uniq_exact_state FROM events WHERE event $pageview AND timestamp {time_window_min} AND timestamp {time_window_max} GROUP BY time_window_start , time_range_startdatetime(2025, 12, 18), time_range_enddatetime(2025, 12, 25), # Variable TTL: recent data refreshes more often ttl_seconds{ 0d: 15 * 60, # current day: 15 min 1d: 60 * 60, # previous day: 1 hour 7d: 24 * 60 * 60, # last week: 1 day default: 7 * 24 * 60 * 60, # older: 7 days }, tableLazyComputationTable.PREAGGREGATION_RESULTS, # Custom placeholders can be passed too placeholders{some_filter: ast.Constant(valuefilter_value)}, ) # A single int TTL still works for uniform expiry result ensure_precomputed( teamself.team, insert_query..., time_range_startdatetime(2025, 12, 18), time_range_enddatetime(2025, 12, 25), ttl_seconds24 * 60 * 60, # 1 day for all ranges ) # Then query from this table directly using the job_ids # Note: You still need to filter by time range since jobs may cover a wider period # e.g., job covers all of January but you only want the first week query parse_select( SELECT uniqExactMerge(uniq_exact_state) as unique_users, toStartOfDay(time_window_start) as day FROM preaggregation_results WHERE job_id IN {job_ids} AND time_window_start {time_start} AND time_window_start {time_end} GROUP BY day , placeholders{ job_ids: ast.Tuple(exprs[ast.Constant(valuestr(jid)) for jid in result.job_ids]), time_start: ast.Constant(valuedatetime(2025, 12, 18)), time_end: ast.Constant(valuedatetime(2025, 12, 25)), }, ) # note that this is using HogQL, which automatically adds a team_id condition对照 ensure_precomputed 源码有几个 README 示例未展开的契约值得注意占位符契约查询必须使用{time_window_min}/{time_window_max}占位符执行器会为每个作业自动替换成该作业的时间范围这两个名字是保留字RESERVED_PLACEHOLDERS与自定义占位符冲突会直接抛ValueError。自动追加的列team_id第一个、job_id第二个、expires_at最后一个由_build_manual_insert_sql自动加到 SELECT 列表所有 SELECT 表达式必须带别名。哈希与插入分离的哨兵占位符time_window_min/max在计算哈希时被替换为哨兵常量__TIME_WINDOW_MIN__等保证同一模板不同窗口得到同一哈希sentinel_placeholders参数允许对每次请求都变但不该使缓存失效的占位符例如datetime.now()做同样的处理而真正值在 INSERT 时照常代入。额外可调参数源码签名中可见README 未逐一展开spill_to_disk为高基数 breakdown 的 GROUP BY 设置max_bytes_before_external_group_by溢出阈值防止 OOM、stale_while_revalidate_seconds借鉴 RFC 5861 的 stale-while-revalidate本应内联计算的请求直接从最近 N 秒内过期的 READY 作业返回staleTrue的结果由调用方后台刷新、run_insertsFalsecheck-only 模式只做检查不做 INSERT未覆盖时立即返回readyFalse适合前台查实时、后台预热的读路径、end_is_data_horizon当 INSERT 自身把time_range_end烤进过滤条件时作业声明不得越过该 horizon。返回值LazyComputationResult带ready、job_ids、errors、memory_exceeded、stale字段memory_exceeded让调用方感知 ClickHouse OOMcode 241以对该团队后续的插入做降级而无需解析错误文本。目标表由LazyComputationTable枚举约束lazy_computation_executor.py除了preaggregation_results还包括experiment_exposures_preaggregated、web_stats_preaggregated、web_stats_paths_preaggregated、web_stats_frustration_preaggregated等十余张产品专用的预聚合表——可见这套框架已被多个产品线复用。可变 TTL 与 TtlSchedulettl_seconds参数接受int统一 TTL或dict按日期区间的 TTL。README 对 dict key 的完整说明是key 用relative_date_parse以团队时区解析——0d— 截断到今天零点今天起的窗口匹配1d— 截断到昨天零点昨天起的窗口匹配7d— 7 天前的截断上周起的窗口匹配24h— 24 小时前的截断2w— 2 周前的截断2026-02-15— 指定日期的截断default— 比所有截断都更老窗口的兜底 TTL匹配规则是最具体者优先短周期优先。读路径上对请求 TTL 而言过期太久的已有作业会被跳过并重算写路径上每个作业按自己日期区间对应的 TTL 创建——不同 TTL 的区间永远不会合并进同一个作业。源码中 TtlSchedule 把这套规则实现为按 cutoff 降序排列的(cutoff_datetime, ttl_seconds)列表get_ttl取第一条window_start cutoff的规则否则用default_ttl_seconds。parse_ttl_schedule 会把 dict 解析为该结构并对非法 key、非正 TTL 抛ValueError。在此基础上源码还暴露了几个 README 未展开、面向真实负载的调度能力均可通过直接传入构造好的TtlSchedule生效max_window_days限制单个作业覆盖的最大天数与 TTL 无关——用于给高基数团队限制单个 INSERT 的 GROUP BY 哈希表宽度settling_period_seconds窗口数据在结束后还会继续变化的时长例如 web analytics 的 24 小时 session 收尾期。在窗口稳定之前计算出的作业只算到稳定时刻为止的新鲜数据不会把运动中的快照按长 TTL 冻结住empty_result_ttl_seconds/empty_result_max_age_seconds零行 INSERT 与真的没数据在 SQL 层不可区分可能是数据源还没同步进来。对数据仓库这类可能滞后的源空结果作业的 TTL 会被压短让窗口更快被重算max_age则限制只对还可能有数据补来的近期空窗口生效避免把 7 天 TTL 的历史变成 6 小时一次的空扫描default_ttl_jitter_secondsbackfill 会让所有分块同一天建好统一 TTL 下整个历史会同时过期、下一次读全部重建。jitter 用窗口起始日期做 SHA-256 得到稳定偏移量_ttl_jitter_offset刻意不用进程内加盐的hash()让各分块错峰过期。split_ranges_by_ttl 负责把缺失区间先展开成每日窗口、逐窗口赋 TTL、再把相邻且 TTL 相同且未超max_window_days的窗口合并保证一个作业只服务一种 TTL。两个值得记住的常量DEFAULT_TTL_SECONDS 7 天EXPIRY_BUFFER_SECONDS 48 小时——ClickHouse 行的 TTL 始终比 Postgres 作业的expires_at多留 48 小时缓冲防止PG 里查到作业、正在使用等待别的作业时它恰好过期被 ClickHouse 删掉的竞态。作业模型Postgres 记账部分唯一索引仲裁Postgres 侧的 PreaggregationJob 记录每个预计算作业team、time_range_start/end、query_hashSHA-25664 字符、statuspending/ready/stale/failed四态、computed_at、expires_at、error并建有(team_id, query_hash)、(team_id, status)、(team_id, time_range_start, time_range_end)、(team_id, expires_at)四个索引。并发仲裁的核心是迁移 0004_unique_pending_job_index.py 建的部分唯一索引CREATE UNIQUE INDEX CONCURRENTLY IF NOT EXISTS unique_pending_job_per_range ON analytics_platform_preaggregationjob (team_id, query_hash, time_range_start, time_range_end) WHERE status pending;注意WHERE status pending这个条件它只约束进行中的作业同一区间最多存在一个 PENDING一旦作业转为 READY行就离开索引过期数据可以被替换重建。create_lazy_computation_job 用bulk_create(ignore_conflictsTrue)发INSERT .. ON CONFLICT DO NOTHING所以抢输是静默无操作而不是回滚事务的报错随后按主键回探一次刻意钉在写连接上确认自己的行是否真的插入。并发与竞态处理这是 README 花最大篇幅、也是整个设计最有含金量的部分。执行器以同步内联方式在execute()内处理作业——没有后台队列PENDING 的含义就是某个 pod 里有一个 INSERT 正在跑。等待进行中的作业当查询 B 请求的数据正被查询 A 计算时B 不会重复建作业而是等待 A 完成。执行器为每个 PENDING 作业订阅 Redis pubsub 频道频道名preagg:job:{job_id}见 computation_notifications.py作业完成时发布通知、等待方立刻被唤醒默认等待超时 180 秒。Redis 只是快速唤醒的优化而非事实来源——PG 作业状态才是权威等待方在首次进入、INSERT 之后、以及被通知或 pubsub 超时唤醒时会查 PG。源码中等待循环用指数退避的轮询间隔兜底1 秒起步翻倍至 30 秒封顶LazyComputationExecutor的构造参数完整列出了这些可调项wait_timeout_seconds180、poll_interval_seconds1.0、max_poll_interval_seconds30、max_retries1、stale_pending_threshold_seconds60、ch_start_grace_period_seconds60。每个 job ID 只执行一次 INSERT每个作业 ID 恰好对应一条 INSERT 语句。这一点至关重要如果作业中途失败无法判断哪些数据插进去了、哪些没有用同一 job ID 重试可能产生重复或不一致的数据。set_ch_query_started 用 RedisSET NX落地这个约束——key 已存在则直接抛RuntimeError因为 job ID 复用属于 bug 而非可恢复场景。多等待者 作业失败的竞态一个作业失败时多个等待者可能同时尝试创建替代作业。部分唯一索引在数据库层面原子地保证每个区间只会出现一个 PENDING 作业README 给出的时序是作业 A 失败等待者 B 和 C 都尝试创建替代作业一个成功拿到新作业另一个的INSERT .. ON CONFLICT DO NOTHING成为静默 no-opcreate_lazy_computation_job返回None失败方在下一轮扫描发现胜出方的作业并继续等待它。替代作业沿用原区间创建替代作业时使用与失败作业完全相同的时间范围而不是原查询请求的范围。这保证所有等待者协调在同一个替代作业上即使它们最初请求的是互相重叠但不同的范围。重试预算按等待者独立计数每个等待者本地跟踪自己的失败次数。超过可配置的重试上限默认 1即总共 2 次尝试后该等待者停止重试并把作业报告为永久失败。这意味着新查询自带全新的重试预算——较老的查询放弃的地方较新的查询仍可能成功。错误是否可重试由 NON_RETRYABLE_CLICKHOUSE_ERROR_CODES 判定语法错误62、未知表/列/函数60/16/46/47、类型错误43/53、超时159、并发查询过多202、读数据量超限307、内存超限241都不重试——重试永远不会成功还会加剧集群压力错误会直接冒给调用方回退到实时查询。僵死 PENDING 作业的双阶段探测如果执行器进程在作业 PENDING 期间崩溃其他等待者通过基于 Redis 的 ClickHouse 存活检查发现它is_ch_query_alive 读poll_query_performance写入的心跳 key不需要查 PG。探测分两阶段_is_job_staleINSERT 尚未开始每个执行器跑 INSERT 前都会设置preagg:ch_started:{job_id}若该 key 不存在且作业年龄超过宽限期默认 60 秒视为僵死——执行器很可能在到达 INSERT 前就崩了INSERT 已开始但心跳过期poll_query_performance为每个活跃 ClickHouse 查询维护一个 60 秒 TTL 的心跳 key。若启动标记存在但心跳已过期且作业年龄超过僵死阈值默认 60 秒说明查询已不在运行。僵死作业被原子地标记为 FAILEDUPDATE ... WHERE status pending防止多等待者竞争随后走正常的替代流程——系统因此能从自己等待的那个进程的崩溃中恢复。写入一致性分布式 ClickHouse 下的 read-your-own-writes这套系统INSERT 完立刻 SELECT 回读在分布式 复制 ClickHouse 上存在两层不可靠性CONSISTENCY.md 记录了完整的调研Distributed 表层preaggregation_results是按sipHash64(job_id)分片的 Distributed 表默认异步转发——INSERT 先落在发起节点本地、后台线程再送目标分片返回时数据可能还不可查复制层即使数据到达目标分片默认只有 1 个副本确认写入SELECT 打到另一个副本会读到旧/空结果。调研对比了五个方案Aquorum 顺序一致性B仅 quorumCquorum SYSTEM SYNC REPLICAD直写分片表E确定性副本路由最终采用方案 E并在 _get_insert_settings 中以逐查询设置落地不依赖服务器 profile因为调研发现生产集群的 profile 配置与开发环境不一致insert_distributed_sync 1即distributed_foreground_insert的旧名保留旧名以兼容版本把 INSERT 变同步数据直达目标分片后才返回代价是到最慢分片的一次网络 RTT同机房 1–10msload_balancing in_orderINSERT 和 SELECT 确定性地优先同一副本读路径零额外开销insert_quorum auto测试与 DEBUG 单节点下设为 0多数副本确认写入才返回覆盖首选副本在 INSERT 与 SELECT 之间挂掉的故障切换边界insert_quorum_timeout 2000ms把 ClickHouse 默认 60 秒的 quorum 等待压到 2 秒——quorum 损坏时快速失败、走重试和实时查询回退严格优于挂起 10 分钟。源码注释特别说明quorum 等待超时抛出的 code 319 不在不可重试清单里重试是幂等的ReplacingMergeTree 下重复 INSERT 安全而 code 159max_execution_time仍保持不可重试。CONSISTENCY.md 还分析了为什么不用select_sequential_consistency它是不告警静默返回旧数据的组合雷区且需要insert_quorum_parallel0的每表序列化锁吞吐上限约 10 INSERT/s以及分片键sipHash64(job_id)让单作业全落单分片与WHERE job_id IN (...)的optimize_skip_unused_shards剪枝天然契合。该文档还诚实记录了两个已知局限并发首次读取者可能在最短 TTL 区间上产生约 2 倍冗余 INSERT读路径filter_overlapping_jobs保证结果字节级一致冗余行随 TTL 过期或被 ReplacingMergeTree 压缩以及过期但仍 PENDING 的行会阻塞窗口_try_fail_expired_pending_job已提供创建路径上的清理。可观测性每次execute()调用同时产出结构化日志和 Prometheus 计数器。分层思路执行器级计数器回答调用方是否被服务作业级计数器回答PG 作业的流转速度是否跟得上创建速度。Prometheus执行器级lazy_computation_executions_total每次executor.execute()调用自增一次标签为label取值outcomesuccess、timeout、non_retryable_error、max_retries_exceededcache_statehit、partial_hit、miss见下table正在填充的预聚合表如preaggregation_results作业级作业同步跑在execute()内没有后台队列PENDING 只表示某个 pod 里有一个 INSERT 在飞。对statuspending行做周期采样会漏掉两次抓取之间开始又结束的作业且对吞吐毫无信息量因此改用两个计数器在 PG 状态跃迁的精确时刻自增lazy_computation_jobs_created_total{cache_state, table}— 每插入一个 PENDING 行自增一次每个缺失区间、每个执行器一次。部分唯一索引竞争中的失败方不自增所以计数与 PG 行插入一致。cache_state镜像执行器级标签全新execute()里创建的是miss为已有 READY 数据补洞的是partial_hithit永不会出现hit 不创建任何东西。lazy_computation_job_create_conflicts_total{table}— 每次因 PENDING 行已占据unique_pending_job_per_range槽位而跳过创建时自增。稳定的背景速率是预期内的warmers 与 SWR 再验证按设计竞争相同窗口持续抬升说明大量写入方挤在同一批窗口或有超过自身expires_at的 PENDING 行在阻塞它已不再服务的窗口。每次冲突同时发一条lazy_computation.job_create_conflict结构化日志含team_id、query_hash、窗口这是这些碰撞值的唯一落点。lazy_computation_jobs_finished_total{outcome, table}— 每次作业到达终态时自增一次。outcome取值ready— INSERT 成功且写了行PENDING → READYready_empty— INSERT 成功但没写行PENDING → READY。仍是成功单列出来是因为空窗口只是临时性算好的见TtlSchedule.empty_result_ttl_seconds该占比持续上升指向数据源滞后而非查询损坏failed— INSERT 抛异常可重试或不可重试PENDING → FAILEDstale— 某等待者探测到属主执行器崩溃_try_mark_stale_job_as_failed且原子更新把行翻成 FAILEDexpired— 创建冲突发现阻塞的 PENDING 行已过自身expires_at并把它翻成 FAILED_try_fail_expired_pending_job解除窗口阻塞。与stale一样每次自增都意味着某个执行器死掉了且没做清理。统计总成功要ready与ready_empty相加告警优先用outcome~failed|stale|expired而非outcome!ready——后者会把空但成功的作业误报成问题。常用 PromQLREADME 原文示例净作业吞吐正数 积压在增长稳态应约等于 0sum(rate(lazy_computation_jobs_created_total[5m])) - sum(rate(lazy_computation_jobs_finished_total[5m]))每表失败占比sum by (table) (rate(lazy_computation_jobs_finished_total{outcome~failed|stale|expired}[5m])) / sum by (table) (rate(lazy_computation_jobs_finished_total[5m]))平均 miss 规模我们 miss 的时候到底算了多少东西sum(rate(lazy_computation_jobs_created_total{cache_statemiss}[5m])) / sum(rate(lazy_computation_executions_total{cache_statemiss}[5m]))平均 partial-hit 补算规模sum(rate(lazy_computation_jobs_created_total{cache_statepartial_hit}[5m])) / sum(rate(lazy_computation_executions_total{cache_statepartial_hit}[5m]))cache_state语义hit— 请求没有做任何新工作未创建作业、未等待partial_hit— 请求做了工作但发现已有 READY 数据miss— 请求做了工作且没有任何已有数据。窗口内全命中率与任意覆盖率sum(rate(lazy_computation_executions_total{cache_statehit}[5m])) / sum(rate(lazy_computation_executions_total[5m]))sum(rate(lazy_computation_executions_total{cache_state~hit|partial_hit}[5m])) / sum(rate(lazy_computation_executions_total[5m]))按表拆解失败sum by (table, outcome) ( rate(lazy_computation_executions_total{outcome!success}[5m]) )结构化日志lazy_computation.executed日志行携带与计数器相同的outcome、cache_state、table字段外加逐调用明细query_hash、jobs_created、jobs_waited_for、total_duration_ms、time_range_days——需要追踪单个具体请求而非聚合曲线时用它。局限性与已知取舍README 的 Limitations 一节如实列出了这套方案的成本应当随功能一起理解自动改写只支持非常特定的查询 pattern当前即每日 pageview 去重人数一类person merge 与迟到事件late-arriving events可能造成数据陈旧——这也是可变 TTL近期短、远期长和settling_period_seconds存在的根因跑执行器 回读结果比直接读原始结果大约慢 30%——预计算不是免费的只有高频复用的查询才划算存中间结果要占空间不能无脑 YOLO 全量预计算。README 的 TODOs 也值得一读它勾勒出当前架构最痛的点等待期间整个 Django 线程被阻塞却没有做有用功计划用异步查询涉及 celery 配合解决stale枚举值目前未被真正使用僵死作业直接标 errored尚缺 PostHog 内部的作业状态流转日志。测试与进一步阅读执行器与改写器各有专门测试tests/test_lazy_computation_executor.py 覆盖等待/竞争/僵死/重试路径包括 CONSISTENCY.md 中提到的并发首次读取冗余 INSERT 的确定性复现test_for_loop_creates_duplicate_after_peer_completes_mid_looptests/test_lazy_computation_transformer.py 配合 test_lazy_computation_transformer.ambr 快照验证 AST 改写结果。建议的阅读顺序是 README行为契约→lazy_computation_executor.py主循环→ CONSISTENCY.md一致性决策背景→ 迁移文件PG 侧约束即可完整重建这套Postgres 记账、ClickHouse 存中间态、Redis 做唤醒的预计算系统。【免费下载链接】posthog:hedgehog: PostHog is the leading platform for building self-driving products. Our developer tools – AI observability, analytics, session replay, flags, experiments, error tracking, logs, and more – capture all the context agents need to diagnose problems, uncover opportunities, and ship fixes. Steer it all from Slack, web, desktop, or the MCP.项目地址: https://gitcode.com/GitHub_Trending/po/posthog创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考