启用 otlp feature 后,agentprof-storage 内置一个双栈 OpenTelemetry receiver —— gRPC 在 :4317、HTTP/protobuf 在 :4318,原生收 Claude Code / Codex / Copilot CLI / 任何 OTel SDK 的 logs / metrics / traces 三信号,落到 SQLite store。不需要外置 collector —— agent 直接 push 到 agentprof,本地循环到「写文件 → 跑 cli」的路径在生产场景里消失。架构由 ADR-0021 钉死,安全硬化由 ADR-0022 钉死。
remote_write endpoint- producer:agent (Claude Code / Codex / Copilot CLI) 的 OTel SDK,按 OTLP 规范 push 三信号。
- wire format:OTLP — gRPC (binary protobuf) 或 HTTP/protobuf;agentprof 两个都接。
- endpoint:agentprof
serve --ingest-otlp(M2.2 子命令),监听 4317/4318。 - sink:SQLite — 通过
IngestPipeline→SessionRouter→ 同 schema 的 store DB。
和 Prometheus remote_write 不同的关键点:OTLP 是长连接 + 流式(一个 agent session 可能用一条 gRPC stream 跑数十分钟),所以 agentprof 必须维护 per-session buffer 状态 —— 这就是 SessionRouter 的存在意义,也是 ADR-0022 LRU cap 要保护的对象。
双栈协议对比(recon 真实端口 / 路径)
| 协议 / 入口 | 默认端口 / 路径 | 适用场景 |
|---|---|---|
| gRPC serve_grpc(cfg, pipeline) | :4317(OTel 官方默认) 3 个 service: LogsServiceServer / MetricsServiceServer / TraceServiceServer | 高吞吐、长连接、流式 — agent SDK 默认首选;防火墙允许 :4317 时用 |
| HTTP/protobuf serve_http(cfg, pipeline) | :4318(OTel 官方默认) 3 条路由: POST /v1/logs / POST /v1/metrics / POST /v1/traces | 穿透只允许 HTTP/HTTPS 的网络环境;和 reverse proxy(nginx / Caddy)友好;OTel SDK 通常 fallback 选这个 |
| TLS + Bearer tls.rs + auth.rs | 两栈都支持:tls_cert + tls_key(必须成对)+ 可选 tls_client_ca(mTLS)+ listen_token(Bearer) | 跨主机 / 生产暴露;本地回环可省。Bearer 单独可用(HTTP 上等于裸传 token,不推荐生产) |
⚠️ Recon 校正:HTTP 栈当前只支持 protobuf body(没 OTLP/HTTP+JSON 实现路径)。两栈共享同一个 OtlpServerConfig 配置 + 同一个 IngestPipeline 后端,所以混合部署(同时开 gRPC + HTTP)共享 store 状态没问题。
👇 三张卡片:① gRPC vs HTTP 怎么选 · ② ADR-0021 整体架构(含 flow diagram)· ③ ADR-0022 4 层防御逐条拆解。
1 gRPC vs HTTP/protobuf 双栈选择 点击展开
TypedEvent 进 pipeline。// crates/agentprof-storage/src/otlp/server_grpc.rs:84
pub async fn serve_grpc(
cfg: OtlpServerConfig,
pipeline: Arc<IngestPipeline>,
) -> Result<GrpcServerHandle, OtlpServerError>;
// crates/agentprof-storage/src/otlp/server_http.rs:96
pub async fn serve_http(
cfg: OtlpServerConfig,
pipeline: Arc<IngestPipeline>,
) -> Result<HttpServerHandle, OtlpServerError>;
两个 fn 都返回 JoinHandle + oneshot::Sender<()> 的 graceful-shutdown 句柄;serve --ingest-otlp 同时 spawn 两者,await 任一异常即整体退出。
serve --ingest-otlp 也可以当 backend exporter target —— 兼容并存。2 ADR-0021 整体架构 + 6 层 pipeline 点击展开
- receiver(
server_grpc/server_http)— 收 OTLP bytes,过 bearer auth / TLS / per-signal size cap,decode 成 protobuf 结构。 - mapper(
otlp/mapper.rs)— OTLPResourceLogs / ResourceMetrics / ResourceSpans→ agentprof 的TypedEvent;从 resource attrs 抽session.id(截断到 256 byte,mapper.rs:521)。 - router(
otlp/router.rs::SessionRouter)—DashMap<SessionId, SessionBuffer>;新 session 创 buffer、已存在的拿现成的;LRU evict 超过max_open_sessions = 1024。 - buffer(
otlp/router.rs::SessionBuffer)— per-session 攒事件;每 buffer 自己有 OOM cap(默认 16 MiB / 100 000 events / 5 min idle);达到任一阈值触发 flush。 - flush sink(
otlp/sink_storage.rs)— buffer 满 / session 结束 / idle 超时 → 序列化整 session 的 events → 喂 agentprof-core 的 analyzer 算AnalysisReport。 - upsert(
Db写)—INSERT OR REPLACE INTO sessions ... WHERE id = ?;同 schema 同 path 同 dual-path 兼容性。
DashMap 而不是 Mutex<HashMap>?Mutex<HashMap> 会让全部 stream 串行化,吞吐塌方。DashMap 是 sharded map(默认 64 段),每个 shard 独立 lock,不同 session 完全并行。Tradeoff:iter 不是一致 snapshot(LRU sweep 时要小心),但 agentprof 的 LRU 是 epoch + heap 维护、不依赖 map iter 顺序。(JoinHandle, oneshot::Sender<()>)。cli 收到 SIGINT/SIGTERM → 通过 oneshot::send(()) 触发;server 内部 select shutdown signal vs tonic / axum 的 graceful 关闭,等 in-flight request 处理完才退;同时 SessionRouter flush 所有 buffer 落 SQLite —— 不丢已收的 event。这是 cli 退出码 0(正常)vs 130(SIGINT)的关键。3 ADR-0022 4 层防御逐条拆解 点击展开
subtle::ConstantTimeEq)// crates/agentprof-storage/src/otlp/auth.rs
use subtle::ConstantTimeEq;
const BEARER_PREFIX: &str = "Bearer ";
// 字符比较走 ConstantTimeEq -- 不让 attacker 用 timing 探测 token 前缀
为什么:naive 的 == short-circuit —— attacker 喂「a」「b」「c」...,agentprof 在错的字节就早退;通过测「拒绝多花了几纳秒」能逐字符暴力。subtle::ConstantTimeEq 对每个字节都走完比较再返结果,时间分布不泄露信息。不防什么:长度差异本身可被旁路检测(agentprof 假设攻击者已知 token 长度 —— 通常 32 字节 hex);这不是问题,密码学社区认可的取舍。
max_decoding_message_size / DefaultBodyLimit).max_decoding_message_size(cfg.max_*_request_bytes)(在 InterceptedService 之前);HTTP 侧每条路由单独挂 DefaultBodyLimit::max(...) layer。关键细节(server_http.rs 注释里专门标了):router-level middleware 不能 drain body,否则 413 保护会先 OOM 再触发 —— 加 logger / decompressor 时要把它移到 per-route layer 里。SessionRouter LRU eviction (cap = 1024)// crates/agentprof-storage/src/otlp/router.rs:171
SessionBufferCaps {
max_bytes: 16 * 1024 * 1024, // 16 MiB per session
max_events: 100_000, // per session
max_idle: Duration::from_secs(5 * 60), // 5 min
max_open_sessions: 1024, // 全局 router 容量
}
问题:attacker 用大量不同 session.id 发 1 event 然后停 —— 每个 session 都创 buffer,DashMap 无限增长。方案:第 1025 个 session 来时,evict 最久未触达的那个 —— 触发 CloseReason::CapacityEvict,flush 它的 buffer 到 SQLite,腾出位。取舍:被 evict 的 session 如果之后又有 event 来,会被视作新 session 重新建 buffer —— 极端情况会被切成多段 SessionRef,但数据不丢。1024 这个数字是「单机一天健康活跃 session ≤ 几十、留 20x 余地」拍的,可调。
session.id 256-byte 上限// crates/agentprof-storage/src/otlp/mapper.rs:521
// ADR-0022 D-5: cap session_id at 256 bytes BEFORE allocating
// the SessionId String -- prevents an attacker
// from forcing GiB-sized id allocations.
问题:OTLP resource attrs 是 string —— attacker 设 session.id 为 1 GB string,agentprof 在 String::from 时就 OOM。方案:从 wire bytes 取 id 前 先看长度,超 256 直接截断 / reject。256 byte 远超合理 session id(UUID 36 字节、SHA256 64 hex 字符)—— 给 prefix path 也留足空间。这个 cap 在 mapper.rs 里、在 SessionRouter::lookup_or_create 之前,确保恶意 id 永远进不了 router map。
下一步
本课讲清了 OTLP receiver 的双栈、ADR-0021 6 层 pipeline、以及 ADR-0022 4 层防御的真实代码位置。下一课「Web dashboard 架构」翻 agentprof serve 的另一面 —— 5 视图 localhost 看板怎么 reuse M2.2 axum 栈、chunk-endpoint pattern 怎么省掉 React/Vue 整个构建链(ADR-0024 D-1..D-7)。
📂 相关源码:
agentprof-storage/otlp/server_grpc.rs
serve_grpc
📂 相关源码:
agentprof-storage/otlp/server_http.rs
serve_http
📂 相关源码:
agentprof-storage/otlp/router.rs
SessionBufferCaps