npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

mongoose-storage-usage

v1.7.1

Published

Theo dõi và thống kê dung lượng MongoDB đã sử dụng theo từng tenant (id_app) cho các model Mongoose trong hệ thống multi-tenant, hỗ trợ cả file đính kèm và cập nhật realtime qua plugin.

Readme

mongoose-storage-usage

Theo dõi & thống kê dung lượng MongoDB đã sử dụng theo từng tenant (id_app) cho các model Mongoose trong hệ thống multi-tenant — hỗ trợ:

  • 🔍 Quét toàn bộ collection có field id_app, tính dung lượng thật (BSON size) theo từng tenant.
  • 📎 Cộng thêm dung lượng file đính kèm (field metadata dạng object/mảng có size, ví dụ upload qua multer).
  • ⚡ Plugin realtime: tự động $inc dung lượng ngay khi document được tạo/sửa/xoá, không cần đợi job quét.
  • ⏰ Cron job quét định kỳ để đối soát (reconcile) số liệu.
  • 🌐 Express router có sẵn (tuỳ chọn) để expose API.

Cài đặt

npm install mongoose-storage-usage

mongoosepeerDependency (bạn tự cài trong dự án của mình, thư viện không bundle mongoose riêng để tránh version conflict):

npm install mongoose

Nếu muốn dùng job quét định kỳ (registerCronJob), cài thêm:

npm install node-cron

Nếu muốn dùng Express router có sẵn (createExpressRouter), cài thêm:

npm install express

Yêu cầu MongoDB server >= 4.4 (dùng operator $bsonSize trong pipeline quét). Hỗ trợ mongoose >= 5.9.0; từ mongoose >= 6.0.0 số liệu dung lượng chính xác tuyệt đối, còn mongoose 5.x dùng ước lượng (xem phần "Xử lý sự cố cài đặt").

Bắt đầu nhanh

const mongoose = require('mongoose');
const { createStorageUsageTracker } = require('mongoose-storage-usage');

await mongoose.connect('mongodb://localhost:27017/my_db');

// 1. Khởi tạo tracker 1 lần khi start app
const storageUsage = createStorageUsageTracker(mongoose, {
  fileFieldsConfig: {
    File: ['file'], // model "File" có field "file" chứa {size, ...}
  },
});

// 2. Gắn plugin vào schema để tự động theo dõi realtime
const FileSchema = new mongoose.Schema({
  id_app: { type: String, required: true, index: true },
  file: {
    fieldname: String,
    originalname: String,
    name: String,
    extension: String,
    size: Number,
    truncated: Boolean,
    buffer: Buffer,
  },
});
FileSchema.plugin(storageUsage.plugin, { fileFields: ['file'] });
mongoose.model('File', FileSchema);

// 3. (Tuỳ chọn) chạy job quét định kỳ để đối soát
storageUsage.registerCronJob('0 2 * * *');

// 4. Truy vấn
const usage = await storageUsage.getTotalUsageByApp('company_123');

Xem ví dụ đầy đủ với Express tại examples/basic-usage.js.

Tích hợp với model quản lý tenant (App) để dễ kiểm soát hơn

Nếu bạn có sẵn 1 model quản lý danh sách tenant, ví dụ:

const AppSchema = new mongoose.Schema({
  name: String,
  address: String,
  expire_date: Date,
  trang_thai: { type: String, enum: ['0', '1'] }, // "0": Dùng thử, "1": Chính thức
});
const App = mongoose.model('App', AppSchema);

...và id_app ở các collection khác chính là App._id, truyền model này vào appModel khi tạo tracker:

const storageUsage = createStorageUsageTracker(mongoose, {
  autoApply: true,
  appModel: App, // <-- chỉ cần dòng này
  fileFieldsConfig: { File: ['file'] },
});

Khi đó, getUsageRanking(), getTotalUsageByApp()dashboard sẽ tự động:

  • Hiển thị tên công ty thay vì chỉ hiển thị id_app (chuỗi ObjectId khó đọc).
  • Hiển thị trạng thái dạng badge: "Dùng thử" / "Chính thức".
  • Hiển thị ngày hết hạn, tự tô màu vàng khi còn ≤7 ngày, đỏ khi đã hết hạn.
  • Cho phép tìm kiếm theo tên trong ô search của dashboard (thay vì chỉ tìm được theo id_app).
  • Hiển thị cả những tenant chưa có dữ liệu dung lượng nào (mới tạo, chưa upload gì) với dung lượng 0 — giúp nhìn thấy toàn cảnh tất cả tenant, không chỉ những tenant đã có dữ liệu.

Tên field không khớp mặc định?

Mặc định thư viện đọc đúng field name, address, expire_date, trang_thai. Nếu model của bạn đặt tên khác, khai báo lại qua appFields:

const storageUsage = createStorageUsageTracker(mongoose, {
  appModel: App,
  appFields: {
    name: 'company_name',      // field thực tế trên schema của bạn
    address: 'company_address',
    expireDate: 'expiredAt',
    status: 'status',
  },
  appStatusLabels: {
    '0': 'Dùng thử',
    '1': 'Chính thức',
    // có thể thêm giá trị khác, vd '2': 'Tạm khoá'
  },
});

Dùng trực tiếp trong code (không qua dashboard)

// Tìm app theo tên
const results = await storageUsage.searchApps('acme');

// Lấy toàn bộ danh sách app (kể cả chưa có dữ liệu dung lượng)
const allApps = await storageUsage.listAllApps();

// Ranking kèm cả app chưa dùng gì
const ranking = await storageUsage.getUsageRanking(100, { includeZeroUsageApps: true });

Không truyền appModel thì mọi thứ vẫn hoạt động như cũ (chỉ hiển thị id_app), không cần thay đổi gì thêm ở phần còn lại của code.

Tối ưu cho import/batch lớn (hàng ngàn document cùng lúc)

Khi insertMany()/updateMany()/deleteMany() xử lý hàng ngàn document cùng lúc (import dữ liệu, đồng bộ hàng loạt...), plugin áp dụng 3 cơ chế tối ưu để không làm chậm hoặc treo server, đồng thời giảm tối đa số lần ghi DB:

1. Ghi dồn (debounce + batch write) — mặc định luôn bật

Thay vì ghi DB ngay mỗi khi có thay đổi, mọi delta được cộng dồn trong bộ nhớ và gộp lại thành 1 lần bulkWrite() duy nhất sau mỗi 300ms (mặc định). Gọi insertMany() 20 lần liên tiếp (import theo từng chunk) → DB chỉ nhận đúng 1–2 lần ghi thay vì 20 lần.

const storageUsage = createStorageUsageTracker(mongoose, {
  bulkOptions: {
    flushIntervalMs: 300, // khoảng thời gian gộp trước khi ghi DB
    maxQueueSize: 5000,   // van an toàn: ép flush nếu số tenant/model đang chờ quá nhiều
  },
});

⚠️ Quan trọng khi shutdown: vì delta được ghi dồn (không ghi ngay), nếu process bị tắt đột ngột TRƯỚC khi cửa sổ debounce trigger, phần delta đang chờ có thể bị mất. Package đã tự động flush khi nhận SIGTERM/SIGINT/beforeExit, nhưng nếu bạn tự quản lý graceful shutdown, gọi thêm cho chắc:

await storageUsage.flushPendingWrites(); // ghi ngay mọi delta đang chờ, không đợi debounce

2. Chia nhỏ + nhường event loop (chunk + yield) — mặc định luôn bật

Việc tính kích thước cho hàng ngàn document trong 1 vòng lặp đồng bộ có thể chiếm dụng event loop liên tục, khiến các request khác trên cùng process bị "đứng khựng" (đây là nguyên nhân "gây sập server" khi import dữ liệu lớn). Plugin tự động chia batch thành từng khối (mặc định 500 document/khối) và nhường lại event loop giữa các khối bằng setImmediate().

bulkOptions: { chunkSize: 500 } // giảm xuống nếu server phải phản hồi cực nhanh, tăng lên nếu ưu tiên tốc độ import

3. Ước lượng bằng lấy mẫu cho batch cực lớn — mặc định bật từ 2000 document

Với insertMany() từ 2000 document trở lên (mặc định), package không tính kích thước từng document mà lấy mẫu (mặc định 200 document, trải đều toàn mảng) rồi ước lượng trung bình nhân với tổng số lượng. Vì các document trong 1 lần import thường có cấu trúc khá đồng nhất (cùng nguồn dữ liệu, cùng field), sai số thường không đáng kể, trong khi tốc độ nhanh hơn hàng chục đến hàng trăm lần.

bulkOptions: {
  sampleThreshold: 2000, // >= ngưỡng này thì chuyển sang lấy mẫu thay vì tính hết
  sampleSize: 200,       // số document lấy mẫu
}

Nếu bạn cần độ chính xác tuyệt đối hơn tốc độ (batch không quá lớn hoặc cần số liệu billing chính xác), tăng sampleThreshold lên rất cao (vd Infinity) để luôn tính chính xác từng document — khi đó vẫn được hưởng lợi từ cơ chế chunk + yield ở mục 2.

4. (Tuỳ chọn nâng cao) Offload sang worker_threads

Với batch cực lớn (chục ngàn document trở lên) xảy ra thường xuyên, có thể bật useWorkerThread để việc tính kích thước chạy hẳn trong 1 thread riêng — event loop chính gần như không bị ảnh hưởng gì:

bulkOptions: {
  useWorkerThread: true,
  workerThreshold: 2000, // chỉ dùng worker khi batch >= ngưỡng này
  workerPoolSize: 2,      // số worker thread tối đa
}

Đánh đổi cần biết: worker thread chạy trong tiến trình con, không truy cập được mongoose.mongo.BSON của main thread (mỗi thread có module registry riêng), nên bên trong worker package luôn dùng ước lượng qua JSON.stringify thay vì tính BSON chính xác — cùng cơ chế fallback dùng khi chạy trên mongoose < 6 (xem phần "Xử lý sự cố cài đặt"). Việc gửi dữ liệu qua ranh giới thread (postMessage) cũng có chi phí serialize riêng, nên useWorkerThread không phải lúc nào cũng nhanh hơn — với hầu hết trường hợp "vài ngàn document", cơ chế sampling ở mục 3 đã đủ nhanh mà không cần thêm độ phức tạp của worker. Chỉ bật khi đã đo đạc thực tế thấy cần thiết.

Khuyến nghị cấu hình theo quy mô

| Quy mô import | Cấu hình khuyến nghị | |---|---| | Vài chục – vài trăm document | Dùng mặc định, không cần chỉnh gì | | Vài ngàn document (< sampleThreshold) | Dùng mặc định — chunk + yield đã đủ mượt | | Chục ngàn document, thỉnh thoảng | Dùng mặc định (sampling tự kích hoạt) | | Chục ngàn document, xảy ra liên tục/nhiều luồng song song | Cân nhắc useWorkerThread: true | | Cần số liệu chính xác tuyệt đối, chấp nhận chậm hơn | Set sampleThreshold: Infinity |

Áp dụng plugin cho toàn bộ model (không cần khai báo từng model)

Có 3 cách, tuỳ vào cấu trúc project:

Cách 1 — autoApply: true (đơn giản nhất, khuyến nghị)

Truyền autoApply: true khi tạo tracker. Mọi schema định nghĩa sau dòng này sẽ tự động được gắn plugin, kể cả khi bạn không truyền fileFields — dung lượng file đính kèm được tra theo fileFieldsConfig (theo tên model) ngay tại thời điểm document thay đổi, nên vẫn hoạt động đúng dù bạn không gọi schema.plugin() thủ công.

// db.js — file khởi tạo kết nối, PHẢI require/gọi TRƯỚC khi các model được định nghĩa
const mongoose = require('mongoose');
const { createStorageUsageTracker } = require('mongoose-storage-usage');

mongoose.connect('mongodb://localhost:27017/my_db');

const storageUsage = createStorageUsageTracker(mongoose, {
  autoApply: true, // <-- chỉ cần dòng này
  fileFieldsConfig: {
    File: ['file'], // model "File" có field "file" chứa metadata upload
  },
});

module.exports = { mongoose, storageUsage };
// models/File.js — không cần gọi schema.plugin() gì cả
const { mongoose } = require('../db');

const FileSchema = new mongoose.Schema({
  id_app: { type: String, required: true },
  file: { size: Number, originalname: String /* ... */ },
});

module.exports = mongoose.model('File', FileSchema);

⚠️ Lưu ý thứ tự require quan trọng: createStorageUsageTracker(mongoose, { autoApply: true }) phải chạy trước khi bất kỳ file model nào được require. Cách an toàn nhất là đặt đoạn khởi tạo này trong file kết nối DB (db.js/database.js), và luôn require('./db') trước khi require('./models/...') ở entry point (app.js/server.js).

Cách 2 — tracker.applyToAllModels() (khi không kiểm soát được thứ tự require)

Nếu project đã có sẵn nhiều model được định nghĩa trước khi bạn tích hợp thư viện (hoặc không chắc thứ tự require), gọi hàm này sau khi tất cả model đã được require — nó sẽ tự quét những model có id_app mà chưa được gắn plugin:

require('./models/File');
require('./models/Order');
require('./models/Invoice');
// ... require hết các model

const storageUsage = createStorageUsageTracker(mongoose, {
  fileFieldsConfig: { File: ['file'] },
});

const appliedModels = storageUsage.applyToAllModels();
console.log('Đã gắn plugin cho:', appliedModels); // ví dụ: ['File', 'Order', 'Invoice']

Có thể gọi lại nhiều lần an toàn (model đã gắn rồi sẽ tự bỏ qua, không gắn trùng). Cũng dùng được để gắn plugin bổ sung cho model mới đăng ký sau này (ví dụ trong plugin hệ thống nạp module động).

Cách 3 — Gắn thủ công từng schema (kiểm soát chi tiết nhất)

Vẫn giữ được nếu bạn muốn tuỳ biến fileFields riêng theo từng model hoặc chỉ muốn áp dụng cho một số model nhất định:

FileSchema.plugin(storageUsage.plugin, { fileFields: ['file'] });
OrderSchema.plugin(storageUsage.plugin);

So sánh nhanh

| Cách | Khi nào dùng | Cần sửa từng model? | |---|---|---| | autoApply: true | Project mới, kiểm soát được thứ tự require | Không | | applyToAllModels() | Project có sẵn, model định nghĩa rải rác nhiều nơi | Không | | Gắn thủ công schema.plugin() | Chỉ muốn áp dụng cho 1 số model, cần tuỳ biến riêng | Có |

API

createStorageUsageTracker(mongooseInstance, options?)

Khởi tạo 1 tracker, gọi 1 lần cho mỗi mongoose instance/connection.

| Option | Mặc định | Mô tả | |---|---|---| | modelName | 'StorageUsage' | Tên model lưu kết quả thống kê | | collectionName | 'storage_usages' | Tên collection lưu kết quả | | connection | mongooseInstance.connection | Dùng khi project có nhiều connection (multi-db) | | fileFieldsConfig | {} | { [modelName]: string[] } — field chứa metadata file, dùng cho scanAllModels() và plugin khi gắn global | | autoApply | false | true = tự động gắn plugin cho mọi schema định nghĩa sau đó (xem phần "Áp dụng cho toàn bộ model") | | appModel | — | Model quản lý tenant (vd "App"), _id trùng id_app. Xem phần "Tích hợp với model quản lý tenant" | | appFields | {name:'name', address:'address', expireDate:'expire_date', status:'trang_thai'} | Map tên field thực tế trên appModel | | appStatusLabels | {'0':'Dùng thử', '1':'Chính thức'} | Map giá trị status -> nhãn hiển thị | | bulkOptions | xem bảng bên dưới | Tối ưu cho import/batch lớn — xem phần "Tối ưu cho import/batch lớn" | | distributedLock | true | Job cron chỉ chạy ở đúng 1 instance khi có nhiều cluster/worker — xem phần "Cron job an toàn khi chạy nhiều cluster" | | lockKey | 'scan_all_models' | Tên lock — các instance phải dùng chung giá trị này mới tranh chấp đúng | | lockTtlMs | 600000 (10 phút) | Lock tự hết hạn sau khoảng thời gian này nếu instance giữ lock crash không kịp release |

Chi tiết bulkOptions:

| Option | Mặc định | Mô tả | |---|---|---| | flushIntervalMs | 300 | Gộp nhiều lần $inc liên tiếp trong khoảng thời gian này thành 1 lần ghi DB | | maxQueueSize | 5000 | Van an toàn: ép flush ngay nếu số entry (id_app+model) đang chờ vượt ngưỡng | | chunkSize | 500 | Số document xử lý mỗi khối trước khi nhường lại event loop | | sampleThreshold | 2000 | insertMany với số lượng >= ngưỡng này sẽ ước lượng bằng lấy mẫu | | sampleSize | 200 | Số document lấy mẫu để ước lượng | | useWorkerThread | false | Offload tính size batch lớn sang worker_threads riêng | | workerThreshold | 2000 | Batch >= ngưỡng này mới dùng worker (khi đã bật useWorkerThread) | | workerPoolSize | 2 | Số worker thread tối đa trong pool |

Trả về object tracker gồm:

| Thuộc tính | Mô tả | |---|---| | StorageUsageModel | Model Mongoose lưu kết quả (dùng nếu cần query tuỳ biến) | | plugin | Mongoose plugin, gắn vào schema: schema.plugin(tracker.plugin, { fileFields: [...] }) | | applyToAllModels(connection?) | Gắn plugin cho các model đã compile mà chưa có plugin, trả về danh sách tên model đã gắn | | scanAllModels() | Quét toàn bộ model có id_app, ghi đè kết quả mới nhất | | scanModelStorage(model) | Quét 1 model cụ thể, trả về mảng {id_app, doc_count, storage_bytes} | | getTotalUsageByApp(idApp) | Tổng dung lượng của 1 tenant, chi tiết theo từng model (kèm thông tin App nếu có cấu hình) | | getUsageRanking(limit?, opts?) | Bảng xếp hạng tenant dùng nhiều dung lượng nhất. opts.includeZeroUsageApps = kèm cả app chưa có dữ liệu | | searchApps(query, limit?) | Tìm App theo tên (cần cấu hình appModel) | | listAllApps(limit?) | Lấy toàn bộ danh sách App (cần cấu hình appModel) | | hasAppModel | true/false — có đang cấu hình appModel hay không | | registerCronJob(cronExpr?, options?) | Đăng ký job quét định kỳ (cần cài node-cron) | | flushPendingWrites() | Ghi ngay mọi delta đang chờ trong hàng đợi debounce xuống DB, không đợi hết cửa sổ thời gian | | shutdown() | flushPendingWrites() + dừng worker thread pool (nếu có bật useWorkerThread) | | hasDistributedLock | true/false — cơ chế lock cho cron có đang bật hay không | | lockOwner | "hostname:pid" của process hiện tại, hữu ích khi debug xem instance nào đã chạy job | | createExpressRouter() | Trả về Express router có sẵn các route API (cần cài express) | | createDashboardRouter() | Trả về Express router phục vụ dashboard UI + API (cần cài express) |

tracker.plugin

schema.plugin(tracker.plugin, { fileFields: ['file'] });
  • Schema phải có field id_app, nếu không plugin sẽ bỏ qua (log warning).
  • fileFields: tên field chứa metadata file — hỗ trợ cả object đơn (file: {...}) lẫn mảng nhiều file (attachments: [{...}, {...}]).
  • Tự động bắt các thao tác: save(), deleteOne() (document & query), findOneAndUpdate/findOneAndReplace (kể cả upsert), updateOne/updateMany, findOneAndDelete/findOneAndRemove, deleteMany, insertMany.

Dashboard quản lý dung lượng (UI có sẵn, không cần build)

Package đi kèm 1 dashboard HTML tĩnh (thuần JS, không React/Vue, không bước build) để xem và quản lý dung lượng trực quan: bảng xếp hạng tenant, thanh đo mức dùng theo ngưỡng cảnh báo tự đặt, xem chi tiết theo từng model, và nút quét lại thủ công.

Mount vào app Express

app.use('/storage-dashboard', storageUsage.createDashboardRouter());

Truy cập http://localhost:3000/storage-dashboard/ là dùng được ngay — không cần cấu hình gì thêm. createDashboardRouter() tự gộp cả API cần thiết (/api/scan, /api/app/:id_app, /api/ranking) vào cùng router nên bạn không cần gọi thêm createExpressRouter() song song (trừ khi bạn muốn tách API ra domain/path khác cho việc khác).

⚠️ Dashboard không có xác thực — nên đặt sau middleware kiểm tra quyền admin của bạn trước khi mount, ví dụ:

app.use('/storage-dashboard', requireAdmin, storageUsage.createDashboardRouter());

Tính năng

  • Bảng xếp hạng tenant theo dung lượng đã dùng, kèm tổng số document.
  • Tên, trạng thái ("Dùng thử"/"Chính thức"), ngày hết hạn hiển thị trực tiếp trên mỗi dòng nếu có cấu hình appModel (xem phần "Tích hợp với model quản lý tenant" ở trên). Ngày hết hạn tự tô vàng khi còn ≤7 ngày, đỏ khi đã hết hạn. Không cấu hình appModel thì dashboard vẫn chạy bình thường, chỉ hiển thị id_app thay vì tên.
  • Ngưỡng cảnh báo tự đặt (MB) — nhập vào ô "Ngưỡng cảnh báo", các thanh đo sẽ chuyển màu xanh (an toàn) → vàng (gần ngưỡng, ≥60%) → đỏ (≥90% hoặc vượt ngưỡng). Nếu chưa đặt ngưỡng, màu được tính theo thứ hạng tương đối so với tenant dùng nhiều nhất. Ngưỡng được lưu ở localStorage trình duyệt để giữ lại giữa các lần mở dashboard.
  • Tìm kiếm theo tên hoặc id_app — khi có appModel, gõ tên công ty là lọc được ngay, không cần nhớ id_app.
  • Hiển thị cả tenant chưa có dữ liệu dung lượng (mới tạo, chưa upload gì) với dung lượng 0 — giúp nhìn thấy toàn cảnh tất cả tenant trong hệ thống.
  • Bấm vào 1 dòng để mở rộng xem chi tiết dung lượng theo từng model của tenant đó (lazy-load, chỉ gọi API khi mở), kèm địa chỉ nếu có.
  • Nút "Quét lại" gọi POST /api/scan để chạy quét thủ công ngay lập tức, tự refresh bảng xếp hạng sau khi quét xong.

Xác thực cho dashboard (khi mount kèm middleware như isAdmin)

Nếu bạn bảo vệ route dashboard bằng middleware yêu cầu token (JWT, header tuỳ biến...), ví dụ:

app.use('/storage-dashboard', isAdmin, storageUsage.createDashboardRouter());

...request tải TRANG dashboard (GET /storage-dashboard/) sẽ đi qua isAdmin bình thường — nhưng các lệnh fetch() mà chính dashboard tự gọi bên trong (api/ranking, api/scan...) thì không tự động có token đó, vì trình duyệt chỉ tự động gắn lại COOKIE cho các request cùng origin, chứ không tự gắn header như Authorization hay token tuỳ biến. Kết quả: trang tải được nhưng dữ liệu bên trong báo lỗi 401/403.

Giải pháp: dùng injectConfig để chuyển tiếp chính token đã giúp request tải trang đi qua isAdmin thành công, nhúng vào trang để JS phía client tự gắn lại cho mọi lệnh gọi API sau đó:

app.use('/storage-dashboard', isAdmin, storageUsage.createDashboardRouter({
  injectConfig: (req) => ({
    headers: { Authorization: req.headers.authorization }, // chuyển tiếp NGUYÊN VẸN header đã có
  }),
}));

injectConfig(req) nhận req của chính request tải trang (đã đi qua isAdmin nên có đầy đủ thông tin xác thực), trả về object gồm:

| Field | Mặc định | Mô tả | |---|---|---| | headers | {} | Header sẽ tự động gắn vào MỌI lệnh fetch nội bộ của dashboard | | tokenHeader | 'Authorization' | Chỉ dùng khi KHÔNG truyền headers trực tiếp — tên header sẽ gắn token vào (xem cách 2 bên dưới) | | tokenPrefix | 'Bearer ' | Tiền tố gắn trước token |

Tuỳ theo cách isAdmin của bạn lấy token, điều chỉnh injectConfig tương ứng:

// isAdmin đọc token từ cookie (đã có sẵn tên field, không cần header đặc biệt)
// -> KHÔNG cần injectConfig gì cả, browser tự động gửi lại cookie cho mọi fetch() cùng origin.

// isAdmin đọc token từ header tuỳ biến (không phải Authorization)
injectConfig: (req) => ({ headers: { 'x-access-token': req.headers['x-access-token'] } })

// isAdmin đọc token từ query string (?token=xxx) trên chính request gốc
injectConfig: (req) => ({ headers: { Authorization: 'Bearer ' + req.query.token } })

// isAdmin gắn sẵn token đã verify vào req.user.token sau khi decode JWT
injectConfig: (req) => ({ headers: { Authorization: 'Bearer ' + req.user.token } })

Cách 2 — không sửa code server (khi không tiện chỉnh route mount): nếu isAdmin của bạn CŨNG chấp nhận token qua query string, chỉ cần chia sẻ link kèm ?token=...:

https://your-app.com/storage-dashboard/?token=eyJhbGciOi...

Dashboard tự đọc token từ URL, lưu vào sessionStorage (mất khi đóng tab, không phải localStorage để giảm rủi ro lộ token lâu dài), và tự gắn thành header Authorization: Bearer <token> cho mọi lệnh fetch nội bộ ở các lần tải lại tiếp theo trong cùng tab — không cần gõ lại ?token= mỗi lần refresh. Đổi tên query param/header/tiền tố qua injectConfig nếu cần:

injectConfig: () => ({ tokenParam: 'accessToken', tokenHeader: 'x-access-token', tokenPrefix: '' })

Dùng dashboard độc lập (không qua Express)

Nếu không dùng Express, bạn vẫn có thể mở trực tiếp node_modules/mongoose-storage-usage/dashboard/index.html bằng bất kỳ static file server nào — chỉ cần tự expose 3 route API (scan, app/:id_app, ranking) ở đường dẫn api/ tương đối so với file HTML đó (dashboard gọi fetch('api/ranking'), không có / ở đầu, nên linh hoạt theo path mount).

Express router có sẵn (chỉ phần API, không kèm giao diện)

app.use('/api/storage-usage', storageUsage.createExpressRouter());

| Method | Path | Mô tả | |---|---|---| | POST | /scan | Trigger quét thủ công (nên giới hạn quyền admin) | | GET | /app/:id_app | Tổng dung lượng của 1 tenant (kèm thông tin App nếu có cấu hình appModel) | | GET | /ranking?limit=20&includeZeroUsageApps=1 | Bảng xếp hạng dung lượng; includeZeroUsageApps=1 để kèm cả app chưa có dữ liệu | | GET | /apps/search?q=acme&limit=20 | Tìm App theo tên (cần cấu hình appModel, trả về [] nếu không) |

Xử lý sự cố cài đặt

Lỗi liên quan đến bson (vd "Unsupported BSON version, bson types must be from bson X.x.x")

Package này không tự khai bson làm dependency riêng. Việc tính dung lượng dùng thẳng mongoose.mongo.BSON — chính bản BSON mà driver MongoDB nội bộ của mongoose bạn đang cài đặt sử dụng để giao tiếp với DB thật. Nhờ vậy sẽ không bao giờ có 2 bản bson khác nhau cùng tồn tại trong node_modules do package này gây ra, nên không thể xảy ra lỗi kiểu "Unsupported BSON version" (lỗi này xảy ra khi 1 object BSON được tạo bởi bản bson A nhưng bị bản bson B — không nhận diện được — cố serialize).

  • mongoose >= 6.0.0: dùng đường tính dung lượng chính xác tuyệt đối (qua chính BSON serializer của driver).
  • mongoose 5.x: mongoose.mongo.BSON chưa tồn tại ở bản này, package tự động chuyển sang ước lượng dung lượng qua JSON.stringify — vẫn hoạt động bình thường, không throw lỗi, chỉ in ra 1 dòng cảnh báo console. Số liệu vẫn dùng tốt để so sánh tương đối dung lượng giữa các tenant, nhưng không chính xác tuyệt đối như BSON thật (BSON có overhead riêng theo từng kiểu dữ liệu mà JSON không phản ánh được, ví dụ ObjectId chiếm 12 byte cố định nhưng khi JSON.stringify lại thành chuỗi hex 24 ký tự).
  • Nếu bạn thấy lỗi npm nhắc tới 1 version bson cụ thể không cài được (ví dụ [email protected]), rất có thể đó là con số không tồn tại trên npm registry (kiểm tra: npm view [email protected] sẽ trả 404) — npm chỉ đang hiển thị dải version (^4.2.2 chẳng hạn) mà mongoose yêu cầu, không phải version chính xác cần cài thủ công. Không cần khai bson trong package.json của bạn nữa — package này không cần bạn làm vậy.

⚠️ Giới hạn cần biết

  • Model.bulkWrite() hoặc thao tác qua native MongoDB driver (không qua Mongoose Query API) sẽ bỏ qua hoàn toàn middleware — plugin sẽ không biết để cập nhật.
  • $inc là atomic ở tầng MongoDB, nhưng plugin phải đọc "trạng thái cũ" bằng 1 query riêng trước khi ghi, nên trong tình huống hiếm có nhiều update chạy đồng thời trên cùng 1 document, số liệu có thể lệch nhẹ.
  • Vì 2 lý do trên, nên kết hợp plugin (realtime, gần đúng) với registerCronJob/scanAllModels() chạy định kỳ ban đêm như bước đối soát để đảm bảo số liệu luôn đúng tuyệt đối.
  • Quét toàn bộ collection lớn (scanAllModels) có thể tốn thời gian; nên chạy vào giờ thấp điểm. Đã bật allowDiskUse(true) để tránh lỗi memory limit 100MB khi group nhiều id_app.
  • Với batch insertMany lớn (>= sampleThreshold, mặc định 2000), số liệu là ước lượng bằng lấy mẫu, không phải tính chính xác từng document — càng thêm lý do nên duy trì scanAllModels() định kỳ để đối soát về số chính xác.
  • Ghi dồn (bulkOptions.flushIntervalMs) nghĩa là delta nằm trong bộ nhớ một khoảng ngắn trước khi ghi DB — nếu process bị kill -9 (không phải SIGTERM/SIGINT) đúng lúc đó, phần delta chưa flush sẽ mất. Đây cũng là lý do nên có job quét định kỳ chạy song song để tự sửa lại số liệu.

Cron job an toàn khi chạy nhiều cluster/worker (PM2, nhiều server/container)

Nếu app của bạn chạy nhiều instance cùng lúc (PM2 cluster mode với nhiều worker, hoặc nhiều server/container phía sau load balancer), mỗi instance đều tự gọi registerCronJob() khi khởi động — nếu không kiểm soát, N instance sẽ có N cron cùng kích hoạt gần như đồng thời, dẫn đến quét trùng lặp và tốn tài nguyên DB không cần thiết.

Mặc định từ bản này, cơ chế distributed lock đã tự động BẬT — không cần cấu hình gì thêm. Cơ chế dùng chung 1 collection nhỏ trên chính MongoDB đang có (không cần Redis hay hạ tầng khác): mỗi lượt cron kích hoạt, các instance cùng "giành" 1 document lock bằng thao tác atomic của MongoDB — chỉ đúng 1 instance thắng và thực sự chạy scanAllModels(), các instance còn lại tự động bỏ qua lượt đó, không cần bạn phân biệt "instance nào là master" theo bất kỳ cách nào (không phụ thuộc NODE_APP_INSTANCE/pm_id của PM2 — hoạt động đúng cả khi chạy nhiều server vật lý khác nhau, không riêng gì PM2 cluster trên 1 máy).

// KHÔNG cần thay đổi gì — cứ gọi registerCronJob() như cũ ở MỌI instance
storageUsage.registerCronJob('0 2 * * *');

Cấu hình (tuỳ chọn)

const storageUsage = createStorageUsageTracker(mongoose, {
  distributedLock: true,        // mặc định true — đặt false nếu CHẮC CHẮN chỉ chạy 1 instance
  lockKey: 'scan_all_models',   // các instance PHẢI dùng chung giá trị này mới tranh chấp đúng lock
  lockTtlMs: 10 * 60 * 1000,    // 10 phút (mặc định) — nên đặt LỚN HƠN thời gian quét thực tế
});
  • lockTtlMs: đóng vai trò an toàn — nếu instance đang giữ lock bị crash giữa chừng (không kịp release), lock tự hết hạn sau khoảng thời gian này để instance khác giành được ở lượt kế tiếp, tránh bị kẹt lock vĩnh viễn. Đặt quá ngắn so với thời gian quét thực tế có thể khiến 2 instance chạy chồng lấn nhau (lock hết hạn trong khi job đầu vẫn đang chạy); đặt quá dài thì sau crash phải chờ lâu hơn mới tự phục hồi. Job xong đều tự release() sớm, không cần đợi hết TTL trong điều kiện bình thường.
  • lockKey: chỉ cần đổi nếu bạn chạy NHIỀU tracker khác nhau (nhiều connection/nhiều app) cùng trỏ vào 1 database và muốn cron của chúng không tranh chấp lẫn nhau.

Debug / theo dõi

console.log(storageUsage.lockOwner); // "hostname:pid" của process hiện tại
storageUsage.registerCronJob('0 2 * * *', {
  onSkipped: (reason) => console.log('Bỏ qua lượt này:', reason), // instance khác đã chạy
  onSuccess: (results) => console.log('Instance này vừa chạy job:', storageUsage.lockOwner),
});

Về lỗi E11000 duplicate key ... storage_usage_locks

Nếu bạn thấy lỗi này xuất hiện trong log MongoDB, đây là hành vi bình thường của cơ chế tranh chấp lock, không phải bug — document lock chỉ được tạo (upsert) đúng 1 lần duy nhất trong suốt vòng đời cluster (lúc bootstrap, khi nhiều instance cùng khởi động lần đầu và cùng cố tạo document lock). Sau lần đó, document tồn tại vĩnh viễn (không bị xoá khi release), nên các lượt tranh chấp tiếp theo chỉ dùng update thông thường — không bao giờ sinh thêm lỗi duplicate key nữa dù chạy bao nhiêu lượt cron. Nếu bạn thấy lỗi này lặp lại liên tục ở MỌI lượt cron (không chỉ 1 lần khi mới deploy), hãy cập nhật lên bản mới nhất của package — các bản trước 1.7.1 xoá hẳn document khi release, khiến lỗi này lặp lại ở mỗi lượt tranh chấp.

⚠️ Lưu ý: cơ chế lock chỉ áp dụng cho cron job (registerCronJob). Gọi trực tiếp scanAllModels() (ví dụ qua route POST /api/scan khi bấm nút "Quét lại" trên dashboard) KHÔNG bị chặn bởi lock — đây là hành vi có chủ đích, vì đó là hành động thủ công của người dùng, không nên bị âm thầm bỏ qua.

Publish package này lên npm (cho maintainer)

Nếu bạn muốn tự publish lại package (ví dụ đổi tên, thêm tính năng riêng):

cd mongoose-storage-usage

# 1. Đăng nhập npm (chỉ cần 1 lần trên máy)
npm login

# 2. Kiểm tra tên package chưa bị trùng
npm view mongoose-storage-usage version   # phải trả về lỗi 404 nghĩa là tên còn trống

# 3. (Tuỳ chọn) sửa "name", "author", "repository" trong package.json
#    Nếu tên bị trùng, đổi thành package có scope: "@ten-cua-ban/mongoose-storage-usage"

# 4. Kiểm tra danh sách file sẽ được đóng gói
npm pack --dry-run

# 5. Publish
npm publish            # hoặc: npm publish --access public (nếu dùng scoped package)

Từ lần sau, khi cập nhật code, tăng version rồi publish lại:

npm version patch   # hoặc minor / major theo mức độ thay đổi
npm publish

Sau khi publish, cài vào bất kỳ project nào khác:

npm install mongoose-storage-usage

License

MIT