Browse Source

Merge pull request #11199 from growilabs/refactor/otel-attributes-cleanup

imprv(otel): Cleanup Resource Attributes and add system/process memory metrics
mergify[bot] 21 hours ago
parent
commit
459921005e

+ 90 - 0
.kiro/specs/opentelemetry/brief.md

@@ -0,0 +1,90 @@
+# Brief: opentelemetry
+
+## Problem
+GROWI は監視・可観測性のために OpenTelemetry を採用しており、`apps/app/src/features/opentelemetry/` 配下に NodeSDK 初期化・Resource Attribute・Custom Metrics・Anonymization の各レイヤを実装している。本 spec は `features/opentelemetry/` の **大局的なメンテナンスリファレンス** として、将来の追加・変更(メトリクス追加、新規 anonymization handler、SDK バージョンアップ)が踏むべき境界線と設計意図を提供する。
+
+## Current State
+- ランタイム: Node.js `^24`(cgroup 系 API・V8 統計が利用可能)。
+- 依存パッケージ:
+  - `@opentelemetry/api ^1.9.0`
+  - `@opentelemetry/sdk-node ^0.217.0`
+  - `@opentelemetry/auto-instrumentations-node ^0.75.0`
+  - `@opentelemetry/exporter-trace-otlp-grpc`, `@opentelemetry/exporter-metrics-otlp-grpc ^0.202.0`
+  - `@opentelemetry/sdk-metrics ^2.0.1`, `@opentelemetry/resources ^2.0.1`, `@opentelemetry/sdk-trace-node ^2.0.1`
+  - `@opentelemetry/semantic-conventions ^1.34.0`
+- 全コードは server-only。クライアント側からの import は無い。
+- ディレクトリ構成 (`apps/app/src/features/opentelemetry/server/`):
+  - `node-sdk.ts` — SDK ライフサイクル管理(`initInstrumentation` / `setupAdditionalResourceAttributes` / `startOpenTelemetry`)。
+  - `node-sdk-configuration.ts` — `NodeSDKConfiguration` 構築と Resource 構築(2 段階初期化)。
+  - `node-sdk-resource.ts` — `NodeSDK._resource` への低レベルアクセサ(リフレクション)。
+  - `logger.ts` — `DiagLogger` を pino logger にアダプトする実装。
+  - `semconv.ts` — incubating semantic conventions のコピー(`service.instance.id`, `http.target`)。
+  - `custom-resource-attributes/` — `os-resource-attributes` / `application-resource-attributes`。identity 専用。
+  - `custom-metrics/` — `application-metrics` / `user-counts-metrics` / `page-counts-metrics` / `system-metrics` + `setupCustomMetrics()` 合成。
+  - `anonymization/` — `httpInstrumentationConfig` と 4 個の handler(search / page-listing / page / page-access)。
+- 設定キー(`config-definition.ts`):
+  - `otel:enabled` (`OPENTELEMETRY_ENABLED`, default `true`)
+  - `otel:isAppSiteUrlHashed` (`OPENTELEMETRY_IS_APP_SITE_URL_HASHED`, default `false`)
+  - `otel:anonymizeInBestEffort` (`OPENTELEMETRY_ANONYMIZE_IN_BEST_EFFORT`, default `false`)
+  - `otel:serviceInstanceId` (`OPENTELEMETRY_SERVICE_INSTANCE_ID`, default `undefined`)
+- Exporter: OTLP gRPC(trace / metric とも)。Endpoint は OTel 標準環境変数(`OTEL_EXPORTER_OTLP_ENDPOINT` 等)で制御。
+- Metric 出力間隔: `PeriodicExportingMetricReader` の `exportIntervalMillis: 300000`(5 分)。
+
+## Desired Outcome
+- `features/opentelemetry/` のすべての公開モジュールが本 spec の Boundary Commitments / Out of Boundary で明示的に分類されており、新規メトリクス追加・新規 anonymization handler 追加・SDK バージョンアップが「どこを触ればよいか / どこを触ってはいけないか」を本 spec 1 か所で参照できる。
+- Resource Attribute は identity 専用、設定値は `growi.configs` info-gauge ラベルへ、観測値は `growi.*` または `system.*` / `process.*` メトリクスへ、というレイヤ責務が明文化されている。
+- 旧来の `apps/app/src/features/opentelemetry/docs/` 配下の散在ドキュメントは破棄され、本 spec が単一の真実ソースになる。
+
+## Approach
+**新規実装ではなくドキュメント統合。** 既に動作している `features/opentelemetry/` の構造を本 spec に固定化する。
+1. SDK ライフサイクル・Resource 2 段階初期化・Custom Metrics 合成・Anonymization の 4 レイヤを Boundary Commitments で分割。
+2. Configuration(env var / config key)と Metric Schema を表形式で明示。
+3. Resource Attribute は identity 専用、設定値は `growi.configs` info-gauge ラベル、観測値(メモリ・ヒープ等)は `system.*` / `process.*` メトリクスへ、というレイヤ責務を Design Decisions として固定する。
+
+## Scope
+- **In**:
+  - `features/opentelemetry/server/` 配下のすべての公開モジュール(SDK / Resource / Metric / Anonymization / Logger / semconv)の責務と境界の明文化。
+  - 設定キー一覧と Resource Attribute / Metric Schema の確定スナップショット。
+  - 既存 Anonymization Handler の登録手順(`handlers/index.ts` への module 追加 + `canHandle` / `handle` インターフェース実装)。
+- **Out**:
+  - 既存メトリクスの名称変更や再構成。
+  - Trace span attribute の追加(`http.target` 以外)。
+  - GROWI 本体の logger pipeline と OpenTelemetry log signal の統合。
+  - フロントエンド(ブラウザ)からの telemetry 出力。
+  - サードパーティ製パッケージ(`@opentelemetry/host-metrics` 等)への置き換え。
+
+## Boundary Candidates
+1. **SDK ライフサイクル**(`node-sdk.ts`, `node-sdk-configuration.ts`, `node-sdk-resource.ts`, `logger.ts`) — SDK 初期化・enable/disable 制御・Resource 2 段階注入。
+2. **Resource Attribute レイヤ**(`custom-resource-attributes/`) — identity 専用属性の供給。
+3. **Custom Metric レイヤ**(`custom-metrics/`) — `growi.*` / `system.*` / `process.*` メトリクスの emit と合成。
+4. **HTTP Anonymization レイヤ**(`anonymization/`) — `http.target` の匿名化と handler の選択ロジック。
+5. **SemConv ローカルコピー**(`semconv.ts`) — 不安定 semconv の固定化。
+
+これら 5 つはそれぞれ独立に拡張・置換可能で、相互の dependency は明確に下流方向に限定されている。
+
+## Out of Boundary
+- `~/server/service/growi-info`(`growiInfoService`) — 上流。本 spec は consumer。
+- `~/server/service/config-manager`(`configManager`) — 上流。本 spec は consumer。
+- `~/utils/growi-version` / `~/utils/logger` — utility。本 spec は consumer。
+- Anonymization で参照する `@growi/core/dist/utils/page-path-utils` の各 helper(`isPermalink`, `isUserPage`, `getUsernameByPath` 等) — `@growi/core` の責務。
+- Auto-instrumentation(HTTP / Express / Mongoose 等)のチューニング — 設定オブジェクトの構造は本 spec が定義するが、各 instrumentation の挙動は上流パッケージの責務。
+- OTLP Exporter の wire 仕様、Prometheus / Grafana / Collector 等の下流ツールチェイン。
+
+## Upstream / Downstream
+- **Upstream**:
+  - `~/server/service/config-manager` — `otel:*` config 4 種。
+  - `~/server/service/growi-info` — `growiInfoService.getGrowiInfo(opts)`。Metric / Resource 双方が consumer。
+  - `~/utils/growi-version` — `service.version` Resource Attribute の供給元。
+- **Downstream**:
+  - OpenTelemetry Collector(OTLP gRPC)。
+  - 受信側ダッシュボード(Prometheus / Grafana / Tempo / Loki 等)。otel-infra 管理者は本 spec の Metric Schema と Resource Attribute 表を参照する。
+
+## Existing Spec Touchpoints
+- **Adjacent**: なし。`growi-logger` spec はアプリケーションロガーの spec で、`logger.ts` の `DiagLogger` アダプタが pino を経由する点で接点があるが、両者の責務は独立。
+
+## Constraints
+- ランタイム要件: Node.js `^24`(cgroup memory API、V8 統計のため)。
+- 新規 npm dependency の追加は原則不可(既存 `@opentelemetry/*` パッケージで完結させる)。追加が必要な場合は `apps/app/.next/node_modules/` 残留有無を確認し `dependencies` 分類が必要かを判定する(参照: `.claude/rules/package-dependencies.md`)。
+- Semconv の不安定 attribute は `semconv.ts` にローカルコピーする(incubating entry-point は import しない)。詳細は [semconv.ts](../../apps/app/src/features/opentelemetry/server/semconv.ts) のコメント参照。
+- `setResource()` は `NodeSDK._resource` への private アクセスを行う(type cast 必須)。OpenTelemetry SDK が public な resource 上書き API を提供したら撤去する。
+- Anonymization の出力先 attribute は `http.target`(incubating)。OTLP semconv で対応 stable attribute が決定したら移行する。

+ 737 - 0
.kiro/specs/opentelemetry/design.md

@@ -0,0 +1,737 @@
+# Technical Design — opentelemetry
+
+## Overview
+
+**Purpose**: GROWI の OpenTelemetry 統合 (`apps/app/src/features/opentelemetry/`) を、SDK ライフサイクル / Resource Attribute / Custom Metric / HTTP Anonymization の 4 レイヤに分けて責務境界を明文化する大局的なメンテナンス spec。
+
+**Users**:
+- GROWI 開発者(メトリクス追加・anonymization handler 追加・SDK バージョンアップを行う)。
+- OpenTelemetry 受信側インフラ管理者(Prometheus / Grafana / Collector を運用する)。
+
+**Impact**: 既に稼働している実装の現状をスナップショットとして固定化する。新規実装はゼロで、コード変更は伴わない。将来の機能追加・変更は本 spec の Boundary Commitments に従って境界の中で行われる。
+
+### Goals
+
+- 4 レイヤそれぞれの責務・境界・依存関係を明文化する。
+- Resource Attribute は identity 専用、設定値は `growi.configs` ラベル、観測値は `growi.*` / `system.*` / `process.*` メトリクスへ、というレイヤ分離を維持する。
+- 新規 Custom Metric / Anonymization Handler の追加手順を「テンプレート化」して、追加時のレビュー差分を最小化する。
+- Resource Attribute / Metric / Span Attribute の責務分離(identity / 設定値 / 観測値 / span attribute)を Design Decisions として固定する。
+
+### Non-Goals
+
+- 既存メトリクス / Resource Attribute の名称変更・再構成。
+- OpenTelemetry Log Signal の利用開始。
+- ブラウザサイドからの telemetry 出力。
+- `@opentelemetry/host-metrics` 等への置き換え。
+- `service.instance.id` の自動生成(現状は `otel:serviceInstanceId` か `app:serviceInstanceId` の config 値を passthrough する)。
+
+## Boundary Commitments
+
+### This Spec Owns
+
+#### Layer 1: SDK ライフサイクル
+
+- `node-sdk.ts` の `initInstrumentation()` / `setupAdditionalResourceAttributes()` / `startOpenTelemetry()` の 3 関数。
+- `overwriteSdkDisabled()` による `OTEL_SDK_DISABLED` と `otel:enabled` の整合化。
+- `node-sdk-configuration.ts` の `generateNodeSDKConfiguration(opts)` および `generateAdditionalResourceAttributes(opts)`。
+- `node-sdk-resource.ts` の `getResource()` / `setResource()`(NodeSDK private `_resource` への reflective アクセサ)。
+- `logger.ts` の `DiagLoggerPinoAdapter` と `initLogger()`。
+- 同一プロセス内の SDK インスタンス二重生成防止(`sdkInstance` モジュール変数)。
+
+#### Layer 2: Resource Attribute(identity 専用)
+
+- `custom-resource-attributes/os-resource-attributes.ts` の `getOsResourceAttributes(): Attributes`。
+- `custom-resource-attributes/application-resource-attributes.ts` の `getApplicationResourceAttributes(): Promise<Attributes>`。
+- `custom-resource-attributes/index.ts` のバレル。
+- emit する identity 属性: `os.type`, `os.platform`, `os.arch`, `growi.service.type`, `growi.deployment.type`。
+- 2 段階初期化(DB 非依存の OS info → DB 初期化後の `service.instance.id` および application info)の責務。
+
+#### Layer 3: Custom Metric
+
+- `custom-metrics/index.ts` の barrel と `setupCustomMetrics(): Promise<void>`。
+- `custom-metrics/application-metrics.ts` の `addApplicationMetrics()`(`growi.configs` info gauge + 5 ラベル)。
+- `custom-metrics/user-counts-metrics.ts` の `addUserCountsMetrics()`(`growi.users.total` / `growi.users.active`)。
+- `custom-metrics/page-counts-metrics.ts` の `addPageCountsMetrics()`(`growi.pages.total`)。
+- `custom-metrics/system-metrics.ts` の `addSystemMetrics()`(`system.memory.limit` / `system.host.memory.total` / `process.memory.usage` / `process.runtime.v8.heap.{used,total,external}`)。
+- Meter 命名規約: `growi-<scope>-metrics`, version `'1.0.0'`。
+- Observation 実装規約: `ObservableGauge` + `addBatchObservableCallback` + try/catch + `diag.createComponentLogger` で例外吸収。
+
+#### Layer 4: HTTP Anonymization
+
+- `anonymization/index.ts` のバレル(`httpInstrumentationConfig` のエクスポート)。
+- `anonymization/anonymize-http-requests.ts` の `startIncomingSpanHook` 実装(module discovery loop)。
+- `anonymization/interfaces/anonymization-module.ts` の `AnonymizationModule` インターフェース。
+- `anonymization/handlers/index.ts` の `anonymizationModules` 配列(4 module の登録順)。
+- 4 つの handler 実装: `search-api-handler.ts`, `page-listing-api-handler.ts`, `page-api-handler.ts`, `page-access-handler.ts`。
+- `anonymization/utils/anonymize-query-params.ts` の `anonymizeQueryParams(target, paramNames)`。
+- `http.target` への匿名化 URL 出力。
+
+#### Layer 5: SemConv ローカルコピー
+
+- `semconv.ts` の `ATTR_SERVICE_INSTANCE_ID` / `ATTR_HTTP_TARGET` 定義。
+- incubating semconv の文字列定数化と上流 minor リリース変更からの隔離。
+
+### Out of Boundary
+
+- `~/server/service/growi-info`(`growiInfoService`) — consumer として利用するのみ。`getGrowiInfo(opts)` の API 仕様は本 spec では定義しない。
+- `~/server/service/config-manager` — `otel:*` config 4 種を読み取るのみ。config 定義の追加・改名は config-manager 側の責務。
+- `~/utils/growi-version` — `service.version` の供給元。
+- `~/utils/logger` — pino logger ファクトリ。
+- `@growi/core/dist/utils/page-path-utils` — anonymization で利用する path helper(`isPermalink` / `isUserPage` / `getUsernameByPath` 等)。
+- 各 `@opentelemetry/*` パッケージの内部実装。Auto-instrumentation の挙動チューニング(HTTP / Express / Mongoose 等の挙動は instrumentation の責務)。
+- Trace span への独自 attribute 付与(`http.target` 以外)。
+- OpenTelemetry Log Signal、ブラウザ telemetry。
+- OTLP wire 仕様および受信側ダッシュボード/アラート。
+
+### Allowed Dependencies
+
+- Node.js 標準モジュール: `node:os`, `node:v8`, `node:process`, `node:crypto`, `node:http`。
+- `@opentelemetry/api`: `metrics`, `diag`, `Attributes`, `Meter`, `ObservableGauge` 等の public API のみ。
+- `@opentelemetry/sdk-node`, `@opentelemetry/sdk-metrics`, `@opentelemetry/sdk-trace-node`, `@opentelemetry/resources`: SDK 初期化用。
+- `@opentelemetry/exporter-trace-otlp-grpc`, `@opentelemetry/exporter-metrics-otlp-grpc`: OTLP gRPC エクスポート。
+- `@opentelemetry/auto-instrumentations-node`: `getNodeAutoInstrumentations()` および `InstrumentationConfigMap` 型。
+- `@opentelemetry/semantic-conventions`: stable attribute のみ(`ATTR_SERVICE_NAME` / `ATTR_SERVICE_VERSION`)。incubating は import しない。
+- 上記以外の新規 npm dependency 追加は不可。追加する場合は `apps/app/.next/node_modules/` 残留有無の確認と `dependencies` 分類が必要(`.claude/rules/package-dependencies.md` 参照)。
+
+### Revalidation Triggers
+
+- `@opentelemetry/api` または `@opentelemetry/sdk-*` のメジャー更新 → Meter / ObservableGauge / NodeSDK API シグネチャの再確認、特に `node-sdk-resource.ts` の `_resource` private アクセスが public 化されていないか確認。
+- `growiInfoService.getGrowiInfo()` の API 変更(追加フラグ削除、返り値型変更)→ 該当する `application-metrics.ts` / `user-counts-metrics.ts` / `page-counts-metrics.ts` / `application-resource-attributes.ts` の参照を再確認。
+- `@opentelemetry/semantic-conventions` の `service.instance.id` / `http.target` の stable 化 → `semconv.ts` のローカル定数を撤去し、stable import に切り替える。
+- Node.js ランタイム要件のダウングレード(`engines.node` が `^24` 未満)→ `process.constrainedMemory()` / `v8.getHeapStatistics()` の互換性再確認。
+- 新規 anonymization 対象パス/パラメータ追加要望 → handler の登録順と canHandle 衝突の再評価。
+- 受信側ダッシュボード/クエリの参照更新が未完了の状態でメトリクス/ラベル変更を行う → ロールアウト順序の再調整。
+
+## Architecture
+
+### Existing Architecture Analysis
+
+`features/opentelemetry/server/` は以下の 5 レイヤを下流方向の単方向依存で構成する:
+
+1. **SDK ライフサイクル** — `node-sdk.ts` が `node-sdk-configuration.ts` / `node-sdk-resource.ts` / `logger.ts` および `custom-metrics/index.ts` を統括する。
+2. **Resource Attribute** — `custom-resource-attributes/` を `node-sdk-configuration.ts` の 2 段階目(`generateAdditionalResourceAttributes`)が consumer として呼ぶ。
+3. **Custom Metric** — `custom-metrics/index.ts` の `setupCustomMetrics()` が起動時に 4 モジュールを順次登録。各モジュールは `growiInfoService` または Node.js stdlib を参照。
+4. **HTTP Anonymization** — `anonymization/index.ts` から export される `httpInstrumentationConfig` が `node-sdk-configuration.ts` の auto-instrumentation 構築時に注入される。
+5. **SemConv** — `semconv.ts` は Layer 1, 2, 4 から参照される葉ノード。
+
+各レイヤは独立に拡張可能で、横断的な相互依存(Custom Metric が Anonymization に依存する等)は存在しない。
+
+### Architecture Pattern & Boundary Map
+
+```mermaid
+graph TB
+    subgraph Layer1["Layer 1: SDK lifecycle"]
+        NodeSdk[node-sdk.ts]
+        NodeSdkCfg[node-sdk-configuration.ts]
+        NodeSdkRes[node-sdk-resource.ts]
+        DiagLogger[logger.ts]
+    end
+    subgraph Layer2["Layer 2: Resource Attribute identity-only"]
+        OsRA[custom-resource-attributes/os-resource-attributes.ts]
+        AppRA[custom-resource-attributes/application-resource-attributes.ts]
+    end
+    subgraph Layer3["Layer 3: Custom Metric"]
+        MetIndex[custom-metrics/index.ts setupCustomMetrics]
+        AppMet[application-metrics.ts]
+        UserMet[user-counts-metrics.ts]
+        PageMet[page-counts-metrics.ts]
+        SysMet[system-metrics.ts]
+    end
+    subgraph Layer4["Layer 4: HTTP Anonymization"]
+        AnonIdx[anonymization/index.ts]
+        AnonHook[anonymize-http-requests.ts]
+        AnonHandlers[handlers/index.ts]
+        SearchH[search-api-handler.ts]
+        PageH[page-api-handler.ts]
+        PageListH[page-listing-api-handler.ts]
+        PageAccH[page-access-handler.ts]
+        QPUtil[utils/anonymize-query-params.ts]
+    end
+    SemConv[semconv.ts]
+    GrowiInfo[~/server/service/growi-info]
+    ConfigMgr[~/server/service/config-manager]
+    Logger[~/utils/logger]
+    Version[~/utils/growi-version]
+
+    NodeSdk --> NodeSdkCfg
+    NodeSdk --> NodeSdkRes
+    NodeSdk --> DiagLogger
+    NodeSdk --> MetIndex
+    NodeSdkCfg --> OsRA
+    NodeSdkCfg --> AppRA
+    NodeSdkCfg --> AnonIdx
+    NodeSdkCfg --> Version
+    NodeSdkCfg --> SemConv
+    AppRA --> GrowiInfo
+    AppMet --> GrowiInfo
+    AppMet --> ConfigMgr
+    UserMet --> GrowiInfo
+    PageMet --> GrowiInfo
+    AnonIdx --> AnonHook
+    AnonHook --> AnonHandlers
+    AnonHandlers --> SearchH
+    AnonHandlers --> PageH
+    AnonHandlers --> PageListH
+    AnonHandlers --> PageAccH
+    SearchH --> QPUtil
+    PageH --> QPUtil
+    PageListH --> QPUtil
+    SearchH --> SemConv
+    PageH --> SemConv
+    PageListH --> SemConv
+    PageAccH --> SemConv
+```
+
+### Bootstrap Sequence
+
+```mermaid
+sequenceDiagram
+    participant App as GROWI app (server entrypoint)
+    participant Lifecycle as Layer 1: node-sdk
+    participant Config as node-sdk-configuration
+    participant SDK as @opentelemetry/sdk-node
+    participant Res as Layer 2: Resource Attr
+    participant Met as Layer 3: setupCustomMetrics
+
+    App->>Lifecycle: initInstrumentation()
+    Lifecycle->>Lifecycle: configManager.loadConfigs(env)
+    Lifecycle->>Lifecycle: overwriteSdkDisabled()
+    alt otel:enabled === true
+        Lifecycle->>Config: generateNodeSDKConfiguration({ enableAnonymization })
+        Config->>Config: build Resource(service.name, service.version)
+        Config->>Config: getNodeAutoInstrumentations(httpInstrumentationConfig?)
+        Config-->>Lifecycle: Configuration
+        Lifecycle->>SDK: new NodeSDK(config)
+    end
+    Note over App: ... DB initialization completes ...
+    App->>Lifecycle: setupAdditionalResourceAttributes()
+    Lifecycle->>Config: generateAdditionalResourceAttributes()
+    Config->>Res: getOsResourceAttributes()
+    Config->>Res: getApplicationResourceAttributes()
+    Config->>Config: resource.merge(service.instance.id + osAttrs + appAttrs)
+    Config-->>Lifecycle: updatedResource
+    Lifecycle->>SDK: setResource(sdkInstance, updatedResource)
+    App->>Lifecycle: startOpenTelemetry()
+    Lifecycle->>SDK: sdkInstance.start()
+    Lifecycle->>Met: setupCustomMetrics()
+    Met->>Met: addApplicationMetrics()
+    Met->>Met: addUserCountsMetrics()
+    Met->>Met: addPageCountsMetrics()
+    Met->>Met: addSystemMetrics()
+```
+
+### Technology Stack
+
+| Layer | Choice / Version | Role in Feature | Notes |
+|-------|------------------|-----------------|-------|
+| Runtime | Node.js `^24` | `process.constrainedMemory()` / `v8.getHeapStatistics()` 等 stdlib API | `apps/app/package.json` の `engines` ではなくリポジトリルートの `engines` で指定 |
+| Telemetry SDK | `@opentelemetry/api ^1.9.0`, `@opentelemetry/sdk-node ^0.217.0`, `@opentelemetry/sdk-metrics ^2.0.1`, `@opentelemetry/sdk-trace-node ^2.0.1`, `@opentelemetry/resources ^2.0.1` | NodeSDK / Meter / Resource | 既存導入済み |
+| Exporter | `@opentelemetry/exporter-trace-otlp-grpc`, `@opentelemetry/exporter-metrics-otlp-grpc ^0.202.0` | OTLP gRPC エクスポート | 引数なしで生成し endpoint は OTel 標準 env var で解決 |
+| Auto-instrumentation | `@opentelemetry/auto-instrumentations-node ^0.75.0` | HTTP / Express / Mongoose 等の自動計測 | `instrumentation-pino`, `instrumentation-fs` は無効化 |
+| SemConv | `@opentelemetry/semantic-conventions ^1.34.0` | stable attribute のみ import | incubating は `semconv.ts` にローカルコピー |
+| Logger | pino(`~/utils/logger`) + `diag` アダプタ | dev 環境のみ DiagLogger を pino に差し替え | production は OpenTelemetry の default diag |
+| Test | Vitest + `vitest-mock-extended` | `vi.mock('node:os'/'node:v8')`, `mock<Meter>()` パターン | 既存テスト基盤 |
+
+## File Structure Plan
+
+### Directory Structure
+
+```
+apps/app/src/features/opentelemetry/server/
+├── index.ts                              # public export: `export * from './node-sdk'`
+├── node-sdk.ts                           # Layer 1: SDK lifecycle entrypoints
+├── node-sdk-configuration.ts             # Layer 1: NodeSDKConfiguration + Resource builders
+├── node-sdk-resource.ts                  # Layer 1: NodeSDK._resource reflective accessor
+├── logger.ts                             # Layer 1: DiagLoggerPinoAdapter
+├── semconv.ts                            # Layer 5: incubating attribute local copy
+├── custom-resource-attributes/
+│   ├── index.ts                          # Layer 2: barrel
+│   ├── os-resource-attributes.ts         # Layer 2: OS identity (stage-1)
+│   ├── os-resource-attributes.spec.ts
+│   ├── application-resource-attributes.ts # Layer 2: GROWI service identity (stage-2)
+│   └── application-resource-attributes.spec.ts
+├── custom-metrics/
+│   ├── index.ts                          # Layer 3: barrel + setupCustomMetrics()
+│   ├── application-metrics.ts            # Layer 3: growi.configs info gauge
+│   ├── application-metrics.spec.ts
+│   ├── user-counts-metrics.ts            # Layer 3: growi.users.{total,active}
+│   ├── user-counts-metrics.spec.ts
+│   ├── page-counts-metrics.ts            # Layer 3: growi.pages.total
+│   ├── page-counts-metrics.spec.ts
+│   ├── system-metrics.ts                 # Layer 3: system.* / process.* memory metrics
+│   └── system-metrics.spec.ts
+└── anonymization/
+    ├── index.ts                          # Layer 4: barrel
+    ├── anonymize-http-requests.ts        # Layer 4: startIncomingSpanHook + module loop
+    ├── interfaces/
+    │   └── anonymization-module.ts       # Layer 4: AnonymizationModule interface
+    ├── handlers/
+    │   ├── index.ts                      # Layer 4: anonymizationModules[] registration
+    │   ├── search-api-handler.ts
+    │   ├── search-api-handler.spec.ts
+    │   ├── page-listing-api-handler.ts
+    │   ├── page-listing-api-handler.spec.ts
+    │   ├── page-api-handler.ts
+    │   ├── page-api-handler.spec.ts
+    │   ├── page-access-handler.ts
+    │   └── page-access-handler.spec.ts
+    └── utils/
+        ├── anonymize-query-params.ts
+        └── anonymize-query-params.spec.ts
+```
+
+### Extension Templates
+
+#### 新規 Custom Metric モジュールの追加
+
+1. `custom-metrics/<scope>-metrics.ts` を新規作成し、`addXxxMetrics(): void` を export する。
+2. ファイル冒頭で `loggerFactory('growi:opentelemetry:custom-metrics:<scope>')` と `diag.createComponentLogger({ namespace: 'growi:custom-metrics:<scope>' })` を初期化する。
+3. `metrics.getMeter('growi-<scope>-metrics', '1.0.0')` で Meter 取得。
+4. `meter.createObservableGauge(name, { description, unit })` で gauge 群を作成。
+5. `meter.addBatchObservableCallback(async (result) => { try { ... } catch (e) { loggerDiag.error(...) } }, [...gauges])` を 1 つ登録。
+6. `custom-metrics/index.ts` の barrel に `export { addXxxMetrics } from './<scope>-metrics';` を追加し、`setupCustomMetrics()` 内で dynamic import + 呼び出し。
+7. `*.spec.ts` を co-locate し、`vi.mock('@opentelemetry/api')` + `mock<Meter>()` パターンで unit test を書く。
+
+#### 新規 Anonymization Handler の追加
+
+1. `anonymization/handlers/<scope>-handler.ts` を新規作成し、`AnonymizationModule` 型の object を export する。
+2. `canHandle(url): boolean` で対象 URL を判別する。先頭一致 / `URL` parser / 正規表現を適宜使用。
+3. `handle(request, url): Record<string, string> | null` で `anonymizeQueryParams()` または独自ロジックを適用し、`{ [ATTR_HTTP_TARGET]: anonymizedUrl }` を返す。何も匿名化しない場合は `null`。
+4. `handlers/index.ts` の `anonymizationModules` 配列に追加。**順序が重要**: より具体的なパスを先に置く(API > 静的 page access)。
+5. `*.spec.ts` を co-locate し、`canHandle` の境界条件と `handle` の URL 変換を網羅する。
+
+## Requirements Traceability
+
+| Requirement | Summary | Components | Interfaces |
+|-------------|---------|------------|------------|
+| 1.1–1.4 | SDK ライフサイクルと有効化制御 | NodeSdkLifecycle, OverwriteSdkDisabled | `initInstrumentation()`, `setupAdditionalResourceAttributes()`, `startOpenTelemetry()` |
+| 2.1–2.4 | identity 専用 Resource Attribute | OsResourceAttributes, ApplicationResourceAttributes, NodeSdkConfiguration | `getOsResourceAttributes()`, `getApplicationResourceAttributes()`, `generateAdditionalResourceAttributes()` |
+| 3.1–3.5 | GROWI 設定情報の info-gauge ラベル統合 | ApplicationMetrics | `addApplicationMetrics()` の observe ラベル |
+| 4.1–4.4 | 業務カウントメトリクス | UserCountsMetrics, PageCountsMetrics | `addUserCountsMetrics()`, `addPageCountsMetrics()` |
+| 5.1–5.5 | コンテナ運用に対応したメモリ系メトリクス | SystemMetrics | `addSystemMetrics()` |
+| 6.1–6.5 | HTTP リクエストの best-effort anonymization | HttpInstrumentationConfig, AnonymizationModules | `httpInstrumentationConfig.startIncomingSpanHook`, 各 `AnonymizationModule.{canHandle,handle}` |
+| 7.1–7.3 | Diag Logger と pino の統合 | DiagLoggerPinoAdapter | `initLogger()` |
+| 8.1–8.3 | メトリクスエクスポートと SDK 設定 | NodeSdkConfiguration | `generateNodeSDKConfiguration()` の reader / exporter / instrumentation 設定 |
+| 9.1–9.2 | SemConv の不安定 attribute のローカルコピー | SemConv | `semconv.ts` の文字列定数 |
+| 10.1–10.3 | 拡張・追加時の境界遵守 | CustomMetricsIndex, AnonymizationHandlersIndex | 拡張テンプレート(File Structure Plan 参照) |
+
+## Components and Interfaces
+
+### Layer 1: SDK ライフサイクル
+
+#### NodeSdkLifecycle
+
+| Field | Detail |
+|-------|--------|
+| Intent | OpenTelemetry SDK のプロセス内ライフサイクル管理 |
+| Requirements | 1.1, 1.2, 1.3, 1.4, 8.1, 8.2, 8.3 |
+
+**Responsibilities & Constraints**
+- 同一プロセス内で 1 つの `NodeSDK` インスタンスのみを保持する。
+- `otel:enabled` が `false` のときは SDK を構築しない。
+- `OTEL_SDK_DISABLED` env var と `otel:enabled` の食い違いを warn で報告し上書きする。
+- Resource 注入は 2 段階(SDK 構築時 / DB 初期化後)に分け、`setResource()` の private API 経由で 2 段階目を反映する。
+- `start()` 直後に `setupCustomMetrics()` を呼び出して Custom Metric の登録を行う。
+
+**Dependencies**
+- Inbound: `apps/app/src/server/app.ts` 系の起動シーケンス(実体は本 spec の外)。
+- Outbound: `@opentelemetry/sdk-node` (`NodeSDK`), `configManager`, `./node-sdk-configuration`, `./node-sdk-resource`, `./logger`, `./custom-metrics`.
+
+##### Service Interface
+```typescript
+export const initInstrumentation: () => Promise<void>;
+export const setupAdditionalResourceAttributes: () => Promise<void>;
+export const startOpenTelemetry: () => void;
+// テスト専用
+export const __testing__: { getSdkInstance, reset };
+```
+
+**Implementation Notes**
+- 二重 init 防止: モジュールスコープの `let sdkInstance: NodeSDK | undefined;` を見て、設定済みなら warn のみで return。
+- `start()` 前後で `instrumentationEnabled` を再確認する(dev 時に env を切り替える運用への配慮)。
+- `setResource()` は `NodeSDK._resource` を直接書き換える。OpenTelemetry SDK が public な resource 上書き API を提供したら撤去する候補(Revalidation Trigger)。
+
+#### NodeSdkConfiguration
+
+| Field | Detail |
+|-------|--------|
+| Intent | `NodeSDKConfiguration` オブジェクトと Resource の構築 |
+| Requirements | 2.1, 2.2, 2.3, 6.1, 6.5, 8.1, 8.2, 8.3 |
+
+**Responsibilities & Constraints**
+- 1 段階目 Resource: `{ service.name: 'growi', service.version: <growi-version> }`。
+- 2 段階目 Resource: `{ service.instance.id?, ...osAttrs, ...appAttrs }` を merge。
+- Trace exporter: `OTLPTraceExporter()`(引数なし)。
+- Metric reader: `PeriodicExportingMetricReader({ exporter: new OTLPMetricExporter(), exportIntervalMillis: 300000 })`。
+- Instrumentation: `getNodeAutoInstrumentations({ pino: disabled, fs: disabled, http: { enabled, ...httpInstrumentationConfig } })`。
+- `enableAnonymization` が `true` のときのみ `httpInstrumentationConfig` を注入。
+
+**Dependencies**
+- Outbound: `@opentelemetry/sdk-node`, `@opentelemetry/sdk-metrics`, `@opentelemetry/exporter-*-otlp-grpc`, `@opentelemetry/auto-instrumentations-node`, `@opentelemetry/resources`, `@opentelemetry/semantic-conventions` (stable), `./semconv`, `./anonymization`, `./custom-resource-attributes`, `~/server/service/config-manager`, `~/utils/growi-version`.
+
+##### Service Interface
+```typescript
+type Option = { enableAnonymization?: boolean };
+type Configuration = Partial<NodeSDKConfiguration> & { resource: Resource };
+export const generateNodeSDKConfiguration: (opts?: Option) => Configuration;
+export const generateAdditionalResourceAttributes: (opts?: Option) => Promise<Resource>;
+```
+
+**Implementation Notes**
+- `configuration` と `resource` をモジュールスコープで保持し、二重生成を防止する。
+- `service.instance.id` の値は `otel:serviceInstanceId` を優先し、フォールバックで `app:serviceInstanceId`。
+
+#### NodeSdkResource
+
+| Field | Detail |
+|-------|--------|
+| Intent | NodeSDK の private `_resource` プロパティへの reflective アクセス |
+| Requirements | 1.3 |
+
+**Responsibilities & Constraints**
+- `getResource(sdk)`: `_resource` の存在を検証し返す。失敗時は throw。
+- `setResource(sdk, resource)`: `getResource` で生存確認した上で `_resource` を上書き。
+
+**Implementation Notes**
+- `as any` キャストでアクセスする。SDK のメジャー更新時に public API が出たら即座に撤去すべき箇所(Revalidation Trigger)。
+
+#### DiagLoggerPinoAdapter
+
+| Field | Detail |
+|-------|--------|
+| Intent | `@opentelemetry/api` の `DiagLogger` を pino logger にアダプトする |
+| Requirements | 7.1, 7.2, 7.3 |
+
+**Responsibilities & Constraints**
+- 開発環境(`NODE_ENV === 'development'`)でのみ `initLogger()` を `node-sdk.ts` から呼び出す。
+- `parseMessage(message, args)` で JSON 文字列を構造化 data に変換し、`logger.error(data, msg)` の pino 引数規約に整合する。
+- `error` / `warn` / `info` / `debug` / `verbose` の 5 メソッドを実装。`verbose` は pino の `trace` レベルにマップ。
+
+### Layer 2: Resource Attribute
+
+#### OsResourceAttributes
+
+| Field | Detail |
+|-------|--------|
+| Intent | OS identity を OTel Resource Attribute として返す |
+| Requirements | 2.1, 2.3 |
+
+**Responsibilities & Constraints**
+- `os.type` / `os.platform` / `os.arch` を返す。
+- 測定値(`os.totalmem` 等)は返さない(System Metric 側の責務)。
+
+**Dependencies**
+- External: `node:os` (`type()`, `platform()`, `arch()`).
+
+##### Service Interface
+```typescript
+export function getOsResourceAttributes(): Attributes;
+// 戻り値: { 'os.type': string, 'os.platform': string, 'os.arch': string }
+```
+
+#### ApplicationResourceAttributes
+
+| Field | Detail |
+|-------|--------|
+| Intent | GROWI service identity を OTel Resource Attribute として返す |
+| Requirements | 2.1, 2.3, 2.4 |
+
+**Responsibilities & Constraints**
+- `growi.service.type` / `growi.deployment.type` を返す。
+- サブシステム設定値(`growi.attachment.type` 等)は返さない(`growi.configs` ラベル側の責務)。
+- try/catch で `growiInfoService` の失敗を吸収し、空 attributes を返す。
+
+**Dependencies**
+- Outbound: `~/server/service/growi-info` の `growiInfoService.getGrowiInfo({})`(dynamic import で循環依存を回避)。
+
+##### Service Interface
+```typescript
+export async function getApplicationResourceAttributes(): Promise<Attributes>;
+// 戻り値: { 'growi.service.type': string, 'growi.deployment.type': string }
+```
+
+### Layer 3: Custom Metric
+
+#### ApplicationMetrics
+
+| Field | Detail |
+|-------|--------|
+| Intent | GROWI 設定情報を info-gauge `growi.configs` のラベルに集約 |
+| Requirements | 3.1, 3.2, 3.3, 3.4, 3.5 |
+
+**Responsibilities & Constraints**
+- `growi.configs` ObservableGauge(unit `'1'`、値は常に 1)を 1 個 emit する。
+- ラベル: `site_url`, `site_url_hashed?`, `wiki_type`, `external_auth_types`, `attachment_type`。
+- `otel:isAppSiteUrlHashed === true` のとき `site_url = '[hashed]'`、`site_url_hashed = SHA-256(appSiteUrl)`。`false` のとき生 URL + `site_url_hashed = undefined`。
+- `external_auth_types` / `attachment_type` の値が未取得時は空文字 `''`。
+
+**Dependencies**
+- Outbound: `growiInfoService.getGrowiInfo({ includeAttachmentInfo: true })`, `configManager.getConfig('otel:isAppSiteUrlHashed')`.
+
+##### Service Interface
+```typescript
+export function addApplicationMetrics(): void;
+```
+
+##### Label Schema: `growi.configs`
+| Label | Source | Notes |
+|-------|--------|-------|
+| `site_url` | `isAppSiteUrlHashed ? '[hashed]' : growiInfo.appSiteUrl` | required |
+| `site_url_hashed` | `isAppSiteUrlHashed ? sha256(appSiteUrl) : undefined` | hashed 時のみ付与 |
+| `wiki_type` | `growiInfo.wikiType` | required |
+| `external_auth_types` | `additionalInfo?.activeExternalAccountTypes?.join(',') \|\| ''` | required(カンマ区切り) |
+| `attachment_type` | `additionalInfo?.attachmentType ?? ''` | required |
+
+#### UserCountsMetrics
+
+| Field | Detail |
+|-------|--------|
+| Intent | GROWI 上のユーザー数とアクティブユーザー数の継続観測 |
+| Requirements | 4.1, 4.2, 4.4 |
+
+**Responsibilities & Constraints**
+- `growi.users.total` / `growi.users.active` の 2 つの ObservableGauge(unit `'users'`)。
+- `growiInfoService.getGrowiInfo({ includeUserCountInfo: true })` を呼び、`additionalInfo.currentUsersCount` / `currentActiveUsersCount` をそれぞれ observe。未取得時は 0。
+
+##### Service Interface
+```typescript
+export function addUserCountsMetrics(): void;
+```
+
+#### PageCountsMetrics
+
+| Field | Detail |
+|-------|--------|
+| Intent | GROWI 上の総ページ数の継続観測 |
+| Requirements | 4.3, 4.4 |
+
+**Responsibilities & Constraints**
+- `growi.pages.total`(unit `'pages'`)の ObservableGauge 1 つ。
+- `growiInfoService.getGrowiInfo({ includePageCountInfo: true })` の `additionalInfo.currentPagesCount` を observe。未取得時は 0。
+
+##### Service Interface
+```typescript
+export function addPageCountsMetrics(): void;
+```
+
+#### SystemMetrics
+
+| Field | Detail |
+|-------|--------|
+| Intent | コンテナ / ホスト / プロセス / V8 ヒープのメモリ系統計を ObservableGauge で出力 |
+| Requirements | 5.1, 5.2, 5.3, 5.4, 5.5 |
+
+**Responsibilities & Constraints**
+- 単一 Meter `growi-system-metrics`(version `'1.0.0'`)で 6 つの ObservableGauge を作成。すべて単位 `'By'`。
+- 1 つの `addBatchObservableCallback` で `process.constrainedMemory()` / `os.totalmem()` / `process.memoryUsage()` / `v8.getHeapStatistics()` を 1 回ずつ呼び、ローカル変数経由で 6 個の gauge に観測値を割り振る。
+- `process.constrainedMemory()` が `> 0` のときのみ `system.memory.limit` を観測、`0` または falsy のときは当該 gauge のみスキップし他 5 個は観測する。
+- コールバック全体を try/catch で囲み、例外時は `loggerDiag.error('Failed to collect system metrics', { error })` を呼んで `result.observe` を一切呼ばずに return。
+
+##### Metric Schema
+| Metric Name | Unit | Source | Skip Condition |
+|-------------|------|--------|----------------|
+| `system.memory.limit` | `By` | `process.constrainedMemory()` | 値が `0` または falsy |
+| `system.host.memory.total` | `By` | `os.totalmem()` | — |
+| `process.memory.usage` | `By` | `process.memoryUsage().rss` | — |
+| `process.runtime.v8.heap.used` | `By` | `v8.getHeapStatistics().used_heap_size` | — |
+| `process.runtime.v8.heap.total` | `By` | `v8.getHeapStatistics().total_heap_size` | — |
+| `process.runtime.v8.heap.external` | `By` | `process.memoryUsage().external` | — |
+
+##### Service Interface
+```typescript
+export function addSystemMetrics(): void;
+```
+
+**Implementation Notes**
+- `process.constrainedMemory()` は Node.js 19.6 で導入 / 20.12 で stable。`apps/app` の `engines.node` は `^24` のためサポートされるが、防御的に `(process as NodeJS.Process & { constrainedMemory?(): number }).constrainedMemory?.() ?? 0` でアクセスする。
+- API 呼び出しの重複を避けるため、各 stdlib API は callback 内で 1 回ずつのみ呼ぶ。
+
+#### CustomMetricsIndex
+
+| Field | Detail |
+|-------|--------|
+| Intent | Custom Metric モジュール群の起動合成点 |
+| Requirements | 4.1–4.4, 10.1 |
+
+**Responsibilities & Constraints**
+- 各モジュールを dynamic import し、`addApplicationMetrics()` / `addUserCountsMetrics()` / `addPageCountsMetrics()` / `addSystemMetrics()` を順次呼ぶ。
+- 新規モジュール追加時はこの順序の末尾に append する(既存ダッシュボードに影響しない)。
+
+##### Service Interface
+```typescript
+export const setupCustomMetrics: () => Promise<void>;
+export { addApplicationMetrics, addPageCountsMetrics, addSystemMetrics, addUserCountsMetrics };
+```
+
+### Layer 4: HTTP Anonymization
+
+#### HttpInstrumentationConfig
+
+| Field | Detail |
+|-------|--------|
+| Intent | `@opentelemetry/instrumentation-http` の `startIncomingSpanHook` に注入し、登録された anonymization module を順次評価する |
+| Requirements | 6.1, 6.5 |
+
+**Responsibilities & Constraints**
+- `startIncomingSpanHook(request)` で URL を取り出し、`anonymizationModules` を `canHandle(url)` で順次フィルタ、マッチした module の `handle(request, url)` の戻り値(`{ [ATTR_HTTP_TARGET]: <anonymized> }` または `null`)を `Object.assign` で集約。
+- 注入は `node-sdk-configuration.ts` 経由で、`otel:anonymizeInBestEffort` が `true` のときのみ行う。
+
+##### Service Interface
+```typescript
+export const httpInstrumentationConfig: InstrumentationConfigMap['@opentelemetry/instrumentation-http'];
+```
+
+#### AnonymizationModule(interface)
+
+| Field | Detail |
+|-------|--------|
+| Intent | 個別 anonymization handler の共通契約 |
+| Requirements | 6.1, 6.2, 6.3, 6.4, 10.2 |
+
+##### Interface
+```typescript
+export interface AnonymizationModule {
+  canHandle(url: string): boolean;
+  handle(request: IncomingMessage, url: string): Record<string, string> | null;
+}
+```
+
+#### AnonymizationHandlersIndex
+
+| Field | Detail |
+|-------|--------|
+| Intent | 登録済み handler のコレクション。**配列順 = 評価順**で、より具体的なパスから書く。 |
+| Requirements | 6.2, 6.3, 6.4, 10.2 |
+
+**Registration Order**
+1. `searchApiModule` — 検索 API
+2. `pageListingApiModule` — page-listing API
+3. `pageApiModule` — pages/list 系 API
+4. `pageAccessModule` — 非 API ページアクセス(最も汎用的なため最後)
+
+#### SearchApiModule
+
+| Field | Detail |
+|-------|--------|
+| Intent | `/_api/search`, `/_search` の `q` クエリパラメータを匿名化 |
+| Requirements | 6.2 |
+
+**canHandle**: `/\/_api\/search(\?|$)/` または `/\/_search(\?|$)/` または `'/_api/search/'` / `'/_search/'` を含む。
+**handle**: `q` パラメータが含まれていれば `anonymizeQueryParams(url, ['q'])`。
+
+#### PageListingApiModule
+
+| Field | Detail |
+|-------|--------|
+| Intent | `/_api/v3/page-listing/{ancestors-children,children,item}` の `path` パラメータを匿名化 |
+| Requirements | 6.3 |
+
+#### PageApiModule
+
+| Field | Detail |
+|-------|--------|
+| Intent | `/_api/v3/pages/{list,subordinated-list}` および `/_api/v3/page/{check-page-existence,get-page-paths-with-descendant-count}` の `path` / `paths` パラメータを匿名化 |
+| Requirements | 6.3 |
+
+#### PageAccessModule
+
+| Field | Detail |
+|-------|--------|
+| Intent | API 以外のページアクセスのうち、`isCreatablePage` を満たすパスのみ匿名化。permalink(ObjectId)は素通し、user ページはユーザー名と残りパスを別々にハッシュ。 |
+| Requirements | 6.4 |
+
+**Behavior**:
+- ルート `/`、静的リソース(`/static/`, `/_next/`, `/favicon`, `/assets/`, 拡張子付き)、`/user`(users top page)、permalink を除外。
+- user page (`/user/<name>/...`) はユーザー名と残りパスを SHA-256 prefix(16 文字)で別々にハッシュ → `/user/[USERNAME_HASHED:<hash>][/?][HASHED:<hash>]`。
+- それ以外の通常 page はパス全体を SHA-256 prefix で 1 ハッシュ → `[HASHED:<hash>]`。
+
+#### AnonymizeQueryParams(utility)
+
+| Field | Detail |
+|-------|--------|
+| Intent | クエリパラメータの値を `[ANONYMIZED]` リテラルに置換する純粋関数 |
+| Requirements | 6.2, 6.3 |
+
+##### Service Interface
+```typescript
+export function anonymizeQueryParams(target: string, paramNames: string[]): string;
+```
+
+**Behavior**:
+- 通常パラメータは `[ANONYMIZED]` で置換。値が JSON 配列フォーマットなら `["[ANONYMIZED]"]` を返す。
+- `paramName[]` 形式の配列パラメータには `[ANONYMIZED]` を 1 つだけ残す。
+- 変更が無ければ入力をそのまま返す(無駄な URL 再構築を避ける)。
+
+### Layer 5: SemConv ローカルコピー
+
+#### SemConv
+
+| Field | Detail |
+|-------|--------|
+| Intent | OpenTelemetry incubating semconv を文字列定数として固定化 |
+| Requirements | 9.1, 9.2 |
+
+##### Definitions
+```typescript
+export const ATTR_SERVICE_INSTANCE_ID = 'service.instance.id';
+export const ATTR_HTTP_TARGET = 'http.target';
+```
+
+**Implementation Notes**
+- 上流の incubating entry-point は import 禁止。stable 化されたら本ファイルから削除し、stable 定数の import に切り替える(Revalidation Trigger)。
+
+## Error Handling
+
+### Error Strategy
+
+各レイヤで発生する例外は **その場で吸収し、上位レイヤや他メトリクスを巻き込まない** ことを基本方針とする。
+
+- **Layer 1(SDK lifecycle)**: SDK 構築失敗時のみ throw を許容(起動を継続できないため)。Resource 取得失敗は warn ログでスキップ。
+- **Layer 2(Resource Attribute)**: `growiInfoService` 失敗時は try/catch で空 `Attributes` を返す(SDK 起動は継続可能)。
+- **Layer 3(Custom Metric)**: 各 `addBatchObservableCallback` 内で try/catch。例外時は `diag.createComponentLogger(...).error(...)` を呼び、その collection cycle では observe を 1 回も呼ばない。次の cycle で再試行。
+- **Layer 4(Anonymization)**: 各 handler の `handle` / `canHandle` で try/catch、失敗時は anonymization をスキップし元の URL のまま span 属性に乗せる(または何もしない)。
+
+### Error Categories and Responses
+
+| Category | 例 | 振る舞い |
+|----------|-----|---------|
+| 起動時 SDK 構築失敗 | `OTLPTraceExporter()` のコンストラクタ例外 | プロセス起動継続不可。throw を上位に伝播。 |
+| Resource 取得失敗 | `growiInfoService.getGrowiInfo()` 例外 | `logger.error` で記録し空 `Attributes` 返却。 |
+| Metric collection 例外 | `growiInfoService` 失敗、stdlib 失敗(理論上発生しない) | `loggerDiag.error` で記録、当該 cycle の observe をスキップ。 |
+| Anonymization 失敗 | URL parse 失敗、handler 内部例外 | `diag` logger に warn / debug、URL は元のまま。 |
+| `process.constrainedMemory()` の戻り値が 0 | 非コンテナ環境 | `system.memory.limit` のみスキップ。他 5 メトリクスは observe。 |
+
+### Monitoring
+
+- Diag ログ namespace 規約:
+  - `growi:custom-metrics:application`
+  - `growi:custom-metrics:user-counts`
+  - `growi:custom-metrics:page-counts`
+  - `growi:custom-metrics:system`
+  - `growi:anonymization:<handler-name>`
+- アプリケーションログ(pino): `loggerFactory('growi:opentelemetry:<sub-namespace>')` で起動完了メッセージを info ログ出力。
+
+## Testing Strategy
+
+### Unit Tests
+
+各モジュールに `*.spec.ts` を co-locate する。テスト設計の指針:
+
+- **Layer 2 spec**(`os-resource-attributes.spec.ts`, `application-resource-attributes.spec.ts`): `vi.mock('node:os')` で stdlib を、`vi.mock` で `growiInfoService` をモックし、戻り値 attributes のキー集合を assert。
+- **Layer 3 spec**: `vi.mock('@opentelemetry/api')` で `metrics`, `diag` をモック、`vitest-mock-extended` の `mock<Meter>()` / `mock<ObservableGauge>()` で gauge を取得し、`meter.addBatchObservableCallback.mock.calls[0][0]` でコールバックを取り出して直接実行する。`result.observe` のモックを assert する。
+- **Layer 4 spec**: handler ごとに `canHandle` の境界条件(先頭一致 / クエリ有無 / 静的リソース除外 / permalink 除外)と `handle` の URL 変換を網羅。`anonymize-query-params.spec.ts` で JSON 配列 / `paramName[]` フォーマットを網羅。
+- **SystemMetrics spec**: `vi.mock('node:os')`, `vi.mock('node:v8')`, `vi.spyOn(process, 'constrainedMemory')`, `vi.spyOn(process, 'memoryUsage')` を組み合わせる。
+
+### Integration Tests
+
+- `node-sdk.spec.ts` が SDK 構築・初期化シーケンスを統合的に検証。
+- `node-sdk.testing.ts` がテストヘルパとして共通利用される。
+
+### Manual / E2E Verification
+
+- 開発 devcontainer で `OTEL_EXPORTER_OTLP_ENDPOINT` を `http://localhost:4317` 等に向け、Collector の receiver ログで以下を確認:
+  - Resource Attribute が identity セット 8 種のみであること。
+  - `growi.configs` の `attachment_type` ラベルが期待値(`aws` / `gcs` / `gridfs` / `local` / `mongodb` / `azure` 等)または空文字であること。
+  - `system.host.memory.total` / `process.memory.usage` / `process.runtime.v8.heap.*` が約 5 分間隔で届くこと。
+  - Docker container で `--memory=512m` を指定した場合に `system.memory.limit` が約 `536870912`、未指定時は emit されないこと。
+- `OPENTELEMETRY_ANONYMIZE_IN_BEST_EFFORT=true` のとき、検索 / 編集等の操作後 span の `http.target` が `[ANONYMIZED]` / `[HASHED:...]` で置換されていること。

+ 139 - 0
.kiro/specs/opentelemetry/requirements.md

@@ -0,0 +1,139 @@
+# Requirements Document
+
+## Introduction
+
+GROWI の OpenTelemetry 統合 (`apps/app/src/features/opentelemetry/`) を **メンテナンスするための大局的な仕様**。SDK ライフサイクル、Resource Attribute、Custom Metric、HTTP Anonymization の 4 レイヤがそれぞれ「何を担い、何を担わないか」を明文化し、新規メトリクスや anonymization handler の追加、SDK のバージョンアップ、設定キーの追加・改名といった将来のメンテナンス時に、本 spec を 1 か所の参照点として運用できる状態を目標とする。
+
+本 spec は新規実装 spec ではなく、既に実装・稼働している `features/opentelemetry/` の **現状の責務境界をスナップショットとして固定化する** 性格を持つ。個別機能の追加・変更は原則として本 spec の Boundary Commitments の範囲内で行われ、境界をまたぐ変更が必要なときは Revalidation Triggers として再評価される。
+
+## Boundary Context
+
+- **In scope**:
+  - NodeSDK の起動・有効化制御・Resource 2 段階初期化 (`node-sdk.ts`, `node-sdk-configuration.ts`, `node-sdk-resource.ts`)。
+  - Diag Logger の pino アダプタ (`logger.ts`)。
+  - SemConv の不安定 attribute のローカルコピー (`semconv.ts`)。
+  - identity 専用の Resource Attribute 供給 (`custom-resource-attributes/`)。
+  - Custom Metric の emit と合成 (`custom-metrics/`、合計 4 モジュール: application / user-counts / page-counts / system)。
+  - HTTP リクエストの best-effort anonymization (`anonymization/`、4 個の handler + utility)。
+  - `otel:*` 設定キー 4 種の利用ポリシー。
+- **Out of scope**:
+  - `growiInfoService` / `configManager` / `loggerFactory` などの上流サービスの設計や API 変更。
+  - 既存メトリクス(`growi.users.total` / `growi.users.active` / `growi.pages.total` / `growi.configs` / `system.*` / `process.*`)の名称変更や再構成。
+  - OpenTelemetry のログシグナル統合(log signal は現状未使用)。
+  - クライアント側(ブラウザ)からの telemetry 出力。
+  - Trace span への独自 attribute 追加(`http.target` 以外)。
+  - OTLP Exporter の wire 仕様や受信側ツールチェイン。
+- **Adjacent expectations**:
+  - 上流: `growiInfoService.getGrowiInfo({ includeAttachmentInfo, includeUserCountInfo, includePageCountInfo })` の API シグネチャ・返り値型が維持されることに依存する。破壊的変更があった場合は Revalidation Triggers として `custom-metrics/` および `custom-resource-attributes/application-resource-attributes.ts` を再評価する。
+  - 上流: `configManager.getConfig('otel:*')` の 4 キーが現状の意味で参照可能であることに依存する。
+  - 下流: OpenTelemetry Collector およびその先のダッシュボード/アラート群が本 spec の Metric Schema / Resource Attribute 表に整合した参照クエリを保持していること。変更時は PR 説明にて運用者へ通知する。
+
+## Requirements
+
+### Requirement 1: SDK ライフサイクルと有効化制御
+
+**Objective:** GROWI 運用者として、OpenTelemetry SDK を環境変数 1 つで有効/無効を切り替えられ、無効時にはランタイムオーバーヘッドや誤った OTLP 接続試行が発生しないことを保証したい。
+
+#### Acceptance Criteria
+
+1. The GROWI server shall `otel:enabled` 設定が `false` のとき、NodeSDK インスタンスを生成せず Resource Attribute 取得や Custom Metric の登録も行わない。
+2. The GROWI server shall `otel:enabled` 設定値と `OTEL_SDK_DISABLED` 環境変数の値が矛盾している場合、`OTEL_SDK_DISABLED` を上書きして整合性を取り、その旨を warn ログとして出力する。
+3. The GROWI server shall NodeSDK 初期化を「SDK 構築・Resource 静的部分のセット」「DB 初期化後の Resource 追加注入」「`start()` 呼び出しと Custom Metric 登録」の 3 段階で行う。各段階は `otel:enabled` を再確認した上で実行する。
+4. The GROWI server shall 同一プロセス内で `initInstrumentation()` を二重に呼ばれても、SDK インスタンスを重複生成しない(再初期化は警告を出してスキップする)。
+
+### Requirement 2: identity 専用 Resource Attribute
+
+**Objective:** 受信側インフラ管理者として、GROWI が emit する Resource Attribute がテレメトリ発生元エンティティの identity 情報のみであり、測定値や設定値が紛れ込まないことを保証したい。
+
+#### Acceptance Criteria
+
+1. The GROWI server shall Resource Attribute として以下のみを emit する: `service.name`, `service.version`, `service.instance.id`(取得できた場合), `os.type`, `os.platform`, `os.arch`, `growi.service.type`, `growi.deployment.type`。
+2. The GROWI server shall 測定値(メモリ使用量・カウント等)および GROWI のサブシステム設定値(attachment / auth provider 種別等)を Resource Attribute として emit しない。
+3. The GROWI server shall Resource Attribute の取得を 2 段階に分け、1 段階目は DB 非依存(service.name / version / OS info)、2 段階目は DB 初期化後(`service.instance.id` / `growi.service.type` / `growi.deployment.type`)に行う。
+4. If Resource Attribute 取得処理で例外が発生した場合, the GROWI server shall 当該段階の Resource Attribute を空オブジェクトで返し、SDK 起動自体は継続する。
+
+### Requirement 3: GROWI 設定情報の info-gauge ラベル統合
+
+**Objective:** 運用者として、GROWI インスタンスの設定情報(site URL、wiki type、外部認証種別、添付ストレージ種別)を 1 つの info-gauge メトリクスから一覧できることを保証したい。
+
+#### Acceptance Criteria
+
+1. The GROWI server shall `growi.configs` という ObservableGauge を Prometheus info パターン(値は常に 1、情報はラベルに格納)で emit する。
+2. The GROWI server shall `growi.configs` に以下のラベルを付与する: `site_url`, `site_url_hashed`, `wiki_type`, `external_auth_types`, `attachment_type`。
+3. If `otel:isAppSiteUrlHashed` が `true`, the GROWI server shall `site_url` を `[hashed]` リテラルにし、`site_url_hashed` に SHA-256 ハッシュ値を入れる。`false` のときは `site_url` に生 URL を入れ `site_url_hashed` は `undefined`(emit されない)。
+4. If `external_auth_types` / `attachment_type` の値が `growiInfoService` から取得できない場合, the GROWI server shall 当該ラベルを空文字 `''` でフォールバックする。
+5. The GROWI server shall ラベル名を snake_case で統一する。
+
+### Requirement 4: 業務カウントメトリクス
+
+**Objective:** 運用者として、GROWI 上の主要エンティティ(ユーザー、ページ)の総数とアクティビティ指標を継続的に観測したい。
+
+#### Acceptance Criteria
+
+1. The GROWI server shall `growi.users.total` メトリクスを総ユーザー数で観測する(単位 `users`)。
+2. The GROWI server shall `growi.users.active` メトリクスをアクティブユーザー数で観測する(単位 `users`)。
+3. The GROWI server shall `growi.pages.total` メトリクスを総ページ数で観測する(単位 `pages`)。
+4. If `growiInfoService` からのカウント値取得が失敗した場合, the GROWI server shall 0 で観測するか、当該収集サイクルでの観測をスキップし、`diag` ロガーに error を記録する。
+
+### Requirement 5: コンテナ運用に対応したメモリ系メトリクス
+
+**Objective:** コンテナ環境(Docker / Kubernetes)で GROWI を運用する管理者として、「コンテナに割り当てられたメモリ上限(cgroup limit)」「ホスト物理メモリ総量」「プロセス RSS」「V8 ヒープの使用/確保/外部メモリ」を別々のメトリクスとして観測できることを保証したい。
+
+#### Acceptance Criteria
+
+1. The GROWI server shall `system.memory.limit` を `process.constrainedMemory()` の戻り値(>0 のとき)で観測する。値が `0` または falsy のときは当該メトリクスのみ観測をスキップする。
+2. The GROWI server shall `system.host.memory.total` を `os.totalmem()` の戻り値で常に観測する。
+3. The GROWI server shall `process.memory.usage` を `process.memoryUsage().rss` で観測する。
+4. The GROWI server shall `process.runtime.v8.heap.used` / `process.runtime.v8.heap.total` / `process.runtime.v8.heap.external` を `v8.getHeapStatistics()` および `process.memoryUsage().external` から観測する。
+5. The GROWI server shall 上記すべてのメトリクスを単位 `By`(bytes)で emit する。
+
+### Requirement 6: HTTP リクエストの best-effort anonymization
+
+**Objective:** プライバシ保護担当者として、`otel:anonymizeInBestEffort` が `true` のとき、ユーザーが入力した検索キーワード・ページパス・ユーザー名がトレース span の `http.target` に平文で残らないことを保証したい。
+
+#### Acceptance Criteria
+
+1. The GROWI server shall `otel:anonymizeInBestEffort` が `true` のとき、HTTP instrumentation の `startIncomingSpanHook` で `anonymizationModules` を順次評価し、`canHandle(url)` が `true` を返した module の `handle()` 結果を span attribute としてマージする。
+2. The GROWI server shall 検索 API (`/_api/search`, `/_search`) の `q` クエリパラメータを `[ANONYMIZED]` に置換する。
+3. The GROWI server shall page-listing API (`/_api/v3/page-listing/{ancestors-children,children,item}`) および page API (`/_api/v3/pages/list`, `/_api/v3/pages/subordinated-list`, `/_api/v3/page/check-page-existence`, `/_api/v3/page/get-page-paths-with-descendant-count`) の `path` / `paths` パラメータを匿名化する。
+4. The GROWI server shall ページアクセス(非 API、permalink でない、創出可能なページパス)に対し、ユーザー名およびページパスを SHA-256 prefix(16 文字)でハッシュし `[USERNAME_HASHED:...]` / `[HASHED:...]` プレースホルダで置換する。permalink(ObjectId)と users top page(`/user`)はそのまま残す。
+5. If `otel:anonymizeInBestEffort` が `false`, the GROWI server shall `startIncomingSpanHook` を渡さず、HTTP instrumentation の標準動作のみを行う。
+
+### Requirement 7: Diag Logger と pino の統合
+
+**Objective:** 開発者として、OpenTelemetry 内部の `diag` ログが GROWI の通常のアプリケーションログ(pino)と同一フォーマット・同一出力先で観測できることを保証したい。
+
+#### Acceptance Criteria
+
+1. When `NODE_ENV === 'development'` かつ `otel:enabled` が `true`, the GROWI server shall `DiagLogger` を pino logger にアダプトする実装 (`DiagLoggerPinoAdapter`) をグローバルに登録する。
+2. The GROWI server shall `diag.error/warn/info/debug/verbose` で受け取ったメッセージが JSON 文字列の場合に parse して構造化 data に変換し、pino の引数規約(data 第 1 引数・message 第 2 引数)に整合する形で渡す。
+3. The GROWI server shall production 環境では `initLogger()` を呼ばない(OpenTelemetry の `diag` 既定動作に委ねる)。
+
+### Requirement 8: メトリクスエクスポートと SDK 設定
+
+**Objective:** 運用者として、OTLP メトリクス/トレースエクスポートが OpenTelemetry SDK 標準の環境変数(`OTEL_EXPORTER_OTLP_ENDPOINT` 等)で制御でき、内部で勝手な default endpoint が固定されないことを保証したい。
+
+#### Acceptance Criteria
+
+1. The GROWI server shall `OTLPTraceExporter` および `OTLPMetricExporter` をコンストラクタ引数なしで生成し、エンドポイントなど exporter 設定は OpenTelemetry SDK 標準の環境変数で解決させる。
+2. The GROWI server shall `PeriodicExportingMetricReader` の `exportIntervalMillis` を 300000(5 分)で初期化する。
+3. The GROWI server shall auto-instrumentation のうち `@opentelemetry/instrumentation-pino` および `@opentelemetry/instrumentation-fs` を明示的に無効化する(pino: log signal を使用しないため、fs: トレース量が膨大すぎるため)。
+
+### Requirement 9: SemConv の不安定 attribute のローカルコピー
+
+**Objective:** 開発者として、`@opentelemetry/semantic-conventions` の incubating attribute をランタイムコードから直接 import せず、本モジュール内のローカルコピーを参照することで、上流の minor リリースでの破壊的変更からアプリケーションを保護したい。
+
+#### Acceptance Criteria
+
+1. The GROWI server shall incubating attribute(`service.instance.id`, `http.target`)を `semconv.ts` 内に文字列定数として定義し、ランタイムコードはこれを import する。
+2. The GROWI server shall `@opentelemetry/semantic-conventions/incubating` からの import をランタイムコードに含めない。
+
+### Requirement 10: 拡張・追加時の境界遵守
+
+**Objective:** 機能を追加・変更するエンジニアとして、新規 Custom Metric や新規 Anonymization Handler を本 spec の境界に従って実装し、レイヤ責務の汚染を回避したい。
+
+#### Acceptance Criteria
+
+1. When 新規 Custom Metric モジュールを追加する, the GROWI server shall `custom-metrics/` 配下にファイルを追加し、`addXxxMetrics(): void` をエクスポートし、`custom-metrics/index.ts` の `setupCustomMetrics()` から呼び出す。
+2. When 新規 Anonymization Handler を追加する, the GROWI server shall `anonymization/handlers/` 配下に `AnonymizationModule` 実装ファイルを追加し、`handlers/index.ts` の `anonymizationModules` 配列に登録する。
+3. The GROWI server shall identity 情報を Resource Attribute 経由で、設定値を `growi.configs` ラベル経由で、観測値を `growi.*` / `system.*` / `process.*` メトリクス経由でそれぞれ emit する責務分離を維持する。

+ 173 - 0
.kiro/specs/opentelemetry/research.md

@@ -0,0 +1,173 @@
+# Research & Design Decisions — opentelemetry
+
+## Summary
+
+- **Feature**: `opentelemetry`(`apps/app/src/features/opentelemetry/` の大局的メンテナンス spec)。
+- **Discovery Scope**: Extension/Refactor — 既存実装を保ったまま、4 レイヤの責務境界を明文化する。
+
+## Research Log
+
+### 既存 ObservableGauge 実装パターン
+
+- **Context**: 新規 Custom Metric を追加するときに既存パターンと整合させる必要がある。
+- **Sources Consulted**:
+  - `custom-metrics/application-metrics.ts`, `user-counts-metrics.ts`, `page-counts-metrics.ts`, `system-metrics.ts`。
+- **Findings**:
+  - 各モジュールは `addXxxMetrics(): void` を export する。
+  - `metrics.getMeter('growi-<scope>-metrics', '1.0.0')` で Meter を取得し、`meter.createObservableGauge(name, { description, unit })` で gauge を作る。
+  - 観測は `meter.addBatchObservableCallback(async (result) => { try { ... } catch (e) { loggerDiag.error(...) } }, [gauge, ...])` で登録。
+  - ロガー初期化: `loggerFactory('growi:opentelemetry:custom-metrics:<scope>')`(pino)と `diag.createComponentLogger({ namespace: 'growi:custom-metrics:<scope>' })`(OTel diag)の 2 つ。
+- **Implications**: 拡張テンプレートとしてこのパターンを design.md に記載済み(File Structure Plan の "Extension Templates")。
+
+### Anonymization Handler の登録順とパターン
+
+- **Context**: 新規 anonymization handler を追加するとき、`canHandle` の衝突を避ける必要がある。
+- **Sources Consulted**: `anonymization/handlers/index.ts`、各 handler の `canHandle` 実装。
+- **Findings**:
+  - 配列順 = 評価順だが、すべてが OR で集約される(複数 module が同一 URL を匿名化することは現状無いが、可能性としては存在する)。
+  - より具体的なパス(API 系)を先、汎用パス(page access)を最後に配置するのが現状の慣習。
+  - `canHandle` は副作用無しで判定のみ、`handle` は失敗時に `null` を返すか元の URL を維持する。
+- **Implications**: 新規 handler 追加時は、既存 4 handler の対象 URL と衝突しないかを `canHandle` ロジックで確認する。
+
+### `process.constrainedMemory()` の挙動
+
+- **Context**: コンテナ環境とそれ以外で挙動が異なるため、`system.memory.limit` の skip 条件を確定する必要がある。
+- **Sources Consulted**: Node.js v20.12 / v24 公式ドキュメント。
+- **Findings**:
+  - 戻り値: cgroup v1 / v2 から取得した「プロセスに割り当てられたメモリ上限のバイト数」。
+  - cgroup が未設定 / detection 失敗時 / macOS・Windows では `0` を返す(v24 でも継続)。
+  - Node.js v19.6 で導入、v20.12 で stable。
+- **Implications**: `value > 0`(falsy)で判定すれば、macOS・Windows・cgroup なし Linux すべてで一貫した「skip」挙動になる。
+
+### NodeSDK `_resource` への private アクセス
+
+- **Context**: 2 段階目の Resource を NodeSDK に注入する必要があり、public API が見当たらない。
+- **Sources Consulted**: `@opentelemetry/sdk-node` の TypeScript 型定義、`node-sdk-resource.ts`。
+- **Findings**:
+  - NodeSDK は constructor で受け取った resource を内部に保持するが、外部から書き換える public API は存在しない(`sdk-node 0.217.0` 時点)。
+  - `_resource` プロパティを直接書き換えることで、`start()` 前に Resource を差し替えられる。
+- **Implications**: `(sdk as any)._resource` への reflective アクセスを `getResource` / `setResource` で隔離。SDK のメジャー更新時に public API が出ていないか Revalidation Trigger として確認する。
+
+## Architecture Pattern Evaluation
+
+| Option | Description | Strengths | Risks / Limitations | Notes |
+|--------|-------------|-----------|---------------------|-------|
+| Custom ObservableGauge per layer | 自前で 4 Meter / 7+ gauge を実装し、`@opentelemetry/host-metrics` を採用しない | 完全制御、cgroup / V8 対応、追加 dep ゼロ、Meter ごとに spec 単位でテスト可能 | コード量増(〜500 行) | **採用** |
+| `@opentelemetry/host-metrics` 採用 | system / process メトリクスをコミュニティパッケージで自動 emit | 既製、ネットワーク・CPU も追加 | cgroup 未対応、V8 ヒープ非対応、不要メトリクス強制 emit、semconv 古い | 不採用(要件 5 未充足) |
+| Single Meter, all metrics | 全 7+ メトリクスを単一 Meter で束ねる | コードが小さい | 観測スコープ(business vs system)の責務が混在、テスト分離困難 | 不採用 |
+| 2-stage Resource initialization | DB 非依存 → DB 初期化後 の 2 段階で Resource を構築 | 循環依存回避、DB 接続前に SDK 部分起動可能 | `_resource` private アクセス必要 | **採用** |
+| Single-stage Resource | すべての Resource を DB 初期化後に作る | private アクセス不要 | OpenTelemetry の起動が DB 接続まで遅延、`service.name` などの基本属性も遅れる | 不採用 |
+| Module-based anonymization | `AnonymizationModule` interface + 配列順評価 | 新規パス追加が局所変更で済む、handler ごとに spec | 配列順への暗黙依存 | **採用** |
+| Centralized anonymization (switch / regex map) | 1 ファイルで if/else または map で振り分け | フローが見やすい | 拡張ごとに 1 ファイルが肥大化、spec が結合 | 不採用 |
+
+## Design Decisions
+
+### Decision: 4 レイヤの責務分離(identity / 設定 / 観測 / anonymization)
+
+- **Context**: Resource Attribute / Metric / Span Attribute それぞれの本来の用途を運用ガイドラインとして固定したい。
+- **Selected Approach**: 以下の 4 分類で責務を分離する。
+  - **identity**(不変または起動時固定) → Resource Attribute
+  - **設定値**(インスタンス設定の確認用、ラベル次元として参照する) → `growi.configs` info gauge ラベル
+  - **観測値**(時間と共に変化するスカラー) → `growi.*` / `system.*` / `process.*` ObservableGauge
+  - **span attribute**(リクエスト単位の情報、必要なら匿名化) → `http.target` 等 incubating semconv
+- **Rationale**: OpenTelemetry の data model(Resource / Metric / Span)に対する公式の意味論に沿う。Resource に measurement や設定値を載せると receiving side でカーディナリティ爆発・誤った集計の原因になる(特に Resource に乗ったホストメモリ量はコンテナ環境で「ホストの値」を返してしまい運用上の判断を誤らせる典型例)。
+- **Trade-offs**: 設計時の判断分岐が増えるが、ダッシュボード保守の堅牢性が大きく上がる。
+
+### Decision: `system.memory.limit` と `system.host.memory.total` を別メトリクスに分離
+
+- **Context**: コンテナ環境で「コンテナの上限」と「ホストの物理メモリ」のどちらを参照したいかは運用観点が異なる。
+- **Alternatives Considered**:
+  1. 単一メトリクス `system.memory.limit` を cgroup → fallback で `os.totalmem` にする。
+  2. `system.memory.limit` と `system.host.memory.total` を別メトリクスにする。
+- **Selected Approach**: 2。`system.memory.limit` は cgroup limit が取れたときのみ観測、`system.host.memory.total` は常に観測。
+- **Rationale**: 「コンテナ上限の有無」自体が運用上の情報。fallback されると bare-metal でも cgroup でも同じシリーズに混在し、ダッシュボードで見分けが付かない。
+- **Trade-offs**: 出力メトリクス数が 1 つ増えるが、運用観点での明瞭さが勝る。
+- **Follow-up**: ダッシュボード移行時の運用者向け説明に「cgroup limit 未設定では `system.memory.limit` が emit されない」を明記する。
+
+### Decision: サブシステム設定値(`attachment.type` 等)は `growi.configs` のラベルへ統合
+
+- **Context**: GROWI インスタンスの設定値(`wiki_type`, `external_auth_types`, `attachment_type` 等)を Resource Attribute に載せるか、専用 info-gauge のラベルに載せるかという選択。
+- **Alternatives Considered**:
+  1. Resource Attribute として emit する。
+  2. `growi.configs` ObservableGauge(値は常に 1)のラベルへ統合(Prometheus info パターン)。
+  3. 設定値ごとに独立した info gauge を新設する。
+- **Selected Approach**: 2。snake_case 統一の単一 info-gauge のラベル群として集約する。
+- **Rationale**: identity(Resource)と設定値を分離することで Resource を「テレメトリ発生元の不変識別子」として清潔に保てる。複数の設定値を 1 つの info-gauge に集約することで「インスタンス設定を 1 か所で見られる」運用が成立する。
+- **Trade-offs**: `growi.configs` のラベル数は機能追加と共に増える。各値が固定 enum 由来のためカーディナリティ影響は限定的。
+- **Follow-up**: 値の取得不能時は空文字 `''` フォールバックで統一する(`undefined` ラベル attribute が emit されないことを利用しない)。
+
+### Decision: `growi.deployment.type` は OTel 標準 `deployment.environment.name` に寄せない
+
+- **Context**: OTel 標準には `deployment.environment.name`("production"/"staging" 等)があるが、GROWI の `growi.deployment.type`("docker"/"k8s"/"growi-docker-compose" 等)はランタイム形態を表し、環境分類とは別概念。
+- **Selected Approach**: `growi.deployment.type` のまま据え置く(Resource Attribute)。
+- **Rationale**: 値の意味が semconv 標準と乖離するため、無理に標準名を当てると誤解を招く。
+- **Follow-up**: 将来的に「環境(prod/stg)」の表現が必要になった時点で、別途 `deployment.environment.name` を追加導入する。
+
+### Decision: 単一 Meter `growi-system-metrics` で system / process / V8 を束ねる
+
+- **Context**: 既存パターンでは目的別に Meter を分けている(application / user-counts / page-counts)。System / Process / V8 のメトリクス群も同様に分けるか統合するかの判断が必要。
+- **Selected Approach**: System / Process / V8 を `growi-system-metrics` 単一 Meter で束ねる。
+- **Rationale**: いずれも「ランタイム / ホストのリソース観測」という単一目的で、`system.*`/`process.*` の prefix で十分名前空間が分離できる。Meter を分けると `addBatchObservableCallback` の呼び出しと spec も二重になり管理コスト増。
+- **Trade-offs**: 将来「process 系のみオフにする」のような細かい制御が困難になるが、現時点で必要性なし。
+
+### Decision: Anonymization は best-effort, module-based, opt-in
+
+- **Context**: 個人情報(検索クエリ・ページパス・ユーザー名)が `http.target` 経由でトレースに残るリスクを下げたいが、auto-instrumentation の挙動を完全に制御することはできない。
+- **Selected Approach**:
+  1. `otel:anonymizeInBestEffort` が `true` のときのみ `startIncomingSpanHook` を注入。
+  2. handler は `AnonymizationModule` interface に従い、`canHandle` で対象選別 / `handle` で attribute を返す。
+  3. 4 つの handler を配列順で評価し、複数 module がマッチしたら `Object.assign` でマージ。
+- **Rationale**: opt-in にすることで導入リスクを抑え、module 化により拡張時の差分が局所化される。
+- **Trade-offs**: 配列順への暗黙依存があり、追加時に既存 handler との衝突確認が必要。
+
+### Decision: SemConv の不安定 attribute は `semconv.ts` にコピー
+
+- **Context**: `@opentelemetry/semantic-conventions/incubating` は minor リリースで破壊的変更を含む可能性があるとアナウンスされている。
+- **Selected Approach**: `service.instance.id`, `http.target` をローカル定数として保持し、ランタイムコードからは local file のみを import する。
+- **Rationale**: OpenTelemetry の[公式推奨](https://opentelemetry.io/docs/specs/semconv/non-normative/code-generation/#stability-and-versioning)に沿う。
+- **Follow-up**: 該当 attribute が stable promotion されたら、stable import に切り替えて local 定数を撤去(Revalidation Trigger)。
+
+### Decision: Metric export interval は 5 分
+
+- **Context**: メトリクス export 頻度は OTLP 帯域と receiving side の負荷、観測解像度のトレードオフ。
+- **Selected Approach**: `PeriodicExportingMetricReader` の `exportIntervalMillis` を 300000(5 分)に設定。
+- **Rationale**: GROWI のメトリクスは business カウント(users / pages)と config 情報が中心で、秒オーダーの解像度は不要。export 頻度を下げることで OTLP 帯域と receiving side の負荷を抑える。
+- **Trade-offs**: メモリ使用量の急変は最大 5 分遅れて観測される。OOM 直前検知などの用途には不十分だが、本 spec の範囲ではトレードオフを受容する。
+
+### Decision: Auto-instrumentation は pino と fs を除外
+
+- **Context**: `getNodeAutoInstrumentations()` を全有効化すると pino log と fs operation がトレース化される。
+- **Selected Approach**: `@opentelemetry/instrumentation-pino` と `@opentelemetry/instrumentation-fs` を `enabled: false` で明示的に無効化。
+- **Rationale**:
+  - **pino**: GROWI は log signal を OTel に送らない。pino instrumentation はトレースに log を相関させる目的だが、現状は使用しない。
+  - **fs**: ファイル I/O が極めて頻繁で、有効化すると span 量が膨大になる。OpenTelemetry 公式の[ガイド](https://opentelemetry.io/docs/languages/js/libraries/#registration)も無効化を推奨。
+
+### Decision: `service.instance.id` は config 値の passthrough、自動生成しない
+
+- **Context**: OTel SDK には `service.instance.id` を UUID 等で自動生成する resource detector があるが、GROWI ではどう扱うか。
+- **Selected Approach**: `otel:serviceInstanceId`(env: `OPENTELEMETRY_SERVICE_INSTANCE_ID`)を優先、フォールバックで `app:serviceInstanceId`(DB 由来)を使用。両方 undefined の場合は emit しない。
+- **Rationale**: 自動生成すると再起動ごとに ID が変わり「同じ GROWI インスタンス」の経時観測が困難になる。明示的に与えられた ID のみを passthrough することで、運用者がレプリカの境界を制御できる。
+- **Trade-offs**: ID 未指定時に emit されないため、レプリカ識別が必要なクエリは値の有無を考慮する必要がある。
+
+## Risks & Mitigations
+
+- **下流ダッシュボードの参照切れ**: 既存 Resource Attribute / Metric を将来変更した場合、receiving side のクエリが値を返さなくなる。**Mitigation**: PR 説明とリリースノートに「Removed → Replaced by」の対応表を記載する慣習を維持する。
+- **`process.constrainedMemory()` のプラットフォーム依存**: Linux cgroup v1/v2 のみサポートで、macOS/Windows では常に 0 を返す。**Mitigation**: 0 のときは `system.memory.limit` を観測しない挙動が、そのまま非対応プラットフォームの振る舞いと一致するため追加対策不要。
+- **新規メトリクスのカーディナリティ**: 観測値メトリクスは label を持たない gauge であり、追加カーディナリティ寄与はインスタンス分のみ。**Mitigation**: 設計上、観測値メトリクスには attribute を付与しないことを徹底(identity は Resource、設定値は `growi.configs` ラベル経由)。
+- **NodeSDK private アクセスの破綻**: `_resource` プロパティが SDK メジャー更新で消滅する可能性。**Mitigation**: Revalidation Trigger として SDK バージョンアップ時にチェック。public API が出たら即座に切り替え。
+- **Anonymization の網羅性不足**: 新規 API パスが追加されたとき、対応する handler を忘れると平文の URL が span に残る。**Mitigation**: 新規 API 追加時のレビューで `anonymization/handlers/` の更新有無を確認する文化を維持。`handlers/index.ts` の `anonymizationModules` 配列が単一の真実ソース。
+- **SemConv 不安定 attribute の stable promotion 漏れ**: `service.instance.id` / `http.target` が stable 化されているのに local 定数を放置すると、最新 OTLP 受信側との互換性が崩れる可能性。**Mitigation**: `@opentelemetry/semantic-conventions` メジャー / minor 更新時に Revalidation Trigger で見直す。
+
+## References
+
+- [OpenTelemetry Node.js SDK](https://open-telemetry.github.io/opentelemetry-js/)
+- [Custom Metrics Documentation](https://opentelemetry.io/docs/instrumentation/js/manual/#creating-metrics)
+- [HTTP Instrumentation Configuration](https://github.com/open-telemetry/opentelemetry-js/tree/main/experimental/packages/opentelemetry-instrumentation-http#configuration)
+- [Semantic Conventions for System Metrics](https://github.com/open-telemetry/semantic-conventions/blob/main/docs/system/system-metrics.md)
+- [Semantic Conventions for Process](https://opentelemetry.io/docs/specs/semconv/runtime-environment/process/)
+- [Resource Semantic Conventions](https://github.com/open-telemetry/semantic-conventions/blob/main/docs/resource/README.md)
+- [SemConv Stability and Versioning](https://opentelemetry.io/docs/specs/semconv/non-normative/code-generation/#stability-and-versioning) — incubating attribute のローカルコピー推奨。
+- [Node.js process.constrainedMemory()](https://nodejs.org/api/process.html#processconstrainedmemory) — cgroup ベースのメモリ上限取得 API。
+- [Node.js v8.getHeapStatistics()](https://nodejs.org/api/v8.html#v8getheapstatistics) — V8 ヒープ統計取得 API。
+- [OpenTelemetry — disabling instrumentations](https://opentelemetry.io/docs/languages/js/libraries/#registration) — fs instrumentation の無効化推奨。
+- 既存実装: `apps/app/src/features/opentelemetry/server/custom-metrics/application-metrics.ts` — ObservableGauge + addBatchObservableCallback のリファレンス実装。

+ 23 - 0
.kiro/specs/opentelemetry/spec.json

@@ -0,0 +1,23 @@
+{
+  "feature_name": "opentelemetry",
+  "created_at": "2026-05-21T00:00:00.000Z",
+  "updated_at": "2026-05-21T13:20:00.000Z",
+  "language": "ja",
+  "phase": "implementation-complete",
+  "approvals": {
+    "requirements": {
+      "generated": true,
+      "approved": true
+    },
+    "design": {
+      "generated": true,
+      "approved": true
+    },
+    "tasks": {
+      "generated": true,
+      "approved": true
+    }
+  },
+  "ready_for_implementation": true,
+  "cleaned_up_at": "2026-05-21T13:20:00.000Z"
+}

+ 29 - 0
.kiro/specs/opentelemetry/tasks.md

@@ -0,0 +1,29 @@
+# Implementation Plan
+
+本 spec は `features/opentelemetry/` の **大局的なメンテナンス spec** であり、新規実装タスクを抱えないドキュメント spec として扱う。実装は既に完了しており、本ファイルは「将来の拡張時に踏むテンプレート」と「Revalidation の手順」を記録する。
+
+## Implementation Notes
+
+### 拡張時のテンプレート参照
+
+新規 Custom Metric / Anonymization Handler の追加手順は [design.md](./design.md) の **File Structure Plan → Extension Templates** を参照する。テンプレートに沿って実装することで、レビューでの差分が局所化され、本 spec の Boundary Commitments を逸脱しない。
+
+### Revalidation 必要時の対応フロー
+
+[design.md](./design.md) の **Boundary Commitments → Revalidation Triggers** に列挙された条件のいずれかが発生したら、以下を順次実施する:
+
+1. 該当する Boundary Commitments セクションを読み返し、変更が境界内で完結するかを評価。
+2. 境界をまたぐ場合は新規 spec として切り出すか、本 spec の Revalidation Triggers と Design Decisions を更新する。
+3. 受信側ダッシュボード / クエリへの影響がある場合は、PR 説明 / リリースノートに「Removed → Replaced by」の対応表を添える。
+
+### 将来の取り扱い候補(Out of Boundary の再評価候補)
+
+以下は本 spec の Out of Boundary に該当するが、将来の要望次第で別 spec として切り出す候補:
+
+- OpenTelemetry Log Signal の利用開始(pino との統合)。
+- CPU / network / GC / event-loop lag メトリクスの追加。
+- `deployment.environment.name`(OTel 標準)への対応。
+- ブラウザ telemetry(Web SDK)の導入。
+- `@opentelemetry/host-metrics` への置き換え(要件 5 を満たせる版がリリースされたら)。
+
+これらは現時点では要件として上がっていないため、追加要望時に新規 spec を起こす。

+ 0 - 123
apps/app/src/features/opentelemetry/docs/custom-metrics/architecture.md

@@ -1,123 +0,0 @@
-# OpenTelemetry Custom Metrics Architecture
-
-## 概要
-
-GROWIのOpenTelemetryカスタムメトリクスは、以下の3つのカテゴリに分類して実装されています:
-
-1. **Resource Attributes** - システム起動時に設定される静的情報
-2. **Config Metrics** - 設定変更により動的に変わる可能性があるメタデータ
-3. **Custom Metrics** - 時間と共に変化する業務メトリクス
-
-## アーキテクチャ
-
-### Resource Attributes
-
-静的なシステム情報をOpenTelemetryのResource Attributesとして設定します。Resource Attributesは2段階で設定されます:
-
-1. **起動時設定**: OS情報など、データベースアクセスが不要な静的情報
-2. **データベース初期化後設定**: アプリケーション情報など、データベースアクセスが必要な情報
-
-#### 実装場所
-```
-src/features/opentelemetry/server/custom-resource-attributes/
-├── os-resource-attributes.ts        # OS情報 (起動時設定)
-└── application-resource-attributes.ts  # アプリケーション固定情報 (DB初期化後設定)
-```
-
-#### OS情報 (`os-resource-attributes.ts`) - 起動時設定
-- `os.type` - OS種別 (Linux, Windows等)
-- `os.platform` - プラットフォーム (linux, darwin等)
-- `os.arch` - アーキテクチャ (x64, arm64等)
-- `os.totalmem` - 総メモリ量
-
-#### アプリケーション固定情報 (`application-resource-attributes.ts`) - DB初期化後設定
-- `growi.service.type` - サービスタイプ
-- `growi.deployment.type` - デプロイメントタイプ
-- `growi.attachment.type` - ファイルアップロードタイプ
-- `growi.installedAt` - インストール日時
-- `growi.installedAt.by_oldest_user` - 最古ユーザー作成日時
-
-### Config Metrics
-
-設定変更により動的に変わる可能性があるメタデータ実装します。値は常に1で、情報はラベルに格納されます。
-
-#### 実装場所
-```
-src/features/opentelemetry/server/custom-metrics/application-metrics.ts
-```
-
-#### 収集される情報
-- `service_instance_id` - サービスインスタンス識別子
-- `site_url` - サイトURL
-- `wiki_type` - Wiki種別 (open/closed)
-- `external_auth_types` - 有効な外部認証プロバイダー
-
-#### メトリクス例
-```
-growi_info{service_instance_id="abc123",site_url="https://wiki.example.com",wiki_type="open",external_auth_types="github,google"} 1
-```
-
-### Custom Metrics
-
-時間と共に変化する業務メトリクスを実装します。数値として監視・アラートの対象となるメトリクスです。
-
-#### 実装場所
-```
-src/features/opentelemetry/server/custom-metrics/
-├── application-metrics.ts  # Config Metrics (既存)
-└── user-counts-metrics.ts  # ユーザー数メトリクス (新規作成)
-```
-
-#### ユーザー数メトリクス (`user-counts-metrics.ts`)
-- `growi.users.total` - 総ユーザー数
-- `growi.users.active` - アクティブユーザー数
-
-## 収集間隔・設定タイミング
-
-### Resource Attributes
-- **OS情報**: アプリケーション起動時に1回のみ設定
-- **アプリケーション情報**: データベース初期化後に1回のみ設定
-
-### Metrics
-- **Config Metrics**: 60秒間隔で収集 (デフォルト)
-- **Custom Metrics**: 60秒間隔で収集 (デフォルト)
-
-### 2段階設定の理由
-
-Resource Attributesが2段階で設定される理由:
-
-1. **循環依存の回避**: アプリケーション情報の取得にはgrowiInfoServiceが必要だが、OpenTelemetry初期化時点では利用できない
-2. **データベース依存**: インストール日時やサービス設定などはデータベースから取得する必要がある
-3. **起動時間の最適化**: データベース接続を待たずにOpenTelemetryの基本機能を開始できる
-
-## 設定の変更
-
-メトリクス収集間隔は `PeriodicExportingMetricReader` の `exportIntervalMillis` で変更可能です:
-
-```typescript
-metricReader: new PeriodicExportingMetricReader({
-  exporter: new OTLPMetricExporter(),
-  exportIntervalMillis: 30000, // 30秒間隔
-}),
-```
-
-## 使用例
-
-### Prometheusでのクエリ例
-
-```promql
-# 総ユーザー数の推移
-growi_users_total
-
-# Wiki種別でグループ化した情報
-growi_info{wiki_type="open"}
-
-# 外部認証を使用しているインスタンス
-growi_info{external_auth_types!=""}
-```
-
-### Grafanaでの可視化例
-
-- ユーザー数の時系列グラフ
-- Wiki種別の分布円グラフ
-- 外部認証プロバイダーの利用状況

+ 0 - 87
apps/app/src/features/opentelemetry/docs/custom-metrics/implementation-guide.md

@@ -1,87 +0,0 @@
-# OpenTelemetry Custom Metrics Implementation Guide
-
-## 改修実装状況
-
-### ✅ 完了した実装
-
-#### 1. Resource Attributes
-- **OS情報**: `src/features/opentelemetry/server/custom-resource-attributes/os-resource-attributes.ts`
-  - OS種別、プラットフォーム、アーキテクチャ、総メモリ量
-  - 起動時に設定
-- **アプリケーション固定情報**: `src/features/opentelemetry/server/custom-resource-attributes/application-resource-attributes.ts`
-  - サービス・デプロイメントタイプ、添付ファイルタイプ、インストール情報
-  - データベース初期化後に設定
-
-#### 2. Config Metrics
-- **実装場所**: `src/features/opentelemetry/server/custom-metrics/application-metrics.ts`
-- **メトリクス**: `growi.configs` (値は常に1、情報はラベルに格納)
-- **収集情報**: サービスインスタンスID、サイトURL、Wiki種別、外部認証タイプ
-
-#### 3. Custom Metrics
-- **実装場所**: `src/features/opentelemetry/server/custom-metrics/user-counts-metrics.ts`
-- **メトリクス**: 
-  - `growi.users.total` - 総ユーザー数
-  - `growi.users.active` - アクティブユーザー数
-
-#### 4. 統合作業
-- **node-sdk-configuration.ts**: OS情報のResource Attributes統合済み
-- **node-sdk.ts**: データベース初期化後のアプリケーション情報設定統合済み
-- **メトリクス初期化**: Config MetricsとCustom Metricsの初期化統合済み
-
-### 📋 実装済みの統合
-
-#### Resource Attributesの2段階設定
-
-**1段階目 (起動時)**: `generateNodeSDKConfiguration`
-```typescript
-// OS情報のみでResourceを作成
-const osAttributes = getOsResourceAttributes();
-resource = resourceFromAttributes({
-  [ATTR_SERVICE_NAME]: 'growi',
-  [ATTR_SERVICE_VERSION]: version,
-  ...osAttributes,
-});
-```
-
-**2段階目 (DB初期化後)**: `setupAdditionalResourceAttributes`
-```typescript
-// アプリケーション情報とサービスインスタンスIDを追加
-const appAttributes = await getApplicationResourceAttributes();
-if (serviceInstanceId != null) {
-  appAttributes[ATTR_SERVICE_INSTANCE_ID] = serviceInstanceId;
-}
-const updatedResource = await generateAdditionalResourceAttributes(appAttributes);
-setResource(sdkInstance, updatedResource);
-```
-
-#### メトリクス収集の統合
-```typescript
-// generateNodeSDKConfiguration内で初期化
-addApplicationMetrics();
-addUserCountsMetrics();
-```
-
-## ファイル構成
-
-```
-src/features/opentelemetry/server/
-├── custom-resource-attributes/
-│   ├── index.ts                           # エクスポート用インデックス
-│   ├── os-resource-attributes.ts          # OS情報
-│   └── application-resource-attributes.ts # アプリケーション情報
-├── custom-metrics/
-│   ├── application-metrics.ts             # Config Metrics (更新済み)
-│   └── user-counts-metrics.ts             # ユーザー数メトリクス (新規)
-└── docs/
-    ├── custom-metrics-architecture.md     # アーキテクチャ文書
-    └── implementation-guide.md            # このファイル
-```
-
-## 設計のポイント
-
-1. **2段階Resource設定**: データベース依存の情報は初期化後に設定して循環依存を回避
-2. **循環依存の回避**: 動的importを使用してgrowiInfoServiceを読み込み
-3. **エラーハンドリング**: 各メトリクス収集でtry-catchを実装
-4. **型安全性**: Optional chainingを使用してundefinedを適切に処理
-5. **ログ出力**: デバッグ用のログを各段階で出力
-6. **起動時間の最適化**: データベース接続を待たずにOpenTelemetryの基本機能を開始

+ 0 - 49
apps/app/src/features/opentelemetry/docs/overview.md

@@ -1,49 +0,0 @@
-# OpenTelemetry Overview
-
-## 現在の実装状況
-
-### 基本機能
-- ✅ **Trace収集**: HTTP、Database等の自動インストルメンテーション
-- ✅ **Metrics収集**: 基本的なアプリケーションメトリクス
-- ✅ **OTLP Export**: gRPCでのデータ送信
-- ✅ **設定管理**: 環境変数による有効/無効制御
-
-### アーキテクチャ
-```
-[GROWI App] → [NodeSDK] → [Auto Instrumentations] → [OTLP Exporter] → [Collector]
-```
-
-### 実装ファイル
-| ファイル | 責務 |
-|---------|------|
-| `node-sdk.ts` | SDK初期化・管理 |
-| `node-sdk-configuration.ts` | 設定生成 |
-| `node-sdk-resource.ts` | リソース属性管理 |
-| `logger.ts` | 診断ログ |
-
-### 設定項目
-| 環境変数 | デフォルト | 説明 |
-|---------|-----------|------|
-| `OTEL_ENABLED` | `false` | 有効/無効 |
-| `OTEL_EXPORTER_OTLP_ENDPOINT` | `http://localhost:4317` | エクスポート先 |
-| `OTEL_SERVICE_NAME` | `growi` | サービス名 |
-| `OTEL_SERVICE_VERSION` | 自動 | バージョン |
-
-### データフロー
-1. **Auto Instrumentation** でHTTP/DB操作を自動計測
-2. **NodeSDK** がスパン・メトリクスを収集
-3. **OTLP Exporter** が外部Collectorに送信
-
-## 制限事項
-- 機密データの匿名化未実装
-- GROWIアプリ固有の情報未送信
-
-## 参考情報
-- [OpenTelemetry Node.js SDK](https://open-telemetry.github.io/opentelemetry-js/)
-- [Custom Metrics Documentation](https://opentelemetry.io/docs/instrumentation/js/manual/#creating-metrics)
-- [HTTP Instrumentation Configuration](https://github.com/open-telemetry/opentelemetry-js/tree/main/experimental/packages/opentelemetry-instrumentation-http#configuration)
-- [Semantic Conventions for System Metrics](https://github.com/open-telemetry/semantic-conventions/blob/main/docs/system/system-metrics.md)
-- [Resource Semantic Conventions](https://github.com/open-telemetry/semantic-conventions/blob/main/docs/resource/README.md)
-
----
-*更新日: 2025-06-19*

+ 6 - 0
apps/app/src/features/opentelemetry/server/custom-metrics/application-metrics.spec.ts

@@ -74,6 +74,7 @@ describe('addApplicationMetrics', () => {
       wikiType: 'open',
       wikiType: 'open',
       additionalInfo: {
       additionalInfo: {
         activeExternalAccountTypes: ['google', 'github'],
         activeExternalAccountTypes: ['google', 'github'],
+        attachmentType: 'aws',
       },
       },
     };
     };
 
 
@@ -102,6 +103,7 @@ describe('addApplicationMetrics', () => {
         site_url_hashed: undefined,
         site_url_hashed: undefined,
         wiki_type: 'open',
         wiki_type: 'open',
         external_auth_types: 'google,github',
         external_auth_types: 'google,github',
+        attachment_type: 'aws',
       });
       });
     });
     });
 
 
@@ -131,6 +133,7 @@ describe('addApplicationMetrics', () => {
         site_url_hashed: expectedHash,
         site_url_hashed: expectedHash,
         wiki_type: 'open',
         wiki_type: 'open',
         external_auth_types: 'google,github',
         external_auth_types: 'google,github',
+        attachment_type: 'aws',
       });
       });
     });
     });
 
 
@@ -145,6 +148,7 @@ describe('addApplicationMetrics', () => {
         ...mockGrowiInfo,
         ...mockGrowiInfo,
         additionalInfo: {
         additionalInfo: {
           activeExternalAccountTypes: [],
           activeExternalAccountTypes: [],
+          attachmentType: 'aws',
         },
         },
       };
       };
       mockGrowiInfoService.getGrowiInfo.mockResolvedValue(growiInfoWithoutAuth);
       mockGrowiInfoService.getGrowiInfo.mockResolvedValue(growiInfoWithoutAuth);
@@ -159,6 +163,7 @@ describe('addApplicationMetrics', () => {
         site_url_hashed: undefined,
         site_url_hashed: undefined,
         wiki_type: 'open',
         wiki_type: 'open',
         external_auth_types: '',
         external_auth_types: '',
+        attachment_type: 'aws',
       });
       });
     });
     });
 
 
@@ -212,6 +217,7 @@ describe('addApplicationMetrics', () => {
         site_url_hashed: undefined,
         site_url_hashed: undefined,
         wiki_type: 'open',
         wiki_type: 'open',
         external_auth_types: '',
         external_auth_types: '',
+        attachment_type: '',
       });
       });
     });
     });
   });
   });

+ 1 - 0
apps/app/src/features/opentelemetry/server/custom-metrics/application-metrics.ts

@@ -55,6 +55,7 @@ export function addApplicationMetrics(): void {
           external_auth_types:
           external_auth_types:
             growiInfo.additionalInfo?.activeExternalAccountTypes?.join(',') ||
             growiInfo.additionalInfo?.activeExternalAccountTypes?.join(',') ||
             '',
             '',
+          attachment_type: growiInfo.additionalInfo?.attachmentType ?? '',
         });
         });
       } catch (error) {
       } catch (error) {
         loggerDiag.error('Failed to collect application config metrics', {
         loggerDiag.error('Failed to collect application config metrics', {

+ 3 - 0
apps/app/src/features/opentelemetry/server/custom-metrics/index.ts

@@ -1,14 +1,17 @@
 export { addApplicationMetrics } from './application-metrics';
 export { addApplicationMetrics } from './application-metrics';
 export { addPageCountsMetrics } from './page-counts-metrics';
 export { addPageCountsMetrics } from './page-counts-metrics';
+export { addSystemMetrics } from './system-metrics';
 export { addUserCountsMetrics } from './user-counts-metrics';
 export { addUserCountsMetrics } from './user-counts-metrics';
 
 
 export const setupCustomMetrics = async (): Promise<void> => {
 export const setupCustomMetrics = async (): Promise<void> => {
   const { addApplicationMetrics } = await import('./application-metrics');
   const { addApplicationMetrics } = await import('./application-metrics');
   const { addUserCountsMetrics } = await import('./user-counts-metrics');
   const { addUserCountsMetrics } = await import('./user-counts-metrics');
   const { addPageCountsMetrics } = await import('./page-counts-metrics');
   const { addPageCountsMetrics } = await import('./page-counts-metrics');
+  const { addSystemMetrics } = await import('./system-metrics');
 
 
   // Add custom metrics
   // Add custom metrics
   addApplicationMetrics();
   addApplicationMetrics();
   addUserCountsMetrics();
   addUserCountsMetrics();
   addPageCountsMetrics();
   addPageCountsMetrics();
+  addSystemMetrics();
 };
 };

+ 373 - 0
apps/app/src/features/opentelemetry/server/custom-metrics/system-metrics.spec.ts

@@ -0,0 +1,373 @@
+import * as os from 'node:os';
+import * as v8 from 'node:v8';
+import { type Meter, metrics, type ObservableGauge } from '@opentelemetry/api';
+import { mock } from 'vitest-mock-extended';
+
+import { addSystemMetrics } from './system-metrics';
+
+// vi.hoisted ensures the factory runs before vi.mock factories (which are also hoisted).
+// This is needed because diag.createComponentLogger() is called at module-load time in
+// system-metrics.ts, so the mock must already hold the reference when the module is imported.
+const { diagErrorMock } = vi.hoisted(() => ({
+  diagErrorMock: { error: vi.fn() },
+}));
+
+// Mock external dependencies
+vi.mock('node:os', () => ({
+  totalmem: vi.fn(),
+}));
+vi.mock('node:v8', () => ({
+  getHeapStatistics: vi.fn(),
+}));
+vi.mock('~/utils/logger', () => ({
+  default: () => ({
+    info: vi.fn(),
+  }),
+}));
+
+vi.mock('@opentelemetry/api', () => ({
+  diag: {
+    createComponentLogger: () => diagErrorMock,
+  },
+  metrics: {
+    getMeter: vi.fn(),
+  },
+}));
+
+describe('addSystemMetrics', () => {
+  const mockMeter = mock<Meter>();
+  const mockGauges: ObservableGauge[] = Array.from({ length: 6 }, () =>
+    mock<ObservableGauge>(),
+  );
+
+  // Assign individual gauges for assertion clarity
+  let mockMemoryLimitGauge: ObservableGauge;
+  let mockHostMemoryTotalGauge: ObservableGauge;
+  let mockProcessMemoryUsageGauge: ObservableGauge;
+  let mockV8HeapUsedGauge: ObservableGauge;
+  let mockV8HeapTotalGauge: ObservableGauge;
+  let mockV8HeapExternalGauge: ObservableGauge;
+
+  beforeEach(() => {
+    vi.clearAllMocks();
+    diagErrorMock.error.mockReset();
+
+    vi.mocked(metrics.getMeter).mockReturnValue(mockMeter);
+
+    // Return different gauge mocks for each createObservableGauge call
+    let callCount = 0;
+    mockMeter.createObservableGauge.mockImplementation(
+      () => mockGauges[callCount++],
+    );
+
+    [
+      mockMemoryLimitGauge,
+      mockHostMemoryTotalGauge,
+      mockProcessMemoryUsageGauge,
+      mockV8HeapUsedGauge,
+      mockV8HeapTotalGauge,
+      mockV8HeapExternalGauge,
+    ] = mockGauges;
+  });
+
+  afterEach(() => {
+    vi.restoreAllMocks();
+  });
+
+  describe('meter and gauge setup', () => {
+    it('should create meter with correct name and version', () => {
+      addSystemMetrics();
+
+      expect(metrics.getMeter).toHaveBeenCalledWith(
+        'growi-system-metrics',
+        '1.0.0',
+      );
+      expect(metrics.getMeter).toHaveBeenCalledTimes(1);
+    });
+
+    it('should create 6 ObservableGauges all with unit By', () => {
+      addSystemMetrics();
+
+      expect(mockMeter.createObservableGauge).toHaveBeenCalledTimes(6);
+
+      const calls = mockMeter.createObservableGauge.mock.calls;
+      const names = calls.map(([name]) => name);
+      expect(names).toContain('system.memory.limit');
+      expect(names).toContain('system.host.memory.total');
+      expect(names).toContain('process.memory.usage');
+      expect(names).toContain('process.runtime.v8.heap.used');
+      expect(names).toContain('process.runtime.v8.heap.total');
+      expect(names).toContain('process.runtime.v8.heap.external');
+
+      // All gauges must use unit 'By'
+      for (const [, options] of calls) {
+        expect(options).toMatchObject({ unit: 'By' });
+      }
+    });
+
+    it('should register a single addBatchObservableCallback with all 6 gauges', () => {
+      addSystemMetrics();
+
+      expect(mockMeter.addBatchObservableCallback).toHaveBeenCalledTimes(1);
+
+      const [, gaugeArray] = mockMeter.addBatchObservableCallback.mock.calls[0];
+      expect(gaugeArray).toHaveLength(6);
+      expect(gaugeArray).toContain(mockMemoryLimitGauge);
+      expect(gaugeArray).toContain(mockHostMemoryTotalGauge);
+      expect(gaugeArray).toContain(mockProcessMemoryUsageGauge);
+      expect(gaugeArray).toContain(mockV8HeapUsedGauge);
+      expect(gaugeArray).toContain(mockV8HeapTotalGauge);
+      expect(gaugeArray).toContain(mockV8HeapExternalGauge);
+    });
+  });
+
+  describe('callback behavior — constrainedMemory > 0', () => {
+    it('should observe system.memory.limit when constrainedMemory returns a positive value (Req 3.1)', async () => {
+      const constrainedMemoryValue = 4_294_967_296; // 4 GiB
+      vi.spyOn(process, 'constrainedMemory').mockReturnValue(
+        constrainedMemoryValue,
+      );
+      vi.spyOn(process, 'memoryUsage').mockReturnValue({
+        rss: 100_000_000,
+        heapUsed: 50_000_000,
+        heapTotal: 80_000_000,
+        external: 5_000_000,
+        arrayBuffers: 1_000_000,
+      });
+      vi.mocked(os.totalmem).mockReturnValue(8_589_934_592);
+      vi.mocked(v8.getHeapStatistics).mockReturnValue({
+        total_heap_size: 80_000_000,
+        total_heap_size_executable: 0,
+        total_physical_size: 0,
+        total_available_size: 0,
+        used_heap_size: 50_000_000,
+        heap_size_limit: 0,
+        malloced_memory: 0,
+        peak_malloced_memory: 0,
+        does_zap_garbage: 0,
+        number_of_native_contexts: 0,
+        number_of_detached_contexts: 0,
+        total_global_handles_size: 0,
+        used_global_handles_size: 0,
+        external_memory: 0,
+      });
+
+      addSystemMetrics();
+
+      const mockResult = { observe: vi.fn() };
+      const callback = mockMeter.addBatchObservableCallback.mock.calls[0][0];
+      await callback(mockResult);
+
+      expect(mockResult.observe).toHaveBeenCalledWith(
+        mockMemoryLimitGauge,
+        constrainedMemoryValue,
+      );
+    });
+  });
+
+  describe('callback behavior — constrainedMemory === 0', () => {
+    it('should NOT observe system.memory.limit when constrainedMemory returns 0 (Req 3.2)', async () => {
+      vi.spyOn(process, 'constrainedMemory').mockReturnValue(0);
+      vi.spyOn(process, 'memoryUsage').mockReturnValue({
+        rss: 100_000_000,
+        heapUsed: 50_000_000,
+        heapTotal: 80_000_000,
+        external: 5_000_000,
+        arrayBuffers: 1_000_000,
+      });
+      vi.mocked(os.totalmem).mockReturnValue(8_589_934_592);
+      vi.mocked(v8.getHeapStatistics).mockReturnValue({
+        total_heap_size: 80_000_000,
+        total_heap_size_executable: 0,
+        total_physical_size: 0,
+        total_available_size: 0,
+        used_heap_size: 50_000_000,
+        heap_size_limit: 0,
+        malloced_memory: 0,
+        peak_malloced_memory: 0,
+        does_zap_garbage: 0,
+        number_of_native_contexts: 0,
+        number_of_detached_contexts: 0,
+        total_global_handles_size: 0,
+        used_global_handles_size: 0,
+        external_memory: 0,
+      });
+
+      addSystemMetrics();
+
+      const mockResult = { observe: vi.fn() };
+      const callback = mockMeter.addBatchObservableCallback.mock.calls[0][0];
+      await callback(mockResult);
+
+      // system.memory.limit must NOT be observed
+      expect(mockResult.observe).not.toHaveBeenCalledWith(
+        mockMemoryLimitGauge,
+        expect.anything(),
+      );
+
+      // All other 5 gauges must still be observed
+      expect(mockResult.observe).toHaveBeenCalledWith(
+        mockHostMemoryTotalGauge,
+        expect.any(Number),
+      );
+      expect(mockResult.observe).toHaveBeenCalledWith(
+        mockProcessMemoryUsageGauge,
+        expect.any(Number),
+      );
+      expect(mockResult.observe).toHaveBeenCalledWith(
+        mockV8HeapUsedGauge,
+        expect.any(Number),
+      );
+      expect(mockResult.observe).toHaveBeenCalledWith(
+        mockV8HeapTotalGauge,
+        expect.any(Number),
+      );
+      expect(mockResult.observe).toHaveBeenCalledWith(
+        mockV8HeapExternalGauge,
+        expect.any(Number),
+      );
+      expect(mockResult.observe).toHaveBeenCalledTimes(5);
+    });
+  });
+
+  describe('callback behavior — metric values', () => {
+    beforeEach(() => {
+      vi.spyOn(process, 'constrainedMemory').mockReturnValue(0);
+      vi.spyOn(process, 'memoryUsage').mockReturnValue({
+        rss: 111_222_333,
+        heapUsed: 44_455_566,
+        heapTotal: 77_888_999,
+        external: 12_345_678,
+        arrayBuffers: 1_000_000,
+      });
+      vi.mocked(os.totalmem).mockReturnValue(16_000_000_000);
+      vi.mocked(v8.getHeapStatistics).mockReturnValue({
+        total_heap_size: 77_888_999,
+        total_heap_size_executable: 0,
+        total_physical_size: 0,
+        total_available_size: 0,
+        used_heap_size: 44_455_566,
+        heap_size_limit: 0,
+        malloced_memory: 0,
+        peak_malloced_memory: 0,
+        does_zap_garbage: 0,
+        number_of_native_contexts: 0,
+        number_of_detached_contexts: 0,
+        total_global_handles_size: 0,
+        used_global_handles_size: 0,
+        external_memory: 0,
+      });
+    });
+
+    it('should observe system.host.memory.total from os.totalmem() (Req 3.3)', async () => {
+      addSystemMetrics();
+
+      const mockResult = { observe: vi.fn() };
+      const callback = mockMeter.addBatchObservableCallback.mock.calls[0][0];
+      await callback(mockResult);
+
+      expect(mockResult.observe).toHaveBeenCalledWith(
+        mockHostMemoryTotalGauge,
+        16_000_000_000,
+      );
+    });
+
+    it('should observe process.memory.usage from process.memoryUsage().rss (Req 4.1)', async () => {
+      addSystemMetrics();
+
+      const mockResult = { observe: vi.fn() };
+      const callback = mockMeter.addBatchObservableCallback.mock.calls[0][0];
+      await callback(mockResult);
+
+      expect(mockResult.observe).toHaveBeenCalledWith(
+        mockProcessMemoryUsageGauge,
+        111_222_333,
+      );
+    });
+
+    it('should observe v8.heap.used from v8.getHeapStatistics().used_heap_size (Req 4.2)', async () => {
+      addSystemMetrics();
+
+      const mockResult = { observe: vi.fn() };
+      const callback = mockMeter.addBatchObservableCallback.mock.calls[0][0];
+      await callback(mockResult);
+
+      expect(mockResult.observe).toHaveBeenCalledWith(
+        mockV8HeapUsedGauge,
+        44_455_566,
+      );
+    });
+
+    it('should observe v8.heap.total from v8.getHeapStatistics().total_heap_size (Req 4.3)', async () => {
+      addSystemMetrics();
+
+      const mockResult = { observe: vi.fn() };
+      const callback = mockMeter.addBatchObservableCallback.mock.calls[0][0];
+      await callback(mockResult);
+
+      expect(mockResult.observe).toHaveBeenCalledWith(
+        mockV8HeapTotalGauge,
+        77_888_999,
+      );
+    });
+
+    it('should observe v8.heap.external from process.memoryUsage().external (Req 4.4)', async () => {
+      addSystemMetrics();
+
+      const mockResult = { observe: vi.fn() };
+      const callback = mockMeter.addBatchObservableCallback.mock.calls[0][0];
+      await callback(mockResult);
+
+      expect(mockResult.observe).toHaveBeenCalledWith(
+        mockV8HeapExternalGauge,
+        12_345_678,
+      );
+    });
+
+    it('should call process.memoryUsage() exactly once per callback invocation (efficiency)', async () => {
+      addSystemMetrics();
+
+      const mockResult = { observe: vi.fn() };
+      const callback = mockMeter.addBatchObservableCallback.mock.calls[0][0];
+      await callback(mockResult);
+
+      expect(process.memoryUsage).toHaveBeenCalledTimes(1);
+    });
+
+    it('should call v8.getHeapStatistics() exactly once per callback invocation (efficiency)', async () => {
+      addSystemMetrics();
+
+      const mockResult = { observe: vi.fn() };
+      const callback = mockMeter.addBatchObservableCallback.mock.calls[0][0];
+      await callback(mockResult);
+
+      expect(v8.getHeapStatistics).toHaveBeenCalledTimes(1);
+    });
+  });
+
+  describe('error handling', () => {
+    it('should call loggerDiag.error and not call observe when an error occurs in callback (Req 5.2)', async () => {
+      const testError = new Error('Simulated metric collection failure');
+      vi.spyOn(process, 'constrainedMemory').mockImplementation(() => {
+        throw testError;
+      });
+
+      addSystemMetrics();
+
+      const mockResult = { observe: vi.fn() };
+      const callback = mockMeter.addBatchObservableCallback.mock.calls[0][0];
+
+      // Should not throw
+      await expect(callback(mockResult)).resolves.toBeUndefined();
+
+      // loggerDiag.error must be called with the error
+      expect(diagErrorMock.error).toHaveBeenCalledWith(
+        'Failed to collect system metrics',
+        { error: testError },
+      );
+
+      // observe must never be called
+      expect(mockResult.observe).not.toHaveBeenCalled();
+    });
+  });
+});

+ 93 - 0
apps/app/src/features/opentelemetry/server/custom-metrics/system-metrics.ts

@@ -0,0 +1,93 @@
+import * as os from 'node:os';
+import * as v8 from 'node:v8';
+import { diag, metrics } from '@opentelemetry/api';
+
+import loggerFactory from '~/utils/logger';
+
+const logger = loggerFactory('growi:opentelemetry:custom-metrics:system');
+const loggerDiag = diag.createComponentLogger({
+  namespace: 'growi:custom-metrics:system',
+});
+
+export function addSystemMetrics(): void {
+  logger.info('Starting system metrics collection');
+
+  const meter = metrics.getMeter('growi-system-metrics', '1.0.0');
+
+  const memoryLimitGauge = meter.createObservableGauge('system.memory.limit', {
+    description: 'Container or OS-imposed memory limit for this process',
+    unit: 'By',
+  });
+  const hostMemoryTotalGauge = meter.createObservableGauge(
+    'system.host.memory.total',
+    {
+      description: 'Total physical memory available on the host',
+      unit: 'By',
+    },
+  );
+  const processMemoryUsageGauge = meter.createObservableGauge(
+    'process.memory.usage',
+    {
+      description: 'Resident Set Size — physical memory in use by this process',
+      unit: 'By',
+    },
+  );
+  const v8HeapUsedGauge = meter.createObservableGauge(
+    'process.runtime.v8.heap.used',
+    {
+      description: 'V8 heap memory currently in use',
+      unit: 'By',
+    },
+  );
+  const v8HeapTotalGauge = meter.createObservableGauge(
+    'process.runtime.v8.heap.total',
+    {
+      description: 'Total V8 heap memory allocated',
+      unit: 'By',
+    },
+  );
+  const v8HeapExternalGauge = meter.createObservableGauge(
+    'process.runtime.v8.heap.external',
+    {
+      description: 'External memory referenced by V8 objects (e.g. Buffers)',
+      unit: 'By',
+    },
+  );
+
+  meter.addBatchObservableCallback(
+    async (result) => {
+      try {
+        // process.constrainedMemory() is available in Node.js >=19.6.0.
+        // On older versions it may not exist; guard with a falsy check.
+        const constrainedMemory =
+          (
+            process as NodeJS.Process & { constrainedMemory?(): number }
+          ).constrainedMemory?.() ?? 0;
+        // Call each system API exactly once per collection cycle.
+        const memUsage = process.memoryUsage();
+        const heapStats = v8.getHeapStatistics();
+
+        if (constrainedMemory) {
+          result.observe(memoryLimitGauge, constrainedMemory);
+        }
+        result.observe(hostMemoryTotalGauge, os.totalmem());
+        result.observe(processMemoryUsageGauge, memUsage.rss);
+        result.observe(v8HeapUsedGauge, heapStats.used_heap_size);
+        result.observe(v8HeapTotalGauge, heapStats.total_heap_size);
+        result.observe(v8HeapExternalGauge, memUsage.external);
+      } catch (error) {
+        loggerDiag.error('Failed to collect system metrics', { error });
+      }
+    },
+    [
+      memoryLimitGauge,
+      hostMemoryTotalGauge,
+      processMemoryUsageGauge,
+      v8HeapUsedGauge,
+      v8HeapTotalGauge,
+      v8HeapExternalGauge,
+    ],
+  );
+
+  logger.info('System metrics collection started successfully');
+}

+ 8 - 11
apps/app/src/features/opentelemetry/server/custom-resource-attributes/application-resource-attributes.spec.ts

@@ -21,13 +21,10 @@ describe('getApplicationResourceAttributes', () => {
     vi.clearAllMocks();
     vi.clearAllMocks();
   });
   });
 
 
-  it('should return complete application resource attributes when growi info is available', async () => {
+  it('should return only service and deployment type attributes when growi info is available', async () => {
     const mockGrowiInfo = {
     const mockGrowiInfo = {
       type: 'app',
       type: 'app',
       deploymentType: 'standalone',
       deploymentType: 'standalone',
-      additionalInfo: {
-        attachmentType: 'local',
-      },
     };
     };
 
 
     mockGrowiInfoService.getGrowiInfo.mockResolvedValue(mockGrowiInfo);
     mockGrowiInfoService.getGrowiInfo.mockResolvedValue(mockGrowiInfo);
@@ -37,28 +34,28 @@ describe('getApplicationResourceAttributes', () => {
     expect(result).toEqual({
     expect(result).toEqual({
       'growi.service.type': 'app',
       'growi.service.type': 'app',
       'growi.deployment.type': 'standalone',
       'growi.deployment.type': 'standalone',
-      'growi.attachment.type': 'local',
-    });
-    expect(mockGrowiInfoService.getGrowiInfo).toHaveBeenCalledWith({
-      includeAttachmentInfo: true,
     });
     });
+    expect(result).not.toHaveProperty('growi.attachment.type');
+    expect(mockGrowiInfoService.getGrowiInfo).toHaveBeenCalledWith({});
   });
   });
 
 
-  it('should handle missing additionalInfo gracefully', async () => {
+  it('should not include growi.attachment.type even when additionalInfo is present', async () => {
     const mockGrowiInfo = {
     const mockGrowiInfo = {
       type: 'app',
       type: 'app',
       deploymentType: 'standalone',
       deploymentType: 'standalone',
-      additionalInfo: undefined,
+      additionalInfo: {
+        attachmentType: 'local',
+      },
     };
     };
 
 
     mockGrowiInfoService.getGrowiInfo.mockResolvedValue(mockGrowiInfo);
     mockGrowiInfoService.getGrowiInfo.mockResolvedValue(mockGrowiInfo);
 
 
     const result = await getApplicationResourceAttributes();
     const result = await getApplicationResourceAttributes();
 
 
+    expect(result).not.toHaveProperty('growi.attachment.type');
     expect(result).toEqual({
     expect(result).toEqual({
       'growi.service.type': 'app',
       'growi.service.type': 'app',
       'growi.deployment.type': 'standalone',
       'growi.deployment.type': 'standalone',
-      'growi.attachment.type': undefined,
     });
     });
   });
   });
 
 

+ 1 - 4
apps/app/src/features/opentelemetry/server/custom-resource-attributes/application-resource-attributes.ts

@@ -17,15 +17,12 @@ export async function getApplicationResourceAttributes(): Promise<Attributes> {
     // Dynamic import to avoid circular dependencies
     // Dynamic import to avoid circular dependencies
     const { growiInfoService } = await import('~/server/service/growi-info');
     const { growiInfoService } = await import('~/server/service/growi-info');
 
 
-    const growiInfo = await growiInfoService.getGrowiInfo({
-      includeAttachmentInfo: true,
-    });
+    const growiInfo = await growiInfoService.getGrowiInfo({});
 
 
     const attributes: Attributes = {
     const attributes: Attributes = {
       // Service configuration (rarely changes after system setup)
       // Service configuration (rarely changes after system setup)
       'growi.service.type': growiInfo.type,
       'growi.service.type': growiInfo.type,
       'growi.deployment.type': growiInfo.deploymentType,
       'growi.deployment.type': growiInfo.deploymentType,
-      'growi.attachment.type': growiInfo.additionalInfo?.attachmentType,
     };
     };
 
 
     logger.info({ attributes }, 'Application resource attributes collected');
     logger.info({ attributes }, 'Application resource attributes collected');

+ 2 - 12
apps/app/src/features/opentelemetry/server/custom-resource-attributes/os-resource-attributes.spec.ts

@@ -5,7 +5,6 @@ vi.mock('node:os', () => ({
   type: vi.fn(),
   type: vi.fn(),
   platform: vi.fn(),
   platform: vi.fn(),
   arch: vi.fn(),
   arch: vi.fn(),
-  totalmem: vi.fn(),
 }));
 }));
 
 
 describe('getOsResourceAttributes', () => {
 describe('getOsResourceAttributes', () => {
@@ -13,7 +12,6 @@ describe('getOsResourceAttributes', () => {
     type: ReturnType<typeof vi.fn>;
     type: ReturnType<typeof vi.fn>;
     platform: ReturnType<typeof vi.fn>;
     platform: ReturnType<typeof vi.fn>;
     arch: ReturnType<typeof vi.fn>;
     arch: ReturnType<typeof vi.fn>;
-    totalmem: ReturnType<typeof vi.fn>;
   };
   };
 
 
   beforeEach(async () => {
   beforeEach(async () => {
@@ -28,13 +26,11 @@ describe('getOsResourceAttributes', () => {
       type: 'Linux',
       type: 'Linux',
       platform: 'linux' as const,
       platform: 'linux' as const,
       arch: 'x64',
       arch: 'x64',
-      totalmem: 16777216000,
     };
     };
 
 
     mockOs.type.mockReturnValue(mockOsData.type);
     mockOs.type.mockReturnValue(mockOsData.type);
     mockOs.platform.mockReturnValue(mockOsData.platform);
     mockOs.platform.mockReturnValue(mockOsData.platform);
     mockOs.arch.mockReturnValue(mockOsData.arch);
     mockOs.arch.mockReturnValue(mockOsData.arch);
-    mockOs.totalmem.mockReturnValue(mockOsData.totalmem);
 
 
     const result = getOsResourceAttributes();
     const result = getOsResourceAttributes();
 
 
@@ -42,8 +38,8 @@ describe('getOsResourceAttributes', () => {
       'os.type': 'Linux',
       'os.type': 'Linux',
       'os.platform': 'linux',
       'os.platform': 'linux',
       'os.arch': 'x64',
       'os.arch': 'x64',
-      'os.totalmem': 16777216000,
     });
     });
+    expect(result).not.toHaveProperty('os.totalmem');
   });
   });
 
 
   it('should call all required os module functions', () => {
   it('should call all required os module functions', () => {
@@ -51,14 +47,12 @@ describe('getOsResourceAttributes', () => {
     mockOs.type.mockReturnValue('Linux');
     mockOs.type.mockReturnValue('Linux');
     mockOs.platform.mockReturnValue('linux');
     mockOs.platform.mockReturnValue('linux');
     mockOs.arch.mockReturnValue('x64');
     mockOs.arch.mockReturnValue('x64');
-    mockOs.totalmem.mockReturnValue(16777216000);
 
 
     getOsResourceAttributes();
     getOsResourceAttributes();
 
 
     expect(mockOs.type).toHaveBeenCalledOnce();
     expect(mockOs.type).toHaveBeenCalledOnce();
     expect(mockOs.platform).toHaveBeenCalledOnce();
     expect(mockOs.platform).toHaveBeenCalledOnce();
     expect(mockOs.arch).toHaveBeenCalledOnce();
     expect(mockOs.arch).toHaveBeenCalledOnce();
-    expect(mockOs.totalmem).toHaveBeenCalledOnce();
   });
   });
 
 
   it('should handle different OS types correctly', () => {
   it('should handle different OS types correctly', () => {
@@ -68,13 +62,11 @@ describe('getOsResourceAttributes', () => {
           type: 'Windows_NT',
           type: 'Windows_NT',
           platform: 'win32',
           platform: 'win32',
           arch: 'x64',
           arch: 'x64',
-          totalmem: 8589934592,
         },
         },
         expected: {
         expected: {
           'os.type': 'Windows_NT',
           'os.type': 'Windows_NT',
           'os.platform': 'win32',
           'os.platform': 'win32',
           'os.arch': 'x64',
           'os.arch': 'x64',
-          'os.totalmem': 8589934592,
         },
         },
       },
       },
       {
       {
@@ -82,13 +74,11 @@ describe('getOsResourceAttributes', () => {
           type: 'Darwin',
           type: 'Darwin',
           platform: 'darwin',
           platform: 'darwin',
           arch: 'arm64',
           arch: 'arm64',
-          totalmem: 17179869184,
         },
         },
         expected: {
         expected: {
           'os.type': 'Darwin',
           'os.type': 'Darwin',
           'os.platform': 'darwin',
           'os.platform': 'darwin',
           'os.arch': 'arm64',
           'os.arch': 'arm64',
-          'os.totalmem': 17179869184,
         },
         },
       },
       },
     ];
     ];
@@ -97,10 +87,10 @@ describe('getOsResourceAttributes', () => {
       mockOs.type.mockReturnValue(input.type);
       mockOs.type.mockReturnValue(input.type);
       mockOs.platform.mockReturnValue(input.platform as NodeJS.Platform);
       mockOs.platform.mockReturnValue(input.platform as NodeJS.Platform);
       mockOs.arch.mockReturnValue(input.arch);
       mockOs.arch.mockReturnValue(input.arch);
-      mockOs.totalmem.mockReturnValue(input.totalmem);
 
 
       const result = getOsResourceAttributes();
       const result = getOsResourceAttributes();
       expect(result).toEqual(expected);
       expect(result).toEqual(expected);
+      expect(result).not.toHaveProperty('os.totalmem');
     });
     });
   });
   });
 });
 });

+ 0 - 2
apps/app/src/features/opentelemetry/server/custom-resource-attributes/os-resource-attributes.ts

@@ -18,14 +18,12 @@ export function getOsResourceAttributes(): Attributes {
     type: os.type(),
     type: os.type(),
     platform: os.platform(),
     platform: os.platform(),
     arch: os.arch(),
     arch: os.arch(),
-    totalmem: os.totalmem(),
   };
   };
 
 
   const attributes: Attributes = {
   const attributes: Attributes = {
     'os.type': osInfo.type,
     'os.type': osInfo.type,
     'os.platform': osInfo.platform,
     'os.platform': osInfo.platform,
     'os.arch': osInfo.arch,
     'os.arch': osInfo.arch,
-    'os.totalmem': osInfo.totalmem,
   };
   };
 
 
   logger.info({ attributes }, 'OS resource attributes collected');
   logger.info({ attributes }, 'OS resource attributes collected');