');
+ expect(categoryRow).not.toContain('→');
+ });
+});
diff --git a/packages-private/diagnostics-shared/test/html.test.ts b/packages-private/diagnostics-shared/test/html.test.ts
new file mode 100644
index 00000000000..6e9dbc48b0b
--- /dev/null
+++ b/packages-private/diagnostics-shared/test/html.test.ts
@@ -0,0 +1,63 @@
+/*
+ * SPDX-FileCopyrightText: syuilo and misskey-project
+ * SPDX-License-Identifier: AGPL-3.0-only
+ */
+
+import { describe, expect, test } from 'vitest';
+import { html, joinHtml, raw, Raw } from '../src/html';
+
+describe('html', () => {
+ test('escapes interpolated values by default', () => {
+ const value = '';
+ expect(String(html`${value}
`)).toBe('<script>alert(1)</script>
');
+ });
+
+ test('escapes values used in attribute position', () => {
+ const url = 'x">';
+ expect(String(html``)).toBe('');
+ });
+
+ test('leaves the static parts of the template untouched', () => {
+ expect(String(html`x
`)).toBe('x
');
+ });
+
+ test('renders nullish values as an empty string', () => {
+ expect(String(html`${null}${undefined}
`)).toBe('');
+ });
+
+ test('does not double-escape nested fragments', () => {
+ const inner = html`${'a&b'}`;
+ expect(String(html`${inner}
`)).toBe('a&b
');
+ });
+
+ // 配列をそのまま文字列化するとカンマ区切りで潰れる。実際に過去これで表が壊れた
+ test('joins arrays without separators instead of stringifying them', () => {
+ const items = [html`1`, html`2`];
+ expect(String(html``)).toBe('');
+ });
+
+ test('escapes array elements that are not fragments', () => {
+ expect(String(html`${['', '']}
`)).toBe('<a><b>
');
+ });
+});
+
+describe('raw', () => {
+ test('embeds trusted markup without escaping', () => {
+ expect(String(html``)).toBe('');
+ });
+
+ test('produces a Raw fragment', () => {
+ expect(raw('x')).toBeInstanceOf(Raw);
+ expect(html`x`).toBeInstanceOf(Raw);
+ });
+});
+
+describe('joinHtml', () => {
+ test('joins fragments with the given separator', () => {
+ expect(String(joinHtml([html`1`, html`2`], '\n'))).toBe('1\n2');
+ });
+
+ test('returns an empty fragment for an empty list', () => {
+ expect(String(joinHtml([], '\n'))).toBe('');
+ });
+});
diff --git a/packages-private/diagnostics-shared/test/metric-table.test.ts b/packages-private/diagnostics-shared/test/metric-table.test.ts
new file mode 100644
index 00000000000..c9fd69ae097
--- /dev/null
+++ b/packages-private/diagnostics-shared/test/metric-table.test.ts
@@ -0,0 +1,145 @@
+/*
+ * SPDX-FileCopyrightText: syuilo and misskey-project
+ * SPDX-License-Identifier: AGPL-3.0-only
+ */
+
+import { describe, expect, test } from 'vitest';
+import {
+ renderMetricComparisonTable,
+ type MetricComparisonRow,
+} from '../src/metric-table';
+
+type Sample = { value: number };
+
+const defaultRow: MetricComparisonRow = {
+ label: 'Metric',
+ getValue: sample => sample.value,
+ formatValue: value => `${value} units`,
+ absoluteThreshold: 10,
+};
+
+function samples(...values: number[]): Sample[] {
+ return values.map(value => ({ value }));
+}
+
+describe('renderMetricComparisonTable', () => {
+ test('renders the fixed five-column layout, raw label, MAD, and percentage by default', () => {
+ const table = renderMetricComparisonTable(
+ samples(100, 100, 100),
+ samples(120, 120, 120),
+ [defaultRow],
+ );
+
+ expect(table.split('\n')).toStrictEqual([
+ '| Metric | @ Base | @ Head | Δ | MAD |',
+ '| --- | ---: | ---: | ---: | ---: |',
+ '| Metric | 100 units
±\u00A00 units | 120 units
±\u00A00 units | $\\color{orange}{\\text{+20~units}}$
$\\color{orange}{\\text{+20\\\\%}}$ | 0 units |',
+ ]);
+ });
+
+ test('can hide second lines and insert a five-column separator row', () => {
+ const table = renderMetricComparisonTable(
+ samples(100, 100, 100),
+ samples(120, 120, 120),
+ [{
+ ...defaultRow,
+ showMedianMad: false,
+ showDeltaPercentage: false,
+ separatorAfter: true,
+ }],
+ );
+
+ expect(table.split('\n')).toStrictEqual([
+ '| Metric | @ Base | @ Head | Δ | MAD |',
+ '| --- | ---: | ---: | ---: | ---: |',
+ '| Metric | 100 units | 120 units | $\\color{orange}{\\text{+20~units}}$ | 0 units |',
+ '| | | | | |',
+ ]);
+ });
+
+ test('leaves both delta lines uncolored below the absolute threshold and can filter the row', () => {
+ const base = samples(100, 100, 100);
+ const head = samples(109, 109, 109);
+ const table = renderMetricComparisonTable(base, head, [defaultRow]);
+
+ expect(table).toContain('$\\text{+9~units}$
$\\text{+9\\\\%}$');
+ expect(table).not.toContain('\\color{');
+ expect(renderMetricComparisonTable(base, head, [defaultRow], {
+ onlySignificantChanges: true,
+ })).toBe('**(No significant changes)**');
+ });
+
+ test('renders a no-data marker when no rows are configured', () => {
+ expect(renderMetricComparisonTable(
+ samples(100, 100, 100),
+ samples(120, 120, 120),
+ [],
+ )).toBe('**(No significant changes)**');
+ });
+
+ test('treats the absolute threshold itself as significant', () => {
+ const table = renderMetricComparisonTable(
+ samples(100, 100, 100),
+ samples(110, 110, 110),
+ [defaultRow],
+ { onlySignificantChanges: true },
+ );
+
+ expect(table).toContain('$\\color{orange}{\\text{+10~units}}$');
+ expect(table).toContain('$\\color{orange}{\\text{+10\\\\%}}$');
+ });
+
+ test('requires the delta to be strictly outside three combined MADs', () => {
+ const inside = renderMetricComparisonTable(
+ samples(100, 110, 120),
+ samples(109, 119, 129),
+ [{ ...defaultRow, absoluteThreshold: 1 }],
+ );
+ const boundary = renderMetricComparisonTable(
+ samples(100, 100, 100),
+ samples(120, 130, 140),
+ [defaultRow],
+ );
+ const outside = renderMetricComparisonTable(
+ samples(100, 100, 100),
+ samples(121, 131, 141),
+ [defaultRow],
+ );
+
+ expect(inside).toContain('$\\text{+9~units}$');
+ expect(inside).not.toContain('\\color{');
+ expect(boundary).toContain('$\\text{+30~units}$');
+ expect(boundary).not.toContain('\\color{');
+ expect(outside).toContain('$\\color{orange}{\\text{+31~units}}$');
+ expect(outside).toContain('$\\color{orange}{\\text{+31\\\\%}}$');
+ });
+
+ test('colours a significant decrease green on both delta lines', () => {
+ const table = renderMetricComparisonTable(
+ samples(120, 120, 120),
+ samples(100, 100, 100),
+ [defaultRow],
+ );
+
+ expect(table).toContain('$\\color{green}{\\text{-20~units}}$');
+ expect(table).toContain('$\\color{green}{\\text{-16.7\\\\%}}$');
+ });
+
+ test('shows a dash for percentage when the base median is zero', () => {
+ const table = renderMetricComparisonTable(
+ samples(0, 0, 0),
+ samples(20, 20, 20),
+ [defaultRow],
+ );
+
+ expect(table).toContain('$\\color{orange}{\\text{+20~units}}$
-');
+ });
+
+ test('propagates the minimum sample contract', () => {
+ expect(() => renderMetricComparisonTable(
+ samples(100),
+ samples(120, 120),
+ [defaultRow],
+ )).toThrow('At least two samples per side are required');
+ });
+});
diff --git a/packages-private/diagnostics-shared/test/stats.test.ts b/packages-private/diagnostics-shared/test/stats.test.ts
new file mode 100644
index 00000000000..22da6a65317
--- /dev/null
+++ b/packages-private/diagnostics-shared/test/stats.test.ts
@@ -0,0 +1,194 @@
+/*
+ * SPDX-FileCopyrightText: syuilo and misskey-project
+ * SPDX-License-Identifier: AGPL-3.0-only
+ */
+
+import { describe, expect, test } from 'vitest';
+import {
+ finiteMedian,
+ independentDeltaSummary,
+ isOutsideObservedNoise,
+ mad,
+ median,
+ pairedDeltaSummary,
+ sampleSpread,
+ type IndependentDeltaSummary,
+} from '../src/stats';
+
+describe('median', () => {
+ test('takes the middle value of an odd-length sample', () => {
+ expect(median([3, 1, 2])).toBe(2);
+ });
+
+ // 偶数長では平均を整数に丸める。KiB単位の整数値を扱う前提のため
+ test('rounds the average of an even-length sample', () => {
+ expect(median([1, 2])).toBe(2);
+ expect(median([1, 4])).toBe(3);
+ });
+});
+
+describe('mad', () => {
+ test('measures the median absolute deviation', () => {
+ expect(mad([1, 1, 1])).toBe(0);
+ expect(mad([1, 2, 3])).toBe(1);
+ });
+
+ test('refuses to compute from a single sample', () => {
+ expect(() => mad([1])).toThrow();
+ });
+});
+
+describe('finiteMedian', () => {
+ test('ignores non-finite entries', () => {
+ expect(finiteMedian([1, null, undefined, Number.NaN, 3])).toBe(2);
+ });
+
+ test('returns null by default when nothing is finite', () => {
+ expect(finiteMedian([null, undefined])).toBeNull();
+ });
+
+ test('returns the supplied default when nothing is finite', () => {
+ expect(finiteMedian([null, undefined], 0)).toBe(0);
+ });
+});
+
+describe('sampleSpread', () => {
+ test('needs at least two finite samples', () => {
+ expect(sampleSpread([1])).toBeNull();
+ expect(sampleSpread([1, null])).toBeNull();
+ expect(sampleSpread([1, 3])).toBe(1);
+ });
+});
+
+describe('pairedDeltaSummary', () => {
+ const base = [
+ { round: 1, value: 100 },
+ { round: 2, value: 200 },
+ { round: 3, value: 300 },
+ ];
+
+ test('compares base and head within the same round', () => {
+ const head = [
+ { round: 1, value: 110 },
+ { round: 2, value: 230 },
+ { round: 3, value: 320 },
+ ];
+
+ expect(pairedDeltaSummary(base, head, sample => sample.value)).toStrictEqual({
+ median: 20,
+ mad: 10,
+ min: 10,
+ max: 30,
+ samples: 3,
+ });
+ });
+
+ test('drops rounds that only one side has', () => {
+ const head = [
+ { round: 1, value: 110 },
+ { round: 2, value: 230 },
+ { round: 9, value: 999 },
+ ];
+
+ expect(pairedDeltaSummary(base, head, sample => sample.value).samples).toBe(2);
+ });
+
+ // 1サンプルでも中央値・最小・最大は定まる (偏差は常に0)
+ test('summarizes a single paired round without treating MAD as an error', () => {
+ expect(pairedDeltaSummary([base[0]], [{ round: 1, value: 130 }], sample => sample.value)).toStrictEqual({
+ median: 30,
+ mad: 0,
+ min: 30,
+ max: 30,
+ samples: 1,
+ });
+ });
+
+ test('fails loudly when no round is shared', () => {
+ expect(() => pairedDeltaSummary(base, [{ round: 9, value: 1 }], sample => sample.value)).toThrow(/no rounds in common/);
+ });
+
+ // 負のroundはwarmupを表すので集計に混ぜない
+ test('ignores warmup rounds', () => {
+ const warmupBase = [{ round: -1, value: 0 }, ...base];
+ const warmupHead = [{ round: -1, value: 9999 }, { round: 1, value: 110 }, { round: 2, value: 230 }, { round: 3, value: 320 }];
+
+ expect(pairedDeltaSummary(warmupBase, warmupHead, sample => sample.value).samples).toBe(3);
+ });
+});
+
+describe('independentDeltaSummary', () => {
+ const base = [290_000, 292_900, 295_800, 298_700, 301_600]
+ .map((value, index) => ({ round: index + 1, value }));
+ const head = [292_900, 296_300, 298_700, 301_600, 290_000]
+ .map((value, index) => ({ round: index + 1, value }));
+
+ test('uses the difference of independent medians instead of the paired median', () => {
+ expect(pairedDeltaSummary(base, head, sample => sample.value).median).toBe(2_900);
+
+ const summary = independentDeltaSummary(base, head, sample => sample.value);
+ expect(summary).toMatchObject({
+ baseMedian: 295_800,
+ headMedian: 296_300,
+ delta: 500,
+ baseMad: 2_900,
+ headMad: 3_400,
+ baseSamples: 5,
+ headSamples: 5,
+ });
+ expect(summary.combinedMad).toBeCloseTo(Math.hypot(2_900, 3_400));
+ });
+
+ test('allows unequal sample counts', () => {
+ const summary = independentDeltaSummary(
+ [{ value: 10 }, { value: 20 }],
+ [{ value: 20 }, { value: 30 }, { value: 40 }],
+ sample => sample.value,
+ );
+
+ expect(summary).toStrictEqual({
+ baseMedian: 15,
+ headMedian: 30,
+ delta: 15,
+ baseMad: 5,
+ headMad: 10,
+ combinedMad: Math.hypot(5, 10),
+ baseSamples: 2,
+ headSamples: 3,
+ });
+ });
+
+ test('throws when either side has fewer than two samples', () => {
+ expect(() => independentDeltaSummary(
+ [{ value: 10 }],
+ [{ value: 20 }, { value: 20 }],
+ sample => sample.value,
+ )).toThrow('At least two samples per side are required');
+
+ expect(() => independentDeltaSummary(
+ [{ value: 10 }, { value: 10 }],
+ [{ value: 20 }],
+ sample => sample.value,
+ )).toThrow('At least two samples per side are required');
+ });
+
+ test('treats exactly three combined MADs as noise and only larger deltas as outside noise', () => {
+ function summary(delta: number, combinedMad: number): IndependentDeltaSummary {
+ return {
+ baseMedian: 100,
+ headMedian: 100 + delta,
+ delta,
+ baseMad: 0,
+ headMad: combinedMad,
+ combinedMad,
+ baseSamples: 3,
+ headSamples: 3,
+ };
+ }
+
+ expect(isOutsideObservedNoise(summary(29, 10))).toBe(false);
+ expect(isOutsideObservedNoise(summary(30, 10))).toBe(false);
+ expect(isOutsideObservedNoise(summary(31, 10))).toBe(true);
+ expect(isOutsideObservedNoise(summary(-31, 10))).toBe(true);
+ });
+});
diff --git a/packages-private/diagnostics-shared/tsconfig.json b/packages-private/diagnostics-shared/tsconfig.json
new file mode 100644
index 00000000000..6672c40acb3
--- /dev/null
+++ b/packages-private/diagnostics-shared/tsconfig.json
@@ -0,0 +1,20 @@
+{
+ "$schema": "https://json.schemastore.org/tsconfig",
+ "compilerOptions": {
+ "target": "ESNext",
+ "module": "ESNext",
+ "moduleResolution": "bundler",
+ "strict": true,
+ "strictFunctionTypes": true,
+ "strictNullChecks": true,
+ "esModuleInterop": true,
+ "skipLibCheck": true,
+ "noEmit": true,
+ "types": ["node"]
+ },
+ "include": [
+ "src/**/*.ts",
+ "test/**/*.ts"
+ ],
+ "exclude": []
+}
diff --git a/packages/backend/migration/1784899839024-HashtagTableDefaults.js b/packages/backend/migration/1784899839024-HashtagTableDefaults.js
new file mode 100644
index 00000000000..4b890fa8493
--- /dev/null
+++ b/packages/backend/migration/1784899839024-HashtagTableDefaults.js
@@ -0,0 +1,26 @@
+/*
+ * SPDX-FileCopyrightText: syuilo and misskey-project
+ * SPDX-License-Identifier: AGPL-3.0-only
+ */
+
+export class HashtagTableDefaults1784899839024 {
+ name = 'HashtagTableDefaults1784899839024'
+
+ async up(queryRunner) {
+ await queryRunner.query(`ALTER TABLE "hashtag" ALTER COLUMN "mentionedUserIds" SET DEFAULT '{}'`);
+ await queryRunner.query(`ALTER TABLE "hashtag" ALTER COLUMN "mentionedLocalUserIds" SET DEFAULT '{}'`);
+ await queryRunner.query(`ALTER TABLE "hashtag" ALTER COLUMN "mentionedRemoteUserIds" SET DEFAULT '{}'`);
+ await queryRunner.query(`ALTER TABLE "hashtag" ALTER COLUMN "attachedUserIds" SET DEFAULT '{}'`);
+ await queryRunner.query(`ALTER TABLE "hashtag" ALTER COLUMN "attachedLocalUserIds" SET DEFAULT '{}'`);
+ await queryRunner.query(`ALTER TABLE "hashtag" ALTER COLUMN "attachedRemoteUserIds" SET DEFAULT '{}'`);
+ }
+
+ async down(queryRunner) {
+ await queryRunner.query(`ALTER TABLE "hashtag" ALTER COLUMN "attachedRemoteUserIds" DROP DEFAULT`);
+ await queryRunner.query(`ALTER TABLE "hashtag" ALTER COLUMN "attachedLocalUserIds" DROP DEFAULT`);
+ await queryRunner.query(`ALTER TABLE "hashtag" ALTER COLUMN "attachedUserIds" DROP DEFAULT`);
+ await queryRunner.query(`ALTER TABLE "hashtag" ALTER COLUMN "mentionedRemoteUserIds" DROP DEFAULT`);
+ await queryRunner.query(`ALTER TABLE "hashtag" ALTER COLUMN "mentionedLocalUserIds" DROP DEFAULT`);
+ await queryRunner.query(`ALTER TABLE "hashtag" ALTER COLUMN "mentionedUserIds" DROP DEFAULT`);
+ }
+}
diff --git a/packages/backend/package.json b/packages/backend/package.json
index b8729614bf8..ebfe45c48a4 100644
--- a/packages/backend/package.json
+++ b/packages/backend/package.json
@@ -19,11 +19,11 @@
"build": "rolldown -c",
"build:unit": "rolldown -c --sourcemap",
"build:e2e": "rolldown -c --e2e",
- "build:tsc": "tsgo -p tsconfig.json && tsc-alias -p tsconfig.json",
+ "build:tsc": "tsc -p tsconfig.json && tsc-alias -p tsconfig.json",
"watch": "pnpm compile-config && node ./scripts/watch.mjs",
"restart": "pnpm build && pnpm start",
"dev": "pnpm compile-config && rolldown -c --watch",
- "typecheck": "tsgo --noEmit && tsgo -p test --noEmit && tsgo -p test-federation --noEmit",
+ "typecheck": "tsc --noEmit && tsc -p test --noEmit && tsc -p test-federation --noEmit",
"eslint": "eslint --quiet \"{src,test-federation}/**/*.ts\"",
"lint": "pnpm typecheck && pnpm eslint",
"test": "pnpm build:unit && cross-env NODE_ENV=test pnpm compile-config && vitest --config vitest.config.unit.ts",
@@ -52,105 +52,105 @@
"utf-8-validate": "6.0.6"
},
"dependencies": {
- "@aws-sdk/client-s3": "3.1075.0",
- "@aws-sdk/lib-storage": "3.1075.0",
+ "@aws-sdk/client-s3": "3.1107.0",
+ "@aws-sdk/lib-storage": "3.1107.0",
"@fastify/accepts": "5.0.4",
- "@fastify/cors": "11.2.0",
- "@fastify/http-proxy": "11.5.0",
- "@fastify/multipart": "10.0.0",
- "@fastify/static": "9.1.3",
+ "@fastify/cors": "11.3.0",
+ "@fastify/http-proxy": "11.6.0",
+ "@fastify/multipart": "10.1.0",
+ "@fastify/static": "10.1.3",
"@kitajs/html": "4.2.13",
"@misskey-dev/emoji-assets": "17.0.3",
"@misskey-dev/emoji-data": "17.0.3",
"@misskey-dev/sharp-read-bmp": "1.3.1",
"@misskey-dev/summaly": "5.5.1",
- "@napi-rs/canvas": "1.0.1",
- "@nestjs/common": "11.1.27",
- "@nestjs/core": "11.1.27",
- "@nestjs/testing": "11.1.27",
- "@oxc-project/runtime": "0.137.0",
+ "@napi-rs/canvas": "1.0.5",
+ "@nestjs/common": "11.1.29",
+ "@nestjs/core": "11.1.29",
+ "@nestjs/testing": "11.1.29",
+ "@oxc-project/runtime": "0.144.0",
"@peertube/http-signature": "1.7.0",
- "@sentry/node": "10.62.0",
- "@sentry/profiling-node": "10.62.0",
+ "@sentry/node": "10.70.0",
+ "@sentry/profiling-node": "10.70.0",
"@simplewebauthn/server": "13.3.2",
- "@smithy/node-http-handler": "4.8.2",
+ "@smithy/node-http-handler": "4.9.13",
"accepts": "1.3.8",
"ajv": "8.20.0",
"archiver": "8.0.0",
"bcryptjs": "3.0.3",
"blurhash": "2.0.5",
- "bullmq": "5.79.2",
+ "bullmq": "6.0.11",
"cacheable-lookup": "7.0.0",
- "chalk": "5.6.2",
+ "chalk": "6.0.0",
"chalk-template": "1.1.2",
"chokidar": "5.0.0",
"color-convert": "3.1.3",
"content-disposition": "2.0.1",
"date-fns": "4.4.0",
"deep-email-validator": "0.1.27",
- "fastify": "5.9.0",
- "fastify-raw-body": "5.0.0",
- "feed": "5.2.1",
+ "fastify": "5.11.3",
+ "fastify-raw-body": "6.0.1",
+ "feed": "6.0.0",
"file-type": "22.0.1",
"fluent-ffmpeg": "2.1.3",
- "got": "15.0.7",
+ "got": "15.1.0",
"hpagent": "1.2.0",
- "http-link-header": "1.1.3",
+ "http-link-header": "1.1.4",
"i18n": "workspace:*",
"ioredis": "5.11.1",
"ip-cidr": "4.0.2",
- "ipaddr.js": "2.4.0",
+ "ipaddr.js": "2.5.0",
"is-svg": "6.1.0",
"json5": "2.2.3",
"jsonld": "9.0.0",
- "juice": "12.1.1",
- "meilisearch": "0.58.0",
+ "juice": "12.1.2",
+ "meilisearch": "0.60.0",
"mfm-js": "0.26.0",
"mime-types": "3.0.2",
"misskey-js": "workspace:*",
"misskey-reversi": "workspace:*",
"ms": "3.0.0-canary.202508261828",
- "nanoid": "5.1.16",
+ "nanoid": "6.0.1",
"nested-property": "4.0.0",
"node-fetch": "3.3.2",
- "node-html-parser": "8.0.3",
- "nodemailer": "9.0.1",
+ "node-html-parser": "9.0.1",
+ "nodemailer": "9.0.5",
"os-utils": "0.0.14",
"otpauth": "9.5.1",
- "pg": "8.22.0",
+ "pg": "8.23.0",
"pkce-challenge": "6.0.0",
"probe-image-size": "7.3.0",
"promise-limit": "2.7.0",
"qrcode": "1.5.4",
"random-seed": "0.3.0",
"ratelimiter": "3.4.1",
- "re2": "1.25.0",
+ "re2": "1.26.1",
"reflect-metadata": "0.2.2",
"rename": "1.0.4",
"rss-parser": "3.13.0",
- "sanitize-html": "2.17.5",
+ "sanitize-html": "2.17.6",
"secure-json-parse": "4.1.0",
"semver": "7.8.5",
- "sharp": "0.35.2",
+ "sharp": "0.35.3",
"slacc": "0.1.5",
"strict-event-emitter-types": "2.0.0",
"stringz": "2.1.0",
- "systeminformation": "5.31.11",
+ "systeminformation": "5.33.1",
"tinycolor2": "1.6.0",
"tmp": "0.2.7",
- "tsc-alias": "1.8.17",
- "typeorm": "1.0.0",
+ "tsc-alias": "1.9.1",
+ "typeorm": "1.1.0",
"ulid": "3.0.2",
"vary": "1.1.2",
"web-push": "3.6.7",
- "ws": "8.21.0",
+ "ws": "8.21.3",
"xev": "3.0.2"
},
"devDependencies": {
"@kitajs/ts-html-plugin": "4.1.4",
- "@nestjs/platform-express": "11.1.27",
+ "@nestjs/platform-express": "11.1.29",
"@rollup/plugin-esm-shim": "0.1.8",
- "@sentry/vue": "10.62.0",
+ "@sentry/vue": "10.70.0",
"@sinonjs/fake-timers": "15.4.0",
"@types/accepts": "1.3.7",
"@types/archiver": "8.0.0",
@@ -159,15 +159,15 @@
"@types/jsonld": "1.5.15",
"@types/mime-types": "3.0.1",
"@types/ms": "2.1.0",
- "@types/node": "26.0.1",
+ "@types/node": "26.2.0",
"@types/nodemailer": "8.0.1",
- "@types/pg": "8.20.0",
+ "@types/pg": "8.21.0",
"@types/qrcode": "1.5.6",
"@types/random-seed": "0.3.5",
"@types/ratelimiter": "3.4.6",
"@types/rename": "1.0.7",
"@types/sanitize-html": "2.16.1",
- "@types/semver": "7.7.1",
+ "@types/semver": "7.8.0",
"@types/simple-oauth2": "5.0.8",
"@types/sinonjs__fake-timers": "15.0.1",
"@types/tinycolor2": "1.4.6",
@@ -175,21 +175,21 @@
"@types/vary": "1.1.3",
"@types/web-push": "3.6.4",
"@types/ws": "8.18.1",
- "@typescript-eslint/eslint-plugin": "8.62.0",
- "@typescript-eslint/parser": "8.62.0",
- "@vitest/coverage-v8": "4.1.9",
+ "@typescript-eslint/eslint-plugin": "8.67.0",
+ "@typescript-eslint/parser": "8.67.0",
+ "@vitest/coverage-v8": "4.1.10",
"aws-sdk-client-mock": "4.1.0",
"cbor2": "2.3.0",
"cross-env": "10.1.0",
"eslint-plugin-import": "2.32.0",
- "execa": "9.6.1",
+ "execa": "10.0.1",
"fkill": "10.0.3",
- "js-yaml": "5.2.0",
+ "js-yaml": "5.2.3",
"pid-port": "2.1.1",
- "rolldown": "1.1.3",
+ "rolldown": "1.2.3",
"simple-oauth2": "5.1.0",
- "vite": "8.1.0",
- "vitest": "4.1.9",
- "vitest-mock-extended": "4.0.0"
+ "vite": "8.2.1",
+ "vitest": "4.1.10",
+ "vitest-mock-extended": "5.1.1"
}
}
diff --git a/packages/backend/rolldown.config.ts b/packages/backend/rolldown.config.ts
index c454c915a03..76bfa35421a 100644
--- a/packages/backend/rolldown.config.ts
+++ b/packages/backend/rolldown.config.ts
@@ -159,7 +159,7 @@ export default defineConfig((args) => {
clearScreen: false,
},
// ビルドの高速化のために、watchモードのときは外部モジュールは全てバンドルしないようにする
- external: isWatchMode ? /^(?!@\/)[^.\/](?!:[\/\\])/ : externalModules,
+ external: isWatchMode ? /^(?!@\/|\0)[^.\/](?!:[\/\\])/ : externalModules,
};
}
});
diff --git a/packages/backend/scripts/measure-memory.mts b/packages/backend/scripts/measure-memory.mts
deleted file mode 100644
index 74b84d998ff..00000000000
--- a/packages/backend/scripts/measure-memory.mts
+++ /dev/null
@@ -1,631 +0,0 @@
-/*
- * SPDX-FileCopyrightText: syuilo and misskey-project
- * SPDX-License-Identifier: AGPL-3.0-only
- */
-
-import { ChildProcess, fork } from 'node:child_process';
-import { setTimeout } from 'node:timers/promises';
-import { fileURLToPath } from 'node:url';
-import { dirname, join } from 'node:path';
-import { tmpdir } from 'node:os';
-//import * as http from 'node:http';
-import * as fs from 'node:fs/promises';
-import { heapSnapshotCategory, type HeapSnapshotData } from '../../../.github/scripts/heap-snapshot-util.mts';
-
-const __filename = fileURLToPath(import.meta.url);
-const __dirname = dirname(__filename);
-
-function readIntegerEnv(name, defaultValue, min) {
- const rawValue = process.env[name];
- if (rawValue == null || rawValue === '') return defaultValue;
- if (!/^\d+$/.test(rawValue)) throw new Error(`${name} must be an integer`);
-
- const value = Number(rawValue);
- if (!Number.isSafeInteger(value) || value < min) throw new Error(`${name} must be >= ${min}`);
- return value;
-}
-
-function readBooleanEnv(name, defaultValue) {
- const rawValue = process.env[name];
- if (rawValue == null || rawValue === '') return defaultValue;
- if (rawValue === '1' || rawValue === 'true') return true;
- if (rawValue === '0' || rawValue === 'false') return false;
- throw new Error(`${name} must be one of: 1, 0, true, false`);
-}
-
-const SAMPLE_COUNT = readIntegerEnv('MK_MEMORY_SAMPLE_COUNT', 3, 1); // Number of samples to measure
-const STARTUP_TIMEOUT = readIntegerEnv('MK_MEMORY_STARTUP_TIMEOUT_MS', 120000, 1); // Timeout for server startup
-const MEMORY_SETTLE_TIME = readIntegerEnv('MK_MEMORY_SETTLE_TIME_MS', 10000, 0); // Wait after startup for memory to settle
-const IPC_TIMEOUT = readIntegerEnv('MK_MEMORY_IPC_TIMEOUT_MS', 30000, 1); // Timeout for IPC responses
-const REQUEST_COUNT = readIntegerEnv('MK_MEMORY_REQUEST_COUNT', 10, 0);
-const HEAP_SNAPSHOT = readBooleanEnv('MK_MEMORY_HEAP_SNAPSHOT', false);
-const HEAP_SNAPSHOT_TIMEOUT = readIntegerEnv('MK_MEMORY_HEAP_SNAPSHOT_TIMEOUT_MS', 120000, 1);
-const HEAP_SNAPSHOT_BREAKDOWN_TOP_N = readIntegerEnv('MK_MEMORY_HEAP_SNAPSHOT_BREAKDOWN_TOP_N', 6, 1);
-const HEAP_SNAPSHOT_SAVE_PATH = process.env.MK_MEMORY_HEAP_SNAPSHOT_SAVE_PATH;
-
-const procStatusKeys = ['VmPeak', 'VmSize', 'VmHWM', 'VmRSS', 'VmData', 'VmStk', 'VmExe', 'VmLib', 'VmPTE', 'VmSwap'] as const;
-const smapsRollupKeys = ['Pss', 'Shared_Clean', 'Shared_Dirty', 'Private_Clean', 'Private_Dirty', 'Swap', 'SwapPss'] as const;
-
-type GcMessage = 'gc ok' | 'gc unavailable';
-type RuntimeMemoryUsageMessage = {
- type: 'memory usage';
- value: NodeJS.MemoryUsage;
-};
-type HeapSnapshotMessage = {
- type: 'heap snapshot';
- path?: string;
-};
-type HeapSnapshotErrorMessage = {
- type: 'heap snapshot error';
- message: string;
-};
-type HeapSnapshotResponseMessage = HeapSnapshotMessage | HeapSnapshotErrorMessage;
-
-function parseMemoryFile(content: string, keys: KS, path: string, required: boolean): Record {
- const result = {} as Record;
- for (const _key of keys) {
- const key = _key as KS[number];
- const match = content.match(new RegExp(`${key}:\\s+(\\d+)\\s+kB`));
- if (match) {
- result[key] = parseInt(match[1], 10);
- } else if (required) {
- throw new Error(`Failed to parse ${key} from ${path}`);
- }
- }
- return result;
-}
-
-function bytesToKiB(value: number) {
- return Math.round(value / 1024);
-}
-
-function sanitizeHeapSnapshotBreakdownLabel(value, fallback = 'unknown') {
- const label = String(value ?? '').replace(/\s+/g, ' ').trim();
- if (label === '') return fallback;
- if (label.length <= 80) return label;
- return `${label.slice(0, 77)}...`;
-}
-
-function classifyHeapSnapshotBreakdown(category: keyof typeof heapSnapshotCategory, type, name) {
- if (category === 'strings') return type;
-
- if (category === 'jsArrays') {
- if (type === 'array elements') return 'Array elements';
- if (type === 'object' && name === 'Array') return 'Array objects';
- return sanitizeHeapSnapshotBreakdownLabel(`${type}: ${name}`);
- }
-
- if (category === 'typedArrays') {
- if (name === 'system / JSArrayBufferData') return 'ArrayBuffer data';
- return sanitizeHeapSnapshotBreakdownLabel(`${type}: ${name}`);
- }
-
- if (category === 'systemObjects') {
- if (name.startsWith('system /')) return sanitizeHeapSnapshotBreakdownLabel(name);
- if (name.startsWith('(system ')) return sanitizeHeapSnapshotBreakdownLabel(name);
- return sanitizeHeapSnapshotBreakdownLabel(`${type}: ${name}`, type);
- }
-
- if (category === 'otherJsObjects') {
- if (type === 'object') return sanitizeHeapSnapshotBreakdownLabel(`object: ${name}`, 'object: unknown');
- return type;
- }
-
- if (category === 'otherNonJsObjects') {
- if (type === 'extra native bytes') return 'Extra native bytes';
- if (type === 'native') return sanitizeHeapSnapshotBreakdownLabel(`native: ${name}`, 'native: unknown');
- return sanitizeHeapSnapshotBreakdownLabel(`${type}: ${name}`, type);
- }
-
- if (category === 'code') {
- const lowerName = name.toLowerCase();
- if (lowerName.includes('bytecode')) return 'bytecode';
- if (lowerName.includes('builtin')) return 'builtins';
- if (lowerName.includes('regexp')) return 'regexp code';
- if (lowerName.includes('stub')) return 'stubs';
- return sanitizeHeapSnapshotBreakdownLabel(`code: ${name}`, 'code: unknown');
- }
-
- return sanitizeHeapSnapshotBreakdownLabel(`${type}: ${name}`, type);
-}
-
-function collapseHeapSnapshotBreakdown(breakdowns: Record>) {
- const collapsed = {} as Record>;
-
- for (const [category, children] of Object.entries(breakdowns)) {
- const entries = Object.entries(children)
- .filter(([, value]) => value > 0)
- .toSorted((a, b) => b[1] - a[1]);
-
- const topEntries = entries.slice(0, HEAP_SNAPSHOT_BREAKDOWN_TOP_N);
- const otherValue = entries
- .slice(HEAP_SNAPSHOT_BREAKDOWN_TOP_N)
- .reduce((sum, [, value]) => sum + value, 0);
-
- const categoryBreakdown = Object.fromEntries(topEntries);
- if (otherValue > 0) categoryBreakdown.Other = otherValue;
- if (Object.keys(categoryBreakdown).length > 0) collapsed[category] = categoryBreakdown;
- }
-
- return collapsed;
-}
-
-// Keep these buckets aligned with Chrome DevTools' heap snapshot Statistics view.
-function analyzeHeapSnapshot(snapshot) {
- const meta = snapshot?.snapshot?.meta;
- const nodes = snapshot?.nodes;
- const edges = snapshot?.edges;
- const strings = snapshot?.strings;
- if (meta == null || !Array.isArray(nodes) || !Array.isArray(edges) || !Array.isArray(strings)) {
- throw new Error('Invalid heap snapshot format');
- }
-
- const nodeFields = meta.node_fields;
- if (!Array.isArray(nodeFields)) throw new Error('Invalid heap snapshot node fields');
- const edgeFields = meta.edge_fields;
- if (!Array.isArray(edgeFields)) throw new Error('Invalid heap snapshot edge fields');
-
- const typeOffset = nodeFields.indexOf('type');
- const nameOffset = nodeFields.indexOf('name');
- const selfSizeOffset = nodeFields.indexOf('self_size');
- const edgeCountOffset = nodeFields.indexOf('edge_count');
- if (typeOffset < 0 || nameOffset < 0 || selfSizeOffset < 0 || edgeCountOffset < 0) {
- throw new Error('Heap snapshot is missing required node fields');
- }
- const edgeTypeOffset = edgeFields.indexOf('type');
- const edgeNameOffset = edgeFields.indexOf('name_or_index');
- const edgeToNodeOffset = edgeFields.indexOf('to_node');
- if (edgeTypeOffset < 0 || edgeNameOffset < 0 || edgeToNodeOffset < 0) {
- throw new Error('Heap snapshot is missing required edge fields');
- }
-
- const nodeTypeNames = meta.node_types?.[typeOffset];
- if (!Array.isArray(nodeTypeNames)) throw new Error('Invalid heap snapshot node types');
- const edgeTypeNames = meta.edge_types?.[edgeTypeOffset];
- if (!Array.isArray(edgeTypeNames)) throw new Error('Invalid heap snapshot edge types');
-
- function createEmptyHeapSnapshotCategoryMap() {
- return Object.fromEntries(Object.keys(heapSnapshotCategory).map(category => [category, 0])) as Record;
- }
-
- const nodeFieldCount = nodeFields.length;
- const edgeFieldCount = edgeFields.length;
- const nativeType = nodeTypeNames.indexOf('native');
- const codeType = nodeTypeNames.indexOf('code');
- const hiddenType = nodeTypeNames.indexOf('hidden');
- const stringTypes = new Set([
- nodeTypeNames.indexOf('string'),
- nodeTypeNames.indexOf('concatenated string'),
- nodeTypeNames.indexOf('sliced string'),
- ]);
- const internalEdgeType = edgeTypeNames.indexOf('internal');
- const extraNativeBytes = Number.isFinite(snapshot.snapshot.extra_native_bytes) ? snapshot.snapshot.extra_native_bytes : 0;
- const categories = createEmptyHeapSnapshotCategoryMap();
- const nodeCounts = createEmptyHeapSnapshotCategoryMap();
- const breakdowns = Object.fromEntries(
- (Object.keys(heapSnapshotCategory) as (keyof typeof heapSnapshotCategory)[])
- .filter(category => category !== 'total')
- .map(category => [category, {}]),
- );
-
- function addValue(map: Record, key: string, value: number) {
- map[key] = (map[key] ?? 0) + value;
- }
-
- const edgeStartIndexes = new Map();
- const retainerCounts = new Map();
- let edgeIndex = 0;
- for (let nodeIndex = 0; nodeIndex < nodes.length; nodeIndex += nodeFieldCount) {
- edgeStartIndexes.set(nodeIndex, edgeIndex);
- const edgeCount = nodes[nodeIndex + edgeCountOffset] ?? 0;
- for (let i = 0; i < edgeCount; i++, edgeIndex += edgeFieldCount) {
- const toNodeIndex = edges[edgeIndex + edgeToNodeOffset];
- retainerCounts.set(toNodeIndex, (retainerCounts.get(toNodeIndex) ?? 0) + 1);
- }
- }
-
- const jsArrayElementNodeIndexes = new Set();
-
- function addCategoryValue(category: keyof typeof heapSnapshotCategory, value: number, type: string, name: string, nodeIndex: number | null = null) {
- if (value <= 0) return;
- categories[category] += value;
- addValue(breakdowns[category], classifyHeapSnapshotBreakdown(category, type, name), value);
- if (nodeIndex != null) nodeCounts[category]++;
- }
-
- function addJsArrayElementSize(nodeIndex: number) {
- const beginEdgeIndex = edgeStartIndexes.get(nodeIndex) ?? 0;
- const edgeCount = nodes[nodeIndex + edgeCountOffset] ?? 0;
- for (let i = 0, currentEdgeIndex = beginEdgeIndex; i < edgeCount; i++, currentEdgeIndex += edgeFieldCount) {
- const edgeType = edges[currentEdgeIndex + edgeTypeOffset];
- if (edgeType !== internalEdgeType) continue;
-
- const edgeName = strings[edges[currentEdgeIndex + edgeNameOffset]];
- if (edgeName !== 'elements') continue;
-
- const elementsNodeIndex = edges[currentEdgeIndex + edgeToNodeOffset];
- if ((retainerCounts.get(elementsNodeIndex) ?? 0) === 1) {
- const elementsSize = nodes[elementsNodeIndex + selfSizeOffset] ?? 0;
- addCategoryValue('jsArrays', elementsSize, 'array elements', 'Array elements', elementsNodeIndex);
- jsArrayElementNodeIndexes.add(elementsNodeIndex);
- }
- break;
- }
- }
-
- if (extraNativeBytes > 0) {
- addCategoryValue('otherNonJsObjects', extraNativeBytes, 'extra native bytes', 'extra native bytes');
- }
-
- for (let nodeIndex = 0; nodeIndex < nodes.length; nodeIndex += nodeFieldCount) {
- const typeId = nodes[nodeIndex + typeOffset];
- const type = nodeTypeNames[typeId] ?? 'unknown';
- const name = strings[nodes[nodeIndex + nameOffset]] ?? '';
- const selfSize = nodes[nodeIndex + selfSizeOffset] ?? 0;
- categories.total += selfSize;
- nodeCounts.total++;
-
- if (typeId === hiddenType) {
- addCategoryValue('systemObjects', selfSize, type, name, nodeIndex);
- continue;
- }
-
- if (typeId === nativeType) {
- if (name === 'system / JSArrayBufferData') {
- addCategoryValue('typedArrays', selfSize, type, name, nodeIndex);
- } else {
- addCategoryValue('otherNonJsObjects', selfSize, type, name, nodeIndex);
- }
- continue;
- }
-
- if (typeId === codeType) {
- addCategoryValue('code', selfSize, type, name, nodeIndex);
- continue;
- }
-
- if (stringTypes.has(typeId)) {
- addCategoryValue('strings', selfSize, type, name, nodeIndex);
- continue;
- }
-
- if (name === 'Array') {
- addCategoryValue('jsArrays', selfSize, type, name, nodeIndex);
- addJsArrayElementSize(nodeIndex);
- continue;
- }
- }
-
- categories.total += extraNativeBytes;
-
- for (let nodeIndex = 0; nodeIndex < nodes.length; nodeIndex += nodeFieldCount) {
- if (jsArrayElementNodeIndexes.has(nodeIndex)) continue;
-
- const typeId = nodes[nodeIndex + typeOffset];
- if (typeId === hiddenType || typeId === nativeType || typeId === codeType || stringTypes.has(typeId)) continue;
-
- const name = strings[nodes[nodeIndex + nameOffset]] ?? '';
- if (name === 'Array') continue;
-
- const type = nodeTypeNames[typeId] ?? 'unknown';
- const selfSize = nodes[nodeIndex + selfSizeOffset] ?? 0;
- addCategoryValue('otherJsObjects', selfSize, type, name, nodeIndex);
- }
-
- return {
- categories,
- nodeCounts,
- breakdowns: collapseHeapSnapshotBreakdown(breakdowns),
- };
-}
-
-async function getMemoryUsage(pid: number) {
- const path = `/proc/${pid}/status`;
- const status = await fs.readFile(path, 'utf-8');
- return parseMemoryFile(status, procStatusKeys, path, true);
-}
-
-async function getSmapsRollupMemoryUsage(pid: number) {
- const path = `/proc/${pid}/smaps_rollup`;
- const smapsRollup = await fs.readFile(path, 'utf-8');
- return parseMemoryFile(smapsRollup, smapsRollupKeys, path, false);
-}
-
-function isRecord(value: unknown): value is Record {
- return value != null && typeof value === 'object';
-}
-
-function isGcMessage(message: unknown): message is GcMessage {
- return message === 'gc ok' || message === 'gc unavailable';
-}
-
-function isRuntimeMemoryUsageMessage(message: unknown): message is RuntimeMemoryUsageMessage {
- return isRecord(message) && message.type === 'memory usage' && isRecord(message.value);
-}
-
-function isHeapSnapshotResponseMessage(message: unknown): message is HeapSnapshotResponseMessage {
- if (!isRecord(message)) return false;
- if (message.type === 'heap snapshot') return true;
- return message.type === 'heap snapshot error' && typeof message.message === 'string';
-}
-
-function waitForMessage(serverProcess: ChildProcess, predicate: (message: unknown) => message is T, description: string, timeout = IPC_TIMEOUT) {
- return new Promise((resolve, reject) => {
- const timer = globalThis.setTimeout(() => {
- serverProcess.off('message', onMessage);
- reject(new Error(`Timed out waiting for ${description}`));
- }, timeout);
-
- const onMessage = (message: unknown) => {
- if (!predicate(message)) return;
- globalThis.clearTimeout(timer);
- serverProcess.off('message', onMessage);
- resolve(message);
- };
-
- serverProcess.on('message', onMessage);
- });
-}
-
-async function getRuntimeMemoryUsage(serverProcess: ChildProcess) {
- const response = waitForMessage(
- serverProcess,
- isRuntimeMemoryUsageMessage,
- 'memory usage',
- );
-
- serverProcess.send('memory usage');
-
- const message = await response;
- const memoryUsage = message.value;
-
- return {
- HeapTotal: bytesToKiB(memoryUsage.heapTotal),
- HeapUsed: bytesToKiB(memoryUsage.heapUsed),
- External: bytesToKiB(memoryUsage.external),
- ArrayBuffers: bytesToKiB(memoryUsage.arrayBuffers),
- };
-}
-
-async function getHeapSnapshotStatistics(serverProcess: ChildProcess): Promise {
- if (!HEAP_SNAPSHOT) return null;
-
- const snapshotPath = join(tmpdir(), `misskey-backend-heap-${process.pid}-${serverProcess.pid}-${Date.now()}.heapsnapshot`);
- const response = waitForMessage(
- serverProcess,
- isHeapSnapshotResponseMessage,
- 'heap snapshot',
- HEAP_SNAPSHOT_TIMEOUT,
- );
-
- serverProcess.send({
- type: 'heap snapshot',
- path: snapshotPath,
- });
-
- const message = await response;
- if (message.type === 'heap snapshot error') {
- throw new Error(`Failed to write heap snapshot: ${message.message}`);
- }
-
- const writtenPath = typeof message.path === 'string' ? message.path : snapshotPath;
-
- try {
- if (HEAP_SNAPSHOT_SAVE_PATH != null && HEAP_SNAPSHOT_SAVE_PATH !== '') {
- await fs.mkdir(dirname(HEAP_SNAPSHOT_SAVE_PATH), { recursive: true });
- await fs.copyFile(writtenPath, HEAP_SNAPSHOT_SAVE_PATH);
- }
-
- const snapshot = JSON.parse(await fs.readFile(writtenPath, 'utf-8'));
- return analyzeHeapSnapshot(snapshot);
- } finally {
- await fs.unlink(writtenPath).catch(err => {
- process.stderr.write(`Failed to delete heap snapshot ${writtenPath}: ${err.message}\n`);
- });
- }
-}
-
-async function getAllMemoryUsage(serverProcess: ChildProcess) {
- const pid = serverProcess.pid!;
- return {
- ...await getMemoryUsage(pid),
- ...await getSmapsRollupMemoryUsage(pid),
- ...await getRuntimeMemoryUsage(serverProcess),
- };
-}
-
-async function measureMemory() {
- // Start the Misskey backend server using fork to enable IPC
- const serverProcess = fork(join(__dirname, '../built/entry.js'), [], {
- cwd: join(__dirname, '..'),
- env: {
- ...process.env,
- NODE_ENV: 'production',
- MK_DISABLE_CLUSTERING: '1',
- MK_ONLY_SERVER: '1',
- MK_NO_DAEMONS: '1',
- },
- stdio: ['pipe', 'pipe', 'pipe', 'ipc'],
- execArgv: [...process.execArgv, '--expose-gc'],
- });
-
- let serverReady = false;
-
- // Listen for the 'ok' message from the server indicating it's ready
- serverProcess.on('message', (message) => {
- if (message === 'ok') {
- serverReady = true;
- }
- });
-
- // Handle server output
- serverProcess.stdout?.on('data', (data) => {
- process.stderr.write(`[server stdout] ${data}`);
- });
-
- serverProcess.stderr?.on('data', (data) => {
- process.stderr.write(`[server stderr] ${data}`);
- });
-
- // Handle server error
- serverProcess.on('error', (err) => {
- process.stderr.write(`[server error] ${err}\n`);
- });
-
- async function triggerGc() {
- const ok = waitForMessage(
- serverProcess,
- isGcMessage,
- 'GC completion',
- );
-
- serverProcess.send('gc');
-
- const message = await ok;
- if (message === 'gc unavailable') {
- throw new Error('GC is unavailable. Start the process with --expose-gc to enable this feature.');
- }
-
- await setTimeout(1000);
- }
-
- //function createRequest() {
- // return new Promise((resolve, reject) => {
- // const req = http.request({
- // host: 'localhost',
- // port: 61812,
- // path: '/api/meta',
- // method: 'POST',
- // }, (res) => {
- // res.on('data', () => { });
- // res.on('end', () => {
- // resolve();
- // });
- // });
- // req.on('error', (err) => {
- // reject(err);
- // });
- // req.end();
- // });
- //}
-
- // Wait for server to be ready or timeout
- const startupStartTime = Date.now();
- while (!serverReady) {
- if (Date.now() - startupStartTime > STARTUP_TIMEOUT) {
- serverProcess.kill('SIGTERM');
- throw new Error('Server startup timeout');
- }
- await setTimeout(100);
- }
-
- const startupTime = Date.now() - startupStartTime;
- process.stderr.write(`Server started in ${startupTime}ms\n`);
-
- // Wait for memory to settle
- await setTimeout(MEMORY_SETTLE_TIME);
-
- //const beforeGc = await getAllMemoryUsage(serverProcess);
-
- await triggerGc();
-
- const memoryUsageAfterGC = await getAllMemoryUsage(serverProcess);
-
- //// create some http requests to simulate load
- //await Promise.all(
- // Array.from({ length: REQUEST_COUNT }).map(() => createRequest()),
- //);
-
- //await triggerGc();
-
- //const afterRequest = await getAllMemoryUsage(serverProcess);
-
- const heapSnapshotAfterGc = await getHeapSnapshotStatistics(serverProcess);
-
- // Stop the server
- serverProcess.kill('SIGTERM');
-
- // Wait for process to exit
- let exited = false;
- await new Promise((resolve) => {
- serverProcess.on('exit', () => {
- exited = true;
- resolve(undefined);
- });
- // Force kill after 10 seconds if not exited
- setTimeout(10000).then(() => {
- if (!exited) {
- serverProcess.kill('SIGKILL');
- }
- resolve(undefined);
- });
- });
-
- const result = {
- timestamp: new Date().toISOString(),
- phases: {
- //beforeGc,
- afterGc: {
- memoryUsage: memoryUsageAfterGC,
- heapSnapshot: heapSnapshotAfterGc,
- },
- //afterRequest,
- },
- };
-
- return result;
-}
-
-export type MemoryReportRaw = {
- timestamp: string;
- sampleCount: number;
- measurement: {
- startupTimeoutMs: number;
- memorySettleTimeMs: number;
- ipcTimeoutMs: number;
- requestCount: number;
- heapSnapshot: {
- enabled: boolean;
- timeoutMs: number;
- breakdownTopN: number;
- };
- };
- samples: Awaited>[];
-};
-
-async function main() {
- const results = [];
- for (let i = 0; i < SAMPLE_COUNT; i++) {
- process.stderr.write(`Starting sample ${i + 1}/${SAMPLE_COUNT}\n`);
- const res = await measureMemory();
- results.push(res);
- }
-
- const result: MemoryReportRaw = {
- timestamp: new Date().toISOString(),
- sampleCount: SAMPLE_COUNT,
- measurement: {
- startupTimeoutMs: STARTUP_TIMEOUT,
- memorySettleTimeMs: MEMORY_SETTLE_TIME,
- ipcTimeoutMs: IPC_TIMEOUT,
- requestCount: REQUEST_COUNT,
- heapSnapshot: {
- enabled: HEAP_SNAPSHOT,
- timeoutMs: HEAP_SNAPSHOT_TIMEOUT,
- breakdownTopN: HEAP_SNAPSHOT_BREAKDOWN_TOP_N,
- },
- },
- samples: results,
- };
-
- // Output as JSON to stdout
- console.log(JSON.stringify(result, null, 2));
-}
-
-main().catch((err) => {
- console.error(JSON.stringify({
- error: err.message,
- timestamp: new Date().toISOString(),
- }));
- process.exit(1);
-});
diff --git a/packages/backend/scripts/watch.mjs b/packages/backend/scripts/watch.mjs
index 9d608b233c5..a0ccea3b16b 100644
--- a/packages/backend/scripts/watch.mjs
+++ b/packages/backend/scripts/watch.mjs
@@ -21,7 +21,7 @@ import { execa } from 'execa';
});
}, 3000);
- execa('tsgo', ['-w', '-p', 'tsconfig.json'], {
+ execa('tsc', ['-w', '-p', 'tsconfig.json'], {
stdout: process.stdout,
stderr: process.stderr,
});
diff --git a/packages/backend/src/boot/entry.ts b/packages/backend/src/boot/entry.ts
index 9fcb4d73caf..03e5c395c36 100644
--- a/packages/backend/src/boot/entry.ts
+++ b/packages/backend/src/boot/entry.ts
@@ -10,10 +10,11 @@
import cluster from 'node:cluster';
import { EventEmitter } from 'node:events';
import { writeHeapSnapshot } from 'node:v8';
-import chalk from 'chalk';
import Xev from 'xev';
import Logger from '@/logger.js';
import { envOption } from '../env.js';
+import { installProcessErrorHandlers } from './process-error-handler.js';
+import { isShutdownInProgress } from './shutdown-handler.js';
import { readyRef } from './ready.js';
import 'reflect-metadata';
@@ -27,6 +28,8 @@ const logger = new Logger('core', 'cyan');
const clusterLogger = logger.createSubLogger('cluster', 'orange');
const ev = new Xev();
+installProcessErrorHandlers({ logger, quiet: envOption.quiet });
+
//#region Events
// Listen new workers
@@ -41,27 +44,19 @@ cluster.on('online', worker => {
// Listen for dying workers
cluster.on('exit', worker => {
- // Replace the dead worker,
- // we're not sentimental
- clusterLogger.error(chalk.red(`[${worker.id}] died :(`));
- cluster.fork();
-});
-
-// Display detail of unhandled promise rejection
-if (!envOption.quiet) {
- process.on('unhandledRejection', console.dir);
-}
+ if (isShutdownInProgress()) {
+ clusterLogger.info(`Process exited during shutdown: [${worker.id}]`);
+ return;
+ }
-// Display detail of uncaught exception
-process.on('uncaughtException', err => {
- try {
- logger.error(err);
- console.trace(err);
- } catch { }
+ // 終了したワーカーは従来どおり再生成し、表示色は出力処理へ任せます。
+ clusterLogger.error(`[${worker.id}] died :(`);
+ cluster.fork();
});
// Dying away...
process.on('exit', code => {
+ if (isShutdownInProgress()) return;
logger.info(`The process is going to exit with code ${code}`);
});
@@ -69,12 +64,10 @@ process.on('exit', code => {
if (!envOption.disableClustering) {
if (cluster.isPrimary) {
- logger.info(`Start main process... pid: ${process.pid}`);
const { masterMain } = await import('./master.js');
await masterMain();
ev.mount();
} else if (cluster.isWorker) {
- logger.info(`Start worker process... pid: ${process.pid}`);
const { workerMain } = await import('./worker.js');
await workerMain();
} else {
@@ -82,7 +75,6 @@ if (!envOption.disableClustering) {
}
} else {
// 非clusterの場合はMasterのみが起動するため、Workerの処理は行わない(cluster.isWorker === trueの状態でこのブロックに来ることはない)
- logger.info(`Start main process... pid: ${process.pid}`);
const { masterMain } = await import('./master.js');
await masterMain();
ev.mount();
diff --git a/packages/backend/src/boot/master.ts b/packages/backend/src/boot/master.ts
index aac68cb02f9..3cb340e9637 100644
--- a/packages/backend/src/boot/master.ts
+++ b/packages/backend/src/boot/master.ts
@@ -11,17 +11,30 @@ import chalkTemplate from 'chalk-template';
import Logger from '@/logger.js';
import { loadConfig } from '@/config.js';
import type { Config } from '@/config.js';
+import { configureLogging, shutdownLogging } from '@/logging/logging-runtime.js';
+import type { LogFormat } from '@/logging/types.js';
import { showMachineInfo } from '@/misc/show-machine-info.js';
import { envOption } from '@/env.js';
-import { initTelemetry } from '@/core/telemetry/telemetry-registry.js';
+import { initTelemetry, shutdownTelemetry } from '@/core/telemetry/telemetry-registry.js';
import { initExtraThreadPool, jobQueue, server } from './common.js';
+import { installShutdownSignalHandlers } from './shutdown-handler.js';
const logger = new Logger('core', 'cyan');
const bootLogger = logger.createSubLogger('boot', 'magenta');
const themeColor = chalk.hex('#86b300');
-function greet(props: { version: string }) {
+/** 起動時の案内を、選択されたログ形式に合わせて出力します。 */
+function greet(props: { version: string; format: LogFormat }) {
+ if (!envOption.quiet && props.format === 'json') {
+ // JSONモードでは生のコンソール出力を避け、各案内を1件ずつ構造化ログにします。
+ bootLogger.info('Welcome to Misskey!');
+ bootLogger.info(`Misskey v${props.version}`, null, true);
+ bootLogger.info('Misskey is an open-source decentralized microblogging platform.');
+ bootLogger.info('If you like Misskey, please consider donating to support dev. https://misskey-hub.net/docs/donate/');
+ return;
+ }
+
if (!envOption.quiet) {
//#region Misskey logo
const v = `v${props.version}`;
@@ -52,7 +65,9 @@ export async function masterMain() {
// initialize app
try {
config = loadConfigBoot();
- greet({ version: config.version });
+ logger.info(`Start main process... pid: ${process.pid}`);
+ bootLogger.createSubLogger('config').succ('Loaded');
+ greet({ version: config.version, format: config.logging?.format ?? 'pretty' });
showEnvironment();
await showMachineInfo(bootLogger);
showNodejsVersion();
@@ -67,7 +82,16 @@ export async function masterMain() {
initExtraThreadPool(config);
- await initTelemetry(config);
+ try {
+ await initTelemetry(config);
+ } catch (e) {
+ bootLogger.error(e instanceof Error ? e : new Error(String(e)), null, true);
+ process.exit(1);
+ }
+ installShutdownSignalHandlers({
+ shutdownTasks: [shutdownTelemetry, shutdownLogging],
+ onRegistered: message => bootLogger.info(message),
+ });
bootLogger.info(
`mode: [disableClustering: ${envOption.disableClustering}, onlyServer: ${envOption.onlyServer}, onlyQueue: ${envOption.onlyQueue}]`,
@@ -125,12 +149,14 @@ function showNodejsVersion(): void {
nodejsLogger.info(`Version ${process.version} detected.`);
}
+/** 設定を読み込み、成功時に後続のログ出力形式を適用します。 */
function loadConfigBoot(): Config {
const configLogger = bootLogger.createSubLogger('config');
let config;
try {
config = loadConfig();
+ configureLogging(config.logging);
} catch (exception) {
if (typeof exception === 'string') {
configLogger.error(exception);
@@ -142,8 +168,6 @@ function loadConfigBoot(): Config {
throw exception;
}
- configLogger.succ('Loaded');
-
return config;
}
diff --git a/packages/backend/src/boot/process-error-handler.ts b/packages/backend/src/boot/process-error-handler.ts
new file mode 100644
index 00000000000..c1224f1923f
--- /dev/null
+++ b/packages/backend/src/boot/process-error-handler.ts
@@ -0,0 +1,59 @@
+/*
+ * SPDX-FileCopyrightText: syuilo and misskey-project
+ * SPDX-License-Identifier: AGPL-3.0-only
+ */
+
+import type { LogWriteInput } from '@/logging/types.js';
+
+/** プロセス例外をロガーへ渡すために必要な最小の処理対象です。 */
+export type ProcessErrorHandlerProcess = {
+ on(event: 'unhandledRejection', listener: (reason: unknown) => void): unknown;
+ on(event: 'uncaughtException', listener: (error: Error) => void): unknown;
+};
+
+/** 例外記録に必要なロガーの最小インターフェースです。 */
+export type ProcessErrorHandlerLogger = {
+ write(input: LogWriteInput): void;
+};
+
+/** プロセス例外ハンドラーの登録に使う依存関係です。 */
+export type ProcessErrorHandlerOptions = {
+ readonly process?: ProcessErrorHandlerProcess;
+ readonly logger: ProcessErrorHandlerLogger;
+ readonly quiet: boolean;
+};
+
+/** ログ記録の失敗が別の例外を起こさないよう、プロセス異常を安全に記録します。 */
+function writeProcessError(
+ logger: ProcessErrorHandlerLogger,
+ eventName: 'process.unhandled_rejection' | 'process.uncaught_exception',
+ message: string,
+ error: unknown,
+): void {
+ try {
+ // 元の値をerrorへ渡し、JSON形式の出力処理で安全な形へ正規化します。
+ logger.write({
+ level: 'error',
+ eventName,
+ message,
+ error,
+ });
+ } catch {
+ // 例外処理中のログ失敗で、さらにプロセスを不安定にしないよう握りつぶします。
+ }
+}
+
+/** 未処理のPromise拒否と未捕捉例外を構造化ログへ接続します。 */
+export function installProcessErrorHandlers(options: ProcessErrorHandlerOptions): void {
+ const processLike: ProcessErrorHandlerProcess = options.process ?? (process as unknown as ProcessErrorHandlerProcess);
+
+ if (!options.quiet) {
+ processLike.on('unhandledRejection', reason => {
+ writeProcessError(options.logger, 'process.unhandled_rejection', 'Unhandled promise rejection', reason);
+ });
+ }
+
+ processLike.on('uncaughtException', error => {
+ writeProcessError(options.logger, 'process.uncaught_exception', 'Uncaught exception', error);
+ });
+}
diff --git a/packages/backend/src/boot/shutdown-handler.ts b/packages/backend/src/boot/shutdown-handler.ts
new file mode 100644
index 00000000000..a0d85d1df69
--- /dev/null
+++ b/packages/backend/src/boot/shutdown-handler.ts
@@ -0,0 +1,100 @@
+/*
+ * SPDX-FileCopyrightText: syuilo and misskey-project
+ * SPDX-License-Identifier: AGPL-3.0-only
+ */
+
+type ShutdownSignalProcess = {
+ once(event: 'SIGTERM' | 'SIGINT', listener: () => Promise): unknown;
+};
+
+const SHUTDOWN_TIMEOUT_MS = 10_000;
+
+export type ShutdownTask = () => Promise;
+
+export type ShutdownHandlerOptions = {
+ /** The process-like object that receives the signal handlers. */
+ process?: ShutdownSignalProcess;
+ /** Shutdown tasks, executed in array order. */
+ shutdownTasks: readonly ShutdownTask[];
+ /** Process termination function. */
+ exit?: (code: number) => void;
+ /** Optional boot logger hook used after signal handlers are registered. */
+ onRegistered?: (message: string) => void;
+};
+
+let shuttingDown = false;
+
+/**
+ * Register the process-level shutdown signals.
+ *
+ * Boot owns signal coordination and receives shutdown tasks through callbacks
+ * so individual domains do not depend on each other.
+ *
+ * 注意: このプロジェクトでは app.enableShutdownHooks() が一切呼ばれていないため、
+ * NestJSのOnApplicationShutdown経由のgraceful shutdown(GlobalModule.dispose()によるDB/Redis切断、
+ * QueueProcessorService.stop()によるqueue drain、ServerService.dispose()によるfastify/WebSocket close)は
+ * SIGTERM/SIGINTを起点には発火しない。このhandlerはそれらを経由せず、登録された終了処理を実行して即exitする。
+ * 将来enableShutdownHooks()を配線する場合は、この即exitとNestJS側のshutdown sequenceが競合しないよう順序を設計すること。
+ */
+export function installShutdownSignalHandlers(options: ShutdownHandlerOptions): void {
+ // テストではprocess/exitを差し替え、本番では実processにSIGTERM/SIGINT handlerを登録する。
+ const processLike = options.process ?? process;
+ const exit = options.exit ?? ((code: number) => process.exit(code));
+
+ const handleSignal = async () => {
+ // 同時に複数signalが来てもflushを二重実行せず、cluster refork抑止用の状態もここで立てる。
+ if (shuttingDown) return;
+ shuttingDown = true;
+
+ let timedOut = false;
+ let timeout: NodeJS.Timeout | undefined;
+ try {
+ // 処理時間上限つきのシャットダウンプロセス
+ await Promise.race([
+ (async () => {
+ for (const shutdownTask of options.shutdownTasks) {
+ if (timedOut) return;
+ try {
+ await shutdownTask();
+ } catch (error) {
+ // 1つの終了処理の失敗で後続タスクを妨げないよう、stderrへフォールバックする。
+ try {
+ console.error('Shutdown task failed:', error);
+ } catch {
+ // stderrの出力自体が失敗しても、残りの終了処理とexitは継続する。
+ }
+ }
+ }
+ })(),
+ new Promise(resolve => {
+ timeout = setTimeout(() => {
+ timedOut = true;
+ try {
+ console.error(`Shutdown tasks timed out after ${SHUTDOWN_TIMEOUT_MS}ms.`);
+ } catch {
+ // stderrの出力自体が失敗してもexitは継続する。
+ }
+ resolve();
+ }, SHUTDOWN_TIMEOUT_MS);
+ }),
+ ]);
+ } finally {
+ if (timeout != null) clearTimeout(timeout);
+ }
+
+ // 既存挙動と同じく、終了処理後はプロセスを終了する。
+ exit(0);
+ };
+
+ // onceにして、同じsignalでhandlerが再入しないようにする。
+ processLike.once('SIGTERM', handleSignal);
+ processLike.once('SIGINT', handleSignal);
+
+ // app.enableShutdownHooks()未配線の現状、SIGTERM/SIGINT時には登録済み終了処理のみを行う。
+ options.onRegistered?.('Registered SIGTERM/SIGINT shutdown handler (this process does not perform NestJS graceful shutdown on these signals).');
+}
+
+export function isShutdownInProgress(): boolean {
+ // masterのcluster exit handlerが、意図したshutdown中のworker終了を再forkしないために参照する。
+ return shuttingDown;
+}
diff --git a/packages/backend/src/boot/worker.ts b/packages/backend/src/boot/worker.ts
index 00d5dd90634..71857f9a527 100644
--- a/packages/backend/src/boot/worker.ts
+++ b/packages/backend/src/boot/worker.ts
@@ -4,20 +4,45 @@
*/
import cluster from 'node:cluster';
+import Logger from '@/logger.js';
import { envOption } from '@/env.js';
import { loadConfig } from '@/config.js';
-import { initTelemetry } from '@/core/telemetry/telemetry-registry.js';
+import type { Config } from '@/config.js';
+import { configureLogging, shutdownLogging } from '@/logging/logging-runtime.js';
+import { initTelemetry, shutdownTelemetry } from '@/core/telemetry/telemetry-registry.js';
import { initExtraThreadPool, jobQueue, server } from './common.js';
+import { installShutdownSignalHandlers } from './shutdown-handler.js';
+
+const logger = new Logger('core', 'cyan');
+const bootLogger = logger.createSubLogger('boot', 'magenta');
/**
* Init worker process
*/
export async function workerMain() {
- const config = loadConfig();
+ let config: Config;
+ try {
+ config = loadConfig();
+ configureLogging(config.logging);
+ logger.info(`Start worker process... pid: ${process.pid}`);
+ } catch (e) {
+ bootLogger.error(e instanceof Error ? e : new Error(String(e)), null, true);
+ process.exit(1);
+ return;
+ }
initExtraThreadPool(config);
- await initTelemetry(config);
+ try {
+ await initTelemetry(config);
+ } catch (e) {
+ bootLogger.error(e instanceof Error ? e : new Error(String(e)), null, true);
+ process.exit(1);
+ }
+ installShutdownSignalHandlers({
+ shutdownTasks: [shutdownTelemetry, shutdownLogging],
+ onRegistered: message => bootLogger.info(message),
+ });
if (envOption.onlyServer) {
await server();
diff --git a/packages/backend/src/config.ts b/packages/backend/src/config.ts
index 439148d9fed..a50c8d5315d 100644
--- a/packages/backend/src/config.ts
+++ b/packages/backend/src/config.ts
@@ -9,9 +9,11 @@ import { dirname, resolve } from 'node:path';
import { type FastifyServerOptions } from 'fastify';
import type * as Sentry from '@sentry/node';
import type * as SentryVue from '@sentry/vue';
-import type { RedisOptions } from 'ioredis';
+import type { RedisOptions as IoRedisRedisOptions } from 'ioredis';
+import type { RedisOptions as BullMqRedisOptions } from 'bullmq';
+import type { AccessLogConfiguration, LogFormat, LogLevelSetting } from './logging/types.js';
-type RedisOptionsSource = Partial & {
+type RedisOptionsRequiredFields = {
host: string;
port: number;
family?: number;
@@ -19,6 +21,8 @@ type RedisOptionsSource = Partial & {
db?: number;
prefix?: string;
};
+type RedisOptionsSource = Partial & RedisOptionsRequiredFields;
+type RedisOptionsResolved = IoRedisRedisOptions & BullMqRedisOptions & RedisOptionsRequiredFields;
type SentryBackendConfig = {
options: Partial;
@@ -138,6 +142,10 @@ type Source = {
pidFile: string;
logging?: {
+ format?: LogFormat;
+ level?: LogLevelSetting;
+ domains?: Record | null;
+ access?: AccessLogConfiguration;
sql?: {
disableQueryTruncation?: boolean,
enableQueryParamLogging?: boolean,
@@ -200,6 +208,10 @@ export type Config = {
deliverJobMaxAttempts: number | undefined;
inboxJobMaxAttempts: number | undefined;
logging?: {
+ format?: LogFormat;
+ level?: LogLevelSetting;
+ domains?: Record | null;
+ access?: AccessLogConfiguration;
sql?: {
disableQueryTruncation?: boolean,
enableQueryParamLogging?: boolean,
@@ -224,11 +236,11 @@ export type Config = {
mediaProxy: string;
externalMediaProxyEnabled: boolean;
videoThumbnailGenerator: string | null;
- redis: RedisOptions & RedisOptionsSource;
- redisForPubsub: RedisOptions & RedisOptionsSource;
- redisForJobQueue: RedisOptions & RedisOptionsSource;
- redisForTimelines: RedisOptions & RedisOptionsSource;
- redisForReactions: RedisOptions & RedisOptionsSource;
+ redis: RedisOptionsResolved;
+ redisForPubsub: RedisOptionsResolved;
+ redisForJobQueue: RedisOptionsResolved;
+ redisForTimelines: RedisOptionsResolved;
+ redisForReactions: RedisOptionsResolved;
sentryForBackend: SentryBackendConfig | undefined;
sentryForFrontend: {
options: Partial & { dsn: string };
@@ -405,7 +417,7 @@ function tryCreateUrl(url: string) {
}
}
-function convertRedisOptions(options: RedisOptionsSource, host: string): RedisOptions & RedisOptionsSource {
+function convertRedisOptions(options: RedisOptionsSource, host: string): RedisOptionsResolved {
return {
...options,
password: options.pass,
diff --git a/packages/backend/src/core/ChannelFollowingService.ts b/packages/backend/src/core/ChannelFollowingService.ts
index 7dae8d10f39..54198fdc06c 100644
--- a/packages/backend/src/core/ChannelFollowingService.ts
+++ b/packages/backend/src/core/ChannelFollowingService.ts
@@ -13,6 +13,8 @@ import { GlobalEvents, GlobalEventService } from '@/core/GlobalEventService.js';
import { bindThis } from '@/decorators.js';
import type { MiLocalUser } from '@/models/User.js';
import { RedisKVCache } from '@/misc/cache.js';
+import { IdentifiableError } from '@/misc/identifiable-error.js';
+import { isDuplicateKeyValueError } from '@/misc/is-duplicate-key-value-error.js';
@Injectable()
export class ChannelFollowingService implements OnModuleInit {
@@ -96,11 +98,18 @@ export class ChannelFollowingService implements OnModuleInit {
requestUser: MiLocalUser,
targetChannel: MiChannel,
): Promise {
- await this.channelFollowingsRepository.insert({
- id: this.idService.gen(),
- followerId: requestUser.id,
- followeeId: targetChannel.id,
- });
+ try {
+ await this.channelFollowingsRepository.insert({
+ id: this.idService.gen(),
+ followerId: requestUser.id,
+ followeeId: targetChannel.id,
+ });
+ } catch (e) {
+ if (isDuplicateKeyValueError(e)) {
+ throw new IdentifiableError('6e335e39-0203-4418-a936-b3f2dc987845', 'already following');
+ }
+ throw e;
+ }
this.globalEventService.publishInternalEvent('followChannel', {
userId: requestUser.id,
diff --git a/packages/backend/src/core/CoreModule.ts b/packages/backend/src/core/CoreModule.ts
index eda33d2e701..e581b697367 100644
--- a/packages/backend/src/core/CoreModule.ts
+++ b/packages/backend/src/core/CoreModule.ts
@@ -18,7 +18,7 @@ import { FlashService } from '@/core/FlashService.js';
import { ChannelMutingService } from '@/core/ChannelMutingService.js';
import { AccountMoveService } from './AccountMoveService.js';
import { AccountUpdateService } from './AccountUpdateService.js';
-import { AiService } from './AiService.js';
+import { SensitiveMediaDetectionService } from './SensitiveMediaDetectionService.js';
import { AnnouncementService } from './AnnouncementService.js';
import { AntennaService } from './AntennaService.js';
import { AchievementService } from './AchievementService.js';
@@ -173,7 +173,7 @@ const $AbuseReportService: Provider = { provide: 'AbuseReportService', useExisti
const $AbuseReportNotificationService: Provider = { provide: 'AbuseReportNotificationService', useExisting: AbuseReportNotificationService };
const $AccountMoveService: Provider = { provide: 'AccountMoveService', useExisting: AccountMoveService };
const $AccountUpdateService: Provider = { provide: 'AccountUpdateService', useExisting: AccountUpdateService };
-const $AiService: Provider = { provide: 'AiService', useExisting: AiService };
+const $SensitiveMediaDetectionService: Provider = { provide: 'SensitiveMediaDetectionService', useExisting: SensitiveMediaDetectionService };
const $AnnouncementService: Provider = { provide: 'AnnouncementService', useExisting: AnnouncementService };
const $AntennaService: Provider = { provide: 'AntennaService', useExisting: AntennaService };
const $AchievementService: Provider = { provide: 'AchievementService', useExisting: AchievementService };
@@ -334,7 +334,7 @@ const $ApQuestionService: Provider = { provide: 'ApQuestionService', useExisting
AbuseReportNotificationService,
AccountMoveService,
AccountUpdateService,
- AiService,
+ SensitiveMediaDetectionService,
AnnouncementService,
AntennaService,
AchievementService,
@@ -494,7 +494,7 @@ const $ApQuestionService: Provider = { provide: 'ApQuestionService', useExisting
$AbuseReportNotificationService,
$AccountMoveService,
$AccountUpdateService,
- $AiService,
+ $SensitiveMediaDetectionService,
$AnnouncementService,
$AntennaService,
$AchievementService,
@@ -652,7 +652,7 @@ const $ApQuestionService: Provider = { provide: 'ApQuestionService', useExisting
AbuseReportNotificationService,
AccountMoveService,
AccountUpdateService,
- AiService,
+ SensitiveMediaDetectionService,
AnnouncementService,
AntennaService,
AchievementService,
@@ -811,7 +811,7 @@ const $ApQuestionService: Provider = { provide: 'ApQuestionService', useExisting
$AbuseReportNotificationService,
$AccountMoveService,
$AccountUpdateService,
- $AiService,
+ $SensitiveMediaDetectionService,
$AnnouncementService,
$AntennaService,
$AchievementService,
diff --git a/packages/backend/src/core/FileInfoService.ts b/packages/backend/src/core/FileInfoService.ts
index 516eb745142..f0befc2a855 100644
--- a/packages/backend/src/core/FileInfoService.ts
+++ b/packages/backend/src/core/FileInfoService.ts
@@ -16,12 +16,12 @@ import probeImageSize from 'probe-image-size';
import { sharpBmp } from '@misskey-dev/sharp-read-bmp';
import * as blurhash from 'blurhash';
import { createTempDir } from '@/misc/create-temp.js';
-import { AiService } from '@/core/AiService.js';
+import { SensitiveMediaDetectionService } from '@/core/SensitiveMediaDetectionService.js';
import { LoggerService } from '@/core/LoggerService.js';
import type Logger from '@/logger.js';
import { bindThis } from '@/decorators.js';
import { isMimeImage } from '@/misc/is-mime-image.js';
-import type { Prediction } from '@/core/AiService.js';
+import type { Prediction } from '@/core/SensitiveMediaDetectionService.js';
export type FileInfo = {
size: number;
@@ -54,7 +54,7 @@ export class FileInfoService {
private logger: Logger;
constructor(
- private aiService: AiService,
+ private sensitiveMediaDetectionService: SensitiveMediaDetectionService,
private loggerService: LoggerService,
) {
this.logger = this.loggerService.getLogger('file-info');
@@ -266,7 +266,7 @@ export class FileInfoService {
fs.promises.unlink(path);
}
}
- const predictions = await this.aiService.detectSensitiveMany(frameBuffers);
+ const predictions = await this.sensitiveMediaDetectionService.detectSensitiveMany(frameBuffers);
const results = predictions.filter((x): x is Prediction[] => x != null).map(x => judgePrediction(x));
// 判定に成功したフレームが 0 件のとき(接続先未設定・通信失敗等)は、
// Math.ceil(0) との比較が 0 >= 0 で真になり全動画がセンシティブ扱いになってしまうため、
@@ -291,7 +291,7 @@ export class FileInfoService {
.flatten({ background: { r: 119, g: 119, b: 119 } }) // 透過部分を18%グレーで塗りつぶす
.png()
.toBuffer();
- const result = await this.aiService.detectSensitive(png);
+ const result = await this.sensitiveMediaDetectionService.detectSensitive(png);
if (result) {
[sensitive, porn] = judgePrediction(result);
}
diff --git a/packages/backend/src/core/HashtagService.ts b/packages/backend/src/core/HashtagService.ts
index beed786d1b9..750fbb06ffe 100644
--- a/packages/backend/src/core/HashtagService.ts
+++ b/packages/backend/src/core/HashtagService.ts
@@ -16,11 +16,20 @@ import { UserEntityService } from '@/core/entities/UserEntityService.js';
import { bindThis } from '@/decorators.js';
import { FeaturedService } from '@/core/FeaturedService.js';
import { UtilityService } from '@/core/UtilityService.js';
-import { isDuplicateKeyValueError } from '@/misc/is-duplicate-key-value-error.js';
import Logger from '../logger.js';
const logger = new Logger('hashtag/create');
+type AttachedOrMentioned = 'attached' | 'mentioned';
+type UpdatingHashtagColumn = {
+ totalUserIds: keyof MiHashtag & `${AttachedOrMentioned}UserIds`,
+ totalUsersCount: keyof MiHashtag & `${AttachedOrMentioned}UsersCount`,
+ localUserIds: keyof MiHashtag & `${AttachedOrMentioned}LocalUserIds`,
+ localUsersCount: keyof MiHashtag & `${AttachedOrMentioned}LocalUsersCount`,
+ remoteUserIds: keyof MiHashtag & `${AttachedOrMentioned}RemoteUserIds`,
+ remoteUsersCount: keyof MiHashtag & `${AttachedOrMentioned}RemoteUsersCount`,
+};
+
@Injectable()
export class HashtagService {
constructor(
@@ -68,126 +77,93 @@ export class HashtagService {
// TODO: サンプリング
this.updateHashtagsRanking(tag, user.id);
- {
- const index = await this.hashtagsRepository.findOneBy({ name: tag });
-
- if (index == null && inc) {
- try {
- if (isUserAttached) {
- await this.hashtagsRepository.insert({
- id: this.idService.gen(),
- name: tag,
- mentionedUserIds: [],
- mentionedUsersCount: 0,
- mentionedLocalUserIds: [],
- mentionedLocalUsersCount: 0,
- mentionedRemoteUserIds: [],
- mentionedRemoteUsersCount: 0,
- attachedUserIds: [user.id],
- attachedUsersCount: 1,
- attachedLocalUserIds: this.userEntityService.isLocalUser(user) ? [user.id] : [],
- attachedLocalUsersCount: this.userEntityService.isLocalUser(user) ? 1 : 0,
- attachedRemoteUserIds: this.userEntityService.isRemoteUser(user) ? [user.id] : [],
- attachedRemoteUsersCount: this.userEntityService.isRemoteUser(user) ? 1 : 0,
- } as MiHashtag);
- } else {
- await this.hashtagsRepository.insert({
- id: this.idService.gen(),
- name: tag,
- mentionedUserIds: [user.id],
- mentionedUsersCount: 1,
- mentionedLocalUserIds: this.userEntityService.isLocalUser(user) ? [user.id] : [],
- mentionedLocalUsersCount: this.userEntityService.isLocalUser(user) ? 1 : 0,
- mentionedRemoteUserIds: this.userEntityService.isRemoteUser(user) ? [user.id] : [],
- mentionedRemoteUsersCount: this.userEntityService.isRemoteUser(user) ? 1 : 0,
- attachedUserIds: [],
- attachedUsersCount: 0,
- attachedLocalUserIds: [],
- attachedLocalUsersCount: 0,
- attachedRemoteUserIds: [],
- attachedRemoteUsersCount: 0,
- } as MiHashtag);
- }
- return;
- } catch (err) {
- if (isDuplicateKeyValueError(err)) {
- logger.info(`Duplicate insertion detected. Falling back to update. #${tag}`);
- } else {
- throw err;
- }
- }
- }
+ const column: UpdatingHashtagColumn = isUserAttached ? {
+ totalUserIds: 'attachedUserIds',
+ totalUsersCount: 'attachedUsersCount',
+ localUserIds: 'attachedLocalUserIds',
+ localUsersCount: 'attachedLocalUsersCount',
+ remoteUserIds: 'attachedRemoteUserIds',
+ remoteUsersCount: 'attachedRemoteUsersCount',
+ } : {
+ totalUserIds: 'mentionedUserIds',
+ totalUsersCount: 'mentionedUsersCount',
+ localUserIds: 'mentionedLocalUserIds',
+ localUsersCount: 'mentionedLocalUsersCount',
+ remoteUserIds: 'mentionedRemoteUserIds',
+ remoteUsersCount: 'mentionedRemoteUsersCount',
+ };
+
+ if (inc) {
+ await this.#incrementHashTag(user, tag, column);
+ } else {
+ await this.#decrementHashTag(user, tag, column);
}
+ }
- await this.db.transaction(async transactionalEntityManager => {
- const transactionalHashtagRepository = transactionalEntityManager
- .getRepository(MiHashtag);
-
- const index = await transactionalHashtagRepository
- .createQueryBuilder()
- .setLock('pessimistic_write')
- .where('name = :name', { name: tag })
- .getOne();
-
- if (index == null) return;
-
- const set = {} as any;
-
- if (isUserAttached) {
- if (inc) {
- // 自分が初めてこのタグを使ったなら
- if (!index.attachedUserIds.some(id => id === user.id)) {
- set.attachedUserIds = () => `array_append("attachedUserIds", '${user.id}')`;
- set.attachedUsersCount = () => '"attachedUsersCount" + 1';
- }
- // 自分が(ローカル内で)初めてこのタグを使ったなら
- if (this.userEntityService.isLocalUser(user) && !index.attachedLocalUserIds.some(id => id === user.id)) {
- set.attachedLocalUserIds = () => `array_append("attachedLocalUserIds", '${user.id}')`;
- set.attachedLocalUsersCount = () => '"attachedLocalUsersCount" + 1';
- }
- // 自分が(リモートで)初めてこのタグを使ったなら
- if (this.userEntityService.isRemoteUser(user) && !index.attachedRemoteUserIds.some(id => id === user.id)) {
- set.attachedRemoteUserIds = () => `array_append("attachedRemoteUserIds", '${user.id}')`;
- set.attachedRemoteUsersCount = () => '"attachedRemoteUsersCount" + 1';
- }
- } else {
- set.attachedUserIds = () => `array_remove("attachedUserIds", '${user.id}')`;
- set.attachedUsersCount = () => '"attachedUsersCount" - 1';
- if (this.userEntityService.isLocalUser(user)) {
- set.attachedLocalUserIds = () => `array_remove("attachedLocalUserIds", '${user.id}')`;
- set.attachedLocalUsersCount = () => '"attachedLocalUsersCount" - 1';
- } else {
- set.attachedRemoteUserIds = () => `array_remove("attachedRemoteUserIds", '${user.id}')`;
- set.attachedRemoteUsersCount = () => '"attachedRemoteUsersCount" - 1';
- }
- }
- } else {
- // 自分が初めてこのタグを使ったなら
- if (!index.mentionedUserIds.some(id => id === user.id)) {
- set.mentionedUserIds = () => `array_append("mentionedUserIds", '${user.id}')`;
- set.mentionedUsersCount = () => '"mentionedUsersCount" + 1';
- }
- // 自分が(ローカル内で)初めてこのタグを使ったなら
- if (this.userEntityService.isLocalUser(user) && !index.mentionedLocalUserIds.some(id => id === user.id)) {
- set.mentionedLocalUserIds = () => `array_append("mentionedLocalUserIds", '${user.id}')`;
- set.mentionedLocalUsersCount = () => '"mentionedLocalUsersCount" + 1';
- }
- // 自分が(リモートで)初めてこのタグを使ったなら
- if (this.userEntityService.isRemoteUser(user) && !index.mentionedRemoteUserIds.some(id => id === user.id)) {
- set.mentionedRemoteUserIds = () => `array_append("mentionedRemoteUserIds", '${user.id}')`;
- set.mentionedRemoteUsersCount = () => '"mentionedRemoteUsersCount" + 1';
- }
- }
+ async #incrementHashTag(
+ user: { id: MiUser['id']; host: MiUser['host']; },
+ tag: string,
+ columns: UpdatingHashtagColumn,
+ ) {
+ const isLocal = this.userEntityService.isLocalUser(user);
+ const { totalUserIds, totalUsersCount } = columns;
+ const localOrRemoteUserIds = isLocal ? columns.localUserIds : columns.remoteUserIds;
+ const localOrRemoteUserCount = isLocal ? columns.localUsersCount : columns.remoteUsersCount;
+
+ const runner = this.db.createQueryRunner('master');
+ try {
+ await runner.query(
+ `INSERT into "hashtag"("id", "name", "${totalUserIds}", "${totalUsersCount}", "${localOrRemoteUserIds}",
+ "${localOrRemoteUserCount}")
+ VALUES ($3, $1, ARRAY [$2], 1, ARRAY [$2], 1)
+ ON CONFLICT ("name")
+ DO UPDATE SET "${totalUserIds}" = ${appendUserIdIfNotExists(totalUserIds)},
+ "${totalUsersCount}" = ${incrementCountIfNotExists(totalUserIds, totalUsersCount)},
+ "${localOrRemoteUserIds}" = ${appendUserIdIfNotExists(localOrRemoteUserIds)},
+ "${localOrRemoteUserCount}" = ${incrementCountIfNotExists(localOrRemoteUserIds, localOrRemoteUserCount)}`,
+ [tag, user.id, this.idService.gen()],
+ );
+ } finally {
+ await runner.release();
+ }
- if (Object.keys(set).length > 0) {
- await transactionalHashtagRepository
- .createQueryBuilder()
- .update()
- .where('id = :id', { id: index.id })
- .set(set)
- .execute();
- }
- });
+ function appendUserIdIfNotExists(userIds: keyof MiHashtag & `${string}UserIds`): string {
+ return `CASE WHEN NOT ("hashtag"."${userIds}" @> ARRAY[$2 ::varchar]) THEN array_append("hashtag"."${userIds}", $2) ELSE "hashtag"."${userIds}" END`;
+ }
+
+ function incrementCountIfNotExists(userIds: keyof MiHashtag & `${string}UserIds`, userCount: keyof MiHashtag & `${string}UsersCount`): string {
+ return `CASE WHEN NOT ("hashtag"."${userIds}" @> ARRAY[$2 ::varchar]) THEN "hashtag"."${userCount}" + 1 ELSE "hashtag"."${userCount}" END`;
+ }
+ }
+
+ async #decrementHashTag(
+ user: { id: MiUser['id']; host: MiUser['host']; },
+ tag: string,
+ columns: UpdatingHashtagColumn,
+ ) {
+ const isLocal = this.userEntityService.isLocalUser(user);
+ const { totalUserIds, totalUsersCount } = columns;
+ const localOrRemoteUserIds = isLocal ? columns.localUserIds : columns.remoteUserIds;
+ const localOrRemoteUserCount = isLocal ? columns.localUsersCount : columns.remoteUsersCount;
+
+ const runner = this.db.createQueryRunner('master');
+ try {
+ await runner.query(
+ `UPDATE "hashtag"
+ SET "${totalUserIds}" = array_remove("${totalUserIds}", $2),
+ "${totalUsersCount}" = ${decrementIfExists(totalUserIds, totalUsersCount)},
+ "${localOrRemoteUserIds}" = array_remove("${localOrRemoteUserIds}", $2),
+ "${localOrRemoteUserCount}" = ${decrementIfExists(localOrRemoteUserIds, localOrRemoteUserCount)}
+ WHERE "name" = $1`,
+ [tag, user.id],
+ );
+ } finally {
+ await runner.release();
+ }
+
+ function decrementIfExists(userIds: keyof MiHashtag & `${string}UserIds`, userCount: keyof MiHashtag & `${string}UsersCount`): string {
+ return `CASE WHEN ("${userIds}" @> ARRAY[$2]) THEN "${userCount}" - 1 ELSE "${userCount}" END`;
+ }
}
@bindThis
diff --git a/packages/backend/src/core/HttpRequestService.ts b/packages/backend/src/core/HttpRequestService.ts
index 5714bde8bf1..f7c76f4705d 100644
--- a/packages/backend/src/core/HttpRequestService.ts
+++ b/packages/backend/src/core/HttpRequestService.ts
@@ -19,7 +19,7 @@ import { bindThis } from '@/decorators.js';
import { validateContentTypeSetAsActivityPub } from '@/core/activitypub/misc/validator.js';
import { assertActivityMatchesUrl, FetchAllowSoftFailMask } from '@/core/activitypub/misc/check-against-url.js';
import type { IObject } from '@/core/activitypub/type.js';
-import type { Response } from 'node-fetch';
+import type { BodyInit, Response } from 'node-fetch';
import type { URL } from 'node:url';
export type HttpRequestSendOptions = {
@@ -311,7 +311,7 @@ export class HttpRequestService {
url: string,
args: {
method?: string,
- body?: string,
+ body?: BodyInit,
headers?: Record,
timeout?: number,
size?: number,
diff --git a/packages/backend/src/core/NoteCreateService.ts b/packages/backend/src/core/NoteCreateService.ts
index a54c60a0c60..7ac201796cd 100644
--- a/packages/backend/src/core/NoteCreateService.ts
+++ b/packages/backend/src/core/NoteCreateService.ts
@@ -520,6 +520,11 @@ export class NoteCreateService implements OnApplicationShutdown {
// specified / direct noteはreject
throw new Error('Renote target is not public or home');
}
+
+ // ローカルのみをRenoteしたらローカルのみにする
+ if (data.renote.localOnly && data.channel == null) {
+ data.localOnly = true;
+ }
}
// Check blocking
@@ -534,19 +539,35 @@ export class NoteCreateService implements OnApplicationShutdown {
}
}
- // 返信対象がpublicではないならhomeにする
- if (data.reply && data.reply.visibility !== 'public' && data.visibility === 'public') {
- data.visibility = 'home';
- }
-
- // ローカルのみをRenoteしたらローカルのみにする
- if (data.renote && data.renote.localOnly && data.channel == null) {
- data.localOnly = true;
- }
+ if (data.reply) {
+ switch (data.reply.visibility) {
+ case 'public':
+ // public noteは無条件にreply可能
+ break;
+ case 'home':
+ // home noteはhome以下にreply可能
+ if (data.visibility === 'public') {
+ data.visibility = 'home';
+ }
+ break;
+ case 'followers':
+ // followers noteはfollowers以下にreply可能
+ if (data.visibility === 'public' || data.visibility === 'home') {
+ data.visibility = 'followers';
+ }
+ break;
+ case 'specified':
+ // specified / direct noteはspecifiedのみreply可能
+ if (data.visibility !== 'specified') {
+ data.visibility = 'specified';
+ }
+ break;
+ }
- // ローカルのみにリプライしたらローカルのみにする
- if (data.reply && data.reply.localOnly && data.channel == null) {
- data.localOnly = true;
+ // ローカルのみにリプライしたらローカルのみにする
+ if (data.reply.localOnly && data.channel == null) {
+ data.localOnly = true;
+ }
}
if (data.text) {
diff --git a/packages/backend/src/core/NoteUpdateService.ts b/packages/backend/src/core/NoteUpdateService.ts
index daf54e8d7b5..57180af9dd8 100644
--- a/packages/backend/src/core/NoteUpdateService.ts
+++ b/packages/backend/src/core/NoteUpdateService.ts
@@ -4,8 +4,10 @@
*/
import { setImmediate } from 'node:timers/promises';
+import util from 'util';
import * as mfm from 'mfm-js';
import { Inject, Injectable, OnApplicationShutdown } from '@nestjs/common';
+import { In } from 'typeorm';
import { extractCustomEmojisFromMfm } from '@/misc/extract-custom-emojis-from-mfm.js';
import { extractHashtags } from '@/misc/extract-hashtags.js';
import { MiNote, IMentionedRemoteUsers } from '@/models/Note.js';
@@ -21,9 +23,7 @@ import { ApRendererService } from '@/core/activitypub/ApRendererService.js';
import { ApDeliverManagerService } from '@/core/activitypub/ApDeliverManagerService.js';
import { UserEntityService } from '@/core/entities/UserEntityService.js';
import { RelayService } from '@/core/RelayService.js';
-import { In } from 'typeorm';
import ActiveUsersChart from '@/core/chart/charts/active-users.js';
-import util from 'util';
export type NoteUpdateData = {
updatedAt?: Date | null;
@@ -141,7 +141,6 @@ export class NoteUpdateService implements OnApplicationShutdown {
//#region AP deliver
if (this.userEntityService.isLocalUser(user)) {
setImmediate(async () => {
- // @ts-ignore
const noteActivity = await this.renderNoteActivity(updatedNote, user);
await this.deliverToConcerned(user, updatedNote, noteActivity);
});
diff --git a/packages/backend/src/core/PageService.ts b/packages/backend/src/core/PageService.ts
index 4abeb30fce3..ebabe0570db 100644
--- a/packages/backend/src/core/PageService.ts
+++ b/packages/backend/src/core/PageService.ts
@@ -141,8 +141,6 @@ export class PageService {
eyeCatchingImageId: body.eyeCatchingImage === undefined ? undefined : (body.eyeCatchingImage?.id ?? null),
});
- console.log('page.content', page.content);
-
if (body.content != null) {
const beforeReferencedNotes = this.collectReferencedNotes(page.content);
const afterReferencedNotes = this.collectReferencedNotes(body.content);
diff --git a/packages/backend/src/core/QueueModule.ts b/packages/backend/src/core/QueueModule.ts
index ecd96261e0d..3c893ea4191 100644
--- a/packages/backend/src/core/QueueModule.ts
+++ b/packages/backend/src/core/QueueModule.ts
@@ -31,63 +31,67 @@ export type ObjectStorageQueue = Bull.Queue;
export type UserWebhookDeliverQueue = Bull.Queue;
export type SystemWebhookDeliverQueue = Bull.Queue;
+function createQueue(queueName: string, config: Config): Bull.Queue {
+ return new Bull.Queue(queueName, baseQueueOptions(config, queueName));
+}
+
const $system: Provider = {
provide: 'queue:system',
- useFactory: (config: Config) => new Bull.Queue(QUEUE.SYSTEM, baseQueueOptions(config, QUEUE.SYSTEM)),
+ useFactory: (config: Config) => createQueue>(QUEUE.SYSTEM, config),
inject: [DI.config],
};
const $endedPollNotification: Provider = {
provide: 'queue:endedPollNotification',
- useFactory: (config: Config) => new Bull.Queue(QUEUE.ENDED_POLL_NOTIFICATION, baseQueueOptions(config, QUEUE.ENDED_POLL_NOTIFICATION)),
+ useFactory: (config: Config) => createQueue(QUEUE.ENDED_POLL_NOTIFICATION, config),
inject: [DI.config],
};
const $postScheduledNote: Provider = {
provide: 'queue:postScheduledNote',
- useFactory: (config: Config) => new Bull.Queue(QUEUE.POST_SCHEDULED_NOTE, baseQueueOptions(config, QUEUE.POST_SCHEDULED_NOTE)),
+ useFactory: (config: Config) => createQueue(QUEUE.POST_SCHEDULED_NOTE, config),
inject: [DI.config],
};
const $deliver: Provider = {
provide: 'queue:deliver',
- useFactory: (config: Config) => new Bull.Queue(QUEUE.DELIVER, baseQueueOptions(config, QUEUE.DELIVER)),
+ useFactory: (config: Config) => createQueue(QUEUE.DELIVER, config),
inject: [DI.config],
};
const $inbox: Provider = {
provide: 'queue:inbox',
- useFactory: (config: Config) => new Bull.Queue(QUEUE.INBOX, baseQueueOptions(config, QUEUE.INBOX)),
+ useFactory: (config: Config) => createQueue(QUEUE.INBOX, config),
inject: [DI.config],
};
const $db: Provider = {
provide: 'queue:db',
- useFactory: (config: Config) => new Bull.Queue(QUEUE.DB, baseQueueOptions(config, QUEUE.DB)),
+ useFactory: (config: Config) => createQueue>(QUEUE.DB, config),
inject: [DI.config],
};
const $relationship: Provider = {
provide: 'queue:relationship',
- useFactory: (config: Config) => new Bull.Queue(QUEUE.RELATIONSHIP, baseQueueOptions(config, QUEUE.RELATIONSHIP)),
+ useFactory: (config: Config) => createQueue(QUEUE.RELATIONSHIP, config),
inject: [DI.config],
};
const $objectStorage: Provider = {
provide: 'queue:objectStorage',
- useFactory: (config: Config) => new Bull.Queue(QUEUE.OBJECT_STORAGE, baseQueueOptions(config, QUEUE.OBJECT_STORAGE)),
+ useFactory: (config: Config) => createQueue>(QUEUE.OBJECT_STORAGE, config),
inject: [DI.config],
};
const $userWebhookDeliver: Provider = {
provide: 'queue:userWebhookDeliver',
- useFactory: (config: Config) => new Bull.Queue(QUEUE.USER_WEBHOOK_DELIVER, baseQueueOptions(config, QUEUE.USER_WEBHOOK_DELIVER)),
+ useFactory: (config: Config) => createQueue(QUEUE.USER_WEBHOOK_DELIVER, config),
inject: [DI.config],
};
const $systemWebhookDeliver: Provider = {
provide: 'queue:systemWebhookDeliver',
- useFactory: (config: Config) => new Bull.Queue(QUEUE.SYSTEM_WEBHOOK_DELIVER, baseQueueOptions(config, QUEUE.SYSTEM_WEBHOOK_DELIVER)),
+ useFactory: (config: Config) => createQueue(QUEUE.SYSTEM_WEBHOOK_DELIVER, config),
inject: [DI.config],
};
diff --git a/packages/backend/src/core/QueueService.ts b/packages/backend/src/core/QueueService.ts
index e416e3112ac..404eb1f6be0 100644
--- a/packages/backend/src/core/QueueService.ts
+++ b/packages/backend/src/core/QueueService.ts
@@ -905,7 +905,7 @@ export class QueueService {
const isPaused = await queue.isPaused();
const metrics_completed = await queue.getMetrics('completed', 0, MetricsTime.ONE_WEEK);
const metrics_failed = await queue.getMetrics('failed', 0, MetricsTime.ONE_WEEK);
- const db = parseRedisInfo(await (await queue.client).info());
+ const db = parseRedisInfo(await (await queue.getBackend().client).info());
return {
name: queueType,
diff --git a/packages/backend/src/core/AiService.ts b/packages/backend/src/core/SensitiveMediaDetectionService.ts
similarity index 91%
rename from packages/backend/src/core/AiService.ts
rename to packages/backend/src/core/SensitiveMediaDetectionService.ts
index fe21af09f47..22dcb5fc9bd 100644
--- a/packages/backend/src/core/AiService.ts
+++ b/packages/backend/src/core/SensitiveMediaDetectionService.ts
@@ -4,7 +4,6 @@
*/
import { Injectable, Inject } from '@nestjs/common';
-import fetch from 'node-fetch';
import { DI } from '@/di-symbols.js';
import { bindThis } from '@/decorators.js';
import { HttpRequestService } from '@/core/HttpRequestService.js';
@@ -70,7 +69,7 @@ function isDetectImagesResponse(v: unknown): v is DetectImagesResponse {
const DETECT_IMAGES_PATH = 'v1/detect-images';
@Injectable()
-export class AiService {
+export class SensitiveMediaDetectionService {
private logger: Logger;
constructor(
@@ -80,7 +79,7 @@ export class AiService {
private httpRequestService: HttpRequestService,
private loggerService: LoggerService,
) {
- this.logger = this.loggerService.getLogger('ai');
+ this.logger = this.loggerService.getLogger('sensitive-media-detection');
}
/**
@@ -131,9 +130,6 @@ export class AiService {
@bindThis
private async detectChunk(url: string, apiKey: string | null, timeout: number, chunk: Buffer[]): Promise<(Prediction[] | null)[]> {
- const controller = new AbortController();
- const timer = setTimeout(() => controller.abort(), timeout);
-
try {
const form = new FormData();
for (let i = 0; i < chunk.length; i++) {
@@ -148,14 +144,13 @@ export class AiService {
headers['Authorization'] = `Bearer ${apiKey}`;
}
- const res = await fetch(url, {
+ const res = await this.httpRequestService.send(url, {
method: 'POST',
headers,
body: form,
- // 外部サービスとして通常の proxy / private address 制限を適用する。
- // サイドカーへの private network 接続は allowedPrivateNetworks 等で明示的に許可する。
- agent: (u) => this.httpRequestService.getAgentByUrl(u),
- signal: controller.signal,
+ timeout,
+ }, {
+ throwErrorWhenResponseNotOk: false,
});
if (!res.ok) {
@@ -181,8 +176,6 @@ export class AiService {
} catch (err) {
this.logger.warn(`sensitive detection error: ${err instanceof Error ? err.message : String(err)}`);
return chunk.map(() => null);
- } finally {
- clearTimeout(timer);
}
}
}
diff --git a/packages/backend/src/core/UserKeypairService.ts b/packages/backend/src/core/UserKeypairService.ts
index 92d61cd103f..fa6f58b15ab 100644
--- a/packages/backend/src/core/UserKeypairService.ts
+++ b/packages/backend/src/core/UserKeypairService.ts
@@ -5,6 +5,7 @@
import { Inject, Injectable, OnApplicationShutdown } from '@nestjs/common';
import * as Redis from 'ioredis';
+import * as nodeCrypto from 'crypto';
import type { MiUser } from '@/models/User.js';
import type { UserKeypairsRepository } from '@/models/_.js';
import { RedisKVCache } from '@/misc/cache.js';
@@ -23,10 +24,10 @@ export class UserKeypairService implements OnApplicationShutdown {
@Inject(DI.userKeypairsRepository)
private userKeypairsRepository: UserKeypairsRepository,
) {
- this.cache = new RedisKVCache(this.redisClient, 'userKeypair', {
+ this.cache = new RedisKVCache(this.redisClient, 'userKeypair:v2', {
lifetime: 1000 * 60 * 60 * 24, // 24h
memoryCacheLifetime: 1000 * 60 * 60, // 1h
- fetcher: (key) => this.userKeypairsRepository.findOneByOrFail({ userId: key }),
+ fetcher: (key) => this.fetcher(key),
toRedisConverter: (value) => JSON.stringify(value),
fromRedisConverter: (value) => JSON.parse(value),
});
@@ -46,4 +47,18 @@ export class UserKeypairService implements OnApplicationShutdown {
public onApplicationShutdown(signal?: string | undefined): void {
this.dispose();
}
+
+ @bindThis
+ public async fetcher(userId: MiUser['id']): Promise {
+ const keyPair = await this.userKeypairsRepository.findOneByOrFail({ userId });
+
+ // migrate PKCS#1 => PKCS#8. legacy misskey generated PKCS#1 but slacc only accepts PKCS#8
+ if (keyPair.privateKey.includes('-----BEGIN RSA PRIVATE KEY-----')) {
+ const pkcs8Key = nodeCrypto.createPrivateKey({ key: keyPair.privateKey, format: 'pem', type: 'pkcs1' }).export({ format: 'pem', type: 'pkcs8' });
+ keyPair.privateKey = pkcs8Key;
+ void this.userKeypairsRepository.update(userId, { privateKey: pkcs8Key });
+ }
+
+ return keyPair;
+ }
}
diff --git a/packages/backend/src/core/telemetry/adapters/SentryTelemetryAdapter.ts b/packages/backend/src/core/telemetry/adapters/SentryTelemetryAdapter.ts
index b1858c31c11..126049e5205 100644
--- a/packages/backend/src/core/telemetry/adapters/SentryTelemetryAdapter.ts
+++ b/packages/backend/src/core/telemetry/adapters/SentryTelemetryAdapter.ts
@@ -3,14 +3,19 @@
* SPDX-License-Identifier: AGPL-3.0-only
*/
-import type { Config } from '@/config.js';
-import type { TelemetryAdapter, TelemetryCaptureMessageOptions } from './TelemetryAdapter.js';
+import type { LogTraceContext } from '@/logging/types.js';
+import type * as SentryNode from '@sentry/node';
+import type { NodeOptions } from '@sentry/node';
+import type { SentryBackendConfig, TelemetryAdapter, TelemetryCaptureMessageOptions } from './TelemetryAdapter.js';
-type SentryIntegrationsOption = NonNullable;
+// Sentryのtransportが詰まってもプロセス終了を妨げないようにする。
+const DEFAULT_SHUTDOWN_TIMEOUT = 5000;
+
+type SentryIntegrationsOption = NonNullable;
// eslint-disable-next-line @typescript-eslint/no-explicit-any
type SentryIntegrationFactory = Extract any[]>;
type SentryIntegration = Parameters[0][number];
-type SentryNodeOptions = import('@sentry/node').NodeOptions;
+type SentryNodeOptions = NodeOptions;
type BuildSentryIntegrationsOptions = {
disabledIntegrations?: string[];
@@ -37,7 +42,7 @@ export function buildSentryIntegrations(options: BuildSentryIntegrationsOptions)
}
export function buildSentryNodeOptions(
- config: NonNullable,
+ config: SentryBackendConfig,
nodeProfilingIntegration?: () => SentryIntegration,
): SentryNodeOptions {
return {
@@ -65,11 +70,11 @@ export function buildSentryNodeOptions(
export class SentryTelemetryAdapter implements TelemetryAdapter {
private constructor(
- private readonly Sentry: typeof import('@sentry/node'),
+ private readonly Sentry: typeof SentryNode,
) {
}
- public static async create(config: NonNullable): Promise {
+ public static async create(config: SentryBackendConfig): Promise {
const Sentry = await import('@sentry/node');
const { nodeProfilingIntegration } = await import('@sentry/profiling-node');
@@ -86,11 +91,21 @@ export class SentryTelemetryAdapter implements TelemetryAdapter {
});
}
+ /** activeなSpanの識別子を、Logging基盤で扱える形式へ変換します。 */
+ public getActiveTraceContext(): LogTraceContext | undefined {
+ const activeSpan = this.Sentry.getActiveSpan();
+ if (activeSpan == null) return undefined;
+
+ const { traceId, spanId, traceFlags } = activeSpan.spanContext();
+ return { traceId, spanId, traceFlags };
+ }
+
public startSpan(name: string, fn: () => T): T {
return this.Sentry.startSpan({ name }, fn);
}
public async shutdown(): Promise {
- await this.Sentry.close();
+ // timeout未指定だとtransportのflushが詰まった際にプロセス終了を妨げるため、上限時間を設ける。
+ await this.Sentry.close(DEFAULT_SHUTDOWN_TIMEOUT);
}
}
diff --git a/packages/backend/src/core/telemetry/adapters/TelemetryAdapter.ts b/packages/backend/src/core/telemetry/adapters/TelemetryAdapter.ts
index d074de056d2..d17430d0c0c 100644
--- a/packages/backend/src/core/telemetry/adapters/TelemetryAdapter.ts
+++ b/packages/backend/src/core/telemetry/adapters/TelemetryAdapter.ts
@@ -3,19 +3,46 @@
* SPDX-License-Identifier: AGPL-3.0-only
*/
+import type { Config } from '@/config.js';
+import type { LogTraceContext } from '@/logging/types.js';
+
+export type SentryBackendConfig = NonNullable;
+
export interface TelemetryCaptureMessageOptions {
+ /** 現在はエラー通知用途だけに絞る。追加する場合は各adapterでの扱いを揃えること。 */
level: 'error';
+
+ /** Sentryではuser.idへ渡す補助情報です。 */
userId?: string;
+
+ /** queue名やendpoint名など、通知先で調査に使う補助情報。 */
extra?: Record;
}
/**
- * Sentry・OpenTelemetryなど、エラートラッキング/APMサービスごとの実装差異を隠蔽するための抽象。
+ * エラートラッキング/APMサービスごとの実装差異を隠蔽するための抽象。
* 新しいサービスを追加する場合はこのインターフェースを実装するアダプタをこのディレクトリに追加し、
* telemetry-registry.tsのinitTelemetry内で登録する。
*/
export interface TelemetryAdapter {
+ /**
+ * 実行中の処理で起きたエラー相当の事象を記録する。
+ * Sentryではmessage通知として扱う。
+ */
captureMessage(message: string, opts: TelemetryCaptureMessageOptions): void;
+
+ /** 現在のactive Spanからログへ付加するTrace Contextを取得する。 */
+ getActiveTraceContext?(): LogTraceContext | undefined;
+
+ /**
+ * API endpointやqueue jobなど、呼び出し側の処理単位をspanで包む。
+ * fnの戻り値・例外はそのまま呼び出し側へ返し、Promiseの場合はsettleまでspanを閉じない。
+ */
startSpan(name: string, fn: () => T): T;
+
+ /**
+ * プロセス終了時にtelemetry backendへ残りのデータをflushする。
+ * 実装側ではtransport停止に引きずられないよう、待機時間に上限を設ける。
+ */
shutdown(): Promise;
}
diff --git a/packages/backend/src/core/telemetry/telemetry-registry.ts b/packages/backend/src/core/telemetry/telemetry-registry.ts
index 90dc2678374..aa17a97f765 100644
--- a/packages/backend/src/core/telemetry/telemetry-registry.ts
+++ b/packages/backend/src/core/telemetry/telemetry-registry.ts
@@ -4,6 +4,7 @@
*/
import type { Config } from '@/config.js';
+import { setLogTraceContextProvider } from '@/logging/logging-runtime.js';
import { SentryTelemetryAdapter } from './adapters/SentryTelemetryAdapter.js';
import type { TelemetryAdapter, TelemetryCaptureMessageOptions } from './adapters/TelemetryAdapter.js';
@@ -16,17 +17,27 @@ const adapters: TelemetryAdapter[] = [];
export async function initTelemetry(config: Config): Promise {
if (config.sentryForBackend) {
- adapters.push(await SentryTelemetryAdapter.create(config.sentryForBackend));
+ const adapter = await SentryTelemetryAdapter.create(config.sentryForBackend);
+ adapters.push(adapter);
+ // Telemetryの初期化後に登録し、初期化前のBootstrapログは従来どおり出力する。
+ setLogTraceContextProvider(() => adapter.getActiveTraceContext?.());
}
}
export function captureMessage(message: string, opts: TelemetryCaptureMessageOptions): void {
+ // 有効なadapterすべてへ通知し、宛先ごとの差異はadapter内に閉じ込める。
for (const adapter of adapters) {
adapter.captureMessage(message, opts);
}
}
export function startSpan(name: string, fn: () => T): T {
+ // 有効なadapterが無い/1つだけの場合(実運用上の大半のケース)は、
+ // 毎リクエスト/ジョブでreduceRightのclosureを組み立てる無駄を避ける。
+ if (adapters.length === 0) return fn();
+ if (adapters.length === 1) return adapters[0].startSpan(name, fn);
+
+ // 将来複数adapterを登録する場合でも同じ処理を入れ子にラップし、呼び出し側のAPIは1回のstartSpanに保つ。
const wrapped = adapters.reduceRight<() => T>(
(inner, adapter) => () => adapter.startSpan(name, inner),
fn,
@@ -35,5 +46,6 @@ export function startSpan(name: string, fn: () => T): T {
}
export async function shutdownTelemetry(): Promise {
- await Promise.all(adapters.map(adapter => adapter.shutdown()));
+ // 終了時は登録済みadapterを並列にflush/shutdownする。
+ await Promise.allSettled(adapters.map(adapter => adapter.shutdown()));
}
diff --git a/packages/backend/src/logger.ts b/packages/backend/src/logger.ts
index ce76f8d05e5..4144c1cf046 100644
--- a/packages/backend/src/logger.ts
+++ b/packages/backend/src/logger.ts
@@ -3,109 +3,149 @@
* SPDX-License-Identifier: AGPL-3.0-only
*/
-import cluster from 'node:cluster';
-import chalk from 'chalk';
-import { default as convertColor } from 'color-convert';
-import { format as dateFormat } from 'date-fns';
import { bindThis } from '@/decorators.js';
-import { envOption } from './env.js';
+import { logManager } from './logging/logging-runtime.js';
+import type { LogEntryInput, LogLevel, LoggerContext, LogWriteInput } from './logging/types.js';
import type { Keyword } from 'color-convert';
-type Context = {
- name: string;
- color?: Keyword;
-};
-
-type Level = 'error' | 'success' | 'warning' | 'debug' | 'info';
+// 旧APIのdataは表示用の任意値を受け取り、Errorや配列も既存呼び出しで使用されています。
+type LegacyData = Record | null;
+/**
+ * ロガー名の階層と従来の公開APIを提供する薄い窓口です。
+ * 出力条件の判断や整形はLogManagerとLogBackendへ委譲します。
+ */
// eslint-disable-next-line import/no-default-export
export default class Logger {
- private context: Context;
- private parentLogger: Logger | null = null;
+ private context: readonly LoggerContext[];
+ /** 指定した名前を起点とするLoggerを作成します。 */
constructor(context: string, color?: Keyword) {
- this.context = {
+ this.context = [{
name: context,
- color: color,
- };
+ color,
+ }];
}
+ /**
+ * 現在のロガーを親として、下位の名前を持つLoggerを作成します。
+ */
@bindThis
public createSubLogger(context: string, color?: Keyword): Logger {
const logger = new Logger(context, color);
- logger.parentLogger = this;
+ logger.context = [...this.context, ...logger.context];
return logger;
}
+ /**
+ * 従来APIの引数を共通形式へ変換し、LogManagerへ渡します。
+ */
@bindThis
- private log(level: Level, message: string, data?: Record | null, important = false, subContexts: Context[] = []): void {
- if (envOption.quiet) return;
-
- if (this.parentLogger) {
- this.parentLogger.log(level, message, data, important, [this.context].concat(subContexts));
- return;
- }
-
- const time = dateFormat(new Date(), 'HH:mm:ss');
- const worker = cluster.isPrimary ? '*' : cluster.worker!.id;
- const l =
- level === 'error' ? important ? chalk.bgRed.white('ERR ') : chalk.red('ERR ') :
- level === 'warning' ? chalk.yellow('WARN') :
- level === 'success' ? important ? chalk.bgGreen.white('DONE') : chalk.green('DONE') :
- level === 'debug' ? chalk.gray('VERB') :
- level === 'info' ? chalk.blue('INFO') :
- null;
- const contexts = [this.context].concat(subContexts).map(d => d.color ? chalk.rgb(...convertColor.keyword.rgb(d.color))(d.name) : chalk.white(d.name));
- const m =
- level === 'error' ? chalk.red(message) :
- level === 'warning' ? chalk.yellow(message) :
- level === 'success' ? chalk.green(message) :
- level === 'debug' ? chalk.gray(message) :
- level === 'info' ? message :
- null;
+ private log(level: LogLevel, message: string, data?: unknown, important = false, legacyLevel?: 'success', error?: unknown): void {
+ logManager.write({
+ level,
+ message,
+ context: this.context,
+ ...(typeof error !== 'undefined' ? { error } : {}),
+ compatibility: {
+ legacyLevel,
+ important,
+ data,
+ },
+ });
+ }
- let log = `${l} ${worker}\t[${contexts.join(' ')}]\t${m}`;
- if (envOption.withLogTime) log = chalk.gray(time) + ' ' + log;
+ /** level別メソッドの構造化入力にlevelとLoggerのcontextを付けて渡します。 */
+ @bindThis
+ private logStructured(level: LogLevel, input: LogEntryInput): void {
+ this.write({
+ ...input,
+ level,
+ });
+ }
- const args: unknown[] = [important ? chalk.bold(log) : log];
- if (data != null) {
- args.push(data);
- }
- console.log(...args);
+ /** 構造化ログをLoggerのcontext付きでLogManagerへ渡します。 */
+ @bindThis
+ public write(input: LogWriteInput): void {
+ logManager.write({
+ ...input,
+ context: this.context,
+ });
}
+ /** 処理を継続できない状況を記録します。 */
+ public error(input: LogEntryInput): void;
+ public error(error: Error, data?: LegacyData, important?: boolean): void;
+ public error(message: string, data?: LegacyData, important?: boolean): void;
+ public error(errorOrMessage: string | Error, data?: LegacyData, important?: boolean): void;
@bindThis
- public error(x: string | Error, data?: Record | null, important = false): void { // 実行を継続できない状況で使う
+ public error(x: LogEntryInput | string | Error, data?: LegacyData, important = false): void {
if (x instanceof Error) {
+ // エラー本体も第2引数へ残し、従来どおりスタックなどを確認できるようにします。
data = data ?? {};
data.e = x;
- this.log('error', x.toString(), data, important);
- } else if (typeof x === 'object') {
- this.log('error', `${(x as any).message ?? (x as any).name ?? x}`, data, important);
+ this.log('error', x.toString(), data, important, undefined, x);
+ } else if (typeof x === 'string') {
+ this.log('error', x, data, important);
} else {
- this.log('error', `${x}`, data, important);
+ this.logStructured('error', x);
}
}
+ /** 処理は継続できるものの、改善が必要な状況を記録します。 */
+ public warn(input: LogEntryInput): void;
+ public warn(message: string): void;
+ public warn(message: string, data?: LegacyData, important?: boolean): void;
@bindThis
- public warn(message: string, data?: Record | null, important = false): void { // 実行を継続できるが改善すべき状況で使う
- this.log('warning', message, data, important);
+ public warn(inputOrMessage: LogEntryInput | string, data?: LegacyData, important = false): void {
+ if (typeof inputOrMessage === 'string') {
+ this.log('warn', inputOrMessage, data, important);
+ } else {
+ this.logStructured('warn', inputOrMessage);
+ }
+ }
+
+ /** 処理が成功したことを、従来のDONE表示で記録します。 */
+ @bindThis
+ public succ(message: string, data?: Record | null, important = false): void {
+ this.log('info', message, data, important, 'success');
}
+ /** 開発者向けの調査情報を記録します。 */
+ public debug(input: LogEntryInput): void;
+ public debug(message: string): void;
+ public debug(message: string, data?: LegacyData, important?: boolean): void;
@bindThis
- public succ(message: string, data?: Record | null, important = false): void { // 何かに成功した状況で使う
- this.log('success', message, data, important);
+ public debug(inputOrMessage: LogEntryInput | string, data?: LegacyData, important = false): void {
+ if (typeof inputOrMessage === 'string') {
+ this.log('debug', inputOrMessage, data, important);
+ } else {
+ this.logStructured('debug', inputOrMessage);
+ }
}
+ /** 通常の動作状況を記録します。 */
+ public info(input: LogEntryInput): void;
+ public info(message: string): void;
+ public info(message: string, data?: LegacyData, important?: boolean): void;
@bindThis
- public debug(message: string, data?: Record | null, important = false): void { // デバッグ用に使う(開発者に必要だが利用者に不要な情報)
- if (process.env.NODE_ENV !== 'production' || envOption.verbose) {
- this.log('debug', message, data, important);
+ public info(inputOrMessage: LogEntryInput | string, data?: LegacyData, important = false): void {
+ if (typeof inputOrMessage === 'string') {
+ this.log('info', inputOrMessage, data, important);
+ } else {
+ this.logStructured('info', inputOrMessage);
}
}
+ /** 致命的な状況を構造化ログとして記録します。 */
+ public fatal(input: LogEntryInput): void;
+ public fatal(message: string): void;
@bindThis
- public info(message: string, data?: Record | null, important = false): void { // それ以外
- this.log('info', message, data, important);
+ public fatal(inputOrMessage: LogEntryInput | string): void {
+ if (typeof inputOrMessage === 'string') {
+ this.logStructured('fatal', { message: inputOrMessage });
+ } else {
+ this.logStructured('fatal', inputOrMessage);
+ }
}
}
diff --git a/packages/backend/src/logging/BootstrapConsoleBackend.ts b/packages/backend/src/logging/BootstrapConsoleBackend.ts
new file mode 100644
index 00000000000..42016acef64
--- /dev/null
+++ b/packages/backend/src/logging/BootstrapConsoleBackend.ts
@@ -0,0 +1,54 @@
+/*
+ * SPDX-FileCopyrightText: syuilo and misskey-project
+ * SPDX-License-Identifier: AGPL-3.0-only
+ */
+
+import type { LogBackend } from './LogBackend.js';
+import type { AccessLogRecord, LogRecord } from './types.js';
+
+/** 設定読込前の最小出力処理が外部から受け取る依存関係です。 */
+export type BootstrapConsoleBackendDependencies = {
+ readonly output: (...args: unknown[]) => void;
+};
+
+const defaultDependencies: BootstrapConsoleBackendDependencies = {
+ output: (...args) => console.log(...args),
+};
+
+/**
+ * 設定読込前でも利用できる、依存の少ないコンソール出力です。
+ * 設定ファイルの読み込み失敗を報告するため、Pretty backendやTelemetryには依存しません。
+ */
+export class BootstrapConsoleBackend implements LogBackend {
+ private readonly dependencies: BootstrapConsoleBackendDependencies;
+
+ constructor(dependencies: Partial = {}) {
+ this.dependencies = {
+ ...defaultDependencies,
+ ...dependencies,
+ };
+ }
+
+ public write(record: LogRecord): void {
+ const worker = record.isPrimary ? '*' : record.workerId ?? '?';
+ const line = `${record.timestamp} ${record.level.toUpperCase()} ${worker}\t[${record.loggerName}]\t${record.message}`;
+ const args: unknown[] = [line];
+
+ if (record.compatibility?.data != null) {
+ args.push(record.compatibility.data);
+ } else if (record.eventName != null || record.attributes != null || record.error != null) {
+ args.push({
+ ...(record.eventName != null ? { eventName: record.eventName } : {}),
+ ...(record.attributes != null ? { attributes: record.attributes } : {}),
+ ...(record.error != null ? { error: record.error } : {}),
+ });
+ }
+
+ this.dependencies.output(...args);
+ }
+
+ /** 設定前のAccess logは本文を含めず、最小限の情報だけを出力します。 */
+ public writeAccess(record: AccessLogRecord): void {
+ this.dependencies.output(`${record.timestamp} ACCESS ${record.method} ${record.route ?? '-'} ${record.statusCode}`);
+ }
+}
diff --git a/packages/backend/src/logging/JsonConsoleBackend.ts b/packages/backend/src/logging/JsonConsoleBackend.ts
new file mode 100644
index 00000000000..fe6f8f0e2be
--- /dev/null
+++ b/packages/backend/src/logging/JsonConsoleBackend.ts
@@ -0,0 +1,126 @@
+/*
+ * SPDX-FileCopyrightText: syuilo and misskey-project
+ * SPDX-License-Identifier: AGPL-3.0-only
+ */
+
+import type { LogBackend } from './LogBackend.js';
+import type { AccessLogRecord, LogRecord } from './types.js';
+
+/** JSON形式のログを1行で出力する処理が外部から受け取る依存関係です。 */
+export type JsonConsoleBackendDependencies = {
+ readonly output: (line: string) => void;
+};
+
+/** JSON形式で出力する項目を、運用上安定した形として定義します。 */
+type JsonLogRecord = {
+ readonly timestamp: string;
+ readonly level: LogRecord['level'];
+ readonly message: string;
+ readonly loggerName: string;
+ readonly eventName?: string;
+ readonly attributes?: LogRecord['attributes'];
+ readonly error?: LogRecord['error'];
+ readonly processId: number;
+ readonly isPrimary: boolean;
+ readonly workerId: number | null;
+ readonly trace_id?: string;
+ readonly span_id?: string;
+ readonly trace_flags?: number;
+};
+
+/** Access logのJSON出力項目です。通常ログと混同しない形を保ちます。 */
+type JsonAccessLogRecord = {
+ readonly type: 'access';
+ readonly timestamp: string;
+ readonly method: string;
+ readonly route: string | null;
+ readonly statusCode: number;
+ readonly durationMs: number;
+ readonly responseSizeBytes: number | null;
+ readonly errorType?: string;
+ readonly requestBody?: AccessLogRecord['requestBody'];
+ readonly responseBody?: AccessLogRecord['responseBody'];
+ readonly processId: number;
+ readonly isPrimary: boolean;
+ readonly workerId: number | null;
+ readonly trace_id?: string;
+ readonly span_id?: string;
+ readonly trace_flags?: number;
+};
+
+const defaultDependencies: JsonConsoleBackendDependencies = {
+ output: line => console.log(line),
+};
+
+/** LogRecordからJSONへ出す項目だけを選び、内部情報を誤って含めないようにします。 */
+function createJsonLogRecord(record: LogRecord): JsonLogRecord {
+ // 色や旧APIの生データは表示専用の情報なので、機械向け形式へは持ち込みません。
+ return {
+ timestamp: record.timestamp,
+ level: record.level,
+ message: record.message,
+ loggerName: record.loggerName,
+ // 任意項目は値がある場合だけ含め、空の項目を増やさないようにします。
+ ...(record.eventName != null ? { eventName: record.eventName } : {}),
+ ...(record.attributes != null ? { attributes: record.attributes } : {}),
+ ...(record.error != null ? { error: record.error } : {}),
+ // 実行主体の情報は常に出し、ログを横断して検索できる形を保ちます。
+ processId: record.processId,
+ isPrimary: record.isPrimary,
+ workerId: record.workerId,
+ // active Spanの情報は値が存在するログだけ、標準のsnake_case名で出力します。
+ ...(record.traceId != null ? { trace_id: record.traceId } : {}),
+ ...(record.spanId != null ? { span_id: record.spanId } : {}),
+ ...(record.traceFlags != null ? { trace_flags: record.traceFlags } : {}),
+ };
+}
+
+/** Access logから公開する項目だけを選び、1行JSONの形へ整えます。 */
+function createJsonAccessLogRecord(record: AccessLogRecord): JsonAccessLogRecord {
+ return {
+ type: 'access',
+ timestamp: record.timestamp,
+ method: record.method,
+ route: record.route,
+ statusCode: record.statusCode,
+ durationMs: record.durationMs,
+ responseSizeBytes: record.responseSizeBytes,
+ ...(record.errorType != null ? { errorType: record.errorType } : {}),
+ ...(record.requestBody !== undefined ? { requestBody: record.requestBody } : {}),
+ ...(record.responseBody !== undefined ? { responseBody: record.responseBody } : {}),
+ processId: record.processId,
+ isPrimary: record.isPrimary,
+ workerId: record.workerId,
+ ...(record.traceId != null ? { trace_id: record.traceId } : {}),
+ ...(record.spanId != null ? { span_id: record.spanId } : {}),
+ ...(record.traceFlags != null ? { trace_flags: record.traceFlags } : {}),
+ };
+}
+
+/**
+ * LogRecordを1行のJSONへ変換し、ログ収集基盤が扱える標準出力へ渡します。
+ * LoggerやLogManagerから出力形式を切り離し、Pretty形式と同じ記録を共有します。
+ */
+export class JsonConsoleBackend implements LogBackend {
+ private readonly dependencies: JsonConsoleBackendDependencies;
+
+ /** 出力処理を受け取り、テストや起動環境ごとに出力先を差し替えます。 */
+ constructor(dependencies: Partial = {}) {
+ this.dependencies = {
+ ...defaultDependencies,
+ ...dependencies,
+ };
+ }
+
+ /** 1件のログをJSON文字列へ変換し、改行を含まない1回の出力として渡します。 */
+ public write(record: LogRecord): void {
+ // JSON.stringifyが改行などをエスケープするため、1ログ1行の契約を保てます。
+ this.dependencies.output(JSON.stringify(createJsonLogRecord(record)));
+ }
+
+ /** Access logを1行JSONとして出力します。本文は設定で有効化された場合のみ、秘匿処理済みで含まれます。 */
+ public writeAccess(record: AccessLogRecord): void {
+ // JSON.stringifyが改行をエスケープするため、1件を1物理行に保ちます。
+ this.dependencies.output(JSON.stringify(createJsonAccessLogRecord(record)));
+ }
+}
diff --git a/packages/backend/src/logging/LogBackend.ts b/packages/backend/src/logging/LogBackend.ts
new file mode 100644
index 00000000000..0b79effe7f9
--- /dev/null
+++ b/packages/backend/src/logging/LogBackend.ts
@@ -0,0 +1,24 @@
+/*
+ * SPDX-FileCopyrightText: syuilo and misskey-project
+ * SPDX-License-Identifier: AGPL-3.0-only
+ */
+
+import type { AccessLogRecord, LogRecord } from './types.js';
+
+/**
+ * 整形済みのログを実際の出力先へ渡すための共通窓口です。
+ * Loggerを特定の出力形式へ依存させず、後から出力先を追加できるようにします。
+ */
+export interface LogBackend {
+ /** ログを一件出力します。 */
+ write(record: LogRecord): void;
+
+ /** Access logを一件出力します。 */
+ writeAccess?(record: AccessLogRecord): void;
+
+ /** 保留中の出力がある場合に、すべて書き出します。 */
+ flush?(): void | Promise;
+
+ /** 出力先が持つ資源を解放します。 */
+ close?(): void | Promise;
+}
diff --git a/packages/backend/src/logging/LogManager.ts b/packages/backend/src/logging/LogManager.ts
new file mode 100644
index 00000000000..ef78f97bfe2
--- /dev/null
+++ b/packages/backend/src/logging/LogManager.ts
@@ -0,0 +1,414 @@
+/*
+ * SPDX-FileCopyrightText: syuilo and misskey-project
+ * SPDX-License-Identifier: AGPL-3.0-only
+ */
+
+import cluster from 'node:cluster';
+import process from 'node:process';
+import { envOption } from '@/env.js';
+import {
+ findLegacyLogError,
+ normalizeLogAttributes,
+ normalizeLogValue,
+ serializeLogError,
+ type LogNormalizationProfile,
+} from './LogNormalizer.js';
+import type { LogBackend } from './LogBackend.js';
+import type {
+ AccessLogConfiguration,
+ AccessLogRecord,
+ AccessLogRecordInput,
+ AccessLogStatusClass,
+ LogLevel,
+ LogLevelSetting,
+ LogRecord,
+ LogRecordInput,
+ LogTraceContext,
+ LogTraceContextProvider,
+} from './types.js';
+
+/** ログを出力したプロセスを識別するための情報です。 */
+export type LogProcessInfo = {
+ readonly processId: number;
+ readonly isPrimary: boolean;
+ readonly workerId: number | null;
+};
+
+/**
+ * 実行環境から取得する値をまとめた依存関係です。
+ * テストでは固定値へ差し替え、時刻やプロセス状態に左右されないようにします。
+ */
+export type LogManagerDependencies = {
+ readonly now: () => Date;
+ readonly getProcessInfo: () => LogProcessInfo;
+ readonly isQuiet: () => boolean;
+ readonly isVerbose: () => boolean;
+ readonly getNodeEnv: () => string | undefined;
+};
+
+/** ログ管理の初期化時に指定できる正規化設定です。 */
+export type LogManagerOptions = {
+ readonly normalizationProfile?: LogNormalizationProfile;
+};
+
+/** 起動時に適用するログ出力設定です。 */
+export type LogManagerConfiguration = {
+ readonly level?: LogLevelSetting;
+ readonly domains?: Readonly> | null;
+ readonly access?: AccessLogConfiguration;
+};
+
+/** 正規化済みのAccess log設定です。 */
+export type ResolvedAccessLogConfiguration = {
+ readonly statusClasses: readonly AccessLogStatusClass[];
+ readonly bodies: {
+ readonly request: boolean;
+ readonly response: boolean;
+ readonly maxBytes: number;
+ };
+};
+
+const logLevelOrder: Readonly> = {
+ debug: 0,
+ info: 1,
+ warn: 2,
+ error: 3,
+ fatal: 4,
+};
+
+const validLogLevels = new Set(['debug', 'info', 'warn', 'error', 'fatal', 'off']);
+const validAccessStatusClasses = new Set(['2xx', '3xx', '4xx', '5xx']);
+const defaultAccessBodyMaxBytes = 16 * 1024;
+const maxAccessBodyBytes = 128 * 1024;
+
+function validateLogLevel(value: unknown, path: string): LogLevelSetting | undefined {
+ if (typeof value === 'undefined') return undefined;
+ if (typeof value !== 'string' || !validLogLevels.has(value as LogLevelSetting)) {
+ throw new Error(`${path} must be one of debug, info, warn, error, fatal, or off`);
+ }
+ return value as LogLevelSetting;
+}
+
+function validateDomainName(domain: string): void {
+ if (domain.length === 0 || domain.trim() !== domain || domain.split('.').some(segment => segment.length === 0)) {
+ throw new Error(`logging.domains contains an invalid domain name: ${JSON.stringify(domain)}`);
+ }
+}
+
+/** Access logを明示的に有効化していない場合の設定を作成します。 */
+function createDisabledAccessLogConfiguration(): ResolvedAccessLogConfiguration {
+ return {
+ statusClasses: [],
+ bodies: {
+ request: false,
+ response: false,
+ maxBytes: defaultAccessBodyMaxBytes,
+ },
+ };
+}
+
+/** Access log設定を検証し、本番環境では本文だけを無効化します。 */
+function resolveAccessConfiguration(configuration: unknown, nodeEnv: string | undefined): {
+ readonly access: ResolvedAccessLogConfiguration;
+ readonly warnings: readonly string[];
+} {
+ if (configuration == null) return { access: createDisabledAccessLogConfiguration(), warnings: [] };
+ if (typeof configuration !== 'object' || Array.isArray(configuration)) {
+ throw new Error('logging.access must be an object');
+ }
+
+ const raw = configuration as {
+ statusClasses?: unknown;
+ bodies?: unknown;
+ };
+ let statusClasses: AccessLogStatusClass[] = [];
+ if (typeof raw.statusClasses !== 'undefined') {
+ if (!Array.isArray(raw.statusClasses)) {
+ throw new Error('logging.access.statusClasses must be an array');
+ }
+ statusClasses = [...new Set(raw.statusClasses.map((statusClass, index) => {
+ if (typeof statusClass !== 'string' || !validAccessStatusClasses.has(statusClass as AccessLogStatusClass)) {
+ throw new Error(`logging.access.statusClasses[${index}] must be one of 2xx, 3xx, 4xx, or 5xx`);
+ }
+ return statusClass as AccessLogStatusClass;
+ }))];
+ }
+
+ let request = false;
+ let response = false;
+ let maxBytes = defaultAccessBodyMaxBytes;
+ if (typeof raw.bodies !== 'undefined') {
+ if (typeof raw.bodies !== 'object' || raw.bodies === null || Array.isArray(raw.bodies)) {
+ throw new Error('logging.access.bodies must be an object');
+ }
+ const bodies = raw.bodies as { request?: unknown; response?: unknown; maxBytes?: unknown };
+ if (typeof bodies.request !== 'undefined' && typeof bodies.request !== 'boolean') {
+ throw new Error('logging.access.bodies.request must be a boolean');
+ }
+ if (typeof bodies.response !== 'undefined' && typeof bodies.response !== 'boolean') {
+ throw new Error('logging.access.bodies.response must be a boolean');
+ }
+ const configuredMaxBytes = bodies.maxBytes;
+ if (typeof configuredMaxBytes !== 'undefined' && (typeof configuredMaxBytes !== 'number' || !Number.isSafeInteger(configuredMaxBytes) || configuredMaxBytes <= 0 || configuredMaxBytes > maxAccessBodyBytes)) {
+ throw new Error(`logging.access.bodies.maxBytes must be a positive integer no greater than ${maxAccessBodyBytes}`);
+ }
+ request = bodies.request ?? false;
+ response = bodies.response ?? false;
+ maxBytes = typeof configuredMaxBytes === 'number' ? configuredMaxBytes : defaultAccessBodyMaxBytes;
+ }
+
+ if (nodeEnv === 'production' && (request || response)) {
+ return {
+ access: {
+ statusClasses,
+ bodies: { request: false, response: false, maxBytes },
+ },
+ warnings: ['logging.access.bodies is disabled in production mode'],
+ };
+ }
+
+ return {
+ access: { statusClasses, bodies: { request, response, maxBytes } },
+ warnings: [],
+ };
+}
+
+/** 通常ログとAccess logの設定をまとめて検証し、起動時警告も返します。 */
+function resolveConfiguration(configuration: LogManagerConfiguration | undefined, nodeEnv: string | undefined): {
+ readonly level: LogLevelSetting | undefined;
+ readonly domains: readonly (readonly [string, LogLevelSetting])[];
+ readonly access: ResolvedAccessLogConfiguration;
+ readonly warnings: readonly string[];
+} {
+ if (configuration == null) return { level: undefined, domains: [], access: createDisabledAccessLogConfiguration(), warnings: [] };
+
+ const level = validateLogLevel(configuration.level, 'logging.level');
+ const access = resolveAccessConfiguration(configuration.access, nodeEnv);
+ if (configuration.domains == null) return { level, domains: [], ...access };
+ if (typeof configuration.domains !== 'object' || configuration.domains === null || Array.isArray(configuration.domains)) {
+ throw new Error('logging.domains must be an object');
+ }
+
+ const domains = Object.entries(configuration.domains).map(([domain, value]) => {
+ validateDomainName(domain);
+ const level = validateLogLevel(value, `logging.domains.${domain}`);
+ if (typeof level === 'undefined') {
+ throw new Error(`logging.domains.${domain} must be configured`);
+ }
+ return [domain, level] as const;
+ }).sort((left, right) => right[0].length - left[0].length);
+
+ return { level, domains, ...access };
+}
+
+const defaultDependencies: LogManagerDependencies = {
+ now: () => new Date(),
+ getProcessInfo: () => ({
+ processId: process.pid,
+ isPrimary: cluster.isPrimary,
+ workerId: cluster.isPrimary ? null : (cluster.worker?.id ?? null),
+ }),
+ isQuiet: () => envOption.quiet,
+ isVerbose: () => envOption.verbose,
+ getNodeEnv: () => process.env.NODE_ENV,
+};
+
+/**
+ * ログの出力可否を判断し、すべての出力先で共通となる情報を付加します。
+ * Loggerと出力先の間に置くことで、設定や共通情報の扱いを一か所へ集約します。
+ */
+export class LogManager {
+ private backend: LogBackend;
+ private readonly dependencies: LogManagerDependencies;
+ private normalizationProfile: LogNormalizationProfile;
+ private traceContextProvider: LogTraceContextProvider | undefined;
+ private configuredLevel: LogLevelSetting | undefined;
+ private configuredDomains: readonly (readonly [string, LogLevelSetting])[];
+ private accessConfiguration: ResolvedAccessLogConfiguration;
+ private shutdownPromise: Promise | undefined;
+
+ /**
+ * 出力先と実行環境から値を取得する処理を受け取ります。
+ * 実行環境の取得処理は、必要な項目だけテスト用に差し替えられます。
+ */
+ constructor(
+ backend: LogBackend,
+ dependencies: Partial = {},
+ options: LogManagerOptions = {},
+ ) {
+ this.backend = backend;
+ this.dependencies = {
+ ...defaultDependencies,
+ ...dependencies,
+ };
+ this.normalizationProfile = options.normalizationProfile ?? 'standard';
+ this.traceContextProvider = undefined;
+ this.configuredLevel = undefined;
+ this.configuredDomains = [];
+ this.accessConfiguration = createDisabledAccessLogConfiguration();
+ }
+
+ /**
+ * 以後のログを書き込む出力先を切り替えます。
+ * 作成済みのLoggerにも切り替えを反映するため、LogManager側で保持します。
+ */
+ public setBackend(backend: LogBackend): void {
+ this.backend = backend;
+ }
+
+ /** 起動時の既定levelとdomain別levelを適用します。 */
+ public configure(configuration?: LogManagerConfiguration): readonly string[] {
+ const resolved = resolveConfiguration(configuration, this.dependencies.getNodeEnv());
+ this.configuredLevel = resolved.level;
+ this.configuredDomains = resolved.domains;
+ this.accessConfiguration = resolved.access;
+ return resolved.warnings;
+ }
+
+ /** Fastifyフックが参照する正規化済みのAccess log設定を返します。 */
+ public getAccessLogConfiguration(): ResolvedAccessLogConfiguration {
+ return this.accessConfiguration;
+ }
+
+ /** 正規化方式を切り替え、既に作成済みのLoggerにも反映します。 */
+ public setNormalizationProfile(profile: LogNormalizationProfile): void {
+ this.normalizationProfile = profile;
+ }
+
+ /** ログ出力時にactiveなTrace Contextを取得する処理を登録します。 */
+ public setTraceContextProvider(provider?: LogTraceContextProvider): void {
+ this.traceContextProvider = provider;
+ }
+
+ /** 現在の処理に紐付くTrace Contextを取得します。 */
+ public getActiveTraceContext(): LogTraceContext | undefined {
+ return this.traceContextProvider?.();
+ }
+
+ /** backendに残っているログをflushしてから終了処理を行います。 */
+ public shutdown(): Promise {
+ if (this.shutdownPromise != null) return this.shutdownPromise;
+
+ this.shutdownPromise = (async () => {
+ try {
+ await this.backend.flush?.();
+ } finally {
+ await this.backend.close?.();
+ }
+ })();
+
+ return this.shutdownPromise;
+ }
+
+ private getDefaultLevel(): LogLevel {
+ if (this.dependencies.isVerbose()) return 'debug';
+ return this.dependencies.getNodeEnv() === 'production' ? 'info' : 'debug';
+ }
+
+ private getThreshold(loggerName: string): LogLevelSetting {
+ let threshold: LogLevelSetting | undefined;
+ for (const [domain, level] of this.configuredDomains) {
+ if (loggerName === domain || loggerName.startsWith(`${domain}.`)) {
+ threshold = level;
+ break;
+ }
+ }
+
+ threshold ??= this.configuredLevel ?? this.getDefaultLevel();
+
+ // verboseは障害調査用の緊急モードとして、明示されたoff以外をdebugまで下げる。
+ // offは意図的な無効化なので、verboseでも再有効化しない。
+ return threshold === 'off' || !this.dependencies.isVerbose() ? threshold : 'debug';
+ }
+
+ private shouldWrite(input: LogRecordInput, loggerName: string): boolean {
+ const threshold = this.getThreshold(loggerName);
+ if (threshold === 'off') return false;
+ return logLevelOrder[input.level] >= logLevelOrder[threshold];
+ }
+
+ /**
+ * 出力条件を確認し、共通情報を付加して出力先へ渡します。
+ */
+ public write(input: LogRecordInput): void {
+ // `quiet`は他の条件より優先し、ログに付随する情報の取得も行いません。
+ if (this.dependencies.isQuiet()) return;
+
+ const loggerName = input.context.map(segment => segment.name).join('.');
+ if (!this.shouldWrite(input, loggerName)) return;
+
+ const processInfo = this.dependencies.getProcessInfo();
+ // 呼び出し側の配列を共有せず、親から末端までの順序を固定します。
+ const context = [...input.context];
+ // 出力を実際に行う直前にだけ正規化し、捨てられるdebugログのコストを抑えます。
+ const { attributes, error: inputError, ...inputWithoutStructuredValues } = input;
+ const normalizedAttributes = typeof attributes !== 'undefined'
+ ? normalizeLogAttributes(attributes, { profile: this.normalizationProfile })
+ : undefined;
+ const error = inputError ?? findLegacyLogError(input.compatibility?.data);
+ const normalizedError = typeof error !== 'undefined'
+ ? serializeLogError(error, { profile: this.normalizationProfile })
+ : undefined;
+ // 実際に出力するログだけ、TelemetryからactiveなTrace Contextを取得します。
+ const traceContext = this.traceContextProvider?.();
+ const record = {
+ ...inputWithoutStructuredValues,
+ context,
+ timestamp: this.dependencies.now().toISOString(),
+ loggerName,
+ processId: processInfo.processId,
+ isPrimary: processInfo.isPrimary,
+ workerId: processInfo.workerId,
+ ...(traceContext ?? {}),
+ ...(normalizedAttributes ? { attributes: normalizedAttributes } : {}),
+ ...(normalizedError ? { error: normalizedError } : {}),
+ } as LogRecord;
+
+ this.backend.write(record);
+ }
+
+ /** status classの設定を確認し、Access logの出力対象か判断します。 */
+ public shouldWriteAccess(statusCode: number): boolean {
+ if (this.dependencies.isQuiet()) return false;
+ const statusClass = `${Math.floor(statusCode / 100)}xx` as AccessLogStatusClass;
+ return validAccessStatusClasses.has(statusClass) && this.accessConfiguration.statusClasses.includes(statusClass);
+ }
+
+ /** Access logのフック自体を登録してよい状態か、quietを含めて判定します。 */
+ public isAccessLogEnabled(): boolean {
+ return !this.dependencies.isQuiet() && this.accessConfiguration.statusClasses.length > 0;
+ }
+
+ /** HTTP応答へ共通情報と本文の安全な正規化を加えてAccess logを渡します。 */
+ public writeAccess(input: AccessLogRecordInput): void {
+ if (!this.shouldWriteAccess(input.statusCode)) return;
+
+ const processInfo = this.dependencies.getProcessInfo();
+ const { requestBody, responseBody, traceContext, ...inputWithoutOptionalValues } = input;
+ const bodyOptions = {
+ profile: this.normalizationProfile,
+ limits: { maxBytes: this.accessConfiguration.bodies.maxBytes },
+ } as const;
+ const normalizedRequestBody = this.accessConfiguration.bodies.request && typeof requestBody !== 'undefined'
+ ? normalizeLogValue(requestBody, bodyOptions)
+ : undefined;
+ const normalizedResponseBody = this.accessConfiguration.bodies.response && typeof responseBody !== 'undefined'
+ ? normalizeLogValue(responseBody, bodyOptions)
+ : undefined;
+ // HTTPフックが開始時に保持したContextだけを使い、応答時に別のSpanを紐付けません。
+ const resolvedTraceContext = traceContext;
+ const record: AccessLogRecord = {
+ type: 'access',
+ ...inputWithoutOptionalValues,
+ timestamp: this.dependencies.now().toISOString(),
+ processId: processInfo.processId,
+ isPrimary: processInfo.isPrimary,
+ workerId: processInfo.workerId,
+ ...(typeof normalizedRequestBody !== 'undefined' ? { requestBody: normalizedRequestBody } : {}),
+ ...(typeof normalizedResponseBody !== 'undefined' ? { responseBody: normalizedResponseBody } : {}),
+ ...(resolvedTraceContext ?? {}),
+ };
+
+ this.backend.writeAccess?.(record);
+ }
+}
diff --git a/packages/backend/src/logging/LogNormalizer.ts b/packages/backend/src/logging/LogNormalizer.ts
new file mode 100644
index 00000000000..117f03b7ba9
--- /dev/null
+++ b/packages/backend/src/logging/LogNormalizer.ts
@@ -0,0 +1,400 @@
+/*
+ * SPDX-FileCopyrightText: syuilo and misskey-project
+ * SPDX-License-Identifier: AGPL-3.0-only
+ */
+
+import { Buffer } from 'node:buffer';
+import type { LogAttributeValue, LogAttributes, SerializedError } from './types.js';
+
+/** 正規化の粒度を表します。詳細指定でも秘匿処理は常に有効です。 */
+export type LogNormalizationProfile = 'standard' | 'detailed';
+
+/** 正規化で使う上限値です。 */
+export type LogNormalizationLimits = {
+ readonly maxDepth: number;
+ readonly maxEntries: number;
+ readonly maxStringBytes: number;
+ readonly maxBytes: number;
+};
+
+/** 属性のキーを秘匿すべきか判定する関数です。 */
+export type LogRedactor = (path: readonly string[], key: string) => boolean;
+
+/** 正規化処理へ渡す設定です。 */
+export type LogNormalizationOptions = {
+ readonly profile?: LogNormalizationProfile;
+ readonly limits?: Partial;
+ readonly redactor?: LogRedactor;
+};
+
+/** 通常運用でログが肥大化しないようにした上限です。 */
+export const STANDARD_LOG_NORMALIZATION_LIMITS: LogNormalizationLimits = {
+ maxDepth: 6,
+ maxEntries: 100,
+ maxStringBytes: 8 * 1024,
+ maxBytes: 64 * 1024,
+};
+
+/** 障害調査時により多くの情報を残す上限です。 */
+export const DETAILED_LOG_NORMALIZATION_LIMITS: LogNormalizationLimits = {
+ maxDepth: 10,
+ maxEntries: 1000,
+ maxStringBytes: 64 * 1024,
+ maxBytes: 256 * 1024,
+};
+
+const REDACTED = '[REDACTED]';
+const CIRCULAR = '[Circular]';
+const TRUNCATED = '[Truncated]';
+const UNSUPPORTED = '[Unsupported]';
+const TRUNCATED_KEY = '[Truncated]';
+
+const sensitiveKeyParts = [
+ 'password',
+ 'passwd',
+ 'passphrase',
+ 'token',
+ 'secret',
+ 'authorization',
+ 'cookie',
+ 'apikey',
+ 'privatekey',
+ 'credential',
+ 'captcha',
+ 'hcaptcharesponse',
+ 'grecaptcharesponse',
+ 'turnstileresponse',
+ 'mcaptcharesponse',
+ 'testcaptcharesponse',
+];
+
+/** キー名を比較用に揃え、区切り文字による表記揺れを吸収します。 */
+function normalizeKey(key: string): string {
+ return key.toLowerCase().replace(/[-_.\s]/g, '');
+}
+
+/** 既定の秘匿対象を判定します。Misskey APIの`i`も認証情報として扱います。 */
+export function defaultLogRedactor(_path: readonly string[], key: string): boolean {
+ const normalized = normalizeKey(key);
+ return normalized === 'i' || sensitiveKeyParts.some(part => normalized.includes(part));
+}
+
+/** 選択した方式と個別指定を合わせて、実際の上限値を決めます。 */
+export function resolveLogNormalizationLimits(options: LogNormalizationOptions = {}): LogNormalizationLimits {
+ const base = options.profile === 'detailed'
+ ? DETAILED_LOG_NORMALIZATION_LIMITS
+ : STANDARD_LOG_NORMALIZATION_LIMITS;
+ const limits = {
+ ...base,
+ ...options.limits,
+ };
+ return {
+ maxDepth: Math.max(0, limits.maxDepth),
+ maxEntries: Math.max(0, limits.maxEntries),
+ maxStringBytes: Math.max(1, limits.maxStringBytes),
+ maxBytes: Math.max(2, limits.maxBytes),
+ };
+}
+
+/** 値が通常のオブジェクトとして読めるか判定します。 */
+function isObject(value: unknown): value is Record {
+ return typeof value === 'object' && value !== null;
+}
+
+/** 外部入力のキーを安全に格納するため、prototypeを持たない属性領域を作成します。 */
+function createAttributeMap(): Record {
+ return Object.create(null) as Record;
+}
+
+/** 値を文字列化し、文字列化処理自体の例外もログ処理へ漏らさないようにします。 */
+function stringifySafely(value: unknown): string {
+ try {
+ return String(value);
+ } catch {
+ return UNSUPPORTED;
+ }
+}
+
+/** UTF-8のバイト数を測ります。ログの上限を文字数ではなく出力サイズで揃えるために使います。 */
+function byteLength(value: string): number {
+ return Buffer.byteLength(value, 'utf8');
+}
+
+/** 文字列をUTF-8の上限内へ切り詰めます。 */
+function normalizeString(value: string, maxBytes: number): string {
+ if (byteLength(value) <= maxBytes) return value;
+ const suffix = `…${TRUNCATED}`;
+ if (byteLength(suffix) > maxBytes) {
+ const end = findMaxPrefixLength(value, '', maxBytes);
+ return value.slice(0, end);
+ }
+ const end = findMaxPrefixLength(value, suffix, maxBytes);
+ return value.slice(0, end) + suffix;
+}
+
+function charUtf8Len(codePoint: number): number {
+ return codePoint < 0x80 ? 1
+ : codePoint < 0x800 ? 2
+ : codePoint < 0x10000 ? 3
+ : 4;
+}
+
+function charUtf16Len(codePoint: number): number {
+ return codePoint < 0x10000 ? 1 : 2;
+}
+
+function findMaxPrefixLength(value: string, suffix: string, maxBytes: number) {
+ let usedBytes = byteLength(suffix);
+ let prefixLength = 0;
+ while (prefixLength < value.length) {
+ // eslint-disable-next-line @typescript-eslint/no-non-null-assertion -- similar to for-of, but for-of requires additional allocations
+ const cp = value.codePointAt(prefixLength)!;
+ const charBytes = charUtf8Len(cp);
+ if (usedBytes + charBytes > maxBytes) break;
+ usedBytes += charBytes;
+ prefixLength += charUtf16Len(cp);
+ }
+ return prefixLength;
+}
+
+/** 特殊な値に対しても、エラー判定で例外を発生させないようにします。 */
+function isErrorValue(value: unknown): value is Error {
+ try {
+ return value instanceof Error;
+ } catch {
+ return false;
+ }
+}
+
+/** 値の読み出しを安全に行い、壊れたログ属性が本処理を中断しないようにします。 */
+function readProperty(value: Record, key: string): unknown {
+ try {
+ return value[key];
+ } catch {
+ return `${UNSUPPORTED}: property access failed`;
+ }
+}
+
+/** 属性をJSONへ出力できる値へ変換します。 */
+function normalizeValue(
+ value: unknown,
+ path: readonly string[],
+ depth: number,
+ seen: WeakSet