feat(logging-p0): complete Wave 2 - Auth/Extractor/Cleaner services transformed
This commit is contained in:
@@ -1,7 +1,7 @@
|
||||
import { hostname } from 'os'
|
||||
import { SessionManager } from '../user/session-manager'
|
||||
import { UpdateService } from '../update/update-service'
|
||||
import { createLogger } from '../logger'
|
||||
import { createLogger, run, getRequestId, getContext } from '../logger'
|
||||
import { logAudit } from '../logger/audit-logger'
|
||||
import { ValidationError } from '../../types/errors'
|
||||
import type { UserInfo } from '../../types/user.types'
|
||||
@@ -23,120 +23,212 @@ export class AuthApplicationService {
|
||||
) {}
|
||||
|
||||
async getComputerName(): Promise<string> {
|
||||
const requestId = getRequestId()
|
||||
if (requestId) {
|
||||
log.debug('Get computer name', { requestId })
|
||||
}
|
||||
return hostname()
|
||||
}
|
||||
|
||||
async silentLogin(): Promise<SilentLoginResponse> {
|
||||
if (this.silentLoginPromise) {
|
||||
log.debug('Reusing in-flight silent login request')
|
||||
log.debug('Reusing in-flight silent login request', { requestId: getRequestId() })
|
||||
return this.silentLoginPromise
|
||||
}
|
||||
|
||||
this.silentLoginPromise = this.performSilentLogin()
|
||||
try {
|
||||
return await this.silentLoginPromise
|
||||
} finally {
|
||||
this.silentLoginPromise = null
|
||||
}
|
||||
}
|
||||
this.silentLoginPromise = run(
|
||||
async (): Promise<SilentLoginResponse> => {
|
||||
const requestId = getRequestId()
|
||||
const context = getContext()
|
||||
const startTime = performance.now()
|
||||
|
||||
private async performSilentLogin(): Promise<SilentLoginResponse> {
|
||||
log.info('Attempting silent login')
|
||||
const success = await this.sessionManager.loginByComputerName()
|
||||
const userInfo = this.sessionManager.getUserInfo()
|
||||
try {
|
||||
log.info('Attempting silent login', { requestId, operation: context?.operation })
|
||||
const success = await this.sessionManager.loginByComputerName()
|
||||
const userInfo = this.sessionManager.getUserInfo()
|
||||
|
||||
if (!success || !userInfo) {
|
||||
await this.updateService.setUserContext(null)
|
||||
throw new ValidationError('无感登录失败:未找到匹配用户', 'VAL_INVALID_INPUT')
|
||||
}
|
||||
if (!success || !userInfo) {
|
||||
await this.updateService.setUserContext(null)
|
||||
const error = new ValidationError('无感登录失败:未找到匹配用户', 'VAL_INVALID_INPUT')
|
||||
log.error('Silent login failed - user not found', {
|
||||
operation: 'silentLogin',
|
||||
requestId,
|
||||
userId: userInfo?.id,
|
||||
username: userInfo?.username,
|
||||
computerName: hostname(),
|
||||
error
|
||||
})
|
||||
throw error
|
||||
}
|
||||
|
||||
await this.updateService.setUserContext(userInfo.userType)
|
||||
await this.updateService.setUserContext(userInfo.userType)
|
||||
|
||||
const requiresUserSelection = userInfo.userType === 'Admin'
|
||||
log.info('Silent login successful', {
|
||||
username: userInfo.username,
|
||||
userType: userInfo.userType,
|
||||
requiresUserSelection
|
||||
})
|
||||
const requiresUserSelection = userInfo.userType === 'Admin'
|
||||
log.info('Silent login successful', {
|
||||
requestId,
|
||||
operation: context?.operation,
|
||||
username: userInfo.username,
|
||||
userType: userInfo.userType,
|
||||
requiresUserSelection,
|
||||
userId: userInfo.id
|
||||
})
|
||||
|
||||
this.writeAuditLog('LOGIN', String(userInfo.id), {
|
||||
username: userInfo.username,
|
||||
computerName: hostname(),
|
||||
resource: 'ERP_SYSTEM',
|
||||
status: 'success',
|
||||
metadata: { loginType: 'silent', userType: userInfo.userType }
|
||||
})
|
||||
this.writeAuditLog('LOGIN', String(userInfo.id), {
|
||||
username: userInfo.username,
|
||||
computerName: hostname(),
|
||||
resource: 'ERP_SYSTEM',
|
||||
status: 'success',
|
||||
metadata: { loginType: 'silent', userType: userInfo.userType }
|
||||
})
|
||||
|
||||
return {
|
||||
success: true,
|
||||
userInfo,
|
||||
requiresUserSelection
|
||||
}
|
||||
return {
|
||||
success: true,
|
||||
userInfo,
|
||||
requiresUserSelection
|
||||
}
|
||||
} finally {
|
||||
const durationMs = performance.now() - startTime
|
||||
if (durationMs > 1000) {
|
||||
log.warn(`Silent login took ${durationMs.toFixed(2)}ms (SLOW)`, {
|
||||
operation: 'silentLogin',
|
||||
requestId,
|
||||
durationMs
|
||||
})
|
||||
} else {
|
||||
log.debug(`Silent login completed in ${durationMs.toFixed(2)}ms`, {
|
||||
operation: 'silentLogin',
|
||||
requestId,
|
||||
durationMs
|
||||
})
|
||||
}
|
||||
}
|
||||
},
|
||||
{ operation: 'silentLogin' }
|
||||
)
|
||||
return this.silentLoginPromise
|
||||
}
|
||||
|
||||
async login(username: string, password: string): Promise<LoginResponse> {
|
||||
if (!username || !password) {
|
||||
log.warn('Login attempt with missing credentials')
|
||||
log.warn('Login attempt with missing credentials', { requestId: getRequestId() })
|
||||
throw new ValidationError('请输入用户名和密码', 'VAL_MISSING_REQUIRED')
|
||||
}
|
||||
|
||||
log.info('Login attempt', { username })
|
||||
const success = await this.sessionManager.login(username, password)
|
||||
const userInfo = this.sessionManager.getUserInfo()
|
||||
return run(
|
||||
async (): Promise<LoginResponse> => {
|
||||
const requestId = getRequestId()
|
||||
const context = getContext()
|
||||
|
||||
if (!success || !userInfo) {
|
||||
this.writeAuditLog('LOGIN', '0', {
|
||||
username,
|
||||
computerName: hostname(),
|
||||
resource: 'ERP_SYSTEM',
|
||||
status: 'failure',
|
||||
metadata: { loginType: 'credentials', reason: 'invalid_credentials' }
|
||||
})
|
||||
const startTime = performance.now()
|
||||
|
||||
log.warn('Login failed - invalid credentials', { username })
|
||||
await this.updateService.setUserContext(null)
|
||||
throw new ValidationError('用户名或密码错误', 'VAL_INVALID_INPUT')
|
||||
}
|
||||
try {
|
||||
log.info('Login attempt', { username, requestId, operation: context?.operation })
|
||||
const success = await this.sessionManager.login(username, password)
|
||||
const userInfo = this.sessionManager.getUserInfo()
|
||||
|
||||
log.info('Login successful', { username, userType: userInfo.userType })
|
||||
await this.updateService.setUserContext(userInfo.userType)
|
||||
if (!success || !userInfo) {
|
||||
this.writeAuditLog('LOGIN', '0', {
|
||||
username,
|
||||
computerName: hostname(),
|
||||
resource: 'ERP_SYSTEM',
|
||||
status: 'failure',
|
||||
metadata: { loginType: 'credentials', reason: 'invalid_credentials' }
|
||||
})
|
||||
|
||||
this.writeAuditLog('LOGIN', String(userInfo.id), {
|
||||
username: userInfo.username,
|
||||
computerName: hostname(),
|
||||
resource: 'ERP_SYSTEM',
|
||||
status: 'success',
|
||||
metadata: { loginType: 'credentials', userType: userInfo.userType }
|
||||
})
|
||||
const error = new ValidationError('用户名或密码错误', 'VAL_INVALID_INPUT')
|
||||
log.warn('Login failed - invalid credentials', {
|
||||
username,
|
||||
requestId,
|
||||
operation: context?.operation,
|
||||
error
|
||||
})
|
||||
await this.updateService.setUserContext(null)
|
||||
throw error
|
||||
}
|
||||
|
||||
return {
|
||||
success: true,
|
||||
userInfo
|
||||
}
|
||||
log.info('Login successful', {
|
||||
requestId,
|
||||
operation: context?.operation,
|
||||
username,
|
||||
userType: userInfo.userType,
|
||||
userId: userInfo.id
|
||||
})
|
||||
await this.updateService.setUserContext(userInfo.userType)
|
||||
|
||||
this.writeAuditLog('LOGIN', String(userInfo.id), {
|
||||
username: userInfo.username,
|
||||
computerName: hostname(),
|
||||
resource: 'ERP_SYSTEM',
|
||||
status: 'success',
|
||||
metadata: { loginType: 'credentials', userType: userInfo.userType }
|
||||
})
|
||||
|
||||
return {
|
||||
success: true,
|
||||
userInfo
|
||||
}
|
||||
} finally {
|
||||
const durationMs = performance.now() - startTime
|
||||
if (durationMs > 1000) {
|
||||
log.warn(`Login took ${durationMs.toFixed(2)}ms (SLOW)`, {
|
||||
operation: 'login',
|
||||
requestId,
|
||||
durationMs,
|
||||
username
|
||||
})
|
||||
} else {
|
||||
log.debug(`Login completed in ${durationMs.toFixed(2)}ms`, {
|
||||
operation: 'login',
|
||||
requestId,
|
||||
durationMs
|
||||
})
|
||||
}
|
||||
}
|
||||
},
|
||||
{ operation: 'login' }
|
||||
)
|
||||
}
|
||||
|
||||
async logout(): Promise<void> {
|
||||
const userInfo = this.sessionManager.getUserInfo()
|
||||
log.info('User logout', { username: userInfo?.username })
|
||||
return run(
|
||||
async () => {
|
||||
const requestId = getRequestId()
|
||||
const context = getContext()
|
||||
const userInfo = this.sessionManager.getUserInfo()
|
||||
|
||||
if (userInfo) {
|
||||
this.writeAuditLog('LOGOUT', String(userInfo.id), {
|
||||
username: userInfo.username,
|
||||
computerName: hostname(),
|
||||
resource: 'ERP_SYSTEM',
|
||||
status: 'success',
|
||||
metadata: { userType: userInfo.userType }
|
||||
})
|
||||
}
|
||||
log.info('User logout', {
|
||||
requestId,
|
||||
operation: context?.operation,
|
||||
username: userInfo?.username,
|
||||
userId: userInfo?.id
|
||||
})
|
||||
|
||||
this.sessionManager.logout()
|
||||
await this.updateService.setUserContext(null)
|
||||
if (userInfo) {
|
||||
this.writeAuditLog('LOGOUT', String(userInfo.id), {
|
||||
username: userInfo.username,
|
||||
computerName: hostname(),
|
||||
resource: 'ERP_SYSTEM',
|
||||
status: 'success',
|
||||
metadata: { userType: userInfo.userType }
|
||||
})
|
||||
}
|
||||
|
||||
this.sessionManager.logout()
|
||||
await this.updateService.setUserContext(null)
|
||||
},
|
||||
{ operation: 'logout' }
|
||||
)
|
||||
}
|
||||
|
||||
getCurrentUser(): CurrentUserResponse {
|
||||
const requestId = getRequestId()
|
||||
const isAuthenticated = this.sessionManager.isAuthenticated()
|
||||
const userInfo = this.sessionManager.getUserInfo()
|
||||
|
||||
if (requestId) {
|
||||
log.debug('Get current user', { requestId, isAuthenticated, userId: userInfo?.id })
|
||||
}
|
||||
|
||||
return {
|
||||
isAuthenticated,
|
||||
userInfo: userInfo ?? undefined
|
||||
@@ -144,27 +236,73 @@ export class AuthApplicationService {
|
||||
}
|
||||
|
||||
async getAllUsers(): Promise<UserInfo[]> {
|
||||
log.debug('Fetching all users for admin selection')
|
||||
const requestId = getRequestId()
|
||||
log.debug('Fetching all users for admin selection', { requestId })
|
||||
return this.sessionManager.getAllUsers()
|
||||
}
|
||||
|
||||
async switchUser(userInfo: UserInfo): Promise<UserSelectionResponse> {
|
||||
log.info('User switch attempt', { targetUser: userInfo.username })
|
||||
const success = this.sessionManager.switchUser(userInfo)
|
||||
return run(
|
||||
async (): Promise<UserSelectionResponse> => {
|
||||
const requestId = getRequestId()
|
||||
const context = getContext()
|
||||
const startTime = performance.now()
|
||||
|
||||
if (!success) {
|
||||
log.warn('User switch failed')
|
||||
throw new ValidationError('用户切换失败', 'VAL_INVALID_INPUT')
|
||||
}
|
||||
try {
|
||||
log.info('User switch attempt', {
|
||||
requestId,
|
||||
operation: context?.operation,
|
||||
targetUser: userInfo.username,
|
||||
targetUserId: userInfo.id
|
||||
})
|
||||
const success = this.sessionManager.switchUser(userInfo)
|
||||
|
||||
const newUser = this.sessionManager.getUserInfo()
|
||||
log.info('User switch successful', { newUsername: newUser?.username })
|
||||
await this.updateService.setUserContext(newUser?.userType ?? null)
|
||||
if (!success) {
|
||||
const error = new ValidationError('用户切换失败', 'VAL_INVALID_INPUT')
|
||||
log.warn('User switch failed', {
|
||||
requestId,
|
||||
operation: context?.operation,
|
||||
targetUser: userInfo.username,
|
||||
targetUserId: userInfo.id,
|
||||
error
|
||||
})
|
||||
throw error
|
||||
}
|
||||
|
||||
return {
|
||||
success: true,
|
||||
userInfo: newUser ?? undefined
|
||||
}
|
||||
const newUser = this.sessionManager.getUserInfo()
|
||||
log.info('User switch successful', {
|
||||
requestId,
|
||||
operation: context?.operation,
|
||||
newUsername: newUser?.username,
|
||||
newUserId: newUser?.id,
|
||||
newUserType: newUser?.userType
|
||||
})
|
||||
await this.updateService.setUserContext(newUser?.userType ?? null)
|
||||
|
||||
return {
|
||||
success: true,
|
||||
userInfo: newUser ?? undefined
|
||||
}
|
||||
} finally {
|
||||
const durationMs = performance.now() - startTime
|
||||
if (durationMs > 1000) {
|
||||
log.warn(`User switch took ${durationMs.toFixed(2)}ms (SLOW)`, {
|
||||
operation: 'switchUser',
|
||||
requestId,
|
||||
durationMs,
|
||||
targetUser: userInfo.username
|
||||
})
|
||||
} else {
|
||||
log.debug(`User switch completed in ${durationMs.toFixed(2)}ms`, {
|
||||
operation: 'switchUser',
|
||||
requestId,
|
||||
durationMs
|
||||
})
|
||||
}
|
||||
}
|
||||
},
|
||||
{ operation: 'switchUser', userId: String(userInfo.id) }
|
||||
)
|
||||
}
|
||||
|
||||
isAdmin(): boolean {
|
||||
|
||||
@@ -3,7 +3,7 @@ import { ErpAuthService } from './erp-auth'
|
||||
import type { CleanerInput, CleanerResult, OrderCleanDetail } from '../../types/cleaner.types'
|
||||
import type { ErpSession } from '../../types/erp.types'
|
||||
import type { FrameLocator, Locator, Page } from 'playwright'
|
||||
import { createLogger } from '../logger'
|
||||
import { createLogger, run, trackDuration } from '../logger'
|
||||
|
||||
const log = createLogger('CleanerService')
|
||||
|
||||
@@ -173,6 +173,15 @@ export class CleanerService {
|
||||
}
|
||||
|
||||
async clean(input: CleanerInput): Promise<CleanerResult> {
|
||||
return run(
|
||||
async () => {
|
||||
return await this.performCleanup(input)
|
||||
},
|
||||
{ operation: 'cleaner' }
|
||||
)
|
||||
}
|
||||
|
||||
private async performCleanup(input: CleanerInput): Promise<CleanerResult> {
|
||||
const result: CleanerResult = {
|
||||
ordersProcessed: 0,
|
||||
materialsDeleted: 0,
|
||||
@@ -184,6 +193,7 @@ export class CleanerService {
|
||||
}
|
||||
|
||||
const totalOrders = input.orderNumbers.length
|
||||
const totalMaterials = input.materialCodes.length
|
||||
const dryRun = input.dryRun ?? this.dryRun
|
||||
const queryBatchSize = clampNumber(
|
||||
input.queryBatchSize,
|
||||
@@ -200,10 +210,12 @@ export class CleanerService {
|
||||
|
||||
log.info('Starting cleaner', {
|
||||
totalOrders,
|
||||
materialCount: input.materialCodes.length,
|
||||
totalMaterials,
|
||||
dryRun,
|
||||
queryBatchSize,
|
||||
processConcurrency
|
||||
processConcurrency,
|
||||
orderNumbers: input.orderNumbers,
|
||||
materialCodes: input.materialCodes
|
||||
})
|
||||
|
||||
const deleteSet = new Set(input.materialCodes)
|
||||
@@ -231,56 +243,75 @@ export class CleanerService {
|
||||
log.info('Processing cleaner batch', {
|
||||
batchIndex: batchIndex + 1,
|
||||
totalBatches: orderBatches.length,
|
||||
batchSize: batchOrders.length
|
||||
batchSize: batchOrders.length,
|
||||
totalOrders,
|
||||
totalMaterials
|
||||
})
|
||||
|
||||
await this.queryOrders(workFrame, batchOrders)
|
||||
await this.waitForLoading(workFrame)
|
||||
// Track batch processing duration with 5s slow threshold
|
||||
await trackDuration(
|
||||
async () => {
|
||||
await this.queryOrders(workFrame, batchOrders)
|
||||
await this.waitForLoading(workFrame)
|
||||
|
||||
const queriedRows = await this.collectQueryResultRows(workFrame)
|
||||
const queriedOrderNumbersInBatch = new Set(queriedRows.map((row) => row.orderNumber))
|
||||
const queriedRows = await this.collectQueryResultRows(workFrame)
|
||||
const queriedOrderNumbersInBatch = new Set(queriedRows.map((row) => row.orderNumber))
|
||||
|
||||
await runWithConcurrency(queriedRows, processConcurrency, async (row) => {
|
||||
const { rowIndex, orderNumber } = row
|
||||
const openedDetailPage = await popupMutex.runExclusive(async () => {
|
||||
return await this.openDetailPageFromRow(workFrame, popupPage!, rowIndex)
|
||||
})
|
||||
await runWithConcurrency(queriedRows, processConcurrency, async (row) => {
|
||||
const { rowIndex, orderNumber } = row
|
||||
const openedDetailPage = await popupMutex.runExclusive(async () => {
|
||||
return await this.openDetailPageFromRow(workFrame, popupPage!, rowIndex)
|
||||
})
|
||||
|
||||
let detail: OrderCleanDetail
|
||||
try {
|
||||
detail = await this.processDetailPage({
|
||||
detailPage: openedDetailPage,
|
||||
deleteSet,
|
||||
dryRun,
|
||||
expectedOrderNumber: orderNumber,
|
||||
progressState,
|
||||
onProgress: input.onProgress
|
||||
let detail: OrderCleanDetail
|
||||
try {
|
||||
detail = await this.processDetailPage({
|
||||
detailPage: openedDetailPage,
|
||||
deleteSet,
|
||||
dryRun,
|
||||
expectedOrderNumber: orderNumber,
|
||||
progressState,
|
||||
onProgress: input.onProgress
|
||||
})
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : 'Unknown error'
|
||||
detail = this.createErrorDetail(orderNumber, message)
|
||||
} finally {
|
||||
progressState.completedOrders += 1
|
||||
}
|
||||
|
||||
result.details.push(detail)
|
||||
|
||||
if (detail.errors.length > 0) {
|
||||
result.errors.push(`Order ${detail.orderNumber}: ${detail.errors.join('; ')}`)
|
||||
return
|
||||
}
|
||||
|
||||
result.ordersProcessed += 1
|
||||
result.materialsDeleted += detail.materialsDeleted
|
||||
result.materialsSkipped += detail.materialsSkipped
|
||||
})
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : 'Unknown error'
|
||||
detail = this.createErrorDetail(orderNumber, message)
|
||||
} finally {
|
||||
progressState.completedOrders += 1
|
||||
|
||||
const missingOrders = getMissingOrders(batchOrders, queriedOrderNumbersInBatch)
|
||||
for (const missingOrder of missingOrders) {
|
||||
const missingMessage = '订单未出现在查询结果中'
|
||||
result.errors.push(`Order ${missingOrder}: ${missingMessage}`)
|
||||
result.details.push(this.createErrorDetail(missingOrder, missingMessage))
|
||||
}
|
||||
},
|
||||
{
|
||||
operationName: `batch-${batchIndex + 1}-${orderBatches[batchIndex].length}-orders`,
|
||||
message: `Batch ${batchIndex + 1}/${orderBatches.length}`,
|
||||
slowThresholdMs: 5000,
|
||||
context: {
|
||||
batchIndex: batchIndex + 1,
|
||||
totalBatches: orderBatches.length,
|
||||
batchSize: batchOrders.length,
|
||||
totalOrders,
|
||||
totalMaterials
|
||||
}
|
||||
}
|
||||
|
||||
result.details.push(detail)
|
||||
|
||||
if (detail.errors.length > 0) {
|
||||
result.errors.push(`Order ${detail.orderNumber}: ${detail.errors.join('; ')}`)
|
||||
return
|
||||
}
|
||||
|
||||
result.ordersProcessed += 1
|
||||
result.materialsDeleted += detail.materialsDeleted
|
||||
result.materialsSkipped += detail.materialsSkipped
|
||||
})
|
||||
|
||||
const missingOrders = getMissingOrders(batchOrders, queriedOrderNumbersInBatch)
|
||||
for (const missingOrder of missingOrders) {
|
||||
const missingMessage = '订单未出现在查询结果中'
|
||||
result.errors.push(`Order ${missingOrder}: ${missingMessage}`)
|
||||
result.details.push(this.createErrorDetail(missingOrder, missingMessage))
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
const retryResult = await this.retryFailedOrders({
|
||||
@@ -321,11 +352,21 @@ export class CleanerService {
|
||||
ordersProcessed: result.ordersProcessed,
|
||||
materialsDeleted: result.materialsDeleted,
|
||||
materialsSkipped: result.materialsSkipped,
|
||||
errorCount: result.errors.length
|
||||
errorCount: result.errors.length,
|
||||
totalOrders,
|
||||
totalMaterials,
|
||||
dryRun
|
||||
})
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : 'Unknown error'
|
||||
log.error('Cleaner failed', { error: message })
|
||||
log.error('Cleaner failed', {
|
||||
error: message,
|
||||
totalOrders,
|
||||
totalMaterials,
|
||||
dryRun,
|
||||
orderNumbers: input.orderNumbers,
|
||||
materialCodes: input.materialCodes
|
||||
})
|
||||
result.errors.push(`Clean failed: ${message}`)
|
||||
} finally {
|
||||
if (popupPage) {
|
||||
@@ -780,86 +821,112 @@ export class CleanerService {
|
||||
return result
|
||||
}
|
||||
|
||||
log.info('Starting retry for failed orders', { count: failedDetails.length })
|
||||
log.info('Starting retry for failed orders', {
|
||||
count: failedDetails.length,
|
||||
totalOrders: params.failedDetails.length
|
||||
})
|
||||
|
||||
const MAX_RETRIES = 2
|
||||
|
||||
for (let detailIndex = 0; detailIndex < failedDetails.length; detailIndex++) {
|
||||
const failedDetail = failedDetails[detailIndex]
|
||||
const orderNumber = failedDetail.orderNumber
|
||||
const retryAttempts: import('../../types/cleaner.types').RetryAttempt[] = []
|
||||
// Track overall retry process duration
|
||||
const trackedResult = await trackDuration(
|
||||
async () => {
|
||||
const retryResult: RetryResult = {
|
||||
retriedOrders: 0,
|
||||
successfulRetries: 0,
|
||||
updatedDetails: []
|
||||
}
|
||||
|
||||
for (let attempt = 1; attempt <= MAX_RETRIES; attempt++) {
|
||||
try {
|
||||
log.info(`Retrying order ${orderNumber} (attempt ${attempt}/${MAX_RETRIES})`)
|
||||
for (let detailIndex = 0; detailIndex < failedDetails.length; detailIndex++) {
|
||||
const failedDetail = failedDetails[detailIndex]
|
||||
const orderNumber = failedDetail.orderNumber
|
||||
const retryAttempts: import('../../types/cleaner.types').RetryAttempt[] = []
|
||||
|
||||
await this.queryOrders(workFrame, [orderNumber])
|
||||
await this.waitForLoading(workFrame)
|
||||
for (let attempt = 1; attempt <= MAX_RETRIES; attempt++) {
|
||||
try {
|
||||
log.info(`Retrying order ${orderNumber} (attempt ${attempt}/${MAX_RETRIES})`)
|
||||
|
||||
const rows = workFrame.locator('tbody tr')
|
||||
const rowCount = await rows.count()
|
||||
if (rowCount === 0) {
|
||||
throw new Error('订单重试查询无结果')
|
||||
}
|
||||
await this.queryOrders(workFrame, [orderNumber])
|
||||
await this.waitForLoading(workFrame)
|
||||
|
||||
const detailPage = await this.openDetailPageFromCurrentQuery(workFrame, popupPage)
|
||||
const retryDetail = await this.processDetailPage({
|
||||
detailPage,
|
||||
deleteSet,
|
||||
dryRun,
|
||||
expectedOrderNumber: orderNumber,
|
||||
progressState: {
|
||||
completedOrders: detailIndex,
|
||||
totalOrders: failedDetails.length
|
||||
},
|
||||
onProgress: (message, progress, extra) => {
|
||||
onProgress?.(
|
||||
`[重试 ${attempt}/${MAX_RETRIES}] ${message}`,
|
||||
progress,
|
||||
extra ? { ...extra, phase: 'processing' as const } : undefined
|
||||
)
|
||||
const rows = workFrame.locator('tbody tr')
|
||||
const rowCount = await rows.count()
|
||||
if (rowCount === 0) {
|
||||
throw new Error('订单重试查询无结果')
|
||||
}
|
||||
|
||||
const detailPage = await this.openDetailPageFromCurrentQuery(workFrame, popupPage)
|
||||
const retryDetail = await this.processDetailPage({
|
||||
detailPage,
|
||||
deleteSet,
|
||||
dryRun,
|
||||
expectedOrderNumber: orderNumber,
|
||||
progressState: {
|
||||
completedOrders: detailIndex,
|
||||
totalOrders: failedDetails.length
|
||||
},
|
||||
onProgress: (message, progress, extra) => {
|
||||
onProgress?.(
|
||||
`[重试 ${attempt}/${MAX_RETRIES}] ${message}`,
|
||||
progress,
|
||||
extra ? { ...extra, phase: 'processing' as const } : undefined
|
||||
)
|
||||
}
|
||||
})
|
||||
|
||||
retryResult.successfulRetries += 1
|
||||
retryResult.updatedDetails.push({
|
||||
...retryDetail,
|
||||
retryCount: attempt,
|
||||
retriedAt: Date.now(),
|
||||
retrySuccess: true,
|
||||
retryAttempts
|
||||
})
|
||||
retryResult.retriedOrders += 1
|
||||
break
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : 'Unknown error'
|
||||
log.warn(`Retry attempt ${attempt} failed for order ${orderNumber}: ${message}`)
|
||||
|
||||
retryAttempts.push({
|
||||
attempt,
|
||||
error: message,
|
||||
timestamp: Date.now()
|
||||
})
|
||||
|
||||
if (attempt === MAX_RETRIES) {
|
||||
retryResult.updatedDetails.push({
|
||||
...failedDetail,
|
||||
retryCount: MAX_RETRIES,
|
||||
retryAttempts,
|
||||
retriedAt: Date.now(),
|
||||
retrySuccess: false
|
||||
})
|
||||
retryResult.retriedOrders += 1
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
result.successfulRetries += 1
|
||||
result.updatedDetails.push({
|
||||
...retryDetail,
|
||||
retryCount: attempt,
|
||||
retriedAt: Date.now(),
|
||||
retrySuccess: true,
|
||||
retryAttempts
|
||||
})
|
||||
result.retriedOrders += 1
|
||||
break
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : 'Unknown error'
|
||||
log.warn(`Retry attempt ${attempt} failed for order ${orderNumber}: ${message}`)
|
||||
|
||||
retryAttempts.push({
|
||||
attempt,
|
||||
error: message,
|
||||
timestamp: Date.now()
|
||||
})
|
||||
|
||||
if (attempt === MAX_RETRIES) {
|
||||
result.updatedDetails.push({
|
||||
...failedDetail,
|
||||
retryCount: MAX_RETRIES,
|
||||
retryAttempts,
|
||||
retriedAt: Date.now(),
|
||||
retrySuccess: false
|
||||
})
|
||||
result.retriedOrders += 1
|
||||
}
|
||||
}
|
||||
|
||||
log.info('Retry process completed', {
|
||||
retriedOrders: retryResult.retriedOrders,
|
||||
successfulRetries: retryResult.successfulRetries,
|
||||
totalRetryOrders: failedDetails.length
|
||||
})
|
||||
|
||||
return retryResult
|
||||
},
|
||||
{
|
||||
operationName: 'retry-failed-orders',
|
||||
message: 'Retry failed orders',
|
||||
slowThresholdMs: 5000,
|
||||
context: {
|
||||
totalRetryOrders: failedDetails.length,
|
||||
dryRun
|
||||
}
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
log.info('Retry process completed', {
|
||||
retriedOrders: result.retriedOrders,
|
||||
successfulRetries: result.successfulRetries
|
||||
})
|
||||
|
||||
return result
|
||||
return trackedResult.result
|
||||
}
|
||||
}
|
||||
|
||||
@@ -10,7 +10,8 @@ import type {
|
||||
LogLevel
|
||||
} from '../../types/extractor.types'
|
||||
import { DataImportService } from '../database/data-importer'
|
||||
import { createLogger } from '../logger'
|
||||
import { createLogger, withRequestContext, getRequestId } from '../logger'
|
||||
import { trackDuration } from '../logger/performance-monitor'
|
||||
|
||||
const log = createLogger('ExtractorService')
|
||||
|
||||
@@ -50,70 +51,105 @@ export class ExtractorService {
|
||||
orderRecordCounts: []
|
||||
}
|
||||
|
||||
try {
|
||||
const session = this.authService.getSession()
|
||||
|
||||
// Call ExtractorCore to execute web page operations
|
||||
const core = new ExtractorCore()
|
||||
const coreResult = await core.downloadAllBatches({
|
||||
session,
|
||||
orderNumbers: input.orderNumbers,
|
||||
downloadDir: this.downloadDir,
|
||||
batchSize: input.batchSize || 100,
|
||||
onProgress: input.onProgress
|
||||
})
|
||||
|
||||
result.downloadedFiles = coreResult.downloadedFiles
|
||||
result.errors = coreResult.errors
|
||||
|
||||
// Merge downloaded files (original logic preserved)
|
||||
if (result.downloadedFiles.length > 0) {
|
||||
const totalBatches = result.downloadedFiles.length
|
||||
const totalPoints = 1 + totalBatches + 2
|
||||
const progressPerPoint = 100 / totalPoints
|
||||
const mergeProgress = (1 + totalBatches) * progressPerPoint
|
||||
|
||||
input.onProgress?.('正在合并文件...', mergeProgress, {
|
||||
phase: 'merging',
|
||||
totalBatches
|
||||
// Wrap entire extraction in request context for unified logging
|
||||
return withRequestContext(
|
||||
async () => {
|
||||
const requestId = getRequestId()
|
||||
log.info('Starting extraction', {
|
||||
orderCount: input.orderNumbers.length,
|
||||
batchSize: input.batchSize || 100,
|
||||
downloadDir: this.downloadDir,
|
||||
requestId
|
||||
})
|
||||
const mergeResult = await this.mergeFiles(result.downloadedFiles)
|
||||
result.mergedFile = mergeResult.mergedFile
|
||||
result.recordCount = mergeResult.recordCount
|
||||
result.orderRecordCounts = mergeResult.orderRecordCounts
|
||||
|
||||
// Add merge error to result if any
|
||||
if (mergeResult.error) {
|
||||
result.errors.push(mergeResult.error)
|
||||
}
|
||||
try {
|
||||
const session = this.authService.getSession()
|
||||
|
||||
// Always clean up temporary files regardless of merge success
|
||||
await this.cleanupTempFiles(result.downloadedFiles)
|
||||
|
||||
// Auto-import to database if merge was successful
|
||||
if (result.mergedFile) {
|
||||
const importProgress = (1 + totalBatches + 1) * progressPerPoint
|
||||
input.onProgress?.('正在写入数据库...', importProgress, {
|
||||
phase: 'importing',
|
||||
totalBatches
|
||||
})
|
||||
const importResult = await this.importToDatabaseWithLogging(
|
||||
result.mergedFile,
|
||||
input.onLog
|
||||
// Call ExtractorCore to execute web page operations with timing
|
||||
const core = new ExtractorCore()
|
||||
const coreResult = await trackDuration(
|
||||
async () =>
|
||||
core.downloadAllBatches({
|
||||
session,
|
||||
orderNumbers: input.orderNumbers,
|
||||
downloadDir: this.downloadDir,
|
||||
batchSize: input.batchSize || 100,
|
||||
onProgress: input.onProgress
|
||||
}),
|
||||
{
|
||||
operationName: 'Batch Download',
|
||||
context: {
|
||||
orderCount: input.orderNumbers.length,
|
||||
batchSize: input.batchSize || 100
|
||||
}
|
||||
}
|
||||
)
|
||||
result.importResult = importResult
|
||||
|
||||
if (!importResult.success && importResult.errors.length > 0) {
|
||||
result.errors.push(...importResult.errors)
|
||||
result.downloadedFiles = coreResult.result.downloadedFiles
|
||||
result.errors = coreResult.result.errors
|
||||
|
||||
// Merge downloaded files (original logic preserved)
|
||||
if (result.downloadedFiles.length > 0) {
|
||||
const totalBatches = result.downloadedFiles.length
|
||||
const totalPoints = 1 + totalBatches + 2
|
||||
const progressPerPoint = 100 / totalPoints
|
||||
const mergeProgress = (1 + totalBatches) * progressPerPoint
|
||||
|
||||
input.onProgress?.('正在合并文件...', mergeProgress, {
|
||||
phase: 'merging',
|
||||
totalBatches
|
||||
})
|
||||
const mergeResult = await this.mergeFiles(result.downloadedFiles, input.orderNumbers)
|
||||
result.mergedFile = mergeResult.mergedFile
|
||||
result.recordCount = mergeResult.recordCount
|
||||
result.orderRecordCounts = mergeResult.orderRecordCounts
|
||||
|
||||
// Add merge error to result if any
|
||||
if (mergeResult.error) {
|
||||
result.errors.push(mergeResult.error)
|
||||
}
|
||||
|
||||
// Always clean up temporary files regardless of merge success
|
||||
await this.cleanupTempFiles(result.downloadedFiles, input.orderNumbers)
|
||||
|
||||
// Auto-import to database if merge was successful
|
||||
if (result.mergedFile) {
|
||||
const importProgress = (1 + totalBatches + 1) * progressPerPoint
|
||||
input.onProgress?.('正在写入数据库...', importProgress, {
|
||||
phase: 'importing',
|
||||
totalBatches
|
||||
})
|
||||
const importResult = await this.importToDatabaseWithLogging(
|
||||
result.mergedFile,
|
||||
input.onLog
|
||||
)
|
||||
result.importResult = importResult
|
||||
|
||||
if (!importResult.success && importResult.errors.length > 0) {
|
||||
result.errors.push(...importResult.errors)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : 'Unknown error'
|
||||
result.errors.push(`Extraction failed: ${message}`)
|
||||
}
|
||||
|
||||
return result
|
||||
log.info('Extraction completed successfully', {
|
||||
recordCount: result.recordCount,
|
||||
fileCount: result.downloadedFiles.length
|
||||
})
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : 'Unknown error'
|
||||
log.error('Extraction failed', {
|
||||
error: message,
|
||||
orderNumbers: input.orderNumbers,
|
||||
downloadDir: this.downloadDir,
|
||||
requestId: getRequestId()
|
||||
})
|
||||
result.errors.push(`Extraction failed: ${message}`)
|
||||
}
|
||||
|
||||
return result
|
||||
},
|
||||
{ operation: 'extract' }
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -121,9 +157,13 @@ export class ExtractorService {
|
||||
* Uses ExcelParser to parse and combine all material plans
|
||||
*
|
||||
* @param filePaths - Array of downloaded Excel file paths
|
||||
* @param orderNumbers - Order numbers for context logging
|
||||
* @returns Merged file path, total record count, and optional error message
|
||||
*/
|
||||
private async mergeFiles(filePaths: string[]): Promise<{
|
||||
private async mergeFiles(
|
||||
filePaths: string[],
|
||||
orderNumbers: string[]
|
||||
): Promise<{
|
||||
mergedFile: string | null
|
||||
recordCount: number
|
||||
error?: string
|
||||
@@ -133,75 +173,101 @@ export class ExtractorService {
|
||||
return { mergedFile: null, recordCount: 0, orderRecordCounts: [] }
|
||||
}
|
||||
|
||||
log.info('Starting merge', { fileCount: filePaths.length })
|
||||
const parser = new ExcelParser()
|
||||
log.info('Starting merge', { fileCount: filePaths.length, orderCount: orderNumbers.length })
|
||||
|
||||
// Collect all orders with full order info and materials
|
||||
// Each order has: { orderInfo: OrderHeader, materials: MaterialRow[] }
|
||||
const allOrders: Array<{ orderInfo: any; materials: any[] }> = []
|
||||
// Track merge operation duration and unwrap result
|
||||
const trackedResult = await trackDuration(
|
||||
async () => {
|
||||
const parser = new ExcelParser()
|
||||
|
||||
// Parse each downloaded file and collect orders
|
||||
for (const filePath of filePaths) {
|
||||
try {
|
||||
log.debug('Parsing file', { filePath })
|
||||
await parser.parse(filePath)
|
||||
// After parse(), the parser store orders internally as lastOrders
|
||||
const orders = (parser as any).lastOrders
|
||||
log.debug('File parsed', { filePath, orderCount: orders?.length || 0 })
|
||||
if (orders && Array.isArray(orders)) {
|
||||
allOrders.push(...orders)
|
||||
// Collect all orders with full order info and materials
|
||||
// Each order has: { orderInfo: OrderHeader, materials: MaterialRow[] }
|
||||
const allOrders: Array<{ orderInfo: any; materials: any[] }> = []
|
||||
|
||||
// Parse each downloaded file and collect orders
|
||||
for (const filePath of filePaths) {
|
||||
try {
|
||||
log.debug('Parsing file', { filePath })
|
||||
await parser.parse(filePath)
|
||||
// After parse(), the parser store orders internally as lastOrders
|
||||
const orders = (parser as any).lastOrders
|
||||
log.debug('File parsed', { filePath, orderCount: orders?.length || 0 })
|
||||
if (orders && Array.isArray(orders)) {
|
||||
allOrders.push(...orders)
|
||||
}
|
||||
} catch (error) {
|
||||
const errorMsg = error instanceof Error ? error.message : String(error)
|
||||
log.error('Failed to parse file', {
|
||||
filePath,
|
||||
error: errorMsg,
|
||||
orderNumbers,
|
||||
batchId: filePaths.indexOf(filePath)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// Calculate total record count (total material rows)
|
||||
let recordCount = 0
|
||||
const orderRecordCounts: Array<{ orderNumber: string; recordCount: number }> = []
|
||||
for (const order of allOrders) {
|
||||
const count = order.materials.length
|
||||
recordCount += count
|
||||
orderRecordCounts.push({
|
||||
orderNumber: order.orderInfo.productionOrder || '',
|
||||
recordCount: count
|
||||
})
|
||||
}
|
||||
|
||||
log.info('Merge summary', { orderCount: allOrders.length, recordCount })
|
||||
|
||||
if (recordCount === 0) {
|
||||
log.warn('No records found in any downloaded files', { orderNumbers })
|
||||
return { mergedFile: null, recordCount: 0, orderRecordCounts }
|
||||
}
|
||||
|
||||
// Generate output filename with timestamp
|
||||
const timestamp = new Date()
|
||||
.toISOString()
|
||||
.replace(/[-:T]/g, '')
|
||||
.replace(/\..+/, '')
|
||||
.slice(0, 14)
|
||||
const outputPath = path.join(this.downloadDir, `merged_${timestamp}.xlsx`)
|
||||
|
||||
// Save with error handling
|
||||
try {
|
||||
log.info('Saving merged file', { outputPath })
|
||||
await this.saveMergedOrders(allOrders, outputPath)
|
||||
log.info('Merged file saved successfully', { recordCount })
|
||||
return { mergedFile: outputPath, recordCount, orderRecordCounts }
|
||||
} catch (error) {
|
||||
const errorMsg = error instanceof Error ? error.message : String(error)
|
||||
const errorStack = error instanceof Error ? error.stack : ''
|
||||
log.error('Failed to save merged file', {
|
||||
error: errorMsg,
|
||||
stack: errorStack,
|
||||
orderNumbers,
|
||||
downloadDir: this.downloadDir
|
||||
})
|
||||
// Return parsed record count and error info even if save fails
|
||||
return {
|
||||
mergedFile: null,
|
||||
recordCount,
|
||||
orderRecordCounts,
|
||||
error: `保存合并文件失败:${errorMsg}`
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
operationName: 'File Merge',
|
||||
context: {
|
||||
fileCount: filePaths.length,
|
||||
orderCount: orderNumbers.length,
|
||||
orderNumbers
|
||||
}
|
||||
} catch (error) {
|
||||
const errorMsg = error instanceof Error ? error.message : String(error)
|
||||
log.error('Failed to parse file', { filePath, error: errorMsg })
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
// Calculate total record count (total material rows)
|
||||
let recordCount = 0
|
||||
const orderRecordCounts: Array<{ orderNumber: string; recordCount: number }> = []
|
||||
for (const order of allOrders) {
|
||||
const count = order.materials.length
|
||||
recordCount += count
|
||||
orderRecordCounts.push({
|
||||
orderNumber: order.orderInfo.productionOrder || '',
|
||||
recordCount: count
|
||||
})
|
||||
}
|
||||
|
||||
log.info('Merge summary', { orderCount: allOrders.length, recordCount })
|
||||
|
||||
if (recordCount === 0) {
|
||||
log.warn('No records found in any downloaded files')
|
||||
return { mergedFile: null, recordCount: 0, orderRecordCounts }
|
||||
}
|
||||
|
||||
// Generate output filename with timestamp
|
||||
const timestamp = new Date()
|
||||
.toISOString()
|
||||
.replace(/[-:T]/g, '')
|
||||
.replace(/\..+/, '')
|
||||
.slice(0, 14)
|
||||
const outputPath = path.join(this.downloadDir, `merged_${timestamp}.xlsx`)
|
||||
|
||||
// Save with error handling
|
||||
try {
|
||||
log.info('Saving merged file', { outputPath })
|
||||
await this.saveMergedOrders(allOrders, outputPath)
|
||||
log.info('Merged file saved successfully', { recordCount })
|
||||
return { mergedFile: outputPath, recordCount, orderRecordCounts }
|
||||
} catch (error) {
|
||||
const errorMsg = error instanceof Error ? error.message : String(error)
|
||||
const errorStack = error instanceof Error ? error.stack : ''
|
||||
log.error('Failed to save merged file', { error: errorMsg, stack: errorStack })
|
||||
// Return parsed record count and error info even if save fails
|
||||
return {
|
||||
mergedFile: null,
|
||||
recordCount,
|
||||
orderRecordCounts,
|
||||
error: `保存合并文件失败:${errorMsg}`
|
||||
}
|
||||
}
|
||||
return trackedResult.result
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -308,14 +374,19 @@ export class ExtractorService {
|
||||
* Clean up temporary batch files after merging
|
||||
* @param filePaths - Array of temporary file paths to delete
|
||||
*/
|
||||
private async cleanupTempFiles(filePaths: string[]): Promise<void> {
|
||||
private async cleanupTempFiles(filePaths: string[], orderNumbers?: string[]): Promise<void> {
|
||||
for (const filePath of filePaths) {
|
||||
try {
|
||||
await fs.unlink(filePath)
|
||||
log.debug('Deleted temporary file', { filePath })
|
||||
} catch (error) {
|
||||
// Log error but don't fail the main process
|
||||
log.error('Failed to delete temporary file', { filePath, error })
|
||||
log.error('Failed to delete temporary file', {
|
||||
filePath,
|
||||
error,
|
||||
orderNumbers,
|
||||
downloadDir: this.downloadDir
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -333,41 +404,58 @@ export class ExtractorService {
|
||||
log.info('Starting database import', { filePath })
|
||||
onLog?.('info', `开始导入数据到数据库...`)
|
||||
|
||||
const importService = new DataImportService()
|
||||
// Track import operation duration and unwrap result
|
||||
const trackedResult = await trackDuration(
|
||||
async () => {
|
||||
const importService = new DataImportService()
|
||||
|
||||
try {
|
||||
const result = await importService.importFromExcel(filePath, 1000)
|
||||
try {
|
||||
const result = await importService.importFromExcel(filePath, 1000)
|
||||
|
||||
log.info('Import completed', {
|
||||
success: result.success,
|
||||
recordsRead: result.recordsRead,
|
||||
recordsDeleted: result.recordsDeleted,
|
||||
recordsImported: result.recordsImported
|
||||
})
|
||||
log.info('Import completed', {
|
||||
success: result.success,
|
||||
recordsRead: result.recordsRead,
|
||||
recordsDeleted: result.recordsDeleted,
|
||||
recordsImported: result.recordsImported
|
||||
})
|
||||
|
||||
if (result.success) {
|
||||
onLog?.(
|
||||
'success',
|
||||
`导入完成:读取 ${result.recordsRead} 条,删除 ${result.recordsDeleted} 条,导入 ${result.recordsImported} 条`
|
||||
)
|
||||
} else if (result.errors.length > 0) {
|
||||
result.errors.forEach((err) => onLog?.('error', err))
|
||||
if (result.success) {
|
||||
onLog?.(
|
||||
'success',
|
||||
`导入完成:读取 ${result.recordsRead} 条,删除 ${result.recordsDeleted} 条,导入 ${result.recordsImported} 条`
|
||||
)
|
||||
} else if (result.errors.length > 0) {
|
||||
result.errors.forEach((err) => onLog?.('error', err))
|
||||
}
|
||||
|
||||
return result
|
||||
} catch (error) {
|
||||
const errorMsg = error instanceof Error ? error.message : String(error)
|
||||
log.error('Import failed', {
|
||||
error: errorMsg,
|
||||
filePath,
|
||||
downloadDir: this.downloadDir
|
||||
})
|
||||
onLog?.('error', `导入失败:${errorMsg}`)
|
||||
|
||||
return {
|
||||
success: false,
|
||||
recordsRead: 0,
|
||||
recordsDeleted: 0,
|
||||
recordsImported: 0,
|
||||
uniqueSourceNumbers: 0,
|
||||
errors: [errorMsg]
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
operationName: 'Database Import',
|
||||
context: {
|
||||
filePath
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
return result
|
||||
} catch (error) {
|
||||
const errorMsg = error instanceof Error ? error.message : String(error)
|
||||
log.error('Import failed', { error: errorMsg })
|
||||
onLog?.('error', `导入失败:${errorMsg}`)
|
||||
|
||||
return {
|
||||
success: false,
|
||||
recordsRead: 0,
|
||||
recordsDeleted: 0,
|
||||
recordsImported: 0,
|
||||
uniqueSourceNumbers: 0,
|
||||
errors: [errorMsg]
|
||||
}
|
||||
}
|
||||
return trackedResult.result
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,10 +3,13 @@
|
||||
*
|
||||
* Provides comprehensive error serialization and formatting for logging.
|
||||
* Captures full error context including stack traces, causes, and custom properties.
|
||||
*
|
||||
* Enhanced with request context tracking for distributed tracing support.
|
||||
*/
|
||||
|
||||
import type { ErrorLike, SerializedError } from '../../types/errors'
|
||||
import { isProduction } from './shared'
|
||||
import { getRequestId } from './request-context'
|
||||
|
||||
/**
|
||||
* Check if value is an Error or Error-like object
|
||||
@@ -152,6 +155,29 @@ export function extractErrorContext(error: SerializedError): {
|
||||
/**
|
||||
* Format error for console/file logging
|
||||
* Returns a formatted string with all error details
|
||||
*
|
||||
* @param error - The error to format (Error object or Error-like)
|
||||
* @param context - Optional context for logging
|
||||
* @param context.operation - Business operation being performed (e.g., 'extract', 'clean', 'validate')
|
||||
* @param context.module - Module/Service name where error occurred
|
||||
* @param context.userId - User ID performing the operation
|
||||
* @param context.requestId - Request/trace ID for distributed tracing (auto-injected if not provided)
|
||||
* @param context.batchId - Batch identifier for batch operations
|
||||
* @param context.duration - Operation duration in milliseconds
|
||||
* @param context.orderNumbers - Order numbers related to the operation
|
||||
* @param context.materialCodes - Material codes related to the operation
|
||||
* @returns Object with formatted message and metadata for logging
|
||||
*
|
||||
* @example
|
||||
* ```typescript
|
||||
* const { message, metadata } = formatErrorForLogging(error, {
|
||||
* operation: 'extract',
|
||||
* userId: 'user123',
|
||||
* batchId: 'batch-001',
|
||||
* duration: 1500
|
||||
* })
|
||||
* logger.error(message, metadata)
|
||||
* ```
|
||||
*/
|
||||
export function formatErrorForLogging(
|
||||
error: unknown,
|
||||
@@ -159,6 +185,11 @@ export function formatErrorForLogging(
|
||||
operation?: string
|
||||
module?: string
|
||||
userId?: string
|
||||
requestId?: string
|
||||
batchId?: string
|
||||
duration?: number
|
||||
orderNumbers?: string[]
|
||||
materialCodes?: string[]
|
||||
[key: string]: unknown
|
||||
}
|
||||
): {
|
||||
@@ -170,11 +201,27 @@ export function formatErrorForLogging(
|
||||
const errorToLog = isProd ? sanitizeError(serialized) : serialized
|
||||
const errorContext = extractErrorContext(errorToLog)
|
||||
|
||||
// Auto-inject requestId from async context if not explicitly provided
|
||||
const autoRequestId = getRequestId()
|
||||
const requestId = context?.requestId || autoRequestId
|
||||
|
||||
const metadata: Record<string, unknown> = {
|
||||
error: errorToLog,
|
||||
...(requestId && { requestId }),
|
||||
...context
|
||||
}
|
||||
|
||||
// Remove undefined context fields to keep logs clean
|
||||
if (context) {
|
||||
const cleanMetadata: Record<string, unknown> = {}
|
||||
for (const [key, value] of Object.entries(metadata)) {
|
||||
if (value !== undefined) {
|
||||
cleanMetadata[key] = value
|
||||
}
|
||||
}
|
||||
Object.assign(metadata, cleanMetadata)
|
||||
}
|
||||
|
||||
// Add error location context if available
|
||||
if (errorContext.fileName) {
|
||||
metadata.errorLocation = {
|
||||
@@ -202,6 +249,28 @@ export function formatErrorForLogging(
|
||||
/**
|
||||
* Log error with full context
|
||||
* Wrapper for logger.error that ensures complete error information is captured
|
||||
*
|
||||
* @param logger - Logger instance with error method
|
||||
* @param error - The error to log (Error object or Error-like)
|
||||
* @param options - Logging options
|
||||
* @param options.message - Custom message to prepend to error message
|
||||
* @param options.operation - Business operation being performed
|
||||
* @param options.module - Module/Service name
|
||||
* @param options.userId - User ID performing the operation
|
||||
* @param options.requestId - Request/trace ID (auto-injected if not provided)
|
||||
* @param options.batchId - Batch identifier for batch operations
|
||||
* @param options.duration - Operation duration in milliseconds
|
||||
* @param options.context - Additional custom context fields
|
||||
*
|
||||
* @example
|
||||
* ```typescript
|
||||
* logError(logger, error, {
|
||||
* operation: 'extract',
|
||||
* userId: 'user123',
|
||||
* message: 'Failed to process order',
|
||||
* duration: 1500
|
||||
* })
|
||||
* ```
|
||||
*/
|
||||
export function logError(
|
||||
logger: { error: (message: string, meta?: Record<string, unknown>) => void },
|
||||
@@ -211,14 +280,29 @@ export function logError(
|
||||
operation?: string
|
||||
module?: string
|
||||
userId?: string
|
||||
requestId?: string
|
||||
batchId?: string
|
||||
duration?: number
|
||||
context?: Record<string, unknown>
|
||||
} = {}
|
||||
): void {
|
||||
const { message: customMessage, operation, module: moduleName, userId, context } = options
|
||||
const {
|
||||
message: customMessage,
|
||||
operation,
|
||||
module: moduleName,
|
||||
userId,
|
||||
requestId,
|
||||
batchId,
|
||||
duration,
|
||||
context
|
||||
} = options
|
||||
const { message, metadata } = formatErrorForLogging(error, {
|
||||
operation,
|
||||
module: moduleName,
|
||||
userId,
|
||||
requestId,
|
||||
batchId,
|
||||
duration,
|
||||
...context
|
||||
})
|
||||
|
||||
@@ -241,3 +325,77 @@ export function throwAfterLogging(
|
||||
logError(logger, error, options)
|
||||
throw error
|
||||
}
|
||||
|
||||
/**
|
||||
* Enhanced error logging helper with automatic context injection
|
||||
*
|
||||
* Simplifies error logging by automatically injecting requestId from async context
|
||||
* and providing a concise API for common logging scenarios.
|
||||
*
|
||||
* @param logger - Logger instance with error method
|
||||
* @param error - The error to log (Error object or Error-like)
|
||||
* @param context - Business context for the error
|
||||
* @param context.operation - Business operation (REQUIRED for enhanced logging)
|
||||
* @param context.userId - User ID performing the operation
|
||||
* @param context.batchId - Batch identifier for batch operations
|
||||
* @param context.duration - Operation duration in milliseconds (e.g., from performance monitoring)
|
||||
* @param context.orderNumbers - Order numbers related to the operation
|
||||
* @param context.materialCodes - Material codes related to the operation
|
||||
* @param context.module - Module/Service name (defaults to 'unknown' if not provided)
|
||||
* @param customMessage - Optional custom message to prepend (if not provided, uses error message)
|
||||
*
|
||||
* @example
|
||||
* ```typescript
|
||||
* import { enhancedLogError } from './error-utils'
|
||||
*
|
||||
* // Simple usage with auto-injected requestId
|
||||
* enhancedLogError(logger, error, { operation: 'extract', userId: 'user123' })
|
||||
*
|
||||
* // With performance metrics
|
||||
* const duration = Date.now() - startTime
|
||||
* enhancedLogError(logger, error, {
|
||||
* operation: 'clean',
|
||||
* userId: 'user456',
|
||||
* batchId: 'batch-001',
|
||||
* duration,
|
||||
* orderNumbers: ['ORD-123', 'ORD-124']
|
||||
* })
|
||||
* ```
|
||||
*/
|
||||
export function enhancedLogError(
|
||||
logger: { error: (message: string, meta?: Record<string, unknown>) => void },
|
||||
error: unknown,
|
||||
context: {
|
||||
operation: string
|
||||
userId?: string
|
||||
batchId?: string
|
||||
duration?: number
|
||||
orderNumbers?: string[]
|
||||
materialCodes?: string[]
|
||||
module?: string
|
||||
},
|
||||
customMessage?: string
|
||||
): void {
|
||||
const {
|
||||
operation,
|
||||
userId,
|
||||
batchId,
|
||||
duration,
|
||||
orderNumbers,
|
||||
materialCodes,
|
||||
module: moduleName
|
||||
} = context
|
||||
|
||||
logError(logger, error, {
|
||||
message: customMessage,
|
||||
operation,
|
||||
module: moduleName,
|
||||
userId,
|
||||
batchId,
|
||||
duration,
|
||||
context: {
|
||||
...(orderNumbers && { orderNumbers }),
|
||||
...(materialCodes && { materialCodes })
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
@@ -15,6 +15,7 @@ import { BrowserWindow } from 'electron'
|
||||
import { serializeError, sanitizeError } from './error-utils'
|
||||
import { getLogDir, isProduction } from './shared'
|
||||
import { IPC_CHANNELS } from '../../../shared/ipc-channels'
|
||||
import { getContext, run } from './request-context'
|
||||
|
||||
// Cache isProduction() at module load — app.isPackaged never changes at runtime
|
||||
const IS_PROD = isProduction()
|
||||
@@ -37,50 +38,83 @@ function isSerializedError(value: unknown): boolean {
|
||||
const consoleFormat = winston.format.combine(
|
||||
winston.format.timestamp({ format: 'YYYY-MM-DD HH:mm:ss' }),
|
||||
winston.format.colorize(),
|
||||
winston.format.printf(({ timestamp, level, message, context, error, ...meta }) => {
|
||||
const contextStr = context ? `[${context}]` : ''
|
||||
|
||||
// Format error with full stack trace
|
||||
let errorStr = ''
|
||||
if (error) {
|
||||
// Skip re-serialization if already a serialized error object
|
||||
const serialized: { stack?: string; message: string } = isSerializedError(error)
|
||||
? (error as { stack?: string; message: string })
|
||||
: IS_PROD
|
||||
? sanitizeError(serializeError(error))
|
||||
: serializeError(error)
|
||||
if (serialized.stack) {
|
||||
errorStr = `\n${serialized.stack}`
|
||||
} else {
|
||||
errorStr = ` ${serialized.message}`
|
||||
// Auto-inject requestId from async context
|
||||
winston.format((info) => {
|
||||
const context = getContext()
|
||||
if (context) {
|
||||
info.requestId = context.requestId
|
||||
if (context.userId) {
|
||||
info.userId = context.userId
|
||||
}
|
||||
if (context.operation) {
|
||||
info.operation = context.operation
|
||||
}
|
||||
}
|
||||
return info
|
||||
})(),
|
||||
winston.format.printf(
|
||||
({ timestamp, level, message, context, error, requestId, userId, operation, ...meta }) => {
|
||||
const contextStr = context ? `[${context}]` : ''
|
||||
const requestIdStr = requestId ? ` [${requestId}]` : ''
|
||||
const userStr = userId ? ` (user:${userId})` : ''
|
||||
const opStr = operation ? ` op:${operation}` : ''
|
||||
|
||||
let metaStr = ''
|
||||
if (Object.keys(meta).length > 0) {
|
||||
try {
|
||||
metaStr = ` ${JSON.stringify(meta, null, 2)}`
|
||||
} catch {
|
||||
// Fallback for circular references: stringify primitives, replace complex objects with placeholder
|
||||
metaStr = ` ${JSON.stringify(
|
||||
Object.fromEntries(
|
||||
Object.entries(meta).map(([k, v]) => [
|
||||
k,
|
||||
v !== null && typeof v === 'object' ? `[Object]` : v
|
||||
])
|
||||
),
|
||||
null,
|
||||
2
|
||||
)}`
|
||||
// Format error with full stack trace
|
||||
let errorStr = ''
|
||||
if (error) {
|
||||
// Skip re-serialization if already a serialized error object
|
||||
const serialized: { stack?: string; message: string } = isSerializedError(error)
|
||||
? (error as { stack?: string; message: string })
|
||||
: IS_PROD
|
||||
? sanitizeError(serializeError(error))
|
||||
: serializeError(error)
|
||||
if (serialized.stack) {
|
||||
errorStr = `\n${serialized.stack}`
|
||||
} else {
|
||||
errorStr = ` ${serialized.message}`
|
||||
}
|
||||
}
|
||||
|
||||
let metaStr = ''
|
||||
if (Object.keys(meta).length > 0) {
|
||||
try {
|
||||
metaStr = ` ${JSON.stringify(meta, null, 2)}`
|
||||
} catch {
|
||||
// Fallback for circular references: stringify primitives, replace complex objects with placeholder
|
||||
metaStr = ` ${JSON.stringify(
|
||||
Object.fromEntries(
|
||||
Object.entries(meta).map(([k, v]) => [
|
||||
k,
|
||||
v !== null && typeof v === 'object' ? `[Object]` : v
|
||||
])
|
||||
),
|
||||
null,
|
||||
2
|
||||
)}`
|
||||
}
|
||||
}
|
||||
return `${timestamp} [${level}]${contextStr}${requestIdStr}${userStr}${opStr} ${message}${errorStr}${metaStr}`
|
||||
}
|
||||
return `${timestamp} [${level}]${contextStr} ${message}${errorStr}${metaStr}`
|
||||
})
|
||||
)
|
||||
)
|
||||
|
||||
// Custom format for file output - JSON with full error details
|
||||
const fileFormat = winston.format.combine(
|
||||
winston.format.timestamp({ format: 'YYYY-MM-DD HH:mm:ss' }),
|
||||
// Auto-inject requestId from async context for file logs
|
||||
winston.format((info) => {
|
||||
const context = getContext()
|
||||
if (context) {
|
||||
info.requestId = context.requestId
|
||||
if (context.userId) {
|
||||
info.userId = context.userId
|
||||
}
|
||||
if (context.operation) {
|
||||
info.operation = context.operation
|
||||
}
|
||||
}
|
||||
return info
|
||||
})(),
|
||||
winston.format((info) => {
|
||||
// Serialize errors in metadata (skip if already serialized)
|
||||
if (info.error) {
|
||||
@@ -189,11 +223,48 @@ export function createLogger(context: string): winston.Logger {
|
||||
return logger.child({ context })
|
||||
}
|
||||
|
||||
/**
|
||||
* Execute a function with automatic request-scoped logging
|
||||
*
|
||||
* This wrapper ensures all logging within the function has access to the request context.
|
||||
* It's a convenience wrapper around RequestContext.run() that also ensures the logger
|
||||
* properly captures the context.
|
||||
*
|
||||
* @param fn - The async function to execute within the context
|
||||
* @param context - Optional business context (userId, operation)
|
||||
* @returns Promise resolving to the function's return value
|
||||
*
|
||||
* @example
|
||||
* ```typescript
|
||||
* await withRequestContext(async () => {
|
||||
* logger.info('Processing order') // Will include requestId, userId, operation
|
||||
* await processOrder()
|
||||
* }, { userId: 'user123', operation: 'process-order' })
|
||||
* ```
|
||||
*/
|
||||
export async function withRequestContext<T>(
|
||||
fn: () => Promise<T>,
|
||||
context?: { userId?: string; operation?: string }
|
||||
): Promise<T> {
|
||||
return run(fn, context)
|
||||
}
|
||||
|
||||
// Re-export error utilities for convenience
|
||||
export { logError, formatErrorForLogging, serializeError, extractErrorContext } from './error-utils'
|
||||
|
||||
// Export request context management for async-context logging
|
||||
export { run, getRequestId, getContext, withContext, type LoggerContext } from './request-context'
|
||||
|
||||
// Export the main logger for direct use
|
||||
export default logger
|
||||
|
||||
// Export performance monitoring utilities
|
||||
export {
|
||||
trackDuration,
|
||||
PerformanceTracker,
|
||||
createPerformanceTracker,
|
||||
DEFAULT_SLOW_THRESHOLD_MS
|
||||
} from './performance-monitor'
|
||||
|
||||
// Export log level types for convenience
|
||||
export type LogLevel = 'error' | 'warn' | 'info' | 'debug' | 'verbose'
|
||||
|
||||
340
src/main/services/logger/performance-monitor.ts
Normal file
340
src/main/services/logger/performance-monitor.ts
Normal file
@@ -0,0 +1,340 @@
|
||||
/**
|
||||
* Logger Performance Monitoring Utilities
|
||||
*
|
||||
* Provides performance tracking and timing utilities that integrate with the Winston logger.
|
||||
*
|
||||
* Features:
|
||||
* - trackDuration: Wrap async functions and auto-log execution time
|
||||
* - PerformanceTracker: Track multiple metrics over time
|
||||
* - Slow operation detection with configurable thresholds
|
||||
* - Performance warnings for operations exceeding thresholds
|
||||
*/
|
||||
|
||||
import type { Logger } from 'winston'
|
||||
import logger from './index'
|
||||
|
||||
/**
|
||||
* Default threshold for slow operation warnings (in milliseconds)
|
||||
*/
|
||||
export const DEFAULT_SLOW_THRESHOLD_MS = 1000
|
||||
|
||||
/**
|
||||
* Result of a tracked operation
|
||||
*/
|
||||
export interface TrackDurationResult<T> {
|
||||
/** The result value from the operation */
|
||||
result: T
|
||||
/** Execution duration in milliseconds */
|
||||
durationMs: number
|
||||
/** Whether the operation exceeded the slow threshold */
|
||||
isSlow: boolean
|
||||
}
|
||||
|
||||
/**
|
||||
* Configuration for trackDuration
|
||||
*/
|
||||
export interface TrackDurationOptions {
|
||||
/** Operation name for logging */
|
||||
operationName: string
|
||||
/** Custom log message (optional) */
|
||||
message?: string
|
||||
/** Slow threshold in ms (overrides default) */
|
||||
slowThresholdMs?: number
|
||||
/** Log level for duration info (default: 'debug') */
|
||||
logLevel?: 'debug' | 'info' | 'verbose'
|
||||
/** Additional context to include in logs */
|
||||
context?: Record<string, unknown>
|
||||
}
|
||||
|
||||
/**
|
||||
* Metrics tracked by PerformanceTracker
|
||||
*/
|
||||
export interface PerformanceMetrics {
|
||||
/** Total number of operations tracked */
|
||||
count: number
|
||||
/** Total duration of all operations in milliseconds */
|
||||
totalDurationMs: number
|
||||
/** Minimum duration in milliseconds */
|
||||
minDurationMs: number
|
||||
/** Maximum duration in milliseconds */
|
||||
maxDurationMs: number
|
||||
/** Average duration in milliseconds */
|
||||
avgDurationMs: number
|
||||
/** Number of operations exceeding slow threshold */
|
||||
slowOperationCount: number
|
||||
}
|
||||
|
||||
/**
|
||||
* Track the duration of an async operation and log the result
|
||||
*
|
||||
* @param fn - The async function to track
|
||||
* @param options - Configuration options including operation name and threshold
|
||||
* @returns Promise resolving to TrackDurationResult with result, duration, and slow flag
|
||||
*
|
||||
* @example
|
||||
* ```typescript
|
||||
* const result = await trackDuration(
|
||||
* async () => await someAsyncOperation(),
|
||||
* { operationName: 'Database Query', slowThresholdMs: 500 }
|
||||
* );
|
||||
* console.log(result.result, result.durationMs);
|
||||
* ```
|
||||
*/
|
||||
export async function trackDuration<T>(
|
||||
fn: () => Promise<T>,
|
||||
options: TrackDurationOptions
|
||||
): Promise<TrackDurationResult<T>> {
|
||||
const {
|
||||
operationName,
|
||||
message = `Operation "${operationName}"`,
|
||||
slowThresholdMs = DEFAULT_SLOW_THRESHOLD_MS,
|
||||
logLevel = 'debug',
|
||||
context = {}
|
||||
} = options
|
||||
|
||||
const startTime = performance.now()
|
||||
|
||||
try {
|
||||
const result = await fn()
|
||||
const durationMs = performance.now() - startTime
|
||||
const isSlow = durationMs > slowThresholdMs
|
||||
|
||||
// Log the result
|
||||
const logMessage = `${message} completed in ${durationMs.toFixed(2)}ms`
|
||||
if (isSlow) {
|
||||
logger.warn(`${logMessage} (SLOW - exceeded ${slowThresholdMs}ms threshold)`, {
|
||||
operation: operationName,
|
||||
durationMs,
|
||||
slowThresholdMs,
|
||||
...context
|
||||
})
|
||||
} else {
|
||||
logger[logLevel](logMessage, {
|
||||
operation: operationName,
|
||||
durationMs,
|
||||
...context
|
||||
})
|
||||
}
|
||||
|
||||
return { result, durationMs, isSlow }
|
||||
} catch (error) {
|
||||
const durationMs = performance.now() - startTime
|
||||
const isSlow = durationMs > slowThresholdMs
|
||||
|
||||
// Log the error with duration
|
||||
logger.error(`${message} failed after ${durationMs.toFixed(2)}ms`, {
|
||||
operation: operationName,
|
||||
durationMs,
|
||||
slowThresholdMs,
|
||||
error,
|
||||
...context
|
||||
})
|
||||
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* PerformanceTracker class for tracking multiple metrics over time
|
||||
*
|
||||
* Tracks operation counts, durations, and identifies slow operations.
|
||||
* Useful for monitoring service-level performance and identifying bottlenecks.
|
||||
*
|
||||
* @example
|
||||
* ```typescript
|
||||
* const tracker = new PerformanceTracker('DataService', 500);
|
||||
*
|
||||
* // Track individual operations
|
||||
* await tracker.track('fetchData', async () => fetchData());
|
||||
* await tracker.track('saveData', async () => saveData());
|
||||
*
|
||||
* // Get metrics
|
||||
* const metrics = tracker.getMetrics();
|
||||
* console.log(`Avg duration: ${metrics.avgDurationMs}ms`);
|
||||
*
|
||||
* // Log summary
|
||||
* tracker.logSummary();
|
||||
* ```
|
||||
*/
|
||||
export class PerformanceTracker {
|
||||
private operationName: string
|
||||
private slowThresholdMs: number
|
||||
private durations: number[] = []
|
||||
private slowCount = 0
|
||||
private log: Logger
|
||||
|
||||
/**
|
||||
* Create a new PerformanceTracker
|
||||
*
|
||||
* @param operationName - Name of the operation/category being tracked
|
||||
* @param slowThresholdMs - Custom slow threshold in ms (default: 1000)
|
||||
* @param customLogger - Optional custom logger instance (default: main logger)
|
||||
*/
|
||||
constructor(
|
||||
operationName: string,
|
||||
slowThresholdMs: number = DEFAULT_SLOW_THRESHOLD_MS,
|
||||
customLogger?: Logger
|
||||
) {
|
||||
this.operationName = operationName
|
||||
this.slowThresholdMs = slowThresholdMs
|
||||
this.log = customLogger ?? logger
|
||||
}
|
||||
|
||||
/**
|
||||
* Track an async operation and record its duration
|
||||
*
|
||||
* @param name - Specific name of this operation instance
|
||||
* @param fn - The async function to track
|
||||
* @param context - Optional context to log with the operation
|
||||
* @returns Promise resolving to the function's result
|
||||
*
|
||||
* @example
|
||||
* ```typescript
|
||||
* const result = await tracker.track('getUserById', async () => getUserById(id), {
|
||||
* userId: id
|
||||
* });
|
||||
* ```
|
||||
*/
|
||||
async track<T>(
|
||||
name: string,
|
||||
fn: () => Promise<T>,
|
||||
context?: Record<string, unknown>
|
||||
): Promise<T> {
|
||||
const startTime = performance.now()
|
||||
|
||||
try {
|
||||
const result = await fn()
|
||||
const durationMs = performance.now() - startTime
|
||||
|
||||
this.recordDuration(durationMs)
|
||||
|
||||
if (durationMs > this.slowThresholdMs) {
|
||||
this.log.warn(`[${this.operationName}] ${name} took ${durationMs.toFixed(2)}ms (SLOW)`, {
|
||||
operation: name,
|
||||
durationMs,
|
||||
slowThresholdMs: this.slowThresholdMs,
|
||||
...context
|
||||
})
|
||||
} else {
|
||||
this.log.debug(`[${this.operationName}] ${name} completed in ${durationMs.toFixed(2)}ms`, {
|
||||
operation: name,
|
||||
durationMs,
|
||||
...context
|
||||
})
|
||||
}
|
||||
|
||||
return result
|
||||
} catch (error) {
|
||||
const durationMs = performance.now() - startTime
|
||||
this.recordDuration(durationMs)
|
||||
|
||||
this.log.error(`[${this.operationName}] ${name} failed after ${durationMs.toFixed(2)}ms`, {
|
||||
operation: name,
|
||||
durationMs,
|
||||
error,
|
||||
...context
|
||||
})
|
||||
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Record a duration measurement (for manual tracking)
|
||||
*
|
||||
* @param durationMs - Duration in milliseconds
|
||||
*/
|
||||
recordDuration(durationMs: number): void {
|
||||
this.durations.push(durationMs)
|
||||
if (durationMs > this.slowThresholdMs) {
|
||||
this.slowCount++
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get current performance metrics
|
||||
*
|
||||
* @returns PerformanceMetrics with aggregated statistics
|
||||
*/
|
||||
getMetrics(): PerformanceMetrics {
|
||||
const count = this.durations.length
|
||||
if (count === 0) {
|
||||
return {
|
||||
count: 0,
|
||||
totalDurationMs: 0,
|
||||
minDurationMs: 0,
|
||||
maxDurationMs: 0,
|
||||
avgDurationMs: 0,
|
||||
slowOperationCount: 0
|
||||
}
|
||||
}
|
||||
|
||||
const totalDurationMs = this.durations.reduce((sum, d) => sum + d, 0)
|
||||
const minDurationMs = Math.min(...this.durations)
|
||||
const maxDurationMs = Math.max(...this.durations)
|
||||
const avgDurationMs = totalDurationMs / count
|
||||
|
||||
return {
|
||||
count,
|
||||
totalDurationMs,
|
||||
minDurationMs,
|
||||
maxDurationMs,
|
||||
avgDurationMs,
|
||||
slowOperationCount: this.slowCount
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Log a summary of performance metrics
|
||||
*
|
||||
* @param level - Log level for summary (default: 'info')
|
||||
* @param message - Custom message prefix (optional)
|
||||
*/
|
||||
logSummary(level: 'info' | 'warn' | 'debug' = 'info', message?: string): void {
|
||||
const metrics = this.getMetrics()
|
||||
const summaryMessage = message || `[${this.operationName}] Performance Summary`
|
||||
|
||||
this.log[level](summaryMessage, {
|
||||
totalOperations: metrics.count,
|
||||
avgDurationMs: `${metrics.avgDurationMs.toFixed(2)}ms`,
|
||||
minDurationMs: `${metrics.minDurationMs.toFixed(2)}ms`,
|
||||
maxDurationMs: `${metrics.maxDurationMs.toFixed(2)}ms`,
|
||||
slowOperations: metrics.slowOperationCount,
|
||||
slowPercentage:
|
||||
metrics.count > 0
|
||||
? ((metrics.slowOperationCount / metrics.count) * 100).toFixed(1) + '%'
|
||||
: '0%',
|
||||
totalDurationMs: `${metrics.totalDurationMs.toFixed(2)}ms`
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Reset all tracked metrics
|
||||
*/
|
||||
reset(): void {
|
||||
this.durations = []
|
||||
this.slowCount = 0
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a performance tracker for a specific service or module
|
||||
*
|
||||
* Convenience function that returns a new PerformanceTracker instance.
|
||||
* Useful for creating trackers with consistent naming conventions.
|
||||
*
|
||||
* @param context - Context/module name (e.g., 'DatabaseService', 'ERPExtractor')
|
||||
* @param slowThresholdMs - Optional custom slow threshold
|
||||
* @returns New PerformanceTracker instance
|
||||
*
|
||||
* @example
|
||||
* ```typescript
|
||||
* const dbTracker = createPerformanceTracker('DatabaseService', 200);
|
||||
* ```
|
||||
*/
|
||||
export function createPerformanceTracker(
|
||||
context: string,
|
||||
slowThresholdMs?: number
|
||||
): PerformanceTracker {
|
||||
return new PerformanceTracker(context, slowThresholdMs)
|
||||
}
|
||||
152
src/main/services/logger/request-context.ts
Normal file
152
src/main/services/logger/request-context.ts
Normal file
@@ -0,0 +1,152 @@
|
||||
/**
|
||||
* Request Context Management using AsyncLocalStorage
|
||||
*
|
||||
* Provides async-context propagation for request-scoped logging metadata.
|
||||
* Uses Node.js AsyncLocalStorage to maintain isolated context across async/await boundaries.
|
||||
*
|
||||
* Features:
|
||||
* - Automatic requestId generation with crypto.randomUUID()
|
||||
* - Support for userId and operation tracking
|
||||
* - Complete context isolation between concurrent requests
|
||||
* - Backward compatible with non-request logging scenarios
|
||||
*
|
||||
* @example
|
||||
* ```typescript
|
||||
* import { run, getRequestId, getContext } from './request-context'
|
||||
*
|
||||
* await run(async () => {
|
||||
* const requestId = getRequestId() // Available throughout async chain
|
||||
* await someAsyncOperation()
|
||||
* }, { userId: 'user123', operation: 'extract' })
|
||||
* ```
|
||||
*/
|
||||
|
||||
import { AsyncLocalStorage } from 'async_hooks'
|
||||
import { randomUUID } from 'crypto'
|
||||
|
||||
/**
|
||||
* Logger context structure containing request-scoped metadata
|
||||
*/
|
||||
export interface LoggerContext {
|
||||
/** Unique identifier for this request (auto-generated UUID v4) */
|
||||
requestId: string
|
||||
/** User ID performing the operation (optional, set by caller) */
|
||||
userId?: string
|
||||
/** Operation being performed (optional, e.g., 'extract', 'clean', 'validate') */
|
||||
operation?: string
|
||||
}
|
||||
|
||||
/**
|
||||
* AsyncLocalStorage instance for request context
|
||||
* Each async execution scope has its own isolated context
|
||||
*/
|
||||
const storage = new AsyncLocalStorage<LoggerContext>()
|
||||
|
||||
/**
|
||||
* Execute a function within a request context scope
|
||||
*
|
||||
* Creates a new context with auto-generated requestId and optional business metadata.
|
||||
* All async operations within the callback can access this context via getRequestId() or getContext().
|
||||
*
|
||||
* @param fn - The async function to execute within the context
|
||||
* @param context - Optional business context (userId, operation)
|
||||
* @returns Promise resolving to the function's return value
|
||||
*
|
||||
* @example
|
||||
* ```typescript
|
||||
* await run(async () => {
|
||||
* // requestId is available here and in all nested async calls
|
||||
* const id = getRequestId()
|
||||
* await processOrder()
|
||||
* }, { userId: 'user123', operation: 'extract' })
|
||||
* ```
|
||||
*/
|
||||
export function run<T>(
|
||||
fn: () => Promise<T>,
|
||||
context?: Omit<LoggerContext, 'requestId'>
|
||||
): Promise<T> {
|
||||
const fullContext: LoggerContext = {
|
||||
requestId: randomUUID(),
|
||||
userId: context?.userId,
|
||||
operation: context?.operation
|
||||
}
|
||||
|
||||
return storage.run(fullContext, fn)
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the current request ID from the async context
|
||||
*
|
||||
* @returns The current requestId, or undefined if not in a request context
|
||||
*
|
||||
* @example
|
||||
* ```typescript
|
||||
* function logSomething() {
|
||||
* const requestId = getRequestId()
|
||||
* logger.info(`Processing...`, { requestId })
|
||||
* }
|
||||
* ```
|
||||
*/
|
||||
export function getRequestId(): string | undefined {
|
||||
const context = storage.getStore()
|
||||
return context?.requestId
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the full logger context from the current async scope
|
||||
*
|
||||
* @returns The complete LoggerContext, or undefined if not in a request context
|
||||
*
|
||||
* @example
|
||||
* ```typescript
|
||||
* const context = getContext()
|
||||
* if (context) {
|
||||
* logger.info('Operation', {
|
||||
* requestId: context.requestId,
|
||||
* userId: context.userId,
|
||||
* operation: context.operation
|
||||
* })
|
||||
* }
|
||||
* ```
|
||||
*/
|
||||
export function getContext(): LoggerContext | undefined {
|
||||
return storage.getStore()
|
||||
}
|
||||
|
||||
/**
|
||||
* Execute a function with a modified context
|
||||
*
|
||||
* Creates a new context scope based on the current context with selective overrides.
|
||||
* Useful for nested operations that need to change specific context fields.
|
||||
*
|
||||
* @param fn - The async function to execute
|
||||
* @param overrides - Context fields to override
|
||||
* @returns Promise resolving to the function's return value
|
||||
*
|
||||
* @example
|
||||
* ```typescript
|
||||
* await run(async () => {
|
||||
* // Outer context: operation='extract'
|
||||
* await withContext(async () => {
|
||||
* // Inner context: operation='validate-subtask'
|
||||
* }, { operation: 'validate-subtask' })
|
||||
* }, { operation: 'extract' })
|
||||
* ```
|
||||
*/
|
||||
export function withContext<T>(
|
||||
fn: () => Promise<T>,
|
||||
overrides: Partial<Omit<LoggerContext, 'requestId'>>
|
||||
): Promise<T> {
|
||||
const currentContext = storage.getStore()
|
||||
const newContext: LoggerContext = currentContext
|
||||
? {
|
||||
...currentContext,
|
||||
...overrides
|
||||
}
|
||||
: {
|
||||
requestId: randomUUID(),
|
||||
...overrides
|
||||
}
|
||||
|
||||
return storage.run(newContext, fn)
|
||||
}
|
||||
Reference in New Issue
Block a user