1
0
mirror of https://github.com/misskey-dev/misskey.git synced 2026-07-15 08:14:53 +02:00

Compare commits

..

30 Commits

Author SHA1 Message Date
syuilo
ed8a51b46f New translations ja-jp.yml (Tagalog)
[ci skip]
2026-07-15 13:37:49 +09:00
syuilo
77bb7cf1af New translations ja-jp.yml (Indonesian)
[ci skip]
2026-07-15 13:37:45 +09:00
syuilo
1d97f980cc New translations ja-jp.yml (Spanish)
[ci skip]
2026-07-15 13:37:21 +09:00
github-actions[bot]
1110d334c8 Bump version to 2026.7.0-alpha.5 2026-07-15 04:36:32 +00:00
おさむのひと
667919581e enhance: 新しいLoggerのコア実装 (#17714)
* enhance: impl logger core

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

* fix

* wip

* fix

* fix

* Update Changelog

* spdx

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

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

* fix

* regenerate

* Potential fix for pull request finding

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

* Potential fix for pull request finding

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

* Potential fix for pull request finding

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

* Potential fix for pull request finding

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

* fix

* fix

* fix

---------

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

* fix CHANGELOG.md

* remove playwright file

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

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

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

---------

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

* fix
2026-07-12 17:15:13 +09:00
かっこかり
aab7903ab0 fix(backend): summalyの結果のキャッシュが後続処理によって書き換わる問題を修正
Follow-up of #17367
2026-07-12 16:54:57 +09:00
github-actions[bot]
6ceda0f004 Bump version to 2026.7.0-alpha.3 2026-07-12 03:12:51 +00:00
かっこかり
df5c491f4c enhance(backend): summalyの結果をキャッシュするように (#17367)
* enhance(backend): summalyの結果をキャッシュするように

* fix

* fix: unify memorykvcache

* fix

* refactor

* fix

* Update Changelog
2026-07-12 12:08:59 +09:00
syuilo
15c187ffa2 enhance(frontend): misskey-native lightbox (#17687)
* wip

* wip

* Update MkImageGallery.item.vue

* wip

* Update MkImageGallery.item.vue

* Update MkImageGallery.item.vue

* wip

* wip

* wip

* wip

* wip

* Update MkImageGallery.vue

* Update MkImageGallery.vue

* wip

* wip

* Update MkMediaList.vue

* Update MkImageGallery.item.vue

* fix

* wip

* Update MkImageGallery.item.vue

* wip

* wip

* Update MkImageGallery.item.vue

* refactor

* Update MkImageGallery.item.vue

* Update MkImageGallery.vue

* Delete animation.ts

* wip

* wip

* Update MkImageGallery.item.vue

* Update MkImageGallery.item.vue

* wip

* wip

* Update MkImageGallery.vue

* Update MkImageGallery.vue

* Update MkImageGallery.item.vue

* fix

* Update MkImageGallery.item.vue

* add note

* lint

* Update MkMediaList.vue

* refactor

* Update MkImageGallery.item.vue

* wip

* Update MkImageGallery.item.vue

* fix

* Update MkImageGallery.item.vue

* wip

* Update MkImageGallery.item.vue

* wip

* wip

* Update MkImageGallery.item.vue

* Update MkImageGallery.item.vue

* Update MkImageGallery.item.vue

* Update MkImageGallery.item.vue

* Update MkVideoContol.vue

* wip

* Update MkMediaVideo.vue

* Update MkMediaVideo.vue

* wip

* Update MkImageGallery.vue

* Update MkVideoContol.vue

* Update MkVideoContol.vue

* Improve image viewer (#17696)

* wip

* fix

* fix

* 🎨

* fix

* fix: Video要素をpropsで渡すのをやめる

* 🎨

* fix

* wip

* fix

* fix: 画面外にドラッグできるのを修正

* fix lint

* fix

* fix

* fix

* refactor

* add comment

* fix: 画像を新しいタブで開くが機能しなくなっていた問題を修正

* fix

* clean up

* attempt to fix

* fix

* enhance: ブラウザの戻るボタンで閉じる

* Revert "fix: 画面外にドラッグできるのを修正"

This reverts commit ea2ddc2620.

* Reapply "fix: 画面外にドラッグできるのを修正"

This reverts commit 87171980a7.

* fix

* fix: 存在しない要素がsourceElementのときに閉じると画像が飛んでいく問題を修正

* fix: clampに余白を追加

* fix duplicated condition

* fix

* fix typo

* fix(frontend/imagegallery): ボタンの当たり判定がまだ小さいのを修正 (#17697)

* wip

* fix

* fix

* 🎨

* fix

* fix: Video要素をpropsで渡すのをやめる

* 🎨

* fix

* wip

* fix

* fix: 画面外にドラッグできるのを修正

* fix lint

* fix

* fix

* fix

* refactor

* add comment

* fix: 画像を新しいタブで開くが機能しなくなっていた問題を修正

* fix

* clean up

* attempt to fix

* fix

* enhance: ブラウザの戻るボタンで閉じる

* Revert "fix: 画面外にドラッグできるのを修正"

This reverts commit ea2ddc2620.

* Reapply "fix: 画面外にドラッグできるのを修正"

This reverts commit 87171980a7.

* fix

* fix: 存在しない要素がsourceElementのときに閉じると画像が飛んでいく問題を修正

* fix: clampに余白を追加

* fix duplicated condition

* fix

* fix typo

* fix

* Update Changelog

* indent

* fix

* fix

* refactor: rename componet

* fix

* fix

* fix

---------

Co-authored-by: かっこかり <67428053+kakkokari-gtyih@users.noreply.github.com>
2026-07-12 11:58:44 +09:00
おさむのひと
b4abbe432e fix: ensure color output for child processes in Windows environment (#17695) 2026-07-11 15:27:34 +09:00
おさむのひと
2335eb7b7f chore: pnpm dev時のプロセス管理をより綿密に行い、Windows環境でのプロセス残留を抑止 (#17690) 2026-07-10 21:51:01 +09:00
かっこかり
059fbaa92b fix(frontend/test): follow-up of #17682 of 2 (#17685)
* fix(frontend/test): follow-up of #17682 of 2

* fix
2026-07-08 22:37:19 +09:00
かっこかり
76ab562810 fix: follow-up of #17682 [ci skip] 2026-07-08 21:19:35 +09:00
かっこかり
9472e72678 enhance(frontend/test): frontend e2eをPlaywrightに移行 (#17682)
* enhance(frontend/test): frontend e2eをVitest Browser Modeに移行

* fix

* fix unit tests

* fix

* fix

* update playwright

* fix

* fix

* fix

* fix

* fix

* fix

* fix

* fix

* perf

* Revert "perf"

This reverts commit e60e965c3c.

* fix
2026-07-08 20:58:02 +09:00
かっこかり
9440423a73 refactor(frontend): フロントエンドのfluent-emojisをemoji-assetsから取ってくるように (#17662)
refactor(frontend): フロントエンドのfluent-emojis使用箇所を `@misskey-dev/emoji-assets` のものに変更
2026-07-08 20:11:13 +09:00
かっこかり
b2867eb0d7 fix(frontend): ディレクティブのイベントリスナーがマウント解除後も残る問題を修正 (#17683)
* fix(frontend): ツールチップがマウント解除後もイベントリスナーが残る問題を修正

* more fix

* fix

* fix/refactor

* Update Changelog

* update comment

* refactor

* fix
2026-07-08 19:45:43 +09:00
syuilo
5590f8bad9 chore(backend): COOP/COEPヘッダーを送信するテスト用引数 2026-07-08 13:15:03 +09:00
おさむのひと
c15f93e48d fix: Sentry 使用環境下にて、外部送信リクエストへ sentry-trace / baggage ヘッダが既定で付与されないように (#17677)
* fix: Sentry 使用環境下にて、外部送信リクエストへ `sentry-trace` / `baggage` ヘッダーが既定で付与されないように

* fix
2026-07-08 09:46:44 +09:00
205 changed files with 7733 additions and 3014 deletions

View File

@@ -35,7 +35,7 @@ Misskey リポジトリの `.claude/` 構成を 7 カテゴリで採点し、改
| 2 | Context Efficiency | frontmatter description の冗長度、SKILL.md の長さ分布、重複情報、CLAUDE.md の肥大化 |
| 3 | Quality Gates | Stop / PreToolUse / PostToolUse hook の整備、`/quality-gate` 等の完了前ゲートの有無、自動 lint/typecheck |
| 4 | Memory Persistence | `.claude/skills/*/SKILL.md``references/` の同期状態を評価。プロジェクト側 `.claude/memory/` は未採用方針 (auto-memory はユーザーホーム側で自動運用) のため、ここを採点起点にせず既定 5/10 から開始する |
| 5 | Eval Coverage | `working-on-backend` / `working-on-frontend` の testing リファレンス (backend-testing.md / frontend-testing.md) の網羅、Misskey 固有の e2e/fed/Storybook/Cypress 適用ガイド |
| 5 | Eval Coverage | `working-on-backend` / `working-on-frontend` の testing リファレンス (backend-testing.md / frontend-testing.md) の網羅、Misskey 固有の e2e/fed/Storybook/Playwright 適用ガイド |
| 6 | Security Guardrails | SPDX 規約適用、migration 不変性ルール、ja-JP.yml 限定編集ルール、secrets 検出 |
| 7 | Cost Efficiency | enabledPlugins の重複・過剰、context-budget の整備、MCP 過剰登録なし |

View File

@@ -21,7 +21,7 @@ note: 元 ECC 版は言語自動判定 + format/lint/type のジェネリック
`/quality-gate [scope]`
完了前の **軽量** 品質チェック。重い E2E / 連合テスト (test:e2e / test:fed / Cypress) は CI 側で実行されるため、本コマンドには含めない。
完了前の **軽量** 品質チェック。重い E2E / 連合テスト (test:e2e / test:fed / Playwright) は CI 側で実行されるため、本コマンドには含めない。
## Scope
@@ -120,4 +120,4 @@ Frontend ut: PASS (87/87)
- ジェネリックな言語自動判定を排除し、Misskey 固定 pipeline に。
- formatter フェーズなし (Misskey は ESLint --fix のみ採用)。
- e2e / federation / Cypress は重いため除外し CI 側に委譲。
- e2e / federation / Playwright は重いため除外し CI 側に委譲。

View File

@@ -26,7 +26,7 @@ cp .github/misskey/test.yml .config/test.yml
補足:
- ルートの `pnpm start:test` (Cypress 用にテストサーバーを起動するコマンド) を使う経路では実行時に `ncp` で自動コピーされる ([package.json](../../../../../package.json))。それ以外で backend テストを直接走らせる時は上記の手動コピーが必要
- ルートの `pnpm start:test` (Playwright 用にテストサーバーを起動するコマンド) を使う経路では実行時に `ncp` で自動コピーされる ([package.json](../../../../../package.json))。それ以外で backend テストを直接走らせる時は上記の手動コピーが必要
- すでに `.config/test.yml` があれば各テストスクリプトの内部 `compile-config` で十分なので、追加で `pnpm --filter backend compile-config` を叩く必要はない
- `pnpm start:test` は backend e2e テスト (`pnpm --filter backend test:e2e`) の前提ではない (ポート競合の元になるため使わないこと)

View File

@@ -27,7 +27,7 @@ SKILL.md 本体は references への索引だけ。具体的な手順や規約
- SCSS Modules / `--MI_THEME-*` `--MI-*` CSS 変数 / グローバル utility class (`_button` 等) → [references/knowledge/scss-modules.md](references/knowledge/scss-modules.md)
- `os.alert` / `os.confirm` / `os.popup` 等 UI ヘルパー (ブラウザ標準 `alert()` 直呼びは禁止) → [references/knowledge/os-api.md](references/knowledge/os-api.md)
- `*.stories.impl.ts` 併設規則 + 複数 story / argTypes / layout / action パターン → [references/knowledge/storybook.md](references/knowledge/storybook.md)
- frontend Vitest / Cypress E2E の書き方と前提 → [references/knowledge/frontend-testing.md](references/knowledge/frontend-testing.md)
- frontend Vitest / Playwright E2E の書き方と前提 → [references/knowledge/frontend-testing.md](references/knowledge/frontend-testing.md)
## 必ず最後に通る場所

View File

@@ -1,4 +1,4 @@
# Frontend テスト (Vitest / Cypress)
# Frontend テスト (Vitest / Playwright)
Misskey frontend のテスト構成。
@@ -13,11 +13,11 @@ pnpm --filter frontend test-and-coverage # カバレッジ付き
- 主な配置: `packages/frontend/test/*.test.ts` (例: `i18n.test.ts`, `theme.test.ts`, `is-birthday.test.ts`)
- ビルドツール周りなど対象コードと隣接させた方が分かりやすいテストは、コードと同じディレクトリに `*.test.ts` として置く (例: [packages/frontend/lib/rollup-plugin-unwind-css-module-class-name.test.ts](../../../../../packages/frontend/lib/rollup-plugin-unwind-css-module-class-name.test.ts))
- 共有コンポーネント (`MkX.vue`) のユニットテストは現状少なく、`*.spec.ts` / `__tests__/` 形式は採用していない (Storybook + Cypress でカバー)
- 共有コンポーネント (`MkX.vue`) のユニットテストは現状少なく、`*.spec.ts` / `__tests__/` 形式は採用していない (Storybook + Playwright でカバー)
## Cypress E2E
## Playwright E2E
Cypress**起動済みのテストサーバー** に対して走るため、unit より前提が多い。[.github/workflows/test-frontend.yml](../../../../../.github/workflows/test-frontend.yml) の `e2e` ジョブと同じ手順をローカルで踏む:
Playwright**起動済みのテストサーバー** に対して走るため、unit より前提が多い。[.github/workflows/test-frontend.yml](../../../../../.github/workflows/test-frontend.yml) の `e2e` ジョブと同じ手順をローカルで踏む:
```bash
# 1. テスト用 DB / Redis を起動 (テスト用ポート。開発用の compose.local-db.yml ではない)
@@ -29,15 +29,14 @@ cp .github/misskey/test.yml .config/test.yml
# 3. 全体ビルド
pnpm build
# 4. テストサーバー起動 + Cypress 実行 (いずれもルートから)
pnpm e2e # 内部で pnpm start:test を起動し http://localhost:61812 を待って Cypress run
pnpm cy:open # 対話的に開く (サーバーは別途 pnpm start:test で起動しておく)
# 4. テストサーバー起動 + Playwright 実行 (いずれもルートから)
pnpm e2e # 内部で pnpm start:test を起動し http://localhost:61812 を待って Playwright を実行
```
- 設定: ルート [cypress.config.ts](../../../../../cypress.config.ts)
- テスト本体は [cypress/](../../../../../cypress/) 配下
- 設定: ルート [packages/frontend/playwright.config.ts](../../../../../packages/frontend/playwright.config.ts) で、`packages/frontend/test/e2e/` 配下の `*.spec.ts` を対象にする
- テスト本体は [packages/frontend/test/e2e/](../../../../../packages/frontend/test/e2e/) 配下に配置する。
新規 frontend 機能の E2E は Cypress に書くのが基本。ただし対象は主要 UI フロー (login / post / drive etc) に限定し、細かい単位テストは Vitest または Storybook で代替する慣習。
新規 frontend 機能の E2E は Playwright に書くのが基本。ただし対象は主要 UI フロー (login / post / drive etc) に限定し、細かい単位テストは Vitest または Storybook で代替する慣習。
## Storybook (視覚確認 + Chromatic 視覚回帰)
@@ -55,6 +54,6 @@ pnpm --filter frontend build-storybook # 静的ビルド
frontend のテスト種別で DB / Redis の要否が違う:
- **Vitest (unit)** — DB 不要。ロジック / コンポーネント単体のテストで backend に繋がない (CI の `vitest` ジョブにも `services:` は無い)
- **Cypress (E2E)** — テストサーバー (`pnpm start:test`) 経由で backend に繋ぐため DB / Redis が必要。**テスト用ポートの [packages/backend/test/compose.yml](../../../../../packages/backend/test/compose.yml)** を使う (上記 Cypress E2E の手順を参照)
- **Playwright (E2E)** — テストサーバー (`pnpm start:test`) 経由で backend に繋ぐため DB / Redis が必要。**テスト用ポートの [packages/backend/test/compose.yml](../../../../../packages/backend/test/compose.yml)** を使う (上記 Playwright E2E の手順を参照)
開発用の `compose.local-db.yml` (db `5432` / redis `6379`) は **テストには使わない**。テスト用の `packages/backend/test/compose.yml` (`54312` / `56312`) とはポートが異なり、混同すると接続できない。

View File

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

View File

@@ -308,7 +308,7 @@ id: 'aidx'
# # To check enabled names, set `options.debug: true` and see
# # "Integration installed: <name>" in the Sentry logs.
# #
# # As of 2026-07-05 / @sentry/node 10.59.0, useful names for Misskey include:
# # As of 2026-07-07 / @sentry/node 10.62.0, useful names for Misskey include:
# # Postgres ... DB queries (when pg is externalized)
# # Redis ... ioredis commands
# # Fastify ... inbound HTTP routes
@@ -317,6 +317,74 @@ id: 'aidx'
# disabledIntegrations: ['Postgres']
# options:
# dsn: 'https://examplePublicKey@o0.ingest.sentry.io/0'
# # By default, Misskey prevents Sentry trace headers (`sentry-trace` and
# # `baggage`) from being sent to remote ActivityPub/Webhook/etc. hosts.
# # To intentionally propagate distributed traces to trusted internal services,
# # list only those internal URL patterns here.
# # Avoid broad patterns that match remote servers unless you intentionally
# # want to restore Sentry's legacy behavior of propagating traces to all
# # outbound HTTP requests.
# #tracePropagationTargets: []
# # Internal allowlist example:
# #tracePropagationTargets:
# # - 'internal-service.example'
# ┌─────────┐
#───┘ Tracing └────────────────────────────────────────────────
# OpenTelemetry distributed tracing (Traces only, opt-in).
# Existing API and Queue spans are exported to an OTLP http/protobuf endpoint.
#
# Standard OTEL_* environment variables are honored for values omitted below:
# OTEL_EXPORTER_OTLP_TRACES_ENDPOINT, OTEL_EXPORTER_OTLP_ENDPOINT,
# OTEL_EXPORTER_OTLP_HEADERS, OTEL_TRACES_SAMPLER, OTEL_TRACES_SAMPLER_ARG, etc.
#
# When this is enabled together with sentryForBackend, Misskey shares Sentry's
# TracerProvider and adds an OTLP SpanProcessor to it. In that mode:
# - sampleRate below is ignored; sampling follows sentryForBackend.options
# tracesSampleRate / tracesSampler.
# - resourceAttributes below is ignored; set OTEL_SERVICE_NAME and
# OTEL_RESOURCE_ATTRIBUTES in the environment if you need to override them.
# - spans produced by Sentry integrations are exported to both Sentry and OTLP.
# - sentry-trace / baggage propagation to outbound remote requests is disabled
# by default while OTel is enabled. Set propagateTraceToRemote: true only if
# you intentionally want Sentry trace propagation for your deployment.
#otelForBackend:
# endpoint: 'http://localhost:4318/v1/traces'
# #headers:
# # authorization: 'Bearer xxxxx'
# #sampleRate: 1.0
# # Record PostgreSQL query spans. Disabled by default to avoid instrumentation
# # overhead when database-level diagnostics are not needed.
# #capturePgSpans: false
# # Include raw SQL text in PostgreSQL spans. Requires capturePgSpans and is
# # disabled by default because non-parameterized queries can expose values to
# # the OTLP Collector.
# #capturePgStatement: false
# # Include PostgreSQL connection-pool spans with an existing parent span.
# # Requires capturePgSpans and is disabled by default to avoid noisy spans
# # from connection churn.
# #capturePgConnectionSpans: false
# # Record Redis command spans. Disabled by default because subscribing to the
# # ioredis diagnostics channel adds work to every Redis command, including
# # commands without a parent span. Enable for development diagnostics or when
# # the added overhead is acceptable in production.
# #captureRedisCommandSpans: false
# # Include Redis startup/reconnect spans. Disabled by default to avoid noisy
# # connection churn; these spans are recorded even without an HTTP or job
# # parent span.
# #captureRedisConnectionSpans: false
# # Record Redis commands without an HTTP or job parent span. Disabled by
# # default because queue polling and background work can produce many root
# # traces; only applies when captureRedisCommandSpans is enabled.
# #captureRedisRootSpans: false
# #resourceAttributes:
# # deployment.environment: 'production'
# #propagateTraceToRemote: false
# # Queue worker spans are independent traces linked to their enqueuer by default.
# # Set to 'parent' to make them child spans instead. This can create very large
# # traces for high-fan-out queues such as federation delivery.
# #jobTraceContextMode: 'link'
#sentryForFrontend:
# vueIntegration:
@@ -395,9 +463,8 @@ proxyBypassHosts:
# For security reasons, uploading attachments from the intranet is prohibited,
# but exceptions can be made from the following settings. Default value is "undefined".
# Read changelog to learn more (Improvements of 12.90.0 (2021/09/04)).
#allowedPrivateNetworks: [
# '127.0.0.1/32'
#]
#allowedPrivateNetworks:
# - '127.0.0.1/32'
# Upload or download file size limits (bytes)
#maxFileSize: 262144000

View File

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

View File

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

View File

@@ -12,4 +12,4 @@ pnpm install --frozen-lockfile
cp .devcontainer/devcontainer.yml .config/default.yml
pnpm build
pnpm migrate
pnpm exec cypress install
pnpm --filter frontend exec playwright install --with-deps chromium

2
.github/labeler.yml vendored
View File

@@ -16,7 +16,7 @@
'packages/frontend:test':
- any:
- changed-files:
- any-glob-to-any-file: ['cypress/**/*']
- any-glob-to-any-file: ['packages/frontend/test/**/*']
'packages/sw':
- any:

View File

@@ -44,7 +44,6 @@ jobs:
}
directories=(
"cypress/e2e"
"packages/backend/migration"
"packages/backend/src"
"packages/backend/test"

View File

@@ -57,11 +57,6 @@ jobs:
name: E2E tests (frontend)
runs-on: ubuntu-latest
strategy:
fail-fast: false
matrix:
browser: [chrome]
services:
postgres:
image: postgres:18
@@ -79,12 +74,6 @@ jobs:
- uses: actions/checkout@v6.0.3
with:
submodules: true
# https://github.com/cypress-io/cypress-docker-images/issues/150
#- name: Install mplayer for FireFox
# run: sudo apt install mplayer -y
# if: ${{ matrix.browser == 'firefox' }}
#- uses: browser-actions/setup-firefox@latest
# if: ${{ matrix.browser == 'firefox' }}
- name: Setup pnpm
uses: pnpm/action-setup@v6.0.9
- name: Use Node.js
@@ -97,29 +86,14 @@ jobs:
run: cp .github/misskey/test.yml .config
- name: Build
run: pnpm build
# https://github.com/cypress-io/cypress/issues/4351#issuecomment-559489091
- name: ALSA Env
run: echo -e 'pcm.!default {\n type hw\n card 0\n}\n\nctl.!default {\n type hw\n card 0\n}' > ~/.asoundrc
# XXX: This tries reinstalling Cypress if the binary is not cached
# Remove this when the cache issue is fixed
- name: Cypress install
run: pnpm exec cypress install
- name: Cypress run
uses: cypress-io/github-action@v7.4.0
- name: Playwright install
working-directory: packages/frontend
run: pnpm exec playwright install --with-deps chromium
- name: Test
run: pnpm start-server-and-test start:test http://localhost:61812 "pnpm --filter frontend test:e2e"
timeout-minutes: 15
with:
install: false
start: pnpm start:test
wait-on: 'http://localhost:61812'
headed: true
browser: ${{ matrix.browser }}
- uses: actions/upload-artifact@v7
if: failure()
with:
name: ${{ matrix.browser }}-cypress-screenshots
path: cypress/screenshots
- uses: actions/upload-artifact@v7
if: always()
with:
name: ${{ matrix.browser }}-cypress-videos
path: cypress/videos
name: playwright-e2e-artifacts
path: packages/frontend/test/e2e/artifacts

7
.gitignore vendored
View File

@@ -23,9 +23,8 @@ packages/sw/.yarn/cache
# pnpm
.pnpm-store
# Cypress
cypress/screenshots
cypress/videos
# Playwright
packages/frontend/test/e2e/artifacts
# Coverage
coverage
@@ -35,7 +34,7 @@ coverage
!/.config/example.yml
!/.config/docker_example.yml
!/.config/docker_example.env
!/.config/cypress-devcontainer.yml
!/.config/playwright-devcontainer.yml
docker-compose.yml
./compose.yml
.devcontainer/compose.yml

View File

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

View File

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

View File

@@ -1,7 +0,0 @@
import { defineConfig } from 'cypress'
export default defineConfig({
e2e: {
baseUrl: 'http://localhost:61812',
},
})

View File

@@ -1,265 +0,0 @@
/*
* SPDX-FileCopyrightText: syuilo and misskey-project
* SPDX-License-Identifier: AGPL-3.0-only
*/
describe('Before setup instance', () => {
beforeEach(() => {
cy.resetState();
});
afterEach(() => {
// テスト終了直前にページ遷移するようなテストケース(例えばアカウント作成)だと、たぶんCypressのバグでブラウザの内容が次のテストケースに引き継がれてしまう(例えばアカウントが作成し終わった段階からテストが始まる)。
// waitを入れることでそれを防止できる
cy.wait(1000);
});
it('successfully loads', () => {
cy.visitHome();
});
it('setup instance', () => {
cy.visitHome();
cy.intercept('POST', '/api/admin/accounts/create').as('signup');
cy.get('[data-cy-admin-initial-password] input').type('example_password_please_change_this_or_you_will_get_hacked');
cy.get('[data-cy-admin-username] input').type('admin');
cy.get('[data-cy-admin-password] input').type('admin1234');
cy.get('[data-cy-admin-ok]').click();
// なぜか動かない
//cy.wait('@signup').should('have.property', 'response.statusCode');
cy.wait('@signup');
cy.intercept('POST', '/api/admin/update-meta').as('update-meta');
cy.get('[data-cy-next]').click();
cy.get('[data-cy-server-name] input').type('Testskey');
cy.get('[data-cy-server-setup-wizard-apply]').click();
cy.wait('@update-meta');
});
});
describe('After setup instance', () => {
beforeEach(() => {
cy.resetState();
// インスタンス初期セットアップ
cy.registerUser('admin', 'pass', true);
});
afterEach(() => {
// テスト終了直前にページ遷移するようなテストケース(例えばアカウント作成)だと、たぶんCypressのバグでブラウザの内容が次のテストケースに引き継がれてしまう(例えばアカウントが作成し終わった段階からテストが始まる)。
// waitを入れることでそれを防止できる
cy.wait(1000);
});
it('successfully loads', () => {
cy.visitHome();
});
it('signup', () => {
cy.visitHome();
cy.intercept('POST', '/api/signup').as('signup');
cy.get('[data-cy-signup]').click();
cy.get('[data-cy-signup-rules-continue]').should('be.disabled');
cy.get('[data-cy-signup-rules-notes-agree] [data-cy-switch-toggle]').click();
cy.get('[data-cy-modal-dialog-ok]').click();
cy.get('[data-cy-signup-rules-continue]').should('not.be.disabled');
cy.get('[data-cy-signup-rules-continue]').click();
cy.get('[data-cy-signup-submit]').should('be.disabled');
cy.get('[data-cy-signup-username] input').type('alice');
cy.get('[data-cy-signup-submit]').should('be.disabled');
cy.get('[data-cy-signup-password] input').type('alice1234');
cy.get('[data-cy-signup-submit]').should('be.disabled');
cy.get('[data-cy-signup-password-retype] input').type('alice1234');
cy.get('[data-cy-signup-submit]').should('be.disabled');
cy.get('[data-cy-signup-invitation-code] input').type('test-invitation-code');
cy.get('[data-cy-signup-submit]').should('not.be.disabled');
cy.get('[data-cy-signup-submit]').click();
cy.wait('@signup');
});
it('signup with duplicated username', () => {
cy.registerUser('alice', 'alice1234');
cy.visitHome();
// ユーザー名が重複している場合の挙動確認
cy.get('[data-cy-signup]').click();
cy.get('[data-cy-signup-rules-continue]').should('be.disabled');
cy.get('[data-cy-signup-rules-notes-agree] [data-cy-switch-toggle]').click();
cy.get('[data-cy-modal-dialog-ok]').click();
cy.get('[data-cy-signup-rules-continue]').should('not.be.disabled');
cy.get('[data-cy-signup-rules-continue]').click();
cy.get('[data-cy-signup-username] input').type('alice');
cy.get('[data-cy-signup-password] input').type('alice1234');
cy.get('[data-cy-signup-password-retype] input').type('alice1234');
cy.get('[data-cy-signup-submit]').should('be.disabled');
});
});
describe('After user signup', () => {
beforeEach(() => {
cy.resetState();
// インスタンス初期セットアップ
cy.registerUser('admin', 'pass', true);
// ユーザー作成
cy.registerUser('alice', 'alice1234');
});
afterEach(() => {
// テスト終了直前にページ遷移するようなテストケース(例えばアカウント作成)だと、たぶんCypressのバグでブラウザの内容が次のテストケースに引き継がれてしまう(例えばアカウントが作成し終わった段階からテストが始まる)。
// waitを入れることでそれを防止できる
cy.wait(1000);
});
it('successfully loads', () => {
cy.visitHome();
});
it('signin', () => {
cy.visitHome();
cy.intercept('POST', '/api/signin-flow').as('signin');
cy.get('[data-cy-signin]').click();
cy.get('[data-cy-signin-page-input]').should('be.visible', { timeout: 1000 });
// Enterキーで続行できるかの確認も兼ねる
cy.get('[data-cy-signin-username] input').type('alice{enter}');
cy.get('[data-cy-signin-page-password]').should('be.visible', { timeout: 10000 });
// Enterキーで続行できるかの確認も兼ねる
cy.get('[data-cy-signin-password] input').type('alice1234{enter}');
cy.wait('@signin');
});
it('suspend', function() {
cy.request('POST', '/api/admin/suspend-user', {
i: this.admin.token,
userId: this.alice.id,
});
cy.visitHome();
cy.get('[data-cy-signin]').click();
cy.get('[data-cy-signin-page-input]').should('be.visible', { timeout: 1000 });
cy.get('[data-cy-signin-username] input').type('alice{enter}');
// TODO: cypressにブラウザの言語指定できる機能が実装され次第英語のみテストするようにする
cy.contains(/アカウントが凍結されています|This account has been suspended due to/gi);
});
});
describe('After user signed in', () => {
beforeEach(() => {
cy.resetState();
// インスタンス初期セットアップ
cy.registerUser('admin', 'pass', true);
// ユーザー作成
cy.registerUser('alice', 'alice1234');
cy.login('alice', 'alice1234');
});
afterEach(() => {
// テスト終了直前にページ遷移するようなテストケース(例えばアカウント作成)だと、たぶんCypressのバグでブラウザの内容が次のテストケースに引き継がれてしまう(例えばアカウントが作成し終わった段階からテストが始まる)。
// waitを入れることでそれを防止できる
cy.wait(1000);
});
it('successfully loads', () => {
// 表示に時間がかかるのでデフォルト秒数だとタイムアウトする
cy.get('[data-cy-user-setup-continue]', { timeout: 30000 }).should('be.visible');
});
it('account setup wizard', () => {
// 表示に時間がかかるのでデフォルト秒数だとタイムアウトする
cy.get('[data-cy-user-setup-continue]', { timeout: 30000 }).click();
cy.get('[data-cy-user-setup-user-name] input').type('ありす');
cy.get('[data-cy-user-setup-user-description] textarea').type('ほげ');
// TODO: アイコン設定テスト
cy.get('[data-cy-user-setup-continue]').click();
// プライバシー設定
cy.get('[data-cy-user-setup-continue]').click();
// フォローはスキップ
cy.get('[data-cy-user-setup-continue]').click();
// プッシュ通知設定はスキップ
cy.get('[data-cy-user-setup-continue]').click();
cy.get('[data-cy-user-setup-continue]').click();
});
});
describe('After user setup', () => {
beforeEach(() => {
cy.resetState();
// インスタンス初期セットアップ
cy.registerUser('admin', 'pass', true);
// ユーザー作成
cy.registerUser('alice', 'alice1234');
cy.login('alice', 'alice1234');
// アカウント初期設定ウィザード
// 表示に時間がかかるのでデフォルト秒数だとタイムアウトする
cy.get('[data-cy-user-setup] [data-cy-modal-window-close]', { timeout: 30000 }).click();
cy.get('[data-cy-modal-dialog-ok]').click();
});
afterEach(() => {
// テスト終了直前にページ遷移するようなテストケース(例えばアカウント作成)だと、たぶんCypressのバグでブラウザの内容が次のテストケースに引き継がれてしまう(例えばアカウントが作成し終わった段階からテストが始まる)。
// waitを入れることでそれを防止できる
cy.wait(1000);
});
it('note', () => {
cy.get('[data-cy-open-post-form]').should('be.visible');
cy.get('[data-cy-open-post-form]').click();
cy.get('[data-cy-post-form-text]').type('Hello, Misskey!');
cy.get('[data-cy-open-post-form-submit]').click();
cy.contains('Hello, Misskey!', { timeout: 15000 });
});
it('open note form with hotkey', () => {
// Wait until the page loads
cy.get('[data-cy-open-post-form]').should('be.visible');
// Use trigger() to give different `code` to test if hotkeys also work on non-QWERTY keyboards.
cy.document().trigger("keydown", { eventConstructor: 'KeyboardEvent', key: "n", code: "KeyL" });
// See if the form is opened
cy.get('[data-cy-post-form-text]').should('be.visible');
// Close it
cy.focused().trigger("keydown", { eventConstructor: 'KeyboardEvent', key: "Escape", code: "Escape" });
// See if the form is closed
cy.get('[data-cy-post-form-text]').should('not.be.visible');
});
});
// TODO: 投稿フォームの公開範囲指定のテスト
// TODO: 投稿フォームのファイル添付のテスト
// TODO: 投稿フォームのハッシュタグ保持フィールドのテスト

View File

@@ -1,35 +0,0 @@
/*
* SPDX-FileCopyrightText: syuilo and misskey-project
* SPDX-License-Identifier: AGPL-3.0-only
*/
describe('Router transition', () => {
describe('Redirect', () => {
// サーバの初期化。ルートのテストに関しては各describeごとに1度だけ実行で十分だと思う使いまわした方が早い
before(() => {
cy.resetState();
// インスタンス初期セットアップ
cy.registerUser('admin', 'pass', true);
// ユーザー作成
cy.registerUser('alice', 'alice1234');
cy.login('alice', 'alice1234');
// アカウント初期設定ウィザード
// 表示に時間がかかるのでデフォルト秒数だとタイムアウトする
cy.get('[data-cy-user-setup] [data-cy-modal-window-close]', { timeout: 30000 }).click();
cy.wait(500);
cy.get('[data-cy-modal-dialog-ok]').click();
});
it('redirect to user profile', () => {
// テストのためだけに用意されたリダイレクト用ルートに飛ぶ
cy.visit('/redirect-test');
// プロフィールページのURLであることを確認する
cy.url().should('include', '/@alice')
});
});
});

View File

@@ -1,76 +0,0 @@
/*
* SPDX-FileCopyrightText: syuilo and misskey-project
* SPDX-License-Identifier: AGPL-3.0-only
*/
/* flaky
describe('After user signed in', () => {
beforeEach(() => {
cy.resetState();
cy.viewport('macbook-16');
// インスタンス初期セットアップ
cy.registerUser('admin', 'pass', true);
// ユーザー作成
cy.registerUser('alice', 'alice1234');
cy.login('alice', 'alice1234');
// アカウント初期設定ウィザード
cy.get('[data-cy-user-setup] [data-cy-modal-window-close]').click();
cy.get('[data-cy-modal-dialog-ok]').click();
});
afterEach(() => {
// テスト終了直前にページ遷移するようなテストケース(例えばアカウント作成)だと、たぶんCypressのバグでブラウザの内容が次のテストケースに引き継がれてしまう(例えばアカウントが作成し終わった段階からテストが始まる)。
// waitを入れることでそれを防止できる
cy.wait(1000);
});
it('widget edit toggle is visible', () => {
cy.get('[data-cy-widget-edit]').should('be.visible');
});
it('widget select should be visible in edit mode', () => {
cy.get('[data-cy-widget-edit]').click();
cy.get('[data-cy-widget-select]').should('be.visible');
});
it('first widget should be removed', () => {
cy.get('[data-cy-widget-edit]').click();
cy.get('[data-cy-customize-container]:first-child [data-cy-customize-container-remove]._button').click();
cy.get('[data-cy-customize-container]').should('have.length', 2);
});
function buildWidgetTest(widgetName) {
it(`${widgetName} widget should get added`, () => {
cy.get('[data-cy-widget-edit]').click();
cy.get('[data-cy-widget-select] select').select(widgetName, { force: true });
cy.get('[data-cy-bg]._modalBg[data-cy-transparent]').click({ multiple: true, force: true });
cy.get('[data-cy-widget-add]').click({ force: true });
cy.get(`[data-cy-mkw-${widgetName}]`).should('exist');
});
}
buildWidgetTest('memo');
buildWidgetTest('notifications');
buildWidgetTest('timeline');
buildWidgetTest('calendar');
buildWidgetTest('rss');
buildWidgetTest('trends');
buildWidgetTest('clock');
buildWidgetTest('activity');
buildWidgetTest('photos');
buildWidgetTest('digitalClock');
buildWidgetTest('federation');
buildWidgetTest('postForm');
buildWidgetTest('slideshow');
buildWidgetTest('serverMetric');
buildWidgetTest('onlineUsers');
buildWidgetTest('jobQueue');
buildWidgetTest('button');
buildWidgetTest('aiscript');
buildWidgetTest('aichan');
});
*/

View File

@@ -1,5 +0,0 @@
{
"name": "Using fixtures to represent data",
"email": "hello@cypress.io",
"body": "Fixtures are a great way to mock data for responses to routes"
}

View File

@@ -1,67 +0,0 @@
// ***********************************************
// This example commands.js shows you how to
// create various custom commands and overwrite
// existing commands.
//
// For more comprehensive examples of custom
// commands please read more here:
// https://on.cypress.io/custom-commands
// ***********************************************
//
//
// -- This is a parent command --
// Cypress.Commands.add('login', (email, password) => { ... })
//
//
// -- This is a child command --
// Cypress.Commands.add('drag', { prevSubject: 'element'}, (subject, options) => { ... })
//
//
// -- This is a dual command --
// Cypress.Commands.add('dismiss', { prevSubject: 'optional'}, (subject, options) => { ... })
//
//
// -- This will overwrite an existing command --
// Cypress.Commands.overwrite('visit', (originalFn, url, options) => { ... })
Cypress.Commands.add('visitHome', () => {
cy.visit('/');
cy.get('button', { timeout: 30000 }).should('be.visible');
})
Cypress.Commands.add('resetState', () => {
// iframe.contentWindow.indexedDB.deleteDatabase() がchromeのバグで使用できないため、indexedDBを無効化している。
// see https://github.com/misskey-dev/misskey/issues/13605#issuecomment-2053652123
/*
cy.window().then(win => {
win.indexedDB.deleteDatabase('keyval-store');
});
*/
cy.request('POST', '/api/reset-db', {}).as('reset');
cy.get('@reset').its('status').should('equal', 204);
cy.reload(true);
});
Cypress.Commands.add('registerUser', (username, password, isAdmin = false) => {
const route = isAdmin ? '/api/admin/accounts/create' : '/api/signup';
cy.request('POST', route, {
username: username,
password: password,
...(isAdmin ? { setupPassword: 'example_password_please_change_this_or_you_will_get_hacked' } : {}),
}).its('body').as(username);
});
Cypress.Commands.add('login', (username, password) => {
cy.visitHome();
cy.intercept('POST', '/api/signin-flow').as('signin');
cy.get('[data-cy-signin]').click();
cy.get('[data-cy-signin-page-input]').should('be.visible', { timeout: 1000 });
cy.get('[data-cy-signin-username] input').type(`${username}{enter}`);
cy.get('[data-cy-signin-page-password]').should('be.visible', { timeout: 10000 });
cy.get('[data-cy-signin-password] input').type(`${password}{enter}`);
cy.wait('@signin').as('signedIn');
});

View File

@@ -1,34 +0,0 @@
// ***********************************************************
// This example support/index.js is processed and
// loaded automatically before your test files.
//
// This is a great place to put global configuration and
// behavior that modifies Cypress.
//
// You can change the location of this file or turn off
// automatically serving support files with the
// 'supportFile' configuration option.
//
// You can read more here:
// https://on.cypress.io/configuration
// ***********************************************************
// Import commands.js using ES2015 syntax:
import './commands'
// Alternatively you can use CommonJS syntax:
// require('./commands')
Cypress.on('uncaught:exception', (err, runnable) => {
if ([
'The source image cannot be decoded',
// Chrome
'ResizeObserver loop limit exceeded',
// Firefox
'ResizeObserver loop completed with undelivered notifications',
].some(msg => err.message.includes(msg))) {
return false;
}
});

View File

@@ -1,19 +0,0 @@
declare global {
namespace Cypress {
interface Chainable {
login(username: string, password: string): Chainable<void>;
registerUser(
username: string,
password: string,
isAdmin?: boolean
): Chainable<void>;
resetState(): Chainable<void>;
visitHome(): Chainable<void>;
}
}
}
export {}

View File

@@ -1,8 +0,0 @@
{
"compilerOptions": {
"lib": ["dom"],
"target": "esnext",
"types": ["cypress", "node"]
},
"include": ["./**/*.ts"]
}

View File

@@ -584,7 +584,7 @@ showFixedPostForm: "Mostrar formulario de publicación sobre la línea de tiempo
showFixedPostFormInChannel: "Mostrar el formulario de publicación por encima de la cronología (Canales)"
withRepliesByDefaultForNewlyFollowed: "Incluir por defecto respuestas de usuarios recién seguidos en la línea de tiempo"
newNoteRecived: "Tienes una nota nueva"
newNote: "Nueva nota"
newNote: "Nuevas notas"
sounds: "Sonidos"
sound: "Sonidos"
notificationSoundSettings: "Configuración del sonido de las notificaciones"
@@ -1219,7 +1219,7 @@ keepScreenOn: "Mantener pantalla encendida"
verifiedLink: "Propiedad del enlace verificada"
notifyNotes: "Notificar nuevas notas"
unnotifyNotes: "Dejar de notificar nuevas notas"
notifyUsers: "Usuarios que han activado las notificaciones de publicaciones"
notifyUsers: "Cuentas con notificaciones activadas"
authentication: "Autenticación"
authenticationRequiredToContinue: "Por favor, autentifícate para continuar"
dateAndTime: "Fecha y hora"

View File

@@ -1358,6 +1358,7 @@ _chat:
invitations: "Undang"
history: "Riwayat obrolan"
noHistory: "Tidak ada riwayat"
join: "Gabung"
members: "Anggota"
home: "Beranda"
send: "Kirim"
@@ -2555,6 +2556,8 @@ _deck:
useSimpleUiForNonRootPages: "Gunakan antarmuka sederhana ke halaman yang dituju"
usedAsMinWidthWhenFlexible: "Lebar minimum akan digunakan untuk ini ketika opsi \"Atur-otomatis lebar\" dinyalakan"
flexible: "Atur-otomatis lebar"
_howToUse:
settings_title: "Pengaturan UI"
_columns:
main: "Utama"
widgets: "Widget"

1
locales/tl-PH.yml Normal file
View File

@@ -0,0 +1 @@
---

View File

@@ -1,6 +1,6 @@
{
"name": "misskey",
"version": "2026.7.0-alpha.2",
"version": "2026.7.0-alpha.5",
"codename": "nasubi",
"repository": {
"type": "git",
@@ -41,10 +41,8 @@
"dev": "node scripts/dev.mjs",
"check-dts": "node scripts/check-dts.mjs",
"lint": "pnpm --no-bail -r lint && pnpm check-dts",
"cy:open": "pnpm cypress open --config-file=cypress.config.ts",
"cy:run": "pnpm cypress run",
"e2e": "pnpm start-server-and-test start:test http://localhost:61812 cy:run",
"e2e-dev-container": "ncp ./.config/cypress-devcontainer.yml ./.config/test.yml && pnpm start-server-and-test start:test http://localhost:61812 cy:run",
"e2e": "pnpm start-server-and-test start:test http://localhost:61812 \"pnpm --filter frontend test:e2e\"",
"e2e-dev-container": "ncp ./.config/playwright-devcontainer.yml ./.config/test.yml && pnpm start-server-and-test start:test http://localhost:61812 \"pnpm --filter frontend test:e2e\"",
"backend-unit-test": "cd packages/backend && pnpm test",
"backend-unit-test-and-coverage": "cd packages/backend && pnpm test-and-coverage",
"test": "pnpm -r test",
@@ -68,7 +66,6 @@
"@typescript-eslint/parser": "8.62.0",
"@typescript/native-preview": "7.0.0-dev.20260426.1",
"cross-env": "10.1.0",
"cypress": "15.18.0",
"eslint": "9.39.4",
"globals": "17.7.0",
"ncp": "2.0.0",

View File

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

View File

@@ -3,6 +3,7 @@ import { version as summalyVersion } from '@misskey-dev/summaly';
import type { Plugin, ExternalOption } from 'rolldown';
import { execa, execaNode } from 'execa';
import type { ResultPromise } from 'execa';
import fkill from 'fkill';
import esmShim from '@rollup/plugin-esm-shim';
/**
@@ -10,6 +11,7 @@ import esmShim from '@rollup/plugin-esm-shim';
*/
function backendDevServerPlugin(): Plugin {
let backendProcess: ResultPromise | null = null;
let backendShutdownPromise: Promise<void> | null = null;
async function runBuildAssets() {
await execa('pnpm', ['run', 'build-assets'], {
@@ -20,12 +22,31 @@ function backendDevServerPlugin(): Plugin {
}
async function killBackendProcess() {
if (backendProcess) {
backendProcess.catch(() => {}); // backendProcess.kill()によって発生する例外を無視するためにcatch()を呼び出す
backendProcess.kill();
await new Promise(resolve => backendProcess!.on('exit', resolve));
backendProcess = null;
}
if (backendShutdownPromise) return backendShutdownPromise;
if (!backendProcess) return;
const processToKill = backendProcess;
backendProcess = null;
processToKill.catch(() => {}); // プロセスの終了によって発生する例外を無視するためにcatch()を呼び出す
backendShutdownPromise = (async () => {
if (process.platform === 'win32' && processToKill.pid != null) {
await fkill(processToKill.pid, {
force: true,
tree: true,
silent: true,
waitForExit: 5000,
});
} else {
processToKill.kill();
}
await processToKill.catch(() => {});
})().finally(() => {
backendShutdownPromise = null;
});
return backendShutdownPromise;
}
return {
@@ -49,6 +70,9 @@ function backendDevServerPlugin(): Plugin {
await runBuildAssets();
}
},
async closeWatcher() {
await killBackendProcess();
},
};
}
@@ -63,6 +87,7 @@ export default defineConfig((args) => {
'class-validator',
/^@sentry\/.*/,
/^@sentry-internal\/.*/,
/^@opentelemetry\/.*/,
'@nestjs/websockets/socket-module',
'@nestjs/microservices/microservices-module',
'@nestjs/microservices',

View File

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

View File

@@ -14,6 +14,7 @@ import type { Config } from '@/config.js';
import { showMachineInfo } from '@/misc/show-machine-info.js';
import { envOption } from '@/env.js';
import { initTelemetry } from '@/core/telemetry/telemetry-registry.js';
import { installTelemetrySignalHandlers } from '@/core/telemetry/telemetry-shutdown.js';
import { initExtraThreadPool, jobQueue, server } from './common.js';
const logger = new Logger('core', 'cyan');
@@ -67,7 +68,13 @@ export async function masterMain() {
initExtraThreadPool(config);
await initTelemetry(config);
try {
await initTelemetry(config);
} catch (e) {
bootLogger.error(e instanceof Error ? e : new Error(String(e)), null, true);
process.exit(1);
}
installTelemetrySignalHandlers();
bootLogger.info(
`mode: [disableClustering: ${envOption.disableClustering}, onlyServer: ${envOption.onlyServer}, onlyQueue: ${envOption.onlyQueue}]`,

View File

@@ -4,11 +4,16 @@
*/
import cluster from 'node:cluster';
import Logger from '@/logger.js';
import { envOption } from '@/env.js';
import { loadConfig } from '@/config.js';
import { initTelemetry } from '@/core/telemetry/telemetry-registry.js';
import { installTelemetrySignalHandlers } from '@/core/telemetry/telemetry-shutdown.js';
import { initExtraThreadPool, jobQueue, server } from './common.js';
const logger = new Logger('core', 'cyan');
const bootLogger = logger.createSubLogger('boot', 'magenta');
/**
* Init worker process
*/
@@ -17,7 +22,13 @@ export async function workerMain() {
initExtraThreadPool(config);
await initTelemetry(config);
try {
await initTelemetry(config);
} catch (e) {
bootLogger.error(e instanceof Error ? e : new Error(String(e)), null, true);
process.exit(1);
}
installTelemetrySignalHandlers();
if (envOption.onlyServer) {
await server();

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -11,6 +11,7 @@ const envOption = {
verbose: false,
withLogTime: false,
quiet: false,
enableCrossOriginIsolation: false,
};
for (const key of Object.keys(envOption) as (keyof typeof envOption)[]) {

View File

@@ -3,80 +3,59 @@
* SPDX-License-Identifier: AGPL-3.0-only
*/
import cluster from 'node:cluster';
import chalk from 'chalk';
import { default as convertColor } from 'color-convert';
import { format as dateFormat } from 'date-fns';
import { bindThis } from '@/decorators.js';
import { envOption } from './env.js';
import { logManager } from './logging/logging-runtime.js';
import type { LogLevel, LoggerContext } from './logging/types.js';
import type { Keyword } from 'color-convert';
type Context = {
name: string;
color?: Keyword;
};
type Level = 'error' | 'success' | 'warning' | 'debug' | 'info';
/**
* ロガー名の階層と従来の公開APIを提供する薄い窓口です。
* 出力条件の判断や整形はLogManagerとLogBackendへ委譲します。
*/
// eslint-disable-next-line import/no-default-export
export default class Logger {
private context: Context;
private parentLogger: Logger | null = null;
private context: readonly LoggerContext[];
/** 指定した名前を起点とするLoggerを作成します。 */
constructor(context: string, color?: Keyword) {
this.context = {
this.context = [{
name: context,
color: color,
};
color,
}];
}
/**
* 現在のロガーを親として、下位の名前を持つLoggerを作成します。
*/
@bindThis
public createSubLogger(context: string, color?: Keyword): Logger {
const logger = new Logger(context, color);
logger.parentLogger = this;
logger.context = [...this.context, ...logger.context];
return logger;
}
/**
* 従来APIの引数を共通形式へ変換し、LogManagerへ渡します。
*/
@bindThis
private log(level: Level, message: string, data?: Record<string, any> | null, important = false, subContexts: Context[] = []): void {
if (envOption.quiet) return;
if (this.parentLogger) {
this.parentLogger.log(level, message, data, important, [this.context].concat(subContexts));
return;
}
const time = dateFormat(new Date(), 'HH:mm:ss');
const worker = cluster.isPrimary ? '*' : cluster.worker!.id;
const l =
level === 'error' ? important ? chalk.bgRed.white('ERR ') : chalk.red('ERR ') :
level === 'warning' ? chalk.yellow('WARN') :
level === 'success' ? important ? chalk.bgGreen.white('DONE') : chalk.green('DONE') :
level === 'debug' ? chalk.gray('VERB') :
level === 'info' ? chalk.blue('INFO') :
null;
const contexts = [this.context].concat(subContexts).map(d => d.color ? chalk.rgb(...convertColor.keyword.rgb(d.color))(d.name) : chalk.white(d.name));
const m =
level === 'error' ? chalk.red(message) :
level === 'warning' ? chalk.yellow(message) :
level === 'success' ? chalk.green(message) :
level === 'debug' ? chalk.gray(message) :
level === 'info' ? message :
null;
let log = `${l} ${worker}\t[${contexts.join(' ')}]\t${m}`;
if (envOption.withLogTime) log = chalk.gray(time) + ' ' + log;
const args: unknown[] = [important ? chalk.bold(log) : log];
if (data != null) {
args.push(data);
}
console.log(...args);
private log(level: LogLevel, message: string, data?: Record<string, any> | null, important = false, legacyLevel?: 'success'): void {
logManager.write({
level,
message,
context: this.context,
compatibility: {
legacyLevel,
important,
data,
},
});
}
/** 処理を継続できない状況を記録します。 */
@bindThis
public error(x: string | Error, data?: Record<string, any> | null, important = false): void { // 実行を継続できない状況で使う
public error(x: string | Error, data?: Record<string, any> | null, important = false): void {
if (x instanceof Error) {
// Error本体も第2引数へ残し、従来どおりスタックなどを確認できるようにします。
data = data ?? {};
data.e = x;
this.log('error', x.toString(), data, important);
@@ -87,25 +66,27 @@ export default class Logger {
}
}
/** 処理は継続できるものの、改善が必要な状況を記録します。 */
@bindThis
public warn(message: string, data?: Record<string, any> | null, important = false): void { // 実行を継続できるが改善すべき状況で使う
this.log('warning', message, data, important);
public warn(message: string, data?: Record<string, any> | null, important = false): void {
this.log('warn', message, data, important);
}
/** 処理が成功したことを、従来のDONE表示で記録します。 */
@bindThis
public succ(message: string, data?: Record<string, any> | null, important = false): void { // 何かに成功した状況で使う
this.log('success', message, data, important);
public succ(message: string, data?: Record<string, any> | null, important = false): void {
this.log('info', message, data, important, 'success');
}
/** 開発者向けの調査情報を記録します。 */
@bindThis
public debug(message: string, data?: Record<string, any> | null, important = false): void { // デバッグ用に使う(開発者に必要だが利用者に不要な情報)
if (process.env.NODE_ENV !== 'production' || envOption.verbose) {
this.log('debug', message, data, important);
}
public debug(message: string, data?: Record<string, any> | null, important = false): void {
this.log('debug', message, data, important);
}
/** 通常の動作状況を記録します。 */
@bindThis
public info(message: string, data?: Record<string, any> | null, important = false): void { // それ以外
public info(message: string, data?: Record<string, any> | null, important = false): void {
this.log('info', message, data, important);
}
}

View File

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

View File

@@ -0,0 +1,94 @@
/*
* SPDX-FileCopyrightText: syuilo and misskey-project
* SPDX-License-Identifier: AGPL-3.0-only
*/
import cluster from 'node:cluster';
import process from 'node:process';
import { envOption } from '@/env.js';
import type { LogBackend } from './LogBackend.js';
import type { LogRecordInput } from './types.js';
/** ログを出力したプロセスを識別するための情報です。 */
export type LogProcessInfo = {
readonly processId: number;
readonly isPrimary: boolean;
readonly workerId: number | null;
};
/**
* 実行環境から取得する値をまとめた依存関係です。
* テストでは固定値へ差し替え、時刻やプロセス状態に左右されないようにします。
*/
export type LogManagerDependencies = {
readonly now: () => Date;
readonly getProcessInfo: () => LogProcessInfo;
readonly isQuiet: () => boolean;
readonly isVerbose: () => boolean;
readonly getNodeEnv: () => string | undefined;
};
const defaultDependencies: LogManagerDependencies = {
now: () => new Date(),
getProcessInfo: () => ({
processId: process.pid,
isPrimary: cluster.isPrimary,
workerId: cluster.isPrimary ? null : (cluster.worker?.id ?? null),
}),
isQuiet: () => envOption.quiet,
isVerbose: () => envOption.verbose,
getNodeEnv: () => process.env.NODE_ENV,
};
/**
* ログの出力可否を判断し、すべての出力先で共通となる情報を付加します。
* Loggerと出力先の間に置くことで、設定や共通情報の扱いを一か所へ集約します。
*/
export class LogManager {
private backend: LogBackend;
private readonly dependencies: LogManagerDependencies;
/**
* 出力先と実行環境から値を取得する処理を受け取ります。
* 実行環境の取得処理は、必要な項目だけテスト用に差し替えられます。
*/
constructor(backend: LogBackend, dependencies: Partial<LogManagerDependencies> = {}) {
this.backend = backend;
this.dependencies = {
...defaultDependencies,
...dependencies,
};
}
/**
* 以後のログを書き込む出力先を切り替えます。
* 作成済みのLoggerにも切り替えを反映するため、LogManager側で保持します。
*/
public setBackend(backend: LogBackend): void {
this.backend = backend;
}
/**
* 出力条件を確認し、共通情報を付加して出力先へ渡します。
*/
public write(input: LogRecordInput): void {
// `quiet`は他の条件より優先し、ログに付随する情報の取得も行いません。
if (this.dependencies.isQuiet()) return;
// 本番環境のデバッグログは、明示的に`verbose`が指定された場合だけ出力します。
if (input.level === 'debug' && this.dependencies.getNodeEnv() === 'production' && !this.dependencies.isVerbose()) return;
const processInfo = this.dependencies.getProcessInfo();
// 呼び出し側の配列を共有せず、親から末端までの順序を固定します。
const context = [...input.context];
this.backend.write({
...input,
context,
timestamp: this.dependencies.now().toISOString(),
loggerName: context.map(segment => segment.name).join('.'),
processId: processInfo.processId,
isPrimary: processInfo.isPrimary,
workerId: processInfo.workerId,
});
}
}

View File

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

View File

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

View File

@@ -0,0 +1,50 @@
/*
* SPDX-FileCopyrightText: syuilo and misskey-project
* SPDX-License-Identifier: AGPL-3.0-only
*/
import type { Keyword } from 'color-convert';
/** ログの重要度を表します。 */
export type LogLevel = 'debug' | 'info' | 'warn' | 'error' | 'fatal';
/**
* ロガー名を構成する一要素です。
* 色はPretty形式での表示だけに使い、ログの意味には影響させません。
*/
export type LoggerContext = {
readonly name: string;
readonly color?: Keyword;
};
/**
* 従来のコンソール表示を維持するための情報です。
* 構造化ログの項目と混同しないよう、互換用の領域へ分離しています。
*/
export type LogCompatibility = {
readonly legacyLevel?: 'success';
readonly important?: boolean;
readonly data?: Record<string, unknown> | null;
};
/**
* 呼び出し側からLogManagerへ渡す、時刻などを付加する前のログです。
*/
export type LogRecordInput = {
readonly level: LogLevel;
readonly message: string;
readonly context: readonly LoggerContext[];
readonly compatibility?: LogCompatibility;
};
/**
* 出力先へ渡すログです。
* LogManagerが時刻やプロセス情報を付加するため、出力形式に依存せず利用できます。
*/
export type LogRecord = LogRecordInput & {
readonly timestamp: string;
readonly loggerName: string;
readonly processId: number;
readonly isPrimary: boolean;
readonly workerId: number | null;
};

View File

@@ -204,14 +204,13 @@ export class RedisSingleCache<T> {
}
}
// TODO: メモリ節約のためあまり参照されないキーを定期的に削除できるようにする?
export class MemoryKVCache<T> {
private readonly cache = new Map<string, { date: number; value: T; }>();
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<T> {
* @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<T> {
this.cache.delete(key);
return undefined;
}
if (this.limit !== Infinity) {
// access 順を更新して LRU を保つ
this.cache.delete(key);
this.cache.set(key, cached);
}
return cached.value;
}

View File

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

View File

@@ -21,6 +21,7 @@ import { genIdenticon } from '@/misc/gen-identicon.js';
import { UserEntityService } from '@/core/entities/UserEntityService.js';
import { LoggerService } from '@/core/LoggerService.js';
import { bindThis } from '@/decorators.js';
import { envOption } from '@/env.js';
import { ActivityPubServerService } from './ActivityPubServerService.js';
import { NodeinfoServerService } from './NodeinfoServerService.js';
import { ApiServerService } from './api/ApiServerService.js';
@@ -31,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));
@@ -79,6 +81,7 @@ export class ServerService implements OnApplicationShutdown {
logger: false,
});
this.#fastify = fastify;
await registerHttpServerInstrumentation(fastify, this.config);
// HSTS
// 6months (15552000sec)
@@ -89,6 +92,15 @@ export class ServerService implements OnApplicationShutdown {
});
}
// for test
if (envOption.enableCrossOriginIsolation) {
fastify.addHook('onRequest', (request, reply, done) => {
reply.header('Cross-Origin-Opener-Policy', 'same-origin');
reply.header('Cross-Origin-Embedder-Policy', 'credentialless');
done();
});
}
// Register raw-body parser for ActivityPub HTTP signature validation.
await fastify.register(fastifyRawBody, {
global: false,

View File

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

View File

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

View File

@@ -3,13 +3,15 @@
* 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 { deepClone } from '@/misc/clone.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 +20,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<SummalyResult>;
private readonly summalyDefaultUserAgent: string;
constructor(
@@ -34,6 +37,7 @@ export class UrlPreviewService {
private loggerService: LoggerService,
) {
this.logger = this.loggerService.getLogger('url-preview');
this.summaryCache = new MemoryKVCache<SummalyResult>(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 +84,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 = deepClone(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 +136,8 @@ export class UrlPreviewService {
}
}
private async fetchSummary(url: string, meta: MiMeta, lang?: string): Promise<SummalyResult> {
@bindThis
private async fetchSummary(url: string, lang?: string): Promise<SummalyResult> {
const { summaly } = await import('@misskey-dev/summaly');
return summaly(url, {
@@ -130,25 +147,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<SummalyResult> {
const proxy = meta.urlPreviewSummaryProxyUrl!;
@bindThis
private fetchSummaryFromProxy(url: string, lang?: string): Promise<SummalyResult> {
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<SummalyResult>(`${proxy}?${queryStr}`, 'application/json, */*', undefined, true);
}
@bindThis
public dispose(): void {
this.summaryCache.dispose();
}
@bindThis
public onApplicationShutdown(): void {
this.dispose();
}
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,125 @@
/*
* SPDX-FileCopyrightText: syuilo and misskey-project
* SPDX-License-Identifier: AGPL-3.0-only
*/
import { describe, expect, test, vi } from 'vitest';
import { LogManager } from '@/logging/LogManager.js';
import type { LogBackend } from '@/logging/LogBackend.js';
import type { LogRecordInput } from '@/logging/types.js';
/** テストで使う最小構成のログ入力を作成します。 */
function createInput(level: LogRecordInput['level'] = 'info'): LogRecordInput {
return {
level,
message: 'message',
context: [
{ name: 'queue', color: 'red' },
{ name: 'deliver', color: 'blue' },
],
};
}
/** 実行環境を固定したLogManagerと、出力確認用の関数を作成します。 */
function createManager(options: {
quiet?: boolean;
verbose?: boolean;
nodeEnv?: string;
isPrimary?: boolean;
workerId?: number | null;
} = {}) {
const write = vi.fn<LogBackend['write']>();
const manager = new LogManager({ write }, {
now: () => new Date('2025-01-02T03:04:05.678Z'),
getProcessInfo: () => ({
processId: 1234,
isPrimary: options.isPrimary ?? true,
workerId: options.workerId ?? null,
}),
isQuiet: () => options.quiet ?? false,
isVerbose: () => options.verbose ?? false,
getNodeEnv: () => options.nodeEnv ?? 'development',
});
return { manager, write };
}
describe('LogManager', () => {
test('adds logger and process metadata while preserving root-to-leaf context order', () => {
const { manager, write } = createManager();
const input = createInput();
manager.write(input);
expect(write).toHaveBeenCalledOnce();
expect(write).toHaveBeenCalledWith({
...input,
context: [
{ name: 'queue', color: 'red' },
{ name: 'deliver', color: 'blue' },
],
timestamp: '2025-01-02T03:04:05.678Z',
loggerName: 'queue.deliver',
processId: 1234,
isPrimary: true,
workerId: null,
});
expect(write.mock.calls[0][0].context).not.toBe(input.context);
});
test('records worker process metadata', () => {
const { manager, write } = createManager({ isPrimary: false, workerId: 7 });
manager.write(createInput());
expect(write.mock.calls[0][0]).toMatchObject({
processId: 1234,
isPrimary: false,
workerId: 7,
});
});
test('does not call the backend in quiet mode', () => {
const { manager, write } = createManager({ quiet: true, verbose: true });
manager.write(createInput('fatal'));
manager.write(createInput('debug'));
expect(write).not.toHaveBeenCalled();
});
test('writes debug logs outside production', () => {
const { manager, write } = createManager({ nodeEnv: 'development' });
manager.write(createInput('debug'));
expect(write).toHaveBeenCalledOnce();
});
test('suppresses debug logs in production by default', () => {
const { manager, write } = createManager({ nodeEnv: 'production' });
manager.write(createInput('debug'));
expect(write).not.toHaveBeenCalled();
});
test('writes debug logs in verbose production mode', () => {
const { manager, write } = createManager({ nodeEnv: 'production', verbose: true });
manager.write(createInput('debug'));
expect(write).toHaveBeenCalledOnce();
});
test('uses a replaced backend for subsequent records', () => {
const { manager, write } = createManager();
const replacementWrite = vi.fn<LogBackend['write']>();
manager.setBackend({ write: replacementWrite });
manager.write(createInput());
expect(write).not.toHaveBeenCalled();
expect(replacementWrite).toHaveBeenCalledOnce();
});
});

View File

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

View File

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

View File

@@ -38,6 +38,29 @@ describe('misc:MemoryKVCache', () => {
cache.dispose();
});
test('keeps current behavior when limit is omitted', () => {
const cache = new MemoryKVCache<number>(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<number>(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<string>(1000);

View File

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

View File

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

View File

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

View File

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

View File

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

Binary file not shown.

Before

Width:  |  Height:  |  Size: 36 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 25 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 18 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 34 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 39 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 30 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 43 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 31 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 31 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 30 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 40 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 27 KiB

View File

@@ -61,7 +61,6 @@ it('Composition API (standard)', () => {
import { c as api, d as store, i as i18n, aD as notePage, bN as ImgWithBlurhash, bY as getStaticImageUrl, _ as _export_sfc } from './app-!~{001}~.js';
import { M as MkContainer } from './MkContainer-!~{03M}~.js';
import { b as defineComponent, a as ref, e as onMounted, z as resolveComponent, g as openBlock, h as createBlock, i as withCtx, K as createTextVNode, E as toDisplayString, u as unref, l as createBaseVNode, q as normalizeClass, B as createCommentVNode, k as createElementBlock, F as Fragment, C as renderList, A as createVNode } from './vue-!~{002}~.js';
import './photoswipe-!~{003}~.js';
const _hoisted_1 = /* @__PURE__ */ createBaseVNode("i", { class: "ti ti-photo" }, null, -1);
const _sfc_main = /* @__PURE__ */ defineComponent({
@@ -179,7 +178,6 @@ export { index_photos as default };
import { c as api, d as store, i as i18n, aD as notePage, bN as ImgWithBlurhash, bY as getStaticImageUrl, _ as _export_sfc } from './app-!~{001}~.js';
import { M as MkContainer } from './MkContainer-!~{03M}~.js';
import { b as defineComponent, a as ref, e as onMounted, z as resolveComponent, g as openBlock, h as createBlock, i as withCtx, K as createTextVNode, E as toDisplayString, u as unref, l as createBaseVNode, q as normalizeClass, B as createCommentVNode, k as createElementBlock, F as Fragment, C as renderList, A as createVNode } from './vue-!~{002}~.js';
import './photoswipe-!~{003}~.js';
const _hoisted_1 = /* @__PURE__ */ createBaseVNode("i", { class: "ti ti-photo" }, null, -1);
const index_photos = /* @__PURE__ */ defineComponent({

View File

@@ -9,8 +9,10 @@
"build-storybook-pre": "(tsgo -p .storybook || echo done.) && node .storybook/generate.js && node .storybook/preload-locale.js && node .storybook/preload-theme.js",
"build-storybook": "pnpm build-storybook-pre && storybook build --webpack-stats-json storybook-static",
"chromatic": "chromatic",
"test": "vitest --run --globals",
"test-and-coverage": "vitest --run --coverage --globals",
"test": "vitest --run --globals --config vitest.config.unit.ts",
"test-and-coverage": "vitest --run --coverage --globals --config vitest.config.unit.ts",
"test:e2e": "playwright test --config playwright.config.ts",
"test:browser": "vitest --run --globals --config vitest.config.browser.ts",
"typecheck": "vue-tsc --noEmit",
"eslint": "eslint --quiet \"src/**/*.{ts,vue}\"",
"lint": "pnpm typecheck && pnpm eslint"
@@ -54,7 +56,6 @@
"misskey-bubble-game": "workspace:*",
"misskey-js": "workspace:*",
"misskey-reversi": "workspace:*",
"photoswipe": "5.4.4",
"punycode.js": "2.3.1",
"qr-code-styling": "1.9.2",
"qr-scanner": "1.4.2",
@@ -71,6 +72,7 @@
"devDependencies": {
"@misskey-dev/emoji-assets": "17.0.3",
"@misskey-dev/summaly": "5.5.1",
"@playwright/test": "1.61.1",
"@rollup/plugin-json": "6.1.0",
"@rollup/pluginutils": "5.4.0",
"@storybook/addon-essentials": "8.6.18",
@@ -104,6 +106,7 @@
"@types/tinycolor2": "1.4.6",
"@typescript-eslint/eslint-plugin": "8.62.0",
"@typescript-eslint/parser": "8.62.0",
"@vitest/browser-playwright": "4.1.9",
"@vitest/coverage-v8": "4.1.9",
"@vue/compiler-core": "3.5.39",
"astring": "1.9.0",
@@ -120,6 +123,7 @@
"msw-storybook-addon": "2.0.7",
"nodemon": "3.1.14",
"oxc-walker": "1.0.0",
"playwright": "1.61.1",
"prettier": "3.9.1",
"react": "19.2.7",
"react-dom": "19.2.7",

View File

@@ -0,0 +1,32 @@
/*
* SPDX-FileCopyrightText: syuilo and misskey-project
* SPDX-License-Identifier: AGPL-3.0-only
*/
import { defineConfig } from '@playwright/test';
export default defineConfig({
testDir: './test/e2e',
reporter: 'list',
fullyParallel: false,
workers: 1,
timeout: 60_000,
expect: {
timeout: 10_000,
},
outputDir: './test/e2e/artifacts',
use: {
locale: 'en-US',
baseURL: 'http://localhost:61812',
headless: true,
screenshot: 'only-on-failure',
trace: 'retain-on-failure',
video: 'off',
},
projects: [{
name: 'chromium',
use: {
browserName: 'chromium',
},
}],
});

View File

@@ -0,0 +1,183 @@
<!--
SPDX-FileCopyrightText: syuilo and misskey-project
SPDX-License-Identifier: AGPL-3.0-only
-->
<template>
<canvas
v-show="show"
ref="canvas"
:width="canvasWidth"
:height="canvasHeight"
draggable="false"
tabindex="-1"
style="-webkit-user-drag: none;"
></canvas>
</template>
<script lang="ts">
import DrawBlurhash from '@/workers/draw-blurhash?worker';
import TestWebGL2 from '@/workers/test-webgl2?worker';
import { WorkerMultiDispatch } from '@@/js/worker-multi-dispatch.js';
// テスト環境で Web Worker インスタンスは作成できない
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-expect-error
const isTest = (import.meta.env.MODE === 'test' || window.isPlaywright);
const canvasPromise = new Promise<WorkerMultiDispatch | HTMLCanvasElement>(resolve => {
if (isTest) {
const canvas = window.document.createElement('canvas');
canvas.width = 64;
canvas.height = 64;
resolve(canvas);
return;
}
const testWorker = new TestWebGL2();
testWorker.addEventListener('message', event => {
if (event.data.result) {
const workers = new WorkerMultiDispatch(
() => new DrawBlurhash(),
Math.min(navigator.hardwareConcurrency - 1, 4),
);
resolve(workers);
} else {
const canvas = window.document.createElement('canvas');
canvas.width = 64;
canvas.height = 64;
resolve(canvas);
}
testWorker.terminate();
});
});
</script>
<script lang="ts" setup>
import { watch, ref, shallowRef, useTemplateRef, onMounted, onUnmounted } from 'vue';
import { genId } from '@/utility/id.js';
import { extractAvgColorFromBlurhash } from '@@/js/extract-avg-color-from-blurhash.js';
const props = withDefaults(defineProps<{
blurhash: string | null;
onlyAvgColor?: boolean;
width?: number;
height?: number;
// v-showが何故か動作しないため
show?: boolean;
}>(), {
onlyAvgColor: false,
width: 64,
height: 64,
show: true,
});
const canvas = useTemplateRef('canvas');
const canvasWidth = ref(64);
const canvasHeight = ref(64);
const viewId = genId();
const bitmapTmp = shallowRef<CanvasImageSource | undefined>();
watch([() => props.width, () => props.height, canvas], () => {
const ratio = props.width / props.height;
if (ratio > 1) {
canvasWidth.value = Math.round(64 * ratio);
canvasHeight.value = 64;
} else {
canvasWidth.value = 64;
canvasHeight.value = Math.round(64 / ratio);
}
}, {
immediate: true,
});
watch(() => props.blurhash, () => {
draw();
});
function drawImage(bitmap: CanvasImageSource) {
// canvasがないmountedされていない場合はTmpに保存しておく
if (!canvas.value) {
bitmapTmp.value = bitmap;
return;
}
// canvasがあれば描画する
bitmapTmp.value = undefined;
const ctx = canvas.value.getContext('2d');
if (!ctx) return;
ctx.drawImage(bitmap, 0, 0, canvasWidth.value, canvasHeight.value);
}
function drawAvg() {
if (!canvas.value) return;
const color = (props.blurhash != null && extractAvgColorFromBlurhash(props.blurhash)) || '#888';
const ctx = canvas.value.getContext('2d');
if (!ctx) return;
// avgColorでお茶をにごす
ctx.beginPath();
ctx.fillStyle = color;
ctx.fillRect(0, 0, canvasWidth.value, canvasHeight.value);
}
async function draw() {
if (isTest && props.blurhash == null) return;
drawAvg();
if (props.blurhash == null) return;
if (props.onlyAvgColor) return;
const work = await canvasPromise;
if (work instanceof WorkerMultiDispatch) {
work.postMessage(
{
id: viewId,
hash: props.blurhash,
},
undefined,
);
} else {
try {
const { render } = await import('buraha');
render(props.blurhash, work);
drawImage(work);
} catch (error) {
console.error('Error occurred during drawing blurhash', error);
}
}
}
function workerOnMessage(event: MessageEvent) {
if (event.data.id !== viewId) return;
drawImage(event.data.bitmap as ImageBitmap);
}
canvasPromise.then(work => {
if (work instanceof WorkerMultiDispatch) {
work.addListener(workerOnMessage);
}
draw();
});
onMounted(() => {
// drawImageがmountedより先に呼ばれている場合はここで描画する
if (bitmapTmp.value) {
drawImage(bitmapTmp.value);
}
});
onUnmounted(() => {
canvasPromise.then(work => {
if (work instanceof WorkerMultiDispatch) {
work.removeListener(workerOnMessage);
}
});
});
</script>

View File

@@ -21,8 +21,8 @@ SPDX-License-Identifier: AGPL-3.0-only
</div>
<div v-else>
<div style="font-size: 13px; margin-bottom: 4px;">Type "ai-chan-kawaii" to pass captcha</div>
<input v-model="testcaptchaInput" data-cy-testcaptcha-input/>
<button type="button" data-cy-testcaptcha-submit @click="testcaptchaSubmit">Submit</button>
<input v-model="testcaptchaInput" data-testid="testcaptcha-input"/>
<button type="button" data-testid="testcaptcha-submit" @click="testcaptchaSubmit">Submit</button>
</div>
</div>
<div v-else ref="captchaEl"></div>

View File

@@ -31,8 +31,8 @@ SPDX-License-Identifier: AGPL-3.0-only
</MkInput>
<MkSelect v-if="select" v-model="selectedValue" :items="selectDef" autofocus></MkSelect>
<div v-if="(showOkButton || showCancelButton) && !actions" :class="$style.buttons">
<MkButton v-if="showOkButton" data-cy-modal-dialog-ok inline primary rounded :autofocus="!input && !select" :disabled="okButtonDisabledReason != null" @click="ok">{{ okText ?? ((showCancelButton || input || select) ? i18n.ts.ok : i18n.ts.gotIt) }}</MkButton>
<MkButton v-if="showCancelButton || input || select" data-cy-modal-dialog-cancel inline rounded @click="cancel">{{ cancelText ?? i18n.ts.cancel }}</MkButton>
<MkButton v-if="showOkButton" data-testid="modal-dialog-ok" inline primary rounded :autofocus="!input && !select" :disabled="okButtonDisabledReason != null" @click="ok">{{ okText ?? ((showCancelButton || input || select) ? i18n.ts.ok : i18n.ts.gotIt) }}</MkButton>
<MkButton v-if="showCancelButton || input || select" data-testid="modal-dialog-cancel" inline rounded @click="cancel">{{ cancelText ?? i18n.ts.cancel }}</MkButton>
</div>
<div v-if="actions" :class="$style.buttons">
<MkButton v-for="action in actions" :key="action.text" inline rounded :primary="action.primary" :danger="action.danger" @click="() => { action.callback(); modal?.close(); }">{{ action.text }}</MkButton>

View File

@@ -7,7 +7,7 @@ SPDX-License-Identifier: AGPL-3.0-only
<div ref="rootEl" :class="$style.root" role="group" :aria-expanded="opened">
<MkStickyContainer>
<template #header>
<button :class="[$style.header, { [$style.opened]: opened }]" class="_button" role="button" data-cy-folder-header @click="toggle">
<button :class="[$style.header, { [$style.opened]: opened }]" class="_button" role="button" data-testid="folder-header" @click="toggle">
<div :class="$style.headerIcon"><slot name="icon"></slot></div>
<div :class="$style.headerText">
<div :class="$style.headerTextMain">

View File

@@ -14,18 +14,15 @@ SPDX-License-Identifier: AGPL-3.0-only
:enterToClass="prefer.s.animation && props.transition?.enterToClass || undefined"
:leaveFromClass="prefer.s.animation && props.transition?.leaveFromClass || undefined"
>
<canvas
v-show="hide"
<MkBlurhash
key="canvas"
ref="canvas"
:class="$style.canvas"
:width="canvasWidth"
:height="canvasHeight"
:title="title ?? undefined"
draggable="false"
tabindex="-1"
style="-webkit-user-drag: none;"
></canvas>
:blurhash="hash ?? null"
:height="imgHeight ?? undefined"
:width="imgWidth ?? undefined"
:onlyAvgColor="props.onlyAvgColor"
:show="hide"
/>
<img
v-show="!hide"
key="img"
@@ -36,6 +33,7 @@ SPDX-License-Identifier: AGPL-3.0-only
:src="src ?? undefined"
:title="title ?? undefined"
:alt="alt ?? undefined"
:data-marker="marker ?? undefined"
loading="eager"
decoding="async"
draggable="false"
@@ -46,50 +44,10 @@ SPDX-License-Identifier: AGPL-3.0-only
</div>
</template>
<script lang="ts">
import DrawBlurhash from '@/workers/draw-blurhash?worker';
import TestWebGL2 from '@/workers/test-webgl2?worker';
import { WorkerMultiDispatch } from '@@/js/worker-multi-dispatch.js';
import { extractAvgColorFromBlurhash } from '@@/js/extract-avg-color-from-blurhash.js';
// テスト環境で Web Worker インスタンスは作成できない
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-expect-error
const isTest = (import.meta.env.MODE === 'test' || window.Cypress != null);
const canvasPromise = new Promise<WorkerMultiDispatch | HTMLCanvasElement>(resolve => {
if (isTest) {
const canvas = window.document.createElement('canvas');
canvas.width = 64;
canvas.height = 64;
resolve(canvas);
return;
}
const testWorker = new TestWebGL2();
testWorker.addEventListener('message', event => {
if (event.data.result) {
const workers = new WorkerMultiDispatch(
() => new DrawBlurhash(),
Math.min(navigator.hardwareConcurrency - 1, 4),
);
resolve(workers);
} else {
const canvas = window.document.createElement('canvas');
canvas.width = 64;
canvas.height = 64;
resolve(canvas);
}
testWorker.terminate();
});
});
</script>
<script lang="ts" setup>
import { computed, nextTick, onMounted, onUnmounted, useTemplateRef, watch, ref } from 'vue';
import { genId } from '@/utility/id.js';
import { render } from 'buraha';
import { computed, nextTick, useTemplateRef, watch, ref } from 'vue';
import { prefer } from '@/preferences.js';
import MkBlurhash from '@/components/MkBlurhash.vue';
const props = withDefaults(defineProps<{
transition?: {
@@ -110,6 +68,7 @@ const props = withDefaults(defineProps<{
cover?: boolean;
forceBlurhash?: boolean;
onlyAvgColor?: boolean; // 軽量化のためにBlurhashを使わずに平均色だけを描画
marker?: string;
}>(), {
transition: null,
src: null,
@@ -122,16 +81,11 @@ const props = withDefaults(defineProps<{
onlyAvgColor: false,
});
const viewId = genId();
const canvas = useTemplateRef('canvas');
const root = useTemplateRef('root');
const img = useTemplateRef('img');
const loaded = ref(false);
const canvasWidth = ref(64);
const canvasHeight = ref(64);
const imgWidth = ref(props.width);
const imgHeight = ref(props.height);
const bitmapTmp = ref<CanvasImageSource | undefined>();
const hide = computed(() => !loaded.value || props.forceBlurhash);
function waitForDecode() {
@@ -150,14 +104,6 @@ function waitForDecode() {
watch([() => props.width, () => props.height, root], () => {
const ratio = props.width / props.height;
if (ratio > 1) {
canvasWidth.value = Math.round(64 * ratio);
canvasHeight.value = 64;
} else {
canvasWidth.value = 64;
canvasHeight.value = Math.round(64 / ratio);
}
const clientWidth = root.value?.clientWidth ?? 300;
imgWidth.value = clientWidth;
imgHeight.value = Math.round(clientWidth / ratio);
@@ -165,97 +111,10 @@ watch([() => props.width, () => props.height, root], () => {
immediate: true,
});
function drawImage(bitmap: CanvasImageSource) {
// canvasがないmountedされていない場合はTmpに保存しておく
if (!canvas.value) {
bitmapTmp.value = bitmap;
return;
}
// canvasがあれば描画する
bitmapTmp.value = undefined;
const ctx = canvas.value.getContext('2d');
if (!ctx) return;
ctx.drawImage(bitmap, 0, 0, canvasWidth.value, canvasHeight.value);
}
function drawAvg() {
if (!canvas.value) return;
const color = (props.hash != null && extractAvgColorFromBlurhash(props.hash)) || '#888';
const ctx = canvas.value.getContext('2d');
if (!ctx) return;
// avgColorでお茶をにごす
ctx.beginPath();
ctx.fillStyle = color;
ctx.fillRect(0, 0, canvasWidth.value, canvasHeight.value);
}
async function draw() {
if (isTest && props.hash == null) return;
drawAvg();
if (props.hash == null) return;
if (props.onlyAvgColor) return;
const work = await canvasPromise;
if (work instanceof WorkerMultiDispatch) {
work.postMessage(
{
id: viewId,
hash: props.hash,
},
undefined,
);
} else {
try {
render(props.hash, work);
drawImage(work);
} catch (error) {
console.error('Error occurred during drawing blurhash', error);
}
}
}
function workerOnMessage(event: MessageEvent) {
if (event.data.id !== viewId) return;
drawImage(event.data.bitmap as ImageBitmap);
}
canvasPromise.then(work => {
if (work instanceof WorkerMultiDispatch) {
work.addListener(workerOnMessage);
}
draw();
});
watch(() => props.src, () => {
waitForDecode();
});
watch(() => props.hash, () => {
draw();
});
onMounted(() => {
// drawImageがmountedより先に呼ばれている場合はここで描画する
if (bitmapTmp.value) {
drawImage(bitmapTmp.value);
}
waitForDecode();
});
onUnmounted(() => {
canvasPromise.then(work => {
if (work instanceof WorkerMultiDispatch) {
work.removeListener(workerOnMessage);
}
});
}, {
immediate: true,
});
</script>

View File

@@ -0,0 +1,39 @@
<!--
SPDX-FileCopyrightText: syuilo and misskey-project
SPDX-License-Identifier: AGPL-3.0-only
-->
<template>
<div :class="$style.root" class="_gaps_s">
<MkKeyValue v-if="content.filename != null">
<template #key>{{ i18n.ts.fileName }}</template>
<template #value>{{ content.filename }}</template>
</MkKeyValue>
<MkKeyValue v-if="content.file != null">
<template #key>{{ i18n.ts.description }}</template>
<template #value>
<div :class="$style.pre">{{ content.file.comment ? content.file.comment : `(${i18n.ts.none})` }}</div>
</template>
</MkKeyValue>
</div>
</template>
<script lang="ts" setup>
import MkKeyValue from '@/components/MkKeyValue.vue';
import { i18n } from '@/i18n.js';
import type { Content } from '@/components/MkLightbox.item.vue';
const props = defineProps<{
content: Content;
}>();
</script>
<style lang="scss" module>
.root {
padding: 4px 14px;
}
.pre {
white-space: pre-wrap;
}
</style>

File diff suppressed because it is too large Load Diff

Some files were not shown because too many files have changed in this diff Show More