Compare commits

...

28 Commits

Author SHA1 Message Date
syuilo a98ac7216d New translations ja-jp.yml (Persian)
[ci skip]
2026-08-01 21:20:20 +09:00
syuilo 491fea52a5 New translations ja-jp.yml (Persian)
[ci skip]
2026-08-01 20:01:25 +09:00
syuilo c507ae9988 New translations ja-jp.yml (Persian)
[ci skip]
2026-08-01 18:39:21 +09:00
syuilo daaa0dac88 New translations ja-jp.yml (Persian)
[ci skip]
2026-08-01 17:42:59 +09:00
syuilo 8e467aec09 New translations ja-jp.yml (Chinese Traditional)
[ci skip]
2026-08-01 02:12:15 +09:00
4ster1sk 4db251696d fix(test): setup.sh で $1.config.json の生成条件が $1.default.yml を参照していたのを修正 (#17826)
* fix(test): setup.sh で $1.config.json の生成条件が $1.default.yml を参照していたのを修正

* update .gitignore
2026-07-31 19:34:17 +09:00
github-actions[bot] d54a9dcd3e [skip ci] Update CHANGELOG.md (prepend template) 2026-07-31 07:48:04 +00:00
github-actions[bot] 8ea4a0ecac Release: 2026.7.0 2026-07-31 07:47:55 +00:00
おさむのひと 8dd59a8c06 fix: RSS系ウィジェットにフィードURL検査機構を追加 (#17831) 2026-07-31 16:11:39 +09:00
おさむのひと f74a303556 fix: sensitive-detectorへのリクエスト時もHttpRequestServiceを使うように (#17828) 2026-07-31 13:43:51 +09:00
おさむのひと 589d43b38b Update packages/backend/src/logging/PrettyConsoleBackend.ts
Co-authored-by: anatawa12 <anatawa12@icloud.com>
2026-07-30 21:31:42 +09:00
かっこかり bcc3bc4fd7 fix(frontend): MkLightboxの動画の縦横比を事前に確定させるように (#17822)
* fix(frontend): MkLightboxの動画の縦横比を事前に確定させるように

* fix

* fix
2026-07-29 14:47:58 +09:00
おさむのひと 6f579b6854 chore: docker_example.ymlのsentryForBackend更新が漏れていたのを追記 (#17817) 2026-07-28 21:25:08 +09:00
github-actions[bot] d600525bb2 Bump version to 2026.7.0-rc.0 2026-07-28 10:21:11 +00:00
おさむのひと 6dac6f7928 fix: OTel除去 (#17812) 2026-07-28 19:15:44 +09:00
かっこかり 318af2e1ff fix(frontend): follow-up of #17811 (#17814) 2026-07-28 17:35:33 +09:00
かっこかり a44e419d5a fix(frontend): follow-up of 2509a28 (#17815) 2026-07-28 17:35:18 +09:00
かっこかり 2509a28813 Merge commit from fork 2026-07-28 11:11:56 +09:00
おさむのひと a12ee2e5d7 Merge commit from fork 2026-07-28 11:09:37 +09:00
かっこかり 703aa2360b enhance(frontend): UploaderItemsの詳細メニューからもプレビューを起動できるように (#17811)
* enhance(frontend): UploaderItemsの詳細メニューからもプレビューを起動できるように

* clean up
2026-07-27 18:34:31 +09:00
かっこかり cb58ff32a4 fix(gh): check-misskey-js-autogenが失敗する問題を修正 (#17809)
* fix(gh): check-misskey-js-autogenが失敗する問題を修正

* fix comment

* fix
2026-07-27 16:48:36 +09:00
かっこかり f6979333d4 docs: Update CHANGELOG.md to include about js-yaml changes (#17805) 2026-07-27 00:14:34 +09:00
かっこかり e897255e71 fix 2026-07-25 21:43:41 +09:00
syuilo daf2a85539 Update CHANGELOG.md 2026-07-25 21:37:20 +09:00
chocolate-pie 1a59ec20e3 Merge commit from fork
* fix: Fix improper authorization in `admin/reset-password`

* Apply suggestions from code review

Co-authored-by: かっこかり <67428053+kakkokari-gtyih@users.noreply.github.com>

* fix: improper authorization in `admin/unset-mfa`

* fix

---------

Co-authored-by: かっこかり <67428053+kakkokari-gtyih@users.noreply.github.com>
2026-07-25 21:34:46 +09:00
4ster1sk 3e31e59bcd fix(backend): フォロー中のチャンネルを再度フォローした際にALREADY_FOLLOWINGエラーを返すように (#17802)
* test(e2e): フォロー中のチャンネルに再度フォローAPIを叩いた場合のテストを追加

* fix(backend): フォロー中のチャンネルを再度フォローした際にALREADY_FOLLOWINGエラーを返すように

* docs(changelog): update changelog
2026-07-25 21:09:12 +09:00
anatawa12 982d4905c0 use UPSERT instead of select for hashtag statistics update (#17795)
* feat: set defaults for hashtag table

* chore: use upsert to update hashtag table

* fix: query runner not cleaned up

* fix: query runner not cleaned up correctly

* chore: replace await using => try-finally
2026-07-25 19:48:20 +09:00
renovate[bot] 7ecce0901c fix(deps): update dependency js-yaml to v5.2.2 [security] (#17797)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-07-25 16:07:49 +09:00
69 changed files with 1022 additions and 3057 deletions
+23
View File
@@ -173,8 +173,31 @@ id: 'aidx'
#sentryForBackend: #sentryForBackend:
# enableNodeProfiling: true # enableNodeProfiling: true
# # Specify Sentry integration names to disable individual auto-instrumentation.
# # The names are integration .name values, not factory function names.
# # To check enabled names, set `options.debug: true` and see
# # "Integration installed: <name>" in the Sentry logs.
# #
# # 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
# # Http ... inbound/outbound HTTP; disabling this can also affect request isolation
# # NodeFetch ... fetch/undici requests
# disabledIntegrations: ['Postgres']
# options: # options:
# dsn: 'https://examplePublicKey@o0.ingest.sentry.io/0' # 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'
#sentryForFrontend: #sentryForFrontend:
# vueIntegration: # vueIntegration:
-57
View File
@@ -329,63 +329,6 @@ id: 'aidx'
# #tracePropagationTargets: # #tracePropagationTargets:
# # - 'internal-service.example' # # - '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: #sentryForFrontend:
# vueIntegration: # vueIntegration:
# tracingOptions: # tracingOptions:
@@ -0,0 +1,73 @@
name: Check Misskey JS autogen (comment)
on:
workflow_run:
types: [completed]
workflows:
- Check Misskey JS autogen # check-misskey-js-autogen.yml
jobs:
comment-misskey-js-autogen:
runs-on: ubuntu-latest
# No code is cloned or executed here, so it is safe to hold pull-requests: write
if: ${{ github.event.workflow_run.event == 'pull_request' }}
permissions:
actions: read
pull-requests: write
steps:
# The result is missing when the run failed before the check job (e.g. the PR does not build), so do not fail here
- name: Download result
id: download-result
continue-on-error: true
uses: actions/download-artifact@v8
with:
name: misskey-js-autogen-result
path: result
github-token: ${{ github.token }}
repository: ${{ github.repository }}
run-id: ${{ github.event.workflow_run.id }}
# The artifact comes from a run that built fork code, so validate it before using
- name: Load result
id: load-result
if: steps.download-result.outcome == 'success'
run: |
pr_number="$(cat result/pr-number)"
changes="$(cat result/changes)"
case "$pr_number" in
''|*[!0-9]*) echo "invalid pr number"; exit 1;;
esac
case "$changes" in
true|false) ;;
*) echo "invalid result"; exit 1;;
esac
echo "pr-number=$pr_number" >> "$GITHUB_OUTPUT"
echo "changes=$changes" >> "$GITHUB_OUTPUT"
- name: send message
if: steps.load-result.outputs.changes == 'true'
uses: thollander/actions-comment-pull-request@v3
with:
pr-number: ${{ steps.load-result.outputs.pr-number }}
comment-tag: check-misskey-js-autogen
message: |-
Thank you for sending us a great Pull Request! 👍
Please regenerate misskey-js type definitions! 🙏
example:
```sh
pnpm run build-misskey-js-with-types
```
- name: send message
if: steps.load-result.outputs.changes == 'false'
uses: thollander/actions-comment-pull-request@v3
with:
pr-number: ${{ steps.load-result.outputs.pr-number }}
comment-tag: check-misskey-js-autogen
mode: delete
message: "Thank you!"
create_if_not_exists: false
+17 -26
View File
@@ -1,7 +1,8 @@
# this name is used in check-misskey-js-autogen.comment.yml so be careful when change name
name: Check Misskey JS autogen name: Check Misskey JS autogen
on: on:
pull_request_target: pull_request:
branches: branches:
- master - master
- develop - develop
@@ -10,7 +11,6 @@ on:
- packages/backend/** - packages/backend/**
jobs: jobs:
# pull_request_target safety: permissions: read-all, and there are no secrets used in this job
generate-misskey-js: generate-misskey-js:
runs-on: ubuntu-latest runs-on: ubuntu-latest
permissions: permissions:
@@ -58,7 +58,6 @@ jobs:
name: generated-misskey-js name: generated-misskey-js
path: packages/misskey-js/generator/built/autogen path: packages/misskey-js/generator/built/autogen
# pull_request_target safety: permissions: read-all, and no user codes are executed
get-actual-misskey-js: get-actual-misskey-js:
runs-on: ubuntu-latest runs-on: ubuntu-latest
permissions: permissions:
@@ -78,12 +77,11 @@ jobs:
name: actual-misskey-js name: actual-misskey-js
path: packages/misskey-js/src/autogen path: packages/misskey-js/src/autogen
# pull_request_target safety: nothing is cloned from repository check-misskey-js-autogen:
comment-misskey-js-autogen:
runs-on: ubuntu-latest runs-on: ubuntu-latest
needs: [generate-misskey-js, get-actual-misskey-js] needs: [generate-misskey-js, get-actual-misskey-js]
permissions: permissions:
pull-requests: write contents: read
steps: steps:
- name: download generated-misskey-js - name: download generated-misskey-js
uses: actions/download-artifact@v8 uses: actions/download-artifact@v8
@@ -111,28 +109,21 @@ jobs:
- name: Print full diff - name: Print full diff
run: cat ./misskey-js.diff run: cat ./misskey-js.diff
- name: send message # A fork's pull_request token cannot comment, so hand the result to check-misskey-js-autogen.comment.yml
if: steps.check-changes.outputs.changes == 'true' - name: Save result
uses: thollander/actions-comment-pull-request@v3 env:
with: PR_NUMBER: ${{ github.event.pull_request.number }}
comment-tag: check-misskey-js-autogen CHANGES: ${{ steps.check-changes.outputs.changes }}
message: |- run: |
Thank you for sending us a great Pull Request! 👍 mkdir -p result
Please regenerate misskey-js type definitions! 🙏 echo "$PR_NUMBER" > result/pr-number
echo "$CHANGES" > result/changes
example: - name: Upload result
```sh uses: actions/upload-artifact@v7
pnpm run build-misskey-js-with-types
```
- name: send message
if: steps.check-changes.outputs.changes == 'false'
uses: thollander/actions-comment-pull-request@v3
with: with:
comment-tag: check-misskey-js-autogen name: misskey-js-autogen-result
mode: delete path: result
message: "Thank you!"
create_if_not_exists: false
- name: Make failure if changes are detected - name: Make failure if changes are detected
if: steps.check-changes.outputs.changes == 'true' if: steps.check-changes.outputs.changes == 'true'
+20 -11
View File
@@ -1,3 +1,15 @@
## Unreleased
### General
-
### Client
-
### Server
-
## 2026.7.0 ## 2026.7.0
### Note ### Note
@@ -12,11 +24,14 @@
- Node.js のセキュリティアップデートに伴い、最低動作バージョンを 22.22.2 / 24.17.0 / 26.4.0 に引き上げました。 - Node.js のセキュリティアップデートに伴い、最低動作バージョンを 22.22.2 / 24.17.0 / 26.4.0 に引き上げました。
- Docker Image は Node.js 26.4.0-trixie に更新されています。 - Docker Image は Node.js 26.4.0-trixie に更新されています。
- バックエンドで画像処理に用いているライブラリ sharp のシステム要件の変更により、**SSE4.2 命令セットをサポートしていない x86_64 CPU では Misskey が正しく動作しなくなります**。仮想マシンに Misskey をデプロイしている場合や、古いハードウェアをお使いの場合は、アップデート前にお使いの環境をご確認ください。なお、ARM64 など x86_64 ではない環境においてはこの変更による影響はありません。 - バックエンドで画像処理に用いているライブラリ sharp のシステム要件の変更により、**SSE4.2 命令セットをサポートしていない x86_64 CPU では Misskey が正しく動作しなくなります**。仮想マシンに Misskey をデプロイしている場合や、古いハードウェアをお使いの場合は、アップデート前にお使いの環境をご確認ください。なお、ARM64 など x86_64 ではない環境においてはこの変更による影響はありません。
- YAMLパーサーをアップデートし、より厳格なチェックが行われるようになりました。起動時にconfigの読み取りで構文エラーが発生する可能性があります。\
例えば `allowPrivateNetworks` を 2026.6.0 までの `example.yml` の構文を元に記述している場合は、配列の閉じ括弧のインデントを上げるか、リスト表示に書き換える必要があります。詳しくは https://github.com/misskey-dev/misskey/pull/17701 をご覧ください。
### General ### General
- Feat: コントロールパネルから二要素認証を解除できるように - Feat: コントロールパネルから二要素認証を解除できるように
- Feat: 条件に一致したURLプレビューのサムネイルを隠すことができるように - Feat: 条件に一致したURLプレビューのサムネイルを隠すことができるように
(Based on https://github.com/MisskeyIO/misskey/pull/214) (Based on https://github.com/MisskeyIO/misskey/pull/214)
- Enhance: 依存関係の更新
### Client ### Client
- 2025.4.0 以前の設定情報の移行処理が削除されました - 2025.4.0 以前の設定情報の移行処理が削除されました
@@ -31,6 +46,7 @@
- Fix: 一部の画像をビューワーで読み込んだ際に正しく表示されない問題を修正 - Fix: 一部の画像をビューワーで読み込んだ際に正しく表示されない問題を修正
- Enhance: ファイルアップロード前にプレビューできるように - Enhance: ファイルアップロード前にプレビューできるように
- Enhance: タブがバックグラウンドの間は必要ない定期更新処理を停止するように - Enhance: タブがバックグラウンドの間は必要ない定期更新処理を停止するように
- Enhance: 翻訳の更新
- Fix: 「画像を新しいタブで開く」が機能しなくなっていた問題を修正 - Fix: 「画像を新しいタブで開く」が機能しなくなっていた問題を修正
- Fix: デバイスタイプをスマートフォンに固定している状態で画面幅が広いとき、画面左上のアイコンが表示されない問題を修正 - Fix: デバイスタイプをスマートフォンに固定している状態で画面幅が広いとき、画面左上のアイコンが表示されない問題を修正
- Fix: チャットでIMEの変換を確定するEnterでメッセージが送信されてしまうことがある問題を修正 - Fix: チャットでIMEの変換を確定するEnterでメッセージが送信されてしまうことがある問題を修正
@@ -44,21 +60,12 @@
- Fix: ノートの詳細表示で削除された引用元が表示されない問題を修正 - Fix: ノートの詳細表示で削除された引用元が表示されない問題を修正
### Server ### Server
- Feat: OpenTelemetryサポート
- 詳細な設定はconfigファイルを参照してください。
- Sentryとの併用も可能です。Sentry併用時は、PostgreSQL Query と Redis command は Sentry で計装されます。
- 以下の自動計装をサポートしています。(計装対象にする項目は設定可能)
- PostgreSQL query
- Redis command
- 全ての受信HTTPリクエスト
- 全ての送信HTTPリクエスト
- ジョブキュー(エンキュー元のトレースを含む)
- Feat: ログ基盤の刷新 - Feat: ログ基盤の刷新
- API内部エラーのログに構造化属性と正規化したエラー情報を付与し、認証情報を自動的に秘匿するように(従来形式の表示は維持) - API内部エラーのログに構造化属性と正規化したエラー情報を付与し、認証情報を自動的に秘匿するように(従来形式の表示は維持)
- ログ全体の既定出力レベルとドメインごとの出力レベルを設定できるように - ログ全体の既定出力レベルとドメインごとの出力レベルを設定できるように
- バックエンドのログを1行JSON形式で出力できるように - バックエンドのログを1行JSON形式で出力できるように
- OpenTelemetryのTrace ContextをJSON形式のログへ関連付けられるように - SentryのTrace ContextをJSON形式のログへ関連付けられるように
- HTTPのAccess logをstatus class単位で出力できるように(開発時のリクエスト・レスポンス本文、OpenTelemetryのTrace Contextにも対応) - HTTPのAccess logをstatus class単位で出力できるように(開発時のリクエスト・レスポンス本文、SentryのTrace Contextにも対応)
- Enhance: Sentry バックエンドの自動計装を `sentryForBackend.disabledIntegrations` で個別に無効化できるように - Enhance: Sentry バックエンドの自動計装を `sentryForBackend.disabledIntegrations` で個別に無効化できるように
- Enhance: センシティブメディアの判定を外部サービス ([sensitive-detector](https://github.com/misskey-dev/sensitive-detector)) に分離し、`nsfwjs` / `@tensorflow/tfjs(-node)` の同梱と NSFW 判定モデルを廃止 (#16804) - Enhance: センシティブメディアの判定を外部サービス ([sensitive-detector](https://github.com/misskey-dev/sensitive-detector)) に分離し、`nsfwjs` / `@tensorflow/tfjs(-node)` の同梱と NSFW 判定モデルを廃止 (#16804)
- Enhance: Node.js 22.22.2以降、24.17.0以降、26.4.0以降をサポートするように - Enhance: Node.js 22.22.2以降、24.17.0以降、26.4.0以降をサポートするように
@@ -71,6 +78,8 @@
- Fix: フォロワー限定投稿へのリプライをホーム投稿に出来る問題を修正 - Fix: フォロワー限定投稿へのリプライをホーム投稿に出来る問題を修正
- Fix: ファイルをアップロードするAPIにて、処理終了後に一時ファイルが削除されないことがある問題を修正 - Fix: ファイルをアップロードするAPIにて、処理終了後に一時ファイルが削除されないことがある問題を修正
- Fix: 初期設定で作成したアカウント以外でアカウント作成APIが使用できない問題を修正 - Fix: 初期設定で作成したアカウント以外でアカウント作成APIが使用できない問題を修正
- Fix: フォロー中のチャンネルを再度フォローした際にALREADY_FOLLOWINGエラーを返すように
- Fix: セキュリティに関する修正
## 2026.6.0 ## 2026.6.0
+81
View File
@@ -0,0 +1,81 @@
---
_lang_: "فارسی"
headlineMisskey: "یک شبکه از طریق یادداشت ها متصل گردید."
introMisskey: "خوش آمدید! Misskey یک سرویس میکروبلاگینگ متن باز و غیرمتمرکز می باشد.\nیک \"یادداشت\" درست کن و در مورد اتفاق هایی که داره می افته و یا به دیگران درمورد خودت بگو📡\nشما می توانید به سرعت با کمک قابلیت \"واکنش ها\" به یادداشت های دیگران واکنش نشون بدید👍\nیک دنیای جدید رو کاوش کنید🚀"
poweredByMisskeyDescription: "{name} یکی از سرور های متن باز پلتفرم <b>Misskey</b> می باشد."
monthAndDay: "{day}/{month}"
search: "جستجو"
reset: "بازنشانی"
notifications: "اعلان ها"
username: "نام کاربری"
password: "گذرواژه"
initialPasswordForSetup: "گذرواژه برای راه‌اندازی تنظیمات اولیه"
initialPasswordIsIncorrect: "گذرواژه برای راه‌اندازی تنظیمات اولیه نادرست می باشد."
initialPasswordForSetupDescription: "اگر خودتان Misskey را نصب کرده اید، لطفا از گذرواژه ای که در فایل تنظیمات وارد کرده اید استفاده کنید.\nاگه از سرویس میزبانی برای Misskey استفاده می کنید، لطفا از گذرواژه ای که توسط آنها ارائه شده استفاده کنید.\nاگر گذرواژه ای تنظیم نکردید، بخش را خالی بگذارید و ادامه دهید."
forgotPassword: "گذرواژه ام را فراموش کردم"
fetchingAsApObject: "در حال دریافت از فدیوِرس"
ok: "باشه"
gotIt: "متوجه شدم"
cancel: "لغو"
noThankYou: "الان نه"
enterUsername: "نام کاربری خود را وارد کنید"
renotedBy: "بازنشر شده توسط {user}"
noNotes: "هیچ یادداشتی موجود نیست"
noNotifications: "اعلانی موجود نیست"
instance: "سرور"
settings: "تنظیمات"
notificationSettings: "تنظیمات اعلان"
basicSettings: "تنظیمات اصلی"
otherSettings: "تنظیمات بیشتر"
openInWindow: "در پنجره باز کن"
profile: "نمایه"
timeline: "نوار زمانی"
noAccountDescription: "این کاربر هنوز درون شرح حال خودش چیزی ننوشته."
login: "ورود"
loggingIn: "وارد شده"
logout: "خروج"
signup: "ثبت‌نام"
uploading: "بارگذاری..."
save: "ذخیره"
users: "کاربران"
addUser: "افزودن کاربر"
favorite: "افزودن به برگزیده ها"
favorites: "برگزیده ها"
unfavorite: "حذف از برگزیده ها"
favorited: "به برگزیده ها اضافه شد."
alreadyFavorited: "قبلا به برگزیده ها اضافه شده."
cantFavorite: "نمی توان به برگزیده ها اضافه کرد."
pin: "سنجاق کردن"
unpin: "حذف سنجاق"
copyContent: "رونوشت از محتوا"
copyLink: "رونوشت از پیوند"
copyRemoteLink: "رونوشت از پیوند خارجی"
copyLinkRenote: "رونوشت از پیوند بازنشر"
delete: "حذف"
deleteAndEdit: "حذف و ویرایش"
deleteAndEditConfirm: "آیا می خواهید که این یادداشت را حذف و دوباره ویرایش کنید؟ در این صورت همچنین تمام واکنش ها، بازنشر ها و پاسخ ها به این یادداشت پاک خواهد شد."
addToList: "افزودن به فهرست"
pinned: "سنجاق کردن"
instances: "سرور"
remove: "حذف"
smtpUser: "نام کاربری"
smtpPass: "گذرواژه"
user: "کاربران"
searchByGoogle: "جستجو"
_sfx:
notification: "اعلان ها"
_2fa:
renewTOTPCancel: "الان نه"
_widgets:
profile: "نمایه"
notifications: "اعلان ها"
timeline: "نوار زمانی"
_profile:
username: "نام کاربری"
_notification:
_types:
login: "ورود"
_deck:
_columns:
notifications: "اعلان ها"
tl: "نوار زمانی"
+15
View File
@@ -619,6 +619,8 @@ output: "輸出"
script: "腳本" script: "腳本"
disablePagesScript: "停用頁面的 AiScript 腳本" disablePagesScript: "停用頁面的 AiScript 腳本"
updateRemoteUser: "更新遠端使用者資訊" updateRemoteUser: "更新遠端使用者資訊"
unsetMfa: "解除雙重驗證"
unsetMfaConfirm: "要解除雙重驗證嗎?"
unsetUserAvatar: "移除使用者的大頭貼" unsetUserAvatar: "移除使用者的大頭貼"
unsetUserAvatarConfirm: "確定要移除使用者的大頭貼嗎?" unsetUserAvatarConfirm: "確定要移除使用者的大頭貼嗎?"
unsetUserBanner: "移除使用者的橫幅圖像" unsetUserBanner: "移除使用者的橫幅圖像"
@@ -1417,6 +1419,8 @@ addToEmojiPalette: "增加表情符號調色盤"
emojiPaletteAlreadyAddedConfirm: "此表情符號在這個表情符號調色盤裡已經有了。確定要增加嗎?" emojiPaletteAlreadyAddedConfirm: "此表情符號在這個表情符號調色盤裡已經有了。確定要增加嗎?"
append: "加在最後" append: "加在最後"
prepend: "加在前面" prepend: "加在前面"
urlPreviewSensitiveList: "限制縮圖顯示的 URL"
urlPreviewSensitiveListDescription: "以空格指定為 AND,以換行指定為 OR。若以斜線(/)包圍則視為正規表達式。符合條件時,將不再顯示縮圖。"
_imageEditing: _imageEditing:
_vars: _vars:
caption: "檔案標題" caption: "檔案標題"
@@ -2149,6 +2153,15 @@ _sensitiveMediaDetection:
setSensitiveFlagAutomaticallyDescription: "即使將此設定關閉,判定結果也會保留在內部。" setSensitiveFlagAutomaticallyDescription: "即使將此設定關閉,判定結果也會保留在內部。"
analyzeVideos: "啟用影片分析" analyzeVideos: "啟用影片分析"
analyzeVideosDescription: "除了靜止影像以外,也分析影片。伺服器的負荷會稍微增加。" analyzeVideosDescription: "除了靜止影像以外,也分析影片。伺服器的負荷會稍微增加。"
externalServiceInfo: "敏感媒體的判定已分離至外部服務(sensitive-detector)。若要使用此功能,必須另外設定 Sidecar 服務,並設定下方的連接資訊。若未設定連接資訊,則不會執行判定(視為非敏感內容)。"
apiUrl: "判定服務的連接資訊 URL"
apiUrlDescription: "sensitive-detector 服務的基礎網址(例如:http://localhost:3009)。若要連接位於私有網路上的服務,請在設定檔的 allowedPrivateNetworks 中允許對應的連接網路。若使用代理伺服器(Proxy),也請一併設定 proxyBypassHosts。若留白,則不會執行敏感內容判定。"
apiKey: "API 金鑰"
apiKeyDescription: "若判定服務端已設定認證(Bearer Token),請輸入此資訊。若未設定,請保持空白。"
timeout: "連線逾時(微秒)"
timeoutDescription: "單次判定請求的逾時時間。"
maxImagesPerRequest: "每筆請求的最大圖片數量"
maxImagesPerRequestDescription: "當判定包含多個影格的內容(如影片等)時,為每筆請求所允許的單次最大圖片數量。超過此數量的部分將會分割並依序發送。請確保此數值不超過 sensitive-detector 端的 maxParts 設定(預設值:10)。若超過該數值,該區塊(Chunk)的所有項目將被視為非敏感內容。"
_emailUnavailable: _emailUnavailable:
used: "已被使用" used: "已被使用"
format: "格式無效" format: "格式無效"
@@ -2461,6 +2474,7 @@ _permissions:
"read:admin:show-moderation-log": "查看審查紀錄" "read:admin:show-moderation-log": "查看審查紀錄"
"read:admin:show-user": "查看使用者的私密資訊" "read:admin:show-user": "查看使用者的私密資訊"
"write:admin:suspend-user": "凍結使用者" "write:admin:suspend-user": "凍結使用者"
"write:admin:unset-mfa": "解除使用者的雙重驗證"
"write:admin:unset-user-avatar": "刪除使用者的頭像" "write:admin:unset-user-avatar": "刪除使用者的頭像"
"write:admin:unset-user-banner": "刪除使用者的橫幅" "write:admin:unset-user-banner": "刪除使用者的橫幅"
"write:admin:unsuspend-user": "解除凍結使用者" "write:admin:unsuspend-user": "解除凍結使用者"
@@ -2976,6 +2990,7 @@ _moderationLogTypes:
createAvatarDecoration: "建立頭像裝飾" createAvatarDecoration: "建立頭像裝飾"
updateAvatarDecoration: "更新頭像裝飾" updateAvatarDecoration: "更新頭像裝飾"
deleteAvatarDecoration: "刪除頭像裝飾" deleteAvatarDecoration: "刪除頭像裝飾"
unsetMfa: "解除使用者的雙重驗證"
unsetUserAvatar: "移除使用者的大頭貼" unsetUserAvatar: "移除使用者的大頭貼"
unsetUserBanner: "移除使用者的橫幅圖像" unsetUserBanner: "移除使用者的橫幅圖像"
createSystemWebhook: "建立 SystemWebhook" createSystemWebhook: "建立 SystemWebhook"
+2 -2
View File
@@ -1,6 +1,6 @@
{ {
"name": "misskey", "name": "misskey",
"version": "2026.7.0-beta.6", "version": "2026.7.0",
"codename": "nasubi", "codename": "nasubi",
"repository": { "repository": {
"type": "git", "type": "git",
@@ -57,7 +57,7 @@
"esbuild": "0.28.1", "esbuild": "0.28.1",
"execa": "9.6.1", "execa": "9.6.1",
"ignore-walk": "9.0.0", "ignore-walk": "9.0.0",
"js-yaml": "5.2.1", "js-yaml": "5.2.2",
"tar": "7.5.21" "tar": "7.5.21"
}, },
"devDependencies": { "devDependencies": {
@@ -0,0 +1,26 @@
/*
* SPDX-FileCopyrightText: syuilo and misskey-project
* SPDX-License-Identifier: AGPL-3.0-only
*/
export class HashtagTableDefaults1784899839024 {
name = 'HashtagTableDefaults1784899839024'
async up(queryRunner) {
await queryRunner.query(`ALTER TABLE "hashtag" ALTER COLUMN "mentionedUserIds" SET DEFAULT '{}'`);
await queryRunner.query(`ALTER TABLE "hashtag" ALTER COLUMN "mentionedLocalUserIds" SET DEFAULT '{}'`);
await queryRunner.query(`ALTER TABLE "hashtag" ALTER COLUMN "mentionedRemoteUserIds" SET DEFAULT '{}'`);
await queryRunner.query(`ALTER TABLE "hashtag" ALTER COLUMN "attachedUserIds" SET DEFAULT '{}'`);
await queryRunner.query(`ALTER TABLE "hashtag" ALTER COLUMN "attachedLocalUserIds" SET DEFAULT '{}'`);
await queryRunner.query(`ALTER TABLE "hashtag" ALTER COLUMN "attachedRemoteUserIds" SET DEFAULT '{}'`);
}
async down(queryRunner) {
await queryRunner.query(`ALTER TABLE "hashtag" ALTER COLUMN "attachedRemoteUserIds" DROP DEFAULT`);
await queryRunner.query(`ALTER TABLE "hashtag" ALTER COLUMN "attachedLocalUserIds" DROP DEFAULT`);
await queryRunner.query(`ALTER TABLE "hashtag" ALTER COLUMN "attachedUserIds" DROP DEFAULT`);
await queryRunner.query(`ALTER TABLE "hashtag" ALTER COLUMN "mentionedRemoteUserIds" DROP DEFAULT`);
await queryRunner.query(`ALTER TABLE "hashtag" ALTER COLUMN "mentionedLocalUserIds" DROP DEFAULT`);
await queryRunner.query(`ALTER TABLE "hashtag" ALTER COLUMN "mentionedUserIds" DROP DEFAULT`);
}
}
+1 -11
View File
@@ -57,7 +57,6 @@
"@fastify/cors": "11.3.0", "@fastify/cors": "11.3.0",
"@fastify/http-proxy": "11.5.0", "@fastify/http-proxy": "11.5.0",
"@fastify/multipart": "10.1.0", "@fastify/multipart": "10.1.0",
"@fastify/otel": "0.20.1",
"@fastify/static": "10.1.2", "@fastify/static": "10.1.2",
"@kitajs/html": "4.2.13", "@kitajs/html": "4.2.13",
"@misskey-dev/emoji-assets": "17.0.3", "@misskey-dev/emoji-assets": "17.0.3",
@@ -68,15 +67,6 @@
"@nestjs/common": "11.1.28", "@nestjs/common": "11.1.28",
"@nestjs/core": "11.1.28", "@nestjs/core": "11.1.28",
"@nestjs/testing": "11.1.28", "@nestjs/testing": "11.1.28",
"@opentelemetry/api": "1.9.1",
"@opentelemetry/core": "2.9.0",
"@opentelemetry/exporter-trace-otlp-proto": "0.220.0",
"@opentelemetry/instrumentation": "0.219.0",
"@opentelemetry/instrumentation-pg": "0.72.0",
"@opentelemetry/resources": "2.9.0",
"@opentelemetry/sdk-trace-base": "2.9.0",
"@opentelemetry/sdk-trace-node": "2.9.0",
"@opentelemetry/semantic-conventions": "1.43.0",
"@oxc-project/runtime": "0.139.0", "@oxc-project/runtime": "0.139.0",
"@peertube/http-signature": "1.7.0", "@peertube/http-signature": "1.7.0",
"@sentry/node": "10.65.0", "@sentry/node": "10.65.0",
@@ -193,7 +183,7 @@
"eslint-plugin-import": "2.32.0", "eslint-plugin-import": "2.32.0",
"execa": "9.6.1", "execa": "9.6.1",
"fkill": "10.0.3", "fkill": "10.0.3",
"js-yaml": "5.2.1", "js-yaml": "5.2.2",
"pid-port": "2.1.1", "pid-port": "2.1.1",
"rolldown": "1.1.5", "rolldown": "1.1.5",
"simple-oauth2": "5.1.0", "simple-oauth2": "5.1.0",
-1
View File
@@ -87,7 +87,6 @@ export default defineConfig((args) => {
'class-validator', 'class-validator',
/^@sentry\/.*/, /^@sentry\/.*/,
/^@sentry-internal\/.*/, /^@sentry-internal\/.*/,
/^@opentelemetry\/.*/,
'@nestjs/websockets/socket-module', '@nestjs/websockets/socket-module',
'@nestjs/microservices/microservices-module', '@nestjs/microservices/microservices-module',
'@nestjs/microservices', '@nestjs/microservices',
-18
View File
@@ -27,21 +27,6 @@ type SentryBackendConfig = {
disabledIntegrations?: string[]; 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';
};
/** /**
* 設定ファイルの型 * 設定ファイルの型
*/ */
@@ -87,7 +72,6 @@ type Source = {
scope?: 'local' | 'global' | string[]; scope?: 'local' | 'global' | string[];
}; };
sentryForBackend?: SentryBackendConfig; sentryForBackend?: SentryBackendConfig;
otelForBackend?: OtelBackendConfig;
sentryForFrontend?: { sentryForFrontend?: {
options: Partial<SentryVue.BrowserOptions> & { dsn: string }; options: Partial<SentryVue.BrowserOptions> & { dsn: string };
vueIntegration?: SentryVue.VueIntegrationOptions | null; vueIntegration?: SentryVue.VueIntegrationOptions | null;
@@ -233,7 +217,6 @@ export type Config = {
redisForTimelines: RedisOptions & RedisOptionsSource; redisForTimelines: RedisOptions & RedisOptionsSource;
redisForReactions: RedisOptions & RedisOptionsSource; redisForReactions: RedisOptions & RedisOptionsSource;
sentryForBackend: SentryBackendConfig | undefined; sentryForBackend: SentryBackendConfig | undefined;
otelForBackend: OtelBackendConfig | undefined;
sentryForFrontend: { sentryForFrontend: {
options: Partial<SentryVue.BrowserOptions> & { dsn: string }; options: Partial<SentryVue.BrowserOptions> & { dsn: string };
vueIntegration?: SentryVue.VueIntegrationOptions | null; vueIntegration?: SentryVue.VueIntegrationOptions | null;
@@ -339,7 +322,6 @@ export function loadConfig(): Config {
redisForTimelines: config.redisForTimelines ? convertRedisOptions(config.redisForTimelines, host) : redis, redisForTimelines: config.redisForTimelines ? convertRedisOptions(config.redisForTimelines, host) : redis,
redisForReactions: config.redisForReactions ? convertRedisOptions(config.redisForReactions, host) : redis, redisForReactions: config.redisForReactions ? convertRedisOptions(config.redisForReactions, host) : redis,
sentryForBackend: config.sentryForBackend, sentryForBackend: config.sentryForBackend,
otelForBackend: config.otelForBackend,
sentryForFrontend: config.sentryForFrontend, sentryForFrontend: config.sentryForFrontend,
id: config.id, id: config.id,
proxy: config.proxy, proxy: config.proxy,
+4 -11
View File
@@ -4,7 +4,6 @@
*/ */
import { Injectable, Inject } from '@nestjs/common'; import { Injectable, Inject } from '@nestjs/common';
import fetch from 'node-fetch';
import { DI } from '@/di-symbols.js'; import { DI } from '@/di-symbols.js';
import { bindThis } from '@/decorators.js'; import { bindThis } from '@/decorators.js';
import { HttpRequestService } from '@/core/HttpRequestService.js'; import { HttpRequestService } from '@/core/HttpRequestService.js';
@@ -131,9 +130,6 @@ export class AiService {
@bindThis @bindThis
private async detectChunk(url: string, apiKey: string | null, timeout: number, chunk: Buffer[]): Promise<(Prediction[] | null)[]> { private async detectChunk(url: string, apiKey: string | null, timeout: number, chunk: Buffer[]): Promise<(Prediction[] | null)[]> {
const controller = new AbortController();
const timer = setTimeout(() => controller.abort(), timeout);
try { try {
const form = new FormData(); const form = new FormData();
for (let i = 0; i < chunk.length; i++) { for (let i = 0; i < chunk.length; i++) {
@@ -148,14 +144,13 @@ export class AiService {
headers['Authorization'] = `Bearer ${apiKey}`; headers['Authorization'] = `Bearer ${apiKey}`;
} }
const res = await fetch(url, { const res = await this.httpRequestService.send(url, {
method: 'POST', method: 'POST',
headers, headers,
body: form, body: form,
// 外部サービスとして通常の proxy / private address 制限を適用する。 timeout,
// サイドカーへの private network 接続は allowedPrivateNetworks 等で明示的に許可する。 }, {
agent: (u) => this.httpRequestService.getAgentByUrl(u), throwErrorWhenResponseNotOk: false,
signal: controller.signal,
}); });
if (!res.ok) { if (!res.ok) {
@@ -181,8 +176,6 @@ export class AiService {
} catch (err) { } catch (err) {
this.logger.warn(`sensitive detection error: ${err instanceof Error ? err.message : String(err)}`); this.logger.warn(`sensitive detection error: ${err instanceof Error ? err.message : String(err)}`);
return chunk.map(() => null); return chunk.map(() => null);
} finally {
clearTimeout(timer);
} }
} }
} }
@@ -13,6 +13,8 @@ import { GlobalEvents, GlobalEventService } from '@/core/GlobalEventService.js';
import { bindThis } from '@/decorators.js'; import { bindThis } from '@/decorators.js';
import type { MiLocalUser } from '@/models/User.js'; import type { MiLocalUser } from '@/models/User.js';
import { RedisKVCache } from '@/misc/cache.js'; import { RedisKVCache } from '@/misc/cache.js';
import { IdentifiableError } from '@/misc/identifiable-error.js';
import { isDuplicateKeyValueError } from '@/misc/is-duplicate-key-value-error.js';
@Injectable() @Injectable()
export class ChannelFollowingService implements OnModuleInit { export class ChannelFollowingService implements OnModuleInit {
@@ -96,11 +98,18 @@ export class ChannelFollowingService implements OnModuleInit {
requestUser: MiLocalUser, requestUser: MiLocalUser,
targetChannel: MiChannel, targetChannel: MiChannel,
): Promise<void> { ): Promise<void> {
await this.channelFollowingsRepository.insert({ try {
id: this.idService.gen(), await this.channelFollowingsRepository.insert({
followerId: requestUser.id, id: this.idService.gen(),
followeeId: targetChannel.id, followerId: requestUser.id,
}); followeeId: targetChannel.id,
});
} catch (e) {
if (isDuplicateKeyValueError(e)) {
throw new IdentifiableError('6e335e39-0203-4418-a936-b3f2dc987845', 'already following');
}
throw e;
}
this.globalEventService.publishInternalEvent('followChannel', { this.globalEventService.publishInternalEvent('followChannel', {
userId: requestUser.id, userId: requestUser.id,
+90 -114
View File
@@ -16,11 +16,20 @@ import { UserEntityService } from '@/core/entities/UserEntityService.js';
import { bindThis } from '@/decorators.js'; import { bindThis } from '@/decorators.js';
import { FeaturedService } from '@/core/FeaturedService.js'; import { FeaturedService } from '@/core/FeaturedService.js';
import { UtilityService } from '@/core/UtilityService.js'; import { UtilityService } from '@/core/UtilityService.js';
import { isDuplicateKeyValueError } from '@/misc/is-duplicate-key-value-error.js';
import Logger from '../logger.js'; import Logger from '../logger.js';
const logger = new Logger('hashtag/create'); const logger = new Logger('hashtag/create');
type AttachedOrMentioned = 'attached' | 'mentioned';
type UpdatingHashtagColumn = {
totalUserIds: keyof MiHashtag & `${AttachedOrMentioned}UserIds`,
totalUsersCount: keyof MiHashtag & `${AttachedOrMentioned}UsersCount`,
localUserIds: keyof MiHashtag & `${AttachedOrMentioned}LocalUserIds`,
localUsersCount: keyof MiHashtag & `${AttachedOrMentioned}LocalUsersCount`,
remoteUserIds: keyof MiHashtag & `${AttachedOrMentioned}RemoteUserIds`,
remoteUsersCount: keyof MiHashtag & `${AttachedOrMentioned}RemoteUsersCount`,
};
@Injectable() @Injectable()
export class HashtagService { export class HashtagService {
constructor( constructor(
@@ -68,126 +77,93 @@ export class HashtagService {
// TODO: サンプリング // TODO: サンプリング
this.updateHashtagsRanking(tag, user.id); this.updateHashtagsRanking(tag, user.id);
{ const column: UpdatingHashtagColumn = isUserAttached ? {
const index = await this.hashtagsRepository.findOneBy({ name: tag }); totalUserIds: 'attachedUserIds',
totalUsersCount: 'attachedUsersCount',
localUserIds: 'attachedLocalUserIds',
localUsersCount: 'attachedLocalUsersCount',
remoteUserIds: 'attachedRemoteUserIds',
remoteUsersCount: 'attachedRemoteUsersCount',
} : {
totalUserIds: 'mentionedUserIds',
totalUsersCount: 'mentionedUsersCount',
localUserIds: 'mentionedLocalUserIds',
localUsersCount: 'mentionedLocalUsersCount',
remoteUserIds: 'mentionedRemoteUserIds',
remoteUsersCount: 'mentionedRemoteUsersCount',
};
if (index == null && inc) { if (inc) {
try { await this.#incrementHashTag(user, tag, column);
if (isUserAttached) { } else {
await this.hashtagsRepository.insert({ await this.#decrementHashTag(user, tag, column);
id: this.idService.gen(), }
name: tag, }
mentionedUserIds: [],
mentionedUsersCount: 0, async #incrementHashTag(
mentionedLocalUserIds: [], user: { id: MiUser['id']; host: MiUser['host']; },
mentionedLocalUsersCount: 0, tag: string,
mentionedRemoteUserIds: [], columns: UpdatingHashtagColumn,
mentionedRemoteUsersCount: 0, ) {
attachedUserIds: [user.id], const isLocal = this.userEntityService.isLocalUser(user);
attachedUsersCount: 1, const { totalUserIds, totalUsersCount } = columns;
attachedLocalUserIds: this.userEntityService.isLocalUser(user) ? [user.id] : [], const localOrRemoteUserIds = isLocal ? columns.localUserIds : columns.remoteUserIds;
attachedLocalUsersCount: this.userEntityService.isLocalUser(user) ? 1 : 0, const localOrRemoteUserCount = isLocal ? columns.localUsersCount : columns.remoteUsersCount;
attachedRemoteUserIds: this.userEntityService.isRemoteUser(user) ? [user.id] : [],
attachedRemoteUsersCount: this.userEntityService.isRemoteUser(user) ? 1 : 0, const runner = this.db.createQueryRunner('master');
} as MiHashtag); try {
} else { await runner.query(
await this.hashtagsRepository.insert({ `INSERT into "hashtag"("id", "name", "${totalUserIds}", "${totalUsersCount}", "${localOrRemoteUserIds}",
id: this.idService.gen(), "${localOrRemoteUserCount}")
name: tag, VALUES ($3, $1, ARRAY [$2], 1, ARRAY [$2], 1)
mentionedUserIds: [user.id], ON CONFLICT ("name")
mentionedUsersCount: 1, DO UPDATE SET "${totalUserIds}" = ${appendUserIdIfNotExists(totalUserIds)},
mentionedLocalUserIds: this.userEntityService.isLocalUser(user) ? [user.id] : [], "${totalUsersCount}" = ${incrementCountIfNotExists(totalUserIds, totalUsersCount)},
mentionedLocalUsersCount: this.userEntityService.isLocalUser(user) ? 1 : 0, "${localOrRemoteUserIds}" = ${appendUserIdIfNotExists(localOrRemoteUserIds)},
mentionedRemoteUserIds: this.userEntityService.isRemoteUser(user) ? [user.id] : [], "${localOrRemoteUserCount}" = ${incrementCountIfNotExists(localOrRemoteUserIds, localOrRemoteUserCount)}`,
mentionedRemoteUsersCount: this.userEntityService.isRemoteUser(user) ? 1 : 0, [tag, user.id, this.idService.gen()],
attachedUserIds: [], );
attachedUsersCount: 0, } finally {
attachedLocalUserIds: [], await runner.release();
attachedLocalUsersCount: 0,
attachedRemoteUserIds: [],
attachedRemoteUsersCount: 0,
} as MiHashtag);
}
return;
} catch (err) {
if (isDuplicateKeyValueError(err)) {
logger.info(`Duplicate insertion detected. Falling back to update. #${tag}`);
} else {
throw err;
}
}
}
} }
await this.db.transaction(async transactionalEntityManager => { function appendUserIdIfNotExists(userIds: keyof MiHashtag & `${string}UserIds`): string {
const transactionalHashtagRepository = transactionalEntityManager return `CASE WHEN NOT ("hashtag"."${userIds}" @> ARRAY[$2 ::varchar]) THEN array_append("hashtag"."${userIds}", $2) ELSE "hashtag"."${userIds}" END`;
.getRepository(MiHashtag); }
const index = await transactionalHashtagRepository function incrementCountIfNotExists(userIds: keyof MiHashtag & `${string}UserIds`, userCount: keyof MiHashtag & `${string}UsersCount`): string {
.createQueryBuilder() return `CASE WHEN NOT ("hashtag"."${userIds}" @> ARRAY[$2 ::varchar]) THEN "hashtag"."${userCount}" + 1 ELSE "hashtag"."${userCount}" END`;
.setLock('pessimistic_write') }
.where('name = :name', { name: tag }) }
.getOne();
if (index == null) return; async #decrementHashTag(
user: { id: MiUser['id']; host: MiUser['host']; },
tag: string,
columns: UpdatingHashtagColumn,
) {
const isLocal = this.userEntityService.isLocalUser(user);
const { totalUserIds, totalUsersCount } = columns;
const localOrRemoteUserIds = isLocal ? columns.localUserIds : columns.remoteUserIds;
const localOrRemoteUserCount = isLocal ? columns.localUsersCount : columns.remoteUsersCount;
const set = {} as any; const runner = this.db.createQueryRunner('master');
try {
await runner.query(
`UPDATE "hashtag"
SET "${totalUserIds}" = array_remove("${totalUserIds}", $2),
"${totalUsersCount}" = ${decrementIfExists(totalUserIds, totalUsersCount)},
"${localOrRemoteUserIds}" = array_remove("${localOrRemoteUserIds}", $2),
"${localOrRemoteUserCount}" = ${decrementIfExists(localOrRemoteUserIds, localOrRemoteUserCount)}
WHERE "name" = $1`,
[tag, user.id],
);
} finally {
await runner.release();
}
if (isUserAttached) { function decrementIfExists(userIds: keyof MiHashtag & `${string}UserIds`, userCount: keyof MiHashtag & `${string}UsersCount`): string {
if (inc) { return `CASE WHEN ("${userIds}" @> ARRAY[$2]) THEN "${userCount}" - 1 ELSE "${userCount}" END`;
// 自分が初めてこのタグを使ったなら }
if (!index.attachedUserIds.some(id => id === user.id)) {
set.attachedUserIds = () => `array_append("attachedUserIds", '${user.id}')`;
set.attachedUsersCount = () => '"attachedUsersCount" + 1';
}
// 自分が(ローカル内で)初めてこのタグを使ったなら
if (this.userEntityService.isLocalUser(user) && !index.attachedLocalUserIds.some(id => id === user.id)) {
set.attachedLocalUserIds = () => `array_append("attachedLocalUserIds", '${user.id}')`;
set.attachedLocalUsersCount = () => '"attachedLocalUsersCount" + 1';
}
// 自分が(リモートで)初めてこのタグを使ったなら
if (this.userEntityService.isRemoteUser(user) && !index.attachedRemoteUserIds.some(id => id === user.id)) {
set.attachedRemoteUserIds = () => `array_append("attachedRemoteUserIds", '${user.id}')`;
set.attachedRemoteUsersCount = () => '"attachedRemoteUsersCount" + 1';
}
} else {
set.attachedUserIds = () => `array_remove("attachedUserIds", '${user.id}')`;
set.attachedUsersCount = () => '"attachedUsersCount" - 1';
if (this.userEntityService.isLocalUser(user)) {
set.attachedLocalUserIds = () => `array_remove("attachedLocalUserIds", '${user.id}')`;
set.attachedLocalUsersCount = () => '"attachedLocalUsersCount" - 1';
} else {
set.attachedRemoteUserIds = () => `array_remove("attachedRemoteUserIds", '${user.id}')`;
set.attachedRemoteUsersCount = () => '"attachedRemoteUsersCount" - 1';
}
}
} else {
// 自分が初めてこのタグを使ったなら
if (!index.mentionedUserIds.some(id => id === user.id)) {
set.mentionedUserIds = () => `array_append("mentionedUserIds", '${user.id}')`;
set.mentionedUsersCount = () => '"mentionedUsersCount" + 1';
}
// 自分が(ローカル内で)初めてこのタグを使ったなら
if (this.userEntityService.isLocalUser(user) && !index.mentionedLocalUserIds.some(id => id === user.id)) {
set.mentionedLocalUserIds = () => `array_append("mentionedLocalUserIds", '${user.id}')`;
set.mentionedLocalUsersCount = () => '"mentionedLocalUsersCount" + 1';
}
// 自分が(リモートで)初めてこのタグを使ったなら
if (this.userEntityService.isRemoteUser(user) && !index.mentionedRemoteUserIds.some(id => id === user.id)) {
set.mentionedRemoteUserIds = () => `array_append("mentionedRemoteUserIds", '${user.id}')`;
set.mentionedRemoteUsersCount = () => '"mentionedRemoteUsersCount" + 1';
}
}
if (Object.keys(set).length > 0) {
await transactionalHashtagRepository
.createQueryBuilder()
.update()
.where('id = :id', { id: index.id })
.set(set)
.execute();
}
});
} }
@bindThis @bindThis
@@ -19,7 +19,7 @@ import { bindThis } from '@/decorators.js';
import { validateContentTypeSetAsActivityPub } from '@/core/activitypub/misc/validator.js'; import { validateContentTypeSetAsActivityPub } from '@/core/activitypub/misc/validator.js';
import { assertActivityMatchesUrl, FetchAllowSoftFailMask } from '@/core/activitypub/misc/check-against-url.js'; import { assertActivityMatchesUrl, FetchAllowSoftFailMask } from '@/core/activitypub/misc/check-against-url.js';
import type { IObject } from '@/core/activitypub/type.js'; import type { IObject } from '@/core/activitypub/type.js';
import type { Response } from 'node-fetch'; import type { BodyInit, Response } from 'node-fetch';
import type { URL } from 'node:url'; import type { URL } from 'node:url';
export type HttpRequestSendOptions = { export type HttpRequestSendOptions = {
@@ -311,7 +311,7 @@ export class HttpRequestService {
url: string, url: string,
args: { args: {
method?: string, method?: string,
body?: string, body?: BodyInit,
headers?: Record<string, string>, headers?: Record<string, string>,
timeout?: number, timeout?: number,
size?: number, size?: number,
+1 -7
View File
@@ -9,7 +9,6 @@ import { DI } from '@/di-symbols.js';
import type { Config } from '@/config.js'; import type { Config } from '@/config.js';
import { baseQueueOptions, QUEUE } from '@/queue/const.js'; import { baseQueueOptions, QUEUE } from '@/queue/const.js';
import { allSettled } from '@/misc/promise-tracker.js'; import { allSettled } from '@/misc/promise-tracker.js';
import { instrumentQueue } from '@/core/telemetry/queue-instrumentation.js';
import { import {
DeliverJobData, DeliverJobData,
EndedPollNotificationJobData, EndedPollNotificationJobData,
@@ -33,12 +32,7 @@ export type UserWebhookDeliverQueue = Bull.Queue<UserWebhookDeliverJobData>;
export type SystemWebhookDeliverQueue = Bull.Queue<SystemWebhookDeliverJobData>; export type SystemWebhookDeliverQueue = Bull.Queue<SystemWebhookDeliverJobData>;
function createQueue<T extends object>(queueName: string, config: Config): Bull.Queue<T> { function createQueue<T extends object>(queueName: string, config: Config): Bull.Queue<T> {
const queue = new Bull.Queue<T>(queueName, baseQueueOptions(config, queueName)); return 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 = { const $system: Provider = {
@@ -5,7 +5,7 @@
import { Injectable } from '@nestjs/common'; import { Injectable } from '@nestjs/common';
import { bindThis } from '@/decorators.js'; import { bindThis } from '@/decorators.js';
import { captureMessage, shutdownTelemetry, startSpan, startSpanWithTraceContext } from './telemetry-registry.js'; import { captureMessage, shutdownTelemetry, startSpan } from './telemetry-registry.js';
import type { OnApplicationShutdown } from '@nestjs/common'; import type { OnApplicationShutdown } from '@nestjs/common';
import type { TelemetryCaptureMessageOptions } from './adapters/TelemetryAdapter.js'; import type { TelemetryCaptureMessageOptions } from './adapters/TelemetryAdapter.js';
@@ -21,13 +21,6 @@ export class TelemetryService implements OnApplicationShutdown {
return startSpan(name, fn); 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 @bindThis
public async onApplicationShutdown(_signal?: string): Promise<void> { public async onApplicationShutdown(_signal?: string): Promise<void> {
await shutdownTelemetry(); await shutdownTelemetry();
@@ -1,259 +0,0 @@
/*
* 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 { LogTraceContext } from '@/logging/types.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();
});
}
/** activeなSpanの識別子を、Logging基盤で扱える形式へ変換します。 */
public getActiveTraceContext(): LogTraceContext | undefined {
const activeSpan = this.deps.getActiveSpan();
if (activeSpan == null) return undefined;
const { traceId, spanId, traceFlags } = activeSpan.spanContext();
return { traceId, spanId, traceFlags };
}
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';
}
@@ -3,18 +3,13 @@
* SPDX-License-Identifier: AGPL-3.0-only * SPDX-License-Identifier: AGPL-3.0-only
*/ */
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 { LogTraceContext } from '@/logging/types.js'; import type { LogTraceContext } from '@/logging/types.js';
import type * as SentryNode from '@sentry/node'; import type * as SentryNode from '@sentry/node';
import type { NodeOptions } from '@sentry/node'; import type { NodeOptions } from '@sentry/node';
import type { OtelBackendRuntimeConfig, SentryBackendConfig, TelemetryAdapter, TelemetryCaptureMessageOptions } from './TelemetryAdapter.js'; import type { SentryBackendConfig, TelemetryAdapter, TelemetryCaptureMessageOptions } from './TelemetryAdapter.js';
import type { QueueTraceContextCarrier, QueueTraceContextDeps } from '../queue-trace-context.js';
// OpenTelemetryAdapterのDEFAULT_SHUTDOWN_TIMEOUTと揃え、Sentryのtransportが詰まってもプロセス終了を妨げないようにする。 // Sentryのtransportが詰まってもプロセス終了を妨げないようにする。
const DEFAULT_SHUTDOWN_TIMEOUT = 5000; const DEFAULT_SHUTDOWN_TIMEOUT = 5000;
const logger = new Logger('telemetry', 'green');
type SentryIntegrationsOption = NonNullable<NodeOptions['integrations']>; type SentryIntegrationsOption = NonNullable<NodeOptions['integrations']>;
// eslint-disable-next-line @typescript-eslint/no-explicit-any // eslint-disable-next-line @typescript-eslint/no-explicit-any
@@ -73,50 +68,9 @@ export function buildSentryNodeOptions(
}; };
} }
type BuildSentryOtlpInitOptions = {
sentryConfig: SentryBackendConfig;
otelConfig: OtelBackendRuntimeConfig;
otlpProcessor: unknown;
nodeProfilingIntegration?: () => SentryIntegration;
warn?: (message: string) => void;
};
export function buildSentryOtlpInitOptions(options: BuildSentryOtlpInitOptions): SentryNodeOptions {
// OTel併存時も、remoteへtrace headerを漏らさないデフォルトはSentry単体時と揃える。
// propagateTraceToRemote: true か、options.tracePropagationTargets の明示指定がある場合のみ既定を上書きする。
const { tracePropagationTargets, ...sentryOptions } = options.sentryConfig.options;
const propagateTraceToRemote = options.otelConfig.propagateTraceToRemote === true || tracePropagationTargets != null;
const warn = options.warn ?? ((message: string) => logger.warn(message));
if (options.otelConfig.sampleRate != null) {
warn('otelForBackend.sampleRate is ignored when sentryForBackend is also configured; configure sentryForBackend.options.tracesSampleRate or tracesSampler instead.');
}
if (options.otelConfig.resourceAttributes != null) {
warn('otelForBackend.resourceAttributes is ignored when sentryForBackend is also configured; configure OTEL_RESOURCE_ATTRIBUTES instead.');
}
return {
...buildSentryNodeOptions({
...options.sentryConfig,
options: {
...sentryOptions,
...(propagateTraceToRemote ? { tracePropagationTargets } : {}),
},
}, options.nodeProfilingIntegration),
// Sentryの単一TracerProviderにOTLP processorを追加し、親欠損や二重providerを避ける。
openTelemetrySpanProcessors: [
...(options.sentryConfig.options.openTelemetrySpanProcessors ?? []),
options.otlpProcessor as NonNullable<SentryNodeOptions['openTelemetrySpanProcessors']>[number],
],
};
}
export class SentryTelemetryAdapter implements TelemetryAdapter { export class SentryTelemetryAdapter implements TelemetryAdapter {
private constructor( private constructor(
private readonly Sentry: typeof SentryNode, private readonly Sentry: typeof SentryNode,
private readonly queueTraceContext?: QueueTraceContextDeps,
) { ) {
} }
@@ -129,45 +83,6 @@ export class SentryTelemetryAdapter implements TelemetryAdapter {
return new SentryTelemetryAdapter(Sentry); 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 { public captureMessage(message: string, opts: TelemetryCaptureMessageOptions): void {
this.Sentry.captureMessage(message, { this.Sentry.captureMessage(message, {
level: opts.level, level: opts.level,
@@ -189,19 +104,6 @@ export class SentryTelemetryAdapter implements TelemetryAdapter {
return this.Sentry.startSpan({ name }, fn); 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> { public async shutdown(): Promise<void> {
// timeout未指定だとtransportのflushが詰まった際にプロセス終了を妨げるため、上限時間を設ける。 // timeout未指定だとtransportのflushが詰まった際にプロセス終了を妨げるため、上限時間を設ける。
await this.Sentry.close(DEFAULT_SHUTDOWN_TIMEOUT); await this.Sentry.close(DEFAULT_SHUTDOWN_TIMEOUT);
@@ -5,19 +5,14 @@
import type { Config } from '@/config.js'; import type { Config } from '@/config.js';
import type { LogTraceContext } from '@/logging/types.js'; import type { LogTraceContext } from '@/logging/types.js';
import type { QueueTraceContextCarrier } from '../queue-trace-context.js';
export type SentryBackendConfig = NonNullable<Config['sentryForBackend']>; export type SentryBackendConfig = NonNullable<Config['sentryForBackend']>;
export type OtelBackendConfig = NonNullable<Config['otelForBackend']>;
export type OtelBackendRuntimeConfig = OtelBackendConfig & {
serviceVersion: string;
};
export interface TelemetryCaptureMessageOptions { export interface TelemetryCaptureMessageOptions {
/** 現在はエラー通知用途だけに絞る。追加する場合は各adapterでの扱いを揃えること。 */ /** 現在はエラー通知用途だけに絞る。追加する場合は各adapterでの扱いを揃えること。 */
level: 'error'; level: 'error';
/** Sentryではuser.idへ渡す。OTel adapterは現在span属性へ付与していないため、必要ならadapter側で拡張する。 */ /** Sentryではuser.idへ渡す補助情報です。 */
userId?: string; userId?: string;
/** queue名やendpoint名など、通知先で調査に使う補助情報。 */ /** queue名やendpoint名など、通知先で調査に使う補助情報。 */
@@ -25,14 +20,14 @@ export interface TelemetryCaptureMessageOptions {
} }
/** /**
* Sentry・OpenTelemetryなど、エラートラッキング/APMサービスごとの実装差異を隠蔽するための抽象。 * エラートラッキング/APMサービスごとの実装差異を隠蔽するための抽象。
* 新しいサービスを追加する場合はこのインターフェースを実装するアダプタをこのディレクトリに追加し、 * 新しいサービスを追加する場合はこのインターフェースを実装するアダプタをこのディレクトリに追加し、
* telemetry-registry.tsのinitTelemetry内で登録する。 * telemetry-registry.tsのinitTelemetry内で登録する。
*/ */
export interface TelemetryAdapter { export interface TelemetryAdapter {
/** /**
* 実行中の処理で起きたエラー相当の事象を記録する。 * 実行中の処理で起きたエラー相当の事象を記録する。
* Sentryはmessage通知、OTelはactive spanまたは短命spanへの例外記録として扱う。 * Sentryはmessage通知として扱う。
*/ */
captureMessage(message: string, opts: TelemetryCaptureMessageOptions): void; captureMessage(message: string, opts: TelemetryCaptureMessageOptions): void;
@@ -45,18 +40,6 @@ export interface TelemetryAdapter {
*/ */
startSpan<T>(name: string, fn: () => T): T; 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する。 * プロセス終了時にtelemetry backendへ残りのデータをflushする。
* 実装側ではtransport停止に引きずられないよう、待機時間に上限を設ける。 * 実装側ではtransport停止に引きずられないよう、待機時間に上限を設ける。
@@ -1,86 +0,0 @@
/*
* 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();
}
};
}
@@ -1,122 +0,0 @@
/*
* 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;
}
@@ -1,33 +0,0 @@
/*
* 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;
}
@@ -1,182 +0,0 @@
/*
* 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';
}
@@ -1,178 +0,0 @@
/*
* 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;
}
@@ -1,40 +0,0 @@
/*
* 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(' ')}`;
}
@@ -5,10 +5,8 @@
import type { Config } from '@/config.js'; import type { Config } from '@/config.js';
import { setLogTraceContextProvider } from '@/logging/logging-runtime.js'; import { setLogTraceContextProvider } from '@/logging/logging-runtime.js';
import { OpenTelemetryAdapter } from './adapters/OpenTelemetryAdapter.js';
import { SentryTelemetryAdapter } from './adapters/SentryTelemetryAdapter.js'; import { SentryTelemetryAdapter } from './adapters/SentryTelemetryAdapter.js';
import type { OtelBackendRuntimeConfig, TelemetryAdapter, TelemetryCaptureMessageOptions } from './adapters/TelemetryAdapter.js'; import type { TelemetryAdapter, TelemetryCaptureMessageOptions } from './adapters/TelemetryAdapter.js';
import type { QueueTraceContextCarrier } from './queue-trace-context.js';
/** /**
* NestのDIコンテナが構築される前(boot処理内)で初期化する必要があるため、 * NestのDIコンテナが構築される前(boot処理内)で初期化する必要があるため、
@@ -18,24 +16,8 @@ import type { QueueTraceContextCarrier } from './queue-trace-context.js';
const adapters: TelemetryAdapter[] = []; const adapters: TelemetryAdapter[] = [];
export async function initTelemetry(config: Config): Promise<void> { export async function initTelemetry(config: Config): Promise<void> {
const otelForBackend: OtelBackendRuntimeConfig | undefined = config.otelForBackend == null ? undefined : { if (config.sentryForBackend) {
...config.otelForBackend, const adapter = await SentryTelemetryAdapter.create(config.sentryForBackend);
serviceVersion: config.version,
};
// SentryとOTelを同時に使う場合はproviderを分けず、Sentry側へOTLP processorを追加する。
let adapter: TelemetryAdapter | undefined;
if (config.sentryForBackend && otelForBackend) {
adapter = await SentryTelemetryAdapter.createWithOtlpExport(config.sentryForBackend, otelForBackend);
} else if (config.sentryForBackend) {
// Sentry単体時は既存のSentry adapterだけを登録する。
adapter = await SentryTelemetryAdapter.create(config.sentryForBackend);
} else if (otelForBackend) {
// OTel単体時だけMisskey自身でNodeTracerProviderを立てる。
adapter = await OpenTelemetryAdapter.create(otelForBackend);
}
if (adapter != null) {
adapters.push(adapter); adapters.push(adapter);
// Telemetryの初期化後に登録し、初期化前のBootstrapログは従来どおり出力する。 // Telemetryの初期化後に登録し、初期化前のBootstrapログは従来どおり出力する。
setLogTraceContextProvider(() => adapter.getActiveTraceContext?.()); setLogTraceContextProvider(() => adapter.getActiveTraceContext?.());
@@ -63,19 +45,6 @@ export function startSpan<T>(name: string, fn: () => T): T {
return wrapped(); return wrapped();
} }
export function injectTraceContext(carrier: QueueTraceContextCarrier): void {
// Queue の carrier は共有データなので、通知と異なり全 adapter にブロードキャストしない。
// OTel provider は現在 1 つだけなので、同じ header を上書きしないよう最初の対応 adapter だけを使う。
adapters.find(adapter => adapter.injectTraceContext != null)?.injectTraceContext?.(carrier);
}
export function startSpanWithTraceContext<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> { export async function shutdownTelemetry(): Promise<void> {
// 終了時は登録済みadapterを並列にflush/shutdownする。 // 終了時は登録済みadapterを並列にflush/shutdownする。
await Promise.allSettled(adapters.map(adapter => adapter.shutdown())); await Promise.allSettled(adapters.map(adapter => adapter.shutdown()));
@@ -1,11 +0,0 @@
/*
* SPDX-FileCopyrightText: syuilo and misskey-project
* SPDX-License-Identifier: AGPL-3.0-only
*/
/**
* Telemetry-specific shutdown is implemented by telemetry-registry.ts.
* Signal coordination belongs to boot/shutdown-handler.ts so telemetry and
* logging remain independent domains.
*/
export { shutdownTelemetry } from './telemetry-registry.js';
@@ -73,7 +73,7 @@ export class PrettyConsoleBackend implements LogBackend {
presentationLevel === 'debug' ? chalk.gray(record.message) : presentationLevel === 'debug' ? chalk.gray(record.message) :
presentationLevel === 'info' ? record.message : presentationLevel === 'info' ? record.message :
null; null;
// 主プロセスは従来どおり「*」、子プロセスはワーカー番号で識別します。 // 主プロセスは「*」で示し、子プロセスはワーカー番号で識別します。
const worker = record.isPrimary ? '*' : record.workerId; const worker = record.isPrimary ? '*' : record.workerId;
let log = `${label} ${worker}\t[${contexts.join(' ')}]\t${message}`; let log = `${label} ${worker}\t[${contexts.join(' ')}]\t${message}`;
+6
View File
@@ -20,6 +20,7 @@ export class MiHashtag {
@Column({ @Column({
...id(), ...id(),
default: [],
array: true, array: true,
}) })
public mentionedUserIds: MiUser['id'][]; public mentionedUserIds: MiUser['id'][];
@@ -32,6 +33,7 @@ export class MiHashtag {
@Column({ @Column({
...id(), ...id(),
default: [],
array: true, array: true,
}) })
public mentionedLocalUserIds: MiUser['id'][]; public mentionedLocalUserIds: MiUser['id'][];
@@ -44,6 +46,7 @@ export class MiHashtag {
@Column({ @Column({
...id(), ...id(),
default: [],
array: true, array: true,
}) })
public mentionedRemoteUserIds: MiUser['id'][]; public mentionedRemoteUserIds: MiUser['id'][];
@@ -56,6 +59,7 @@ export class MiHashtag {
@Column({ @Column({
...id(), ...id(),
default: [],
array: true, array: true,
}) })
public attachedUserIds: MiUser['id'][]; public attachedUserIds: MiUser['id'][];
@@ -68,6 +72,7 @@ export class MiHashtag {
@Column({ @Column({
...id(), ...id(),
default: [],
array: true, array: true,
}) })
public attachedLocalUserIds: MiUser['id'][]; public attachedLocalUserIds: MiUser['id'][];
@@ -80,6 +85,7 @@ export class MiHashtag {
@Column({ @Column({
...id(), ...id(),
default: [],
array: true, array: true,
}) })
public attachedRemoteUserIds: MiUser['id'][]; public attachedRemoteUserIds: MiUser['id'][];
@@ -11,7 +11,7 @@ import type Logger from '@/logger.js';
import { bindThis } from '@/decorators.js'; import { bindThis } from '@/decorators.js';
import { TelemetryService } from '@/core/telemetry/TelemetryService.js'; import { TelemetryService } from '@/core/telemetry/TelemetryService.js';
import { CheckModeratorsActivityProcessorService } from '@/queue/processors/CheckModeratorsActivityProcessorService.js'; import { CheckModeratorsActivityProcessorService } from '@/queue/processors/CheckModeratorsActivityProcessorService.js';
import { runQueueJobWithTraceContext } from './queue-job-runner.js'; import { runQueueJob } from './queue-job-runner.js';
import { UserWebhookDeliverProcessorService } from './processors/UserWebhookDeliverProcessorService.js'; import { UserWebhookDeliverProcessorService } from './processors/UserWebhookDeliverProcessorService.js';
import { SystemWebhookDeliverProcessorService } from './processors/SystemWebhookDeliverProcessorService.js'; import { SystemWebhookDeliverProcessorService } from './processors/SystemWebhookDeliverProcessorService.js';
import { EndedPollNotificationProcessorService } from './processors/EndedPollNotificationProcessorService.js'; import { EndedPollNotificationProcessorService } from './processors/EndedPollNotificationProcessorService.js';
@@ -159,8 +159,7 @@ export class QueueProcessorService implements OnApplicationShutdown {
}; };
} }
// 以下の各 Worker は job.data に保存された enqueue 元の trace context を復元し、 // 以下の各 Worker はジョブの実処理全体を worker span で囲む。
// ジョブの実処理全体を Link または parent の worker span で囲む。
//#region system //#region system
{ {
const processer = (job: Bull.Job) => { const processer = (job: Bull.Job) => {
@@ -180,10 +179,9 @@ export class QueueProcessorService implements OnApplicationShutdown {
const logger = this.logger.createSubLogger('system'); const logger = this.logger.createSubLogger('system');
this.systemQueueWorker = new Bull.Worker(QUEUE.SYSTEM, (job) => { this.systemQueueWorker = new Bull.Worker(QUEUE.SYSTEM, (job) => {
return runQueueJobWithTraceContext( return runQueueJob(
this.telemetryService, this.telemetryService,
'Queue: System: ' + job.name, 'Queue: System: ' + job.name,
job.data,
() => processer(job) as Promise<void>, () => processer(job) as Promise<void>,
err => { err => {
logger.error(`failed(${err.name}: ${err.message}) id=${job.id}`, { job: renderJob(job), e: renderError(err) }); logger.error(`failed(${err.name}: ${err.message}) id=${job.id}`, { job: renderJob(job), e: renderError(err) });
@@ -235,10 +233,9 @@ export class QueueProcessorService implements OnApplicationShutdown {
const logger = this.logger.createSubLogger('db'); const logger = this.logger.createSubLogger('db');
this.dbQueueWorker = new Bull.Worker(QUEUE.DB, (job) => { this.dbQueueWorker = new Bull.Worker(QUEUE.DB, (job) => {
return runQueueJobWithTraceContext( return runQueueJob(
this.telemetryService, this.telemetryService,
'Queue: DB: ' + job.name, 'Queue: DB: ' + job.name,
job.data,
() => processer(job), () => processer(job),
err => { err => {
logger.error(`failed(${err.name}: ${err.message}) id=${job.id}`, { job: renderJob(job), e: renderError(err) }); logger.error(`failed(${err.name}: ${err.message}) id=${job.id}`, { job: renderJob(job), e: renderError(err) });
@@ -266,10 +263,9 @@ export class QueueProcessorService implements OnApplicationShutdown {
const logger = this.logger.createSubLogger('deliver'); const logger = this.logger.createSubLogger('deliver');
this.deliverQueueWorker = new Bull.Worker(QUEUE.DELIVER, (job) => { this.deliverQueueWorker = new Bull.Worker(QUEUE.DELIVER, (job) => {
return runQueueJobWithTraceContext( return runQueueJob(
this.telemetryService, this.telemetryService,
'Queue: Deliver', 'Queue: Deliver',
job.data,
() => this.deliverProcessorService.process(job), () => this.deliverProcessorService.process(job),
err => { err => {
logger.error(`failed(${err.name}: ${err.message}) ${getJobInfo(job)} to=${job.data.to}`, { e: renderError(err) }); logger.error(`failed(${err.name}: ${err.message}) ${getJobInfo(job)} to=${job.data.to}`, { e: renderError(err) });
@@ -305,10 +301,9 @@ export class QueueProcessorService implements OnApplicationShutdown {
const logger = this.logger.createSubLogger('inbox'); const logger = this.logger.createSubLogger('inbox');
this.inboxQueueWorker = new Bull.Worker(QUEUE.INBOX, (job) => { this.inboxQueueWorker = new Bull.Worker(QUEUE.INBOX, (job) => {
return runQueueJobWithTraceContext( return runQueueJob(
this.telemetryService, this.telemetryService,
'Queue: Inbox', 'Queue: Inbox',
job.data,
() => this.inboxProcessorService.process(job), () => this.inboxProcessorService.process(job),
err => { err => {
const activityId = job.data.activity ? job.data.activity.id : 'none'; const activityId = job.data.activity ? job.data.activity.id : 'none';
@@ -345,10 +340,9 @@ export class QueueProcessorService implements OnApplicationShutdown {
const logger = this.logger.createSubLogger('user-webhook'); const logger = this.logger.createSubLogger('user-webhook');
this.userWebhookDeliverQueueWorker = new Bull.Worker(QUEUE.USER_WEBHOOK_DELIVER, (job) => { this.userWebhookDeliverQueueWorker = new Bull.Worker(QUEUE.USER_WEBHOOK_DELIVER, (job) => {
return runQueueJobWithTraceContext( return runQueueJob(
this.telemetryService, this.telemetryService,
'Queue: UserWebhookDeliver', 'Queue: UserWebhookDeliver',
job.data,
() => this.userWebhookDeliverProcessorService.process(job), () => this.userWebhookDeliverProcessorService.process(job),
err => { err => {
logger.error(`failed(${err.name}: ${err.message}) ${getJobInfo(job)} to=${job.data.to}`, { e: renderError(err) }); logger.error(`failed(${err.name}: ${err.message}) ${getJobInfo(job)} to=${job.data.to}`, { e: renderError(err) });
@@ -384,10 +378,9 @@ export class QueueProcessorService implements OnApplicationShutdown {
const logger = this.logger.createSubLogger('system-webhook'); const logger = this.logger.createSubLogger('system-webhook');
this.systemWebhookDeliverQueueWorker = new Bull.Worker(QUEUE.SYSTEM_WEBHOOK_DELIVER, (job) => { this.systemWebhookDeliverQueueWorker = new Bull.Worker(QUEUE.SYSTEM_WEBHOOK_DELIVER, (job) => {
return runQueueJobWithTraceContext( return runQueueJob(
this.telemetryService, this.telemetryService,
'Queue: SystemWebhookDeliver', 'Queue: SystemWebhookDeliver',
job.data,
() => this.systemWebhookDeliverProcessorService.process(job), () => this.systemWebhookDeliverProcessorService.process(job),
err => { err => {
logger.error(`failed(${err.name}: ${err.message}) ${getJobInfo(job)} to=${job.data.to}`, { e: renderError(err) }); logger.error(`failed(${err.name}: ${err.message}) ${getJobInfo(job)} to=${job.data.to}`, { e: renderError(err) });
@@ -432,10 +425,9 @@ export class QueueProcessorService implements OnApplicationShutdown {
const logger = this.logger.createSubLogger('relationship'); const logger = this.logger.createSubLogger('relationship');
this.relationshipQueueWorker = new Bull.Worker(QUEUE.RELATIONSHIP, (job) => { this.relationshipQueueWorker = new Bull.Worker(QUEUE.RELATIONSHIP, (job) => {
return runQueueJobWithTraceContext( return runQueueJob(
this.telemetryService, this.telemetryService,
'Queue: Relationship: ' + job.name, 'Queue: Relationship: ' + job.name,
job.data,
() => processer(job), () => processer(job),
err => { err => {
logger.error(`failed(${err.name}: ${err.message}) id=${job.id}`, { job: renderJob(job), e: renderError(err) }); logger.error(`failed(${err.name}: ${err.message}) id=${job.id}`, { job: renderJob(job), e: renderError(err) });
@@ -475,10 +467,9 @@ export class QueueProcessorService implements OnApplicationShutdown {
const logger = this.logger.createSubLogger('objectStorage'); const logger = this.logger.createSubLogger('objectStorage');
this.objectStorageQueueWorker = new Bull.Worker(QUEUE.OBJECT_STORAGE, (job) => { this.objectStorageQueueWorker = new Bull.Worker(QUEUE.OBJECT_STORAGE, (job) => {
return runQueueJobWithTraceContext( return runQueueJob(
this.telemetryService, this.telemetryService,
'Queue: ObjectStorage: ' + job.name, 'Queue: ObjectStorage: ' + job.name,
job.data,
() => processer(job) as Promise<void>, () => processer(job) as Promise<void>,
err => { err => {
logger.error(`failed(${err.name}: ${err.message}) id=${job.id}`, { job: renderJob(job), e: renderError(err) }); logger.error(`failed(${err.name}: ${err.message}) id=${job.id}`, { job: renderJob(job), e: renderError(err) });
@@ -507,10 +498,9 @@ export class QueueProcessorService implements OnApplicationShutdown {
const logger = this.logger.createSubLogger('ended-poll-notification'); const logger = this.logger.createSubLogger('ended-poll-notification');
this.endedPollNotificationQueueWorker = new Bull.Worker(QUEUE.ENDED_POLL_NOTIFICATION, (job) => { this.endedPollNotificationQueueWorker = new Bull.Worker(QUEUE.ENDED_POLL_NOTIFICATION, (job) => {
return runQueueJobWithTraceContext( return runQueueJob(
this.telemetryService, this.telemetryService,
'Queue: EndedPollNotification', 'Queue: EndedPollNotification',
job.data,
() => this.endedPollNotificationProcessorService.process(job), () => this.endedPollNotificationProcessorService.process(job),
err => { err => {
logger.error(`failed(${err.name}: ${err.message}) id=${job.id}`, { job: renderJob(job), e: renderError(err) }); logger.error(`failed(${err.name}: ${err.message}) id=${job.id}`, { job: renderJob(job), e: renderError(err) });
@@ -532,10 +522,9 @@ export class QueueProcessorService implements OnApplicationShutdown {
const logger = this.logger.createSubLogger('post-scheduled-note'); const logger = this.logger.createSubLogger('post-scheduled-note');
this.postScheduledNoteQueueWorker = new Bull.Worker(QUEUE.POST_SCHEDULED_NOTE, (job) => { this.postScheduledNoteQueueWorker = new Bull.Worker(QUEUE.POST_SCHEDULED_NOTE, (job) => {
return runQueueJobWithTraceContext( return runQueueJob(
this.telemetryService, this.telemetryService,
'Queue: PostScheduledNote', 'Queue: PostScheduledNote',
job.data,
() => this.postScheduledNoteProcessorService.process(job), () => this.postScheduledNoteProcessorService.process(job),
err => { err => {
logger.error(`failed(${err.name}: ${err.message}) id=${job.id}`, { job: renderJob(job), e: renderError(err) }); logger.error(`failed(${err.name}: ${err.message}) id=${job.id}`, { job: renderJob(job), e: renderError(err) });
@@ -5,17 +5,16 @@
import type { TelemetryService } from '@/core/telemetry/TelemetryService.js'; import type { TelemetryService } from '@/core/telemetry/TelemetryService.js';
type QueueTelemetryService = Pick<TelemetryService, 'startSpanWithTraceContext'>; type QueueTelemetryService = Pick<TelemetryService, 'startSpan'>;
/** QueueのprocessorをTrace Context付きで実行し、失敗処理をSpan内で行います。 */ /** Queueのprocessorを実行し、失敗処理をSpan内で行います。 */
export function runQueueJobWithTraceContext<T>( export function runQueueJob<T>(
telemetryService: QueueTelemetryService, telemetryService: QueueTelemetryService,
spanName: string, spanName: string,
jobData: object,
processJob: () => T | Promise<T>, processJob: () => T | Promise<T>,
onError: (error: Error) => void, onError: (error: Error) => void,
): Promise<T> { ): Promise<T> {
return telemetryService.startSpanWithTraceContext(spanName, jobData, async (): Promise<T> => { return telemetryService.startSpan(spanName, async (): Promise<T> => {
try { try {
return await processJob(); return await processJob();
} catch (error) { } catch (error) {
@@ -32,7 +32,6 @@ import { HealthServerService } from './HealthServerService.js';
import { ClientServerService } from './web/ClientServerService.js'; import { ClientServerService } from './web/ClientServerService.js';
import { OpenApiServerService } from './api/openapi/OpenApiServerService.js'; import { OpenApiServerService } from './api/openapi/OpenApiServerService.js';
import { OAuth2ProviderService } from './oauth/OAuth2ProviderService.js'; import { OAuth2ProviderService } from './oauth/OAuth2ProviderService.js';
import { registerHttpServerInstrumentation } from './http-server-instrumentation.js';
import { registerHttpAccessLog } from './http-access-log.js'; import { registerHttpAccessLog } from './http-access-log.js';
const _dirname = fileURLToPath(new URL('.', import.meta.url)); const _dirname = fileURLToPath(new URL('.', import.meta.url));
@@ -82,7 +81,6 @@ export class ServerService implements OnApplicationShutdown {
logger: false, logger: false,
}); });
this.#fastify = fastify; this.#fastify = fastify;
await registerHttpServerInstrumentation(fastify, this.config);
registerHttpAccessLog(fastify); registerHttpAccessLog(fastify);
// HSTS // HSTS
@@ -10,6 +10,7 @@ import { ApiError } from '@/server/api/error.js';
import type { UsersRepository, UserProfilesRepository, MiMeta } from '@/models/_.js'; import type { UsersRepository, UserProfilesRepository, MiMeta } from '@/models/_.js';
import { DI } from '@/di-symbols.js'; import { DI } from '@/di-symbols.js';
import { secureRndstr } from '@/misc/secure-rndstr.js'; import { secureRndstr } from '@/misc/secure-rndstr.js';
import { RoleService } from '@/core/RoleService.js';
import { ModerationLogService } from '@/core/ModerationLogService.js'; import { ModerationLogService } from '@/core/ModerationLogService.js';
export const meta = { export const meta = {
@@ -25,10 +26,10 @@ export const meta = {
code: 'NO_SUCH_USER', code: 'NO_SUCH_USER',
id: 'ccafc7fe-5074-4edd-9dc0-8ef9ef6a701d', id: 'ccafc7fe-5074-4edd-9dc0-8ef9ef6a701d',
}, },
cannotResetPasswordOfRootUser: { accessDenied: {
message: 'Cannot reset password of the root user.', message: 'Access denied.',
code: 'CANNOT_RESET_PASSWORD_OF_ROOT_USER', code: 'ACCESS_DENIED',
id: 'f28fc207-42ca-44c7-a577-44b4f0ec5999', id: 'cda8f8ce-89a6-4f92-8055-33bbe0c1464d',
}, },
}, },
@@ -66,6 +67,7 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
@Inject(DI.userProfilesRepository) @Inject(DI.userProfilesRepository)
private userProfilesRepository: UserProfilesRepository, private userProfilesRepository: UserProfilesRepository,
private roleService: RoleService,
private moderationLogService: ModerationLogService, private moderationLogService: ModerationLogService,
) { ) {
super(meta, paramDef, async (ps, me) => { super(meta, paramDef, async (ps, me) => {
@@ -75,8 +77,8 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
throw new ApiError(meta.errors.noSuchUser); throw new ApiError(meta.errors.noSuchUser);
} }
if (this.serverSettings.rootUserId === user.id) { if (await this.roleService.isAdministrator(user) && me.id !== user.id) {
throw new ApiError(meta.errors.cannotResetPasswordOfRootUser); throw new ApiError(meta.errors.accessDenied);
} }
const passwd = secureRndstr(8); const passwd = secureRndstr(8);
@@ -11,6 +11,7 @@ import { MiUserProfile } from '@/models/UserProfile.js';
import { MiUserSecurityKey } from '@/models/UserSecurityKey.js'; import { MiUserSecurityKey } from '@/models/UserSecurityKey.js';
import type { UsersRepository } from '@/models/_.js'; import type { UsersRepository } from '@/models/_.js';
import { DI } from '@/di-symbols.js'; import { DI } from '@/di-symbols.js';
import { RoleService } from '@/core/RoleService.js';
import { ModerationLogService } from '@/core/ModerationLogService.js'; import { ModerationLogService } from '@/core/ModerationLogService.js';
export const meta = { export const meta = {
@@ -26,6 +27,11 @@ export const meta = {
code: 'NO_SUCH_USER', code: 'NO_SUCH_USER',
id: 'ccafc7fe-5074-4edd-9dc0-8ef9ef6a701d', id: 'ccafc7fe-5074-4edd-9dc0-8ef9ef6a701d',
}, },
accessDenied: {
message: 'Access denied.',
code: 'ACCESS_DENIED',
id: 'cda8f8ce-89a6-4f92-8055-33bbe0c1464d',
},
}, },
} as const; } as const;
@@ -46,6 +52,7 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
@Inject(DI.usersRepository) @Inject(DI.usersRepository)
private usersRepository: UsersRepository, private usersRepository: UsersRepository,
private roleService: RoleService,
private moderationLogService: ModerationLogService, private moderationLogService: ModerationLogService,
) { ) {
super(meta, paramDef, async (ps, me) => { super(meta, paramDef, async (ps, me) => {
@@ -55,6 +62,10 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
throw new ApiError(meta.errors.noSuchUser); throw new ApiError(meta.errors.noSuchUser);
} }
if (await this.roleService.isAdministrator(user) && me.id !== user.id) {
throw new ApiError(meta.errors.accessDenied);
}
await this.db.transaction(async (transactionalEntityManager) => { await this.db.transaction(async (transactionalEntityManager) => {
// パスキーを全て削除 // パスキーを全て削除
await transactionalEntityManager.delete(MiUserSecurityKey, { userId: user.id }); await transactionalEntityManager.delete(MiUserSecurityKey, { userId: user.id });
@@ -8,6 +8,7 @@ import { Endpoint } from '@/server/api/endpoint-base.js';
import type { ChannelsRepository } from '@/models/_.js'; import type { ChannelsRepository } from '@/models/_.js';
import { DI } from '@/di-symbols.js'; import { DI } from '@/di-symbols.js';
import { ChannelFollowingService } from '@/core/ChannelFollowingService.js'; import { ChannelFollowingService } from '@/core/ChannelFollowingService.js';
import { IdentifiableError } from '@/misc/identifiable-error.js';
import { ApiError } from '../../error.js'; import { ApiError } from '../../error.js';
export const meta = { export const meta = {
@@ -25,6 +26,11 @@ export const meta = {
code: 'NO_SUCH_CHANNEL', code: 'NO_SUCH_CHANNEL',
id: 'c0031718-d573-4e85-928e-10039f1fbb68', id: 'c0031718-d573-4e85-928e-10039f1fbb68',
}, },
alreadyFollowing: {
message: 'You are already following that channel.',
code: 'ALREADY_FOLLOWING',
id: '7db31665-651e-40c1-8e6e-28e9ad829a2d',
},
}, },
} as const; } as const;
@@ -52,7 +58,14 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
throw new ApiError(meta.errors.noSuchChannel); throw new ApiError(meta.errors.noSuchChannel);
} }
await this.channelFollowingService.follow(me, channel); try {
await this.channelFollowingService.follow(me, channel);
} catch (e) {
if (e instanceof IdentifiableError) {
if (e.id === '6e335e39-0203-4418-a936-b3f2dc987845') throw new ApiError(meta.errors.alreadyFollowing);
}
throw e;
}
}); });
} }
} }
@@ -7,8 +7,11 @@ import Parser from 'rss-parser';
import { Injectable } from '@nestjs/common'; import { Injectable } from '@nestjs/common';
import { Endpoint } from '@/server/api/endpoint-base.js'; import { Endpoint } from '@/server/api/endpoint-base.js';
import { HttpRequestService } from '@/core/HttpRequestService.js'; import { HttpRequestService } from '@/core/HttpRequestService.js';
import { ApiError } from '../error.js';
const rssParser = new Parser(); const MAX_URL_LENGTH = 8192;
const MAX_RESPONSE_SIZE = 1024 * 1024;
const MAX_CONCURRENT_REQUESTS = 32;
export const meta = { export const meta = {
tags: ['meta'], tags: ['meta'],
@@ -17,6 +20,34 @@ export const meta = {
allowGet: true, allowGet: true,
cacheSec: 60 * 3, cacheSec: 60 * 3,
limit: {
duration: 60 * 1000,
max: 300,
},
errors: {
invalidUrl: {
message: 'Invalid URL.',
code: 'INVALID_URL',
id: '89b7ee05-ccfc-4bdd-9b13-61172fd1e06c',
httpStatusCode: 400,
},
fetchRssFailed: {
message: 'Failed to fetch RSS.',
code: 'FETCH_RSS_FAILED',
id: '8db5d3d8-31d7-452f-b0cc-ca3b8925de12',
kind: 'server',
httpStatusCode: 422,
},
fetchRssUnavailable: {
message: 'RSS fetching is temporarily unavailable.',
code: 'FETCH_RSS_UNAVAILABLE',
id: '91e6ff44-c63f-4725-9ad0-b7a40d7f7655',
kind: 'server',
httpStatusCode: 503,
},
},
res: { res: {
type: 'object', type: 'object',
properties: { properties: {
@@ -215,21 +246,84 @@ export const paramDef = {
@Injectable() @Injectable()
export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-disable-line import/no-default-export export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-disable-line import/no-default-export
private readonly inFlightRequests = new Map<string, Promise<Awaited<ReturnType<Parser['parseString']>>>>();
private activeRequestCount = 0;
constructor( constructor(
private httpRequestService: HttpRequestService, private httpRequestService: HttpRequestService,
) { ) {
super(meta, paramDef, async (ps, me) => { super(meta, paramDef, async (ps) => {
const res = await this.httpRequestService.send(ps.url, { const url = this.normalizeUrl(ps.url);
method: 'GET', const inFlightRequest = this.inFlightRequests.get(url);
headers: { if (inFlightRequest != null) {
Accept: 'application/rss+xml, */*', return await inFlightRequest;
}, }
timeout: 5000,
});
const text = await res.text(); if (this.activeRequestCount >= MAX_CONCURRENT_REQUESTS) {
throw new ApiError(meta.errors.fetchRssUnavailable);
}
return rssParser.parseString(text); this.activeRequestCount++;
const request = this.fetchRss(url)
.catch(() => {
throw new ApiError(meta.errors.fetchRssFailed);
})
.finally(() => {
this.inFlightRequests.delete(url);
this.activeRequestCount--;
});
this.inFlightRequests.set(url, request);
return await request;
}); });
} }
private normalizeUrl(input: string): string {
if (input.length === 0 || input.length > MAX_URL_LENGTH) {
throw new ApiError(meta.errors.invalidUrl);
}
let url: URL;
try {
url = new URL(input);
} catch {
throw new ApiError(meta.errors.invalidUrl);
}
if (
(url.protocol !== 'http:' && url.protocol !== 'https:') ||
url.username !== '' ||
url.password !== ''
) {
throw new ApiError(meta.errors.invalidUrl);
}
url.hash = '';
return url.href;
}
private async fetchRss(url: string): Promise<Awaited<ReturnType<Parser['parseString']>>> {
const res = await this.httpRequestService.send(url, {
method: 'GET',
headers: {
Accept: 'application/rss+xml, */*',
},
timeout: 5000,
size: MAX_RESPONSE_SIZE,
});
const finalUrl = new URL(res.url);
if (finalUrl.protocol !== 'http:' && finalUrl.protocol !== 'https:') {
throw new Error('Invalid final URL protocol');
}
const text = await res.text();
const rssParser = new Parser({
xml2js: {
async: true,
},
});
return await rssParser.parseString(text);
}
} }
@@ -123,7 +123,7 @@ export function registerHttpAccessLog(fastify: FastifyInstance, manager: LogMana
const states = new WeakMap<object, AccessRequestState>(); const states = new WeakMap<object, AccessRequestState>();
// HTTP計装の後に登録し、リクエスト開始時のactiveなTrace Contextを保存します。 // リクエスト開始時のactiveなTrace Contextを保存します。
fastify.addHook('onRequest', (request, _reply, done) => { fastify.addHook('onRequest', (request, _reply, done) => {
states.set(request, { states.set(request, {
traceContext: manager.getActiveTraceContext(), traceContext: manager.getActiveTraceContext(),
@@ -1,35 +0,0 @@
/*
* 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());
}
@@ -4,3 +4,4 @@ volumes
docker.env docker.env
*.test.conf *.test.conf
*.test.default.yml *.test.default.yml
*.test.config.json
+1 -1
View File
@@ -28,7 +28,7 @@ function generate {
-days 500 -days 500
if [ ! -f .config/docker.env ]; then cp .config/example.docker.env .config/docker.env; fi if [ ! -f .config/docker.env ]; then cp .config/example.docker.env .config/docker.env; fi
if [ ! -f .config/$1.conf ]; then sed "s/\${HOST}/$1/g" .config/example.conf > .config/$1.conf; fi if [ ! -f .config/$1.conf ]; then sed "s/\${HOST}/$1/g" .config/example.conf > .config/$1.conf; fi
if [ ! -f .config/$1.default.yml ]; then sed "s/\${HOST}/$1/g" .config/example.config.json > .config/$1.config.json; fi if [ ! -f .config/$1.config.json ]; then sed "s/\${HOST}/$1/g" .config/example.config.json > .config/$1.config.json; fi
} }
generate a.test generate a.test
+36
View File
@@ -0,0 +1,36 @@
/*
* SPDX-FileCopyrightText: syuilo and misskey-project
* SPDX-License-Identifier: AGPL-3.0-only
*/
process.env.NODE_ENV = 'test';
import * as assert from 'assert';
import { describe, beforeAll, test } from 'vitest';
import { api, castAsError, signup } from '../utils.js';
import type * as misskey from 'misskey-js';
describe('Channel', () => {
let alice: misskey.entities.SignupResponse;
beforeAll(async () => {
alice = await signup({ username: 'alice' });
});
describe('Follow', () => {
let channel: misskey.entities.ChannelsCreateResponse;
beforeAll(async () => {
const res = await api('channels/create', { name: 'follow-test-channel' }, alice);
channel = res.body;
});
test('フォローしているチャンネルを再度フォローするとALREADY_FOLLOWINGエラーになる', async () => {
const res1 = await api('channels/follow', { channelId: channel.id }, alice);
assert.strictEqual(res1.status, 204);
const res2 = await api('channels/follow', { channelId: channel.id }, alice);
assert.strictEqual(res2.status, 400);
assert.strictEqual(castAsError(res2.body as any).error.code, 'ALREADY_FOLLOWING');
});
});
});
@@ -1,88 +0,0 @@
/*
* 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);
});
+32 -33
View File
@@ -4,19 +4,12 @@
*/ */
import { describe, test, expect, vi, beforeEach } from 'vitest'; import { describe, test, expect, vi, beforeEach } from 'vitest';
import type { AiService as AiServiceType, Prediction } from '@/core/AiService.js';
import type { MiMeta } from '@/models/_.js'; import type { MiMeta } from '@/models/_.js';
import type { HttpRequestService } from '@/core/HttpRequestService.js'; import type { HttpRequestService } from '@/core/HttpRequestService.js';
import type { LoggerService } from '@/core/LoggerService.js'; import type { LoggerService } from '@/core/LoggerService.js';
import { AiService, type Prediction } from '@/core/AiService.js';
// AiService が直接 import している node-fetch をモックして、外部サービスへの送信内容と const sendMock = vi.fn();
// レスポンス解釈を検証する。
const { fetchMock } = vi.hoisted(() => ({ fetchMock: vi.fn() }));
vi.mock('node-fetch', () => ({ default: fetchMock }));
const { AiService } = await import('@/core/AiService.js');
let getAgentByUrlMock: ReturnType<typeof vi.fn>;
const DEFAULT_META = { const DEFAULT_META = {
sensitiveMediaDetectionApiUrl: 'http://localhost:3009' as string | null, sensitiveMediaDetectionApiUrl: 'http://localhost:3009' as string | null,
@@ -25,10 +18,9 @@ const DEFAULT_META = {
sensitiveMediaDetectionMaxImagesPerRequest: 4, sensitiveMediaDetectionMaxImagesPerRequest: 4,
}; };
function makeService(metaOverrides: Partial<typeof DEFAULT_META> = {}): AiServiceType { function makeService(metaOverrides: Partial<typeof DEFAULT_META> = {}): AiService {
const meta = { ...DEFAULT_META, ...metaOverrides } as unknown as MiMeta; const meta = { ...DEFAULT_META, ...metaOverrides } as unknown as MiMeta;
getAgentByUrlMock = vi.fn(() => undefined); const httpRequestService = { send: sendMock } as unknown as HttpRequestService;
const httpRequestService = { getAgentByUrl: getAgentByUrlMock } as unknown as HttpRequestService;
const loggerService = { const loggerService = {
getLogger: () => ({ warn: () => {}, error: () => {}, info: () => {} }), getLogger: () => ({ warn: () => {}, error: () => {}, info: () => {} }),
} as unknown as LoggerService; } as unknown as LoggerService;
@@ -52,11 +44,11 @@ const buf = (s: string) => Buffer.from(s);
describe('AiService', () => { describe('AiService', () => {
beforeEach(() => { beforeEach(() => {
fetchMock.mockReset(); sendMock.mockReset();
}); });
test('正常: 送信順を保った予測値配列を返す', async () => { test('正常: 送信順を保った予測値配列を返す', async () => {
fetchMock.mockResolvedValue(okResponse([ sendMock.mockResolvedValue(okResponse([
{ success: true, predictions: neutral() }, { success: true, predictions: neutral() },
{ success: true, predictions: [{ className: 'Porn', probability: 0.8 }] }, { success: true, predictions: [{ className: 'Porn', probability: 0.8 }] },
])); ]));
@@ -66,30 +58,35 @@ describe('AiService', () => {
[{ className: 'Neutral', probability: 0.99 }], [{ className: 'Neutral', probability: 0.99 }],
[{ className: 'Porn', probability: 0.8 }], [{ className: 'Porn', probability: 0.8 }],
]); ]);
expect(fetchMock).toHaveBeenCalledTimes(1); expect(sendMock).toHaveBeenCalledTimes(1);
expect(fetchMock.mock.calls[0][0]).toBe('http://localhost:3009/v1/detect-images'); expect(sendMock.mock.calls[0][0]).toBe('http://localhost:3009/v1/detect-images');
}); });
test('外部サービス: 通常の outbound agent を使用する', async () => { test('外部サービス: HttpRequestService を使用する', async () => {
fetchMock.mockResolvedValue(okResponse([{ success: true, predictions: neutral() }])); sendMock.mockResolvedValue(okResponse([{ success: true, predictions: neutral() }]));
const svc = makeService({ sensitiveMediaDetectionApiUrl: 'https://detector.example.com' }); const svc = makeService({ sensitiveMediaDetectionApiUrl: 'https://detector.example.com' });
await svc.detectSensitiveMany([buf('a')]); await svc.detectSensitiveMany([buf('a')]);
const requestOptions = fetchMock.mock.calls[0][1] as { agent: (url: URL) => unknown }; expect(sendMock).toHaveBeenCalledWith('https://detector.example.com/v1/detect-images', {
requestOptions.agent(new URL('https://detector.example.com/v1/detect-images')); method: 'POST',
expect(getAgentByUrlMock).toHaveBeenCalledWith(expect.any(URL)); headers: {},
body: expect.any(FormData),
timeout: 5000,
}, {
throwErrorWhenResponseNotOk: false,
});
}); });
test('detectSensitive: 単一画像はバッチの先頭を返す', async () => { test('detectSensitive: 単一画像はバッチの先頭を返す', async () => {
fetchMock.mockResolvedValue(okResponse([{ success: true, predictions: neutral() }])); sendMock.mockResolvedValue(okResponse([{ success: true, predictions: neutral() }]));
const svc = makeService(); const svc = makeService();
const res = await svc.detectSensitive(buf('a')); const res = await svc.detectSensitive(buf('a'));
expect(res).toEqual(neutral()); expect(res).toEqual(neutral());
}); });
test('部分失敗: 失敗パーツのみ null になる', async () => { test('部分失敗: 失敗パーツのみ null になる', async () => {
fetchMock.mockResolvedValue(okResponse([ sendMock.mockResolvedValue(okResponse([
{ success: true, predictions: neutral() }, { success: true, predictions: neutral() },
{ success: false, error: { code: 'IMAGE_DECODE_FAILED', message: 'x' } }, { success: false, error: { code: 'IMAGE_DECODE_FAILED', message: 'x' } },
])); ]));
@@ -100,14 +97,14 @@ describe('AiService', () => {
}); });
test('非200: チャンク全件 null(例外を投げない)', async () => { test('非200: チャンク全件 null(例外を投げない)', async () => {
fetchMock.mockResolvedValue({ ok: false, status: 503, statusText: 'Service Unavailable', json: async () => ({}) }); sendMock.mockResolvedValue({ ok: false, status: 503, statusText: 'Service Unavailable', json: async () => ({}) });
const svc = makeService(); const svc = makeService();
const res = await svc.detectSensitiveMany([buf('a'), buf('b')]); const res = await svc.detectSensitiveMany([buf('a'), buf('b')]);
expect(res).toEqual([null, null]); expect(res).toEqual([null, null]);
}); });
test('通信エラー: チャンク全件 null(例外を投げない)', async () => { test('通信エラー: チャンク全件 null(例外を投げない)', async () => {
fetchMock.mockRejectedValue(new Error('network down')); sendMock.mockRejectedValue(new Error('network down'));
const svc = makeService(); const svc = makeService();
const res = await svc.detectSensitiveMany([buf('a')]); const res = await svc.detectSensitiveMany([buf('a')]);
expect(res).toEqual([null]); expect(res).toEqual([null]);
@@ -117,11 +114,11 @@ describe('AiService', () => {
const svc = makeService({ sensitiveMediaDetectionApiUrl: null }); const svc = makeService({ sensitiveMediaDetectionApiUrl: null });
const res = await svc.detectSensitiveMany([buf('a'), buf('b')]); const res = await svc.detectSensitiveMany([buf('a'), buf('b')]);
expect(res).toEqual([null, null]); expect(res).toEqual([null, null]);
expect(fetchMock).not.toHaveBeenCalled(); expect(sendMock).not.toHaveBeenCalled();
}); });
test('チャンク分割: maxImagesPerRequest ごとに順次送信する', async () => { test('チャンク分割: maxImagesPerRequest ごとに順次送信する', async () => {
fetchMock.mockResolvedValue(okResponse([ sendMock.mockResolvedValue(okResponse([
{ success: true, predictions: neutral() }, { success: true, predictions: neutral() },
{ success: true, predictions: neutral() }, { success: true, predictions: neutral() },
{ success: true, predictions: neutral() }, { success: true, predictions: neutral() },
@@ -130,22 +127,24 @@ describe('AiService', () => {
const svc = makeService({ sensitiveMediaDetectionMaxImagesPerRequest: 2 }); const svc = makeService({ sensitiveMediaDetectionMaxImagesPerRequest: 2 });
const res = await svc.detectSensitiveMany([buf('a'), buf('b'), buf('c'), buf('d'), buf('e')]); const res = await svc.detectSensitiveMany([buf('a'), buf('b'), buf('c'), buf('d'), buf('e')]);
// 5 枚を 2 枚ずつ → 3 リクエスト、結果は順序を保って 5 件。 // 5 枚を 2 枚ずつ → 3 リクエスト、結果は順序を保って 5 件。
expect(fetchMock).toHaveBeenCalledTimes(3); expect(sendMock).toHaveBeenCalledTimes(3);
expect(res).toHaveLength(5); expect(res).toHaveLength(5);
expect(res.every(x => x != null)).toBe(true); expect(res.every(x => x != null)).toBe(true);
}); });
test('APIキー設定時のみ Authorization: Bearer を付与する', async () => { test('APIキー設定時のみ Authorization: Bearer を付与する', async () => {
fetchMock.mockResolvedValue(okResponse([{ success: true, predictions: neutral() }])); sendMock.mockResolvedValue(okResponse([{ success: true, predictions: neutral() }]));
const withKey = makeService({ sensitiveMediaDetectionApiKey: 'secret' }); const withKey = makeService({ sensitiveMediaDetectionApiKey: 'secret' });
await withKey.detectSensitiveMany([buf('a')]); await withKey.detectSensitiveMany([buf('a')]);
expect((fetchMock.mock.calls[0][1] as any).headers.Authorization).toBe('Bearer secret'); const withKeyHeaders = (sendMock.mock.calls[0][1] as { headers: Record<string, string> }).headers;
expect(withKeyHeaders.Authorization).toBe('Bearer secret');
fetchMock.mockClear(); sendMock.mockClear();
fetchMock.mockResolvedValue(okResponse([{ success: true, predictions: neutral() }])); sendMock.mockResolvedValue(okResponse([{ success: true, predictions: neutral() }]));
const withoutKey = makeService(); const withoutKey = makeService();
await withoutKey.detectSensitiveMany([buf('a')]); await withoutKey.detectSensitiveMany([buf('a')]);
expect((fetchMock.mock.calls[0][1] as any).headers.Authorization).toBeUndefined(); const withoutKeyHeaders = (sendMock.mock.calls[0][1] as { headers: Record<string, string> }).headers;
expect(withoutKeyHeaders.Authorization).toBeUndefined();
}); });
}); });
@@ -1,379 +0,0 @@
/*
* 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('returns the active span context for log enrichment', () => {
const adapter = new OpenTelemetryAdapter({
tracer: { startActiveSpan: vi.fn() },
provider: { shutdown: vi.fn() },
getActiveSpan: () => ({
spanContext: () => ({
traceId: '0123456789abcdef0123456789abcdef',
spanId: '0123456789abcdef',
traceFlags: 0,
}),
} as any),
spanStatusCodeError: SpanStatusCode.ERROR,
shutdownTimeout: 10,
});
expect(adapter.getActiveTraceContext()).toEqual({
traceId: '0123456789abcdef0123456789abcdef',
spanId: '0123456789abcdef',
traceFlags: 0,
});
});
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');
});
});
@@ -4,7 +4,7 @@
*/ */
import { describe, expect, test, vi } from 'vitest'; import { describe, expect, test, vi } from 'vitest';
import { SentryTelemetryAdapter, buildSentryIntegrations, buildSentryNodeOptions, buildSentryOtlpInitOptions } from '@/core/telemetry/adapters/SentryTelemetryAdapter.js'; import { SentryTelemetryAdapter, buildSentryIntegrations, buildSentryNodeOptions } from '@/core/telemetry/adapters/SentryTelemetryAdapter.js';
type TestIntegration = Parameters<ReturnType<typeof buildSentryIntegrations>>[0][number]; type TestIntegration = Parameters<ReturnType<typeof buildSentryIntegrations>>[0][number];
@@ -35,9 +35,7 @@ describe('SentryTelemetryAdapter', () => {
nodeProfilingIntegration: () => testIntegration('ProfilingIntegration'), nodeProfilingIntegration: () => testIntegration('ProfilingIntegration'),
}); });
const result = integrations([ const result = integrations([testIntegration('Http')]);
testIntegration('Http'),
]);
expect(result.map((integration: TestIntegration) => integration.name)).toEqual(['Http', 'ProfilingIntegration']); expect(result.map((integration: TestIntegration) => integration.name)).toEqual(['Http', 'ProfilingIntegration']);
}); });
@@ -50,9 +48,7 @@ describe('SentryTelemetryAdapter', () => {
warn, warn,
}); });
const result = integrations([ const result = integrations([testIntegration('Http')]);
testIntegration('Http'),
]);
expect(result.map((integration: TestIntegration) => integration.name)).toEqual(['Http']); expect(result.map((integration: TestIntegration) => integration.name)).toEqual(['Http']);
expect(warn).toHaveBeenCalledWith('Unknown Sentry integration configured in sentryForBackend.disabledIntegrations: Unknown'); expect(warn).toHaveBeenCalledWith('Unknown Sentry integration configured in sentryForBackend.disabledIntegrations: Unknown');
@@ -77,88 +73,6 @@ describe('SentryTelemetryAdapter', () => {
expect(options.tracePropagationTargets).toEqual(['^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 trace context', () => { describe('SentryTelemetryAdapter trace context', () => {
@@ -220,69 +134,3 @@ describe('SentryTelemetryAdapter.shutdown', () => {
vi.doUnmock('@sentry/profiling-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');
});
});
@@ -1,108 +0,0 @@
/*
* 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 });
});
});
@@ -1,57 +0,0 @@
/*
* 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);
});
});
@@ -1,132 +0,0 @@
/*
* 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');
});
});
@@ -4,13 +4,13 @@
*/ */
import { describe, expect, test, vi } from 'vitest'; import { describe, expect, test, vi } from 'vitest';
import { runQueueJobWithTraceContext } from '@/queue/queue-job-runner.js'; import { runQueueJob } from '@/queue/queue-job-runner.js';
import { TelemetryService } from '@/core/telemetry/TelemetryService.js'; import { TelemetryService } from '@/core/telemetry/TelemetryService.js';
describe('runQueueJobWithTraceContext', () => { describe('runQueueJob', () => {
test('returns the processor result without invoking the error handler', async () => { test('returns the processor result without invoking the error handler', async () => {
let spanActive = false; let spanActive = false;
const startSpanWithTraceContext = vi.fn(<T>(_name: string, _jobData: object, fn: () => T): T => { const startSpan = vi.fn(<T>(_name: string, fn: () => T): T => {
spanActive = true; spanActive = true;
const result = fn(); const result = fn();
if (result instanceof Promise) return result.finally(() => { spanActive = false; }) as T; if (result instanceof Promise) return result.finally(() => { spanActive = false; }) as T;
@@ -18,11 +18,11 @@ describe('runQueueJobWithTraceContext', () => {
return result; return result;
}); });
const telemetryService = { const telemetryService = {
startSpanWithTraceContext, startSpan,
} as unknown as TelemetryService; } as unknown as TelemetryService;
const onError = vi.fn(); const onError = vi.fn();
await expect(runQueueJobWithTraceContext(telemetryService, 'Queue: test', {}, () => 'ok', onError)).resolves.toBe('ok'); await expect(runQueueJob(telemetryService, 'Queue: test', () => 'ok', onError)).resolves.toBe('ok');
expect(onError).not.toHaveBeenCalled(); expect(onError).not.toHaveBeenCalled();
expect(spanActive).toBe(false); expect(spanActive).toBe(false);
@@ -30,7 +30,7 @@ describe('runQueueJobWithTraceContext', () => {
test('handles failures while the processor span is active and rethrows the original error', async () => { test('handles failures while the processor span is active and rethrows the original error', async () => {
let spanActive = false; let spanActive = false;
const startSpanWithTraceContext = vi.fn(<T>(_name: string, _jobData: object, fn: () => T): T => { const startSpan = vi.fn(<T>(_name: string, fn: () => T): T => {
spanActive = true; spanActive = true;
const result = fn(); const result = fn();
if (result instanceof Promise) return result.finally(() => { spanActive = false; }) as T; if (result instanceof Promise) return result.finally(() => { spanActive = false; }) as T;
@@ -38,7 +38,7 @@ describe('runQueueJobWithTraceContext', () => {
return result; return result;
}); });
const telemetryService = { const telemetryService = {
startSpanWithTraceContext, startSpan,
} as unknown as TelemetryService; } as unknown as TelemetryService;
const onError = vi.fn((error: Error) => { const onError = vi.fn((error: Error) => {
expect(spanActive).toBe(true); expect(spanActive).toBe(true);
@@ -46,7 +46,7 @@ describe('runQueueJobWithTraceContext', () => {
}); });
const originalError = new Error('failed'); const originalError = new Error('failed');
await expect(runQueueJobWithTraceContext(telemetryService, 'Queue: test', {}, async () => { await expect(runQueueJob(telemetryService, 'Queue: test', async () => {
throw originalError; throw originalError;
}, onError)).rejects.toBe(originalError); }, onError)).rejects.toBe(originalError);
@@ -0,0 +1,192 @@
/*
* SPDX-FileCopyrightText: syuilo and misskey-project
* SPDX-License-Identifier: AGPL-3.0-only
*/
import { beforeEach, describe, expect, test, vi } from 'vitest';
import { HttpRequestService } from '@/core/HttpRequestService.js';
import FetchRssEndpoint, { meta } from '@/server/api/endpoints/fetch-rss.js';
import { ApiError } from '@/server/api/error.js';
import type { Mocked } from 'vitest';
import type { Response } from 'node-fetch';
const rssParserMocks = vi.hoisted(() => ({
constructor: vi.fn(),
parseString: vi.fn(),
}));
vi.mock('rss-parser', () => ({
default: class {
constructor(options: unknown) {
rssParserMocks.constructor(options);
}
public parseString(input: string) {
return rssParserMocks.parseString(input);
}
},
}));
const RSS = '<?xml version="1.0"?><rss version="2.0"><channel><title>Test</title></channel></rss>';
function deferred<T>() {
let resolve!: (value: T) => void;
let reject!: (reason?: unknown) => void;
const promise = new Promise<T>((res, rej) => {
resolve = res;
reject = rej;
});
return { promise, resolve, reject };
}
function response(url: string, text = RSS): Response {
return {
url,
text: vi.fn().mockResolvedValue(text),
} as unknown as Response;
}
describe('fetch-rss endpoint', () => {
let httpRequestService: Mocked<HttpRequestService>;
let endpoint: FetchRssEndpoint;
beforeEach(() => {
rssParserMocks.constructor.mockReset();
rssParserMocks.parseString.mockReset();
rssParserMocks.parseString.mockResolvedValue({ items: [] });
httpRequestService = {
send: vi.fn(),
} as unknown as Mocked<HttpRequestService>;
endpoint = new FetchRssEndpoint(httpRequestService);
});
async function exec(url: string) {
return await endpoint.exec({ url }, null, null);
}
async function expectApiError(promise: Promise<unknown>, code: string, status: number) {
await expect(promise).rejects.toMatchObject({
code,
httpStatusCode: status,
info: undefined,
});
}
test.each([
'',
'not a URL',
'ftp://example.com/feed.xml',
`https://example.com/${'a'.repeat(8192)}`,
])('rejects invalid URL: %s', async (url) => {
await expectApiError(exec(url), 'INVALID_URL', 400);
expect(httpRequestService.send).not.toHaveBeenCalled();
});
test.each([
'https://user@example.com/feed.xml',
'https://user:password@example.com/feed.xml',
])('rejects URL containing credentials: %s', async (url) => {
await expectApiError(exec(url), 'INVALID_URL', 400);
expect(httpRequestService.send).not.toHaveBeenCalled();
});
test('does not expose details from internal errors', async () => {
httpRequestService.send.mockRejectedValue(new Error('secret upstream details'));
const promise = exec('https://example.com/feed.xml');
await expectApiError(promise, 'FETCH_RSS_FAILED', 422);
await expect(promise).rejects.not.toMatchObject({
message: expect.stringContaining('secret upstream details'),
});
});
test('passes the 1 MiB response limit to HttpRequestService', async () => {
httpRequestService.send.mockResolvedValue(response('https://example.com/feed.xml'));
await exec('https://example.com/feed.xml');
expect(httpRequestService.send).toHaveBeenCalledWith('https://example.com/feed.xml', expect.objectContaining({
size: 1024 * 1024,
}));
});
test('creates an asynchronous RSS parser for every request', async () => {
httpRequestService.send
.mockResolvedValueOnce(response('https://example.com/first.xml'))
.mockResolvedValueOnce(response('https://example.com/second.xml'));
await exec('https://example.com/first.xml');
await exec('https://example.com/second.xml');
expect(rssParserMocks.constructor).toHaveBeenCalledTimes(2);
expect(rssParserMocks.constructor).toHaveBeenNthCalledWith(1, { xml2js: { async: true } });
expect(rssParserMocks.constructor).toHaveBeenNthCalledWith(2, { xml2js: { async: true } });
});
test('rejects a non-HTTP final URL without exposing it', async () => {
httpRequestService.send.mockResolvedValue(response('file:///secret/feed.xml'));
await expectApiError(exec('https://example.com/feed.xml'), 'FETCH_RSS_FAILED', 422);
});
test('shares an in-flight request for the same normalized URL', async () => {
const pending = deferred<Response>();
httpRequestService.send.mockReturnValue(pending.promise);
const first = exec('HTTPS://EXAMPLE.COM:443/feed.xml#first');
const second = exec('https://example.com/feed.xml#second');
expect(httpRequestService.send).toHaveBeenCalledTimes(1);
pending.resolve(response('https://example.com/feed.xml'));
await expect(Promise.all([first, second])).resolves.toHaveLength(2);
expect(httpRequestService.send).toHaveBeenCalledTimes(1);
});
test('limits concurrent requests for different URLs to 32', async () => {
const pending = deferred<Response>();
httpRequestService.send.mockReturnValue(pending.promise);
const requests = Array.from({ length: 32 }, (_, i) => exec(`https://example.com/${i}.xml`));
expect(httpRequestService.send).toHaveBeenCalledTimes(32);
await expectApiError(exec('https://example.com/overflow.xml'), 'FETCH_RSS_UNAVAILABLE', 503);
expect(httpRequestService.send).toHaveBeenCalledTimes(32);
pending.resolve(response('https://example.com/feed.xml'));
await Promise.all(requests);
httpRequestService.send.mockResolvedValue(response('https://example.com/after.xml'));
await expect(exec('https://example.com/after.xml')).resolves.toBeDefined();
});
test('cleans up the in-flight request and concurrency slot after success', async () => {
httpRequestService.send.mockResolvedValue(response('https://example.com/feed.xml'));
await exec('https://example.com/feed.xml');
await exec('https://example.com/feed.xml');
expect(httpRequestService.send).toHaveBeenCalledTimes(2);
});
test('cleans up the in-flight request and concurrency slot after failure', async () => {
httpRequestService.send.mockRejectedValue(new Error('upstream failed'));
const requests = Array.from({ length: 32 }, (_, i) => exec(`https://example.com/${i}.xml`));
await Promise.allSettled(requests);
httpRequestService.send.mockResolvedValue(response('https://example.com/0.xml'));
await expect(exec('https://example.com/0.xml')).resolves.toBeDefined();
expect(httpRequestService.send).toHaveBeenCalledTimes(33);
});
test('has the expected rate limit metadata', () => {
expect(meta.limit).toEqual({
duration: 60 * 1000,
max: 300,
});
});
test('uses only the declared structured API errors', () => {
expect(new ApiError(meta.errors.invalidUrl)).toMatchObject({ code: 'INVALID_URL', httpStatusCode: 400 });
expect(new ApiError(meta.errors.fetchRssFailed)).toMatchObject({ code: 'FETCH_RSS_FAILED', httpStatusCode: 422 });
expect(new ApiError(meta.errors.fetchRssUnavailable)).toMatchObject({ code: 'FETCH_RSS_UNAVAILABLE', httpStatusCode: 503 });
});
});
@@ -1,53 +0,0 @@
/*
* 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);
});
});
@@ -1,108 +0,0 @@
/*
* 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();
});
});
@@ -1,148 +0,0 @@
/*
* 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();
});
});
@@ -9,8 +9,6 @@ import type { Config } from '@/config.js';
const mocks = vi.hoisted(() => { const mocks = vi.hoisted(() => {
return { return {
sentryCreate: vi.fn(), sentryCreate: vi.fn(),
sentryCreateWithOtlpExport: vi.fn(),
otelCreate: vi.fn(),
setLogTraceContextProvider: vi.fn(), setLogTraceContextProvider: vi.fn(),
}; };
}); });
@@ -22,13 +20,6 @@ vi.mock('@/logging/logging-runtime.js', () => ({
vi.mock('@/core/telemetry/adapters/SentryTelemetryAdapter.js', () => ({ vi.mock('@/core/telemetry/adapters/SentryTelemetryAdapter.js', () => ({
SentryTelemetryAdapter: { SentryTelemetryAdapter: {
create: mocks.sentryCreate, create: mocks.sentryCreate,
createWithOtlpExport: mocks.sentryCreateWithOtlpExport,
},
}));
vi.mock('@/core/telemetry/adapters/OpenTelemetryAdapter.js', () => ({
OpenTelemetryAdapter: {
create: mocks.otelCreate,
}, },
})); }));
@@ -43,44 +34,37 @@ describe('telemetry-registry', () => {
beforeEach(() => { beforeEach(() => {
vi.resetModules(); vi.resetModules();
mocks.sentryCreate.mockReset(); mocks.sentryCreate.mockReset();
mocks.sentryCreateWithOtlpExport.mockReset();
mocks.otelCreate.mockReset();
mocks.setLogTraceContextProvider.mockReset(); mocks.setLogTraceContextProvider.mockReset();
mocks.sentryCreate.mockResolvedValue({ shutdown: vi.fn(), captureMessage: vi.fn(), startSpan: vi.fn() }); 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 () => { test('does not initialize an adapter when Sentry is not configured', async () => {
const { initTelemetry } = await import('@/core/telemetry/telemetry-registry.js'); const { initTelemetry } = await import('@/core/telemetry/telemetry-registry.js');
const otelForBackend = { endpoint: 'http://collector:4318/v1/traces' };
await initTelemetry(config({ otelForBackend })); await initTelemetry(config({}));
expect(mocks.otelCreate).toHaveBeenCalledWith({
...otelForBackend,
serviceVersion: '2026.1.0',
});
expect(mocks.sentryCreate).not.toHaveBeenCalled(); expect(mocks.sentryCreate).not.toHaveBeenCalled();
expect(mocks.sentryCreateWithOtlpExport).not.toHaveBeenCalled(); expect(mocks.setLogTraceContextProvider).not.toHaveBeenCalled();
}); });
test('registers the adapter trace context provider after telemetry initialization', async () => { test('initializes Sentry and registers its trace context provider', async () => {
const { initTelemetry } = await import('@/core/telemetry/telemetry-registry.js'); const { initTelemetry } = await import('@/core/telemetry/telemetry-registry.js');
const sentryForBackend = { options: {}, enableNodeProfiling: false };
const getActiveTraceContext = vi.fn(() => ({ const getActiveTraceContext = vi.fn(() => ({
traceId: '0123456789abcdef0123456789abcdef', traceId: '0123456789abcdef0123456789abcdef',
spanId: '0123456789abcdef', spanId: '0123456789abcdef',
traceFlags: 0, traceFlags: 0,
})); }));
mocks.otelCreate.mockResolvedValue({ mocks.sentryCreate.mockResolvedValue({
shutdown: vi.fn(), shutdown: vi.fn(),
captureMessage: vi.fn(), captureMessage: vi.fn(),
startSpan: vi.fn(), startSpan: vi.fn(),
getActiveTraceContext, getActiveTraceContext,
}); });
await initTelemetry(config({ otelForBackend: { endpoint: 'http://collector:4318/v1/traces' } })); await initTelemetry(config({ sentryForBackend }));
expect(mocks.sentryCreate).toHaveBeenCalledWith(sentryForBackend);
expect(mocks.setLogTraceContextProvider).toHaveBeenCalledWith(expect.any(Function)); expect(mocks.setLogTraceContextProvider).toHaveBeenCalledWith(expect.any(Function));
const provider = mocks.setLogTraceContextProvider.mock.calls[0][0] as () => unknown; const provider = mocks.setLogTraceContextProvider.mock.calls[0][0] as () => unknown;
expect(provider()).toEqual({ expect(provider()).toEqual({
@@ -91,21 +75,6 @@ describe('telemetry-registry', () => {
expect(getActiveTraceContext).toHaveBeenCalledOnce(); expect(getActiveTraceContext).toHaveBeenCalledOnce();
}); });
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 () => { test('startSpan runs fn directly when no adapter is registered', async () => {
const { startSpan } = await import('@/core/telemetry/telemetry-registry.js'); const { startSpan } = await import('@/core/telemetry/telemetry-registry.js');
@@ -114,67 +83,32 @@ describe('telemetry-registry', () => {
expect(fn).toHaveBeenCalledTimes(1); expect(fn).toHaveBeenCalledTimes(1);
}); });
test('startSpan delegates directly to the single registered adapter without extra wrapping', async () => { test('startSpan delegates to the Sentry adapter', async () => {
const { initTelemetry, startSpan } = await import('@/core/telemetry/telemetry-registry.js'); 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()); const adapterStartSpan = vi.fn((_name: string, fn: () => string) => fn());
mocks.otelCreate.mockResolvedValue({ shutdown: vi.fn(), captureMessage: vi.fn(), startSpan: adapterStartSpan }); mocks.sentryCreate.mockResolvedValue({ shutdown: vi.fn(), captureMessage: vi.fn(), startSpan: adapterStartSpan });
await initTelemetry(config({ otelForBackend })); await initTelemetry(config({ sentryForBackend: { options: {}, enableNodeProfiling: false } }));
const fn = vi.fn().mockReturnValue('result'); const fn = vi.fn().mockReturnValue('result');
expect(startSpan('test', fn)).toBe('result'); expect(startSpan('test', fn)).toBe('result');
expect(adapterStartSpan).toHaveBeenCalledWith('test', fn); expect(adapterStartSpan).toHaveBeenCalledWith('test', fn);
}); });
test('startSpan wraps work through multiple registered adapters in order for future adapter combinations', async () => { test('shutdownTelemetry waits for all registered adapters even when one rejects', 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 { initTelemetry, shutdownTelemetry } = await import('@/core/telemetry/telemetry-registry.js');
const sentryShutdown = vi.fn().mockRejectedValue(new Error('sentry failed')); const firstShutdown = vi.fn().mockRejectedValue(new Error('first failed'));
const otelShutdown = vi.fn().mockResolvedValue(undefined); const secondShutdown = vi.fn().mockResolvedValue(undefined);
mocks.sentryCreate.mockResolvedValue({ shutdown: sentryShutdown, captureMessage: vi.fn(), startSpan: vi.fn() }); mocks.sentryCreate
mocks.otelCreate.mockResolvedValue({ shutdown: otelShutdown, captureMessage: vi.fn(), startSpan: vi.fn() }); .mockResolvedValueOnce({ shutdown: firstShutdown, captureMessage: vi.fn(), startSpan: vi.fn() })
.mockResolvedValueOnce({ shutdown: secondShutdown, captureMessage: vi.fn(), startSpan: vi.fn() });
await initTelemetry(config({ sentryForBackend: { options: {}, enableNodeProfiling: false } })); const sentryForBackend = { options: {}, enableNodeProfiling: false };
await initTelemetry(config({ otelForBackend: { endpoint: 'http://collector:4318/v1/traces' } })); await initTelemetry(config({ sentryForBackend }));
await initTelemetry(config({ sentryForBackend }));
await expect(shutdownTelemetry()).resolves.toBeUndefined(); await expect(shutdownTelemetry()).resolves.toBeUndefined();
expect(sentryShutdown).toHaveBeenCalledTimes(1); expect(firstShutdown).toHaveBeenCalledTimes(1);
expect(otelShutdown).toHaveBeenCalledTimes(1); expect(secondShutdown).toHaveBeenCalledTimes(1);
}); });
}); });
+8
View File
@@ -27,6 +27,14 @@ export function extractDomain(url: string) {
return match ? match[1] : null; return match ? match[1] : null;
} }
export function tryParseUrl(url: string | URL, base?: string | URL): URL | null {
try {
return new URL(url, base);
} catch {
return null;
}
}
export function maybeMakeRelative(urlStr: string, baseStr: string): string { export function maybeMakeRelative(urlStr: string, baseStr: string): string {
try { try {
const baseObj = new URL(baseStr); const baseObj = new URL(baseStr);
@@ -83,16 +83,16 @@ SPDX-License-Identifier: AGPL-3.0-only
v-else-if="content.type === 'video'" v-else-if="content.type === 'video'"
ref="videoEl" ref="videoEl"
data-gallery-click-action="video" data-gallery-click-action="video"
:class="$style.content" :class="[$style.video, { [$style.videoSized]: videoAspectRatio != null }]"
:src="content.url" :src="content.url"
:alt="content.file?.comment ?? undefined" :alt="content.file?.comment ?? undefined"
draggable="false" draggable="false"
:controls="prefer.s.useNativeUiForVideoAudioPlayer" :controls="prefer.s.useNativeUiForVideoAudioPlayer"
playsinline playsinline
@loadedmetadata="originalContentLoaded = true" @loadedmetadata="onVideoLoadedMetadata"
@click.stop="onVideoClick" @click.stop="onVideoClick"
></video> ></video>
<div v-if="content.type === 'video' && !prefer.s.useNativeUiForVideoAudioPlayer && !isVideoPlaying" data-gallery-click-action="video" :class="$style.playIconWrapper"> <div v-if="content.type === 'video' && !prefer.s.useNativeUiForVideoAudioPlayer && !isVideoPlaying" :class="$style.playIconWrapper">
<div :class="$style.playIcon"> <div :class="$style.playIcon">
<i class="ti ti-player-play"></i> <i class="ti ti-player-play"></i>
</div> </div>
@@ -224,6 +224,21 @@ const isVideoPlaying = computed(() => videoControl.value?.isPlaying ?? false);
const isVideoActuallyPlaying = computed(() => videoControl.value?.isActuallyPlaying ?? false); const isVideoActuallyPlaying = computed(() => videoControl.value?.isActuallyPlaying ?? false);
let canOpenAnimation = false; let canOpenAnimation = false;
const videoAspectRatio = ref<number | null>(
props.content.width != null && props.content.height != null && props.content.width > 0 && props.content.height > 0
? props.content.width / props.content.height
: null
);
function onVideoLoadedMetadata() {
originalContentLoaded.value = true;
// ドライブ上のメタデータが無い場合に限り、動画自体の初期サイズから縦横比を確定させる
if (videoAspectRatio.value != null) return;
if (videoEl.value == null || videoEl.value.videoWidth === 0 || videoEl.value.videoHeight === 0) return;
videoAspectRatio.value = videoEl.value.videoWidth / videoEl.value.videoHeight;
}
const headerSize = 30; const headerSize = 30;
const footerSize = props.content.type === 'video' && !prefer.s.useNativeUiForVideoAudioPlayer ? 80 : 0; const footerSize = props.content.type === 'video' && !prefer.s.useNativeUiForVideoAudioPlayer ? 80 : 0;
@@ -941,6 +956,25 @@ defineExpose({
object-fit: contain; object-fit: contain;
} }
.video {
display: block;
user-select: none;
position: absolute;
top: 50%;
left: 50%;
translate: -50% -50%;
width: 100%;
height: 100%;
object-fit: contain;
}
.videoSized {
width: min(100cqw, calc(100cqh * v-bind("videoAspectRatio ?? 16 / 9")));
height: auto;
background-color: #000;
aspect-ratio: v-bind("videoAspectRatio ?? 16 / 9");
}
.loading { .loading {
position: absolute; position: absolute;
top: 0; top: 0;
@@ -969,6 +1003,8 @@ defineExpose({
position: relative; position: relative;
width: 100%; width: 100%;
height: 100%; height: 100%;
// .videoSizedが使う100cqw / 100cqhの基準 (= paddingを除いた実際の表示領域)
container-type: size;
transition: scale 200ms ease, opacity 200ms ease !important; transition: scale 200ms ease, opacity 200ms ease !important;
} }
@@ -985,6 +1021,7 @@ defineExpose({
height: 100%; height: 100%;
display: grid; display: grid;
place-items: center; place-items: center;
pointer-events: none;
} }
.playIcon { .playIcon {
@@ -1000,8 +1037,8 @@ defineExpose({
transition: scale 100ms ease; transition: scale 100ms ease;
} }
.playIconWrapper:hover .playIcon, // アイコン自体はクリックを受け取らないので、hoverは下のvideo要素を経由して拾う
.playIcon:hover { .video:hover ~ .playIconWrapper .playIcon {
scale: 1.2; scale: 1.2;
} }
@@ -49,7 +49,7 @@ SPDX-License-Identifier: AGPL-3.0-only
</template> </template>
<script lang="ts" setup> <script lang="ts" setup>
import { computed, onUnmounted, watch } from 'vue'; import { computed } from 'vue';
import { isLink } from '@@/js/is-link.js'; import { isLink } from '@@/js/is-link.js';
import type { UploaderItem } from '@/composables/use-uploader.js'; import type { UploaderItem } from '@/composables/use-uploader.js';
import { getUploadName } from '@/composables/use-uploader.js'; import { getUploadName } from '@/composables/use-uploader.js';
@@ -57,6 +57,7 @@ import { i18n } from '@/i18n.js';
import MkButton from '@/components/MkButton.vue'; import MkButton from '@/components/MkButton.vue';
import bytes from '@/filters/bytes.js'; import bytes from '@/filters/bytes.js';
import * as os from '@/os.js'; import * as os from '@/os.js';
import type { Content } from '@/components/MkLightbox.item.vue';
const props = defineProps<{ const props = defineProps<{
items: UploaderItem[]; items: UploaderItem[];
@@ -72,32 +73,6 @@ const emit = defineEmits<{
(ev: 'showMenuViaContextmenu', item: UploaderItem, event: PointerEvent): void; (ev: 'showMenuViaContextmenu', item: UploaderItem, event: PointerEvent): void;
}>(); }>();
//#region objectUrlMap
const objectUrlMap = new Map<UploaderItem, string>();
watch(() => props.items, () => {
for (const item of props.items) {
if (!objectUrlMap.has(item)) {
objectUrlMap.set(item, URL.createObjectURL(item.file));
}
}
for (const [item, url] of objectUrlMap.entries()) {
if (!props.items.includes(item)) {
URL.revokeObjectURL(url);
objectUrlMap.delete(item);
}
}
}, { immediate: true, deep: true });
onUnmounted(() => {
for (const url of objectUrlMap.values()) {
URL.revokeObjectURL(url);
}
objectUrlMap.clear();
});
//#endregion
function getUploadNameParts(item: UploaderItem): { function getUploadNameParts(item: UploaderItem): {
baseName: string; baseName: string;
extension: string | null; extension: string | null;
@@ -127,13 +102,15 @@ function onContextmenu(item: UploaderItem, ev: PointerEvent) {
async function onThumbnailClick(item: UploaderItem, ev: PointerEvent) { async function onThumbnailClick(item: UploaderItem, ev: PointerEvent) {
if (item.file.type.startsWith('image') || item.file.type.startsWith('video')) { if (item.file.type.startsWith('image') || item.file.type.startsWith('video')) {
const contents = props.items.filter(item => item.file.type.startsWith('image') || item.file.type.startsWith('video')).map(item => ({ const contents = props.items
id: item.id, .filter(item => item.file.type.startsWith('image') || item.file.type.startsWith('video'))
type: (item.file.type.startsWith('video') ? 'video' as const : 'image' as const), .map<Content>(item => ({
url: objectUrlMap.get(item)!, id: item.id,
thumbnailUrl: item.thumbnail, type: (item.file.type.startsWith('video') ? 'video' : 'image'),
filename: getUploadName(item), url: item.objectUrl,
})); thumbnailUrl: item.thumbnail,
filename: getUploadName(item),
}));
const { dispose } = await os.popupAsyncWithDialog(import('@/components/MkLightbox.vue').then(x => x.default), { const { dispose } = await os.popupAsyncWithDialog(import('@/components/MkLightbox.vue').then(x => x.default), {
defaultIndex: contents.findIndex(content => content.id === item.id), defaultIndex: contents.findIndex(content => content.id === item.id),
@@ -16,6 +16,7 @@ import { i18n } from '@/i18n.js';
import { prefer } from '@/preferences.js'; import { prefer } from '@/preferences.js';
import { isWebpSupported } from '@/utility/isWebpSupported.js'; import { isWebpSupported } from '@/utility/isWebpSupported.js';
import { uploadFile, UploadAbortedError } from '@/utility/drive.js'; import { uploadFile, UploadAbortedError } from '@/utility/drive.js';
import type { Content } from '@/components/MkLightbox.item.vue';
import * as os from '@/os.js'; import * as os from '@/os.js';
import { ensureSignin } from '@/i.js'; import { ensureSignin } from '@/i.js';
@@ -74,6 +75,7 @@ export type UploaderItem = {
compressedSize?: number | null; compressedSize?: number | null;
preprocessedFile?: Blob | null; preprocessedFile?: Blob | null;
file: File; file: File;
objectUrl: string;
watermarkPreset: WatermarkPreset | null; watermarkPreset: WatermarkPreset | null;
watermarkLayers: WatermarkLayers | null; watermarkLayers: WatermarkLayers | null;
imageFrameParams: ImageFrameParams | null; imageFrameParams: ImageFrameParams | null;
@@ -133,12 +135,13 @@ export function useUploader(options: {
const filename = file.name ?? 'untitled'; const filename = file.name ?? 'untitled';
const extension = filename.split('.').length > 1 ? '.' + filename.split('.').pop() : ''; const extension = filename.split('.').length > 1 ? '.' + filename.split('.').pop() : '';
const watermarkPreset = uploaderFeatures.value.watermark && $i.policies.watermarkAvailable ? (prefer.s.watermarkPresets.find(p => p.id === prefer.s.defaultWatermarkPresetId) ?? null) : null; const watermarkPreset = uploaderFeatures.value.watermark && $i.policies.watermarkAvailable ? (prefer.s.watermarkPresets.find(p => p.id === prefer.s.defaultWatermarkPresetId) ?? null) : null;
const objectUrl = window.URL.createObjectURL(file);
items.value.push({ items.value.push({
id, id,
name: prefer.s.keepOriginalFilename ? filename : id + extension, name: prefer.s.keepOriginalFilename ? filename : id + extension,
suffix: '', suffix: '',
progress: null, progress: null,
thumbnail: THUMBNAIL_SUPPORTED_TYPES.includes(file.type) ? window.URL.createObjectURL(file) : null, thumbnail: THUMBNAIL_SUPPORTED_TYPES.includes(file.type) ? objectUrl : null,
preprocessing: false, preprocessing: false,
preprocessProgress: null, preprocessProgress: null,
uploading: false, uploading: false,
@@ -150,6 +153,7 @@ export function useUploader(options: {
watermarkLayers: watermarkPreset?.layers ?? null, watermarkLayers: watermarkPreset?.layers ?? null,
imageFrameParams: null, imageFrameParams: null,
file: markRaw(file), file: markRaw(file),
objectUrl,
}); });
const reactiveItem = items.value.at(-1)!; const reactiveItem = items.value.at(-1)!;
preprocess(reactiveItem).then(() => { preprocess(reactiveItem).then(() => {
@@ -163,8 +167,24 @@ export function useUploader(options: {
} }
} }
function removeItem(item: UploaderItem) { function revokeItemObjectUrls(item: UploaderItem) {
if (item.thumbnail != null) URL.revokeObjectURL(item.thumbnail); if (item.thumbnail != null) URL.revokeObjectURL(item.thumbnail);
URL.revokeObjectURL(item.objectUrl);
}
function createItemObjectUrl(item: UploaderItem, file: Blob | File): string {
revokeItemObjectUrls(item);
return window.URL.createObjectURL(file);
}
function updateItemObjectUrls(item: UploaderItem, file: Blob | File) {
const newObjectUrl = createItemObjectUrl(item, file);
item.objectUrl = newObjectUrl;
item.thumbnail = THUMBNAIL_SUPPORTED_TYPES.includes(file.type) ? newObjectUrl : null;
}
function removeItem(item: UploaderItem) {
revokeItemObjectUrls(item);
items.value.splice(items.value.indexOf(item), 1); items.value.splice(items.value.indexOf(item), 1);
} }
@@ -214,7 +234,35 @@ export function useUploader(options: {
closed: () => dispose(), closed: () => dispose(),
}); });
}, },
}, { });
if (item.file.type.startsWith('image/') || item.file.type.startsWith('video/')) {
menu.push({
text: i18n.ts.preview,
icon: 'ti ti-photo-search',
action: async () => {
const contents = items.value
.filter(item => item.file.type.startsWith('image/') || item.file.type.startsWith('video/'))
.map<Content>(item => ({
id: item.id,
type: item.file.type.startsWith('video/') ? 'video' : 'image',
url: item.objectUrl,
thumbnail: item.thumbnail,
filename: getUploadName(item),
caption: item.caption ?? null,
}));
const { dispose } = await os.popupAsyncWithDialog(import('@/components/MkLightbox.vue').then(x => x.default), {
defaultIndex: contents.findIndex(x => x.id === item.id),
contents,
}, {
closed: () => dispose(),
});
},
});
}
menu.push({
type: 'divider', type: 'divider',
}); });
} }
@@ -235,11 +283,12 @@ export function useUploader(options: {
text: i18n.ts.cropImage, text: i18n.ts.cropImage,
action: async () => { action: async () => {
const cropped = await os.cropImageFile(item.file, { aspectRatio: null }); const cropped = await os.cropImageFile(item.file, { aspectRatio: null });
if (item.thumbnail != null) URL.revokeObjectURL(item.thumbnail); const newObjectUrl = createItemObjectUrl(item, cropped);
items.value.splice(items.value.indexOf(item), 1, { items.value.splice(items.value.indexOf(item), 1, {
...item, ...item,
file: markRaw(cropped), file: markRaw(cropped),
thumbnail: window.URL.createObjectURL(cropped), thumbnail: THUMBNAIL_SUPPORTED_TYPES.includes(cropped.type) ? newObjectUrl : null,
objectUrl: newObjectUrl,
}); });
const reactiveItem = items.value.find(x => x.id === item.id)!; const reactiveItem = items.value.find(x => x.id === item.id)!;
preprocess(reactiveItem).then(() => { preprocess(reactiveItem).then(() => {
@@ -260,11 +309,12 @@ export function useUploader(options: {
image: item.file, image: item.file,
}, { }, {
ok: (file) => { ok: (file) => {
if (item.thumbnail != null) URL.revokeObjectURL(item.thumbnail); const newObjectUrl = createItemObjectUrl(item, file);
items.value.splice(items.value.indexOf(item), 1, { items.value.splice(items.value.indexOf(item), 1, {
...item, ...item,
file: markRaw(file), file: markRaw(file),
thumbnail: window.URL.createObjectURL(file), thumbnail: THUMBNAIL_SUPPORTED_TYPES.includes(file.type) ? newObjectUrl : null,
objectUrl: newObjectUrl,
}); });
const reactiveItem = items.value.find(x => x.id === item.id)!; const reactiveItem = items.value.find(x => x.id === item.id)!;
preprocess(reactiveItem).then(() => { preprocess(reactiveItem).then(() => {
@@ -694,8 +744,7 @@ export function useUploader(options: {
imageBitmap.close(); imageBitmap.close();
if (item.thumbnail != null) URL.revokeObjectURL(item.thumbnail); updateItemObjectUrls(item, preprocessedFile);
item.thumbnail = THUMBNAIL_SUPPORTED_TYPES.includes(preprocessedFile.type) ? window.URL.createObjectURL(preprocessedFile) : null;
item.preprocessedFile = markRaw(preprocessedFile); item.preprocessedFile = markRaw(preprocessedFile);
} }
@@ -754,14 +803,13 @@ export function useUploader(options: {
item.suffix = ''; item.suffix = '';
} }
if (item.thumbnail != null) URL.revokeObjectURL(item.thumbnail); updateItemObjectUrls(item, preprocessedFile);
item.thumbnail = THUMBNAIL_SUPPORTED_TYPES.includes(preprocessedFile.type) ? window.URL.createObjectURL(preprocessedFile) : null;
item.preprocessedFile = markRaw(preprocessedFile); item.preprocessedFile = markRaw(preprocessedFile);
} }
function reset() { function reset() {
for (const item of items.value) { for (const item of items.value) {
if (item.thumbnail != null) URL.revokeObjectURL(item.thumbnail); revokeItemObjectUrls(item);
} }
abortAll(); abortAll();
@@ -30,6 +30,8 @@ SPDX-License-Identifier: AGPL-3.0-only
import { ref } from 'vue'; import { ref } from 'vue';
import * as Misskey from 'misskey-js'; import * as Misskey from 'misskey-js';
import { useInterval } from '@@/js/use-interval.js'; import { useInterval } from '@@/js/use-interval.js';
import { url as baseUrl } from '@@/js/config.js';
import { tryParseUrl } from '@@/js/url.js';
import MkMarqueeText from '@/components/MkMarqueeText.vue'; import MkMarqueeText from '@/components/MkMarqueeText.vue';
import { shuffle } from '@/utility/shuffle.js'; import { shuffle } from '@/utility/shuffle.js';
@@ -53,7 +55,11 @@ const tick = () => {
if (props.shuffle) { if (props.shuffle) {
shuffle(feed.items); shuffle(feed.items);
} }
items.value = feed.items; items.value = feed.items.filter((item) => {
if (!item.link) return false;
const itemUrl = tryParseUrl(item.link, baseUrl);
return itemUrl != null && ['http:', 'https:'].includes(itemUrl.protocol);
});
fetching.value = false; fetching.value = false;
key.value++; key.value++;
}); });
+7 -2
View File
@@ -24,10 +24,11 @@ import { ref, watch, computed } from 'vue';
import * as Misskey from 'misskey-js'; import * as Misskey from 'misskey-js';
import { url as base } from '@@/js/config.js'; import { url as base } from '@@/js/config.js';
import { useInterval } from '@@/js/use-interval.js'; import { useInterval } from '@@/js/use-interval.js';
import { tryParseUrl } from '@@/js/url.js';
import { useWidgetPropsManager } from './widget.js'; import { useWidgetPropsManager } from './widget.js';
import { i18n } from '@/i18n.js';
import type { WidgetComponentEmits, WidgetComponentExpose, WidgetComponentProps } from './widget.js'; import type { WidgetComponentEmits, WidgetComponentExpose, WidgetComponentProps } from './widget.js';
import type { FormWithDefault, GetFormResultType } from '@/utility/form.js'; import type { FormWithDefault, GetFormResultType } from '@/utility/form.js';
import { i18n } from '@/i18n.js';
import MkContainer from '@/components/MkContainer.vue'; import MkContainer from '@/components/MkContainer.vue';
const name = 'rss'; const name = 'rss';
@@ -81,7 +82,11 @@ const tick = () => {
window.fetch(fetchEndpoint.value, {}) window.fetch(fetchEndpoint.value, {})
.then(res => res.json()) .then(res => res.json())
.then((feed: Misskey.entities.FetchRssResponse) => { .then((feed: Misskey.entities.FetchRssResponse) => {
rawItems.value = feed.items; rawItems.value = feed.items.filter((item) => {
if (!item.link) return false;
const itemUrl = tryParseUrl(item.link, base);
return itemUrl != null && ['http:', 'https:'].includes(itemUrl.protocol);
});
fetching.value = false; fetching.value = false;
}); });
}; };
@@ -29,15 +29,16 @@ SPDX-License-Identifier: AGPL-3.0-only
<script lang="ts" setup> <script lang="ts" setup>
import { ref, watch, computed } from 'vue'; import { ref, watch, computed } from 'vue';
import * as Misskey from 'misskey-js'; import * as Misskey from 'misskey-js';
import { url as base } from '@@/js/config.js';
import { useInterval } from '@@/js/use-interval.js';
import { tryParseUrl } from '@@/js/url.js';
import { useWidgetPropsManager } from './widget.js'; import { useWidgetPropsManager } from './widget.js';
import type { WidgetComponentEmits, WidgetComponentExpose, WidgetComponentProps } from './widget.js'; import type { WidgetComponentEmits, WidgetComponentExpose, WidgetComponentProps } from './widget.js';
import MkMarqueeText from '@/components/MkMarqueeText.vue';
import type { FormWithDefault, GetFormResultType } from '@/utility/form.js'; import type { FormWithDefault, GetFormResultType } from '@/utility/form.js';
import MkMarqueeText from '@/components/MkMarqueeText.vue';
import MkContainer from '@/components/MkContainer.vue'; import MkContainer from '@/components/MkContainer.vue';
import { shuffle } from '@/utility/shuffle.js'; import { shuffle } from '@/utility/shuffle.js';
import { i18n } from '@/i18n.js'; import { i18n } from '@/i18n.js';
import { url as base } from '@@/js/config.js';
import { useInterval } from '@@/js/use-interval.js';
const name = 'rssTicker'; const name = 'rssTicker';
@@ -121,7 +122,11 @@ const tick = () => {
window.fetch(fetchEndpoint.value, {}) window.fetch(fetchEndpoint.value, {})
.then(res => res.json()) .then(res => res.json())
.then((feed: Misskey.entities.FetchRssResponse) => { .then((feed: Misskey.entities.FetchRssResponse) => {
rawItems.value = feed.items; rawItems.value = feed.items.filter((item) => {
if (!item.link) return false;
const itemUrl = tryParseUrl(item.link, base);
return itemUrl != null && ['http:', 'https:'].includes(itemUrl.protocol);
});
fetching.value = false; fetching.value = false;
key.value++; key.value++;
}); });
+21
View File
@@ -0,0 +1,21 @@
/*
* SPDX-FileCopyrightText: syuilo and misskey-project
* SPDX-License-Identifier: AGPL-3.0-only
*/
import { describe, expect, test } from 'vitest';
import { tryParseUrl } from '@@/js/url.js';
describe('tryParseUrl', () => {
test('parses an absolute URL', () => {
expect(tryParseUrl('https://example.com/path')?.href).toBe('https://example.com/path');
});
test('parses a relative URL against a base URL', () => {
expect(tryParseUrl('/path', 'https://example.com/base')?.href).toBe('https://example.com/path');
});
test('returns null for an invalid URL', () => {
expect(tryParseUrl('https://[invalid')).toBeNull();
});
});
+1 -1
View File
@@ -38,6 +38,6 @@
"tsx": "4.23.0" "tsx": "4.23.0"
}, },
"dependencies": { "dependencies": {
"js-yaml": "5.2.1" "js-yaml": "5.2.2"
} }
} }
+1 -1
View File
@@ -1,7 +1,7 @@
{ {
"type": "module", "type": "module",
"name": "misskey-js", "name": "misskey-js",
"version": "2026.7.0-beta.6", "version": "2026.7.0",
"description": "Misskey SDK for JavaScript", "description": "Misskey SDK for JavaScript",
"license": "MIT", "license": "MIT",
"main": "./built/index.js", "main": "./built/index.js",
+9
View File
@@ -21879,6 +21879,15 @@ export interface operations {
'application/json': components['schemas']['Error']; 'application/json': components['schemas']['Error'];
}; };
}; };
/** @description Too many requests */
429: {
headers: {
[name: string]: unknown;
};
content: {
'application/json': components['schemas']['Error'];
};
};
/** @description Internal server error */ /** @description Internal server error */
500: { 500: {
headers: { headers: {
+9 -214
View File
@@ -24,8 +24,8 @@ importers:
specifier: 9.0.0 specifier: 9.0.0
version: 9.0.0 version: 9.0.0
js-yaml: js-yaml:
specifier: 5.2.1 specifier: 5.2.2
version: 5.2.1 version: 5.2.2
tar: tar:
specifier: 7.5.21 specifier: 7.5.21
version: 7.5.21 version: 7.5.21
@@ -188,9 +188,6 @@ importers:
'@fastify/multipart': '@fastify/multipart':
specifier: 10.1.0 specifier: 10.1.0
version: 10.1.0 version: 10.1.0
'@fastify/otel':
specifier: 0.20.1
version: 0.20.1(@opentelemetry/api@1.9.1)
'@fastify/static': '@fastify/static':
specifier: 10.1.2 specifier: 10.1.2
version: 10.1.2 version: 10.1.2
@@ -221,33 +218,6 @@ importers:
'@nestjs/testing': '@nestjs/testing':
specifier: 11.1.28 specifier: 11.1.28
version: 11.1.28(@nestjs/common@11.1.28(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.28)(@nestjs/platform-express@11.1.28) version: 11.1.28(@nestjs/common@11.1.28(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.28)(@nestjs/platform-express@11.1.28)
'@opentelemetry/api':
specifier: 1.9.1
version: 1.9.1
'@opentelemetry/core':
specifier: 2.9.0
version: 2.9.0(@opentelemetry/api@1.9.1)
'@opentelemetry/exporter-trace-otlp-proto':
specifier: 0.220.0
version: 0.220.0(@opentelemetry/api@1.9.1)
'@opentelemetry/instrumentation':
specifier: 0.219.0
version: 0.219.0(@opentelemetry/api@1.9.1)
'@opentelemetry/instrumentation-pg':
specifier: 0.72.0
version: 0.72.0(@opentelemetry/api@1.9.1)
'@opentelemetry/resources':
specifier: 2.9.0
version: 2.9.0(@opentelemetry/api@1.9.1)
'@opentelemetry/sdk-trace-base':
specifier: 2.9.0
version: 2.9.0(@opentelemetry/api@1.9.1)
'@opentelemetry/sdk-trace-node':
specifier: 2.9.0
version: 2.9.0(@opentelemetry/api@1.9.1)
'@opentelemetry/semantic-conventions':
specifier: 1.43.0
version: 1.43.0
'@oxc-project/runtime': '@oxc-project/runtime':
specifier: 0.139.0 specifier: 0.139.0
version: 0.139.0 version: 0.139.0
@@ -592,8 +562,8 @@ importers:
specifier: 10.0.3 specifier: 10.0.3
version: 10.0.3 version: 10.0.3
js-yaml: js-yaml:
specifier: 5.2.1 specifier: 5.2.2
version: 5.2.1 version: 5.2.2
pid-port: pid-port:
specifier: 2.1.1 specifier: 2.1.1
version: 2.1.1 version: 2.1.1
@@ -1230,8 +1200,8 @@ importers:
packages/i18n: packages/i18n:
dependencies: dependencies:
js-yaml: js-yaml:
specifier: 5.2.1 specifier: 5.2.2
version: 5.2.1 version: 5.2.2
devDependencies: devDependencies:
'@types/node': '@types/node':
specifier: 26.1.1 specifier: 26.1.1
@@ -1941,11 +1911,6 @@ packages:
'@fastify/multipart@10.1.0': '@fastify/multipart@10.1.0':
resolution: {integrity: sha512-b2r6CovmLQvLFJ5HJDtxVigZjcO9TwkRGslhiNQxsGJwilm5B3eqvXPOVLudC44zXMXJITpQ/iEATIE7zoXqcQ==} resolution: {integrity: sha512-b2r6CovmLQvLFJ5HJDtxVigZjcO9TwkRGslhiNQxsGJwilm5B3eqvXPOVLudC44zXMXJITpQ/iEATIE7zoXqcQ==}
'@fastify/otel@0.20.1':
resolution: {integrity: sha512-UG9gQUbqfBWcJNeO0bxj03xkoEE8NGJkIedN/red11elHFeZuVgoHKAx0x+wO4kGedyyQv8NWC62AdpGEvyM6g==}
peerDependencies:
'@opentelemetry/api': ^1.9.0
'@fastify/proxy-addr@5.1.0': '@fastify/proxy-addr@5.1.0':
resolution: {integrity: sha512-INS+6gh91cLUjB+PVHfu1UqcB76Sqtpyp7bnL+FYojhjygvOPA9ctiD/JDKsyD9Xgu4hUhCSJBPig/w7duNajw==} resolution: {integrity: sha512-INS+6gh91cLUjB+PVHfu1UqcB76Sqtpyp7bnL+FYojhjygvOPA9ctiD/JDKsyD9Xgu4hUhCSJBPig/w7duNajw==}
@@ -2649,10 +2614,6 @@ packages:
'@open-draft/until@2.1.0': '@open-draft/until@2.1.0':
resolution: {integrity: sha512-U69T3ItWHvLwGg5eJ0n3I62nWuE6ilHlmz7zM0npLBRvPRd7e6NYmg54vvRtP5mZG7kZqZCFVdsTWo7BPtBujg==} resolution: {integrity: sha512-U69T3ItWHvLwGg5eJ0n3I62nWuE6ilHlmz7zM0npLBRvPRd7e6NYmg54vvRtP5mZG7kZqZCFVdsTWo7BPtBujg==}
'@opentelemetry/api-logs@0.219.0':
resolution: {integrity: sha512-FFx7YnaYJlIjqWW/AG/yAZ0L/NEY724PipXXXQLdtZPbLwBGbUMTGL1i/esI56TWfTUXxhLfpgrnWJCG8aUJyg==}
engines: {node: '>=8.0.0'}
'@opentelemetry/api-logs@0.220.0': '@opentelemetry/api-logs@0.220.0':
resolution: {integrity: sha512-CmVa4ImJ+ynfrPMNaAXHET6Bhb44SwzmfyVJFq9ni2jgXJR/l7C6gfVFddNmHP+ZOkP9cf4f9DBe68qVLTHc9w==} resolution: {integrity: sha512-CmVa4ImJ+ynfrPMNaAXHET6Bhb44SwzmfyVJFq9ni2jgXJR/l7C6gfVFddNmHP+ZOkP9cf4f9DBe68qVLTHc9w==}
engines: {node: '>=8.0.0'} engines: {node: '>=8.0.0'}
@@ -2661,84 +2622,30 @@ packages:
resolution: {integrity: sha512-gLyJlPHPZYdAk1JENA9LeHejZe1Ti77/pTeFm/nMXmQH/HFZlcS/O2XJB+L8fkbrNSqhdtlvjBVjxwUYanNH5Q==} resolution: {integrity: sha512-gLyJlPHPZYdAk1JENA9LeHejZe1Ti77/pTeFm/nMXmQH/HFZlcS/O2XJB+L8fkbrNSqhdtlvjBVjxwUYanNH5Q==}
engines: {node: '>=8.0.0'} engines: {node: '>=8.0.0'}
'@opentelemetry/context-async-hooks@2.9.0':
resolution: {integrity: sha512-OQ0vzvbZBiUhjqLnUaoNfYmP8553Crr3aggB4y0ZUi815mZ7idpdJXQmoKdeBKJelYttoBlLSSHubmyw3wvX4w==}
engines: {node: ^18.19.0 || >=20.6.0}
peerDependencies:
'@opentelemetry/api': '>=1.0.0 <1.10.0'
'@opentelemetry/core@2.9.0': '@opentelemetry/core@2.9.0':
resolution: {integrity: sha512-m2nckMT80NnmjTYSPjJQObBJ+8dgkoajEOUbznL8AHZ3T3yHRk2P7gI1PhEBc1+lOnrYE9UWrWHqJDsmqjmNbw==} resolution: {integrity: sha512-m2nckMT80NnmjTYSPjJQObBJ+8dgkoajEOUbznL8AHZ3T3yHRk2P7gI1PhEBc1+lOnrYE9UWrWHqJDsmqjmNbw==}
engines: {node: ^18.19.0 || >=20.6.0} engines: {node: ^18.19.0 || >=20.6.0}
peerDependencies: peerDependencies:
'@opentelemetry/api': '>=1.0.0 <1.10.0' '@opentelemetry/api': '>=1.0.0 <1.10.0'
'@opentelemetry/exporter-trace-otlp-proto@0.220.0':
resolution: {integrity: sha512-voTAD8XgJxlK7zLkXh8EzMB09zrQr3tyY/BsnDTlDiQU/UdK58MZ63A3mUjdEDrxMjCVmBHU3WQJhRmQe+Dvzg==}
engines: {node: ^18.19.0 || >=20.6.0}
peerDependencies:
'@opentelemetry/api': ^1.3.0
'@opentelemetry/instrumentation-pg@0.72.0':
resolution: {integrity: sha512-p9xrFc/6R8t6Y293sTYLZ83LnzZo/qY0bBPA4xabdQt0Qjt8i1SlYFsIeGY2Jmf5WcESNUdjQB3NxWnt5Ox7zw==}
engines: {node: ^18.19.0 || >=20.6.0}
peerDependencies:
'@opentelemetry/api': ^1.3.0
'@opentelemetry/instrumentation@0.219.0':
resolution: {integrity: sha512-X5t7I8GyIO9rmGHwoedZLREpQqrF1WW2nxzNNym6HOKpFiE+rvqV3ngC0xcZVO2YwIGf3KKmRdWrYwdwz3H9RQ==}
engines: {node: ^18.19.0 || >=20.6.0}
peerDependencies:
'@opentelemetry/api': ^1.3.0
'@opentelemetry/instrumentation@0.220.0': '@opentelemetry/instrumentation@0.220.0':
resolution: {integrity: sha512-xQx3E2WxP1mDvKzxLxX+CTCtNLa560YJZ3087qYHerl2YmiKpv7AH+dAy7vmx+eVrZ5BwhfWUAVoKOoxCNHcpw==} resolution: {integrity: sha512-xQx3E2WxP1mDvKzxLxX+CTCtNLa560YJZ3087qYHerl2YmiKpv7AH+dAy7vmx+eVrZ5BwhfWUAVoKOoxCNHcpw==}
engines: {node: ^18.19.0 || >=20.6.0} engines: {node: ^18.19.0 || >=20.6.0}
peerDependencies: peerDependencies:
'@opentelemetry/api': ^1.3.0 '@opentelemetry/api': ^1.3.0
'@opentelemetry/otlp-exporter-base@0.220.0':
resolution: {integrity: sha512-CXYo8UD5Mn9YbgebO2EL4wejtA+gxLmLiu6HCk2KH2BR7XhFN6/6p1UlCb23DYCjeYkndevLHuejCCN1yx4+OQ==}
engines: {node: ^18.19.0 || >=20.6.0}
peerDependencies:
'@opentelemetry/api': ^1.3.0
'@opentelemetry/otlp-transformer@0.220.0':
resolution: {integrity: sha512-lXGrv7KXZ0gNH9SVNUaa6vv6phVYGvJxfXAlMbzbakiXru75f5MZl8Z7oqiMMQD77riVHJCFlQvbZs/VVN2/4A==}
engines: {node: ^18.19.0 || >=20.6.0}
peerDependencies:
'@opentelemetry/api': ^1.3.0
'@opentelemetry/resources@2.9.0': '@opentelemetry/resources@2.9.0':
resolution: {integrity: sha512-jyA5MBLQ+Dkl3+JsZkUoUvL7yHvU64kLsvpXKarWm6347Sl1t1bXFTFykUePNpT5WH5pm9a2Qtt03iIYQhZ1Fg==} resolution: {integrity: sha512-jyA5MBLQ+Dkl3+JsZkUoUvL7yHvU64kLsvpXKarWm6347Sl1t1bXFTFykUePNpT5WH5pm9a2Qtt03iIYQhZ1Fg==}
engines: {node: ^18.19.0 || >=20.6.0} engines: {node: ^18.19.0 || >=20.6.0}
peerDependencies: peerDependencies:
'@opentelemetry/api': '>=1.3.0 <1.10.0' '@opentelemetry/api': '>=1.3.0 <1.10.0'
'@opentelemetry/sdk-logs@0.220.0':
resolution: {integrity: sha512-WywcTkQtv2iNmt+6y5Kcd4rzvx9bLVsBa2Nwcmg01IUaBTkTow3W4d9KE5vNBpEDtb9tp21WcRBY/lANRrApYA==}
engines: {node: ^18.19.0 || >=20.6.0}
peerDependencies:
'@opentelemetry/api': '>=1.4.0 <1.10.0'
'@opentelemetry/sdk-metrics@2.9.0':
resolution: {integrity: sha512-Xx8RGS4H5XEBl01WuCreMIpiah9cCXMbSkeuIePPdD2cUpq/vUzYmj8E/MK1OsbOc93FuAD4jfn2WOacKwLn7Q==}
engines: {node: ^18.19.0 || >=20.6.0}
peerDependencies:
'@opentelemetry/api': '>=1.9.0 <1.10.0'
'@opentelemetry/sdk-trace-base@2.9.0': '@opentelemetry/sdk-trace-base@2.9.0':
resolution: {integrity: sha512-cp9zmTl62R8PJrpvFcmc8N2JQU/xfa0S+61q511Nji+QxCfZ8Ifvg7H27G8cANe4crg4RTrWsVvanHiXjSp6ag==} resolution: {integrity: sha512-cp9zmTl62R8PJrpvFcmc8N2JQU/xfa0S+61q511Nji+QxCfZ8Ifvg7H27G8cANe4crg4RTrWsVvanHiXjSp6ag==}
engines: {node: ^18.19.0 || >=20.6.0} engines: {node: ^18.19.0 || >=20.6.0}
peerDependencies: peerDependencies:
'@opentelemetry/api': '>=1.3.0 <1.10.0' '@opentelemetry/api': '>=1.3.0 <1.10.0'
'@opentelemetry/sdk-trace-node@2.9.0':
resolution: {integrity: sha512-ec9a7ps37huy5itYk0MalaZdSLlM6AXWp/FhtEjgMpp5leEGojBDvAl/UWttQnkMZOvFHKzRESn8TD3yKTF5nQ==}
engines: {node: ^18.19.0 || >=20.6.0}
peerDependencies:
'@opentelemetry/api': '>=1.0.0 <1.10.0'
'@opentelemetry/sdk-trace@2.9.0': '@opentelemetry/sdk-trace@2.9.0':
resolution: {integrity: sha512-sGA19HvtrrSKYsseHphluH6j3p6Xa3fqc7c7y8f/7mYWejc1lyDFcpSdD1kYa50HCLUeEo4zA5bW0pniaPszuw==} resolution: {integrity: sha512-sGA19HvtrrSKYsseHphluH6j3p6Xa3fqc7c7y8f/7mYWejc1lyDFcpSdD1kYa50HCLUeEo4zA5bW0pniaPszuw==}
engines: {node: ^18.19.0 || >=20.6.0} engines: {node: ^18.19.0 || >=20.6.0}
@@ -2749,12 +2656,6 @@ packages:
resolution: {integrity: sha512-eSYWTm620tTk45EKSedaUL8MFYI8hW164hIXsgIHyxu3VobUB3fFCu5t0hQby6OoWRPsG1KkKUG2M5UadiLiVg==} resolution: {integrity: sha512-eSYWTm620tTk45EKSedaUL8MFYI8hW164hIXsgIHyxu3VobUB3fFCu5t0hQby6OoWRPsG1KkKUG2M5UadiLiVg==}
engines: {node: '>=14'} engines: {node: '>=14'}
'@opentelemetry/sql-common@0.42.0':
resolution: {integrity: sha512-nwUwUU+8O8a4bnLqk6CodWeegGMEANgC94KTAhXcpGWLrW/2/hek/0ajNbjXnSOoNuCX+nteUPs46HFHhou9Xw==}
engines: {node: ^18.19.0 || >=20.6.0}
peerDependencies:
'@opentelemetry/api': ^1.1.0
'@oxc-parser/binding-android-arm-eabi@0.127.0': '@oxc-parser/binding-android-arm-eabi@0.127.0':
resolution: {integrity: sha512-0LC7ye4hvqbIKxAzThzvswgHLFu2AURKzYLeSVvLdu2TBOYWQDmHnTqPLeA597BcUCxiLqLsS4CJ5uoI5WYWCQ==} resolution: {integrity: sha512-0LC7ye4hvqbIKxAzThzvswgHLFu2AURKzYLeSVvLdu2TBOYWQDmHnTqPLeA597BcUCxiLqLsS4CJ5uoI5WYWCQ==}
engines: {node: ^20.19.0 || >=22.12.0} engines: {node: ^20.19.0 || >=22.12.0}
@@ -4036,12 +3937,6 @@ packages:
'@types/offscreencanvas@2019.7.3': '@types/offscreencanvas@2019.7.3':
resolution: {integrity: sha512-ieXiYmgSRXUDeOntE1InxjWyvEelZGP63M+cGuquuRLuIKKT1osnkXjxev9B7d1nXSug5vpunx+gNlbVxMlC9A==} resolution: {integrity: sha512-ieXiYmgSRXUDeOntE1InxjWyvEelZGP63M+cGuquuRLuIKKT1osnkXjxev9B7d1nXSug5vpunx+gNlbVxMlC9A==}
'@types/pg-pool@2.0.7':
resolution: {integrity: sha512-U4CwmGVQcbEuqpyju8/ptOKg6gEC+Tqsvj2xS9o1g71bUh8twxnC6ZL5rZKCsGN0iyH0CwgUyc9VR5owNQF9Ng==}
'@types/pg@8.15.6':
resolution: {integrity: sha512-NoaMtzhxOrubeL/7UZuNTrejB4MPAJ0RpxZqXQf2qXuVlTPuG6Y8p4u9dKRaue4yjmC7ZhzVO2/Yyyn25znrPQ==}
'@types/pg@8.20.0': '@types/pg@8.20.0':
resolution: {integrity: sha512-bEPFOaMAHTEP1EzpvHTbmwR8UsFyHSKsRisLIHVMXnpNefSbGA1bD6CVy+qKjGSqmZqNqBDV2azOBo8TgkcVow==} resolution: {integrity: sha512-bEPFOaMAHTEP1EzpvHTbmwR8UsFyHSKsRisLIHVMXnpNefSbGA1bD6CVy+qKjGSqmZqNqBDV2azOBo8TgkcVow==}
@@ -6500,8 +6395,8 @@ packages:
resolution: {integrity: sha512-1td788aAnnZ5qs7V2QIRl1owjtYpbKt749Y3xauqQgwIIGF/xXWz1wMTEBx5O3LK3lXLVuqXPdPxj2BoFHaW9Q==} resolution: {integrity: sha512-1td788aAnnZ5qs7V2QIRl1owjtYpbKt749Y3xauqQgwIIGF/xXWz1wMTEBx5O3LK3lXLVuqXPdPxj2BoFHaW9Q==}
hasBin: true hasBin: true
js-yaml@5.2.1: js-yaml@5.2.2:
resolution: {integrity: sha512-zfLtNfQqxVqq3uaTqSkh4x4hZw3KHobGUA0fJUj4wawW8bsQLTVqpHdXSIzidh7o+4lEW36tANuAGdaFx6Zgnw==} resolution: {integrity: sha512-dayzUzKkJ1MkuUtZglSebU43utNXH0OWQByK9rKOOuYIO8M5TV1y+n8ALMdG0rdzBnfNkOmZEqrURepb0ejqBw==}
hasBin: true hasBin: true
jsbn@0.1.1: jsbn@0.1.1:
@@ -10055,16 +9950,6 @@ snapshots:
fastify-plugin: 6.0.0 fastify-plugin: 6.0.0
secure-json-parse: 4.1.0 secure-json-parse: 4.1.0
'@fastify/otel@0.20.1(@opentelemetry/api@1.9.1)':
dependencies:
'@opentelemetry/api': 1.9.1
'@opentelemetry/core': 2.9.0(@opentelemetry/api@1.9.1)
'@opentelemetry/instrumentation': 0.219.0(@opentelemetry/api@1.9.1)
'@opentelemetry/semantic-conventions': 1.43.0
minimatch: 10.2.5
transitivePeerDependencies:
- supports-color
'@fastify/proxy-addr@5.1.0': '@fastify/proxy-addr@5.1.0':
dependencies: dependencies:
'@fastify/forwarded': 3.0.1 '@fastify/forwarded': 3.0.1
@@ -10673,55 +10558,17 @@ snapshots:
'@open-draft/until@2.1.0': {} '@open-draft/until@2.1.0': {}
'@opentelemetry/api-logs@0.219.0':
dependencies:
'@opentelemetry/api': 1.9.1
'@opentelemetry/api-logs@0.220.0': '@opentelemetry/api-logs@0.220.0':
dependencies: dependencies:
'@opentelemetry/api': 1.9.1 '@opentelemetry/api': 1.9.1
'@opentelemetry/api@1.9.1': {} '@opentelemetry/api@1.9.1': {}
'@opentelemetry/context-async-hooks@2.9.0(@opentelemetry/api@1.9.1)':
dependencies:
'@opentelemetry/api': 1.9.1
'@opentelemetry/core@2.9.0(@opentelemetry/api@1.9.1)': '@opentelemetry/core@2.9.0(@opentelemetry/api@1.9.1)':
dependencies: dependencies:
'@opentelemetry/api': 1.9.1 '@opentelemetry/api': 1.9.1
'@opentelemetry/semantic-conventions': 1.43.0 '@opentelemetry/semantic-conventions': 1.43.0
'@opentelemetry/exporter-trace-otlp-proto@0.220.0(@opentelemetry/api@1.9.1)':
dependencies:
'@opentelemetry/api': 1.9.1
'@opentelemetry/core': 2.9.0(@opentelemetry/api@1.9.1)
'@opentelemetry/otlp-exporter-base': 0.220.0(@opentelemetry/api@1.9.1)
'@opentelemetry/otlp-transformer': 0.220.0(@opentelemetry/api@1.9.1)
'@opentelemetry/resources': 2.9.0(@opentelemetry/api@1.9.1)
'@opentelemetry/sdk-trace': 2.9.0(@opentelemetry/api@1.9.1)
'@opentelemetry/instrumentation-pg@0.72.0(@opentelemetry/api@1.9.1)':
dependencies:
'@opentelemetry/api': 1.9.1
'@opentelemetry/core': 2.9.0(@opentelemetry/api@1.9.1)
'@opentelemetry/instrumentation': 0.220.0(@opentelemetry/api@1.9.1)
'@opentelemetry/semantic-conventions': 1.43.0
'@opentelemetry/sql-common': 0.42.0(@opentelemetry/api@1.9.1)
'@types/pg': 8.15.6
'@types/pg-pool': 2.0.7
transitivePeerDependencies:
- supports-color
'@opentelemetry/instrumentation@0.219.0(@opentelemetry/api@1.9.1)':
dependencies:
'@opentelemetry/api': 1.9.1
'@opentelemetry/api-logs': 0.219.0
import-in-the-middle: 3.0.1
require-in-the-middle: 8.0.1
transitivePeerDependencies:
- supports-color
'@opentelemetry/instrumentation@0.220.0(@opentelemetry/api@1.9.1)': '@opentelemetry/instrumentation@0.220.0(@opentelemetry/api@1.9.1)':
dependencies: dependencies:
'@opentelemetry/api': 1.9.1 '@opentelemetry/api': 1.9.1
@@ -10731,42 +10578,12 @@ snapshots:
transitivePeerDependencies: transitivePeerDependencies:
- supports-color - supports-color
'@opentelemetry/otlp-exporter-base@0.220.0(@opentelemetry/api@1.9.1)':
dependencies:
'@opentelemetry/api': 1.9.1
'@opentelemetry/core': 2.9.0(@opentelemetry/api@1.9.1)
'@opentelemetry/otlp-transformer': 0.220.0(@opentelemetry/api@1.9.1)
'@opentelemetry/otlp-transformer@0.220.0(@opentelemetry/api@1.9.1)':
dependencies:
'@opentelemetry/api': 1.9.1
'@opentelemetry/api-logs': 0.220.0
'@opentelemetry/core': 2.9.0(@opentelemetry/api@1.9.1)
'@opentelemetry/resources': 2.9.0(@opentelemetry/api@1.9.1)
'@opentelemetry/sdk-logs': 0.220.0(@opentelemetry/api@1.9.1)
'@opentelemetry/sdk-metrics': 2.9.0(@opentelemetry/api@1.9.1)
'@opentelemetry/sdk-trace': 2.9.0(@opentelemetry/api@1.9.1)
'@opentelemetry/resources@2.9.0(@opentelemetry/api@1.9.1)': '@opentelemetry/resources@2.9.0(@opentelemetry/api@1.9.1)':
dependencies: dependencies:
'@opentelemetry/api': 1.9.1 '@opentelemetry/api': 1.9.1
'@opentelemetry/core': 2.9.0(@opentelemetry/api@1.9.1) '@opentelemetry/core': 2.9.0(@opentelemetry/api@1.9.1)
'@opentelemetry/semantic-conventions': 1.43.0 '@opentelemetry/semantic-conventions': 1.43.0
'@opentelemetry/sdk-logs@0.220.0(@opentelemetry/api@1.9.1)':
dependencies:
'@opentelemetry/api': 1.9.1
'@opentelemetry/api-logs': 0.220.0
'@opentelemetry/core': 2.9.0(@opentelemetry/api@1.9.1)
'@opentelemetry/resources': 2.9.0(@opentelemetry/api@1.9.1)
'@opentelemetry/semantic-conventions': 1.43.0
'@opentelemetry/sdk-metrics@2.9.0(@opentelemetry/api@1.9.1)':
dependencies:
'@opentelemetry/api': 1.9.1
'@opentelemetry/core': 2.9.0(@opentelemetry/api@1.9.1)
'@opentelemetry/resources': 2.9.0(@opentelemetry/api@1.9.1)
'@opentelemetry/sdk-trace-base@2.9.0(@opentelemetry/api@1.9.1)': '@opentelemetry/sdk-trace-base@2.9.0(@opentelemetry/api@1.9.1)':
dependencies: dependencies:
'@opentelemetry/api': 1.9.1 '@opentelemetry/api': 1.9.1
@@ -10775,13 +10592,6 @@ snapshots:
'@opentelemetry/sdk-trace': 2.9.0(@opentelemetry/api@1.9.1) '@opentelemetry/sdk-trace': 2.9.0(@opentelemetry/api@1.9.1)
'@opentelemetry/semantic-conventions': 1.43.0 '@opentelemetry/semantic-conventions': 1.43.0
'@opentelemetry/sdk-trace-node@2.9.0(@opentelemetry/api@1.9.1)':
dependencies:
'@opentelemetry/api': 1.9.1
'@opentelemetry/context-async-hooks': 2.9.0(@opentelemetry/api@1.9.1)
'@opentelemetry/core': 2.9.0(@opentelemetry/api@1.9.1)
'@opentelemetry/sdk-trace-base': 2.9.0(@opentelemetry/api@1.9.1)
'@opentelemetry/sdk-trace@2.9.0(@opentelemetry/api@1.9.1)': '@opentelemetry/sdk-trace@2.9.0(@opentelemetry/api@1.9.1)':
dependencies: dependencies:
'@opentelemetry/api': 1.9.1 '@opentelemetry/api': 1.9.1
@@ -10791,11 +10601,6 @@ snapshots:
'@opentelemetry/semantic-conventions@1.43.0': {} '@opentelemetry/semantic-conventions@1.43.0': {}
'@opentelemetry/sql-common@0.42.0(@opentelemetry/api@1.9.1)':
dependencies:
'@opentelemetry/api': 1.9.1
'@opentelemetry/core': 2.9.0(@opentelemetry/api@1.9.1)
'@oxc-parser/binding-android-arm-eabi@0.127.0': '@oxc-parser/binding-android-arm-eabi@0.127.0':
optional: true optional: true
@@ -12027,16 +11832,6 @@ snapshots:
'@types/offscreencanvas@2019.7.3': {} '@types/offscreencanvas@2019.7.3': {}
'@types/pg-pool@2.0.7':
dependencies:
'@types/pg': 8.20.0
'@types/pg@8.15.6':
dependencies:
'@types/node': 26.1.1
pg-protocol: 1.15.0
pg-types: 2.2.0
'@types/pg@8.20.0': '@types/pg@8.20.0':
dependencies: dependencies:
'@types/node': 26.1.1 '@types/node': 26.1.1
@@ -15042,7 +14837,7 @@ snapshots:
dependencies: dependencies:
argparse: 2.0.1 argparse: 2.0.1
js-yaml@5.2.1: js-yaml@5.2.2:
dependencies: dependencies:
argparse: 2.0.1 argparse: 2.0.1
+2 -2
View File
@@ -54,14 +54,14 @@ minimumReleaseAgeExclude:
- slacc-win32-x64-msvc - slacc-win32-x64-msvc
- '@typescript/native-preview*' - '@typescript/native-preview*'
- vite # そのうち消す - vite # そのうち消す
# Renovate security update: @opentelemetry/core@2.8.0
- "@opentelemetry/core@2.8.0"
# Renovate security update: typeorm@1.1.0 # Renovate security update: typeorm@1.1.0
- typeorm@1.1.0 - typeorm@1.1.0
# Renovate security update: @fastify/static@10.1.2 # Renovate security update: @fastify/static@10.1.2
- "@fastify/static@10.1.2" - "@fastify/static@10.1.2"
# Renovate security update: tar@7.5.21 # Renovate security update: tar@7.5.21
- tar@7.5.21 - tar@7.5.21
# Renovate security update: js-yaml@5.2.2
- js-yaml@5.2.2
overrides: overrides:
'@aiscript-dev/aiscript-languageserver': '-' '@aiscript-dev/aiscript-languageserver': '-'
'bullmq>ioredis': 5.11.1 'bullmq>ioredis': 5.11.1