feat(observability): add structured application logging

Emit correlated JSON logs from the API and FunASR services, propagate request IDs to the client, and configure bounded opt-in Docker logging.

Document the host-wide Grafana/Loki query workflow and keep observability deployment ownership outside the application repository.
This commit is contained in:
2026-07-22 13:52:40 +08:00
parent a63e9a1705
commit a875fe9f63
30 changed files with 1261 additions and 817 deletions

View File

@@ -132,7 +132,8 @@ type HttpMethod = 'GET' | 'POST' | 'PUT' | 'DELETE'
export class ApiRequestError extends Error {
constructor(
message: string,
readonly statusCode?: number
readonly statusCode?: number,
readonly requestId?: string
) {
super(message)
this.name = 'ApiRequestError'
@@ -173,6 +174,20 @@ export function getApiErrorMessage(error: unknown): string {
return error.message
}
export function getApiRequestId(error: unknown): string {
return error instanceof ApiRequestError ? error.requestId || '' : ''
}
export function copyApiRequestId(requestId: string): void {
if (!requestId) return
wx.setClipboardData({
data: requestId,
success() {
wx.showToast({ title: '请求编号已复制', icon: 'none' })
}
})
}
export function ensureAuthentication(): Promise<AuthSession> {
return ensureAuthSession(getApiBaseUrl())
}
@@ -223,7 +238,13 @@ function requestOnce<T>(
}
const body = response.data as ApiErrorBody
reject(new ApiRequestError(body?.error || `服务返回 HTTP ${response.statusCode}`, response.statusCode))
reject(
new ApiRequestError(
body?.error || `服务返回 HTTP ${response.statusCode}`,
response.statusCode,
responseRequestId(response.header)
)
)
},
fail(error) {
reject(new ApiRequestError(`无法连接后端:${error.errMsg}`))
@@ -489,14 +510,26 @@ function uploadVoiceSegmentOnce(
try {
body = JSON.parse(response.data) as RawVoiceAudioSegment | ApiErrorBody
} catch {
reject(new ApiRequestError('服务返回了无效的录音响应', response.statusCode))
reject(
new ApiRequestError(
'服务返回了无效的录音响应',
response.statusCode,
responseRequestId(uploadResponseHeaders(response))
)
)
return
}
if (response.statusCode >= 200 && response.statusCode < 300) {
resolve(mapVoiceSegment(body as RawVoiceAudioSegment))
return
}
reject(new ApiRequestError((body as ApiErrorBody).error || `服务返回 HTTP ${response.statusCode}`, response.statusCode))
reject(
new ApiRequestError(
(body as ApiErrorBody).error || `服务返回 HTTP ${response.statusCode}`,
response.statusCode,
responseRequestId(uploadResponseHeaders(response))
)
)
},
fail(error) {
reject(new ApiRequestError(`录音上传失败:${error.errMsg}`))
@@ -505,6 +538,20 @@ function uploadVoiceSegmentOnce(
})
}
function responseRequestId(headers: Record<string, unknown> | undefined): string | undefined {
for (const [name, value] of Object.entries(headers || {})) {
if (name.toLowerCase() !== 'x-request-id' || typeof value !== 'string') continue
const requestId = value.trim()
if (requestId) return requestId
}
return undefined
}
function uploadResponseHeaders(response: unknown): Record<string, unknown> | undefined {
const headers = (response as { header?: unknown }).header
return headers && typeof headers === 'object' ? (headers as Record<string, unknown>) : undefined
}
export async function finishVoiceLesson(lessonId: string): Promise<VoiceLessonFinished> {
const result = await request<RawVoiceLessonFinished>(
`/api/v1/feedback-lessons/${encodeURIComponent(lessonId)}/finish`,