IlmHamroh
JavaScript Full-stack/25-qism. HTTP, Express va REST API44/51-dars23 daqiqa
Mundarija (29)

Correlation ID: X-Request-Id, AsyncLocalStorage va so'rov kuzatuvi

Qisqacha: Correlation ID — bitta so'rovga beriladigan noyob belgi. U so'rovning har log qatorida, javob sarlavhasida va ichki so'rovlarda bo'ladi. vazifalar-api da u X-Request-Id sarlavhasidan olinadi (yoki yangi UUID), pino-http dan keyin AsyncLocalStorage.run(req.log, next) bilan butun zanjirga "yopishtiriladi". Natija: xizmat qatlami, xato ishlovchi va hatto javobdan keyin ishlaydigan fon ishi ham req ni olmasdan o'sha sorovId bilan yozadi. Servislar orasida standart format — W3C traceparent.

Bu darsda

  • So'rov ID'si qayerdan kelishi va unga qachon ishonish mumkinligini tushuntirasiz.
  • AsyncLocalStorage bilan logger'ni chuqur funksiyalarga argumentsiz yetkazasiz.
  • Ichki so'rovlarda ID'ni keyingi servisga uzatasiz.
  • traceparent sarlavhasini o'qiy olasiz va bola qiymatini yasaysiz.
  • Holat 44 ni yakunlab, loglar korrelyatsiyasini test bilan tekshirasiz.

Oldin bilishingiz kerak: AsyncLocalStorage, Pino chuqur, Express + TypeScript, Bulk va uzoq davom etuvchi operatsiyalar.

1. Nega bu kerak?

Malika qo'llab-quvvatlash xizmatiga yozadi: "Vazifa qo'shmoqchi edim, 'Kutilmagan xato' chiqdi. Pastida sorovId: 3e5b6c8b-… deb yozilgan." Bu ID — Problem Details javobidagi maydon. Endi siz loglardan shu ID bo'yicha qidirasiz va topasiz: so'rov qachon keldi, qaysi xizmat funksiyasi ishladi, qayerda xato chiqdi, qancha vaqt ketdi.

Buning uchun so'rovning har qatorida shu ID bo'lishi kerak. Faqat pino-http ning yakuniy qatorida emas — xizmat qatlamida ham, xato ishlovchida ham, javob ketgandan keyin ishlaydigan fon ishida ham.

Correlation ID (correlation ID) — "bog'lovchi belgi": turli joylarda yozilgan qatorlarni bitta so'rovga bog'laydi. Hayotiy o'xshatish: pochta posilkasining trek raqami. Posilka Toshkentdan Samarqandga bir nechta ombor orqali o'tadi. Har ombor o'z jurnaliga yozadi, lekin hammasi bitta trek raqami bilan. Raqamni kiritsangiz — butun yo'l ko'rinadi.

2. ID qayerdan keladi

2.1 Kanon: X-Request-Id (holat 17)

Express + TypeScript darsida sorovId() middleware'ini qo'shgan edik:

ts
const YAROQLI = /^[\w.-]{8,128}$/;

export function sorovId(yangiId: () => string = randomUUID) {
  return (req, res, next) => {
    const kelgan = req.get("x-request-id");
    req.sorovId =
      kelgan !== undefined && YAROQLI.test(kelgan)
        ? kelgan
        : yangiId();
    res.set("X-Request-Id", req.sorovId);
    next();
  };
}

(Tiplar qisqartirildi.) Qoida uch qadamda:

  1. Mijoz yoki proxy X-Request-Id yuborgan bo'lsa va u yaroqli bo'lsa — o'shani olamiz.
  2. Bo'lmasa — yangi UUID (crypto.randomUUID).
  3. Javobga qaytaramiz — mijoz uni ko'rsatishi yoki xato xabariga qo'shishi mumkin.

X-Request-Id — standart emas, lekin keng tarqalgan nom (nginx, Heroku, bulut balanserlari ishlatadi).

2.2 Hujumchi nigohi: tashqi ID'ga ishonish

Mijozdan kelgan ID — ishonchsiz ma'lumot. Hujumchi nima qila oladi?

Hujum Natija Kanon himoyasi
\n bilan soxta log qatori log injection regex: faqat \w . -
1 MB uzunlikdagi ID log va xotira shishadi 8–128 belgi
boshqa foydalanuvchining ID'si tergovni chalkashtirish ID — faqat kuzatish uchun, ruxsat uchun emas

Uchinchi qator muhim: correlation ID hech qachon autentifikatsiya yoki ruxsat uchun ishlatilmaydi. U — faqat trek raqami. Ishlab chiqarishda ko'pincha ID'ni reverse proxy yasaydi va mijozdan kelganini almashtiradi. Bunda ichki servislar faqat proxy'ga ishonadi.

3. ID'ni har qatorga yetkazish

3.1 Muammo: req chuqurga bormaydi

pino-http har so'rovga req.log beradi — sorovId bog'langan bola logger (Pino chuqur). Marshrut ichida req.log.info(...) ishlaydi. Lekin xizmat qatlami (xizmat.ts) — HTTP'ni bilmaydi va req ni olmaydi (qatlamli tuzilma qarori). Uni qanday qilib req.log bilan yozdiramiz?

Ikki yo'l bor:

Yo'l Afzalligi Kamchiligi
har funksiyaga log argumenti aniq, testga qulay o'nlab imzolar o'zgaradi
AsyncLocalStorage imzolar toza "yashirin" bog'liqlik

Log — kesib o'tuvchi narsa (har joyda kerak, lekin biznes mantiqqa tegishli emas). AsyncLocalStorage darsidagi qoida aynan shunday holatlar uchun: ALS'ga faqat so'rov ID, joriy foydalanuvchi, til, trace kabilar qo'yiladi.

3.2 Kichik server bilan ko'ramiz

node:http, ALS va o'zimizning kichik log bilan. Ikki parallel so'rov, har biri o'z ID'si bilan. Xizmat funksiyasi vazifaYarat ID haqida hech narsa bilmaydi:

js
import { createServer } from "node:http";
import { AsyncLocalStorage } from "node:async_hooks";
import { setTimeout as kut } from "node:timers/promises";

const kontekst = new AsyncLocalStorage();

function log(xabar, maydonlar = {}) {
  const sorovId = kontekst.getStore() ?? "-";
  const qator = { sorovId, ...maydonlar, msg: xabar };
  console.log(JSON.stringify(qator));
}

// xizmat qatlami: req'ni ham, ID'ni ham bilmaydi
async function vazifaYarat(matn) {
  await kut(matn.length * 10); // baza o'rnida
  log("vazifa yaratildi", { matn });
}

const server = createServer((req, res) => {
  const id = req.headers["x-request-id"] ?? "yangi-id";
  res.setHeader("X-Request-Id", id);
  kontekst.run(id, async () => {
    const matn = decodeURIComponent(req.url.slice(1));
    await vazifaYarat(matn);
    res.end("ok");
    log("javob ketdi");
  });
});

server.listen(0, "127.0.0.1");
await new Promise((ok) => server.once("listening", ok));
const asos = `http://127.0.0.1:${server.address().port}`;
const sora = (id, matn) =>
  fetch(`${asos}/${encodeURIComponent(matn)}`, {
    headers: { "X-Request-Id": id },
  });

await Promise.all([
  sora("aaa-0001", "Hisobot yozish"),
  sora("bbb-0002", "Non"),
]);
log("hammasi tugadi");
server.closeAllConnections();
server.close();

Konsolda:

text
{"sorovId":"bbb-0002","matn":"Non","msg":"vazifa yaratildi"}
{"sorovId":"bbb-0002","msg":"javob ketdi"}
{"sorovId":"aaa-0001","matn":"Hisobot yozish","msg":"vazifa yaratildi"}
{"sorovId":"aaa-0001","msg":"javob ketdi"}
{"sorovId":"-","msg":"hammasi tugadi"}

Birinchi so'rov (aaa) birinchi yuborildi, lekin sekinroq bajarildi — bbb uni quvib o'tdi. Shunga qaramay har qatorda to'g'ri ID. vazifaYarat ning imzosida ID yo'q. So'rovdan tashqarida esa — -.

sequenceDiagram
  participant M as Mijoz
  participant S as sorovId()
  participant H as pino-http + ALS
  participant X as xizmat.ts
  M->>S: POST, X-Request-Id: kor-0001
  S->>H: req.sorovId = kor-0001
  H->>X: als.run(req.log, next)
  X-->>X: joriyLog().info("vazifa yaratildi")
  H-->>M: 201, X-Request-Id: kor-0001

Diagrammaga qarang: ID bir marta qo'yiladi (sorovId()), bir marta ALS'ga joylanadi, keyin xizmat uni "havodan" oladi.

3.3 Kanon: joriyLog va sorovLogi ning ikkinchi qismi

Kanon ALS'ga ID satrini emas, butun bola logger'ni (req.log) qo'yadi. Shunda pastdagi kod oddiy Pino logger bilan ishlaydi (src/umumiy/log.ts, holat 44):

ts
const kontekst = new AsyncLocalStorage<Log>();

/** Joriy so'rovning logger'i; so'rovdan tashqarida — asosiy */
export function joriyLog(asosiy: Log): Log {
  return kontekst.getStore() ?? asosiy;
}

export function sorovLogi(log: Log): RequestHandler[] {
  const http = pinoHttp({ /* … Pino chuqur darsida */ });
  return [
    http,
    (req, _res, next) => {
      kontekst.run(req.log, next);
    },
  ];
}

sorovLogi endi ikkita middleware qaytaradi: pino-http va ALS o'rami. kontekst.run(req.log, next) — next() va undan keyingi hamma middleware, marshrut va ular boshlagan async ish shu kontekstda ishlaydi.

Bitta POST /v1/vazifalar (X-Request-Id: kor-0001) kanon zanjiridan qanday o'tadi:

Qadam Middleware Natija
1 sorovId() req.sorovId = "kor-0001"; javobga X-Request-Id sarlavhasi
2 pino-http req.log — sorovId bog'langan bola logger; vaqt o'lchash boshlandi
3 ALS o'rami kontekst.run(req.log, next) — bundan keyingi hamma narsa shu kontekstda
4 helmet, cors, static xavfsizlik va CORS sarlavhalari; logga tegmaydi
5 express.json() tana o'qiladi — kontekst saqlanib qoladi
6 idempotentlik, router marshrut xizmatni chaqiradi (req uzatilmaydi)
7 xizmat.yarat joriyLog() → req.log: "vazifa yaratildi" qatori sorovId bilan
8 javob 201 → finish pino-http yakuniy qatori: "POST /v1/vazifalar 201", ms bilan

7-qadamdagi qator 8-qadamdagidan oldin yoziladi: pino-http o'z qatorini javob tugagandagina yozadi.

Xizmatga logger to'g'ridan emas, funksiya sifatida beriladi (ilova.ts):

ts
const xizmat = vazifaXizmati(ombor, () => joriyLog(log));

Nega funksiya? vazifaXizmati ilova ishga tushganda bir marta yasaladi — o'sha paytda hech qanday so'rov yo'q. Funksiya esa har chaqiruvda joriy so'rovning logger'ini oladi. Xizmat ichida (xizmat.ts):

ts
const v = await ombor.yarat(yangi);
log().info({ vazifaId: v.id }, "vazifa yaratildi");

Testda esa log sifatida soxta funksiya berish oson — ALS'siz (Mock, stub, spy va fake).

3.4 Javobdan keyin: fon ishi

Bulk va uzoq operatsiyalar darsidagi eksport 202 qaytaradi va ishni fonda davom ettiradi. Javob ketdi — so'rov "tugadi". Fon ishi qaysi ID bilan yozadi?

ts
// eksport.ts (holat 44): logger javobdan OLDIN olinadi
const l = log();
bajar(ish).then(
  () => {
    l.info({ eksportId: ish.id, soni: ish.tayyor }, "eksport tayyor");
  },
  (x: unknown) => {
    ish.holat = "xato";
    ish.xato = "Eksport bajarilmadi";
    l.error({ err: x, eksportId: ish.id }, "eksport bajarilmadi");
  },
);

Kanon jonli chiqishi (tekshiruv-44.txt) — 202 javobi va 1 ms keyingi fon qatori, ikkalasida bir xil sorovId:

text
{"daraja":"info","time":"2026-10-24T06:04:49.109Z","ilova":"vazifalar-api","sorovId":"kanon-44-0003","req":{"method":"POST","url":"/v1/eksportlar","ip":"127.0.0.1","userAgent":"curl/8.19.0"},"res":{"status":202},"ms":1,"msg":"POST /v1/eksportlar 202"}
{"daraja":"info","time":"2026-10-24T06:04:49.110Z","ilova":"vazifalar-api","sorovId":"kanon-44-0003","eksportId":"deb4d671-f6e4-484d-87d4-a8cfa1d92697","soni":1,"msg":"eksport tayyor"}

Aslida bajar() ALS kontekstida boshlangani uchun ichidagi log() ham to'g'ri ID'ni olardi. Lekin kanon logger'ni oldindan l ga oldi — o'qiganga aniq ko'rinsin.

3.5 Kanon tekshirgan tuzoq: so'rov tanasi

ALS haqida eski maqolalarda ogohlantirish bor: "body-parser oqim hodisalari orqali o'qiydi, kontekst yo'qolishi mumkin". AsyncLocalStorage darsida ko'rgansiz: EventEmitter tinglovchisi emit qilganning kontekstida ishlaydi. Kanon buni tekshirdi: express.json() tanani o'qigandan keyin ham xizmat qatori to'g'ri sorovId bilan chiqdi — test bor (log.test.ts, "POST tanasi bilan ham"). Sabab tartibda: ALS o'rami express.json() dan oldin turadi, oqim o'qish shu kontekst ichida boshlanadi.

Tekshirib ko'ring: Kimdir app.use(sorovLogi(log)) ni express.json() dan keyin ko'chirdi. Endi nima o'zgaradi?

Javob

Ikki narsa. Birinchisi — JSON xatosi (400) va 413 kabi javoblar express.json() da, ya'ni pino-http ga yetmasdan chiqadi: ular uchun so'rov qatori yozilmaydi (yoki ID'siz yoziladi). Ikkinchisi — tanani o'qish ALS'dan tashqarida boshlanadi. Qoida: ID va log middleware'i — zanjirning eng boshida, sorovId() dan keyin darhol. Kanondagi tartib: sorovId → sorovLogi → helmet → cors → static → json.

4. Servislararo uzatish

4.1 Bitta ID — bir nechta servis

vazifalar-api testlar qismida webhook yuboradi — boshqa servisga HTTP so'rov. Kelajakda (29-qism) Telegram bot servisi bo'ladi. Malikaning so'rovi ikki servisdan o'tsa, ikkalasining logida bir xil ID bo'lishi kerak. Buning uchun ichki so'rovga sarlavhani qo'shamiz:

js
import { createServer } from "node:http";
import { once } from "node:events";
import { AsyncLocalStorage } from "node:async_hooks";

const kontekst = new AsyncLocalStorage();
const log = (servis, xabar) => {
  const id = kontekst.getStore()?.id ?? "-";
  console.log(`[${servis}] [${id}] ${xabar}`);
};

// Ichki so'rov: joriy so'rov ID'sini sarlavhaga qo'shadi
function ichkiFetch(url) {
  const id = kontekst.getStore()?.id;
  const headers = id ? { "X-Request-Id": id } : {};
  return fetch(url, { headers });
}

function servis(nom, ishlov) {
  return createServer((req, res) => {
    const id = req.headers["x-request-id"] ?? `${nom}-yangi`;
    kontekst.run({ id }, () => ishlov(req, res));
  }).listen(0, "127.0.0.1");
}
const manzil = (s) => `http://127.0.0.1:${s.address().port}`;

const xabar = servis("xabar", (req, res) => {
  log("xabar", "bildirishnoma yuborildi");
  res.end();
});
const api = servis("api", async (req, res) => {
  log("api", "vazifa bajarildi");
  await ichkiFetch(manzil(xabar));
  log("api", "javob ketdi");
  res.end();
});
await Promise.all([once(api, "listening"), once(xabar, "listening")]);

const sarlavha = { "X-Request-Id": "mijoz-0007" };
await fetch(manzil(api), { headers: sarlavha });
for (const s of [api, xabar]) {
  s.closeAllConnections();
  s.close();
}

Konsolda:

text
[api] [mijoz-0007] vazifa bajarildi
[xabar] [mijoz-0007] bildirishnoma yuborildi
[api] [mijoz-0007] javob ketdi

Ikkala servisda bir ID. ichkiFetch o'rniga oddiy fetch ishlatsangiz — xabar servisi xabar-yangi deb yozardi va zanjir uzilardi. O'zingiz almashtirib ko'ring.

Kanonda webhookXabarchi ID'ni sarlavhaga qo'shmaydi — webhook tashqi, begona servisga ketadi va bizning ichki ID'miz unga kerak emas. Lekin javob logida sorovId bor (tekshiruv-51.txt, «webhook yuborildi» qatori). Ichki servislar bo'lganda esa uzatish shart.

4.2 W3C traceparent

Bitta X-Request-Id oddiy holatlar uchun yetadi. Katta tizimlarda ko'proq kerak: so'rov qaysi servisdan qaysi biriga o'tgani, har qadam qancha vaqt olgani — daraxt ko'rinishida. Bunga taqsimlangan kuzatuv (distributed tracing) deyiladi. Uning standart sarlavhasi — W3C Trace Context tavsiyasidagi traceparent:

text
traceparent: 00-4bf92f3577b34da6a3ce929d0e0e4736-00f067aa0ba902b7-01
Qism Uzunlik Ma'nosi
00 2 versiya
4bf9…4736 32 hex trace-id — butun so'rov zanjiri, hamma servisda bir xil
00f0…02b7 16 hex parent-id (span) — shu sarlavhani yuborgan qadam
01 2 bayroqlar: 01 — bu trace yozib olinadi (sampled)

Har servis trace-id'ni saqlaydi, parent-id o'rniga esa o'z qadamining yangi ID'sini qo'yib keyingisiga yuboradi. Natijada qadamlar daraxti tiklanadi. Brauzerda sinab ko'ring:

js
const NAQSH = /^00-([0-9a-f]{32})-([0-9a-f]{16})-([0-9a-f]{2})$/;

function traceparentOch(qiymat) {
  const m = NAQSH.exec(qiymat);
  if (!m || /^0+$/.test(m[1]) || /^0+$/.test(m[2])) return null;
  const namuna = (parseInt(m[3], 16) & 1) === 1;
  return { traceId: m[1], spanId: m[2], namuna };
}

function keyingiTraceparent(ota, yangiSpanId) {
  const bayroq = ota.namuna ? "01" : "00";
  return `00-${ota.traceId}-${yangiSpanId}-${bayroq}`;
}

const kelgan =
  "00-4bf92f3577b34da6a3ce929d0e0e4736-00f067aa0ba902b7-01";
const ota = traceparentOch(kelgan);
console.log(ota.traceId);
console.log(ota.spanId, ota.namuna);
console.log(keyingiTraceparent(ota, "b7ad6b7169203331"));
console.log(traceparentOch("salom"));
const nollar = "00-" + "0".repeat(32) + "-00f067aa0ba902b7-01";
console.log(traceparentOch(nollar));

Konsolda:

text
4bf92f3577b34da6a3ce929d0e0e4736
00f067aa0ba902b7 true
00-4bf92f3577b34da6a3ce929d0e0e4736-b7ad6b7169203331-01
null
null

Standart bo'yicha hammasi nol bo'lgan trace-id yaroqsiz — shuning uchun oxirgi qator null. Yaroqsiz sarlavha kelsa, servis uni tashlab, yangi trace boshlaydi.

Qo'lda yozish shart emas: OpenTelemetry kutubxonasi traceparent ni o'zi o'qiydi, yaratadi, fetch ga qo'shadi va ichida xuddi shu ALS'dan foydalanadi. Kanon uni qo'shmadi — servis bitta. OpenTelemetry — 34-qismda.

5. API qadami: correlation ID va commit (holat 44, yakun)

Holat 44 ning oxirgi qismi:

Fayl O'zgarish
src/umumiy/log.ts AsyncLocalStorage<Log>, joriyLog(), sorovLogi → 2 middleware
src/ilova.ts vazifaXizmati(ombor, () => joriyLog(log)), eksport va xato ishlovchisi ham joriyLog; xatoLogi olib tashlanadi
src/vazifalar/xizmat.ts "vazifa yaratildi", "vazifa o'chirildi" qatorlari
src/eksport/eksport.ts "eksport tayyor" / "eksport bajarilmadi"
src/umumiy/idempotentlik.ts console.error o'rniga req.log.error
test/xato.test.ts 500 testi console.error ni emas, xotiradagi log qatorini tekshiradi
test/log.test.ts 4 test (yangi)

Yakuniy src/umumiy/log.ts — to'liq (holat 44; uzun qatorlar va izohlar telefon uchun bo'lindi, kod aynan):

ts
// log.ts — strukturali (JSON) log: Pino. stdout'ga yoziladi
// (12-factor) — faylga yozish, rotatsiya, yig'ish — muhitning
// ishi (Docker, Loki — 34-qism).
// Correlation ID: har so'rov AsyncLocalStorage ichida — xizmat
// va fon ishlari ham joriy so'rov logger'ini oladi
// (req'ni uzatmasdan)
import { AsyncLocalStorage } from "node:async_hooks";
import { pino } from "pino";
import type { DestinationStream, Logger, LoggerOptions } from "pino";
import { pinoHttp } from "pino-http";
import type { RequestHandler } from "express";

export type Log = Logger;

/** Hech qachon logga tushmasligi kerak bo'lgan joylar —
 * qiymat "[yashirildi]" ga almashadi */
export const YASHIRIN = [
  "req.headers.authorization",
  "req.headers.cookie",
  'req.headers["x-api-key"]',
  "res.headers['set-cookie']",
  "*.parol",
  "*.token",
];

export function logYarat(
  daraja: string,
  manzil?: DestinationStream,
): Log {
  const sozlama: LoggerOptions = {
    level: daraja,
    // vaqt ISO ko'rinishda (epoch ms emas) — odam ham o'qisin;
    // Toshkentga aylantirish — ko'rsatuvchi vositada
    timestamp: pino.stdTimeFunctions.isoTime,
    redact: { paths: YASHIRIN, censor: "[yashirildi]" },
    base: { ilova: "vazifalar-api" },
    formatters: { level: (label) => ({ daraja: label }) },
  };
  return manzil === undefined ? pino(sozlama) : pino(sozlama, manzil);
}

const kontekst = new AsyncLocalStorage<Log>();

/** Joriy so'rovning logger'i (sorovId bilan);
 * so'rovdan tashqarida — asosiy logger */
export function joriyLog(asosiy: Log): Log {
  return kontekst.getStore() ?? asosiy;
}

/**
 * pino-http: har so'rov oxirida bitta qator
 * (metod, yo'l, status, vaqt),
 * sorovId — bizning X-Request-Id; keyin qolgan zanjir
 * AsyncLocalStorage
 * ichida ishlaydi. sorovId() middleware'idan KEYIN qo'yiladi
 */
function asliYol(req: { url?: string | undefined }): string {
  const r = req as { originalUrl?: string; url?: string };
  return (r.originalUrl ?? r.url ?? "").split("?")[0] ?? "";
}

export function sorovLogi(log: Log): RequestHandler[] {
  const http = pinoHttp({
    logger: log,
    genReqId: (req) =>
      (req as unknown as { sorovId: string }).sorovId,
    customAttributeKeys: { reqId: "sorovId", responseTime: "ms" },
    // req.log (va AsyncLocalStorage'dagi) child logger'ga
    // faqat sorovId
    // bog'lanadi — har qatorga butun req yozilmasin
    quietReqLogger: true,
    // 4xx — warn, 5xx — error (xato ishlovchi o'zi ham yozadi),
    // qolgani info
    customLogLevel: (_req, res, xato) =>
      xato !== undefined || res.statusCode >= 500
        ? "error"
        : res.statusCode >= 400
          ? "warn"
          : "info",
    // req.url — router ichida qisqaradi ("/"),
    // originalUrl — to'liq yo'l
    customSuccessMessage: (req, res) =>
      `${String(req.method)} ${asliYol(req)} ` +
      String(res.statusCode),
    customErrorMessage: (req, res) =>
      `${String(req.method)} ${asliYol(req)} ` +
      String(res.statusCode),
    serializers: {
      // to'liq sarlavhalar o'rniga keraklilari (hajm va maxfiylik)
      req: (req: {
        method: string;
        url: string;
        raw?: { originalUrl?: string };
        headers: Record<string, string | undefined>;
        remoteAddress?: string;
      }) => ({
        method: req.method,
        url: req.raw?.originalUrl ?? req.url,
        ip: req.remoteAddress,
        userAgent: req.headers["user-agent"],
      }),
      res: (res: { statusCode: number }) => ({
        status: res.statusCode,
      }),
    },
  });
  return [
    http,
    (req, _res, next) => {
      kontekst.run(req.log, next);
    },
  ];
}

Qolgan fayllar — Pino chuqur darsi oxiridagi kesimdan farq ko'rinishida (uzun qatorlar bo'lindi, kod aynan). src/ilova.ts — xatoLogi o'rniga hamma joyda joriyLog(log):

diff
--- a/ilova.ts
+++ b/ilova.ts
-import type { XatoLogi } from "./umumiy/xato-ishlovchi.ts";
-import { logYarat, sorovLogi } from "./umumiy/log.ts";
+import { joriyLog, logYarat, sorovLogi } from "./umumiy/log.ts";
@@ Bogliqliklar @@
-  /** kutilmagan xatolarni yozish (sukut — console.error) */
-  xatoLogi?: XatoLogi;
@@ ilovaYarat parametrlari @@
-  xatoLogi,
   log = logYarat("silent"),
@@ middleware tartibi @@
   app.use(sorovId());
+  // so'rov logi + AsyncLocalStorage konteksti (sorovId) —
+  // qolgan hamma middleware va handler shu kontekst ichida
   app.use(sorovLogi(log));
@@ routerlar @@
-  const xizmat = vazifaXizmati(ombor);
+  const xizmat = vazifaXizmati(ombor, () => joriyLog(log));
-  v1.use("/eksportlar", eksportRouteri(ombor));
+  v1.use(
+    "/eksportlar",
+    eksportRouteri(ombor, { log: () => joriyLog(log) }),
+  );
@@ oxiri @@
-  app.use(xatoIshlovchi(xatoLogi));
+  app.use(
+    xatoIshlovchi((_id, xato) => {
+      joriyLog(log).error({ err: xato }, "kutilmagan xato");
+    }),
+  );

src/vazifalar/xizmat.ts — logger funksiya sifatida, sukut — jim:

diff
--- a/xizmat.ts
+++ b/xizmat.ts
+import type { Log } from "../umumiy/log.ts";
@@ vazifaXizmati @@
-export function vazifaXizmati(ombor: VazifaOmbori) {
+// log — funksiya: har chaqiruvda JORIY so'rov logger'i
+// (AsyncLocalStorage) — sorovId avtomatik qo'shiladi
+export function vazifaXizmati(
+  ombor: VazifaOmbori,
+  log: () => Pick<Log, "info"> = () => ({ info: () => undefined }),
+) {
@@ yarat @@
-      return ombor.yarat(yangi);
+      const v = await ombor.yarat(yangi);
+      log().info({ vazifaId: v.id }, "vazifa yaratildi");
+      return v;
@@ ochir @@
       natija(id, await ombor.ochir(id, shart));
+      log().info({ vazifaId: id }, "vazifa o'chirildi");

src/eksport/eksport.ts — log parametri; fon ishining yangi ko'rinishi «Javobdan keyin: fon ishi» bo'limida:

diff
--- a/eksport.ts
+++ b/eksport.ts
+import type { Log } from "../umumiy/log.ts";
@@ BOLAK dan keyin @@
+type EksportLogi = Pick<Log, "info" | "error">;
+const jimLog: EksportLogi = {
+  info: () => undefined,
+  error: () => undefined,
+};
@@ eksportRouteri parametrlari @@
   bolakdanKeyin = (): Promise<void> => navbat(),
+  log = (): EksportLogi => jimLog,
@@ POST / @@
-    bajar(ish).catch((x: unknown) => {
-      ish.holat = "xato";
-      ish.xato = "Eksport bajarilmadi";
-      console.error(`[eksport ${ish.id}]`, x);
-    });
+    const l = log();
+    bajar(ish).then( /* «Javobdan keyin: fon ishi» */ );

src/umumiy/idempotentlik.ts — req.log Pino chuqur darsidan beri bor:

diff
       saqla.catch((x: unknown) => {
-        console.error(
-          `[${req.sorovId}] idempotentlik omboriga yozilmadi`, x);
+        req.log.error(
+          { err: x }, "idempotentlik omboriga yozilmadi");
       });

test/xato.test.ts — console.error ni almashtirish o'rniga xotiradagi log:

diff
-import { jsonYubor } from "./yordamchi.ts";
+import { jsonYubor, xotiraLogi } from "./yordamchi.ts";
@@ before/after @@
-const loglar: unknown[][] = [];
-const aslError = console.error;
+const { log, qatorlar } = xotiraLogi();
 before(async () => {
-  console.error = (...x: unknown[]) => loglar.push(x);
-  const { server, manzil } = await ishga({ port: 0,
-    bogliqliklar: { ombor: new BuzuqOmbor() } });
+  const { server, manzil } = await ishga({ port: 0,
+    bogliqliklar: { ombor: new BuzuqOmbor(), log } });
   asos = manzil;
   toxta = () => yop(server, 1000);
 });
-after(async () => {
-  console.error = aslError;
-  await toxta();
-});
+after(() => toxta());
@@ 500 testining oxiri @@
-  assert.ok(loglar.some((q) => q[0] === "[test-500-abcdef]"
-    && q[1] instanceof Error));
+  // logda — to'liq xato (stack bilan) va o'sha sorovId
+  const q = qatorlar.find((x) => x.msg === "kutilmagan xato");
+  assert.ok(q);
+  assert.equal(q.sorovId, "test-500-abcdef");
+  assert.equal(q.daraja, "error");
+  assert.match(String((q.err as { stack?: string } | undefined)
+    ?.stack), /ulanish uzildi/);

Nega bu test aynan bugun o'zgaradi? U log qatorida sorovId ni talab qiladi. Xato ishlovchi joriyLog(log) orqali req.log ni olgandagina shunday bo'ladi — ya'ni ALS o'rami bilan birga.

Testlar — xotiraLogi() bilan (Pino chuqur darsidagi yordamchi). test/log.test.ts — to'liq (holat 44; qatorlar bo'lindi, kod aynan):

ts
import { after, before, test } from "node:test";
import assert from "node:assert/strict";
import { ishgaTushir as ishga } from "../src/ishga-tushir.ts";
import { yop } from "../src/yopish.ts";
import { XotiraOmbori } from "../src/vazifalar/xotira-ombor.ts";
import { xotiraLogi } from "./yordamchi.ts";

const { log, qatorlar } = xotiraLogi();
let asos = "";
let toxta: () => Promise<boolean>;
before(async () => {
  const { server, manzil } = await ishga({
    port: 0,
    bogliqliklar: { ombor: new XotiraOmbori(), log },
  });
  asos = manzil;
  toxta = () => yop(server, 1000);
});
after(() => toxta());

test("correlation ID: xizmat ichidagi log (req'siz) " +
  "so'rov sorovId'sini oladi — POST tanasi bilan ham " +
  "(AsyncLocalStorage)", async () => {
  await fetch(`${asos}/v1/vazifalar`, {
    method: "POST",
    headers: {
      "Content-Type": "application/json",
      "X-Request-Id": "kor-0001-abc",
    },
    body: '{"matn":"Non"}',
  });
  const xizmat = qatorlar.find((q) => q.msg === "vazifa yaratildi");
  assert.ok(xizmat);
  assert.equal(xizmat.sorovId, "kor-0001-abc");
  assert.equal(xizmat.vazifaId, 1);
  const sorov = qatorlar.find(
    (q) => q.msg === "POST /v1/vazifalar 201",
  );
  assert.ok(sorov);
  assert.equal(sorov.sorovId, "kor-0001-abc");
  assert.equal(sorov.daraja, "info");
  assert.equal(typeof sorov.ms, "number");
  assert.match(String(sorov.time), /^\d{4}-\d{2}-\d{2}T.*Z$/);
});

test("parallel so'rovlar loglari aralashmaydi", async () => {
  await Promise.all(
    ["par-aaaa-1", "par-bbbb-2", "par-cccc-3"].map((id, i) =>
      fetch(`${asos}/v1/vazifalar`, {
        method: "POST",
        headers: {
          "Content-Type": "application/json",
          "X-Request-Id": id,
        },
        body: JSON.stringify({ matn: `p${String(i)}` }),
      })),
  );
  const yaratilgan = qatorlar.filter(
    (q) => q.msg === "vazifa yaratildi" &&
      String(q.sorovId).startsWith("par-"),
  );
  assert.equal(yaratilgan.length, 3);
  assert.equal(new Set(yaratilgan.map((q) => q.sorovId)).size, 3);
});

test("4xx — warn; Authorization va cookie logga tushmaydi (redact)",
  async () => {
    await fetch(`${asos}/v1/vazifalar/999`, {
      headers: {
        Authorization: "Bearer SOXTA-TOKEN",
        "X-Request-Id": "red-0001-abc",
      },
    });
    const q = qatorlar.find(
      (x) => x.sorovId === "red-0001-abc" &&
        x.msg === "GET /v1/vazifalar/999 404",
    );
    assert.ok(q);
    assert.equal(q.daraja, "warn");
    assert.doesNotMatch(JSON.stringify(qatorlar), /SOXTA-TOKEN/);
  });

test("fon ishi (eksport) logi ham boshlagan so'rov sorovId'si bilan",
  async () => {
    const r = await fetch(`${asos}/v1/eksportlar`, {
      method: "POST",
      headers: { "X-Request-Id": "eks-0001-abc" },
    });
    const joy = String(r.headers.get("location"));
    for (let i = 0; i < 50 &&
      !qatorlar.some((q) => q.msg === "eksport tayyor"); i++) {
      await new Promise((ok) => setTimeout(ok, 10));
    }
    const q = qatorlar.find((x) => x.msg === "eksport tayyor");
    assert.ok(q);
    assert.equal(q.sorovId, "eks-0001-abc");
    assert.equal(q.eksportId, joy.split("/").at(-1));
  });

Uchinchi va to'rtinchi testlar assert.doesNotMatch bilan "token yo'q" ni va fon ishini kutish siklini (50 × 10 ms — fon ishi tugaguncha) ko'rsatadi.

Tekshiruv (tekshiruv-44.txt): tip 0, lint 0, test 58/58; prod bog'liqliklar 6 (84 paket); sovuq start 440 ms. Commit (kanon xabari):

bash
git add -A
git commit -m "Log: Pino JSON (stdout), pino-http, redact, X-Request-Id → AsyncLocalStorage orqali hamma qatorlarda; pino-pretty faqat dev"

6. Ko'p uchraydigan xatolar

6.1 Logger'ni ilova ishga tushganda olib qo'yish

vazifaXizmati(ombor, joriyLog(log)) — qavslar bilan, bir marta chaqirildi: so'rov yo'q paytda olingan asosiy logger abadiy qoladi, ID hech qachon chiqmaydi. Funksiya uzating: () => joriyLog(log).

6.2 ALS o'ramini zanjir oxiriga qo'yish

«Tekshirib ko'ring» savolidagi holat. sorovId() → sorovLogi() — eng boshida.

6.3 Tashqi ID'ni tekshirmasdan olish

Log injection va shishgan loglar. Regex va uzunlik chegarasi; proxy orqasida — proxy yasagan ID.

6.4 Ichki so'rovda ID'ni unutish

Ikkinchi servis yangi ID yasaydi — zanjir uziladi. Bitta ichkiFetch yordamchisi (yoki OpenTelemetry), oddiy fetch emas.

6.5 ID'ni ruxsat uchun ishlatish

"Bu so'rov ID'si bizniki, demak ichki so'rov" — xato: ID'ni istalgan kishi yuboradi. ID — faqat kuzatish uchun.

7. Mashqlar

1-mashq (oson): qaysi ID?

sorovId() middleware'iga uchta so'rov keldi: (a) X-Request-Id yo'q; (b) X-Request-Id: kor-0001-abc; (c) X-Request-Id: a b. Javobdagi X-Request-Id qanday bo'ladi?

Yechim

(a) yangi UUID. (b) kor-0001-abc — yaroqli (12 belgi, faqat harf, raqam, tire). (c) yangi UUID — bo'shliq [\w.-] ga kirmaydi, uzunligi ham 3 (8 dan kam). Kanon testi (middleware.test.ts) aynan shu uchta holatni tekshiradi.

2-mashq (o'rta): ALS'siz va ALS bilan

«Kichik server bilan ko'ramiz» bo'limidagi kodda kontekst.run(id, async () => {…}) o'rniga oddiy global let joriyId = id yozing va log ichida joriyId ni o'qing. Natija qanday o'zgaradi va nega?

Yechim

Biz ishga tushirdik (ikki marta, natija bir xil):

text
{"sorovId":"bbb-0002","matn":"Non","msg":"vazifa yaratildi"}
{"sorovId":"bbb-0002","msg":"javob ketdi"}
{"sorovId":"bbb-0002","matn":"Hisobot yozish","msg":"vazifa yaratildi"}
{"sorovId":"bbb-0002","msg":"javob ketdi"}
{"sorovId":"bbb-0002","msg":"hammasi tugadi"}

Hamma qator — bbb-0002, hatto "Hisobot yozish" (u aaa niki) va so'rovdan tashqaridagi oxirgi qator. aaa keldi — joriyId = "aaa-0001", await kut(…) da to'xtadi. bbb keldi — joriyId = "bbb-0002". Keyin aaa uyg'onganda global'da bbb-0002 turibdi va u hech qachon tiklanmaydi. Bu — AsyncLocalStorage darsidagi "global o'zgaruvchi" xatosining server ko'rinishi. Qaysi ID "yutishi" so'rovlar kelish tartibiga bog'liq.

3-mashq (qiyin): traceparent bilan ichki so'rov

ichkiFetch ni kengaytiring: kontekstda { id, traceparent } bo'lsin. Kirishda traceparent sarlavhasi yaroqli bo'lsa — uni oling, bo'lmasa yangisini yasang (trace-id — 32, span — 16 tasodifiy hex; crypto.getRandomValues). Ichki so'rovda trace-id saqlansin, span yangi bo'lsin. Natijani tekshirish uchun tasodifni parametr qiling: yangiHex(n) funksiyasini tashqaridan bering.

Yechim

Asosiy qism — toza funksiya, tasodif parametr orqali (bu g'oya Vaqt va tasodifni boshqarish darsida chuqurlashadi):

js
const NAQSH = /^00-([0-9a-f]{32})-([0-9a-f]{16})-([0-9a-f]{2})$/;

function kiruvchiTrace(sarlavha, yangiHex) {
  const m = NAQSH.exec(sarlavha ?? "");
  if (m && !/^0+$/.test(m[1])) return { traceId: m[1], bayroq: m[3] };
  return { traceId: yangiHex(32), bayroq: "01" };
}

function chiquvchiTraceparent(trace, yangiHex) {
  return `00-${trace.traceId}-${yangiHex(16)}-${trace.bayroq}`;
}

let n = 0;
const soxtaHex = (uzunlik) => String(++n).padStart(uzunlik, "a");

const t1 = kiruvchiTrace(
  "00-4bf92f3577b34da6a3ce929d0e0e4736-00f067aa0ba902b7-01",
  soxtaHex,
);
console.log(chiquvchiTraceparent(t1, soxtaHex));
const t2 = kiruvchiTrace(undefined, soxtaHex);
console.log(chiquvchiTraceparent(t2, soxtaHex));

Konsolda:

text
00-4bf92f3577b34da6a3ce929d0e0e4736-aaaaaaaaaaaaaaa1-01
00-aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa2-aaaaaaaaaaaaaaa3-01

Haqiqiy kodda yangiHex — (n) => [...crypto.getRandomValues(new Uint8Array(n / 2))].map((b) => b.toString(16).padStart(2, "0")).join(""). Serverga ulash: kontekst.run({ id, trace: kiruvchiTrace(req.headers.traceparent, yangiHex) }, …) va ichkiFetch ichida traceparent: chiquvchiTraceparent(store.trace, yangiHex).

4-mashq: API qadami

«API qadami» bo'limidagi o'zgarishlarni qiling, test/log.test.ts ni yozing va npm run check ni o'tkazing (58 test). Keyin LOG_LEVEL=info npm start bilan curl -H "X-Request-Id: men-0001-test" -X POST …/v1/vazifalar yuboring va ikkala qatorda (xizmat va so'rov) men-0001-test borligini ko'ring. Kanon xabari bilan commit qiling.

Yechim

Kutiladigan chiqish — «Javobdan keyin: fon ishi» va «Logging asoslari» darsidagi kanon qatorlariga o'xshash: avval "msg":"vazifa yaratildi" (xizmat), keyin "msg":"POST /v1/vazifalar 201" (pino-http), ikkalasida "sorovId":"men-0001-test". Xizmat qatori oldin chiqadi — pino-http qatori javob tugaganda yoziladi. Windows cmd da qo'shtirnoqlar boshqacha ishlaydi; Git Bash yoki PowerShell qulayroq.

8. Real ishda

  • Qo'llab-quvvatlash: xato sahifasida yoki Problem Details'da sorovId ko'rsatish — foydalanuvchi bilan dasturchini bog'laydigan eng arzon usul.
  • nginx va balanserlar: nginx'da $request_id o'zgaruvchisi bor — proxy ID yasab, proxy_set_header X-Request-Id $request_id bilan backend'ga beradi (33-qism).
  • Kuzatuv tizimlari: Jaeger, Grafana Tempo, Datadog APM, Sentry — traceparent bilan ishlaydi; log qatoriga traceId qo'shilsa, logdan trace'ga bir bosishda o'tiladi (34-qism).
  • Intervyu savollari: "Correlation ID nima?", "Uni Node'da chuqur funksiyaga qanday yetkazasiz?", "Mikroservislar orasida-chi?", "traceparent qanday tuzilgan?".

Xulosa

  • Correlation ID — so'rovning trek raqami: har log qatorida, javob sarlavhasida, ichki so'rovlarda.
  • Kanon: X-Request-Id (yaroqli bo'lsa) yoki yangi UUID; tashqi ID — ishonchsiz, faqat kuzatish uchun.
  • kontekst.run(req.log, next) — keyingi hamma kod joriyLog() bilan so'rov logger'ini oladi; xizmatga logger funksiya sifatida beriladi.
  • Fon ishi javobdan keyin ham o'sha ID bilan yozadi; tana o'qilgandan keyin ham kontekst saqlanadi (kanon testi).
  • Servislar orasida ID sarlavhada uzatiladi; standart — W3C traceparent (trace-id saqlanadi, span yangilanadi); OpenTelemetry buni avtomatlashtiradi.
  • Holat 44: 58/58 test, commit.

Keyingi dars: Backend test strategiyasi — unit, integration, e2e va contract testlar; nega backend'da integration test ustun va vazifalar-api ning 67 testi qaysi darajalarga bo'linadi.

Manbalar

  • W3C: Trace Context — Recommendation, 2021-11-23 (traceparent formati va yaroqsiz qiymatlar).
  • Node.js hujjati: AsyncLocalStorage (v24).
  • OpenTelemetry: Context propagation.
  • Kanon: vazifalar-api holat 44 (src/umumiy/log.ts, src/umumiy/sorov-id.ts, test/log.test.ts), natijalar/tekshiruv-44.txt.
Ulashish:Telegram'da

Izohlar (0)

Izoh yozish uchun kiring.

  • Hozircha izoh yo'q. Birinchi bo'ling!
Correlation ID: X-Request-Id, AsyncLocalStorage va so'rov kuzatuvi — IlmHamroh