> For AI agents: the complete documentation index is available at /llms.txt, the full documentation bundle is available at /llms-full.txt.

# Observability

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

## 三端速查

| 端                   | 主 SDK (版本)                                                                                     | 上报到                                | 接入文件                                                        |
| ------------------- | ---------------------------------------------------------------------------------------------- | ---------------------------------- | ----------------------------------------------------------- |
| Frontend (apps/web) | `@sentry/vue ^10.43.0`                                                                         | Sentry only                        | `src/utils/sentry.ts`                                       |
| apps/api Lambda     | `@sentry/aws-serverless ^10.43.0` + `@aws-lambda-powertools/* ^2.29.0` + `just-metrics ^0.0.2` | Sentry + CloudWatch EMF metrics    | `src/services/observability.ts`                             |
| Backend Lambda      | `@sentry/aws-serverless ^10.43.0` + `@aws-lambda-powertools/logger ^2.30.x`                    | Lark webhook + Sentry + CloudWatch | `lambda/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:24` 判 `hostname === '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`):

```ts
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 → `/:id`、`s-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` / `method`。`tracer.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_WEBHOOK` 或 `discord.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 末尾):

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

没 `SENTRY_DSN` 时 `wrapHandler` 直接返回原 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 仍然 `SnsAction` 到 `alarmTopic`
- 但新加了一个 `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 Logs   |          Sentry         |      Lark webhook      |    DLQ    |
| ---------------------------------------- | :-----------------: | :---------------------: | :--------------------: | :-------: |
| Frontend JS 错误(非 ignoreErrors)           |          —          |           YES           |            —           |     —     |
| Frontend session replay(error 时)         |          —          |           YES           |            —           |     —     |
| apps/api `AppError`(4xx/5xx)             |  YES(`logger.warn`) |            NO           |           NO           |     —     |
| apps/api unhandled exception             | YES(`logger.error`) |   YES(`captureError`)   |           NO           |     —     |
| apps/api metric 异常(latency / error rate) |   YES(EMF metric)   |   YES(`just-metrics`)   |           NO           |     —     |
| backend Lambda `logger.error`            |         YES         | YES(`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)       |         YES         |           YES           |           YES          |     —     |

**决策树**:

- 后端 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` 是手填的)。

```bash
# 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)
