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:
55
utils/api.ts
55
utils/api.ts
@@ -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`,
|
||||
|
||||
Reference in New Issue
Block a user