Skip to content

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 loglib/wlog.jsเขียน log ลงไฟล์ใน logs/{yyyy}/{mm}/{dd}/ แยกตาม type
Request loglib/logging.jsApollo plugin ที่ log ทุก GraphQL request + response

App log — write-only file logger

wlog ไม่ได้ใช้ winston, pino หรือ bunyan เขียนเองจาก fs.appendFileSync ทั้งหมด

9:20:vtrc-api/api/src/lib/wlog.js
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/Ofs.appendFileSync block 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

30:34:vtrc-api/api/src/lib/wlog.js
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

11:36:vtrc-api/api/src/lib/logging.js
  // [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

6:8:vtrc-api/api/src/lib/logging.js
    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

31:98:vtrc-api/api/src/lib/responErrors.js
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

168:191:vtrc-api/api/src/lib/responErrors.js
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

61:64:vtrc-api/api/src/index.js
    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 out

Connection — redisConnection()

12:20:vtrc-api/api/src/lib/redis.js
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

15:20:vtrc-api/api/src/lib/controllers/redis.controller.js
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

2:4:vtrc-api/api/src/lib/controllers/redis.controller.js
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

7:23:vtrc-api/api/src/lib/file.js
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

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

  • writeFilewlog ใช้เขียน log
  • getLogPath() — สร้าง path logs/{yyyy}/{mm}/{dd} ตามวัน
  • moveFile — ใช้ตอน multer upload เสร็จ
  • deleteFileOnStorage — ใช้ตอน cleanup PDF (ดู บท 3.7)

Push notification — firebase/firebasePushNotification.js

14:17:vtrc-api/api/src/lib/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

39:47:vtrc-api/api/src/lib/firebase/firebasePushNotification.js
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.log device token / setData ในทุกการส่ง → PII leak ใน stdout

Fire-and-forget

81:105:vtrc-api/api/src/lib/firebase/firebasePushNotification.js
        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 ที่ติดมา

10:13:vtrc-api/api/src/config.js
  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

153:155:vtrc-api/api/src/config.js
  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

1:14:vtrc-api/api/src/config.js
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 แต่ ไม่ได้ใช้

76:91:vtrc-api/api/src/index.js
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 corsscors และลบ 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 — ไม่มี SIGTERM handler ปิด 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 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


Utility code ที่ใช้บ่อย

lib/clouse.js — GraphQL filter builder

clouseFrontEnd และ clouseBackOffice สร้าง Sequelize where clause จาก GraphQL args ให้ resolvers หลายตัว (post, doc, course, contactUs, ...)

21:36:vtrc-api/api/src/lib/clouse.js
  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)

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

lib/encrypt/index.js — hash helper

1:4:vtrc-api/api/src/lib/encrypt/index.js
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() แทนเพื่อให้เขียนลงไฟล์


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

ไป บท 3.10 Scorecard + known bugs