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

@@ -1,4 +1,10 @@
import { getApiErrorMessage, getProfileDefaults, updateProfileDefaults } from '../../utils/api'
import {
copyApiRequestId,
getApiErrorMessage,
getApiRequestId,
getProfileDefaults,
updateProfileDefaults
} from '../../utils/api'
import type { StudentProfileDefaults } from '../../utils/types'
const grades = ['初一', '初二', '初三']
@@ -30,7 +36,8 @@ Page({
maximumDuration,
loading: false,
saving: false,
errorMessage: ''
errorMessage: '',
errorRequestId: ''
},
onShow() {
@@ -38,7 +45,7 @@ Page({
},
async loadDefaults(showSuccess = false) {
this.setData({ loading: true, errorMessage: '' })
this.setData({ loading: true, errorMessage: '', errorRequestId: '' })
try {
const defaults = await getProfileDefaults()
this.setData({
@@ -49,7 +56,7 @@ Page({
})
if (showSuccess) wx.showToast({ title: '预设已刷新', icon: 'success' })
} catch (error) {
this.setData({ errorMessage: getApiErrorMessage(error) })
this.setData({ errorMessage: getApiErrorMessage(error), errorRequestId: getApiRequestId(error) })
} finally {
this.setData({ loading: false })
}
@@ -59,6 +66,10 @@ Page({
void this.loadDefaults(true)
},
copyRequestId() {
copyApiRequestId(this.data.errorRequestId)
},
onGradeChange(event: PickerEvent) {
this.setData({ 'defaults.grade': grades[Number(event.detail.value)] })
},

View File

@@ -13,7 +13,13 @@
</view>
<view wx:if="{{errorMessage}}" class="status-banner">
<text>{{errorMessage}}</text>
<view class="status-copy">
<text>{{errorMessage}}</text>
<view wx:if="{{errorRequestId}}" class="status-request-id">
<text>请求编号:{{errorRequestId}}</text>
<text class="status-action" bindtap="copyRequestId">复制</text>
</view>
</view>
<text class="status-action" bindtap="onReload">重试</text>
</view>

View File

@@ -1,4 +1,5 @@
import {
copyApiRequestId,
createFeedbackRecord,
createVoiceLesson,
ensureFeedbackSession,
@@ -6,6 +7,7 @@ import {
generateFeedbackFromVoice,
getActiveFeedbackSession,
getApiErrorMessage,
getApiRequestId,
listProfiles,
markVoiceLessonApplied,
retryVoiceSegment,
@@ -114,7 +116,8 @@ Page({
voiceStatus: '语音录入',
voiceActionable: false,
primaryActionText: '保存反馈',
errorMessage: ''
errorMessage: '',
errorRequestId: ''
},
onLoad() {
@@ -189,7 +192,7 @@ Page({
},
async loadProfiles() {
this.setData({ loading: true, errorMessage: '' })
this.setData({ loading: true, errorMessage: '', errorRequestId: '' })
try {
const profiles = await listProfiles()
const matchedProfileIndex = profiles.findIndex((profile) => profile.id === this.data.draft.profileId)
@@ -203,7 +206,7 @@ Page({
})
await this.loadActiveSession(profileId)
} catch (error) {
this.setData({ errorMessage: getApiErrorMessage(error) })
this.setData({ errorMessage: getApiErrorMessage(error), errorRequestId: getApiRequestId(error) })
} finally {
this.setData({ loading: false })
}
@@ -227,7 +230,7 @@ Page({
this.syncVoicePresentation(session)
this.scheduleVoicePolling(session)
} catch (error) {
this.setData({ errorMessage: getApiErrorMessage(error) })
this.setData({ errorMessage: getApiErrorMessage(error), errorRequestId: getApiRequestId(error) })
}
},
@@ -309,6 +312,10 @@ Page({
void this.loadProfiles()
},
copyRequestId() {
copyApiRequestId(this.data.errorRequestId)
},
onProfileChange(event: PickerEvent) {
this.clearDraftSaveTimer()
const selectedProfileIndex = Number(event.detail.value)

View File

@@ -6,7 +6,13 @@
<text class="page-subtitle">填写课堂表现并保存到教学反馈记录。</text>
<view wx:if="{{errorMessage}}" class="status-banner">
<text>{{errorMessage}}</text>
<view class="status-copy">
<text>{{errorMessage}}</text>
<view wx:if="{{errorRequestId}}" class="status-request-id">
<text>请求编号:{{errorRequestId}}</text>
<text class="status-action" bindtap="copyRequestId">复制</text>
</view>
</view>
<text class="status-action" bindtap="retryLoad">重试</text>
</view>

View File

@@ -1,7 +1,9 @@
import {
copyApiRequestId,
ensureAuthentication,
getApiBaseUrl,
getApiErrorMessage,
getApiRequestId,
getHealth,
setApiBaseUrl
} from '../../utils/api'
@@ -13,10 +15,12 @@ Page({
apiBaseUrl: getApiBaseUrl(),
connectionText: '尚未检测',
connectionTone: 'idle',
connectionRequestId: '',
testing: false,
authText: '尚未登录',
authTone: 'idle',
authUserId: ''
authUserId: '',
authRequestId: ''
},
onShow() {
@@ -37,12 +41,21 @@ Page({
await this.checkConnection()
await this.checkAuthentication()
} catch (error) {
this.setData({ connectionText: getApiErrorMessage(error), connectionTone: 'error' })
this.setData({
connectionText: getApiErrorMessage(error),
connectionTone: 'error',
connectionRequestId: getApiRequestId(error)
})
}
},
async checkConnection() {
this.setData({ testing: true, connectionText: '正在检测服务...', connectionTone: 'idle' })
this.setData({
testing: true,
connectionText: '正在检测服务...',
connectionTone: 'idle',
connectionRequestId: ''
})
try {
const health = await getHealth()
let connectionText = '服务、数据库和语音转录正常'
@@ -62,30 +75,42 @@ Page({
}
this.setData({
connectionText,
connectionTone
connectionTone,
connectionRequestId: ''
})
} catch (error) {
this.setData({ connectionText: getApiErrorMessage(error), connectionTone: 'error' })
this.setData({
connectionText: getApiErrorMessage(error),
connectionTone: 'error',
connectionRequestId: getApiRequestId(error)
})
} finally {
this.setData({ testing: false })
}
},
async checkAuthentication() {
this.setData({ authText: '正在登录...', authTone: 'idle', authUserId: '' })
this.setData({ authText: '正在登录...', authTone: 'idle', authUserId: '', authRequestId: '' })
try {
const session = await ensureAuthentication()
this.setData({
authText: '微信身份已登录',
authTone: 'success',
authUserId: session.userId
authUserId: session.userId,
authRequestId: ''
})
} catch (error) {
this.setData({
authText: getApiErrorMessage(error),
authTone: 'error',
authUserId: ''
authUserId: '',
authRequestId: getApiRequestId(error)
})
}
},
copyRequestId(event: WechatMiniprogram.TouchEvent) {
const { requestId } = event.currentTarget.dataset as { requestId: string }
copyApiRequestId(requestId)
}
})

View File

@@ -13,6 +13,10 @@
<text class="connection-dot connection-{{connectionTone}}"></text>
<text class="connection-text">{{connectionText}}</text>
</view>
<view wx:if="{{connectionRequestId}}" class="request-id-row">
<text>请求编号:{{connectionRequestId}}</text>
<text class="status-action" data-request-id="{{connectionRequestId}}" bindtap="copyRequestId">复制</text>
</view>
<button class="primary-button check-button" loading="{{testing}}" disabled="{{testing}}" bindtap="saveAndCheck">保存并检测</button>
</view>
@@ -22,6 +26,10 @@
<text class="connection-dot connection-{{authTone}}"></text>
<text class="connection-text">{{authText}}</text>
</view>
<view wx:if="{{authRequestId}}" class="request-id-row">
<text>请求编号:{{authRequestId}}</text>
<text class="status-action" data-request-id="{{authRequestId}}" bindtap="copyRequestId">复制</text>
</view>
<text wx:if="{{authUserId}}" class="identity-value">{{authUserId}}</text>
</view>
</view>

View File

@@ -70,6 +70,10 @@
font-size: 25rpx;
}
.request-id-row {
margin-left: 28rpx;
}
.check-button {
margin-top: 24rpx;
}

View File

@@ -1,6 +1,8 @@
import {
copyApiRequestId,
deleteFeedbackRecord,
getApiErrorMessage,
getApiRequestId,
listFeedbackRecords,
listProfiles
} from '../../utils/api'
@@ -17,7 +19,8 @@ Page({
selectedProfileIndex: 0,
loading: false,
deletingId: '',
errorMessage: ''
errorMessage: '',
errorRequestId: ''
},
onShow() {
@@ -26,7 +29,7 @@ Page({
},
async loadData() {
this.setData({ loading: true, errorMessage: '' })
this.setData({ loading: true, errorMessage: '', errorRequestId: '' })
try {
const profiles = await listProfiles()
const selectedProfile = profiles[this.data.selectedProfileIndex - 1]
@@ -38,7 +41,7 @@ Page({
records: this.withProfileNames(records, profiles)
})
} catch (error) {
this.setData({ errorMessage: getApiErrorMessage(error) })
this.setData({ errorMessage: getApiErrorMessage(error), errorRequestId: getApiRequestId(error) })
} finally {
this.setData({ loading: false })
}
@@ -55,15 +58,19 @@ Page({
void this.loadData()
},
copyRequestId() {
copyApiRequestId(this.data.errorRequestId)
},
async onProfileChange(event: PickerEvent) {
const selectedProfileIndex = Number(event.detail.value)
const profile = this.data.profiles[selectedProfileIndex - 1]
this.setData({ selectedProfileIndex, loading: true, errorMessage: '' })
this.setData({ selectedProfileIndex, loading: true, errorMessage: '', errorRequestId: '' })
try {
const records = await listFeedbackRecords(profile?.id)
this.setData({ records: this.withProfileNames(records, this.data.profiles) })
} catch (error) {
this.setData({ errorMessage: getApiErrorMessage(error) })
this.setData({ errorMessage: getApiErrorMessage(error), errorRequestId: getApiRequestId(error) })
} finally {
this.setData({ loading: false })
}

View File

@@ -6,7 +6,13 @@
<text class="page-subtitle">按学生查阅已保存的课堂反馈。</text>
<view wx:if="{{errorMessage}}" class="status-banner">
<text>{{errorMessage}}</text>
<view class="status-copy">
<text>{{errorMessage}}</text>
<view wx:if="{{errorRequestId}}" class="status-request-id">
<text>请求编号:{{errorRequestId}}</text>
<text class="status-action" bindtap="copyRequestId">复制</text>
</view>
</view>
<text class="status-action" bindtap="retryLoad">重试</text>
</view>

View File

@@ -1,7 +1,9 @@
import {
copyApiRequestId,
createProfile,
deleteProfile as deleteProfileRequest,
getApiErrorMessage,
getApiRequestId,
getProfileDefaults,
listFeedbackRecords,
listProfiles
@@ -107,7 +109,8 @@ Page({
loading: false,
saving: false,
deletingId: '',
errorMessage: ''
errorMessage: '',
errorRequestId: ''
},
onShow() {
@@ -119,7 +122,7 @@ Page({
},
async refreshProfiles(showSuccess = false) {
this.setData({ loading: true, errorMessage: '' })
this.setData({ loading: true, errorMessage: '', errorRequestId: '' })
try {
const profiles = (await listProfiles()).map(withAcademicTermLabel)
const filteredProfiles = filterProfiles(
@@ -139,7 +142,7 @@ Page({
})
if (showSuccess) wx.showToast({ title: '档案已刷新', icon: 'success' })
} catch (error) {
this.setData({ errorMessage: getApiErrorMessage(error) })
this.setData({ errorMessage: getApiErrorMessage(error), errorRequestId: getApiRequestId(error) })
} finally {
this.setData({ loading: false })
}
@@ -153,6 +156,10 @@ Page({
void this.refreshProfiles()
},
copyRequestId() {
copyApiRequestId(this.data.errorRequestId)
},
onSearch(event: InputEvent) {
const query = event.detail.value
this.setData({

View File

@@ -6,7 +6,13 @@
<text class="page-subtitle">管理学生默认信息,用于快速生成课堂反馈</text>
<view wx:if="{{errorMessage}}" class="status-banner">
<text>{{errorMessage}}</text>
<view class="status-copy">
<text>{{errorMessage}}</text>
<view wx:if="{{errorRequestId}}" class="status-request-id">
<text>请求编号:{{errorRequestId}}</text>
<text class="status-action" bindtap="copyRequestId">复制</text>
</view>
</view>
<text class="status-action" bindtap="retryLoad">重试</text>
</view>