diff --git a/CHANGELOG.md b/CHANGELOG.md index 8492476c66..3b306d7156 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -37,6 +37,7 @@ - Fix: 非ログイン時トップページをスクロール操作できないことがある問題を修正 ### Server +- Enhance: OpenTelemetryで全受信HTTPリクエストを自動計装 - Enhance: センシティブメディアの判定を外部サービス ([sensitive-detector](https://github.com/misskey-dev/sensitive-detector)) に分離し、`nsfwjs` / `@tensorflow/tfjs(-node)` の同梱と NSFW 判定モデルを廃止 (#16804) - Enhance: バックエンドの `otelForBackend` 設定で OpenTelemetry Traces を OTLP Collector に送信できるように - Enhance: OpenTelemetry を単独で利用する際、外向き HTTP リクエストを自動計装するように diff --git a/packages/backend/package.json b/packages/backend/package.json index 22e7574331..3b3e9a0c22 100644 --- a/packages/backend/package.json +++ b/packages/backend/package.json @@ -57,6 +57,7 @@ "@fastify/cors": "11.2.0", "@fastify/http-proxy": "11.5.0", "@fastify/multipart": "10.0.0", + "@fastify/otel": "0.19.0", "@fastify/static": "9.1.3", "@kitajs/html": "4.2.13", "@misskey-dev/emoji-assets": "17.0.3", diff --git a/packages/backend/src/server/ServerService.ts b/packages/backend/src/server/ServerService.ts index 1333c1a88b..45ce830b29 100644 --- a/packages/backend/src/server/ServerService.ts +++ b/packages/backend/src/server/ServerService.ts @@ -32,6 +32,7 @@ import { HealthServerService } from './HealthServerService.js'; import { ClientServerService } from './web/ClientServerService.js'; import { OpenApiServerService } from './api/openapi/OpenApiServerService.js'; import { OAuth2ProviderService } from './oauth/OAuth2ProviderService.js'; +import { registerHttpServerInstrumentation } from './http-server-instrumentation.js'; const _dirname = fileURLToPath(new URL('.', import.meta.url)); @@ -80,6 +81,7 @@ export class ServerService implements OnApplicationShutdown { logger: false, }); this.#fastify = fastify; + await registerHttpServerInstrumentation(fastify, this.config); // HSTS // 6months (15552000sec) diff --git a/packages/backend/src/server/api/ApiCallService.ts b/packages/backend/src/server/api/ApiCallService.ts index dc3a88ca27..65b4abee05 100644 --- a/packages/backend/src/server/api/ApiCallService.ts +++ b/packages/backend/src/server/api/ApiCallService.ts @@ -151,7 +151,7 @@ export class ApiCallService implements OnApplicationShutdown { endpoint: IEndpoint & { exec: any }, request: FastifyRequest<{ Body: Record | undefined, Querystring: Record }>, reply: FastifyReply, - ): void { + ): Promise { const body = request.method === 'GET' ? request.query : request.body; @@ -162,10 +162,11 @@ export class ApiCallService implements OnApplicationShutdown { : body?.['i']; if (token != null && typeof token !== 'string') { reply.code(400); - return; + return Promise.resolve(); } - this.authenticateService.authenticate(token).then(([user, app]) => { - this.call(endpoint, user, app, body, null, request).then((res) => { + + return this.telemetryService.startSpan('API: ' + endpoint.name, () => this.authenticateService.authenticate(token).then(([user, app]) => { + const call = this.call(endpoint, user, app, body, null, request).then((res) => { if (request.method === 'GET' && endpoint.meta.cacheSec && !token && !user) { reply.header('Cache-Control', `public, max-age=${endpoint.meta.cacheSec}`); } @@ -177,9 +178,11 @@ export class ApiCallService implements OnApplicationShutdown { if (user) { this.logIp(request, user); } + + return call; }).catch(err => { this.#sendAuthenticationError(reply, err); - }); + })); } @bindThis @@ -222,8 +225,9 @@ export class ApiCallService implements OnApplicationShutdown { reply.code(400); return; } - this.authenticateService.authenticate(token).then(([user, app]) => { - this.call(endpoint, user, app, fields, { + + return await this.telemetryService.startSpan('API: ' + endpoint.name, () => this.authenticateService.authenticate(token).then(([user, app]) => { + const call = this.call(endpoint, user, app, fields, { name: multipartData.filename, path: path, }, request).then((res) => { @@ -235,9 +239,11 @@ export class ApiCallService implements OnApplicationShutdown { if (user) { this.logIp(request, user); } + + return call; }).catch(err => { this.#sendAuthenticationError(reply, err); - }); + })); } @bindThis @@ -431,9 +437,10 @@ export class ApiCallService implements OnApplicationShutdown { } } - // API invoking - return await this.telemetryService.startSpan('API: ' + ep.name, () => ep.exec(data, user, token, file, request.ip, request.headers) - .catch((err: Error) => this.#onExecError(ep, data, err, user?.id))); + // The API span starts in handleRequest/handleMultipartRequest so it also covers + // authentication, rate limiting, and parameter validation. + return await ep.exec(data, user, token, file, request.ip, request.headers) + .catch((err: Error) => this.#onExecError(ep, data, err, user?.id)); } @bindThis diff --git a/packages/backend/src/server/http-server-instrumentation.ts b/packages/backend/src/server/http-server-instrumentation.ts new file mode 100644 index 0000000000..c17f3c1bc0 --- /dev/null +++ b/packages/backend/src/server/http-server-instrumentation.ts @@ -0,0 +1,35 @@ +/* + * SPDX-FileCopyrightText: syuilo and misskey-project + * SPDX-License-Identifier: AGPL-3.0-only + */ + +import type { Config } from '@/config.js'; +import type { FastifyInstance } from 'fastify'; + +type TelemetryConfig = Pick; + +export function shouldRegisterHttpServerInstrumentation(config: TelemetryConfig): boolean { + // Sentryもリクエストspanを作成するため、両方を登録すると重複して出力される。 + return config.otelForBackend != null && config.sentryForBackend == null; +} + +/** + * すべてのルート・フックより前にリクエスト計装を登録し、ActivityPubや + * well-knownを含む全HTTP受信経路を1つのroot spanとして計測する。 + */ +export async function registerHttpServerInstrumentation(fastify: FastifyInstance, config: TelemetryConfig): Promise { + if (!shouldRegisterHttpServerInstrumentation(config)) return; + + const { FastifyOtelInstrumentation } = await import('@fastify/otel'); + const instrumentation = new FastifyOtelInstrumentation({ + requestHook: (span, request) => { + const route = request.routeOptions.url; + if (route != null) { + // デフォルトだとトレース名が「request」で固定されてしまうため、判別がつかなくなる。 + // ルート名をspan名に設定することで、トレースビューでルートごとの処理時間を確認できるようになる。 + span.updateName(`${request.method} ${route}`); + } + }, + }); + await fastify.register(instrumentation.plugin()); +} diff --git a/packages/backend/test/unit/server/http-server-instrumentation.ts b/packages/backend/test/unit/server/http-server-instrumentation.ts new file mode 100644 index 0000000000..3e4baae14a --- /dev/null +++ b/packages/backend/test/unit/server/http-server-instrumentation.ts @@ -0,0 +1,53 @@ +/* + * SPDX-FileCopyrightText: syuilo and misskey-project + * SPDX-License-Identifier: AGPL-3.0-only + */ + +import { describe, expect, test, vi } from 'vitest'; +import { shouldRegisterHttpServerInstrumentation, registerHttpServerInstrumentation } from '@/server/http-server-instrumentation.js'; + +const mocks = vi.hoisted(() => ({ + plugin: vi.fn(), + instrumentation: vi.fn(), +})); + +vi.mock('@fastify/otel', () => ({ + FastifyOtelInstrumentation: class { + public plugin = mocks.plugin; + + public constructor(options: unknown) { + mocks.instrumentation(options); + } + }, +})); + +describe('http-server-instrumentation', () => { + test('registers Fastify instrumentation when only OpenTelemetry is configured', async () => { + const plugin = vi.fn(); + const fastify = { register: vi.fn().mockResolvedValue(undefined) }; + mocks.plugin.mockReturnValue(plugin); + + await registerHttpServerInstrumentation(fastify as any, { otelForBackend: {} } as any); + + expect(mocks.instrumentation).toHaveBeenCalledTimes(1); + expect(fastify.register).toHaveBeenCalledWith(plugin); + + const requestHook = mocks.instrumentation.mock.calls[0][0].requestHook; + const span = { updateName: vi.fn() }; + requestHook(span, { method: 'POST', routeOptions: { url: '/notes/create' } }); + expect(span.updateName).toHaveBeenCalledWith('POST /notes/create'); + }); + + test('does not register duplicate request instrumentation with Sentry', async () => { + const fastify = { register: vi.fn() }; + + await registerHttpServerInstrumentation(fastify as any, { otelForBackend: {}, sentryForBackend: {} } as any); + + expect(fastify.register).not.toHaveBeenCalled(); + expect(shouldRegisterHttpServerInstrumentation({ otelForBackend: {}, sentryForBackend: {} } as any)).toBe(false); + }); + + test('does not register instrumentation without OpenTelemetry', () => { + expect(shouldRegisterHttpServerInstrumentation({} as any)).toBe(false); + }); +}); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 554448f89d..6293db07fd 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -89,6 +89,9 @@ importers: '@fastify/multipart': specifier: 10.0.0 version: 10.0.0 + '@fastify/otel': + specifier: 0.19.0 + version: 0.19.0(@opentelemetry/api@1.9.1) '@fastify/static': specifier: 9.1.3 version: 9.1.3 @@ -1855,6 +1858,11 @@ packages: '@fastify/multipart@10.0.0': resolution: {integrity: sha512-pUx3Z1QStY7E7kwvDTIvB6P+rF5lzP+iqPgZyJyG3yBJVPvQaZxzDHYbQD89rbY0ciXrMOyGi8ezHDVexLvJDA==} + '@fastify/otel@0.19.0': + resolution: {integrity: sha512-gf2S5IhN72Jqi/09wOmWAsKscG/AwXSq9rrESnTVBIkMKl8Y+7FqA7bzf9OEa5/H8oxugc7belJOTURb5tZXuQ==} + peerDependencies: + '@opentelemetry/api': ^1.9.0 + '@fastify/proxy-addr@5.1.0': resolution: {integrity: sha512-INS+6gh91cLUjB+PVHfu1UqcB76Sqtpyp7bnL+FYojhjygvOPA9ctiD/JDKsyD9Xgu4hUhCSJBPig/w7duNajw==} @@ -2562,6 +2570,10 @@ packages: resolution: {integrity: sha512-40lSJeqYO8Uz2Yj7u94/SJWE/wONa7rmMKjI1ZcIjgf3MHNHv1OZUCrCETGuaRF62d5pQD1wKIW+L4lmSMTzZA==} engines: {node: '>=8.0.0'} + '@opentelemetry/api-logs@0.218.0': + resolution: {integrity: sha512-fmEWp5kXlGEc3i/lR698Hz41DfGyN4Tbe4g7L1AxSc7fF8Xeh/FQ9Quqpa9dVA413Q1Ad43QOLzU4JoXgbFPWw==} + engines: {node: '>=8.0.0'} + '@opentelemetry/api@1.9.1': resolution: {integrity: sha512-gLyJlPHPZYdAk1JENA9LeHejZe1Ti77/pTeFm/nMXmQH/HFZlcS/O2XJB+L8fkbrNSqhdtlvjBVjxwUYanNH5Q==} engines: {node: '>=8.0.0'} @@ -2602,6 +2614,12 @@ packages: peerDependencies: '@opentelemetry/api': ^1.3.0 + '@opentelemetry/instrumentation@0.218.0': + resolution: {integrity: sha512-mIZil8Es+sYDK5m+DQiwAwF57F14TF2YlEqvIjZ/RQWcxDBwRGsKfdK2Tv65OU9meQKCMzSIFS9mxAcnAb6Bkg==} + engines: {node: ^18.19.0 || >=20.6.0} + peerDependencies: + '@opentelemetry/api': ^1.3.0 + '@opentelemetry/otlp-exporter-base@0.214.0': resolution: {integrity: sha512-u1Gdv0/E9wP+apqWf7Wv2npXmgJtxsW2XL0TEv9FZloTZRuMBKmu8cYVXwS4Hm3q/f/3FuCnPTgiwYvIqRSpRg==} engines: {node: ^18.19.0 || >=20.6.0} @@ -9971,6 +9989,16 @@ snapshots: fastify-plugin: 5.1.0 secure-json-parse: 4.1.0 + '@fastify/otel@0.19.0(@opentelemetry/api@1.9.1)': + dependencies: + '@opentelemetry/api': 1.9.1 + '@opentelemetry/core': 2.7.1(@opentelemetry/api@1.9.1) + '@opentelemetry/instrumentation': 0.218.0(@opentelemetry/api@1.9.1) + '@opentelemetry/semantic-conventions': 1.40.0 + minimatch: 10.2.5 + transitivePeerDependencies: + - supports-color + '@fastify/proxy-addr@5.1.0': dependencies: '@fastify/forwarded': 3.0.1 @@ -10580,6 +10608,10 @@ snapshots: dependencies: '@opentelemetry/api': 1.9.1 + '@opentelemetry/api-logs@0.218.0': + dependencies: + '@opentelemetry/api': 1.9.1 + '@opentelemetry/api@1.9.1': {} '@opentelemetry/context-async-hooks@2.7.1(@opentelemetry/api@1.9.1)': @@ -10619,6 +10651,15 @@ snapshots: transitivePeerDependencies: - supports-color + '@opentelemetry/instrumentation@0.218.0(@opentelemetry/api@1.9.1)': + dependencies: + '@opentelemetry/api': 1.9.1 + '@opentelemetry/api-logs': 0.218.0 + import-in-the-middle: 3.0.1 + require-in-the-middle: 8.0.1 + transitivePeerDependencies: + - supports-color + '@opentelemetry/otlp-exporter-base@0.214.0(@opentelemetry/api@1.9.1)': dependencies: '@opentelemetry/api': 1.9.1