博客 • Claude Code / OpenTelemetry

升级到 Claude Code 2.1.312 之后,OpenTelemetry 里不再出现 claude_code.safety_event,审计 JSON 要怎样改字段?

2026 年 10 月 27 日,Anthropic 发布 Claude Code 2.1.312。OpenTelemetry 导出里不再发送事件 claude_code.safety_event;该事件曾在 10 月 21 日的 2.1.307 为被 permission rule、auto 模式、safety check、hook 或 sandbox 拦下的 tool call 新增。

同版还把官方 marketplace 插件里固定 URL 远程 MCP 的 span 属性 mcp_server.name 从占位值 custom 改成真实插件服务器名。仍按 safety_event 计数或仍把 custom 当分组键的 Grafana 看板会在升级后误报,需要本地 JSON 配置加版本门槛。

拦截原因与 auto 评测上下文见 Managed Agents auto 评测;本地 permission 队列写法见 Agent permission policy;非交互 MCP 超时 JSON 见 MCP elicitation timeout。本篇只写 2.1.312 的 OpenTelemetry 字段退役与 mcp_server.name 对齐,以及 telemetry_gate 怎样分支。

先记住这一点:2.1.312 改的是 OpenTelemetry 事件名与 MCP span 属性,不是 permission rule 本身。下面按 changelog、已退役的 safety_event 样本、流水线 JSON 与 schema、mcp_server.name 对比、闸门对照表、telemetry_gate 源码,以及 JSONNote 里怎样校验配置。

2.1.312 在 OpenTelemetry 里停发 safety_event 并修正 marketplace MCP 名称

变更说明见 Claude Code changelog,标签页见 v2.1.312 release。相对 2.1.307,telemetry 面最显眼的两处是 safety_event 整条下线,以及 marketplace 固定 URL MCP 不再把 mcp_server.name 写成 custom。

permission rule、hook、sandbox 仍会拦截 tool call,只是不再单独导出 safety_event 事件。审计应改查既有 tool span 的 outcome 或你们自有的 approval JSON,而不是等 claude_code.safety_event 重新出现。

claude_code.safety_event 在 2.1.307 长什么样、312 为何不再出现?

2.1.307 把被 permission rule、auto、safety check、hook 或 sandbox 挡下的调用封装成 OpenTelemetry 事件 claude_code.safety_event,常见属性含 tool.name、outcome blocked 与 source permission_rule。下面样本仅用于对照旧导出形状,312 客户端不应再产生同名事件。

2.1.307 时代仍可能出现的 claude_code.safety_event 形状(312 已停发)。
{
  "name": "claude_code.safety_event",
  "attributes": {
    "tool.name": "Bash",
    "outcome": "blocked",
    "source": "permission_rule"
  }
}

若 312 集群仍收到该事件,优先查是否混跑旧二进制或 collector 重放旧批次。telemetry_gate 在 claudeCodeVersion 至少 2.1.312 且 span.name 仍为 claude_code.safety_event 时应返回 unexpected_event,而不是当作正常拦截计数。

审计流水线 JSON 怎样写 claudeCodeVersion 与 pipeline?

把每条导出批次绑到 claudeCodeVersion,并用 pipeline 声明你在数 safety_event 还是按 mcp_server.name 分组。312 仍写 count_safety_event 会触发 retire_counter;混跑旧客户端时版本字段必须来自 agent 自报,而不是 collector 安装日。

流水线配置:版本 2.1.312,pipeline 与 marketplace 开关。
{
  "claudeCodeVersion": "2.1.312",
  "pipeline": "count_safety_event",
  "marketplacePlugin": true
}

用 JSON Schema 固定 additionalProperties 与 enum,避免运维手写 pipeline 拼错。schema 失败时 telemetry_gate 应先返回 schema,不要在字段缺失时硬判 ok 或 retire_counter。

流水线 JSON Schema:先过形状,再进 telemetry_gate。
{
  "$schema": "https://json-schema.org/draft/2020-12/schema",
  "type": "object",
  "additionalProperties": false,
  "required": ["claudeCodeVersion", "pipeline"],
  "properties": {
    "claudeCodeVersion": { "type": "string", "pattern": "^\\d+\\.\\d+\\.\\d+$" },
    "pipeline": { "enum": ["count_safety_event", "mcp_server_group"] },
    "marketplacePlugin": { "type": "boolean" }
  }
}

marketplace 远程 MCP 的 mcp_server.name 为何从 custom 改成插件名?

2.1.312 起,来自 Anthropic 官方 marketplace 插件、固定 URL 的远程 MCP 在 tools/call span 上写入真实插件服务器名(例如 github),不再用 custom 占位。自托管或手写 URL 的 MCP 命名规则不在本篇范围;marketplacePlugin 为 true 时仍见 custom 说明分组规则未更新。

2.1.312:mcp_server.name 为 marketplace 插件真实名。
{
  "name": "tools/call",
  "attributes": {
    "mcp_server.name": "github",
    "tool.name": "create_issue"
  }
}
2.1.311 及更早:固定 URL marketplace MCP 常写成 custom。
{
  "name": "tools/call",
  "attributes": {
    "mcp_server.name": "custom",
    "tool.name": "create_issue"
  }
}
配置与 span 现象telemetry_gate 返回运维动作
claudeCodeVersion 至少 2.1.312,pipeline 仍为 count_safety_eventretire_counter下线 safety_event 计数器,改查 tool span outcome 或 approval JSON。
版本至少 2.1.312 仍收到 span.name claude_code.safety_eventunexpected_event查混跑旧二进制或 collector 重放,不要计入正常拦截。
pipeline 为 mcp_server_group,marketplacePlugin true,mcp_server.name 仍为 customstale_mcp_alias更新 Grafana 分组与告警键,把 custom 映射改为插件真实名。
claudeCodeVersion 低于 2.1.312,pipeline count_safety_event,span 不是 safety_eventmissing_safety旧线客户端可能未导出 safety_event;放宽计数或按版本分桶,勿与 unexpected_event 合并。

telemetry_gate 怎样在 schema 与五条业务分支间选择?

实现顺序:先用流水线 JSON Schema 校验 config;读 span.name 与 attributes.mcp_server.name;比较 parse 后的 claudeCodeVersion 与 2.1.312。312 上仍见 safety_event 名 → unexpected_event;312 且 pipeline count_safety_event → retire_counter;低于 312 且 pipeline 要数 safety_event 但 span 不是 → missing_safety;312、mcp_server_group、marketplacePlugin 且 custom → stale_mcp_alias;否则 ok。

不要把 retire_counter 与 ok 合成单一 passed 位。报表分开计数,才能看到还有多少团队未改 pipeline 字段,以及 MCP 分组是否仍卡在 custom。

telemetry_gate:schema、unexpected_event、retire_counter、missing_safety、stale_mcp_alias、ok。
def actual_type(data: object) -> str:
    if data is None:
        return "null"
    if isinstance(data, bool):
        return "boolean"
    if isinstance(data, list):
        return "array"
    if isinstance(data, int):
        return "integer"
    if isinstance(data, float):
        return "number"
    if isinstance(data, str):
        return "string"
    if isinstance(data, dict):
        return "object"
    raise TypeError(type(data))

def validate(data: object, schema: dict) -> list[str]:
    errors: list[str] = []
    if not isinstance(schema, dict):
        return errors
    types = schema.get("type")
    if isinstance(types, str):
        types = [types]
    actual = actual_type(data)
    if types and not any(t == actual or (t == "number" and actual == "integer") for t in types):
        errors.append("type")
    if "enum" in schema and not any(value == data for value in schema["enum"]):
        errors.append("enum")
    if isinstance(data, str) and "minLength" in schema and len(data) < schema["minLength"]:
        errors.append("minLength")
    if isinstance(data, dict) and schema.get("properties"):
        for key in schema.get("required") or []:
            if key not in data:
                errors.append(f"required:{key}")
        if schema.get("additionalProperties") is False:
            for key in data:
                if key not in schema["properties"]:
                    errors.append(f"additional:{key}")
        for key, sub in schema["properties"].items():
            if key in data and data[key] is not None:
                errors.extend(validate(data[key], sub))
    return errors

def parse_version(text: str) -> tuple[int, int, int]:
    parts = text.split(".")
    if len(parts) != 3:
        return (0, 0, 0)
    try:
        return (int(parts[0]), int(parts[1]), int(parts[2]))
    except ValueError:
        return (0, 0, 0)

def telemetry_gate(config: dict, span: dict, schema: dict) -> str:
    if validate(config, schema):
        return "schema"
    version = parse_version(str(config.get("claudeCodeVersion", "0.0.0")))
    attrs = span.get("attributes") if isinstance(span.get("attributes"), dict) else {}
    name = span.get("name")
    if version >= (2, 1, 312) and name == "claude_code.safety_event":
        return "unexpected_event"
    if version >= (2, 1, 312) and config.get("pipeline") == "count_safety_event":
        return "retire_counter"
    if version < (2, 1, 312) and config.get("pipeline") == "count_safety_event" and name != "claude_code.safety_event":
        return "missing_safety"
    if (
        version >= (2, 1, 312)
        and config.get("pipeline") == "mcp_server_group"
        and config.get("marketplacePlugin")
        and attrs.get("mcp_server.name") == "custom"
    ):
        return "stale_mcp_alias"
    return "ok"

在 JSONNote 里怎样校验流水线 JSON 与 span 样本?

  1. 1
    用 Schema 固定流水线配置

    打开 JSON Schema。第一栏贴流水线 schema,第二栏贴 claudeCodeVersion 与 pipeline 配置。enum 或 pattern 错误会先标红,telemetry_gate 不应在形状错误时返回业务分支。

  2. 2
    用格式化核对 span 属性

    打开 JSON 格式化,贴从 collector 抽出的 tools/call span。确认 mcp_server.name 是 github 还是 custom,再决定要不要走 stale_mcp_alias 分支。

  3. 3
    用 Diff 对比 MCP 名称前后

    打开 JSON Diff。左边放 2.1.312 新 span,右边放仍写 custom 的旧样本。Diff 高亮的 mcp_server.name 就是 Grafana 分组最该改的一行。

常见问题

2.1.312 还会把拦截写成别的 OpenTelemetry 事件名吗?

changelog 只声明停发 claude_code.safety_event,没有承诺用一对一替代事件。审计应依赖既有 tool span、permission 日志或你们自有的 approval JSON,而不是等待新 safety 事件名。

能否在 2.1.312 上继续用 count_safety_event .pipeline?

字段可以留作迁移期标记,但 telemetry_gate 会对 312 返回 retire_counter,提醒计数器永远等不到事件。应改 pipeline 枚举或切到 tool span outcome 指标,而不是把 retire_counter 当 ok 静默通过。

自托管 MCP 还会看到 mcp_server.name 等于 custom 吗?

本篇 stale_mcp_alias 只针对官方 marketplace 插件且 marketplacePlugin 为 true 的固定 URL 远程 MCP。自托管命名不在 2.1.312 这条 changelog 里;不要把所有 custom 都当成配置错误。

混跑 2.1.307 与 2.1.312 时 missing_safety 与 unexpected_event 怎么分?

unexpected_event 要求配置声明至少 2.1.312 却仍收到 claude_code.safety_event。missing_safety 要求配置版本低于 312、pipeline 要数 safety_event,而 span 不是该事件。两者都依赖 claudeCodeVersion 字段,不能只看 collector 收到的事件总量。

telemetry_gate 返回 ok 是否表示可以忽略 MCP 名称变化?

ok 只表示当前 config 与 span 样本通过全部规则,例如已改用 mcp_server_group 且见到真实插件名,或已下线 count_safety_event。它不代表未来批次不会混进旧 span;持续抽样仍必要。

总结

Claude Code 2.1.312 把 OpenTelemetry 里的 safety_event 专用导出拿掉,并把 marketplace 远程 MCP 的 mcp_server.name 对齐到真实插件名。审计 JSON 必须写清 claudeCodeVersion 与 pipeline,让 telemetry_gate 在混跑与迁移期给出可执行分支。

直接结论:claudeCodeVersion 至少 2.1.312 仍见 claude_code.safety_event 返回 unexpected_event;同版本且 pipeline 仍为 count_safety_event 返回 retire_counter 并改指标;低于 2.1.312 要数 safety_event 而 span 不是该事件返回 missing_safety;312 上 marketplacePlugin 为 true 且 mcp_server.name 仍为 custom 返回 stale_mcp_alias;配置不过 schema 先返回 schema;其余 ok。

下一步:用 Schema 固定流水线 JSON,用格式化看清 span 属性,用 Diff 对比 mcp_server.name 前后,并在报表里分开统计 telemetry_gate 返回值。

返回博客