Blog • Claude Code / OpenTelemetry
After upgrading to Claude Code 2.1.312, claude_code.safety_event no longer appears in OpenTelemetry. Which audit JSON fields should you change?
On October 27, 2026 Anthropic shipped Claude Code 2.1.312. OpenTelemetry export no longer sends claude_code.safety_event, which 2.1.307 on October 21 had added for tool calls stopped by a permission rule, auto mode, safety check, hook, or sandbox.
The same release sets mcp_server.name on spans from fixed-URL remote MCP in official marketplace plugins to the real server name instead of the placeholder custom. Dashboards that still count safety_event or group on custom will false-alarm after upgrade unless local JSON config adds a version gate.
Block reasons and auto evaluation context are in Managed Agents auto evaluation; local permission queue patterns are in Agent permission policy; non-interactive MCP timeout JSON is in MCP elicitation timeout. This article covers only 2.1.312 OpenTelemetry field retirement, mcp_server.name alignment, and how telemetry_gate branches.
- From 2.1.312 the client no longer emits claude_code.safety_event; listeners should treat the name as unexpected_event.
- When pipeline stays count_safety_event and claudeCodeVersion is 2.1.312, the gate should return retire_counter to retire the counter.
- On 2.1.312, marketplace remote MCP should show plugin names like github in mcp_server.name; custom with marketplacePlugin true should yield stale_mcp_alias.
- When 2.1.307 and 2.1.312 run together, spans without safety_event on the old line should return missing_safety, not unexpected_event.
Keep this in mind: 2.1.312 changes OpenTelemetry event names and MCP span attributes, not permission rules themselves. Below: changelog, retired safety_event sample, pipeline JSON and schema, mcp_server.name comparison, gate table, telemetry_gate source, and validating config in JSONNote.
2.1.312 stops safety_event in OpenTelemetry and fixes marketplace MCP names
Release notes are in the Claude Code changelog, tag page at v2.1.312 release. Versus 2.1.307, the two visible telemetry changes are retiring claude_code.safety_event entirely and no longer writing custom as mcp_server.name on fixed-URL marketplace MCP.
Permission rules, hooks, and sandbox still block tool calls; only the dedicated safety_event export is gone. Audits should read outcome on existing tool spans or your own approval JSON instead of waiting for claude_code.safety_event to return.
What did claude_code.safety_event look like in 2.1.307 and why is it gone in 312?
2.1.307 wrapped blocked calls into OpenTelemetry event claude_code.safety_event with attributes such as tool.name, outcome blocked, and source permission_rule. The sample below is for legacy shape only; 2.1.312 clients must not emit the same name.
{
"name": "claude_code.safety_event",
"attributes": {
"tool.name": "Bash",
"outcome": "blocked",
"source": "permission_rule"
}
}
If a 312 fleet still receives the event, check for mixed old binaries or a collector replaying old batches. telemetry_gate should return unexpected_event when claudeCodeVersion is at least 2.1.312 and span.name is still claude_code.safety_event, not treat it as a normal block count.
How should audit pipeline JSON set claudeCodeVersion and pipeline?
Bind each export batch to claudeCodeVersion and use pipeline to declare whether you count safety_event or group by mcp_server.name. Keeping count_safety_event on 312 triggers retire_counter; when mixing old clients the version must come from the agent, not the collector install date.
{
"claudeCodeVersion": "2.1.312",
"pipeline": "count_safety_event",
"marketplacePlugin": true
}
Pin additionalProperties and enums with JSON Schema so operators cannot misspell pipeline. When schema fails, telemetry_gate must return schema first instead of guessing ok or retire_counter on missing fields.
{
"$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" }
}
}
Why does mcp_server.name on marketplace remote MCP change from custom to the plugin name?
From 2.1.312, remote MCP from official Anthropic marketplace plugins with a fixed URL writes the real plugin server name (for example github) on tools/call spans instead of custom. Self-hosted naming is out of scope; custom with marketplacePlugin true means grouping rules are stale.
{
"name": "tools/call",
"attributes": {
"mcp_server.name": "github",
"tool.name": "create_issue"
}
}
{
"name": "tools/call",
"attributes": {
"mcp_server.name": "custom",
"tool.name": "create_issue"
}
}
| Config and span signal | telemetry_gate returns | Operator action |
|---|---|---|
| claudeCodeVersion at least 2.1.312, pipeline still count_safety_event | retire_counter | Retire the safety_event counter; read tool span outcome or approval JSON instead. |
| Version at least 2.1.312 but span.name is still claude_code.safety_event | unexpected_event | Check mixed old binaries or collector replay; do not count as normal blocks. |
| pipeline mcp_server_group, marketplacePlugin true, mcp_server.name still custom | stale_mcp_alias | Update Grafana groups and alert keys; remap custom to real plugin names. |
| claudeCodeVersion below 2.1.312, pipeline count_safety_event, span is not safety_event | missing_safety | Old-line clients may omit safety_event; relax the counter or bucket by version, do not merge with unexpected_event. |
How does telemetry_gate choose among schema and five business branches?
Order: validate config with pipeline JSON Schema; read span.name and attributes.mcp_server.name; compare parsed claudeCodeVersion to 2.1.312. On 312, safety_event name → unexpected_event; 312 with count_safety_event → retire_counter; below 312 with count pipeline but span not safety_event → missing_safety; 312, mcp_server_group, marketplacePlugin, and custom → stale_mcp_alias; else ok.
Do not collapse retire_counter and ok into one passed bit. Count them separately in reports to see how many teams have not updated pipeline fields and whether MCP grouping still sticks on custom.
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"
How do you validate pipeline JSON and span samples in JSONNote?
- 1Pin pipeline config with Schema
Open JSON Schema. Paste the pipeline schema in the first field and claudeCodeVersion plus pipeline config in the second. Enum or pattern errors flag first; telemetry_gate must not return business branches on a bad shape.
- 2Use Format to inspect span attributes
Open JSON Format, paste a tools/call span from the collector. Confirm whether mcp_server.name is github or custom before choosing the stale_mcp_alias branch.
- 3Diff MCP name before and after
Open JSON Diff. Put the 2.1.312 span on the left and a legacy custom sample on the right. The highlighted mcp_server.name is the line your Grafana grouping must change.
Common questions
Will 2.1.312 emit blocks under a different OpenTelemetry event name?
The changelog only retires claude_code.safety_event; it does not promise a one-to-one replacement event. Rely on existing tool spans, permission logs, or your approval JSON instead of waiting for a new safety event name.
Can pipeline stay count_safety_event on 2.1.312?
You may keep the field as a migration marker, but telemetry_gate returns retire_counter on 312 because the counter will never see events. Change the pipeline enum or move to tool span outcome metrics instead of treating retire_counter as ok.
Will self-hosted MCP still show mcp_server.name custom?
stale_mcp_alias in this article applies only to fixed-URL remote MCP from official marketplace plugins with marketplacePlugin true. Self-hosted naming is outside this 2.1.312 changelog; do not treat every custom as misconfiguration.
How do missing_safety and unexpected_event split when mixing 2.1.307 and 2.1.312?
unexpected_event needs config at least 2.1.312 while claude_code.safety_event still arrives. missing_safety needs config below 312 with count_safety_event pipeline while the span is not that event. Both depend on claudeCodeVersion, not total events at the collector.
Does telemetry_gate ok mean you can ignore MCP name changes?
ok only means this config and span sample passed every rule, such as using mcp_server_group with a real plugin name or retiring count_safety_event. It does not guarantee future batches will not include legacy spans; keep sampling.
Summary
Claude Code 2.1.312 removes the dedicated OpenTelemetry safety_event export and aligns mcp_server.name on marketplace remote MCP to real plugin names. Audit JSON must record claudeCodeVersion and pipeline so telemetry_gate can branch during mixed fleets and migration.
Decision: claudeCodeVersion at least 2.1.312 with claude_code.safety_event → unexpected_event; same version with count_safety_event pipeline → retire_counter and change metrics; below 2.1.312 counting safety_event while span is not that event → missing_safety; on 312 with marketplacePlugin true and mcp_server.name still custom → stale_mcp_alias; config failing schema → schema first; otherwise ok.
Next: pin pipeline JSON with Schema, use Format to see span attributes, Diff mcp_server.name before and after, and report telemetry_gate return values separately.