3.1.9 · Cross-cutting concerns
บทนี้รวมสิ่งที่ตัดผ่านหลาย layer — logging, error formatting, caching, file utilities, CORS, env config, notification push และ utility ที่ใช้ทั่วระบบ แต่ละข้อมี trade-off ที่ควรรู้ตอน maintain
ทุก citation ในบทนี้ re-verify กับ source บนดิสก์ (vtrc-api/api/src/...) วันที่ 2026-07-13 — line numbers ตรงกับ v1 ทุกบรรทัด
Logging — wlog + GraphQLLogging
vtrc-api มี logging แยกเป็น 2 ชั้น
| ชั้น | ไฟล์ | บรรทัด | หน้าที่ |
|---|---|---|---|
| App log | lib/wlog.js | 72 | เขียน log ลงไฟล์ใน logs/{yyyy}/{mm}/{dd}/ แยกตาม type |
| Request log | lib/logging.js | 108 | Apollo plugin ที่ log ทุก GraphQL request + response |
App log — write-only file logger
wlog ไม่ได้ใช้ winston, pino หรือ bunyan เขียนเองจาก fs.appendFileSync ทั้งหมด ที่ vtrc-api/api/src/lib/wlog.js:9 core function เป็นดังนี้
const writeLog = (text, type = '') => {
const date = DateClass.makeDate()
type = (type === '') ? '' : type + '_'
const logPath = path.resolve(FileMethods.getLogPath(), '../')
const logFile = path.resolve(logPath, `${type}${date.yyyy}-${date.mm}-${date.dd}.log`)
if (!fs.existsSync(logPath)) {
fs.mkdirSync(logPath, { recursive: true })
}
return FileMethods.writeFile(logFile, `${date.date.toLocaleString('th-th', { timeZone: TIME_ZONE_TEXT })} - ${text}\n`, {}, true)
}FileMethods.writeFile (ดู lib/file.js:8-23) เรียก fs.appendFileSync ต่อใน append mode → เป็น synchronous I/O ทุกครั้งที่เรียก
ระดับ log ทั้งหมด — แต่ละระดับถูก export ที่ vtrc-api/api/src/lib/wlog.js:72 และเขียนไฟล์คนละชื่อ
logs/2026/07/2026-07-09.log ← info (default)
logs/2026/07/error_2026-07-09.log
logs/2026/07/warning_2026-07-09.log
logs/2026/07/query_2026-07-09.log
logs/2026/07/notificationLog_2026-07-09.log
logs/2026/07/notificationLogError_2026-07-09.log
logs/2026/07/slipError_2026-07-09.log
logs/2026/07/verify_2026-07-09.log
logs/2026/07/forceLogs_2026-07-09.logปัญหาหลักของ wlog ที่ต้องรู้ตอน maintain:
- synchronous I/O —
fs.appendFileSyncblock event loop ทุกครั้งที่เรียก ถ้ามี request พร้อมกัน 100 ตัวที่ log ทุกตัว → response time พุ่ง - ไม่มี rotation — ไฟล์เติบโตไม่จำกัด ต้องจัดการ logrotate นอกแอป (ปกติที่ OS level) หรือ docker log driver truncation
- ไม่มี log level runtime config — เปิดปิดต้องแก้ code และ deploy ใหม่
- เขียนลง disk เท่านั้น — ไม่ ship ไปที่ stdout / journald / Loki / Datadog ทำให้ log aggregation ฝั่ง platform ต้องอ่านไฟล์ใน container เอง
Log format — JSON string concat
vtrc-api/api/src/lib/wlog.js:30
const logTextFormat = (code, source, type = 'INFO', text = '', args = {}) => {
let params = { CODE: code, SOURCE: source, TYPE: type.toUpperCase(), text }
params = { ...params, ...args }
return JSON.stringify(params)
}ทุกจุดที่เรียกจะใช้ pattern log.error(log.logTextFormat('CODE', 'Method:fnName', 'ERROR', msg, { extra })) — output เป็น JSON บรรทัดเดียวเสมอ แต่ไม่ได้มี schema ที่ validate ทำให้ grep ที่ field ไม่ค่อยเชื่อถือได้ (บางจุดส่ง { error: e } บางจุดส่ง { err } บางจุดส่ง { e })
Request log — Apollo plugin
GraphQLLogging เป็น Apollo plugin ที่ register ที่ index.js:71-73
plugins: [
() => new GraphQLLogging()
]vtrc-api/api/src/lib/logging.js:11
requestDidStart(requestContext) {
const requestLogObj = this.buildRequestContextObject(requestContext)
if (!this.isEmpty(requestLogObj)) {
log.writeLog(`[INFO] ${JSON.stringify(requestLogObj)}`, 'request')
}
return {
willSendResponse(responseContext) {
const { operationName, context } = responseContext
if (operationName !== 'IntrospectionQuery') {
const { errors } = responseContext
const logLevel = (typeof errors !== 'undefined') ? 'ERROR' : 'INFO'
const responseLogObj = {
...requestLogObj,
...errors
}
log.writeLog(`[${logLevel}] ${JSON.stringify(responseLogObj)}`, 'response')
}
}
}
}ข้อดี — มี PII redaction ที่ vtrc-api/api/src/lib/logging.js:6
this.replaceValue = '_REPLACED_'
this.replaceVariables = ['oldPassword', 'newPassword', 'confirmNewPassword', 'password', 'confirmPassword', 'pwd', 'passwd']
this.replaceHeaders = ['authorization', 'apiKey']รหัสผ่านและ header ที่ sensitive ถูกแทนด้วย _REPLACED_ ก่อนเขียนลง request log ผ่าน replaceVariableValue ที่ logging.js:38-49 (clone object ก่อนแล้ว map key ที่ตรง replace list)
ข้อเสียที่ควรรู้:
- ไม่ได้ log duration — ตอน debug "request นี้ช้า" ต้องเทียบ timestamp ของ request กับ response เอง
errorsถูก spread เข้าไปใน log object (...errors) ถ้า error object มี key ซ้ำกับ request field จะ override- ไม่ log tracing ID — ทำให้ correlate log ข้าม service (vtrc-api → cu-central-api) ไม่ได้
- IntrospectionQuery ถูกข้ามไปเฉพาะ response log แต่ request log ก็ยังเขียน (เงื่อนไข skip อยู่ใน
buildRequestContextObjectที่คืน{}และถูกisEmptyกรอง)
Error handling — responErrors
GraphQL error ทั้งหมดวิ่งผ่าน lib/responErrors.js#graphqlFormatError (228 บรรทัด, ผูกที่ index.js:61-64) ไฟล์นี้เป็นศูนย์กลางของ error surface ทั้งหมดของระบบ
Error code catalog
vtrc-api/api/src/lib/responErrors.js:31
const getResposeMessage = (name) => {
let codes = {
// API
NO_API_KEY: 'API key is incorrect.',
// Auth
AUTHEN_FAILED: 'Username or Password is incorrect.',
DATA_NOT_MATCH: 'Information not match in database.',
REFRESH_TOKEN_FAILED: 'Token or Refresh token is invalid.',
TOKEN_AND_REFRESH_TOKEN_MISMATCH: 'Token and Refresh token mismatch.',
TOKEN_INVALID: 'Session ถูกขัดจังหวะ หรือไม่ถูกต้อง',
TOKEN_EXPIRED: 'Token is expired.',
ACCESS_KEY_INVALID: 'Access Key is invalid.',
USER_NOT_FOUND: 'ไม่พบข้อมูลผู้ใช้งาน',
// ... more codes ...
LEAVE_CANT_CANCEL: 'ไม่สามารถยกเลิกการลาได้',
STATUS_CANT_UPDATE: 'ไม่สามารถปิดการใช้งานได้'
}
codes = { ...centralizeApiErrorCodes(), ...thirdPartyApiErrorCodes(), ...resStudyLeaveError(), ...codes }
if (typeof codes[name] !== 'undefined') {
return `${codes[name]}`
} else {
return 'Something went wrong, please try again'
}
}codes ถูก merge จาก 4 แหล่ง — หลัก + centralizeApiErrorCodes() (47 codes, line 100-147) + thirdPartyApiErrorCodes() (Conicle 8 codes, line 149-161) + resStudyLeaveError() (study leave 17 codes, line 209-229) ถ้า code ไม่ตรงทั้ง 4 แหล่ง → fall back 'Something went wrong, please try again'
จุดที่ควรรู้:
- typo
responError/getResposeMessage(missing 's' ใน response) ลามไปทั้ง codebase — ห้ามแก้เพราะจะกระทบ call site หลายสิบจุด - error message ผสม 3 ภาษา (ไทย, อังกฤษ, ผสม) — ไม่มี i18n layer ทำให้ front-end ต้อง hard-code หรือ pass message ตรงๆ
- order ของ merge (
...codesอยู่ท้ายสุด) → codes ใน main dict override ชื่อเดียวกันใน centralize/third-party (เช่นPAYROLL_SLIP_NOT_EXISTS,PASSWORD_DOESNT_MATCHมีซ้ำ)
responError — throw helper
vtrc-api/api/src/lib/responErrors.js:3
export const responError = (code, args = {}, type, message = null, allowArgs = false) => {
if (type === 'AuthenticationError') {
throw new AuthenticationError(getResposeMessage(code), code, { invalidArgs: Object.keys(args) })
} else if (type === 'UserInputError') {
throw new UserInputError(getResposeMessage(code), code, { invalidArgs: Object.keys(args) })
} else {
// ApolloError path with optional remainingCount (login attempt tracking)
}
}branch ที่น่าสนใจคือ args.hasOwnProperty('remainingCount') (line 12) — ถ้ามี remainingCount จะคำนวณ count = 4 - args.remainingCount เพื่อแสดงจำนวนครั้งที่เหลือใน login attempt แต่ตัวเลข 4 ติดอยู่ใน code ทำให้ถ้าจะแก้ policy "login 5 ครั้งแล้วล็อก" ต้องแก้ทุกจุดที่อ้างถึง
Error masking ใน production
vtrc-api/api/src/lib/responErrors.js:168
export const graphqlFormatError = (err) => {
if (err.message.startsWith('Cannot return null for non-nullable field')) {
log.error(`[NON_NULLABLE_FIELD] ${JSON.stringify(err)}`)
return responError('INTERNAL_SERVER_ERROR', {}, '', 'Something went wrong, please try again')
} else if (err.extensions.code === 'GRAPHQL_VALIDATION_FAILED') {
log.error(`[GRAPHQL_VALIDATE_FAILED] ${JSON.stringify(err)}`)
return responError('ValidationError', {}, '', 'Validate failed. Cannot query.')
} else if (err.extensions.code === 'INTERNAL_SERVER_ERROR') {
const exception = err.extensions.exception || null
if (exception !== null) {
const exceptionName = exception.name
if (exceptionName === 'SequelizeDatabaseError') {
return responseQueryError(err, err.path || '')
} else {
log.error(log.logTextFormat('INTERNAL_SERVER_ERROR', err.path || '', 'ERROR', `INTERNAL_SERVER_ERROR ${err.name}`, { err }))
return responError('INTERNAL_SERVER_ERROR', {}, '', 'Something went wrong, please try again')
}
}
}
return err
}3 case ที่ mask ใน production:
Cannot return null for non-nullable field→ "Something went wrong" (บัง case ที่ schema ผิด)GRAPHQL_VALIDATION_FAILED→ "Validate failed. Cannot query."INTERNAL_SERVER_ERRORที่ไม่ใช่ Sequelize → mask ทั้งหมด (ยกเว้นSequelizeDatabaseErrorที่ส่งต่อไปresponseQueryError)
mask แบบนี้ทำให้ debug ฝั่ง client ยาก — เพราะ client เห็นแค่ generic message ไม่รู้ว่า root cause คืออะไร
formatError ที่ index.js — console.log แบบ raw
vtrc-api/api/src/index.js:61
formatError: (err) => {
console.log("err ->", err);
return IS_PRODUCTION ? graphqlFormatError(err) : err
},ทุก error จะถูก console.log ก่อน format — สำหรับ debug local ดี แต่ใน production คือ log ที่ไม่ structured ปะปนกับ stdout ของ container ทำให้ grep ยาก
Caching — Redis ที่ตายซะก่อน
Redis มีอยู่ใน codebase แต่ แทบไม่ได้ใช้ใน production paths
lib/redis.js (93 บรรทัด) wrapper รอบ redis client (set/get/del)
├── lib/controllers/redis.controller.js setCacheData / getCacheData (cache:{prefix}:{uid})
├── resolvers/post.js cache caller: commented out
├── resolvers/course.js cache caller: commented out
├── resolvers/hrmi.js cache import: commented out
├── routes.js cache caller: commented out
└── lib/controllers/file/file.js cache caller: commented outทุก caller ถูก comment ไว้ เหลือแค่ wrapper + controller ที่ไม่มีใครเรียก การ revive Redis ต้อง uncomment ทั้ง 5 จุดและทดสอบ cache invalidation ใหม่ทั้งหมด
Connection — redisConnection()
vtrc-api/api/src/lib/redis.js:12
export const redisConnection = () => {
client.on('connect', () => {
console.log('[REDIS] ✅ Redis connection has been established successfully.')
})
client.on('error', (error) => {
console.error('[REDIS] ❌ Unable to connect to redis database:', error)
})
}ถูกเรียกที่ index.js:100 แต่ connection ไม่มี retry strategy ที่ custom — ถ้า Redis down ตอน boot จะ log error แล้วรันต่อ (silent degradation) แต่ไม่มี alert ไม่มี metric ทำให้ production อาจรันโดยไม่มี cache โดยไม่มีใครรู้
Wrapper API มี 3 ตัว — redisSet(key, value, expSeconds) (line 28), redisGet(key) (line 56), redisDelete(key) (line 75) ทั้งหมดใช้ Promise + callback pattern ของ redis package (legacy v3 API ไม่ใช่ v4 native promise) ทุก error ถูก catch และเขียน log ผ่าน log.error ก่อน reject
Bug ที่ไม่ trigger เพราะไม่มี caller
vtrc-api/api/src/lib/controllers/redis.controller.js:15
export const getCacheData = async (cacheKeyName, uid) => {
tr
const cacheId = buildDataCacheId(cacheKeyName, uid)
const data = await redisGet(cacheId)
return data
}tr บนบรรทัด 16 คือ ReferenceError: tr is not defined — ถ้า function นี้ถูกเรียกจะ throw ทันที แต่ทุก caller ถูก comment หมดแล้ว เลยไม่มีใครเจอ (ยืนยันยังเป็นแบบนี้ในปี 2026)
สำคัญ — ถ้าจะ revive Redis caching ต้องแก้ tr นี้ก่อน มิฉะนั้น cache read แรกที่เรียกจะ crash เป็น ReferenceError และ caller ทั้งหมดต้อง handle error นี้
Cache key pattern
vtrc-api/api/src/lib/controllers/redis.controller.js:2
export const buildDataCacheId = (prefix, uid) => {
return `cache:${prefix}:${uid}`
}Pattern cache:{prefix}:{uid} TTL 36000s (10 ชม.) — ไม่มี version tag ทำให้ invalidate ทั้งก้อนยาก ต้อง flush DB หรือใส่ prefix ใหม่ ไม่มี cache stampede protection (no lock, no single-flight) ถ้า key หายจาก cache request ทั้งหมดที่มาพร้อมกันจะยิง DB พร้อมกัน
File utilities — lib/file.js
FileClass (131 บรรทัด) คือ utility class เดียวที่จัดการ file I/O ทั้งระบบ — upload, move, delete, save path, mime
vtrc-api/api/src/lib/file.js:8
writeFile (file, content, option = {}, appendMode = false) {
this.mkdirSync(file, true)
if (appendMode === true) {
fs.appendFileSync(file, content, option)
} else {
fs.writeFileSync(file, content, option)
}
if (fs.existsSync(file)) {
return true
} else {
return false
}
}ทุก method synchronous (writeFileSync, readFileSync, appendFileSync, unlinkSync) — เหมาะกับไฟล์เล็ก แต่ถ้าใช้กับ PDF ที่ ~5MB บน request พร้อมกัน → event loop block อย่างชัดเจน
moveFile ที่ file.js:43 ใช้ pattern อ่านเขียนลบแทน fs.renameSync เพราะข้อจำกัด cross-device link (อ้างอิง SO 37153666 ใน comment) — ทำให้ move ไฟล์ใหญ่ใช้เวลา 2x ของขนาดไฟล์ และใช้ memory = ขนาดไฟล์ต่อ request
จุดที่ใช้บ่อย:
writeFile—wlogใช้เขียน log ทุกครั้ง (append mode)getLogPath()— สร้าง pathlogs/{yyyy}/{mm}/{dd}ตามวัน (file.js:104)moveFile— ใช้ตอน multer upload เสร็จ (REST routes)deleteFileOnStorage— ใช้ตอน cleanup PDF หลัง generate (ดู 3.1.7 REST routes)
Push notification — firebase/firebasePushNotification.js
ไฟล์นี้ (109 บรรทัด) เป็นทางเดียวที่ระบบส่ง push ไปที่ mobile app
vtrc-api/api/src/lib/firebase/firebasePushNotification.js:14
admin.initializeApp({
credential: admin.credential.cert(serviceAccount),
databaseURL: databaseURL
})Service account key ถูก require ที่ line 11 จากไฟล์ lib/firebase/vtrc-290007-firebase-adminsdk-ckbdn-0d183f0406.json — เป็น GCP credential เช่นเดียวกับ vtrc-gcp-sa-key.json ที่ root repo (ห้าม log ห้าม paste)
Hard-coded device token
vtrc-api/api/src/lib/firebase/firebasePushNotification.js:39
export const pushNotification = async (deviceToken, title, body, linkId, warpNoti, notificationId = null) => {
try {
let currentDeviceToken = ["cz2pPurpz0UonugQHclgHA:APA91bEL9JPoOPeTvXb5MjeOYV_yTJ1eyD0jF3j71dV92nJsc2L_i-Uc7hb8zsSb1szL2QrzTNXrcsW6tQeGgKhgSZTLGRRBRWqfQSrns9P3gxYpyndlzp1KAgh0UQhvrznNcHsFgK36"]
// let currentDeviceToken = ["f7DBpvVfRTGAQk42Pb3eqB:APA91b..."]
// if (NODE_ENV === "production") {
console.log("deviceToken ->", deviceToken);
currentDeviceToken = deviceToken
// }
console.log("currentDeviceToken >> ", currentDeviceToken);จุดที่น่ากังวล 3 ข้อ:
- บรรทัด 41 — FCM registration token ตัวจริงติดอยู่ใน code (token ของ device ทดสอบของ dev คนใดคนหนึ่ง) ถ้า commit นี้รั่วออก public repo จะส่ง push ไปที่ device นั้นได้จนกว่าจะ revoke ที่ Firebase console
- บรรทัด 43-46 —
NODE_ENV === "production"block ถูก comment ออก ทำให้ hard-coded token ถูก override ด้วยdeviceTokenจริงเสมอ (นี่คือสิ่งที่ "ช่วยชีวิต" — ถ้า uncomment จะส่ง push ไปแค่ device ของ dev คนเดียว) - บรรทัด 44, 47, 80 —
console.logdevice token / setData ในทุกการส่ง → PII leak ใน stdout ของ container ทำให้ log aggregator ที่เก็บ stdout จะมี token ทั้งหมด
Fire-and-forget
vtrc-api/api/src/lib/firebase/firebasePushNotification.js:81
admin.messaging().sendMulticast(setData)
.then((response) => {
if (response.failureCount > 0) {
// ...
create(setDataFail)
log.notificationLogError(...)
return false
}
log.notificationLog(...)
return true
}).catch(error => console.log(" notificationLog error ->", error))
return true
} catch (err) {
log.error(...)
responError('NOTIFICATION_CAN_NOT_SEND')
}
}function return true ที่บรรทัด 105 ทันที — ไม่รอ sendMulticast ทำงานเสร็จ ทำให้ caller ไม่รู้ว่าส่งสำเร็จจริงไหม ถ้า caller คาดหวัง await ก่อน response → race condition (caller คืน 200 แต่ push ยังไม่ถึง device)
getAccessToken ที่ line 19 ใช้ legacy Google APIs JWT แทนที่จะใช้ admin.messaging() ที่มี built-in auth — เป็น code ที่เหลือจากการ copy-paste ตัวอย่างเก่า ปัจจุบัน getAccessToken ไม่ถูกเรียกจากที่ไหนเลย (vestigial)
Env + config — config.js
Config ทั้งหมดอยู่ใน api/src/config.js (522 บรรทัด) — env var, device keys, role mapping, business constants เป็นไฟล์เดียวที่ export ค่า global ทั้งระบบ
Default secrets ที่ติดมา
vtrc-api/api/src/config.js:12
JWT_SECRET = 'secret',JWT_SECRET default เป็น 'secret' — ถ้า env var ไม่ถูกตั้งใน deployment จะใช้ค่า default นี้ ทำให้ JWT sign/verify ใช้ secret ที่ทราบกัน (ดูรายละเอียดใน 3.1.3 Auth + Session) ทำให้แอป "รันได้" โดยไม่ตั้ง env แต่ JWT ปลอมแปลงได้หมด
Hard-coded business UUID
vtrc-api/api/src/config.js:153
KEY_PLASMA = '47837e2d-7788-4a1a-8e24-a90abf6742f4',
KEY_HR = ['305A814A-CEAC-449B-BF92-CCAEC3475B1A', '5A764C0F-6C0D-46C5-83E0-9B94C755BFE2'],
CONICLE_PAGE = ['MyLibrary', 'LearningRequest', 'Home'],business key ติดอยู่ใน code:
KEY_PLASMA— UUID ของกองการเวชศาสตร์ธนาคารเลือด ใช้ที่clouse.js(line 188) เพื่อ filter สิทธิ์ดู content ของธนาคารเลือดโดยเฉพาะKEY_HR— UUID ของฝ่าย HR ใช้ gate เมนู/สิทธิ์บางอย่างใน backofficeCONICLE_PAGE— page name whitelist ของ Conicle ที่ vtrc-api รู้จัก
การเปลี่ยนแปลง org structure ต้องมาแก้ code + deploy ใหม่ ไม่สามารถแก้ที่ DB หรือ admin UI ได้
Config shape — destructure จาก process.env
vtrc-api/api/src/config.js:1
require('dotenv').config()
export const {
APP_NO = 0,
APP_PORT = 8081,
NODE_ENV = 'development',
API_SERVICE_PATH = '/api/graphql',
API_PREFIX_PATH = '',
TIME_ZONE = 'Asia/Bangkok',
TIME_ZONE_TEXT = 'Asia/Bangkok',
JWT_SECRET = 'secret',
DB_SCHEMA = '',
DB_USERNAME = '',จุดที่ควรรู้:
dotenvโหลด.envถ้ามี — แต่ใน production ใช้ env var โดยตรง ผ่าน docker / k8s- ทุกค่ามี default — ทำให้แอปรันได้แม้ไม่ตั้ง env, แต่ default หลายตัวไม่ปลอดภัย (
JWT_SECRET,API_DEVICE_KEYที่มี prod key จริงใน code) - ไม่มี validation — ถ้าใส่
APP_PORT=abcจะ crash ตอนapp.listenโดยไม่มี error ที่อ่านง่าย API_DEVICE_KEY(line 41-148) เป็น dict ที่มี key prod/uat จริงใน source code — เป็น secret leak เช่นกัน
CORS
CORS config ถูกประกาศที่ index.js:76-85 แต่ ไม่ได้ใช้
vtrc-api/api/src/index.js:76
const corss = {
origin: ['https://vtrc.redcross.or.th',
'https://vtrcbackoffice.redcross.or.th',
'https://uat-vtrc.redcross.or.th',
'https://uat-vtrcbackoffice.redcross.or.th',
'http://localhost:3000'],
// origin: "*",
methods: ['GET', 'POST'],
allowedHeaders: ['Content-Type', 'authorization', 'apiKey'],
}
server.applyMiddleware({ app, path: servicePath, bodyParserConfig: { limit: '50mb' }, corss })
app.disable('x-powered-by')
app.use(cors())
app.use(bodyParser.json());สองปัญหาที่ทำให้ allowlist ไม่ทำงาน:
- typo
corss— ถูก pass เข้าapplyMiddlewareแต่ key ที่ถูกต้องของ apollo-server-express คือcorsไม่ใช่corss→ Apollo ไม่รับ config นี้ (silent ignore) app.use(cors())fallback — บรรทัด 91 ใช้ default config ของcorspackage = origin*ทุก origin → CORS เปิดหมด
ผลคือ allowlist 5 origin ที่ตั้งใจไว้ไม่ทำงานจริง ต้องแก้ key corss → cors และลบ app.use(cors()) ออก (หรือ merge config เข้าไป) ตอนนี้ server ตอบ Access-Control-Allow-Origin: * ทุก request — ทำให้ cross-site request จาก origin ใดก็ได้ผ่าน
Global process handlers — ขาด
สิ่งที่ vtrc-api ไม่มี แต่ production service ควรมี:
process.on('unhandledRejection', ...)— promise reject ที่ไม่มี catch จะ log ที่ stdout แล้วหายไป ไม่มี alert ไม่มี trackingprocess.on('uncaughtException', ...)— sync exception นอก try/catch จะ crash process ทันที (container restart)- Graceful shutdown — ไม่มี
SIGTERMhandler ปิด HTTP server + DB connection + cron schedule อย่างสะอาด
ผลคือตอน k8s rolling deploy บาง request ถูกตัดกลางคันโดยไม่มี retry ฝั่ง server ทำให้ user เห็น 5xx ชั่วคราวตอน deploy แม้แอปจะ "available" อยู่
Health check — ขาด
ไม่มี /health หรือ /healthz endpoint
- k8s probe ต้องใช้ TCP check ที่ port แทน (ไม่รู้ว่า app ready จริงไหม รู้แค่ port เปิด)
- ไม่มี route ที่บอกว่า DB / Redis / cu-central-api reachable ไหม ตอนนี้
- debug "production แอปขึ้นไหม" ต้องลองยิง GraphQL เอง
ผลคือ incident detection ช้า — k8s ไม่ restart pod ที่ "run ได้แต่ serve ไม่ได้" จนกว่าจะ crash ทำให้ downtime ที่ "app แฮงค์เงียบ" อยู่นานกว่าจะถูกสังเกตเห็น
Observability ที่ขาด
| สิ่งที่มี | สิ่งที่ไม่มี |
|---|---|
| file logger (wlog) | structured logging (JSON + schema) |
| request log + response log | tracing ID / correlation ID |
console.log console output | log aggregation (Loki / ELK / Datadog) |
| ไม่มี metric | Prometheus / metrics endpoint |
| ไม่มี distributed tracing | OpenTelemetry / Jaeger |
| ไม่มี error tracking | Sentry / Bugsnag |
| ไม่มี APM | New Relic / AppDynamics |
ปัจจุบัน debug production issue ต้อง tail log file ใน container แล้ว grep ด้วยมือ — ทำให้แต่ละ incident ใช้เวลานานกว่าจะเจอ root cause และไม่สามารถ correlate ข้าม service (vtrc-api ↔ cu-central-api ↔ Conicle) ได้
Utility code ที่ใช้บ่อย
lib/clouse.js — GraphQL filter builder (502 บรรทัด)
clouseFrontEnd และ clouseBackOffice สร้าง Sequelize where clause จาก GraphQL args ให้ resolvers หลายตัว (post, doc, course, contactUs, ...)
vtrc-api/api/src/lib/clouse.js:21
if (args.limit) {
let limit
if (limit > 50) {
limit = 50
} else {
limit = args.limit
}
moreConditions = {
...moreConditions,
limit: limit
}Bug — limit cap ไม่ทำงาน
- บรรทัด 22 ประกาศ
let limitไม่มีค่า →undefined - บรรทัด 23
if (limit > 50)→undefined > 50→falseเสมอ - บรรทัด 26
limit = args.limit— ใช้ค่าที่ส่งมาเลย
ผลคือ client ขอ limit: 10000 ได้ ทำให้ query หนักเกินความจำเป็น (potential DoS) bug เดียวกันปรากฏซ้ำใน clouseBackOffice ที่ line 297-303 ด้วย
การแก้ — เปลี่ยน let limit เป็น let limit = args.limit
lib/clouse.js — KEY_PLASMA filter logic
vtrc-api/api/src/lib/clouse.js:188
let plasma = arrayDivision.filter(division => division === KEY_PLASMA)
// ...
if (plasma.length > 0) {
if (id === 'contactUsCategoryId' || id === 'docGroupId') {
conditions = { ...conditions }
} else {
conditions = { ...conditions, status: "PUBLIC" }
}
}ถ้า user เป็นสมาชิกกองการเวชศาสตร์ธนาคารเลือด (มี KEY_PLASMA ใน division list) จะเห็นได้แค่ content PUBLIC เท่านั้น (ยกเว้น contact category และ doc group) — เป็น business rule ที่ติดอยู่ใน code ทำให้ debug "ทำไม user นี้เห็น content น้อยกว่า" ต้องมาไล่ logic นี้
lib/encrypt/index.js — hash helper (4 บรรทัด)
vtrc-api/api/src/lib/encrypt/index.js:1
import * as crypto from "crypto"
export const EncryptAll = async (input, type) => {
return crypto.createHash(type).update(input).digest('hex')
}utility เดียว — wrap Node crypto สำหรับ hash แบบทั่วไป (sha256, md5, ...) ใช้น้อยมากเพราะ auth มี flow เฉพาะ bcrypt แยกต่างหาก (ดู 3.1.3 Auth + Session)
เคล็ดลับตอนทำงานจริง
ถ้าอยากรู้ว่า request ช้า — ทำได้ยาก
ไม่มี duration log ใน request/response log ต้องเพิ่มเอง หรือใช้ performance.now() ใน resolver ที่สงสัย ทางเลือก — ติด OpenTelemetry tracing ที่ Apollo plugin แล้วส่งไป Jaeger
ถ้า error ที่ client เห็นไม่ตรงกับที่ server log
production mask error ที่ graphqlFormatError — ใน wlog.js error_*.log จะมี raw กว่า ให้ดูที่ logs/{yyyy}/{mm}/{dd}/error_{yyyy}-{mm}-{dd}.log ใน container ที่มี [NON_NULLABLE_FIELD], [GRAPHQL_VALIDATE_FAILED], หรือ INTERNAL_SERVER_ERROR prefix
ก่อน uncomment Redis caller
แก้ tr ที่ redis.controller.js:16 ก่อน ไม่งั้น first call crash เป็น ReferenceError และต้องเช็คด้วยว่า Redis ที่ deployment จริงมี host/port/password ถูกต้องใน env var REDIS_HOST, REDIS_PORT, REDIS_PASSWORD
ก่อนแก้ error code string
error message ทั้งหมดกระจายอยู่ใน 4 function (getResposeMessage, centralizeApiErrorCodes, thirdPartyApiErrorCodes, resStudyLeaveError) — grep ทั้ง 4 ก่อนแก้ ไม่งั้น override กัน (เพราะ main codes อยู่ท้าย merge จะชนะ)
อย่าเพิ่ม console.log ใน production path
console.log ทุกตัวใน vtrc-api ไปที่ stdout ของ container — ปนกับ request log และ error log ทำให้ grep ยาก ใช้ log.info() / log.error() แทนเพื่อให้เขียนลงไฟล์แยก type
ถ้า push notification ไม่ถึง
เช็คก่อนว่า caller ของ pushNotification ใช้ await จริงไหม — function นี้ return true ก่อนที่ FCM จะตอบ ทำให้ caller ไม่รู้ผลจริง ดู notificationLogError_{date}.log สำหรับ failure และ notificationFail table ใน DB สำหรับ record ที่บันทึกไว้
ถ้าจะแก้ clouse.js
ระวัง bug limit ที่ซ้ำกัน 2 ที่ (clouseFrontEnd line 22 และ clouseBackOffice line 298) และ KEY_PLASMA filter ที่ gate การเห็น content ตาม org — แก้แล้วต้องทดสอบกับ user จริงหลาย role