





















| 设计原则 | 运维价值 |
|---|---|
| 无侵入性 | 监控 / 日志逻辑与业务逻辑解耦,业务迭代不影响运维观测 |
| 关注点分离 | Metrics、Logging、Tracing 可分别实现,按需组合 |
| 惰性执行 | 仅在事件触发时执行回调,无请求时无性能损耗 |
| 兼容 Prometheus | 指标命名 / 格式符合 Prometheus 规范,降低运维接入成本 |
New(ms *metrics.Set) 函数是为 SRE 打造的核心指标采集器,其设计完全围绕 可观测性 展开,我们从 SRE 运维需求拆解每个指标的价值。
SRE 最关注 性能 和 易用性,这也是 VM 团队自研指标库的核心原因:
| 特性 | VictoriaMetrics/metrics | Prometheus client_golang | SRE 视角的优势 |
|---|---|---|---|
| 性能 | 原子操作实现 Counter,无锁设计 | 基于 mutex,高并发下有锁竞争 | 高并发场景下更低的性能损耗 |
| 指标创建 | GetOrCreateCounter 动态创建 | 需预注册所有指标 | 无需提前定义所有标签组合,适配动态场景(如任意工具名 / URI) |
| 内存占用 | 更轻量,无冗余封装 | 功能丰富但冗余多 | 降低容器内存占用,减少 OOM 风险 |
| Prometheus 兼容 | 支持 WritePrometheus 接口 | 原生支持 | 无缝接入现有 Prometheus/Grafana 体系 |
💡 深入理解:为什么所有 Metrics 钩子都注册在 After 阶段?
这是一个精心的设计选择,而非偶然。AddAfterInitialize、AddAfterCallTool 等后置钩子确保只有成功处理的请求才被计数,避免了"请求还没处理完就计数"导致的指标失真。唯一的例外是 AddOnError——它注册在全局错误钩子上,专门兜底捕获所有阶段的异常。
另一个值得注意的细节:GetOrCreateCounter 的"动态创建"能力是关键——它允许在运行时根据实际的 client_name、tool_name 动态生成指标,无需预定义所有标签组合。这对 MCP Server 这种工具名、客户端名不可预知的场景至关重要。如果用 Prometheus 官方库,你需要提前 Register 所有可能的指标——在动态场景下几乎不可能。
以下是 New(ms *metrics.Set) 函数的完整源码(位于 cmd/mcp-victoriametrics/hooks/hooks.go),逐段拆解:
func New(ms *metrics.Set) *server.Hooks {
hooks := &server.Hooks{}
// ① 客户端初始化指标
hooks.AddAfterInitialize(func(_ context.Context, _ any, message *mcp.InitializeRequest, _ *mcp.InitializeResult) {
ms.GetOrCreateCounter(fmt.Sprintf(
`mcp_victoriametrics_initialize_total{client_name="%s",client_version="%s"}`,
message.Params.ClientInfo.Name,
message.Params.ClientInfo.Version,
)).Inc()
})
// ② 列表操作指标(工具/资源/提示词)
hooks.AddAfterListTools(func(_ context.Context, _ any, _ *mcp.ListToolsRequest, _ *mcp.ListToolsResult) {
ms.GetOrCreateCounter(`mcp_victoriametrics_list_tools_total`).Inc()
})
hooks.AddAfterListResources(func(_ context.Context, _ any, _ *mcp.ListResourcesRequest, _ *mcp.ListResourcesResult) {
ms.GetOrCreateCounter(`mcp_victoriametrics_list_resources_total`).Inc()
})
hooks.AddAfterListPrompts(func(_ context.Context, _ any, _ *mcp.ListPromptsRequest, _ *mcp.ListPromptsResult) {
ms.GetOrCreateCounter(`mcp_victoriametrics_list_prompts_total`).Inc()
})
// ③ 工具调用指标(核心业务监控)
hooks.AddAfterCallTool(func(_ context.Context, _ any, message *mcp.CallToolRequest, result any) {
isError := false
if r, ok := result.(*mcp.CallToolResult); ok {
isError = r.IsError
}
ms.GetOrCreateCounter(fmt.Sprintf(
`mcp_victoriametrics_call_tool_total{name="%s",is_error="%t"}`,
message.Params.Name,
isError,
)).Inc()
})
// ④ 提示词获取指标
hooks.AddAfterGetPrompt(func(_ context.Context, _ any, message *mcp.GetPromptRequest, _ *mcp.GetPromptResult) {
ms.GetOrCreateCounter(fmt.Sprintf(
`mcp_victoriametrics_get_prompt_total{name="%s"}`,
message.Params.Name,
)).Inc()
})
// ⑤ 资源读取指标
hooks.AddAfterReadResource(func(_ context.Context, _ any, message *mcp.ReadResourceRequest, _ *mcp.ReadResourceResult) {
ms.GetOrCreateCounter(fmt.Sprintf(
`mcp_victoriametrics_read_resource_total{uri="%s"}`,
message.Params.URI,
)).Inc()
})
// ⑥ 全局错误指标
hooks.AddOnError(func(_ context.Context, _ any, method mcp.MCPMethod, _ any, err error) {
ms.GetOrCreateCounter(fmt.Sprintf(
`mcp_victoriametrics_error_total{method="%s",error="%s"}`,
method,
err,
)).Inc()
})
return hooks
}
源码关键细节解读:
any(而非 *mcp.CallToolResult),这是 mcp-go 框架的设计——后置钩子的 result 参数使用泛型接口,需要通过类型断言 result.(*mcp.CallToolResult) 获取具体类型。这是防御性编程的体现。hooks.AddAfterInitialize(func(_ context.Context, _ any, message *mcp.InitializeRequest, _ *mcp.InitializeResult) {
ms.GetOrCreateCounter(fmt.Sprintf(
`mcp_victoriametrics_initialize_total{client_name="%s",client_version="%s"}`,
message.Params.ClientInfo.Name,
message.Params.ClientInfo.Version,
)).Inc()
})
SRE 运维价值:
AddAfterInitialize(后置钩子),而非 AddBeforeInitialize,确保只有初始化成功的客户端才被计数hooks.AddAfterListTools(func(_ context.Context, _ any, _ *mcp.ListToolsRequest, _ *mcp.ListToolsResult) {
ms.GetOrCreateCounter(`mcp_victoriametrics_list_tools_total`).Inc()
})
hooks.AddAfterListResources(func(_ context.Context, _ any, _ *mcp.ListResourcesRequest, _ *mcp.ListResourcesResult) {
ms.GetOrCreateCounter(`mcp_victoriametrics_list_resources_total`).Inc()
})
hooks.AddAfterListPrompts(func(_ context.Context, _ any, _ *mcp.ListPromptsRequest, _ *mcp.ListPromptsResult) {
ms.GetOrCreateCounter(`mcp_victoriametrics_list_prompts_total`).Inc()
})
SRE 运维价值:
_ 忽略(context、id、request、result),因为列表操作只需计数,不需要提取任何业务信息hooks.AddAfterCallTool(func(_ context.Context, _ any, message *mcp.CallToolRequest, result any) {
isError := false
if r, ok := result.(*mcp.CallToolResult); ok {
isError = r.IsError
}
ms.GetOrCreateCounter(fmt.Sprintf(
`mcp_victoriametrics_call_tool_total{name="%s",is_error="%t"}`,
message.Params.Name,
isError,
)).Inc()
})
SRE 运维价值(核心):
result.(*mcp.CallToolResult) 是防御性编程——AddAfterCallTool 的回调签名中 result 类型为 any,必须通过类型断言获取 IsError 字段。若断言失败(理论上不会),isError 保持 false 默认值,不会导致 panic(SRE 最怕监控本身出问题)is_error 标签使用 %t 格式化布尔值(输出 "true"/"false" 字符串),而非 0/1,这是 Prometheus 标签的常见实践// 提示词获取
hooks.AddAfterGetPrompt(func(_ context.Context, _ any, message *mcp.GetPromptRequest, _ *mcp.GetPromptResult) {
ms.GetOrCreateCounter(fmt.Sprintf(
`mcp_victoriametrics_get_prompt_total{name="%s"}`,
message.Params.Name,
)).Inc()
})
// 资源读取
hooks.AddAfterReadResource(func(_ context.Context, _ any, message *mcp.ReadResourceRequest, _ *mcp.ReadResourceResult) {
ms.GetOrCreateCounter(fmt.Sprintf(
`mcp_victoriametrics_read_resource_total{uri="%s"}`,
message.Params.URI,
)).Inc()
})
SRE 运维价值:
message.Params.URI 作为标签值,提示词使用 message.Params.Name——两者分别对应 MCP 协议中资源的 URI 标识和提示词的名称标识hooks.AddOnError(func(_ context.Context, _ any, method mcp.MCPMethod, _ any, err error) {
ms.GetOrCreateCounter(fmt.Sprintf(
`mcp_victoriametrics_error_total{method="%s",error="%s"}`,
method,
err,
)).Inc()
})
SRE 运维价值:
_ any 忽略,只关注 method 和 err,这是因为错误指标不需要请求体内容,只需知道哪个方法出了什么错error 标签直接使用 err 的字符串表示(通过 %s 格式化调用 err.Error()),可能导致指标基数爆炸(SRE 需注意:生产环境可优化为错误类型枚举,如 “invalid_param”“connection_error”)⚠️ 深入理解:error 标签的"基数爆炸"陷阱
这是整个 Hooks 实现中最值得 SRE 警惕的设计。error="%s" 直接将 err.Error() 的完整字符串作为标签值——如果错误信息包含动态内容(如请求 ID、时间戳、具体参数),每次错误都会创建一个全新的时间序列。在高错误率场景下,Prometheus 的内存和存储会被迅速耗尽,这就是所谓的"基数爆炸"(Cardinality Explosion)。
生产环境建议:将 error 标签替换为错误类型枚举。例如用 errors.Is(err, ErrToolNotFound) 判断后输出 "tool_not_found",而非原始错误字符串。这样标签基数可控(十几种错误类型 vs 无限种错误字符串),Prometheus 存储压力大幅降低。
在 main.go 中,指标通过 HTTP 端点暴露,这是 SRE 最熟悉的方式:
// main.go 关键代码(cmd/mcp-victoriametrics/main.go)
mux.HandleFunc("/metrics", func(w http.ResponseWriter, _ *http.Request) {
ms.WritePrometheus(w) // 自定义MCP指标(由 New(ms) 注册的所有 Counter)
metrics.WriteProcessMetrics(w) // 进程级指标(CPU/内存/GC等)
})
源码细节:
ms.WritePrometheus(w) 输出的是 metrics.NewSet() 实例中通过 GetOrCreateCounter 动态创建的所有指标metrics.WriteProcessMetrics(w) 是全局函数,输出 Go 运行时指标(goroutine 数、GC 耗时、内存分配等),与 MCP 业务指标分开管理if c.IsStdio() 分支中可以看到SRE 落地建议:
NewLoggerHooks() 函数为 SRE 提供了结构化、全生命周期的日志体系,解决了 故障排查时无日志、日志无上下文 的核心痛点。
以下是 NewLoggerHooks() 的完整源码(位于 cmd/mcp-victoriametrics/hooks/hooks.go):
func NewLoggerHooks() *server.Hooks {
hooks := &server.Hooks{}
// ① 会话生命周期日志
hooks.AddOnRegisterSession(func(_ context.Context, session server.ClientSession) {
slog.Info("Session registered",
"session_id", session.SessionID(),
)
})
hooks.AddOnUnregisterSession(func(_ context.Context, session server.ClientSession) {
slog.Info("Session unregistered",
"session_id", session.SessionID(),
)
})
// ② 请求全生命周期日志(通用钩子)
hooks.AddBeforeAny(func(ctx context.Context, id any, method mcp.MCPMethod, message any) {
sessionID := extractSessionID(ctx)
slog.Info("MCP request received",
"request_id", id,
"session_id", sessionID,
"method", string(method),
"message", toJSON(message),
)
})
hooks.AddOnSuccess(func(ctx context.Context, id any, method mcp.MCPMethod, message any, result any) {
sessionID := extractSessionID(ctx)
slog.Info("MCP request succeeded",
"request_id", id,
"session_id", sessionID,
"method", string(method),
"message", toJSON(message),
"result", toJSON(result),
)
})
hooks.AddOnError(func(ctx context.Context, id any, method mcp.MCPMethod, message any, err error) {
sessionID := extractSessionID(ctx)
slog.Error("MCP request failed",
"request_id", id,
"session_id", sessionID,
"method", string(method),
"message", toJSON(message),
"error", err.Error(),
)
})
// ③ 业务关键操作日志(方法级钩子)
hooks.AddAfterInitialize(func(_ context.Context, id any, msg *mcp.InitializeRequest, _ *mcp.InitializeResult) {
slog.Info("Client initialized",
"request_id", id,
"client_name", msg.Params.ClientInfo.Name,
"client_version", msg.Params.ClientInfo.Version,
"protocol_version", msg.Params.ProtocolVersion,
)
})
hooks.AddAfterCallTool(func(_ context.Context, id any, msg *mcp.CallToolRequest, result any) {
isError := false
if r, ok := result.(*mcp.CallToolResult); ok {
isError = r.IsError
}
slog.Info("Tool called",
"request_id", id,
"tool_name", msg.Params.Name,
"is_error", isError,
)
})
return hooks
}
源码关键细节解读:
AddBeforeAny/AddOnSuccess/AddOnError 覆盖所有请求的全生命周期,AddAfterInitialize/AddAfterCallTool 则对关键操作做额外的结构化日志输出。这意味着一次 CallTool 请求会产生至少 3 条日志:BeforeAny(请求进入)→ OnSuccess(请求成功)→ AfterCallTool(工具调用详情)。extractSessionID(ctx) 从 context 中获取,而非从参数传入。这是因为 session 信息在 MCP 框架中通过 context 传递(server.ClientSessionFromContext(ctx)),体现了 Go 的 context 传值最佳实践。slog.Error 而非 slog.Info:这是唯一使用 Error 级别的地方,便于日志系统按级别过滤和告警。| 日志特性 | 实现方式 | 运维价值 |
|---|---|---|
| 结构化 | 使用 slog(JSON 格式) | 可被 ELK/Loki 等日志系统解析,支持字段过滤 / 聚合 |
| 全上下文 | 包含 request_id/session_id/method | 可通过 request_id 串联单次请求的全生命周期,快速定位问题 |
| 分级日志 | Info(正常流程)/Error(异常) | 可配置日志采集规则,Error 级别日志优先告警 |
| 无侵入序列化 | toJSON 函数(失败返回空字符串) | 日志序列化失败不影响主流程,避免 “监控导致业务故障” |
💡 深入理解:日志钩子的"三层漏斗"设计
NewLoggerHooks 的日志设计形成了一个三层漏斗:通用层(BeforeAny / OnSuccess / OnError)覆盖所有请求的全生命周期 → 方法层(AfterInitialize / AfterCallTool)对关键操作做额外记录 → 会话层(OnRegisterSession / OnUnregisterSession)追踪连接生命周期。三层互补,不遗漏任何运维关键信息。
一个容易忽略的设计亮点:toJSON 函数在序列化失败时返回空字符串而非 panic。这体现了"监控永远不能成为故障源"的 SRE 铁律——日志是辅助手段,绝不能因为日志序列化失败而导致业务请求中断。同理,extractSessionID 在 context 中找不到 session 时返回空字符串,而非报错。
NewLoggerHooks 依赖两个辅助函数,它们的实现同样体现了防御性编程思想:
// extractSessionID 从 context 中提取 session ID
// 若 context 中无 session 信息(如 stdio 模式),返回空字符串
func extractSessionID(ctx context.Context) string {
session := server.ClientSessionFromContext(ctx)
if session != nil {
return session.SessionID()
}
return ""
}
// toJSON 将任意值转为 JSON 字符串用于日志输出
// 关键设计:序列化失败时返回空字符串,而非 panic 或返回 error
// 这确保了日志逻辑永远不会导致业务流程中断
func toJSON(v any) string {
if v == nil {
return ""
}
b, err := json.Marshal(v)
if err != nil {
return ""
}
return string(b)
}
源码细节:
extractSessionID 使用 server.ClientSessionFromContext(ctx)——这是 mcp-go 框架提供的 API,在 HTTP/SSE 模式下 context 中会携带 session 信息,但在 stdio 模式下可能为 nil,因此需要 nil 检查。toJSON 的双重防御:先检查 v == nil(避免 json.Marshal(nil) 输出 "null"),再检查 err != nil(处理不可序列化的类型如 channel、func)。两种情况都返回空字符串,保证日志输出的安全性。hooks.AddOnRegisterSession(func(_ context.Context, session server.ClientSession) {
slog.Info("Session registered", "session_id", session.SessionID())
})
hooks.AddOnUnregisterSession(func(_ context.Context, session server.ClientSession) {
slog.Info("Session unregistered", "session_id", session.SessionID())
})
SRE 运维价值:
func(ctx context.Context, session server.ClientSession),与其他钩子不同——没有 id/method/message 参数,因为会话注册/注销不属于 MCP 请求生命周期,而是传输层事件// 请求进入(BeforeAny 通用前置钩子)
hooks.AddBeforeAny(func(ctx context.Context, id any, method mcp.MCPMethod, message any) {
sessionID := extractSessionID(ctx)
slog.Info("MCP request received",
"request_id", id,
"session_id", sessionID,
"method", string(method),
"message", toJSON(message),
)
})
// 请求成功(OnSuccess 通用成功钩子)
hooks.AddOnSuccess(func(ctx context.Context, id any, method mcp.MCPMethod, message any, result any) {
sessionID := extractSessionID(ctx)
slog.Info("MCP request succeeded",
"request_id", id,
"session_id", sessionID,
"method", string(method),
"message", toJSON(message),
"result", toJSON(result),
)
})
// 请求失败(OnError 通用错误钩子)
hooks.AddOnError(func(ctx context.Context, id any, method mcp.MCPMethod, message any, err error) {
sessionID := extractSessionID(ctx)
slog.Error("MCP request failed",
"request_id", id,
"session_id", sessionID,
"method", string(method),
"message", toJSON(message),
"error", err.Error(),
)
})
SRE 运维价值:
err.Error() 而非 toJSON(err),因为 error 接口的 JSON 序列化可能丢失信息(只输出 {}),直接调用 Error() 方法更可靠// 客户端初始化详情日志
hooks.AddAfterInitialize(func(_ context.Context, id any, msg *mcp.InitializeRequest, _ *mcp.InitializeResult) {
slog.Info("Client initialized",
"request_id", id,
"client_name", msg.Params.ClientInfo.Name,
"client_version", msg.Params.ClientInfo.Version,
"protocol_version", msg.Params.ProtocolVersion,
)
})
// 工具调用详情日志
hooks.AddAfterCallTool(func(_ context.Context, id any, msg *mcp.CallToolRequest, result any) {
isError := false
if r, ok := result.(*mcp.CallToolResult); ok {
isError = r.IsError
}
slog.Info("Tool called",
"request_id", id,
"tool_name", msg.Params.Name,
"is_error", isError,
)
})
SRE 运维价值:
result.(*mcp.CallToolResult)),但日志钩子额外记录了 request_id,而 Metrics 钩子不需要——这体现了两类钩子的职责分离:Metrics 关注聚合统计,Logging 关注单次请求追踪Merge函数是 Hooks 机制的 粘合剂,其设计完美契合 SRE 的 可扩展性 诉求。
以下是 Merge 函数的完整源码(位于 cmd/mcp-victoriametrics/hooks/hooks.go):
func Merge(hooksList ...*server.Hooks) *server.Hooks {
combined := &server.Hooks{}
for _, h := range hooksList {
if h == nil {
continue
}
combined.OnRegisterSession = append(combined.OnRegisterSession, h.OnRegisterSession...)
combined.OnUnregisterSession = append(combined.OnUnregisterSession, h.OnUnregisterSession...)
combined.OnBeforeAny = append(combined.OnBeforeAny, h.OnBeforeAny...)
combined.OnSuccess = append(combined.OnSuccess, h.OnSuccess...)
combined.OnError = append(combined.OnError, h.OnError...)
combined.OnRequestInitialization = append(combined.OnRequestInitialization, h.OnRequestInitialization...)
combined.OnBeforeInitialize = append(combined.OnBeforeInitialize, h.OnBeforeInitialize...)
combined.OnAfterInitialize = append(combined.OnAfterInitialize, h.OnAfterInitialize...)
combined.OnBeforePing = append(combined.OnBeforePing, h.OnBeforePing...)
combined.OnAfterPing = append(combined.OnAfterPing, h.OnAfterPing...)
combined.OnBeforeSetLevel = append(combined.OnBeforeSetLevel, h.OnBeforeSetLevel...)
combined.OnAfterSetLevel = append(combined.OnAfterSetLevel, h.OnAfterSetLevel...)
combined.OnBeforeListResources = append(combined.OnBeforeListResources, h.OnBeforeListResources...)
combined.OnAfterListResources = append(combined.OnAfterListResources, h.OnAfterListResources...)
combined.OnBeforeListResourceTemplates = append(combined.OnBeforeListResourceTemplates, h.OnBeforeListResourceTemplates...)
combined.OnAfterListResourceTemplates = append(combined.OnAfterListResourceTemplates, h.OnAfterListResourceTemplates...)
combined.OnBeforeReadResource = append(combined.OnBeforeReadResource, h.OnBeforeReadResource...)
combined.OnAfterReadResource = append(combined.OnAfterReadResource, h.OnAfterReadResource...)
combined.OnBeforeListPrompts = append(combined.OnBeforeListPrompts, h.OnBeforeListPrompts...)
combined.OnAfterListPrompts = append(combined.OnAfterListPrompts, h.OnAfterListPrompts...)
combined.OnBeforeGetPrompt = append(combined.OnBeforeGetPrompt, h.OnBeforeGetPrompt...)
combined.OnAfterGetPrompt = append(combined.OnAfterGetPrompt, h.OnAfterGetPrompt...)
combined.OnBeforeListTools = append(combined.OnBeforeListTools, h.OnBeforeListTools...)
combined.OnAfterListTools = append(combined.OnAfterListTools, h.OnAfterListTools...)
combined.OnBeforeCallTool = append(combined.OnBeforeCallTool, h.OnBeforeCallTool...)
combined.OnAfterCallTool = append(combined.OnAfterCallTool, h.OnAfterCallTool...)
}
return combined
}
源码关键细节与潜在问题:
Hooks 结构体已新增 OnBeforeSamplingCreateMessage、OnAfterSamplingCreateMessage、OnBeforeListRoots、OnAfterListRoots、OnBeforeElicitationCreate、OnAfterElicitationCreate 共 6 个字段,但 Merge 函数并未合并这些字段。这意味着如果有 Hooks 注册了这些新钩子,合并后会丢失。这是一个需要关注的兼容性问题。append 操作保证了先传入的 Hooks 中的回调先执行。在 main.go 中 hooks.Merge(metricsHooks, loggingHooks),Metrics 钩子先于 Logging 钩子执行——这意味着指标计数在日志记录之前完成。if h == nil { continue } 确保传入 nil 不会 panic,但注意这里只检查了整个 Hooks 指针是否为 nil,不检查单个字段切片——因为 append(nil, ...)... 在 Go 中是安全的(nil slice 可以被 append)。从 cmd/mcp-victoriametrics/main.go 源码可以看到 Hooks 的实际组装和使用方式:
// main.go 关键代码
ms := metrics.NewSet()
// 分别创建 Metrics Hooks 和 Logger Hooks
metricsHooks := hooks.New(ms)
loggingHooks := hooks.NewLoggerHooks()
// 合并为一个 Hooks 实例
combinedHooks := hooks.Merge(metricsHooks, loggingHooks)
// 传入 MCP Server
s := server.NewMCPServer(
"VictoriaMetrics",
fmt.Sprintf("v%s (date: %s)", version, date),
server.WithRecovery(),
server.WithLogging(),
server.WithToolCapabilities(false),
server.WithResourceCapabilities(false, false),
server.WithPromptCapabilities(false),
server.WithHooks(combinedHooks), // 注入合并后的 Hooks
server.WithInstructions(`...`),
)
源码细节:
server.WithHooks(combinedHooks) 是 mcp-go 框架提供的 Option 模式,将 Hooks 注入到 MCP Server 中。框架在处理每个请求时,会在对应的生命周期节点调用 Hooks 中注册的回调。server.WithRecovery() 和 server.WithLogging() 是框架内置的中间件,与自定义 Hooks 互补——WithRecovery 防止 panic 导致进程崩溃,WithLogging 提供框架级日志。metrics.NewSet() 创建了一个独立的指标集合(而非使用全局默认集合),这允许 MCP 业务指标与进程级指标分开管理,在 /metrics 端点中分别输出。| 特性 | 运维价值 |
|---|---|
| 多 Hooks 合并 | Metrics 和 Logging 可独立开发、测试、部署,按需组合(如测试环境可关闭 Metrics) |
| 兼容 nil | 避免某类 Hooks 未初始化导致程序崩溃,提升鲁棒性 |
| 全字段覆盖 | 覆盖 26 个钩子字段(但需注意上游新增字段的同步,见上文潜在 Bug 分析) |
💡 深入理解:Merge 为什么是"粘合剂"而非"继承链"?
传统 OOP 思维可能会用继承来扩展 Hooks(如 MetricsHooks extends BaseHooks),但 Go 没有继承,Merge 用的是组合(Composition)思想——每个 Hooks 实例是独立的功能单元,Merge 只是把它们的回调切片拼接在一起。这意味着你可以像搭积木一样自由组合:Merge(metrics, logging) 用于生产环境,Merge(logging) 用于调试环境,Merge(metrics, logging, tracing) 用于全链路观测。
需要警惕的是 Merge 的顺序敏感性:append 保证先传入的 Hooks 先执行。在 Merge(metricsHooks, loggingHooks) 中,Metrics 钩子先于 Logging 钩子执行——如果 Metrics 钩子 panic(虽然不应该),后续的 Logging 钩子就不会执行。这也是为什么 server.WithRecovery() 在 main.go 中被启用的原因之一。
若需新增 链路追踪 能力,SRE 可无需修改现有代码,仅需:
// 1. 实现Tracing Hooks
func NewTracingHooks(tracer *otel.Tracer) *server.Hooks {
hooks := &server.Hooks{}
hooks.AddBeforeAny(func(ctx context.Context, id any, method mcp.MCPMethod, message any) {
// 启动span
ctx, span := tracer.Start(ctx, string(method))
span.SetAttribute("request_id", fmt.Sprintf("%v", id))
span.SetAttribute("session_id", extractSessionID(ctx))
})
hooks.AddOnSuccess(func(ctx context.Context, id any, method mcp.MCPMethod, message any, result any) {
// 结束span(成功)
span := trace.SpanFromContext(ctx)
span.End()
})
hooks.AddOnError(func(ctx context.Context, id any, method mcp.MCPMethod, message any, err error) {
// 结束span(失败)
span := trace.SpanFromContext(ctx)
span.RecordError(err)
span.End()
})
return hooks
}
// 2. 合并到现有Hooks(在 main.go 中)
metricsHooks := hooks.New(ms)
loggingHooks := hooks.NewLoggerHooks()
tracingHooks := NewTracingHooks(tracer)
combinedHooks := hooks.Merge(metricsHooks, loggingHooks, tracingHooks)
// 3. 传入MCP Server
s := server.NewMCPServer(
"VictoriaMetrics",
version,
server.WithHooks(combinedHooks),
)
SRE 价值:新增链路追踪完全不影响现有监控 / 日志逻辑,符合 开闭原则,降低变更风险。
⚠️ 注意:上述 Tracing 示例中 AddBeforeAny 钩子修改了 ctx(ctx, span := tracer.Start(ctx, ...)),但当前 mcp-go 框架的 BeforeAnyHookFunc 签名不返回 ctx,因此修改后的 ctx 不会传递到后续钩子和业务逻辑中。实际生产中需要通过其他方式(如 context.WithValue 在请求入口注入 span)来实现完整的链路追踪。
💡 深入理解:从源码到生产,Hooks 的"最后一公里"
源码中的 Hooks 实现是"骨架",生产落地才是"血肉"。源码给了你 7 个 Counter 指标和 5 类结构化日志,但真正让它发挥价值的是:Prometheus 的抓取配置、Grafana 的面板设计、告警规则的阈值调优、日志系统的采集链路。下面的最佳实践就是帮你把这些"骨架"变成可落地的运维体系。
一个核心原则:监控的成本不能超过监控的价值。源码中的 error="%s" 标签就是反面教材——它提供了最细粒度的错误信息,但代价是可能的基数爆炸。下面的优化建议都围绕这个原则展开:在信息量和成本之间找到最佳平衡点。
对 SRE 而言,mcp-victoriametrics 的 Hooks 机制是 可观测性的最优解,其核心价值可总结为:
| 维度 | 价值 |
|---|---|
| 可观测性 | 提供 “指标 + 日志 + 可扩展追踪” 的全维度观测能力,覆盖 MCP Server 所有生命周期 |
| 可维护性 | 监控 / 日志逻辑与业务解耦,变更风险低,迭代效率高 |
| 可扩展性 | 新增观测维度无需修改核心代码,仅需实现新的 Hooks 并合并 |
| 鲁棒性 | 防御性编程(如 nil 处理、序列化失败返回空)避免监控逻辑导致业务故障 |
| 兼容性 | 指标兼容 Prometheus,日志兼容主流日志系统,降低运维接入成本 |
从 SRE 视角看,Hooks 机制不仅是代码技巧,更是运维左移的最佳实践 —— 将可观测性设计融入代码架构,而非事后补丁,这也是 VictoriaMetrics 能成为高性能监控系统的核心原因之一。
🎯 全文核心洞察:三个函数撑起整个可观测性体系
回顾整个 hooks.go,核心代码量不到 200 行,却只用了三个函数就构建了完整的可观测性体系:New(ms) 负责指标采集(7 个 Counter 覆盖全业务),NewLoggerHooks() 负责结构化日志(三层漏斗覆盖全生命周期),Merge() 负责组合扩展(积木式拼装任意观测能力)。
这种设计的精髓在于"约束即自由"——mcp-go 框架通过 server.Hooks 结构体约束了所有可能的挂载点,而 VictoriaMetrics 团队在这些约束内,用最少的代码实现了最大的运维价值。对 SRE 来说,这不仅是一个可以直接复用的可观测性方案,更是一种值得借鉴的架构思维:把横切关注点从业务代码中彻底剥离,让监控成为架构的一部分,而非事后的补丁。
附录:生产环境监控面板(Grafana)核心指标
| 图表 | 指标表达式 | 说明 |
|---|---|---|
| 客户端分布 | sum(mcp_victoriametrics_initialize_total) by (client_name) |
各 AI 客户端的连接数 |
| 工具调用错误率 | rate(mcp_victoriametrics_call_tool_total{is_error="true"}[5m]) / rate(mcp_victoriametrics_call_tool_total[5m]) |
按工具名分组展示错误率 |
| 资源访问热度 | topk(10, sum(mcp_victoriametrics_read_resource_total) by (uri)) |
访问量前 10 的资源 URI |
| 全局错误数 | rate(mcp_victoriametrics_error_total[5m]) |
按 MCP 方法分组展示错误数 |
| 活跃会话数 | count by (instance) (changes(mcp_victoriametrics_initialize_total[1m])) |
每分钟新增会话数 |
此内容由惯性聚合(RSS阅读器)自动聚合整理,仅供阅读参考。 原文来自 — 版权归原作者所有。