iii 全链路可观测性实战:用控制台与 engine 级日志/追踪观测 Linkly 短链服务
【免费下载链接】iiiEffortlessly compose, extend, and observe every service in real-time for the first time ever.项目地址: https://gitcode.com/GitHub_Trending/mo/iii
本教程基于 Linkly 短链服务(iii 项目的官方实战教程第二章),讲解 iii 系统的端到端可观测性:无需为每个服务单独接入 tracing 库,只要跨 worker 调用流经引擎,引擎就能自动为整个系统生成分布式追踪与结构化日志。读完本章,你将掌握三件事:用iii console实时查看 worker、trace 与日志;为函数补充结构化日志并通过engine::logs::list直接读取;用engine::traces::list/engine::traces::tree还原一次跨 worker 请求的完整调用树与耗时分布,从而定位"哪个环节拖慢了请求"。
为什么 iii 的可观测性是"系统属性"而非"服务插件"
在传统微服务架构里,端到端追踪通常意味着在每个服务中引入 tracing 库、手动传递 request ID、统一上报格式,才能把散落的日志串成一条链路。iii 的不同之处在于:每一次跨 worker 调用(worker.trigger)都必然经过引擎,因此引擎天然拥有整张调用图,可以替整个系统完成 tracing 与日志采集,而不需要每个 worker 各自接入 SDK 或透传上下文。
这一点在 Linkly 教程里体现得非常直接:iii project init创建项目时,默认的config.yaml中已包含iii-observabilityworker(引擎内置注入,无需也不应在config.yaml中重复声明,详见 engine/src/workers/observability/README.md)。此后:
- 每个请求都会自动获得一条 trace;
- 每个
Logger输出(例如logger.info(...))都会被自动采集并关联到对应 trace; - 所有 worker 的日志与 span 统一汇入同一个存储与查询面。
也就是说,可观测性不是"装在每个服务上的附加件",而是系统本身固有的属性。这也正是本章想让你停下来体会的核心观点。
打开控制台:观察 Linkly 实时运行
前置条件是引擎仍在运行(第一章启动的引擎不要关)。另开一个终端启动控制台——一个用于检视引擎的浏览器 UI:
iii console在浏览器打开 http://127.0.0.1:3113。控制台会列出你添加的每一个 worker,以及它们注册的函数(functions)与触发器(triggers)。左侧导航栏的 WORKERS、FUNCTIONS、TRIGGERS、STATES、QUEUES、TRACES、LOGS、CONFIG 等面板分别对应引擎的不同可观测面。
现在制造一点流量,让调用在界面上"实时流进来":先创建一个短链,再连续访问它 5 次:
curl -s -X POST http://127.0.0.1:3111/links \ -H 'Content-Type: application/json' -d '{"url":"https://iii.dev","code":"iii"}' for n in $(seq 1 5); do curl -s -o /dev/null http://127.0.0.1:3111/s/iii; done回到控制台的 TRACES 面板,你会看到GET /s/:code的追踪条目持续刷新。点击任意一条 redirect 追踪,右侧会展开完整的瀑布视图(waterfall),展示一次跳转如何从iii-http进入linkworker、再经引擎折返的完整 span 时间线:
注意:为了得到这些追踪,你没有添加任何 tracing 库,也没有在服务之间手动传递 request ID。这正是前文"系统属性"的直观印证。
可观测性的底座:iii-observability 与 OpenTelemetry
控制台只是iii-observabilityworker 提供的一个展示面。从源码与内置文档看(engine/src/workers/observability/skills/SKILL.md),该 worker 基于OpenTelemetry(OTel)构建:分布式追踪、结构化日志、带 rollup 的指标、告警规则、采样配置、baggage 传播全部开箱即用,且每个能力面都是一个可调用的engine::*函数。
这意味着你不会被控制台锁死:
- 其 traces、metrics、logs 都以 OTel 格式发射;
- 把该 worker 指向任意 OTel 兼容后端(如 Honeycomb、Grafana、Datadog 等),iii 的追踪数据即可直接流入;
- 若想保留控制台的本地查询能力、同时外发到 collector,可将 exporter 设为
both(trace 与 log 分别对应exporter与logs_exporter两个字段)。
关键的配置项(可通过configuration::set以 id 为iii-observability写入运行时配置,或直接使用对应的OTEL_*环境变量):
| 字段 | 类型/默认值 | 说明 |
|---|---|---|
enabled | boolean,默认false(OTEL_ENABLED) | 是否启用 OpenTelemetry 追踪导出 |
service_name | string,默认"iii"(OTEL_SERVICE_NAME) | trace/metrics 中的服务名,即前文日志里的service_name |
exporter | memory/otlp/both,默认otlp(OTEL_EXPORTER_TYPE) | trace 导出方式;本地开发常用memory,生产外发可用both |
endpoint | string,默认http://localhost:4317(OTEL_EXPORTER_OTLP_ENDPOINT) | OTLP collector 地址;https://走 TLS,http://走明文 |
sampling_ratio | 0.0–1.0,默认1.0(OTEL_TRACES_SAMPLER_ARG) | 全局 trace 采样比例 |
memory_max_spans | number,默认1000(OTEL_MEMORY_MAX_SPANS) | 内存中保留的最大 span 数 |
logs_enabled | boolean | 是否启用结构化日志存储 |
logs_exporter | memory/otlp/both,默认memory(OTEL_LOGS_EXPORTER) | 日志导出方式 |
logs_max_count/logs_retention_seconds | number,默认1000/3600 | 内存日志条数上限与保留时长 |
level | trace/debug/info/warn/error,默认info | 存储的最低日志级别 |
alerts | AlertRule[] | 基于指标的告警规则(如iii.invocations.error超过阈值触发) |
OTLP 传输默认走 gRPC;如需 OTLP/HTTP protobuf,可在启动引擎前设置OTEL_EXPORTER_OTLP_PROTOCOL=http/protobuf(或用OTEL_EXPORTER_OTLP_TRACES_PROTOCOL/OTEL_EXPORTER_OTLP_METRICS_PROTOCOL做信号级覆盖),iii 会自动为 traces/metrics 追加/v1/traces、/v1/metrics路径,日志则发往/v1/logs。认证头可通过OTEL_EXPORTER_OTLP_HEADERS等环境变量注入,凭证应放在环境变量或密钥管理器中,不要写进配置文件。
对大多数团队而言,控制台(或你自己的 OTel 后端)已经覆盖日常需求。本章剩余部分是一个可选深挖:直接从引擎读取同样的日志与追踪。这正是把可观测性接入脚本、CI 或 Agent 的方式。如果只想走常规路径,可以直接跳去 Ch. 3: Persist everything 学习持久化。
给link::resolve补上日志
第一章里link::create已经写入了日志(logger.info("link created", { code, url }))。为了让每一次短码解析也有据可查,给link::resolve加一行对应的结构化日志。编辑link/src/index.ts:
worker.registerFunction("link::resolve", async (payload: { code: string }) => { const stored = await worker.trigger<{ scope: string; key: string }, { url: string } | null>({ function_id: "state::get", payload: { scope: "links", key: payload.code }, }); logger.info("link resolved", { code: payload.code, found: !!stored?.url }); return { url: stored?.url ?? null }; });关键点在于logger.info("link resolved", { code: payload.code, found: !!stored?.url }):第二个参数是一个结构化对象,它会原样进入日志的log.data字段(稍后你会从引擎读回它)。由于 worker 以tsx watch方式运行(见第一章的package.json),保存文件后 worker 会自动重载,新日志即刻生效。
保存后制造一些混合流量——包含一次成功解析、5 次重复访问、以及一次必然 404 的未知短码:
curl -s -X POST http://127.0.0.1:3111/links \ -H 'Content-Type: application/json' -d '{"url":"https://iii.dev","code":"iii"}' for n in $(seq 1 5); do curl -s -o /dev/null http://127.0.0.1:3111/s/iii; done curl -s -o /dev/null http://127.0.0.1:3111/s/missing从引擎直接读取日志
控制台能看,引擎也能查。iii trigger除了调用业务函数,还可以调用引擎内置的遥测函数。读取最近 100 条日志,并用jq只筛出link resolved条目、投影出我们关心的字段:
iii trigger engine::logs::list limit=100 \ | jq '.logs[] | select(.body == "link resolved") | { body, "log.data": .attributes["log.data"], trace_id, service_name }'这里的
jq管道把响应裁剪到link resolved条目,只保留本教程关心的字段;去掉管道可以看引擎能提供的全部信息。
输出类似:
{ "body": "link resolved", "log.data": { "code": "iii", "found": true }, "trace_id": "6b20e1fe001742c25bb7dc570b57fe42", "service_name": "iii-node" }逐字段解读:
log.data与你传给logger.info的对象完全一致——这是结构化日志的价值:机器可读、可过滤、可聚合;trace_id把这条日志关联回它所属的 trace,这正是下一步要追查的线索;service_name是iii-node,说明这条日志由 Node SDK 侧的 worker 记录。
engine::logs::list支持丰富的过滤参数,对应实现见 engine/src/trigger_formats.rs 中engine::traces::list/engine::traces::tree/engine::logs::list的函数注册;日志查询面还支持start_time/end_time/trace_id/span_id/severity_min/severity_text/offset/limit等过滤器(详见 engine/src/workers/observability/skills/SKILL.md 的函数表)。此外,iii-observability还提供engine::log::info等五个发射函数(info/warn/error/debug/trace),以及engine::logs::clear用于清空内存日志存储。
追踪一次跨 worker 的跳转
每个请求同时也是一条 trace。先取最近一条 redirect 追踪:
iii trigger engine::traces::list name="GET /s/:code" limit=1拿到结果中的trace_id后,用engine::traces::tree把整条请求还原成一棵父子 span 树。下面的jq脚本会按深度缩进每个 span,并打印其service_name与耗时(毫秒):
iii trigger engine::traces::tree trace_id=<trace_id> | jq -r ' def walk(depth): (" " * depth // "") + .name + " (" + .service_name + ") " + (((.end_time_unix_nano - .start_time_unix_nano) / 1e6 * 1000 | round) / 1000 | tostring) + " ms", (.children[]? | walk(depth + 1)); .roots[] | walk(0) '
jq管道递归遍历roots树,按深度缩进并打印service_name与毫秒级耗时。一次跳转的完整路径横跨两个 worker,输出形如:
GET /s/:code (iii) 2.32 ms call http::redirect (iii) 2.228 ms call http::redirect (iii-node) 1.335 ms handle_invocation link::resolve (iii) 0.624 ms call link::resolve (iii) 0.582 ms call link::resolve (iii-node) 0.174 ms读这棵树,你能看清整条链路:
- 请求经
iii-http到达,产生根 spanGET /s/:code(引擎侧,服务名iii); - 引擎调用
linkworker 上的http::redirect,span 在引擎侧(iii)与 worker 侧(iii-node)各记录一次; http::redirect内部再经引擎调用link::resolve,同样两侧各有一个 span,直至iii-state返回结果。
每个 span 的耗时都单独计时——这就是请求慢下来时你该去看的视图:通过逐级耗时对比,能立刻判断瓶颈在引擎调度、worker 处理还是某个下游函数。注意这里 worker 侧的 span 通过 OTLP 摄取,会有短暂的导出延迟,因此刚发起的请求 trace 可能在最初一两秒看起来不完整;稍等片刻或改读稍早的 trace 即可。
engine::traces::list的查询参数
从源码看(engine/src/workers/observability/mod.rs 中的TracesListInput),engine::traces::list支持以下过滤器与分页参数,非常适合接入脚本或 Agent:
trace_id:按指定 trace ID 精确过滤;trace_ids支持一次展开一组 trace;service_name/name:按服务名或 span 名做大小写不敏感的子串匹配;status:error/pending/ok/unset;min_duration_ms/max_duration_ms:按 span 时长(毫秒,支持亚毫秒精度)过滤,这正是"找慢请求"的基础;start_time/end_time:Unix 毫秒时间窗过滤;sort_by(start_time/duration(别名duration_ms)/service_name/name,默认start_time)与sort_order(asc/desc,默认asc);search_all_spans:为 true 时按 trace 内任意span 匹配 name 过滤,而非仅匹配根 span;attribute_projection:只返回指定任意属性,缩小响应体;offset/limit(默认 100)用于分页;include_internal:是否包含引擎内部(engine.*)span,默认排除。
配套的engine::traces::spans返回完整 span 记录(含 attributes、events、links),适合需要完整载荷的细节/时间线消费者;engine::traces::tree则以trace_id为必填参数返回层级树;另有engine::traces::group_by可按属性聚合 span 统计、engine::traces::clear清空内存 span 存储。
找出最慢的短链
单条 trace 只能看一次请求;要横向对比大量请求,就按耗时把 redirect span 排序,最慢的排最前:
iii trigger engine::traces::list name="GET /s/:code" sort_by=duration_ms sort_order=desc limit=10 | jq -c '[.spans[] | ((.end_time_unix_nano - .start_time_unix_nano) / 1e6 * 1000 | round / 1000)]'最慢的 redirect 会浮到列表顶部;拿到任意一条的trace_id后用engine::traces::tree展开,就能定位是哪一个 hop(引擎调度、worker 处理、下游函数)在拖后腿。这正是把"用户感觉慢"转化为"具体 span 慢"的标准排查路径。
注:当前仓库文档中标注了一个验证遗留问题——
sort_by=duration_ms的排序在引擎某次修复落地前可能出现倒序/未排序的情况;若排序结果与预期不符,可先手动比对 span 时长,待引擎修复后此行为将恢复正常。
小结与下一步
至此,Linkly 已经"可观测"了:
- 控制台(
iii console)实时展示每一个 worker、trace 与 log; - 用
iii trigger engine::logs::list可读取同样的结构化日志,trace_id将日志与 trace 关联; - 用
engine::traces::tree可把一次跨两个 worker 的跳转还原成带逐级耗时的 span 树; - 用
engine::traces::list的排序与时长过滤能力可横向对比、找出最慢请求。
你全程没有引入任何 tracing 库或手动透传 request ID——端到端可观测性是 iii 系统的固有属性,由引擎内置的iii-observabilityworker(OTel 底座)自动提供,且同一份数据既可查于控制台、也可经engine::*函数接入脚本/CI/Agent,还能以标准 OTel 协议外发到任意兼容后端。
目前的链接仍只保存在内存中(第一章把iii-state设成了in_memory),重启引擎即被清空。下一步进入 Ch. 3: Persist everything,把短链数据迁入持久化存储。
【免费下载链接】iiiEffortlessly compose, extend, and observe every service in real-time for the first time ever.项目地址: https://gitcode.com/GitHub_Trending/mo/iii
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考