Skip to content

2.7 · Cross-cutting concerns

บทนี้เล่าเรื่องที่ตัดผ่านทุก feature — logging, error handling, observability, config

เป็นเรื่องที่ไม่มีใครคิดถึงตอนเขียน feature แต่ตอน debug ตอน 3 ทุ่มจะอยากรู้ว่า "แกะรอยอย่างไร"


Logging — 2 ระบบคู่กัน

vtrc-api มี logger คู่กัน 2 ตัว เขียนลงคนละที่

1. wlog.js — เขียนลงไฟล์ flat text

ที่มา — vtrc-api/api/src/lib/wlog.js:1-73

logs/
  └── <yyyy>/
        └── <mm>/
            └── <dd>/
                ├── info_<date>.log
                ├── warning_<date>.log
                ├── error_<date>.log
                ├── query_<date>.log
                ├── slipError_<date>.log
                ├── verify_<date>.log
                └── ...

ทุก entry format เดียวกัน

<th-TH locale date> - {"CODE": "...", "SOURCE": "...", "TYPE": "...", "text": "...", ...}

helper functions 10 ตัว (wlog.js:36-72) — info, warning, error, query, notificationLog, notificationLogError, slipError, verify, forceLogs แต่ละตัวแค่เลือก prefix ของชื่อไฟล์ต่างกัน

2. logging.js — Apollo plugin เฉพาะ GraphQL

ที่มา — vtrc-api/api/src/lib/logging.js:1-108

เป็น Apollo plugin ที่ log ทุก GraphQL request + response — skip IntrospectionQuery

{
  query: "...",
  variables: {...},
  headers: {...},
  from: { appId, userId }
}

redact list (logging.js:5-9) — แทนที่ field เหล่านี้ด้วย _REPLACED_

  • variables: password, confirmPassword, oldPassword
  • headers: authorization, apiKey

ระวัง — redact ไม่ครบ identityCard, token, refreshToken, empCode ยังถูก log ใน cleartext


Log รวมกัน — อ่านยาก

เพราะทุก replica เขียน log ลง directory เดียวกัน (docker-compose.yml:59, 72, 84, 98 — mount ./api-logs เหมือนกัน)

api-logs/
  └── logs/2025/07/09/
      ├── info_09-07-2025.log   ← ทุก replica เขียนที่นี่
      └── error_09-07-2025.log  ← ไม่มี replica tag

จะรู้ว่า log entry มาจาก replica ไหน ต้อง grep เนื้อหา (ถ้ามี APP_NO ใน entry นั้น) — ไม่มี guaranteed ว่ามี


console.log กระจัดกระจาย

นอกจาก logger ทั้ง 2 ตัว ยังมี console.log ในโค้ดจำนวนมาก — เช่น index.js:55, 62, 99 (formatError log ทุก error ด้วย console.log)

ผลคือ stdout กับไฟล์ log มีเนื้อหาไม่ตรงกัน ตอน debug ต้องดูทั้งคู่


Frontend ปิด console ไว้หมด

อย่าแปลกใจที่ console.log ใน frontend ไม่พิมพ์อะไร — vtrc-web/src/containers/App/index.js:15 เขียนทับไว้

js
console.log = console.warn = console.error = () => {}

ถ้าต้องการ debug frontend ต้อง comment บรรทัดนี้ก่อน (แล้วอย่าลืม uncomment กลับ)


Error handling — responError เป็นศูนย์กลาง

vtrc-api/api/src/lib/responErrors.js:3-29 — function เดียวที่ throw ทุก error

js
function responError(code) {
  throw new ApolloError(i18nMessage(code), code)
}

code เป็น string ที่ match กับ message catalog (i18nMessage) — ตัวอย่าง

  • TOKEN_EXPIRED
  • TOKEN_INVALID
  • SESSION_EXPIRED
  • ACCESS_KEY_INVALID
  • INTERNAL_SERVER_ERROR

ระวัง — caller หลายที่เรียก responError(...) โดยไม่ return (session.js:39, 44, 62, 68) พึ่งพาว่ามัน throw ถ้าวันหนึ่งเปลี่ยนให้ไม่ throw → code path พังทันที


formatError ใน Apollo

vtrc-api/api/src/index.js:62-74 — format error ก่อนส่งกลับ frontend

js
formatError: (err) => {
  console.log(err)  // log ทุก error
  return err
}

ทุก error จะถูก console.log — ในกรณี debug ดี แต่ใน production คือ noise จำนวนมาก

debug: !IS_PRODUCTION ควบคุม stack trace — ถ้า NODE_ENV=production จะไม่ส่ง stack ออกไป


Observability — แทบไม่มี

VTRC ไม่มีระบบ monitoring ที่ครบครัน — ไม่มี Prometheus, ไม่มี Grafana, ไม่มี distributed tracing

สิ่งที่มี

  • console.log + wlog → text files
  • /ais-smsgw endpoint (routes.js:990-993) — stub ที่ echo OK ไม่ได้ทำอะไร
  • playground: true + introspection: true ใน Apollo — เปิดไว้ทุก env รวมถึง production

ไม่มี health check endpoint — ไม่มี /health หรือ /readiness ที่ Docker หรือ load balancer ใช้ตรวจสอบได้

ถ้าทำ modernization — สิ่งแรกที่ควรเพิ่มคือ

  1. /health + /readiness endpoint
  2. Prometheus metrics
  3. OpenTelemetry trace

Config — ทุกอย่างใน config.js

vtrc-api/api/src/config.js (522 บรรทัด) — เป็น single file ที่ dotenv.config() แล้ว destructure process.env

หมวดหมู่

  • runtime identity (APP_NO, APP_PORT, NODE_ENV, paths, timezone)
  • secrets (JWT_SECRET, DB creds, Redis creds)
  • centralize bridge (END_POINT_CENTRALIZE, END_POINT_CENTRALIZE_AUTH, API_CENTRALIZE_KEY)
  • inline device-key registry API_DEVICE_KEY (prod + uat arrays) — บรรทัด 41-149
  • access-role/menu constants — บรรทัด 150-370
  • HR status lookup tables (relationHrmi, statusHrmi, statusKcash) — บรรทัด 392-519

ระวัง

  • JWT_SECRET='secret' default (config.js:12) — ถ้า env ไม่ตั้ง จะใช้ string 'secret' ที่ใครก็รู้
  • prod device keys commit อยู่ใน source (sensitive — ห้าม log ห้าม paste)

Centralize-api ใช้ logger คนละแบบ

centralize-api/src/libs/log/logs.middleware.ts:1-68I3GatewayLogger คือ class ที่ implement LoggerService ของ NestJS

logs/<DD-MM-YYYY>.log

ระวัง — เขียน ANSI color escape (\x1b[32m) ลงไฟล์ด้วย (logs.middleware.ts:22-24) — ทำให้อ่านไฟล์ยาก

stream เปิดทุกครั้งที่สร้าง instance — logs.middleware.ts:12-17 — แต่ไม่ปิด ถ้า service รันนาน → leak file descriptor


Error filter ของ centralize-api

centralize-api/src/libs/exception/http-exception.filter.ts:1-26

ts
@Catch(HttpException)
export class HttpExceptionFilter implements ExceptionFilter {
  catch(exception: HttpException, host: ArgumentsHost) {
    const response = host.switchToHttp().getResponse()
    const status = exception.getStatus()
    const message = exception.getResponse()

    this.logger.log(message)  // log(), ไม่ใช่ error()
    response.status(status).json({
      statusCode: status,
      message: message['message'] || message['error']
    })
  }
}

ระวัง 2 จุด

  1. log ทุก error ที่ระดับ log (info) ไม่ใช่ error — ตอน filter log จะหาผิดระดับ
  2. จับแค่ HttpException — driver error ของ mssql ที่ไม่ใช่ HTTP จะ escape filter → response เป็น default 500 ของ NestJS

เคล็ดลับตอนทำงานจริง

ตอน debug หา "เกิดอะไรขึ้น"

อย่าเริ่มที่ log แบบสุ่ม — ไล่ตามลำดับนี้

  1. รู้ userId ของคนที่เจอปัญหา
  2. grep userId ใน logs/.../<date>/ ทุกไฟล์
  3. ดู error log ก่อน ถ้าไม่เจอ ค่อยดู info log
  4. ถ้าเป็น GraphQL request → ดู entry ในรูปแบบ { query, variables, headers, from: { appId, userId } }

ถ้าเจอ "log ไม่ครบ"

อาจจะเพราะ

  • request ตกที่ replica ที่ mount volume เสีย → log ไม่ถูกเขียน
  • format ไม่ใช่ JSON → ไม่ match grep pattern
  • error เกิดก่อน resolver → ไม่ผ่าน logging.js

ลองดู stdout ของ container ด้วย (docker logs vtrc-api-nodejs-2) เพราะบางทีมีแค่ console.log

อย่าเชื่อ message ของ error ใน frontend

frontend แสดง message ตาม code ของ Apollo error บางครั้ง code เดียวกันใช้หลายที่ → context ไม่ชัดเจน

ตัวอย่าง — TOKEN_INVALID เกิดได้จาก

  • JWT ผิดรูปแบบ
  • session ไม่มีใน DB
  • apiKey header ผิด

ต้องดู log ใน backend ถึงรู้สาเหตุจริง

"ทำไม NODE_ENV=production แต่ยังเห็น playground"

เพราะ vtrc-api/docker-compose.yml:54NODE_ENV=production ถูก comment ไว้! container รันด้วย default 'development' จึงเปิด playground + introspection + debug

อย่าลืม uncomment ตอน build ใหม่


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

ไป บท 2.8 ข้อจำกัดทางสถาปัตยกรรม + debt