jskelet 0.3.0 → 0.3.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +11 -0
- package/docs/07-yapilandirma.md +45 -0
- package/docs/10-dagitim.md +5 -0
- package/docs/en/07-configuration.md +45 -0
- package/docs/en/10-deployment.md +5 -0
- package/package.json +1 -1
- package/src/client/admin/i18n.js +14 -6
- package/src/client/admin/panel.css +7 -2
- package/src/client/admin/panel.html +9 -0
- package/src/client/admin/panel.js +20 -11
- package/src/config/defaults.js +38 -0
- package/src/config/index.js +91 -2
- package/src/log.mjs +73 -43
- package/src/server/admin/snapshot.js +116 -1
- package/src/server/create-app.js +23 -6
- package/src/server/logs/access-middleware.js +66 -0
- package/src/server/logs/file-sink.js +66 -0
- package/src/server/logs/pipeline.js +164 -0
- package/src/server/logs/s3-put.js +214 -0
- package/src/server/logs/s3-sink.js +112 -0
|
@@ -2,9 +2,12 @@
|
|
|
2
2
|
* Admin paneli cache / host / process dökümü.
|
|
3
3
|
*
|
|
4
4
|
* HTML gövdesi ve veri değerleri dönmez — yalnızca meta.
|
|
5
|
+
* System sayfasındaki çubuklar **bu sürecin** payını gösterir (makine
|
|
6
|
+
* toplamına oranla), host'un genel doluluğunu değil.
|
|
5
7
|
*/
|
|
6
8
|
import fs from "node:fs";
|
|
7
9
|
import os from "node:os";
|
|
10
|
+
import path from "node:path";
|
|
8
11
|
import process from "node:process";
|
|
9
12
|
import { getConfig } from "../../config/index.js";
|
|
10
13
|
import {
|
|
@@ -29,8 +32,25 @@ import { prewarmProgress } from "../prewarm.js";
|
|
|
29
32
|
/** Listelerin üst sınırı: veri önbelleğinde on binlerce anahtar olabiliyor. */
|
|
30
33
|
const MAX_LISTED = 500;
|
|
31
34
|
|
|
35
|
+
/** Disk ölçümünde atlanan dizinler — node_modules her poll'da taranmasın. */
|
|
36
|
+
const SKIP_DIRS = new Set([
|
|
37
|
+
"node_modules",
|
|
38
|
+
".git",
|
|
39
|
+
".hg",
|
|
40
|
+
".svn",
|
|
41
|
+
"coverage",
|
|
42
|
+
".turbo",
|
|
43
|
+
".next",
|
|
44
|
+
]);
|
|
45
|
+
|
|
32
46
|
export const BOOT_ID = `${Date.now()}-${process.pid}`;
|
|
33
47
|
|
|
48
|
+
/** @type {{ usage: NodeJS.CpuUsage; at: number } | null} */
|
|
49
|
+
let cpuSample = null;
|
|
50
|
+
|
|
51
|
+
/** @type {{ at: number, bytes: number, root: string } | null} */
|
|
52
|
+
let diskSample = null;
|
|
53
|
+
|
|
34
54
|
/**
|
|
35
55
|
* @param {string} query Anahtar filtresi (boş → filtre yok).
|
|
36
56
|
* @returns {Promise<object>}
|
|
@@ -46,6 +66,9 @@ export async function snapshot(query) {
|
|
|
46
66
|
const htmlMatched = html.filter(matches);
|
|
47
67
|
const dataMatched = data.filter(matches);
|
|
48
68
|
const usage = process.memoryUsage();
|
|
69
|
+
const host = await hostStatus();
|
|
70
|
+
const cpuPercent = sampleProcessCpu(host.cpus);
|
|
71
|
+
const appDiskBytes = await sampleAppDisk(getConfig().root);
|
|
49
72
|
|
|
50
73
|
return {
|
|
51
74
|
boot: BOOT_ID,
|
|
@@ -56,13 +79,17 @@ export async function snapshot(query) {
|
|
|
56
79
|
node: FRAMEWORK_NODE_RANGE,
|
|
57
80
|
homepage: FRAMEWORK_HOMEPAGE,
|
|
58
81
|
},
|
|
59
|
-
host
|
|
82
|
+
host,
|
|
60
83
|
process: {
|
|
61
84
|
pid: process.pid,
|
|
62
85
|
node: process.version,
|
|
63
86
|
uptime: process.uptime(),
|
|
64
87
|
env: process.env.NODE_ENV ?? "production",
|
|
65
88
|
memory: { rss: usage.rss, heapUsed: usage.heapUsed },
|
|
89
|
+
/** 0–100: bu sürecin tüm çekirdek kapasitesine oranı. */
|
|
90
|
+
cpuPercent,
|
|
91
|
+
/** Proje kökünün (node_modules hariç) disk boyutu. */
|
|
92
|
+
diskBytes: appDiskBytes,
|
|
66
93
|
},
|
|
67
94
|
html: {
|
|
68
95
|
size: getHtmlCacheSize(),
|
|
@@ -94,6 +121,94 @@ export async function snapshot(query) {
|
|
|
94
121
|
};
|
|
95
122
|
}
|
|
96
123
|
|
|
124
|
+
/**
|
|
125
|
+
* İki örnek arasındaki `process.cpuUsage` farkından süreç CPU yüzdesi.
|
|
126
|
+
* İlk çağrıda örnek alınır ve 0 döner — panelin bir sonraki turunda anlamlı.
|
|
127
|
+
*
|
|
128
|
+
* @param {number} cores
|
|
129
|
+
* @returns {number}
|
|
130
|
+
*/
|
|
131
|
+
function sampleProcessCpu(cores) {
|
|
132
|
+
const now = Date.now();
|
|
133
|
+
const current = process.cpuUsage();
|
|
134
|
+
|
|
135
|
+
if (!cpuSample) {
|
|
136
|
+
cpuSample = { usage: current, at: now };
|
|
137
|
+
return 0;
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
const elapsedMs = now - cpuSample.at;
|
|
141
|
+
const delta = process.cpuUsage(cpuSample.usage);
|
|
142
|
+
cpuSample = { usage: current, at: now };
|
|
143
|
+
|
|
144
|
+
if (elapsedMs <= 0 || cores <= 0) return 0;
|
|
145
|
+
|
|
146
|
+
// user+system mikro-saniye → ms; duvar süresi × çekirdek = kapasite.
|
|
147
|
+
const cpuMs = (delta.user + delta.system) / 1000;
|
|
148
|
+
const capacityMs = elapsedMs * cores;
|
|
149
|
+
return Math.max(0, Math.min(100, (cpuMs / capacityMs) * 100));
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
/**
|
|
153
|
+
* Proje kökünün boyutu (ağır dizinler atlanır). 60 sn önbellek.
|
|
154
|
+
*
|
|
155
|
+
* @param {string} root
|
|
156
|
+
* @returns {Promise<number>}
|
|
157
|
+
*/
|
|
158
|
+
async function sampleAppDisk(root) {
|
|
159
|
+
if (diskSample && diskSample.root === root && Date.now() - diskSample.at < 60_000) {
|
|
160
|
+
return diskSample.bytes;
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
const bytes = await measureDir(root);
|
|
164
|
+
diskSample = { at: Date.now(), bytes, root };
|
|
165
|
+
return bytes;
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
/**
|
|
169
|
+
* @param {string} dir
|
|
170
|
+
* @returns {Promise<number>}
|
|
171
|
+
*/
|
|
172
|
+
async function measureDir(dir) {
|
|
173
|
+
let total = 0;
|
|
174
|
+
|
|
175
|
+
/** @type {string[]} */
|
|
176
|
+
const stack = [dir];
|
|
177
|
+
|
|
178
|
+
while (stack.length) {
|
|
179
|
+
const current = stack.pop();
|
|
180
|
+
if (!current) continue;
|
|
181
|
+
|
|
182
|
+
let entries;
|
|
183
|
+
try {
|
|
184
|
+
entries = await fs.promises.readdir(current, { withFileTypes: true });
|
|
185
|
+
} catch {
|
|
186
|
+
continue;
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
for (const entry of entries) {
|
|
190
|
+
if (entry.name === "." || entry.name === "..") continue;
|
|
191
|
+
if (entry.isDirectory() && SKIP_DIRS.has(entry.name)) continue;
|
|
192
|
+
|
|
193
|
+
const full = path.join(current, entry.name);
|
|
194
|
+
if (entry.isDirectory()) {
|
|
195
|
+
stack.push(full);
|
|
196
|
+
continue;
|
|
197
|
+
}
|
|
198
|
+
if (!entry.isFile()) continue;
|
|
199
|
+
|
|
200
|
+
try {
|
|
201
|
+
const stats = await fs.promises.stat(full);
|
|
202
|
+
total += stats.size;
|
|
203
|
+
} catch {
|
|
204
|
+
// silinmiş / erişilemeyen dosya
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
return total;
|
|
210
|
+
}
|
|
211
|
+
|
|
97
212
|
/**
|
|
98
213
|
* @returns {Promise<object>}
|
|
99
214
|
*/
|
package/src/server/create-app.js
CHANGED
|
@@ -10,6 +10,8 @@
|
|
|
10
10
|
* 3. headers → devGate → redirects → trailingSlash — gate'in 404'ü
|
|
11
11
|
* redirect'ten önce; trailingSlash config redirects'ten sonra, böylece
|
|
12
12
|
* açık kurallar istenen yolu önce görür.
|
|
13
|
+
* 3b. access log (açıksa) — tamamlanan yanıtların süresi; admin/prewarm
|
|
14
|
+
* içeride elenir.
|
|
13
15
|
* 4. staticPrecompressed → express.static — build'de üretilmiş `.br`/`.gz`
|
|
14
16
|
* kopyalar varsa onlar servis edilir (kalite 11), yoksa istek altındaki
|
|
15
17
|
* static'e düşer ve middleware anında sıkıştırır (kalite 5).
|
|
@@ -42,6 +44,7 @@ import { isPrewarmRequest, notePrewarmError, startPrewarm } from "./prewarm.js";
|
|
|
42
44
|
import { trackUpstreamFetch } from "./upstream-tracking.js";
|
|
43
45
|
import { configureUpstreamLimiter } from "./upstream-limiter.js";
|
|
44
46
|
import { connectRedis, disconnectRedis } from "./redis.js";
|
|
47
|
+
import { configureLogs, flushLogs, closeLogs } from "./logs/pipeline.js";
|
|
45
48
|
import { isNotFoundError, isRedirectError } from "../http/control-flow.js";
|
|
46
49
|
|
|
47
50
|
/**
|
|
@@ -68,6 +71,10 @@ export async function createApp(options = {}) {
|
|
|
68
71
|
// dokunmayan, tamamen opsiyonel bir adım.
|
|
69
72
|
await connectRedis(config);
|
|
70
73
|
|
|
74
|
+
// Kalıcı log sink'leri (dosya / S3). Credential eksikse uyarı + no-op;
|
|
75
|
+
// site düşmez. Access middleware gerektiğinde biraz aşağıda mount edilir.
|
|
76
|
+
const { accessLog } = await configureLogs(config);
|
|
77
|
+
|
|
71
78
|
const app = express();
|
|
72
79
|
|
|
73
80
|
app.disable("x-powered-by");
|
|
@@ -90,6 +97,13 @@ export async function createApp(options = {}) {
|
|
|
90
97
|
app.use(redirects());
|
|
91
98
|
app.use(trailingSlash());
|
|
92
99
|
|
|
100
|
+
// Access log: headers/redirects sonrası, statikten önce — böylece
|
|
101
|
+
// tamamlanan her yanıt (304 dahil) süre alır; admin/prewarm içeride elenir.
|
|
102
|
+
if (accessLog) {
|
|
103
|
+
const { accessLogMiddleware } = await import("./logs/access-middleware.js");
|
|
104
|
+
app.use(accessLogMiddleware());
|
|
105
|
+
}
|
|
106
|
+
|
|
93
107
|
app.use(staticPrecompressed(config.dirs.public));
|
|
94
108
|
app.use(
|
|
95
109
|
express.static(config.dirs.public, {
|
|
@@ -248,8 +262,10 @@ export async function startServer(options = {}) {
|
|
|
248
262
|
/**
|
|
249
263
|
* `SIGTERM`/`SIGINT` sonrası düzenli kapanış.
|
|
250
264
|
*
|
|
251
|
-
* Kapatılması gereken
|
|
252
|
-
* bitmesini bekliyor; sert `disconnect` yarıda
|
|
265
|
+
* Kapatılması gereken dış kaynaklar Redis ve log sink buffer'ları. Redis
|
|
266
|
+
* `quit` uçuştaki komutların bitmesini bekliyor; sert `disconnect` yarıda
|
|
267
|
+
* kalan bir `SET` bırakabiliyor. S3 sink kapanışta kalan batch'i PutObject
|
|
268
|
+
* ile gönderir.
|
|
253
269
|
*
|
|
254
270
|
* Açık HTTP bağlantıları **beklenmez**. `close()` tek başına yalnızca yeni
|
|
255
271
|
* bağlantıyı reddediyor; keep-alive bir istemci ya da dev panelinin
|
|
@@ -276,10 +292,11 @@ function attachShutdown(server) {
|
|
|
276
292
|
server.close();
|
|
277
293
|
server.closeAllConnections?.();
|
|
278
294
|
|
|
279
|
-
void disconnectRedis().
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
295
|
+
void Promise.all([disconnectRedis(), flushLogs().then(() => closeLogs())])
|
|
296
|
+
.finally(() => {
|
|
297
|
+
clearTimeout(timer);
|
|
298
|
+
process.exit(0);
|
|
299
|
+
});
|
|
283
300
|
};
|
|
284
301
|
|
|
285
302
|
process.once("SIGTERM", shutdown);
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* HTTP access log middleware.
|
|
3
|
+
*
|
|
4
|
+
* Admin ve prewarm yolları atlanır. Kayıt `log.http` ile stdout'a (console
|
|
5
|
+
* açıksa) ve `acceptLogEntry` ile sink'lere gider. `emitHttp` kapalı olduğu
|
|
6
|
+
* için `log.http` abonelere ikinci kez yazmaz.
|
|
7
|
+
*/
|
|
8
|
+
import * as log from "../../log.mjs";
|
|
9
|
+
import { getConfig } from "../../config/index.js";
|
|
10
|
+
import { acceptLogEntry } from "./pipeline.js";
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* @param {{ basePath?: string }} [options]
|
|
14
|
+
* @returns {import('express').RequestHandler}
|
|
15
|
+
*/
|
|
16
|
+
export function accessLogMiddleware(options = {}) {
|
|
17
|
+
const config = getConfig();
|
|
18
|
+
const brand = config.brand;
|
|
19
|
+
const basePath =
|
|
20
|
+
options.basePath ??
|
|
21
|
+
(config.admin.enabled ? config.admin.basePath : null);
|
|
22
|
+
|
|
23
|
+
/** @type {import('express').RequestHandler} */
|
|
24
|
+
return (req, res, next) => {
|
|
25
|
+
const pathname = req.path || "";
|
|
26
|
+
if (basePath && (pathname === basePath || pathname.startsWith(`${basePath}/`))) {
|
|
27
|
+
return next();
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
if (req.headers["user-agent"] === brand.prewarmUserAgent) {
|
|
31
|
+
return next();
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
const started = process.hrtime.bigint();
|
|
35
|
+
|
|
36
|
+
res.on("finish", () => {
|
|
37
|
+
const ms = Number(process.hrtime.bigint() - started) / 1e6;
|
|
38
|
+
const cache = /** @type {string | null} */ (
|
|
39
|
+
res.getHeader(brand.cacheHeader) ?? null
|
|
40
|
+
);
|
|
41
|
+
|
|
42
|
+
const info = {
|
|
43
|
+
method: req.method,
|
|
44
|
+
url: req.originalUrl,
|
|
45
|
+
status: res.statusCode,
|
|
46
|
+
ms,
|
|
47
|
+
cache,
|
|
48
|
+
};
|
|
49
|
+
|
|
50
|
+
// Stdout (console açıksa). emitHttp kapalı → subscribe çiftlemez.
|
|
51
|
+
log.http(info);
|
|
52
|
+
|
|
53
|
+
acceptLogEntry({
|
|
54
|
+
kind: "http",
|
|
55
|
+
method: info.method,
|
|
56
|
+
url: info.url,
|
|
57
|
+
path: req.path,
|
|
58
|
+
status: info.status,
|
|
59
|
+
ms: info.ms,
|
|
60
|
+
cache: info.cache,
|
|
61
|
+
});
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
next();
|
|
65
|
+
};
|
|
66
|
+
}
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Günlük rotasyonlu NDJSON dosya sink'i.
|
|
3
|
+
*
|
|
4
|
+
* Yol `path.resolve(root, dir)` ile bulunur — `getConfig().dirs` şişirilmez;
|
|
5
|
+
* log dizini opsiyonel bir yüzey ve çoğu kurulumda hiç açılmaz.
|
|
6
|
+
*/
|
|
7
|
+
import fs from "node:fs/promises";
|
|
8
|
+
import path from "node:path";
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* @typedef {{ write: (entry: Record<string, unknown>) => Promise<void>,
|
|
12
|
+
* flush: () => Promise<void>, close: () => Promise<void> }} LogSink
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* @param {{ root: string, dir: string }} options
|
|
17
|
+
* @returns {LogSink}
|
|
18
|
+
*/
|
|
19
|
+
export function createFileSink(options) {
|
|
20
|
+
const baseDir = path.resolve(options.root, options.dir);
|
|
21
|
+
/** @type {string | null} */
|
|
22
|
+
let currentDay = null;
|
|
23
|
+
/** @type {string | null} */
|
|
24
|
+
let currentPath = null;
|
|
25
|
+
let ready = false;
|
|
26
|
+
|
|
27
|
+
/** @returns {string} */
|
|
28
|
+
function dayStamp(date = new Date()) {
|
|
29
|
+
const y = date.getUTCFullYear();
|
|
30
|
+
const m = String(date.getUTCMonth() + 1).padStart(2, "0");
|
|
31
|
+
const d = String(date.getUTCDate()).padStart(2, "0");
|
|
32
|
+
return `${y}-${m}-${d}`;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* @param {string} day
|
|
37
|
+
* @returns {Promise<string>}
|
|
38
|
+
*/
|
|
39
|
+
async function ensureFile(day) {
|
|
40
|
+
if (!ready) {
|
|
41
|
+
await fs.mkdir(baseDir, { recursive: true });
|
|
42
|
+
ready = true;
|
|
43
|
+
}
|
|
44
|
+
if (currentDay === day && currentPath) return currentPath;
|
|
45
|
+
currentDay = day;
|
|
46
|
+
currentPath = path.join(baseDir, `jskelet-${day}.log`);
|
|
47
|
+
return currentPath;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
return {
|
|
51
|
+
async write(entry) {
|
|
52
|
+
const day = dayStamp(
|
|
53
|
+
typeof entry.at === "number" ? new Date(entry.at) : new Date(),
|
|
54
|
+
);
|
|
55
|
+
const file = await ensureFile(day);
|
|
56
|
+
await fs.appendFile(file, `${JSON.stringify(entry)}\n`, "utf8");
|
|
57
|
+
},
|
|
58
|
+
async flush() {
|
|
59
|
+
// appendFile senkron flush eder; ekstra iş yok.
|
|
60
|
+
},
|
|
61
|
+
async close() {
|
|
62
|
+
currentDay = null;
|
|
63
|
+
currentPath = null;
|
|
64
|
+
},
|
|
65
|
+
};
|
|
66
|
+
}
|
|
@@ -0,0 +1,164 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Structured log pipeline: `log.subscribe` → kinds filtresi → file / S3.
|
|
3
|
+
*
|
|
4
|
+
* Sink yokken abone olunmaz: `emitLog` boş Set üzerinde döner ve stdout
|
|
5
|
+
* yoluna maliyet eklemez. Credential eksikse S3 açılmaz, site ayağa kalkar.
|
|
6
|
+
*/
|
|
7
|
+
import process from "node:process";
|
|
8
|
+
import * as log from "../../log.mjs";
|
|
9
|
+
import { createFileSink } from "./file-sink.js";
|
|
10
|
+
import { createS3Sink } from "./s3-sink.js";
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* @typedef {import('../../config/index.js').LogsConfig} LogsConfig
|
|
14
|
+
* @typedef {import('../../config/index.js').ResolvedConfig} ResolvedConfig
|
|
15
|
+
* @typedef {import('./file-sink.js').LogSink} LogSink
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
/** @type {(() => void) | null} */
|
|
19
|
+
let unsubscribe = null;
|
|
20
|
+
/** @type {LogSink[]} */
|
|
21
|
+
let sinks = [];
|
|
22
|
+
/** @type {Set<string>} */
|
|
23
|
+
let kindSet = new Set();
|
|
24
|
+
let accessMounted = false;
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* @returns {import('./s3-put.js').AwsCredentials | null}
|
|
28
|
+
*/
|
|
29
|
+
function readCredentials() {
|
|
30
|
+
const accessKeyId = process.env.AWS_ACCESS_KEY_ID;
|
|
31
|
+
const secretAccessKey = process.env.AWS_SECRET_ACCESS_KEY;
|
|
32
|
+
if (!accessKeyId || !secretAccessKey) return null;
|
|
33
|
+
return {
|
|
34
|
+
accessKeyId,
|
|
35
|
+
secretAccessKey,
|
|
36
|
+
sessionToken: process.env.AWS_SESSION_TOKEN || null,
|
|
37
|
+
};
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* Access middleware mount edilmeli mi?
|
|
42
|
+
*
|
|
43
|
+
* Dev'de yalnızca console isteniyorsa mount edilmez — HTML satırlarını
|
|
44
|
+
* zaten devtools basıyor; çift kayıt olmasın. Sink açıksa ya da production
|
|
45
|
+
* console access log istiyorsa mount edilir.
|
|
46
|
+
*
|
|
47
|
+
* @param {LogsConfig} logs
|
|
48
|
+
* @returns {boolean}
|
|
49
|
+
*/
|
|
50
|
+
export function shouldMountAccessLog(logs) {
|
|
51
|
+
if (!logs.kinds.includes("http")) return false;
|
|
52
|
+
if (logs.file.enabled || logs.s3.enabled) return true;
|
|
53
|
+
return logs.console && process.env.NODE_ENV !== "development";
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* @param {ResolvedConfig} config
|
|
58
|
+
* @returns {Promise<{ accessLog: boolean }>}
|
|
59
|
+
*/
|
|
60
|
+
export async function configureLogs(config) {
|
|
61
|
+
await closeLogs();
|
|
62
|
+
|
|
63
|
+
const logs = config.logs;
|
|
64
|
+
kindSet = new Set(logs.kinds);
|
|
65
|
+
accessMounted = shouldMountAccessLog(logs);
|
|
66
|
+
|
|
67
|
+
log.configureLog({
|
|
68
|
+
console: logs.console,
|
|
69
|
+
// Access middleware HTTP'yi kendisi pipeline'a yazar; log.http yalnızca
|
|
70
|
+
// stdout için kalsın — aksi hâlde HTML istekleri (devtools) çiftlenir.
|
|
71
|
+
emitHttp: !accessMounted,
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
/** @type {LogSink[]} */
|
|
75
|
+
const next = [];
|
|
76
|
+
|
|
77
|
+
if (logs.file.enabled) {
|
|
78
|
+
next.push(createFileSink({ root: config.root, dir: logs.file.dir }));
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
if (logs.s3.enabled) {
|
|
82
|
+
const credentials = readCredentials();
|
|
83
|
+
if (!logs.s3.bucket) {
|
|
84
|
+
console.warn("[logs] s3.enabled but no bucket (set JSKELET_LOG_BUCKET or logs.s3.bucket)");
|
|
85
|
+
} else if (!logs.s3.region) {
|
|
86
|
+
console.warn("[logs] s3.enabled but no region (set AWS_REGION or logs.s3.region)");
|
|
87
|
+
} else if (!credentials) {
|
|
88
|
+
console.warn(
|
|
89
|
+
"[logs] s3.enabled but AWS_ACCESS_KEY_ID / AWS_SECRET_ACCESS_KEY missing — S3 sink disabled",
|
|
90
|
+
);
|
|
91
|
+
} else {
|
|
92
|
+
next.push(
|
|
93
|
+
createS3Sink({
|
|
94
|
+
bucket: logs.s3.bucket,
|
|
95
|
+
prefix: logs.s3.prefix,
|
|
96
|
+
region: logs.s3.region,
|
|
97
|
+
endpoint: logs.s3.endpoint,
|
|
98
|
+
credentials,
|
|
99
|
+
flushIntervalMs: logs.s3.flushIntervalMs,
|
|
100
|
+
maxBatch: logs.s3.maxBatch,
|
|
101
|
+
}),
|
|
102
|
+
);
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
sinks = next;
|
|
107
|
+
|
|
108
|
+
if (sinks.length) {
|
|
109
|
+
unsubscribe = log.subscribe((raw) => {
|
|
110
|
+
acceptLogEntry(raw);
|
|
111
|
+
});
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
return { accessLog: accessMounted };
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/**
|
|
118
|
+
* Access middleware ve `log.subscribe` ortak giriş noktası.
|
|
119
|
+
*
|
|
120
|
+
* @param {Record<string, unknown>} raw
|
|
121
|
+
*/
|
|
122
|
+
export function acceptLogEntry(raw) {
|
|
123
|
+
if (!sinks.length) return;
|
|
124
|
+
|
|
125
|
+
const kind = typeof raw.kind === "string" ? raw.kind : "event";
|
|
126
|
+
if (!kindSet.has(kind)) return;
|
|
127
|
+
|
|
128
|
+
const entry = {
|
|
129
|
+
...raw,
|
|
130
|
+
kind,
|
|
131
|
+
at: typeof raw.at === "number" ? raw.at : Date.now(),
|
|
132
|
+
};
|
|
133
|
+
|
|
134
|
+
for (const sink of sinks) {
|
|
135
|
+
void sink.write(entry).catch((error) => {
|
|
136
|
+
console.warn("[logs] sink write failed", error);
|
|
137
|
+
});
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
/**
|
|
142
|
+
* Kapanışta buffer'ları boşaltır.
|
|
143
|
+
*
|
|
144
|
+
* @returns {Promise<void>}
|
|
145
|
+
*/
|
|
146
|
+
export async function flushLogs() {
|
|
147
|
+
await Promise.all(sinks.map((sink) => sink.flush()));
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
/**
|
|
151
|
+
* @returns {Promise<void>}
|
|
152
|
+
*/
|
|
153
|
+
export async function closeLogs() {
|
|
154
|
+
if (unsubscribe) {
|
|
155
|
+
unsubscribe();
|
|
156
|
+
unsubscribe = null;
|
|
157
|
+
}
|
|
158
|
+
const closing = sinks;
|
|
159
|
+
sinks = [];
|
|
160
|
+
await Promise.all(closing.map((sink) => sink.close()));
|
|
161
|
+
kindSet = new Set();
|
|
162
|
+
accessMounted = false;
|
|
163
|
+
log.configureLog({ console: true, emitHttp: true });
|
|
164
|
+
}
|