Effect 可观测性:Effect.fn 命名、Metric 基数与错误上报的边界
上一篇运行时边界与测试里,我只用几行代码介绍了 Effect.withSpan 和 Effect.annotateLogs,证明 tracing 是内置的。这一篇回答更难的问题:打什么、不打什么、放在哪一层。
API 已经够用,真正的难点是纪律。打点打多了,是噪音加成本;打错了地方,是高基数把指标后端拖垮;该上报的没上报,是出事后才发现没有证据。
下面所有代码都在 strict 与 exactOptionalPropertyTypes 下编译通过,涉及运行时行为的结论也都实际跑过。
不变量:一个事实只属于一种信号
我把三种信号的分工压成一句话:
- span 回答「这一次请求里发生了什么」,可以带高基数属性(订单号、用户 id)。
- Metric 回答「整体上有多少、多快」,标签必须低基数。
- log 回答「这个时刻发生了一件值得记住的离散事件」,是 span 的补充,不是 span 的替代。
违反这条分工,症状很固定:把 user_id 做成 Metric 标签,指标序列数随用户数线性增长;把每一次循环迭代都 Effect.log,真正的告警被淹没;只有日志没有 span,排查一个跨服务请求只能靠 grep 时间戳。
判断一个事实该进哪里,只问两个问题:它的取值集合有界吗?有界才可能进 Metric。它需要和一次具体请求关联吗?需要就进 span。
Effect.fn:让命名成为函数的一部分
span 的价值首先来自名字。靠手写 Effect.withSpan("...") 贴在调用点,名字会和函数脱节,改了函数忘了改名字。Effect.fn("name") 把名字和函数定义绑在一起:
// charge.ts
import { Effect } from "effect"
class ChargeError extends Error {
readonly _tag = "ChargeError"
}
export const charge = Effect.fn("Billing.charge")(function* (orderId: string, cents: number) {
yield* Effect.annotateCurrentSpan({ "order.id": orderId })
yield* Effect.annotateCurrentSpan("order.amount_cents", cents)
yield* Effect.sleep("1 millis")
if (cents > 1000) return yield* Effect.fail(new ChargeError("too big"))
return cents
})我的约定是 模块.动作,比如 Billing.charge、Orders.place。名字是低基数的稳定标识,会成为 trace 后端里聚合和告警的 key,所以绝不能把 id 拼进名字。变化的部分放属性。
有一个细节要纠正。我沉淀的旧资料里写着 Effect.fn 会自动把参数作为 attribute,我在 4.0.5 上实际记录 span 后确认并没有:上面的 span 只有我手动 annotateCurrentSpan 的两个属性。这是好事,自动记录参数会把请求体、令牌之类的敏感数据悄悄送进 trace 后端。属性必须是显式的、经过挑选的。
Effect.fn 还接受第二个参数,直接声明 span 选项:
import { Effect } from "effect"
export const place = Effect.fn("Orders.place", {
attributes: { "app.feature": "checkout" },
kind: "server"
})(function* (orderId: string) {
yield* Effect.annotateCurrentSpan("order.id", orderId)
})Effect.fnUntraced 是不创建 span 的版本。我的规则是:公共边界一律 Effect.fn,只有外层已经有 span 的热路径内部 helper 才用 fnUntraced。把它当默认选择,等于在最需要可见性的边界上主动关灯。
span 会如实记录失败
我用一个自定义 Tracer 记录 span,验证结论而不是凭印象:
import { Effect, Exit, Tracer } from "effect"
import { charge } from "./charge.ts"
const recorded: Array<Tracer.NativeSpan> = []
const recordingTracer = Tracer.make({
span(options) {
const span = new Tracer.NativeSpan(options)
recorded.push(span)
return span
}
})
const run = Effect.gen(function* () {
yield* charge("o1", 10)
yield* Effect.exit(charge("o2", 5000))
}).pipe(Effect.withTracer(recordingTracer))
await Effect.runPromise(run)
for (const span of recorded) {
const outcome = span.status._tag === "Ended" && Exit.isFailure(span.status.exit) ? "failed" : "ok"
console.log(span.name, Object.fromEntries(span.attributes), outcome)
}运行结果里,第一个 span 是成功,第二个 span 的 exit 是 Fail,原因也被保留在 span 上。也就是说,你不需要为了「让 trace 里看到失败」而在 catchTag 里再手动打日志;failure 会随 span 的 exit 自然带出,这是前面讲 typed errors 时那条「错误是值」的直接红利。
这段代码同时就是断言 span 的测试写法。旧资料里的 TestServices.spans 在 4.0.5 里并不存在,正确做法就是用 Tracer.make 自己造一个记录型 Tracer,再用 Effect.withTracer 注入。它不依赖任何导出包,测试里零成本。
日志:只记离散事件,结构化优先
日志要克制。我对日志的判断标准是:去掉它,事后能不能从 span 和 Metric 还原出来。能还原的就不该打。
日志的三个工具是 Effect.log*、Effect.annotateLogs 和 Effect.withLogSpan。注解对整段 effect 内的所有日志生效,所以请求级字段在入口加一次:
import { Effect, Logger, References } from "effect"
const lines: Array<unknown> = []
const capture = Logger.make<unknown, void>((options) => {
lines.push({
level: options.logLevel,
message: options.message,
annotations: options.fiber.getRef(References.CurrentLogAnnotations),
spans: options.fiber.getRef(References.CurrentLogSpans).map((s) => s[0])
})
})
const handler = Effect.fn("Orders.handle")(function* (id: string) {
yield* Effect.logInfo("start", { id })
yield* Effect.logDebug("not emitted at the default Info level")
return id
})
await Effect.runPromise(
handler("o1").pipe(
Effect.annotateLogs({ tenant: "acme" }),
Effect.withLogSpan("handle"),
Effect.provide(Logger.layer([capture]))
)
)
console.log(JSON.stringify(lines))输出只有一条 Info 日志,注解里带着 tenant,log span 里带着 handle,Debug 那条被默认的最低级别过滤掉。这里有两处和 v3 不同:自定义 logger 用 Logger.layer([...]) 安装,不再是 Logger.replace(Logger.defaultLogger, ...);最低级别用 References.MinimumLogLevel 控制,生产里提到 Warn 只需要 Layer.succeed(References.MinimumLogLevel, "Warn")。
输出格式的选择很实在:开发环境用默认的可读格式,生产用 Logger.consoleJson,一行一条 JSON,让日志平台按字段检索,而不是用正则去解析一句话。
Metric:基数是第一约束
v4 的 Metric 有 counter、gauge、histogram、timer、frequency、summary。先看一个最常见的组合,请求计数加耗时分布:
import { Effect, Metric } from "effect"
const latency = Metric.timer("checkout_latency")
const failures = Metric.counter("checkout_failures_total", { incremental: true })
export const instrument = <A, E, R>(self: Effect.Effect<A, E, R>) =>
self.pipe(
Effect.trackDuration(latency),
Effect.trackErrors(Metric.withConstantInput(failures, 1))
)
const place = instrument(Effect.fail("nope"))
await Effect.runPromise(Effect.exit(place))
console.log((await Effect.runPromise(Metric.value(latency))).count)
console.log(await Effect.runPromise(Metric.value(failures)))这里有几个我实际踩验证出来的点:
- 失败的执行也会被
trackDuration记录,计数是 1。如果换成手写的Effect.timed再tap去更新 histogram,失败路径会被直接跳过,延迟分布只剩成功样本,p99 会好看得失真。优先用trackDuration。 trackErrors会把错误值本身当作 Metric 的输入,所以直接传一个Counter<number>类型不通过。用Metric.withConstantInput(failures, 1)把输入固定为 1。- v3 的
Metric.increment和Metric.boundaries.exponential在 4.0.5 里都不存在,对应的是Metric.update和Metric.exponentialBoundaries。
标签的纪律
Metric.withAttributes 给指标加维度,每一种取值组合都是一条独立序列:
import { Effect, Metric } from "effect"
const requests = Metric.counter("orders_requests_total", { incremental: true })
const okRequests = Metric.withAttributes(requests, { route: "POST /orders", outcome: "ok" })
await Effect.runPromise(
Effect.gen(function* () {
yield* Metric.update(okRequests, 1)
yield* Metric.update(requests, 1)
const snapshot = yield* Metric.snapshot
console.log(snapshot.map((m) => `${m.id}${JSON.stringify(m.attributes ?? {})}`))
})
)快照里带标签的序列和不带标签的序列是两条,互不累加:上面对 okRequests 的更新不会出现在 requests 的计数里。这是排查「为什么总数对不上」时的第一个怀疑对象。
我的标签准入标准:取值来自编译期可枚举的集合。route 用路由模板而不是实际 URL,outcome 用 ok、error 这类有限枚举,错误类型用 _tag。订单号、用户 id、租户 id 一律不进 Metric,放 span 属性。
至于 gauge,它表示「当前值」,每次 update 会替换而不是累加。把它用来统计在途请求数时,增减必须成对出现在 acquireRelease 或 ensuring 里,否则一次中断就会让读数永久漂移。
导出:核心包够用,@effect/opentelemetry 是桥
没有外部 sink 的应用,什么都不需要装,日志和 span 已经在运行。需要导出时有两条路。
第一条是核心包自带的 OTLP 模块 effect/observability,不引入任何 OpenTelemetry SDK:
import { Layer } from "effect"
import { FetchHttpClient } from "effect/http"
import { OtlpSerialization, OtlpTracer } from "effect/observability"
export const TracingLive = OtlpTracer.layer({
url: "http://localhost:4318/v1/traces",
resource: { serviceName: "orders-api", serviceVersion: "1.0.0" }
}).pipe(
Layer.provide(OtlpSerialization.layerJson),
Layer.provide(FetchHttpClient.layer)
)我在本地起了一个只接收 POST 的 HTTP 服务,确认这个 Layer 被释放时会把 span 以 JSON 发往 /v1/traces。同一个模块里还有 OtlpLogger、OtlpMetrics 和 PrometheusMetrics。
第二条是 @effect/opentelemetry,当你已经有 OpenTelemetry SDK 生态(采样器、自动埋点、现成 exporter)时用它。4.0.5 版本与核心包同号,并且不是单包闭环,需要同时安装 @opentelemetry/* 的 peer 依赖。配置对象也和旧资料不同:resource 是普通对象而不是 new Resource(...)。
import { NodeSdk } from "@effect/opentelemetry"
import { SimpleSpanProcessor, type SpanExporter } from "@opentelemetry/sdk-trace-base"
declare const exporter: SpanExporter
export const OtelLive = NodeSdk.layer(() => ({
resource: {
serviceName: "orders-api",
serviceVersion: "1.0.0",
attributes: { "deployment.environment": "staging" }
},
spanProcessor: new SimpleSpanProcessor(exporter)
}))生产环境把 SimpleSpanProcessor 换成 BatchSpanProcessor 并接 OTLP exporter。我用一个收集型 exporter 跑过:嵌套的两个 Effect.fn 在 SDK 里确实形成父子关系,服务名也带上了。
两条路共同的规则是:导出是 Layer,只在入口装配。业务代码里不应该出现 @opentelemetry/api 的 import,也不应该出现 DSN 或 endpoint。换导出后端只改一个 Layer。
少打点:该关的要关
span 有成本,且大部分流量没有排查价值。三个工具:
Effect.withTracerEnabled(false)对一段 effect 完全不创建 span。我验证过,被包住的Effect.fn不会出现在 Tracer 收到的 span 列表里。- span 的
level选项配合References.MinimumTraceLevel:低于最低级别的 span 仍会被创建,但sampled为false,不会被导出。这适合把调试级的细粒度 span 留在代码里,生产默认不采。 - HTTP 客户端有
HttpClient.TracerDisabledWhen这个 Reference,用谓词按请求排除,比如健康检查:
import { Layer } from "effect"
import { HttpClient } from "effect/http"
export const NoHealthTrace = Layer.succeed(HttpClient.TracerDisabledWhen)(
(req) => req.url.endsWith("/healthz")
)另有一个边界:循环体内部不要为每次迭代开 span。一次批处理一个 span,条数、失败数作为属性,而不是 N 个子 span。
错误上报:Cause 不是 incident
可观测性最容易搞混的是「出错」和「出事」。typed errors 那一篇已经区分了 failure 与 defect:Fail 是预期的控制流,Die 才是 bug。上报策略要延续这个区分,而不是把所有 Effect.fail 都推给告警平台。
4.0.5 的核心包有 ErrorReporter。它在边界处接收 Cause,自动去重、跳过中断,并读取错误对象上的三个标注:忽略、严重度、属性。
import { Data, Effect, ErrorReporter } from "effect"
class NotFound extends Data.TaggedError("NotFound")<{ readonly id: string }> {
readonly [ErrorReporter.ignore] = true
}
class ChargeFailed extends Data.TaggedError("ChargeFailed")<{ readonly orderId: string }> {
readonly [ErrorReporter.severity] = "Error" as const
readonly [ErrorReporter.attributes] = { "order.id": this.orderId }
}
const seen: Array<string> = []
const reporter = ErrorReporter.make(({ error, severity, attributes }) => {
seen.push(`${error.name}|${severity}|${JSON.stringify(attributes)}`)
})
const run = (e: Effect.Effect<never, unknown>) =>
Effect.runPromise(
e.pipe(Effect.withErrorReporting, Effect.provide(ErrorReporter.layer([reporter])), Effect.exit)
)
await run(Effect.fail(new NotFound({ id: "1" })))
await run(Effect.fail(new ChargeFailed({ orderId: "o1" })))
await run(Effect.die(new Error("bug")))
await run(Effect.interrupt)
console.log(seen)实际输出只有两条:ChargeFailed 以 Error 级别带着 order.id 上报;裸的 Error("bug") 作为 defect 上报,但严重度是默认的 Info。NotFound 因为标注了忽略而被丢弃,中断则被框架自动跳过。
有一个值得警惕的默认值:没有标注严重度的 defect 并不是 Error 级。如果你的告警规则按严重度过滤,未标注的 defect 会悄悄滑过去。要么在 reporter 回调里对 defect 统一升级,要么给领域错误显式标注。
我对 reporter 的边界要求有四条:
- 指纹用稳定的错误
_tag加操作名,不用Cause.pretty或带动态内容的 message,否则同一个问题会裂成无数条。 - 投递与业务结果隔离。上报失败、超时都不能让请求失败,所以回调里只做入队,真正的网络调用放到异步批量里。
- 脱敏发生在 reporter 之前或之内:Authorization、cookie、令牌、请求体不进属性。
- 供应商 SDK 只出现在 reporter 的实现里,领域层只看见
ErrorReporter这个抽象。
小结
可观测性的设计,核心是让每个事实落在唯一正确的信号里:
- 有界取值的聚合进 Metric,高基数、与请求绑定的进 span 属性,离散事件进结构化日志。
- 公共边界一律
Effect.fn("模块.动作"),名字稳定低基数,属性显式挑选;fnUntraced只留给热路径内部。 - 延迟用
trackDuration,因为它连失败样本一起记录;Metric 标签必须来自可枚举集合。 - 导出是入口处的 Layer:核心包 OTLP 零依赖,已有 OpenTelemetry 生态再用
@effect/opentelemetry。 - 用
Tracer.make造记录型 Tracer 做 span 断言,用ErrorReporter把Cause变成可行动的事件,并显式处理 defect 的严重度。
下一步,当一个流程长到要跨进程重启、需要补偿与幂等时,可观测性就要和持久化执行结合起来,这是 Workflow 那一篇的主题。