Skip to content

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 loglib/wlog.js72เขียน log ลงไฟล์ใน logs/{yyyy}/{mm}/{dd}/ แยกตาม type
Request loglib/logging.js108Apollo 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 เป็นดังนี้

javascript
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/Ofs.appendFileSync block 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

javascript
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

javascript
plugins: [
    () => new GraphQLLogging()
]

vtrc-api/api/src/lib/logging.js:11

javascript
  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

javascript
    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

javascript
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

javascript
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

javascript
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

javascript
    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

javascript
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

javascript
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

javascript
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

javascript
  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

จุดที่ใช้บ่อย:

  • writeFilewlog ใช้เขียน log ทุกครั้ง (append mode)
  • getLogPath() — สร้าง path logs/{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

javascript
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

javascript
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-46NODE_ENV === "production" block ถูก comment ออก ทำให้ hard-coded token ถูก override ด้วย deviceToken จริงเสมอ (นี่คือสิ่งที่ "ช่วยชีวิต" — ถ้า uncomment จะส่ง push ไปแค่ device ของ dev คนเดียว)
  • บรรทัด 44, 47, 80console.log device token / setData ในทุกการส่ง → PII leak ใน stdout ของ container ทำให้ log aggregator ที่เก็บ stdout จะมี token ทั้งหมด

Fire-and-forget

vtrc-api/api/src/lib/firebase/firebasePushNotification.js:81

javascript
        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

javascript
  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

javascript
  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 เมนู/สิทธิ์บางอย่างใน backoffice
  • CONICLE_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

javascript
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

javascript
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 ของ cors package = origin * ทุก origin → CORS เปิดหมด

ผลคือ allowlist 5 origin ที่ตั้งใจไว้ไม่ทำงานจริง ต้องแก้ key corsscors และลบ 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 ไม่มี tracking
  • process.on('uncaughtException', ...) — sync exception นอก try/catch จะ crash process ทันที (container restart)
  • Graceful shutdown — ไม่มี SIGTERM handler ปิด 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 logtracing ID / correlation ID
console.log console outputlog aggregation (Loki / ELK / Datadog)
ไม่มี metricPrometheus / metrics endpoint
ไม่มี distributed tracingOpenTelemetry / Jaeger
ไม่มี error trackingSentry / Bugsnag
ไม่มี APMNew 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

javascript
  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 > 50false เสมอ
  • บรรทัด 26 limit = args.limit — ใช้ค่าที่ส่งมาเลย

ผลคือ client ขอ limit: 10000 ได้ ทำให้ query หนักเกินความจำเป็น (potential DoS) bug เดียวกันปรากฏซ้ำใน clouseBackOffice ที่ line 297-303 ด้วย

การแก้ — เปลี่ยน let limit เป็น let limit = args.limit

lib/clouse.jsKEY_PLASMA filter logic

vtrc-api/api/src/lib/clouse.js:188

javascript
    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

javascript
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


ขั้นตอนถัดไป

ไป 3.1.10 Scorecard + known bugs