1
0
mirror of https://github.com/misskey-dev/misskey.git synced 2026-07-15 23:24:45 +02:00

Compare commits

..

27 Commits

Author SHA1 Message Date
syuilo
fb483d8367 Merge branch 'codex/frontend-bundle-generated-chunks' into develop 2026-07-15 22:06:40 +09:00
syuilo
ef852e6ab0 fix(dev): validate frontend startup manifest 2026-07-15 21:55:12 +09:00
syuilo
0ad72528da test(dev): check frontend bundle report 2026-07-15 21:34:13 +09:00
syuilo
11c6fb1567 test(dev): capture expected frontend report failures 2026-07-15 21:29:01 +09:00
syuilo
afca066833 fix(dev): clarify frontend chunk comparisons 2026-07-15 21:22:28 +09:00
syuilo
945fd40578 fix(dev): aggregate generated frontend chunks 2026-07-15 21:15:17 +09:00
syuilo
b6996a2766 chore: ignore local worktrees 2026-07-15 21:00:39 +09:00
syuilo
9076a4ecf3 docs: plan generated chunk reporting fix 2026-07-15 20:56:56 +09:00
syuilo
d74a7a515c docs: design generated chunk handling 2026-07-15 20:46:42 +09:00
syuilo
ce411817b4 Update .coderabbit.yaml 2026-07-15 20:44:18 +09:00
おさむのひと
0849dc7cdd enhance: 構造化ログAPIと正規化 (#17719)
* enhance: 構造化ログAPIと正規化

* fix changelog

* fixed copilot review

* fixed coderabbit review
2026-07-15 20:14:19 +09:00
syuilo
ed70123ace Create .coderabbit.yaml 2026-07-15 19:12:33 +09:00
github-actions[bot]
1110d334c8 Bump version to 2026.7.0-alpha.5 2026-07-15 04:36:32 +00:00
おさむのひと
667919581e enhance: 新しいLoggerのコア実装 (#17714)
* enhance: impl logger core

* fix order
2026-07-15 13:29:39 +09:00
かっこかり
152e0fc48c perf(frontend): タイムラインのリアルタイム更新のアニメーションでTransitionGroupを使用しないように (#17708)
* perf(frontend): タイムラインのリアルタイム更新のアニメーションでTransitionGroupを使用しないように

* fix

* wip

* fix

* fix

* Update Changelog

* spdx

* remove unnecessary css declaration
2026-07-15 12:06:25 +09:00
かっこかり
e3cb641c98 Update CHANGELOG.md 2026-07-15 00:03:14 +09:00
おさむのひと
4d7e62d677 feat: Otel pgとredis自動計装 (#17712) 2026-07-14 20:36:02 +09:00
おさむのひと
ecd85e5613 feat: OTelのHTTPサーバ自動計装を追加 (#17711) 2026-07-14 18:06:25 +09:00
github-actions[bot]
91ea966b04 Bump version to 2026.7.0-alpha.4 2026-07-14 07:58:57 +00:00
おさむのひと
3d4c62b0bb feat: BullMQのジョブ追加時に発動する手動計測の追加 (#17710)
* feat: BullMQのジョブ追加時に発動する手動計測の追加

* add comment
2026-07-14 16:36:40 +09:00
おさむのひと
a98469d34d feat: OTelのHTTPリクエスト自動計装を追加 (#17709) 2026-07-14 14:13:33 +09:00
renovate[bot]
5a2acf71a0 fix(deps): update dependency @opentelemetry/core to v2.8.0 [security] (#17707)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-07-14 11:39:49 +09:00
おさむのひと
49e59a3d56 feat(backend): add OTLP trace export (#17679)
* feat(backend): add OTLP trace export

* fix

* regenerate

* Potential fix for pull request finding

Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>

* Potential fix for pull request finding

Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>

* Potential fix for pull request finding

Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>

* Potential fix for pull request finding

Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>

* fix

* fix

* fix

---------

Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
2026-07-14 11:22:14 +09:00
おさむのひと
ecd1d615ff fix(frontend): 未ログイン時のトップページがスクロール不可だったのを修正 (#17681)
* fix(frontend): 未ログイン時のトップページがスクロール不可だったのを修正

* fix CHANGELOG.md

* remove playwright file

* fix(frontend): 非ログイン時トップページを背景の上でもスクロールできるよう修正

.root を min-height から develop と同じ height + overflow: auto に戻して .root 自身を
スクロールコンテナとし、装飾レイヤ (.bg / .shape1 / .shape2) の pointer-events: none と
併せて、コンテンツ列以外 (背景) の上でもホイールスクロールが効くようにする。
CSS のみの対応で、TL のマーキーアニメーションやノート / フェデレーションのクリック挙動は
変更しない。

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-13 20:21:18 +09:00
Genki.Takeuchi
53c398ce43 fix(frontend): loadRawImages有効時、リモート画像をタップしてからビューワーが開くまでに初回のみラグが発生する問題を修正 (#17703)
ビューワーを表示するトリガーをサムネイル画像又はオリジナル画像がロードされたときに修正
2026-07-13 19:58:05 +09:00
かっこかり
f74b427e67 fix(backend): configテンプレートのallowedPrivateNetworksをリスト記法に変更してエラーの発生を抑制 (#17701)
* fix(backend): configテンプレートのallowedPrivateNetworksをリスト記法に変更してエラーの発生を抑制

* fix
2026-07-12 17:15:13 +09:00
かっこかり
aab7903ab0 fix(backend): summalyの結果のキャッシュが後続処理によって書き換わる問題を修正
Follow-up of #17367
2026-07-12 16:54:57 +09:00
70 changed files with 5513 additions and 421 deletions

9
.coderabbit.yaml Normal file
View File

@@ -0,0 +1,9 @@
reviews:
profile: "chill"
high_level_summary: false
poem: true
auto_review:
enabled: true
drafts: false
base_branches:
- "develop"

View File

@@ -233,9 +233,8 @@ proxyBypassHosts:
# For security reasons, uploading attachments from the intranet is prohibited,
# but exceptions can be made from the following settings. Default value is "undefined".
# Read changelog to learn more (Improvements of 12.90.0 (2021/09/04)).
#allowedPrivateNetworks: [
# '127.0.0.1/32'
#]
#allowedPrivateNetworks:
# - '127.0.0.1/32'
# Upload or download file size limits (bytes)
#maxFileSize: 262144000

View File

@@ -329,6 +329,63 @@ id: 'aidx'
# #tracePropagationTargets:
# # - 'internal-service.example'
# ┌─────────┐
#───┘ Tracing └────────────────────────────────────────────────
# OpenTelemetry distributed tracing (Traces only, opt-in).
# Existing API and Queue spans are exported to an OTLP http/protobuf endpoint.
#
# Standard OTEL_* environment variables are honored for values omitted below:
# OTEL_EXPORTER_OTLP_TRACES_ENDPOINT, OTEL_EXPORTER_OTLP_ENDPOINT,
# OTEL_EXPORTER_OTLP_HEADERS, OTEL_TRACES_SAMPLER, OTEL_TRACES_SAMPLER_ARG, etc.
#
# When this is enabled together with sentryForBackend, Misskey shares Sentry's
# TracerProvider and adds an OTLP SpanProcessor to it. In that mode:
# - sampleRate below is ignored; sampling follows sentryForBackend.options
# tracesSampleRate / tracesSampler.
# - resourceAttributes below is ignored; set OTEL_SERVICE_NAME and
# OTEL_RESOURCE_ATTRIBUTES in the environment if you need to override them.
# - spans produced by Sentry integrations are exported to both Sentry and OTLP.
# - sentry-trace / baggage propagation to outbound remote requests is disabled
# by default while OTel is enabled. Set propagateTraceToRemote: true only if
# you intentionally want Sentry trace propagation for your deployment.
#otelForBackend:
# endpoint: 'http://localhost:4318/v1/traces'
# #headers:
# # authorization: 'Bearer xxxxx'
# #sampleRate: 1.0
# # Record PostgreSQL query spans. Disabled by default to avoid instrumentation
# # overhead when database-level diagnostics are not needed.
# #capturePgSpans: false
# # Include raw SQL text in PostgreSQL spans. Requires capturePgSpans and is
# # disabled by default because non-parameterized queries can expose values to
# # the OTLP Collector.
# #capturePgStatement: false
# # Include PostgreSQL connection-pool spans with an existing parent span.
# # Requires capturePgSpans and is disabled by default to avoid noisy spans
# # from connection churn.
# #capturePgConnectionSpans: false
# # Record Redis command spans. Disabled by default because subscribing to the
# # ioredis diagnostics channel adds work to every Redis command, including
# # commands without a parent span. Enable for development diagnostics or when
# # the added overhead is acceptable in production.
# #captureRedisCommandSpans: false
# # Include Redis startup/reconnect spans. Disabled by default to avoid noisy
# # connection churn; these spans are recorded even without an HTTP or job
# # parent span.
# #captureRedisConnectionSpans: false
# # Record Redis commands without an HTTP or job parent span. Disabled by
# # default because queue polling and background work can produce many root
# # traces; only applies when captureRedisCommandSpans is enabled.
# #captureRedisRootSpans: false
# #resourceAttributes:
# # deployment.environment: 'production'
# #propagateTraceToRemote: false
# # Queue worker spans are independent traces linked to their enqueuer by default.
# # Set to 'parent' to make them child spans instead. This can create very large
# # traces for high-fan-out queues such as federation delivery.
# #jobTraceContextMode: 'link'
#sentryForFrontend:
# vueIntegration:
# tracingOptions:
@@ -406,9 +463,8 @@ proxyBypassHosts:
# For security reasons, uploading attachments from the intranet is prohibited,
# but exceptions can be made from the following settings. Default value is "undefined".
# Read changelog to learn more (Improvements of 12.90.0 (2021/09/04)).
#allowedPrivateNetworks: [
# '127.0.0.1/32'
#]
#allowedPrivateNetworks:
# - '127.0.0.1/32'
# Upload or download file size limits (bytes)
#maxFileSize: 262144000

View File

@@ -218,9 +218,8 @@ proxyBypassHosts:
# Media Proxy
#mediaProxy: https://example.com/proxy
allowedPrivateNetworks: [
'127.0.0.1/32'
]
allowedPrivateNetworks:
- '127.0.0.1/32'
# Upload or download file size limits (bytes)
#maxFileSize: 262144000

View File

@@ -205,9 +205,8 @@ proxyBypassHosts:
# Media Proxy
#mediaProxy: https://example.com/proxy
allowedPrivateNetworks: [
'127.0.0.1/32'
]
allowedPrivateNetworks:
- '127.0.0.1/32'
# Upload or download file size limits (bytes)
#maxFileSize: 262144000

View File

@@ -40,13 +40,24 @@ function escapeCell(value: string) {
type Manifest = Record<string, { file?: string; src?: string; name?: string; isEntry?: boolean; imports?: string[] }>;
const stableNamedChunks = new Set(['vue', 'i18n']);
type FileEntry = {
key: string;
comparisonKey: string | null;
displayName: string;
file: string;
manifestKeys: string[];
size: number;
};
type CollectedReport = {
manifest: Manifest;
chunks: FileEntry[];
comparableChunks: Record<string, FileEntry>;
chunksByManifestKey: Record<string, FileEntry>;
startupFiles: string[];
};
function entryDisplayName(entry: FileEntry) {
if (!entry) return '';
return entry.displayName || entry.file;
@@ -60,23 +71,26 @@ function findEntryKey(manifest: Manifest) {
?? null;
}
function stableChunkKey(manifestKey: string, chunk: Manifest[string]) {
return chunk.src ?? (chunk.name ? `chunk:${chunk.name}` : manifestKey);
function stableChunkKey(chunk: Manifest[string]) {
if (chunk.src != null) return `src:${util.normalizePath(chunk.src)}`;
if (chunk.name != null && stableNamedChunks.has(chunk.name)) return `named:${chunk.name}`;
return null;
}
function collectStartupKeys(manifest: Manifest) {
function collectStartupManifestKeys(manifest: Manifest) {
const entryKey = findEntryKey(manifest);
const keys = new Set<string>();
if (entryKey == null) return keys;
if (entryKey == null) throw new Error('Unable to find frontend startup entry in Vite manifest.');
function visit(key: string) {
function visit(key: string, importedBy?: string) {
if (keys.has(key)) return;
const chunk = manifest[key];
if (!chunk || !chunk.file?.endsWith('.js')) return;
keys.add(stableChunkKey(key, chunk));
for (const importKey of chunk.imports ?? []) {
visit(importKey);
}
const importContext = importedBy == null ? '' : ` imported by "${importedBy}"`;
if (chunk == null) throw new Error(`Startup manifest key "${key}"${importContext} is missing.`);
if (chunk.file == null || chunk.file.length === 0) throw new Error(`Startup manifest key "${key}"${importContext} has no output file.`);
if (!chunk.file.endsWith('.js')) throw new Error(`Startup manifest key "${key}"${importContext} resolves to non-JavaScript output "${chunk.file}".`);
keys.add(key);
for (const importKey of chunk.imports ?? []) visit(importKey, key);
}
visit(entryKey);
@@ -102,26 +116,41 @@ async function resolveBuiltFile(outDir: string, file: string) {
};
}
async function collectReport(repoDir: string) {
async function collectReport(repoDir: string): Promise<CollectedReport> {
const outDir = path.join(repoDir, 'built/_frontend_vite_');
const manifestPath = path.join(outDir, 'manifest.json');
const manifest = JSON.parse(await fs.readFile(manifestPath, 'utf8')) as Manifest;
const byKey = new Map<string, FileEntry>();
const byFile = new Set<string>();
const chunksByFile = new Map<string, FileEntry>();
const comparableChunks = new Map<string, FileEntry>();
const chunksByManifestKey = new Map<string, FileEntry>();
for (const [key, chunk] of Object.entries(manifest)) {
for (const [manifestKey, chunk] of Object.entries(manifest)) {
if (!chunk.file?.endsWith('.js')) continue;
const builtFile = await resolveBuiltFile(outDir, chunk.file);
const size = await util.fileSize(builtFile.absolutePath);
const stableKey = stableChunkKey(key, chunk);
const displayName = chunk.src ?? chunk.name ?? key;
byKey.set(stableKey, {
key: stableKey,
displayName,
file: builtFile.relativePath,
size,
});
byFile.add(builtFile.relativePath);
const comparisonKey = stableChunkKey(chunk);
let entry = chunksByFile.get(builtFile.relativePath);
if (entry == null) {
entry = {
comparisonKey,
displayName: chunk.src ?? chunk.name ?? manifestKey,
file: builtFile.relativePath,
manifestKeys: [manifestKey],
size: await util.fileSize(builtFile.absolutePath),
};
chunksByFile.set(entry.file, entry);
} else if (entry.comparisonKey !== comparisonKey) {
throw new Error(`Conflicting identities for ${entry.file}`);
} else {
entry.manifestKeys.push(manifestKey);
}
chunksByManifestKey.set(manifestKey, entry);
if (comparisonKey != null) {
const existing = comparableChunks.get(comparisonKey);
if (existing != null && existing.file !== entry.file) {
throw new Error(`Duplicate stable chunk key "${comparisonKey}": ${existing.file}, ${entry.file}`);
}
comparableChunks.set(comparisonKey, entry);
}
}
const localeDir = path.join(outDir, locale);
@@ -129,21 +158,29 @@ async function collectReport(repoDir: string) {
for await (const fullPath of util.traverseDirectory(localeDir)) {
if (!fullPath.endsWith('.js')) continue;
const relativePath = util.normalizePath(path.relative(outDir, fullPath));
if (byFile.has(relativePath)) continue;
const size = await util.fileSize(fullPath);
byKey.set(relativePath, {
key: relativePath,
if (chunksByFile.has(relativePath)) continue;
chunksByFile.set(relativePath, {
comparisonKey: null,
displayName: relativePath,
file: relativePath,
size,
manifestKeys: [],
size: await util.fileSize(fullPath),
});
}
}
const startupFiles = new Set<string>();
for (const manifestKey of collectStartupManifestKeys(manifest)) {
const entry = chunksByManifestKey.get(manifestKey);
if (entry != null) startupFiles.add(entry.file);
}
return {
manifest,
chunks: Object.fromEntries(byKey),
startupKeys: [...collectStartupKeys(manifest)],
chunks: [...chunksByFile.values()],
comparableChunks: Object.fromEntries(comparableChunks),
chunksByManifestKey: Object.fromEntries(chunksByManifestKey),
startupFiles: [...startupFiles],
};
}
@@ -312,16 +349,17 @@ function renderVisualizerSummaryTable(before: ReturnType<typeof collectVisualize
];
}
function getChunkComparisonRows(keys: string[], before: Awaited<ReturnType<typeof collectReport>>, after: Awaited<ReturnType<typeof collectReport>>) {
return keys.map((key) => {
const beforeEntry = before.chunks[key];
const afterEntry = after.chunks[key];
function getChunkComparisonRows(keys: string[], before: Record<string, FileEntry>, after: Record<string, FileEntry>) {
return keys.map(key => {
const beforeEntry = before[key];
const afterEntry = after[key];
const beforeSize = beforeEntry?.size ?? 0;
const afterSize = afterEntry?.size ?? 0;
return {
key,
name: entryDisplayName(beforeEntry ?? afterEntry),
chunkFile: beforeEntry?.file ?? afterEntry?.file,
beforeFile: beforeEntry?.file,
afterFile: afterEntry?.file,
beforeSize,
afterSize,
changeType: beforeEntry == null ? 'added' : afterEntry == null ? 'removed' : beforeSize !== afterSize ? 'updated' : 'unchanged',
@@ -330,6 +368,36 @@ function getChunkComparisonRows(keys: string[], before: Awaited<ReturnType<typeo
});
}
type ChunkAggregate = {
beforeSize: number;
afterSize: number;
beforeCount: number;
afterCount: number;
};
function sumChunkSizes(chunks: FileEntry[]) {
return chunks.reduce((sum, chunk) => sum + chunk.size, 0);
}
function generatedAggregate(before: FileEntry[], after: FileEntry[]): ChunkAggregate {
const beforeGenerated = before.filter(chunk => chunk.comparisonKey == null);
const afterGenerated = after.filter(chunk => chunk.comparisonKey == null);
return {
beforeSize: sumChunkSizes(beforeGenerated),
afterSize: sumChunkSizes(afterGenerated),
beforeCount: beforeGenerated.length,
afterCount: afterGenerated.length,
};
}
function comparableMap(chunks: FileEntry[]) {
const entries: [string, FileEntry][] = [];
for (const chunk of chunks) {
if (chunk.comparisonKey != null) entries.push([chunk.comparisonKey, chunk]);
}
return Object.fromEntries(entries);
}
function summarizeChunkChanges(rows: ReturnType<typeof getChunkComparisonRows>) {
return {
updated: rows.filter((row) => row.changeType === 'updated').length,
@@ -349,60 +417,81 @@ function compareChunkComparisonRows(a: ReturnType<typeof getChunkComparisonRows>
|| a.name.localeCompare(b.name);
}
function chunkMarkdownTable(rows: ReturnType<typeof getChunkComparisonRows>, total?: { beforeSize: number; afterSize: number }) {
if (rows.length === 0) return '_No data_';
function chunkFileDisplay(row: ReturnType<typeof getChunkComparisonRows>[number]) {
if (row.beforeFile == null) return row.afterFile ?? '';
if (row.afterFile == null || row.beforeFile === row.afterFile) return row.beforeFile;
return `${row.beforeFile}${row.afterFile}`;
}
function chunkMarkdownTable(
rows: ReturnType<typeof getChunkComparisonRows>,
total?: { beforeSize: number; afterSize: number },
generated?: ChunkAggregate,
) {
if (rows.length === 0 && total == null && generated == null) return '_No data_';
const lines = [
'| Chunk | Before | After | Δ | Δ (%) |',
'| --- | ---: | ---: | ---: | ---: |',
];
let hasSummaryRow = false;
if (total != null) {
lines.push(`| (total) | ${util.formatBytes(total.beforeSize)} | ${util.formatBytes(total.afterSize)} | ${util.calcAndFormatDeltaBytes(total.beforeSize, total.afterSize, 1000)} | ${util.calcAndFormatDeltaPercent(total.beforeSize, total.afterSize, 0.1).replaceAll('\\%', '\\\\%')} |`);
lines.push('| | | | | |');
hasSummaryRow = true;
}
if (generated != null && (generated.beforeCount > 0 || generated.afterCount > 0)) {
lines.push(`| (other generated chunks) | ${util.formatBytes(generated.beforeSize)} | ${util.formatBytes(generated.afterSize)} | ${util.calcAndFormatDeltaBytes(generated.beforeSize, generated.afterSize, 1000)} | ${util.calcAndFormatDeltaPercent(generated.beforeSize, generated.afterSize, 0.1).replaceAll('\\%', '\\\\%')} |`);
hasSummaryRow = true;
}
if (hasSummaryRow && rows.length > 0) lines.push('| | | | | |');
for (const row of rows) {
const chunkFile = chunkFileDisplay(row);
if (row.changeType === 'added') {
lines.push(`| <details><summary>\`${escapeCell(row.name)}\`</summary> \`${escapeCell(row.chunkFile)}\` </details> | ${util.formatBytes(row.beforeSize)} | ${util.formatBytes(row.afterSize)} | ${util.calcAndFormatDeltaBytes(row.beforeSize, row.afterSize, 1000)} | $\\color{orange}{\\text{( + )}}$ |`);
lines.push(`| <details><summary>\`${escapeCell(row.name)}\`</summary> \`${escapeCell(chunkFile)}\` </details> | ${util.formatBytes(row.beforeSize)} | ${util.formatBytes(row.afterSize)} | ${util.calcAndFormatDeltaBytes(row.beforeSize, row.afterSize, 1000)} | $\\color{orange}{\\text{( + )}}$ |`);
} else if (row.changeType === 'removed') {
lines.push(`| <details><summary>\`${escapeCell(row.name)}\`</summary> \`${escapeCell(row.chunkFile)}\` </details> | ${util.formatBytes(row.beforeSize)} | ${util.formatBytes(row.afterSize)} | ${util.calcAndFormatDeltaBytes(row.beforeSize, row.afterSize, 1000)} | $\\color{green}{\\text{( - )}}$ |`);
lines.push(`| <details><summary>\`${escapeCell(row.name)}\`</summary> \`${escapeCell(chunkFile)}\` </details> | ${util.formatBytes(row.beforeSize)} | ${util.formatBytes(row.afterSize)} | ${util.calcAndFormatDeltaBytes(row.beforeSize, row.afterSize, 1000)} | $\\color{green}{\\text{( - )}}$ |`);
} else {
lines.push(`| <details><summary>\`${escapeCell(row.name)}\`</summary> \`${escapeCell(row.chunkFile)}\` </details> | ${util.formatBytes(row.beforeSize)} | ${util.formatBytes(row.afterSize)} | ${util.calcAndFormatDeltaBytes(row.beforeSize, row.afterSize, 1000)} | ${util.calcAndFormatDeltaPercent(row.beforeSize, row.afterSize, 0.1).replaceAll('\\%', '\\\\%')} |`);
lines.push(`| <details><summary>\`${escapeCell(row.name)}\`</summary> \`${escapeCell(chunkFile)}\` </details> | ${util.formatBytes(row.beforeSize)} | ${util.formatBytes(row.afterSize)} | ${util.calcAndFormatDeltaBytes(row.beforeSize, row.afterSize, 1000)} | ${util.calcAndFormatDeltaPercent(row.beforeSize, row.afterSize, 0.1).replaceAll('\\%', '\\\\%')} |`);
}
}
if (generated != null && (generated.beforeCount > 0 || generated.afterCount > 0)) {
lines.push('');
lines.push(`_${generated.beforeCount} before / ${generated.afterCount} after generated chunks are grouped._`);
}
return lines.join('\n');
}
function renderFrontendChunkReport(before: Awaited<ReturnType<typeof collectReport>>, after: Awaited<ReturnType<typeof collectReport>>) {
const commonChunkKeys = Object.keys(before.chunks).filter((key) => after.chunks[key] != null);
const addedChunkKeys = Object.keys(after.chunks).filter((key) => before.chunks[key] == null);
const removedChunkKeys = Object.keys(before.chunks).filter((key) => after.chunks[key] == null);
const allChunkKeys = [
...commonChunkKeys,
...addedChunkKeys,
...removedChunkKeys,
];
//const comparisonRows = getChunkComparisonRows(commonChunkKeys, before, after);
const allComparisonRows = getChunkComparisonRows(allChunkKeys, before, after);
const beforeComparable = before.comparableChunks;
const afterComparable = after.comparableChunks;
const allChunkKeys = [...new Set([...Object.keys(beforeComparable), ...Object.keys(afterComparable)])];
const allComparisonRows = getChunkComparisonRows(allChunkKeys, beforeComparable, afterComparable);
const changedRows = allComparisonRows.filter((row) => row.changeType !== 'unchanged');
const diffSummary = summarizeChunkChanges(changedRows);
const diffTotal = {
beforeSize: allComparisonRows.reduce((sum, row) => sum + row.beforeSize, 0),
afterSize: allComparisonRows.reduce((sum, row) => sum + row.afterSize, 0),
beforeSize: sumChunkSizes(before.chunks),
afterSize: sumChunkSizes(after.chunks),
};
const diffGenerated = generatedAggregate(before.chunks, after.chunks);
const diffRows = changedRows.sort(compareChunkComparisonRows).slice(0, 30); // TODO: 実際に30を超えて切り捨てられたrowがあった場合はその旨をmarkdown内に表示するようにする
const startupKeys = new Set([
...before.startupKeys,
...after.startupKeys,
]);
const startupComparisonRows = getChunkComparisonRows([...startupKeys], before, after);
const beforeStartupFiles = new Set(before.startupFiles);
const afterStartupFiles = new Set(after.startupFiles);
const beforeStartupChunks = before.chunks.filter(chunk => beforeStartupFiles.has(chunk.file));
const afterStartupChunks = after.chunks.filter(chunk => afterStartupFiles.has(chunk.file));
const beforeStartupComparable = comparableMap(beforeStartupChunks);
const afterStartupComparable = comparableMap(afterStartupChunks);
const startupKeys = [...new Set([...Object.keys(beforeStartupComparable), ...Object.keys(afterStartupComparable)])];
const startupComparisonRows = getChunkComparisonRows(startupKeys, beforeStartupComparable, afterStartupComparable);
const startupRows = startupComparisonRows.sort(compareChunkComparisonRows);
const startupSummary = summarizeChunkChanges(startupComparisonRows);
const startupTotal = {
beforeSize: startupComparisonRows.reduce((sum, row) => sum + row.beforeSize, 0),
afterSize: startupComparisonRows.reduce((sum, row) => sum + row.afterSize, 0),
beforeSize: sumChunkSizes(beforeStartupChunks),
afterSize: sumChunkSizes(afterStartupChunks),
};
const startupGenerated = generatedAggregate(beforeStartupChunks, afterStartupChunks);
//const largeRows = comparisonRows
// .sort((a, b) => b.sortSize - a.sortSize || a.name.localeCompare(b.name))
@@ -412,14 +501,14 @@ function renderFrontendChunkReport(before: Awaited<ReturnType<typeof collectRepo
'<details open>',
`<summary>${formatChunkChangeSummary('Chunk size diff', diffSummary)}</summary>`,
'',
chunkMarkdownTable(diffRows, diffTotal),
chunkMarkdownTable(diffRows, diffTotal, diffGenerated),
'',
'</details>',
'',
'<details>',
`<summary>${formatChunkChangeSummary('Startup chunk size', startupSummary)}</summary>`,
'',
chunkMarkdownTable(startupRows, startupTotal),
chunkMarkdownTable(startupRows, startupTotal, startupGenerated),
'',
`_Startup chunks are the Vite entry for \`src/_boot_.ts\` and its static imports._`,
'',

View File

@@ -0,0 +1,233 @@
/*
* SPDX-FileCopyrightText: syuilo and misskey-project
* SPDX-License-Identifier: AGPL-3.0-only
*/
import assert from 'node:assert/strict';
import { execFile } from 'node:child_process';
import { promises as fs } from 'node:fs';
import os from 'node:os';
import path from 'node:path';
import test, { type TestContext } from 'node:test';
import * as util from './utility.mts';
type Manifest = Record<string, {
file?: string;
src?: string;
name?: string;
isEntry?: boolean;
imports?: string[];
}>;
const repoDir = path.resolve(import.meta.dirname, '../..');
const reportScript = path.join(repoDir, '.github/scripts/frontend-js-size.mts');
async function writeBuild(repo: string, manifest: Manifest, sizes: Record<string, number>) {
const outDir = path.join(repo, 'built/_frontend_vite_/');
await fs.mkdir(path.join(outDir, 'ja-JP'), { recursive: true });
await fs.writeFile(path.join(outDir, 'manifest.json'), JSON.stringify(manifest));
for (const [file, size] of Object.entries(sizes)) {
const localizedFile = file.replace(/^scripts\//, '');
const outputFile = path.join(outDir, 'ja-JP', localizedFile);
await fs.mkdir(path.dirname(outputFile), { recursive: true });
await fs.writeFile(outputFile, Buffer.alloc(size));
}
}
async function runReport(t: TestContext, before: { manifest: Manifest; sizes: Record<string, number> }, after: { manifest: Manifest; sizes: Record<string, number> }, expectFailure = false) {
const root = await fs.mkdtemp(path.join(os.tmpdir(), 'frontend-js-size-'));
t.after(() => fs.rm(root, { recursive: true, force: true }));
const beforeDir = path.join(root, 'before');
const afterDir = path.join(root, 'after');
const beforeStats = path.join(root, 'before-stats.json');
const afterStats = path.join(root, 'after-stats.json');
const reportFile = path.join(root, 'report.md');
await writeBuild(beforeDir, before.manifest, before.sizes);
await writeBuild(afterDir, after.manifest, after.sizes);
await fs.writeFile(beforeStats, '{}');
await fs.writeFile(afterStats, '{}');
const args = [reportScript, beforeDir, afterDir, beforeStats, afterStats, reportFile];
if (expectFailure) {
return new Promise<string>((resolve, reject) => {
execFile(process.execPath, args, (error, _stdout, stderr) => {
if (error == null) {
reject(new Error('Expected frontend report script to fail'));
} else {
resolve(stderr);
}
});
});
}
await util.run(process.execPath, args);
return fs.readFile(reportFile, 'utf8');
}
function fixture(suffix: string, generatedName: string, sizes: { entry: number; generatedA: number; generatedB: number; vue: number; i18n: number }) {
const files = {
entry: `scripts/entry-${suffix}.js`,
generatedA: `scripts/generated-a-${suffix}.js`,
generatedB: `scripts/generated-b-${suffix}.js`,
vue: `scripts/vue-${suffix}.js`,
i18n: `scripts/i18n-${suffix}.js`,
};
return {
manifest: {
'src/_boot_.ts': { file: files.entry, src: 'src/_boot_.ts', name: 'entry', isEntry: true, imports: ['_generatedA', '_generatedB', '_vue', '_i18n'] },
_generatedA: { file: files.generatedA, name: generatedName },
_generatedB: { file: files.generatedB, name: generatedName },
_vue: { file: files.vue, name: 'vue' },
_i18n: { file: files.i18n, name: 'i18n' },
},
sizes: Object.fromEntries(Object.entries(files).map(([key, file]) => [file, sizes[key as keyof typeof sizes]])),
};
}
test('groups generated chunks while preserving full and startup totals', async t => {
const report = await runReport(
t,
fixture('before', 'dist', { entry: 100, generatedA: 10, generatedB: 20, vue: 40, i18n: 50 }),
fixture('after', 'esm', { entry: 110, generatedA: 30, generatedB: 40, vue: 45, i18n: 50 }),
);
assert.match(report, /\| \(total\) \| 220 B \| 275 B \|/);
assert.equal(report.match(/\| \(other generated chunks\) \| 30 B \| 70 B \|/g)?.length, 2);
assert.equal(report.match(/_2 before \/ 2 after generated chunks are grouped\._/g)?.length, 2);
assert.doesNotMatch(report, /<summary>`(?:dist|esm)`<\/summary>/);
assert.match(report, /<summary>`src\/_boot_\.ts`<\/summary>/);
assert.match(report, /<summary>`vue`<\/summary>/);
});
test('fails instead of overwriting duplicate stable chunk keys', async t => {
const duplicateVue = fixture('before', 'dist', { entry: 100, generatedA: 10, generatedB: 20, vue: 40, i18n: 50 });
duplicateVue.manifest._vueDuplicate = { file: 'scripts/vue-duplicate-before.js', name: 'vue' };
duplicateVue.sizes['scripts/vue-duplicate-before.js'] = 60;
const diagnostic = await runReport(
t,
duplicateVue,
fixture('after', 'esm', { entry: 110, generatedA: 30, generatedB: 40, vue: 45, i18n: 50 }),
true,
);
assert.match(diagnostic, /Duplicate stable chunk key "named:vue".*vue-before\.js.*vue-duplicate-before\.js/);
});
test('shows both filenames for an updated stable chunk', async t => {
const report = await runReport(
t,
fixture('before', 'dist', { entry: 100, generatedA: 10, generatedB: 20, vue: 40, i18n: 50 }),
fixture('after', 'esm', { entry: 110, generatedA: 30, generatedB: 40, vue: 45, i18n: 50 }),
);
assert.match(report, /`ja-JP\/entry-before\.js → ja-JP\/entry-after\.js`/);
assert.match(report, /`ja-JP\/vue-before\.js → ja-JP\/vue-after\.js`/);
});
test('fails descriptively when the startup entry is missing', async t => {
const before = fixture('before', 'dist', { entry: 100, generatedA: 10, generatedB: 20, vue: 40, i18n: 50 });
delete before.manifest['src/_boot_.ts'];
delete before.sizes['scripts/entry-before.js'];
const diagnostic = await runReport(
t,
before,
fixture('after', 'esm', { entry: 110, generatedA: 30, generatedB: 40, vue: 45, i18n: 50 }),
true,
);
assert.match(diagnostic, /Unable to find frontend startup entry in Vite manifest/);
});
test('fails descriptively when a static import is missing from the manifest', async t => {
const before = fixture('before', 'dist', { entry: 100, generatedA: 10, generatedB: 20, vue: 40, i18n: 50 });
before.manifest['src/_boot_.ts'].imports = ['_missing'];
const diagnostic = await runReport(
t,
before,
fixture('after', 'esm', { entry: 110, generatedA: 30, generatedB: 40, vue: 45, i18n: 50 }),
true,
);
assert.match(diagnostic, /Startup manifest key "_missing".*is missing/);
});
test('fails descriptively when a static import has no output file', async t => {
const before = fixture('before', 'dist', { entry: 100, generatedA: 10, generatedB: 20, vue: 40, i18n: 50 });
before.manifest['src/_boot_.ts'].imports = ['_malformed'];
before.manifest._malformed = { name: 'malformed' };
const diagnostic = await runReport(
t,
before,
fixture('after', 'esm', { entry: 110, generatedA: 30, generatedB: 40, vue: 45, i18n: 50 }),
true,
);
assert.match(diagnostic, /Startup manifest key "_malformed".*has no output file/);
});
test('fails descriptively when a static import resolves to a non-JavaScript output', async t => {
const before = fixture('before', 'dist', { entry: 100, generatedA: 10, generatedB: 20, vue: 40, i18n: 50 });
before.manifest['src/_boot_.ts'].imports = ['_malformed'];
before.manifest._malformed = { file: 'assets/malformed.css', name: 'malformed' };
const diagnostic = await runReport(
t,
before,
fixture('after', 'esm', { entry: 110, generatedA: 30, generatedB: 40, vue: 45, i18n: 50 }),
true,
);
assert.match(diagnostic, /Startup manifest key "_malformed".*non-JavaScript output "assets\/malformed\.css"/);
});
test('keeps source-backed additions and removals as individual rows', async t => {
const before = fixture('before', 'dist', { entry: 100, generatedA: 10, generatedB: 20, vue: 40, i18n: 50 });
before.manifest._removed = { file: 'scripts/removed-before.js', src: 'src/removed.ts' };
before.sizes['scripts/removed-before.js'] = 12;
const after = fixture('after', 'esm', { entry: 110, generatedA: 30, generatedB: 40, vue: 45, i18n: 50 });
after.manifest._added = { file: 'scripts/added-after.js', src: 'src/added.ts' };
after.sizes['scripts/added-after.js'] = 13;
const report = await runReport(t, before, after);
assert.match(report, /<summary>`src\/added\.ts`<\/summary> `ja-JP\/added-after\.js` <\/details> \| 0 B \| 13 B \|/);
assert.match(report, /<summary>`src\/removed\.ts`<\/summary> `ja-JP\/removed-before\.js` <\/details> \| 12 B \| 0 B \|/);
});
test('counts an unmanifested localized JavaScript file in totals and the generated aggregate', async t => {
const before = fixture('before', 'dist', { entry: 100, generatedA: 10, generatedB: 20, vue: 40, i18n: 50 });
before.sizes['scripts/unmanifested-before.js'] = 15;
const report = await runReport(
t,
before,
fixture('after', 'esm', { entry: 110, generatedA: 30, generatedB: 40, vue: 45, i18n: 50 }),
);
assert.match(report, /\| \(total\) \| 235 B \| 275 B \|/);
assert.match(report, /\| \(other generated chunks\) \| 45 B \| 70 B \|/);
assert.match(report, /_3 before \/ 2 after generated chunks are grouped\._/);
});
test('counts duplicate manifest entries for one physical output only once', async t => {
const before = fixture('before', 'dist', { entry: 100, generatedA: 10, generatedB: 20, vue: 40, i18n: 50 });
before.manifest._generatedAlias = { file: 'scripts/generated-a-before.js', name: 'dist' };
const report = await runReport(
t,
before,
fixture('after', 'esm', { entry: 110, generatedA: 30, generatedB: 40, vue: 45, i18n: 50 }),
);
assert.match(report, /\| \(total\) \| 220 B \| 275 B \|/);
assert.match(report, /_2 before \/ 2 after generated chunks are grouped\._/);
});
test('does not count generated-aggregate-only changes as individual chunk changes', async t => {
const report = await runReport(
t,
fixture('before', 'dist', { entry: 100, generatedA: 10, generatedB: 20, vue: 40, i18n: 50 }),
fixture('after', 'esm', { entry: 100, generatedA: 30, generatedB: 40, vue: 40, i18n: 50 }),
);
assert.match(report, /<summary>Chunk size diff \(0 updated, 0 added, 0 removed\)<\/summary>/);
assert.match(report, /<summary>Startup chunk size \(0 updated, 0 added, 0 removed\)<\/summary>/);
assert.equal(report.match(/\| \(other generated chunks\) \| 30 B \| 70 B \|/g)?.length, 2);
});

View File

@@ -18,6 +18,8 @@ on:
- packages/misskey-reversi/**
- packages/shared/eslint.config.js
- scripts/check-dts*.mjs
- .github/scripts/frontend-js-size*.mts
- .github/scripts/utility.mts
- .github/workflows/lint.yml
- package.json
pull_request:
@@ -34,6 +36,8 @@ on:
- packages/misskey-reversi/**
- packages/shared/eslint.config.js
- scripts/check-dts*.mjs
- .github/scripts/frontend-js-size*.mts
- .github/scripts/utility.mts
- .github/workflows/lint.yml
- package.json
jobs:
@@ -136,3 +140,12 @@ jobs:
- run: pnpm i --frozen-lockfile
- run: node --test scripts/check-dts.test.mjs
- run: pnpm check-dts
frontend-bundle-report-test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v6.0.3
- uses: actions/setup-node@v6.4.0
with:
node-version-file: '.node-version'
- run: node --test .github/scripts/frontend-js-size.test.mts

View File

@@ -29,18 +29,30 @@
- Fix: 幅が狭い画面で動画の再生が困難な問題を修正
- Fix: 一部の画像のみセンシティブなとき、ビューワー内で画像を切り替えるとセンシティブな画像がそのまま表示される問題を修正
- Fix: 一部の画像をビューワーで読み込んだ際に正しく表示されない問題を修正
- Enhance: タイムラインの読み込みパフォーマンスを改善
- Fix: 「画像を新しいタブで開く」が機能しなくなっていた問題を修正
- Fix: デバイスタイプをスマートフォンに固定している状態で画面幅が広いとき、画面左上のアイコンが表示されない問題を修正
- Fix: チャットでIMEの変換を確定するEnterでメッセージが送信されてしまうことがある問題を修正
- Fix: 自分へのメンションに対する色分けで、判定が大文字/小文字を区別していた問題を修正
- Fix: いくつかのイベントリスナーが正しく解除されない問題を修正(メモリ使用量の改善)
- Fix: 非ログイン時トップページをスクロール操作できないことがある問題を修正
### Server
- Enhance: センシティブメディアの判定を外部サービス ([sensitive-detector](https://github.com/misskey-dev/sensitive-detector)) に分離し、`nsfwjs` / `@tensorflow/tfjs(-node)` の同梱と NSFW 判定モデルを廃止 (#16804)
- Feat: OpenTelemetryサポート
- 詳細な設定はconfigファイルを参照してください。
- Sentryとの併用も可能です。Sentry併用時は、PostgreSQL Query と Redis command は Sentry で計装されます。
- 以下の自動計装をサポートしています。(計装対象にする項目は設定可能)
- PostgreSQL query
- Redis command
- 全ての受信HTTPリクエスト
- 全ての送信HTTPリクエスト
- ジョブキュー(エンキュー元のトレースを含む)
- Enhance: Sentry バックエンドの自動計装を `sentryForBackend.disabledIntegrations` で個別に無効化できるように
- Enhance: センシティブメディアの判定を外部サービス ([sensitive-detector](https://github.com/misskey-dev/sensitive-detector)) に分離し、`nsfwjs` / `@tensorflow/tfjs(-node)` の同梱と NSFW 判定モデルを廃止 (#16804)
- Enhance: Node.js 22.23.0以降、24.17.0以降、26.4.0以降をサポートするように
- Enhance: Docker Image の Node.js を 26.4.0 に、Debian を trixie (v13) に更新
- Enhance: URLプレビューの結果を内部でキャッシュするように
- Enhance: API内部エラーのログに構造化属性と正規化したエラー情報を付与し、認証情報を自動的に秘匿するように従来形式の表示は維持
- Fix: `/stats` API のレスポンス型が正しくない問題を修正
- Fix: ハッシュタグに関連するデータを更新する際のエラーハンドリングを修正
- Fix: Sentry 使用環境下にて、Misskey が発行した SQL クエリが span に含まれない問題を修正

View File

@@ -224,9 +224,8 @@ id: "aidx"
# Media Proxy
#mediaProxy: https://example.com/proxy
#allowedPrivateNetworks: [
# '127.0.0.1/32'
#]
allowedPrivateNetworks:
- '127.0.0.1/32'
# Upload or download file size limits (bytes)
#maxFileSize: 262144000

View File

@@ -0,0 +1,153 @@
# Frontend bundle report: generated chunk handling
## Background
The frontend bundle report compares the Vite manifests produced for the pull request base and head builds. Chunks with a `src` value are currently identified by that source path. Chunks without `src` are identified by Rolldown's generated `name`.
Generated names are not unique or stable identities. Multiple unrelated shared chunks can all be called `dist`, `esm`, `index`, or a similar path-derived name. The current collection logic stores those chunks in a `Map` under `chunk:<name>`, so later entries overwrite earlier entries. This produces two problems:
1. unrelated chunks can be compared as if they were the same chunk; and
2. overwritten chunks are omitted from the reported total.
Individual size changes for these generated shared chunks are not sufficiently actionable to justify heuristic matching.
## Goals
- Never compare unrelated generated chunks as the same chunk.
- Keep generated chunks out of the individual chunk-diff rows.
- Preserve the contribution of every physical JavaScript chunk in totals.
- Show the aggregate size change of generated chunks so unexplained bundle growth remains visible.
- Apply the same rules to the full chunk report and the startup chunk report.
- Continue comparing source-backed chunks and intentionally named chunks.
## Non-goals
- Inferring chunk identity from module-set similarity.
- Preserving an `updated` relationship for every shared chunk after code splitting changes.
- Changing the frontend's code-splitting strategy or output filenames.
- Suppressing specific names such as `esm` or `dist` with a denylist.
## Approaches considered
### Denylist generated-looking names
Exclude names such as `esm`, `dist`, `lib`, and `index`.
This is not selected because Rolldown can generate many other names, and a denylist can also hide an intentionally named chunk. It would leave the underlying name collision and total-size bug in place.
### Compare only stable identities and aggregate the rest
Classify chunks by whether the report has a stable semantic identity. Source-backed chunks and explicitly supported manual chunk names remain individually comparable. All other generated chunks are retained as physical files but shown only as an aggregate.
This is the selected approach. It removes false comparisons without introducing matching heuristics, and it keeps all bytes visible.
### Match shared chunks by their module sets
Use visualizer metadata to compare exact or similar sets of module IDs.
This could retain more individual diffs, but splits, merges, dependency-version paths, and small module movements make the matching policy complex and potentially misleading. It can be added later if aggregate data proves insufficient.
## Design
### Separate physical chunks from comparable identities
`collectReport` will no longer use a single map keyed by the comparison identity as its source of truth. It will collect every resolved JavaScript output file exactly once into a physical chunk collection.
Each physical chunk contains:
- its resolved relative output path;
- its byte size;
- its manifest key, when present;
- its display name; and
- an optional comparison key.
The physical output path is used only for de-duplication within one build. It is not used to match changed chunks across builds.
### Comparison-key classification
A chunk receives a comparison key only in one of these cases:
1. `chunk.src` is present: use `src:<normalized source path>`.
2. `chunk.name` is in an explicit allowlist of intentionally stable manual chunks: use `named:<name>`.
The initial stable-name allowlist is:
- `vue`
- `i18n`
These names correspond to the explicit `codeSplitting.groups` configuration in `packages/frontend/vite.config.ts`.
All other manifest chunks without `src`, including generated names such as `esm` and `dist`, receive no comparison key. JavaScript files found in the localized output directory but absent from the manifest also receive no comparison key.
If a supposedly stable comparison key occurs more than once within one build, the report must not silently overwrite it. Collection will fail with a descriptive error because duplicate `src` or allowlisted manual names violate the assumptions required for a correct comparison.
### Full chunk report
The report computes three independent values:
1. **Total:** the sum of every unique physical JavaScript chunk.
2. **Generated chunk aggregate:** the sum of chunks without a comparison key.
3. **Individual rows:** before/after comparisons for stable comparison keys only.
The table starts with the existing `(total)` row. When either build contains generated chunks, it then includes one aggregate row such as:
```text
(other generated chunks)
```
This row compares aggregate sizes, not individual chunk identities. Generated chunks do not participate in the updated/added/removed row counts.
The report includes a short note stating how many generated chunks were grouped on each side. This makes the scope of the aggregate explicit without listing noisy filenames.
### Startup chunk report
Startup traversal continues to follow the entry chunk's static `imports`, but it records manifest keys or resolved physical file paths rather than generated comparison keys. This prevents two startup chunks named `dist` from collapsing into one.
Startup totals include every unique physical startup chunk. Stable startup chunks receive individual rows, while startup chunks without stable identities contribute to the same `(other generated chunks)` aggregate row within the startup table.
### Displayed filenames
For an individually comparable chunk, the details should expose both filenames when they differ. A single before-side filename must not imply that the after size belongs to that same file.
The display can use one filename when unchanged and `before → after` when the content-hashed filename differs.
The generated aggregate row does not display an arbitrary representative filename.
## Data flow
1. Read each build's Vite manifest.
2. Resolve localized output files and collect unique physical chunks.
3. Attach optional stable comparison keys using the classification rules.
4. Traverse startup imports using manifest identities and map them to physical chunks.
5. Calculate full and startup totals from physical chunks.
6. Calculate generated aggregates from chunks without comparison keys.
7. Compare only unique stable keys for individual rows.
8. Render totals, generated aggregates, and stable rows.
## Error handling
- A manifest entry whose expected localized JavaScript file is absent remains a fatal report-generation error.
- Duplicate physical output paths are de-duplicated and counted once.
- Duplicate stable comparison keys fail with an error that includes the key and conflicting files.
- Missing or malformed manifest data remains fatal rather than producing a partial report.
## Testing
Add focused fixtures or pure-function tests covering:
- two unrelated `dist` chunks are both counted in total and grouped into the generated aggregate;
- `dist` and `esm` chunks never produce individual comparison rows;
- source-backed chunks with the same `src` are reported as updated;
- source-backed additions and removals remain visible;
- allowlisted `vue` and `i18n` chunks remain individually comparable;
- duplicate stable keys produce a descriptive error instead of overwriting;
- full totals equal the sum of all unique physical chunks;
- startup totals include multiple same-name generated chunks exactly once each;
- generated aggregate changes do not affect the updated/added/removed summary counts; and
- differing before/after filenames are rendered without attributing both sizes to one file.
Validation should include the focused tests and repository lint. No CHANGELOG entry is required because this changes developer-facing CI reporting rather than Misskey user behavior.
## Future extension
If aggregate generated-chunk data is later found insufficient, visualizer module metadata can support a separate opt-in analysis. That extension must preserve the physical-chunk accounting introduced here and must not reintroduce name-based identity.

View File

@@ -1,6 +1,6 @@
{
"name": "misskey",
"version": "2026.7.0-alpha.3",
"version": "2026.7.0-alpha.5",
"codename": "nasubi",
"repository": {
"type": "git",

View File

@@ -57,6 +57,7 @@
"@fastify/cors": "11.2.0",
"@fastify/http-proxy": "11.5.0",
"@fastify/multipart": "10.0.0",
"@fastify/otel": "0.19.0",
"@fastify/static": "9.1.3",
"@kitajs/html": "4.2.13",
"@misskey-dev/emoji-assets": "17.0.3",
@@ -67,6 +68,14 @@
"@nestjs/common": "11.1.27",
"@nestjs/core": "11.1.27",
"@nestjs/testing": "11.1.27",
"@opentelemetry/api": "1.9.1",
"@opentelemetry/core": "2.8.0",
"@opentelemetry/exporter-trace-otlp-proto": "0.214.0",
"@opentelemetry/instrumentation-pg": "0.72.0",
"@opentelemetry/resources": "2.7.1",
"@opentelemetry/sdk-trace-base": "2.7.1",
"@opentelemetry/sdk-trace-node": "2.7.1",
"@opentelemetry/semantic-conventions": "1.40.0",
"@oxc-project/runtime": "0.137.0",
"@peertube/http-signature": "1.7.0",
"@sentry/node": "10.62.0",

View File

@@ -87,6 +87,7 @@ export default defineConfig((args) => {
'class-validator',
/^@sentry\/.*/,
/^@sentry-internal\/.*/,
/^@opentelemetry\/.*/,
'@nestjs/websockets/socket-module',
'@nestjs/microservices/microservices-module',
'@nestjs/microservices',

View File

@@ -13,6 +13,7 @@ import { writeHeapSnapshot } from 'node:v8';
import chalk from 'chalk';
import Xev from 'xev';
import Logger from '@/logger.js';
import { isTelemetryShutdownInProgress } from '@/core/telemetry/telemetry-shutdown.js';
import { envOption } from '../env.js';
import { readyRef } from './ready.js';
@@ -41,6 +42,11 @@ cluster.on('online', worker => {
// Listen for dying workers
cluster.on('exit', worker => {
if (isTelemetryShutdownInProgress()) {
clusterLogger.info(`Process exited during shutdown: [${worker.id}]`);
return;
}
// Replace the dead worker,
// we're not sentimental
clusterLogger.error(chalk.red(`[${worker.id}] died :(`));

View File

@@ -14,6 +14,7 @@ import type { Config } from '@/config.js';
import { showMachineInfo } from '@/misc/show-machine-info.js';
import { envOption } from '@/env.js';
import { initTelemetry } from '@/core/telemetry/telemetry-registry.js';
import { installTelemetrySignalHandlers } from '@/core/telemetry/telemetry-shutdown.js';
import { initExtraThreadPool, jobQueue, server } from './common.js';
const logger = new Logger('core', 'cyan');
@@ -67,7 +68,13 @@ 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);
}
installTelemetrySignalHandlers();
bootLogger.info(
`mode: [disableClustering: ${envOption.disableClustering}, onlyServer: ${envOption.onlyServer}, onlyQueue: ${envOption.onlyQueue}]`,

View File

@@ -4,11 +4,16 @@
*/
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 { installTelemetrySignalHandlers } from '@/core/telemetry/telemetry-shutdown.js';
import { initExtraThreadPool, jobQueue, server } from './common.js';
const logger = new Logger('core', 'cyan');
const bootLogger = logger.createSubLogger('boot', 'magenta');
/**
* Init worker process
*/
@@ -17,7 +22,13 @@ export async function workerMain() {
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);
}
installTelemetrySignalHandlers();
if (envOption.onlyServer) {
await server();

View File

@@ -26,6 +26,21 @@ type SentryBackendConfig = {
disabledIntegrations?: string[];
};
type OtelBackendConfig = {
endpoint?: string;
headers?: Record<string, string>;
sampleRate?: number;
capturePgSpans?: boolean;
capturePgStatement?: boolean;
capturePgConnectionSpans?: boolean;
captureRedisCommandSpans?: boolean;
captureRedisConnectionSpans?: boolean;
captureRedisRootSpans?: boolean;
resourceAttributes?: Record<string, string>;
propagateTraceToRemote?: boolean;
jobTraceContextMode?: 'link' | 'parent';
};
/**
* 設定ファイルの型
*/
@@ -71,6 +86,7 @@ type Source = {
scope?: 'local' | 'global' | string[];
};
sentryForBackend?: SentryBackendConfig;
otelForBackend?: OtelBackendConfig;
sentryForFrontend?: {
options: Partial<SentryVue.BrowserOptions> & { dsn: string };
vueIntegration?: SentryVue.VueIntegrationOptions | null;
@@ -208,6 +224,7 @@ export type Config = {
redisForTimelines: RedisOptions & RedisOptionsSource;
redisForReactions: RedisOptions & RedisOptionsSource;
sentryForBackend: SentryBackendConfig | undefined;
otelForBackend: OtelBackendConfig | undefined;
sentryForFrontend: {
options: Partial<SentryVue.BrowserOptions> & { dsn: string };
vueIntegration?: SentryVue.VueIntegrationOptions | null;
@@ -313,6 +330,7 @@ export function loadConfig(): Config {
redisForTimelines: config.redisForTimelines ? convertRedisOptions(config.redisForTimelines, host) : redis,
redisForReactions: config.redisForReactions ? convertRedisOptions(config.redisForReactions, host) : redis,
sentryForBackend: config.sentryForBackend,
otelForBackend: config.otelForBackend,
sentryForFrontend: config.sentryForFrontend,
id: config.id,
proxy: config.proxy,

View File

@@ -9,6 +9,7 @@ import { DI } from '@/di-symbols.js';
import type { Config } from '@/config.js';
import { baseQueueOptions, QUEUE } from '@/queue/const.js';
import { allSettled } from '@/misc/promise-tracker.js';
import { instrumentQueue } from '@/core/telemetry/queue-instrumentation.js';
import {
DeliverJobData,
EndedPollNotificationJobData,
@@ -31,63 +32,72 @@ export type ObjectStorageQueue = Bull.Queue;
export type UserWebhookDeliverQueue = Bull.Queue<UserWebhookDeliverJobData>;
export type SystemWebhookDeliverQueue = Bull.Queue<SystemWebhookDeliverJobData>;
function createQueue<T extends object>(queueName: string, config: Config): Bull.Queue<T> {
const queue = new Bull.Queue<T>(queueName, baseQueueOptions(config, queueName));
// Queue のラップは、enqueue 時に OTel context をジョブデータへ埋め込むためのもの。
// Sentry 単独ではジョブ間の context 伝播を使わないので、OTel 未設定時は元の Queue を返す。
if (config.otelForBackend == null) return queue;
return instrumentQueue(queue);
}
const $system: Provider = {
provide: 'queue:system',
useFactory: (config: Config) => new Bull.Queue(QUEUE.SYSTEM, baseQueueOptions(config, QUEUE.SYSTEM)),
useFactory: (config: Config) => createQueue<Record<string, unknown>>(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<EndedPollNotificationJobData>(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<PostScheduledNoteJobData>(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<DeliverJobData>(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<InboxJobData>(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<Record<string, unknown>>(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<RelationshipJobData>(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<Record<string, unknown>>(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<UserWebhookDeliverJobData>(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<SystemWebhookDeliverJobData>(QUEUE.SYSTEM_WEBHOOK_DELIVER, config),
inject: [DI.config],
};

View File

@@ -5,7 +5,7 @@
import { Injectable } from '@nestjs/common';
import { bindThis } from '@/decorators.js';
import { captureMessage, shutdownTelemetry, startSpan } from './telemetry-registry.js';
import { captureMessage, shutdownTelemetry, startSpan, startSpanWithTraceContext } from './telemetry-registry.js';
import type { OnApplicationShutdown } from '@nestjs/common';
import type { TelemetryCaptureMessageOptions } from './adapters/TelemetryAdapter.js';
@@ -21,6 +21,13 @@ export class TelemetryService implements OnApplicationShutdown {
return startSpan(name, fn);
}
@bindThis
public startSpanWithTraceContext<T>(name: string, jobData: object, fn: () => T): T {
// jobData に enqueue 元の context があれば worker span へ復元する。
// context の無い既存ジョブは、通常の startSpan と同じ扱いになる。
return startSpanWithTraceContext(name, jobData, fn);
}
@bindThis
public async onApplicationShutdown(_signal?: string): Promise<void> {
await shutdownTelemetry();

View File

@@ -0,0 +1,249 @@
/*
* SPDX-FileCopyrightText: syuilo and misskey-project
* SPDX-License-Identifier: AGPL-3.0-only
*/
import * as os from 'node:os';
import cluster from 'node:cluster';
import { envOption } from '@/env.js';
import { registerDiagLogger } from '@/core/telemetry/telemetry-diag.js';
import { installHttpClientInstrumentation } from '@/core/telemetry/http-client-instrumentation.js';
import { installDatabaseInstrumentation } from '@/core/telemetry/database-instrumentation.js';
import { installRedisInstrumentation } from '@/core/telemetry/redis-instrumentation.js';
import { executeSpan, getQueueTraceContextMode, injectActiveTraceContext, recordSpanError, startSpanWithQueueTraceContext } from '@/core/telemetry/queue-trace-context.js';
import type { Span, SpanStatusCode, Tracer } from '@opentelemetry/api';
import type { Resource, ResourceDetector } from '@opentelemetry/resources';
import type { ParentBasedSampler, Sampler } from '@opentelemetry/sdk-trace-base';
import type { OtelBackendRuntimeConfig, TelemetryAdapter, TelemetryCaptureMessageOptions } from './TelemetryAdapter.js';
import type { QueueTraceContextCarrier, QueueTraceContextDeps } from '../queue-trace-context.js';
const DEFAULT_SHUTDOWN_TIMEOUT = 5000;
type OpenTelemetryAdapterDeps = {
tracer: Pick<Tracer, 'startActiveSpan'>;
provider: {
shutdown(): Promise<void>;
};
getActiveSpan: () => Span | undefined;
spanStatusCodeError: SpanStatusCode;
shutdownTimeout: number;
shutdownHttpClientInstrumentation?: () => void;
shutdownDatabaseInstrumentation?: () => void;
shutdownRedisInstrumentation?: () => void;
queueTraceContext?: QueueTraceContextDeps;
};
type CreateSamplerDeps = {
ParentBasedSampler: new (config: { root: Sampler }) => ParentBasedSampler;
TraceIdRatioBasedSampler: new (sampleRate: number) => Sampler;
};
type CreateResourceDeps = {
defaultResource: () => Resource;
resourceFromAttributes: (attributes: Record<string, string>) => Resource;
detectResources: (config: { detectors: ResourceDetector[] }) => Resource;
envDetector: ResourceDetector;
serviceNameAttribute: string;
serviceInstanceIdAttribute: string;
serviceVersionAttribute: string;
serviceVersion: string;
};
export class OpenTelemetryAdapter implements TelemetryAdapter {
public constructor(
private readonly deps: OpenTelemetryAdapterDeps,
) {
}
public static async create(config: OtelBackendRuntimeConfig): Promise<OpenTelemetryAdapter> {
const [
{ context, diag, DiagLogLevel, propagation, ROOT_CONTEXT, SpanKind, SpanStatusCode, trace },
{ W3CTraceContextPropagator },
{ OTLPTraceExporter },
{ defaultResource, detectResources, envDetector, resourceFromAttributes },
{ BatchSpanProcessor, ParentBasedSampler, TraceIdRatioBasedSampler },
{ NodeTracerProvider },
{ ATTR_SERVICE_INSTANCE_ID, ATTR_SERVICE_NAME, ATTR_SERVICE_VERSION },
] = await Promise.all([
import('@opentelemetry/api'),
import('@opentelemetry/core'),
import('@opentelemetry/exporter-trace-otlp-proto'),
import('@opentelemetry/resources'),
import('@opentelemetry/sdk-trace-base'),
import('@opentelemetry/sdk-trace-node'),
import('@opentelemetry/semantic-conventions'),
]);
// OTel SDK内部のexport失敗は既定だと見えにくいため、Misskeyのloggerへ橋渡しする。
registerDiagLogger(diag, DiagLogLevel.WARN);
// endpoint/headersを未指定にしておくと、OTEL_EXPORTER_OTLP_* 環境変数の標準fallbackが効く。
const exporter = new OTLPTraceExporter({
...(config.endpoint != null ? { url: config.endpoint } : {}),
...(config.headers != null ? { headers: config.headers } : {}),
});
const spanProcessor = new BatchSpanProcessor(exporter);
// SDK 2.xではSpanProcessorをprovider生成時に渡す。ここでOTel単体用のproviderを作る。
const provider = new NodeTracerProvider({
resource: createResource(config, {
defaultResource,
resourceFromAttributes,
detectResources,
envDetector,
serviceNameAttribute: ATTR_SERVICE_NAME,
serviceInstanceIdAttribute: ATTR_SERVICE_INSTANCE_ID,
serviceVersionAttribute: ATTR_SERVICE_VERSION,
serviceVersion: config.serviceVersion,
}),
...(config.sampleRate != null ? { sampler: createSampler(config.sampleRate, {
ParentBasedSampler,
TraceIdRatioBasedSampler,
}) } : {}),
spanProcessors: [spanProcessor],
});
// HTTP送信には注入しないが、将来のQueue連結でpropagation APIを使える状態にする。
provider.register({
propagator: new W3CTraceContextPropagator(),
});
// provider操作をdepsに閉じ込め、span wrapper本体をユニットテストしやすくする。
const tracer = provider.getTracer('misskey-backend');
return new OpenTelemetryAdapter({
tracer,
provider,
getActiveSpan: () => trace.getActiveSpan(),
spanStatusCodeError: SpanStatusCode.ERROR,
shutdownTimeout: DEFAULT_SHUTDOWN_TIMEOUT,
shutdownHttpClientInstrumentation: installHttpClientInstrumentation({
tracer,
spanKindClient: SpanKind.CLIENT,
spanStatusCodeError: SpanStatusCode.ERROR,
}),
// pg のrequire hookとioredis diagnostics channelは、Nest moduleの動的importより前に有効化する。
shutdownDatabaseInstrumentation: await installDatabaseInstrumentation(provider, {
capturePgSpans: config.capturePgSpans === true,
capturePgStatement: config.capturePgStatement === true,
capturePgConnectionSpans: config.capturePgConnectionSpans === true,
}),
shutdownRedisInstrumentation: installRedisInstrumentation(tracer, SpanKind.CLIENT, SpanStatusCode.ERROR, {
captureConnectionSpans: config.captureRedisConnectionSpans === true,
captureCommandSpans: config.captureRedisCommandSpans === true,
requireParentSpan: config.captureRedisRootSpans !== true,
}),
queueTraceContext: {
tracer,
propagation,
trace,
getActiveContext: () => context.active(),
rootContext: ROOT_CONTEXT,
mode: getQueueTraceContextMode(config.jobTraceContextMode),
spanStatusCodeError: SpanStatusCode.ERROR,
},
});
}
public captureMessage(message: string, _opts: TelemetryCaptureMessageOptions): void {
// captureMessageは例外通知APIなので、OTelでは対象spanにエラー状態を付ける。
// アクティブspanが無い場合(例: BullMQのjob処理が既に完了しspanが閉じた後の'failed'イベント)でも
// 通知を握り潰さないよう、報告専用の短命spanを作ってそこに記録する。
const span = this.deps.getActiveSpan();
if (span != null) {
recordSpanError(span, new Error(message), this.deps.spanStatusCodeError);
return;
}
this.deps.tracer.startActiveSpan('captureMessage', reportSpan => {
recordSpanError(reportSpan, new Error(message), this.deps.spanStatusCodeError);
reportSpan.end();
});
}
public startSpan<T>(name: string, fn: () => T): T {
// 既存のTelemetryAdapter契約に合わせ、同期/非同期どちらでも同じspan lifetimeを保証する。
return this.deps.tracer.startActiveSpan(name, span => executeSpan(span, fn, this.deps.spanStatusCodeError));
}
public injectTraceContext(carrier: QueueTraceContextCarrier): void {
const queueTraceContext = this.deps.queueTraceContext;
// Queue context 用の依存は任意なので、無い場合はジョブデータを変更しない。
if (queueTraceContext == null) return;
injectActiveTraceContext(queueTraceContext, carrier);
}
public startSpanWithTraceContext<T>(name: string, jobData: object, fn: () => T): T {
const queueTraceContext = this.deps.queueTraceContext;
// Queue context 用の依存が無い場合は、従来の span 作成経路と同じ動作を保つ。
if (queueTraceContext == null) return this.startSpan(name, fn);
return startSpanWithQueueTraceContext(queueTraceContext, name, jobData, fn, () => this.startSpan(name, fn));
}
public async shutdown(): Promise<void> {
this.deps.shutdownHttpClientInstrumentation?.();
this.deps.shutdownDatabaseInstrumentation?.();
this.deps.shutdownRedisInstrumentation?.();
// BatchSpanProcessorのflushが詰まってもプロセス終了を妨げないよう、上限時間を設ける。
// タイムアウト側のtimerは、flushが先に終わった場合にイベントループを無駄に引き留めないようclearする。
let timer: NodeJS.Timeout | undefined;
await Promise.race([
this.deps.provider.shutdown(),
new Promise<void>(resolve => {
timer = setTimeout(resolve, this.deps.shutdownTimeout);
}),
]).finally(() => {
if (timer != null) clearTimeout(timer);
});
}
}
export function createResource(config: OtelBackendRuntimeConfig, deps: CreateResourceDeps): Resource {
// resourceを明示指定するとSDKのdefaultResource()は自動付与されなくなる(マージではなく上書き)ため、
// telemetry.sdk.*等の標準属性を失わないよう明示的にmergeする。
const misskeyDefaultResource = deps.resourceFromAttributes({
[deps.serviceNameAttribute]: 'misskey-backend',
[deps.serviceInstanceIdAttribute]: `${os.hostname()}:${process.pid}`,
[deps.serviceVersionAttribute]: deps.serviceVersion,
'misskey.process.role': getMisskeyProcessRole(),
});
// OTel標準の OTEL_SERVICE_NAME / OTEL_RESOURCE_ATTRIBUTES を尊重する。
// mergeは右辺が優先されるため、config.resourceAttributesを最優先にする。
return deps.defaultResource()
.merge(misskeyDefaultResource)
.merge(deps.detectResources({ detectors: [deps.envDetector] }))
.merge(deps.resourceFromAttributes(config.resourceAttributes ?? {}));
}
export function createSampler(sampleRate: number, deps: CreateSamplerDeps): ParentBasedSampler {
// 設定ミスを無言でAlwaysOn/AlwaysOffに倒さず、起動時に明確に失敗させる。
// (YAMLでクォートされた数値文字列などnumber型の保証が無い値が来てもここで弾く)
if (typeof sampleRate !== 'number' || !Number.isFinite(sampleRate) || sampleRate < 0 || sampleRate > 1) {
throw new Error('otelForBackend.sampleRate must be a number between 0.0 and 1.0.');
}
return new deps.ParentBasedSampler({
root: new deps.TraceIdRatioBasedSampler(sampleRate),
});
}
export function getMisskeyProcessRole(): string {
// Trace backend上でserver/queue/workerを見分けられるよう、Misskey固有の役割をresourceに載せる。
if (envOption.disableClustering) {
if (envOption.onlyServer) return 'primary-server';
if (envOption.onlyQueue) return 'primary-queue';
return 'primary-server+queue';
}
if (cluster.isPrimary) {
if (envOption.onlyServer) return 'fork-only';
if (envOption.onlyQueue) return 'primary-queue';
return 'primary-server';
}
// worker.tsのworkerMainに合わせる: onlyServerならserver()、それ以外はjobQueue()を実行する。
if (envOption.onlyServer) return 'worker-server';
return 'worker-queue';
}

View File

@@ -3,14 +3,23 @@
* SPDX-License-Identifier: AGPL-3.0-only
*/
import type { Config } from '@/config.js';
import type { TelemetryAdapter, TelemetryCaptureMessageOptions } from './TelemetryAdapter.js';
import Logger from '@/logger.js';
import { registerDiagLogger } from '@/core/telemetry/telemetry-diag.js';
import { getQueueTraceContextMode, injectActiveTraceContext, startSpanWithQueueTraceContext } from '@/core/telemetry/queue-trace-context.js';
import type * as SentryNode from '@sentry/node';
import type { NodeOptions } from '@sentry/node';
import type { OtelBackendRuntimeConfig, SentryBackendConfig, TelemetryAdapter, TelemetryCaptureMessageOptions } from './TelemetryAdapter.js';
import type { QueueTraceContextCarrier, QueueTraceContextDeps } from '../queue-trace-context.js';
type SentryIntegrationsOption = NonNullable<import('@sentry/node').NodeOptions['integrations']>;
// OpenTelemetryAdapterのDEFAULT_SHUTDOWN_TIMEOUTと揃え、Sentryのtransportが詰まってもプロセス終了を妨げないようにする。
const DEFAULT_SHUTDOWN_TIMEOUT = 5000;
const logger = new Logger('telemetry', 'green');
type SentryIntegrationsOption = NonNullable<NodeOptions['integrations']>;
// eslint-disable-next-line @typescript-eslint/no-explicit-any
type SentryIntegrationFactory = Extract<SentryIntegrationsOption, (integrations: any[]) => any[]>;
type SentryIntegration = Parameters<SentryIntegrationFactory>[0][number];
type SentryNodeOptions = import('@sentry/node').NodeOptions;
type SentryNodeOptions = NodeOptions;
type BuildSentryIntegrationsOptions = {
disabledIntegrations?: string[];
@@ -37,7 +46,7 @@ export function buildSentryIntegrations(options: BuildSentryIntegrationsOptions)
}
export function buildSentryNodeOptions(
config: NonNullable<Config['sentryForBackend']>,
config: SentryBackendConfig,
nodeProfilingIntegration?: () => SentryIntegration,
): SentryNodeOptions {
return {
@@ -63,13 +72,54 @@ export function buildSentryNodeOptions(
};
}
type BuildSentryOtlpInitOptions = {
sentryConfig: SentryBackendConfig;
otelConfig: OtelBackendRuntimeConfig;
otlpProcessor: unknown;
nodeProfilingIntegration?: () => SentryIntegration;
warn?: (message: string) => void;
};
export function buildSentryOtlpInitOptions(options: BuildSentryOtlpInitOptions): SentryNodeOptions {
// OTel併存時も、remoteへtrace headerを漏らさないデフォルトはSentry単体時と揃える。
// propagateTraceToRemote: true か、options.tracePropagationTargets の明示指定がある場合のみ既定を上書きする。
const { tracePropagationTargets, ...sentryOptions } = options.sentryConfig.options;
const propagateTraceToRemote = options.otelConfig.propagateTraceToRemote === true || tracePropagationTargets != null;
const warn = options.warn ?? ((message: string) => logger.warn(message));
if (options.otelConfig.sampleRate != null) {
warn('otelForBackend.sampleRate is ignored when sentryForBackend is also configured; configure sentryForBackend.options.tracesSampleRate or tracesSampler instead.');
}
if (options.otelConfig.resourceAttributes != null) {
warn('otelForBackend.resourceAttributes is ignored when sentryForBackend is also configured; configure OTEL_RESOURCE_ATTRIBUTES instead.');
}
return {
...buildSentryNodeOptions({
...options.sentryConfig,
options: {
...sentryOptions,
...(propagateTraceToRemote ? { tracePropagationTargets } : {}),
},
}, options.nodeProfilingIntegration),
// Sentryの単一TracerProviderにOTLP processorを追加し、親欠損や二重providerを避ける。
openTelemetrySpanProcessors: [
...(options.sentryConfig.options.openTelemetrySpanProcessors ?? []),
options.otlpProcessor as NonNullable<SentryNodeOptions['openTelemetrySpanProcessors']>[number],
],
};
}
export class SentryTelemetryAdapter implements TelemetryAdapter {
private constructor(
private readonly Sentry: typeof import('@sentry/node'),
private readonly Sentry: typeof SentryNode,
private readonly queueTraceContext?: QueueTraceContextDeps,
) {
}
public static async create(config: NonNullable<Config['sentryForBackend']>): Promise<SentryTelemetryAdapter> {
public static async create(config: SentryBackendConfig): Promise<SentryTelemetryAdapter> {
const Sentry = await import('@sentry/node');
const { nodeProfilingIntegration } = await import('@sentry/profiling-node');
@@ -78,6 +128,45 @@ export class SentryTelemetryAdapter implements TelemetryAdapter {
return new SentryTelemetryAdapter(Sentry);
}
public static async createWithOtlpExport(
sentryConfig: SentryBackendConfig,
otelConfig: OtelBackendRuntimeConfig,
): Promise<SentryTelemetryAdapter> {
const Sentry = await import('@sentry/node');
const { nodeProfilingIntegration } = await import('@sentry/profiling-node');
const { context, diag, DiagLogLevel, propagation, ROOT_CONTEXT, SpanStatusCode, trace } = await import('@opentelemetry/api');
const { BatchSpanProcessor } = await import('@opentelemetry/sdk-trace-base');
const { OTLPTraceExporter } = await import('@opentelemetry/exporter-trace-otlp-proto');
registerDiagLogger(diag, DiagLogLevel.WARN);
// OTLP送信だけを担うprocessorを作り、provider生成はSentry.init側に任せる。
const otlpProcessor = new BatchSpanProcessor(new OTLPTraceExporter({
...(otelConfig.endpoint != null ? { url: otelConfig.endpoint } : {}),
...(otelConfig.headers != null ? { headers: otelConfig.headers } : {}),
}));
// SentryとOTLPを同一providerに集約することで、どちらの宛先にも同じspan実体を流す。
Sentry.init(buildSentryOtlpInitOptions({
sentryConfig,
otelConfig,
otlpProcessor,
nodeProfilingIntegration,
}));
// Sentry が初期化した同じ OTel provider から tracer/context API を受け取り、
// Queue を跨ぐ context 伝播も Sentry と OTLP の両方へ同一 span として出力する。
return new SentryTelemetryAdapter(Sentry, {
tracer: trace.getTracer('misskey-backend'),
propagation,
trace,
getActiveContext: () => context.active(),
rootContext: ROOT_CONTEXT,
mode: getQueueTraceContextMode(otelConfig.jobTraceContextMode),
spanStatusCodeError: SpanStatusCode.ERROR,
});
}
public captureMessage(message: string, opts: TelemetryCaptureMessageOptions): void {
this.Sentry.captureMessage(message, {
level: opts.level,
@@ -90,7 +179,21 @@ export class SentryTelemetryAdapter implements TelemetryAdapter {
return this.Sentry.startSpan({ name }, fn);
}
public injectTraceContext(carrier: QueueTraceContextCarrier): void {
// Sentry 単体構成では queueTraceContext を持たず、従来どおりジョブデータを変更しない。
if (this.queueTraceContext == null) return;
injectActiveTraceContext(this.queueTraceContext, carrier);
}
public startSpanWithTraceContext<T>(name: string, jobData: object, fn: () => T): T {
// Sentry 単体構成では Sentry 既存の span 作成経路を使う。
if (this.queueTraceContext == null) return this.startSpan(name, fn);
return startSpanWithQueueTraceContext(this.queueTraceContext, name, jobData, fn, () => this.startSpan(name, fn));
}
public async shutdown(): Promise<void> {
await this.Sentry.close();
// timeout未指定だとtransportのflushが詰まった際にプロセス終了を妨げるため、上限時間を設ける。
await this.Sentry.close(DEFAULT_SHUTDOWN_TIMEOUT);
}
}

View File

@@ -3,9 +3,23 @@
* SPDX-License-Identifier: AGPL-3.0-only
*/
import type { Config } from '@/config.js';
import type { QueueTraceContextCarrier } from '../queue-trace-context.js';
export type SentryBackendConfig = NonNullable<Config['sentryForBackend']>;
export type OtelBackendConfig = NonNullable<Config['otelForBackend']>;
export type OtelBackendRuntimeConfig = OtelBackendConfig & {
serviceVersion: string;
};
export interface TelemetryCaptureMessageOptions {
/** 現在はエラー通知用途だけに絞る。追加する場合は各adapterでの扱いを揃えること。 */
level: 'error';
/** Sentryではuser.idへ渡す。OTel adapterは現在span属性へ付与していないため、必要ならadapter側で拡張する。 */
userId?: string;
/** queue名やendpoint名など、通知先で調査に使う補助情報。 */
extra?: Record<string, unknown>;
}
@@ -15,7 +29,33 @@ export interface TelemetryCaptureMessageOptions {
* telemetry-registry.tsのinitTelemetry内で登録する。
*/
export interface TelemetryAdapter {
/**
* 実行中の処理で起きたエラー相当の事象を記録する。
* Sentryはmessage通知、OTelはactive spanまたは短命spanへの例外記録として扱う。
*/
captureMessage(message: string, opts: TelemetryCaptureMessageOptions): void;
/**
* API endpointやqueue jobなど、呼び出し側の処理単位をspanで包む。
* fnの戻り値・例外はそのまま呼び出し側へ返し、Promiseの場合はsettleまでspanを閉じない。
*/
startSpan<T>(name: string, fn: () => T): T;
/**
* BullMQ のジョブデータへ保存する carrier に、active trace context を注入する。
* OTel を使わない adapter は実装しない。
*/
injectTraceContext?(carrier: QueueTraceContextCarrier): void;
/**
* ジョブに保存された enqueue 元の context を、worker span の Link または parent として復元する。
* context を持たないジョブの互換性は adapter 側で保つ。
*/
startSpanWithTraceContext?<T>(name: string, jobData: object, fn: () => T): T;
/**
* プロセス終了時にtelemetry backendへ残りのデータをflushする。
* 実装側ではtransport停止に引きずられないよう、待機時間に上限を設ける。
*/
shutdown(): Promise<void>;
}

View File

@@ -0,0 +1,86 @@
/*
* SPDX-FileCopyrightText: syuilo and misskey-project
* SPDX-License-Identifier: AGPL-3.0-only
*/
import type { Span, TracerProvider } from '@opentelemetry/api';
type Instrumentation = {
setTracerProvider(provider: TracerProvider): void;
enable(): void;
disable(): void;
};
type PgInstrumentationConfig = {
enhancedDatabaseReporting: boolean;
requireParentSpan: boolean;
ignoreConnectSpans: boolean;
requestHook?(span: Span): void;
};
type InstrumentationConstructor = new (config: PgInstrumentationConfig) => Instrumentation;
type InstrumentationDeps = {
PgInstrumentation: InstrumentationConstructor;
};
type DatabaseInstrumentationOptions = {
capturePgStatement: boolean;
capturePgConnectionSpans: boolean;
};
type InstallDatabaseInstrumentationOptions = DatabaseInstrumentationOptions & {
capturePgSpans: boolean;
};
/**
* pg はアプリケーションが import する前に有効化しないと、require hook 型の
* 自動計装がモジュールを patch できない。そのため、
* telemetry provider の登録直後にこの関数を呼び出す。
*/
export async function installDatabaseInstrumentation(provider: TracerProvider, options: InstallDatabaseInstrumentationOptions): Promise<() => void> {
if (!options.capturePgSpans) return () => {};
const { PgInstrumentation } = await import('@opentelemetry/instrumentation-pg');
return installInstrumentation(provider, { PgInstrumentation }, options);
}
export function installInstrumentation(provider: TracerProvider, deps: InstrumentationDeps, options: DatabaseInstrumentationOptions = {
capturePgStatement: false,
capturePgConnectionSpans: false,
}): () => void {
const instrumentations = [
new deps.PgInstrumentation({
// SQLパラメータには投稿内容・認証情報などが含まれ得るため、常に記録しない。
enhancedDatabaseReporting: false,
requireParentSpan: true,
ignoreConnectSpans: !options.capturePgConnectionSpans,
// instrumentation-pgはSQL本文を無加工で属性へ追加する。明示opt-in時だけ残す。
...(options.capturePgStatement ? {} : {
requestHook: (span: Span) => {
span.setAttribute('db.statement', '[REDACTED]');
span.setAttribute('db.query.text', '[REDACTED]');
},
}),
}),
];
try {
for (const instrumentation of instrumentations) {
instrumentation.setTracerProvider(provider);
instrumentation.enable();
}
} catch (error) {
for (const instrumentation of instrumentations) {
instrumentation.disable();
}
throw error;
}
return () => {
for (const instrumentation of instrumentations) {
instrumentation.disable();
}
};
}

View File

@@ -0,0 +1,122 @@
/*
* SPDX-FileCopyrightText: syuilo and misskey-project
* SPDX-License-Identifier: AGPL-3.0-only
*/
import { channel } from 'node:diagnostics_channel';
import type { ClientRequest, IncomingMessage } from 'node:http';
import type { Span, SpanOptions, SpanStatusCode, Tracer } from '@opentelemetry/api';
const HTTP_CLIENT_REQUEST_CREATED = 'http.client.request.created';
const HTTP_CLIENT_RESPONSE_FINISH = 'http.client.response.finish';
const HTTP_CLIENT_REQUEST_ERROR = 'http.client.request.error';
type HttpClientSpan = Pick<Span, 'end' | 'recordException' | 'setAttribute' | 'setStatus'>;
type HttpClientInstrumentationDeps = {
tracer: Pick<Tracer, 'startSpan'>;
spanKindClient: SpanOptions['kind'];
spanStatusCodeError: SpanStatusCode;
subscribe: (name: string, listener: (message: unknown) => void) => () => void;
};
type RequestCreatedMessage = { request: ClientRequest };
type ResponseFinishMessage = { request: ClientRequest; response: IncomingMessage };
type RequestErrorMessage = { request: ClientRequest; error: Error };
/**
* require フックを使わず、Node.js 組み込み HTTP クライアントの diagnostics channel を計装する。
* telemetry 初期化前に読み込まれたモジュールも対象になる。
*/
export function createHttpClientInstrumentation(deps: HttpClientInstrumentationDeps): () => void {
const spans = new WeakMap<ClientRequest, HttpClientSpan>();
const unsubscribeCreated = deps.subscribe(HTTP_CLIENT_REQUEST_CREATED, (message: unknown) => {
const { request } = message as RequestCreatedMessage;
const { url, host, port } = getRequestDetails(request);
const method = request.method ?? 'GET';
const span = deps.tracer.startSpan(method, {
kind: deps.spanKindClient,
attributes: {
'http.request.method': method,
'url.full': url,
'server.address': host,
'server.port': port,
},
});
spans.set(request, span);
});
const unsubscribeResponseFinish = deps.subscribe(HTTP_CLIENT_RESPONSE_FINISH, (message: unknown) => {
const { request, response } = message as ResponseFinishMessage;
const span = spans.get(request);
if (span == null) return;
const statusCode = response.statusCode;
if (statusCode != null) {
span.setAttribute('http.response.status_code', statusCode);
}
if (response.httpVersion != null) {
span.setAttribute('network.protocol.version', response.httpVersion);
}
if (statusCode != null && statusCode >= 400) {
span.setAttribute('error.type', String(statusCode));
span.setStatus({ code: deps.spanStatusCodeError });
}
span.end();
spans.delete(request);
});
const unsubscribeRequestError = deps.subscribe(HTTP_CLIENT_REQUEST_ERROR, (message: unknown) => {
const { request, error } = message as RequestErrorMessage;
const span = spans.get(request);
if (span == null) return;
span.recordException(error);
span.setAttribute('error.type', getErrorType(error));
span.setStatus({ code: deps.spanStatusCodeError });
span.end();
spans.delete(request);
});
return () => {
unsubscribeCreated();
unsubscribeResponseFinish();
unsubscribeRequestError();
};
}
export function installHttpClientInstrumentation(deps: Omit<HttpClientInstrumentationDeps, 'subscribe'>): () => void {
return createHttpClientInstrumentation({
...deps,
subscribe: (name, listener) => {
const diagnosticChannel = channel(name);
diagnosticChannel.subscribe(listener);
return () => diagnosticChannel.unsubscribe(listener);
},
});
}
function getRequestDetails(request: ClientRequest): { url: string; host: string; port: number } {
const protocol = request.protocol ?? 'http:';
const host = request.getHeader('host')?.toString() ?? request.host ?? 'localhost';
const url = new URL(request.path || '/', `${protocol}//${host}`);
// URL 属性には認証情報やクエリ文字列を含めない。
url.username = '';
url.password = '';
url.search = '';
url.hash = '';
return {
url: url.toString(),
host: url.hostname,
// URL.port は既定ポートでは空文字列になるため、スキームから補う。
port: url.port === '' ? (url.protocol === 'https:' ? 443 : 80) : Number(url.port),
};
}
function getErrorType(error: Error): string {
// Node.js の system error code は安定した低カーディナリティの識別子になる。
const code = (error as NodeJS.ErrnoException).code;
return code ?? error.name;
}

View File

@@ -0,0 +1,33 @@
/*
* SPDX-FileCopyrightText: syuilo and misskey-project
* SPDX-License-Identifier: AGPL-3.0-only
*/
import { injectTraceContext } from './telemetry-registry.js';
import { injectQueueTraceContext } from './queue-trace-context.js';
import type * as Bull from 'bullmq';
/**
* Queue の add/addBulk をラップし、全ての BullMQ enqueue 経路を一箇所で捕捉する。
* QueueService を通さず直接 add/addBulk する呼び出し元もあるため、それぞれで注入すると漏れやすい。
*/
export function instrumentQueue<T extends object>(queue: Bull.Queue<T>): Bull.Queue<T> {
// BullMQ のメソッドは Queue インスタンスを this として使うため、差し替え前に bind して保持する。
const add = queue.add.bind(queue);
queue.add = ((name, data, opts) => {
// BullMQ が data を Redis 用にシリアライズする前に、enqueue 元の context を内部フィールドへ追加する。
injectQueueTraceContext(data, injectTraceContext);
return add(name, data, opts);
}) as typeof queue.add;
// addBulk は複数ジョブを一度にシリアライズするので、各 data へ同じ context を注入する。
const addBulk = queue.addBulk.bind(queue);
queue.addBulk = ((jobs) => {
for (const job of jobs) {
injectQueueTraceContext(job.data, injectTraceContext);
}
return addBulk(jobs);
}) as typeof queue.addBulk;
return queue;
}

View File

@@ -0,0 +1,182 @@
/*
* SPDX-FileCopyrightText: syuilo and misskey-project
* SPDX-License-Identifier: AGPL-3.0-only
*/
import type { Context, PropagationAPI, Span, SpanContext, SpanOptions, SpanStatusCode, Tracer } from '@opentelemetry/api';
/*
* enqueue (push) から worker による取得・処理 (pop) までをテレメトリ上で関連付け、
* Queue を挟む非同期処理を一連の流れとして追跡できるようにする。
* そのため、trace context を次の流れで伝播する:
*
* 1. producer 側で active context を W3C Trace Context 形式の carrier へ注入する。
* 2. carrier をジョブデータの内部フィールドに保存し、BullMQ/Redis 経由で worker へ渡す。
* 3. worker 側で carrier を context へ戻し、設定に応じて Link または parent に使う。
*
* OTel API への依存を引数にしているのは、OTel 単体構成と Sentry + OTLP 構成で
* 同じ伝播処理を使うため。
*/
/** Redis 上のジョブデータにだけ保存する、OpenTelemetry propagator 用の carrier。 */
export type QueueTraceContextCarrier = Record<string, string>;
export type QueueTraceContextMode = 'link' | 'parent';
// 通常のジョブプロセッサが参照しない Misskey 内部フィールドとして、ユーザー定義の data と区別する。
const QUEUE_TRACE_CONTEXT_KEY = '__misskeyTraceContext';
export type QueueTraceContextDeps = {
tracer: Pick<Tracer, 'startActiveSpan'>;
propagation: Pick<PropagationAPI, 'extract' | 'inject'>;
trace: {
getSpanContext(context: Context): SpanContext | undefined;
};
getActiveContext: () => Context;
rootContext: Context;
mode: QueueTraceContextMode;
spanStatusCodeError: SpanStatusCode;
};
export type QueueSpanContext = {
options: SpanOptions;
parentContext: Context;
};
type QueueSpanContextDeps = Pick<QueueTraceContextDeps, 'propagation' | 'trace' | 'rootContext' | 'mode'>;
/**
* enqueue 元の active context を、ジョブ本来のデータを壊さない内部フィールドとして保持する。
* propagator が何も注入しなかった場合は、Redis に不要な空オブジェクトを残さない。
*/
export function injectQueueTraceContext(data: unknown, inject: (carrier: QueueTraceContextCarrier) => void): void {
// BullMQ の型定義外から渡される値も考慮し、書き込めない値は無視する。
if (data == null || typeof data !== 'object') return;
const carrier: QueueTraceContextCarrier = {};
inject(carrier);
// active span が無い場合、propagator は何も注入しない。空の内部フィールドは Redis に保存しない。
if (Object.keys(carrier).length === 0) return;
Object.assign(data, { [QUEUE_TRACE_CONTEXT_KEY]: carrier });
}
/** 現在実行中の span を propagator の標準形式で carrier へ書き出す。 */
export function injectActiveTraceContext(deps: QueueTraceContextDeps, carrier: QueueTraceContextCarrier): void {
deps.propagation.inject(deps.getActiveContext(), carrier);
}
/**
* ジョブに保存された carrier から、worker span の開始に必要な context と options を組み立てる。
*
* - parent: enqueue span の子として同じ trace を継続する。
* - link: worker span を別の root trace にし、enqueue span への関連だけを Link に残す。
*
* link モードで trace を分けることで、worker 側の sampling 判定を enqueue 側から独立させられる。
*/
export function getQueueSpanContext(data: unknown, deps: QueueSpanContextDeps): QueueSpanContext | undefined {
const carrier = getQueueTraceContextCarrier(data);
if (carrier == null) return undefined;
// Redis から復元した carrier は別プロセス由来なので、現在の context ではなく ROOT_CONTEXT から展開する。
const extractedContext = deps.propagation.extract(deps.rootContext, carrier);
if (deps.mode === 'parent') {
return {
options: {},
parentContext: extractedContext,
};
}
// Link が受け取るのは Context ではなく SpanContext なので、extract 後に取り出す。
const spanContext = deps.trace.getSpanContext(extractedContext);
return {
options: {
root: true,
...(spanContext != null ? { links: [{ context: spanContext }] } : {}),
},
parentContext: deps.rootContext,
};
}
/**
* context を持つジョブは Link/parent の規則で span を開始する。
* デプロイ前に enqueue されたジョブなど、context を持たない場合は既存の adapter 固有実装へ委ねる。
*/
export function startSpanWithQueueTraceContext<T>(
deps: QueueTraceContextDeps,
name: string,
jobData: object,
fn: () => T,
fallback: () => T,
): T {
const spanContext = getQueueSpanContext(jobData, deps);
if (spanContext == null) return fallback();
return deps.tracer.startActiveSpan(name, spanContext.options, spanContext.parentContext, span => executeSpan(span, fn, deps.spanStatusCodeError));
}
/**
* 既存の TelemetryAdapter 契約に合わせ、同期値と Promise のどちらを返す処理も span で包む。
* 成功・失敗のどちらでも処理完了まで span を開き、必ず一度だけ閉じる。
*/
export function executeSpan<T>(span: Span, fn: () => T, spanStatusCodeError: SpanStatusCode): T {
try {
const result = fn();
if (isPromiseLike(result)) {
// fn() の戻り値は T のまま保ちつつ、Promise の settle 時に span を閉じる。
return result.then(
value => {
span.end();
return value;
},
error => {
recordSpanError(span, error, spanStatusCodeError);
span.end();
throw error;
},
) as T;
}
span.end();
return result;
} catch (error) {
// fn() が同期的に throw した場合も、Promise の reject と同じ形で記録して呼び出し元へ戻す。
recordSpanError(span, error, spanStatusCodeError);
span.end();
throw error;
}
}
/** Error 以外の throw 値も OTel exporter が扱える例外に正規化し、span をエラー状態にする。 */
export function recordSpanError(span: Span, error: unknown, spanStatusCodeError: SpanStatusCode): void {
const exception = error instanceof Error ? error : new Error(String(error));
span.recordException(exception);
span.setStatus({
code: spanStatusCodeError,
message: exception.message,
});
}
/**
* 未設定時は、enqueue と worker の sampling を分離できる link を使う。
* 設定ミスを無言でフォールバックさせないため、未知の値は起動時にエラーにする。
*/
export function getQueueTraceContextMode(mode: unknown): QueueTraceContextMode {
if (mode == null || mode === 'link') return 'link';
if (mode === 'parent') return 'parent';
throw new Error('otelForBackend.jobTraceContextMode must be either \'link\' or \'parent\'.');
}
function getQueueTraceContextCarrier(data: unknown): QueueTraceContextCarrier | undefined {
if (data == null || typeof data !== 'object') return undefined;
const carrier = (data as Record<string, unknown>)[QUEUE_TRACE_CONTEXT_KEY];
if (carrier == null || typeof carrier !== 'object' || Array.isArray(carrier)) return undefined;
// Redis 上の旧いジョブや壊れたデータで worker を落とさないよう、propagator に渡せる string map だけを受け入れる。
const entries = Object.entries(carrier);
if (entries.length === 0 || entries.some(([, value]) => typeof value !== 'string')) return undefined;
return Object.fromEntries(entries) as QueueTraceContextCarrier;
}
function isPromiseLike<T>(value: T): value is T & PromiseLike<Awaited<T>> {
// native Promise に限らず thenable も span の完了を待てるよう、instanceof ではなく then の有無で判定する。
return value != null && typeof (value as { then?: unknown }).then === 'function';
}

View File

@@ -0,0 +1,178 @@
/*
* SPDX-FileCopyrightText: syuilo and misskey-project
* SPDX-License-Identifier: AGPL-3.0-only
*/
import { tracingChannel } from 'node:diagnostics_channel';
import { context, trace } from '@opentelemetry/api';
import type { Span, SpanKind, SpanStatusCode, Tracer } from '@opentelemetry/api';
type IORedisCommandContext = {
command: string;
args: string[];
database: number;
serverAddress: string;
serverPort: number | undefined;
};
type IORedisConnectContext = {
serverAddress: string;
serverPort: number | undefined;
};
type TracingChannelSubscribers<T extends object> = {
start(message: T): void;
end(message: T & { error?: unknown }): void;
asyncStart(message: T & { error?: unknown }): void;
asyncEnd(message: T & { error?: unknown }): void;
error(message: T & { error: unknown }): void;
};
type TracingChannel<T extends object> = {
subscribe(subscribers: TracingChannelSubscribers<T>): void;
unsubscribe(subscribers: TracingChannelSubscribers<T>): void;
};
type RedisInstrumentationDeps = {
tracingChannel<T extends object>(name: string): TracingChannel<T>;
tracer: Pick<Tracer, 'startSpan'>;
getActiveSpan(): Span | undefined;
spanKindClient: SpanKind;
spanStatusCodeError: SpanStatusCode;
};
type RedisInstrumentationOptions = {
captureCommandSpans?: boolean;
/** requireParentSpan is the official ioredis instrumentation's default. */
requireParentSpan?: boolean;
captureConnectionSpans?: boolean;
};
/**
* ioredis 5.11以降が公開する native diagnostics channel を購読する。
* バンドル後もioredis自身が発行するイベントを使うため、require hook に
* 依存せず、ESM import 経路でもコマンド span を取得できる。
*/
export function installRedisInstrumentation(
tracer: Pick<Tracer, 'startSpan'>,
spanKindClient: SpanKind,
spanStatusCodeError: SpanStatusCode,
options: RedisInstrumentationOptions = {},
): () => void {
return createRedisInstrumentation({
tracingChannel,
tracer,
getActiveSpan: () => trace.getSpan(context.active()),
spanKindClient,
spanStatusCodeError,
}, options);
}
export function createRedisInstrumentation(deps: RedisInstrumentationDeps, options: RedisInstrumentationOptions = {}): () => void {
const requireParentSpan = options.requireParentSpan ?? true;
const cleanup: Array<() => void> = [];
if (options.captureCommandSpans === true) {
const commandChannel = deps.tracingChannel<IORedisCommandContext>('ioredis:command');
const commandSubscribers = createTracingChannelSubscribers(commandChannel, deps, requireParentSpan, message => ({
name: message.command,
attributes: {
'db.system.name': 'redis',
'db.namespace': message.database.toString(10),
'db.operation.name': message.command,
'server.address': message.serverAddress,
...(message.serverPort != null ? { 'server.port': message.serverPort } : {}),
},
}));
cleanup.push(() => commandChannel.unsubscribe(commandSubscribers));
}
if (options.captureConnectionSpans === true) {
const connectChannel = deps.tracingChannel<IORedisConnectContext>('ioredis:connect');
// Connection spans are explicitly opt-in and should include startup and reconnect attempts.
const connectSubscribers = createTracingChannelSubscribers(connectChannel, deps, false, message => ({
name: 'connect',
attributes: {
'db.system.name': 'redis',
'db.operation.name': 'connect',
'server.address': message.serverAddress,
...(message.serverPort != null ? { 'server.port': message.serverPort } : {}),
},
}));
cleanup.push(() => connectChannel.unsubscribe(connectSubscribers));
}
return () => {
for (const unsubscribe of cleanup) {
unsubscribe();
}
};
}
function createTracingChannelSubscribers<T extends object>(
channel: TracingChannel<T>,
deps: RedisInstrumentationDeps,
requireParentSpan: boolean,
getSpanOptions: (message: T) => { name: string; attributes: Record<string, string | number> },
): TracingChannelSubscribers<T> {
const spans = new WeakMap<object, { span: Span; recordedError: boolean }>();
const recordError = (state: { span: Span; recordedError: boolean }, value: unknown): void => {
if (state.recordedError) return;
const error = toError(value);
state.span.recordException(error);
state.span.setStatus({ code: deps.spanStatusCodeError, message: error.message });
state.span.setAttribute('error.type', getErrorType(error));
const statusCode = getRedisErrorStatusCode(error.message);
if (statusCode != null) state.span.setAttribute('db.response.status_code', statusCode);
state.recordedError = true;
};
const finish = (message: T & { error?: unknown }): void => {
const state = spans.get(message);
if (state == null) return;
if (message.error != null) {
recordError(state, message.error);
}
state.span.end();
spans.delete(message);
};
const subscribers: TracingChannelSubscribers<T> = {
start: (message) => {
if (requireParentSpan && deps.getActiveSpan() == null) return;
const options = getSpanOptions(message);
const span = deps.tracer.startSpan(options.name, {
kind: deps.spanKindClient,
attributes: options.attributes,
});
spans.set(message, { span, recordedError: false });
},
// Promiseを返すコマンドでは完了前にもendイベントが来るため、同期例外だけここで閉じる。
end: (message) => {
if (message.error != null) finish(message);
},
asyncStart: () => {},
asyncEnd: finish,
error: (message) => {
const state = spans.get(message);
if (state == null) return;
recordError(state, message.error);
},
};
channel.subscribe(subscribers);
return subscribers;
}
function toError(value: unknown): Error {
return value instanceof Error ? value : new Error(String(value));
}
function getRedisErrorStatusCode(message: string): string | undefined {
return message.match(/^([A-Z][A-Z0-9_]*)\b/)?.[1];
}
function getErrorType(error: Error): string {
return (error as NodeJS.ErrnoException).code ?? error.name;
}

View File

@@ -0,0 +1,40 @@
/*
* SPDX-FileCopyrightText: syuilo and misskey-project
* SPDX-License-Identifier: AGPL-3.0-only
*/
import Logger from '@/logger.js';
import type { DiagAPI, DiagLogger, DiagLogLevel } from '@opentelemetry/api';
export function registerDiagLogger(
diagApi: DiagAPI,
diagLogLevelWarn: DiagLogLevel,
): void {
// diagはプロセスグローバルなので、通常運用で必要なWARN以上だけをMisskeyのログに流す。
const logger = new Logger('otel', 'green');
const diagLogger: DiagLogger = {
error: (message, ...args) => logger.error(formatDiagMessage(message, args)),
warn: (message, ...args) => logger.warn(formatDiagMessage(message, args)),
info: (message, ...args) => logger.info(formatDiagMessage(message, args)),
debug: (message, ...args) => logger.debug(formatDiagMessage(message, args)),
verbose: (message, ...args) => logger.debug(formatDiagMessage(message, args)),
};
diagApi.setLogger(diagLogger, {
logLevel: diagLogLevelWarn,
suppressOverrideMessage: true,
});
}
function formatDiagMessage(message: string, args: unknown[]): string {
if (args.length === 0) return message;
return `${message} ${args.map(arg => {
if (arg instanceof Error) return arg.stack ?? arg.message;
if (typeof arg === 'string') return arg;
try {
return JSON.stringify(arg);
} catch {
return String(arg);
}
}).join(' ')}`;
}

View File

@@ -4,8 +4,10 @@
*/
import type { Config } from '@/config.js';
import { OpenTelemetryAdapter } from './adapters/OpenTelemetryAdapter.js';
import { SentryTelemetryAdapter } from './adapters/SentryTelemetryAdapter.js';
import type { TelemetryAdapter, TelemetryCaptureMessageOptions } from './adapters/TelemetryAdapter.js';
import type { OtelBackendRuntimeConfig, TelemetryAdapter, TelemetryCaptureMessageOptions } from './adapters/TelemetryAdapter.js';
import type { QueueTraceContextCarrier } from './queue-trace-context.js';
/**
* NestのDIコンテナが構築される前(boot処理内)で初期化する必要があるため、
@@ -15,18 +17,37 @@ import type { TelemetryAdapter, TelemetryCaptureMessageOptions } from './adapter
const adapters: TelemetryAdapter[] = [];
export async function initTelemetry(config: Config): Promise<void> {
if (config.sentryForBackend) {
const otelForBackend: OtelBackendRuntimeConfig | undefined = config.otelForBackend == null ? undefined : {
...config.otelForBackend,
serviceVersion: config.version,
};
// SentryとOTelを同時に使う場合はproviderを分けず、Sentry側へOTLP processorを追加する。
if (config.sentryForBackend && otelForBackend) {
adapters.push(await SentryTelemetryAdapter.createWithOtlpExport(config.sentryForBackend, otelForBackend));
} else if (config.sentryForBackend) {
// Sentry単体時は既存のSentry adapterだけを登録する。
adapters.push(await SentryTelemetryAdapter.create(config.sentryForBackend));
} else if (otelForBackend) {
// OTel単体時だけMisskey自身でNodeTracerProviderを立てる。
adapters.push(await OpenTelemetryAdapter.create(otelForBackend));
}
}
export function captureMessage(message: string, opts: TelemetryCaptureMessageOptions): void {
// 有効なadapterすべてへ通知し、宛先ごとの差異はadapter内に閉じ込める。
for (const adapter of adapters) {
adapter.captureMessage(message, opts);
}
}
export function startSpan<T>(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,
@@ -34,6 +55,20 @@ export function startSpan<T>(name: string, fn: () => T): T {
return wrapped();
}
export async function shutdownTelemetry(): Promise<void> {
await Promise.all(adapters.map(adapter => adapter.shutdown()));
export function injectTraceContext(carrier: QueueTraceContextCarrier): void {
// Queue の carrier は共有データなので、通知と異なり全 adapter にブロードキャストしない。
// OTel provider は現在 1 つだけなので、同じ header を上書きしないよう最初の対応 adapter だけを使う。
adapters.find(adapter => adapter.injectTraceContext != null)?.injectTraceContext?.(carrier);
}
export function startSpanWithTraceContext<T>(name: string, jobData: object, fn: () => T): T {
// Queue context を解釈できる adapter に span 作成を任せる。
// 対応 adapter が無い構成では通常の startSpan へフォールバックする。
const adapter = adapters.find(adapter => adapter.startSpanWithTraceContext != null);
return adapter?.startSpanWithTraceContext?.(name, jobData, fn) ?? startSpan(name, fn);
}
export async function shutdownTelemetry(): Promise<void> {
// 終了時は登録済みadapterを並列にflush/shutdownする。
await Promise.allSettled(adapters.map(adapter => adapter.shutdown()));
}

View File

@@ -0,0 +1,66 @@
/*
* SPDX-FileCopyrightText: syuilo and misskey-project
* SPDX-License-Identifier: AGPL-3.0-only
*/
import Logger from '@/logger.js';
import { shutdownTelemetry as defaultShutdownTelemetry } from './telemetry-registry.js';
const logger = new Logger('telemetry', 'green');
type SignalProcess = {
once(event: 'SIGTERM' | 'SIGINT', listener: () => Promise<void>): unknown;
};
type InstallTelemetrySignalHandlersOptions = {
process?: SignalProcess;
shutdownTelemetry?: () => Promise<void>;
exit?: (code: number) => void;
};
let shuttingDown = false;
export function installTelemetrySignalHandlers(options: InstallTelemetrySignalHandlersOptions = {}): void {
// テストではprocess/exitを差し替え、本番では実processにSIGTERM/SIGINT handlerを登録する。
//
// 注意: このプロジェクトでは app.enableShutdownHooks() が一切呼ばれていないため、
// NestJSのOnApplicationShutdown経由のgraceful shutdown(GlobalModule.dispose()によるDB/Redis切断、
// QueueProcessorService.stop()によるqueue drain、ServerService.dispose()によるfastify/WebSocket close)は
// 本PR以前から実質的に無効(SIGTERM/SIGINTを起点に発火することが無い)だった。
// このhandlerはリポジトリで初めて有効になる本番SIGTERM/SIGINT handlerであり、telemetryのflushのみを行い
// 上記のgraceful shutdown処理を経由せずに即exitする。将来enableShutdownHooks()を配線する場合は、
// この即exitとNestJS側のshutdown sequenceが競合しないよう順序を設計すること。
const processLike = options.process ?? process;
const shutdownTelemetry = options.shutdownTelemetry ?? defaultShutdownTelemetry;
const exit = options.exit ?? ((code: number) => process.exit(code));
const handleSignal = async () => {
// 同時に複数signalが来てもflushを二重実行せず、cluster refork抑止用の状態もここで立てる。
if (shuttingDown) return;
shuttingDown = true;
try {
// 終了直前にBatchSpanProcessor/Sentryのbufferをflushする。
// (DB/Redis/queue/HTTPサーバーのgraceful closeはここでは行わない。上記の注意を参照。)
await shutdownTelemetry();
} catch {
// telemetry flushの失敗でプロセス終了が止まらないよう、ここでは握り潰す。
} finally {
// 既存挙動と同じく、telemetry flush後はプロセスを終了する。
exit(0);
}
};
// onceにして、同じsignalでhandlerが再入しないようにする。
processLike.once('SIGTERM', handleSignal);
processLike.once('SIGINT', handleSignal);
// app.enableShutdownHooks()未配線の現状、SIGTERM/SIGINT時の処理はtelemetry flushのみであることを
// 起動時に明示し、DB/Redis/queue/HTTPサーバーのgraceful closeが行われない点を運用者が把握できるようにする。
logger.info('Registered SIGTERM/SIGINT handler for telemetry flush (this process does not perform NestJS graceful shutdown on these signals).');
}
export function isTelemetryShutdownInProgress(): boolean {
// masterのcluster exit handlerが、意図したshutdown中のworker終了を再forkしないために参照する。
return shuttingDown;
}

View File

@@ -3,83 +3,72 @@
* 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 { 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を提供する薄い窓口です。
* 出力条件の判断や整形は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<string, any> | 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;
let log = `${l} ${worker}\t[${contexts.join(' ')}]\t${m}`;
if (envOption.withLogTime) log = chalk.gray(time) + ' ' + log;
const args: unknown[] = [important ? chalk.bold(log) : log];
if (data != null) {
args.push(data);
}
console.log(...args);
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,
},
});
}
/** 構造化ログをLoggerのcontext付きでLogManagerへ渡します。 */
@bindThis
public error(x: string | Error, data?: Record<string, any> | null, important = false): void { // 実行を継続できない状況で使う
public write(input: LogWriteInput): void {
logManager.write({
...input,
context: this.context,
});
}
/** 処理を継続できない状況を記録します。 */
@bindThis
public error(x: string | Error, data?: Record<string, any> | null, important = false): void {
if (x instanceof Error) {
// エラー本体も第2引数へ残し、従来どおりスタックなどを確認できるようにします。
data = data ?? {};
data.e = x;
this.log('error', x.toString(), data, important);
this.log('error', x.toString(), data, important, undefined, x);
} else if (typeof x === 'object') {
this.log('error', `${(x as any).message ?? (x as any).name ?? x}`, data, important);
} else {
@@ -87,25 +76,27 @@ export default class Logger {
}
}
/** 処理は継続できるものの、改善が必要な状況を記録します。 */
@bindThis
public warn(message: string, data?: Record<string, any> | null, important = false): void { // 実行を継続できるが改善すべき状況で使う
this.log('warning', message, data, important);
public warn(message: string, data?: Record<string, any> | null, important = false): void {
this.log('warn', message, data, important);
}
/** 処理が成功したことを、従来のDONE表示で記録します。 */
@bindThis
public succ(message: string, data?: Record<string, any> | null, important = false): void { // 何かに成功した状況で使う
this.log('success', message, data, important);
public succ(message: string, data?: Record<string, any> | null, important = false): void {
this.log('info', message, data, important, 'success');
}
/** 開発者向けの調査情報を記録します。 */
@bindThis
public debug(message: string, data?: Record<string, any> | null, important = false): void { // デバッグ用に使う(開発者に必要だが利用者に不要な情報)
if (process.env.NODE_ENV !== 'production' || envOption.verbose) {
this.log('debug', message, data, important);
}
public debug(message: string, data?: Record<string, any> | null, important = false): void {
this.log('debug', message, data, important);
}
/** 通常の動作状況を記録します。 */
@bindThis
public info(message: string, data?: Record<string, any> | null, important = false): void { // それ以外
public info(message: string, data?: Record<string, any> | null, important = false): void {
this.log('info', message, data, important);
}
}

View File

@@ -0,0 +1,21 @@
/*
* SPDX-FileCopyrightText: syuilo and misskey-project
* SPDX-License-Identifier: AGPL-3.0-only
*/
import type { LogRecord } from './types.js';
/**
* 整形済みのログを実際の出力先へ渡すための共通窓口です。
* Loggerを特定の出力形式へ依存させず、後から出力先を追加できるようにします。
*/
export interface LogBackend {
/** ログを一件出力します。 */
write(record: LogRecord): void;
/** 保留中の出力がある場合に、すべて書き出します。 */
flush?(): void | Promise<void>;
/** 出力先が持つ資源を解放します。 */
close?(): void | Promise<void>;
}

View File

@@ -0,0 +1,129 @@
/*
* 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,
serializeLogError,
type LogNormalizationProfile,
} from './LogNormalizer.js';
import type { LogBackend } from './LogBackend.js';
import type { LogRecord, LogRecordInput } 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;
};
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;
/**
* 出力先と実行環境から値を取得する処理を受け取ります。
* 実行環境の取得処理は、必要な項目だけテスト用に差し替えられます。
*/
constructor(
backend: LogBackend,
dependencies: Partial<LogManagerDependencies> = {},
options: LogManagerOptions = {},
) {
this.backend = backend;
this.dependencies = {
...defaultDependencies,
...dependencies,
};
this.normalizationProfile = options.normalizationProfile ?? 'standard';
}
/**
* 以後のログを書き込む出力先を切り替えます。
* 作成済みのLoggerにも切り替えを反映するため、LogManager側で保持します。
*/
public setBackend(backend: LogBackend): void {
this.backend = backend;
}
/** 正規化方式を切り替え、既に作成済みのLoggerにも反映します。 */
public setNormalizationProfile(profile: LogNormalizationProfile): void {
this.normalizationProfile = profile;
}
/**
* 出力条件を確認し、共通情報を付加して出力先へ渡します。
*/
public write(input: LogRecordInput): void {
// `quiet`は他の条件より優先し、ログに付随する情報の取得も行いません。
if (this.dependencies.isQuiet()) return;
// 本番環境のデバッグログは、明示的に`verbose`が指定された場合だけ出力します。
if (input.level === 'debug' && this.dependencies.getNodeEnv() === 'production' && !this.dependencies.isVerbose()) 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;
const record = {
...inputWithoutStructuredValues,
context,
timestamp: this.dependencies.now().toISOString(),
loggerName: context.map(segment => segment.name).join('.'),
processId: processInfo.processId,
isPrimary: processInfo.isPrimary,
workerId: processInfo.workerId,
...(normalizedAttributes ? { attributes: normalizedAttributes } : {}),
...(normalizedError ? { error: normalizedError } : {}),
} as LogRecord;
this.backend.write(record);
}
}

View File

@@ -0,0 +1,386 @@
/*
* 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<LogNormalizationLimits>;
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',
'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<string, unknown> {
return typeof value === 'object' && value !== null;
}
/** 外部入力のキーを安全に格納するため、prototypeを持たない属性領域を作成します。 */
function createAttributeMap(): Record<string, LogAttributeValue> {
return Object.create(null) as Record<string, LogAttributeValue>;
}
/** 値を文字列化し、文字列化処理自体の例外もログ処理へ漏らさないようにします。 */
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 findMaxPrefixLength(value: string, suffix: string, maxBytes: number): number {
let lower = 0;
let upper = value.length;
while (lower < upper) {
const middle = Math.ceil((lower + upper) / 2);
if (byteLength(value.slice(0, middle) + suffix) <= maxBytes) {
lower = middle;
} else {
upper = middle - 1;
}
}
// UTF-16のサロゲート対を途中で切らないよう、必要なら1文字戻します。
if (lower > 0 && lower < value.length) {
const code = value.charCodeAt(lower - 1);
if (code >= 0xd800 && code <= 0xdbff) lower--;
}
return lower;
}
/** 特殊な値に対しても、エラー判定で例外を発生させないようにします。 */
function isErrorValue(value: unknown): value is Error {
try {
return value instanceof Error;
} catch {
return false;
}
}
/** 値の読み出しを安全に行い、壊れたログ属性が本処理を中断しないようにします。 */
function readProperty(value: Record<string, unknown>, 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<object>,
limits: LogNormalizationLimits,
redactor: LogRedactor,
): LogAttributeValue {
if (value === null) return null;
if (typeof value === 'string') return normalizeString(value, limits.maxStringBytes);
if (typeof value === 'boolean') return value;
if (typeof value === 'number') return Number.isFinite(value) ? value : stringifySafely(value);
if (typeof value === 'bigint') return value.toString(10);
if (typeof value === 'undefined') return `${UNSUPPORTED}: undefined`;
if (typeof value === 'function' || typeof value === 'symbol') return `${UNSUPPORTED}: ${typeof value}`;
let isArray = false;
try {
if (value instanceof Date) {
return normalizeString(value.toISOString(), limits.maxStringBytes);
}
isArray = Array.isArray(value);
if (!isArray) {
const prototype = Object.getPrototypeOf(value);
if (prototype !== Object.prototype && prototype !== null) return `${UNSUPPORTED}: object`;
}
} catch {
return `${UNSUPPORTED}: object access failed`;
}
if (seen.has(value)) return CIRCULAR;
if (depth >= limits.maxDepth) return TRUNCATED;
seen.add(value);
try {
if (isArray) {
const arrayValue = value as readonly unknown[];
const result: LogAttributeValue[] = [];
const entries = Math.min(arrayValue.length, limits.maxEntries);
for (let i = 0; i < entries; i++) {
result.push(normalizeValue(arrayValue[i], [...path, String(i)], depth + 1, seen, limits, redactor));
}
if (arrayValue.length > entries) result.push(TRUNCATED);
return result;
}
// 攻撃者が指定した`__proto__`を通常の属性として保持するため、null prototypeを使います。
const result = createAttributeMap();
const keys = Object.keys(value).sort();
const entries = Math.min(keys.length, limits.maxEntries);
for (let i = 0; i < entries; i++) {
const key = keys[i];
// 秘匿対象は値を読み出す前に置き換え、読み出し処理や巨大な値にも触れません。
result[key] = redactor(path, key)
? REDACTED
: normalizeValue(readProperty(value as Record<string, unknown>, key), [...path, key], depth + 1, seen, limits, redactor);
}
if (keys.length > entries) result[TRUNCATED_KEY] = TRUNCATED;
return result;
} catch {
return `${UNSUPPORTED}: object access failed`;
} finally {
seen.delete(value);
}
}
/** JSON文字列化した値のバイト数を測ります。正規化後の最終上限に使います。 */
function serializedByteLength(value: LogAttributeValue): number {
return byteLength(JSON.stringify(value));
}
/** 正規化済みの値を上限内へ再帰的に縮めます。 */
function trimToByteLimit(value: LogAttributeValue, maxBytes: number): LogAttributeValue {
if (serializedByteLength(value) <= maxBytes) return value;
if (typeof value === 'string') return normalizeString(value, maxBytes);
if (Array.isArray(value)) {
const result: LogAttributeValue[] = [];
for (const item of value) {
const trimmedItem = trimToByteLimit(item, maxBytes);
const candidate = [...result, trimmedItem];
if (serializedByteLength(candidate) > maxBytes) break;
result.push(trimmedItem);
}
if (result.length < value.length && serializedByteLength([...result, TRUNCATED]) <= maxBytes) result.push(TRUNCATED);
return result;
}
if (isObject(value)) {
// 上限調整中も`__proto__`を安全に属性として扱えるようにします。
const result = createAttributeMap();
for (const key of Object.keys(value).sort()) {
const candidate = Object.assign(createAttributeMap(), result, { [key]: trimToByteLimit(value[key], maxBytes) });
if (serializedByteLength(candidate) > maxBytes) break;
result[key] = candidate[key];
}
if (Object.keys(result).length < Object.keys(value).length) {
const candidate = Object.assign(createAttributeMap(), result, { [TRUNCATED_KEY]: TRUNCATED });
if (serializedByteLength(candidate) <= maxBytes) return candidate;
}
return result;
}
return TRUNCATED;
}
/** エラーらしい原因情報かを安全に判定します。特殊な値もログ処理を壊しません。 */
function isErrorLike(value: unknown): value is Record<string, unknown> {
if (!isObject(value)) return false;
try {
return ['name', 'message', 'stack', 'cause'].some(key => key in value);
} catch {
return false;
}
}
/** 属性をJSONへ出力できるオブジェクトへ正規化します。 */
export function normalizeLogAttributes(value: unknown, options: LogNormalizationOptions = {}): LogAttributes {
const limits = resolveLogNormalizationLimits(options);
const redactor = options.redactor ?? defaultLogRedactor;
const normalized = normalizeValue(value, [], 0, new WeakSet<object>(), limits, redactor);
const root = isObject(normalized) && !Array.isArray(normalized) ? normalized : { value: normalized };
return trimToByteLimit(root as LogAttributes, limits.maxBytes) as LogAttributes;
}
/** 旧APIのdata領域から、エラー本体を見つけて構造化した形へ渡します。 */
export function findLegacyLogError(value: unknown): unknown {
if (isErrorValue(value)) return value;
if (!isObject(value)) return undefined;
for (const key of ['e', 'err', 'error', 'stack']) {
const candidate = readProperty(value, key);
if (isErrorValue(candidate)) return candidate;
}
return undefined;
}
/** エラーの原因情報を含めて、一定の形へ正規化します。 */
function serializeErrorValue(
value: unknown,
depth: number,
seen: WeakSet<object>,
limits: LogNormalizationLimits,
redactor: LogRedactor,
): SerializedError {
if (!isObject(value)) {
return {
type: value === null ? 'null' : typeof value,
message: normalizeString(stringifySafely(value), limits.maxStringBytes),
};
}
if (seen.has(value)) return { type: 'CircularError', message: CIRCULAR };
seen.add(value);
try {
const name = readProperty(value, 'name');
const message = readProperty(value, 'message');
const stack = readProperty(value, 'stack');
const cause = readProperty(value, 'cause');
const constructor = readProperty(value, 'constructor');
const constructorName = typeof constructor === 'function' || isObject(constructor)
? readProperty(constructor as Record<string, unknown>, 'name')
: undefined;
const type = normalizeString(
typeof name === 'string' && name.length > 0
? name
: typeof constructorName === 'string' && constructorName.length > 0 ? constructorName : 'Error',
limits.maxStringBytes,
);
const result: { type: string; message: string; stack?: string; cause?: SerializedError | LogAttributeValue } = {
type,
message: normalizeString(typeof message === 'string' ? message : stringifySafely(value), limits.maxStringBytes),
};
if (typeof stack === 'string') result.stack = normalizeString(stack, limits.maxStringBytes);
if (typeof cause !== 'undefined') {
result.cause = depth < limits.maxDepth
? isErrorValue(cause) || isErrorLike(cause)
? serializeErrorValue(cause, depth + 1, seen, limits, redactor)
: normalizeValue(cause, ['cause'], depth + 1, seen, limits, redactor)
: TRUNCATED;
}
return result;
} finally {
seen.delete(value);
}
}
/** エラーの必須項目を残したまま、全体の出力サイズを上限内へ縮めます。 */
function trimSerializedError(value: SerializedError, maxBytes: number): SerializedError {
const requiredLimit = Math.max(0, Math.floor((maxBytes - 24) / 2));
const result: { type: string; message: string; stack?: string; cause?: SerializedError | LogAttributeValue } = {
type: normalizeString(value.type, requiredLimit),
message: normalizeString(value.message, requiredLimit),
};
if (serializedByteLength(result as unknown as LogAttributeValue) > maxBytes) {
return { type: '', message: '' };
}
if (value.stack != null) {
const candidate = { ...result, stack: value.stack };
if (serializedByteLength(candidate as unknown as LogAttributeValue) <= maxBytes) result.stack = value.stack;
}
if (value.cause != null) {
const cause = typeof value.cause === 'object'
? trimToByteLimit(value.cause as LogAttributeValue, maxBytes)
: value.cause;
const candidate = { ...result, cause };
if (serializedByteLength(candidate as unknown as LogAttributeValue) <= maxBytes) result.cause = cause as SerializedError | LogAttributeValue;
}
return result;
}
/** 任意のエラー入力を、ログへ安全に埋め込める形へ変換します。 */
export function serializeLogError(value: unknown, options: LogNormalizationOptions = {}): SerializedError | undefined {
if (typeof value === 'undefined' || value === null) return undefined;
const limits = resolveLogNormalizationLimits(options);
// 必須項目を含む最小のJSON形すら収まらない場合は、上限を超えないよう出力しません。
if (limits.maxBytes < 24) return undefined;
const serialized = serializeErrorValue(value, 0, new WeakSet<object>(), limits, options.redactor ?? defaultLogRedactor);
return trimSerializedError(serialized, limits.maxBytes);
}

View File

@@ -0,0 +1,90 @@
/*
* SPDX-FileCopyrightText: syuilo and misskey-project
* SPDX-License-Identifier: AGPL-3.0-only
*/
import chalk from 'chalk';
import { default as convertColor } from 'color-convert';
import { format as dateFormat } from 'date-fns';
import { envOption } from '@/env.js';
import type { LogBackend } from './LogBackend.js';
import type { LogRecord } from './types.js';
/** 見やすい形式の出力処理が外部から受け取る依存関係です。 */
export type PrettyConsoleBackendDependencies = {
readonly output: (...args: unknown[]) => void;
readonly withLogTime: () => boolean;
};
const defaultDependencies: PrettyConsoleBackendDependencies = {
output: (...args) => console.log(...args),
withLogTime: () => envOption.withLogTime,
};
/**
* 人が読みやすい従来形式へ整形し、コンソールへ出力します。
* 色、ラベル、時刻などの見た目だけを担当し、出力可否はLogManagerへ任せます。
*/
export class PrettyConsoleBackend implements LogBackend {
private readonly dependencies: PrettyConsoleBackendDependencies;
/**
* 出力処理と時刻表示の判定処理を受け取ります。
* 省略時は従来どおり、標準のコンソールと環境設定を使用します。
*/
constructor(dependencies: Partial<PrettyConsoleBackendDependencies> = {}) {
this.dependencies = {
...defaultDependencies,
...dependencies,
};
}
/**
* 共通のログを従来形式へ整形して一件出力します。
*/
public write(record: LogRecord): void {
const legacyLevel = record.compatibility?.legacyLevel;
// `fatal`は重大なエラーとして扱い、従来の`important`と同じ強調表示にします。
const important = record.level === 'fatal' || (record.compatibility?.important ?? false);
const presentationLevel = record.level === 'fatal' ? 'error' : (legacyLevel ?? record.level);
const label =
presentationLevel === 'error' ? important ? chalk.bgRed.white('ERR ') : chalk.red('ERR ') :
presentationLevel === 'warn' ? chalk.yellow('WARN') :
presentationLevel === 'success' ? important ? chalk.bgGreen.white('DONE') : chalk.green('DONE') :
presentationLevel === 'debug' ? chalk.gray('VERB') :
presentationLevel === 'info' ? chalk.blue('INFO') :
null;
const contexts = record.context.map(context => context.color
? chalk.rgb(...convertColor.keyword.rgb(context.color))(context.name)
: chalk.white(context.name));
const message =
presentationLevel === 'error' ? chalk.red(record.message) :
presentationLevel === 'warn' ? chalk.yellow(record.message) :
presentationLevel === 'success' ? chalk.green(record.message) :
presentationLevel === 'debug' ? chalk.gray(record.message) :
presentationLevel === 'info' ? record.message :
null;
// 主プロセスは従来どおり「*」、子プロセスはワーカー番号で識別します。
const worker = record.isPrimary ? '*' : record.workerId;
let log = `${label} ${worker}\t[${contexts.join(' ')}]\t${message}`;
if (this.dependencies.withLogTime()) {
log = chalk.gray(dateFormat(new Date(record.timestamp), 'HH:mm:ss')) + ' ' + log;
}
// `data`は文字列へ埋め込まず、第2引数として渡す従来の挙動を維持します。
const args: unknown[] = [important ? chalk.bold(log) : log];
if (record.compatibility?.data != null) {
// 旧形式の値はそのまま第2引数へ渡し、既存の表示と調査方法を保ちます。
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);
}
}

View File

@@ -0,0 +1,13 @@
/*
* SPDX-FileCopyrightText: syuilo and misskey-project
* SPDX-License-Identifier: AGPL-3.0-only
*/
import { LogManager } from './LogManager.js';
import { PrettyConsoleBackend } from './PrettyConsoleBackend.js';
/**
* プロセス内のすべてのLoggerが共有するLogManagerです。
* Logger作成後も同じLogManagerを参照するため、出力先の切り替えを一括で反映できます。
*/
export const logManager = new LogManager(new PrettyConsoleBackend());

View File

@@ -0,0 +1,80 @@
/*
* SPDX-FileCopyrightText: syuilo and misskey-project
* SPDX-License-Identifier: AGPL-3.0-only
*/
import type { Keyword } from 'color-convert';
/** ログの重要度を表します。 */
export type LogLevel = 'debug' | 'info' | 'warn' | 'error' | 'fatal';
/** 正規化後にログ属性として扱えるJSONの値です。 */
export type LogAttributeValue =
| string
| number
| boolean
| null
| readonly LogAttributeValue[]
| { readonly [key: string]: LogAttributeValue };
/** 正規化後のログ属性です。 */
export type LogAttributes = Readonly<Record<string, LogAttributeValue>>;
/** ロガーの呼び出し側が構造化ログとして指定する入力です。 */
export type LogWriteInput = {
readonly level: LogLevel;
readonly message: string;
readonly eventName?: string;
readonly attributes?: Readonly<Record<string, unknown>>;
readonly error?: unknown;
};
/**
* ロガー名を構成する一要素です。
* 色は見やすい形式での表示だけに使い、ログの意味には影響させません。
*/
export type LoggerContext = {
readonly name: string;
readonly color?: Keyword;
};
/**
* 従来のコンソール表示を維持するための情報です。
* 構造化ログの項目と混同しないよう、互換用の領域へ分離しています。
* `data`は従来表示を保つため正規化せず、秘匿が必要な値は構造化属性へ移します。
*/
export type LogCompatibility = {
readonly legacyLevel?: 'success';
readonly important?: boolean;
readonly data?: unknown;
};
/**
* 呼び出し側からLogManagerへ渡す、時刻などを付加する前のログです。
*/
export type LogRecordInput = LogWriteInput & {
readonly context: readonly LoggerContext[];
readonly compatibility?: LogCompatibility;
};
/** エラーをJSONへ出力するために正規化した形です。 */
export type SerializedError = {
readonly type: string;
readonly message: string;
readonly stack?: string;
readonly cause?: SerializedError | LogAttributeValue;
};
/**
* 出力先へ渡すログです。
* `compatibility.data`は見やすい形式だけが使う従来値で、構造化した出力先は属性とエラーを利用します。
*/
export type LogRecord = Omit<LogRecordInput, 'attributes' | 'error'> & {
readonly timestamp: string;
readonly loggerName: string;
readonly processId: number;
readonly isPrimary: boolean;
readonly workerId: number | null;
readonly attributes?: LogAttributes;
readonly error?: SerializedError;
};

View File

@@ -158,6 +158,8 @@ export class QueueProcessorService implements OnApplicationShutdown {
};
}
// 以下の各 Worker は job.data に保存された enqueue 元の trace context を復元し、
// ジョブの実処理全体を Link または parent の worker span で囲む。
//#region system
{
const processer = (job: Bull.Job) => {
@@ -176,7 +178,7 @@ export class QueueProcessorService implements OnApplicationShutdown {
};
this.systemQueueWorker = new Bull.Worker(QUEUE.SYSTEM, (job) => {
return this.telemetryService.startSpan('Queue: System: ' + job.name, () => processer(job));
return this.telemetryService.startSpanWithTraceContext('Queue: System: ' + job.name, job.data, () => processer(job));
}, {
...baseWorkerOptions(this.config, QUEUE.SYSTEM),
autorun: false,
@@ -227,7 +229,7 @@ export class QueueProcessorService implements OnApplicationShutdown {
};
this.dbQueueWorker = new Bull.Worker(QUEUE.DB, (job) => {
return this.telemetryService.startSpan('Queue: DB: ' + job.name, () => processer(job));
return this.telemetryService.startSpanWithTraceContext('Queue: DB: ' + job.name, job.data, () => processer(job));
}, {
...baseWorkerOptions(this.config, QUEUE.DB),
autorun: false,
@@ -253,7 +255,7 @@ export class QueueProcessorService implements OnApplicationShutdown {
//#region deliver
{
this.deliverQueueWorker = new Bull.Worker(QUEUE.DELIVER, (job) => {
return this.telemetryService.startSpan('Queue: Deliver', () => this.deliverProcessorService.process(job));
return this.telemetryService.startSpanWithTraceContext('Queue: Deliver', job.data, () => this.deliverProcessorService.process(job));
}, {
...baseWorkerOptions(this.config, QUEUE.DELIVER),
autorun: false,
@@ -287,7 +289,7 @@ export class QueueProcessorService implements OnApplicationShutdown {
//#region inbox
{
this.inboxQueueWorker = new Bull.Worker(QUEUE.INBOX, (job) => {
return this.telemetryService.startSpan('Queue: Inbox', () => this.inboxProcessorService.process(job));
return this.telemetryService.startSpanWithTraceContext('Queue: Inbox', job.data, () => this.inboxProcessorService.process(job));
}, {
...baseWorkerOptions(this.config, QUEUE.INBOX),
autorun: false,
@@ -321,7 +323,7 @@ export class QueueProcessorService implements OnApplicationShutdown {
//#region user-webhook deliver
{
this.userWebhookDeliverQueueWorker = new Bull.Worker(QUEUE.USER_WEBHOOK_DELIVER, (job) => {
return this.telemetryService.startSpan('Queue: UserWebhookDeliver', () => this.userWebhookDeliverProcessorService.process(job));
return this.telemetryService.startSpanWithTraceContext('Queue: UserWebhookDeliver', job.data, () => this.userWebhookDeliverProcessorService.process(job));
}, {
...baseWorkerOptions(this.config, QUEUE.USER_WEBHOOK_DELIVER),
autorun: false,
@@ -355,7 +357,7 @@ export class QueueProcessorService implements OnApplicationShutdown {
//#region system-webhook deliver
{
this.systemWebhookDeliverQueueWorker = new Bull.Worker(QUEUE.SYSTEM_WEBHOOK_DELIVER, (job) => {
return this.telemetryService.startSpan('Queue: SystemWebhookDeliver', () => this.systemWebhookDeliverProcessorService.process(job));
return this.telemetryService.startSpanWithTraceContext('Queue: SystemWebhookDeliver', job.data, () => this.systemWebhookDeliverProcessorService.process(job));
}, {
...baseWorkerOptions(this.config, QUEUE.SYSTEM_WEBHOOK_DELIVER),
autorun: false,
@@ -399,7 +401,7 @@ export class QueueProcessorService implements OnApplicationShutdown {
};
this.relationshipQueueWorker = new Bull.Worker(QUEUE.RELATIONSHIP, (job) => {
return this.telemetryService.startSpan('Queue: Relationship: ' + job.name, () => processer(job));
return this.telemetryService.startSpanWithTraceContext('Queue: Relationship: ' + job.name, job.data, () => processer(job));
}, {
...baseWorkerOptions(this.config, QUEUE.RELATIONSHIP),
autorun: false,
@@ -438,7 +440,7 @@ export class QueueProcessorService implements OnApplicationShutdown {
};
this.objectStorageQueueWorker = new Bull.Worker(QUEUE.OBJECT_STORAGE, (job) => {
return this.telemetryService.startSpan('Queue: ObjectStorage: ' + job.name, () => processer(job));
return this.telemetryService.startSpanWithTraceContext('Queue: ObjectStorage: ' + job.name, job.data, () => processer(job));
}, {
...baseWorkerOptions(this.config, QUEUE.OBJECT_STORAGE),
autorun: false,
@@ -465,7 +467,7 @@ export class QueueProcessorService implements OnApplicationShutdown {
//#region ended poll notification
{
this.endedPollNotificationQueueWorker = new Bull.Worker(QUEUE.ENDED_POLL_NOTIFICATION, (job) => {
return this.telemetryService.startSpan('Queue: EndedPollNotification', () => this.endedPollNotificationProcessorService.process(job));
return this.telemetryService.startSpanWithTraceContext('Queue: EndedPollNotification', job.data, () => this.endedPollNotificationProcessorService.process(job));
}, {
...baseWorkerOptions(this.config, QUEUE.ENDED_POLL_NOTIFICATION),
autorun: false,
@@ -476,7 +478,7 @@ export class QueueProcessorService implements OnApplicationShutdown {
//#region post scheduled note
{
this.postScheduledNoteQueueWorker = new Bull.Worker(QUEUE.POST_SCHEDULED_NOTE, async (job) => {
return this.telemetryService.startSpan('Queue: PostScheduledNote', () => this.postScheduledNoteProcessorService.process(job));
return this.telemetryService.startSpanWithTraceContext('Queue: PostScheduledNote', job.data, () => this.postScheduledNoteProcessorService.process(job));
}, {
...baseWorkerOptions(this.config, QUEUE.POST_SCHEDULED_NOTE),
autorun: false,

View File

@@ -32,6 +32,7 @@ import { HealthServerService } from './HealthServerService.js';
import { ClientServerService } from './web/ClientServerService.js';
import { OpenApiServerService } from './api/openapi/OpenApiServerService.js';
import { OAuth2ProviderService } from './oauth/OAuth2ProviderService.js';
import { registerHttpServerInstrumentation } from './http-server-instrumentation.js';
const _dirname = fileURLToPath(new URL('.', import.meta.url));
@@ -80,6 +81,7 @@ export class ServerService implements OnApplicationShutdown {
logger: false,
});
this.#fastify = fastify;
await registerHttpServerInstrumentation(fastify, this.config);
// HSTS
// 6months (15552000sec)

View File

@@ -110,15 +110,16 @@ export class ApiCallService implements OnApplicationShutdown {
throw err;
} else {
const errId = randomUUID();
this.logger.error(`Internal error occurred in ${ep.name}: ${err.message}`, {
ep: ep.name,
ps: data,
e: {
message: err.message,
code: err.name,
stack: err.stack,
id: errId,
this.logger.write({
level: 'error',
eventName: 'api.endpoint.failed',
message: `Internal error occurred in ${ep.name}: ${err.message}`,
attributes: {
'api.endpoint': ep.name,
'error.id': errId,
'api.params': data,
},
error: err,
});
this.telemetryService.captureMessage(`Internal error occurred in ${ep.name}: ${err.message}`, {
@@ -126,7 +127,6 @@ export class ApiCallService implements OnApplicationShutdown {
userId,
extra: {
ep: ep.name,
ps: data,
e: {
message: err.message,
code: err.name,
@@ -151,7 +151,7 @@ export class ApiCallService implements OnApplicationShutdown {
endpoint: IEndpoint & { exec: any },
request: FastifyRequest<{ Body: Record<string, unknown> | undefined, Querystring: Record<string, unknown> }>,
reply: FastifyReply,
): void {
): Promise<void> {
const body = request.method === 'GET'
? request.query
: request.body;
@@ -162,10 +162,11 @@ export class ApiCallService implements OnApplicationShutdown {
: body?.['i'];
if (token != null && typeof token !== 'string') {
reply.code(400);
return;
return Promise.resolve();
}
this.authenticateService.authenticate(token).then(([user, app]) => {
this.call(endpoint, user, app, body, null, request).then((res) => {
return this.telemetryService.startSpan('API: ' + endpoint.name, () => this.authenticateService.authenticate(token).then(([user, app]) => {
const call = this.call(endpoint, user, app, body, null, request).then((res) => {
if (request.method === 'GET' && endpoint.meta.cacheSec && !token && !user) {
reply.header('Cache-Control', `public, max-age=${endpoint.meta.cacheSec}`);
}
@@ -177,9 +178,11 @@ export class ApiCallService implements OnApplicationShutdown {
if (user) {
this.logIp(request, user);
}
return call;
}).catch(err => {
this.#sendAuthenticationError(reply, err);
});
}));
}
@bindThis
@@ -222,8 +225,9 @@ export class ApiCallService implements OnApplicationShutdown {
reply.code(400);
return;
}
this.authenticateService.authenticate(token).then(([user, app]) => {
this.call(endpoint, user, app, fields, {
return await this.telemetryService.startSpan('API: ' + endpoint.name, () => this.authenticateService.authenticate(token).then(([user, app]) => {
const call = this.call(endpoint, user, app, fields, {
name: multipartData.filename,
path: path,
}, request).then((res) => {
@@ -235,9 +239,11 @@ export class ApiCallService implements OnApplicationShutdown {
if (user) {
this.logIp(request, user);
}
return call;
}).catch(err => {
this.#sendAuthenticationError(reply, err);
});
}));
}
@bindThis
@@ -431,9 +437,10 @@ export class ApiCallService implements OnApplicationShutdown {
}
}
// API invoking
return await this.telemetryService.startSpan('API: ' + ep.name, () => ep.exec(data, user, token, file, request.ip, request.headers)
.catch((err: Error) => this.#onExecError(ep, data, err, user?.id)));
// The API span starts in handleRequest/handleMultipartRequest so it also covers
// authentication, rate limiting, and parameter validation.
return await ep.exec(data, user, token, file, request.ip, request.headers)
.catch((err: Error) => this.#onExecError(ep, data, err, user?.id));
}
@bindThis

View File

@@ -0,0 +1,35 @@
/*
* SPDX-FileCopyrightText: syuilo and misskey-project
* SPDX-License-Identifier: AGPL-3.0-only
*/
import type { Config } from '@/config.js';
import type { FastifyInstance } from 'fastify';
type TelemetryConfig = Pick<Config, 'otelForBackend' | 'sentryForBackend'>;
export function shouldRegisterHttpServerInstrumentation(config: TelemetryConfig): boolean {
// Sentryもリクエストspanを作成するため、両方を登録すると重複して出力される。
return config.otelForBackend != null && config.sentryForBackend == null;
}
/**
* すべてのルート・フックより前にリクエスト計装を登録し、ActivityPubや
* well-knownを含む全HTTP受信経路を1つのroot spanとして計測する。
*/
export async function registerHttpServerInstrumentation(fastify: FastifyInstance, config: TelemetryConfig): Promise<void> {
if (!shouldRegisterHttpServerInstrumentation(config)) return;
const { FastifyOtelInstrumentation } = await import('@fastify/otel');
const instrumentation = new FastifyOtelInstrumentation({
requestHook: (span, request) => {
const route = request.routeOptions.url;
if (route != null) {
// デフォルトだとトレース名が「request」で固定されてしまうため、判別がつかなくなる。
// ルート名をspan名に設定することで、トレースビューでルートごとの処理時間を確認できるようになる。
span.updateName(`${request.method} ${route}`);
}
},
});
await fastify.register(instrumentation.plugin());
}

View File

@@ -9,6 +9,7 @@ import { DI } from '@/di-symbols.js';
import type { Config } from '@/config.js';
import { HttpRequestService } from '@/core/HttpRequestService.js';
import type Logger from '@/logger.js';
import { deepClone } from '@/misc/clone.js';
import { query } from '@/misc/prelude/url.js';
import { MemoryKVCache } from '@/misc/cache.js';
import { LoggerService } from '@/core/LoggerService.js';
@@ -101,7 +102,7 @@ export class UrlPreviewService implements OnApplicationShutdown {
return result;
};
const summary = await this.summaryCache.fetchMaybe(`${url}@${lang ?? '_DEFAULT_'}`, fetcher);
const summary = deepClone(await this.summaryCache.fetchMaybe(`${url}@${lang ?? '_DEFAULT_'}`, fetcher));
if (summary == null) {
throw new Error('Invalid summary');

View File

@@ -0,0 +1,88 @@
/*
* SPDX-FileCopyrightText: syuilo and misskey-project
* SPDX-License-Identifier: AGPL-3.0-only
*/
import { randomUUID } from 'node:crypto';
import { describe, expect, test } from 'vitest';
import { Queue, Worker } from 'bullmq';
import { SpanKind, SpanStatusCode } from '@opentelemetry/api';
import { InMemorySpanExporter, SimpleSpanProcessor } from '@opentelemetry/sdk-trace-base';
import { NodeTracerProvider } from '@opentelemetry/sdk-trace-node';
import { loadConfig } from '@/config.js';
import { installRedisInstrumentation } from '@/core/telemetry/redis-instrumentation.js';
const config = loadConfig();
describe('Redis telemetry instrumentation', () => {
test('records Redis spans below HTTP and BullMQ worker spans without Redis arguments', async () => {
const exporter = new InMemorySpanExporter();
const provider = new NodeTracerProvider({
spanProcessors: [new SimpleSpanProcessor(exporter)],
});
provider.register();
const tracer = provider.getTracer('telemetry-redis-instrumentation-test');
const uninstall = installRedisInstrumentation(tracer, SpanKind.CLIENT, SpanStatusCode.ERROR, {
captureCommandSpans: true,
});
const queueName = `telemetry-${randomUUID()}`;
const prefix = `telemetry-${randomUUID()}`;
const connection = {
host: config.redis.host,
port: config.redis.port,
...(config.redis.password != null ? { password: config.redis.password } : {}),
};
const queue = new Queue(queueName, { connection, prefix });
let worker: Worker | undefined;
let httpSpanId: string | undefined;
let jobSpanId: string | undefined;
const secret = `secret-${randomUUID()}`;
try {
const processed = new Promise<void>((resolve, reject) => {
worker = new Worker(queueName, async job => {
return await tracer.startActiveSpan('Queue: telemetry test', async jobSpan => {
jobSpanId = jobSpan.spanContext().spanId;
try {
// updateData uses BullMQ's worker-side ioredis client.
await job.updateData({ secret });
return 'ok';
} finally {
jobSpan.end();
}
});
}, { connection, prefix });
worker.once('completed', () => resolve());
worker.once('failed', (_job, error) => reject(error));
});
await tracer.startActiveSpan('HTTP POST /telemetry-test', async httpSpan => {
httpSpanId = httpSpan.spanContext().spanId;
try {
// Queue#add uses BullMQ's producer-side ioredis client.
await queue.add('probe', { secret });
} finally {
httpSpan.end();
}
});
await processed;
await provider.forceFlush();
const redisSpans = exporter.getFinishedSpans().filter(span => span.attributes['db.system.name'] === 'redis');
expect(redisSpans.some(span => span.parentSpanContext?.spanId === httpSpanId)).toBe(true);
expect(redisSpans.some(span => span.parentSpanContext?.spanId === jobSpanId)).toBe(true);
for (const span of redisSpans) {
expect(span.attributes).not.toHaveProperty('db.statement');
expect(span.attributes).not.toHaveProperty('db.query.text');
expect(Object.values(span.attributes)).not.toContain(secret);
}
} finally {
await worker?.close();
await queue.obliterate({ force: true });
await queue.close();
uninstall();
await provider.shutdown();
}
}, 30000);
});

View File

@@ -1,60 +0,0 @@
/*
* SPDX-FileCopyrightText: syuilo and misskey-project
* SPDX-License-Identifier: AGPL-3.0-only
*/
import { describe, expect, test, vi } from 'vitest';
import { buildSentryIntegrations } from '@/core/telemetry/adapters/SentryTelemetryAdapter.js';
type TestIntegration = Parameters<ReturnType<typeof buildSentryIntegrations>>[0][number];
function testIntegration(name: string): TestIntegration {
return { name };
}
describe('SentryTelemetryAdapter', () => {
test('removes disabled integrations from Sentry defaults', () => {
const integrations = buildSentryIntegrations({
disabledIntegrations: ['Postgres'],
enableNodeProfiling: false,
});
const result = integrations([
testIntegration('Http'),
testIntegration('Postgres'),
testIntegration('Redis'),
]);
expect(result.map((integration: TestIntegration) => integration.name)).toEqual(['Http', 'Redis']);
});
test('keeps profiling integration when enabled', () => {
const integrations = buildSentryIntegrations({
disabledIntegrations: [],
enableNodeProfiling: true,
nodeProfilingIntegration: () => testIntegration('ProfilingIntegration'),
});
const result = integrations([
testIntegration('Http'),
]);
expect(result.map((integration: TestIntegration) => integration.name)).toEqual(['Http', 'ProfilingIntegration']);
});
test('warns about unknown disabled integration names without removing defaults', () => {
const warn = vi.fn();
const integrations = buildSentryIntegrations({
disabledIntegrations: ['Unknown'],
enableNodeProfiling: false,
warn,
});
const result = integrations([
testIntegration('Http'),
]);
expect(result.map((integration: TestIntegration) => integration.name)).toEqual(['Http']);
expect(warn).toHaveBeenCalledWith('Unknown Sentry integration configured in sentryForBackend.disabledIntegrations: Unknown');
});
});

View File

@@ -0,0 +1,357 @@
/*
* SPDX-FileCopyrightText: syuilo and misskey-project
* SPDX-License-Identifier: AGPL-3.0-only
*/
import { beforeEach, describe, expect, test, vi } from 'vitest';
import { SpanStatusCode } from '@opentelemetry/api';
import { defaultResource, detectResources, envDetector, resourceFromAttributes } from '@opentelemetry/resources';
import { ParentBasedSampler, TraceIdRatioBasedSampler } from '@opentelemetry/sdk-trace-base';
import { ATTR_SERVICE_INSTANCE_ID, ATTR_SERVICE_NAME, ATTR_SERVICE_VERSION } from '@opentelemetry/semantic-conventions';
import type { Context, SpanContext } from '@opentelemetry/api';
import { OpenTelemetryAdapter, createResource, createSampler, getMisskeyProcessRole } from '@/core/telemetry/adapters/OpenTelemetryAdapter.js';
const mocks = vi.hoisted(() => {
return {
envOption: {
disableClustering: false,
onlyServer: false,
onlyQueue: false,
},
isPrimary: false,
};
});
vi.mock('@/env.js', () => ({
envOption: mocks.envOption,
}));
vi.mock('node:cluster', () => ({
default: {
get isPrimary() {
return mocks.isPrimary;
},
},
}));
const samplerDeps = {
ParentBasedSampler,
TraceIdRatioBasedSampler,
};
describe('OpenTelemetryAdapter', () => {
test('wraps async work in an active span and ends it after success', async () => {
const span = {
end: vi.fn(),
recordException: vi.fn(),
setStatus: vi.fn(),
};
const tracer = {
startActiveSpan: vi.fn(async (_name: string, fn: (spanArg: any) => Promise<string>) => fn(span)),
} as any;
const provider = {
shutdown: vi.fn(),
};
const adapter = new OpenTelemetryAdapter({
tracer,
provider,
getActiveSpan: () => undefined,
spanStatusCodeError: SpanStatusCode.ERROR,
shutdownTimeout: 10,
});
await expect(adapter.startSpan('API: test', async () => 'ok')).resolves.toBe('ok');
expect(tracer.startActiveSpan).toHaveBeenCalledWith('API: test', expect.any(Function));
expect(span.recordException).not.toHaveBeenCalled();
expect(span.setStatus).not.toHaveBeenCalled();
expect(span.end).toHaveBeenCalledTimes(1);
});
test('records thrown errors on the active span before rethrowing', async () => {
const error = new Error('boom');
const span = {
end: vi.fn(),
recordException: vi.fn(),
setStatus: vi.fn(),
};
const tracer = {
startActiveSpan: vi.fn(async (_name: string, fn: (spanArg: any) => Promise<void>) => fn(span)),
} as any;
const adapter = new OpenTelemetryAdapter({
tracer,
provider: { shutdown: vi.fn() },
getActiveSpan: () => undefined,
spanStatusCodeError: SpanStatusCode.ERROR,
shutdownTimeout: 10,
});
await expect(adapter.startSpan('Queue: test', async () => {
throw error;
})).rejects.toThrow(error);
expect(span.recordException).toHaveBeenCalledWith(error);
expect(span.setStatus).toHaveBeenCalledWith({
code: SpanStatusCode.ERROR,
message: error.message,
});
expect(span.end).toHaveBeenCalledTimes(1);
});
test('creates a root worker span linked to the enqueue span by default', () => {
const span = {
end: vi.fn(),
recordException: vi.fn(),
setStatus: vi.fn(),
};
const rootContext = {} as Context;
const extractedContext = {} as Context;
const sourceSpanContext = {
traceId: '0123456789abcdef0123456789abcdef',
spanId: '0123456789abcdef',
traceFlags: 1,
isRemote: true,
} as SpanContext;
const propagation = {
isPropagationApi: true,
inject: vi.fn(),
extract(this: { isPropagationApi: boolean }) {
if (!this.isPropagationApi) throw new Error('lost propagation API receiver');
return extractedContext;
},
};
const tracer = {
startActiveSpan: vi.fn((_name: string, _options: unknown, _context: unknown, fn: (spanArg: typeof span) => string) => fn(span)),
} as any;
const adapter = new OpenTelemetryAdapter({
tracer,
provider: { shutdown: vi.fn() },
getActiveSpan: () => undefined,
spanStatusCodeError: SpanStatusCode.ERROR,
shutdownTimeout: 10,
queueTraceContext: {
tracer,
propagation: propagation as any,
trace: { getSpanContext: () => sourceSpanContext },
getActiveContext: () => rootContext,
rootContext,
mode: 'link',
spanStatusCodeError: SpanStatusCode.ERROR,
},
});
expect(adapter.startSpanWithTraceContext('Queue: Deliver', {
__misskeyTraceContext: {
traceparent: '00-0123456789abcdef0123456789abcdef-0123456789abcdef-01',
},
}, () => 'ok')).toBe('ok');
expect(tracer.startActiveSpan).toHaveBeenCalledWith('Queue: Deliver', {
root: true,
links: [{ context: sourceSpanContext }],
}, rootContext, expect.any(Function));
expect(span.end).toHaveBeenCalledTimes(1);
});
test('bridges captureMessage to the active span when one exists', () => {
const activeSpan = {
recordException: vi.fn(),
setStatus: vi.fn(),
};
const adapter = new OpenTelemetryAdapter({
tracer: { startActiveSpan: vi.fn() },
provider: { shutdown: vi.fn() },
getActiveSpan: () => activeSpan as any,
spanStatusCodeError: SpanStatusCode.ERROR,
shutdownTimeout: 10,
});
adapter.captureMessage('Queue failed', {
level: 'error',
extra: { queue: 'deliver' },
});
expect(activeSpan.recordException).toHaveBeenCalledWith(expect.objectContaining({
message: 'Queue failed',
}));
expect(activeSpan.setStatus).toHaveBeenCalledWith({
code: SpanStatusCode.ERROR,
message: 'Queue failed',
});
});
test('times out shutdown instead of waiting forever', async () => {
vi.useFakeTimers();
const adapter = new OpenTelemetryAdapter({
tracer: { startActiveSpan: vi.fn() },
provider: { shutdown: vi.fn(() => new Promise<void>(() => {})) },
getActiveSpan: () => undefined,
spanStatusCodeError: SpanStatusCode.ERROR,
shutdownTimeout: 50,
});
const shutdown = adapter.shutdown();
await vi.advanceTimersByTimeAsync(50);
await expect(shutdown).resolves.toBeUndefined();
vi.useRealTimers();
});
test('clears the shutdown timeout timer once provider.shutdown() resolves first', async () => {
vi.useFakeTimers();
const clearTimeoutSpy = vi.spyOn(global, 'clearTimeout');
const adapter = new OpenTelemetryAdapter({
tracer: { startActiveSpan: vi.fn() },
provider: { shutdown: vi.fn().mockResolvedValue(undefined) },
getActiveSpan: () => undefined,
spanStatusCodeError: SpanStatusCode.ERROR,
shutdownTimeout: 5000,
});
await adapter.shutdown();
expect(clearTimeoutSpy).toHaveBeenCalled();
clearTimeoutSpy.mockRestore();
vi.useRealTimers();
});
test('captureMessage starts a standalone span to report the error when there is no active span', () => {
const reportSpan = {
end: vi.fn(),
recordException: vi.fn(),
setStatus: vi.fn(),
};
const tracer = {
startActiveSpan: vi.fn((_name: string, fn: (spanArg: typeof reportSpan) => void) => fn(reportSpan)),
};
const adapter = new OpenTelemetryAdapter({
tracer: tracer as any,
provider: { shutdown: vi.fn() },
getActiveSpan: () => undefined,
spanStatusCodeError: SpanStatusCode.ERROR,
shutdownTimeout: 10,
});
adapter.captureMessage('Queue: Deliver failed', {
level: 'error',
extra: { queue: 'deliver' },
});
expect(tracer.startActiveSpan).toHaveBeenCalledWith('captureMessage', expect.any(Function));
expect(reportSpan.recordException).toHaveBeenCalledWith(expect.objectContaining({
message: 'Queue: Deliver failed',
}));
expect(reportSpan.setStatus).toHaveBeenCalledWith({
code: SpanStatusCode.ERROR,
message: 'Queue: Deliver failed',
});
expect(reportSpan.end).toHaveBeenCalledTimes(1);
});
});
describe('createSampler', () => {
test('accepts sample rates within [0, 1]', () => {
expect(() => createSampler(0, samplerDeps)).not.toThrow();
expect(() => createSampler(0.5, samplerDeps)).not.toThrow();
expect(() => createSampler(1, samplerDeps)).not.toThrow();
});
test('rejects sample rates outside [0, 1]', () => {
expect(() => createSampler(-0.1, samplerDeps)).toThrow();
expect(() => createSampler(1.1, samplerDeps)).toThrow();
});
test('rejects NaN instead of silently disabling sampling', () => {
expect(() => createSampler(Number.NaN, samplerDeps)).toThrow();
});
test('rejects non-number values that pass through YAML as strings', () => {
expect(() => createSampler('0.5' as unknown as number, samplerDeps)).toThrow();
});
});
describe('createResource', () => {
test('lets explicit config override OTEL resource env, and env override Misskey defaults', () => {
const previousServiceName = process.env['OTEL_SERVICE_NAME'];
const previousResourceAttributes = process.env['OTEL_RESOURCE_ATTRIBUTES'];
process.env['OTEL_SERVICE_NAME'] = 'env-service';
process.env['OTEL_RESOURCE_ATTRIBUTES'] = [
'deployment.environment=staging',
'misskey.process.role=env-role',
'service.instance.id=env-instance',
'env.only=value',
].join(',');
try {
const resource = createResource({
serviceVersion: '2026.1.0',
resourceAttributes: {
[ATTR_SERVICE_NAME]: 'config-service',
'deployment.environment': 'production',
'config.only': 'value',
},
}, {
defaultResource,
resourceFromAttributes,
detectResources,
envDetector,
serviceNameAttribute: ATTR_SERVICE_NAME,
serviceInstanceIdAttribute: ATTR_SERVICE_INSTANCE_ID,
serviceVersionAttribute: ATTR_SERVICE_VERSION,
serviceVersion: '2026.1.0',
});
expect(resource.attributes[ATTR_SERVICE_NAME]).toBe('config-service');
expect(resource.attributes[ATTR_SERVICE_INSTANCE_ID]).toBe('env-instance');
expect(resource.attributes[ATTR_SERVICE_VERSION]).toBe('2026.1.0');
expect(resource.attributes['deployment.environment']).toBe('production');
expect(resource.attributes['misskey.process.role']).toBe('env-role');
expect(resource.attributes['env.only']).toBe('value');
expect(resource.attributes['config.only']).toBe('value');
} finally {
if (previousServiceName == null) {
delete process.env['OTEL_SERVICE_NAME'];
} else {
process.env['OTEL_SERVICE_NAME'] = previousServiceName;
}
if (previousResourceAttributes == null) {
delete process.env['OTEL_RESOURCE_ATTRIBUTES'];
} else {
process.env['OTEL_RESOURCE_ATTRIBUTES'] = previousResourceAttributes;
}
}
});
});
describe('getMisskeyProcessRole', () => {
beforeEach(() => {
mocks.envOption.disableClustering = false;
mocks.envOption.onlyServer = false;
mocks.envOption.onlyQueue = false;
mocks.isPrimary = false;
});
test('labels non-clustered onlyServer as primary-server', () => {
mocks.envOption.disableClustering = true;
mocks.envOption.onlyServer = true;
expect(getMisskeyProcessRole()).toBe('primary-server');
});
test('labels clustered primary with onlyServer as fork-only', () => {
mocks.isPrimary = true;
mocks.envOption.onlyServer = true;
expect(getMisskeyProcessRole()).toBe('fork-only');
});
test('labels clustered worker running the HTTP server (onlyServer) as worker-server, not worker-queue', () => {
mocks.isPrimary = false;
mocks.envOption.onlyServer = true;
expect(getMisskeyProcessRole()).toBe('worker-server');
});
test('labels clustered worker without onlyServer as worker-queue', () => {
mocks.isPrimary = false;
mocks.envOption.onlyServer = false;
expect(getMisskeyProcessRole()).toBe('worker-queue');
});
});

View File

@@ -3,12 +3,61 @@
* SPDX-License-Identifier: AGPL-3.0-only
*/
process.env.NODE_ENV = 'test';
import { describe, expect, test, vi } from 'vitest';
import { SentryTelemetryAdapter, buildSentryIntegrations, buildSentryNodeOptions, buildSentryOtlpInitOptions } from '@/core/telemetry/adapters/SentryTelemetryAdapter.js';
import { describe, expect, test } from 'vitest';
import { buildSentryNodeOptions } from '../../../../../src/core/telemetry/adapters/SentryTelemetryAdapter.js';
type TestIntegration = Parameters<ReturnType<typeof buildSentryIntegrations>>[0][number];
function testIntegration(name: string): TestIntegration {
return { name };
}
describe('SentryTelemetryAdapter', () => {
test('removes disabled integrations from Sentry defaults', () => {
const integrations = buildSentryIntegrations({
disabledIntegrations: ['Postgres'],
enableNodeProfiling: false,
});
const result = integrations([
testIntegration('Http'),
testIntegration('Postgres'),
testIntegration('Redis'),
]);
expect(result.map((integration: TestIntegration) => integration.name)).toEqual(['Http', 'Redis']);
});
test('keeps profiling integration when enabled', () => {
const integrations = buildSentryIntegrations({
disabledIntegrations: [],
enableNodeProfiling: true,
nodeProfilingIntegration: () => testIntegration('ProfilingIntegration'),
});
const result = integrations([
testIntegration('Http'),
]);
expect(result.map((integration: TestIntegration) => integration.name)).toEqual(['Http', 'ProfilingIntegration']);
});
test('warns about unknown disabled integration names without removing defaults', () => {
const warn = vi.fn();
const integrations = buildSentryIntegrations({
disabledIntegrations: ['Unknown'],
enableNodeProfiling: false,
warn,
});
const result = integrations([
testIntegration('Http'),
]);
expect(result.map((integration: TestIntegration) => integration.name)).toEqual(['Http']);
expect(warn).toHaveBeenCalledWith('Unknown Sentry integration configured in sentryForBackend.disabledIntegrations: Unknown');
});
describe('buildSentryNodeOptions', () => {
test('disables outbound trace propagation by default', () => {
const options = buildSentryNodeOptions({
enableNodeProfiling: false,
@@ -28,4 +77,178 @@ describe('buildSentryNodeOptions', () => {
expect(options.tracePropagationTargets).toEqual(['^https://internal\\.example/']);
});
test('builds Sentry options that export spans to both Sentry and OTLP', () => {
const existingProcessor = { name: 'existingProcessor' };
const otlpProcessor = { name: 'otlpProcessor' };
const result = buildSentryOtlpInitOptions({
sentryConfig: {
enableNodeProfiling: false,
disabledIntegrations: ['Redis'],
options: {
openTelemetrySpanProcessors: [existingProcessor as any],
tracesSampleRate: 0.25,
},
},
otelConfig: { serviceVersion: '2026.1.0' },
otlpProcessor,
});
expect(result.tracesSampleRate).toBe(0.25);
expect(result.openTelemetrySpanProcessors).toEqual([existingProcessor, otlpProcessor]);
// OTel併存時もremoteへtrace headerを漏らさないデフォルトはSentry単体時と揃える。
expect(result.tracePropagationTargets).toEqual([]);
expect((result.integrations as any)([
testIntegration('Http'),
testIntegration('Redis'),
testIntegration('Postgres'),
]).map((integration: TestIntegration) => integration.name)).toEqual(['Http', 'Postgres']);
});
test('does not disable Sentry trace propagation when explicitly enabled for OTel coexistence', () => {
const result = buildSentryOtlpInitOptions({
sentryConfig: {
enableNodeProfiling: false,
options: {},
},
otelConfig: {
serviceVersion: '2026.1.0',
propagateTraceToRemote: true,
},
otlpProcessor: { name: 'otlpProcessor' },
});
expect(result.tracePropagationTargets).toBeUndefined();
});
test('honors explicit tracePropagationTargets for OTel coexistence even without propagateTraceToRemote', () => {
const result = buildSentryOtlpInitOptions({
sentryConfig: {
enableNodeProfiling: false,
options: {
tracePropagationTargets: ['^https://internal\\.example/'],
},
},
otelConfig: { serviceVersion: '2026.1.0' },
otlpProcessor: { name: 'otlpProcessor' },
});
expect(result.tracePropagationTargets).toEqual(['^https://internal\\.example/']);
});
test('warns when OTel-only options are ignored in Sentry coexistence mode', () => {
const warn = vi.fn();
buildSentryOtlpInitOptions({
sentryConfig: {
enableNodeProfiling: false,
options: {},
},
otelConfig: {
serviceVersion: '2026.1.0',
sampleRate: 0.25,
resourceAttributes: {
'deployment.environment': 'production',
},
},
otlpProcessor: { name: 'otlpProcessor' },
warn,
});
expect(warn).toHaveBeenCalledWith(expect.stringContaining('otelForBackend.sampleRate is ignored'));
expect(warn).toHaveBeenCalledWith(expect.stringContaining('otelForBackend.resourceAttributes is ignored'));
});
});
describe('SentryTelemetryAdapter.shutdown', () => {
test('bounds Sentry.close() with a timeout so a stuck transport cannot hang process shutdown', async () => {
const close = vi.fn().mockResolvedValue(true);
vi.doMock('@sentry/node', () => ({
init: vi.fn(),
close,
}));
vi.doMock('@sentry/profiling-node', () => ({
nodeProfilingIntegration: vi.fn(),
}));
const adapter = await SentryTelemetryAdapter.create({
enableNodeProfiling: false,
options: {},
});
await adapter.shutdown();
expect(close).toHaveBeenCalledTimes(1);
expect(close).toHaveBeenCalledWith(expect.any(Number));
expect(close.mock.calls[0][0]).toBeGreaterThan(0);
vi.doUnmock('@sentry/node');
vi.doUnmock('@sentry/profiling-node');
});
});
describe('SentryTelemetryAdapter.createWithOtlpExport', () => {
test('registers the OTel diag logger before creating the OTLP exporter', async () => {
const init = vi.fn();
const close = vi.fn();
const setLogger = vi.fn();
const nodeProfilingIntegration = vi.fn();
const BatchSpanProcessor = vi.fn(function (this: { exporter: unknown }, exporter: unknown) {
this.exporter = exporter;
});
const OTLPTraceExporter = vi.fn(function (this: { options: unknown }, options: unknown) {
this.options = options;
});
vi.doMock('@sentry/node', () => ({
init,
close,
}));
vi.doMock('@sentry/profiling-node', () => ({
nodeProfilingIntegration,
}));
vi.doMock('@opentelemetry/api', () => ({
context: { active: vi.fn() },
diag: { setLogger },
DiagLogLevel: { WARN: 50 },
propagation: { inject: vi.fn(), extract: vi.fn() },
ROOT_CONTEXT: {},
SpanStatusCode: { ERROR: 2 },
trace: { getTracer: vi.fn(), getSpanContext: vi.fn() },
}));
vi.doMock('@opentelemetry/sdk-trace-base', () => ({
BatchSpanProcessor,
}));
vi.doMock('@opentelemetry/exporter-trace-otlp-proto', () => ({
OTLPTraceExporter,
}));
await SentryTelemetryAdapter.createWithOtlpExport({
enableNodeProfiling: false,
options: {},
}, {
serviceVersion: '2026.1.0',
endpoint: 'http://collector:4318/v1/traces',
});
expect(setLogger).toHaveBeenCalledWith(expect.objectContaining({
error: expect.any(Function),
warn: expect.any(Function),
}), {
logLevel: 50,
suppressOverrideMessage: true,
});
expect(OTLPTraceExporter).toHaveBeenCalledWith({
url: 'http://collector:4318/v1/traces',
});
expect(init).toHaveBeenCalledWith(expect.objectContaining({
openTelemetrySpanProcessors: [expect.any(Object)],
}));
vi.doUnmock('@sentry/node');
vi.doUnmock('@sentry/profiling-node');
vi.doUnmock('@opentelemetry/api');
vi.doUnmock('@opentelemetry/sdk-trace-base');
vi.doUnmock('@opentelemetry/exporter-trace-otlp-proto');
});
});

View File

@@ -0,0 +1,108 @@
/*
* SPDX-FileCopyrightText: syuilo and misskey-project
* SPDX-License-Identifier: AGPL-3.0-only
*/
import { describe, expect, test, vi } from 'vitest';
import { SpanKind, SpanStatusCode } from '@opentelemetry/api';
import { createHttpClientInstrumentation } from '@/core/telemetry/http-client-instrumentation.js';
function request() {
return {
method: 'POST',
protocol: 'https:',
path: '/inbox?token=secret',
host: 'remote.example',
getHeader: vi.fn((name: string) => name === 'host' ? 'user:password@remote.example:8443' : undefined),
};
}
describe('http-client-instrumentation', () => {
test('creates and completes a sanitized CLIENT span from diagnostics channels', () => {
const listeners = new Map<string, (message: unknown) => void>();
const span = {
end: vi.fn(),
recordException: vi.fn(),
setAttribute: vi.fn(),
setStatus: vi.fn(),
};
const tracer = { startSpan: vi.fn(() => span) } as any;
const unsubscribe = createHttpClientInstrumentation({
tracer,
spanKindClient: SpanKind.CLIENT,
spanStatusCodeError: SpanStatusCode.ERROR,
subscribe: (name, listener) => {
listeners.set(name, listener);
return () => listeners.delete(name);
},
});
const clientRequest = request();
listeners.get('http.client.request.created')!({ request: clientRequest });
listeners.get('http.client.response.finish')!({
request: clientRequest,
response: { statusCode: 201, httpVersion: '1.1' },
});
expect(tracer.startSpan).toHaveBeenCalledWith('POST', {
kind: SpanKind.CLIENT,
attributes: {
'http.request.method': 'POST',
'url.full': 'https://remote.example:8443/inbox',
'server.address': 'remote.example',
'server.port': 8443,
},
});
expect(span.setAttribute).toHaveBeenCalledWith('http.response.status_code', 201);
expect(span.setAttribute).toHaveBeenCalledWith('network.protocol.version', '1.1');
expect(span.end).toHaveBeenCalledTimes(1);
unsubscribe();
expect(listeners).toHaveLength(0);
});
test('records a request error and ends the span once', () => {
const listeners = new Map<string, (message: unknown) => void>();
const span = { end: vi.fn(), recordException: vi.fn(), setAttribute: vi.fn(), setStatus: vi.fn() };
const error = Object.assign(new Error('connection refused'), { code: 'ECONNREFUSED' });
const clientRequest = request();
createHttpClientInstrumentation({
tracer: { startSpan: vi.fn(() => span) } as any,
spanKindClient: SpanKind.CLIENT,
spanStatusCodeError: SpanStatusCode.ERROR,
subscribe: (name, listener) => {
listeners.set(name, listener);
return () => listeners.delete(name);
},
});
listeners.get('http.client.request.created')!({ request: clientRequest });
listeners.get('http.client.request.error')!({ request: clientRequest, error });
listeners.get('http.client.response.finish')!({ request: clientRequest, response: { statusCode: 200 } });
expect(span.recordException).toHaveBeenCalledWith(error);
expect(span.setAttribute).toHaveBeenCalledWith('error.type', 'ECONNREFUSED');
expect(span.setStatus).toHaveBeenCalledWith({ code: SpanStatusCode.ERROR });
expect(span.end).toHaveBeenCalledTimes(1);
});
test('records the response status code as error.type for an error response', () => {
const listeners = new Map<string, (message: unknown) => void>();
const span = { end: vi.fn(), recordException: vi.fn(), setAttribute: vi.fn(), setStatus: vi.fn() };
const clientRequest = request();
createHttpClientInstrumentation({
tracer: { startSpan: vi.fn(() => span) } as any,
spanKindClient: SpanKind.CLIENT,
spanStatusCodeError: SpanStatusCode.ERROR,
subscribe: (name, listener) => {
listeners.set(name, listener);
return () => listeners.delete(name);
},
});
listeners.get('http.client.request.created')!({ request: clientRequest });
listeners.get('http.client.response.finish')!({ request: clientRequest, response: { statusCode: 502 } });
expect(span.setAttribute).toHaveBeenCalledWith('error.type', '502');
expect(span.setStatus).toHaveBeenCalledWith({ code: SpanStatusCode.ERROR });
});
});

View File

@@ -0,0 +1,57 @@
/*
* SPDX-FileCopyrightText: syuilo and misskey-project
* SPDX-License-Identifier: AGPL-3.0-only
*/
import { beforeEach, describe, expect, test, vi } from 'vitest';
import type * as Bull from 'bullmq';
import { instrumentQueue } from '@/core/telemetry/queue-instrumentation.js';
const mocks = vi.hoisted(() => ({
injectTraceContext: vi.fn((carrier: Record<string, string>) => {
carrier['traceparent'] = '00-0123456789abcdef0123456789abcdef-0123456789abcdef-01';
}),
}));
vi.mock('@/core/telemetry/telemetry-registry.js', () => ({
injectTraceContext: mocks.injectTraceContext,
}));
describe('queue-instrumentation', () => {
beforeEach(() => {
mocks.injectTraceContext.mockClear();
});
test('injects the active trace context for add()', () => {
const add = vi.fn();
const queue = instrumentQueue({ add, addBulk: vi.fn() } as unknown as Bull.Queue<{ noteId: string }>);
const data = { noteId: '9d6b9a65-46c9-4e1b-a640-9589693893c9' };
queue.add('endedPollNotification', data);
expect(mocks.injectTraceContext).toHaveBeenCalledTimes(1);
expect(data).toMatchObject({
__misskeyTraceContext: {
traceparent: '00-0123456789abcdef0123456789abcdef-0123456789abcdef-01',
},
});
expect(add).toHaveBeenCalledWith('endedPollNotification', data, undefined);
});
test('injects every job passed to addBulk()', () => {
const addBulk = vi.fn();
const queue = instrumentQueue({ add: vi.fn(), addBulk } as unknown as Bull.Queue<{ to: string }>);
const jobs = [
{ name: 'deliver', data: { to: 'https://remote.example/inbox' } },
{ name: 'deliver', data: { to: 'https://remote2.example/inbox' } },
];
queue.addBulk(jobs);
expect(mocks.injectTraceContext).toHaveBeenCalledTimes(2);
expect(jobs).toEqual(expect.arrayContaining([
expect.objectContaining({ data: expect.objectContaining({ __misskeyTraceContext: expect.any(Object) }) }),
]));
expect(addBulk).toHaveBeenCalledWith(jobs);
});
});

View File

@@ -0,0 +1,132 @@
/*
* SPDX-FileCopyrightText: syuilo and misskey-project
* SPDX-License-Identifier: AGPL-3.0-only
*/
import { describe, expect, test, vi } from 'vitest';
import type { Context, SpanContext } from '@opentelemetry/api';
import { getQueueSpanContext, getQueueTraceContextMode, injectActiveTraceContext, injectQueueTraceContext } from '@/core/telemetry/queue-trace-context.js';
const rootContext = {} as Context;
const extractedContext = {} as Context;
const sourceSpanContext: SpanContext = {
traceId: '0123456789abcdef0123456789abcdef',
spanId: '0123456789abcdef',
traceFlags: 1,
isRemote: true,
};
function jobData() {
return {
name: 'deliver',
__misskeyTraceContext: {
traceparent: '00-0123456789abcdef0123456789abcdef-0123456789abcdef-01',
},
};
}
describe('queue-trace-context', () => {
test('stores only a non-empty carrier in the job data', () => {
const data = { noteId: '9d6b9a65-46c9-4e1b-a640-9589693893c9' };
injectQueueTraceContext(data, carrier => {
carrier['traceparent'] = '00-0123456789abcdef0123456789abcdef-0123456789abcdef-01';
});
expect(data).toMatchObject({
__misskeyTraceContext: {
traceparent: '00-0123456789abcdef0123456789abcdef-0123456789abcdef-01',
},
});
});
test('does not store an empty carrier when no active trace exists', () => {
const data = { noteId: '9d6b9a65-46c9-4e1b-a640-9589693893c9' };
injectQueueTraceContext(data, () => {});
expect(data).not.toHaveProperty('__misskeyTraceContext');
});
test('ignores non-object job data', () => {
const inject = vi.fn();
injectQueueTraceContext(null, inject);
injectQueueTraceContext('not a job object', inject);
expect(inject).not.toHaveBeenCalled();
});
test('injects the active context with the configured propagator', () => {
const activeContext = {} as Context;
const carrier = {};
const inject = vi.fn();
injectActiveTraceContext({
tracer: { startActiveSpan: vi.fn() } as any,
propagation: { inject, extract: vi.fn() } as any,
trace: { getSpanContext: vi.fn() },
getActiveContext: () => activeContext,
rootContext,
mode: 'link',
spanStatusCodeError: 2 as any,
}, carrier);
expect(inject).toHaveBeenCalledWith(activeContext, carrier);
});
test('starts a new root trace with a link by default', () => {
const extract = vi.fn(() => extractedContext);
const getSpanContext = vi.fn(() => sourceSpanContext);
const result = getQueueSpanContext(jobData(), {
rootContext,
propagation: { inject: vi.fn(), extract },
trace: { getSpanContext },
mode: 'link',
});
expect(extract).toHaveBeenCalledWith(rootContext, jobData().__misskeyTraceContext);
expect(result).toEqual({
options: {
root: true,
links: [{ context: sourceSpanContext }],
},
parentContext: rootContext,
});
});
test('uses the extracted context as the parent when parent mode is selected', () => {
const result = getQueueSpanContext(jobData(), {
rootContext,
propagation: { inject: vi.fn(), extract: () => extractedContext },
trace: { getSpanContext: () => sourceSpanContext },
mode: 'parent',
});
expect(result).toEqual({
options: {},
parentContext: extractedContext,
});
});
test('ignores malformed or missing carriers', () => {
const extract = vi.fn(() => extractedContext);
const deps = {
rootContext,
propagation: { inject: vi.fn(), extract },
trace: { getSpanContext: () => sourceSpanContext },
mode: 'link' as const,
};
expect(getQueueSpanContext({}, deps)).toBeUndefined();
expect(getQueueSpanContext({ __misskeyTraceContext: { traceparent: 1 } }, deps)).toBeUndefined();
expect(extract).not.toHaveBeenCalled();
});
test('defaults to link mode and rejects invalid configuration', () => {
expect(getQueueTraceContextMode(undefined)).toBe('link');
expect(getQueueTraceContextMode('parent')).toBe('parent');
expect(() => getQueueTraceContextMode('children')).toThrow('otelForBackend.jobTraceContextMode');
});
});

View File

@@ -0,0 +1,203 @@
/*
* SPDX-FileCopyrightText: syuilo and misskey-project
* SPDX-License-Identifier: AGPL-3.0-only
*/
import { describe, expect, test, vi } from 'vitest';
import { LogManager } from '@/logging/LogManager.js';
import type { LogBackend } from '@/logging/LogBackend.js';
import type { LogRecordInput } from '@/logging/types.js';
/** テストで使う最小構成のログ入力を作成します。 */
function createInput(level: LogRecordInput['level'] = 'info'): LogRecordInput {
return {
level,
message: 'message',
context: [
{ name: 'queue', color: 'red' },
{ name: 'deliver', color: 'blue' },
],
};
}
/** 実行環境を固定したLogManagerと、出力確認用の関数を作成します。 */
function createManager(options: {
quiet?: boolean;
verbose?: boolean;
nodeEnv?: string;
isPrimary?: boolean;
workerId?: number | null;
normalizationProfile?: 'standard' | 'detailed';
} = {}) {
const write = vi.fn<LogBackend['write']>();
const manager = new LogManager({ write }, {
now: () => new Date('2025-01-02T03:04:05.678Z'),
getProcessInfo: () => ({
processId: 1234,
isPrimary: options.isPrimary ?? true,
workerId: options.workerId ?? null,
}),
isQuiet: () => options.quiet ?? false,
isVerbose: () => options.verbose ?? false,
getNodeEnv: () => options.nodeEnv ?? 'development',
}, {
normalizationProfile: options.normalizationProfile,
});
return { manager, write };
}
describe('LogManager', () => {
test('adds logger and process metadata while preserving root-to-leaf context order', () => {
const { manager, write } = createManager();
const input = createInput();
manager.write(input);
expect(write).toHaveBeenCalledOnce();
expect(write).toHaveBeenCalledWith({
...input,
context: [
{ name: 'queue', color: 'red' },
{ name: 'deliver', color: 'blue' },
],
timestamp: '2025-01-02T03:04:05.678Z',
loggerName: 'queue.deliver',
processId: 1234,
isPrimary: true,
workerId: null,
});
expect(write.mock.calls[0][0].context).not.toBe(input.context);
});
test('records worker process metadata', () => {
const { manager, write } = createManager({ isPrimary: false, workerId: 7 });
manager.write(createInput());
expect(write.mock.calls[0][0]).toMatchObject({
processId: 1234,
isPrimary: false,
workerId: 7,
});
});
test('does not call the backend in quiet mode', () => {
const { manager, write } = createManager({ quiet: true, verbose: true });
manager.write(createInput('fatal'));
manager.write(createInput('debug'));
expect(write).not.toHaveBeenCalled();
});
test('writes debug logs outside production', () => {
const { manager, write } = createManager({ nodeEnv: 'development' });
manager.write(createInput('debug'));
expect(write).toHaveBeenCalledOnce();
});
test('suppresses debug logs in production by default', () => {
const { manager, write } = createManager({ nodeEnv: 'production' });
manager.write(createInput('debug'));
expect(write).not.toHaveBeenCalled();
});
test('writes debug logs in verbose production mode', () => {
const { manager, write } = createManager({ nodeEnv: 'production', verbose: true });
manager.write(createInput('debug'));
expect(write).toHaveBeenCalledOnce();
});
test('uses a replaced backend for subsequent records', () => {
const { manager, write } = createManager();
const replacementWrite = vi.fn<LogBackend['write']>();
manager.setBackend({ write: replacementWrite });
manager.write(createInput());
expect(write).not.toHaveBeenCalled();
expect(replacementWrite).toHaveBeenCalledOnce();
});
test('normalizes structured attributes and errors before writing', () => {
const { manager, write } = createManager();
const error = new TypeError('broken');
manager.write({
...createInput('error'),
eventName: 'api.endpoint.failed',
attributes: { i: 'secret', safe: 'value' },
error,
});
expect(write.mock.calls[0][0]).toMatchObject({
eventName: 'api.endpoint.failed',
attributes: { i: '[REDACTED]', safe: 'value' },
error: { type: 'TypeError', message: 'broken' },
});
});
test('does not pass raw structured values when normalization omits an error', () => {
const { manager, write } = createManager();
manager.write({
...createInput('error'),
attributes: { detail: 'value' },
error: null,
});
expect(write.mock.calls[0][0].attributes).toEqual({ detail: 'value' });
expect(write.mock.calls[0][0]).not.toHaveProperty('error');
});
test('keeps legacy data for the pretty output while serializing its Error separately', () => {
const { manager, write } = createManager();
const error = new Error('legacy failure');
const data = { detail: 'legacy', e: error };
manager.write({
...createInput('error'),
compatibility: { data },
});
expect(write.mock.calls[0][0].compatibility?.data).toBe(data);
expect(write.mock.calls[0][0]).toMatchObject({
error: { type: 'Error', message: 'legacy failure' },
});
expect(write.mock.calls[0][0].attributes).toBeUndefined();
});
test('supports the detailed normalization profile', () => {
const { manager, write } = createManager({ normalizationProfile: 'detailed' });
manager.write({
...createInput(),
attributes: { nested: { level1: { level2: { level3: { value: 'kept' } } } } },
});
expect(write.mock.calls[0][0].attributes).toEqual({
nested: { level1: { level2: { level3: { value: 'kept' } } } },
});
});
test('switches the normalization profile for existing loggers', () => {
const { manager, write } = createManager();
const nested = { level1: { level2: { level3: { level4: { level5: { level6: { value: 'kept' } } } } } } };
manager.write({ ...createInput(), attributes: nested });
manager.setNormalizationProfile('detailed');
manager.write({ ...createInput(), attributes: nested });
expect(write.mock.calls[0][0].attributes).toMatchObject({
level1: { level2: { level3: { level4: { level5: { level6: '[Truncated]' } } } } },
});
expect(write.mock.calls[1][0].attributes).toEqual(nested);
});
});

View File

@@ -0,0 +1,136 @@
/*
* SPDX-FileCopyrightText: syuilo and misskey-project
* SPDX-License-Identifier: AGPL-3.0-only
*/
import { describe, expect, test } from 'vitest';
import {
findLegacyLogError,
normalizeLogAttributes,
serializeLogError,
} from '@/logging/LogNormalizer.js';
describe('LogNormalizer', () => {
test('normalizes common non-JSON values', () => {
expect(normalizeLogAttributes({
date: new Date('2025-01-02T03:04:05.678Z'),
bigint: 123n,
infinity: Infinity,
undefinedValue: undefined,
})).toEqual({
bigint: '123',
date: '2025-01-02T03:04:05.678Z',
infinity: 'Infinity',
undefinedValue: '[Unsupported]: undefined',
});
});
test('marks unsupported objects and invalid dates without throwing', () => {
expect(normalizeLogAttributes({
map: new Map([['key', 'value']]),
invalidDate: new Date('invalid'),
})).toEqual({
invalidDate: '[Unsupported]: object access failed',
map: '[Unsupported]: object',
});
});
test('preserves __proto__ as a normal attribute key', () => {
const value = JSON.parse('{"__proto__":{"nested":"value"},"large":"' + 'x'.repeat(100) + '"}') as Record<string, unknown>;
const normalized = normalizeLogAttributes(value, { limits: { maxBytes: 64 } });
expect(Object.keys(normalized)).toContain('__proto__');
expect(normalized['__proto__']).toEqual({ nested: 'value' });
expect(Object.getPrototypeOf(normalized)).toBeNull();
});
test('redacts sensitive fields recursively, including the Misskey i token', () => {
expect(normalizeLogAttributes({
i: 'top-level-token',
request: {
Authorization: 'bearer-token',
password: 'password',
nested: [{ api_key: 'api-key' }],
},
visible: 'value',
})).toEqual({
i: '[REDACTED]',
request: {
Authorization: '[REDACTED]',
password: '[REDACTED]',
nested: [{ api_key: '[REDACTED]' }],
},
visible: 'value',
});
});
test('keeps cycles finite and marks depth and entry truncation', () => {
const cycle: Record<string, unknown> = { value: 'ok' };
cycle.self = cycle;
expect(normalizeLogAttributes({
cycle,
deep: { level1: { level2: { level3: 'value' } } },
}, {
limits: { maxDepth: 2, maxEntries: 10 },
})).toEqual({
cycle: { self: '[Circular]', value: 'ok' },
deep: { level1: '[Truncated]' },
});
expect(normalizeLogAttributes({ entries: { a: 1, b: 2, c: 3 } }, { limits: { maxEntries: 2 } })).toEqual({
entries: { a: 1, b: 2, '[Truncated]': '[Truncated]' },
});
});
test('uses the detailed profile while retaining the redaction policy', () => {
const value = { level1: { level2: { level3: { level4: 'value' } } }, token: 'secret' };
expect(normalizeLogAttributes(value, { limits: { maxDepth: 3 } })).toMatchObject({ level1: { level2: { level3: '[Truncated]' } }, token: '[REDACTED]' });
expect(normalizeLogAttributes(value, { profile: 'detailed' })).toEqual({
level1: { level2: { level3: { level4: 'value' } } },
token: '[REDACTED]',
});
});
test('keeps normalized attributes within the configured byte limit', () => {
const normalized = normalizeLogAttributes({ first: 'a'.repeat(100), second: 'b'.repeat(100) }, { limits: { maxBytes: 32 } });
expect(Buffer.byteLength(JSON.stringify(normalized), 'utf8')).toBeLessThanOrEqual(32);
});
test('truncates long multibyte strings without splitting characters', () => {
const normalized = normalizeLogAttributes({ value: '😀'.repeat(100) }, { limits: { maxStringBytes: 16, maxBytes: 100 } });
expect(Buffer.byteLength(JSON.stringify(normalized.value), 'utf8')).toBeLessThanOrEqual(16);
expect(JSON.stringify(normalized.value)).not.toContain('<27>');
});
test('serializes Error and its cause consistently', () => {
const cause = new Error('root cause');
const error = new TypeError('outer error', { cause });
expect(serializeLogError(error)).toMatchObject({
type: 'TypeError',
message: 'outer error',
cause: {
type: 'Error',
message: 'root cause',
},
});
});
test('keeps serialized Error output within its byte limit', () => {
const serialized = serializeLogError(new Error('a long message'), { limits: { maxBytes: 32 } });
expect(serialized).toBeDefined();
expect(Buffer.byteLength(JSON.stringify(serialized), 'utf8')).toBeLessThanOrEqual(32);
expect(serializeLogError(new Error('message'), { limits: { maxBytes: 20 } })).toBeUndefined();
});
test('finds Error values in legacy data', () => {
const error = new Error('legacy');
expect(findLegacyLogError({ e: error })).toBe(error);
expect(findLegacyLogError({ stack: 'not-an-error' })).toBeUndefined();
});
});

View File

@@ -0,0 +1,120 @@
/*
* SPDX-FileCopyrightText: syuilo and misskey-project
* SPDX-License-Identifier: AGPL-3.0-only
*/
import { beforeEach, describe, expect, test, vi } from 'vitest';
import type { LogRecordInput } from '@/logging/types.js';
const mocks = vi.hoisted(() => ({
write: vi.fn<(input: LogRecordInput) => void>(),
}));
vi.mock('@/logging/logging-runtime.js', () => ({
logManager: {
write: mocks.write,
},
}));
import Logger from '@/logger.js';
describe('Logger', () => {
beforeEach(() => {
mocks.write.mockReset();
});
test('passes immutable multi-level root-to-leaf context to the manager', () => {
const root = new Logger('root', 'red');
const child = root.createSubLogger('child', 'green');
const leaf = child.createSubLogger('leaf', 'blue');
leaf.info('from leaf');
root.info('from root');
expect(mocks.write.mock.calls[0][0]).toMatchObject({
level: 'info',
message: 'from leaf',
context: [
{ name: 'root', color: 'red' },
{ name: 'child', color: 'green' },
{ name: 'leaf', color: 'blue' },
],
});
expect(mocks.write.mock.calls[1][0].context).toEqual([
{ name: 'root', color: 'red' },
]);
});
test('maps succ to info with the legacy success presentation', () => {
new Logger('root').succ('completed', { count: 1 }, true);
expect(mocks.write).toHaveBeenCalledWith({
level: 'info',
message: 'completed',
context: [{ name: 'root', color: undefined }],
compatibility: {
legacyLevel: 'success',
important: true,
data: { count: 1 },
},
});
});
test('supports structured log input while preserving the context hierarchy', () => {
new Logger('root').createSubLogger('child').write({
level: 'error',
eventName: 'example.failed',
message: 'failed',
attributes: { id: 'id' },
error: new Error('broken'),
});
expect(mocks.write).toHaveBeenCalledWith(expect.objectContaining({
level: 'error',
eventName: 'example.failed',
context: [{ name: 'root', color: undefined }, { name: 'child', color: undefined }],
}));
});
test('uses Error.toString and adds the Error to existing data', () => {
const logger = new Logger('root');
const error = new TypeError('broken');
const data: Record<string, unknown> = { requestId: 'request' };
logger.error(error, data, true);
expect(data).toEqual({ requestId: 'request', e: error });
expect(mocks.write).toHaveBeenCalledWith({
level: 'error',
message: 'TypeError: broken',
context: [{ name: 'root', color: undefined }],
error,
compatibility: {
legacyLevel: undefined,
important: true,
data,
},
});
});
test('creates data containing the Error when none is supplied', () => {
const error = new Error('broken');
new Logger('root').error(error);
expect(mocks.write.mock.calls[0][0].compatibility?.data).toEqual({ e: error });
});
test('keeps public methods bound when called separately', () => {
const logger = new Logger('root');
const info = logger.info;
info('bound');
expect(mocks.write).toHaveBeenCalledWith(expect.objectContaining({
level: 'info',
message: 'bound',
context: [{ name: 'root', color: undefined }],
}));
});
});

View File

@@ -0,0 +1,167 @@
/*
* SPDX-FileCopyrightText: syuilo and misskey-project
* SPDX-License-Identifier: AGPL-3.0-only
*/
import { describe, expect, test, vi } from 'vitest';
import type { LogRecord } from '@/logging/types.js';
type Formatter = ((value: unknown) => string) & { white?: Formatter };
const chalkMock = vi.hoisted(() => {
const format = (name: string): Formatter => (value: unknown) => `<${name}>${String(value)}</${name}>`;
const bgRed = format('bgRed');
bgRed.white = format('bgRed.white');
const bgGreen = format('bgGreen');
bgGreen.white = format('bgGreen.white');
return {
red: format('red'),
yellow: format('yellow'),
green: format('green'),
gray: format('gray'),
blue: format('blue'),
white: format('white'),
bold: format('bold'),
bgRed,
bgGreen,
rgb: (red: number, green: number, blue: number) => format(`rgb:${red},${green},${blue}`),
};
});
vi.mock('chalk', () => ({
default: chalkMock,
}));
import { PrettyConsoleBackend } from '@/logging/PrettyConsoleBackend.js';
/** 見やすい形式のテストで使う共通のログを作成します。 */
function createRecord(overrides: Partial<LogRecord> = {}): LogRecord {
return {
level: 'info',
message: 'message',
context: [{ name: 'root' }],
timestamp: '2025-01-02T03:04:05.678Z',
loggerName: 'root',
processId: 1234,
isPrimary: true,
workerId: null,
...overrides,
};
}
describe('PrettyConsoleBackend', () => {
test.each([
{ level: 'error', label: '<red>ERR </red>', message: '<red>message</red>' },
{ level: 'warn', label: '<yellow>WARN</yellow>', message: '<yellow>message</yellow>' },
{ level: 'debug', label: '<gray>VERB</gray>', message: '<gray>message</gray>' },
{ level: 'info', label: '<blue>INFO</blue>', message: 'message' },
] as const)('formats the $level label and message', ({ level, label, message }) => {
const output = vi.fn();
const backend = new PrettyConsoleBackend({ output, withLogTime: () => false });
backend.write(createRecord({ level }));
expect(output).toHaveBeenCalledWith(`${label} *\t[<white>root</white>]\t${message}`);
});
test('formats legacy success as DONE while retaining the info severity', () => {
const output = vi.fn();
const backend = new PrettyConsoleBackend({ output, withLogTime: () => false });
backend.write(createRecord({
level: 'info',
compatibility: { legacyLevel: 'success' },
}));
expect(output).toHaveBeenCalledWith('<green>DONE</green> *\t[<white>root</white>]\t<green>message</green>');
});
test('formats contexts from root to leaf using their configured colors', () => {
const output = vi.fn();
const backend = new PrettyConsoleBackend({ output, withLogTime: () => false });
backend.write(createRecord({
context: [
{ name: 'root', color: 'red' },
{ name: 'child' },
{ name: 'leaf', color: 'blue' },
],
}));
expect(output).toHaveBeenCalledWith('<blue>INFO</blue> *\t[<rgb:255,0,0>root</rgb:255,0,0> <white>child</white> <rgb:0,0,255>leaf</rgb:0,0,255>]\tmessage');
});
test('prefixes the time derived from the record timestamp when enabled', () => {
const output = vi.fn();
const backend = new PrettyConsoleBackend({ output, withLogTime: () => true });
backend.write(createRecord());
expect(output).toHaveBeenCalledWith('<gray>03:04:05</gray> <blue>INFO</blue> *\t[<white>root</white>]\tmessage');
});
test('uses the worker id for a worker process', () => {
const output = vi.fn();
const backend = new PrettyConsoleBackend({ output, withLogTime: () => false });
backend.write(createRecord({ isPrimary: false, workerId: 7 }));
expect(output).toHaveBeenCalledWith('<blue>INFO</blue> 7\t[<white>root</white>]\tmessage');
});
test('bolds an important record and passes data as the second output argument', () => {
const output = vi.fn();
const data = { detail: 'value' };
const backend = new PrettyConsoleBackend({ output, withLogTime: () => false });
backend.write(createRecord({
level: 'error',
compatibility: { important: true, data },
}));
expect(output).toHaveBeenCalledWith(
'<bold><bgRed.white>ERR </bgRed.white> *\t[<white>root</white>]\t<red>message</red></bold>',
data,
);
});
test('treats fatal records as important errors', () => {
const output = vi.fn();
const backend = new PrettyConsoleBackend({ output, withLogTime: () => false });
backend.write(createRecord({ level: 'fatal' }));
expect(output).toHaveBeenCalledWith('<bold><bgRed.white>ERR </bgRed.white> *\t[<white>root</white>]\t<red>message</red></bold>');
});
test('omits null data from output arguments', () => {
const output = vi.fn();
const backend = new PrettyConsoleBackend({ output, withLogTime: () => false });
backend.write(createRecord({ compatibility: { data: null } }));
expect(output).toHaveBeenCalledTimes(1);
expect(output.mock.calls[0]).toHaveLength(1);
});
test('passes structured event details as normalized output data', () => {
const output = vi.fn();
const backend = new PrettyConsoleBackend({ output, withLogTime: () => false });
backend.write(createRecord({
eventName: 'api.endpoint.failed',
attributes: { 'api.endpoint': 'notes/show' },
error: { type: 'TypeError', message: 'broken' },
}));
expect(output).toHaveBeenCalledWith(
'<blue>INFO</blue> *\t[<white>root</white>]\tmessage',
{
eventName: 'api.endpoint.failed',
attributes: { 'api.endpoint': 'notes/show' },
error: { type: 'TypeError', message: 'broken' },
},
);
});
});

View File

@@ -0,0 +1,98 @@
/*
* SPDX-FileCopyrightText: syuilo and misskey-project
* SPDX-License-Identifier: AGPL-3.0-only
*/
import { describe, expect, test, vi } from 'vitest';
import { ApiCallService } from '@/server/api/ApiCallService.js';
import Logger from '@/logger.js';
import { envOption } from '@/env.js';
import { logManager } from '@/logging/logging-runtime.js';
import { PrettyConsoleBackend } from '@/logging/PrettyConsoleBackend.js';
import type { LogBackend } from '@/logging/LogBackend.js';
import type { LogRecord } from '@/logging/types.js';
/** API失敗ログを確認するための最小Fastify応答を作成します。 */
function createReply() {
return {
code: vi.fn(),
header: vi.fn(),
send: vi.fn(),
};
}
/** APIサービスの依存関係を最小限の仮実装へ差し替えます。 */
function createService() {
const authenticateService = {
authenticate: vi.fn().mockResolvedValue([null, null]),
};
const telemetryService = {
startSpan: vi.fn((_name: string, callback: () => unknown) => callback()),
captureMessage: vi.fn(),
};
const apiLoggerService = { logger: new Logger('api') };
const service = new ApiCallService(
{} as never,
{} as never,
{} as never,
authenticateService as never,
{} as never,
{} as never,
apiLoggerService as never,
telemetryService as never,
);
return { service, telemetryService };
}
describe('ApiCallService structured error logging', () => {
test('redacts API credentials and serializes the endpoint error', async () => {
const write = vi.fn<LogBackend['write']>();
logManager.setBackend({ write });
const previousQuiet = envOption.quiet;
envOption.quiet = false;
const { service, telemetryService } = createService();
try {
const reply = createReply();
const endpoint = {
name: 'notes/show',
meta: {},
params: {},
exec: vi.fn().mockRejectedValue(new TypeError('broken endpoint')),
};
const request = {
method: 'POST',
body: {
i: 'native-token',
password: 'password',
options: { visible: true },
},
query: {},
headers: {},
ip: '127.0.0.1',
};
await service.handleRequest(endpoint as never, request as never, reply as never);
const record = write.mock.calls[0][0] as LogRecord;
expect(record).toMatchObject({
eventName: 'api.endpoint.failed',
attributes: {
'api.endpoint': 'notes/show',
'api.params': {
i: '[REDACTED]',
password: '[REDACTED]',
options: { visible: true },
},
},
error: { type: 'TypeError', message: 'broken endpoint' },
});
expect(record.attributes?.['error.id']).toEqual(expect.any(String));
expect(telemetryService.captureMessage.mock.calls[0][1].extra).not.toHaveProperty('ps');
} finally {
service.dispose();
envOption.quiet = previousQuiet;
logManager.setBackend(new PrettyConsoleBackend({ output: () => undefined }));
}
});
});

View File

@@ -0,0 +1,53 @@
/*
* SPDX-FileCopyrightText: syuilo and misskey-project
* SPDX-License-Identifier: AGPL-3.0-only
*/
import { describe, expect, test, vi } from 'vitest';
import { shouldRegisterHttpServerInstrumentation, registerHttpServerInstrumentation } from '@/server/http-server-instrumentation.js';
const mocks = vi.hoisted(() => ({
plugin: vi.fn(),
instrumentation: vi.fn(),
}));
vi.mock('@fastify/otel', () => ({
FastifyOtelInstrumentation: class {
public plugin = mocks.plugin;
public constructor(options: unknown) {
mocks.instrumentation(options);
}
},
}));
describe('http-server-instrumentation', () => {
test('registers Fastify instrumentation when only OpenTelemetry is configured', async () => {
const plugin = vi.fn();
const fastify = { register: vi.fn().mockResolvedValue(undefined) };
mocks.plugin.mockReturnValue(plugin);
await registerHttpServerInstrumentation(fastify as any, { otelForBackend: {} } as any);
expect(mocks.instrumentation).toHaveBeenCalledTimes(1);
expect(fastify.register).toHaveBeenCalledWith(plugin);
const requestHook = mocks.instrumentation.mock.calls[0][0].requestHook;
const span = { updateName: vi.fn() };
requestHook(span, { method: 'POST', routeOptions: { url: '/notes/create' } });
expect(span.updateName).toHaveBeenCalledWith('POST /notes/create');
});
test('does not register duplicate request instrumentation with Sentry', async () => {
const fastify = { register: vi.fn() };
await registerHttpServerInstrumentation(fastify as any, { otelForBackend: {}, sentryForBackend: {} } as any);
expect(fastify.register).not.toHaveBeenCalled();
expect(shouldRegisterHttpServerInstrumentation({ otelForBackend: {}, sentryForBackend: {} } as any)).toBe(false);
});
test('does not register instrumentation without OpenTelemetry', () => {
expect(shouldRegisterHttpServerInstrumentation({} as any)).toBe(false);
});
});

View File

@@ -0,0 +1,108 @@
/*
* SPDX-FileCopyrightText: syuilo and misskey-project
* SPDX-License-Identifier: AGPL-3.0-only
*/
import { describe, expect, test, vi } from 'vitest';
import { installDatabaseInstrumentation, installInstrumentation } from '@/core/telemetry/database-instrumentation.js';
describe('database-instrumentation', () => {
test('does not install PostgreSQL instrumentation when disabled', async () => {
const uninstall = await installDatabaseInstrumentation({} as any, {
capturePgSpans: false,
capturePgStatement: false,
capturePgConnectionSpans: false,
});
expect(uninstall).toBeTypeOf('function');
expect(() => uninstall()).not.toThrow();
});
test('registers pg instrumentation with the active provider', () => {
const provider = {};
const pg = { setTracerProvider: vi.fn(), enable: vi.fn(), disable: vi.fn() };
const config = vi.fn();
const PgInstrumentation = class {
public constructor(options: unknown) {
config(options);
return pg as any;
}
};
const uninstall = installInstrumentation(provider as any, {
PgInstrumentation: PgInstrumentation as any,
}, {
capturePgStatement: false,
capturePgConnectionSpans: false,
});
expect(pg.setTracerProvider).toHaveBeenCalledWith(provider);
expect(pg.enable).toHaveBeenCalledOnce();
expect(config).toHaveBeenCalledWith(expect.objectContaining({
enhancedDatabaseReporting: false,
requireParentSpan: true,
ignoreConnectSpans: true,
requestHook: expect.any(Function),
}));
const span = { setAttribute: vi.fn() };
(config.mock.calls[0][0] as { requestHook: (span: any) => void }).requestHook(span);
expect(span.setAttribute).toHaveBeenCalledWith('db.statement', '[REDACTED]');
expect(span.setAttribute).toHaveBeenCalledWith('db.query.text', '[REDACTED]');
uninstall();
expect(pg.disable).toHaveBeenCalledOnce();
});
test('keeps SQL statement attributes when explicitly enabled', () => {
const pg = { setTracerProvider: vi.fn(), enable: vi.fn(), disable: vi.fn() };
const config = vi.fn();
installInstrumentation({} as any, {
PgInstrumentation: class {
public constructor(options: unknown) {
config(options);
return pg as any;
}
} as any,
}, {
capturePgStatement: true,
capturePgConnectionSpans: false,
});
expect(config.mock.calls[0][0]).not.toHaveProperty('requestHook');
});
test('enables connection spans when explicitly configured', () => {
const pg = { setTracerProvider: vi.fn(), enable: vi.fn(), disable: vi.fn() };
const config = vi.fn();
installInstrumentation({} as any, {
PgInstrumentation: class {
public constructor(options: unknown) {
config(options);
return pg as any;
}
} as any,
}, {
capturePgStatement: false,
capturePgConnectionSpans: true,
});
expect(config).toHaveBeenCalledWith(expect.objectContaining({
ignoreConnectSpans: false,
}));
});
test('cleans up both instrumentations when initialization fails', () => {
const pg = { setTracerProvider: vi.fn(), enable: vi.fn(), disable: vi.fn() };
pg.enable.mockImplementation(() => { throw new Error('failed'); });
expect(() => installInstrumentation({} as any, {
PgInstrumentation: class { public constructor() { return pg as any; } } as any,
})).toThrow('failed');
expect(pg.disable).toHaveBeenCalledOnce();
});
});

View File

@@ -0,0 +1,148 @@
/*
* SPDX-FileCopyrightText: syuilo and misskey-project
* SPDX-License-Identifier: AGPL-3.0-only
*/
import { describe, expect, test, vi } from 'vitest';
import { SpanKind, SpanStatusCode } from '@opentelemetry/api';
import { createRedisInstrumentation } from '@/core/telemetry/redis-instrumentation.js';
describe('redis-instrumentation', () => {
test('creates and completes a span for an ioredis command', () => {
let subscribers: any;
const unsubscribe = vi.fn();
const span = { end: vi.fn(), recordException: vi.fn(), setStatus: vi.fn(), setAttribute: vi.fn() };
const tracer = { startSpan: vi.fn(() => span) };
const uninstall = createRedisInstrumentation({
tracingChannel: () => ({ subscribe: (value) => { subscribers = value; }, unsubscribe }),
tracer: tracer as any,
getActiveSpan: () => ({}) as any,
spanKindClient: SpanKind.CLIENT,
spanStatusCodeError: SpanStatusCode.ERROR,
}, { captureCommandSpans: true });
const command = { command: 'get', args: ['key'], database: 0, serverAddress: 'redis', serverPort: 6379 };
subscribers.start(command);
subscribers.asyncEnd(command);
expect(tracer.startSpan).toHaveBeenCalledWith('get', expect.objectContaining({
kind: SpanKind.CLIENT,
attributes: expect.objectContaining({
'db.system.name': 'redis',
'db.operation.name': 'get',
'server.address': 'redis',
'server.port': 6379,
}),
}));
expect(span.end).toHaveBeenCalledOnce();
uninstall();
expect(unsubscribe).toHaveBeenCalledWith(subscribers);
});
test('records rejected Redis commands as errors', () => {
let subscribers: any;
const span = { end: vi.fn(), recordException: vi.fn(), setStatus: vi.fn(), setAttribute: vi.fn() };
createRedisInstrumentation({
tracingChannel: () => ({ subscribe: (value) => { subscribers = value; }, unsubscribe: vi.fn() }),
tracer: { startSpan: vi.fn(() => span) } as any,
getActiveSpan: () => ({}) as any,
spanKindClient: SpanKind.CLIENT,
spanStatusCodeError: SpanStatusCode.ERROR,
}, { captureCommandSpans: true });
const command = { command: 'get', args: ['key'], database: 0, serverAddress: 'redis', serverPort: undefined };
const error = Object.assign(new Error('ERR Redis failed'), { code: 'ERR' });
subscribers.start(command);
Object.assign(command, { error });
subscribers.error(command);
subscribers.asyncEnd(command);
expect(span.recordException).toHaveBeenCalledWith(error);
expect(span.recordException).toHaveBeenCalledOnce();
expect(span.setStatus).toHaveBeenCalledWith({ code: SpanStatusCode.ERROR, message: 'ERR Redis failed' });
expect(span.setAttribute).toHaveBeenCalledWith('error.type', 'ERR');
expect(span.setAttribute).toHaveBeenCalledWith('db.response.status_code', 'ERR');
expect(span.end).toHaveBeenCalledOnce();
});
test('does not create a root span when no parent span is active', () => {
let subscribers: any;
const tracer = { startSpan: vi.fn() };
createRedisInstrumentation({
tracingChannel: () => ({ subscribe: (value) => { subscribers = value; }, unsubscribe: vi.fn() }),
tracer: tracer as any,
getActiveSpan: () => undefined,
spanKindClient: SpanKind.CLIENT,
spanStatusCodeError: SpanStatusCode.ERROR,
}, { captureCommandSpans: true });
subscribers.start({ command: 'get', args: ['key'], database: 0, serverAddress: 'redis', serverPort: 6379 });
expect(tracer.startSpan).not.toHaveBeenCalled();
});
test('creates a root span when explicitly enabled', () => {
let subscribers: any;
const span = { end: vi.fn(), recordException: vi.fn(), setStatus: vi.fn(), setAttribute: vi.fn() };
const tracer = { startSpan: vi.fn(() => span) };
createRedisInstrumentation({
tracingChannel: () => ({ subscribe: (value) => { subscribers = value; }, unsubscribe: vi.fn() }),
tracer: tracer as any,
getActiveSpan: () => undefined,
spanKindClient: SpanKind.CLIENT,
spanStatusCodeError: SpanStatusCode.ERROR,
}, { captureCommandSpans: true, requireParentSpan: false });
const command = { command: 'get', args: ['key'], database: 0, serverAddress: 'redis', serverPort: 6379 };
subscribers.start(command);
subscribers.asyncEnd(command);
expect(tracer.startSpan).toHaveBeenCalledOnce();
expect(span.end).toHaveBeenCalledOnce();
});
test('records connection spans only when explicitly enabled', () => {
const subscribers = new Map<string, any>();
const unsubscribe = vi.fn();
const span = { end: vi.fn(), recordException: vi.fn(), setStatus: vi.fn(), setAttribute: vi.fn() };
const tracer = { startSpan: vi.fn(() => span) };
const uninstall = createRedisInstrumentation({
tracingChannel: (name) => ({ subscribe: (value) => { subscribers.set(name, value); }, unsubscribe }),
tracer: tracer as any,
getActiveSpan: () => undefined,
spanKindClient: SpanKind.CLIENT,
spanStatusCodeError: SpanStatusCode.ERROR,
}, { captureConnectionSpans: true });
const connection = { serverAddress: 'redis', serverPort: 6379 };
subscribers.get('ioredis:connect').start(connection);
subscribers.get('ioredis:connect').asyncEnd(connection);
expect(tracer.startSpan).toHaveBeenCalledWith('connect', expect.objectContaining({
kind: SpanKind.CLIENT,
attributes: expect.objectContaining({
'db.operation.name': 'connect',
'server.address': 'redis',
'server.port': 6379,
}),
}));
expect(span.end).toHaveBeenCalledOnce();
uninstall();
expect(unsubscribe).toHaveBeenCalledWith(subscribers.get('ioredis:connect'));
});
test('does not subscribe to Redis command diagnostics unless explicitly enabled', () => {
const tracingChannel = vi.fn();
createRedisInstrumentation({
tracingChannel,
tracer: { startSpan: vi.fn() } as any,
getActiveSpan: () => undefined,
spanKindClient: SpanKind.CLIENT,
spanStatusCodeError: SpanStatusCode.ERROR,
});
expect(tracingChannel).not.toHaveBeenCalled();
});
});

View File

@@ -0,0 +1,148 @@
/*
* SPDX-FileCopyrightText: syuilo and misskey-project
* SPDX-License-Identifier: AGPL-3.0-only
*/
import { beforeEach, describe, expect, test, vi } from 'vitest';
import type { Config } from '@/config.js';
const mocks = vi.hoisted(() => {
return {
sentryCreate: vi.fn(),
sentryCreateWithOtlpExport: vi.fn(),
otelCreate: vi.fn(),
};
});
vi.mock('@/core/telemetry/adapters/SentryTelemetryAdapter.js', () => ({
SentryTelemetryAdapter: {
create: mocks.sentryCreate,
createWithOtlpExport: mocks.sentryCreateWithOtlpExport,
},
}));
vi.mock('@/core/telemetry/adapters/OpenTelemetryAdapter.js', () => ({
OpenTelemetryAdapter: {
create: mocks.otelCreate,
},
}));
function config(overrides: Partial<Config>): Config {
return {
version: '2026.1.0',
...overrides,
} as Config;
}
describe('telemetry-registry', () => {
beforeEach(() => {
vi.resetModules();
mocks.sentryCreate.mockReset();
mocks.sentryCreateWithOtlpExport.mockReset();
mocks.otelCreate.mockReset();
mocks.sentryCreate.mockResolvedValue({ shutdown: vi.fn(), captureMessage: vi.fn(), startSpan: vi.fn() });
mocks.sentryCreateWithOtlpExport.mockResolvedValue({ shutdown: vi.fn(), captureMessage: vi.fn(), startSpan: vi.fn() });
mocks.otelCreate.mockResolvedValue({ shutdown: vi.fn(), captureMessage: vi.fn(), startSpan: vi.fn() });
});
test('uses OpenTelemetryAdapter when only otelForBackend is configured', async () => {
const { initTelemetry } = await import('@/core/telemetry/telemetry-registry.js');
const otelForBackend = { endpoint: 'http://collector:4318/v1/traces' };
await initTelemetry(config({ otelForBackend }));
expect(mocks.otelCreate).toHaveBeenCalledWith({
...otelForBackend,
serviceVersion: '2026.1.0',
});
expect(mocks.sentryCreate).not.toHaveBeenCalled();
expect(mocks.sentryCreateWithOtlpExport).not.toHaveBeenCalled();
});
test('adds OTLP export to the Sentry provider when both Sentry and OTel are configured', async () => {
const { initTelemetry } = await import('@/core/telemetry/telemetry-registry.js');
const sentryForBackend = { options: {}, enableNodeProfiling: false };
const otelForBackend = { endpoint: 'http://collector:4318/v1/traces' };
await initTelemetry(config({ sentryForBackend, otelForBackend }));
expect(mocks.sentryCreateWithOtlpExport).toHaveBeenCalledWith(sentryForBackend, {
...otelForBackend,
serviceVersion: '2026.1.0',
});
expect(mocks.sentryCreate).not.toHaveBeenCalled();
expect(mocks.otelCreate).not.toHaveBeenCalled();
});
test('startSpan runs fn directly when no adapter is registered', async () => {
const { startSpan } = await import('@/core/telemetry/telemetry-registry.js');
const fn = vi.fn().mockReturnValue('result');
expect(startSpan('test', fn)).toBe('result');
expect(fn).toHaveBeenCalledTimes(1);
});
test('startSpan delegates directly to the single registered adapter without extra wrapping', async () => {
const { initTelemetry, startSpan } = await import('@/core/telemetry/telemetry-registry.js');
const otelForBackend = { endpoint: 'http://collector:4318/v1/traces' };
const adapterStartSpan = vi.fn((_name: string, fn: () => string) => fn());
mocks.otelCreate.mockResolvedValue({ shutdown: vi.fn(), captureMessage: vi.fn(), startSpan: adapterStartSpan });
await initTelemetry(config({ otelForBackend }));
const fn = vi.fn().mockReturnValue('result');
expect(startSpan('test', fn)).toBe('result');
expect(adapterStartSpan).toHaveBeenCalledWith('test', fn);
});
test('startSpan wraps work through multiple registered adapters in order for future adapter combinations', async () => {
const { initTelemetry, startSpan } = await import('@/core/telemetry/telemetry-registry.js');
const calls: string[] = [];
mocks.sentryCreate.mockResolvedValue({
shutdown: vi.fn(),
captureMessage: vi.fn(),
startSpan: vi.fn((_name: string, fn: () => string) => {
calls.push('sentry:start');
const result = fn();
calls.push('sentry:end');
return result;
}),
});
mocks.otelCreate.mockResolvedValue({
shutdown: vi.fn(),
captureMessage: vi.fn(),
startSpan: vi.fn((_name: string, fn: () => string) => {
calls.push('otel:start');
const result = fn();
calls.push('otel:end');
return result;
}),
});
await initTelemetry(config({ sentryForBackend: { options: {}, enableNodeProfiling: false } }));
await initTelemetry(config({ otelForBackend: { endpoint: 'http://collector:4318/v1/traces' } }));
const fn = vi.fn(() => {
calls.push('work');
return 'result';
});
expect(startSpan('test', fn)).toBe('result');
expect(calls).toEqual(['sentry:start', 'otel:start', 'work', 'otel:end', 'sentry:end']);
});
test('shutdownTelemetry waits for every adapter even when one shutdown rejects', async () => {
const { initTelemetry, shutdownTelemetry } = await import('@/core/telemetry/telemetry-registry.js');
const sentryShutdown = vi.fn().mockRejectedValue(new Error('sentry failed'));
const otelShutdown = vi.fn().mockResolvedValue(undefined);
mocks.sentryCreate.mockResolvedValue({ shutdown: sentryShutdown, captureMessage: vi.fn(), startSpan: vi.fn() });
mocks.otelCreate.mockResolvedValue({ shutdown: otelShutdown, captureMessage: vi.fn(), startSpan: vi.fn() });
await initTelemetry(config({ sentryForBackend: { options: {}, enableNodeProfiling: false } }));
await initTelemetry(config({ otelForBackend: { endpoint: 'http://collector:4318/v1/traces' } }));
await expect(shutdownTelemetry()).resolves.toBeUndefined();
expect(sentryShutdown).toHaveBeenCalledTimes(1);
expect(otelShutdown).toHaveBeenCalledTimes(1);
});
});

View File

@@ -0,0 +1,59 @@
/*
* SPDX-FileCopyrightText: syuilo and misskey-project
* SPDX-License-Identifier: AGPL-3.0-only
*/
import { describe, expect, test, vi } from 'vitest';
describe('telemetry-shutdown', () => {
test('flushes telemetry once and exits on SIGTERM or SIGINT', async () => {
vi.resetModules();
const { installTelemetrySignalHandlers, isTelemetryShutdownInProgress } = await import('@/core/telemetry/telemetry-shutdown.js');
const handlers = new Map<string, () => Promise<void>>();
const processLike = {
once: vi.fn((event: string, handler: () => Promise<void>) => {
handlers.set(event, handler);
return processLike;
}),
};
const shutdownTelemetry = vi.fn().mockResolvedValue(undefined);
const exit = vi.fn();
installTelemetrySignalHandlers({ process: processLike, shutdownTelemetry, exit });
expect(processLike.once).toHaveBeenCalledWith('SIGTERM', expect.any(Function));
expect(processLike.once).toHaveBeenCalledWith('SIGINT', expect.any(Function));
expect(isTelemetryShutdownInProgress()).toBe(false);
await handlers.get('SIGTERM')!();
await handlers.get('SIGINT')!();
expect(isTelemetryShutdownInProgress()).toBe(true);
expect(shutdownTelemetry).toHaveBeenCalledTimes(1);
expect(exit).toHaveBeenCalledTimes(1);
expect(exit).toHaveBeenCalledWith(0);
});
test('exits even when telemetry shutdown rejects', async () => {
vi.resetModules();
const { installTelemetrySignalHandlers } = await import('@/core/telemetry/telemetry-shutdown.js');
const handlers = new Map<string, () => Promise<void>>();
const processLike = {
once: vi.fn((event: string, handler: () => Promise<void>) => {
handlers.set(event, handler);
return processLike;
}),
};
const exit = vi.fn();
installTelemetrySignalHandlers({
process: processLike,
shutdownTelemetry: vi.fn().mockRejectedValue(new Error('flush failed')),
exit,
});
await handlers.get('SIGINT')!();
expect(exit).toHaveBeenCalledWith(0);
});
});

View File

@@ -702,7 +702,7 @@ function animateFromSourceToNeutral() {
});
}
watch(thumbnailContentLoaded, () => {
watch([thumbnailContentLoaded, originalContentLoaded], () => {
animateFromSourceToNeutral();
}, { once: true });

View File

@@ -19,18 +19,15 @@ SPDX-License-Identifier: AGPL-3.0-only
<div :class="$style.newBg2"></div>
<button class="_button" :class="$style.newButton" @click="releaseQueue()"><i class="ti ti-circle-arrow-up"></i> {{ i18n.ts.newNote }}</button>
</div>
<component
:is="prefer.s.animation ? TransitionGroup : 'div'"
:class="$style.notes"
:enterActiveClass="$style.transition_x_enterActive"
:leaveActiveClass="$style.transition_x_leaveActive"
:enterFromClass="$style.transition_x_enterFrom"
:leaveToClass="$style.transition_x_leaveTo"
:moveClass="$style.transition_x_move"
tag="div"
>
<template v-for="(note, i) in paginator.items.value" :key="note.id">
<div v-if="i > 0 && isSeparatorNeeded(paginator.items.value[i -1].createdAt, note.createdAt)" :data-scroll-anchor="note.id">
<div :class="$style.notes">
<component
:is="prefer.s.animation ? MkStreamingTimelineItem : 'div'"
v-for="(note, i) in paginator.items.value"
v-bind="prefer.s.animation ? { animatingIn: note._shouldAnimateIn_, animatingOut: note._shouldAnimateOut_ } : {}"
:key="note.id"
:data-scroll-anchor="note.id"
>
<div v-if="i > 0 && isSeparatorNeeded(paginator.items.value[i -1].createdAt, note.createdAt) && paginator.items.value[i -1]._shouldAnimateOut_ !== true">
<div :class="$style.date">
<span><i class="ti ti-chevron-up"></i> {{ getSeparatorInfo(paginator.items.value[i -1].createdAt, note.createdAt)?.prevText }}</span>
<span style="height: 1em; width: 1px; background: var(--MI_THEME-divider);"></span>
@@ -38,15 +35,15 @@ SPDX-License-Identifier: AGPL-3.0-only
</div>
<MkNote :class="$style.note" :note="note" :withHardMute="true"/>
</div>
<div v-else-if="note._shouldInsertAd_" :data-scroll-anchor="note.id">
<div v-else-if="note._shouldInsertAd_">
<MkNote :class="$style.note" :note="note" :withHardMute="true"/>
<div :class="$style.ad">
<MkAd :preferForms="['horizontal', 'horizontal-big']"/>
</div>
</div>
<MkNote v-else :class="$style.note" :note="note" :withHardMute="true" :data-scroll-anchor="note.id"/>
</template>
</component>
<MkNote v-else :class="$style.note" :note="note" :withHardMute="true"/>
</component>
</div>
<button v-show="paginator.canFetchOlder.value" key="_more_" v-appear="prefer.s.enableInfiniteScroll ? paginator.fetchOlder : null" :disabled="paginator.fetchingOlder.value" class="_button" :class="$style.more" @click="paginator.fetchOlder">
<div v-if="!paginator.fetchingOlder.value">{{ i18n.ts.loadMore }}</div>
<MkLoading v-else :inline="true"/>
@@ -56,7 +53,7 @@ SPDX-License-Identifier: AGPL-3.0-only
</template>
<script lang="ts" setup>
import { computed, watch, onUnmounted, provide, useTemplateRef, TransitionGroup, onMounted, shallowRef, ref, markRaw } from 'vue';
import { computed, watch, onUnmounted, provide, useTemplateRef, onMounted, markRaw } from 'vue';
import * as Misskey from 'misskey-js';
import { useInterval } from '@@/js/use-interval.js';
import { useDocumentVisibility } from '@@/js/use-document-visibility.js';
@@ -72,10 +69,10 @@ import { instance } from '@/instance.js';
import { prefer } from '@/preferences.js';
import { store } from '@/store.js';
import MkNote from '@/components/MkNote.vue';
import MkButton from '@/components/MkButton.vue';
import MkStreamingTimelineItem, { ITEM_REMOVAL_MS } from '@/components/MkStreamingTimelineItem.vue';
import { i18n } from '@/i18n.js';
import { DI } from '@/di.js';
import { globalEvents, useGlobalEvent } from '@/events.js';
import { useGlobalEvent } from '@/events.js';
import { isSeparatorNeeded, getSeparatorInfo } from '@/utility/timeline-date-separate.js';
import { Paginator } from '@/utility/paginator.js';
@@ -104,6 +101,8 @@ provide('inTimeline', true);
provide('tl_withSensitive', computed(() => props.withSensitive));
provide(DI.inChannel, computed(() => props.src === 'channel' ? props.channel ?? null : null));
const itemRemovalDelay = prefer.s.animation ? ITEM_REMOVAL_MS : false;
let paginator: IPaginator<Misskey.entities.Note>;
if (props.src === 'antenna') {
@@ -112,6 +111,7 @@ if (props.src === 'antenna') {
antennaId: props.antenna!,
})),
useShallowRef: true,
itemRemovalDelay,
}));
} else if (props.src === 'home') {
paginator = markRaw(new Paginator('notes/timeline', {
@@ -120,6 +120,7 @@ if (props.src === 'antenna') {
withFiles: props.onlyFiles ? true : undefined,
})),
useShallowRef: true,
itemRemovalDelay,
}));
} else if (props.src === 'local') {
paginator = markRaw(new Paginator('notes/local-timeline', {
@@ -129,6 +130,7 @@ if (props.src === 'antenna') {
withFiles: props.onlyFiles ? true : undefined,
})),
useShallowRef: true,
itemRemovalDelay,
}));
} else if (props.src === 'social') {
paginator = markRaw(new Paginator('notes/hybrid-timeline', {
@@ -138,6 +140,7 @@ if (props.src === 'antenna') {
withFiles: props.onlyFiles ? true : undefined,
})),
useShallowRef: true,
itemRemovalDelay,
}));
} else if (props.src === 'global') {
paginator = markRaw(new Paginator('notes/global-timeline', {
@@ -146,10 +149,12 @@ if (props.src === 'antenna') {
withFiles: props.onlyFiles ? true : undefined,
})),
useShallowRef: true,
itemRemovalDelay,
}));
} else if (props.src === 'mentions') {
paginator = markRaw(new Paginator('notes/mentions', {
useShallowRef: true,
itemRemovalDelay,
}));
} else if (props.src === 'directs') {
paginator = markRaw(new Paginator('notes/mentions', {
@@ -157,6 +162,7 @@ if (props.src === 'antenna') {
visibility: 'specified',
},
useShallowRef: true,
itemRemovalDelay,
}));
} else if (props.src === 'list') {
paginator = markRaw(new Paginator('notes/user-list-timeline', {
@@ -166,6 +172,7 @@ if (props.src === 'antenna') {
listId: props.list!,
})),
useShallowRef: true,
itemRemovalDelay,
}));
} else if (props.src === 'channel') {
paginator = markRaw(new Paginator('channels/timeline', {
@@ -173,6 +180,7 @@ if (props.src === 'antenna') {
channelId: props.channel!,
})),
useShallowRef: true,
itemRemovalDelay,
}));
} else if (props.src === 'role') {
paginator = markRaw(new Paginator('roles/notes', {
@@ -180,6 +188,7 @@ if (props.src === 'antenna') {
roleId: props.role!,
})),
useShallowRef: true,
itemRemovalDelay,
}));
} else {
throw new Error('Unrecognized timeline type: ' + props.src);
@@ -428,45 +437,16 @@ defineExpose({
</script>
<style lang="scss" module>
.transition_x_move {
transition: transform 0.7s cubic-bezier(0.23, 1, 0.32, 1);
}
.transition_x_enterActive {
transition: transform 0.7s cubic-bezier(0.23, 1, 0.32, 1), opacity 0.7s cubic-bezier(0.23, 1, 0.32, 1);
&.note,
.note {
/* Skip Note Rendering有効時、TransitionGroupでnoteを追加するときに一瞬がくっとなる問題を抑制する */
content-visibility: visible !important;
}
}
.transition_x_leaveActive {
transition: height 0.2s cubic-bezier(0,.5,.5,1), opacity 0.2s cubic-bezier(0,.5,.5,1);
}
.transition_x_enterFrom {
opacity: 0;
transform: translateY(max(-64px, -100%));
}
@supports (interpolate-size: allow-keywords) {
.transition_x_leaveTo {
interpolate-size: allow-keywords; // heightのtransitionを動作させるために必要
height: 0;
}
}
.transition_x_leaveTo {
opacity: 0;
}
.notes {
container-type: inline-size;
background: var(--MI_THEME-panel);
}
.date,
.note {
box-sizing: border-box;
}
.note:not(:empty) {
border-bottom: solid 0.5px var(--MI_THEME-divider);
}

View File

@@ -14,16 +14,15 @@ SPDX-License-Identifier: AGPL-3.0-only
</div>
<div v-else ref="rootEl">
<component
:is="prefer.s.animation ? TransitionGroup : 'div'" :class="[$style.notifications]"
:enterActiveClass="$style.transition_x_enterActive"
:leaveActiveClass="$style.transition_x_leaveActive"
:enterFromClass="$style.transition_x_enterFrom"
:leaveToClass="$style.transition_x_leaveTo"
:moveClass="$style.transition_x_move"
tag="div"
>
<div v-for="(notification, i) in paginator.items.value" :key="notification.id" :data-scroll-anchor="notification.id" :class="$style.item">
<div :class="$style.notifications">
<component
:is="prefer.s.animation ? MkStreamingTimelineItem : 'div'"
v-for="(notification, i) in paginator.items.value"
v-bind="prefer.s.animation ? { animatingIn: notification._shouldAnimateIn_, animatingOut: notification._shouldAnimateOut_ } : {}"
:key="notification.id"
:data-scroll-anchor="notification.id"
:class="$style.item"
>
<div v-if="i > 0 && isSeparatorNeeded(paginator.items.value[i -1].createdAt, notification.createdAt)" :class="$style.date">
<span><i class="ti ti-chevron-up"></i> {{ getSeparatorInfo(paginator.items.value[i -1].createdAt, notification.createdAt)?.prevText }}</span>
<span style="height: 1em; width: 1px; background: var(--MI_THEME-divider);"></span>
@@ -31,8 +30,8 @@ SPDX-License-Identifier: AGPL-3.0-only
</div>
<MkNote v-if="['reply', 'quote', 'mention'].includes(notification.type) && 'note' in notification" :class="$style.content" :note="notification.note" :withHardMute="true"/>
<XNotification v-else :class="$style.content" :notification="notification" :withTime="true" :full="true"/>
</div>
</component>
</component>
</div>
<button v-show="paginator.canFetchOlder.value" key="_more_" v-appear="prefer.s.enableInfiniteScroll ? paginator.fetchOlder : null" :disabled="paginator.fetchingOlder.value" class="_button" :class="$style.more" @click="paginator.fetchOlder">
<div v-if="!paginator.fetchingOlder.value">{{ i18n.ts.loadMore }}</div>
<MkLoading v-else/>
@@ -42,7 +41,7 @@ SPDX-License-Identifier: AGPL-3.0-only
</template>
<script lang="ts" setup>
import { onUnmounted, onMounted, computed, useTemplateRef, TransitionGroup, markRaw, watch } from 'vue';
import { onUnmounted, onMounted, computed, useTemplateRef, markRaw, watch } from 'vue';
import * as Misskey from 'misskey-js';
import { notificationTypes } from 'misskey-js';
import { useInterval } from '@@/js/use-interval.js';
@@ -53,6 +52,7 @@ import MkNote from '@/components/MkNote.vue';
import { useStream } from '@/stream.js';
import { i18n } from '@/i18n.js';
import MkPullToRefresh from '@/components/MkPullToRefresh.vue';
import MkStreamingTimelineItem, { ITEM_REMOVAL_MS } from '@/components/MkStreamingTimelineItem.vue';
import { prefer } from '@/preferences.js';
import { store } from '@/store.js';
import { isSeparatorNeeded, getSeparatorInfo } from '@/utility/timeline-date-separate.js';
@@ -64,16 +64,20 @@ const props = defineProps<{
const rootEl = useTemplateRef('rootEl');
const itemRemovalDelay = prefer.s.animation ? ITEM_REMOVAL_MS : false;
const paginator = prefer.s.useGroupedNotifications ? markRaw(new Paginator('i/notifications-grouped', {
limit: 20,
computedParams: computed(() => ({
excludeTypes: props.excludeTypes ?? undefined,
})),
itemRemovalDelay,
})) : markRaw(new Paginator('i/notifications', {
limit: 20,
computedParams: computed(() => ({
excludeTypes: props.excludeTypes ?? undefined,
})),
itemRemovalDelay,
}));
const MIN_POLLING_INTERVAL = 1000 * 10;
@@ -186,38 +190,8 @@ defineExpose({
</script>
<style lang="scss" module>
.transition_x_move {
transition: transform 0.7s cubic-bezier(0.23, 1, 0.32, 1);
}
.transition_x_enterActive {
transition: transform 0.7s cubic-bezier(0.23, 1, 0.32, 1), opacity 0.7s cubic-bezier(0.23, 1, 0.32, 1);
&.content,
.content {
/* Skip Note Rendering有効時、TransitionGroupで通知を追加するときに一瞬がくっとなる問題を抑制する */
content-visibility: visible !important;
}
}
.transition_x_leaveActive {
transition: height 0.2s cubic-bezier(0,.5,.5,1), opacity 0.2s cubic-bezier(0,.5,.5,1);
}
.transition_x_enterFrom {
opacity: 0;
transform: translateY(max(-64px, -100%));
}
@supports (interpolate-size: allow-keywords) {
.transition_x_enterFrom {
interpolate-size: allow-keywords; // heightのtransitionを動作させるために必要
height: 0;
}
}
.transition_x_leaveTo {
opacity: 0;
.item {
border-bottom: solid 0.5px var(--MI_THEME-divider);
}
.notifications {
@@ -225,10 +199,6 @@ defineExpose({
background: var(--MI_THEME-panel);
}
.item {
border-bottom: solid 0.5px var(--MI_THEME-divider);
}
.date {
display: flex;
font-size: 85%;

View File

@@ -0,0 +1,117 @@
<!--
SPDX-FileCopyrightText: syuilo and misskey-project
SPDX-License-Identifier: AGPL-3.0-only
-->
<template>
<div
ref="rootEl"
:class="[$style.root, {
[$style.enter]: animatingIn,
[$style.leave]: animatingOut,
[$style.animating]: animating,
}]"
@animationstart="animating = true"
@animationend="animating = false"
>
<div ref="innerEl">
<slot></slot>
</div>
</div>
</template>
<script lang="ts">
export const ITEM_REMOVAL_MS = 200;
const supportsInterpolateSize = CSS.supports('interpolate-size: allow-keywords');
let resizeObserver: ResizeObserver | null = null;
if (!supportsInterpolateSize) {
resizeObserver = new ResizeObserver((entries) => {
for (const entry of entries) {
const target = entry.target as HTMLElement;
const root = target.parentElement;
if (root != null) {
root.style.setProperty('--child-height', `${entry.contentRect.height}px`);
}
}
});
}
</script>
<script setup lang="ts">
import { useTemplateRef, onMounted, onBeforeUnmount, ref } from 'vue';
const props = defineProps<{
animatingIn?: boolean;
animatingOut?: boolean;
}>();
const rootEl = useTemplateRef('rootEl');
const innerEl = useTemplateRef('innerEl');
const animating = ref(false);
onMounted(() => {
if (resizeObserver != null && rootEl.value != null && innerEl.value != null) {
resizeObserver.observe(innerEl.value);
rootEl.value.style.setProperty('--child-height', `${innerEl.value.getBoundingClientRect().height}px`);
}
});
onBeforeUnmount(() => {
if (resizeObserver != null && innerEl.value != null) {
resizeObserver.unobserve(innerEl.value);
}
});
</script>
<style module lang="scss">
.animating {
overflow: clip;
}
.inner {
display: flow-root;
}
.enter {
animation: enterAnim 0.7s cubic-bezier(0.23, 1, 0.32, 1) both;
}
.leave {
animation: leaveAnim 0.2s cubic-bezier(0,.5,.5,1) both;
}
@supports (interpolate-size: allow-keywords) {
.root {
interpolate-size: allow-keywords;
}
}
@keyframes enterAnim {
from {
height: 0;
opacity: 0;
transform: translateY(max(-64px, -100%));
}
to {
height: var(--child-height, auto);
opacity: 1;
transform: translateY(0);
}
}
@keyframes leaveAnim {
from {
height: var(--child-height, auto);
opacity: 1;
}
to {
height: 0;
opacity: 0;
}
}
</style>

View File

@@ -72,6 +72,8 @@ misskeyApiGet('federation/instances', {
right: 0;
width: 80vw; // 100%からshapeの幅を引いている
height: 100vh;
// 固定レイヤがホイール操作を奪い、コンテンツ列以外の上でページをスクロールできなくなるのを防ぐ (issue #17680)
pointer-events: none;
}
.tl {
@@ -100,6 +102,7 @@ misskeyApiGet('federation/instances', {
height: 100vh;
background: var(--MI_THEME-accent);
clip-path: polygon(0% 0%, 45% 0%, 20% 100%, 0% 100%);
pointer-events: none; // 装飾レイヤ。ホイール操作を透過させる (→ .bg 参照)
}
.shape2 {
position: fixed;
@@ -110,6 +113,7 @@ misskeyApiGet('federation/instances', {
background: var(--MI_THEME-accent);
clip-path: polygon(0% 0%, 25% 0%, 35% 100%, 0% 100%);
opacity: 0.5;
pointer-events: none; // 装飾レイヤ。ホイール操作を透過させる (→ .bg 参照)
}
.logoWrapper {

View File

@@ -36,6 +36,8 @@ import { instance as meta } from '@/instance.js';
right: 0;
width: 100vw;
height: 100vh;
// 固定レイヤがホイール操作を奪い、コンテンツ列以外の上でページをスクロールできなくなるのを防ぐ (issue #17680)
pointer-events: none;
}
.logoWrapper {

View File

@@ -17,6 +17,8 @@ export type MisskeyEntity = {
id: string;
createdAt: string;
_shouldInsertAd_?: boolean;
_shouldAnimateIn_?: boolean;
_shouldAnimateOut_?: boolean;
};
type AbsEndpointType = {
@@ -107,6 +109,8 @@ export class Paginator<
private canFetchDetection: 'safe' | 'limit' | null = null;
private aheadQueue: T[] = [];
private useShallowRef: SRef;
private itemRemovalDelay: number | false;
private removalTimers = new Map<string, number>();
// 配列内の要素をどのような順序で並べるか
// newest: 新しいものが先頭 (default)
@@ -138,6 +142,9 @@ export class Paginator<
useShallowRef?: SRef;
// アイテム削除時にアニメーションを待つ時間 (ms)
itemRemovalDelay?: number | false;
canSearch?: boolean;
searchParamName?: keyof E['req'];
}) {
@@ -160,6 +167,7 @@ export class Paginator<
this.noPaging = props.noPaging ?? false;
this.offsetMode = props.offsetMode ?? false;
this.canSearch = props.canSearch ?? false;
this.itemRemovalDelay = props.itemRemovalDelay ?? false;
this.searchParamName = props.searchParamName ?? 'search';
this.getNewestId = this.getNewestId.bind(this);
@@ -191,6 +199,7 @@ export class Paginator<
}
public async init(): Promise<void> {
this.clearRemovalTimers();
this.items.value = [];
this.aheadQueue = [];
this.queuedAheadItemsCount.value = 0;
@@ -384,6 +393,8 @@ export class Paginator<
public prepend(item: T): void {
if (this.items.value.some(x => x.id === item.id)) return;
item._shouldAnimateIn_ = true;
item._shouldAnimateOut_ = false;
this.items.value.unshift(item);
this.trim(false);
if (this.useShallowRef) triggerRef(this.items);
@@ -399,6 +410,9 @@ export class Paginator<
public releaseQueue(): void {
if (this.aheadQueue.length === 0) return; // これやらないと余計なre-renderが走る
for (const item of this.aheadQueue) {
item._shouldAnimateIn_ = true;
}
this.unshiftItems(this.aheadQueue);
this.aheadQueue = [];
this.queuedAheadItemsCount.value = 0;
@@ -407,13 +421,43 @@ export class Paginator<
public removeItem(id: string): void {
// TODO: queueからも消す
if (this.itemRemovalDelay === false) {
const index = this.items.value.findIndex(x => x.id === id);
if (index !== -1) {
this.items.value.splice(index, 1);
if (this.useShallowRef) triggerRef(this.items);
}
return;
}
const index = this.items.value.findIndex(x => x.id === id);
if (index !== -1) {
this.items.value.splice(index, 1);
if (this.useShallowRef) triggerRef(this.items);
const item = this.items.value[index]!;
item._shouldAnimateOut_ = true;
if (this.useShallowRef) triggerRef(this.items);
if (this.removalTimers.has(id)) return;
this.removalTimers.set(id, window.setTimeout(() => {
this.removalTimers.delete(id);
const currentIndex = this.items.value.findIndex(x => x.id === id);
if (currentIndex !== -1) {
this.items.value.splice(currentIndex, 1);
if (this.useShallowRef) triggerRef(this.items);
}
}, this.itemRemovalDelay + 20)); // アニメーション終了からやや余裕をもたせる
}
}
private clearRemovalTimers(): void {
for (const timer of this.removalTimers.values()) {
window.clearTimeout(timer);
}
this.removalTimers.clear();
}
public updateItem(id: string, updater: (item: T) => T): void {
// TODO: queueのも更新

View File

@@ -1,7 +1,7 @@
{
"type": "module",
"name": "misskey-js",
"version": "2026.7.0-alpha.3",
"version": "2026.7.0-alpha.5",
"description": "Misskey SDK for JavaScript",
"license": "MIT",
"main": "./built/index.js",

409
pnpm-lock.yaml generated
View File

@@ -6,6 +6,7 @@ settings:
overrides:
'@aiscript-dev/aiscript-languageserver': '-'
bullmq>ioredis: 5.11.1
chokidar: 5.0.0
lodash: 4.18.1
@@ -89,6 +90,9 @@ importers:
'@fastify/multipart':
specifier: 10.0.0
version: 10.0.0
'@fastify/otel':
specifier: 0.19.0
version: 0.19.0(@opentelemetry/api@1.9.1)
'@fastify/static':
specifier: 9.1.3
version: 9.1.3
@@ -119,6 +123,30 @@ importers:
'@nestjs/testing':
specifier: 11.1.27
version: 11.1.27(@nestjs/common@11.1.27(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.27)(@nestjs/platform-express@11.1.27)
'@opentelemetry/api':
specifier: 1.9.1
version: 1.9.1
'@opentelemetry/core':
specifier: 2.8.0
version: 2.8.0(@opentelemetry/api@1.9.1)
'@opentelemetry/exporter-trace-otlp-proto':
specifier: 0.214.0
version: 0.214.0(@opentelemetry/api@1.9.1)
'@opentelemetry/instrumentation-pg':
specifier: 0.72.0
version: 0.72.0(@opentelemetry/api@1.9.1)
'@opentelemetry/resources':
specifier: 2.7.1
version: 2.7.1(@opentelemetry/api@1.9.1)
'@opentelemetry/sdk-trace-base':
specifier: 2.7.1
version: 2.7.1(@opentelemetry/api@1.9.1)
'@opentelemetry/sdk-trace-node':
specifier: 2.7.1
version: 2.7.1(@opentelemetry/api@1.9.1)
'@opentelemetry/semantic-conventions':
specifier: 1.40.0
version: 1.40.0
'@oxc-project/runtime':
specifier: 0.137.0
version: 0.137.0
@@ -127,10 +155,10 @@ importers:
version: 1.7.0
'@sentry/node':
specifier: 10.62.0
version: 10.62.0(@opentelemetry/core@2.7.1(@opentelemetry/api@1.9.1))
version: 10.62.0(@opentelemetry/core@2.8.0(@opentelemetry/api@1.9.1))
'@sentry/profiling-node':
specifier: 10.62.0
version: 10.62.0(@opentelemetry/core@2.7.1(@opentelemetry/api@1.9.1))
version: 10.62.0(@opentelemetry/core@2.8.0(@opentelemetry/api@1.9.1))
'@simplewebauthn/server':
specifier: 13.3.2
version: 13.3.2
@@ -1834,6 +1862,11 @@ packages:
'@fastify/multipart@10.0.0':
resolution: {integrity: sha512-pUx3Z1QStY7E7kwvDTIvB6P+rF5lzP+iqPgZyJyG3yBJVPvQaZxzDHYbQD89rbY0ciXrMOyGi8ezHDVexLvJDA==}
'@fastify/otel@0.19.0':
resolution: {integrity: sha512-gf2S5IhN72Jqi/09wOmWAsKscG/AwXSq9rrESnTVBIkMKl8Y+7FqA7bzf9OEa5/H8oxugc7belJOTURb5tZXuQ==}
peerDependencies:
'@opentelemetry/api': ^1.9.0
'@fastify/proxy-addr@5.1.0':
resolution: {integrity: sha512-INS+6gh91cLUjB+PVHfu1UqcB76Sqtpyp7bnL+FYojhjygvOPA9ctiD/JDKsyD9Xgu4hUhCSJBPig/w7duNajw==}
@@ -2228,9 +2261,6 @@ packages:
'@ioredis/commands@1.10.0':
resolution: {integrity: sha512-UmeW7z4LfctwoQ5wkhVzgq8tXkreED2xZGpX+Bg+zA+WJFZCT6c062AfCK/Dfk81xZnnwdhJCUMkitihRaoC2Q==}
'@ioredis/commands@1.5.1':
resolution: {integrity: sha512-JH8ZL/ywcJyR9MmJ5BNqZllXNZQqQbnVZOqpPQqE1vHiFgAw4NHbvE0FOduNU8IX9babitBT46571OnPTT0Zcw==}
'@isaacs/cliui@8.0.2':
resolution: {integrity: sha512-O8jcjabXaleOG9DQ0+ARXWZBTfnP4WNAqzuiJK7ll44AmxGKv/J2M4TPjxjY3znBCfvBXFzucm1twdyFybFqEA==}
engines: {node: '>=12'}
@@ -2541,38 +2571,136 @@ packages:
resolution: {integrity: sha512-40lSJeqYO8Uz2Yj7u94/SJWE/wONa7rmMKjI1ZcIjgf3MHNHv1OZUCrCETGuaRF62d5pQD1wKIW+L4lmSMTzZA==}
engines: {node: '>=8.0.0'}
'@opentelemetry/api-logs@0.218.0':
resolution: {integrity: sha512-fmEWp5kXlGEc3i/lR698Hz41DfGyN4Tbe4g7L1AxSc7fF8Xeh/FQ9Quqpa9dVA413Q1Ad43QOLzU4JoXgbFPWw==}
engines: {node: '>=8.0.0'}
'@opentelemetry/api-logs@0.220.0':
resolution: {integrity: sha512-CmVa4ImJ+ynfrPMNaAXHET6Bhb44SwzmfyVJFq9ni2jgXJR/l7C6gfVFddNmHP+ZOkP9cf4f9DBe68qVLTHc9w==}
engines: {node: '>=8.0.0'}
'@opentelemetry/api@1.9.1':
resolution: {integrity: sha512-gLyJlPHPZYdAk1JENA9LeHejZe1Ti77/pTeFm/nMXmQH/HFZlcS/O2XJB+L8fkbrNSqhdtlvjBVjxwUYanNH5Q==}
engines: {node: '>=8.0.0'}
'@opentelemetry/context-async-hooks@2.7.1':
resolution: {integrity: sha512-OPFBYuXEn1E4ja3Y6eeA7O+ZnLBNcXTV5Cgsn1VaqBZ6hC5FnpZPLBNme1LJY8ZtF4aOujPKFoeWN4ik487KuQ==}
engines: {node: ^18.19.0 || >=20.6.0}
peerDependencies:
'@opentelemetry/api': '>=1.0.0 <1.10.0'
'@opentelemetry/core@2.6.1':
resolution: {integrity: sha512-8xHSGWpJP9wBxgBpnqGL0R3PbdWQndL1Qp50qrg71+B28zK5OQmUgcDKLJgzyAAV38t4tOyLMGDD60LneR5W8g==}
engines: {node: ^18.19.0 || >=20.6.0}
peerDependencies:
'@opentelemetry/api': '>=1.0.0 <1.10.0'
'@opentelemetry/core@2.7.1':
resolution: {integrity: sha512-QAqIj32AtK6+pEVNG7EOVxHdE06RP+FM5qpiEJ4RtDcFIqKUZHYhl7/7UY5efhwmwNAg7j8QbJVBLxMerc0+gw==}
engines: {node: ^18.19.0 || >=20.6.0}
peerDependencies:
'@opentelemetry/api': '>=1.0.0 <1.10.0'
'@opentelemetry/core@2.8.0':
resolution: {integrity: sha512-hd1Lfh8p545nNz+jq1Ejfz+Mn1hyLuxYn1YzTfFNrxr8urEWMNQLPf1Th8kjOH+HxwawCrtgBp8JpBUR4ZSgww==}
engines: {node: ^18.19.0 || >=20.6.0}
peerDependencies:
'@opentelemetry/api': '>=1.0.0 <1.10.0'
'@opentelemetry/exporter-trace-otlp-proto@0.214.0':
resolution: {integrity: sha512-ON0spYWb2yAdQ9b+ItNyK0c6qdtcs+0eVR4YFJkhJL7agfT8sHFg0e5EesauSRiTHPZHiDobI92k77q0lwAmqg==}
engines: {node: ^18.19.0 || >=20.6.0}
peerDependencies:
'@opentelemetry/api': ^1.3.0
'@opentelemetry/instrumentation-pg@0.72.0':
resolution: {integrity: sha512-p9xrFc/6R8t6Y293sTYLZ83LnzZo/qY0bBPA4xabdQt0Qjt8i1SlYFsIeGY2Jmf5WcESNUdjQB3NxWnt5Ox7zw==}
engines: {node: ^18.19.0 || >=20.6.0}
peerDependencies:
'@opentelemetry/api': ^1.3.0
'@opentelemetry/instrumentation@0.214.0':
resolution: {integrity: sha512-MHqEX5Dk59cqVah5LiARMACku7jXSVk9iVDWOea4x3cr7VfdByeDCURK6o1lntT1JS/Tsovw01UJrBhN3/uC5w==}
engines: {node: ^18.19.0 || >=20.6.0}
peerDependencies:
'@opentelemetry/api': ^1.3.0
'@opentelemetry/instrumentation@0.218.0':
resolution: {integrity: sha512-mIZil8Es+sYDK5m+DQiwAwF57F14TF2YlEqvIjZ/RQWcxDBwRGsKfdK2Tv65OU9meQKCMzSIFS9mxAcnAb6Bkg==}
engines: {node: ^18.19.0 || >=20.6.0}
peerDependencies:
'@opentelemetry/api': ^1.3.0
'@opentelemetry/instrumentation@0.220.0':
resolution: {integrity: sha512-xQx3E2WxP1mDvKzxLxX+CTCtNLa560YJZ3087qYHerl2YmiKpv7AH+dAy7vmx+eVrZ5BwhfWUAVoKOoxCNHcpw==}
engines: {node: ^18.19.0 || >=20.6.0}
peerDependencies:
'@opentelemetry/api': ^1.3.0
'@opentelemetry/otlp-exporter-base@0.214.0':
resolution: {integrity: sha512-u1Gdv0/E9wP+apqWf7Wv2npXmgJtxsW2XL0TEv9FZloTZRuMBKmu8cYVXwS4Hm3q/f/3FuCnPTgiwYvIqRSpRg==}
engines: {node: ^18.19.0 || >=20.6.0}
peerDependencies:
'@opentelemetry/api': ^1.3.0
'@opentelemetry/otlp-transformer@0.214.0':
resolution: {integrity: sha512-DSaYcuBRh6uozfsWN3R8HsN0yDhCuWP7tOFdkUOVaWD1KVJg8m4qiLUsg/tNhTLS9HUYUcwNpwL2eroLtsZZ/w==}
engines: {node: ^18.19.0 || >=20.6.0}
peerDependencies:
'@opentelemetry/api': ^1.3.0
'@opentelemetry/resources@2.6.1':
resolution: {integrity: sha512-lID/vxSuKWXM55XhAKNoYXu9Cutoq5hFdkbTdI/zDKQktXzcWBVhNsOkiZFTMU9UtEWuGRNe0HUgmsFldIdxVA==}
engines: {node: ^18.19.0 || >=20.6.0}
peerDependencies:
'@opentelemetry/api': '>=1.3.0 <1.10.0'
'@opentelemetry/resources@2.7.1':
resolution: {integrity: sha512-DeT6KKolmC4e/dRQvMQ/RwlnzhaqeiFOXY5ngoOPJ07GgVVKxZOg9EcrNZb5aTzUn+iCrJldAgOfQm1O/QfPAQ==}
engines: {node: ^18.19.0 || >=20.6.0}
peerDependencies:
'@opentelemetry/api': '>=1.3.0 <1.10.0'
'@opentelemetry/sdk-logs@0.214.0':
resolution: {integrity: sha512-zf6acnScjhsaBUU22zXZ/sLWim1dfhUAbGXdMmHmNG3LfBnQ3DKsOCITb2IZwoUsNNMTogqFKBnlIPPftUgGwA==}
engines: {node: ^18.19.0 || >=20.6.0}
peerDependencies:
'@opentelemetry/api': '>=1.4.0 <1.10.0'
'@opentelemetry/sdk-metrics@2.6.1':
resolution: {integrity: sha512-9t9hJHX15meBy2NmTJxL+NJfXmnausR2xUDvE19XQce0Qi/GBtDGamU8nS1RMbdgDmhgpm3VaOu2+fiS/SfTpQ==}
engines: {node: ^18.19.0 || >=20.6.0}
peerDependencies:
'@opentelemetry/api': '>=1.9.0 <1.10.0'
'@opentelemetry/sdk-trace-base@2.6.1':
resolution: {integrity: sha512-r86ut4T1e8vNwB35CqCcKd45yzqH6/6Wzvpk2/cZB8PsPLlZFTvrh8yfOS3CYZYcUmAx4hHTZJ8AO8Dj8nrdhw==}
engines: {node: ^18.19.0 || >=20.6.0}
peerDependencies:
'@opentelemetry/api': '>=1.3.0 <1.10.0'
'@opentelemetry/sdk-trace-base@2.7.1':
resolution: {integrity: sha512-NAYIlsF8MPUsKqJMiDQJTMPOmlbawC1Iz/omMLygZ1C9am8fTKYjTaI+OZM+WTY3t3Glo0wnOg/6/pac6RGPPw==}
engines: {node: ^18.19.0 || >=20.6.0}
peerDependencies:
'@opentelemetry/api': '>=1.3.0 <1.10.0'
'@opentelemetry/sdk-trace-node@2.7.1':
resolution: {integrity: sha512-pCpQxU68lV+I9s9svqMyVu5iHdDDUnqUpSxqwyCU8A9ejEsSnMPCbearwsUO4yk08ZJzAIUCFuReMdVQvHrdvg==}
engines: {node: ^18.19.0 || >=20.6.0}
peerDependencies:
'@opentelemetry/api': '>=1.0.0 <1.10.0'
'@opentelemetry/semantic-conventions@1.40.0':
resolution: {integrity: sha512-cifvXDhcqMwwTlTK04GBNeIe7yyo28Mfby85QXFe1Yk8nmi36Ab/5UQwptOx84SsoGNRg+EVSjwzfSZMy6pmlw==}
engines: {node: '>=14'}
'@opentelemetry/sql-common@0.42.0':
resolution: {integrity: sha512-nwUwUU+8O8a4bnLqk6CodWeegGMEANgC94KTAhXcpGWLrW/2/hek/0ajNbjXnSOoNuCX+nteUPs46HFHhou9Xw==}
engines: {node: ^18.19.0 || >=20.6.0}
peerDependencies:
'@opentelemetry/api': ^1.1.0
'@oxc-parser/binding-android-arm-eabi@0.127.0':
resolution: {integrity: sha512-0LC7ye4hvqbIKxAzThzvswgHLFu2AURKzYLeSVvLdu2TBOYWQDmHnTqPLeA597BcUCxiLqLsS4CJ5uoI5WYWCQ==}
engines: {node: ^20.19.0 || >=22.12.0}
@@ -2965,6 +3093,33 @@ packages:
'@polka/url@1.0.0-next.29':
resolution: {integrity: sha512-wwQAWhWSuHaag8c4q/KN/vCoeOJYshAIvMQwD4GpSb3OiZklFfvAgmj0VCBBImRpuF/aFgIRzllXlVX93Jevww==}
'@protobufjs/aspromise@1.1.2':
resolution: {integrity: sha512-j+gKExEuLmKwvz3OgROXtrJ2UG2x8Ch2YZUxahh+s1F2HZ+wAceUNLkvy6zKCPVRkU++ZWQrdxsUeQXmcg4uoQ==}
'@protobufjs/base64@1.1.2':
resolution: {integrity: sha512-AZkcAA5vnN/v4PDqKyMR5lx7hZttPDgClv83E//FMNhR2TMcLUhfRUBHCmSl0oi9zMgDDqRUJkSxO3wm85+XLg==}
'@protobufjs/codegen@2.0.5':
resolution: {integrity: sha512-zgXFLzW3Ap33e6d0Wlj4MGIm6Ce8O89n/apUaGNB/jx+hw+ruWEp7EwGUshdLKVRCxZW12fp9r40E1mQrf/34g==}
'@protobufjs/eventemitter@1.1.1':
resolution: {integrity: sha512-vW1GmwMZNnL+gMRaovlh9yZX74kc+TTU3FObkkurpMaRtBfLP3ldjS9KQWlwZgraRE0+dheEEoAxdzcJQ8eXZg==}
'@protobufjs/fetch@1.1.1':
resolution: {integrity: sha512-GpptLrs57adMSuHi3VNj0mAF8dwh36LMaYF6XyJ6JMWlVsc+t42tm1HSEDmOs3A8fC9yyeisgLhsTVQokOZ0zw==}
'@protobufjs/float@1.0.2':
resolution: {integrity: sha512-Ddb+kVXlXst9d+R9PfTIxh1EdNkgoRe5tOX6t01f1lYWOvJnSPDBlG241QLzcyPdoNTsblLUdujGSE4RzrTZGQ==}
'@protobufjs/path@1.1.2':
resolution: {integrity: sha512-6JOcJ5Tm08dOHAbdR3GrvP+yUUfkjG5ePsHYczMFLq3ZmMkAD98cDgcT2iA1lJ9NVwFd4tH/iSSoe44YWkltEA==}
'@protobufjs/pool@1.1.0':
resolution: {integrity: sha512-0kELaGSIDBKvcgS4zkjz1PeddatrjYcmMWOlAuAPwAeccUrPHdUqo/J6LiymHHEiJT5NrF1UVwxY14f+fy4WQw==}
'@protobufjs/utf8@1.1.1':
resolution: {integrity: sha512-oOAWABowe8EAbMyWKM0tYDKi8Yaox52D+HWZhAIJqQXbqe0xI/GV7FhLWqlEKreMkfDjshR5FKgi3mnle0h6Eg==}
'@readme/better-ajv-errors@2.4.0':
resolution: {integrity: sha512-9WODaOAKSl/mU+MYNZ2aHCrkoRSvmQ+1YkLj589OEqqjOAhbn8j7Z+ilYoiTu/he6X63/clsxxAB4qny9/dDzg==}
engines: {node: '>=18'}
@@ -3862,6 +4017,12 @@ packages:
'@types/offscreencanvas@2019.7.3':
resolution: {integrity: sha512-ieXiYmgSRXUDeOntE1InxjWyvEelZGP63M+cGuquuRLuIKKT1osnkXjxev9B7d1nXSug5vpunx+gNlbVxMlC9A==}
'@types/pg-pool@2.0.7':
resolution: {integrity: sha512-U4CwmGVQcbEuqpyju8/ptOKg6gEC+Tqsvj2xS9o1g71bUh8twxnC6ZL5rZKCsGN0iyH0CwgUyc9VR5owNQF9Ng==}
'@types/pg@8.15.6':
resolution: {integrity: sha512-NoaMtzhxOrubeL/7UZuNTrejB4MPAJ0RpxZqXQf2qXuVlTPuG6Y8p4u9dKRaue4yjmC7ZhzVO2/Yyyn25znrPQ==}
'@types/pg@8.20.0':
resolution: {integrity: sha512-bEPFOaMAHTEP1EzpvHTbmwR8UsFyHSKsRisLIHVMXnpNefSbGA1bD6CVy+qKjGSqmZqNqBDV2azOBo8TgkcVow==}
@@ -4805,10 +4966,6 @@ packages:
resolution: {integrity: sha512-rwHwUfXL40Chm1r08yrhU3qpUvdVlgkKNeyeGPOxnW8/SyVDvgRaed/Uz54AqWNaTCAThlj6QAs3TZcKI0xDEw==}
engines: {node: '>=0.10.0'}
cluster-key-slot@1.1.2:
resolution: {integrity: sha512-RMr0FhtfXemyinomL4hrWcYJxmX6deFdCxpJzhDttxgO1+bcCnkk+9drydLVDmAMG7NE6aN/fl4F7ucU/90gAA==}
engines: {node: '>=0.10.0'}
color-convert@2.0.1:
resolution: {integrity: sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==}
engines: {node: '>=7.0.0'}
@@ -5955,10 +6112,6 @@ packages:
resolution: {integrity: sha512-7m1vEcPCxXYI8HqnL8CKI6siDyD+eIWSwgB3DZA+ZTogxk9I4CDnj4wilt9x/+/QbHI4YG5YZNmC6458/e9Ktg==}
deprecated: The Intersection Observer polyfill is no longer needed and can safely be removed. Intersection Observer has been Baseline since 2019.
ioredis@5.10.1:
resolution: {integrity: sha512-HuEDBTI70aYdx1v6U97SbNx9F1+svQKBDo30o0b9fw055LMepzpOOd0Ccg9Q6tbqmBSJaMuY0fB7yw9/vjBYCA==}
engines: {node: '>=12.22.0'}
ioredis@5.11.1:
resolution: {integrity: sha512-ehuGcf94bQXhfagULNXrJdfnWO38v070jxSx/qE87Kjzmu2fU7ro5EFAb+OPituLqgfyuQaym5DlrNydW2sJ9A==}
engines: {node: '>=12.22.0'}
@@ -6449,12 +6602,6 @@ packages:
resolution: {integrity: sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==}
engines: {node: '>=10'}
lodash.defaults@4.2.0:
resolution: {integrity: sha512-qjxPLHd3r5DnsdGacqOMU6pb/avJzdh9tFX2ymgoZE27BmjXrNy/y4LoaiTeAb+O3gL8AfpJGtqfX/ae2leYYQ==}
lodash.isarguments@3.1.0:
resolution: {integrity: sha512-chi4NHZlZqZD18a0imDHnZPrDeBbTtVN7GXMwuGdRH9qotxAjYs3aVLKc7zNOG9eddR5Ksd8rvFEBc9SsggPpg==}
lodash.merge@4.6.2:
resolution: {integrity: sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==}
@@ -6465,6 +6612,9 @@ packages:
resolution: {integrity: sha512-8XPvpAA8uyhfteu8pIvQxpJZ7SYYdpUivZpGy6sFsBuKRY/7rQGavedeB8aK+Zkyq6upMFVL/9AW6vOYzfRyLg==}
engines: {node: '>=10'}
long@5.3.2:
resolution: {integrity: sha512-mNAgZ1GmyNhD7AuqnTG3/VQ26o760+ZYBPKjPvugO8+nLbYfX6TVpJPseBvopbdY+qpZ/lKUnmEc1LeZYS3QAA==}
longest-streak@3.1.0:
resolution: {integrity: sha512-9Ri+o0JYgehTaVBBDoMqIl8GXtbWg711O3srftcHhZ0dqnETqLaoIK0x17fUw9rFSlK/0NlsKe0Ahhyl5pXE2g==}
@@ -7406,6 +7556,10 @@ packages:
proto-list@1.2.4:
resolution: {integrity: sha512-vtK/94akxsTMhe0/cbfpR+syPuszcuwhqVjJq26CuNDgFGj682oRBXOP5MJpv2r7JtE8MsiepGIqvvOTBwn2vA==}
protobufjs@7.6.4:
resolution: {integrity: sha512-RJJPTTpvFfHcWLkIa2JFWK4XvtSzS0yEWDmunqHXli1h3JlkbcQZXDZdcWxv+JK3Xsl5/UFDPZ0iGm7DAengYw==}
engines: {node: '>=12.0.0'}
proxy-addr@2.0.7:
resolution: {integrity: sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg==}
engines: {node: '>= 0.10'}
@@ -9850,6 +10004,16 @@ snapshots:
fastify-plugin: 5.1.0
secure-json-parse: 4.1.0
'@fastify/otel@0.19.0(@opentelemetry/api@1.9.1)':
dependencies:
'@opentelemetry/api': 1.9.1
'@opentelemetry/core': 2.7.1(@opentelemetry/api@1.9.1)
'@opentelemetry/instrumentation': 0.218.0(@opentelemetry/api@1.9.1)
'@opentelemetry/semantic-conventions': 1.40.0
minimatch: 10.2.5
transitivePeerDependencies:
- supports-color
'@fastify/proxy-addr@5.1.0':
dependencies:
'@fastify/forwarded': 3.0.1
@@ -10151,8 +10315,6 @@ snapshots:
'@ioredis/commands@1.10.0': {}
'@ioredis/commands@1.5.1': {}
'@isaacs/cliui@8.0.2':
dependencies:
string-width: 5.1.2
@@ -10459,13 +10621,56 @@ snapshots:
dependencies:
'@opentelemetry/api': 1.9.1
'@opentelemetry/api-logs@0.218.0':
dependencies:
'@opentelemetry/api': 1.9.1
'@opentelemetry/api-logs@0.220.0':
dependencies:
'@opentelemetry/api': 1.9.1
'@opentelemetry/api@1.9.1': {}
'@opentelemetry/context-async-hooks@2.7.1(@opentelemetry/api@1.9.1)':
dependencies:
'@opentelemetry/api': 1.9.1
'@opentelemetry/core@2.6.1(@opentelemetry/api@1.9.1)':
dependencies:
'@opentelemetry/api': 1.9.1
'@opentelemetry/semantic-conventions': 1.40.0
'@opentelemetry/core@2.7.1(@opentelemetry/api@1.9.1)':
dependencies:
'@opentelemetry/api': 1.9.1
'@opentelemetry/semantic-conventions': 1.40.0
'@opentelemetry/core@2.8.0(@opentelemetry/api@1.9.1)':
dependencies:
'@opentelemetry/api': 1.9.1
'@opentelemetry/semantic-conventions': 1.40.0
'@opentelemetry/exporter-trace-otlp-proto@0.214.0(@opentelemetry/api@1.9.1)':
dependencies:
'@opentelemetry/api': 1.9.1
'@opentelemetry/core': 2.6.1(@opentelemetry/api@1.9.1)
'@opentelemetry/otlp-exporter-base': 0.214.0(@opentelemetry/api@1.9.1)
'@opentelemetry/otlp-transformer': 0.214.0(@opentelemetry/api@1.9.1)
'@opentelemetry/resources': 2.6.1(@opentelemetry/api@1.9.1)
'@opentelemetry/sdk-trace-base': 2.6.1(@opentelemetry/api@1.9.1)
'@opentelemetry/instrumentation-pg@0.72.0(@opentelemetry/api@1.9.1)':
dependencies:
'@opentelemetry/api': 1.9.1
'@opentelemetry/core': 2.7.1(@opentelemetry/api@1.9.1)
'@opentelemetry/instrumentation': 0.220.0(@opentelemetry/api@1.9.1)
'@opentelemetry/semantic-conventions': 1.40.0
'@opentelemetry/sql-common': 0.42.0(@opentelemetry/api@1.9.1)
'@types/pg': 8.15.6
'@types/pg-pool': 2.0.7
transitivePeerDependencies:
- supports-color
'@opentelemetry/instrumentation@0.214.0(@opentelemetry/api@1.9.1)':
dependencies:
'@opentelemetry/api': 1.9.1
@@ -10475,12 +10680,74 @@ snapshots:
transitivePeerDependencies:
- supports-color
'@opentelemetry/instrumentation@0.218.0(@opentelemetry/api@1.9.1)':
dependencies:
'@opentelemetry/api': 1.9.1
'@opentelemetry/api-logs': 0.218.0
import-in-the-middle: 3.0.1
require-in-the-middle: 8.0.1
transitivePeerDependencies:
- supports-color
'@opentelemetry/instrumentation@0.220.0(@opentelemetry/api@1.9.1)':
dependencies:
'@opentelemetry/api': 1.9.1
'@opentelemetry/api-logs': 0.220.0
import-in-the-middle: 3.0.1
require-in-the-middle: 8.0.1
transitivePeerDependencies:
- supports-color
'@opentelemetry/otlp-exporter-base@0.214.0(@opentelemetry/api@1.9.1)':
dependencies:
'@opentelemetry/api': 1.9.1
'@opentelemetry/core': 2.6.1(@opentelemetry/api@1.9.1)
'@opentelemetry/otlp-transformer': 0.214.0(@opentelemetry/api@1.9.1)
'@opentelemetry/otlp-transformer@0.214.0(@opentelemetry/api@1.9.1)':
dependencies:
'@opentelemetry/api': 1.9.1
'@opentelemetry/api-logs': 0.214.0
'@opentelemetry/core': 2.6.1(@opentelemetry/api@1.9.1)
'@opentelemetry/resources': 2.6.1(@opentelemetry/api@1.9.1)
'@opentelemetry/sdk-logs': 0.214.0(@opentelemetry/api@1.9.1)
'@opentelemetry/sdk-metrics': 2.6.1(@opentelemetry/api@1.9.1)
'@opentelemetry/sdk-trace-base': 2.6.1(@opentelemetry/api@1.9.1)
protobufjs: 7.6.4
'@opentelemetry/resources@2.6.1(@opentelemetry/api@1.9.1)':
dependencies:
'@opentelemetry/api': 1.9.1
'@opentelemetry/core': 2.6.1(@opentelemetry/api@1.9.1)
'@opentelemetry/semantic-conventions': 1.40.0
'@opentelemetry/resources@2.7.1(@opentelemetry/api@1.9.1)':
dependencies:
'@opentelemetry/api': 1.9.1
'@opentelemetry/core': 2.7.1(@opentelemetry/api@1.9.1)
'@opentelemetry/semantic-conventions': 1.40.0
'@opentelemetry/sdk-logs@0.214.0(@opentelemetry/api@1.9.1)':
dependencies:
'@opentelemetry/api': 1.9.1
'@opentelemetry/api-logs': 0.214.0
'@opentelemetry/core': 2.6.1(@opentelemetry/api@1.9.1)
'@opentelemetry/resources': 2.6.1(@opentelemetry/api@1.9.1)
'@opentelemetry/semantic-conventions': 1.40.0
'@opentelemetry/sdk-metrics@2.6.1(@opentelemetry/api@1.9.1)':
dependencies:
'@opentelemetry/api': 1.9.1
'@opentelemetry/core': 2.6.1(@opentelemetry/api@1.9.1)
'@opentelemetry/resources': 2.6.1(@opentelemetry/api@1.9.1)
'@opentelemetry/sdk-trace-base@2.6.1(@opentelemetry/api@1.9.1)':
dependencies:
'@opentelemetry/api': 1.9.1
'@opentelemetry/core': 2.6.1(@opentelemetry/api@1.9.1)
'@opentelemetry/resources': 2.6.1(@opentelemetry/api@1.9.1)
'@opentelemetry/semantic-conventions': 1.40.0
'@opentelemetry/sdk-trace-base@2.7.1(@opentelemetry/api@1.9.1)':
dependencies:
'@opentelemetry/api': 1.9.1
@@ -10488,8 +10755,20 @@ snapshots:
'@opentelemetry/resources': 2.7.1(@opentelemetry/api@1.9.1)
'@opentelemetry/semantic-conventions': 1.40.0
'@opentelemetry/sdk-trace-node@2.7.1(@opentelemetry/api@1.9.1)':
dependencies:
'@opentelemetry/api': 1.9.1
'@opentelemetry/context-async-hooks': 2.7.1(@opentelemetry/api@1.9.1)
'@opentelemetry/core': 2.7.1(@opentelemetry/api@1.9.1)
'@opentelemetry/sdk-trace-base': 2.7.1(@opentelemetry/api@1.9.1)
'@opentelemetry/semantic-conventions@1.40.0': {}
'@opentelemetry/sql-common@0.42.0(@opentelemetry/api@1.9.1)':
dependencies:
'@opentelemetry/api': 1.9.1
'@opentelemetry/core': 2.7.1(@opentelemetry/api@1.9.1)
'@oxc-parser/binding-android-arm-eabi@0.127.0':
optional: true
@@ -10803,6 +11082,26 @@ snapshots:
'@polka/url@1.0.0-next.29': {}
'@protobufjs/aspromise@1.1.2': {}
'@protobufjs/base64@1.1.2': {}
'@protobufjs/codegen@2.0.5': {}
'@protobufjs/eventemitter@1.1.1': {}
'@protobufjs/fetch@1.1.1':
dependencies:
'@protobufjs/aspromise': 1.1.2
'@protobufjs/float@1.0.2': {}
'@protobufjs/path@1.1.2': {}
'@protobufjs/pool@1.1.0': {}
'@protobufjs/utf8@1.1.1': {}
'@readme/better-ajv-errors@2.4.0(ajv@8.20.0)':
dependencies:
'@babel/code-frame': 7.29.0
@@ -11058,15 +11357,15 @@ snapshots:
dependencies:
'@sentry/core': 10.62.0
'@sentry/node-core@10.62.0(@opentelemetry/api@1.9.1)(@opentelemetry/core@2.7.1(@opentelemetry/api@1.9.1))(@opentelemetry/instrumentation@0.214.0(@opentelemetry/api@1.9.1))(@opentelemetry/sdk-trace-base@2.7.1(@opentelemetry/api@1.9.1))':
'@sentry/node-core@10.62.0(@opentelemetry/api@1.9.1)(@opentelemetry/core@2.8.0(@opentelemetry/api@1.9.1))(@opentelemetry/instrumentation@0.214.0(@opentelemetry/api@1.9.1))(@opentelemetry/sdk-trace-base@2.7.1(@opentelemetry/api@1.9.1))':
dependencies:
'@sentry/conventions': 0.12.0
'@sentry/core': 10.62.0
'@sentry/opentelemetry': 10.62.0(@opentelemetry/api@1.9.1)(@opentelemetry/core@2.7.1(@opentelemetry/api@1.9.1))(@opentelemetry/sdk-trace-base@2.7.1(@opentelemetry/api@1.9.1))
'@sentry/opentelemetry': 10.62.0(@opentelemetry/api@1.9.1)(@opentelemetry/core@2.8.0(@opentelemetry/api@1.9.1))(@opentelemetry/sdk-trace-base@2.7.1(@opentelemetry/api@1.9.1))
import-in-the-middle: 3.0.1
optionalDependencies:
'@opentelemetry/api': 1.9.1
'@opentelemetry/core': 2.7.1(@opentelemetry/api@1.9.1)
'@opentelemetry/core': 2.8.0(@opentelemetry/api@1.9.1)
'@opentelemetry/instrumentation': 0.214.0(@opentelemetry/api@1.9.1)
'@opentelemetry/sdk-trace-base': 2.7.1(@opentelemetry/api@1.9.1)
@@ -11075,15 +11374,15 @@ snapshots:
detect-libc: 2.1.2
node-abi: 3.92.0
'@sentry/node@10.62.0(@opentelemetry/core@2.7.1(@opentelemetry/api@1.9.1))':
'@sentry/node@10.62.0(@opentelemetry/core@2.8.0(@opentelemetry/api@1.9.1))':
dependencies:
'@opentelemetry/api': 1.9.1
'@opentelemetry/instrumentation': 0.214.0(@opentelemetry/api@1.9.1)
'@opentelemetry/sdk-trace-base': 2.7.1(@opentelemetry/api@1.9.1)
'@opentelemetry/semantic-conventions': 1.40.0
'@sentry/core': 10.62.0
'@sentry/node-core': 10.62.0(@opentelemetry/api@1.9.1)(@opentelemetry/core@2.7.1(@opentelemetry/api@1.9.1))(@opentelemetry/instrumentation@0.214.0(@opentelemetry/api@1.9.1))(@opentelemetry/sdk-trace-base@2.7.1(@opentelemetry/api@1.9.1))
'@sentry/opentelemetry': 10.62.0(@opentelemetry/api@1.9.1)(@opentelemetry/core@2.7.1(@opentelemetry/api@1.9.1))(@opentelemetry/sdk-trace-base@2.7.1(@opentelemetry/api@1.9.1))
'@sentry/node-core': 10.62.0(@opentelemetry/api@1.9.1)(@opentelemetry/core@2.8.0(@opentelemetry/api@1.9.1))(@opentelemetry/instrumentation@0.214.0(@opentelemetry/api@1.9.1))(@opentelemetry/sdk-trace-base@2.7.1(@opentelemetry/api@1.9.1))
'@sentry/opentelemetry': 10.62.0(@opentelemetry/api@1.9.1)(@opentelemetry/core@2.8.0(@opentelemetry/api@1.9.1))(@opentelemetry/sdk-trace-base@2.7.1(@opentelemetry/api@1.9.1))
'@sentry/server-utils': 10.62.0
import-in-the-middle: 3.0.1
transitivePeerDependencies:
@@ -11091,18 +11390,18 @@ snapshots:
- '@opentelemetry/exporter-trace-otlp-http'
- supports-color
'@sentry/opentelemetry@10.62.0(@opentelemetry/api@1.9.1)(@opentelemetry/core@2.7.1(@opentelemetry/api@1.9.1))(@opentelemetry/sdk-trace-base@2.7.1(@opentelemetry/api@1.9.1))':
'@sentry/opentelemetry@10.62.0(@opentelemetry/api@1.9.1)(@opentelemetry/core@2.8.0(@opentelemetry/api@1.9.1))(@opentelemetry/sdk-trace-base@2.7.1(@opentelemetry/api@1.9.1))':
dependencies:
'@opentelemetry/api': 1.9.1
'@opentelemetry/core': 2.7.1(@opentelemetry/api@1.9.1)
'@opentelemetry/core': 2.8.0(@opentelemetry/api@1.9.1)
'@opentelemetry/sdk-trace-base': 2.7.1(@opentelemetry/api@1.9.1)
'@sentry/conventions': 0.12.0
'@sentry/core': 10.62.0
'@sentry/profiling-node@10.62.0(@opentelemetry/core@2.7.1(@opentelemetry/api@1.9.1))':
'@sentry/profiling-node@10.62.0(@opentelemetry/core@2.8.0(@opentelemetry/api@1.9.1))':
dependencies:
'@sentry/core': 10.62.0
'@sentry/node': 10.62.0(@opentelemetry/core@2.7.1(@opentelemetry/api@1.9.1))
'@sentry/node': 10.62.0(@opentelemetry/core@2.8.0(@opentelemetry/api@1.9.1))
'@sentry/node-cpu-profiler': 2.4.2
transitivePeerDependencies:
- '@opentelemetry/core'
@@ -11711,6 +12010,16 @@ snapshots:
'@types/offscreencanvas@2019.7.3': {}
'@types/pg-pool@2.0.7':
dependencies:
'@types/pg': 8.20.0
'@types/pg@8.15.6':
dependencies:
'@types/node': 26.0.1
pg-protocol: 1.15.0
pg-types: 2.2.0
'@types/pg@8.20.0':
dependencies:
'@types/node': 26.0.1
@@ -12581,7 +12890,7 @@ snapshots:
bullmq@5.79.2:
dependencies:
cron-parser: 4.9.0
ioredis: 5.10.1
ioredis: 5.11.1
msgpackr: 2.0.4
node-abort-controller: 3.1.1
semver: 7.8.5
@@ -12780,8 +13089,6 @@ snapshots:
cluster-key-slot@1.1.1: {}
cluster-key-slot@1.1.2: {}
color-convert@2.0.1:
dependencies:
color-name: 1.1.4
@@ -14143,20 +14450,6 @@ snapshots:
intersection-observer@0.12.2: {}
ioredis@5.10.1:
dependencies:
'@ioredis/commands': 1.5.1
cluster-key-slot: 1.1.2
debug: 4.4.3
denque: 2.1.0
lodash.defaults: 4.2.0
lodash.isarguments: 3.1.0
redis-errors: 1.2.0
redis-parser: 3.0.0
standard-as-callback: 2.1.0
transitivePeerDependencies:
- supports-color
ioredis@5.11.1:
dependencies:
'@ioredis/commands': 1.10.0
@@ -14613,10 +14906,6 @@ snapshots:
dependencies:
p-locate: 5.0.0
lodash.defaults@4.2.0: {}
lodash.isarguments@3.1.0: {}
lodash.merge@4.6.2: {}
lodash@4.18.1: {}
@@ -14626,6 +14915,8 @@ snapshots:
chalk: 4.1.2
is-unicode-supported: 0.1.0
long@5.3.2: {}
longest-streak@3.1.0: {}
loupe@3.2.1: {}
@@ -15784,6 +16075,20 @@ snapshots:
proto-list@1.2.4: {}
protobufjs@7.6.4:
dependencies:
'@protobufjs/aspromise': 1.1.2
'@protobufjs/base64': 1.1.2
'@protobufjs/codegen': 2.0.5
'@protobufjs/eventemitter': 1.1.1
'@protobufjs/fetch': 1.1.1
'@protobufjs/float': 1.0.2
'@protobufjs/path': 1.1.2
'@protobufjs/pool': 1.1.0
'@protobufjs/utf8': 1.1.1
'@types/node': 26.0.1
long: 5.3.2
proxy-addr@2.0.7:
dependencies:
forwarded: 0.2.0

View File

@@ -30,6 +30,7 @@ allowBuilds:
v-code-diff: true
vue-demi: true
exifreader: false
protobufjs: false
minimumReleaseAge: 10080 # delay 7days to mitigate supply-chain attack
minimumReleaseAgeExclude:
- '@misskey-dev/*'
@@ -52,8 +53,11 @@ minimumReleaseAgeExclude:
- slacc-win32-x64-msvc
- '@typescript/native-preview*'
- vite # そのうち消す
# Renovate security update: @opentelemetry/core@2.8.0
- "@opentelemetry/core@2.8.0"
overrides:
'@aiscript-dev/aiscript-languageserver': '-'
'bullmq>ioredis': 5.11.1
chokidar: 5.0.0
lodash: 4.18.1
engineStrict: true