@@ -100,6 +100,7 @@ import { hms } from '@/filters/hms.js';
import MkMediaRange from '@/components/MkMediaRange.vue';
import { $i, iAmModerator } from '@/i.js';
import { prefer } from '@/preferences.js';
+import { getFileMenu } from '@/utility/get-file-menu.js';
import { canRevealFile, shouldHideFileByDefault } from '@/utility/sensitive-file.js';
const props = defineProps<{
@@ -208,56 +209,11 @@ function showMenu(ev: MouseEvent) {
{
type: 'divider',
},
- {
- text: i18n.ts.hide,
- icon: 'ti ti-eye-off',
- action: () => {
- hide.value = true;
- },
- },
];
- if (iAmModerator) {
- menu.push({
- text: props.audio.isSensitive ? i18n.ts.unmarkAsSensitive : i18n.ts.markAsSensitive,
- icon: props.audio.isSensitive ? 'ti ti-eye' : 'ti ti-eye-exclamation',
- danger: true,
- action: () => toggleSensitive(props.audio),
- });
- }
-
- const details: MenuItem[] = [];
- if ($i?.id === props.audio.userId) {
- details.push({
- type: 'link',
- text: i18n.ts._fileViewer.title,
- icon: 'ti ti-info-circle',
- to: `/my/drive/file/${props.audio.id}`,
- });
- }
-
- if (iAmModerator) {
- details.push({
- type: 'link',
- text: i18n.ts.moderation,
- icon: 'ti ti-photo-exclamation',
- to: `/admin/file/${props.audio.id}`,
- });
- }
-
- if (details.length > 0) {
- menu.push({ type: 'divider' }, ...details);
- }
-
- if (prefer.s.devMode) {
- menu.push({ type: 'divider' }, {
- icon: 'ti ti-hash',
- text: i18n.ts.copyFileId,
- action: () => {
- copyToClipboard(props.audio.id);
- },
- });
- }
+ menu.push(...getFileMenu(props.audio, (newState) => {
+ hide.value = newState;
+ }));
menuShowing.value = true;
os.popupMenu(menu, ev.currentTarget ?? ev.target, {
@@ -268,20 +224,6 @@ function showMenu(ev: MouseEvent) {
});
}
-async function toggleSensitive(file: Misskey.entities.DriveFile) {
- const { canceled } = await os.confirm({
- type: 'warning',
- text: file.isSensitive ? i18n.ts.unmarkAsSensitiveConfirm : i18n.ts.markAsSensitiveConfirm,
- });
-
- if (canceled) return;
-
- os.apiWithDialog('drive/files/update', {
- fileId: file.id,
- isSensitive: !file.isSensitive,
- });
-}
-
// MediaControl: Common State
const oncePlayed = ref(false);
const isReady = ref(false);
diff --git a/packages/frontend/src/components/MkMediaImage.vue b/packages/frontend/src/components/MkMediaImage.vue
index 4236bd943a..01f449753b 100644
--- a/packages/frontend/src/components/MkMediaImage.vue
+++ b/packages/frontend/src/components/MkMediaImage.vue
@@ -4,7 +4,7 @@ SPDX-License-Identifier: AGPL-3.0-only
-->
-
+
@@ -68,16 +70,14 @@ SPDX-License-Identifier: AGPL-3.0-only
diff --git a/packages/frontend/src/components/MkMediaList.vue b/packages/frontend/src/components/MkMediaList.vue
index 9090e74bb6..a0ae2c2bca 100644
--- a/packages/frontend/src/components/MkMediaList.vue
+++ b/packages/frontend/src/components/MkMediaList.vue
@@ -20,8 +20,23 @@ SPDX-License-Identifier: AGPL-3.0-only
]"
>
-
-
+
+
@@ -29,18 +44,16 @@ SPDX-License-Identifier: AGPL-3.0-only
diff --git a/packages/frontend/src/components/MkVideoControl.vue b/packages/frontend/src/components/MkVideoControl.vue
new file mode 100644
index 0000000000..b1e58f9d18
--- /dev/null
+++ b/packages/frontend/src/components/MkVideoControl.vue
@@ -0,0 +1,318 @@
+
+
+
+
+
+
+
+
+
+
+
+
{{ hms(elapsedTimeMs) }} / {{ hms(durationMs) }}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/packages/frontend/src/di.ts b/packages/frontend/src/di.ts
index eddb0dcb35..b029451e92 100644
--- a/packages/frontend/src/di.ts
+++ b/packages/frontend/src/di.ts
@@ -19,4 +19,5 @@ export const DI = {
inModal: Symbol() as InjectionKey
,
inAppSearchMarkerId: Symbol() as InjectionKey[>,
inChannel: Symbol() as InjectionKey | null>, // 現在開いているチャンネルのID
+ mkLightboxItemVideoEl: Symbol() as InjectionKey][>,
};
diff --git a/packages/frontend/src/utility/double-tap.ts b/packages/frontend/src/utility/double-tap.ts
new file mode 100644
index 0000000000..a5242dc508
--- /dev/null
+++ b/packages/frontend/src/utility/double-tap.ts
@@ -0,0 +1,56 @@
+/*
+ * SPDX-FileCopyrightText: syuilo and misskey-project
+ * SPDX-License-Identifier: AGPL-3.0-only
+ */
+
+export function makeDoubleTapDetector(onDoubletap: (event: TouchEvent) => void) {
+ const positionThreshold = 10; // px
+ const durationThreshold = 300; // ms
+
+ let lastTapTime = 0;
+ let lastTapPosition = { x: 0, y: 0 };
+
+ function onTouchstart(ev: TouchEvent) {
+ if (ev.touches.length !== 1) return;
+
+ const currentTime = new Date().getTime();
+ const tapLength = currentTime - lastTapTime;
+ const positionDelta = Math.max(
+ Math.abs(ev.touches[0].clientX - lastTapPosition.x),
+ Math.abs(ev.touches[0].clientY - lastTapPosition.y),
+ );
+
+ if (tapLength < durationThreshold && tapLength > 0 && positionDelta < positionThreshold) { // ダブルタップ
+ onDoubletap(ev);
+ lastTapTime = 0;
+ return;
+ }
+
+ lastTapTime = currentTime;
+ lastTapPosition = {
+ x: ev.touches[0].clientX,
+ y: ev.touches[0].clientY,
+ };
+ }
+
+ function onTouchmove(ev: TouchEvent) {
+ const positionDelta = Math.max(
+ Math.abs(ev.touches[0].clientX - lastTapPosition.x),
+ Math.abs(ev.touches[0].clientY - lastTapPosition.y),
+ );
+ if (positionDelta > positionThreshold) {
+ lastTapTime = 0;
+ }
+ }
+
+ function reset() {
+ lastTapTime = 0;
+ lastTapPosition = { x: 0, y: 0 };
+ }
+
+ return {
+ onTouchstart,
+ onTouchmove,
+ reset,
+ };
+}
diff --git a/packages/frontend/src/utility/get-drive-file-menu.ts b/packages/frontend/src/utility/get-drive-file-menu.ts
index 21138259cb..391af78e1f 100644
--- a/packages/frontend/src/utility/get-drive-file-menu.ts
+++ b/packages/frontend/src/utility/get-drive-file-menu.ts
@@ -97,6 +97,7 @@ async function deleteFile(file: Misskey.entities.DriveFile) {
globalEvents.emit('driveFilesDeleted', [file]);
}
+/** 自分のドライブファイルを操作する際のメニュー */
export function getDriveFileMenu(file: Misskey.entities.DriveFile, folder?: Misskey.entities.DriveFolder | null): MenuItem[] {
const _isImage = file.type.startsWith('image/');
diff --git a/packages/frontend/src/utility/get-file-menu.ts b/packages/frontend/src/utility/get-file-menu.ts
new file mode 100644
index 0000000000..362c6dda33
--- /dev/null
+++ b/packages/frontend/src/utility/get-file-menu.ts
@@ -0,0 +1,83 @@
+/*
+ * SPDX-FileCopyrightText: syuilo and misskey-project
+ * SPDX-License-Identifier: AGPL-3.0-only
+ */
+
+import * as Misskey from 'misskey-js';
+import { $i, iAmModerator } from '@/i.js';
+import { i18n } from '@/i18n.js';
+import { prefer } from '@/preferences.js';
+import { copyToClipboard } from '@/utility/copy-to-clipboard.js';
+import * as os from '@/os.js';
+import type { MenuItem } from '@/types/menu.js';
+
+/** 添付ファイルなど、公開ファイル用のメニュー */
+export function getFileMenu(file: Misskey.entities.DriveFile, onHideStateUpdated?: (newState: boolean) => void): MenuItem[] {
+ const menuItems: MenuItem[] = [];
+
+ if (onHideStateUpdated != null) {
+ menuItems.push({
+ text: i18n.ts.hide,
+ icon: 'ti ti-eye-off',
+ action: () => {
+ onHideStateUpdated(true);
+ },
+ });
+ }
+
+ if (iAmModerator) {
+ menuItems.push({
+ text: file.isSensitive ? i18n.ts.unmarkAsSensitive : i18n.ts.markAsSensitive,
+ icon: 'ti ti-eye-exclamation',
+ danger: true,
+ action: async () => {
+ const { canceled } = await os.confirm({
+ type: 'warning',
+ text: file.isSensitive ? i18n.ts.unmarkAsSensitiveConfirm : i18n.ts.markAsSensitiveConfirm,
+ });
+
+ if (canceled) return;
+
+ os.apiWithDialog('drive/files/update', {
+ fileId: file.id,
+ isSensitive: !file.isSensitive,
+ });
+ },
+ });
+ }
+
+ const details: MenuItem[] = [];
+ if ($i?.id === file.userId) {
+ details.push({
+ type: 'link',
+ text: i18n.ts._fileViewer.title,
+ icon: 'ti ti-info-circle',
+ to: `/my/drive/file/${file.id}`,
+ });
+ }
+
+ if (iAmModerator) {
+ details.push({
+ type: 'link',
+ text: i18n.ts.moderation,
+ icon: 'ti ti-photo-exclamation',
+ to: `/admin/file/${file.id}`,
+ });
+ }
+
+ if (details.length > 0) {
+ menuItems.push({ type: 'divider' }, ...details);
+ }
+
+ if (prefer.s.devMode) {
+ menuItems.push({ type: 'divider' }, {
+ icon: 'ti ti-hash',
+ text: i18n.ts.copyFileId,
+ action: () => {
+ copyToClipboard(file.id);
+ },
+ });
+ }
+
+ return menuItems;
+}
diff --git a/packages/frontend/src/utility/hotkey.ts b/packages/frontend/src/utility/hotkey.ts
index 9c1e66a22e..7be2504cf1 100644
--- a/packages/frontend/src/utility/hotkey.ts
+++ b/packages/frontend/src/utility/hotkey.ts
@@ -53,7 +53,6 @@ let latestHotkey: Pattern & { callback: CallbackFunction } | null = null;
export const makeHotkey = (keymap: Keymap, ignoreElements = IGNORE_ELEMENTS) => {
const actions = parseKeymap(keymap);
return (ev: KeyboardEvent) => {
- if ('pswp' in window && window.pswp != null) return;
if (window.document.activeElement != null) {
if (ignoreElements.includes(window.document.activeElement.tagName.toLowerCase())) return;
if (getHTMLElementOrNull(window.document.activeElement)?.isContentEditable) return;
diff --git a/packages/frontend/src/utility/sensitive-file.ts b/packages/frontend/src/utility/sensitive-file.ts
index f1fc909e4a..ed3b391aaa 100644
--- a/packages/frontend/src/utility/sensitive-file.ts
+++ b/packages/frontend/src/utility/sensitive-file.ts
@@ -8,8 +8,8 @@ import * as os from '@/os.js';
import { prefer } from '@/preferences.js';
import { i18n } from '@/i18n.js';
-export function shouldHideFileByDefault(file: Misskey.entities.DriveFile): boolean {
- if (prefer.s.nsfw === 'force' || prefer.s.dataSaver.media) {
+export function shouldHideFileByDefault(file: Misskey.entities.DriveFile, ignoreDataSaver = false): boolean {
+ if (prefer.s.nsfw === 'force' || (!ignoreDataSaver && prefer.s.dataSaver.media)) {
return true;
}
diff --git a/packages/frontend/vite.config.ts b/packages/frontend/vite.config.ts
index b230f1818b..e90aa4a404 100644
--- a/packages/frontend/vite.config.ts
+++ b/packages/frontend/vite.config.ts
@@ -220,9 +220,6 @@ export function getConfig(): UserConfig {
groups: [{
name: 'vue',
test: /node_modules[\\/]vue/,
- }, {
- name: 'photoswipe',
- test: /node_modules[\\/]photoswipe/,
}, {
// split i18n related module to distinct module
name: 'i18n',
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index cd06dcf875..738be6be51 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -646,9 +646,6 @@ importers:
misskey-reversi:
specifier: workspace:*
version: link:../misskey-reversi
- photoswipe:
- specifier: 5.4.4
- version: 5.4.4
punycode.js:
specifier: 2.3.1
version: 2.3.1
@@ -7237,10 +7234,6 @@ packages:
pgpass@1.0.5:
resolution: {integrity: sha512-FdW9r/jQZhSeohs1Z3sI1yxFQNFvMcnmfuj4WBMUTxOrAyLMaTcE1aAMBiTlbMNaXvBCQuVi0R7hd8udDSP7ug==}
- photoswipe@5.4.4:
- resolution: {integrity: sha512-WNFHoKrkZNnvFFhbHL93WDkW3ifwVOXSW3w1UuZZelSmgXpIGiZSNlZJq37rR8YejqME2rHs9EhH9ZvlvFH2NA==}
- engines: {node: '>= 0.12.0'}
-
picocolors@1.1.1:
resolution: {integrity: sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==}
@@ -15637,8 +15630,6 @@ snapshots:
dependencies:
split2: 4.2.0
- photoswipe@5.4.4: {}
-
picocolors@1.1.1: {}
picomatch@2.3.2: {}
From df5c491f4c6561595b826b75f82826e9af0b04ee Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?=E3=81=8B=E3=81=A3=E3=81=93=E3=81=8B=E3=82=8A?=
<67428053+kakkokari-gtyih@users.noreply.github.com>
Date: Sun, 12 Jul 2026 12:08:59 +0900
Subject: [PATCH 031/168] =?UTF-8?q?enhance(backend):=20summaly=E3=81=AE?=
=?UTF-8?q?=E7=B5=90=E6=9E=9C=E3=82=92=E3=82=AD=E3=83=A3=E3=83=83=E3=82=B7?=
=?UTF-8?q?=E3=83=A5=E3=81=99=E3=82=8B=E3=82=88=E3=81=86=E3=81=AB=20(#1736?=
=?UTF-8?q?7)?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
* enhance(backend): summalyの結果をキャッシュするように
* fix
* fix: unify memorykvcache
* fix
* refactor
* fix
* Update Changelog
---
CHANGELOG.md | 1 +
packages/backend/src/misc/cache.ts | 27 ++++++-
.../src/server/web/UrlPreviewService.ts | 75 +++++++++++++------
packages/backend/test/unit/misc/cache.ts | 23 ++++++
4 files changed, 100 insertions(+), 26 deletions(-)
diff --git a/CHANGELOG.md b/CHANGELOG.md
index f8e73fed58..cca052f102 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -40,6 +40,7 @@
- Enhance: Sentry バックエンドの自動計装を `sentryForBackend.disabledIntegrations` で個別に無効化できるように
- 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プレビューの結果を内部でキャッシュするように
- Fix: `/stats` API のレスポンス型が正しくない問題を修正
- Fix: ハッシュタグに関連するデータを更新する際のエラーハンドリングを修正
- Fix: Sentry 使用環境下にて、Misskey が発行した SQL クエリが span に含まれない問題を修正
diff --git a/packages/backend/src/misc/cache.ts b/packages/backend/src/misc/cache.ts
index f1eae582a8..936b049f00 100644
--- a/packages/backend/src/misc/cache.ts
+++ b/packages/backend/src/misc/cache.ts
@@ -204,14 +204,13 @@ export class RedisSingleCache {
}
}
-// TODO: メモリ節約のためあまり参照されないキーを定期的に削除できるようにする?
-
export class MemoryKVCache {
private readonly cache = new Map();
private readonly gcIntervalHandle = setInterval(() => this.gc(), 1000 * 60 * 3); // 3m
constructor(
private readonly lifetime: number,
+ private readonly limit: number = Infinity,
) {}
@bindThis
@@ -220,6 +219,25 @@ export class MemoryKVCache {
* @deprecated これを直接呼び出すべきではない。InternalEventなどで変更を全てのプロセス/マシンに通知するべき
*/
public set(key: string, value: T): void {
+ if (this.limit <= 0) {
+ throw new Error('Limit must be greater than 0');
+ }
+
+ if (this.limit !== Infinity) {
+ this.gc();
+
+ // 挿入順を更新して LRU を保つため、同一キーは一度削除する
+ this.cache.delete(key);
+
+ while (this.cache.size >= this.limit) {
+ const oldestKey = this.cache.keys().next().value;
+ if (oldestKey == null) {
+ throw new Error('Cache is empty but size exceeds the limit');
+ }
+ this.cache.delete(oldestKey);
+ }
+ }
+
this.cache.set(key, {
date: Date.now(),
value,
@@ -234,6 +252,11 @@ export class MemoryKVCache {
this.cache.delete(key);
return undefined;
}
+ if (this.limit !== Infinity) {
+ // access 順を更新して LRU を保つ
+ this.cache.delete(key);
+ this.cache.set(key, cached);
+ }
return cached.value;
}
diff --git a/packages/backend/src/server/web/UrlPreviewService.ts b/packages/backend/src/server/web/UrlPreviewService.ts
index e5ba8c596c..cb36b960ec 100644
--- a/packages/backend/src/server/web/UrlPreviewService.ts
+++ b/packages/backend/src/server/web/UrlPreviewService.ts
@@ -3,13 +3,14 @@
* SPDX-License-Identifier: AGPL-3.0-only
*/
-import { Inject, Injectable } from '@nestjs/common';
+import { Inject, Injectable, OnApplicationShutdown } from '@nestjs/common';
import type { SummalyResult } from '@misskey-dev/summaly';
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 { query } from '@/misc/prelude/url.js';
+import { MemoryKVCache } from '@/misc/cache.js';
import { LoggerService } from '@/core/LoggerService.js';
import { UtilityService } from '@/core/UtilityService.js';
import { bindThis } from '@/decorators.js';
@@ -18,8 +19,9 @@ import { MiMeta } from '@/models/Meta.js';
import type { FastifyRequest, FastifyReply } from 'fastify';
@Injectable()
-export class UrlPreviewService {
+export class UrlPreviewService implements OnApplicationShutdown {
private logger: Logger;
+ private summaryCache: MemoryKVCache;
private readonly summalyDefaultUserAgent: string;
constructor(
@@ -34,6 +36,7 @@ export class UrlPreviewService {
private loggerService: LoggerService,
) {
this.logger = this.loggerService.getLogger('url-preview');
+ this.summaryCache = new MemoryKVCache(1000 * 60 * 60, 100); // 1h, 100件
this.summalyDefaultUserAgent = `SummalyBot/${_SUMMALY_VERSION_} (${this.config.url}; +https://github.com/misskey-dev/summaly/blob/master/README.md)`;
}
@@ -80,20 +83,32 @@ export class UrlPreviewService {
: `Getting preview of ${url}@${lang} ...`);
try {
- const summary = this.meta.urlPreviewSummaryProxyUrl
- ? await this.fetchSummaryFromProxy(url, this.meta, lang)
- : await this.fetchSummary(url, this.meta, lang);
+ const fetcher = async () => {
+ const result = await (
+ this.meta.urlPreviewSummaryProxyUrl
+ ? this.fetchSummaryFromProxy(url, lang)
+ : this.fetchSummary(url, lang)
+ );
+
+ if (!result.url.startsWith('http://') && !result.url.startsWith('https://')) {
+ return undefined;
+ }
+
+ if (result.player.url && !result.player.url.startsWith('http://') && !result.player.url.startsWith('https://')) {
+ return undefined;
+ }
+
+ return result;
+ };
+
+ const summary = await this.summaryCache.fetchMaybe(`${url}@${lang ?? '_DEFAULT_'}`, fetcher);
+
+ if (summary == null) {
+ throw new Error('Invalid summary');
+ }
this.logger.succ(`Got preview of ${url}: ${summary.title}`);
- if (!(summary.url.startsWith('http://') || summary.url.startsWith('https://'))) {
- throw new Error('unsupported schema included');
- }
-
- if (summary.player.url && !(summary.player.url.startsWith('http://') || summary.player.url.startsWith('https://'))) {
- throw new Error('unsupported schema included');
- }
-
summary.icon = this.wrap(summary.icon);
summary.thumbnail = this.wrap(summary.thumbnail);
@@ -120,7 +135,8 @@ export class UrlPreviewService {
}
}
- private async fetchSummary(url: string, meta: MiMeta, lang?: string): Promise {
+ @bindThis
+ private async fetchSummary(url: string, lang?: string): Promise {
const { summaly } = await import('@misskey-dev/summaly');
return summaly(url, {
@@ -130,25 +146,36 @@ export class UrlPreviewService {
http: this.httpRequestService.httpAgent,
https: this.httpRequestService.httpsAgent,
},
- userAgent: meta.urlPreviewUserAgent ?? this.summalyDefaultUserAgent,
- operationTimeout: meta.urlPreviewTimeout,
- contentLengthLimit: meta.urlPreviewMaximumContentLength,
- contentLengthRequired: meta.urlPreviewRequireContentLength,
+ userAgent: this.meta.urlPreviewUserAgent ?? this.summalyDefaultUserAgent,
+ operationTimeout: this.meta.urlPreviewTimeout,
+ contentLengthLimit: this.meta.urlPreviewMaximumContentLength,
+ contentLengthRequired: this.meta.urlPreviewRequireContentLength,
});
}
- private fetchSummaryFromProxy(url: string, meta: MiMeta, lang?: string): Promise {
- const proxy = meta.urlPreviewSummaryProxyUrl!;
+ @bindThis
+ private fetchSummaryFromProxy(url: string, lang?: string): Promise {
+ const proxy = this.meta.urlPreviewSummaryProxyUrl!;
const queryStr = query({
url: url,
lang: lang ?? 'ja-JP',
followRedirects: this.meta.urlPreviewAllowRedirect,
- userAgent: meta.urlPreviewUserAgent ?? this.summalyDefaultUserAgent,
- operationTimeout: meta.urlPreviewTimeout,
- contentLengthLimit: meta.urlPreviewMaximumContentLength,
- contentLengthRequired: meta.urlPreviewRequireContentLength,
+ userAgent: this.meta.urlPreviewUserAgent ?? this.summalyDefaultUserAgent,
+ operationTimeout: this.meta.urlPreviewTimeout,
+ contentLengthLimit: this.meta.urlPreviewMaximumContentLength,
+ contentLengthRequired: this.meta.urlPreviewRequireContentLength,
});
return this.httpRequestService.getJson(`${proxy}?${queryStr}`, 'application/json, */*', undefined, true);
}
+
+ @bindThis
+ public dispose(): void {
+ this.summaryCache.dispose();
+ }
+
+ @bindThis
+ public onApplicationShutdown(): void {
+ this.dispose();
+ }
}
diff --git a/packages/backend/test/unit/misc/cache.ts b/packages/backend/test/unit/misc/cache.ts
index 297378304e..80085cf148 100644
--- a/packages/backend/test/unit/misc/cache.ts
+++ b/packages/backend/test/unit/misc/cache.ts
@@ -38,6 +38,29 @@ describe('misc:MemoryKVCache', () => {
cache.dispose();
});
+ test('keeps current behavior when limit is omitted', () => {
+ const cache = new MemoryKVCache(1000 * 60);
+ cache.set('a', 1);
+ cache.set('b', 2);
+ cache.set('c', 3);
+ expect(cache.get('a')).toBe(1);
+ expect(cache.get('b')).toBe(2);
+ expect(cache.get('c')).toBe(3);
+ cache.dispose();
+ });
+
+ test('evicts least recently used entry when limit is reached', () => {
+ const cache = new MemoryKVCache(1000 * 60, 2);
+ cache.set('a', 1);
+ cache.set('b', 2);
+ expect(cache.get('a')).toBe(1);
+ cache.set('c', 3);
+ expect(cache.get('a')).toBe(1);
+ expect(cache.get('b')).toBeUndefined();
+ expect(cache.get('c')).toBe(3);
+ cache.dispose();
+ });
+
describe('gc()', () => {
test('removes expired entries', () => {
const cache = new MemoryKVCache(1000);
From 6ceda0f004958c9dda1633b5c2ad274069a1b59f Mon Sep 17 00:00:00 2001
From: "github-actions[bot]"
Date: Sun, 12 Jul 2026 03:12:51 +0000
Subject: [PATCH 032/168] Bump version to 2026.7.0-alpha.3
---
package.json | 2 +-
packages/misskey-js/package.json | 2 +-
2 files changed, 2 insertions(+), 2 deletions(-)
diff --git a/package.json b/package.json
index c90030f3a5..60c6087bcd 100644
--- a/package.json
+++ b/package.json
@@ -1,6 +1,6 @@
{
"name": "misskey",
- "version": "2026.7.0-alpha.2",
+ "version": "2026.7.0-alpha.3",
"codename": "nasubi",
"repository": {
"type": "git",
diff --git a/packages/misskey-js/package.json b/packages/misskey-js/package.json
index e03e240187..b935f34d53 100644
--- a/packages/misskey-js/package.json
+++ b/packages/misskey-js/package.json
@@ -1,7 +1,7 @@
{
"type": "module",
"name": "misskey-js",
- "version": "2026.7.0-alpha.2",
+ "version": "2026.7.0-alpha.3",
"description": "Misskey SDK for JavaScript",
"license": "MIT",
"main": "./built/index.js",
From aab7903ab0fa1d3505d9e6e32c105dbfbcd4864a Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?=E3=81=8B=E3=81=A3=E3=81=93=E3=81=8B=E3=82=8A?=
<67428053+kakkokari-gtyih@users.noreply.github.com>
Date: Sun, 12 Jul 2026 16:54:57 +0900
Subject: [PATCH 033/168] =?UTF-8?q?fix(backend):=20summaly=E3=81=AE?=
=?UTF-8?q?=E7=B5=90=E6=9E=9C=E3=81=AE=E3=82=AD=E3=83=A3=E3=83=83=E3=82=B7?=
=?UTF-8?q?=E3=83=A5=E3=81=8C=E5=BE=8C=E7=B6=9A=E5=87=A6=E7=90=86=E3=81=AB?=
=?UTF-8?q?=E3=82=88=E3=81=A3=E3=81=A6=E6=9B=B8=E3=81=8D=E6=8F=9B=E3=82=8F?=
=?UTF-8?q?=E3=82=8B=E5=95=8F=E9=A1=8C=E3=82=92=E4=BF=AE=E6=AD=A3?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Follow-up of #17367
---
packages/backend/src/server/web/UrlPreviewService.ts | 3 ++-
1 file changed, 2 insertions(+), 1 deletion(-)
diff --git a/packages/backend/src/server/web/UrlPreviewService.ts b/packages/backend/src/server/web/UrlPreviewService.ts
index cb36b960ec..7b7ee2c425 100644
--- a/packages/backend/src/server/web/UrlPreviewService.ts
+++ b/packages/backend/src/server/web/UrlPreviewService.ts
@@ -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');
From f74b427e67cb11d2ab182e8efbeb2ffb7338c0a4 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?=E3=81=8B=E3=81=A3=E3=81=93=E3=81=8B=E3=82=8A?=
<67428053+kakkokari-gtyih@users.noreply.github.com>
Date: Sun, 12 Jul 2026 17:15:13 +0900
Subject: [PATCH 034/168] =?UTF-8?q?fix(backend):=20config=E3=83=86?=
=?UTF-8?q?=E3=83=B3=E3=83=97=E3=83=AC=E3=83=BC=E3=83=88=E3=81=AEallowedPr?=
=?UTF-8?q?ivateNetworks=E3=82=92=E3=83=AA=E3=82=B9=E3=83=88=E8=A8=98?=
=?UTF-8?q?=E6=B3=95=E3=81=AB=E5=A4=89=E6=9B=B4=E3=81=97=E3=81=A6=E3=82=A8?=
=?UTF-8?q?=E3=83=A9=E3=83=BC=E3=81=AE=E7=99=BA=E7=94=9F=E3=82=92=E6=8A=91?=
=?UTF-8?q?=E5=88=B6=20(#17701)?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
* fix(backend): configテンプレートのallowedPrivateNetworksをリスト記法に変更してエラーの発生を抑制
* fix
---
.config/docker_example.yml | 5 ++---
.config/example.yml | 5 ++---
.config/playwright-devcontainer.yml | 5 ++---
.devcontainer/devcontainer.yml | 5 ++---
chart/files/default.yml | 5 ++---
5 files changed, 10 insertions(+), 15 deletions(-)
diff --git a/.config/docker_example.yml b/.config/docker_example.yml
index 60a314c31c..2f4ea329d2 100644
--- a/.config/docker_example.yml
+++ b/.config/docker_example.yml
@@ -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
diff --git a/.config/example.yml b/.config/example.yml
index 7151d5ae5b..8719464d22 100644
--- a/.config/example.yml
+++ b/.config/example.yml
@@ -406,9 +406,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
diff --git a/.config/playwright-devcontainer.yml b/.config/playwright-devcontainer.yml
index 7656526019..144b893741 100644
--- a/.config/playwright-devcontainer.yml
+++ b/.config/playwright-devcontainer.yml
@@ -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
diff --git a/.devcontainer/devcontainer.yml b/.devcontainer/devcontainer.yml
index cd99d063d5..8bbe1f8599 100644
--- a/.devcontainer/devcontainer.yml
+++ b/.devcontainer/devcontainer.yml
@@ -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
diff --git a/chart/files/default.yml b/chart/files/default.yml
index c148cade9f..61fa6d6f0c 100644
--- a/chart/files/default.yml
+++ b/chart/files/default.yml
@@ -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
From 53c398ce431b13922b1557db94174cc28d7fa425 Mon Sep 17 00:00:00 2001
From: "Genki.Takeuchi"
Date: Mon, 13 Jul 2026 19:58:05 +0900
Subject: [PATCH 035/168] =?UTF-8?q?fix(frontend):=20loadRawImages=E6=9C=89?=
=?UTF-8?q?=E5=8A=B9=E6=99=82=E3=80=81=E3=83=AA=E3=83=A2=E3=83=BC=E3=83=88?=
=?UTF-8?q?=E7=94=BB=E5=83=8F=E3=82=92=E3=82=BF=E3=83=83=E3=83=97=E3=81=97?=
=?UTF-8?q?=E3=81=A6=E3=81=8B=E3=82=89=E3=83=93=E3=83=A5=E3=83=BC=E3=83=AF?=
=?UTF-8?q?=E3=83=BC=E3=81=8C=E9=96=8B=E3=81=8F=E3=81=BE=E3=81=A7=E3=81=AB?=
=?UTF-8?q?=E5=88=9D=E5=9B=9E=E3=81=AE=E3=81=BF=E3=83=A9=E3=82=B0=E3=81=8C?=
=?UTF-8?q?=E7=99=BA=E7=94=9F=E3=81=99=E3=82=8B=E5=95=8F=E9=A1=8C=E3=82=92?=
=?UTF-8?q?=E4=BF=AE=E6=AD=A3=20(#17703)?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
ビューワーを表示するトリガーをサムネイル画像又はオリジナル画像がロードされたときに修正
---
packages/frontend/src/components/MkLightbox.item.vue | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/packages/frontend/src/components/MkLightbox.item.vue b/packages/frontend/src/components/MkLightbox.item.vue
index e11e5469d1..14ee8d00d9 100644
--- a/packages/frontend/src/components/MkLightbox.item.vue
+++ b/packages/frontend/src/components/MkLightbox.item.vue
@@ -702,7 +702,7 @@ function animateFromSourceToNeutral() {
});
}
-watch(thumbnailContentLoaded, () => {
+watch([thumbnailContentLoaded, originalContentLoaded], () => {
animateFromSourceToNeutral();
}, { once: true });
From ecd1d615ff8c3d4bbadfa59d9a5b855d0d221443 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?=E3=81=8A=E3=81=95=E3=82=80=E3=81=AE=E3=81=B2=E3=81=A8?=
<46447427+samunohito@users.noreply.github.com>
Date: Mon, 13 Jul 2026 20:21:18 +0900
Subject: [PATCH 036/168] =?UTF-8?q?fix(frontend):=20=E6=9C=AA=E3=83=AD?=
=?UTF-8?q?=E3=82=B0=E3=82=A4=E3=83=B3=E6=99=82=E3=81=AE=E3=83=88=E3=83=83?=
=?UTF-8?q?=E3=83=97=E3=83=9A=E3=83=BC=E3=82=B8=E3=81=8C=E3=82=B9=E3=82=AF?=
=?UTF-8?q?=E3=83=AD=E3=83=BC=E3=83=AB=E4=B8=8D=E5=8F=AF=E3=81=A0=E3=81=A3?=
=?UTF-8?q?=E3=81=9F=E3=81=AE=E3=82=92=E4=BF=AE=E6=AD=A3=20(#17681)?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
* 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)
---------
Co-authored-by: Claude Opus 4.8 (1M context)
---
CHANGELOG.md | 1 +
packages/frontend/src/pages/welcome.entrance.classic.vue | 4 ++++
packages/frontend/src/pages/welcome.entrance.simple.vue | 2 ++
3 files changed, 7 insertions(+)
diff --git a/CHANGELOG.md b/CHANGELOG.md
index cca052f102..bc08ee9739 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -34,6 +34,7 @@
- Fix: チャットでIMEの変換を確定するEnterでメッセージが送信されてしまうことがある問題を修正
- Fix: 自分へのメンションに対する色分けで、判定が大文字/小文字を区別していた問題を修正
- Fix: いくつかのイベントリスナーが正しく解除されない問題を修正(メモリ使用量の改善)
+- Fix: 非ログイン時トップページをスクロール操作できないことがある問題を修正
### Server
- Enhance: センシティブメディアの判定を外部サービス ([sensitive-detector](https://github.com/misskey-dev/sensitive-detector)) に分離し、`nsfwjs` / `@tensorflow/tfjs(-node)` の同梱と NSFW 判定モデルを廃止 (#16804)
diff --git a/packages/frontend/src/pages/welcome.entrance.classic.vue b/packages/frontend/src/pages/welcome.entrance.classic.vue
index cddec3332e..fcbb96db14 100644
--- a/packages/frontend/src/pages/welcome.entrance.classic.vue
+++ b/packages/frontend/src/pages/welcome.entrance.classic.vue
@@ -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 {
diff --git a/packages/frontend/src/pages/welcome.entrance.simple.vue b/packages/frontend/src/pages/welcome.entrance.simple.vue
index edbd312249..947afa69aa 100644
--- a/packages/frontend/src/pages/welcome.entrance.simple.vue
+++ b/packages/frontend/src/pages/welcome.entrance.simple.vue
@@ -36,6 +36,8 @@ import { instance as meta } from '@/instance.js';
right: 0;
width: 100vw;
height: 100vh;
+ // 固定レイヤがホイール操作を奪い、コンテンツ列以外の上でページをスクロールできなくなるのを防ぐ (issue #17680)
+ pointer-events: none;
}
.logoWrapper {
From 49e59a3d5694d634e86b552758fdcc3648205f4d Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?=E3=81=8A=E3=81=95=E3=82=80=E3=81=AE=E3=81=B2=E3=81=A8?=
<46447427+samunohito@users.noreply.github.com>
Date: Tue, 14 Jul 2026 11:22:14 +0900
Subject: [PATCH 037/168] 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>
---
.config/example.yml | 29 ++
CHANGELOG.md | 1 +
packages/backend/package.json | 7 +
packages/backend/rolldown.config.ts | 1 +
packages/backend/src/boot/entry.ts | 6 +
packages/backend/src/boot/master.ts | 9 +-
packages/backend/src/boot/worker.ts | 13 +-
packages/backend/src/config.ts | 11 +
.../adapters/OpenTelemetryAdapter.ts | 235 ++++++++++++++
.../adapters/SentryTelemetryAdapter.ts | 93 +++++-
.../telemetry/adapters/TelemetryAdapter.ts | 27 ++
.../src/core/telemetry/telemetry-diag.ts | 40 +++
.../src/core/telemetry/telemetry-registry.ts | 27 +-
.../src/core/telemetry/telemetry-shutdown.ts | 66 ++++
.../test/unit/SentryTelemetryAdapter.ts | 60 ----
.../adapters/OpenTelemetryAdapter.ts | 301 ++++++++++++++++++
.../adapters/SentryTelemetryAdapter.ts | 226 ++++++++++++-
.../backend/test/unit/telemetry-registry.ts | 148 +++++++++
.../backend/test/unit/telemetry-shutdown.ts | 59 ++++
pnpm-lock.yaml | 220 +++++++++++++
pnpm-workspace.yaml | 1 +
21 files changed, 1503 insertions(+), 77 deletions(-)
create mode 100644 packages/backend/src/core/telemetry/adapters/OpenTelemetryAdapter.ts
create mode 100644 packages/backend/src/core/telemetry/telemetry-diag.ts
create mode 100644 packages/backend/src/core/telemetry/telemetry-shutdown.ts
delete mode 100644 packages/backend/test/unit/SentryTelemetryAdapter.ts
create mode 100644 packages/backend/test/unit/core/telemetry/adapters/OpenTelemetryAdapter.ts
create mode 100644 packages/backend/test/unit/telemetry-registry.ts
create mode 100644 packages/backend/test/unit/telemetry-shutdown.ts
diff --git a/.config/example.yml b/.config/example.yml
index 8719464d22..ce4e332c0c 100644
--- a/.config/example.yml
+++ b/.config/example.yml
@@ -329,6 +329,35 @@ 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
+# #resourceAttributes:
+# # deployment.environment: 'production'
+# #propagateTraceToRemote: false
+
#sentryForFrontend:
# vueIntegration:
# tracingOptions:
diff --git a/CHANGELOG.md b/CHANGELOG.md
index bc08ee9739..b272a14152 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -38,6 +38,7 @@
### Server
- Enhance: センシティブメディアの判定を外部サービス ([sensitive-detector](https://github.com/misskey-dev/sensitive-detector)) に分離し、`nsfwjs` / `@tensorflow/tfjs(-node)` の同梱と NSFW 判定モデルを廃止 (#16804)
+- Enhance: バックエンドの `otelForBackend` 設定で OpenTelemetry Traces を OTLP Collector に送信できるように
- Enhance: Sentry バックエンドの自動計装を `sentryForBackend.disabledIntegrations` で個別に無効化できるように
- Enhance: Node.js 22.23.0以降、24.17.0以降、26.4.0以降をサポートするように
- Enhance: Docker Image の Node.js を 26.4.0 に、Debian を trixie (v13) に更新
diff --git a/packages/backend/package.json b/packages/backend/package.json
index 30b6a8a4ca..93021f391a 100644
--- a/packages/backend/package.json
+++ b/packages/backend/package.json
@@ -67,6 +67,13 @@
"@nestjs/common": "11.1.27",
"@nestjs/core": "11.1.27",
"@nestjs/testing": "11.1.27",
+ "@opentelemetry/api": "1.9.1",
+ "@opentelemetry/core": "2.7.1",
+ "@opentelemetry/exporter-trace-otlp-proto": "0.214.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",
diff --git a/packages/backend/rolldown.config.ts b/packages/backend/rolldown.config.ts
index c454c915a0..e6a9888a09 100644
--- a/packages/backend/rolldown.config.ts
+++ b/packages/backend/rolldown.config.ts
@@ -87,6 +87,7 @@ export default defineConfig((args) => {
'class-validator',
/^@sentry\/.*/,
/^@sentry-internal\/.*/,
+ /^@opentelemetry\/.*/,
'@nestjs/websockets/socket-module',
'@nestjs/microservices/microservices-module',
'@nestjs/microservices',
diff --git a/packages/backend/src/boot/entry.ts b/packages/backend/src/boot/entry.ts
index 9fcb4d73ca..a35bd41272 100644
--- a/packages/backend/src/boot/entry.ts
+++ b/packages/backend/src/boot/entry.ts
@@ -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 :(`));
diff --git a/packages/backend/src/boot/master.ts b/packages/backend/src/boot/master.ts
index aac68cb02f..59e1351ea1 100644
--- a/packages/backend/src/boot/master.ts
+++ b/packages/backend/src/boot/master.ts
@@ -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}]`,
diff --git a/packages/backend/src/boot/worker.ts b/packages/backend/src/boot/worker.ts
index 00d5dd9063..32bcbe8fb4 100644
--- a/packages/backend/src/boot/worker.ts
+++ b/packages/backend/src/boot/worker.ts
@@ -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();
diff --git a/packages/backend/src/config.ts b/packages/backend/src/config.ts
index a9ce40db09..8ab82ef4c7 100644
--- a/packages/backend/src/config.ts
+++ b/packages/backend/src/config.ts
@@ -26,6 +26,14 @@ type SentryBackendConfig = {
disabledIntegrations?: string[];
};
+type OtelBackendConfig = {
+ endpoint?: string;
+ headers?: Record;
+ sampleRate?: number;
+ resourceAttributes?: Record;
+ propagateTraceToRemote?: boolean;
+};
+
/**
* 設定ファイルの型
*/
@@ -71,6 +79,7 @@ type Source = {
scope?: 'local' | 'global' | string[];
};
sentryForBackend?: SentryBackendConfig;
+ otelForBackend?: OtelBackendConfig;
sentryForFrontend?: {
options: Partial & { dsn: string };
vueIntegration?: SentryVue.VueIntegrationOptions | null;
@@ -208,6 +217,7 @@ export type Config = {
redisForTimelines: RedisOptions & RedisOptionsSource;
redisForReactions: RedisOptions & RedisOptionsSource;
sentryForBackend: SentryBackendConfig | undefined;
+ otelForBackend: OtelBackendConfig | undefined;
sentryForFrontend: {
options: Partial & { dsn: string };
vueIntegration?: SentryVue.VueIntegrationOptions | null;
@@ -313,6 +323,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,
diff --git a/packages/backend/src/core/telemetry/adapters/OpenTelemetryAdapter.ts b/packages/backend/src/core/telemetry/adapters/OpenTelemetryAdapter.ts
new file mode 100644
index 0000000000..b193948670
--- /dev/null
+++ b/packages/backend/src/core/telemetry/adapters/OpenTelemetryAdapter.ts
@@ -0,0 +1,235 @@
+/*
+ * 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 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';
+
+const DEFAULT_SHUTDOWN_TIMEOUT = 5000;
+
+type OpenTelemetryAdapterDeps = {
+ tracer: Pick;
+ provider: {
+ shutdown(): Promise;
+ };
+ getActiveSpan: () => Span | undefined;
+ spanStatusCodeError: SpanStatusCode;
+ shutdownTimeout: number;
+};
+
+type CreateSamplerDeps = {
+ ParentBasedSampler: new (config: { root: Sampler }) => ParentBasedSampler;
+ TraceIdRatioBasedSampler: new (sampleRate: number) => Sampler;
+};
+
+type CreateResourceDeps = {
+ defaultResource: () => Resource;
+ resourceFromAttributes: (attributes: Record) => 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 {
+ const [
+ { diag, DiagLogLevel, 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本体をユニットテストしやすくする。
+ return new OpenTelemetryAdapter({
+ tracer: provider.getTracer('misskey-backend'),
+ provider,
+ getActiveSpan: () => trace.getActiveSpan(),
+ spanStatusCodeError: SpanStatusCode.ERROR,
+ shutdownTimeout: DEFAULT_SHUTDOWN_TIMEOUT,
+ });
+ }
+
+ 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) {
+ recordError(span, new Error(message), this.deps.spanStatusCodeError);
+ return;
+ }
+
+ this.deps.tracer.startActiveSpan('captureMessage', reportSpan => {
+ recordError(reportSpan, new Error(message), this.deps.spanStatusCodeError);
+ reportSpan.end();
+ });
+ }
+
+ public startSpan(name: string, fn: () => T): T {
+ // 既存のTelemetryAdapter契約に合わせ、同期/非同期どちらでも同じspan lifetimeを保証する。
+ return this.deps.tracer.startActiveSpan(name, span => {
+ try {
+ const result = fn();
+ if (isPromiseLike(result)) {
+ // Promiseの場合は完了/失敗までspanを開いたままにし、失敗時はerror statusへ変換する。
+ return result.then(
+ value => {
+ span.end();
+ return value;
+ },
+ error => {
+ recordError(span, error, this.deps.spanStatusCodeError);
+ span.end();
+ throw error;
+ },
+ ) as T;
+ }
+
+ // 同期成功はここでspanを閉じる。例外はcatch側で記録してから再throwする。
+ span.end();
+ return result;
+ } catch (error) {
+ recordError(span, error, this.deps.spanStatusCodeError);
+ span.end();
+ throw error;
+ }
+ });
+ }
+
+ public async shutdown(): Promise {
+ // BatchSpanProcessorのflushが詰まってもプロセス終了を妨げないよう、上限時間を設ける。
+ // タイムアウト側のtimerは、flushが先に終わった場合にイベントループを無駄に引き留めないようclearする。
+ let timer: NodeJS.Timeout | undefined;
+ await Promise.race([
+ this.deps.provider.shutdown(),
+ new Promise(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),
+ });
+}
+
+function recordError(span: Span, error: unknown, spanStatusCodeError: SpanStatusCode): void {
+ // throw値がError以外でもOTel exporterへ渡せる例外表現に正規化する。
+ const exception = error instanceof Error ? error : new Error(String(error));
+ span.recordException(exception);
+ span.setStatus({
+ code: spanStatusCodeError,
+ message: exception.message,
+ });
+}
+
+function isPromiseLike(value: T): value is T & PromiseLike> {
+ return value != null && typeof (value as { then?: unknown }).then === 'function';
+}
+
+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';
+}
diff --git a/packages/backend/src/core/telemetry/adapters/SentryTelemetryAdapter.ts b/packages/backend/src/core/telemetry/adapters/SentryTelemetryAdapter.ts
index b1858c31c1..3b6ab6d9be 100644
--- a/packages/backend/src/core/telemetry/adapters/SentryTelemetryAdapter.ts
+++ b/packages/backend/src/core/telemetry/adapters/SentryTelemetryAdapter.ts
@@ -3,14 +3,21 @@
* 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 type * as SentryNode from '@sentry/node';
+import type { NodeOptions } from '@sentry/node';
+import type { OtelBackendRuntimeConfig, SentryBackendConfig, TelemetryAdapter, TelemetryCaptureMessageOptions } from './TelemetryAdapter.js';
-type SentryIntegrationsOption = NonNullable;
+// OpenTelemetryAdapterのDEFAULT_SHUTDOWN_TIMEOUTと揃え、Sentryのtransportが詰まってもプロセス終了を妨げないようにする。
+const DEFAULT_SHUTDOWN_TIMEOUT = 5000;
+const logger = new Logger('telemetry', 'green');
+
+type SentryIntegrationsOption = NonNullable;
// eslint-disable-next-line @typescript-eslint/no-explicit-any
type SentryIntegrationFactory = Extract any[]>;
type SentryIntegration = Parameters[0][number];
-type SentryNodeOptions = import('@sentry/node').NodeOptions;
+type SentryNodeOptions = NodeOptions;
type BuildSentryIntegrationsOptions = {
disabledIntegrations?: string[];
@@ -37,7 +44,7 @@ export function buildSentryIntegrations(options: BuildSentryIntegrationsOptions)
}
export function buildSentryNodeOptions(
- config: NonNullable,
+ config: SentryBackendConfig,
nodeProfilingIntegration?: () => SentryIntegration,
): SentryNodeOptions {
return {
@@ -63,13 +70,53 @@ 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[number],
+ ],
+ };
+}
+
export class SentryTelemetryAdapter implements TelemetryAdapter {
private constructor(
- private readonly Sentry: typeof import('@sentry/node'),
+ private readonly Sentry: typeof SentryNode,
) {
}
- public static async create(config: NonNullable): Promise {
+ public static async create(config: SentryBackendConfig): Promise {
const Sentry = await import('@sentry/node');
const { nodeProfilingIntegration } = await import('@sentry/profiling-node');
@@ -78,6 +125,35 @@ export class SentryTelemetryAdapter implements TelemetryAdapter {
return new SentryTelemetryAdapter(Sentry);
}
+ public static async createWithOtlpExport(
+ sentryConfig: SentryBackendConfig,
+ otelConfig: OtelBackendRuntimeConfig,
+ ): Promise {
+ const Sentry = await import('@sentry/node');
+ const { nodeProfilingIntegration } = await import('@sentry/profiling-node');
+ const { diag, DiagLogLevel } = 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,
+ }));
+
+ return new SentryTelemetryAdapter(Sentry);
+ }
+
public captureMessage(message: string, opts: TelemetryCaptureMessageOptions): void {
this.Sentry.captureMessage(message, {
level: opts.level,
@@ -91,6 +167,7 @@ export class SentryTelemetryAdapter implements TelemetryAdapter {
}
public async shutdown(): Promise {
- await this.Sentry.close();
+ // timeout未指定だとtransportのflushが詰まった際にプロセス終了を妨げるため、上限時間を設ける。
+ await this.Sentry.close(DEFAULT_SHUTDOWN_TIMEOUT);
}
}
diff --git a/packages/backend/src/core/telemetry/adapters/TelemetryAdapter.ts b/packages/backend/src/core/telemetry/adapters/TelemetryAdapter.ts
index d074de056d..2152e901db 100644
--- a/packages/backend/src/core/telemetry/adapters/TelemetryAdapter.ts
+++ b/packages/backend/src/core/telemetry/adapters/TelemetryAdapter.ts
@@ -3,9 +3,22 @@
* SPDX-License-Identifier: AGPL-3.0-only
*/
+import type { Config } from '@/config.js';
+
+export type SentryBackendConfig = NonNullable;
+export type OtelBackendConfig = NonNullable;
+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;
}
@@ -15,7 +28,21 @@ 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(name: string, fn: () => T): T;
+
+ /**
+ * プロセス終了時にtelemetry backendへ残りのデータをflushする。
+ * 実装側ではtransport停止に引きずられないよう、待機時間に上限を設ける。
+ */
shutdown(): Promise;
}
diff --git a/packages/backend/src/core/telemetry/telemetry-diag.ts b/packages/backend/src/core/telemetry/telemetry-diag.ts
new file mode 100644
index 0000000000..9d72d0a6bf
--- /dev/null
+++ b/packages/backend/src/core/telemetry/telemetry-diag.ts
@@ -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(' ')}`;
+}
diff --git a/packages/backend/src/core/telemetry/telemetry-registry.ts b/packages/backend/src/core/telemetry/telemetry-registry.ts
index 90dc267837..840def3a14 100644
--- a/packages/backend/src/core/telemetry/telemetry-registry.ts
+++ b/packages/backend/src/core/telemetry/telemetry-registry.ts
@@ -4,8 +4,9 @@
*/
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';
/**
* NestのDIコンテナが構築される前(boot処理内)で初期化する必要があるため、
@@ -15,18 +16,37 @@ import type { TelemetryAdapter, TelemetryCaptureMessageOptions } from './adapter
const adapters: TelemetryAdapter[] = [];
export async function initTelemetry(config: Config): Promise {
- 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(name: string, fn: () => T): T {
+ // 有効なadapterが無い/1つだけの場合(実運用上の大半のケース)は、
+ // 毎リクエスト/ジョブでreduceRightのclosureを組み立てる無駄を避ける。
+ if (adapters.length === 0) return fn();
+ if (adapters.length === 1) return adapters[0].startSpan(name, fn);
+
+ // 将来複数adapterを登録する場合でも同じ処理を入れ子にラップし、呼び出し側のAPIは1回のstartSpanに保つ。
const wrapped = adapters.reduceRight<() => T>(
(inner, adapter) => () => adapter.startSpan(name, inner),
fn,
@@ -35,5 +55,6 @@ export function startSpan(name: string, fn: () => T): T {
}
export async function shutdownTelemetry(): Promise {
- await Promise.all(adapters.map(adapter => adapter.shutdown()));
+ // 終了時は登録済みadapterを並列にflush/shutdownする。
+ await Promise.allSettled(adapters.map(adapter => adapter.shutdown()));
}
diff --git a/packages/backend/src/core/telemetry/telemetry-shutdown.ts b/packages/backend/src/core/telemetry/telemetry-shutdown.ts
new file mode 100644
index 0000000000..07d950f70d
--- /dev/null
+++ b/packages/backend/src/core/telemetry/telemetry-shutdown.ts
@@ -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): unknown;
+};
+
+type InstallTelemetrySignalHandlersOptions = {
+ process?: SignalProcess;
+ shutdownTelemetry?: () => Promise;
+ 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;
+}
diff --git a/packages/backend/test/unit/SentryTelemetryAdapter.ts b/packages/backend/test/unit/SentryTelemetryAdapter.ts
deleted file mode 100644
index 4e66fc08e7..0000000000
--- a/packages/backend/test/unit/SentryTelemetryAdapter.ts
+++ /dev/null
@@ -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>[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');
- });
-});
diff --git a/packages/backend/test/unit/core/telemetry/adapters/OpenTelemetryAdapter.ts b/packages/backend/test/unit/core/telemetry/adapters/OpenTelemetryAdapter.ts
new file mode 100644
index 0000000000..12224bdb51
--- /dev/null
+++ b/packages/backend/test/unit/core/telemetry/adapters/OpenTelemetryAdapter.ts
@@ -0,0 +1,301 @@
+/*
+ * 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 { 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) => 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) => 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('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(() => {})) },
+ 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');
+ });
+});
diff --git a/packages/backend/test/unit/core/telemetry/adapters/SentryTelemetryAdapter.ts b/packages/backend/test/unit/core/telemetry/adapters/SentryTelemetryAdapter.ts
index 263f30520f..00caaaa660 100644
--- a/packages/backend/test/unit/core/telemetry/adapters/SentryTelemetryAdapter.ts
+++ b/packages/backend/test/unit/core/telemetry/adapters/SentryTelemetryAdapter.ts
@@ -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>[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,173 @@ 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', () => ({
+ diag: { setLogger },
+ DiagLogLevel: { WARN: 50 },
+ }));
+ 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');
+ });
});
diff --git a/packages/backend/test/unit/telemetry-registry.ts b/packages/backend/test/unit/telemetry-registry.ts
new file mode 100644
index 0000000000..fc672a3f7d
--- /dev/null
+++ b/packages/backend/test/unit/telemetry-registry.ts
@@ -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 {
+ 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);
+ });
+});
diff --git a/packages/backend/test/unit/telemetry-shutdown.ts b/packages/backend/test/unit/telemetry-shutdown.ts
new file mode 100644
index 0000000000..11c47c4181
--- /dev/null
+++ b/packages/backend/test/unit/telemetry-shutdown.ts
@@ -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 Promise>();
+ const processLike = {
+ once: vi.fn((event: string, handler: () => Promise) => {
+ 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 Promise>();
+ const processLike = {
+ once: vi.fn((event: string, handler: () => Promise) => {
+ 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);
+ });
+});
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index 738be6be51..e7926c7017 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -119,6 +119,27 @@ 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.7.1
+ version: 2.7.1(@opentelemetry/api@1.9.1)
+ '@opentelemetry/exporter-trace-otlp-proto':
+ specifier: 0.214.0
+ version: 0.214.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
@@ -2545,30 +2566,90 @@ packages:
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/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@0.214.0':
resolution: {integrity: sha512-MHqEX5Dk59cqVah5LiARMACku7jXSVk9iVDWOea4x3cr7VfdByeDCURK6o1lntT1JS/Tsovw01UJrBhN3/uC5w==}
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'}
@@ -2965,6 +3046,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'}
@@ -6465,6 +6573,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 +7517,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'}
@@ -10461,11 +10576,29 @@ snapshots:
'@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/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@0.214.0(@opentelemetry/api@1.9.1)':
dependencies:
'@opentelemetry/api': 1.9.1
@@ -10475,12 +10608,56 @@ snapshots:
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,6 +10665,13 @@ 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': {}
'@oxc-parser/binding-android-arm-eabi@0.127.0':
@@ -10803,6 +10987,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
@@ -14626,6 +14830,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 +15990,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
diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml
index fb37eb0537..4abd40eaf9 100644
--- a/pnpm-workspace.yaml
+++ b/pnpm-workspace.yaml
@@ -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/*'
From 5a2acf71a0df62eda2c4d929c1cb75cb5df6c6a7 Mon Sep 17 00:00:00 2001
From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com>
Date: Tue, 14 Jul 2026 11:39:49 +0900
Subject: [PATCH 038/168] fix(deps): update dependency @opentelemetry/core to
v2.8.0 [security] (#17707)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
---
packages/backend/package.json | 2 +-
pnpm-lock.yaml | 39 ++++++++++++++++++++++-------------
pnpm-workspace.yaml | 2 ++
3 files changed, 28 insertions(+), 15 deletions(-)
diff --git a/packages/backend/package.json b/packages/backend/package.json
index 93021f391a..22e7574331 100644
--- a/packages/backend/package.json
+++ b/packages/backend/package.json
@@ -68,7 +68,7 @@
"@nestjs/core": "11.1.27",
"@nestjs/testing": "11.1.27",
"@opentelemetry/api": "1.9.1",
- "@opentelemetry/core": "2.7.1",
+ "@opentelemetry/core": "2.8.0",
"@opentelemetry/exporter-trace-otlp-proto": "0.214.0",
"@opentelemetry/resources": "2.7.1",
"@opentelemetry/sdk-trace-base": "2.7.1",
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index e7926c7017..554448f89d 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -123,8 +123,8 @@ importers:
specifier: 1.9.1
version: 1.9.1
'@opentelemetry/core':
- specifier: 2.7.1
- version: 2.7.1(@opentelemetry/api@1.9.1)
+ 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)
@@ -148,10 +148,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
@@ -2584,6 +2584,12 @@ packages:
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}
@@ -10590,6 +10596,11 @@ snapshots:
'@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
@@ -11262,15 +11273,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)
@@ -11279,15 +11290,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:
@@ -11295,18 +11306,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'
diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml
index 4abd40eaf9..b5cf980f19 100644
--- a/pnpm-workspace.yaml
+++ b/pnpm-workspace.yaml
@@ -53,6 +53,8 @@ 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': '-'
chokidar: 5.0.0
From a98469d34d65e3d07e095ba50e8ddaceea75d24e Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?=E3=81=8A=E3=81=95=E3=82=80=E3=81=AE=E3=81=B2=E3=81=A8?=
<46447427+samunohito@users.noreply.github.com>
Date: Tue, 14 Jul 2026 14:13:33 +0900
Subject: [PATCH 039/168] =?UTF-8?q?feat:=20OTel=E3=81=AEHTTP=E3=83=AA?=
=?UTF-8?q?=E3=82=AF=E3=82=A8=E3=82=B9=E3=83=88=E8=87=AA=E5=8B=95=E8=A8=88?=
=?UTF-8?q?=E8=A3=85=E3=82=92=E8=BF=BD=E5=8A=A0=20(#17709)?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
CHANGELOG.md | 1 +
.../adapters/OpenTelemetryAdapter.ts | 14 +-
.../telemetry/http-client-instrumentation.ts | 122 ++++++++++++++++++
.../telemetry/http-client-instrumentation.ts | 108 ++++++++++++++++
4 files changed, 243 insertions(+), 2 deletions(-)
create mode 100644 packages/backend/src/core/telemetry/http-client-instrumentation.ts
create mode 100644 packages/backend/test/unit/core/telemetry/http-client-instrumentation.ts
diff --git a/CHANGELOG.md b/CHANGELOG.md
index b272a14152..9be4160b5d 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -39,6 +39,7 @@
### Server
- Enhance: センシティブメディアの判定を外部サービス ([sensitive-detector](https://github.com/misskey-dev/sensitive-detector)) に分離し、`nsfwjs` / `@tensorflow/tfjs(-node)` の同梱と NSFW 判定モデルを廃止 (#16804)
- Enhance: バックエンドの `otelForBackend` 設定で OpenTelemetry Traces を OTLP Collector に送信できるように
+- Enhance: OpenTelemetry を単独で利用する際、外向き HTTP リクエストを自動計装するように
- Enhance: Sentry バックエンドの自動計装を `sentryForBackend.disabledIntegrations` で個別に無効化できるように
- Enhance: Node.js 22.23.0以降、24.17.0以降、26.4.0以降をサポートするように
- Enhance: Docker Image の Node.js を 26.4.0 に、Debian を trixie (v13) に更新
diff --git a/packages/backend/src/core/telemetry/adapters/OpenTelemetryAdapter.ts b/packages/backend/src/core/telemetry/adapters/OpenTelemetryAdapter.ts
index b193948670..4fc7f2ef4f 100644
--- a/packages/backend/src/core/telemetry/adapters/OpenTelemetryAdapter.ts
+++ b/packages/backend/src/core/telemetry/adapters/OpenTelemetryAdapter.ts
@@ -7,6 +7,7 @@ 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 type { Span, SpanStatusCode, Tracer } from '@opentelemetry/api';
import type { Resource, ResourceDetector } from '@opentelemetry/resources';
import type { ParentBasedSampler, Sampler } from '@opentelemetry/sdk-trace-base';
@@ -22,6 +23,7 @@ type OpenTelemetryAdapterDeps = {
getActiveSpan: () => Span | undefined;
spanStatusCodeError: SpanStatusCode;
shutdownTimeout: number;
+ shutdownHttpClientInstrumentation?: () => void;
};
type CreateSamplerDeps = {
@@ -48,7 +50,7 @@ export class OpenTelemetryAdapter implements TelemetryAdapter {
public static async create(config: OtelBackendRuntimeConfig): Promise {
const [
- { diag, DiagLogLevel, SpanStatusCode, trace },
+ { diag, DiagLogLevel, SpanKind, SpanStatusCode, trace },
{ W3CTraceContextPropagator },
{ OTLPTraceExporter },
{ defaultResource, detectResources, envDetector, resourceFromAttributes },
@@ -100,12 +102,19 @@ export class OpenTelemetryAdapter implements TelemetryAdapter {
});
// provider操作をdepsに閉じ込め、span wrapper本体をユニットテストしやすくする。
+ const tracer = provider.getTracer('misskey-backend');
+
return new OpenTelemetryAdapter({
- tracer: provider.getTracer('misskey-backend'),
+ tracer,
provider,
getActiveSpan: () => trace.getActiveSpan(),
spanStatusCodeError: SpanStatusCode.ERROR,
shutdownTimeout: DEFAULT_SHUTDOWN_TIMEOUT,
+ shutdownHttpClientInstrumentation: installHttpClientInstrumentation({
+ tracer,
+ spanKindClient: SpanKind.CLIENT,
+ spanStatusCodeError: SpanStatusCode.ERROR,
+ }),
});
}
@@ -157,6 +166,7 @@ export class OpenTelemetryAdapter implements TelemetryAdapter {
}
public async shutdown(): Promise {
+ this.deps.shutdownHttpClientInstrumentation?.();
// BatchSpanProcessorのflushが詰まってもプロセス終了を妨げないよう、上限時間を設ける。
// タイムアウト側のtimerは、flushが先に終わった場合にイベントループを無駄に引き留めないようclearする。
let timer: NodeJS.Timeout | undefined;
diff --git a/packages/backend/src/core/telemetry/http-client-instrumentation.ts b/packages/backend/src/core/telemetry/http-client-instrumentation.ts
new file mode 100644
index 0000000000..a2ae4b5692
--- /dev/null
+++ b/packages/backend/src/core/telemetry/http-client-instrumentation.ts
@@ -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;
+
+type HttpClientInstrumentationDeps = {
+ tracer: Pick;
+ 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();
+
+ 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): () => 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;
+}
diff --git a/packages/backend/test/unit/core/telemetry/http-client-instrumentation.ts b/packages/backend/test/unit/core/telemetry/http-client-instrumentation.ts
new file mode 100644
index 0000000000..e05f4407e4
--- /dev/null
+++ b/packages/backend/test/unit/core/telemetry/http-client-instrumentation.ts
@@ -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 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 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 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 });
+ });
+});
From 3d4c62b0bb66117fea074d8164effe25172c2c84 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?=E3=81=8A=E3=81=95=E3=82=80=E3=81=AE=E3=81=B2=E3=81=A8?=
<46447427+samunohito@users.noreply.github.com>
Date: Tue, 14 Jul 2026 16:36:40 +0900
Subject: [PATCH 040/168] =?UTF-8?q?feat:=20BullMQ=E3=81=AE=E3=82=B8?=
=?UTF-8?q?=E3=83=A7=E3=83=96=E8=BF=BD=E5=8A=A0=E6=99=82=E3=81=AB=E7=99=BA?=
=?UTF-8?q?=E5=8B=95=E3=81=99=E3=82=8B=E6=89=8B=E5=8B=95=E8=A8=88=E6=B8=AC?=
=?UTF-8?q?=E3=81=AE=E8=BF=BD=E5=8A=A0=20(#17710)?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
* feat: BullMQのジョブ追加時に発動する手動計測の追加
* add comment
---
.config/example.yml | 4 +
CHANGELOG.md | 1 +
packages/backend/src/config.ts | 1 +
packages/backend/src/core/QueueModule.ts | 30 ++-
.../src/core/telemetry/TelemetryService.ts | 9 +-
.../adapters/OpenTelemetryAdapter.ts | 73 +++----
.../adapters/SentryTelemetryAdapter.ts | 30 ++-
.../telemetry/adapters/TelemetryAdapter.ts | 13 ++
.../core/telemetry/queue-instrumentation.ts | 33 ++++
.../src/core/telemetry/queue-trace-context.ts | 182 ++++++++++++++++++
.../src/core/telemetry/telemetry-registry.ts | 14 ++
.../src/queue/QueueProcessorService.ts | 22 ++-
.../adapters/OpenTelemetryAdapter.ts | 56 ++++++
.../adapters/SentryTelemetryAdapter.ts | 5 +
.../core/telemetry/queue-instrumentation.ts | 57 ++++++
.../core/telemetry/queue-trace-context.ts | 132 +++++++++++++
16 files changed, 596 insertions(+), 66 deletions(-)
create mode 100644 packages/backend/src/core/telemetry/queue-instrumentation.ts
create mode 100644 packages/backend/src/core/telemetry/queue-trace-context.ts
create mode 100644 packages/backend/test/unit/core/telemetry/queue-instrumentation.ts
create mode 100644 packages/backend/test/unit/core/telemetry/queue-trace-context.ts
diff --git a/.config/example.yml b/.config/example.yml
index ce4e332c0c..78f14854e5 100644
--- a/.config/example.yml
+++ b/.config/example.yml
@@ -357,6 +357,10 @@ id: 'aidx'
# #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:
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 9be4160b5d..8492476c66 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -40,6 +40,7 @@
- Enhance: センシティブメディアの判定を外部サービス ([sensitive-detector](https://github.com/misskey-dev/sensitive-detector)) に分離し、`nsfwjs` / `@tensorflow/tfjs(-node)` の同梱と NSFW 判定モデルを廃止 (#16804)
- Enhance: バックエンドの `otelForBackend` 設定で OpenTelemetry Traces を OTLP Collector に送信できるように
- Enhance: OpenTelemetry を単独で利用する際、外向き HTTP リクエストを自動計装するように
+- Enhance: OpenTelemetry 利用時に BullMQ ジョブを enqueue 元の trace と Span Link で関連付けられるように
- Enhance: Sentry バックエンドの自動計装を `sentryForBackend.disabledIntegrations` で個別に無効化できるように
- Enhance: Node.js 22.23.0以降、24.17.0以降、26.4.0以降をサポートするように
- Enhance: Docker Image の Node.js を 26.4.0 に、Debian を trixie (v13) に更新
diff --git a/packages/backend/src/config.ts b/packages/backend/src/config.ts
index 8ab82ef4c7..ff0df65f93 100644
--- a/packages/backend/src/config.ts
+++ b/packages/backend/src/config.ts
@@ -32,6 +32,7 @@ type OtelBackendConfig = {
sampleRate?: number;
resourceAttributes?: Record;
propagateTraceToRemote?: boolean;
+ jobTraceContextMode?: 'link' | 'parent';
};
/**
diff --git a/packages/backend/src/core/QueueModule.ts b/packages/backend/src/core/QueueModule.ts
index ecd96261e0..ad25631152 100644
--- a/packages/backend/src/core/QueueModule.ts
+++ b/packages/backend/src/core/QueueModule.ts
@@ -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;
export type SystemWebhookDeliverQueue = Bull.Queue;
+function createQueue(queueName: string, config: Config): Bull.Queue {
+ const queue = new Bull.Queue(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>(QUEUE.SYSTEM, config),
inject: [DI.config],
};
const $endedPollNotification: Provider = {
provide: 'queue:endedPollNotification',
- useFactory: (config: Config) => new Bull.Queue(QUEUE.ENDED_POLL_NOTIFICATION, baseQueueOptions(config, QUEUE.ENDED_POLL_NOTIFICATION)),
+ useFactory: (config: Config) => createQueue(QUEUE.ENDED_POLL_NOTIFICATION, config),
inject: [DI.config],
};
const $postScheduledNote: Provider = {
provide: 'queue:postScheduledNote',
- useFactory: (config: Config) => new Bull.Queue(QUEUE.POST_SCHEDULED_NOTE, baseQueueOptions(config, QUEUE.POST_SCHEDULED_NOTE)),
+ useFactory: (config: Config) => createQueue(QUEUE.POST_SCHEDULED_NOTE, config),
inject: [DI.config],
};
const $deliver: Provider = {
provide: 'queue:deliver',
- useFactory: (config: Config) => new Bull.Queue(QUEUE.DELIVER, baseQueueOptions(config, QUEUE.DELIVER)),
+ useFactory: (config: Config) => createQueue(QUEUE.DELIVER, config),
inject: [DI.config],
};
const $inbox: Provider = {
provide: 'queue:inbox',
- useFactory: (config: Config) => new Bull.Queue(QUEUE.INBOX, baseQueueOptions(config, QUEUE.INBOX)),
+ useFactory: (config: Config) => createQueue(QUEUE.INBOX, config),
inject: [DI.config],
};
const $db: Provider = {
provide: 'queue:db',
- useFactory: (config: Config) => new Bull.Queue(QUEUE.DB, baseQueueOptions(config, QUEUE.DB)),
+ useFactory: (config: Config) => createQueue>(QUEUE.DB, config),
inject: [DI.config],
};
const $relationship: Provider = {
provide: 'queue:relationship',
- useFactory: (config: Config) => new Bull.Queue(QUEUE.RELATIONSHIP, baseQueueOptions(config, QUEUE.RELATIONSHIP)),
+ useFactory: (config: Config) => createQueue(QUEUE.RELATIONSHIP, config),
inject: [DI.config],
};
const $objectStorage: Provider = {
provide: 'queue:objectStorage',
- useFactory: (config: Config) => new Bull.Queue(QUEUE.OBJECT_STORAGE, baseQueueOptions(config, QUEUE.OBJECT_STORAGE)),
+ useFactory: (config: Config) => createQueue>(QUEUE.OBJECT_STORAGE, config),
inject: [DI.config],
};
const $userWebhookDeliver: Provider = {
provide: 'queue:userWebhookDeliver',
- useFactory: (config: Config) => new Bull.Queue(QUEUE.USER_WEBHOOK_DELIVER, baseQueueOptions(config, QUEUE.USER_WEBHOOK_DELIVER)),
+ useFactory: (config: Config) => createQueue(QUEUE.USER_WEBHOOK_DELIVER, config),
inject: [DI.config],
};
const $systemWebhookDeliver: Provider = {
provide: 'queue:systemWebhookDeliver',
- useFactory: (config: Config) => new Bull.Queue(QUEUE.SYSTEM_WEBHOOK_DELIVER, baseQueueOptions(config, QUEUE.SYSTEM_WEBHOOK_DELIVER)),
+ useFactory: (config: Config) => createQueue(QUEUE.SYSTEM_WEBHOOK_DELIVER, config),
inject: [DI.config],
};
diff --git a/packages/backend/src/core/telemetry/TelemetryService.ts b/packages/backend/src/core/telemetry/TelemetryService.ts
index e73beb4f8a..8a62a521df 100644
--- a/packages/backend/src/core/telemetry/TelemetryService.ts
+++ b/packages/backend/src/core/telemetry/TelemetryService.ts
@@ -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(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 {
await shutdownTelemetry();
diff --git a/packages/backend/src/core/telemetry/adapters/OpenTelemetryAdapter.ts b/packages/backend/src/core/telemetry/adapters/OpenTelemetryAdapter.ts
index 4fc7f2ef4f..b8aa827588 100644
--- a/packages/backend/src/core/telemetry/adapters/OpenTelemetryAdapter.ts
+++ b/packages/backend/src/core/telemetry/adapters/OpenTelemetryAdapter.ts
@@ -8,10 +8,12 @@ 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 { 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;
@@ -24,6 +26,7 @@ type OpenTelemetryAdapterDeps = {
spanStatusCodeError: SpanStatusCode;
shutdownTimeout: number;
shutdownHttpClientInstrumentation?: () => void;
+ queueTraceContext?: QueueTraceContextDeps;
};
type CreateSamplerDeps = {
@@ -50,7 +53,7 @@ export class OpenTelemetryAdapter implements TelemetryAdapter {
public static async create(config: OtelBackendRuntimeConfig): Promise {
const [
- { diag, DiagLogLevel, SpanKind, SpanStatusCode, trace },
+ { context, diag, DiagLogLevel, propagation, ROOT_CONTEXT, SpanKind, SpanStatusCode, trace },
{ W3CTraceContextPropagator },
{ OTLPTraceExporter },
{ defaultResource, detectResources, envDetector, resourceFromAttributes },
@@ -115,6 +118,15 @@ export class OpenTelemetryAdapter implements TelemetryAdapter {
spanKindClient: SpanKind.CLIENT,
spanStatusCodeError: SpanStatusCode.ERROR,
}),
+ queueTraceContext: {
+ tracer,
+ propagation,
+ trace,
+ getActiveContext: () => context.active(),
+ rootContext: ROOT_CONTEXT,
+ mode: getQueueTraceContextMode(config.jobTraceContextMode),
+ spanStatusCodeError: SpanStatusCode.ERROR,
+ },
});
}
@@ -124,45 +136,34 @@ export class OpenTelemetryAdapter implements TelemetryAdapter {
// 通知を握り潰さないよう、報告専用の短命spanを作ってそこに記録する。
const span = this.deps.getActiveSpan();
if (span != null) {
- recordError(span, new Error(message), this.deps.spanStatusCodeError);
+ recordSpanError(span, new Error(message), this.deps.spanStatusCodeError);
return;
}
this.deps.tracer.startActiveSpan('captureMessage', reportSpan => {
- recordError(reportSpan, new Error(message), this.deps.spanStatusCodeError);
+ recordSpanError(reportSpan, new Error(message), this.deps.spanStatusCodeError);
reportSpan.end();
});
}
public startSpan(name: string, fn: () => T): T {
// 既存のTelemetryAdapter契約に合わせ、同期/非同期どちらでも同じspan lifetimeを保証する。
- return this.deps.tracer.startActiveSpan(name, span => {
- try {
- const result = fn();
- if (isPromiseLike(result)) {
- // Promiseの場合は完了/失敗までspanを開いたままにし、失敗時はerror statusへ変換する。
- return result.then(
- value => {
- span.end();
- return value;
- },
- error => {
- recordError(span, error, this.deps.spanStatusCodeError);
- span.end();
- throw error;
- },
- ) as T;
- }
+ return this.deps.tracer.startActiveSpan(name, span => executeSpan(span, fn, this.deps.spanStatusCodeError));
+ }
- // 同期成功はここでspanを閉じる。例外はcatch側で記録してから再throwする。
- span.end();
- return result;
- } catch (error) {
- recordError(span, error, this.deps.spanStatusCodeError);
- span.end();
- throw error;
- }
- });
+ public injectTraceContext(carrier: QueueTraceContextCarrier): void {
+ const queueTraceContext = this.deps.queueTraceContext;
+ // Queue context 用の依存は任意なので、無い場合はジョブデータを変更しない。
+ if (queueTraceContext == null) return;
+ injectActiveTraceContext(queueTraceContext, carrier);
+ }
+
+ public startSpanWithTraceContext(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 {
@@ -211,20 +212,6 @@ export function createSampler(sampleRate: number, deps: CreateSamplerDeps): Pare
});
}
-function recordError(span: Span, error: unknown, spanStatusCodeError: SpanStatusCode): void {
- // throw値がError以外でもOTel exporterへ渡せる例外表現に正規化する。
- const exception = error instanceof Error ? error : new Error(String(error));
- span.recordException(exception);
- span.setStatus({
- code: spanStatusCodeError,
- message: exception.message,
- });
-}
-
-function isPromiseLike(value: T): value is T & PromiseLike> {
- return value != null && typeof (value as { then?: unknown }).then === 'function';
-}
-
export function getMisskeyProcessRole(): string {
// Trace backend上でserver/queue/workerを見分けられるよう、Misskey固有の役割をresourceに載せる。
if (envOption.disableClustering) {
diff --git a/packages/backend/src/core/telemetry/adapters/SentryTelemetryAdapter.ts b/packages/backend/src/core/telemetry/adapters/SentryTelemetryAdapter.ts
index 3b6ab6d9be..8b9674af6d 100644
--- a/packages/backend/src/core/telemetry/adapters/SentryTelemetryAdapter.ts
+++ b/packages/backend/src/core/telemetry/adapters/SentryTelemetryAdapter.ts
@@ -5,9 +5,11 @@
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';
// OpenTelemetryAdapterのDEFAULT_SHUTDOWN_TIMEOUTと揃え、Sentryのtransportが詰まってもプロセス終了を妨げないようにする。
const DEFAULT_SHUTDOWN_TIMEOUT = 5000;
@@ -113,6 +115,7 @@ export function buildSentryOtlpInitOptions(options: BuildSentryOtlpInitOptions):
export class SentryTelemetryAdapter implements TelemetryAdapter {
private constructor(
private readonly Sentry: typeof SentryNode,
+ private readonly queueTraceContext?: QueueTraceContextDeps,
) {
}
@@ -131,7 +134,7 @@ export class SentryTelemetryAdapter implements TelemetryAdapter {
): Promise {
const Sentry = await import('@sentry/node');
const { nodeProfilingIntegration } = await import('@sentry/profiling-node');
- const { diag, DiagLogLevel } = await import('@opentelemetry/api');
+ 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');
@@ -151,7 +154,17 @@ export class SentryTelemetryAdapter implements TelemetryAdapter {
nodeProfilingIntegration,
}));
- return new SentryTelemetryAdapter(Sentry);
+ // 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 {
@@ -166,6 +179,19 @@ 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(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 {
// timeout未指定だとtransportのflushが詰まった際にプロセス終了を妨げるため、上限時間を設ける。
await this.Sentry.close(DEFAULT_SHUTDOWN_TIMEOUT);
diff --git a/packages/backend/src/core/telemetry/adapters/TelemetryAdapter.ts b/packages/backend/src/core/telemetry/adapters/TelemetryAdapter.ts
index 2152e901db..f9564e7e7e 100644
--- a/packages/backend/src/core/telemetry/adapters/TelemetryAdapter.ts
+++ b/packages/backend/src/core/telemetry/adapters/TelemetryAdapter.ts
@@ -4,6 +4,7 @@
*/
import type { Config } from '@/config.js';
+import type { QueueTraceContextCarrier } from '../queue-trace-context.js';
export type SentryBackendConfig = NonNullable;
export type OtelBackendConfig = NonNullable;
@@ -40,6 +41,18 @@ export interface TelemetryAdapter {
*/
startSpan(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?(name: string, jobData: object, fn: () => T): T;
+
/**
* プロセス終了時にtelemetry backendへ残りのデータをflushする。
* 実装側ではtransport停止に引きずられないよう、待機時間に上限を設ける。
diff --git a/packages/backend/src/core/telemetry/queue-instrumentation.ts b/packages/backend/src/core/telemetry/queue-instrumentation.ts
new file mode 100644
index 0000000000..90eb710efa
--- /dev/null
+++ b/packages/backend/src/core/telemetry/queue-instrumentation.ts
@@ -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(queue: Bull.Queue): Bull.Queue {
+ // 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;
+}
diff --git a/packages/backend/src/core/telemetry/queue-trace-context.ts b/packages/backend/src/core/telemetry/queue-trace-context.ts
new file mode 100644
index 0000000000..d9f7f7462d
--- /dev/null
+++ b/packages/backend/src/core/telemetry/queue-trace-context.ts
@@ -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;
+export type QueueTraceContextMode = 'link' | 'parent';
+
+// 通常のジョブプロセッサが参照しない Misskey 内部フィールドとして、ユーザー定義の data と区別する。
+const QUEUE_TRACE_CONTEXT_KEY = '__misskeyTraceContext';
+
+export type QueueTraceContextDeps = {
+ tracer: Pick;
+ propagation: Pick;
+ trace: {
+ getSpanContext(context: Context): SpanContext | undefined;
+ };
+ getActiveContext: () => Context;
+ rootContext: Context;
+ mode: QueueTraceContextMode;
+ spanStatusCodeError: SpanStatusCode;
+};
+
+export type QueueSpanContext = {
+ options: SpanOptions;
+ parentContext: Context;
+};
+
+type QueueSpanContextDeps = Pick;
+
+/**
+ * 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(
+ 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(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)[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(value: T): value is T & PromiseLike> {
+ // native Promise に限らず thenable も span の完了を待てるよう、instanceof ではなく then の有無で判定する。
+ return value != null && typeof (value as { then?: unknown }).then === 'function';
+}
diff --git a/packages/backend/src/core/telemetry/telemetry-registry.ts b/packages/backend/src/core/telemetry/telemetry-registry.ts
index 840def3a14..b08301816b 100644
--- a/packages/backend/src/core/telemetry/telemetry-registry.ts
+++ b/packages/backend/src/core/telemetry/telemetry-registry.ts
@@ -7,6 +7,7 @@ import type { Config } from '@/config.js';
import { OpenTelemetryAdapter } from './adapters/OpenTelemetryAdapter.js';
import { SentryTelemetryAdapter } from './adapters/SentryTelemetryAdapter.js';
import type { OtelBackendRuntimeConfig, TelemetryAdapter, TelemetryCaptureMessageOptions } from './adapters/TelemetryAdapter.js';
+import type { QueueTraceContextCarrier } from './queue-trace-context.js';
/**
* NestのDIコンテナが構築される前(boot処理内)で初期化する必要があるため、
@@ -54,6 +55,19 @@ export function startSpan(name: string, fn: () => T): T {
return wrapped();
}
+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(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 {
// 終了時は登録済みadapterを並列にflush/shutdownする。
await Promise.allSettled(adapters.map(adapter => adapter.shutdown()));
diff --git a/packages/backend/src/queue/QueueProcessorService.ts b/packages/backend/src/queue/QueueProcessorService.ts
index cd1e752fd5..c20c39f15a 100644
--- a/packages/backend/src/queue/QueueProcessorService.ts
+++ b/packages/backend/src/queue/QueueProcessorService.ts
@@ -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,
diff --git a/packages/backend/test/unit/core/telemetry/adapters/OpenTelemetryAdapter.ts b/packages/backend/test/unit/core/telemetry/adapters/OpenTelemetryAdapter.ts
index 12224bdb51..4b615179f4 100644
--- a/packages/backend/test/unit/core/telemetry/adapters/OpenTelemetryAdapter.ts
+++ b/packages/backend/test/unit/core/telemetry/adapters/OpenTelemetryAdapter.ts
@@ -8,6 +8,7 @@ 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(() => {
@@ -97,6 +98,61 @@ describe('OpenTelemetryAdapter', () => {
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(),
diff --git a/packages/backend/test/unit/core/telemetry/adapters/SentryTelemetryAdapter.ts b/packages/backend/test/unit/core/telemetry/adapters/SentryTelemetryAdapter.ts
index 00caaaa660..c304731987 100644
--- a/packages/backend/test/unit/core/telemetry/adapters/SentryTelemetryAdapter.ts
+++ b/packages/backend/test/unit/core/telemetry/adapters/SentryTelemetryAdapter.ts
@@ -208,8 +208,13 @@ describe('SentryTelemetryAdapter.createWithOtlpExport', () => {
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,
diff --git a/packages/backend/test/unit/core/telemetry/queue-instrumentation.ts b/packages/backend/test/unit/core/telemetry/queue-instrumentation.ts
new file mode 100644
index 0000000000..ac73df3c13
--- /dev/null
+++ b/packages/backend/test/unit/core/telemetry/queue-instrumentation.ts
@@ -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) => {
+ 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);
+ });
+});
diff --git a/packages/backend/test/unit/core/telemetry/queue-trace-context.ts b/packages/backend/test/unit/core/telemetry/queue-trace-context.ts
new file mode 100644
index 0000000000..3ecf4fe270
--- /dev/null
+++ b/packages/backend/test/unit/core/telemetry/queue-trace-context.ts
@@ -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');
+ });
+});
From 91ea966b0432c836555c2f6e6324bd9d637b70ec Mon Sep 17 00:00:00 2001
From: "github-actions[bot]"
Date: Tue, 14 Jul 2026 07:58:57 +0000
Subject: [PATCH 041/168] Bump version to 2026.7.0-alpha.4
---
package.json | 2 +-
packages/misskey-js/package.json | 2 +-
2 files changed, 2 insertions(+), 2 deletions(-)
diff --git a/package.json b/package.json
index 60c6087bcd..643035b384 100644
--- a/package.json
+++ b/package.json
@@ -1,6 +1,6 @@
{
"name": "misskey",
- "version": "2026.7.0-alpha.3",
+ "version": "2026.7.0-alpha.4",
"codename": "nasubi",
"repository": {
"type": "git",
diff --git a/packages/misskey-js/package.json b/packages/misskey-js/package.json
index b935f34d53..af719499c5 100644
--- a/packages/misskey-js/package.json
+++ b/packages/misskey-js/package.json
@@ -1,7 +1,7 @@
{
"type": "module",
"name": "misskey-js",
- "version": "2026.7.0-alpha.3",
+ "version": "2026.7.0-alpha.4",
"description": "Misskey SDK for JavaScript",
"license": "MIT",
"main": "./built/index.js",
From ecd85e56138c187237ce63fc7b9f2c76d4e334f6 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?=E3=81=8A=E3=81=95=E3=82=80=E3=81=AE=E3=81=B2=E3=81=A8?=
<46447427+samunohito@users.noreply.github.com>
Date: Tue, 14 Jul 2026 18:06:25 +0900
Subject: [PATCH 042/168] =?UTF-8?q?feat:=20OTel=E3=81=AEHTTP=E3=82=B5?=
=?UTF-8?q?=E3=83=BC=E3=83=90=E8=87=AA=E5=8B=95=E8=A8=88=E8=A3=85=E3=82=92?=
=?UTF-8?q?=E8=BF=BD=E5=8A=A0=20(#17711)?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
CHANGELOG.md | 1 +
packages/backend/package.json | 1 +
packages/backend/src/server/ServerService.ts | 2 +
.../backend/src/server/api/ApiCallService.ts | 29 ++++++----
.../src/server/http-server-instrumentation.ts | 35 ++++++++++++
.../server/http-server-instrumentation.ts | 53 +++++++++++++++++++
pnpm-lock.yaml | 41 ++++++++++++++
7 files changed, 151 insertions(+), 11 deletions(-)
create mode 100644 packages/backend/src/server/http-server-instrumentation.ts
create mode 100644 packages/backend/test/unit/server/http-server-instrumentation.ts
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 8492476c66..3b306d7156 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -37,6 +37,7 @@
- Fix: 非ログイン時トップページをスクロール操作できないことがある問題を修正
### Server
+- Enhance: OpenTelemetryで全受信HTTPリクエストを自動計装
- Enhance: センシティブメディアの判定を外部サービス ([sensitive-detector](https://github.com/misskey-dev/sensitive-detector)) に分離し、`nsfwjs` / `@tensorflow/tfjs(-node)` の同梱と NSFW 判定モデルを廃止 (#16804)
- Enhance: バックエンドの `otelForBackend` 設定で OpenTelemetry Traces を OTLP Collector に送信できるように
- Enhance: OpenTelemetry を単独で利用する際、外向き HTTP リクエストを自動計装するように
diff --git a/packages/backend/package.json b/packages/backend/package.json
index 22e7574331..3b3e9a0c22 100644
--- a/packages/backend/package.json
+++ b/packages/backend/package.json
@@ -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",
diff --git a/packages/backend/src/server/ServerService.ts b/packages/backend/src/server/ServerService.ts
index 1333c1a88b..45ce830b29 100644
--- a/packages/backend/src/server/ServerService.ts
+++ b/packages/backend/src/server/ServerService.ts
@@ -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)
diff --git a/packages/backend/src/server/api/ApiCallService.ts b/packages/backend/src/server/api/ApiCallService.ts
index dc3a88ca27..65b4abee05 100644
--- a/packages/backend/src/server/api/ApiCallService.ts
+++ b/packages/backend/src/server/api/ApiCallService.ts
@@ -151,7 +151,7 @@ export class ApiCallService implements OnApplicationShutdown {
endpoint: IEndpoint & { exec: any },
request: FastifyRequest<{ Body: Record | undefined, Querystring: Record }>,
reply: FastifyReply,
- ): void {
+ ): Promise {
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
diff --git a/packages/backend/src/server/http-server-instrumentation.ts b/packages/backend/src/server/http-server-instrumentation.ts
new file mode 100644
index 0000000000..c17f3c1bc0
--- /dev/null
+++ b/packages/backend/src/server/http-server-instrumentation.ts
@@ -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;
+
+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 {
+ 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());
+}
diff --git a/packages/backend/test/unit/server/http-server-instrumentation.ts b/packages/backend/test/unit/server/http-server-instrumentation.ts
new file mode 100644
index 0000000000..3e4baae14a
--- /dev/null
+++ b/packages/backend/test/unit/server/http-server-instrumentation.ts
@@ -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);
+ });
+});
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index 554448f89d..6293db07fd 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -89,6 +89,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
@@ -1855,6 +1858,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==}
@@ -2562,6 +2570,10 @@ 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@1.9.1':
resolution: {integrity: sha512-gLyJlPHPZYdAk1JENA9LeHejZe1Ti77/pTeFm/nMXmQH/HFZlcS/O2XJB+L8fkbrNSqhdtlvjBVjxwUYanNH5Q==}
engines: {node: '>=8.0.0'}
@@ -2602,6 +2614,12 @@ packages:
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/otlp-exporter-base@0.214.0':
resolution: {integrity: sha512-u1Gdv0/E9wP+apqWf7Wv2npXmgJtxsW2XL0TEv9FZloTZRuMBKmu8cYVXwS4Hm3q/f/3FuCnPTgiwYvIqRSpRg==}
engines: {node: ^18.19.0 || >=20.6.0}
@@ -9971,6 +9989,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
@@ -10580,6 +10608,10 @@ snapshots:
dependencies:
'@opentelemetry/api': 1.9.1
+ '@opentelemetry/api-logs@0.218.0':
+ dependencies:
+ '@opentelemetry/api': 1.9.1
+
'@opentelemetry/api@1.9.1': {}
'@opentelemetry/context-async-hooks@2.7.1(@opentelemetry/api@1.9.1)':
@@ -10619,6 +10651,15 @@ 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/otlp-exporter-base@0.214.0(@opentelemetry/api@1.9.1)':
dependencies:
'@opentelemetry/api': 1.9.1
From 4d7e62d677fb7ba36775769b0c59d72d984abdd7 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?=E3=81=8A=E3=81=95=E3=82=80=E3=81=AE=E3=81=B2=E3=81=A8?=
<46447427+samunohito@users.noreply.github.com>
Date: Tue, 14 Jul 2026 20:36:02 +0900
Subject: [PATCH 043/168] =?UTF-8?q?feat:=20Otel=20pg=E3=81=A8redis?=
=?UTF-8?q?=E8=87=AA=E5=8B=95=E8=A8=88=E8=A3=85=20(#17712)?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
.config/example.yml | 24 +++
CHANGELOG.md | 1 +
packages/backend/package.json | 1 +
packages/backend/src/config.ts | 6 +
.../adapters/OpenTelemetryAdapter.ts | 17 ++
.../telemetry/database-instrumentation.ts | 86 +++++++++
.../core/telemetry/redis-instrumentation.ts | 178 ++++++++++++++++++
.../e2e/telemetry-redis-instrumentation.ts | 88 +++++++++
.../telemetry-database-instrumentation.ts | 108 +++++++++++
.../unit/telemetry-redis-instrumentation.ts | 148 +++++++++++++++
pnpm-lock.yaml | 113 +++++++----
pnpm-workspace.yaml | 1 +
12 files changed, 731 insertions(+), 40 deletions(-)
create mode 100644 packages/backend/src/core/telemetry/database-instrumentation.ts
create mode 100644 packages/backend/src/core/telemetry/redis-instrumentation.ts
create mode 100644 packages/backend/test/e2e/telemetry-redis-instrumentation.ts
create mode 100644 packages/backend/test/unit/telemetry-database-instrumentation.ts
create mode 100644 packages/backend/test/unit/telemetry-redis-instrumentation.ts
diff --git a/.config/example.yml b/.config/example.yml
index 78f14854e5..14808e2795 100644
--- a/.config/example.yml
+++ b/.config/example.yml
@@ -354,6 +354,30 @@ id: 'aidx'
# #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
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 3b306d7156..3de1701664 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -38,6 +38,7 @@
### Server
- Enhance: OpenTelemetryで全受信HTTPリクエストを自動計装
+- Enhance: OpenTelemetry を単独で利用する際、PostgreSQL query と Redis command の計装を opt-in で利用できるように
- Enhance: センシティブメディアの判定を外部サービス ([sensitive-detector](https://github.com/misskey-dev/sensitive-detector)) に分離し、`nsfwjs` / `@tensorflow/tfjs(-node)` の同梱と NSFW 判定モデルを廃止 (#16804)
- Enhance: バックエンドの `otelForBackend` 設定で OpenTelemetry Traces を OTLP Collector に送信できるように
- Enhance: OpenTelemetry を単独で利用する際、外向き HTTP リクエストを自動計装するように
diff --git a/packages/backend/package.json b/packages/backend/package.json
index 3b3e9a0c22..19151ecd88 100644
--- a/packages/backend/package.json
+++ b/packages/backend/package.json
@@ -71,6 +71,7 @@
"@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",
diff --git a/packages/backend/src/config.ts b/packages/backend/src/config.ts
index ff0df65f93..de67daa31b 100644
--- a/packages/backend/src/config.ts
+++ b/packages/backend/src/config.ts
@@ -30,6 +30,12 @@ type OtelBackendConfig = {
endpoint?: string;
headers?: Record;
sampleRate?: number;
+ capturePgSpans?: boolean;
+ capturePgStatement?: boolean;
+ capturePgConnectionSpans?: boolean;
+ captureRedisCommandSpans?: boolean;
+ captureRedisConnectionSpans?: boolean;
+ captureRedisRootSpans?: boolean;
resourceAttributes?: Record;
propagateTraceToRemote?: boolean;
jobTraceContextMode?: 'link' | 'parent';
diff --git a/packages/backend/src/core/telemetry/adapters/OpenTelemetryAdapter.ts b/packages/backend/src/core/telemetry/adapters/OpenTelemetryAdapter.ts
index b8aa827588..ce98d8b0cf 100644
--- a/packages/backend/src/core/telemetry/adapters/OpenTelemetryAdapter.ts
+++ b/packages/backend/src/core/telemetry/adapters/OpenTelemetryAdapter.ts
@@ -8,6 +8,8 @@ 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';
@@ -26,6 +28,8 @@ type OpenTelemetryAdapterDeps = {
spanStatusCodeError: SpanStatusCode;
shutdownTimeout: number;
shutdownHttpClientInstrumentation?: () => void;
+ shutdownDatabaseInstrumentation?: () => void;
+ shutdownRedisInstrumentation?: () => void;
queueTraceContext?: QueueTraceContextDeps;
};
@@ -118,6 +122,17 @@ export class OpenTelemetryAdapter implements TelemetryAdapter {
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,
@@ -168,6 +183,8 @@ export class OpenTelemetryAdapter implements TelemetryAdapter {
public async shutdown(): Promise {
this.deps.shutdownHttpClientInstrumentation?.();
+ this.deps.shutdownDatabaseInstrumentation?.();
+ this.deps.shutdownRedisInstrumentation?.();
// BatchSpanProcessorのflushが詰まってもプロセス終了を妨げないよう、上限時間を設ける。
// タイムアウト側のtimerは、flushが先に終わった場合にイベントループを無駄に引き留めないようclearする。
let timer: NodeJS.Timeout | undefined;
diff --git a/packages/backend/src/core/telemetry/database-instrumentation.ts b/packages/backend/src/core/telemetry/database-instrumentation.ts
new file mode 100644
index 0000000000..206fa4c916
--- /dev/null
+++ b/packages/backend/src/core/telemetry/database-instrumentation.ts
@@ -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();
+ }
+ };
+}
diff --git a/packages/backend/src/core/telemetry/redis-instrumentation.ts b/packages/backend/src/core/telemetry/redis-instrumentation.ts
new file mode 100644
index 0000000000..b3e8d80b39
--- /dev/null
+++ b/packages/backend/src/core/telemetry/redis-instrumentation.ts
@@ -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 = {
+ 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 = {
+ subscribe(subscribers: TracingChannelSubscribers): void;
+ unsubscribe(subscribers: TracingChannelSubscribers): void;
+};
+
+type RedisInstrumentationDeps = {
+ tracingChannel(name: string): TracingChannel;
+ tracer: Pick;
+ 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,
+ 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('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('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(
+ channel: TracingChannel,
+ deps: RedisInstrumentationDeps,
+ requireParentSpan: boolean,
+ getSpanOptions: (message: T) => { name: string; attributes: Record },
+): TracingChannelSubscribers {
+ const spans = new WeakMap]
-