Observability

状态: 2026-06-06 基于代码 verify 定位: 工程实操 / 字段级约束。架构层"observability 是横切层"已在 architecture/2-backend.md 隐含,本文是接入细节。 不重复: 各端 SDK 升级 / 版本 cutover 见各 repo CLAUDE.md。本文只讲"现状怎么 wired"。

三端速查

主 SDK (版本)上报到接入文件
Frontend (apps/web)@sentry/vue ^10.43.0Sentry onlysrc/utils/sentry.ts
apps/api Lambda@sentry/aws-serverless ^10.43.0 + @aws-lambda-powertools/* ^2.29.0 + just-metrics ^0.0.2Sentry + CloudWatch EMF metricssrc/services/observability.ts
Backend Lambda@sentry/aws-serverless ^10.43.0 + @aws-lambda-powertools/logger ^2.30.xLark webhook + Sentry + CloudWatchlambda/shared/utils/logger.ts + lambda/shared/sentry.ts

跨端串联走 tracePropagationTargets(前端) → X-Request-Id header(apps/api) → SQS message attribute rootCorrelationId(backend Lambdas)。

1. Frontend (apps/web)

studio-website-monorepo/apps/web Vue 3 SPA,部署在 Cloudflare Pages。

初始化时机:src/main.ts:61 initializeSentry(app, router) + src/main.ts:64 setupSessionTracking(),在 bootstrap()setupPlugins / app.mount 之前。

环境 gate:src/utils/sentry.ts:24hostname === 'localhost' || '127.0.0.1'shouldEnable = false,localhost 完全不 init(包括不发请求);否则取 stage 作 environment(test / pre / prod)。

上报内容(src/utils/sentry.ts:99-119):

  • browserTracingIntegration({ router }) — Vue Router 导航追踪
  • replayIntegration — 5% 采样 + 错误时 100%(prod);maskAllText: true + blockAllMedia: true 全脱敏
  • feedbackIntegration — 用户反馈表单,error 时显式触发(autoInject: false)
  • tracesSampleRate: 0.1(prod) / 1.0(test/pre)

分布式 trace:src/utils/sentry.ts:96 tracePropagationTargets: ['localhost', /^https:\/\/.*\.retaintive\.ai/] — 给 retaintive 域名加 sentry-trace / baggage header,串到 apps/api 的 Sentry trace。

User context:

  • 登录后调 src/utils/sentry.ts:37 setSentryUser(cognitoSub, email, username)id 用 Cognito sub(永久 ID)
  • 登出调 src/utils/sentry.ts:52 clearSentryUser()Sentry.setUser(null)
  • 自建 session analytics(src/utils/sentry-analytics.ts) 还会另起 sessionId、track 页面访问 / feature 使用 / org 切换;通过 Sentry.setContext('session', ...)Sentry.setTag('organization.current', orgId) 附在每个 event 上(sentry-analytics.ts:90-95, 191)。

session analytics 接口(src/utils/sentry-analytics.ts:11-21, 26-35):

  • SessionAnalytics — sessionId / startTime / pagesVisited / totalActions / featuresUsed / organizations / errors
  • FeatureCategory enum — DASHBOARD / CALL_ANALYSIS / FILTERS / EXPORTS / AUDIO / SEARCH / SETTINGS / ORGANIZATION
  • composable src/composables/analytics/useSentryAnalytics.ts 暴露 trackAction / trackFilter / trackExport / trackSearch / trackDateRange / trackOrgSwitch / trackCallView / trackAudioPlayback / trackTableAction / trackCacheOperation / trackModal / trackSnapshot / trackThemeChange,全部通过 Sentry.addBreadcrumb + setContext('user_actions', ...) 上报。analytics 错误用 try-catch 吞掉,不影响 app(useSentryAnalytics.ts:122-128)。

噪音过滤(src/utils/sentry.ts:125-189):

  • ignoreErrors:ResizeObserver、浏览器扩展、Script error. — 永远 drop
  • beforeSend:网络错误 / chunk 加载失败默认 drop;但 ErrorBoundary 捕获的会 bypass(event.tags?.error_boundary → return event),保证 UI 崩溃必上报
  • beforeSend 也负责脱敏 — 删 Authorization header,URL 里 token= 替换为 [REDACTED]

Release 版本:release: import.meta.env.VITE_CF_PAGES_COMMIT_SHA || 'studio-web@<stage>'(src/utils/sentry.ts:82),CF Pages 自动注入 commit SHA。

调试:

  • 本地 localhost 直接跳过 init,改 hostname 才能验上报(或临时把 shouldEnable hardcode true)
  • Sentry 项目:studio-web org o4510203520745472(DSN 在 sentry.ts:75,public 安全)
  • 控制台会打 Sentry initialized for environment: <env>(sentry.ts:192)

2. apps/api Lambda

studio-website-monorepo/apps/api Hono on AWS Lambda。

Cold start 接入(src/lambda.ts:13):initSentry() 在模块顶层调用 — 一次 cold start 跑一次。initSentry() 内部读 process.env['SENTRY_DSN'],没设就 warn 并 skip(services/observability.ts:51-72)。

handler 包装(src/lambda.ts:26):

export const handler = Sentry.wrapHandler(async (event, context) => {
  try { return await honoHandler(event, context); }
  finally {
    metrics.publishStoredMetrics();
    await flushSentryMetrics();      // 必须 await,见下
    logger.resetKeys();
  }
});

Sentry.wrapHandler 加 cold start tag、timeout warning(~500ms 前触发)、自动 flush。callbackWaitsForEmptyEventLoop = false 由 wrapHandler 设,所以 flushSentryMetrics() 必须 await — 否则 just-metrics 的 HTTP 请求会被 Lambda freeze 砍掉(services/observability.ts:74-84 注释解释)。

双 metrics 通道:同一份 metric 同时写 CloudWatch EMF(免费,通过 Powertools Metrics)和 Sentry metrics(通过 just-metrics,同 DSN)。两边 dashboard 各自看。

Metric 命名 convention:

  • Powertools 用 PascalCase:EndpointLatency / ClientError / ServerError / CacheHit / CacheMiss / RingCentralLatency / RingCentralError / TokenRefreshSuccess / TokenRefreshFailure / OAuthSuccess / OAuthFailure / DynamoDBLatency / DynamoDBError / DynamoDBConsumedCapacity / CallsQueried / StoreOperation / PhoneNumbersSynced / ConnectionStateChange / SharedAccessOperation / WebhookEventReceived / RecordingDownloadSuccess / RecordingDownloadFailure / RecordingSize(services/observability.ts:99-314)
  • Sentry 用 dot.lowercase:api.latency / api.client_error / api.server_error / cache.hit / cache.miss / ringcentral.latency / ringcentral.error / token.refresh_success / oauth.success
  • Namespace Studio,默认 dimension environment(services/observability.ts:36-42)

Path normalization(services/observability.ts:137-161):UUID → /:ids-xxxx session id → /:sessionId、纯数字 → /:id、非 /v2/ 开头 → /_other未 normalize 的旧版每个 unique path 一个 metric,导致 619 unique paths → 1570 metrics → ~$19/月(代码注释引 AWS Cost Explorer 数据)。

Structured log(middleware/tracing.ts):每 request crypto.randomUUID() 或读 X-Request-Id header → c.set('requestId') + c.header('X-Request-Id') + logger.appendKeys({ requestId })。所有后续 logger.info / logger.error 自动带 requestId。响应 JSON 也会被 inject requestId 字段(tracing.ts:75-90)。

X-Ray subsegment(tracing.ts:44-53):每请求 addNewSubsegment('hono-request'),annotation 加 requestId / path / methodtracer.enabled = false when config.environment === 'test'(services/observability.ts:30-33)。

Error 上报路径(middleware/error-handler.ts):

  • AppError(已知业务错误)→ logger.warn + recordClientError / recordServerError, capture Sentry
  • 其他未捕获错误 → logger.error + captureError(err, { requestId, path, method })(Sentry)+ recordError('Unhandled') + recordServerError('INTERNAL_ERROR')(error-handler.ts:40-53)

tracesSampleRate:prod 0.1 / 非 prod 1.0(services/observability.ts:63)。

调试:

  • CloudWatch Logs Insights 按 requestId 串整条请求:fields @timestamp, @message | filter requestId = "<uuid>"
  • 慢 endpoint 看 CloudWatch metric Studio namespace → EndpointLatency,按 path+method dimension 分组
  • 同样 metric 在 Sentry api.latency distribution,按 attribute path filter
  • 没设 SENTRY_DSN:logger.warn 打 SENTRY_DSN not set, Sentry disabled,sentryMetrics 整体 no-op

3. Backend Lambda (callytics-infrastructure)

callytics-infrastructure/lambda/* — pipeline Lambda(ai-analysis-processor / message-processor / transcribe-processor / contacts-analyzer / neon-sync / reconciliation-orchestrator / storeid-coverage-monitor / alarm-dispatcher / config-manager 等)。

核心 pattern:createLogger(name) + wrapHandler(rawHandler)

fan-out 设计(lambda/shared/utils/logger.ts:284-321):logger.error(message, data) 一次调用,自动:

  1. powertoolsLogger.error() 写 CloudWatch(structured JSON,自动带 serviceName / environment / requestId / messageId / clientId / telephonySessionId / rootCorrelationId / phone / storeId / source)
  2. notifyLarkError(error, context) fire-and-forget 推 Lark webhook(经 lark-dm-bot worker dedup 或直推,见 lambda/shared/utils/lark.ts)
  3. captureException(error, { lambdaName, ...loggerContext, ...data }) Sentry capture

⚠️ 代码注释里写 "Discord + Lark + Sentry" 是历史残留 — 当前仓库没有 Discord webhook 实际 wiring(grep 全 lambda/DISCORD_WEBHOOKdiscord.com/api/webhooks),fan-out 实际只有 Lark + Sentry(CloudWatch 是 Powertools 默认写入,不算 fan-out target)。

Sentry 初始化(lambda/shared/sentry.ts:25-62):

  • initSentry(serviceName) 模块顶层调一次,内部用 initialized flag 防重复
  • SENTRY_DSN 就 warn 一次,所有 helper(captureException / setSentryUser / addBreadcrumb / flush)变 no-op
  • release: callytics-<serviceName>@<env>,tracesSampleRate: prod 0.1 / 其他 1.0,sendDefaultPii: false(后端不带 PII,跟 frontend 不同)
  • beforeSend 跟 frontend 一样脱敏 token=Authorization header(sentry.ts:49-58)
  • createLogger(serviceName) 会自动调 initSentry(serviceName)(logger.ts:243)— 用 createLogger 的 Lambda 不需要单独 initSentry,但显式调更清晰

wrapHandler pattern(各 Lambda handler 末尾):

initSentry('ai-analysis-processor');                    // handler.ts:45
// ...
export const handler = wrapHandler(rawHandler);         // handler.ts:308

SENTRY_DSNwrapHandler 直接返回原 handler(sentry.ts:71-76),不付包装成本。

跨 Lambda trace 串联 — 不是靠 Sentry tracing(后端 Lambda 间 SQS 不自动 propagate),而是靠 lambda/shared/utils/correlation.ts:

  • rootCorrelationId 从 SQS message attribute 取(correlation.ts:41),fallback 用 sqsRecord.messageId
  • setLoggerContext({ rootCorrelationId, phone, storeId, source, ... }) 把它 append 到 Powertools logger 的 persistent keys(logger.ts:71-74),整条 invocation 的日志都带这个 ID
  • CloudWatch Logs Insights 按 rootCorrelationId filter 可以串起一条 RC webhook → AI analysis → message → contacts 全 4 阶段的日志

重要 hygiene(logger.ts:80-101):invocation 结束必须 clearLoggerContext() 移除 persistent keys,否则 warm container 下一条 SQS record 会继承上一条的 rootCorrelationId / clientId(Codex review 2026-05-03 catch)。

周期 Lambda 监控模板:storeid-coverage-monitor(lambda/storeid-coverage-monitor/src/handler.ts)是 "EventBridge cron + DB 查询 + logger.error 越阈触发 fan-out" 的模板。设计 rationale(handler.ts:9-16):

  • logger.error 直接走 Lark + Sentry,$0/月 vs CloudWatch alarm ~$3.70/月 — 选这条 pattern 是因为 storeid 监控是 "跨 6 表派生指标",DB 查询型不适合 CloudWatch metric 表达
  • handler.ts:9-16 注释也提到 "SNS topic 在 test/pre 没人订阅,alarm 黑洞" — 该顾虑在 PR #1049 之后已不成立(AlarmDispatcher Lambda 已订阅 SNS topic);现在 CloudWatch alarm 与周期 Lambda 是两条 first-class pattern,按"监控的对象"选(metric 越阈 vs 跨表业务不变式),不再因 SNS 黑洞回避 alarm 路径

SNS alarm dispatcher(PR #737 / #1049 补的修复路径,lib/stacks/monitoring-stack.ts:216-251):

  • CloudWatch alarm 仍然 SnsActionalarmTopic
  • 但新加了一个 AlarmDispatcher Lambda 订阅这个 SNS topic,handler 把 alarm payload 转成 logger.error() → 走 Lark + Sentry 通道
  • 解决"alarm 有但没人订阅"问题(issue #723)
  • 所有环境无条件创建,SENTRY_DSN 显式写在 environment(monitoring-stack.ts:243)

SQS DLQ(lib/stacks/sqs-stack.ts):每个主队列配 dedicated DLQ,14 天 retention(callLogDLQ / transcriptionResultDLQ / reconciliationDLQ / dailyBatchDLQ / leadProcessorDLQ)。CloudWatch alarm 监控 DLQ 消息数,触发后走 alarm-dispatcher 路径。

调试:

  • CloudWatch Logs Insights filter rootCorrelationId = "..." 串整条 pipeline
  • Lark "View Logs" deep link 用 AWS_LAMBDA_FUNCTION_NAME(runtime auto-inject)构造正确 log group path(logger.ts:234-240,issue #738)
  • Sentry 项目用单一 org,各 Lambda 通过 release: callytics-<serviceName>@<env> 区分
  • 想看一个 catch block 的完整错误链(Node 20+ fetch 错误的真正 root cause 在 error.cause 里),用 extractErrorDetails(err)(logger.ts:172-202)展开 errorType / errorCause(递归 3 层)/ errorStack(前 5 帧)

4. Alert Routing — 哪种错走哪条通道

信号CloudWatch LogsSentryLark webhookDLQ
Frontend JS 错误(非 ignoreErrors)YES
Frontend session replay(error 时)YES
apps/api AppError(4xx/5xx)YES(logger.warn)NONO
apps/api unhandled exceptionYES(logger.error)YES(captureError)NO
apps/api metric 异常(latency / error rate)YES(EMF metric)YES(just-metrics)NO
backend Lambda logger.errorYESYES(captureException)YES(notifyLarkError)
backend Lambda 未捕获异常YES(Powertools)YES(wrapHandler)NO(没经过 logger.error)重试用尽进 DLQ
backend SQS 消息重试用尽YES
CloudWatch alarm 触发YES(via dispatcher)YES(via dispatcher)
周期监控 Lambda 越阈(如 storeid coverage)YESYESYES

决策树:

  • 后端 Lambda 里写代码 → 想触发 Lark 通知 → 走 logger.error(不要直接调 notifyLarkError,会丢 CloudWatch + Sentry)
  • 后端 Lambda 想抓 unhandled exception → wrapHandler 已经管,Sentry 自动捕,但 Lark 不会响(因为没经过 logger.error)— 想响就 catch + logger.error 重抛
  • apps/api 想抓 unhandled → 已经有 errorHandler middleware,自动 Sentry + metric
  • 前端想加追踪事件 → useSentryAnalytics() composable,不要直接 Sentry.captureMessage

5. Common Tasks

找一个 user 的前端错误:

  • Sentry → studio-web project → filter user.id:<cognitoSub>user.email:<email>(sentry-analytics.ts:77-82 设的)
  • 同一 user 跨 session 用 cognitoSub,单 session 用 session.id(setContext 设的)

debug apps/api endpoint 慢:

  • CloudWatch metric Studio namespace → EndpointLatency → 按 path + method dimension 看 p50/p99
  • 或 Sentry → api.latency distribution,按 attribute filter
  • 进 CloudWatch Logs Insights:fields @timestamp, requestId, duration | filter path = "/v2/calls" | sort duration desc | limit 20,拿 requestId 再 fields @message | filter requestId = "<id>" 看整条请求

监控新 backend metric(两条 first-class pattern,按"监控的对象"选):

  • 某 CloudWatch metric 越阈(Lambda Duration / DLQ depth / Queue age / EMF custom)→ 用 CloudWatch alarm + SnsAction → alarmTopic,monitoring-stack 的 alarm-dispatcher Lambda 已订阅该 SNS topic,自动路由到 Lark + Sentry。模板看 lib/stacks/monitoring-stack.ts 已有 12 个 DLQ / Errors / Duration alarm。
  • 跨表 / 跨 service 业务不变式(如 storeId NULL 率 / 跨 6 表的派生指标)→ 写周期 Lambda,EventBridge cron 触发,异常时 logger.error(...) — 自动走 Lark + Sentry,无 SNS,$0/月。模板看 lambda/storeid-coverage-monitor/src/handler.ts
  • 历史 caveat:PR #1049 之前 alarm topic 没 Lambda subscriber,CloudWatch alarm 是 "silent fire"(black hole 订阅者问题,#988)。现在已 wired,两条 pattern 都可用。验证方法见下条。

trigger 测试 alert:

  • 后端:任何一个 Lambda 临时加 logger.error('test alert', { error: 'test' }),跑一次 → Lark 群里能看到
  • 前端:本地 hostname 改成非 localhost(或临时 patch shouldEnable = true)→ throw new Error('test') → Sentry 项目能看到

验证 CloudWatch alarm → SNS → Lark 整条链(改 monitoring-stack / 加新 alarm / on-call 季度演练 / 怀疑 silent failure 时跑):

aws cloudwatch set-alarm-state 强制 alarm 进 ALARM 状态 — 跳过等 metric 真值跨阈值,直接走 alarm action(SNS publish),整条 chain 跟真实 fire 时无差(payload 一致,只是 StateReason 是手填的)。

# 1. 强制 ALARM(只在 test env 用)
aws cloudwatch set-alarm-state \
  --alarm-name call-analytics-test-ai-analysis-dlq-alarm-us-west-2 \
  --state-value ALARM \
  --state-reason "Manual smoke test YYYY-MM-DD — verify DLQ→SNS→Lark chain" \
  --region us-west-2

# 2. 等 ~10s,看 dispatcher Lambda 真的被 invoke
aws logs describe-log-streams \
  --log-group-name /aws/lambda/call-analytics-test-alarm-dispatcher-us-west-2 \
  --region us-west-2 --order-by LastEventTime --descending --limit 1

# 3. confirm 4 个 metric(前 3 个为 1,Errors 为 0 = 链通;publish=0 是 SNS policy broken,fail>0 是 Lambda 崩):
#    AWS/SNS NumberOfMessagesPublished  (CloudWatch → SNS)
#    AWS/SNS NumberOfNotificationsDelivered  (SNS → Lambda)
#    AWS/Lambda Invocations call-analytics-test-alarm-dispatcher-us-west-2
#    AWS/Lambda Errors      call-analytics-test-alarm-dispatcher-us-west-2  → 应为 0

# 4. 去 Lark Service Errors channel 看卡片(summary + detail 一对,reason 字段会带你上面填的字符串,end-to-end traceable)

# 5. cleanup — reset 回 OK
aws cloudwatch set-alarm-state \
  --alarm-name call-analytics-test-ai-analysis-dlq-alarm-us-west-2 \
  --state-value OK \
  --state-reason "Reset after smoke test" \
  --region us-west-2

历史:2026-05-31 起 7 天 silent failure(SNS topic policy 缺 Allow cloudwatch.amazonaws.com SNS:Publish,alarm 转 ALARM 但 SNS publish=0)→ PR #1049 fix → 2026-06-08 用上面方法 3 alarm × 2 卡片 = 6 张 Lark 卡片端到端 verified。详见 callytics-infrastructure#988。

串一条 pipeline 全链路日志:

  • 从 apps/api 拿 X-Request-Id header → 在 CloudWatch 找对应 Lambda 日志
  • backend 阶段从一个 Lambda 拿到 rootCorrelationId(structured log 里)→ Logs Insights 跨 4 个 log group filter 同一 ID,看 RC webhook → AI analysis → message → contacts 完整时序

6. 不在本文范围

  • 各 repo CLAUDE.md 里 Sentry 版本 / DSN 切换历史 / 自托管 → SaaS 迁移 timeline(动态,见各 repo)
  • storeid-coverage-monitor 业务阈值调优、6 个表的 NULL 率定义(见 callytics-infrastructure handler.ts 顶部注释 + issue #647/#651)
  • alarm-dispatcher Lambda 内部 routing 逻辑(由 alarm NewStateValue 路由 error / info / warn,见 issue #723)
  • Lark webhook 鉴权 dedup 协议(@retaintive/common/lark/notifier + retaintive/lark-dm-bot /service-error worker,见 callytics-infrastructure .claude/specs/2026-05-15-unified-lark-alert-dedup-design.md)
  • 跨 repo trace 数据流 / Sentry 项目划分策略(retaintive 后续 review)