mirror of
https://github.com/misskey-dev/misskey.git
synced 2026-07-30 03:45:50 +00:00
Compare commits
20 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 9b2f90f681 | |||
| 7daec62680 | |||
| 440b1e442a | |||
| 1057295d5d | |||
| dad093c6f8 | |||
| 4d85ea8657 | |||
| b1980f1287 | |||
| f42840ad03 | |||
| a01b6af7c1 | |||
| 5b8fdabcf2 | |||
| 41c20ce140 | |||
| 6aad11766a | |||
| 66498639bf | |||
| 76389b684c | |||
| 4b82eab304 | |||
| 3ead8d840e | |||
| c93b093bff | |||
| b33ec08cbf | |||
| 0d677f288a | |||
| 8a3e33218e |
@@ -173,31 +173,8 @@ id: 'aidx'
|
||||
|
||||
#sentryForBackend:
|
||||
# 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:
|
||||
# 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:
|
||||
# vueIntegration:
|
||||
|
||||
@@ -329,6 +329,63 @@ id: 'aidx'
|
||||
# #tracePropagationTargets:
|
||||
# # - 'internal-service.example'
|
||||
|
||||
# ┌─────────┐
|
||||
#───┘ Tracing └────────────────────────────────────────────────
|
||||
# OpenTelemetry distributed tracing (Traces only, opt-in).
|
||||
# Existing API and Queue spans are exported to an OTLP http/protobuf endpoint.
|
||||
#
|
||||
# Standard OTEL_* environment variables are honored for values omitted below:
|
||||
# OTEL_EXPORTER_OTLP_TRACES_ENDPOINT, OTEL_EXPORTER_OTLP_ENDPOINT,
|
||||
# OTEL_EXPORTER_OTLP_HEADERS, OTEL_TRACES_SAMPLER, OTEL_TRACES_SAMPLER_ARG, etc.
|
||||
#
|
||||
# When this is enabled together with sentryForBackend, Misskey shares Sentry's
|
||||
# TracerProvider and adds an OTLP SpanProcessor to it. In that mode:
|
||||
# - sampleRate below is ignored; sampling follows sentryForBackend.options
|
||||
# tracesSampleRate / tracesSampler.
|
||||
# - resourceAttributes below is ignored; set OTEL_SERVICE_NAME and
|
||||
# OTEL_RESOURCE_ATTRIBUTES in the environment if you need to override them.
|
||||
# - spans produced by Sentry integrations are exported to both Sentry and OTLP.
|
||||
# - sentry-trace / baggage propagation to outbound remote requests is disabled
|
||||
# by default while OTel is enabled. Set propagateTraceToRemote: true only if
|
||||
# you intentionally want Sentry trace propagation for your deployment.
|
||||
|
||||
#otelForBackend:
|
||||
# endpoint: 'http://localhost:4318/v1/traces'
|
||||
# #headers:
|
||||
# # authorization: 'Bearer xxxxx'
|
||||
# #sampleRate: 1.0
|
||||
# # Record PostgreSQL query spans. Disabled by default to avoid instrumentation
|
||||
# # overhead when database-level diagnostics are not needed.
|
||||
# #capturePgSpans: false
|
||||
# # Include raw SQL text in PostgreSQL spans. Requires capturePgSpans and is
|
||||
# # disabled by default because non-parameterized queries can expose values to
|
||||
# # the OTLP Collector.
|
||||
# #capturePgStatement: false
|
||||
# # Include PostgreSQL connection-pool spans with an existing parent span.
|
||||
# # Requires capturePgSpans and is disabled by default to avoid noisy spans
|
||||
# # from connection churn.
|
||||
# #capturePgConnectionSpans: false
|
||||
# # Record Redis command spans. Disabled by default because subscribing to the
|
||||
# # ioredis diagnostics channel adds work to every Redis command, including
|
||||
# # commands without a parent span. Enable for development diagnostics or when
|
||||
# # the added overhead is acceptable in production.
|
||||
# #captureRedisCommandSpans: false
|
||||
# # Include Redis startup/reconnect spans. Disabled by default to avoid noisy
|
||||
# # connection churn; these spans are recorded even without an HTTP or job
|
||||
# # parent span.
|
||||
# #captureRedisConnectionSpans: false
|
||||
# # Record Redis commands without an HTTP or job parent span. Disabled by
|
||||
# # default because queue polling and background work can produce many root
|
||||
# # traces; only applies when captureRedisCommandSpans is enabled.
|
||||
# #captureRedisRootSpans: false
|
||||
# #resourceAttributes:
|
||||
# # deployment.environment: 'production'
|
||||
# #propagateTraceToRemote: false
|
||||
# # Queue worker spans are independent traces linked to their enqueuer by default.
|
||||
# # Set to 'parent' to make them child spans instead. This can create very large
|
||||
# # traces for high-fan-out queues such as federation delivery.
|
||||
# #jobTraceContextMode: 'link'
|
||||
|
||||
#sentryForFrontend:
|
||||
# vueIntegration:
|
||||
# tracingOptions:
|
||||
|
||||
@@ -1,73 +0,0 @@
|
||||
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
|
||||
@@ -1,8 +1,7 @@
|
||||
# this name is used in check-misskey-js-autogen.comment.yml so be careful when change name
|
||||
name: Check Misskey JS autogen
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
pull_request_target:
|
||||
branches:
|
||||
- master
|
||||
- develop
|
||||
@@ -11,6 +10,7 @@ on:
|
||||
- packages/backend/**
|
||||
|
||||
jobs:
|
||||
# pull_request_target safety: permissions: read-all, and there are no secrets used in this job
|
||||
generate-misskey-js:
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
@@ -58,6 +58,7 @@ jobs:
|
||||
name: generated-misskey-js
|
||||
path: packages/misskey-js/generator/built/autogen
|
||||
|
||||
# pull_request_target safety: permissions: read-all, and no user codes are executed
|
||||
get-actual-misskey-js:
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
@@ -77,11 +78,12 @@ jobs:
|
||||
name: actual-misskey-js
|
||||
path: packages/misskey-js/src/autogen
|
||||
|
||||
check-misskey-js-autogen:
|
||||
# pull_request_target safety: nothing is cloned from repository
|
||||
comment-misskey-js-autogen:
|
||||
runs-on: ubuntu-latest
|
||||
needs: [generate-misskey-js, get-actual-misskey-js]
|
||||
permissions:
|
||||
contents: read
|
||||
pull-requests: write
|
||||
steps:
|
||||
- name: download generated-misskey-js
|
||||
uses: actions/download-artifact@v8
|
||||
@@ -109,21 +111,28 @@ jobs:
|
||||
- name: Print full diff
|
||||
run: cat ./misskey-js.diff
|
||||
|
||||
# A fork's pull_request token cannot comment, so hand the result to check-misskey-js-autogen.comment.yml
|
||||
- name: Save result
|
||||
env:
|
||||
PR_NUMBER: ${{ github.event.pull_request.number }}
|
||||
CHANGES: ${{ steps.check-changes.outputs.changes }}
|
||||
run: |
|
||||
mkdir -p result
|
||||
echo "$PR_NUMBER" > result/pr-number
|
||||
echo "$CHANGES" > result/changes
|
||||
|
||||
- name: Upload result
|
||||
uses: actions/upload-artifact@v7
|
||||
- name: send message
|
||||
if: steps.check-changes.outputs.changes == 'true'
|
||||
uses: thollander/actions-comment-pull-request@v3
|
||||
with:
|
||||
name: misskey-js-autogen-result
|
||||
path: result
|
||||
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.check-changes.outputs.changes == 'false'
|
||||
uses: thollander/actions-comment-pull-request@v3
|
||||
with:
|
||||
comment-tag: check-misskey-js-autogen
|
||||
mode: delete
|
||||
message: "Thank you!"
|
||||
create_if_not_exists: false
|
||||
|
||||
- name: Make failure if changes are detected
|
||||
if: steps.check-changes.outputs.changes == 'true'
|
||||
|
||||
+11
-8
@@ -12,14 +12,11 @@
|
||||
- Node.js のセキュリティアップデートに伴い、最低動作バージョンを 22.22.2 / 24.17.0 / 26.4.0 に引き上げました。
|
||||
- Docker Image は Node.js 26.4.0-trixie に更新されています。
|
||||
- バックエンドで画像処理に用いているライブラリ 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
|
||||
- Feat: コントロールパネルから二要素認証を解除できるように
|
||||
- Feat: 条件に一致したURLプレビューのサムネイルを隠すことができるように
|
||||
(Based on https://github.com/MisskeyIO/misskey/pull/214)
|
||||
- Enhance: 依存関係の更新
|
||||
|
||||
### Client
|
||||
- 2025.4.0 以前の設定情報の移行処理が削除されました
|
||||
@@ -34,7 +31,6 @@
|
||||
- Fix: 一部の画像をビューワーで読み込んだ際に正しく表示されない問題を修正
|
||||
- Enhance: ファイルアップロード前にプレビューできるように
|
||||
- Enhance: タブがバックグラウンドの間は必要ない定期更新処理を停止するように
|
||||
- Enhance: 翻訳の更新
|
||||
- Fix: 「画像を新しいタブで開く」が機能しなくなっていた問題を修正
|
||||
- Fix: デバイスタイプをスマートフォンに固定している状態で画面幅が広いとき、画面左上のアイコンが表示されない問題を修正
|
||||
- Fix: チャットでIMEの変換を確定するEnterでメッセージが送信されてしまうことがある問題を修正
|
||||
@@ -48,12 +44,21 @@
|
||||
- Fix: ノートの詳細表示で削除された引用元が表示されない問題を修正
|
||||
|
||||
### Server
|
||||
- Feat: OpenTelemetryサポート
|
||||
- 詳細な設定はconfigファイルを参照してください。
|
||||
- Sentryとの併用も可能です。Sentry併用時は、PostgreSQL Query と Redis command は Sentry で計装されます。
|
||||
- 以下の自動計装をサポートしています。(計装対象にする項目は設定可能)
|
||||
- PostgreSQL query
|
||||
- Redis command
|
||||
- 全ての受信HTTPリクエスト
|
||||
- 全ての送信HTTPリクエスト
|
||||
- ジョブキュー(エンキュー元のトレースを含む)
|
||||
- Feat: ログ基盤の刷新
|
||||
- API内部エラーのログに構造化属性と正規化したエラー情報を付与し、認証情報を自動的に秘匿するように(従来形式の表示は維持)
|
||||
- ログ全体の既定出力レベルとドメインごとの出力レベルを設定できるように
|
||||
- バックエンドのログを1行JSON形式で出力できるように
|
||||
- SentryのTrace ContextをJSON形式のログへ関連付けられるように
|
||||
- HTTPのAccess logをstatus class単位で出力できるように(開発時のリクエスト・レスポンス本文、SentryのTrace Contextにも対応)
|
||||
- OpenTelemetryのTrace ContextをJSON形式のログへ関連付けられるように
|
||||
- HTTPのAccess logをstatus class単位で出力できるように(開発時のリクエスト・レスポンス本文、OpenTelemetryのTrace Contextにも対応)
|
||||
- Enhance: Sentry バックエンドの自動計装を `sentryForBackend.disabledIntegrations` で個別に無効化できるように
|
||||
- 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以降をサポートするように
|
||||
@@ -66,8 +71,6 @@
|
||||
- Fix: フォロワー限定投稿へのリプライをホーム投稿に出来る問題を修正
|
||||
- Fix: ファイルをアップロードするAPIにて、処理終了後に一時ファイルが削除されないことがある問題を修正
|
||||
- Fix: 初期設定で作成したアカウント以外でアカウント作成APIが使用できない問題を修正
|
||||
- Fix: フォロー中のチャンネルを再度フォローした際にALREADY_FOLLOWINGエラーを返すように
|
||||
- Fix: セキュリティに関する修正
|
||||
|
||||
## 2026.6.0
|
||||
|
||||
|
||||
+11
-1
@@ -1312,6 +1312,7 @@ noteOfThisUser: "このユーザーのノート一覧"
|
||||
clipNoteLimitExceeded: "これ以上このクリップにノートを追加できません。"
|
||||
performance: "パフォーマンス"
|
||||
modified: "変更あり"
|
||||
modifiedAt: "変更日時"
|
||||
discard: "破棄"
|
||||
thereAreNChanges: "{n}件の変更があります"
|
||||
signinWithPasskey: "パスキーでログイン"
|
||||
@@ -1421,6 +1422,8 @@ append: "末尾に追加"
|
||||
prepend: "先頭に追加"
|
||||
urlPreviewSensitiveList: "サムネイルの表示を制限するURL"
|
||||
urlPreviewSensitiveListDescription: "スペースで区切るとAND指定になり、改行で区切るとOR指定になります。スラッシュで囲むと正規表現になります。一致した場合、サムネイルが表示されなくなります。"
|
||||
latestBackupAt: "最終バックアップ"
|
||||
latestSyncAt: "最終同期"
|
||||
|
||||
_imageEditing:
|
||||
_vars:
|
||||
@@ -1589,7 +1592,9 @@ _preferencesProfile:
|
||||
useSyncBetweenDevicesOptionIfYouWantToSyncSetting: "複数のデバイスで同期したい設定項目が存在する場合は、個別に「複数のデバイスで同期」オプションを有効にしてください。"
|
||||
|
||||
_preferencesBackup:
|
||||
backupAndSync: "バックアップと同期"
|
||||
autoBackup: "自動バックアップ"
|
||||
autoBackup_description: "設定を自動でサーバーに保存し、いつでも復元できるようにします"
|
||||
restoreFromBackup: "バックアップから復元"
|
||||
noBackupsFoundTitle: "バックアップが見つかりませんでした"
|
||||
noBackupsFoundDescription: "自動で作成されたバックアップは見つかりませんでしたが、バックアップファイルを手動で保存している場合、それをインポートして復元することはできます。"
|
||||
@@ -1597,7 +1602,12 @@ _preferencesBackup:
|
||||
youNeedToNameYourProfileToEnableAutoBackup: "自動バックアップを有効にするにはプロファイル名の設定が必要です。"
|
||||
autoPreferencesBackupIsNotEnabledForThisDevice: "このデバイスで設定の自動バックアップは有効になっていません。"
|
||||
backupFound: "設定のバックアップが見つかりました"
|
||||
forceBackup: "設定の強制バックアップ"
|
||||
forceBackup: "今すぐバックアップ"
|
||||
autoSync: "デバイス間同期"
|
||||
autoSync_description: "サーバーに保存された設定を自動で取得し、別のデバイスでの変更と同期できるようにします"
|
||||
forceSync: "今すぐ同期"
|
||||
autoSyncAreYouSure: "デバイス間の同期をオンにしますか?"
|
||||
autoSyncAreYouSure_description: "通信量が増えるため、他のデバイスとこのプロファイルを共有する予定がない場合はオンにしないでください。"
|
||||
|
||||
_accountSettings:
|
||||
requireSigninToViewContents: "コンテンツの表示にログインを必須にする"
|
||||
|
||||
+2
-2
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "misskey",
|
||||
"version": "2026.7.0-rc.0",
|
||||
"version": "2026.7.0-beta.6",
|
||||
"codename": "nasubi",
|
||||
"repository": {
|
||||
"type": "git",
|
||||
@@ -57,7 +57,7 @@
|
||||
"esbuild": "0.28.1",
|
||||
"execa": "9.6.1",
|
||||
"ignore-walk": "9.0.0",
|
||||
"js-yaml": "5.2.2",
|
||||
"js-yaml": "5.2.1",
|
||||
"tar": "7.5.21"
|
||||
},
|
||||
"devDependencies": {
|
||||
|
||||
@@ -1,26 +0,0 @@
|
||||
/*
|
||||
* 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`);
|
||||
}
|
||||
}
|
||||
@@ -57,6 +57,7 @@
|
||||
"@fastify/cors": "11.3.0",
|
||||
"@fastify/http-proxy": "11.5.0",
|
||||
"@fastify/multipart": "10.1.0",
|
||||
"@fastify/otel": "0.20.1",
|
||||
"@fastify/static": "10.1.2",
|
||||
"@kitajs/html": "4.2.13",
|
||||
"@misskey-dev/emoji-assets": "17.0.3",
|
||||
@@ -67,6 +68,15 @@
|
||||
"@nestjs/common": "11.1.28",
|
||||
"@nestjs/core": "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",
|
||||
"@peertube/http-signature": "1.7.0",
|
||||
"@sentry/node": "10.65.0",
|
||||
@@ -183,7 +193,7 @@
|
||||
"eslint-plugin-import": "2.32.0",
|
||||
"execa": "9.6.1",
|
||||
"fkill": "10.0.3",
|
||||
"js-yaml": "5.2.2",
|
||||
"js-yaml": "5.2.1",
|
||||
"pid-port": "2.1.1",
|
||||
"rolldown": "1.1.5",
|
||||
"simple-oauth2": "5.1.0",
|
||||
|
||||
@@ -87,6 +87,7 @@ export default defineConfig((args) => {
|
||||
'class-validator',
|
||||
/^@sentry\/.*/,
|
||||
/^@sentry-internal\/.*/,
|
||||
/^@opentelemetry\/.*/,
|
||||
'@nestjs/websockets/socket-module',
|
||||
'@nestjs/microservices/microservices-module',
|
||||
'@nestjs/microservices',
|
||||
|
||||
@@ -27,6 +27,21 @@ type SentryBackendConfig = {
|
||||
disabledIntegrations?: string[];
|
||||
};
|
||||
|
||||
type OtelBackendConfig = {
|
||||
endpoint?: string;
|
||||
headers?: Record<string, string>;
|
||||
sampleRate?: number;
|
||||
capturePgSpans?: boolean;
|
||||
capturePgStatement?: boolean;
|
||||
capturePgConnectionSpans?: boolean;
|
||||
captureRedisCommandSpans?: boolean;
|
||||
captureRedisConnectionSpans?: boolean;
|
||||
captureRedisRootSpans?: boolean;
|
||||
resourceAttributes?: Record<string, string>;
|
||||
propagateTraceToRemote?: boolean;
|
||||
jobTraceContextMode?: 'link' | 'parent';
|
||||
};
|
||||
|
||||
/**
|
||||
* 設定ファイルの型
|
||||
*/
|
||||
@@ -72,6 +87,7 @@ type Source = {
|
||||
scope?: 'local' | 'global' | string[];
|
||||
};
|
||||
sentryForBackend?: SentryBackendConfig;
|
||||
otelForBackend?: OtelBackendConfig;
|
||||
sentryForFrontend?: {
|
||||
options: Partial<SentryVue.BrowserOptions> & { dsn: string };
|
||||
vueIntegration?: SentryVue.VueIntegrationOptions | null;
|
||||
@@ -217,6 +233,7 @@ export type Config = {
|
||||
redisForTimelines: RedisOptions & RedisOptionsSource;
|
||||
redisForReactions: RedisOptions & RedisOptionsSource;
|
||||
sentryForBackend: SentryBackendConfig | undefined;
|
||||
otelForBackend: OtelBackendConfig | undefined;
|
||||
sentryForFrontend: {
|
||||
options: Partial<SentryVue.BrowserOptions> & { dsn: string };
|
||||
vueIntegration?: SentryVue.VueIntegrationOptions | null;
|
||||
@@ -322,6 +339,7 @@ export function loadConfig(): Config {
|
||||
redisForTimelines: config.redisForTimelines ? convertRedisOptions(config.redisForTimelines, host) : redis,
|
||||
redisForReactions: config.redisForReactions ? convertRedisOptions(config.redisForReactions, host) : redis,
|
||||
sentryForBackend: config.sentryForBackend,
|
||||
otelForBackend: config.otelForBackend,
|
||||
sentryForFrontend: config.sentryForFrontend,
|
||||
id: config.id,
|
||||
proxy: config.proxy,
|
||||
|
||||
@@ -13,8 +13,6 @@ import { GlobalEvents, GlobalEventService } from '@/core/GlobalEventService.js';
|
||||
import { bindThis } from '@/decorators.js';
|
||||
import type { MiLocalUser } from '@/models/User.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()
|
||||
export class ChannelFollowingService implements OnModuleInit {
|
||||
@@ -98,18 +96,11 @@ export class ChannelFollowingService implements OnModuleInit {
|
||||
requestUser: MiLocalUser,
|
||||
targetChannel: MiChannel,
|
||||
): Promise<void> {
|
||||
try {
|
||||
await this.channelFollowingsRepository.insert({
|
||||
id: this.idService.gen(),
|
||||
followerId: requestUser.id,
|
||||
followeeId: targetChannel.id,
|
||||
});
|
||||
} catch (e) {
|
||||
if (isDuplicateKeyValueError(e)) {
|
||||
throw new IdentifiableError('6e335e39-0203-4418-a936-b3f2dc987845', 'already following');
|
||||
}
|
||||
throw e;
|
||||
}
|
||||
await this.channelFollowingsRepository.insert({
|
||||
id: this.idService.gen(),
|
||||
followerId: requestUser.id,
|
||||
followeeId: targetChannel.id,
|
||||
});
|
||||
|
||||
this.globalEventService.publishInternalEvent('followChannel', {
|
||||
userId: requestUser.id,
|
||||
|
||||
@@ -16,20 +16,11 @@ import { UserEntityService } from '@/core/entities/UserEntityService.js';
|
||||
import { bindThis } from '@/decorators.js';
|
||||
import { FeaturedService } from '@/core/FeaturedService.js';
|
||||
import { UtilityService } from '@/core/UtilityService.js';
|
||||
import { isDuplicateKeyValueError } from '@/misc/is-duplicate-key-value-error.js';
|
||||
import Logger from '../logger.js';
|
||||
|
||||
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()
|
||||
export class HashtagService {
|
||||
constructor(
|
||||
@@ -77,93 +68,126 @@ export class HashtagService {
|
||||
// TODO: サンプリング
|
||||
this.updateHashtagsRanking(tag, user.id);
|
||||
|
||||
const column: UpdatingHashtagColumn = isUserAttached ? {
|
||||
totalUserIds: 'attachedUserIds',
|
||||
totalUsersCount: 'attachedUsersCount',
|
||||
localUserIds: 'attachedLocalUserIds',
|
||||
localUsersCount: 'attachedLocalUsersCount',
|
||||
remoteUserIds: 'attachedRemoteUserIds',
|
||||
remoteUsersCount: 'attachedRemoteUsersCount',
|
||||
} : {
|
||||
totalUserIds: 'mentionedUserIds',
|
||||
totalUsersCount: 'mentionedUsersCount',
|
||||
localUserIds: 'mentionedLocalUserIds',
|
||||
localUsersCount: 'mentionedLocalUsersCount',
|
||||
remoteUserIds: 'mentionedRemoteUserIds',
|
||||
remoteUsersCount: 'mentionedRemoteUsersCount',
|
||||
};
|
||||
{
|
||||
const index = await this.hashtagsRepository.findOneBy({ name: tag });
|
||||
|
||||
if (inc) {
|
||||
await this.#incrementHashTag(user, tag, column);
|
||||
} else {
|
||||
await this.#decrementHashTag(user, tag, column);
|
||||
}
|
||||
}
|
||||
|
||||
async #incrementHashTag(
|
||||
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 runner = this.db.createQueryRunner('master');
|
||||
try {
|
||||
await runner.query(
|
||||
`INSERT into "hashtag"("id", "name", "${totalUserIds}", "${totalUsersCount}", "${localOrRemoteUserIds}",
|
||||
"${localOrRemoteUserCount}")
|
||||
VALUES ($3, $1, ARRAY [$2], 1, ARRAY [$2], 1)
|
||||
ON CONFLICT ("name")
|
||||
DO UPDATE SET "${totalUserIds}" = ${appendUserIdIfNotExists(totalUserIds)},
|
||||
"${totalUsersCount}" = ${incrementCountIfNotExists(totalUserIds, totalUsersCount)},
|
||||
"${localOrRemoteUserIds}" = ${appendUserIdIfNotExists(localOrRemoteUserIds)},
|
||||
"${localOrRemoteUserCount}" = ${incrementCountIfNotExists(localOrRemoteUserIds, localOrRemoteUserCount)}`,
|
||||
[tag, user.id, this.idService.gen()],
|
||||
);
|
||||
} finally {
|
||||
await runner.release();
|
||||
if (index == null && inc) {
|
||||
try {
|
||||
if (isUserAttached) {
|
||||
await this.hashtagsRepository.insert({
|
||||
id: this.idService.gen(),
|
||||
name: tag,
|
||||
mentionedUserIds: [],
|
||||
mentionedUsersCount: 0,
|
||||
mentionedLocalUserIds: [],
|
||||
mentionedLocalUsersCount: 0,
|
||||
mentionedRemoteUserIds: [],
|
||||
mentionedRemoteUsersCount: 0,
|
||||
attachedUserIds: [user.id],
|
||||
attachedUsersCount: 1,
|
||||
attachedLocalUserIds: this.userEntityService.isLocalUser(user) ? [user.id] : [],
|
||||
attachedLocalUsersCount: this.userEntityService.isLocalUser(user) ? 1 : 0,
|
||||
attachedRemoteUserIds: this.userEntityService.isRemoteUser(user) ? [user.id] : [],
|
||||
attachedRemoteUsersCount: this.userEntityService.isRemoteUser(user) ? 1 : 0,
|
||||
} as MiHashtag);
|
||||
} else {
|
||||
await this.hashtagsRepository.insert({
|
||||
id: this.idService.gen(),
|
||||
name: tag,
|
||||
mentionedUserIds: [user.id],
|
||||
mentionedUsersCount: 1,
|
||||
mentionedLocalUserIds: this.userEntityService.isLocalUser(user) ? [user.id] : [],
|
||||
mentionedLocalUsersCount: this.userEntityService.isLocalUser(user) ? 1 : 0,
|
||||
mentionedRemoteUserIds: this.userEntityService.isRemoteUser(user) ? [user.id] : [],
|
||||
mentionedRemoteUsersCount: this.userEntityService.isRemoteUser(user) ? 1 : 0,
|
||||
attachedUserIds: [],
|
||||
attachedUsersCount: 0,
|
||||
attachedLocalUserIds: [],
|
||||
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;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function appendUserIdIfNotExists(userIds: keyof MiHashtag & `${string}UserIds`): string {
|
||||
return `CASE WHEN NOT ("hashtag"."${userIds}" @> ARRAY[$2 ::varchar]) THEN array_append("hashtag"."${userIds}", $2) ELSE "hashtag"."${userIds}" END`;
|
||||
}
|
||||
await this.db.transaction(async transactionalEntityManager => {
|
||||
const transactionalHashtagRepository = transactionalEntityManager
|
||||
.getRepository(MiHashtag);
|
||||
|
||||
function incrementCountIfNotExists(userIds: keyof MiHashtag & `${string}UserIds`, userCount: keyof MiHashtag & `${string}UsersCount`): string {
|
||||
return `CASE WHEN NOT ("hashtag"."${userIds}" @> ARRAY[$2 ::varchar]) THEN "hashtag"."${userCount}" + 1 ELSE "hashtag"."${userCount}" END`;
|
||||
}
|
||||
}
|
||||
const index = await transactionalHashtagRepository
|
||||
.createQueryBuilder()
|
||||
.setLock('pessimistic_write')
|
||||
.where('name = :name', { name: tag })
|
||||
.getOne();
|
||||
|
||||
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;
|
||||
if (index == null) return;
|
||||
|
||||
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();
|
||||
}
|
||||
const set = {} as any;
|
||||
|
||||
function decrementIfExists(userIds: keyof MiHashtag & `${string}UserIds`, userCount: keyof MiHashtag & `${string}UsersCount`): string {
|
||||
return `CASE WHEN ("${userIds}" @> ARRAY[$2]) THEN "${userCount}" - 1 ELSE "${userCount}" END`;
|
||||
}
|
||||
if (isUserAttached) {
|
||||
if (inc) {
|
||||
// 自分が初めてこのタグを使ったなら
|
||||
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
|
||||
|
||||
@@ -9,6 +9,7 @@ import { DI } from '@/di-symbols.js';
|
||||
import type { Config } from '@/config.js';
|
||||
import { baseQueueOptions, QUEUE } from '@/queue/const.js';
|
||||
import { allSettled } from '@/misc/promise-tracker.js';
|
||||
import { instrumentQueue } from '@/core/telemetry/queue-instrumentation.js';
|
||||
import {
|
||||
DeliverJobData,
|
||||
EndedPollNotificationJobData,
|
||||
@@ -32,7 +33,12 @@ export type UserWebhookDeliverQueue = Bull.Queue<UserWebhookDeliverJobData>;
|
||||
export type SystemWebhookDeliverQueue = Bull.Queue<SystemWebhookDeliverJobData>;
|
||||
|
||||
function createQueue<T extends object>(queueName: string, config: Config): Bull.Queue<T> {
|
||||
return new Bull.Queue<T>(queueName, baseQueueOptions(config, queueName));
|
||||
const queue = new Bull.Queue<T>(queueName, baseQueueOptions(config, queueName));
|
||||
// Queue のラップは、enqueue 時に OTel context をジョブデータへ埋め込むためのもの。
|
||||
// Sentry 単独ではジョブ間の context 伝播を使わないので、OTel 未設定時は元の Queue を返す。
|
||||
if (config.otelForBackend == null) return queue;
|
||||
|
||||
return instrumentQueue(queue);
|
||||
}
|
||||
|
||||
const $system: Provider = {
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { bindThis } from '@/decorators.js';
|
||||
import { captureMessage, shutdownTelemetry, startSpan } from './telemetry-registry.js';
|
||||
import { captureMessage, shutdownTelemetry, startSpan, startSpanWithTraceContext } from './telemetry-registry.js';
|
||||
import type { OnApplicationShutdown } from '@nestjs/common';
|
||||
import type { TelemetryCaptureMessageOptions } from './adapters/TelemetryAdapter.js';
|
||||
|
||||
@@ -21,6 +21,13 @@ export class TelemetryService implements OnApplicationShutdown {
|
||||
return startSpan(name, fn);
|
||||
}
|
||||
|
||||
@bindThis
|
||||
public startSpanWithTraceContext<T>(name: string, jobData: object, fn: () => T): T {
|
||||
// jobData に enqueue 元の context があれば worker span へ復元する。
|
||||
// context の無い既存ジョブは、通常の startSpan と同じ扱いになる。
|
||||
return startSpanWithTraceContext(name, jobData, fn);
|
||||
}
|
||||
|
||||
@bindThis
|
||||
public async onApplicationShutdown(_signal?: string): Promise<void> {
|
||||
await shutdownTelemetry();
|
||||
|
||||
@@ -0,0 +1,259 @@
|
||||
/*
|
||||
* 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,13 +3,18 @@
|
||||
* 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 * as SentryNode from '@sentry/node';
|
||||
import type { NodeOptions } from '@sentry/node';
|
||||
import type { SentryBackendConfig, TelemetryAdapter, TelemetryCaptureMessageOptions } from './TelemetryAdapter.js';
|
||||
import type { OtelBackendRuntimeConfig, SentryBackendConfig, TelemetryAdapter, TelemetryCaptureMessageOptions } from './TelemetryAdapter.js';
|
||||
import type { QueueTraceContextCarrier, QueueTraceContextDeps } from '../queue-trace-context.js';
|
||||
|
||||
// Sentryのtransportが詰まってもプロセス終了を妨げないようにする。
|
||||
// OpenTelemetryAdapterのDEFAULT_SHUTDOWN_TIMEOUTと揃え、Sentryのtransportが詰まってもプロセス終了を妨げないようにする。
|
||||
const DEFAULT_SHUTDOWN_TIMEOUT = 5000;
|
||||
const logger = new Logger('telemetry', 'green');
|
||||
|
||||
type SentryIntegrationsOption = NonNullable<NodeOptions['integrations']>;
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
@@ -68,9 +73,50 @@ export function buildSentryNodeOptions(
|
||||
};
|
||||
}
|
||||
|
||||
type BuildSentryOtlpInitOptions = {
|
||||
sentryConfig: SentryBackendConfig;
|
||||
otelConfig: OtelBackendRuntimeConfig;
|
||||
otlpProcessor: unknown;
|
||||
nodeProfilingIntegration?: () => SentryIntegration;
|
||||
warn?: (message: string) => void;
|
||||
};
|
||||
|
||||
export function buildSentryOtlpInitOptions(options: BuildSentryOtlpInitOptions): SentryNodeOptions {
|
||||
// OTel併存時も、remoteへtrace headerを漏らさないデフォルトはSentry単体時と揃える。
|
||||
// propagateTraceToRemote: true か、options.tracePropagationTargets の明示指定がある場合のみ既定を上書きする。
|
||||
const { tracePropagationTargets, ...sentryOptions } = options.sentryConfig.options;
|
||||
const propagateTraceToRemote = options.otelConfig.propagateTraceToRemote === true || tracePropagationTargets != null;
|
||||
const warn = options.warn ?? ((message: string) => logger.warn(message));
|
||||
|
||||
if (options.otelConfig.sampleRate != null) {
|
||||
warn('otelForBackend.sampleRate is ignored when sentryForBackend is also configured; configure sentryForBackend.options.tracesSampleRate or tracesSampler instead.');
|
||||
}
|
||||
|
||||
if (options.otelConfig.resourceAttributes != null) {
|
||||
warn('otelForBackend.resourceAttributes is ignored when sentryForBackend is also configured; configure OTEL_RESOURCE_ATTRIBUTES instead.');
|
||||
}
|
||||
|
||||
return {
|
||||
...buildSentryNodeOptions({
|
||||
...options.sentryConfig,
|
||||
options: {
|
||||
...sentryOptions,
|
||||
...(propagateTraceToRemote ? { tracePropagationTargets } : {}),
|
||||
},
|
||||
}, options.nodeProfilingIntegration),
|
||||
|
||||
// Sentryの単一TracerProviderにOTLP processorを追加し、親欠損や二重providerを避ける。
|
||||
openTelemetrySpanProcessors: [
|
||||
...(options.sentryConfig.options.openTelemetrySpanProcessors ?? []),
|
||||
options.otlpProcessor as NonNullable<SentryNodeOptions['openTelemetrySpanProcessors']>[number],
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
export class SentryTelemetryAdapter implements TelemetryAdapter {
|
||||
private constructor(
|
||||
private readonly Sentry: typeof SentryNode,
|
||||
private readonly queueTraceContext?: QueueTraceContextDeps,
|
||||
) {
|
||||
}
|
||||
|
||||
@@ -83,6 +129,45 @@ export class SentryTelemetryAdapter implements TelemetryAdapter {
|
||||
return new SentryTelemetryAdapter(Sentry);
|
||||
}
|
||||
|
||||
public static async createWithOtlpExport(
|
||||
sentryConfig: SentryBackendConfig,
|
||||
otelConfig: OtelBackendRuntimeConfig,
|
||||
): Promise<SentryTelemetryAdapter> {
|
||||
const Sentry = await import('@sentry/node');
|
||||
const { nodeProfilingIntegration } = await import('@sentry/profiling-node');
|
||||
const { context, diag, DiagLogLevel, propagation, ROOT_CONTEXT, SpanStatusCode, trace } = await import('@opentelemetry/api');
|
||||
const { BatchSpanProcessor } = await import('@opentelemetry/sdk-trace-base');
|
||||
const { OTLPTraceExporter } = await import('@opentelemetry/exporter-trace-otlp-proto');
|
||||
|
||||
registerDiagLogger(diag, DiagLogLevel.WARN);
|
||||
|
||||
// OTLP送信だけを担うprocessorを作り、provider生成はSentry.init側に任せる。
|
||||
const otlpProcessor = new BatchSpanProcessor(new OTLPTraceExporter({
|
||||
...(otelConfig.endpoint != null ? { url: otelConfig.endpoint } : {}),
|
||||
...(otelConfig.headers != null ? { headers: otelConfig.headers } : {}),
|
||||
}));
|
||||
|
||||
// SentryとOTLPを同一providerに集約することで、どちらの宛先にも同じspan実体を流す。
|
||||
Sentry.init(buildSentryOtlpInitOptions({
|
||||
sentryConfig,
|
||||
otelConfig,
|
||||
otlpProcessor,
|
||||
nodeProfilingIntegration,
|
||||
}));
|
||||
|
||||
// Sentry が初期化した同じ OTel provider から tracer/context API を受け取り、
|
||||
// Queue を跨ぐ context 伝播も Sentry と OTLP の両方へ同一 span として出力する。
|
||||
return new SentryTelemetryAdapter(Sentry, {
|
||||
tracer: trace.getTracer('misskey-backend'),
|
||||
propagation,
|
||||
trace,
|
||||
getActiveContext: () => context.active(),
|
||||
rootContext: ROOT_CONTEXT,
|
||||
mode: getQueueTraceContextMode(otelConfig.jobTraceContextMode),
|
||||
spanStatusCodeError: SpanStatusCode.ERROR,
|
||||
});
|
||||
}
|
||||
|
||||
public captureMessage(message: string, opts: TelemetryCaptureMessageOptions): void {
|
||||
this.Sentry.captureMessage(message, {
|
||||
level: opts.level,
|
||||
@@ -104,6 +189,19 @@ export class SentryTelemetryAdapter implements TelemetryAdapter {
|
||||
return this.Sentry.startSpan({ name }, fn);
|
||||
}
|
||||
|
||||
public injectTraceContext(carrier: QueueTraceContextCarrier): void {
|
||||
// Sentry 単体構成では queueTraceContext を持たず、従来どおりジョブデータを変更しない。
|
||||
if (this.queueTraceContext == null) return;
|
||||
injectActiveTraceContext(this.queueTraceContext, carrier);
|
||||
}
|
||||
|
||||
public startSpanWithTraceContext<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> {
|
||||
// timeout未指定だとtransportのflushが詰まった際にプロセス終了を妨げるため、上限時間を設ける。
|
||||
await this.Sentry.close(DEFAULT_SHUTDOWN_TIMEOUT);
|
||||
|
||||
@@ -5,14 +5,19 @@
|
||||
|
||||
import type { Config } from '@/config.js';
|
||||
import type { LogTraceContext } from '@/logging/types.js';
|
||||
import type { QueueTraceContextCarrier } from '../queue-trace-context.js';
|
||||
|
||||
export type SentryBackendConfig = NonNullable<Config['sentryForBackend']>;
|
||||
export type OtelBackendConfig = NonNullable<Config['otelForBackend']>;
|
||||
export type OtelBackendRuntimeConfig = OtelBackendConfig & {
|
||||
serviceVersion: string;
|
||||
};
|
||||
|
||||
export interface TelemetryCaptureMessageOptions {
|
||||
/** 現在はエラー通知用途だけに絞る。追加する場合は各adapterでの扱いを揃えること。 */
|
||||
level: 'error';
|
||||
|
||||
/** Sentryではuser.idへ渡す補助情報です。 */
|
||||
/** Sentryではuser.idへ渡す。OTel adapterは現在span属性へ付与していないため、必要ならadapter側で拡張する。 */
|
||||
userId?: string;
|
||||
|
||||
/** queue名やendpoint名など、通知先で調査に使う補助情報。 */
|
||||
@@ -20,14 +25,14 @@ export interface TelemetryCaptureMessageOptions {
|
||||
}
|
||||
|
||||
/**
|
||||
* エラートラッキング/APMサービスごとの実装差異を隠蔽するための抽象。
|
||||
* Sentry・OpenTelemetryなど、エラートラッキング/APMサービスごとの実装差異を隠蔽するための抽象。
|
||||
* 新しいサービスを追加する場合はこのインターフェースを実装するアダプタをこのディレクトリに追加し、
|
||||
* telemetry-registry.tsのinitTelemetry内で登録する。
|
||||
*/
|
||||
export interface TelemetryAdapter {
|
||||
/**
|
||||
* 実行中の処理で起きたエラー相当の事象を記録する。
|
||||
* Sentryではmessage通知として扱う。
|
||||
* Sentryはmessage通知、OTelはactive spanまたは短命spanへの例外記録として扱う。
|
||||
*/
|
||||
captureMessage(message: string, opts: TelemetryCaptureMessageOptions): void;
|
||||
|
||||
@@ -40,6 +45,18 @@ export interface TelemetryAdapter {
|
||||
*/
|
||||
startSpan<T>(name: string, fn: () => T): T;
|
||||
|
||||
/**
|
||||
* BullMQ のジョブデータへ保存する carrier に、active trace context を注入する。
|
||||
* OTel を使わない adapter は実装しない。
|
||||
*/
|
||||
injectTraceContext?(carrier: QueueTraceContextCarrier): void;
|
||||
|
||||
/**
|
||||
* ジョブに保存された enqueue 元の context を、worker span の Link または parent として復元する。
|
||||
* context を持たないジョブの互換性は adapter 側で保つ。
|
||||
*/
|
||||
startSpanWithTraceContext?<T>(name: string, jobData: object, fn: () => T): T;
|
||||
|
||||
/**
|
||||
* プロセス終了時にtelemetry backendへ残りのデータをflushする。
|
||||
* 実装側ではtransport停止に引きずられないよう、待機時間に上限を設ける。
|
||||
|
||||
@@ -0,0 +1,86 @@
|
||||
/*
|
||||
* SPDX-FileCopyrightText: syuilo and misskey-project
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
import type { Span, TracerProvider } from '@opentelemetry/api';
|
||||
|
||||
type Instrumentation = {
|
||||
setTracerProvider(provider: TracerProvider): void;
|
||||
enable(): void;
|
||||
disable(): void;
|
||||
};
|
||||
|
||||
type PgInstrumentationConfig = {
|
||||
enhancedDatabaseReporting: boolean;
|
||||
requireParentSpan: boolean;
|
||||
ignoreConnectSpans: boolean;
|
||||
requestHook?(span: Span): void;
|
||||
};
|
||||
|
||||
type InstrumentationConstructor = new (config: PgInstrumentationConfig) => Instrumentation;
|
||||
|
||||
type InstrumentationDeps = {
|
||||
PgInstrumentation: InstrumentationConstructor;
|
||||
};
|
||||
|
||||
type DatabaseInstrumentationOptions = {
|
||||
capturePgStatement: boolean;
|
||||
capturePgConnectionSpans: boolean;
|
||||
};
|
||||
|
||||
type InstallDatabaseInstrumentationOptions = DatabaseInstrumentationOptions & {
|
||||
capturePgSpans: boolean;
|
||||
};
|
||||
|
||||
/**
|
||||
* pg はアプリケーションが import する前に有効化しないと、require hook 型の
|
||||
* 自動計装がモジュールを patch できない。そのため、
|
||||
* telemetry provider の登録直後にこの関数を呼び出す。
|
||||
*/
|
||||
export async function installDatabaseInstrumentation(provider: TracerProvider, options: InstallDatabaseInstrumentationOptions): Promise<() => void> {
|
||||
if (!options.capturePgSpans) return () => {};
|
||||
|
||||
const { PgInstrumentation } = await import('@opentelemetry/instrumentation-pg');
|
||||
|
||||
return installInstrumentation(provider, { PgInstrumentation }, options);
|
||||
}
|
||||
|
||||
export function installInstrumentation(provider: TracerProvider, deps: InstrumentationDeps, options: DatabaseInstrumentationOptions = {
|
||||
capturePgStatement: false,
|
||||
capturePgConnectionSpans: false,
|
||||
}): () => void {
|
||||
const instrumentations = [
|
||||
new deps.PgInstrumentation({
|
||||
// SQLパラメータには投稿内容・認証情報などが含まれ得るため、常に記録しない。
|
||||
enhancedDatabaseReporting: false,
|
||||
requireParentSpan: true,
|
||||
ignoreConnectSpans: !options.capturePgConnectionSpans,
|
||||
// instrumentation-pgはSQL本文を無加工で属性へ追加する。明示opt-in時だけ残す。
|
||||
...(options.capturePgStatement ? {} : {
|
||||
requestHook: (span: Span) => {
|
||||
span.setAttribute('db.statement', '[REDACTED]');
|
||||
span.setAttribute('db.query.text', '[REDACTED]');
|
||||
},
|
||||
}),
|
||||
}),
|
||||
];
|
||||
|
||||
try {
|
||||
for (const instrumentation of instrumentations) {
|
||||
instrumentation.setTracerProvider(provider);
|
||||
instrumentation.enable();
|
||||
}
|
||||
} catch (error) {
|
||||
for (const instrumentation of instrumentations) {
|
||||
instrumentation.disable();
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
|
||||
return () => {
|
||||
for (const instrumentation of instrumentations) {
|
||||
instrumentation.disable();
|
||||
}
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,122 @@
|
||||
/*
|
||||
* SPDX-FileCopyrightText: syuilo and misskey-project
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
import { channel } from 'node:diagnostics_channel';
|
||||
import type { ClientRequest, IncomingMessage } from 'node:http';
|
||||
import type { Span, SpanOptions, SpanStatusCode, Tracer } from '@opentelemetry/api';
|
||||
|
||||
const HTTP_CLIENT_REQUEST_CREATED = 'http.client.request.created';
|
||||
const HTTP_CLIENT_RESPONSE_FINISH = 'http.client.response.finish';
|
||||
const HTTP_CLIENT_REQUEST_ERROR = 'http.client.request.error';
|
||||
|
||||
type HttpClientSpan = Pick<Span, 'end' | 'recordException' | 'setAttribute' | 'setStatus'>;
|
||||
|
||||
type HttpClientInstrumentationDeps = {
|
||||
tracer: Pick<Tracer, 'startSpan'>;
|
||||
spanKindClient: SpanOptions['kind'];
|
||||
spanStatusCodeError: SpanStatusCode;
|
||||
subscribe: (name: string, listener: (message: unknown) => void) => () => void;
|
||||
};
|
||||
|
||||
type RequestCreatedMessage = { request: ClientRequest };
|
||||
type ResponseFinishMessage = { request: ClientRequest; response: IncomingMessage };
|
||||
type RequestErrorMessage = { request: ClientRequest; error: Error };
|
||||
|
||||
/**
|
||||
* require フックを使わず、Node.js 組み込み HTTP クライアントの diagnostics channel を計装する。
|
||||
* telemetry 初期化前に読み込まれたモジュールも対象になる。
|
||||
*/
|
||||
export function createHttpClientInstrumentation(deps: HttpClientInstrumentationDeps): () => void {
|
||||
const spans = new WeakMap<ClientRequest, HttpClientSpan>();
|
||||
|
||||
const unsubscribeCreated = deps.subscribe(HTTP_CLIENT_REQUEST_CREATED, (message: unknown) => {
|
||||
const { request } = message as RequestCreatedMessage;
|
||||
const { url, host, port } = getRequestDetails(request);
|
||||
const method = request.method ?? 'GET';
|
||||
const span = deps.tracer.startSpan(method, {
|
||||
kind: deps.spanKindClient,
|
||||
attributes: {
|
||||
'http.request.method': method,
|
||||
'url.full': url,
|
||||
'server.address': host,
|
||||
'server.port': port,
|
||||
},
|
||||
});
|
||||
spans.set(request, span);
|
||||
});
|
||||
|
||||
const unsubscribeResponseFinish = deps.subscribe(HTTP_CLIENT_RESPONSE_FINISH, (message: unknown) => {
|
||||
const { request, response } = message as ResponseFinishMessage;
|
||||
const span = spans.get(request);
|
||||
if (span == null) return;
|
||||
|
||||
const statusCode = response.statusCode;
|
||||
if (statusCode != null) {
|
||||
span.setAttribute('http.response.status_code', statusCode);
|
||||
}
|
||||
if (response.httpVersion != null) {
|
||||
span.setAttribute('network.protocol.version', response.httpVersion);
|
||||
}
|
||||
if (statusCode != null && statusCode >= 400) {
|
||||
span.setAttribute('error.type', String(statusCode));
|
||||
span.setStatus({ code: deps.spanStatusCodeError });
|
||||
}
|
||||
span.end();
|
||||
spans.delete(request);
|
||||
});
|
||||
|
||||
const unsubscribeRequestError = deps.subscribe(HTTP_CLIENT_REQUEST_ERROR, (message: unknown) => {
|
||||
const { request, error } = message as RequestErrorMessage;
|
||||
const span = spans.get(request);
|
||||
if (span == null) return;
|
||||
|
||||
span.recordException(error);
|
||||
span.setAttribute('error.type', getErrorType(error));
|
||||
span.setStatus({ code: deps.spanStatusCodeError });
|
||||
span.end();
|
||||
spans.delete(request);
|
||||
});
|
||||
|
||||
return () => {
|
||||
unsubscribeCreated();
|
||||
unsubscribeResponseFinish();
|
||||
unsubscribeRequestError();
|
||||
};
|
||||
}
|
||||
|
||||
export function installHttpClientInstrumentation(deps: Omit<HttpClientInstrumentationDeps, 'subscribe'>): () => void {
|
||||
return createHttpClientInstrumentation({
|
||||
...deps,
|
||||
subscribe: (name, listener) => {
|
||||
const diagnosticChannel = channel(name);
|
||||
diagnosticChannel.subscribe(listener);
|
||||
return () => diagnosticChannel.unsubscribe(listener);
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
function getRequestDetails(request: ClientRequest): { url: string; host: string; port: number } {
|
||||
const protocol = request.protocol ?? 'http:';
|
||||
const host = request.getHeader('host')?.toString() ?? request.host ?? 'localhost';
|
||||
const url = new URL(request.path || '/', `${protocol}//${host}`);
|
||||
// URL 属性には認証情報やクエリ文字列を含めない。
|
||||
url.username = '';
|
||||
url.password = '';
|
||||
url.search = '';
|
||||
url.hash = '';
|
||||
|
||||
return {
|
||||
url: url.toString(),
|
||||
host: url.hostname,
|
||||
// URL.port は既定ポートでは空文字列になるため、スキームから補う。
|
||||
port: url.port === '' ? (url.protocol === 'https:' ? 443 : 80) : Number(url.port),
|
||||
};
|
||||
}
|
||||
|
||||
function getErrorType(error: Error): string {
|
||||
// Node.js の system error code は安定した低カーディナリティの識別子になる。
|
||||
const code = (error as NodeJS.ErrnoException).code;
|
||||
return code ?? error.name;
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
/*
|
||||
* SPDX-FileCopyrightText: syuilo and misskey-project
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
import { injectTraceContext } from './telemetry-registry.js';
|
||||
import { injectQueueTraceContext } from './queue-trace-context.js';
|
||||
import type * as Bull from 'bullmq';
|
||||
|
||||
/**
|
||||
* Queue の add/addBulk をラップし、全ての BullMQ enqueue 経路を一箇所で捕捉する。
|
||||
* QueueService を通さず直接 add/addBulk する呼び出し元もあるため、それぞれで注入すると漏れやすい。
|
||||
*/
|
||||
export function instrumentQueue<T extends object>(queue: Bull.Queue<T>): Bull.Queue<T> {
|
||||
// BullMQ のメソッドは Queue インスタンスを this として使うため、差し替え前に bind して保持する。
|
||||
const add = queue.add.bind(queue);
|
||||
queue.add = ((name, data, opts) => {
|
||||
// BullMQ が data を Redis 用にシリアライズする前に、enqueue 元の context を内部フィールドへ追加する。
|
||||
injectQueueTraceContext(data, injectTraceContext);
|
||||
return add(name, data, opts);
|
||||
}) as typeof queue.add;
|
||||
|
||||
// addBulk は複数ジョブを一度にシリアライズするので、各 data へ同じ context を注入する。
|
||||
const addBulk = queue.addBulk.bind(queue);
|
||||
queue.addBulk = ((jobs) => {
|
||||
for (const job of jobs) {
|
||||
injectQueueTraceContext(job.data, injectTraceContext);
|
||||
}
|
||||
return addBulk(jobs);
|
||||
}) as typeof queue.addBulk;
|
||||
|
||||
return queue;
|
||||
}
|
||||
@@ -0,0 +1,182 @@
|
||||
/*
|
||||
* SPDX-FileCopyrightText: syuilo and misskey-project
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
import type { Context, PropagationAPI, Span, SpanContext, SpanOptions, SpanStatusCode, Tracer } from '@opentelemetry/api';
|
||||
|
||||
/*
|
||||
* enqueue (push) から worker による取得・処理 (pop) までをテレメトリ上で関連付け、
|
||||
* Queue を挟む非同期処理を一連の流れとして追跡できるようにする。
|
||||
* そのため、trace context を次の流れで伝播する:
|
||||
*
|
||||
* 1. producer 側で active context を W3C Trace Context 形式の carrier へ注入する。
|
||||
* 2. carrier をジョブデータの内部フィールドに保存し、BullMQ/Redis 経由で worker へ渡す。
|
||||
* 3. worker 側で carrier を context へ戻し、設定に応じて Link または parent に使う。
|
||||
*
|
||||
* OTel API への依存を引数にしているのは、OTel 単体構成と Sentry + OTLP 構成で
|
||||
* 同じ伝播処理を使うため。
|
||||
*/
|
||||
/** Redis 上のジョブデータにだけ保存する、OpenTelemetry propagator 用の carrier。 */
|
||||
export type QueueTraceContextCarrier = Record<string, string>;
|
||||
export type QueueTraceContextMode = 'link' | 'parent';
|
||||
|
||||
// 通常のジョブプロセッサが参照しない Misskey 内部フィールドとして、ユーザー定義の data と区別する。
|
||||
const QUEUE_TRACE_CONTEXT_KEY = '__misskeyTraceContext';
|
||||
|
||||
export type QueueTraceContextDeps = {
|
||||
tracer: Pick<Tracer, 'startActiveSpan'>;
|
||||
propagation: Pick<PropagationAPI, 'extract' | 'inject'>;
|
||||
trace: {
|
||||
getSpanContext(context: Context): SpanContext | undefined;
|
||||
};
|
||||
getActiveContext: () => Context;
|
||||
rootContext: Context;
|
||||
mode: QueueTraceContextMode;
|
||||
spanStatusCodeError: SpanStatusCode;
|
||||
};
|
||||
|
||||
export type QueueSpanContext = {
|
||||
options: SpanOptions;
|
||||
parentContext: Context;
|
||||
};
|
||||
|
||||
type QueueSpanContextDeps = Pick<QueueTraceContextDeps, 'propagation' | 'trace' | 'rootContext' | 'mode'>;
|
||||
|
||||
/**
|
||||
* enqueue 元の active context を、ジョブ本来のデータを壊さない内部フィールドとして保持する。
|
||||
* propagator が何も注入しなかった場合は、Redis に不要な空オブジェクトを残さない。
|
||||
*/
|
||||
export function injectQueueTraceContext(data: unknown, inject: (carrier: QueueTraceContextCarrier) => void): void {
|
||||
// BullMQ の型定義外から渡される値も考慮し、書き込めない値は無視する。
|
||||
if (data == null || typeof data !== 'object') return;
|
||||
|
||||
const carrier: QueueTraceContextCarrier = {};
|
||||
inject(carrier);
|
||||
|
||||
// active span が無い場合、propagator は何も注入しない。空の内部フィールドは Redis に保存しない。
|
||||
if (Object.keys(carrier).length === 0) return;
|
||||
Object.assign(data, { [QUEUE_TRACE_CONTEXT_KEY]: carrier });
|
||||
}
|
||||
|
||||
/** 現在実行中の span を propagator の標準形式で carrier へ書き出す。 */
|
||||
export function injectActiveTraceContext(deps: QueueTraceContextDeps, carrier: QueueTraceContextCarrier): void {
|
||||
deps.propagation.inject(deps.getActiveContext(), carrier);
|
||||
}
|
||||
|
||||
/**
|
||||
* ジョブに保存された carrier から、worker span の開始に必要な context と options を組み立てる。
|
||||
*
|
||||
* - parent: enqueue span の子として同じ trace を継続する。
|
||||
* - link: worker span を別の root trace にし、enqueue span への関連だけを Link に残す。
|
||||
*
|
||||
* link モードで trace を分けることで、worker 側の sampling 判定を enqueue 側から独立させられる。
|
||||
*/
|
||||
export function getQueueSpanContext(data: unknown, deps: QueueSpanContextDeps): QueueSpanContext | undefined {
|
||||
const carrier = getQueueTraceContextCarrier(data);
|
||||
if (carrier == null) return undefined;
|
||||
|
||||
// Redis から復元した carrier は別プロセス由来なので、現在の context ではなく ROOT_CONTEXT から展開する。
|
||||
const extractedContext = deps.propagation.extract(deps.rootContext, carrier);
|
||||
if (deps.mode === 'parent') {
|
||||
return {
|
||||
options: {},
|
||||
parentContext: extractedContext,
|
||||
};
|
||||
}
|
||||
|
||||
// Link が受け取るのは Context ではなく SpanContext なので、extract 後に取り出す。
|
||||
const spanContext = deps.trace.getSpanContext(extractedContext);
|
||||
return {
|
||||
options: {
|
||||
root: true,
|
||||
...(spanContext != null ? { links: [{ context: spanContext }] } : {}),
|
||||
},
|
||||
parentContext: deps.rootContext,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* context を持つジョブは Link/parent の規則で span を開始する。
|
||||
* デプロイ前に enqueue されたジョブなど、context を持たない場合は既存の adapter 固有実装へ委ねる。
|
||||
*/
|
||||
export function startSpanWithQueueTraceContext<T>(
|
||||
deps: QueueTraceContextDeps,
|
||||
name: string,
|
||||
jobData: object,
|
||||
fn: () => T,
|
||||
fallback: () => T,
|
||||
): T {
|
||||
const spanContext = getQueueSpanContext(jobData, deps);
|
||||
if (spanContext == null) return fallback();
|
||||
|
||||
return deps.tracer.startActiveSpan(name, spanContext.options, spanContext.parentContext, span => executeSpan(span, fn, deps.spanStatusCodeError));
|
||||
}
|
||||
|
||||
/**
|
||||
* 既存の TelemetryAdapter 契約に合わせ、同期値と Promise のどちらを返す処理も span で包む。
|
||||
* 成功・失敗のどちらでも処理完了まで span を開き、必ず一度だけ閉じる。
|
||||
*/
|
||||
export function executeSpan<T>(span: Span, fn: () => T, spanStatusCodeError: SpanStatusCode): T {
|
||||
try {
|
||||
const result = fn();
|
||||
if (isPromiseLike(result)) {
|
||||
// fn() の戻り値は T のまま保ちつつ、Promise の settle 時に span を閉じる。
|
||||
return result.then(
|
||||
value => {
|
||||
span.end();
|
||||
return value;
|
||||
},
|
||||
error => {
|
||||
recordSpanError(span, error, spanStatusCodeError);
|
||||
span.end();
|
||||
throw error;
|
||||
},
|
||||
) as T;
|
||||
}
|
||||
|
||||
span.end();
|
||||
return result;
|
||||
} catch (error) {
|
||||
// fn() が同期的に throw した場合も、Promise の reject と同じ形で記録して呼び出し元へ戻す。
|
||||
recordSpanError(span, error, spanStatusCodeError);
|
||||
span.end();
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/** Error 以外の throw 値も OTel exporter が扱える例外に正規化し、span をエラー状態にする。 */
|
||||
export function recordSpanError(span: Span, error: unknown, spanStatusCodeError: SpanStatusCode): void {
|
||||
const exception = error instanceof Error ? error : new Error(String(error));
|
||||
span.recordException(exception);
|
||||
span.setStatus({
|
||||
code: spanStatusCodeError,
|
||||
message: exception.message,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 未設定時は、enqueue と worker の sampling を分離できる link を使う。
|
||||
* 設定ミスを無言でフォールバックさせないため、未知の値は起動時にエラーにする。
|
||||
*/
|
||||
export function getQueueTraceContextMode(mode: unknown): QueueTraceContextMode {
|
||||
if (mode == null || mode === 'link') return 'link';
|
||||
if (mode === 'parent') return 'parent';
|
||||
throw new Error('otelForBackend.jobTraceContextMode must be either \'link\' or \'parent\'.');
|
||||
}
|
||||
|
||||
function getQueueTraceContextCarrier(data: unknown): QueueTraceContextCarrier | undefined {
|
||||
if (data == null || typeof data !== 'object') return undefined;
|
||||
const carrier = (data as Record<string, unknown>)[QUEUE_TRACE_CONTEXT_KEY];
|
||||
if (carrier == null || typeof carrier !== 'object' || Array.isArray(carrier)) return undefined;
|
||||
|
||||
// Redis 上の旧いジョブや壊れたデータで worker を落とさないよう、propagator に渡せる string map だけを受け入れる。
|
||||
const entries = Object.entries(carrier);
|
||||
if (entries.length === 0 || entries.some(([, value]) => typeof value !== 'string')) return undefined;
|
||||
return Object.fromEntries(entries) as QueueTraceContextCarrier;
|
||||
}
|
||||
|
||||
function isPromiseLike<T>(value: T): value is T & PromiseLike<Awaited<T>> {
|
||||
// native Promise に限らず thenable も span の完了を待てるよう、instanceof ではなく then の有無で判定する。
|
||||
return value != null && typeof (value as { then?: unknown }).then === 'function';
|
||||
}
|
||||
@@ -0,0 +1,178 @@
|
||||
/*
|
||||
* SPDX-FileCopyrightText: syuilo and misskey-project
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
import { tracingChannel } from 'node:diagnostics_channel';
|
||||
import { context, trace } from '@opentelemetry/api';
|
||||
import type { Span, SpanKind, SpanStatusCode, Tracer } from '@opentelemetry/api';
|
||||
|
||||
type IORedisCommandContext = {
|
||||
command: string;
|
||||
args: string[];
|
||||
database: number;
|
||||
serverAddress: string;
|
||||
serverPort: number | undefined;
|
||||
};
|
||||
|
||||
type IORedisConnectContext = {
|
||||
serverAddress: string;
|
||||
serverPort: number | undefined;
|
||||
};
|
||||
|
||||
type TracingChannelSubscribers<T extends object> = {
|
||||
start(message: T): void;
|
||||
end(message: T & { error?: unknown }): void;
|
||||
asyncStart(message: T & { error?: unknown }): void;
|
||||
asyncEnd(message: T & { error?: unknown }): void;
|
||||
error(message: T & { error: unknown }): void;
|
||||
};
|
||||
|
||||
type TracingChannel<T extends object> = {
|
||||
subscribe(subscribers: TracingChannelSubscribers<T>): void;
|
||||
unsubscribe(subscribers: TracingChannelSubscribers<T>): void;
|
||||
};
|
||||
|
||||
type RedisInstrumentationDeps = {
|
||||
tracingChannel<T extends object>(name: string): TracingChannel<T>;
|
||||
tracer: Pick<Tracer, 'startSpan'>;
|
||||
getActiveSpan(): Span | undefined;
|
||||
spanKindClient: SpanKind;
|
||||
spanStatusCodeError: SpanStatusCode;
|
||||
};
|
||||
|
||||
type RedisInstrumentationOptions = {
|
||||
captureCommandSpans?: boolean;
|
||||
/** requireParentSpan is the official ioredis instrumentation's default. */
|
||||
requireParentSpan?: boolean;
|
||||
captureConnectionSpans?: boolean;
|
||||
};
|
||||
|
||||
/**
|
||||
* ioredis 5.11以降が公開する native diagnostics channel を購読する。
|
||||
* バンドル後もioredis自身が発行するイベントを使うため、require hook に
|
||||
* 依存せず、ESM import 経路でもコマンド span を取得できる。
|
||||
*/
|
||||
export function installRedisInstrumentation(
|
||||
tracer: Pick<Tracer, 'startSpan'>,
|
||||
spanKindClient: SpanKind,
|
||||
spanStatusCodeError: SpanStatusCode,
|
||||
options: RedisInstrumentationOptions = {},
|
||||
): () => void {
|
||||
return createRedisInstrumentation({
|
||||
tracingChannel,
|
||||
tracer,
|
||||
getActiveSpan: () => trace.getSpan(context.active()),
|
||||
spanKindClient,
|
||||
spanStatusCodeError,
|
||||
}, options);
|
||||
}
|
||||
|
||||
export function createRedisInstrumentation(deps: RedisInstrumentationDeps, options: RedisInstrumentationOptions = {}): () => void {
|
||||
const requireParentSpan = options.requireParentSpan ?? true;
|
||||
const cleanup: Array<() => void> = [];
|
||||
if (options.captureCommandSpans === true) {
|
||||
const commandChannel = deps.tracingChannel<IORedisCommandContext>('ioredis:command');
|
||||
const commandSubscribers = createTracingChannelSubscribers(commandChannel, deps, requireParentSpan, message => ({
|
||||
name: message.command,
|
||||
attributes: {
|
||||
'db.system.name': 'redis',
|
||||
'db.namespace': message.database.toString(10),
|
||||
'db.operation.name': message.command,
|
||||
'server.address': message.serverAddress,
|
||||
...(message.serverPort != null ? { 'server.port': message.serverPort } : {}),
|
||||
},
|
||||
}));
|
||||
cleanup.push(() => commandChannel.unsubscribe(commandSubscribers));
|
||||
}
|
||||
if (options.captureConnectionSpans === true) {
|
||||
const connectChannel = deps.tracingChannel<IORedisConnectContext>('ioredis:connect');
|
||||
// Connection spans are explicitly opt-in and should include startup and reconnect attempts.
|
||||
const connectSubscribers = createTracingChannelSubscribers(connectChannel, deps, false, message => ({
|
||||
name: 'connect',
|
||||
attributes: {
|
||||
'db.system.name': 'redis',
|
||||
'db.operation.name': 'connect',
|
||||
'server.address': message.serverAddress,
|
||||
...(message.serverPort != null ? { 'server.port': message.serverPort } : {}),
|
||||
},
|
||||
}));
|
||||
cleanup.push(() => connectChannel.unsubscribe(connectSubscribers));
|
||||
}
|
||||
|
||||
return () => {
|
||||
for (const unsubscribe of cleanup) {
|
||||
unsubscribe();
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
function createTracingChannelSubscribers<T extends object>(
|
||||
channel: TracingChannel<T>,
|
||||
deps: RedisInstrumentationDeps,
|
||||
requireParentSpan: boolean,
|
||||
getSpanOptions: (message: T) => { name: string; attributes: Record<string, string | number> },
|
||||
): TracingChannelSubscribers<T> {
|
||||
const spans = new WeakMap<object, { span: Span; recordedError: boolean }>();
|
||||
|
||||
const recordError = (state: { span: Span; recordedError: boolean }, value: unknown): void => {
|
||||
if (state.recordedError) return;
|
||||
const error = toError(value);
|
||||
state.span.recordException(error);
|
||||
state.span.setStatus({ code: deps.spanStatusCodeError, message: error.message });
|
||||
state.span.setAttribute('error.type', getErrorType(error));
|
||||
const statusCode = getRedisErrorStatusCode(error.message);
|
||||
if (statusCode != null) state.span.setAttribute('db.response.status_code', statusCode);
|
||||
state.recordedError = true;
|
||||
};
|
||||
|
||||
const finish = (message: T & { error?: unknown }): void => {
|
||||
const state = spans.get(message);
|
||||
if (state == null) return;
|
||||
|
||||
if (message.error != null) {
|
||||
recordError(state, message.error);
|
||||
}
|
||||
state.span.end();
|
||||
spans.delete(message);
|
||||
};
|
||||
|
||||
const subscribers: TracingChannelSubscribers<T> = {
|
||||
start: (message) => {
|
||||
if (requireParentSpan && deps.getActiveSpan() == null) return;
|
||||
|
||||
const options = getSpanOptions(message);
|
||||
const span = deps.tracer.startSpan(options.name, {
|
||||
kind: deps.spanKindClient,
|
||||
attributes: options.attributes,
|
||||
});
|
||||
spans.set(message, { span, recordedError: false });
|
||||
},
|
||||
// Promiseを返すコマンドでは完了前にもendイベントが来るため、同期例外だけここで閉じる。
|
||||
end: (message) => {
|
||||
if (message.error != null) finish(message);
|
||||
},
|
||||
asyncStart: () => {},
|
||||
asyncEnd: finish,
|
||||
error: (message) => {
|
||||
const state = spans.get(message);
|
||||
if (state == null) return;
|
||||
recordError(state, message.error);
|
||||
},
|
||||
};
|
||||
|
||||
channel.subscribe(subscribers);
|
||||
return subscribers;
|
||||
}
|
||||
|
||||
function toError(value: unknown): Error {
|
||||
return value instanceof Error ? value : new Error(String(value));
|
||||
}
|
||||
|
||||
function getRedisErrorStatusCode(message: string): string | undefined {
|
||||
return message.match(/^([A-Z][A-Z0-9_]*)\b/)?.[1];
|
||||
}
|
||||
|
||||
function getErrorType(error: Error): string {
|
||||
return (error as NodeJS.ErrnoException).code ?? error.name;
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
/*
|
||||
* SPDX-FileCopyrightText: syuilo and misskey-project
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
import Logger from '@/logger.js';
|
||||
import type { DiagAPI, DiagLogger, DiagLogLevel } from '@opentelemetry/api';
|
||||
|
||||
export function registerDiagLogger(
|
||||
diagApi: DiagAPI,
|
||||
diagLogLevelWarn: DiagLogLevel,
|
||||
): void {
|
||||
// diagはプロセスグローバルなので、通常運用で必要なWARN以上だけをMisskeyのログに流す。
|
||||
const logger = new Logger('otel', 'green');
|
||||
const diagLogger: DiagLogger = {
|
||||
error: (message, ...args) => logger.error(formatDiagMessage(message, args)),
|
||||
warn: (message, ...args) => logger.warn(formatDiagMessage(message, args)),
|
||||
info: (message, ...args) => logger.info(formatDiagMessage(message, args)),
|
||||
debug: (message, ...args) => logger.debug(formatDiagMessage(message, args)),
|
||||
verbose: (message, ...args) => logger.debug(formatDiagMessage(message, args)),
|
||||
};
|
||||
|
||||
diagApi.setLogger(diagLogger, {
|
||||
logLevel: diagLogLevelWarn,
|
||||
suppressOverrideMessage: true,
|
||||
});
|
||||
}
|
||||
|
||||
function formatDiagMessage(message: string, args: unknown[]): string {
|
||||
if (args.length === 0) return message;
|
||||
return `${message} ${args.map(arg => {
|
||||
if (arg instanceof Error) return arg.stack ?? arg.message;
|
||||
if (typeof arg === 'string') return arg;
|
||||
try {
|
||||
return JSON.stringify(arg);
|
||||
} catch {
|
||||
return String(arg);
|
||||
}
|
||||
}).join(' ')}`;
|
||||
}
|
||||
@@ -5,8 +5,10 @@
|
||||
|
||||
import type { Config } from '@/config.js';
|
||||
import { setLogTraceContextProvider } from '@/logging/logging-runtime.js';
|
||||
import { OpenTelemetryAdapter } from './adapters/OpenTelemetryAdapter.js';
|
||||
import { SentryTelemetryAdapter } from './adapters/SentryTelemetryAdapter.js';
|
||||
import type { TelemetryAdapter, TelemetryCaptureMessageOptions } from './adapters/TelemetryAdapter.js';
|
||||
import type { OtelBackendRuntimeConfig, TelemetryAdapter, TelemetryCaptureMessageOptions } from './adapters/TelemetryAdapter.js';
|
||||
import type { QueueTraceContextCarrier } from './queue-trace-context.js';
|
||||
|
||||
/**
|
||||
* NestのDIコンテナが構築される前(boot処理内)で初期化する必要があるため、
|
||||
@@ -16,8 +18,24 @@ import type { TelemetryAdapter, TelemetryCaptureMessageOptions } from './adapter
|
||||
const adapters: TelemetryAdapter[] = [];
|
||||
|
||||
export async function initTelemetry(config: Config): Promise<void> {
|
||||
if (config.sentryForBackend) {
|
||||
const adapter = await SentryTelemetryAdapter.create(config.sentryForBackend);
|
||||
const otelForBackend: OtelBackendRuntimeConfig | undefined = config.otelForBackend == null ? undefined : {
|
||||
...config.otelForBackend,
|
||||
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);
|
||||
// Telemetryの初期化後に登録し、初期化前のBootstrapログは従来どおり出力する。
|
||||
setLogTraceContextProvider(() => adapter.getActiveTraceContext?.());
|
||||
@@ -45,6 +63,19 @@ export function startSpan<T>(name: string, fn: () => T): T {
|
||||
return wrapped();
|
||||
}
|
||||
|
||||
export function injectTraceContext(carrier: QueueTraceContextCarrier): void {
|
||||
// Queue の carrier は共有データなので、通知と異なり全 adapter にブロードキャストしない。
|
||||
// OTel provider は現在 1 つだけなので、同じ header を上書きしないよう最初の対応 adapter だけを使う。
|
||||
adapters.find(adapter => adapter.injectTraceContext != null)?.injectTraceContext?.(carrier);
|
||||
}
|
||||
|
||||
export function startSpanWithTraceContext<T>(name: string, jobData: object, fn: () => T): T {
|
||||
// Queue context を解釈できる adapter に span 作成を任せる。
|
||||
// 対応 adapter が無い構成では通常の startSpan へフォールバックする。
|
||||
const adapter = adapters.find(adapter => adapter.startSpanWithTraceContext != null);
|
||||
return adapter?.startSpanWithTraceContext?.(name, jobData, fn) ?? startSpan(name, fn);
|
||||
}
|
||||
|
||||
export async function shutdownTelemetry(): Promise<void> {
|
||||
// 終了時は登録済みadapterを並列にflush/shutdownする。
|
||||
await Promise.allSettled(adapters.map(adapter => adapter.shutdown()));
|
||||
|
||||
@@ -0,0 +1,11 @@
|
||||
/*
|
||||
* 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';
|
||||
@@ -20,7 +20,6 @@ export class MiHashtag {
|
||||
|
||||
@Column({
|
||||
...id(),
|
||||
default: [],
|
||||
array: true,
|
||||
})
|
||||
public mentionedUserIds: MiUser['id'][];
|
||||
@@ -33,7 +32,6 @@ export class MiHashtag {
|
||||
|
||||
@Column({
|
||||
...id(),
|
||||
default: [],
|
||||
array: true,
|
||||
})
|
||||
public mentionedLocalUserIds: MiUser['id'][];
|
||||
@@ -46,7 +44,6 @@ export class MiHashtag {
|
||||
|
||||
@Column({
|
||||
...id(),
|
||||
default: [],
|
||||
array: true,
|
||||
})
|
||||
public mentionedRemoteUserIds: MiUser['id'][];
|
||||
@@ -59,7 +56,6 @@ export class MiHashtag {
|
||||
|
||||
@Column({
|
||||
...id(),
|
||||
default: [],
|
||||
array: true,
|
||||
})
|
||||
public attachedUserIds: MiUser['id'][];
|
||||
@@ -72,7 +68,6 @@ export class MiHashtag {
|
||||
|
||||
@Column({
|
||||
...id(),
|
||||
default: [],
|
||||
array: true,
|
||||
})
|
||||
public attachedLocalUserIds: MiUser['id'][];
|
||||
@@ -85,7 +80,6 @@ export class MiHashtag {
|
||||
|
||||
@Column({
|
||||
...id(),
|
||||
default: [],
|
||||
array: true,
|
||||
})
|
||||
public attachedRemoteUserIds: MiUser['id'][];
|
||||
|
||||
@@ -11,7 +11,7 @@ import type Logger from '@/logger.js';
|
||||
import { bindThis } from '@/decorators.js';
|
||||
import { TelemetryService } from '@/core/telemetry/TelemetryService.js';
|
||||
import { CheckModeratorsActivityProcessorService } from '@/queue/processors/CheckModeratorsActivityProcessorService.js';
|
||||
import { runQueueJob } from './queue-job-runner.js';
|
||||
import { runQueueJobWithTraceContext } from './queue-job-runner.js';
|
||||
import { UserWebhookDeliverProcessorService } from './processors/UserWebhookDeliverProcessorService.js';
|
||||
import { SystemWebhookDeliverProcessorService } from './processors/SystemWebhookDeliverProcessorService.js';
|
||||
import { EndedPollNotificationProcessorService } from './processors/EndedPollNotificationProcessorService.js';
|
||||
@@ -159,7 +159,8 @@ export class QueueProcessorService implements OnApplicationShutdown {
|
||||
};
|
||||
}
|
||||
|
||||
// 以下の各 Worker はジョブの実処理全体を worker span で囲む。
|
||||
// 以下の各 Worker は job.data に保存された enqueue 元の trace context を復元し、
|
||||
// ジョブの実処理全体を Link または parent の worker span で囲む。
|
||||
//#region system
|
||||
{
|
||||
const processer = (job: Bull.Job) => {
|
||||
@@ -179,9 +180,10 @@ export class QueueProcessorService implements OnApplicationShutdown {
|
||||
const logger = this.logger.createSubLogger('system');
|
||||
|
||||
this.systemQueueWorker = new Bull.Worker(QUEUE.SYSTEM, (job) => {
|
||||
return runQueueJob(
|
||||
return runQueueJobWithTraceContext(
|
||||
this.telemetryService,
|
||||
'Queue: System: ' + job.name,
|
||||
job.data,
|
||||
() => processer(job) as Promise<void>,
|
||||
err => {
|
||||
logger.error(`failed(${err.name}: ${err.message}) id=${job.id}`, { job: renderJob(job), e: renderError(err) });
|
||||
@@ -233,9 +235,10 @@ export class QueueProcessorService implements OnApplicationShutdown {
|
||||
const logger = this.logger.createSubLogger('db');
|
||||
|
||||
this.dbQueueWorker = new Bull.Worker(QUEUE.DB, (job) => {
|
||||
return runQueueJob(
|
||||
return runQueueJobWithTraceContext(
|
||||
this.telemetryService,
|
||||
'Queue: DB: ' + job.name,
|
||||
job.data,
|
||||
() => processer(job),
|
||||
err => {
|
||||
logger.error(`failed(${err.name}: ${err.message}) id=${job.id}`, { job: renderJob(job), e: renderError(err) });
|
||||
@@ -263,9 +266,10 @@ export class QueueProcessorService implements OnApplicationShutdown {
|
||||
const logger = this.logger.createSubLogger('deliver');
|
||||
|
||||
this.deliverQueueWorker = new Bull.Worker(QUEUE.DELIVER, (job) => {
|
||||
return runQueueJob(
|
||||
return runQueueJobWithTraceContext(
|
||||
this.telemetryService,
|
||||
'Queue: Deliver',
|
||||
job.data,
|
||||
() => this.deliverProcessorService.process(job),
|
||||
err => {
|
||||
logger.error(`failed(${err.name}: ${err.message}) ${getJobInfo(job)} to=${job.data.to}`, { e: renderError(err) });
|
||||
@@ -301,9 +305,10 @@ export class QueueProcessorService implements OnApplicationShutdown {
|
||||
const logger = this.logger.createSubLogger('inbox');
|
||||
|
||||
this.inboxQueueWorker = new Bull.Worker(QUEUE.INBOX, (job) => {
|
||||
return runQueueJob(
|
||||
return runQueueJobWithTraceContext(
|
||||
this.telemetryService,
|
||||
'Queue: Inbox',
|
||||
job.data,
|
||||
() => this.inboxProcessorService.process(job),
|
||||
err => {
|
||||
const activityId = job.data.activity ? job.data.activity.id : 'none';
|
||||
@@ -340,9 +345,10 @@ export class QueueProcessorService implements OnApplicationShutdown {
|
||||
const logger = this.logger.createSubLogger('user-webhook');
|
||||
|
||||
this.userWebhookDeliverQueueWorker = new Bull.Worker(QUEUE.USER_WEBHOOK_DELIVER, (job) => {
|
||||
return runQueueJob(
|
||||
return runQueueJobWithTraceContext(
|
||||
this.telemetryService,
|
||||
'Queue: UserWebhookDeliver',
|
||||
job.data,
|
||||
() => this.userWebhookDeliverProcessorService.process(job),
|
||||
err => {
|
||||
logger.error(`failed(${err.name}: ${err.message}) ${getJobInfo(job)} to=${job.data.to}`, { e: renderError(err) });
|
||||
@@ -378,9 +384,10 @@ export class QueueProcessorService implements OnApplicationShutdown {
|
||||
const logger = this.logger.createSubLogger('system-webhook');
|
||||
|
||||
this.systemWebhookDeliverQueueWorker = new Bull.Worker(QUEUE.SYSTEM_WEBHOOK_DELIVER, (job) => {
|
||||
return runQueueJob(
|
||||
return runQueueJobWithTraceContext(
|
||||
this.telemetryService,
|
||||
'Queue: SystemWebhookDeliver',
|
||||
job.data,
|
||||
() => this.systemWebhookDeliverProcessorService.process(job),
|
||||
err => {
|
||||
logger.error(`failed(${err.name}: ${err.message}) ${getJobInfo(job)} to=${job.data.to}`, { e: renderError(err) });
|
||||
@@ -425,9 +432,10 @@ export class QueueProcessorService implements OnApplicationShutdown {
|
||||
const logger = this.logger.createSubLogger('relationship');
|
||||
|
||||
this.relationshipQueueWorker = new Bull.Worker(QUEUE.RELATIONSHIP, (job) => {
|
||||
return runQueueJob(
|
||||
return runQueueJobWithTraceContext(
|
||||
this.telemetryService,
|
||||
'Queue: Relationship: ' + job.name,
|
||||
job.data,
|
||||
() => processer(job),
|
||||
err => {
|
||||
logger.error(`failed(${err.name}: ${err.message}) id=${job.id}`, { job: renderJob(job), e: renderError(err) });
|
||||
@@ -467,9 +475,10 @@ export class QueueProcessorService implements OnApplicationShutdown {
|
||||
const logger = this.logger.createSubLogger('objectStorage');
|
||||
|
||||
this.objectStorageQueueWorker = new Bull.Worker(QUEUE.OBJECT_STORAGE, (job) => {
|
||||
return runQueueJob(
|
||||
return runQueueJobWithTraceContext(
|
||||
this.telemetryService,
|
||||
'Queue: ObjectStorage: ' + job.name,
|
||||
job.data,
|
||||
() => processer(job) as Promise<void>,
|
||||
err => {
|
||||
logger.error(`failed(${err.name}: ${err.message}) id=${job.id}`, { job: renderJob(job), e: renderError(err) });
|
||||
@@ -498,9 +507,10 @@ export class QueueProcessorService implements OnApplicationShutdown {
|
||||
const logger = this.logger.createSubLogger('ended-poll-notification');
|
||||
|
||||
this.endedPollNotificationQueueWorker = new Bull.Worker(QUEUE.ENDED_POLL_NOTIFICATION, (job) => {
|
||||
return runQueueJob(
|
||||
return runQueueJobWithTraceContext(
|
||||
this.telemetryService,
|
||||
'Queue: EndedPollNotification',
|
||||
job.data,
|
||||
() => this.endedPollNotificationProcessorService.process(job),
|
||||
err => {
|
||||
logger.error(`failed(${err.name}: ${err.message}) id=${job.id}`, { job: renderJob(job), e: renderError(err) });
|
||||
@@ -522,9 +532,10 @@ export class QueueProcessorService implements OnApplicationShutdown {
|
||||
const logger = this.logger.createSubLogger('post-scheduled-note');
|
||||
|
||||
this.postScheduledNoteQueueWorker = new Bull.Worker(QUEUE.POST_SCHEDULED_NOTE, (job) => {
|
||||
return runQueueJob(
|
||||
return runQueueJobWithTraceContext(
|
||||
this.telemetryService,
|
||||
'Queue: PostScheduledNote',
|
||||
job.data,
|
||||
() => this.postScheduledNoteProcessorService.process(job),
|
||||
err => {
|
||||
logger.error(`failed(${err.name}: ${err.message}) id=${job.id}`, { job: renderJob(job), e: renderError(err) });
|
||||
|
||||
@@ -5,16 +5,17 @@
|
||||
|
||||
import type { TelemetryService } from '@/core/telemetry/TelemetryService.js';
|
||||
|
||||
type QueueTelemetryService = Pick<TelemetryService, 'startSpan'>;
|
||||
type QueueTelemetryService = Pick<TelemetryService, 'startSpanWithTraceContext'>;
|
||||
|
||||
/** Queueのprocessorを実行し、失敗処理をSpan内で行います。 */
|
||||
export function runQueueJob<T>(
|
||||
/** QueueのprocessorをTrace Context付きで実行し、失敗処理をSpan内で行います。 */
|
||||
export function runQueueJobWithTraceContext<T>(
|
||||
telemetryService: QueueTelemetryService,
|
||||
spanName: string,
|
||||
jobData: object,
|
||||
processJob: () => T | Promise<T>,
|
||||
onError: (error: Error) => void,
|
||||
): Promise<T> {
|
||||
return telemetryService.startSpan(spanName, async (): Promise<T> => {
|
||||
return telemetryService.startSpanWithTraceContext(spanName, jobData, async (): Promise<T> => {
|
||||
try {
|
||||
return await processJob();
|
||||
} catch (error) {
|
||||
|
||||
@@ -32,6 +32,7 @@ import { HealthServerService } from './HealthServerService.js';
|
||||
import { ClientServerService } from './web/ClientServerService.js';
|
||||
import { OpenApiServerService } from './api/openapi/OpenApiServerService.js';
|
||||
import { OAuth2ProviderService } from './oauth/OAuth2ProviderService.js';
|
||||
import { registerHttpServerInstrumentation } from './http-server-instrumentation.js';
|
||||
import { registerHttpAccessLog } from './http-access-log.js';
|
||||
|
||||
const _dirname = fileURLToPath(new URL('.', import.meta.url));
|
||||
@@ -81,6 +82,7 @@ export class ServerService implements OnApplicationShutdown {
|
||||
logger: false,
|
||||
});
|
||||
this.#fastify = fastify;
|
||||
await registerHttpServerInstrumentation(fastify, this.config);
|
||||
registerHttpAccessLog(fastify);
|
||||
|
||||
// HSTS
|
||||
|
||||
@@ -10,7 +10,6 @@ import { ApiError } from '@/server/api/error.js';
|
||||
import type { UsersRepository, UserProfilesRepository, MiMeta } from '@/models/_.js';
|
||||
import { DI } from '@/di-symbols.js';
|
||||
import { secureRndstr } from '@/misc/secure-rndstr.js';
|
||||
import { RoleService } from '@/core/RoleService.js';
|
||||
import { ModerationLogService } from '@/core/ModerationLogService.js';
|
||||
|
||||
export const meta = {
|
||||
@@ -26,10 +25,10 @@ export const meta = {
|
||||
code: 'NO_SUCH_USER',
|
||||
id: 'ccafc7fe-5074-4edd-9dc0-8ef9ef6a701d',
|
||||
},
|
||||
accessDenied: {
|
||||
message: 'Access denied.',
|
||||
code: 'ACCESS_DENIED',
|
||||
id: 'cda8f8ce-89a6-4f92-8055-33bbe0c1464d',
|
||||
cannotResetPasswordOfRootUser: {
|
||||
message: 'Cannot reset password of the root user.',
|
||||
code: 'CANNOT_RESET_PASSWORD_OF_ROOT_USER',
|
||||
id: 'f28fc207-42ca-44c7-a577-44b4f0ec5999',
|
||||
},
|
||||
},
|
||||
|
||||
@@ -67,7 +66,6 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
|
||||
@Inject(DI.userProfilesRepository)
|
||||
private userProfilesRepository: UserProfilesRepository,
|
||||
|
||||
private roleService: RoleService,
|
||||
private moderationLogService: ModerationLogService,
|
||||
) {
|
||||
super(meta, paramDef, async (ps, me) => {
|
||||
@@ -77,8 +75,8 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
|
||||
throw new ApiError(meta.errors.noSuchUser);
|
||||
}
|
||||
|
||||
if (await this.roleService.isAdministrator(user) && me.id !== user.id) {
|
||||
throw new ApiError(meta.errors.accessDenied);
|
||||
if (this.serverSettings.rootUserId === user.id) {
|
||||
throw new ApiError(meta.errors.cannotResetPasswordOfRootUser);
|
||||
}
|
||||
|
||||
const passwd = secureRndstr(8);
|
||||
|
||||
@@ -11,7 +11,6 @@ import { MiUserProfile } from '@/models/UserProfile.js';
|
||||
import { MiUserSecurityKey } from '@/models/UserSecurityKey.js';
|
||||
import type { UsersRepository } from '@/models/_.js';
|
||||
import { DI } from '@/di-symbols.js';
|
||||
import { RoleService } from '@/core/RoleService.js';
|
||||
import { ModerationLogService } from '@/core/ModerationLogService.js';
|
||||
|
||||
export const meta = {
|
||||
@@ -27,11 +26,6 @@ export const meta = {
|
||||
code: 'NO_SUCH_USER',
|
||||
id: 'ccafc7fe-5074-4edd-9dc0-8ef9ef6a701d',
|
||||
},
|
||||
accessDenied: {
|
||||
message: 'Access denied.',
|
||||
code: 'ACCESS_DENIED',
|
||||
id: 'cda8f8ce-89a6-4f92-8055-33bbe0c1464d',
|
||||
},
|
||||
},
|
||||
} as const;
|
||||
|
||||
@@ -52,7 +46,6 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
|
||||
@Inject(DI.usersRepository)
|
||||
private usersRepository: UsersRepository,
|
||||
|
||||
private roleService: RoleService,
|
||||
private moderationLogService: ModerationLogService,
|
||||
) {
|
||||
super(meta, paramDef, async (ps, me) => {
|
||||
@@ -62,10 +55,6 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
|
||||
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 transactionalEntityManager.delete(MiUserSecurityKey, { userId: user.id });
|
||||
|
||||
@@ -8,7 +8,6 @@ import { Endpoint } from '@/server/api/endpoint-base.js';
|
||||
import type { ChannelsRepository } from '@/models/_.js';
|
||||
import { DI } from '@/di-symbols.js';
|
||||
import { ChannelFollowingService } from '@/core/ChannelFollowingService.js';
|
||||
import { IdentifiableError } from '@/misc/identifiable-error.js';
|
||||
import { ApiError } from '../../error.js';
|
||||
|
||||
export const meta = {
|
||||
@@ -26,11 +25,6 @@ export const meta = {
|
||||
code: 'NO_SUCH_CHANNEL',
|
||||
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;
|
||||
|
||||
@@ -58,14 +52,7 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
|
||||
throw new ApiError(meta.errors.noSuchChannel);
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
await this.channelFollowingService.follow(me, channel);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,11 +7,8 @@ import Parser from 'rss-parser';
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { Endpoint } from '@/server/api/endpoint-base.js';
|
||||
import { HttpRequestService } from '@/core/HttpRequestService.js';
|
||||
import { ApiError } from '../error.js';
|
||||
|
||||
const MAX_URL_LENGTH = 8192;
|
||||
const MAX_RESPONSE_SIZE = 1024 * 1024;
|
||||
const MAX_CONCURRENT_REQUESTS = 32;
|
||||
const rssParser = new Parser();
|
||||
|
||||
export const meta = {
|
||||
tags: ['meta'],
|
||||
@@ -20,34 +17,6 @@ export const meta = {
|
||||
allowGet: true,
|
||||
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: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
@@ -246,84 +215,21 @@ export const paramDef = {
|
||||
|
||||
@Injectable()
|
||||
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(
|
||||
private httpRequestService: HttpRequestService,
|
||||
) {
|
||||
super(meta, paramDef, async (ps) => {
|
||||
const url = this.normalizeUrl(ps.url);
|
||||
const inFlightRequest = this.inFlightRequests.get(url);
|
||||
if (inFlightRequest != null) {
|
||||
return await inFlightRequest;
|
||||
}
|
||||
super(meta, paramDef, async (ps, me) => {
|
||||
const res = await this.httpRequestService.send(ps.url, {
|
||||
method: 'GET',
|
||||
headers: {
|
||||
Accept: 'application/rss+xml, */*',
|
||||
},
|
||||
timeout: 5000,
|
||||
});
|
||||
|
||||
if (this.activeRequestCount >= MAX_CONCURRENT_REQUESTS) {
|
||||
throw new ApiError(meta.errors.fetchRssUnavailable);
|
||||
}
|
||||
const text = await res.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;
|
||||
return rssParser.parseString(text);
|
||||
});
|
||||
}
|
||||
|
||||
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>();
|
||||
|
||||
// リクエスト開始時のactiveなTrace Contextを保存します。
|
||||
// HTTP計装の後に登録し、リクエスト開始時のactiveなTrace Contextを保存します。
|
||||
fastify.addHook('onRequest', (request, _reply, done) => {
|
||||
states.set(request, {
|
||||
traceContext: manager.getActiveTraceContext(),
|
||||
|
||||
@@ -0,0 +1,35 @@
|
||||
/*
|
||||
* SPDX-FileCopyrightText: syuilo and misskey-project
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
import type { Config } from '@/config.js';
|
||||
import type { FastifyInstance } from 'fastify';
|
||||
|
||||
type TelemetryConfig = Pick<Config, 'otelForBackend' | 'sentryForBackend'>;
|
||||
|
||||
export function shouldRegisterHttpServerInstrumentation(config: TelemetryConfig): boolean {
|
||||
// Sentryもリクエストspanを作成するため、両方を登録すると重複して出力される。
|
||||
return config.otelForBackend != null && config.sentryForBackend == null;
|
||||
}
|
||||
|
||||
/**
|
||||
* すべてのルート・フックより前にリクエスト計装を登録し、ActivityPubや
|
||||
* well-knownを含む全HTTP受信経路を1つのroot spanとして計測する。
|
||||
*/
|
||||
export async function registerHttpServerInstrumentation(fastify: FastifyInstance, config: TelemetryConfig): Promise<void> {
|
||||
if (!shouldRegisterHttpServerInstrumentation(config)) return;
|
||||
|
||||
const { FastifyOtelInstrumentation } = await import('@fastify/otel');
|
||||
const instrumentation = new FastifyOtelInstrumentation({
|
||||
requestHook: (span, request) => {
|
||||
const route = request.routeOptions.url;
|
||||
if (route != null) {
|
||||
// デフォルトだとトレース名が「request」で固定されてしまうため、判別がつかなくなる。
|
||||
// ルート名をspan名に設定することで、トレースビューでルートごとの処理時間を確認できるようになる。
|
||||
span.updateName(`${request.method} ${route}`);
|
||||
}
|
||||
},
|
||||
});
|
||||
await fastify.register(instrumentation.plugin());
|
||||
}
|
||||
@@ -1,36 +0,0 @@
|
||||
/*
|
||||
* 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');
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,88 @@
|
||||
/*
|
||||
* SPDX-FileCopyrightText: syuilo and misskey-project
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
import { randomUUID } from 'node:crypto';
|
||||
import { describe, expect, test } from 'vitest';
|
||||
import { Queue, Worker } from 'bullmq';
|
||||
import { SpanKind, SpanStatusCode } from '@opentelemetry/api';
|
||||
import { InMemorySpanExporter, SimpleSpanProcessor } from '@opentelemetry/sdk-trace-base';
|
||||
import { NodeTracerProvider } from '@opentelemetry/sdk-trace-node';
|
||||
import { loadConfig } from '@/config.js';
|
||||
import { installRedisInstrumentation } from '@/core/telemetry/redis-instrumentation.js';
|
||||
|
||||
const config = loadConfig();
|
||||
|
||||
describe('Redis telemetry instrumentation', () => {
|
||||
test('records Redis spans below HTTP and BullMQ worker spans without Redis arguments', async () => {
|
||||
const exporter = new InMemorySpanExporter();
|
||||
const provider = new NodeTracerProvider({
|
||||
spanProcessors: [new SimpleSpanProcessor(exporter)],
|
||||
});
|
||||
provider.register();
|
||||
|
||||
const tracer = provider.getTracer('telemetry-redis-instrumentation-test');
|
||||
const uninstall = installRedisInstrumentation(tracer, SpanKind.CLIENT, SpanStatusCode.ERROR, {
|
||||
captureCommandSpans: true,
|
||||
});
|
||||
const queueName = `telemetry-${randomUUID()}`;
|
||||
const prefix = `telemetry-${randomUUID()}`;
|
||||
const connection = {
|
||||
host: config.redis.host,
|
||||
port: config.redis.port,
|
||||
...(config.redis.password != null ? { password: config.redis.password } : {}),
|
||||
};
|
||||
const queue = new Queue(queueName, { connection, prefix });
|
||||
let worker: Worker | undefined;
|
||||
let httpSpanId: string | undefined;
|
||||
let jobSpanId: string | undefined;
|
||||
const secret = `secret-${randomUUID()}`;
|
||||
|
||||
try {
|
||||
const processed = new Promise<void>((resolve, reject) => {
|
||||
worker = new Worker(queueName, async job => {
|
||||
return await tracer.startActiveSpan('Queue: telemetry test', async jobSpan => {
|
||||
jobSpanId = jobSpan.spanContext().spanId;
|
||||
try {
|
||||
// updateData uses BullMQ's worker-side ioredis client.
|
||||
await job.updateData({ secret });
|
||||
return 'ok';
|
||||
} finally {
|
||||
jobSpan.end();
|
||||
}
|
||||
});
|
||||
}, { connection, prefix });
|
||||
worker.once('completed', () => resolve());
|
||||
worker.once('failed', (_job, error) => reject(error));
|
||||
});
|
||||
|
||||
await tracer.startActiveSpan('HTTP POST /telemetry-test', async httpSpan => {
|
||||
httpSpanId = httpSpan.spanContext().spanId;
|
||||
try {
|
||||
// Queue#add uses BullMQ's producer-side ioredis client.
|
||||
await queue.add('probe', { secret });
|
||||
} finally {
|
||||
httpSpan.end();
|
||||
}
|
||||
});
|
||||
await processed;
|
||||
await provider.forceFlush();
|
||||
|
||||
const redisSpans = exporter.getFinishedSpans().filter(span => span.attributes['db.system.name'] === 'redis');
|
||||
expect(redisSpans.some(span => span.parentSpanContext?.spanId === httpSpanId)).toBe(true);
|
||||
expect(redisSpans.some(span => span.parentSpanContext?.spanId === jobSpanId)).toBe(true);
|
||||
for (const span of redisSpans) {
|
||||
expect(span.attributes).not.toHaveProperty('db.statement');
|
||||
expect(span.attributes).not.toHaveProperty('db.query.text');
|
||||
expect(Object.values(span.attributes)).not.toContain(secret);
|
||||
}
|
||||
} finally {
|
||||
await worker?.close();
|
||||
await queue.obliterate({ force: true });
|
||||
await queue.close();
|
||||
uninstall();
|
||||
await provider.shutdown();
|
||||
}
|
||||
}, 30000);
|
||||
});
|
||||
@@ -0,0 +1,379 @@
|
||||
/*
|
||||
* 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 { SentryTelemetryAdapter, buildSentryIntegrations, buildSentryNodeOptions } from '@/core/telemetry/adapters/SentryTelemetryAdapter.js';
|
||||
import { SentryTelemetryAdapter, buildSentryIntegrations, buildSentryNodeOptions, buildSentryOtlpInitOptions } from '@/core/telemetry/adapters/SentryTelemetryAdapter.js';
|
||||
|
||||
type TestIntegration = Parameters<ReturnType<typeof buildSentryIntegrations>>[0][number];
|
||||
|
||||
@@ -35,7 +35,9 @@ describe('SentryTelemetryAdapter', () => {
|
||||
nodeProfilingIntegration: () => testIntegration('ProfilingIntegration'),
|
||||
});
|
||||
|
||||
const result = integrations([testIntegration('Http')]);
|
||||
const result = integrations([
|
||||
testIntegration('Http'),
|
||||
]);
|
||||
|
||||
expect(result.map((integration: TestIntegration) => integration.name)).toEqual(['Http', 'ProfilingIntegration']);
|
||||
});
|
||||
@@ -48,7 +50,9 @@ describe('SentryTelemetryAdapter', () => {
|
||||
warn,
|
||||
});
|
||||
|
||||
const result = integrations([testIntegration('Http')]);
|
||||
const result = integrations([
|
||||
testIntegration('Http'),
|
||||
]);
|
||||
|
||||
expect(result.map((integration: TestIntegration) => integration.name)).toEqual(['Http']);
|
||||
expect(warn).toHaveBeenCalledWith('Unknown Sentry integration configured in sentryForBackend.disabledIntegrations: Unknown');
|
||||
@@ -73,6 +77,88 @@ describe('SentryTelemetryAdapter', () => {
|
||||
|
||||
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', () => {
|
||||
@@ -134,3 +220,69 @@ describe('SentryTelemetryAdapter.shutdown', () => {
|
||||
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');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,108 @@
|
||||
/*
|
||||
* SPDX-FileCopyrightText: syuilo and misskey-project
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
import { describe, expect, test, vi } from 'vitest';
|
||||
import { SpanKind, SpanStatusCode } from '@opentelemetry/api';
|
||||
import { createHttpClientInstrumentation } from '@/core/telemetry/http-client-instrumentation.js';
|
||||
|
||||
function request() {
|
||||
return {
|
||||
method: 'POST',
|
||||
protocol: 'https:',
|
||||
path: '/inbox?token=secret',
|
||||
host: 'remote.example',
|
||||
getHeader: vi.fn((name: string) => name === 'host' ? 'user:password@remote.example:8443' : undefined),
|
||||
};
|
||||
}
|
||||
|
||||
describe('http-client-instrumentation', () => {
|
||||
test('creates and completes a sanitized CLIENT span from diagnostics channels', () => {
|
||||
const listeners = new Map<string, (message: unknown) => void>();
|
||||
const span = {
|
||||
end: vi.fn(),
|
||||
recordException: vi.fn(),
|
||||
setAttribute: vi.fn(),
|
||||
setStatus: vi.fn(),
|
||||
};
|
||||
const tracer = { startSpan: vi.fn(() => span) } as any;
|
||||
const unsubscribe = createHttpClientInstrumentation({
|
||||
tracer,
|
||||
spanKindClient: SpanKind.CLIENT,
|
||||
spanStatusCodeError: SpanStatusCode.ERROR,
|
||||
subscribe: (name, listener) => {
|
||||
listeners.set(name, listener);
|
||||
return () => listeners.delete(name);
|
||||
},
|
||||
});
|
||||
const clientRequest = request();
|
||||
|
||||
listeners.get('http.client.request.created')!({ request: clientRequest });
|
||||
listeners.get('http.client.response.finish')!({
|
||||
request: clientRequest,
|
||||
response: { statusCode: 201, httpVersion: '1.1' },
|
||||
});
|
||||
|
||||
expect(tracer.startSpan).toHaveBeenCalledWith('POST', {
|
||||
kind: SpanKind.CLIENT,
|
||||
attributes: {
|
||||
'http.request.method': 'POST',
|
||||
'url.full': 'https://remote.example:8443/inbox',
|
||||
'server.address': 'remote.example',
|
||||
'server.port': 8443,
|
||||
},
|
||||
});
|
||||
expect(span.setAttribute).toHaveBeenCalledWith('http.response.status_code', 201);
|
||||
expect(span.setAttribute).toHaveBeenCalledWith('network.protocol.version', '1.1');
|
||||
expect(span.end).toHaveBeenCalledTimes(1);
|
||||
unsubscribe();
|
||||
expect(listeners).toHaveLength(0);
|
||||
});
|
||||
|
||||
test('records a request error and ends the span once', () => {
|
||||
const listeners = new Map<string, (message: unknown) => void>();
|
||||
const span = { end: vi.fn(), recordException: vi.fn(), setAttribute: vi.fn(), setStatus: vi.fn() };
|
||||
const error = Object.assign(new Error('connection refused'), { code: 'ECONNREFUSED' });
|
||||
const clientRequest = request();
|
||||
createHttpClientInstrumentation({
|
||||
tracer: { startSpan: vi.fn(() => span) } as any,
|
||||
spanKindClient: SpanKind.CLIENT,
|
||||
spanStatusCodeError: SpanStatusCode.ERROR,
|
||||
subscribe: (name, listener) => {
|
||||
listeners.set(name, listener);
|
||||
return () => listeners.delete(name);
|
||||
},
|
||||
});
|
||||
|
||||
listeners.get('http.client.request.created')!({ request: clientRequest });
|
||||
listeners.get('http.client.request.error')!({ request: clientRequest, error });
|
||||
listeners.get('http.client.response.finish')!({ request: clientRequest, response: { statusCode: 200 } });
|
||||
|
||||
expect(span.recordException).toHaveBeenCalledWith(error);
|
||||
expect(span.setAttribute).toHaveBeenCalledWith('error.type', 'ECONNREFUSED');
|
||||
expect(span.setStatus).toHaveBeenCalledWith({ code: SpanStatusCode.ERROR });
|
||||
expect(span.end).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
test('records the response status code as error.type for an error response', () => {
|
||||
const listeners = new Map<string, (message: unknown) => void>();
|
||||
const span = { end: vi.fn(), recordException: vi.fn(), setAttribute: vi.fn(), setStatus: vi.fn() };
|
||||
const clientRequest = request();
|
||||
createHttpClientInstrumentation({
|
||||
tracer: { startSpan: vi.fn(() => span) } as any,
|
||||
spanKindClient: SpanKind.CLIENT,
|
||||
spanStatusCodeError: SpanStatusCode.ERROR,
|
||||
subscribe: (name, listener) => {
|
||||
listeners.set(name, listener);
|
||||
return () => listeners.delete(name);
|
||||
},
|
||||
});
|
||||
|
||||
listeners.get('http.client.request.created')!({ request: clientRequest });
|
||||
listeners.get('http.client.response.finish')!({ request: clientRequest, response: { statusCode: 502 } });
|
||||
|
||||
expect(span.setAttribute).toHaveBeenCalledWith('error.type', '502');
|
||||
expect(span.setStatus).toHaveBeenCalledWith({ code: SpanStatusCode.ERROR });
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,57 @@
|
||||
/*
|
||||
* SPDX-FileCopyrightText: syuilo and misskey-project
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
import { beforeEach, describe, expect, test, vi } from 'vitest';
|
||||
import type * as Bull from 'bullmq';
|
||||
import { instrumentQueue } from '@/core/telemetry/queue-instrumentation.js';
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
injectTraceContext: vi.fn((carrier: Record<string, string>) => {
|
||||
carrier['traceparent'] = '00-0123456789abcdef0123456789abcdef-0123456789abcdef-01';
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock('@/core/telemetry/telemetry-registry.js', () => ({
|
||||
injectTraceContext: mocks.injectTraceContext,
|
||||
}));
|
||||
|
||||
describe('queue-instrumentation', () => {
|
||||
beforeEach(() => {
|
||||
mocks.injectTraceContext.mockClear();
|
||||
});
|
||||
|
||||
test('injects the active trace context for add()', () => {
|
||||
const add = vi.fn();
|
||||
const queue = instrumentQueue({ add, addBulk: vi.fn() } as unknown as Bull.Queue<{ noteId: string }>);
|
||||
const data = { noteId: '9d6b9a65-46c9-4e1b-a640-9589693893c9' };
|
||||
|
||||
queue.add('endedPollNotification', data);
|
||||
|
||||
expect(mocks.injectTraceContext).toHaveBeenCalledTimes(1);
|
||||
expect(data).toMatchObject({
|
||||
__misskeyTraceContext: {
|
||||
traceparent: '00-0123456789abcdef0123456789abcdef-0123456789abcdef-01',
|
||||
},
|
||||
});
|
||||
expect(add).toHaveBeenCalledWith('endedPollNotification', data, undefined);
|
||||
});
|
||||
|
||||
test('injects every job passed to addBulk()', () => {
|
||||
const addBulk = vi.fn();
|
||||
const queue = instrumentQueue({ add: vi.fn(), addBulk } as unknown as Bull.Queue<{ to: string }>);
|
||||
const jobs = [
|
||||
{ name: 'deliver', data: { to: 'https://remote.example/inbox' } },
|
||||
{ name: 'deliver', data: { to: 'https://remote2.example/inbox' } },
|
||||
];
|
||||
|
||||
queue.addBulk(jobs);
|
||||
|
||||
expect(mocks.injectTraceContext).toHaveBeenCalledTimes(2);
|
||||
expect(jobs).toEqual(expect.arrayContaining([
|
||||
expect.objectContaining({ data: expect.objectContaining({ __misskeyTraceContext: expect.any(Object) }) }),
|
||||
]));
|
||||
expect(addBulk).toHaveBeenCalledWith(jobs);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,132 @@
|
||||
/*
|
||||
* SPDX-FileCopyrightText: syuilo and misskey-project
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
import { describe, expect, test, vi } from 'vitest';
|
||||
import type { Context, SpanContext } from '@opentelemetry/api';
|
||||
import { getQueueSpanContext, getQueueTraceContextMode, injectActiveTraceContext, injectQueueTraceContext } from '@/core/telemetry/queue-trace-context.js';
|
||||
|
||||
const rootContext = {} as Context;
|
||||
const extractedContext = {} as Context;
|
||||
const sourceSpanContext: SpanContext = {
|
||||
traceId: '0123456789abcdef0123456789abcdef',
|
||||
spanId: '0123456789abcdef',
|
||||
traceFlags: 1,
|
||||
isRemote: true,
|
||||
};
|
||||
|
||||
function jobData() {
|
||||
return {
|
||||
name: 'deliver',
|
||||
__misskeyTraceContext: {
|
||||
traceparent: '00-0123456789abcdef0123456789abcdef-0123456789abcdef-01',
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
describe('queue-trace-context', () => {
|
||||
test('stores only a non-empty carrier in the job data', () => {
|
||||
const data = { noteId: '9d6b9a65-46c9-4e1b-a640-9589693893c9' };
|
||||
|
||||
injectQueueTraceContext(data, carrier => {
|
||||
carrier['traceparent'] = '00-0123456789abcdef0123456789abcdef-0123456789abcdef-01';
|
||||
});
|
||||
|
||||
expect(data).toMatchObject({
|
||||
__misskeyTraceContext: {
|
||||
traceparent: '00-0123456789abcdef0123456789abcdef-0123456789abcdef-01',
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
test('does not store an empty carrier when no active trace exists', () => {
|
||||
const data = { noteId: '9d6b9a65-46c9-4e1b-a640-9589693893c9' };
|
||||
|
||||
injectQueueTraceContext(data, () => {});
|
||||
|
||||
expect(data).not.toHaveProperty('__misskeyTraceContext');
|
||||
});
|
||||
|
||||
test('ignores non-object job data', () => {
|
||||
const inject = vi.fn();
|
||||
|
||||
injectQueueTraceContext(null, inject);
|
||||
injectQueueTraceContext('not a job object', inject);
|
||||
|
||||
expect(inject).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test('injects the active context with the configured propagator', () => {
|
||||
const activeContext = {} as Context;
|
||||
const carrier = {};
|
||||
const inject = vi.fn();
|
||||
|
||||
injectActiveTraceContext({
|
||||
tracer: { startActiveSpan: vi.fn() } as any,
|
||||
propagation: { inject, extract: vi.fn() } as any,
|
||||
trace: { getSpanContext: vi.fn() },
|
||||
getActiveContext: () => activeContext,
|
||||
rootContext,
|
||||
mode: 'link',
|
||||
spanStatusCodeError: 2 as any,
|
||||
}, carrier);
|
||||
|
||||
expect(inject).toHaveBeenCalledWith(activeContext, carrier);
|
||||
});
|
||||
|
||||
test('starts a new root trace with a link by default', () => {
|
||||
const extract = vi.fn(() => extractedContext);
|
||||
const getSpanContext = vi.fn(() => sourceSpanContext);
|
||||
|
||||
const result = getQueueSpanContext(jobData(), {
|
||||
rootContext,
|
||||
propagation: { inject: vi.fn(), extract },
|
||||
trace: { getSpanContext },
|
||||
mode: 'link',
|
||||
});
|
||||
|
||||
expect(extract).toHaveBeenCalledWith(rootContext, jobData().__misskeyTraceContext);
|
||||
expect(result).toEqual({
|
||||
options: {
|
||||
root: true,
|
||||
links: [{ context: sourceSpanContext }],
|
||||
},
|
||||
parentContext: rootContext,
|
||||
});
|
||||
});
|
||||
|
||||
test('uses the extracted context as the parent when parent mode is selected', () => {
|
||||
const result = getQueueSpanContext(jobData(), {
|
||||
rootContext,
|
||||
propagation: { inject: vi.fn(), extract: () => extractedContext },
|
||||
trace: { getSpanContext: () => sourceSpanContext },
|
||||
mode: 'parent',
|
||||
});
|
||||
|
||||
expect(result).toEqual({
|
||||
options: {},
|
||||
parentContext: extractedContext,
|
||||
});
|
||||
});
|
||||
|
||||
test('ignores malformed or missing carriers', () => {
|
||||
const extract = vi.fn(() => extractedContext);
|
||||
const deps = {
|
||||
rootContext,
|
||||
propagation: { inject: vi.fn(), extract },
|
||||
trace: { getSpanContext: () => sourceSpanContext },
|
||||
mode: 'link' as const,
|
||||
};
|
||||
|
||||
expect(getQueueSpanContext({}, deps)).toBeUndefined();
|
||||
expect(getQueueSpanContext({ __misskeyTraceContext: { traceparent: 1 } }, deps)).toBeUndefined();
|
||||
expect(extract).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test('defaults to link mode and rejects invalid configuration', () => {
|
||||
expect(getQueueTraceContextMode(undefined)).toBe('link');
|
||||
expect(getQueueTraceContextMode('parent')).toBe('parent');
|
||||
expect(() => getQueueTraceContextMode('children')).toThrow('otelForBackend.jobTraceContextMode');
|
||||
});
|
||||
});
|
||||
@@ -4,13 +4,13 @@
|
||||
*/
|
||||
|
||||
import { describe, expect, test, vi } from 'vitest';
|
||||
import { runQueueJob } from '@/queue/queue-job-runner.js';
|
||||
import { runQueueJobWithTraceContext } from '@/queue/queue-job-runner.js';
|
||||
import { TelemetryService } from '@/core/telemetry/TelemetryService.js';
|
||||
|
||||
describe('runQueueJob', () => {
|
||||
describe('runQueueJobWithTraceContext', () => {
|
||||
test('returns the processor result without invoking the error handler', async () => {
|
||||
let spanActive = false;
|
||||
const startSpan = vi.fn(<T>(_name: string, fn: () => T): T => {
|
||||
const startSpanWithTraceContext = vi.fn(<T>(_name: string, _jobData: object, fn: () => T): T => {
|
||||
spanActive = true;
|
||||
const result = fn();
|
||||
if (result instanceof Promise) return result.finally(() => { spanActive = false; }) as T;
|
||||
@@ -18,11 +18,11 @@ describe('runQueueJob', () => {
|
||||
return result;
|
||||
});
|
||||
const telemetryService = {
|
||||
startSpan,
|
||||
startSpanWithTraceContext,
|
||||
} as unknown as TelemetryService;
|
||||
const onError = vi.fn();
|
||||
|
||||
await expect(runQueueJob(telemetryService, 'Queue: test', () => 'ok', onError)).resolves.toBe('ok');
|
||||
await expect(runQueueJobWithTraceContext(telemetryService, 'Queue: test', {}, () => 'ok', onError)).resolves.toBe('ok');
|
||||
|
||||
expect(onError).not.toHaveBeenCalled();
|
||||
expect(spanActive).toBe(false);
|
||||
@@ -30,7 +30,7 @@ describe('runQueueJob', () => {
|
||||
|
||||
test('handles failures while the processor span is active and rethrows the original error', async () => {
|
||||
let spanActive = false;
|
||||
const startSpan = vi.fn(<T>(_name: string, fn: () => T): T => {
|
||||
const startSpanWithTraceContext = vi.fn(<T>(_name: string, _jobData: object, fn: () => T): T => {
|
||||
spanActive = true;
|
||||
const result = fn();
|
||||
if (result instanceof Promise) return result.finally(() => { spanActive = false; }) as T;
|
||||
@@ -38,7 +38,7 @@ describe('runQueueJob', () => {
|
||||
return result;
|
||||
});
|
||||
const telemetryService = {
|
||||
startSpan,
|
||||
startSpanWithTraceContext,
|
||||
} as unknown as TelemetryService;
|
||||
const onError = vi.fn((error: Error) => {
|
||||
expect(spanActive).toBe(true);
|
||||
@@ -46,7 +46,7 @@ describe('runQueueJob', () => {
|
||||
});
|
||||
const originalError = new Error('failed');
|
||||
|
||||
await expect(runQueueJob(telemetryService, 'Queue: test', async () => {
|
||||
await expect(runQueueJobWithTraceContext(telemetryService, 'Queue: test', {}, async () => {
|
||||
throw originalError;
|
||||
}, onError)).rejects.toBe(originalError);
|
||||
|
||||
|
||||
@@ -1,192 +0,0 @@
|
||||
/*
|
||||
* 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 });
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,53 @@
|
||||
/*
|
||||
* SPDX-FileCopyrightText: syuilo and misskey-project
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
import { describe, expect, test, vi } from 'vitest';
|
||||
import { shouldRegisterHttpServerInstrumentation, registerHttpServerInstrumentation } from '@/server/http-server-instrumentation.js';
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
plugin: vi.fn(),
|
||||
instrumentation: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('@fastify/otel', () => ({
|
||||
FastifyOtelInstrumentation: class {
|
||||
public plugin = mocks.plugin;
|
||||
|
||||
public constructor(options: unknown) {
|
||||
mocks.instrumentation(options);
|
||||
}
|
||||
},
|
||||
}));
|
||||
|
||||
describe('http-server-instrumentation', () => {
|
||||
test('registers Fastify instrumentation when only OpenTelemetry is configured', async () => {
|
||||
const plugin = vi.fn();
|
||||
const fastify = { register: vi.fn().mockResolvedValue(undefined) };
|
||||
mocks.plugin.mockReturnValue(plugin);
|
||||
|
||||
await registerHttpServerInstrumentation(fastify as any, { otelForBackend: {} } as any);
|
||||
|
||||
expect(mocks.instrumentation).toHaveBeenCalledTimes(1);
|
||||
expect(fastify.register).toHaveBeenCalledWith(plugin);
|
||||
|
||||
const requestHook = mocks.instrumentation.mock.calls[0][0].requestHook;
|
||||
const span = { updateName: vi.fn() };
|
||||
requestHook(span, { method: 'POST', routeOptions: { url: '/notes/create' } });
|
||||
expect(span.updateName).toHaveBeenCalledWith('POST /notes/create');
|
||||
});
|
||||
|
||||
test('does not register duplicate request instrumentation with Sentry', async () => {
|
||||
const fastify = { register: vi.fn() };
|
||||
|
||||
await registerHttpServerInstrumentation(fastify as any, { otelForBackend: {}, sentryForBackend: {} } as any);
|
||||
|
||||
expect(fastify.register).not.toHaveBeenCalled();
|
||||
expect(shouldRegisterHttpServerInstrumentation({ otelForBackend: {}, sentryForBackend: {} } as any)).toBe(false);
|
||||
});
|
||||
|
||||
test('does not register instrumentation without OpenTelemetry', () => {
|
||||
expect(shouldRegisterHttpServerInstrumentation({} as any)).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,108 @@
|
||||
/*
|
||||
* SPDX-FileCopyrightText: syuilo and misskey-project
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
import { describe, expect, test, vi } from 'vitest';
|
||||
import { installDatabaseInstrumentation, installInstrumentation } from '@/core/telemetry/database-instrumentation.js';
|
||||
|
||||
describe('database-instrumentation', () => {
|
||||
test('does not install PostgreSQL instrumentation when disabled', async () => {
|
||||
const uninstall = await installDatabaseInstrumentation({} as any, {
|
||||
capturePgSpans: false,
|
||||
capturePgStatement: false,
|
||||
capturePgConnectionSpans: false,
|
||||
});
|
||||
|
||||
expect(uninstall).toBeTypeOf('function');
|
||||
expect(() => uninstall()).not.toThrow();
|
||||
});
|
||||
|
||||
test('registers pg instrumentation with the active provider', () => {
|
||||
const provider = {};
|
||||
const pg = { setTracerProvider: vi.fn(), enable: vi.fn(), disable: vi.fn() };
|
||||
const config = vi.fn();
|
||||
const PgInstrumentation = class {
|
||||
public constructor(options: unknown) {
|
||||
config(options);
|
||||
return pg as any;
|
||||
}
|
||||
};
|
||||
|
||||
const uninstall = installInstrumentation(provider as any, {
|
||||
PgInstrumentation: PgInstrumentation as any,
|
||||
}, {
|
||||
capturePgStatement: false,
|
||||
capturePgConnectionSpans: false,
|
||||
});
|
||||
|
||||
expect(pg.setTracerProvider).toHaveBeenCalledWith(provider);
|
||||
expect(pg.enable).toHaveBeenCalledOnce();
|
||||
expect(config).toHaveBeenCalledWith(expect.objectContaining({
|
||||
enhancedDatabaseReporting: false,
|
||||
requireParentSpan: true,
|
||||
ignoreConnectSpans: true,
|
||||
requestHook: expect.any(Function),
|
||||
}));
|
||||
|
||||
const span = { setAttribute: vi.fn() };
|
||||
(config.mock.calls[0][0] as { requestHook: (span: any) => void }).requestHook(span);
|
||||
expect(span.setAttribute).toHaveBeenCalledWith('db.statement', '[REDACTED]');
|
||||
expect(span.setAttribute).toHaveBeenCalledWith('db.query.text', '[REDACTED]');
|
||||
|
||||
uninstall();
|
||||
|
||||
expect(pg.disable).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
test('keeps SQL statement attributes when explicitly enabled', () => {
|
||||
const pg = { setTracerProvider: vi.fn(), enable: vi.fn(), disable: vi.fn() };
|
||||
const config = vi.fn();
|
||||
|
||||
installInstrumentation({} as any, {
|
||||
PgInstrumentation: class {
|
||||
public constructor(options: unknown) {
|
||||
config(options);
|
||||
return pg as any;
|
||||
}
|
||||
} as any,
|
||||
}, {
|
||||
capturePgStatement: true,
|
||||
capturePgConnectionSpans: false,
|
||||
});
|
||||
|
||||
expect(config.mock.calls[0][0]).not.toHaveProperty('requestHook');
|
||||
});
|
||||
|
||||
test('enables connection spans when explicitly configured', () => {
|
||||
const pg = { setTracerProvider: vi.fn(), enable: vi.fn(), disable: vi.fn() };
|
||||
const config = vi.fn();
|
||||
|
||||
installInstrumentation({} as any, {
|
||||
PgInstrumentation: class {
|
||||
public constructor(options: unknown) {
|
||||
config(options);
|
||||
return pg as any;
|
||||
}
|
||||
} as any,
|
||||
}, {
|
||||
capturePgStatement: false,
|
||||
capturePgConnectionSpans: true,
|
||||
});
|
||||
|
||||
expect(config).toHaveBeenCalledWith(expect.objectContaining({
|
||||
ignoreConnectSpans: false,
|
||||
}));
|
||||
});
|
||||
|
||||
test('cleans up both instrumentations when initialization fails', () => {
|
||||
const pg = { setTracerProvider: vi.fn(), enable: vi.fn(), disable: vi.fn() };
|
||||
pg.enable.mockImplementation(() => { throw new Error('failed'); });
|
||||
|
||||
expect(() => installInstrumentation({} as any, {
|
||||
PgInstrumentation: class { public constructor() { return pg as any; } } as any,
|
||||
})).toThrow('failed');
|
||||
|
||||
expect(pg.disable).toHaveBeenCalledOnce();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,148 @@
|
||||
/*
|
||||
* SPDX-FileCopyrightText: syuilo and misskey-project
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
import { describe, expect, test, vi } from 'vitest';
|
||||
import { SpanKind, SpanStatusCode } from '@opentelemetry/api';
|
||||
import { createRedisInstrumentation } from '@/core/telemetry/redis-instrumentation.js';
|
||||
|
||||
describe('redis-instrumentation', () => {
|
||||
test('creates and completes a span for an ioredis command', () => {
|
||||
let subscribers: any;
|
||||
const unsubscribe = vi.fn();
|
||||
const span = { end: vi.fn(), recordException: vi.fn(), setStatus: vi.fn(), setAttribute: vi.fn() };
|
||||
const tracer = { startSpan: vi.fn(() => span) };
|
||||
const uninstall = createRedisInstrumentation({
|
||||
tracingChannel: () => ({ subscribe: (value) => { subscribers = value; }, unsubscribe }),
|
||||
tracer: tracer as any,
|
||||
getActiveSpan: () => ({}) as any,
|
||||
spanKindClient: SpanKind.CLIENT,
|
||||
spanStatusCodeError: SpanStatusCode.ERROR,
|
||||
}, { captureCommandSpans: true });
|
||||
const command = { command: 'get', args: ['key'], database: 0, serverAddress: 'redis', serverPort: 6379 };
|
||||
|
||||
subscribers.start(command);
|
||||
subscribers.asyncEnd(command);
|
||||
|
||||
expect(tracer.startSpan).toHaveBeenCalledWith('get', expect.objectContaining({
|
||||
kind: SpanKind.CLIENT,
|
||||
attributes: expect.objectContaining({
|
||||
'db.system.name': 'redis',
|
||||
'db.operation.name': 'get',
|
||||
'server.address': 'redis',
|
||||
'server.port': 6379,
|
||||
}),
|
||||
}));
|
||||
expect(span.end).toHaveBeenCalledOnce();
|
||||
|
||||
uninstall();
|
||||
expect(unsubscribe).toHaveBeenCalledWith(subscribers);
|
||||
});
|
||||
|
||||
test('records rejected Redis commands as errors', () => {
|
||||
let subscribers: any;
|
||||
const span = { end: vi.fn(), recordException: vi.fn(), setStatus: vi.fn(), setAttribute: vi.fn() };
|
||||
createRedisInstrumentation({
|
||||
tracingChannel: () => ({ subscribe: (value) => { subscribers = value; }, unsubscribe: vi.fn() }),
|
||||
tracer: { startSpan: vi.fn(() => span) } as any,
|
||||
getActiveSpan: () => ({}) as any,
|
||||
spanKindClient: SpanKind.CLIENT,
|
||||
spanStatusCodeError: SpanStatusCode.ERROR,
|
||||
}, { captureCommandSpans: true });
|
||||
const command = { command: 'get', args: ['key'], database: 0, serverAddress: 'redis', serverPort: undefined };
|
||||
const error = Object.assign(new Error('ERR Redis failed'), { code: 'ERR' });
|
||||
|
||||
subscribers.start(command);
|
||||
Object.assign(command, { error });
|
||||
subscribers.error(command);
|
||||
subscribers.asyncEnd(command);
|
||||
|
||||
expect(span.recordException).toHaveBeenCalledWith(error);
|
||||
expect(span.recordException).toHaveBeenCalledOnce();
|
||||
expect(span.setStatus).toHaveBeenCalledWith({ code: SpanStatusCode.ERROR, message: 'ERR Redis failed' });
|
||||
expect(span.setAttribute).toHaveBeenCalledWith('error.type', 'ERR');
|
||||
expect(span.setAttribute).toHaveBeenCalledWith('db.response.status_code', 'ERR');
|
||||
expect(span.end).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
test('does not create a root span when no parent span is active', () => {
|
||||
let subscribers: any;
|
||||
const tracer = { startSpan: vi.fn() };
|
||||
createRedisInstrumentation({
|
||||
tracingChannel: () => ({ subscribe: (value) => { subscribers = value; }, unsubscribe: vi.fn() }),
|
||||
tracer: tracer as any,
|
||||
getActiveSpan: () => undefined,
|
||||
spanKindClient: SpanKind.CLIENT,
|
||||
spanStatusCodeError: SpanStatusCode.ERROR,
|
||||
}, { captureCommandSpans: true });
|
||||
|
||||
subscribers.start({ command: 'get', args: ['key'], database: 0, serverAddress: 'redis', serverPort: 6379 });
|
||||
|
||||
expect(tracer.startSpan).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test('creates a root span when explicitly enabled', () => {
|
||||
let subscribers: any;
|
||||
const span = { end: vi.fn(), recordException: vi.fn(), setStatus: vi.fn(), setAttribute: vi.fn() };
|
||||
const tracer = { startSpan: vi.fn(() => span) };
|
||||
createRedisInstrumentation({
|
||||
tracingChannel: () => ({ subscribe: (value) => { subscribers = value; }, unsubscribe: vi.fn() }),
|
||||
tracer: tracer as any,
|
||||
getActiveSpan: () => undefined,
|
||||
spanKindClient: SpanKind.CLIENT,
|
||||
spanStatusCodeError: SpanStatusCode.ERROR,
|
||||
}, { captureCommandSpans: true, requireParentSpan: false });
|
||||
|
||||
const command = { command: 'get', args: ['key'], database: 0, serverAddress: 'redis', serverPort: 6379 };
|
||||
subscribers.start(command);
|
||||
subscribers.asyncEnd(command);
|
||||
|
||||
expect(tracer.startSpan).toHaveBeenCalledOnce();
|
||||
expect(span.end).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
test('records connection spans only when explicitly enabled', () => {
|
||||
const subscribers = new Map<string, any>();
|
||||
const unsubscribe = vi.fn();
|
||||
const span = { end: vi.fn(), recordException: vi.fn(), setStatus: vi.fn(), setAttribute: vi.fn() };
|
||||
const tracer = { startSpan: vi.fn(() => span) };
|
||||
const uninstall = createRedisInstrumentation({
|
||||
tracingChannel: (name) => ({ subscribe: (value) => { subscribers.set(name, value); }, unsubscribe }),
|
||||
tracer: tracer as any,
|
||||
getActiveSpan: () => undefined,
|
||||
spanKindClient: SpanKind.CLIENT,
|
||||
spanStatusCodeError: SpanStatusCode.ERROR,
|
||||
}, { captureConnectionSpans: true });
|
||||
|
||||
const connection = { serverAddress: 'redis', serverPort: 6379 };
|
||||
subscribers.get('ioredis:connect').start(connection);
|
||||
subscribers.get('ioredis:connect').asyncEnd(connection);
|
||||
|
||||
expect(tracer.startSpan).toHaveBeenCalledWith('connect', expect.objectContaining({
|
||||
kind: SpanKind.CLIENT,
|
||||
attributes: expect.objectContaining({
|
||||
'db.operation.name': 'connect',
|
||||
'server.address': 'redis',
|
||||
'server.port': 6379,
|
||||
}),
|
||||
}));
|
||||
expect(span.end).toHaveBeenCalledOnce();
|
||||
|
||||
uninstall();
|
||||
expect(unsubscribe).toHaveBeenCalledWith(subscribers.get('ioredis:connect'));
|
||||
});
|
||||
|
||||
test('does not subscribe to Redis command diagnostics unless explicitly enabled', () => {
|
||||
const tracingChannel = vi.fn();
|
||||
createRedisInstrumentation({
|
||||
tracingChannel,
|
||||
tracer: { startSpan: vi.fn() } as any,
|
||||
getActiveSpan: () => undefined,
|
||||
spanKindClient: SpanKind.CLIENT,
|
||||
spanStatusCodeError: SpanStatusCode.ERROR,
|
||||
});
|
||||
|
||||
expect(tracingChannel).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -9,6 +9,8 @@ import type { Config } from '@/config.js';
|
||||
const mocks = vi.hoisted(() => {
|
||||
return {
|
||||
sentryCreate: vi.fn(),
|
||||
sentryCreateWithOtlpExport: vi.fn(),
|
||||
otelCreate: vi.fn(),
|
||||
setLogTraceContextProvider: vi.fn(),
|
||||
};
|
||||
});
|
||||
@@ -20,6 +22,13 @@ vi.mock('@/logging/logging-runtime.js', () => ({
|
||||
vi.mock('@/core/telemetry/adapters/SentryTelemetryAdapter.js', () => ({
|
||||
SentryTelemetryAdapter: {
|
||||
create: mocks.sentryCreate,
|
||||
createWithOtlpExport: mocks.sentryCreateWithOtlpExport,
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock('@/core/telemetry/adapters/OpenTelemetryAdapter.js', () => ({
|
||||
OpenTelemetryAdapter: {
|
||||
create: mocks.otelCreate,
|
||||
},
|
||||
}));
|
||||
|
||||
@@ -34,37 +43,44 @@ describe('telemetry-registry', () => {
|
||||
beforeEach(() => {
|
||||
vi.resetModules();
|
||||
mocks.sentryCreate.mockReset();
|
||||
mocks.sentryCreateWithOtlpExport.mockReset();
|
||||
mocks.otelCreate.mockReset();
|
||||
mocks.setLogTraceContextProvider.mockReset();
|
||||
mocks.sentryCreate.mockResolvedValue({ shutdown: vi.fn(), captureMessage: vi.fn(), startSpan: vi.fn() });
|
||||
mocks.sentryCreateWithOtlpExport.mockResolvedValue({ shutdown: vi.fn(), captureMessage: vi.fn(), startSpan: vi.fn() });
|
||||
mocks.otelCreate.mockResolvedValue({ shutdown: vi.fn(), captureMessage: vi.fn(), startSpan: vi.fn() });
|
||||
});
|
||||
|
||||
test('does not initialize an adapter when Sentry is not configured', async () => {
|
||||
test('uses OpenTelemetryAdapter when only otelForBackend is configured', async () => {
|
||||
const { initTelemetry } = await import('@/core/telemetry/telemetry-registry.js');
|
||||
const otelForBackend = { endpoint: 'http://collector:4318/v1/traces' };
|
||||
|
||||
await initTelemetry(config({}));
|
||||
await initTelemetry(config({ otelForBackend }));
|
||||
|
||||
expect(mocks.otelCreate).toHaveBeenCalledWith({
|
||||
...otelForBackend,
|
||||
serviceVersion: '2026.1.0',
|
||||
});
|
||||
expect(mocks.sentryCreate).not.toHaveBeenCalled();
|
||||
expect(mocks.setLogTraceContextProvider).not.toHaveBeenCalled();
|
||||
expect(mocks.sentryCreateWithOtlpExport).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test('initializes Sentry and registers its trace context provider', async () => {
|
||||
test('registers the adapter trace context provider after telemetry initialization', async () => {
|
||||
const { initTelemetry } = await import('@/core/telemetry/telemetry-registry.js');
|
||||
const sentryForBackend = { options: {}, enableNodeProfiling: false };
|
||||
const getActiveTraceContext = vi.fn(() => ({
|
||||
traceId: '0123456789abcdef0123456789abcdef',
|
||||
spanId: '0123456789abcdef',
|
||||
traceFlags: 0,
|
||||
}));
|
||||
mocks.sentryCreate.mockResolvedValue({
|
||||
mocks.otelCreate.mockResolvedValue({
|
||||
shutdown: vi.fn(),
|
||||
captureMessage: vi.fn(),
|
||||
startSpan: vi.fn(),
|
||||
getActiveTraceContext,
|
||||
});
|
||||
|
||||
await initTelemetry(config({ sentryForBackend }));
|
||||
await initTelemetry(config({ otelForBackend: { endpoint: 'http://collector:4318/v1/traces' } }));
|
||||
|
||||
expect(mocks.sentryCreate).toHaveBeenCalledWith(sentryForBackend);
|
||||
expect(mocks.setLogTraceContextProvider).toHaveBeenCalledWith(expect.any(Function));
|
||||
const provider = mocks.setLogTraceContextProvider.mock.calls[0][0] as () => unknown;
|
||||
expect(provider()).toEqual({
|
||||
@@ -75,6 +91,21 @@ describe('telemetry-registry', () => {
|
||||
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 () => {
|
||||
const { startSpan } = await import('@/core/telemetry/telemetry-registry.js');
|
||||
|
||||
@@ -83,32 +114,67 @@ describe('telemetry-registry', () => {
|
||||
expect(fn).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
test('startSpan delegates to the Sentry adapter', async () => {
|
||||
test('startSpan delegates directly to the single registered adapter without extra wrapping', async () => {
|
||||
const { initTelemetry, startSpan } = await import('@/core/telemetry/telemetry-registry.js');
|
||||
const otelForBackend = { endpoint: 'http://collector:4318/v1/traces' };
|
||||
const adapterStartSpan = vi.fn((_name: string, fn: () => string) => fn());
|
||||
mocks.sentryCreate.mockResolvedValue({ shutdown: vi.fn(), captureMessage: vi.fn(), startSpan: adapterStartSpan });
|
||||
mocks.otelCreate.mockResolvedValue({ shutdown: vi.fn(), captureMessage: vi.fn(), startSpan: adapterStartSpan });
|
||||
|
||||
await initTelemetry(config({ sentryForBackend: { options: {}, enableNodeProfiling: false } }));
|
||||
await initTelemetry(config({ otelForBackend }));
|
||||
|
||||
const fn = vi.fn().mockReturnValue('result');
|
||||
expect(startSpan('test', fn)).toBe('result');
|
||||
expect(adapterStartSpan).toHaveBeenCalledWith('test', fn);
|
||||
});
|
||||
|
||||
test('shutdownTelemetry waits for all registered adapters even when one rejects', async () => {
|
||||
const { initTelemetry, shutdownTelemetry } = await import('@/core/telemetry/telemetry-registry.js');
|
||||
const firstShutdown = vi.fn().mockRejectedValue(new Error('first failed'));
|
||||
const secondShutdown = vi.fn().mockResolvedValue(undefined);
|
||||
mocks.sentryCreate
|
||||
.mockResolvedValueOnce({ shutdown: firstShutdown, captureMessage: vi.fn(), startSpan: vi.fn() })
|
||||
.mockResolvedValueOnce({ shutdown: secondShutdown, captureMessage: vi.fn(), startSpan: vi.fn() });
|
||||
test('startSpan wraps work through multiple registered adapters in order for future adapter combinations', async () => {
|
||||
const { initTelemetry, startSpan } = await import('@/core/telemetry/telemetry-registry.js');
|
||||
const calls: string[] = [];
|
||||
mocks.sentryCreate.mockResolvedValue({
|
||||
shutdown: vi.fn(),
|
||||
captureMessage: vi.fn(),
|
||||
startSpan: vi.fn((_name: string, fn: () => string) => {
|
||||
calls.push('sentry:start');
|
||||
const result = fn();
|
||||
calls.push('sentry:end');
|
||||
return result;
|
||||
}),
|
||||
});
|
||||
mocks.otelCreate.mockResolvedValue({
|
||||
shutdown: vi.fn(),
|
||||
captureMessage: vi.fn(),
|
||||
startSpan: vi.fn((_name: string, fn: () => string) => {
|
||||
calls.push('otel:start');
|
||||
const result = fn();
|
||||
calls.push('otel:end');
|
||||
return result;
|
||||
}),
|
||||
});
|
||||
|
||||
const sentryForBackend = { options: {}, enableNodeProfiling: false };
|
||||
await initTelemetry(config({ sentryForBackend }));
|
||||
await initTelemetry(config({ sentryForBackend }));
|
||||
await initTelemetry(config({ sentryForBackend: { options: {}, enableNodeProfiling: false } }));
|
||||
await initTelemetry(config({ otelForBackend: { endpoint: 'http://collector:4318/v1/traces' } }));
|
||||
|
||||
const fn = vi.fn(() => {
|
||||
calls.push('work');
|
||||
return 'result';
|
||||
});
|
||||
|
||||
expect(startSpan('test', fn)).toBe('result');
|
||||
expect(calls).toEqual(['sentry:start', 'otel:start', 'work', 'otel:end', 'sentry:end']);
|
||||
});
|
||||
|
||||
test('shutdownTelemetry waits for every adapter even when one shutdown rejects', async () => {
|
||||
const { initTelemetry, shutdownTelemetry } = await import('@/core/telemetry/telemetry-registry.js');
|
||||
const sentryShutdown = vi.fn().mockRejectedValue(new Error('sentry failed'));
|
||||
const otelShutdown = vi.fn().mockResolvedValue(undefined);
|
||||
mocks.sentryCreate.mockResolvedValue({ shutdown: sentryShutdown, captureMessage: vi.fn(), startSpan: vi.fn() });
|
||||
mocks.otelCreate.mockResolvedValue({ shutdown: otelShutdown, captureMessage: vi.fn(), startSpan: vi.fn() });
|
||||
|
||||
await initTelemetry(config({ sentryForBackend: { options: {}, enableNodeProfiling: false } }));
|
||||
await initTelemetry(config({ otelForBackend: { endpoint: 'http://collector:4318/v1/traces' } }));
|
||||
|
||||
await expect(shutdownTelemetry()).resolves.toBeUndefined();
|
||||
expect(firstShutdown).toHaveBeenCalledTimes(1);
|
||||
expect(secondShutdown).toHaveBeenCalledTimes(1);
|
||||
expect(sentryShutdown).toHaveBeenCalledTimes(1);
|
||||
expect(otelShutdown).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,52 @@
|
||||
# Preferences system
|
||||
|
||||
ユーザーの環境設定を管理するシステム。
|
||||
|
||||
## 指針
|
||||
|
||||
実装上のミスで、**設定値が意図せず失われる(古い値で上書きされる)ことが絶対にあってはならない。**
|
||||
|
||||
設定値が失われる、考えられるシナリオの例:
|
||||
|
||||
- 複数のタブでMisskeyを開いていて、タブAで設定を編集した後、タブBを開いたところ、タブBの古い状態の内容で設定が保存され、タブAで行った編集が巻き戻ってしまった。
|
||||
- プロファイルの同期機能をオンにしたところ、サーバーに保存されていた古い設定でローカルが上書きされてしまった。
|
||||
|
||||
上記のシナリオが絶対に発生しない設計・実装にしなければならない。
|
||||
|
||||
上記以外にも考えられるシナリオがあれば、必ず適切な対処を行う設計・実装にしなければならない。
|
||||
|
||||
## 仕様
|
||||
|
||||
### タブ間同期
|
||||
|
||||
ブラウザで複数タブを開いているとき、あるタブで変更した設定が他のタブでも反映されるようにし、UXを向上させ、古い設定情報が上書き保存されることを防ぐ。
|
||||
|
||||
### 自動バックアップ(cloudBackup)
|
||||
|
||||
定期的、または特定のタイミングなどで、プロファイルをサーバーに自動的にアップロードする。
|
||||
|
||||
### デバイス間同期(cloudSync)
|
||||
|
||||
定期的、または特定のタイミングなどで、サーバーにアップロードされたプロファイルをダウンロードし、ローカルに適用する。
|
||||
|
||||
### 設定項目: デバイス間で同期(syncBetweenDevices, ValueMeta.sync)
|
||||
|
||||
デバイス間・プロファイル横断でその設定項目の設定値を同期する。
|
||||
|
||||
当オプションを有効にするかどうかは設定項目ごとに設定可能。
|
||||
|
||||
## ユーザーストーリー(ユースケース)
|
||||
|
||||
### 同じプロファイルを複数のデバイスで使いたい(同期したい)
|
||||
|
||||
autoBackupとautoSyncをオンにする。
|
||||
|
||||
### (新しいデバイスなどで)既存のプロファイルを継承した新しいプロファイルを作りたい
|
||||
|
||||
継承したいプロファイルをバックアップから復元した後、プロファイルの名前を変える。
|
||||
|
||||
## メモ
|
||||
|
||||
autoBackupとautoSyncの違いがユーザーにとって分かりにくい可能性があるので、機能を一本化するか、個別にオンオフを切り替えられるのではなく「自動バックアップ:『しない/有効/有効+同期』」みたいな選択方式にするなどがいいかもしれない?
|
||||
|
||||
autoBackupはするがautoSyncしたくないケースはあまりないと思うが、一本化すると複数のデバイスで同一のプロファイルを共有しない人(大多数だと思われる)にとっては無駄に通信量が増えるだけになる
|
||||
@@ -191,6 +191,7 @@ SPDX-License-Identifier: AGPL-3.0-only
|
||||
role="menuitem"
|
||||
tabindex="0"
|
||||
:class="['_button', $style.item, { [$style.danger]: item.danger, [$style.active]: unref(item.active) }]"
|
||||
:disabled="unref(item.disabled)"
|
||||
@click.prevent="unref(item.active) ? close(false) : clicked(item.action, $event)"
|
||||
@mouseenter.passive="onItemMouseEnter"
|
||||
@mouseleave.passive="onItemMouseLeave"
|
||||
@@ -629,6 +630,7 @@ function guardMouseMove(ev: MouseEvent) {
|
||||
box-sizing: border-box;
|
||||
max-width: 100vw;
|
||||
min-width: 200px;
|
||||
width: max-content;
|
||||
overflow: auto;
|
||||
overscroll-behavior: contain;
|
||||
|
||||
@@ -701,6 +703,7 @@ function guardMouseMove(ev: MouseEvent) {
|
||||
|
||||
&:disabled {
|
||||
cursor: not-allowed;
|
||||
opacity: 0.5;
|
||||
}
|
||||
|
||||
&.danger {
|
||||
|
||||
@@ -49,7 +49,7 @@ SPDX-License-Identifier: AGPL-3.0-only
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { computed } from 'vue';
|
||||
import { computed, onUnmounted, watch } from 'vue';
|
||||
import { isLink } from '@@/js/is-link.js';
|
||||
import type { UploaderItem } from '@/composables/use-uploader.js';
|
||||
import { getUploadName } from '@/composables/use-uploader.js';
|
||||
@@ -57,7 +57,6 @@ import { i18n } from '@/i18n.js';
|
||||
import MkButton from '@/components/MkButton.vue';
|
||||
import bytes from '@/filters/bytes.js';
|
||||
import * as os from '@/os.js';
|
||||
import type { Content } from '@/components/MkLightbox.item.vue';
|
||||
|
||||
const props = defineProps<{
|
||||
items: UploaderItem[];
|
||||
@@ -73,6 +72,32 @@ const emit = defineEmits<{
|
||||
(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): {
|
||||
baseName: string;
|
||||
extension: string | null;
|
||||
@@ -102,15 +127,13 @@ function onContextmenu(item: UploaderItem, ev: PointerEvent) {
|
||||
|
||||
async function onThumbnailClick(item: UploaderItem, ev: PointerEvent) {
|
||||
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<Content>(item => ({
|
||||
id: item.id,
|
||||
type: (item.file.type.startsWith('video') ? 'video' : 'image'),
|
||||
url: item.objectUrl,
|
||||
thumbnailUrl: item.thumbnail,
|
||||
filename: getUploadName(item),
|
||||
}));
|
||||
const contents = props.items.filter(item => item.file.type.startsWith('image') || item.file.type.startsWith('video')).map(item => ({
|
||||
id: item.id,
|
||||
type: (item.file.type.startsWith('video') ? 'video' as const : 'image' as const),
|
||||
url: objectUrlMap.get(item)!,
|
||||
thumbnailUrl: item.thumbnail,
|
||||
filename: getUploadName(item),
|
||||
}));
|
||||
|
||||
const { dispose } = await os.popupAsyncWithDialog(import('@/components/MkLightbox.vue').then(x => x.default), {
|
||||
defaultIndex: contents.findIndex(content => content.id === item.id),
|
||||
|
||||
@@ -16,7 +16,6 @@ import { i18n } from '@/i18n.js';
|
||||
import { prefer } from '@/preferences.js';
|
||||
import { isWebpSupported } from '@/utility/isWebpSupported.js';
|
||||
import { uploadFile, UploadAbortedError } from '@/utility/drive.js';
|
||||
import type { Content } from '@/components/MkLightbox.item.vue';
|
||||
import * as os from '@/os.js';
|
||||
import { ensureSignin } from '@/i.js';
|
||||
|
||||
@@ -75,7 +74,6 @@ export type UploaderItem = {
|
||||
compressedSize?: number | null;
|
||||
preprocessedFile?: Blob | null;
|
||||
file: File;
|
||||
objectUrl: string;
|
||||
watermarkPreset: WatermarkPreset | null;
|
||||
watermarkLayers: WatermarkLayers | null;
|
||||
imageFrameParams: ImageFrameParams | null;
|
||||
@@ -135,13 +133,12 @@ export function useUploader(options: {
|
||||
const filename = file.name ?? 'untitled';
|
||||
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 objectUrl = window.URL.createObjectURL(file);
|
||||
items.value.push({
|
||||
id,
|
||||
name: prefer.s.keepOriginalFilename ? filename : id + extension,
|
||||
suffix: '',
|
||||
progress: null,
|
||||
thumbnail: THUMBNAIL_SUPPORTED_TYPES.includes(file.type) ? objectUrl : null,
|
||||
thumbnail: THUMBNAIL_SUPPORTED_TYPES.includes(file.type) ? window.URL.createObjectURL(file) : null,
|
||||
preprocessing: false,
|
||||
preprocessProgress: null,
|
||||
uploading: false,
|
||||
@@ -153,7 +150,6 @@ export function useUploader(options: {
|
||||
watermarkLayers: watermarkPreset?.layers ?? null,
|
||||
imageFrameParams: null,
|
||||
file: markRaw(file),
|
||||
objectUrl,
|
||||
});
|
||||
const reactiveItem = items.value.at(-1)!;
|
||||
preprocess(reactiveItem).then(() => {
|
||||
@@ -167,24 +163,8 @@ export function useUploader(options: {
|
||||
}
|
||||
}
|
||||
|
||||
function revokeItemObjectUrls(item: UploaderItem) {
|
||||
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);
|
||||
if (item.thumbnail != null) URL.revokeObjectURL(item.thumbnail);
|
||||
items.value.splice(items.value.indexOf(item), 1);
|
||||
}
|
||||
|
||||
@@ -234,35 +214,7 @@ export function useUploader(options: {
|
||||
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',
|
||||
});
|
||||
}
|
||||
@@ -283,12 +235,11 @@ export function useUploader(options: {
|
||||
text: i18n.ts.cropImage,
|
||||
action: async () => {
|
||||
const cropped = await os.cropImageFile(item.file, { aspectRatio: null });
|
||||
const newObjectUrl = createItemObjectUrl(item, cropped);
|
||||
if (item.thumbnail != null) URL.revokeObjectURL(item.thumbnail);
|
||||
items.value.splice(items.value.indexOf(item), 1, {
|
||||
...item,
|
||||
file: markRaw(cropped),
|
||||
thumbnail: THUMBNAIL_SUPPORTED_TYPES.includes(cropped.type) ? newObjectUrl : null,
|
||||
objectUrl: newObjectUrl,
|
||||
thumbnail: window.URL.createObjectURL(cropped),
|
||||
});
|
||||
const reactiveItem = items.value.find(x => x.id === item.id)!;
|
||||
preprocess(reactiveItem).then(() => {
|
||||
@@ -309,12 +260,11 @@ export function useUploader(options: {
|
||||
image: item.file,
|
||||
}, {
|
||||
ok: (file) => {
|
||||
const newObjectUrl = createItemObjectUrl(item, file);
|
||||
if (item.thumbnail != null) URL.revokeObjectURL(item.thumbnail);
|
||||
items.value.splice(items.value.indexOf(item), 1, {
|
||||
...item,
|
||||
file: markRaw(file),
|
||||
thumbnail: THUMBNAIL_SUPPORTED_TYPES.includes(file.type) ? newObjectUrl : null,
|
||||
objectUrl: newObjectUrl,
|
||||
thumbnail: window.URL.createObjectURL(file),
|
||||
});
|
||||
const reactiveItem = items.value.find(x => x.id === item.id)!;
|
||||
preprocess(reactiveItem).then(() => {
|
||||
@@ -744,7 +694,8 @@ export function useUploader(options: {
|
||||
|
||||
imageBitmap.close();
|
||||
|
||||
updateItemObjectUrls(item, preprocessedFile);
|
||||
if (item.thumbnail != null) URL.revokeObjectURL(item.thumbnail);
|
||||
item.thumbnail = THUMBNAIL_SUPPORTED_TYPES.includes(preprocessedFile.type) ? window.URL.createObjectURL(preprocessedFile) : null;
|
||||
item.preprocessedFile = markRaw(preprocessedFile);
|
||||
}
|
||||
|
||||
@@ -803,13 +754,14 @@ export function useUploader(options: {
|
||||
item.suffix = '';
|
||||
}
|
||||
|
||||
updateItemObjectUrls(item, preprocessedFile);
|
||||
if (item.thumbnail != null) URL.revokeObjectURL(item.thumbnail);
|
||||
item.thumbnail = THUMBNAIL_SUPPORTED_TYPES.includes(preprocessedFile.type) ? window.URL.createObjectURL(preprocessedFile) : null;
|
||||
item.preprocessedFile = markRaw(preprocessedFile);
|
||||
}
|
||||
|
||||
function reset() {
|
||||
for (const item of items.value) {
|
||||
revokeItemObjectUrls(item);
|
||||
if (item.thumbnail != null) URL.revokeObjectURL(item.thumbnail);
|
||||
}
|
||||
|
||||
abortAll();
|
||||
|
||||
@@ -144,7 +144,17 @@ SPDX-License-Identifier: AGPL-3.0-only
|
||||
|
||||
<MkButton v-if="storagePersistenceSupported && !storagePersisted" @click="enableStoragePersistence">{{ i18n.ts._settings.settingsPersistence_title }}</MkButton>
|
||||
|
||||
<MkButton @click="forceCloudBackup">{{ i18n.ts._preferencesBackup.forceBackup }}</MkButton>
|
||||
<SearchMarker :keywords="['profile', 'preferences']">
|
||||
<MkFolder>
|
||||
<template #icon><SearchIcon><i class="ti ti-cogs"></i></SearchIcon></template>
|
||||
<template #label><SearchLabel>{{ i18n.ts.preferencesProfile }}</SearchLabel></template>
|
||||
|
||||
<div class="_buttons">
|
||||
<MkButton @click="forceCloudBackup">{{ i18n.ts._preferencesBackup.forceBackup }}</MkButton>
|
||||
<MkButton @click="forceCloudSync">{{ i18n.ts._preferencesBackup.forceSync }}</MkButton>
|
||||
</div>
|
||||
</MkFolder>
|
||||
</SearchMarker>
|
||||
</div>
|
||||
</SearchMarker>
|
||||
</template>
|
||||
@@ -170,7 +180,7 @@ import MkRolePreview from '@/components/MkRolePreview.vue';
|
||||
import { signout } from '@/signout.js';
|
||||
import { hideAllTips as _hideAllTips, resetAllTips as _resetAllTips } from '@/tips.js';
|
||||
import { suggestReload } from '@/utility/reload-suggest.js';
|
||||
import { cloudBackup } from '@/preferences/utility.js';
|
||||
import { cloudBackup, cloudSync } from '@/preferences/utility.js';
|
||||
|
||||
const $i = ensureSignin();
|
||||
|
||||
@@ -232,6 +242,11 @@ async function forceCloudBackup() {
|
||||
os.success();
|
||||
}
|
||||
|
||||
async function forceCloudSync() {
|
||||
await cloudSync();
|
||||
os.success();
|
||||
}
|
||||
|
||||
const headerActions = computed(() => []);
|
||||
|
||||
const headerTabs = computed(() => []);
|
||||
|
||||
@@ -3,16 +3,14 @@
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
import { BroadcastChannel } from 'broadcast-channel';
|
||||
import { createVisibilityAwareInterval } from '@@/js/interval.js';
|
||||
import type { StorageProvider } from '@/preferences/manager.js';
|
||||
import { cloudBackup } from '@/preferences/utility.js';
|
||||
import { cloudBackup, cloudSync } from '@/preferences/utility.js';
|
||||
import { miLocalStorage } from '@/local-storage.js';
|
||||
import { isSameScope, PreferencesManager } from '@/preferences/manager.js';
|
||||
import { store } from '@/store.js';
|
||||
import { $i } from '@/i.js';
|
||||
import { misskeyApi } from '@/utility/misskey-api.js';
|
||||
import { TAB_ID } from '@/tab-id.js';
|
||||
|
||||
// クラウド同期用グループ名
|
||||
const syncGroup = 'default';
|
||||
@@ -38,11 +36,12 @@ const io: StorageProvider = {
|
||||
const cloudData = await misskeyApi('i/registry/get', {
|
||||
scope: ['client', 'preferences', 'sync'],
|
||||
key: syncGroup + ':' + ctx.key,
|
||||
}) as [any, any][];
|
||||
}) as [any, any, any][];
|
||||
const target = cloudData.find(([scope]) => isSameScope(scope, ctx.scope));
|
||||
if (target == null) return null;
|
||||
return {
|
||||
value: target[1],
|
||||
meta: target[2],
|
||||
};
|
||||
} catch (err: any) {
|
||||
if (err.code === 'NO_SUCH_KEY') { // TODO: いちいちエラーキャッチするのは面倒なのでキーが無くてもエラーにならない maybe-get のようなエンドポイントをバックエンドに実装する
|
||||
@@ -54,12 +53,12 @@ const io: StorageProvider = {
|
||||
},
|
||||
|
||||
cloudSet: async (ctx) => {
|
||||
let cloudData: [any, any][] = [];
|
||||
let cloudData: [any, any, any][] = [];
|
||||
try {
|
||||
cloudData = await misskeyApi('i/registry/get', {
|
||||
scope: ['client', 'preferences', 'sync'],
|
||||
key: syncGroup + ':' + ctx.key,
|
||||
}) as [any, any][];
|
||||
}) as [any, any, any][];
|
||||
} catch (err: any) {
|
||||
if (err.code === 'NO_SUCH_KEY') { // TODO: いちいちエラーキャッチするのは面倒なのでキーが無くてもエラーにならない maybe-get のようなエンドポイントをバックエンドに実装する
|
||||
cloudData = [];
|
||||
@@ -71,9 +70,9 @@ const io: StorageProvider = {
|
||||
const i = cloudData.findIndex(([scope]) => isSameScope(scope, ctx.scope));
|
||||
|
||||
if (i === -1) {
|
||||
cloudData.push([ctx.scope, ctx.value]);
|
||||
cloudData.push([ctx.scope, ctx.value, ctx.meta]);
|
||||
} else {
|
||||
cloudData[i] = [ctx.scope, ctx.value];
|
||||
cloudData[i] = [ctx.scope, ctx.value, ctx.meta];
|
||||
}
|
||||
|
||||
await misskeyApi('i/registry/set', {
|
||||
@@ -88,10 +87,10 @@ const io: StorageProvider = {
|
||||
const fetchings = ctx.needs.map(need => io.cloudGet(need).then(res => [need.key, res] as const));
|
||||
const cloudDatas = await Promise.all(fetchings);
|
||||
|
||||
const res = {} as Partial<Record<string, any>>;
|
||||
const res = {} as Partial<Record<string, { value: any; meta: any; }>>;
|
||||
for (const cloudData of cloudDatas) {
|
||||
if (cloudData[1] != null) {
|
||||
res[cloudData[0]] = cloudData[1].value;
|
||||
res[cloudData[0]] = cloudData[1];
|
||||
}
|
||||
}
|
||||
|
||||
@@ -102,41 +101,10 @@ const io: StorageProvider = {
|
||||
export const prefer = new PreferencesManager(io, $i);
|
||||
|
||||
//#region タブ間同期
|
||||
let latestPreferencesUpdate: {
|
||||
tabId: string;
|
||||
timestamp: number;
|
||||
} | null = null;
|
||||
|
||||
const preferencesChannel = new BroadcastChannel<{
|
||||
type: 'preferencesUpdate';
|
||||
tabId: string;
|
||||
timestamp: number;
|
||||
}>('preferences');
|
||||
|
||||
prefer.on('committed', () => {
|
||||
latestPreferencesUpdate = {
|
||||
tabId: TAB_ID,
|
||||
timestamp: Date.now(),
|
||||
};
|
||||
preferencesChannel.postMessage({
|
||||
type: 'preferencesUpdate',
|
||||
tabId: TAB_ID,
|
||||
timestamp: latestPreferencesUpdate.timestamp,
|
||||
});
|
||||
});
|
||||
|
||||
preferencesChannel.addEventListener('message', (msg) => {
|
||||
if (msg.type === 'preferencesUpdate') {
|
||||
if (msg.tabId === TAB_ID) return;
|
||||
if (latestPreferencesUpdate != null) {
|
||||
if (msg.timestamp <= latestPreferencesUpdate.timestamp) return;
|
||||
}
|
||||
window.addEventListener('storage', (ev) => {
|
||||
if (ev.key === 'preferences') {
|
||||
prefer.reloadProfile();
|
||||
if (_DEV_) console.log('prefer:received update from other tab');
|
||||
latestPreferencesUpdate = {
|
||||
tabId: msg.tabId,
|
||||
timestamp: msg.timestamp,
|
||||
};
|
||||
if (_DEV_) console.log('prefer: received update from other tab');
|
||||
}
|
||||
});
|
||||
//#endregion
|
||||
@@ -156,6 +124,13 @@ createVisibilityAwareInterval(() => {
|
||||
}, 1000 * 60 * 3);
|
||||
//#endregion
|
||||
|
||||
store.loaded.then(() => {
|
||||
if (store.s.enablePreferencesAutoCloudSync) {
|
||||
// TODO: 前回同期してから10分以上経過している場合のみ
|
||||
cloudSync();
|
||||
}
|
||||
});
|
||||
|
||||
if (_DEV_) {
|
||||
(window as any).prefer = prefer;
|
||||
(window as any).cloudBackup = cloudBackup;
|
||||
|
||||
@@ -36,6 +36,7 @@ type Scope = Partial<{
|
||||
|
||||
type ValueMeta = Partial<{
|
||||
sync: boolean;
|
||||
modifiedAt?: number; // 設定値を変更した日時。同期した日時などではない。つまり別のデバイスでA日に変更したものをB日に同期して取得したとしてもmodifiedAtはA日である必要がある
|
||||
}>;
|
||||
|
||||
type PrefRecord<K extends keyof PREF> = [scope: Scope, value: ValueOf<K>, meta: ValueMeta];
|
||||
@@ -74,7 +75,7 @@ export type PreferencesProfile = {
|
||||
id: string;
|
||||
version: string;
|
||||
type: 'main';
|
||||
modifiedAt: number;
|
||||
modifiedAt: number; // 仕様が若干直感的ではない(syncされた値が降ってきたときは更新されないなど)ため、一応残してはいるが積極的な利用はしない方が無難。設定値の新旧比較が必要なら項目ごとのmodifiedAtを使うべし
|
||||
name: string;
|
||||
preferences: {
|
||||
[K in keyof PREF]: PrefRecord<K>[];
|
||||
@@ -88,9 +89,9 @@ export type PossiblyNonNormalizedPreferencesProfile = Omit<PreferencesProfile, '
|
||||
export type StorageProvider = {
|
||||
load: () => PossiblyNonNormalizedPreferencesProfile | null;
|
||||
save: (ctx: { profile: PreferencesProfile; }) => void;
|
||||
cloudGetBulk: <K extends keyof PREF>(ctx: { needs: { key: K; scope: Scope; }[] }) => Promise<Partial<Record<K, ValueOf<K>>>>;
|
||||
cloudGet: <K extends keyof PREF>(ctx: { key: K; scope: Scope; }) => Promise<{ value: ValueOf<K>; } | null>;
|
||||
cloudSet: <K extends keyof PREF>(ctx: { key: K; scope: Scope; value: ValueOf<K>; }) => Promise<void>;
|
||||
cloudGetBulk: <K extends keyof PREF>(ctx: { needs: { key: K; scope: Scope; }[] }) => Promise<Partial<Record<K, { value: ValueOf<K>; meta: { modifiedAt: ValueMeta['modifiedAt'] }; }>>>;
|
||||
cloudGet: <K extends keyof PREF>(ctx: { key: K; scope: Scope; }) => Promise<{ value: ValueOf<K>; meta: { modifiedAt: ValueMeta['modifiedAt'] }; } | null>;
|
||||
cloudSet: <K extends keyof PREF>(ctx: { key: K; scope: Scope; value: ValueOf<K>; meta: { modifiedAt: ValueMeta['modifiedAt'] }; }) => Promise<void>;
|
||||
};
|
||||
|
||||
type PreferencesDefinitionRecord<Default, T = Default extends (...args: any) => infer R ? R : Default> = {
|
||||
@@ -102,14 +103,6 @@ type PreferencesDefinitionRecord<Default, T = Default extends (...args: any) =>
|
||||
|
||||
export type PreferencesDefinition = Record<string, PreferencesDefinitionRecord<any>>;
|
||||
|
||||
type PreferencesManagerEvents = {
|
||||
'committed': <K extends keyof PREF>(ctx: {
|
||||
key: K;
|
||||
value: ValueOf<K>;
|
||||
oldValue: ValueOf<K>;
|
||||
}) => void;
|
||||
};
|
||||
|
||||
export function definePreferences<T extends Record<string, unknown>>(x: {
|
||||
[K in keyof T]: PreferencesDefinitionRecord<T[K]>
|
||||
}): {
|
||||
@@ -188,6 +181,44 @@ function normalizePreferences(preferences: PossiblyNonNormalizedPreferencesProfi
|
||||
return data as PreferencesProfile['preferences'];
|
||||
}
|
||||
|
||||
// 各recordについて、modifiedAtが大きい方を採用する
|
||||
// 引数の参照をmutateしないように注意すること
|
||||
export function mergeProfiles(a: PreferencesProfile, b: PreferencesProfile): PreferencesProfile {
|
||||
const merged = {
|
||||
...a,
|
||||
modifiedAt: Math.max(a.modifiedAt, b.modifiedAt),
|
||||
preferences: {},
|
||||
} as PreferencesProfile;
|
||||
|
||||
for (const _key in PREF_DEF) {
|
||||
const key = _key as keyof PREF;
|
||||
const aRecords = a.preferences[key];
|
||||
const bRecords = b.preferences[key];
|
||||
|
||||
const mergedRecords = [...aRecords];
|
||||
|
||||
for (const bRecord of bRecords) {
|
||||
const existingIndex = mergedRecords.findIndex(([scope]) => isSameScope(scope, bRecord[0]));
|
||||
if (existingIndex === -1) {
|
||||
mergedRecords.push(bRecord);
|
||||
} else {
|
||||
const aRecord = mergedRecords[existingIndex];
|
||||
if ((bRecord[2].modifiedAt ?? 0) > (aRecord[2].modifiedAt ?? 0)) {
|
||||
mergedRecords[existingIndex] = bRecord;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
(merged.preferences[key] as PrefRecord<typeof key>[]) = mergedRecords;
|
||||
}
|
||||
|
||||
return merged;
|
||||
}
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-empty-object-type
|
||||
type PreferencesManagerEvents = {
|
||||
};
|
||||
|
||||
// TODO: PreferencesManagerForGuest のような非ログイン専用のクラスを分離すればthis.currentAccountのnullチェックやaccountがnullであるスコープのレコード挿入などが不要になり綺麗になるかもしれない
|
||||
// と思ったけど操作アカウントが存在しない場合も考慮する現在の設計の方が汎用的かつ堅牢かもしれない
|
||||
// NOTE: accountDependentな設定は初期状態であってもアカウントごとのスコープでレコードを作成しておかないと、サーバー同期する際に正しく動作しなくなる
|
||||
@@ -247,13 +278,13 @@ export class PreferencesManager extends EventEmitter<PreferencesManagerEvents> {
|
||||
}
|
||||
|
||||
// TODO: desync対策 cloudの値のfetchが正常に完了していない状態でcommitすると多分値が上書きされる
|
||||
public commit<K extends keyof PREF>(key: K, value: ValueOf<K>) {
|
||||
public commit<K extends keyof PREF>(key: K, value: ValueOf<K>): PrefRecord<K> | null {
|
||||
const currentAccount = this.currentAccount; // TSを黙らせるため
|
||||
const v = JSON.parse(JSON.stringify(value)); // deep copy 兼 vueのプロキシ解除
|
||||
|
||||
if (deepEqual(this.s[key], v)) {
|
||||
if (_DEV_) console.log('(skip) prefer:commit', key, v);
|
||||
return;
|
||||
return null;
|
||||
}
|
||||
|
||||
if (_DEV_) console.log('prefer:commit', key, v);
|
||||
@@ -262,40 +293,40 @@ export class PreferencesManager extends EventEmitter<PreferencesManagerEvents> {
|
||||
|
||||
const record = this.getMatchedRecordOf(key);
|
||||
|
||||
const _save = () => {
|
||||
this.save();
|
||||
this.emit('committed', {
|
||||
key,
|
||||
value: v,
|
||||
oldValue: this.s[key],
|
||||
});
|
||||
};
|
||||
|
||||
if (parseScope(record[0]).account == null && isAccountDependentKey(key) && currentAccount != null) {
|
||||
this.profile.preferences[key].push([makeScope({
|
||||
const newRecord = [makeScope({
|
||||
server: host,
|
||||
account: currentAccount.id,
|
||||
}), v, {}]);
|
||||
_save();
|
||||
return;
|
||||
}), v, {
|
||||
modifiedAt: Date.now(),
|
||||
}] as PrefRecord<K>;
|
||||
this.profile.preferences[key].push(newRecord);
|
||||
this.save();
|
||||
return newRecord;
|
||||
}
|
||||
|
||||
if (parseScope(record[0]).server == null && isServerDependentKey(key)) {
|
||||
this.profile.preferences[key].push([makeScope({
|
||||
const newRecord = [makeScope({
|
||||
server: host,
|
||||
}), v, {}]);
|
||||
_save();
|
||||
return;
|
||||
}), v, {
|
||||
modifiedAt: Date.now(),
|
||||
}] as PrefRecord<K>;
|
||||
this.profile.preferences[key].push(newRecord);
|
||||
this.save();
|
||||
return newRecord;
|
||||
}
|
||||
|
||||
record[1] = v;
|
||||
_save();
|
||||
record[2].modifiedAt = Date.now();
|
||||
this.save();
|
||||
|
||||
if (record[2].sync) {
|
||||
// awaitの必要なし
|
||||
// TODO: リクエストを間引く
|
||||
this.io.cloudSet({ key, scope: record[0], value: record[1] });
|
||||
this.io.cloudSet({ key, scope: record[0], value: record[1], meta: { modifiedAt: record[2].modifiedAt } });
|
||||
}
|
||||
|
||||
return record;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -364,24 +395,29 @@ export class PreferencesManager extends EventEmitter<PreferencesManagerEvents> {
|
||||
|
||||
const cloudValues = await this.io.cloudGetBulk({ needs });
|
||||
|
||||
let modified = false;
|
||||
|
||||
for (const _key in PREF_DEF) {
|
||||
const key = _key as keyof PREF;
|
||||
const record = this.getMatchedRecordOf(key);
|
||||
if (record[2].sync && Object.hasOwn(cloudValues, key) && cloudValues[key] !== undefined) {
|
||||
const cloudValue = cloudValues[key];
|
||||
if (!deepEqual(cloudValue, record[1])) {
|
||||
this.rewriteRawState(key, cloudValue);
|
||||
record[1] = cloudValue;
|
||||
this.rewriteRawState(key, cloudValue.value);
|
||||
record[1] = cloudValue.value;
|
||||
record[2].modifiedAt = cloudValue.meta.modifiedAt;
|
||||
modified = true;
|
||||
if (_DEV_) console.log('cloud fetched', key, cloudValue);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
this.save();
|
||||
if (modified) this.save();
|
||||
|
||||
if (_DEV_) console.log('cloud fetch completed');
|
||||
}
|
||||
|
||||
public save() {
|
||||
private save() {
|
||||
this.profile.modifiedAt = Date.now();
|
||||
this.profile.version = version;
|
||||
this.io.save({ profile: this.profile });
|
||||
@@ -511,12 +547,12 @@ export class PreferencesManager extends EventEmitter<PreferencesManagerEvents> {
|
||||
newValue = resolvedValue;
|
||||
}
|
||||
|
||||
this.commit(key, newValue);
|
||||
const commitedRecord = this.commit(key, newValue);
|
||||
|
||||
const done = os.waiting();
|
||||
|
||||
try {
|
||||
await this.io.cloudSet({ key, scope: record[0], value: newValue });
|
||||
await this.io.cloudSet({ key, scope: record[0], value: newValue, meta: { modifiedAt: record[2].modifiedAt } });
|
||||
} catch (err) {
|
||||
done();
|
||||
|
||||
@@ -554,20 +590,18 @@ export class PreferencesManager extends EventEmitter<PreferencesManagerEvents> {
|
||||
}
|
||||
|
||||
public reloadProfile() {
|
||||
const newProfile = this.io.load();
|
||||
if (newProfile == null) return;
|
||||
const freshProfile = this.io.load();
|
||||
if (freshProfile == null) return;
|
||||
|
||||
this.profile = {
|
||||
...newProfile,
|
||||
preferences: normalizePreferences(newProfile.preferences, this.currentAccount),
|
||||
...freshProfile,
|
||||
preferences: normalizePreferences(freshProfile.preferences, this.currentAccount),
|
||||
};
|
||||
const states = this.genStates();
|
||||
for (const _key in states) {
|
||||
const key = _key as keyof PREF;
|
||||
this.rewriteRawState(key, states[key]);
|
||||
}
|
||||
|
||||
this.fetchCloudValues();
|
||||
}
|
||||
|
||||
public getPerPrefMenu<K extends keyof PREF>(key: K): MenuItem[] {
|
||||
@@ -617,6 +651,11 @@ export class PreferencesManager extends EventEmitter<PreferencesManagerEvents> {
|
||||
icon: 'ti ti-cloud-cog',
|
||||
text: i18n.ts.syncBetweenDevices,
|
||||
ref: sync,
|
||||
}, {
|
||||
type: 'divider',
|
||||
}, {
|
||||
type: 'label',
|
||||
text: i18n.ts.modifiedAt + ': ' + (this.getMatchedRecordOf(key)[2].modifiedAt ? new Date(this.getMatchedRecordOf(key)[2].modifiedAt!).toLocaleString() : '-'),
|
||||
}];
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,7 +3,8 @@
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
import { ref, watch } from 'vue';
|
||||
import { computed, ref, watch } from 'vue';
|
||||
import { mergeProfiles } from './manager.js';
|
||||
import type { PreferencesProfile } from './manager.js';
|
||||
import type { MenuItem } from '@/types/menu.js';
|
||||
import { copyToClipboard } from '@/utility/copy-to-clipboard.js';
|
||||
@@ -39,6 +40,28 @@ export function getPreferencesProfileMenu(): MenuItem[] {
|
||||
cloudBackup();
|
||||
} else {
|
||||
store.set('enablePreferencesAutoCloudBackup', false);
|
||||
|
||||
autoSyncEnabled.value = false;
|
||||
}
|
||||
});
|
||||
|
||||
const autoSyncEnabled = ref(store.s.enablePreferencesAutoCloudSync);
|
||||
|
||||
watch(autoSyncEnabled, async () => {
|
||||
if (autoSyncEnabled.value) {
|
||||
const confirm = await os.confirm({
|
||||
type: 'warning',
|
||||
title: i18n.ts._preferencesBackup.autoSyncAreYouSure,
|
||||
text: i18n.ts._preferencesBackup.autoSyncAreYouSure_description,
|
||||
});
|
||||
if (confirm.canceled) {
|
||||
autoSyncEnabled.value = false;
|
||||
return;
|
||||
}
|
||||
|
||||
store.set('enablePreferencesAutoCloudSync', true);
|
||||
} else {
|
||||
store.set('enablePreferencesAutoCloudSync', false);
|
||||
}
|
||||
});
|
||||
|
||||
@@ -52,10 +75,42 @@ export function getPreferencesProfileMenu(): MenuItem[] {
|
||||
renameProfile();
|
||||
},
|
||||
}, {
|
||||
type: 'switch',
|
||||
icon: 'ti ti-cloud-up',
|
||||
text: i18n.ts._preferencesBackup.autoBackup,
|
||||
ref: autoBackupEnabled,
|
||||
type: 'parent',
|
||||
text: i18n.ts._preferencesBackup.backupAndSync,
|
||||
caption: i18n.ts.latestBackupAt + ': ' + (store.s.latestPreferencesBackupAt !== 0 ? new Date(store.s.latestPreferencesBackupAt).toLocaleString() : '-'),
|
||||
icon: 'ti ti-cloud',
|
||||
children: [{
|
||||
type: 'switch',
|
||||
icon: 'ti ti-cloud-up',
|
||||
text: i18n.ts._preferencesBackup.autoBackup,
|
||||
caption: i18n.ts._preferencesBackup.autoBackup_description,
|
||||
ref: autoBackupEnabled,
|
||||
}, {
|
||||
type: 'button',
|
||||
icon: 'ti ti-cloud-up',
|
||||
text: i18n.ts._preferencesBackup.forceBackup,
|
||||
disabled: computed(() => !autoBackupEnabled.value),
|
||||
action: () => {
|
||||
cloudBackup();
|
||||
},
|
||||
}, {
|
||||
type: 'divider',
|
||||
}, {
|
||||
type: 'switch',
|
||||
icon: 'ti ti-cloud-down',
|
||||
text: i18n.ts._preferencesBackup.autoSync,
|
||||
caption: i18n.ts._preferencesBackup.autoSync_description,
|
||||
ref: autoSyncEnabled,
|
||||
disabled: computed(() => !autoBackupEnabled.value),
|
||||
}, {
|
||||
type: 'button',
|
||||
icon: 'ti ti-cloud-down',
|
||||
text: i18n.ts._preferencesBackup.forceSync,
|
||||
disabled: computed(() => !autoSyncEnabled.value),
|
||||
action: () => {
|
||||
cloudSync();
|
||||
},
|
||||
}],
|
||||
}, {
|
||||
text: i18n.ts.export,
|
||||
icon: 'ti ti-download',
|
||||
@@ -139,17 +194,55 @@ function importProfile() {
|
||||
input.click();
|
||||
}
|
||||
|
||||
export async function cloudSync() {
|
||||
if ($i == null) return;
|
||||
|
||||
const cloudProfile = await misskeyApi('i/registry/get', {
|
||||
scope: ['client', 'preferences', 'backups'],
|
||||
key: prefer.profile.name,
|
||||
}) as PreferencesProfile | null;
|
||||
|
||||
if (cloudProfile == null) {
|
||||
if (_DEV_) console.log('no backuped profile found, skipping sync');
|
||||
return;
|
||||
}
|
||||
|
||||
if (_DEV_) console.log('backuped profile found, syncing', cloudProfile);
|
||||
|
||||
miLocalStorage.setItem('preferences', JSON.stringify(mergeProfiles(prefer.profile, cloudProfile)));
|
||||
|
||||
prefer.reloadProfile();
|
||||
|
||||
store.set('latestPreferencesSyncAt', Date.now());
|
||||
}
|
||||
|
||||
export async function cloudBackup() {
|
||||
if ($i == null) return;
|
||||
if (!canAutoBackup()) {
|
||||
throw new Error('cannot auto backup for this profile');
|
||||
}
|
||||
|
||||
let currentProfile = prefer.profile;
|
||||
|
||||
if (_DEV_) console.log('cloud backup', currentProfile);
|
||||
|
||||
const backupedProfile = await misskeyApi('i/registry/get', {
|
||||
scope: ['client', 'preferences', 'backups'],
|
||||
key: prefer.profile.name,
|
||||
}) as PreferencesProfile | null;
|
||||
|
||||
// 古い設定で新しいバックアップを上書きしないようにマージ
|
||||
if (backupedProfile != null) {
|
||||
currentProfile = mergeProfiles(currentProfile, backupedProfile);
|
||||
}
|
||||
|
||||
await misskeyApi('i/registry/set', {
|
||||
scope: ['client', 'preferences', 'backups'],
|
||||
key: prefer.profile.name,
|
||||
value: prefer.profile,
|
||||
value: currentProfile,
|
||||
});
|
||||
|
||||
store.set('latestPreferencesBackupAt', Date.now());
|
||||
}
|
||||
|
||||
export async function listCloudBackups() {
|
||||
@@ -186,7 +279,6 @@ export async function restoreFromCloudBackup() {
|
||||
|
||||
const select = await os.select({
|
||||
title: i18n.ts._preferencesBackup.selectBackupToRestore,
|
||||
text: 'ℹ️ ' + i18n.ts._preferencesProfile.shareSameProfileBetweenDevicesIsNotRecommended + ' ' + i18n.ts._preferencesProfile.useSyncBetweenDevicesOptionIfYouWantToSyncSetting,
|
||||
items: backups.map(backup => ({
|
||||
label: backup.name,
|
||||
value: backup.name,
|
||||
|
||||
@@ -110,6 +110,10 @@ export const store = markRaw(new Pizzax('base', {
|
||||
where: 'device',
|
||||
default: false,
|
||||
},
|
||||
enablePreferencesAutoCloudSync: {
|
||||
where: 'device',
|
||||
default: false,
|
||||
},
|
||||
showPreferencesAutoCloudBackupSuggestion: {
|
||||
where: 'device',
|
||||
default: true,
|
||||
@@ -118,6 +122,14 @@ export const store = markRaw(new Pizzax('base', {
|
||||
where: 'device',
|
||||
default: true,
|
||||
},
|
||||
latestPreferencesBackupAt: {
|
||||
where: 'device',
|
||||
default: 0,
|
||||
},
|
||||
latestPreferencesSyncAt: {
|
||||
where: 'device',
|
||||
default: 0,
|
||||
},
|
||||
}));
|
||||
|
||||
// TODO: 他のタブと永続化されたstateを同期
|
||||
|
||||
@@ -23,6 +23,7 @@ export interface MenuButton {
|
||||
danger?: boolean;
|
||||
active?: boolean | ComputedRef<boolean>;
|
||||
avatar?: Misskey.entities.User;
|
||||
disabled?: boolean | Ref<boolean>;
|
||||
action: MenuAction;
|
||||
}
|
||||
|
||||
|
||||
@@ -30,7 +30,6 @@ SPDX-License-Identifier: AGPL-3.0-only
|
||||
import { ref } from 'vue';
|
||||
import * as Misskey from 'misskey-js';
|
||||
import { useInterval } from '@@/js/use-interval.js';
|
||||
import { url as baseUrl } from '@@/js/config.js';
|
||||
import MkMarqueeText from '@/components/MkMarqueeText.vue';
|
||||
import { shuffle } from '@/utility/shuffle.js';
|
||||
|
||||
@@ -54,11 +53,7 @@ const tick = () => {
|
||||
if (props.shuffle) {
|
||||
shuffle(feed.items);
|
||||
}
|
||||
items.value = feed.items.filter((item) => {
|
||||
if (!item.link) return false;
|
||||
const itemUrl = new URL(item.link, baseUrl);
|
||||
return ['http:', 'https:'].includes(itemUrl.protocol);
|
||||
});
|
||||
items.value = feed.items;
|
||||
fetching.value = false;
|
||||
key.value++;
|
||||
});
|
||||
|
||||
@@ -81,11 +81,7 @@ const tick = () => {
|
||||
window.fetch(fetchEndpoint.value, {})
|
||||
.then(res => res.json())
|
||||
.then((feed: Misskey.entities.FetchRssResponse) => {
|
||||
rawItems.value = feed.items.filter((item) => {
|
||||
if (!item.link) return false;
|
||||
const itemUrl = new URL(item.link, base);
|
||||
return ['http:', 'https:'].includes(itemUrl.protocol);
|
||||
});
|
||||
rawItems.value = feed.items;
|
||||
fetching.value = false;
|
||||
});
|
||||
};
|
||||
|
||||
@@ -121,11 +121,7 @@ const tick = () => {
|
||||
window.fetch(fetchEndpoint.value, {})
|
||||
.then(res => res.json())
|
||||
.then((feed: Misskey.entities.FetchRssResponse) => {
|
||||
rawItems.value = feed.items.filter((item) => {
|
||||
if (!item.link) return false;
|
||||
const itemUrl = new URL(item.link, base);
|
||||
return ['http:', 'https:'].includes(itemUrl.protocol);
|
||||
});
|
||||
rawItems.value = feed.items;
|
||||
fetching.value = false;
|
||||
key.value++;
|
||||
});
|
||||
|
||||
@@ -38,6 +38,6 @@
|
||||
"tsx": "4.23.0"
|
||||
},
|
||||
"dependencies": {
|
||||
"js-yaml": "5.2.2"
|
||||
"js-yaml": "5.2.1"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5260,6 +5260,10 @@ export interface Locale extends ILocale {
|
||||
* 変更あり
|
||||
*/
|
||||
"modified": string;
|
||||
/**
|
||||
* 変更日時
|
||||
*/
|
||||
"modifiedAt": string;
|
||||
/**
|
||||
* 破棄
|
||||
*/
|
||||
@@ -5699,6 +5703,14 @@ export interface Locale extends ILocale {
|
||||
* スペースで区切るとAND指定になり、改行で区切るとOR指定になります。スラッシュで囲むと正規表現になります。一致した場合、サムネイルが表示されなくなります。
|
||||
*/
|
||||
"urlPreviewSensitiveListDescription": string;
|
||||
/**
|
||||
* 最終バックアップ
|
||||
*/
|
||||
"latestBackupAt": string;
|
||||
/**
|
||||
* 最終同期
|
||||
*/
|
||||
"latestSyncAt": string;
|
||||
"_imageEditing": {
|
||||
"_vars": {
|
||||
/**
|
||||
@@ -6303,10 +6315,18 @@ export interface Locale extends ILocale {
|
||||
"useSyncBetweenDevicesOptionIfYouWantToSyncSetting": string;
|
||||
};
|
||||
"_preferencesBackup": {
|
||||
/**
|
||||
* バックアップと同期
|
||||
*/
|
||||
"backupAndSync": string;
|
||||
/**
|
||||
* 自動バックアップ
|
||||
*/
|
||||
"autoBackup": string;
|
||||
/**
|
||||
* 設定を自動でサーバーに保存し、いつでも復元できるようにします
|
||||
*/
|
||||
"autoBackup_description": string;
|
||||
/**
|
||||
* バックアップから復元
|
||||
*/
|
||||
@@ -6336,9 +6356,29 @@ export interface Locale extends ILocale {
|
||||
*/
|
||||
"backupFound": string;
|
||||
/**
|
||||
* 設定の強制バックアップ
|
||||
* 今すぐバックアップ
|
||||
*/
|
||||
"forceBackup": string;
|
||||
/**
|
||||
* デバイス間同期
|
||||
*/
|
||||
"autoSync": string;
|
||||
/**
|
||||
* サーバーに保存された設定を自動で取得し、別のデバイスでの変更と同期できるようにします
|
||||
*/
|
||||
"autoSync_description": string;
|
||||
/**
|
||||
* 今すぐ同期
|
||||
*/
|
||||
"forceSync": string;
|
||||
/**
|
||||
* デバイス間の同期をオンにしますか?
|
||||
*/
|
||||
"autoSyncAreYouSure": string;
|
||||
/**
|
||||
* 通信量が増えるため、他のデバイスとこのプロファイルを共有する予定がない場合はオンにしないでください。
|
||||
*/
|
||||
"autoSyncAreYouSure_description": string;
|
||||
};
|
||||
"_accountSettings": {
|
||||
/**
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"type": "module",
|
||||
"name": "misskey-js",
|
||||
"version": "2026.7.0-rc.0",
|
||||
"version": "2026.7.0-beta.6",
|
||||
"description": "Misskey SDK for JavaScript",
|
||||
"license": "MIT",
|
||||
"main": "./built/index.js",
|
||||
|
||||
@@ -21879,15 +21879,6 @@ export interface operations {
|
||||
'application/json': components['schemas']['Error'];
|
||||
};
|
||||
};
|
||||
/** @description Too many requests */
|
||||
429: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
'application/json': components['schemas']['Error'];
|
||||
};
|
||||
};
|
||||
/** @description Internal server error */
|
||||
500: {
|
||||
headers: {
|
||||
|
||||
Generated
+214
-9
@@ -24,8 +24,8 @@ importers:
|
||||
specifier: 9.0.0
|
||||
version: 9.0.0
|
||||
js-yaml:
|
||||
specifier: 5.2.2
|
||||
version: 5.2.2
|
||||
specifier: 5.2.1
|
||||
version: 5.2.1
|
||||
tar:
|
||||
specifier: 7.5.21
|
||||
version: 7.5.21
|
||||
@@ -188,6 +188,9 @@ importers:
|
||||
'@fastify/multipart':
|
||||
specifier: 10.1.0
|
||||
version: 10.1.0
|
||||
'@fastify/otel':
|
||||
specifier: 0.20.1
|
||||
version: 0.20.1(@opentelemetry/api@1.9.1)
|
||||
'@fastify/static':
|
||||
specifier: 10.1.2
|
||||
version: 10.1.2
|
||||
@@ -218,6 +221,33 @@ importers:
|
||||
'@nestjs/testing':
|
||||
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)
|
||||
'@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':
|
||||
specifier: 0.139.0
|
||||
version: 0.139.0
|
||||
@@ -562,8 +592,8 @@ importers:
|
||||
specifier: 10.0.3
|
||||
version: 10.0.3
|
||||
js-yaml:
|
||||
specifier: 5.2.2
|
||||
version: 5.2.2
|
||||
specifier: 5.2.1
|
||||
version: 5.2.1
|
||||
pid-port:
|
||||
specifier: 2.1.1
|
||||
version: 2.1.1
|
||||
@@ -1200,8 +1230,8 @@ importers:
|
||||
packages/i18n:
|
||||
dependencies:
|
||||
js-yaml:
|
||||
specifier: 5.2.2
|
||||
version: 5.2.2
|
||||
specifier: 5.2.1
|
||||
version: 5.2.1
|
||||
devDependencies:
|
||||
'@types/node':
|
||||
specifier: 26.1.1
|
||||
@@ -1911,6 +1941,11 @@ packages:
|
||||
'@fastify/multipart@10.1.0':
|
||||
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':
|
||||
resolution: {integrity: sha512-INS+6gh91cLUjB+PVHfu1UqcB76Sqtpyp7bnL+FYojhjygvOPA9ctiD/JDKsyD9Xgu4hUhCSJBPig/w7duNajw==}
|
||||
|
||||
@@ -2614,6 +2649,10 @@ packages:
|
||||
'@open-draft/until@2.1.0':
|
||||
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':
|
||||
resolution: {integrity: sha512-CmVa4ImJ+ynfrPMNaAXHET6Bhb44SwzmfyVJFq9ni2jgXJR/l7C6gfVFddNmHP+ZOkP9cf4f9DBe68qVLTHc9w==}
|
||||
engines: {node: '>=8.0.0'}
|
||||
@@ -2622,30 +2661,84 @@ packages:
|
||||
resolution: {integrity: sha512-gLyJlPHPZYdAk1JENA9LeHejZe1Ti77/pTeFm/nMXmQH/HFZlcS/O2XJB+L8fkbrNSqhdtlvjBVjxwUYanNH5Q==}
|
||||
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':
|
||||
resolution: {integrity: sha512-m2nckMT80NnmjTYSPjJQObBJ+8dgkoajEOUbznL8AHZ3T3yHRk2P7gI1PhEBc1+lOnrYE9UWrWHqJDsmqjmNbw==}
|
||||
engines: {node: ^18.19.0 || >=20.6.0}
|
||||
peerDependencies:
|
||||
'@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':
|
||||
resolution: {integrity: sha512-xQx3E2WxP1mDvKzxLxX+CTCtNLa560YJZ3087qYHerl2YmiKpv7AH+dAy7vmx+eVrZ5BwhfWUAVoKOoxCNHcpw==}
|
||||
engines: {node: ^18.19.0 || >=20.6.0}
|
||||
peerDependencies:
|
||||
'@opentelemetry/api': ^1.3.0
|
||||
|
||||
'@opentelemetry/otlp-exporter-base@0.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':
|
||||
resolution: {integrity: sha512-jyA5MBLQ+Dkl3+JsZkUoUvL7yHvU64kLsvpXKarWm6347Sl1t1bXFTFykUePNpT5WH5pm9a2Qtt03iIYQhZ1Fg==}
|
||||
engines: {node: ^18.19.0 || >=20.6.0}
|
||||
peerDependencies:
|
||||
'@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':
|
||||
resolution: {integrity: sha512-cp9zmTl62R8PJrpvFcmc8N2JQU/xfa0S+61q511Nji+QxCfZ8Ifvg7H27G8cANe4crg4RTrWsVvanHiXjSp6ag==}
|
||||
engines: {node: ^18.19.0 || >=20.6.0}
|
||||
peerDependencies:
|
||||
'@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':
|
||||
resolution: {integrity: sha512-sGA19HvtrrSKYsseHphluH6j3p6Xa3fqc7c7y8f/7mYWejc1lyDFcpSdD1kYa50HCLUeEo4zA5bW0pniaPszuw==}
|
||||
engines: {node: ^18.19.0 || >=20.6.0}
|
||||
@@ -2656,6 +2749,12 @@ packages:
|
||||
resolution: {integrity: sha512-eSYWTm620tTk45EKSedaUL8MFYI8hW164hIXsgIHyxu3VobUB3fFCu5t0hQby6OoWRPsG1KkKUG2M5UadiLiVg==}
|
||||
engines: {node: '>=14'}
|
||||
|
||||
'@opentelemetry/sql-common@0.42.0':
|
||||
resolution: {integrity: sha512-nwUwUU+8O8a4bnLqk6CodWeegGMEANgC94KTAhXcpGWLrW/2/hek/0ajNbjXnSOoNuCX+nteUPs46HFHhou9Xw==}
|
||||
engines: {node: ^18.19.0 || >=20.6.0}
|
||||
peerDependencies:
|
||||
'@opentelemetry/api': ^1.1.0
|
||||
|
||||
'@oxc-parser/binding-android-arm-eabi@0.127.0':
|
||||
resolution: {integrity: sha512-0LC7ye4hvqbIKxAzThzvswgHLFu2AURKzYLeSVvLdu2TBOYWQDmHnTqPLeA597BcUCxiLqLsS4CJ5uoI5WYWCQ==}
|
||||
engines: {node: ^20.19.0 || >=22.12.0}
|
||||
@@ -3937,6 +4036,12 @@ packages:
|
||||
'@types/offscreencanvas@2019.7.3':
|
||||
resolution: {integrity: sha512-ieXiYmgSRXUDeOntE1InxjWyvEelZGP63M+cGuquuRLuIKKT1osnkXjxev9B7d1nXSug5vpunx+gNlbVxMlC9A==}
|
||||
|
||||
'@types/pg-pool@2.0.7':
|
||||
resolution: {integrity: sha512-U4CwmGVQcbEuqpyju8/ptOKg6gEC+Tqsvj2xS9o1g71bUh8twxnC6ZL5rZKCsGN0iyH0CwgUyc9VR5owNQF9Ng==}
|
||||
|
||||
'@types/pg@8.15.6':
|
||||
resolution: {integrity: sha512-NoaMtzhxOrubeL/7UZuNTrejB4MPAJ0RpxZqXQf2qXuVlTPuG6Y8p4u9dKRaue4yjmC7ZhzVO2/Yyyn25znrPQ==}
|
||||
|
||||
'@types/pg@8.20.0':
|
||||
resolution: {integrity: sha512-bEPFOaMAHTEP1EzpvHTbmwR8UsFyHSKsRisLIHVMXnpNefSbGA1bD6CVy+qKjGSqmZqNqBDV2azOBo8TgkcVow==}
|
||||
|
||||
@@ -6395,8 +6500,8 @@ packages:
|
||||
resolution: {integrity: sha512-1td788aAnnZ5qs7V2QIRl1owjtYpbKt749Y3xauqQgwIIGF/xXWz1wMTEBx5O3LK3lXLVuqXPdPxj2BoFHaW9Q==}
|
||||
hasBin: true
|
||||
|
||||
js-yaml@5.2.2:
|
||||
resolution: {integrity: sha512-dayzUzKkJ1MkuUtZglSebU43utNXH0OWQByK9rKOOuYIO8M5TV1y+n8ALMdG0rdzBnfNkOmZEqrURepb0ejqBw==}
|
||||
js-yaml@5.2.1:
|
||||
resolution: {integrity: sha512-zfLtNfQqxVqq3uaTqSkh4x4hZw3KHobGUA0fJUj4wawW8bsQLTVqpHdXSIzidh7o+4lEW36tANuAGdaFx6Zgnw==}
|
||||
hasBin: true
|
||||
|
||||
jsbn@0.1.1:
|
||||
@@ -9950,6 +10055,16 @@ snapshots:
|
||||
fastify-plugin: 6.0.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':
|
||||
dependencies:
|
||||
'@fastify/forwarded': 3.0.1
|
||||
@@ -10558,17 +10673,55 @@ snapshots:
|
||||
|
||||
'@open-draft/until@2.1.0': {}
|
||||
|
||||
'@opentelemetry/api-logs@0.219.0':
|
||||
dependencies:
|
||||
'@opentelemetry/api': 1.9.1
|
||||
|
||||
'@opentelemetry/api-logs@0.220.0':
|
||||
dependencies:
|
||||
'@opentelemetry/api': 1.9.1
|
||||
|
||||
'@opentelemetry/api@1.9.1': {}
|
||||
|
||||
'@opentelemetry/context-async-hooks@2.9.0(@opentelemetry/api@1.9.1)':
|
||||
dependencies:
|
||||
'@opentelemetry/api': 1.9.1
|
||||
|
||||
'@opentelemetry/core@2.9.0(@opentelemetry/api@1.9.1)':
|
||||
dependencies:
|
||||
'@opentelemetry/api': 1.9.1
|
||||
'@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)':
|
||||
dependencies:
|
||||
'@opentelemetry/api': 1.9.1
|
||||
@@ -10578,12 +10731,42 @@ snapshots:
|
||||
transitivePeerDependencies:
|
||||
- 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)':
|
||||
dependencies:
|
||||
'@opentelemetry/api': 1.9.1
|
||||
'@opentelemetry/core': 2.9.0(@opentelemetry/api@1.9.1)
|
||||
'@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)':
|
||||
dependencies:
|
||||
'@opentelemetry/api': 1.9.1
|
||||
@@ -10592,6 +10775,13 @@ snapshots:
|
||||
'@opentelemetry/sdk-trace': 2.9.0(@opentelemetry/api@1.9.1)
|
||||
'@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)':
|
||||
dependencies:
|
||||
'@opentelemetry/api': 1.9.1
|
||||
@@ -10601,6 +10791,11 @@ snapshots:
|
||||
|
||||
'@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':
|
||||
optional: true
|
||||
|
||||
@@ -11832,6 +12027,16 @@ snapshots:
|
||||
|
||||
'@types/offscreencanvas@2019.7.3': {}
|
||||
|
||||
'@types/pg-pool@2.0.7':
|
||||
dependencies:
|
||||
'@types/pg': 8.20.0
|
||||
|
||||
'@types/pg@8.15.6':
|
||||
dependencies:
|
||||
'@types/node': 26.1.1
|
||||
pg-protocol: 1.15.0
|
||||
pg-types: 2.2.0
|
||||
|
||||
'@types/pg@8.20.0':
|
||||
dependencies:
|
||||
'@types/node': 26.1.1
|
||||
@@ -14837,7 +15042,7 @@ snapshots:
|
||||
dependencies:
|
||||
argparse: 2.0.1
|
||||
|
||||
js-yaml@5.2.2:
|
||||
js-yaml@5.2.1:
|
||||
dependencies:
|
||||
argparse: 2.0.1
|
||||
|
||||
|
||||
+2
-2
@@ -54,14 +54,14 @@ minimumReleaseAgeExclude:
|
||||
- slacc-win32-x64-msvc
|
||||
- '@typescript/native-preview*'
|
||||
- vite # そのうち消す
|
||||
# Renovate security update: @opentelemetry/core@2.8.0
|
||||
- "@opentelemetry/core@2.8.0"
|
||||
# Renovate security update: typeorm@1.1.0
|
||||
- typeorm@1.1.0
|
||||
# Renovate security update: @fastify/static@10.1.2
|
||||
- "@fastify/static@10.1.2"
|
||||
# Renovate security update: tar@7.5.21
|
||||
- tar@7.5.21
|
||||
# Renovate security update: js-yaml@5.2.2
|
||||
- js-yaml@5.2.2
|
||||
overrides:
|
||||
'@aiscript-dev/aiscript-languageserver': '-'
|
||||
'bullmq>ioredis': 5.11.1
|
||||
|
||||
Reference in New Issue
Block a user