3.9 · Cross-cutting concerns
บทนี้รวมสิ่งที่ตัดผ่านหลาย layer — logging, error formatting, caching, file utilities, CORS, env config, notification push และ utility ที่ใช้ทั่วระบบ แต่ละข้อมี trade-off ที่ควรรู้ตอน maintain
Logging — wlog + GraphQLLogging
vtrc-api มี logging แยกเป็น 2 ชั้น
| ชั้น | ไฟล์ | หน้าที่ |
|---|---|---|
| App log | lib/wlog.js | เขียน log ลงไฟล์ใน logs/{yyyy}/{mm}/{dd}/ แยกตาม type |
| Request log | lib/logging.js | Apollo plugin ที่ log ทุก GraphQL request + response |
App log — write-only file logger
wlog ไม่ได้ใช้ winston, pino หรือ bunyan เขียนเองจาก fs.appendFileSync ทั้งหมด
const writeLog = (text, type = '') => {
const date = DateClass.makeDate()
type = (type === '') ? '' : type + '_'
const logPath = path.resolve(FileMethods.getLogPath(), '../') // resolve back from day to month
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)
}ระดับ log ทั้งหมด — แต่ละระดับเขียนไฟล์คนละชื่อ
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:
- synchronous I/O —
fs.appendFileSyncblock event loop ทุกครั้งที่เรียก ถ้ามี request พร้อมกัน 100 ตัวที่ log ทุกตัว → response time พุ่ง - ไม่มี rotation — ไฟล์เติบโตไม่จำกัด ต้องจัดการ logrotate นอกแอป (ปกติที่ OS level)
- ไม่มี log level runtime config — เปิดปิดต้องแก้ code และ deploy ใหม่
- เขียนลง disk เท่านั้น — ไม่ ship ไปที่ stdout / journald / Loki / Datadog
Log format — JSON string concat
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 })
Request log — Apollo plugin
GraphQLLogging เป็น Apollo plugin ที่ register ที่ index.js:71-73
// [MAIN] Fires whenever a GraphQL request is received from a client.
requestDidStart(requestContext) {
const requestLogObj = this.buildRequestContextObject(requestContext)
// Write Request log
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'
// write log
const responseLogObj = {
...requestLogObj,
...errors
}
// Write Response log
log.writeLog(`[${logLevel}] ${JSON.stringify(responseLogObj)}`, 'response')
}
}
}
}ข้อดี — มี PII redaction
this.replaceValue = '_REPLACED_'
this.replaceVariables = ['oldPassword', 'newPassword', 'confirmNewPassword', 'password', 'confirmPassword', 'pwd', 'passwd']
this.replaceHeaders = ['authorization', 'apiKey']รหัสผ่านและ header ที่ sensitive ถูกแทนด้วย _REPLACED_ ก่อนเขียนลง request log
ข้อเสีย:
- ไม่ได้ 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) ไม่ได้
Error handling — responErrors
GraphQL error ทั้งหมดวิ่งผ่าน lib/responErrors.js#graphqlFormatError (ผูกที่ index.js:61-64)
Error code catalog
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 ถูกขัดจังหวะ หรือไม่ถูกต้อง', // login again
TOKEN_EXPIRED: 'Token is expired.', // refresh token only
ACCESS_KEY_INVALID: 'Access Key is invalid.',
USER_NOT_FOUND: 'ไม่พบข้อมูลผู้ใช้งาน',
// ... more codes ...
LEAVE_CANT_CANCEL: 'ไม่สามารถยกเลิกการลาได้',
STATUS_CANT_UPDATE: 'ไม่สามารถปิดการใช้งานได้'
}
codes = { ...centralizeApiErrorCodes(), ...thirdPartyApiErrorCodes(), ...resStudyLeaveError(), ...codes }
// Have response code
if (typeof codes[name] !== 'undefined') {
return `${codes[name]}`
} else {
return 'Something went wrong, please try again'
}
}codes ถูก merge จาก 4 แหล่ง — หลัก + centralize + third-party (Conicle) + study leave
จุดที่ควรรู้:
- typo
responError/getResposeMessageลามไปทั้ง codebase ไม่แนะนำให้แก้เพราะจะกระทบมาก - error message ผสม 3 ภาษา (ไทย, อังกฤษ, ผสม) — ไม่มี i18n layer
- ถ้า code ไม่ตรงทั้ง 4 แหล่ง → fall back
'Something went wrong, please try again'(สั้นแต่ไม่มี context ให้ debug)
Error masking ใน production
export const graphqlFormatError = (err) => {
if (err.message.startsWith('Cannot return null for non-nullable field')) {
// ERR: NON_NULLABLE OF 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') {
// ERR: 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') {
// ERR: SEQUELIZE ERROR e.g. DATABASE QUERY ERROR
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"GRAPHQL_VALIDATION_FAILED→ "Validate failed. Cannot query."INTERNAL_SERVER_ERRORที่ไม่ใช่ Sequelize → mask ทั้งหมด
แต่ mask แบบนี้ทำให้ debug ฝั่ง client ยาก — เพราะ client เห็นแค่ generic message ไม่รู้ว่า root cause คืออะไร
formatError ที่ index.js — console.log แบบ raw
formatError: (err) => {
console.log("err ->", err);
return IS_PRODUCTION ? graphqlFormatError(err) : err
},ทุก error จะถูก console.log ก่อน format — สำหรับ debug local ดี แต่ใน production คือ log ที่ไม่ structured ปะปนกับ stdout
Caching — Redis ที่ตายซะก่อน
Redis มีอยู่ใน codebase แต่ แทบไม่ได้ใช้ใน production paths
lib/redis.js 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 outConnection — redisConnection()
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
Bug ที่ไม่ trigger เพราะไม่มี caller
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 หมดแล้ว เลยไม่มีใครเจอ
สำคัญ — ถ้าจะ revive Redis caching ต้องแก้ tr นี้ก่อน มิฉะนั้น cache read แรกที่เรียกจะ crash
Cache key pattern
export const buildDataCacheId = (prefix, uid) => {
return `cache:${prefix}:${uid}`
}Pattern cache:{prefix}:{uid} TTL 36000s (10 ชม.) — ไม่มี version tag ทำให้ invalidate ทั้งก้อนยาก ต้อง flush DB หรือใส่ prefix ใหม่
File utilities — lib/file.js
FileClass คือ utility class เดียวที่จัดการ file I/O ทั้งระบบ — upload, move, delete, save path, mime
export default class FileClass {
writeFile (file, content, option = {}, appendMode = false) {
// Mkdir if not exists.
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) — เหมาะกับไฟล์เล็ก แต่ถ้าใช้กับ PDF ที่ ~5MB บน request พร้อมกัน → event loop block
จุดที่ใช้บ่อย:
writeFile—wlogใช้เขียน loggetLogPath()— สร้าง pathlogs/{yyyy}/{mm}/{dd}ตามวันmoveFile— ใช้ตอน multer upload เสร็จdeleteFileOnStorage— ใช้ตอน cleanup PDF (ดู บท 3.7)
Push notification — firebase/firebasePushNotification.js
admin.initializeApp({
credential: admin.credential.cert(serviceAccount),
databaseURL: databaseURL
})Service account key ถูก commit อยู่ใน repo ที่ lib/firebase/vtrc-290007-firebase-adminsdk-ckbdn-0d183f0406.json — เป็น GCP credential เช่นเดียวกับ vtrc-gcp-sa-key.json (ห้าม log ห้าม paste)
Hard-coded device token
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);จุดที่น่ากังวล:
- บรรทัด 41 — FCM registration token ตัวจริงติดอยู่ใน code (token ของ device ทดสอบของ dev คนใดคนหนึ่ง) ถ้า commit นี้รั่วออก public repo จะส่ง push ไปที่ device นั้นได้
- บรรทัด 43-46 —
NODE_ENV === "production"block ถูก comment ออก ทำให้ hard-coded token ถูก override ด้วยdeviceTokenจริงเสมอ (ที่ช่วยชีวิต) - บรรทัด 44, 47, 80 —
console.logdevice token / setData ในทุกการส่ง → PII leak ใน stdout
Fire-and-forget
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
Env + config — config.js
Config ทั้งหมดอยู่ใน api/src/config.js (~520 บรรทัด) — env var, device keys, role mapping, business constants
Default secrets ที่ติดมา
TIME_ZONE_TEXT = 'Asia/Bangkok',
// CORE SYSTEM
JWT_SECRET = 'secret',
DB_SCHEMA = '',
DB_USERNAME = '',JWT_SECRET default เป็น 'secret' — ถ้า env var ไม่ถูกตั้งใน deployment จะใช้ค่า default นี้ ทำให้ JWT sign/verify ใช้ secret ที่ทราบกัน (ดูรายละเอียดใน บท 3.3)
Hard-coded business UUID
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 เพื่อ filter สิทธิ์ดู content การเปลี่ยนแปลง org structure ต้องมาแก้ code + deploy
Config shape — destructure จาก process.env
require('dotenv').config()
export const {
APP_NO = 0,
// If deploy to host, you can see port at docker-compose file.
APP_PORT = 8081,
NODE_ENV = 'development',
API_SERVICE_PATH = '/api/graphql',
API_PREFIX_PATH = '',
TIME_ZONE = 'Asia/Bangkok',
TIME_ZONE_TEXT = 'Asia/Bangkok',
// CORE SYSTEM
JWT_SECRET = 'secret',
DB_SCHEMA = '',
DB_USERNAME = '',จุดที่ควรรู้:
dotenvโหลด.envถ้ามี — แต่ใน production ใช้ env var โดยตรง ผ่าน docker- ทุกค่ามี default — ทำให้แอปรันได้แม้ไม่ตั้ง env, แต่ default หลายตัวไม่ปลอดภัย (JWT_SECRET, API_DEVICE_KEY)
- ไม่มี validation — ถ้าใส่
APP_PORT=abcจะ crash ตอนapp.listen
CORS
CORS config ถูกประกาศที่ index.js:76-85 แต่ ไม่ได้ใช้
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());corssถูก pass เข้าapplyMiddlewareแต่ key ที่ถูกต้องของ apollo-server-express คือcorsไม่ใช่corss→ Apollo ไม่รับ config นี้- ต่อมา
app.use(cors())ที่บรรทัด 91 ใช้ default config ของcors= origin*ทุก origin → CORS เปิดหมด
ผลคือ allowlist 5 origin ที่ตั้งใจไว้ไม่ทำงานจริง ต้องแก้ key corss → cors และลบ app.use(cors()) ออก (หรือ merge config เข้าไป)
Global process handlers — ขาด
สิ่งที่ vtrc-api ไม่มี แต่ production service ควรมี:
process.on('unhandledRejection', ...)— promise reject ที่ไม่มี catch จะ log ที่ stdout แล้วหายไปprocess.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
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
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
Utility code ที่ใช้บ่อย
lib/clouse.js — GraphQL filter builder
clouseFrontEnd และ clouseBackOffice สร้าง Sequelize where clause จาก GraphQL args ให้ resolvers หลายตัว (post, doc, course, contactUs, ...)
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)
การแก้ — เปลี่ยน let limit เป็น let limit = args.limit
lib/encrypt/index.js — hash helper
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 เฉพาะ
เคล็ดลับตอนทำงานจริง
ถ้าอยากรู้ว่า request ช้า — ทำได้ยาก
ไม่มี duration log ใน request/response log ต้องเพิ่มเอง หรือใช้ performance.now() ใน resolver ที่สงสัย
ถ้า error ที่ client เห็นไม่ตรับกับที่ server log
production mask error ที่ graphqlFormatError — ใน wlog.js error_*.log จะมี raw กว่า ให้ดูที่ error_{yyyy}-{mm}-{dd}.log ใน container
ก่อน uncomment Redis caller
แก้ tr ที่ redis.controller.js:16 ก่อน ไม่งั้น first call crash
ก่อนแก้ error code string
error message ทั้งหมดกระจายอยู่ใน 4 function (getResposeMessage, centralizeApiErrorCodes, thirdPartyApiErrorCodes, resStudyLeaveError) — grep ทั้ง 4 ก่อนแก้ ไม่งั้น override กัน
อย่าเพิ่ม console.log ใน production path
console.log ทุกตัวใน vtrc-api ไปที่ stdout ของ container — ปนกับ request log และ error log ทำให้ grep ยาก ใช้ log.info() แทนเพื่อให้เขียนลงไฟล์