From eb2c7ff6c6b9c3298298cd6febefe33d78653c20 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: Sat, 4 Jul 2026 11:21:23 +0900 Subject: [PATCH 01/18] =?UTF-8?q?fix(backend/test):=20follow-up=20of=20#17?= =?UTF-8?q?654=20(waitFor=E3=82=92=E4=BB=96=E3=81=AEe2e=E3=83=86=E3=82=B9?= =?UTF-8?q?=E3=83=88=E3=81=AB=E3=82=82=E5=B1=95=E9=96=8B)=20(#17659)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit test(backend): block.ts等のfanout timeline反映待ちレースを修正する (tiramiss-community/endolphin#112) Co-authored-by: おさむのひと <46447427+samunohito@users.noreply.github.com> --- packages/backend/test/e2e/api-visibility.ts | 24 +++++---- packages/backend/test/e2e/block.ts | 20 +++++--- packages/backend/test/e2e/endpoints.ts | 16 +++--- packages/backend/test/e2e/mute.ts | 32 +++++++----- packages/backend/test/e2e/renote-mute.ts | 54 ++++++++++----------- packages/backend/test/e2e/user-notes.ts | 26 +++++----- 6 files changed, 97 insertions(+), 75 deletions(-) diff --git a/packages/backend/test/e2e/api-visibility.ts b/packages/backend/test/e2e/api-visibility.ts index 4f244c0cce..8bfca077ba 100644 --- a/packages/backend/test/e2e/api-visibility.ts +++ b/packages/backend/test/e2e/api-visibility.ts @@ -6,10 +6,12 @@ process.env.NODE_ENV = 'test'; import * as assert from 'assert'; -import { describe, beforeAll, beforeEach, test } from 'vitest'; +import { describe, beforeAll, beforeEach, test, vi } from 'vitest'; import { UserToken, api, post, signup } from '../utils.js'; import type * as misskey from 'misskey-js'; +const waitForPushToTlOptions = { timeout: 3000, interval: 25 }; + describe('API visibility', () => { describe('Note visibility', () => { //#region vars @@ -409,10 +411,12 @@ describe('API visibility', () => { //#region HTL test('[HTL] public-post が 自分が見れる', async () => { - const res = await api('notes/timeline', { limit: 100 }, alice); - assert.strictEqual(res.status, 200); - const notes = res.body.filter(n => n.id === pub.id); - assert.strictEqual(notes[0].text, 'x'); + await vi.waitFor(async () => { + const res = await api('notes/timeline', { limit: 100 }, alice); + assert.strictEqual(res.status, 200); + const notes = res.body.filter(n => n.id === pub.id); + assert.strictEqual(notes[0].text, 'x'); + }, waitForPushToTlOptions); }); test('[HTL] public-post が 非フォロワーから見れない', async () => { @@ -423,10 +427,12 @@ describe('API visibility', () => { }); test('[HTL] followers-post が フォロワーから見れる', async () => { - const res = await api('notes/timeline', { limit: 100 }, follower); - assert.strictEqual(res.status, 200); - const notes = res.body.filter(n => n.id === fol.id); - assert.strictEqual(notes[0].text, 'x'); + await vi.waitFor(async () => { + const res = await api('notes/timeline', { limit: 100 }, follower); + assert.strictEqual(res.status, 200); + const notes = res.body.filter(n => n.id === fol.id); + assert.strictEqual(notes[0].text, 'x'); + }, waitForPushToTlOptions); }); //#endregion diff --git a/packages/backend/test/e2e/block.ts b/packages/backend/test/e2e/block.ts index 9ef4dd8be9..86e27b461e 100644 --- a/packages/backend/test/e2e/block.ts +++ b/packages/backend/test/e2e/block.ts @@ -6,10 +6,12 @@ process.env.NODE_ENV = 'test'; import * as assert from 'assert'; -import { describe, beforeAll, test } from 'vitest'; +import { describe, beforeAll, test, vi } from 'vitest'; import { api, castAsError, post, signup } from '../utils.js'; import type * as misskey from 'misskey-js'; +const waitForPushToTlOptions = { timeout: 3000, interval: 25 }; + describe('Block', () => { // alice blocks bob let alice: misskey.entities.SignupResponse; @@ -75,13 +77,15 @@ describe('Block', () => { const bobNote = await post(bob, { text: 'hi' }); const carolNote = await post(carol, { text: 'hi' }); - const res = await api('notes/local-timeline', {}, bob); - const body = res.body as misskey.entities.Note[]; + await vi.waitFor(async () => { + const res = await api('notes/local-timeline', {}, bob); + const body = res.body as misskey.entities.Note[]; - assert.strictEqual(res.status, 200); - assert.strictEqual(Array.isArray(res.body), true); - assert.strictEqual(body.some(note => note.id === aliceNote.id), false); - assert.strictEqual(body.some(note => note.id === bobNote.id), true); - assert.strictEqual(body.some(note => note.id === carolNote.id), true); + assert.strictEqual(res.status, 200); + assert.strictEqual(Array.isArray(res.body), true); + assert.strictEqual(body.some(note => note.id === aliceNote.id), false); + assert.strictEqual(body.some(note => note.id === bobNote.id), true); + assert.strictEqual(body.some(note => note.id === carolNote.id), true); + }, waitForPushToTlOptions); }); }); diff --git a/packages/backend/test/e2e/endpoints.ts b/packages/backend/test/e2e/endpoints.ts index 09198384c4..402836ca47 100644 --- a/packages/backend/test/e2e/endpoints.ts +++ b/packages/backend/test/e2e/endpoints.ts @@ -6,7 +6,7 @@ process.env.NODE_ENV = 'test'; import * as assert from 'assert'; -import { describe, beforeAll, test, expect } from 'vitest'; +import { describe, beforeAll, test, expect, vi } from 'vitest'; // node-fetch only supports it's own Blob yet // https://github.com/node-fetch/node-fetch/pull/1664 import { Blob } from 'node-fetch'; @@ -14,6 +14,8 @@ import { api, castAsError, initTestDb, post, role, signup, simpleGet, uploadFile import type * as misskey from 'misskey-js'; import { MiUser } from '@/models/_.js'; +const waitForPushToTlOptions = { timeout: 3000, interval: 25 }; + describe('Endpoints', () => { let alice: misskey.entities.SignupResponse; let bob: misskey.entities.SignupResponse; @@ -1149,12 +1151,14 @@ describe('Endpoints', () => { visibility: 'followers', }); - const res = await api('notes/timeline', {}, dave); + await vi.waitFor(async () => { + const res = await api('notes/timeline', {}, dave); - assert.strictEqual(res.status, 200); - assert.strictEqual(Array.isArray(res.body), true); - assert.strictEqual(res.body.length, 1); - assert.strictEqual(res.body[0].id, carolPost.id); + assert.strictEqual(res.status, 200); + assert.strictEqual(Array.isArray(res.body), true); + assert.strictEqual(res.body.length, 1); + assert.strictEqual(res.body[0].id, carolPost.id); + }, waitForPushToTlOptions); }); }); diff --git a/packages/backend/test/e2e/mute.ts b/packages/backend/test/e2e/mute.ts index f5cc875e7c..c579c52a2b 100644 --- a/packages/backend/test/e2e/mute.ts +++ b/packages/backend/test/e2e/mute.ts @@ -6,10 +6,12 @@ process.env.NODE_ENV = 'test'; import * as assert from 'assert'; -import { beforeAll, describe, test } from 'vitest'; +import { beforeAll, describe, test, vi } from 'vitest'; import { api, post, react, signup, waitFire } from '../utils.js'; import type * as misskey from 'misskey-js'; +const waitForPushToTlOptions = { timeout: 3000, interval: 25 }; + describe('Mute', () => { // alice mutes carol let alice: misskey.entities.SignupResponse; @@ -67,13 +69,15 @@ describe('Mute', () => { const bobNote = await post(bob, { text: 'hi' }); const carolNote = await post(carol, { text: 'hi' }); - const res = await api('notes/local-timeline', {}, alice); + await vi.waitFor(async () => { + const res = await api('notes/local-timeline', {}, alice); - assert.strictEqual(res.status, 200); - assert.strictEqual(Array.isArray(res.body), true); - assert.strictEqual(res.body.some(note => note.id === aliceNote.id), true); - assert.strictEqual(res.body.some(note => note.id === bobNote.id), true); - assert.strictEqual(res.body.some(note => note.id === carolNote.id), false); + assert.strictEqual(res.status, 200); + assert.strictEqual(Array.isArray(res.body), true); + assert.strictEqual(res.body.some(note => note.id === aliceNote.id), true); + assert.strictEqual(res.body.some(note => note.id === bobNote.id), true); + assert.strictEqual(res.body.some(note => note.id === carolNote.id), false); + }, waitForPushToTlOptions); }); test('タイムラインにミュートしているユーザーの投稿のRenoteが含まれない', async () => { @@ -83,13 +87,15 @@ describe('Mute', () => { renoteId: carolNote.id, }); - const res = await api('notes/local-timeline', {}, alice); + await vi.waitFor(async () => { + const res = await api('notes/local-timeline', {}, alice); - assert.strictEqual(res.status, 200); - assert.strictEqual(Array.isArray(res.body), true); - assert.strictEqual(res.body.some(note => note.id === aliceNote.id), true); - assert.strictEqual(res.body.some(note => note.id === bobNote.id), false); - assert.strictEqual(res.body.some(note => note.id === carolNote.id), false); + assert.strictEqual(res.status, 200); + assert.strictEqual(Array.isArray(res.body), true); + assert.strictEqual(res.body.some(note => note.id === aliceNote.id), true); + assert.strictEqual(res.body.some(note => note.id === bobNote.id), false); + assert.strictEqual(res.body.some(note => note.id === carolNote.id), false); + }, waitForPushToTlOptions); }); }); diff --git a/packages/backend/test/e2e/renote-mute.ts b/packages/backend/test/e2e/renote-mute.ts index 785c9dff8b..555d7c2a20 100644 --- a/packages/backend/test/e2e/renote-mute.ts +++ b/packages/backend/test/e2e/renote-mute.ts @@ -6,11 +6,12 @@ process.env.NODE_ENV = 'test'; import * as assert from 'assert'; -import { beforeAll, describe, test } from 'vitest'; -import { setTimeout } from 'node:timers/promises'; +import { beforeAll, describe, test, vi } from 'vitest'; import { api, post, signup, waitFire } from '../utils.js'; import type * as misskey from 'misskey-js'; +const waitForPushToTlOptions = { timeout: 3000, interval: 25 }; + describe('Renote Mute', () => { // alice mutes carol let alice: misskey.entities.SignupResponse; @@ -36,16 +37,15 @@ describe('Renote Mute', () => { const carolRenote = await post(carol, { renoteId: bobNote.id }); const carolNote = await post(carol, { text: 'hi' }); - // redisに追加されるのを待つ - await setTimeout(100); + await vi.waitFor(async () => { + const res = await api('notes/local-timeline', {}, alice); - const res = await api('notes/local-timeline', {}, alice); - - assert.strictEqual(res.status, 200); - assert.strictEqual(Array.isArray(res.body), true); - assert.strictEqual(res.body.some(note => note.id === bobNote.id), true); - assert.strictEqual(res.body.some(note => note.id === carolRenote.id), false); - assert.strictEqual(res.body.some(note => note.id === carolNote.id), true); + assert.strictEqual(res.status, 200); + assert.strictEqual(Array.isArray(res.body), true); + assert.strictEqual(res.body.some(note => note.id === bobNote.id), true); + assert.strictEqual(res.body.some(note => note.id === carolRenote.id), false); + assert.strictEqual(res.body.some(note => note.id === carolNote.id), true); + }, waitForPushToTlOptions); }); test('タイムラインにリノートミュートしているユーザーの引用が含まれる', async () => { @@ -53,16 +53,15 @@ describe('Renote Mute', () => { const carolRenote = await post(carol, { renoteId: bobNote.id, text: 'kore' }); const carolNote = await post(carol, { text: 'hi' }); - // redisに追加されるのを待つ - await setTimeout(100); + await vi.waitFor(async () => { + const res = await api('notes/local-timeline', {}, alice); - const res = await api('notes/local-timeline', {}, alice); - - assert.strictEqual(res.status, 200); - assert.strictEqual(Array.isArray(res.body), true); - assert.strictEqual(res.body.some(note => note.id === bobNote.id), true); - assert.strictEqual(res.body.some(note => note.id === carolRenote.id), true); - assert.strictEqual(res.body.some(note => note.id === carolNote.id), true); + assert.strictEqual(res.status, 200); + assert.strictEqual(Array.isArray(res.body), true); + assert.strictEqual(res.body.some(note => note.id === bobNote.id), true); + assert.strictEqual(res.body.some(note => note.id === carolRenote.id), true); + assert.strictEqual(res.body.some(note => note.id === carolNote.id), true); + }, waitForPushToTlOptions); }); // #12956 @@ -70,15 +69,14 @@ describe('Renote Mute', () => { const carolNote = await post(carol, { text: 'hi' }); const bobRenote = await post(bob, { renoteId: carolNote.id }); - // redisに追加されるのを待つ - await setTimeout(100); + await vi.waitFor(async () => { + const res = await api('notes/local-timeline', {}, alice); - const res = await api('notes/local-timeline', {}, alice); - - assert.strictEqual(res.status, 200); - assert.strictEqual(Array.isArray(res.body), true); - assert.strictEqual(res.body.some(note => note.id === carolNote.id), true); - assert.strictEqual(res.body.some(note => note.id === bobRenote.id), true); + assert.strictEqual(res.status, 200); + assert.strictEqual(Array.isArray(res.body), true); + assert.strictEqual(res.body.some(note => note.id === carolNote.id), true); + assert.strictEqual(res.body.some(note => note.id === bobRenote.id), true); + }, waitForPushToTlOptions); }); test('ストリームにリノートミュートしているユーザーのリノートが流れない', async () => { diff --git a/packages/backend/test/e2e/user-notes.ts b/packages/backend/test/e2e/user-notes.ts index 2f89ac54ce..c6fabca05f 100644 --- a/packages/backend/test/e2e/user-notes.ts +++ b/packages/backend/test/e2e/user-notes.ts @@ -6,10 +6,12 @@ process.env.NODE_ENV = 'test'; import * as assert from 'assert'; -import { beforeAll, describe, test } from 'vitest'; +import { beforeAll, describe, test, vi } from 'vitest'; import { api, post, signup, uploadUrl } from '../utils.js'; import type * as misskey from 'misskey-js'; +const waitForPushToTlOptions = { timeout: 3000, interval: 25 }; + describe('users/notes', () => { let alice: misskey.entities.SignupResponse; let jpgNote: misskey.entities.Note; @@ -32,16 +34,18 @@ describe('users/notes', () => { }, 1000 * 60 * 2); test('withFiles', async () => { - const res = await api('users/notes', { - userId: alice.id, - withFiles: true, - }, alice); + await vi.waitFor(async () => { + const res = await api('users/notes', { + userId: alice.id, + withFiles: true, + }, alice); - assert.strictEqual(res.status, 200); - assert.strictEqual(Array.isArray(res.body), true); - assert.strictEqual(res.body.length, 3); - assert.strictEqual(res.body.some((note: any) => note.id === jpgNote.id), true); - assert.strictEqual(res.body.some((note: any) => note.id === pngNote.id), true); - assert.strictEqual(res.body.some((note: any) => note.id === jpgPngNote.id), true); + assert.strictEqual(res.status, 200); + assert.strictEqual(Array.isArray(res.body), true); + assert.strictEqual(res.body.length, 3); + assert.strictEqual(res.body.some((note: any) => note.id === jpgNote.id), true); + assert.strictEqual(res.body.some((note: any) => note.id === pngNote.id), true); + assert.strictEqual(res.body.some((note: any) => note.id === jpgPngNote.id), true); + }, waitForPushToTlOptions); }); }); From 0f056c4955ac9f6c2b46143662a658c5b02aa4ba 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: Sat, 4 Jul 2026 16:06:24 +0900 Subject: [PATCH 02/18] =?UTF-8?q?refactor:=20Sentry=E5=91=BC=E3=81=B3?= =?UTF-8?q?=E5=87=BA=E3=81=97=E5=80=8B=E6=89=80=E3=81=AE=E6=8A=BD=E8=B1=A1?= =?UTF-8?q?=E5=8C=96=20(#17661)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- packages/backend/src/boot/master.ts | 22 +-- packages/backend/src/boot/worker.ts | 22 +-- packages/backend/src/core/CoreModule.ts | 6 + .../src/core/telemetry/TelemetryService.ts | 28 ++++ .../adapters/SentryTelemetryAdapter.ts | 53 +++++++ .../telemetry/adapters/TelemetryAdapter.ts | 21 +++ .../src/core/telemetry/telemetry-registry.ts | 39 +++++ .../src/queue/QueueProcessorService.ts | 149 ++++++------------ .../backend/src/server/api/ApiCallService.ts | 50 ++---- packages/frontend/src/boot/common.ts | 38 +---- packages/frontend/src/telemetry.ts | 45 ++++++ 11 files changed, 260 insertions(+), 213 deletions(-) create mode 100644 packages/backend/src/core/telemetry/TelemetryService.ts create mode 100644 packages/backend/src/core/telemetry/adapters/SentryTelemetryAdapter.ts create mode 100644 packages/backend/src/core/telemetry/adapters/TelemetryAdapter.ts create mode 100644 packages/backend/src/core/telemetry/telemetry-registry.ts create mode 100644 packages/frontend/src/telemetry.ts diff --git a/packages/backend/src/boot/master.ts b/packages/backend/src/boot/master.ts index 533ebe5bbb..aac68cb02f 100644 --- a/packages/backend/src/boot/master.ts +++ b/packages/backend/src/boot/master.ts @@ -13,6 +13,7 @@ import { loadConfig } from '@/config.js'; 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 { initExtraThreadPool, jobQueue, server } from './common.js'; const logger = new Logger('core', 'cyan'); @@ -66,26 +67,7 @@ export async function masterMain() { initExtraThreadPool(config); - if (config.sentryForBackend) { - const Sentry = await import('@sentry/node'); - const { nodeProfilingIntegration } = await import('@sentry/profiling-node'); - - Sentry.init({ - integrations: [ - ...(config.sentryForBackend.enableNodeProfiling ? [nodeProfilingIntegration()] : []), - ], - - // Performance Monitoring - tracesSampleRate: 1.0, // Capture 100% of the transactions - - // Set sampling rate for profiling - this is relative to tracesSampleRate - profilesSampleRate: 1.0, - - maxBreadcrumbs: 0, - - ...config.sentryForBackend.options, - }); - } + await initTelemetry(config); 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 d8fb1eeaac..00d5dd9063 100644 --- a/packages/backend/src/boot/worker.ts +++ b/packages/backend/src/boot/worker.ts @@ -6,6 +6,7 @@ import cluster from 'node:cluster'; import { envOption } from '@/env.js'; import { loadConfig } from '@/config.js'; +import { initTelemetry } from '@/core/telemetry/telemetry-registry.js'; import { initExtraThreadPool, jobQueue, server } from './common.js'; /** @@ -16,26 +17,7 @@ export async function workerMain() { initExtraThreadPool(config); - if (config.sentryForBackend) { - const Sentry = await import('@sentry/node'); - const { nodeProfilingIntegration } = await import('@sentry/profiling-node'); - - Sentry.init({ - integrations: [ - ...(config.sentryForBackend.enableNodeProfiling ? [nodeProfilingIntegration()] : []), - ], - - // Performance Monitoring - tracesSampleRate: 1.0, // Capture 100% of the transactions - - // Set sampling rate for profiling - this is relative to tracesSampleRate - profilesSampleRate: 1.0, - - maxBreadcrumbs: 0, - - ...config.sentryForBackend.options, - }); - } + await initTelemetry(config); if (envOption.onlyServer) { await server(); diff --git a/packages/backend/src/core/CoreModule.ts b/packages/backend/src/core/CoreModule.ts index f075671d93..6ac4eb9079 100644 --- a/packages/backend/src/core/CoreModule.ts +++ b/packages/backend/src/core/CoreModule.ts @@ -154,10 +154,12 @@ import { ApQuestionService } from './activitypub/models/ApQuestionService.js'; import { QueueModule } from './QueueModule.js'; import { QueueService } from './QueueService.js'; import { LoggerService } from './LoggerService.js'; +import { TelemetryService } from './telemetry/TelemetryService.js'; import type { Provider } from '@nestjs/common'; //#region 文字列ベースでのinjection用(循環参照対応のため) const $LoggerService: Provider = { provide: 'LoggerService', useExisting: LoggerService }; +const $TelemetryService: Provider = { provide: 'TelemetryService', useExisting: TelemetryService }; const $AbuseReportService: Provider = { provide: 'AbuseReportService', useExisting: AbuseReportService }; const $AbuseReportNotificationService: Provider = { provide: 'AbuseReportNotificationService', useExisting: AbuseReportNotificationService }; const $AccountMoveService: Provider = { provide: 'AccountMoveService', useExisting: AccountMoveService }; @@ -458,6 +460,7 @@ const $ApQuestionService: Provider = { provide: 'ApQuestionService', useExisting ApPersonService, ApQuestionService, QueueService, + TelemetryService, //#region 文字列ベースでのinjection用(循環参照対応のため) $LoggerService, @@ -606,6 +609,7 @@ const $ApQuestionService: Provider = { provide: 'ApQuestionService', useExisting $ApNoteService, $ApPersonService, $ApQuestionService, + $TelemetryService, //#endregion ], exports: [ @@ -757,6 +761,7 @@ const $ApQuestionService: Provider = { provide: 'ApQuestionService', useExisting ApPersonService, ApQuestionService, QueueService, + TelemetryService, //#region 文字列ベースでのinjection用(循環参照対応のため) $LoggerService, @@ -903,6 +908,7 @@ const $ApQuestionService: Provider = { provide: 'ApQuestionService', useExisting $ApNoteService, $ApPersonService, $ApQuestionService, + $TelemetryService, //#endregion ], }) diff --git a/packages/backend/src/core/telemetry/TelemetryService.ts b/packages/backend/src/core/telemetry/TelemetryService.ts new file mode 100644 index 0000000000..e73beb4f8a --- /dev/null +++ b/packages/backend/src/core/telemetry/TelemetryService.ts @@ -0,0 +1,28 @@ +/* + * SPDX-FileCopyrightText: syuilo and misskey-project + * SPDX-License-Identifier: AGPL-3.0-only + */ + +import { Injectable } from '@nestjs/common'; +import { bindThis } from '@/decorators.js'; +import { captureMessage, shutdownTelemetry, startSpan } from './telemetry-registry.js'; +import type { OnApplicationShutdown } from '@nestjs/common'; +import type { TelemetryCaptureMessageOptions } from './adapters/TelemetryAdapter.js'; + +@Injectable() +export class TelemetryService implements OnApplicationShutdown { + @bindThis + public captureMessage(message: string, opts: TelemetryCaptureMessageOptions): void { + captureMessage(message, opts); + } + + @bindThis + public startSpan(name: string, fn: () => T): T { + return startSpan(name, fn); + } + + @bindThis + public async onApplicationShutdown(_signal?: string): Promise { + await shutdownTelemetry(); + } +} diff --git a/packages/backend/src/core/telemetry/adapters/SentryTelemetryAdapter.ts b/packages/backend/src/core/telemetry/adapters/SentryTelemetryAdapter.ts new file mode 100644 index 0000000000..c155c9d74c --- /dev/null +++ b/packages/backend/src/core/telemetry/adapters/SentryTelemetryAdapter.ts @@ -0,0 +1,53 @@ +/* + * SPDX-FileCopyrightText: syuilo and misskey-project + * SPDX-License-Identifier: AGPL-3.0-only + */ + +import type { Config } from '@/config.js'; +import type { TelemetryAdapter, TelemetryCaptureMessageOptions } from './TelemetryAdapter.js'; + +export class SentryTelemetryAdapter implements TelemetryAdapter { + private constructor( + private readonly Sentry: typeof import('@sentry/node'), + ) { + } + + public static async create(config: NonNullable): Promise { + const Sentry = await import('@sentry/node'); + const { nodeProfilingIntegration } = await import('@sentry/profiling-node'); + + Sentry.init({ + integrations: [ + ...(config.enableNodeProfiling ? [nodeProfilingIntegration()] : []), + ], + + // Performance Monitoring + tracesSampleRate: 1.0, // Capture 100% of the transactions + + // Set sampling rate for profiling - this is relative to tracesSampleRate + profilesSampleRate: 1.0, + + maxBreadcrumbs: 0, + + ...config.options, + }); + + return new SentryTelemetryAdapter(Sentry); + } + + public captureMessage(message: string, opts: TelemetryCaptureMessageOptions): void { + this.Sentry.captureMessage(message, { + level: opts.level, + ...(opts.userId != null ? { user: { id: opts.userId } } : {}), + extra: opts.extra, + }); + } + + public startSpan(name: string, fn: () => T): T { + return this.Sentry.startSpan({ name }, fn); + } + + public async shutdown(): Promise { + await this.Sentry.close(); + } +} diff --git a/packages/backend/src/core/telemetry/adapters/TelemetryAdapter.ts b/packages/backend/src/core/telemetry/adapters/TelemetryAdapter.ts new file mode 100644 index 0000000000..d074de056d --- /dev/null +++ b/packages/backend/src/core/telemetry/adapters/TelemetryAdapter.ts @@ -0,0 +1,21 @@ +/* + * SPDX-FileCopyrightText: syuilo and misskey-project + * SPDX-License-Identifier: AGPL-3.0-only + */ + +export interface TelemetryCaptureMessageOptions { + level: 'error'; + userId?: string; + extra?: Record; +} + +/** + * Sentry・OpenTelemetryなど、エラートラッキング/APMサービスごとの実装差異を隠蔽するための抽象。 + * 新しいサービスを追加する場合はこのインターフェースを実装するアダプタをこのディレクトリに追加し、 + * telemetry-registry.tsのinitTelemetry内で登録する。 + */ +export interface TelemetryAdapter { + captureMessage(message: string, opts: TelemetryCaptureMessageOptions): void; + startSpan(name: string, fn: () => T): T; + shutdown(): Promise; +} diff --git a/packages/backend/src/core/telemetry/telemetry-registry.ts b/packages/backend/src/core/telemetry/telemetry-registry.ts new file mode 100644 index 0000000000..90dc267837 --- /dev/null +++ b/packages/backend/src/core/telemetry/telemetry-registry.ts @@ -0,0 +1,39 @@ +/* + * SPDX-FileCopyrightText: syuilo and misskey-project + * SPDX-License-Identifier: AGPL-3.0-only + */ + +import type { Config } from '@/config.js'; +import { SentryTelemetryAdapter } from './adapters/SentryTelemetryAdapter.js'; +import type { TelemetryAdapter, TelemetryCaptureMessageOptions } from './adapters/TelemetryAdapter.js'; + +/** + * NestのDIコンテナが構築される前(boot処理内)で初期化する必要があるため、 + * DIを介さないモジュールレベルの状態として有効なアダプタを保持する。 + * TelemetryServiceはこの状態への薄いラッパーとして振る舞う。 + */ +const adapters: TelemetryAdapter[] = []; + +export async function initTelemetry(config: Config): Promise { + if (config.sentryForBackend) { + adapters.push(await SentryTelemetryAdapter.create(config.sentryForBackend)); + } +} + +export function captureMessage(message: string, opts: TelemetryCaptureMessageOptions): void { + for (const adapter of adapters) { + adapter.captureMessage(message, opts); + } +} + +export function startSpan(name: string, fn: () => T): T { + const wrapped = adapters.reduceRight<() => T>( + (inner, adapter) => () => adapter.startSpan(name, inner), + fn, + ); + return wrapped(); +} + +export async function shutdownTelemetry(): Promise { + await Promise.all(adapters.map(adapter => adapter.shutdown())); +} diff --git a/packages/backend/src/queue/QueueProcessorService.ts b/packages/backend/src/queue/QueueProcessorService.ts index 2b3b3fc0ad..cd1e752fd5 100644 --- a/packages/backend/src/queue/QueueProcessorService.ts +++ b/packages/backend/src/queue/QueueProcessorService.ts @@ -9,6 +9,7 @@ import type { Config } from '@/config.js'; import { DI } from '@/di-symbols.js'; import type Logger from '@/logger.js'; import { bindThis } from '@/decorators.js'; +import { TelemetryService } from '@/core/telemetry/TelemetryService.js'; import { CheckModeratorsActivityProcessorService } from '@/queue/processors/CheckModeratorsActivityProcessorService.js'; import { UserWebhookDeliverProcessorService } from './processors/UserWebhookDeliverProcessorService.js'; import { SystemWebhookDeliverProcessorService } from './processors/SystemWebhookDeliverProcessorService.js'; @@ -92,6 +93,7 @@ export class QueueProcessorService implements OnApplicationShutdown { private config: Config, private queueLoggerService: QueueLoggerService, + private telemetryService: TelemetryService, private userWebhookDeliverProcessorService: UserWebhookDeliverProcessorService, private systemWebhookDeliverProcessorService: SystemWebhookDeliverProcessorService, private endedPollNotificationProcessorService: EndedPollNotificationProcessorService, @@ -156,13 +158,6 @@ export class QueueProcessorService implements OnApplicationShutdown { }; } - let Sentry: typeof import('@sentry/node') | undefined; - if (this.config.sentryForBackend) { - import('@sentry/node').then((mod) => { - Sentry = mod; - }); - } - //#region system { const processer = (job: Bull.Job) => { @@ -181,11 +176,7 @@ export class QueueProcessorService implements OnApplicationShutdown { }; this.systemQueueWorker = new Bull.Worker(QUEUE.SYSTEM, (job) => { - if (Sentry != null) { - return Sentry.startSpan({ name: 'Queue: System: ' + job.name }, () => processer(job)); - } else { - return processer(job); - } + return this.telemetryService.startSpan('Queue: System: ' + job.name, () => processer(job)); }, { ...baseWorkerOptions(this.config, QUEUE.SYSTEM), autorun: false, @@ -198,12 +189,10 @@ export class QueueProcessorService implements OnApplicationShutdown { .on('completed', (job, result) => logger.debug(`completed(${result}) id=${job.id}`)) .on('failed', (job, err: Error) => { logger.error(`failed(${err.name}: ${err.message}) id=${job?.id ?? '?'}`, { job: renderJob(job), e: renderError(err) }); - if (Sentry != null) { - Sentry.captureMessage(`Queue: System: ${job?.name ?? '?'}: ${err.name}: ${err.message}`, { - level: 'error', - extra: { job, err }, - }); - } + this.telemetryService.captureMessage(`Queue: System: ${job?.name ?? '?'}: ${err.name}: ${err.message}`, { + level: 'error', + extra: { job, err }, + }); }) .on('error', (err: Error) => logger.error(`error ${err.name}: ${err.message}`, { e: renderError(err) })) .on('stalled', (jobId) => logger.warn(`stalled id=${jobId}`)); @@ -238,11 +227,7 @@ export class QueueProcessorService implements OnApplicationShutdown { }; this.dbQueueWorker = new Bull.Worker(QUEUE.DB, (job) => { - if (Sentry != null) { - return Sentry.startSpan({ name: 'Queue: DB: ' + job.name }, () => processer(job)); - } else { - return processer(job); - } + return this.telemetryService.startSpan('Queue: DB: ' + job.name, () => processer(job)); }, { ...baseWorkerOptions(this.config, QUEUE.DB), autorun: false, @@ -255,12 +240,10 @@ export class QueueProcessorService implements OnApplicationShutdown { .on('completed', (job, result) => logger.debug(`completed(${result}) id=${job.id}`)) .on('failed', (job, err) => { logger.error(`failed(${err.name}: ${err.message}) id=${job?.id ?? '?'}`, { job: renderJob(job), e: renderError(err) }); - if (Sentry != null) { - Sentry.captureMessage(`Queue: DB: ${job?.name ?? '?'}: ${err.name}: ${err.message}`, { - level: 'error', - extra: { job, err }, - }); - } + this.telemetryService.captureMessage(`Queue: DB: ${job?.name ?? '?'}: ${err.name}: ${err.message}`, { + level: 'error', + extra: { job, err }, + }); }) .on('error', (err: Error) => logger.error(`error ${err.name}: ${err.message}`, { e: renderError(err) })) .on('stalled', (jobId) => logger.warn(`stalled id=${jobId}`)); @@ -270,11 +253,7 @@ export class QueueProcessorService implements OnApplicationShutdown { //#region deliver { this.deliverQueueWorker = new Bull.Worker(QUEUE.DELIVER, (job) => { - if (Sentry != null) { - return Sentry.startSpan({ name: 'Queue: Deliver' }, () => this.deliverProcessorService.process(job)); - } else { - return this.deliverProcessorService.process(job); - } + return this.telemetryService.startSpan('Queue: Deliver', () => this.deliverProcessorService.process(job)); }, { ...baseWorkerOptions(this.config, QUEUE.DELIVER), autorun: false, @@ -295,12 +274,10 @@ export class QueueProcessorService implements OnApplicationShutdown { .on('completed', (job, result) => logger.debug(`completed(${result}) ${getJobInfo(job, true)} to=${job.data.to}`)) .on('failed', (job, err) => { logger.error(`failed(${err.name}: ${err.message}) ${getJobInfo(job)} to=${job ? job.data.to : '-'}`); - if (Sentry != null) { - Sentry.captureMessage(`Queue: Deliver: ${err.name}: ${err.message}`, { - level: 'error', - extra: { job, err }, - }); - } + this.telemetryService.captureMessage(`Queue: Deliver: ${err.name}: ${err.message}`, { + level: 'error', + extra: { job, err }, + }); }) .on('error', (err: Error) => logger.error(`error ${err.name}: ${err.message}`, { e: renderError(err) })) .on('stalled', (jobId) => logger.warn(`stalled id=${jobId}`)); @@ -310,11 +287,7 @@ export class QueueProcessorService implements OnApplicationShutdown { //#region inbox { this.inboxQueueWorker = new Bull.Worker(QUEUE.INBOX, (job) => { - if (Sentry != null) { - return Sentry.startSpan({ name: 'Queue: Inbox' }, () => this.inboxProcessorService.process(job)); - } else { - return this.inboxProcessorService.process(job); - } + return this.telemetryService.startSpan('Queue: Inbox', () => this.inboxProcessorService.process(job)); }, { ...baseWorkerOptions(this.config, QUEUE.INBOX), autorun: false, @@ -335,12 +308,10 @@ export class QueueProcessorService implements OnApplicationShutdown { .on('completed', (job, result) => logger.debug(`completed(${result}) ${getJobInfo(job, true)}`)) .on('failed', (job, err) => { logger.error(`failed(${err.name}: ${err.message}) ${getJobInfo(job)} activity=${job ? (job.data.activity ? job.data.activity.id : 'none') : '-'}`, { job: renderJob(job), e: renderError(err) }); - if (Sentry != null) { - Sentry.captureMessage(`Queue: Inbox: ${err.name}: ${err.message}`, { - level: 'error', - extra: { job, err }, - }); - } + this.telemetryService.captureMessage(`Queue: Inbox: ${err.name}: ${err.message}`, { + level: 'error', + extra: { job, err }, + }); }) .on('error', (err: Error) => logger.error(`error ${err.name}: ${err.message}`, { e: renderError(err) })) .on('stalled', (jobId) => logger.warn(`stalled id=${jobId}`)); @@ -350,11 +321,7 @@ export class QueueProcessorService implements OnApplicationShutdown { //#region user-webhook deliver { this.userWebhookDeliverQueueWorker = new Bull.Worker(QUEUE.USER_WEBHOOK_DELIVER, (job) => { - if (Sentry != null) { - return Sentry.startSpan({ name: 'Queue: UserWebhookDeliver' }, () => this.userWebhookDeliverProcessorService.process(job)); - } else { - return this.userWebhookDeliverProcessorService.process(job); - } + return this.telemetryService.startSpan('Queue: UserWebhookDeliver', () => this.userWebhookDeliverProcessorService.process(job)); }, { ...baseWorkerOptions(this.config, QUEUE.USER_WEBHOOK_DELIVER), autorun: false, @@ -375,12 +342,10 @@ export class QueueProcessorService implements OnApplicationShutdown { .on('completed', (job, result) => logger.debug(`completed(${result}) ${getJobInfo(job, true)} to=${job.data.to}`)) .on('failed', (job, err) => { logger.error(`failed(${err.name}: ${err.message}) ${getJobInfo(job)} to=${job ? job.data.to : '-'}`); - if (Sentry != null) { - Sentry.captureMessage(`Queue: UserWebhookDeliver: ${err.name}: ${err.message}`, { - level: 'error', - extra: { job, err }, - }); - } + this.telemetryService.captureMessage(`Queue: UserWebhookDeliver: ${err.name}: ${err.message}`, { + level: 'error', + extra: { job, err }, + }); }) .on('error', (err: Error) => logger.error(`error ${err.name}: ${err.message}`, { e: renderError(err) })) .on('stalled', (jobId) => logger.warn(`stalled id=${jobId}`)); @@ -390,11 +355,7 @@ export class QueueProcessorService implements OnApplicationShutdown { //#region system-webhook deliver { this.systemWebhookDeliverQueueWorker = new Bull.Worker(QUEUE.SYSTEM_WEBHOOK_DELIVER, (job) => { - if (Sentry != null) { - return Sentry.startSpan({ name: 'Queue: SystemWebhookDeliver' }, () => this.systemWebhookDeliverProcessorService.process(job)); - } else { - return this.systemWebhookDeliverProcessorService.process(job); - } + return this.telemetryService.startSpan('Queue: SystemWebhookDeliver', () => this.systemWebhookDeliverProcessorService.process(job)); }, { ...baseWorkerOptions(this.config, QUEUE.SYSTEM_WEBHOOK_DELIVER), autorun: false, @@ -415,12 +376,10 @@ export class QueueProcessorService implements OnApplicationShutdown { .on('completed', (job, result) => logger.debug(`completed(${result}) ${getJobInfo(job, true)} to=${job.data.to}`)) .on('failed', (job, err) => { logger.error(`failed(${err.name}: ${err.message}) ${getJobInfo(job)} to=${job ? job.data.to : '-'}`); - if (Sentry != null) { - Sentry.captureMessage(`Queue: SystemWebhookDeliver: ${err.name}: ${err.message}`, { - level: 'error', - extra: { job, err }, - }); - } + this.telemetryService.captureMessage(`Queue: SystemWebhookDeliver: ${err.name}: ${err.message}`, { + level: 'error', + extra: { job, err }, + }); }) .on('error', (err: Error) => logger.error(`error ${err.name}: ${err.message}`, { e: renderError(err) })) .on('stalled', (jobId) => logger.warn(`stalled id=${jobId}`)); @@ -440,11 +399,7 @@ export class QueueProcessorService implements OnApplicationShutdown { }; this.relationshipQueueWorker = new Bull.Worker(QUEUE.RELATIONSHIP, (job) => { - if (Sentry != null) { - return Sentry.startSpan({ name: 'Queue: Relationship: ' + job.name }, () => processer(job)); - } else { - return processer(job); - } + return this.telemetryService.startSpan('Queue: Relationship: ' + job.name, () => processer(job)); }, { ...baseWorkerOptions(this.config, QUEUE.RELATIONSHIP), autorun: false, @@ -462,12 +417,10 @@ export class QueueProcessorService implements OnApplicationShutdown { .on('completed', (job, result) => logger.debug(`completed(${result}) id=${job.id}`)) .on('failed', (job, err) => { logger.error(`failed(${err.name}: ${err.message}) id=${job?.id ?? '?'}`, { job: renderJob(job), e: renderError(err) }); - if (Sentry != null) { - Sentry.captureMessage(`Queue: Relationship: ${job?.name ?? '?'}: ${err.name}: ${err.message}`, { - level: 'error', - extra: { job, err }, - }); - } + this.telemetryService.captureMessage(`Queue: Relationship: ${job?.name ?? '?'}: ${err.name}: ${err.message}`, { + level: 'error', + extra: { job, err }, + }); }) .on('error', (err: Error) => logger.error(`error ${err.name}: ${err.message}`, { e: renderError(err) })) .on('stalled', (jobId) => logger.warn(`stalled id=${jobId}`)); @@ -485,11 +438,7 @@ export class QueueProcessorService implements OnApplicationShutdown { }; this.objectStorageQueueWorker = new Bull.Worker(QUEUE.OBJECT_STORAGE, (job) => { - if (Sentry != null) { - return Sentry.startSpan({ name: 'Queue: ObjectStorage: ' + job.name }, () => processer(job)); - } else { - return processer(job); - } + return this.telemetryService.startSpan('Queue: ObjectStorage: ' + job.name, () => processer(job)); }, { ...baseWorkerOptions(this.config, QUEUE.OBJECT_STORAGE), autorun: false, @@ -503,12 +452,10 @@ export class QueueProcessorService implements OnApplicationShutdown { .on('completed', (job, result) => logger.debug(`completed(${result}) id=${job.id}`)) .on('failed', (job, err) => { logger.error(`failed(${err.name}: ${err.message}) id=${job?.id ?? '?'}`, { job: renderJob(job), e: renderError(err) }); - if (Sentry != null) { - Sentry.captureMessage(`Queue: ObjectStorage: ${job?.name ?? '?'}: ${err.name}: ${err.message}`, { - level: 'error', - extra: { job, err }, - }); - } + this.telemetryService.captureMessage(`Queue: ObjectStorage: ${job?.name ?? '?'}: ${err.name}: ${err.message}`, { + level: 'error', + extra: { job, err }, + }); }) .on('error', (err: Error) => logger.error(`error ${err.name}: ${err.message}`, { e: renderError(err) })) .on('stalled', (jobId) => logger.warn(`stalled id=${jobId}`)); @@ -518,11 +465,7 @@ export class QueueProcessorService implements OnApplicationShutdown { //#region ended poll notification { this.endedPollNotificationQueueWorker = new Bull.Worker(QUEUE.ENDED_POLL_NOTIFICATION, (job) => { - if (Sentry != null) { - return Sentry.startSpan({ name: 'Queue: EndedPollNotification' }, () => this.endedPollNotificationProcessorService.process(job)); - } else { - return this.endedPollNotificationProcessorService.process(job); - } + return this.telemetryService.startSpan('Queue: EndedPollNotification', () => this.endedPollNotificationProcessorService.process(job)); }, { ...baseWorkerOptions(this.config, QUEUE.ENDED_POLL_NOTIFICATION), autorun: false, @@ -533,11 +476,7 @@ export class QueueProcessorService implements OnApplicationShutdown { //#region post scheduled note { this.postScheduledNoteQueueWorker = new Bull.Worker(QUEUE.POST_SCHEDULED_NOTE, async (job) => { - if (Sentry != null) { - return Sentry.startSpan({ name: 'Queue: PostScheduledNote' }, () => this.postScheduledNoteProcessorService.process(job)); - } else { - return this.postScheduledNoteProcessorService.process(job); - } + return this.telemetryService.startSpan('Queue: PostScheduledNote', () => this.postScheduledNoteProcessorService.process(job)); }, { ...baseWorkerOptions(this.config, QUEUE.POST_SCHEDULED_NOTE), autorun: false, diff --git a/packages/backend/src/server/api/ApiCallService.ts b/packages/backend/src/server/api/ApiCallService.ts index 0ccb3df631..dc3a88ca27 100644 --- a/packages/backend/src/server/api/ApiCallService.ts +++ b/packages/backend/src/server/api/ApiCallService.ts @@ -16,6 +16,7 @@ import type { MiMeta, UserIpsRepository } from '@/models/_.js'; import { createTemp } from '@/misc/create-temp.js'; import { bindThis } from '@/decorators.js'; import { RoleService } from '@/core/RoleService.js'; +import { TelemetryService } from '@/core/telemetry/TelemetryService.js'; import type { Config } from '@/config.js'; import { ApiError } from './error.js'; import { RateLimiterService } from './RateLimiterService.js'; @@ -36,7 +37,6 @@ export class ApiCallService implements OnApplicationShutdown { private logger: Logger; private userIpHistories: Map>; private userIpHistoriesClearIntervalId: NodeJS.Timeout; - private Sentry: typeof import('@sentry/node') | null = null; constructor( @Inject(DI.meta) @@ -52,6 +52,7 @@ export class ApiCallService implements OnApplicationShutdown { private rateLimiterService: RateLimiterService, private roleService: RoleService, private apiLoggerService: ApiLoggerService, + private telemetryService: TelemetryService, ) { this.logger = this.apiLoggerService.logger; this.userIpHistories = new Map>(); @@ -59,12 +60,6 @@ export class ApiCallService implements OnApplicationShutdown { this.userIpHistoriesClearIntervalId = setInterval(() => { this.userIpHistories.clear(); }, 1000 * 60 * 60); - - if (this.config.sentryForBackend) { - import('@sentry/node').then((Sentry) => { - this.Sentry = Sentry; - }); - } } #sendApiError(reply: FastifyReply, err: ApiError): void { @@ -126,24 +121,20 @@ export class ApiCallService implements OnApplicationShutdown { }, }); - if (this.Sentry != null) { - this.Sentry.captureMessage(`Internal error occurred in ${ep.name}: ${err.message}`, { - level: 'error', - user: { - id: userId, + this.telemetryService.captureMessage(`Internal error occurred in ${ep.name}: ${err.message}`, { + level: 'error', + userId, + extra: { + ep: ep.name, + ps: data, + e: { + message: err.message, + code: err.name, + stack: err.stack, + id: errId, }, - extra: { - ep: ep.name, - ps: data, - e: { - message: err.message, - code: err.name, - stack: err.stack, - id: errId, - }, - }, - }); - } + }, + }); throw new ApiError(null, { e: { @@ -441,15 +432,8 @@ export class ApiCallService implements OnApplicationShutdown { } // API invoking - if (this.Sentry != null) { - return await this.Sentry.startSpan({ - name: 'API: ' + ep.name, - }, () => ep.exec(data, user, token, file, request.ip, request.headers) - .catch((err: Error) => this.#onExecError(ep, data, err, user?.id))); - } else { - return await ep.exec(data, user, token, file, request.ip, request.headers) - .catch((err: Error) => this.#onExecError(ep, data, err, user?.id)); - } + 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))); } @bindThis diff --git a/packages/frontend/src/boot/common.ts b/packages/frontend/src/boot/common.ts index fa60ec4b58..4c5e601dae 100644 --- a/packages/frontend/src/boot/common.ts +++ b/packages/frontend/src/boot/common.ts @@ -5,7 +5,7 @@ import { watch, version as vueVersion } from 'vue'; import { compareVersions } from 'compare-versions'; -import { version, lang, apiUrl, isSafeMode } from '@@/js/config.js'; +import { version, lang, isSafeMode } from '@@/js/config.js'; import defaultLightTheme from '@@/themes/l-light.json5'; import defaultDarkTheme from '@@/themes/d-green-lime.json5'; import { storeBootloaderErrors } from '@@/js/store-boot-errors'; @@ -30,6 +30,7 @@ import { fetchCustomEmojis } from '@/custom-emojis.js'; import { prefer } from '@/preferences.js'; import { $i } from '@/i.js'; import { launchPlugins } from '@/plugin.js'; +import { initTelemetry } from '@/telemetry.js'; export async function common(createVue: () => Promise>) { console.info(`Misskey v${version}`); @@ -286,40 +287,7 @@ export async function common(createVue: () => Promise>) { return root; })(); - if (instance.sentryForFrontend) { - const Sentry = await import('@sentry/vue'); - Sentry.init({ - app, - integrations: [ - ...(instance.sentryForFrontend.vueIntegration !== undefined ? [ - Sentry.vueIntegration(instance.sentryForFrontend.vueIntegration ?? undefined), - ] : []), - ...(instance.sentryForFrontend.browserTracingIntegration !== undefined ? [ - Sentry.browserTracingIntegration(instance.sentryForFrontend.browserTracingIntegration ?? undefined), - ] : []), - ...(instance.sentryForFrontend.replayIntegration !== undefined ? [ - Sentry.replayIntegration(instance.sentryForFrontend.replayIntegration ?? undefined), - ] : []), - ], - - // Set tracesSampleRate to 1.0 to capture 100% - tracesSampleRate: 1.0, - - // Set `tracePropagationTargets` to control for which URLs distributed tracing should be enabled - ...(instance.sentryForFrontend.browserTracingIntegration !== undefined ? { - tracePropagationTargets: [apiUrl], - } : {}), - - // Capture Replay for 10% of all sessions, - // plus for 100% of sessions with an error - ...(instance.sentryForFrontend.replayIntegration !== undefined ? { - replaysSessionSampleRate: 0.1, - replaysOnErrorSampleRate: 1.0, - } : {}), - - ...instance.sentryForFrontend.options, - }); - } + await initTelemetry(instance, app); try { await launchPlugins(); diff --git a/packages/frontend/src/telemetry.ts b/packages/frontend/src/telemetry.ts new file mode 100644 index 0000000000..5a9d64c2a4 --- /dev/null +++ b/packages/frontend/src/telemetry.ts @@ -0,0 +1,45 @@ +/* + * SPDX-FileCopyrightText: syuilo and misskey-project + * SPDX-License-Identifier: AGPL-3.0-only + */ + +import { apiUrl } from '@@/js/config.js'; +import type { App } from 'vue'; +import type * as Misskey from 'misskey-js'; + +export async function initTelemetry(instance: Misskey.entities.MetaDetailed, app: App): Promise { + if (!instance.sentryForFrontend) return; + + const Sentry = await import('@sentry/vue'); + Sentry.init({ + app, + integrations: [ + ...(instance.sentryForFrontend.vueIntegration !== undefined ? [ + Sentry.vueIntegration(instance.sentryForFrontend.vueIntegration ?? undefined), + ] : []), + ...(instance.sentryForFrontend.browserTracingIntegration !== undefined ? [ + Sentry.browserTracingIntegration(instance.sentryForFrontend.browserTracingIntegration ?? undefined), + ] : []), + ...(instance.sentryForFrontend.replayIntegration !== undefined ? [ + Sentry.replayIntegration(instance.sentryForFrontend.replayIntegration ?? undefined), + ] : []), + ], + + // Set tracesSampleRate to 1.0 to capture 100% + tracesSampleRate: 1.0, + + // Set `tracePropagationTargets` to control for which URLs distributed tracing should be enabled + ...(instance.sentryForFrontend.browserTracingIntegration !== undefined ? { + tracePropagationTargets: [apiUrl], + } : {}), + + // Capture Replay for 10% of all sessions, + // plus for 100% of sessions with an error + ...(instance.sentryForFrontend.replayIntegration !== undefined ? { + replaysSessionSampleRate: 0.1, + replaysOnErrorSampleRate: 1.0, + } : {}), + + ...instance.sentryForFrontend.options, + }); +} From f59bf35615165bbefcbccfa2b0b21fd822d5ecfd 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: Sat, 4 Jul 2026 16:26:22 +0900 Subject: [PATCH 03/18] =?UTF-8?q?feat:=20=E6=9D=A1=E4=BB=B6=E3=81=AB?= =?UTF-8?q?=E4=B8=80=E8=87=B4=E3=81=97=E3=81=9FURL=E3=83=97=E3=83=AC?= =?UTF-8?q?=E3=83=93=E3=83=A5=E3=83=BC=E3=81=AE=E3=82=B5=E3=83=A0=E3=83=8D?= =?UTF-8?q?=E3=82=A4=E3=83=AB=E3=82=92=E9=9A=A0=E3=81=99=E3=81=93=E3=81=A8?= =?UTF-8?q?=E3=81=8C=E3=81=A7=E3=81=8D=E3=82=8B=E3=82=88=E3=81=86=E3=81=AB?= =?UTF-8?q?=20(#17635)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * URLプレビューのサムネイルを隠す機能を追加 (MisskeyIO#214) * Update Changelog * fix * Update Changelog * fix * fix: もうセンシティブと判定されている場合はそれをそのまま通すように * fix: unnecessary import * fix lint --------- Co-authored-by: CyberRex --- CHANGELOG.md | 2 ++ locales/ja-JP.yml | 2 ++ .../1782581064131-urlPreviewSensitiveList.js | 16 ++++++++++++++++ packages/backend/src/models/Meta.ts | 5 +++++ .../src/server/api/endpoints/admin/meta.ts | 9 +++++++++ .../server/api/endpoints/admin/update-meta.ts | 10 ++++++++++ .../backend/src/server/web/UrlPreviewService.ts | 6 ++++++ packages/frontend/src/pages/admin/settings.vue | 9 +++++++++ packages/i18n/src/autogen/locale.ts | 8 ++++++++ packages/misskey-js/src/autogen/types.ts | 2 ++ 10 files changed, 69 insertions(+) create mode 100644 packages/backend/migration/1782581064131-urlPreviewSensitiveList.js diff --git a/CHANGELOG.md b/CHANGELOG.md index bad2db5ce2..a7dd8eefba 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -15,6 +15,8 @@ ### General - Feat: コントロールパネルから二要素認証を解除できるように +- Feat: 条件に一致したURLプレビューのサムネイルを隠すことができるように + (Based on https://github.com/MisskeyIO/misskey/pull/214) ### Client - Fix: チャットでIMEの変換を確定するEnterでメッセージが送信されてしまうことがある問題を修正 diff --git a/locales/ja-JP.yml b/locales/ja-JP.yml index 965330ee82..2f6c912044 100644 --- a/locales/ja-JP.yml +++ b/locales/ja-JP.yml @@ -1419,6 +1419,8 @@ addToEmojiPalette: "絵文字パレットに追加" emojiPaletteAlreadyAddedConfirm: "この絵文字はすでにこの絵文字パレットに含まれています。追加しなおしますか?" append: "末尾に追加" prepend: "先頭に追加" +urlPreviewSensitiveList: "サムネイルの表示を制限するURL" +urlPreviewSensitiveListDescription: "スペースで区切るとAND指定になり、改行で区切るとOR指定になります。スラッシュで囲むと正規表現になります。一致した場合、サムネイルが表示されなくなります。" _imageEditing: _vars: diff --git a/packages/backend/migration/1782581064131-urlPreviewSensitiveList.js b/packages/backend/migration/1782581064131-urlPreviewSensitiveList.js new file mode 100644 index 0000000000..8c0bdedeb9 --- /dev/null +++ b/packages/backend/migration/1782581064131-urlPreviewSensitiveList.js @@ -0,0 +1,16 @@ +/* + * SPDX-FileCopyrightText: syuilo and misskey-project + * SPDX-License-Identifier: AGPL-3.0-only + */ + +export class UrlPreviewSensitiveList1782581064131 { + name = 'UrlPreviewSensitiveList1782581064131' + + async up(queryRunner) { + await queryRunner.query(`ALTER TABLE "meta" ADD "urlPreviewSensitiveList" character varying(3072) array NOT NULL DEFAULT '{}'`); + } + + async down(queryRunner) { + await queryRunner.query(`ALTER TABLE "meta" DROP COLUMN "urlPreviewSensitiveList"`); + } +} diff --git a/packages/backend/src/models/Meta.ts b/packages/backend/src/models/Meta.ts index d4214c0e5a..307b3f7a5d 100644 --- a/packages/backend/src/models/Meta.ts +++ b/packages/backend/src/models/Meta.ts @@ -672,6 +672,11 @@ export class MiMeta { }) public urlPreviewUserAgent: string | null; + @Column('varchar', { + length: 3072, array: true, default: '{}', + }) + public urlPreviewSensitiveList: string[]; + @Column('varchar', { length: 128, default: 'none', diff --git a/packages/backend/src/server/api/endpoints/admin/meta.ts b/packages/backend/src/server/api/endpoints/admin/meta.ts index 956f991d32..849a05c334 100644 --- a/packages/backend/src/server/api/endpoints/admin/meta.ts +++ b/packages/backend/src/server/api/endpoints/admin/meta.ts @@ -544,6 +544,14 @@ export const meta = { type: 'string', optional: false, nullable: true, }, + urlPreviewSensitiveList: { + type: 'array', + optional: false, nullable: false, + items: { + type: 'string', + optional: false, nullable: false, + }, + }, federation: { type: 'string', enum: ['all', 'specified', 'none'], @@ -760,6 +768,7 @@ export default class extends Endpoint { // eslint- urlPreviewRequireContentLength: instance.urlPreviewRequireContentLength, urlPreviewUserAgent: instance.urlPreviewUserAgent, urlPreviewSummaryProxyUrl: instance.urlPreviewSummaryProxyUrl, + urlPreviewSensitiveList: instance.urlPreviewSensitiveList, federation: instance.federation, federationHosts: instance.federationHosts, deliverSuspendedSoftware: instance.deliverSuspendedSoftware, diff --git a/packages/backend/src/server/api/endpoints/admin/update-meta.ts b/packages/backend/src/server/api/endpoints/admin/update-meta.ts index def6a0bda7..4a7df410bc 100644 --- a/packages/backend/src/server/api/endpoints/admin/update-meta.ts +++ b/packages/backend/src/server/api/endpoints/admin/update-meta.ts @@ -189,6 +189,12 @@ export const paramDef = { urlPreviewRequireContentLength: { type: 'boolean' }, urlPreviewUserAgent: { type: 'string', nullable: true }, urlPreviewSummaryProxyUrl: { type: 'string', nullable: true }, + urlPreviewSensitiveList: { + type: 'array', nullable: true, + items: { + type: 'string', + } + }, federation: { type: 'string', enum: ['all', 'none', 'specified'], @@ -734,6 +740,10 @@ export default class extends Endpoint { // eslint- set.urlPreviewSummaryProxyUrl = value === '' ? null : value; } + if (Array.isArray(ps.urlPreviewSensitiveList)) { + set.urlPreviewSensitiveList = ps.urlPreviewSensitiveList.filter(Boolean); + } + if (ps.federation !== undefined) { set.federation = ps.federation; } diff --git a/packages/backend/src/server/web/UrlPreviewService.ts b/packages/backend/src/server/web/UrlPreviewService.ts index 886e876c40..e5ba8c596c 100644 --- a/packages/backend/src/server/web/UrlPreviewService.ts +++ b/packages/backend/src/server/web/UrlPreviewService.ts @@ -11,6 +11,7 @@ import { HttpRequestService } from '@/core/HttpRequestService.js'; import type Logger from '@/logger.js'; import { query } from '@/misc/prelude/url.js'; import { LoggerService } from '@/core/LoggerService.js'; +import { UtilityService } from '@/core/UtilityService.js'; import { bindThis } from '@/decorators.js'; import { ApiError } from '@/server/api/error.js'; import { MiMeta } from '@/models/Meta.js'; @@ -29,6 +30,7 @@ export class UrlPreviewService { private meta: MiMeta, private httpRequestService: HttpRequestService, + private utilityService: UtilityService, private loggerService: LoggerService, ) { this.logger = this.loggerService.getLogger('url-preview'); @@ -95,6 +97,10 @@ export class UrlPreviewService { summary.icon = this.wrap(summary.icon); summary.thumbnail = this.wrap(summary.thumbnail); + if (summary.sensitive !== true) { + summary.sensitive = this.utilityService.isKeyWordIncluded(summary.url, this.meta.urlPreviewSensitiveList); + } + // Cache 1day reply.header('Cache-Control', 'max-age=86400, immutable'); diff --git a/packages/frontend/src/pages/admin/settings.vue b/packages/frontend/src/pages/admin/settings.vue index 99d4455939..31825f17ab 100644 --- a/packages/frontend/src/pages/admin/settings.vue +++ b/packages/frontend/src/pages/admin/settings.vue @@ -240,6 +240,13 @@ SPDX-License-Identifier: AGPL-3.0-only + + + + + + + @@ -465,6 +472,7 @@ const urlPreviewForm = useForm({ urlPreviewRequireContentLength: meta.urlPreviewRequireContentLength, urlPreviewUserAgent: meta.urlPreviewUserAgent ?? '', urlPreviewSummaryProxyUrl: meta.urlPreviewSummaryProxyUrl ?? '', + urlPreviewSensitiveList: meta.urlPreviewSensitiveList.join('\n'), }, async (state) => { await os.apiWithDialog('admin/update-meta', { urlPreviewEnabled: state.urlPreviewEnabled, @@ -474,6 +482,7 @@ const urlPreviewForm = useForm({ urlPreviewRequireContentLength: state.urlPreviewRequireContentLength, urlPreviewUserAgent: state.urlPreviewUserAgent, urlPreviewSummaryProxyUrl: state.urlPreviewSummaryProxyUrl, + urlPreviewSensitiveList: state.urlPreviewSensitiveList.split('\n'), }); fetchInstance(true); }); diff --git a/packages/i18n/src/autogen/locale.ts b/packages/i18n/src/autogen/locale.ts index 367c9bb8f3..5103f3845f 100644 --- a/packages/i18n/src/autogen/locale.ts +++ b/packages/i18n/src/autogen/locale.ts @@ -5691,6 +5691,14 @@ export interface Locale extends ILocale { * 先頭に追加 */ "prepend": string; + /** + * サムネイルの表示を制限するURL + */ + "urlPreviewSensitiveList": string; + /** + * スペースで区切るとAND指定になり、改行で区切るとOR指定になります。スラッシュで囲むと正規表現になります。一致した場合、サムネイルが表示されなくなります。 + */ + "urlPreviewSensitiveListDescription": string; "_imageEditing": { "_vars": { /** diff --git a/packages/misskey-js/src/autogen/types.ts b/packages/misskey-js/src/autogen/types.ts index efb20fd43c..0f5db3e205 100644 --- a/packages/misskey-js/src/autogen/types.ts +++ b/packages/misskey-js/src/autogen/types.ts @@ -9557,6 +9557,7 @@ export interface operations { urlPreviewRequireContentLength: boolean; urlPreviewUserAgent: string | null; urlPreviewSummaryProxyUrl: string | null; + urlPreviewSensitiveList: string[]; /** @enum {string} */ federation: 'all' | 'specified' | 'none'; federationHosts: string[]; @@ -13076,6 +13077,7 @@ export interface operations { urlPreviewRequireContentLength?: boolean; urlPreviewUserAgent?: string | null; urlPreviewSummaryProxyUrl?: string | null; + urlPreviewSensitiveList?: string[] | null; /** @enum {string} */ federation?: 'all' | 'none' | 'specified'; federationHosts?: string[]; From c423bf92a877077af06ea09d14ee8e9e154ca794 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: Sat, 4 Jul 2026 16:47:31 +0900 Subject: [PATCH 04/18] Update CHANGELOG.md --- CHANGELOG.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index a7dd8eefba..cba0f07c7f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,4 +1,4 @@ -## 2026.6.1 +## 2026.7.0 ### Note @@ -19,10 +19,10 @@ (Based on https://github.com/MisskeyIO/misskey/pull/214) ### Client -- Fix: チャットでIMEの変換を確定するEnterでメッセージが送信されてしまうことがある問題を修正 - 2025.4.0 以前の設定情報の移行処理が削除されました - 2025.4.0 から直接 2026.6.0 以上にアップデートする場合は設定が移行されませんので注意してください。移行したい場合は一度 2026.5.1 を経由してください。 - Fix: デバイスタイプをスマートフォンに固定している状態で画面幅が広いとき、画面左上のアイコンが表示されない問題を修正 +- Fix: チャットでIMEの変換を確定するEnterでメッセージが送信されてしまうことがある問題を修正 ### Server - Enhance: センシティブメディアの判定を外部サービス ([sensitive-detector](https://github.com/misskey-dev/sensitive-detector)) に分離し、`nsfwjs` / `@tensorflow/tfjs(-node)` の同梱と NSFW 判定モデルを廃止 (#16804) From 426f6748e5a5d7a2a1fd7be00197bbfa1d41e0bd 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: Sat, 4 Jul 2026 17:44:42 +0900 Subject: [PATCH 05/18] =?UTF-8?q?fix:=20frontend-builder=E3=81=AEutils.ts?= =?UTF-8?q?=E3=81=8C=E5=9E=8B=E3=82=A8=E3=83=A9=E3=83=BC=E3=82=92=E8=B5=B7?= =?UTF-8?q?=E3=81=93=E3=81=97=E3=81=A6=E3=81=84=E3=81=9F=E3=81=AE=E3=82=92?= =?UTF-8?q?=E4=BF=AE=E6=AD=A3=20(#17649)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- packages/frontend-builder/utils.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/frontend-builder/utils.ts b/packages/frontend-builder/utils.ts index f85ae7ea0c..3bb528e25f 100644 --- a/packages/frontend-builder/utils.ts +++ b/packages/frontend-builder/utils.ts @@ -8,5 +8,5 @@ export function assertNever(x: never): never { throw new Error(`Unexpected type: ${(x as any)?.type ?? x}`); } -export function assertType(_node: unknown): asserts node is T { +export function assertType(_node: unknown): asserts _node is T { } From 00b2cb2076d95707047d51974902b9f51c0f67ef 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: Sat, 4 Jul 2026 18:53:10 +0900 Subject: [PATCH 06/18] =?UTF-8?q?fix(backend/test):=20streaming.ts=20e2e?= =?UTF-8?q?=20=E3=81=AE=E6=A4=9C=E8=A8=BC=E4=B8=8D=E5=82=99=E3=82=92?= =?UTF-8?q?=E4=BF=AE=E6=AD=A3=20(#17663)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit fix(backend/test): streaming.ts e2e の検証不備を修正 (tiramiss-community/endolphin#113) Co-authored-by: samunohito <46447427+samunohito@users.noreply.github.com> --- packages/backend/test/e2e/streaming.ts | 13 ++++++++----- 1 file changed, 8 insertions(+), 5 deletions(-) diff --git a/packages/backend/test/e2e/streaming.ts b/packages/backend/test/e2e/streaming.ts index a051be6c3c..ec2e7592fa 100644 --- a/packages/backend/test/e2e/streaming.ts +++ b/packages/backend/test/e2e/streaming.ts @@ -510,7 +510,7 @@ describe('Streaming', () => { test('withReplies: true のとき自分のfollowers投稿に対するリプライが流れる', async () => { const erinNote = await post(erin, { text: 'hi', visibility: 'followers' }); const fired = await waitFire( - erin, 'homeTimeline', // erin:home + erin, 'hybridTimeline', // erin:Hybrid () => api('notes/create', { text: 'hello', replyId: erinNote.id }, ayano), // ayano reply to erin's followers post msg => msg.type === 'note' && msg.body.userId === ayano.id, // wait ayano ); @@ -521,7 +521,7 @@ describe('Streaming', () => { test('withReplies: false でも自分の投稿に対するリプライが流れる', async () => { const ayanoNote = await post(ayano, { text: 'hi', visibility: 'followers' }); const fired = await waitFire( - ayano, 'homeTimeline', // ayano:home + ayano, 'hybridTimeline', // ayano:Hybrid () => api('notes/create', { text: 'hello', replyId: ayanoNote.id }, erin), // erin reply to ayano's followers post msg => msg.type === 'note' && msg.body.userId === erin.id, // wait erin ); @@ -530,9 +530,12 @@ describe('Streaming', () => { }); test('withReplies: true のフォローしていない人のfollowersノートに対するリプライが流れない', async () => { + // ayano は kyoko をフォローしているため kyoko の followers 投稿にリプライできるが、 + // erin は kyoko をフォローしていないため、そのリプライは erin の Hybrid Timeline には流れないはず + const kyokoFollowersNote = await post(kyoko, { text: 'hi', visibility: 'followers' }); const fired = await waitFire( - erin, 'homeTimeline', // erin:home - () => api('notes/create', { text: 'hello', replyId: chitose.id }, ayano), // ayano reply to chitose's post + erin, 'hybridTimeline', // erin:Hybrid + () => api('notes/create', { text: 'hello', replyId: kyokoFollowersNote.id }, ayano), // ayano reply to kyoko's followers post msg => msg.type === 'note' && msg.body.userId === ayano.id, // wait ayano ); @@ -680,7 +683,7 @@ describe('Streaming', () => { const fired = await waitFire( chitose, 'userList', () => api('notes/create', { text: 'foo' }, takumi), - msg => msg.type === 'note' && msg.body.userId === kyoko.id, + msg => msg.type === 'note' && msg.body.userId === takumi.id, { listId: list.id }, ); From 187610d516acc3ff013a708b95ea1b9961c2a54d Mon Sep 17 00:00:00 2001 From: Johann150 Date: Sat, 4 Jul 2026 13:43:42 +0200 Subject: [PATCH 07/18] fix(frontend): correctly detect isMe in mention (#17664) --- packages/frontend/src/components/MkMention.vue | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/frontend/src/components/MkMention.vue b/packages/frontend/src/components/MkMention.vue index f2cf33eb65..016257ba9f 100644 --- a/packages/frontend/src/components/MkMention.vue +++ b/packages/frontend/src/components/MkMention.vue @@ -33,7 +33,7 @@ const canonical = props.host === localHost ? `@${props.username}` : `@${props.us const url = `/${canonical}`; const isMe = $i && ( - `@${props.username}@${toUnicode(props.host)}` === `@${$i.username}@${toUnicode(localHost)}`.toLowerCase() + `@${props.username}@${toUnicode(props.host)}`.toLowerCase() === `@${$i.username}@${toUnicode(localHost)}`.toLowerCase() ); const avatarUrl = computed(() => prefer.s.disableShowingAnimatedImages || prefer.s.dataSaver.avatar From 97082efd9977ac91aa4de437eb9d40d92501a0a4 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: Sat, 4 Jul 2026 20:46:03 +0900 Subject: [PATCH 08/18] Update CHANGELOG.md [ci skip] --- CHANGELOG.md | 1 + 1 file changed, 1 insertion(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index cba0f07c7f..b2c00cce61 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -23,6 +23,7 @@ - 2025.4.0 から直接 2026.6.0 以上にアップデートする場合は設定が移行されませんので注意してください。移行したい場合は一度 2026.5.1 を経由してください。 - Fix: デバイスタイプをスマートフォンに固定している状態で画面幅が広いとき、画面左上のアイコンが表示されない問題を修正 - Fix: チャットでIMEの変換を確定するEnterでメッセージが送信されてしまうことがある問題を修正 +- Fix: 自分へのメンションに対する色分けで、判定が大文字/小文字を区別していた問題を修正 ### Server - Enhance: センシティブメディアの判定を外部サービス ([sensitive-detector](https://github.com/misskey-dev/sensitive-detector)) に分離し、`nsfwjs` / `@tensorflow/tfjs(-node)` の同梱と NSFW 判定モデルを廃止 (#16804) From 1858d5691886a4de8725d98389470ee5e40b3ec4 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, 5 Jul 2026 13:07:17 +0900 Subject: [PATCH 09/18] =?UTF-8?q?fix(frontend):=20=E3=83=AF=E3=83=BC?= =?UTF-8?q?=E3=83=89=E3=83=9F=E3=83=A5=E3=83=BC=E3=83=88=E3=81=AE=E5=88=A4?= =?UTF-8?q?=E5=AE=9A=E3=81=8C=E8=AA=A4=E3=81=A3=E3=81=A6=E3=81=84=E3=82=8B?= =?UTF-8?q?=E5=95=8F=E9=A1=8C=E3=82=92=E4=BF=AE=E6=AD=A3=20(#17668)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- packages/frontend/src/components/MkNote.vue | 6 +- packages/frontend/src/composables/use-note.ts | 58 ++++++++----------- 2 files changed, 24 insertions(+), 40 deletions(-) diff --git a/packages/frontend/src/components/MkNote.vue b/packages/frontend/src/components/MkNote.vue index e55a496bfe..4017924fa4 100644 --- a/packages/frontend/src/components/MkNote.vue +++ b/packages/frontend/src/components/MkNote.vue @@ -192,25 +192,21 @@ SPDX-License-Identifier: AGPL-3.0-only MkDateSeparatedList uses TransitionGroup which requires single element in the child elements so MkNote create empty div instead of no elements --> - +