Skip to content

Commit 07cec6e

Browse files
authored
feat(observability): 接入 Prometheus 指标与 OpenTelemetry/Tempo 链路 (#360)
* feat(observability): 接入 Prometheus 指标与 OpenTelemetry/Tempo 链路 HTTP/WebSocket/Agent/外部 SDK 只打点和打 span,方便定位一轮对话卡在 ASR、LLM 还是 TTS;Grafana 只负责把数据画出来。 * feat(observability): 记录语音会话占用阶段与挂断原因 Grafana 需要当前卡在 ASR/LLM/TTS 的人数,以及 UI 挂断、超时、道别的区分;session_id 只留进程内,不进 Prometheus label。 * feat(observability): 在 Tempo span 上记录 APK 登录用户名 Grafana 按登录页账号筛 turn/tool 轨迹;username 只作为 span 属性,不进 Prometheus label。 * feat(observability): 把 Grafana 看板纳入版本库,展示 APK 登录账号 此前整棵 /observability/ 被 ignore,登录账号筛选只留在本机 Grafana。把看板 JSON 纳入仓库,Tempo 表选出 timeflow.username。 * style: 按 ruff 折行 Qwen Audio 连续模式测试断言 合入 main 后该行超长,CI 的 ruff format --check 失败。 * fix(observability): 保留 VOICE_TELEMETRY 具体类型以绑定用户名查找 mypy 将 Protocol 标注视为对外类型,导致 composition root 无法调用 bind_username_lookup。 * test(observability): 补齐会话占用、用户名查找与错误路径覆盖 覆盖 composition root 的账户查找、NoOp 占用接口,以及握手/播放/数据库监听的边界分支,把 patch 覆盖率抬过 Codecov 门槛。 * fix(observability): 用户名查找失败时回退 unknown,不打断语音回合 数据库短暂不可用时记录脱敏诊断,span 使用 unknown 且不写入缓存,恢复后下一轮仍可补上登录名。 * feat(observability): 用 Compose 在同一台机器部署 Grafana/Prometheus/Tempo 配置改为容器路径和 Docker 服务名,去掉本机绝对路径,便于和 API 一起上云。
1 parent 645d781 commit 07cec6e

75 files changed

Lines changed: 7395 additions & 119 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

‎.env.example‎

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,34 @@
11
POSTGRES_DB=timeapp
22
POSTGRES_USER=timeapp
33
POSTGRES_PASSWORD=timeapp
4+
POSTGRES_HOST=0.0.0.0
45
POSTGRES_PORT=5432
6+
API_HOST=0.0.0.0
57
API_PORT=8000
68
TIMEFLOW_ENVIRONMENT=development
79
# Generate a private value of at least 32 UTF-8 bytes before starting the API.
810
TIMEFLOW_JWT_SECRET=
911
# 访问令牌有效期(秒),默认一个月(30 天)。
1012
TIMEFLOW_JWT_ACCESS_TTL_SECONDS=2592000
1113
TIMEFLOW_CORS_ALLOWED_ORIGINS=http://localhost:8081,http://127.0.0.1:8081
14+
15+
# 云上同一台机器跑 API 时填真实语音密钥;非 development 必须配置,否则 /ws 拒绝启动。
16+
TIMEFLOW_VOICE_AGENT_MODE=1
17+
TIMEFLOW_ALIYUN_AUDIO_API_KEY=
18+
TIMEFLOW_ALIYUN_AUDIO_WORKSPACE_ID=
19+
TIMEFLOW_OPENAI_BASE_URL=
20+
TIMEFLOW_OPENAI_API_KEY=
21+
TIMEFLOW_OPENAI_MODEL=qwen3.6-flash
22+
TIMEFLOW_TENCENT_MAP_KEY=
23+
24+
# 同一台机器跑 Grafana / Prometheus / Tempo 时:
25+
# docker compose -f docker-compose.yml -f docker-compose.observability.yml up -d --build
26+
# 云上建议:API_HOST=127.0.0.1 POSTGRES_HOST=127.0.0.1 TIMEFLOW_ENVIRONMENT=production
27+
GRAFANA_HOST=127.0.0.1
28+
GRAFANA_PORT=3000
29+
GRAFANA_ADMIN_USER=admin
30+
GRAFANA_ADMIN_PASSWORD=
31+
GRAFANA_ROOT_URL=http://127.0.0.1:3000
32+
SENTRY_AUTH_TOKEN=
33+
TIMEFLOW_OTEL_SERVICE_NAME=timeflow-backend
34+
TIMEFLOW_OTEL_EXPORTER_OTLP_ENDPOINT=

‎.gitignore‎

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,13 @@
22
.idea/
33
.cursor/
44
scripts/
5+
# Local Grafana/Prometheus/Tempo install (binaries, WAL, plugins). Config is tracked.
6+
/observability/data/
7+
/observability/downloads/
8+
/observability/grafana/
9+
/observability/prometheus/prometheus
10+
/observability/prometheus/promtool
11+
/observability/tempo/tempo
512
.env
613
.venv/
714
node_modules/

‎backend/.env.example‎

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -83,3 +83,7 @@ TIMEFLOW_TENCENT_MAP_TIMEOUT_SECONDS=5
8383
# Which voice agent backend /ws uses: "1" = end-to-end realtime model (above),
8484
# "2" = ASR + LLM + TTS conversation pipeline (requires ASR, OpenAI-compatible LLM, and TTS).
8585
TIMEFLOW_VOICE_AGENT_MODE=1
86+
87+
# Optional Tempo export. Empty keeps traces off; Prometheus still scrapes GET /metrics.
88+
TIMEFLOW_OTEL_SERVICE_NAME=timeflow-backend
89+
TIMEFLOW_OTEL_EXPORTER_OTLP_ENDPOINT=

‎backend/pyproject.toml‎

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,10 @@ dependencies = [
2222
"sqlalchemy>=2.0,<3",
2323
"uvicorn>=0.51,<1",
2424
"websockets>=15,<16",
25+
"opentelemetry-api>=1.27,<2",
26+
"opentelemetry-exporter-otlp-proto-http>=1.27,<2",
27+
"opentelemetry-sdk>=1.27,<2",
28+
"prometheus-client>=0.21,<1",
2529
]
2630

2731
[dependency-groups]

‎backend/src/timeflow/composition.py‎

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@
2121
from timeflow.intelligence.location import ClientLocation, LocationSearchService
2222
from timeflow.intelligence.ports import ResultSink
2323
from timeflow.intelligence.schedule_category import LlmScheduleCategoryClassifier
24+
from timeflow.observability import VOICE_TELEMETRY
2425

2526

2627
def build_composed_voice_agent(
@@ -63,6 +64,7 @@ def agent_factory(
6364
client_location=client_location,
6465
),
6566
max_tool_rounds=settings.agent_max_tool_rounds,
67+
telemetry=VOICE_TELEMETRY,
6668
)
6769

6870
return ComposedVoiceAgent(
@@ -71,6 +73,7 @@ def agent_factory(
7173
QwenAudioTts(settings),
7274
result_sink,
7375
location_service=location_service,
76+
telemetry=VOICE_TELEMETRY,
7477
)
7578

7679

‎backend/src/timeflow/data/database.py‎

Lines changed: 15 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,16 +1,29 @@
11
"""SQLAlchemy primitives for persistence adapters."""
22

3-
from sqlalchemy import Engine, create_engine
3+
from sqlalchemy import Engine, create_engine, text
44
from sqlalchemy.orm import DeclarativeBase, Session, sessionmaker
55

6+
from timeflow.infrastructure.observability.database import instrument_engine
7+
68

79
class Base(DeclarativeBase):
810
"""Declarative base for TimeFlow-owned database models."""
911

1012

1113
def build_engine(database_url: str) -> Engine:
1214
"""Create a database engine without opening a connection eagerly."""
13-
return create_engine(database_url, pool_pre_ping=True, hide_parameters=True)
15+
engine = create_engine(database_url, pool_pre_ping=True, hide_parameters=True)
16+
return instrument_engine(engine)
17+
18+
19+
def ping_database(engine: Engine) -> bool:
20+
"""Return whether the engine can execute a trivial readiness query."""
21+
try:
22+
with engine.connect() as connection:
23+
connection.execute(text("SELECT 1"))
24+
return True
25+
except Exception:
26+
return False
1427

1528

1629
def build_session_factory(engine: Engine) -> sessionmaker[Session]:

‎backend/src/timeflow/data/repositories/account.py‎

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,10 @@ def get_by_username(self, username: str) -> AccountRecord | None:
2323
model = self._require_session().scalar(select(Account).where(Account.username == username))
2424
return None if model is None else _to_record(model)
2525

26+
def get_by_id(self, account_id: str) -> AccountRecord | None:
27+
model = self._require_session().get(Account, account_id)
28+
return None if model is None else _to_record(model)
29+
2630
def add(self, account: NewAccount) -> AccountRecord:
2731
session = self._require_session()
2832
model = Account(

‎backend/src/timeflow/gateway/http/auth.py‎

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
"""账户访问与稳定认证错误的 HTTP 适配器。"""
22

33
import logging
4+
import time
45
from collections.abc import Callable, Coroutine
56
from dataclasses import dataclass
67
from typing import Any, Protocol
@@ -15,6 +16,7 @@
1516
from timeflow.business.auth import AuthAccessResult, AuthError, AuthErrorCode
1617
from timeflow.gateway.auth_diagnostics import log_sanitized_exception
1718
from timeflow.gateway.http.rate_limit import AuthRateLimiter
19+
from timeflow.gateway.observability.http import record_auth_access
1820

1921
logger = logging.getLogger(__name__)
2022

@@ -180,9 +182,11 @@ def get_route_handler(
180182
original_handler = super().get_route_handler()
181183

182184
async def route_handler(request: Request) -> Response:
185+
started = time.perf_counter()
183186
try:
184187
return await original_handler(request)
185188
except RequestValidationError as error:
189+
record_auth_access("failure", time.perf_counter() - started)
186190
error_code = _single_invalid_request_field(error)
187191
if error_code is None:
188192
return _uncontracted_validation_response()
@@ -213,17 +217,21 @@ def access(
213217
http_request: Request,
214218
request: AuthAccessRequest,
215219
) -> AuthAccessResponse | Response:
220+
started = time.perf_counter()
216221
client = http_request.client
217222
client_key = client.host if client is not None else "unknown"
218223
if not limiter.allow(client_key):
224+
record_auth_access("rate_limited", time.perf_counter() - started)
219225
return _error_response(
220226
AUTH_RATE_LIMITED,
221227
retry_after_seconds=limiter.retry_after_seconds,
222228
)
223229
try:
224230
result = auth_access.access(request.username, request.password)
225231
except Exception as error:
232+
record_auth_access("failure", time.perf_counter() - started)
226233
return _service_error_response(error)
234+
record_auth_access("success", time.perf_counter() - started)
227235
return AuthAccessResponse(
228236
account_id=result.account_id,
229237
access_token=result.access_token,

‎backend/src/timeflow/gateway/http/reminder_state.py‎

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
"""HTTP adapter for syncing a schedule reminder's final confirmed state."""
22

33
import logging
4+
import time
45
from collections.abc import Callable, Coroutine
56
from datetime import UTC, datetime
67
from typing import Annotated, Any, Literal
@@ -24,6 +25,7 @@
2425
AuthenticatedAccount,
2526
AuthenticatedAccountDependency,
2627
)
28+
from timeflow.gateway.observability.http import record_reminder_state
2729

2830
logger = logging.getLogger(__name__)
2931

@@ -91,9 +93,11 @@ def get_route_handler(
9193
original_handler = super().get_route_handler()
9294

9395
async def route_handler(request: Request) -> Response:
96+
started = time.perf_counter()
9497
try:
9598
return await original_handler(request)
9699
except RequestValidationError:
100+
record_reminder_state("invalid", time.perf_counter() - started)
97101
response = ReminderStateInvalidRequestResponse()
98102
return JSONResponse(
99103
status_code=422,
@@ -135,6 +139,7 @@ def confirm_reminder_state(
135139
request: ReminderStateRequest,
136140
account: Annotated[AuthenticatedAccount, Security(trusted_account)],
137141
) -> Response:
142+
started = time.perf_counter()
138143
try:
139144
result = confirmer.confirm(
140145
account_id=account.account_id,
@@ -145,18 +150,23 @@ def confirm_reminder_state(
145150
disposition_state=result.disposition_state,
146151
updated_at=_as_utc(result.updated_at),
147152
)
153+
record_reminder_state("confirmed", time.perf_counter() - started)
148154
return JSONResponse(status_code=200, content=response.model_dump(mode="json"))
149155
except ScheduleBusinessError as error:
150156
if error.code is ScheduleErrorCode.SCHEDULE_NOT_FOUND:
157+
record_reminder_state("not_found", time.perf_counter() - started)
151158
return _error_response(404, "SCHEDULE_NOT_FOUND", "Schedule not found")
152159
if error.code is ScheduleErrorCode.REMINDER_NOT_CONFIGURED:
160+
record_reminder_state("conflict", time.perf_counter() - started)
153161
return _error_response(
154162
409,
155163
"REMINDER_NOT_CONFIGURED",
156164
"Reminder not configured",
157165
)
166+
record_reminder_state("error", time.perf_counter() - started)
158167
return _internal_error_response(error)
159168
except Exception as error:
169+
record_reminder_state("error", time.perf_counter() - started)
160170
return _internal_error_response(error)
161171

162172
return router

‎backend/src/timeflow/gateway/http/schedule_snapshot.py‎

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
"""受保护的账号日程全量快照 HTTP 适配器。"""
22

33
import logging
4+
import time
45
from typing import Annotated
56

67
from fastapi import APIRouter, Depends, Request, Security
@@ -27,6 +28,7 @@
2728
AuthenticatedAccount,
2829
AuthenticatedAccountDependency,
2930
)
31+
from timeflow.gateway.observability.http import record_schedule_snapshot
3032

3133
logger = logging.getLogger(__name__)
3234

@@ -203,11 +205,14 @@ def authenticate_snapshot_account(
203205
def get_schedule_snapshot(
204206
account: Annotated[AuthenticatedAccount, Depends(authenticate_snapshot_account)],
205207
) -> JSONResponse:
208+
started = time.perf_counter()
206209
try:
207210
snapshot = reader.get_account_snapshot(account_id=account.account_id)
208211
response = _build_response(account.account_id, snapshot)
212+
record_schedule_snapshot("success", time.perf_counter() - started, server_error=False)
209213
return JSONResponse(status_code=200, content=response.model_dump(mode="json"))
210214
except Exception as error:
215+
record_schedule_snapshot("error", time.perf_counter() - started, server_error=True)
211216
return _internal_error_response(error)
212217

213218
return router

0 commit comments

Comments
 (0)