Răsfoiți Sursa

configure biome for opentelemetry feature

Futa Arai 7 luni în urmă
părinte
comite
8941ff65fe
28 a modificat fișierele cu 602 adăugiri și 400 ștergeri
  1. 1 0
      apps/app/.eslintrc.js
  2. 20 16
      apps/app/src/features/opentelemetry/server/anonymization/anonymize-http-requests.ts
  3. 23 21
      apps/app/src/features/opentelemetry/server/anonymization/handlers/page-access-handler.spec.ts
  4. 23 20
      apps/app/src/features/opentelemetry/server/anonymization/handlers/page-access-handler.ts
  5. 49 35
      apps/app/src/features/opentelemetry/server/anonymization/handlers/page-api-handler.spec.ts
  6. 30 17
      apps/app/src/features/opentelemetry/server/anonymization/handlers/page-api-handler.ts
  7. 39 29
      apps/app/src/features/opentelemetry/server/anonymization/handlers/page-listing-api-handler.spec.ts
  8. 12 9
      apps/app/src/features/opentelemetry/server/anonymization/handlers/page-listing-api-handler.ts
  9. 15 16
      apps/app/src/features/opentelemetry/server/anonymization/handlers/search-api-handler.spec.ts
  10. 10 5
      apps/app/src/features/opentelemetry/server/anonymization/handlers/search-api-handler.ts
  11. 28 25
      apps/app/src/features/opentelemetry/server/anonymization/utils/anonymize-query-params.spec.ts
  12. 15 8
      apps/app/src/features/opentelemetry/server/anonymization/utils/anonymize-query-params.ts
  13. 32 17
      apps/app/src/features/opentelemetry/server/custom-metrics/application-metrics.spec.ts
  14. 27 14
      apps/app/src/features/opentelemetry/server/custom-metrics/application-metrics.ts
  15. 1 1
      apps/app/src/features/opentelemetry/server/custom-metrics/index.ts
  16. 48 22
      apps/app/src/features/opentelemetry/server/custom-metrics/user-counts-metrics.spec.ts
  17. 26 12
      apps/app/src/features/opentelemetry/server/custom-metrics/user-counts-metrics.ts
  18. 10 6
      apps/app/src/features/opentelemetry/server/custom-resource-attributes/application-resource-attributes.spec.ts
  19. 12 6
      apps/app/src/features/opentelemetry/server/custom-resource-attributes/application-resource-attributes.ts
  20. 1 1
      apps/app/src/features/opentelemetry/server/custom-resource-attributes/index.ts
  21. 1 1
      apps/app/src/features/opentelemetry/server/custom-resource-attributes/os-resource-attributes.spec.ts
  22. 3 1
      apps/app/src/features/opentelemetry/server/custom-resource-attributes/os-resource-attributes.ts
  23. 11 13
      apps/app/src/features/opentelemetry/server/logger.ts
  24. 44 30
      apps/app/src/features/opentelemetry/server/node-sdk-configuration.ts
  25. 1 2
      apps/app/src/features/opentelemetry/server/node-sdk-resource.ts
  26. 68 50
      apps/app/src/features/opentelemetry/server/node-sdk.spec.ts
  27. 52 22
      apps/app/src/features/opentelemetry/server/node-sdk.ts
  28. 0 1
      biome.json

+ 1 - 0
apps/app/.eslintrc.js

@@ -37,6 +37,7 @@ module.exports = {
     'src/features/search/**',
     'src/features/plantuml/**',
     'src/features/external-user-group/**',
+    'src/features/opentelemetry/**',
   ],
   settings: {
     // resolve path aliases by eslint-import-resolver-typescript

+ 20 - 16
apps/app/src/features/opentelemetry/server/anonymization/anonymize-http-requests.ts

@@ -2,24 +2,28 @@ import type { InstrumentationConfigMap } from '@opentelemetry/auto-instrumentati
 
 import { anonymizationModules } from './handlers';
 
-export const httpInstrumentationConfig: InstrumentationConfigMap['@opentelemetry/instrumentation-http'] = {
-  startIncomingSpanHook: (request) => {
-    // Get URL from IncomingMessage (server-side requests)
-    const incomingRequest = request;
-    const url = incomingRequest.url || '';
+export const httpInstrumentationConfig: InstrumentationConfigMap['@opentelemetry/instrumentation-http'] =
+  {
+    startIncomingSpanHook: (request) => {
+      // Get URL from IncomingMessage (server-side requests)
+      const incomingRequest = request;
+      const url = incomingRequest.url || '';
 
-    const attributes = {};
+      const attributes = {};
 
-    // Use efficient module-based approach
-    for (const anonymizationModule of anonymizationModules) {
-      if (anonymizationModule.canHandle(url)) {
-        const moduleAttributes = anonymizationModule.handle(incomingRequest, url);
-        if (moduleAttributes != null) {
-          Object.assign(attributes, moduleAttributes);
+      // Use efficient module-based approach
+      for (const anonymizationModule of anonymizationModules) {
+        if (anonymizationModule.canHandle(url)) {
+          const moduleAttributes = anonymizationModule.handle(
+            incomingRequest,
+            url,
+          );
+          if (moduleAttributes != null) {
+            Object.assign(attributes, moduleAttributes);
+          }
         }
       }
-    }
 
-    return attributes;
-  },
-};
+      return attributes;
+    },
+  };

+ 23 - 21
apps/app/src/features/opentelemetry/server/anonymization/handlers/page-access-handler.spec.ts

@@ -1,26 +1,26 @@
 import type { IncomingMessage } from 'http';
 
-import { describe, it, expect } from 'vitest';
+import { describe, expect, it } from 'vitest';
 
 import { pageAccessModule } from './page-access-handler';
 
 describe('pageAccessModule', () => {
   describe('canHandle', () => {
     it.each`
-      description                   | url                            | expected
-      ${'root path'}                | ${'/'}                         | ${false}
-      ${'API endpoint'}             | ${'/_api/v3/search'}           | ${false}
-      ${'static resource'}          | ${'/static/css/style.css'}     | ${false}
-      ${'favicon'}                  | ${'/favicon.ico'}              | ${false}
-      ${'assets'}                   | ${'/assets/image.png'}         | ${false}
-      ${'Next.js resource'}         | ${'/_next/chunk.js'}           | ${false}
-      ${'file with extension'}      | ${'/file.pdf'}                 | ${false}
-      ${'Users top page'}           | ${'/user'}                     | ${false}
-      ${'Users homepage'}           | ${'/user/john'}                | ${true}
-      ${'Users page'}               | ${'/user/john/projects'}       | ${true}
-      ${'page path'}                | ${'/path/to/page'}             | ${true}
-      ${'ObjectId path'}            | ${'/58a4569921a8424d00a1aa0e'} | ${false}
-      `('should return $expected for $description', ({ url, expected }) => {
+      description              | url                            | expected
+      ${'root path'}           | ${'/'}                         | ${false}
+      ${'API endpoint'}        | ${'/_api/v3/search'}           | ${false}
+      ${'static resource'}     | ${'/static/css/style.css'}     | ${false}
+      ${'favicon'}             | ${'/favicon.ico'}              | ${false}
+      ${'assets'}              | ${'/assets/image.png'}         | ${false}
+      ${'Next.js resource'}    | ${'/_next/chunk.js'}           | ${false}
+      ${'file with extension'} | ${'/file.pdf'}                 | ${false}
+      ${'Users top page'}      | ${'/user'}                     | ${false}
+      ${'Users homepage'}      | ${'/user/john'}                | ${true}
+      ${'Users page'}          | ${'/user/john/projects'}       | ${true}
+      ${'page path'}           | ${'/path/to/page'}             | ${true}
+      ${'ObjectId path'}       | ${'/58a4569921a8424d00a1aa0e'} | ${false}
+    `('should return $expected for $description', ({ url, expected }) => {
       const result = pageAccessModule.canHandle(url);
       expect(result).toBe(expected);
     });
@@ -29,10 +29,10 @@ describe('pageAccessModule', () => {
   describe('handle', () => {
     describe('URL path anonymization', () => {
       it.each`
-        description                     | url                                 | expectedPath
-        ${'user subpage path'}          | ${'/user/john/projects'}            | ${'/user/[USERNAME_HASHED:96d9632f363564cc]/[HASHED:2577c0f557b2e4b5]'}
-        ${'complex path'}               | ${'/wiki/project/documentation'}    | ${'/[HASHED:22ca1a8b9f281349]'}
-        ${'path with special chars'}    | ${'/user-name_123/project!'}        | ${'/[HASHED:7aa6a8f4468baa96]'}
+        description                  | url                              | expectedPath
+        ${'user subpage path'}       | ${'/user/john/projects'}         | ${'/user/[USERNAME_HASHED:96d9632f363564cc]/[HASHED:2577c0f557b2e4b5]'}
+        ${'complex path'}            | ${'/wiki/project/documentation'} | ${'/[HASHED:22ca1a8b9f281349]'}
+        ${'path with special chars'} | ${'/user-name_123/project!'}     | ${'/[HASHED:7aa6a8f4468baa96]'}
       `('should handle $description', ({ url, expectedPath }) => {
         // Ensure canHandle returns true before calling handle
         expect(pageAccessModule.canHandle(url)).toBe(true);
@@ -56,7 +56,8 @@ describe('pageAccessModule', () => {
       const result = pageAccessModule.handle(mockRequest, url);
 
       expect(result).toEqual({
-        'http.target': '/user/[USERNAME_HASHED:96d9632f363564cc]?tab=projects&sort=date',
+        'http.target':
+          '/user/[USERNAME_HASHED:96d9632f363564cc]?tab=projects&sort=date',
       });
     });
 
@@ -70,7 +71,8 @@ describe('pageAccessModule', () => {
       const result = pageAccessModule.handle(mockRequest, url);
 
       expect(result).toEqual({
-        'http.target': '/[HASHED:2f4a824f8eacbc70]?search=test&tags[]=tag1&tags[]=tag2&limit=10',
+        'http.target':
+          '/[HASHED:2f4a824f8eacbc70]?search=test&tags[]=tag1&tags[]=tag2&limit=10',
       });
     });
   });

+ 23 - 20
apps/app/src/features/opentelemetry/server/anonymization/handlers/page-access-handler.ts

@@ -1,20 +1,21 @@
-import { createHash } from 'crypto';
-import type { IncomingMessage } from 'http';
-
 import {
+  getUsernameByPath,
   isCreatablePage,
-  isUsersHomepage,
+  isPermalink,
   isUserPage,
+  isUsersHomepage,
   isUsersTopPage,
-  isPermalink,
-  getUsernameByPath,
 } from '@growi/core/dist/utils/page-path-utils';
 import { diag } from '@opentelemetry/api';
+import { createHash } from 'crypto';
+import type { IncomingMessage } from 'http';
 
 import { ATTR_HTTP_TARGET } from '../../semconv';
 import type { AnonymizationModule } from '../interfaces/anonymization-module';
 
-const logger = diag.createComponentLogger({ namespace: 'growi:anonymization:page-access-handler' });
+const logger = diag.createComponentLogger({
+  namespace: 'growi:anonymization:page-access-handler',
+});
 
 /**
  * Create a hash of the given string
@@ -54,7 +55,8 @@ function anonymizeUrlPath(urlPath: string): string {
           const cleanRemainingPath = remainingPath.replace(/^\/+|\/+$/g, '');
           const hashedRemainingPath = hashString(cleanRemainingPath);
           const leadingSlash = remainingPath.startsWith('/') ? '/' : '';
-          const trailingSlash = remainingPath.endsWith('/') && remainingPath.length > 1 ? '/' : '';
+          const trailingSlash =
+            remainingPath.endsWith('/') && remainingPath.length > 1 ? '/' : '';
 
           return `/user/[USERNAME_HASHED:${hashedUsername}]${leadingSlash}[HASHED:${hashedRemainingPath}]${trailingSlash}`;
         }
@@ -72,11 +74,11 @@ function anonymizeUrlPath(urlPath: string): string {
     // Hash the path and return with original slash structure
     const hashedPath = hashString(cleanPath);
     const leadingSlash = urlPath.startsWith('/') ? '/' : '';
-    const trailingSlash = urlPath.endsWith('/') && urlPath.length > 1 ? '/' : '';
+    const trailingSlash =
+      urlPath.endsWith('/') && urlPath.length > 1 ? '/' : '';
 
     return `${leadingSlash}[HASHED:${hashedPath}]${trailingSlash}`;
-  }
-  catch (error) {
+  } catch (error) {
     logger.warn(`Failed to anonymize URL path: ${error}`);
     return urlPath;
   }
@@ -98,11 +100,14 @@ export const pageAccessModule: AnonymizationModule = {
       if (path === '/') return false;
 
       // Exclude static resources first
-      if (path.includes('/static/')
-        || path.includes('/_next/')
-        || path.includes('/favicon')
-        || path.includes('/assets/')
-        || path.includes('.')) { // Exclude file extensions (images, css, js, etc.)
+      if (
+        path.includes('/static/') ||
+        path.includes('/_next/') ||
+        path.includes('/favicon') ||
+        path.includes('/assets/') ||
+        path.includes('.')
+      ) {
+        // Exclude file extensions (images, css, js, etc.)
         return false;
       }
 
@@ -118,8 +123,7 @@ export const pageAccessModule: AnonymizationModule = {
       // Use GROWI's isCreatablePage logic to determine if this is a valid page path
       // This excludes API endpoints, system paths, etc.
       return isCreatablePage(path);
-    }
-    catch {
+    } catch {
       // If URL parsing fails, don't handle it
       return false;
     }
@@ -148,8 +152,7 @@ export const pageAccessModule: AnonymizationModule = {
       }
 
       return null;
-    }
-    catch (error) {
+    } catch (error) {
       logger.warn(`Failed to anonymize page access URL: ${error}`);
       return null;
     }

+ 49 - 35
apps/app/src/features/opentelemetry/server/anonymization/handlers/page-api-handler.spec.ts

@@ -1,8 +1,6 @@
 import type { IncomingMessage } from 'http';
 
-import {
-  describe, it, expect, beforeEach,
-} from 'vitest';
+import { beforeEach, describe, expect, it } from 'vitest';
 
 import { pageApiModule } from './page-api-handler';
 
@@ -15,22 +13,22 @@ describe('pageApiModule', () => {
 
   describe('canHandle', () => {
     it.each`
-      description                                          | url                                                              | expected
-      ${'pages list endpoint'}                             | ${'/_api/v3/pages/list?path=/home'}                              | ${true}
-      ${'subordinated list endpoint'}                      | ${'/_api/v3/pages/subordinated-list?path=/docs'}                 | ${true}
-      ${'check page existence endpoint'}                   | ${'/_api/v3/page/check-page-existence?path=/wiki'}               | ${true}
-      ${'get page paths with descendant count endpoint'}   | ${'/_api/v3/page/get-page-paths-with-descendant-count?paths=[]'} | ${true}
-      ${'pages list without query'}                        | ${'/_api/v3/pages/list'}                                         | ${true}
-      ${'subordinated list without query'}                 | ${'/_api/v3/pages/subordinated-list'}                            | ${true}
-      ${'check page existence without query'}              | ${'/_api/v3/page/check-page-existence'}                          | ${true}
-      ${'get page paths without query'}                    | ${'/_api/v3/page/get-page-paths-with-descendant-count'}          | ${true}
-      ${'other pages endpoint'}                            | ${'/_api/v3/pages/create'}                                       | ${false}
-      ${'different API version'}                           | ${'/_api/v2/pages/list'}                                         | ${false}
-      ${'non-page API'}                                    | ${'/_api/v3/search'}                                             | ${false}
-      ${'regular page path'}                               | ${'/page/path'}                                                  | ${false}
-      ${'root path'}                                       | ${'/'}                                                           | ${false}
-      ${'empty URL'}                                       | ${''}                                                            | ${false}
-      ${'partial match but different endpoint'}            | ${'/_api/v3/pages-other/list'}                                   | ${false}
+      description                                        | url                                                              | expected
+      ${'pages list endpoint'}                           | ${'/_api/v3/pages/list?path=/home'}                              | ${true}
+      ${'subordinated list endpoint'}                    | ${'/_api/v3/pages/subordinated-list?path=/docs'}                 | ${true}
+      ${'check page existence endpoint'}                 | ${'/_api/v3/page/check-page-existence?path=/wiki'}               | ${true}
+      ${'get page paths with descendant count endpoint'} | ${'/_api/v3/page/get-page-paths-with-descendant-count?paths=[]'} | ${true}
+      ${'pages list without query'}                      | ${'/_api/v3/pages/list'}                                         | ${true}
+      ${'subordinated list without query'}               | ${'/_api/v3/pages/subordinated-list'}                            | ${true}
+      ${'check page existence without query'}            | ${'/_api/v3/page/check-page-existence'}                          | ${true}
+      ${'get page paths without query'}                  | ${'/_api/v3/page/get-page-paths-with-descendant-count'}          | ${true}
+      ${'other pages endpoint'}                          | ${'/_api/v3/pages/create'}                                       | ${false}
+      ${'different API version'}                         | ${'/_api/v2/pages/list'}                                         | ${false}
+      ${'non-page API'}                                  | ${'/_api/v3/search'}                                             | ${false}
+      ${'regular page path'}                             | ${'/page/path'}                                                  | ${false}
+      ${'root path'}                                     | ${'/'}                                                           | ${false}
+      ${'empty URL'}                                     | ${''}                                                            | ${false}
+      ${'partial match but different endpoint'}          | ${'/_api/v3/pages-other/list'}                                   | ${false}
     `('should return $expected for $description: $url', ({ url, expected }) => {
       const result = pageApiModule.canHandle(url);
       expect(result).toBe(expected);
@@ -66,7 +64,8 @@ describe('pageApiModule', () => {
 
     describe('pages/subordinated-list endpoint', () => {
       it('should anonymize path parameter', () => {
-        const originalUrl = '/_api/v3/pages/subordinated-list?path=/user/documents&offset=0';
+        const originalUrl =
+          '/_api/v3/pages/subordinated-list?path=/user/documents&offset=0';
 
         // Verify canHandle returns true for this URL
         expect(pageApiModule.canHandle(originalUrl)).toBe(true);
@@ -74,12 +73,14 @@ describe('pageApiModule', () => {
         const result = pageApiModule.handle(mockRequest, originalUrl);
 
         expect(result).toEqual({
-          'http.target': '/_api/v3/pages/subordinated-list?path=%5BANONYMIZED%5D&offset=0',
+          'http.target':
+            '/_api/v3/pages/subordinated-list?path=%5BANONYMIZED%5D&offset=0',
         });
       });
 
       it('should handle encoded path parameters', () => {
-        const originalUrl = '/_api/v3/pages/subordinated-list?path=%2Fuser%2Fdocs&includeEmpty=true';
+        const originalUrl =
+          '/_api/v3/pages/subordinated-list?path=%2Fuser%2Fdocs&includeEmpty=true';
 
         // Verify canHandle returns true for this URL
         expect(pageApiModule.canHandle(originalUrl)).toBe(true);
@@ -87,14 +88,16 @@ describe('pageApiModule', () => {
         const result = pageApiModule.handle(mockRequest, originalUrl);
 
         expect(result).toEqual({
-          'http.target': '/_api/v3/pages/subordinated-list?path=%5BANONYMIZED%5D&includeEmpty=true',
+          'http.target':
+            '/_api/v3/pages/subordinated-list?path=%5BANONYMIZED%5D&includeEmpty=true',
         });
       });
     });
 
     describe('page/check-page-existence endpoint', () => {
       it('should anonymize path parameter', () => {
-        const originalUrl = '/_api/v3/page/check-page-existence?path=/project/wiki';
+        const originalUrl =
+          '/_api/v3/page/check-page-existence?path=/project/wiki';
 
         // Verify canHandle returns true for this URL
         expect(pageApiModule.canHandle(originalUrl)).toBe(true);
@@ -102,12 +105,14 @@ describe('pageApiModule', () => {
         const result = pageApiModule.handle(mockRequest, originalUrl);
 
         expect(result).toEqual({
-          'http.target': '/_api/v3/page/check-page-existence?path=%5BANONYMIZED%5D',
+          'http.target':
+            '/_api/v3/page/check-page-existence?path=%5BANONYMIZED%5D',
         });
       });
 
       it('should handle multiple parameters including path', () => {
-        const originalUrl = '/_api/v3/page/check-page-existence?path=/docs/api&includePrivate=false';
+        const originalUrl =
+          '/_api/v3/page/check-page-existence?path=/docs/api&includePrivate=false';
 
         // Verify canHandle returns true for this URL
         expect(pageApiModule.canHandle(originalUrl)).toBe(true);
@@ -115,14 +120,16 @@ describe('pageApiModule', () => {
         const result = pageApiModule.handle(mockRequest, originalUrl);
 
         expect(result).toEqual({
-          'http.target': '/_api/v3/page/check-page-existence?path=%5BANONYMIZED%5D&includePrivate=false',
+          'http.target':
+            '/_api/v3/page/check-page-existence?path=%5BANONYMIZED%5D&includePrivate=false',
         });
       });
     });
 
     describe('page/get-page-paths-with-descendant-count endpoint', () => {
       it('should anonymize paths parameter when present', () => {
-        const originalUrl = '/_api/v3/page/get-page-paths-with-descendant-count?paths=["/docs","/wiki"]';
+        const originalUrl =
+          '/_api/v3/page/get-page-paths-with-descendant-count?paths=["/docs","/wiki"]';
 
         // Verify canHandle returns true for this URL
         expect(pageApiModule.canHandle(originalUrl)).toBe(true);
@@ -130,12 +137,14 @@ describe('pageApiModule', () => {
         const result = pageApiModule.handle(mockRequest, originalUrl);
 
         expect(result).toEqual({
-          'http.target': '/_api/v3/page/get-page-paths-with-descendant-count?paths=%5B%22%5BANONYMIZED%5D%22%5D',
+          'http.target':
+            '/_api/v3/page/get-page-paths-with-descendant-count?paths=%5B%22%5BANONYMIZED%5D%22%5D',
         });
       });
 
       it('should handle encoded paths parameter', () => {
-        const originalUrl = '/_api/v3/page/get-page-paths-with-descendant-count?paths=%5B%22%2Fdocs%22%5D';
+        const originalUrl =
+          '/_api/v3/page/get-page-paths-with-descendant-count?paths=%5B%22%2Fdocs%22%5D';
 
         // Verify canHandle returns true for this URL
         expect(pageApiModule.canHandle(originalUrl)).toBe(true);
@@ -143,12 +152,14 @@ describe('pageApiModule', () => {
         const result = pageApiModule.handle(mockRequest, originalUrl);
 
         expect(result).toEqual({
-          'http.target': '/_api/v3/page/get-page-paths-with-descendant-count?paths=%5B%22%5BANONYMIZED%5D%22%5D',
+          'http.target':
+            '/_api/v3/page/get-page-paths-with-descendant-count?paths=%5B%22%5BANONYMIZED%5D%22%5D',
         });
       });
 
       it('should return null when no paths parameter is present', () => {
-        const url = '/_api/v3/page/get-page-paths-with-descendant-count?includeEmpty=true';
+        const url =
+          '/_api/v3/page/get-page-paths-with-descendant-count?includeEmpty=true';
 
         // Verify canHandle returns true for this URL
         expect(pageApiModule.canHandle(url)).toBe(true);
@@ -217,12 +228,14 @@ describe('pageApiModule', () => {
         const result = pageApiModule.handle(mockRequest, originalUrl);
 
         expect(result).toEqual({
-          'http.target': '/_api/v3/page/check-page-existence?path=%5BANONYMIZED%5D',
+          'http.target':
+            '/_api/v3/page/check-page-existence?path=%5BANONYMIZED%5D',
         });
       });
 
       it('should handle empty paths array parameter', () => {
-        const originalUrl = '/_api/v3/page/get-page-paths-with-descendant-count?paths=[]';
+        const originalUrl =
+          '/_api/v3/page/get-page-paths-with-descendant-count?paths=[]';
 
         // Verify canHandle returns true for this URL
         expect(pageApiModule.canHandle(originalUrl)).toBe(true);
@@ -230,7 +243,8 @@ describe('pageApiModule', () => {
         const result = pageApiModule.handle(mockRequest, originalUrl);
 
         expect(result).toEqual({
-          'http.target': '/_api/v3/page/get-page-paths-with-descendant-count?paths=%5BANONYMIZED%5D',
+          'http.target':
+            '/_api/v3/page/get-page-paths-with-descendant-count?paths=%5BANONYMIZED%5D',
         });
       });
     });

+ 30 - 17
apps/app/src/features/opentelemetry/server/anonymization/handlers/page-api-handler.ts

@@ -1,12 +1,13 @@
-import type { IncomingMessage } from 'http';
-
 import { diag } from '@opentelemetry/api';
+import type { IncomingMessage } from 'http';
 
 import { ATTR_HTTP_TARGET } from '../../semconv';
 import type { AnonymizationModule } from '../interfaces/anonymization-module';
 import { anonymizeQueryParams } from '../utils/anonymize-query-params';
 
-const logger = diag.createComponentLogger({ namespace: 'growi:anonymization:page-api-handler' });
+const logger = diag.createComponentLogger({
+  namespace: 'growi:anonymization:page-api-handler',
+});
 
 /**
  * Page API anonymization module
@@ -16,10 +17,12 @@ export const pageApiModule: AnonymizationModule = {
    * Check if this module can handle page API endpoints
    */
   canHandle(url: string): boolean {
-    return url.includes('/_api/v3/pages/list')
-      || url.includes('/_api/v3/pages/subordinated-list')
-      || url.includes('/_api/v3/page/check-page-existence')
-      || url.includes('/_api/v3/page/get-page-paths-with-descendant-count');
+    return (
+      url.includes('/_api/v3/pages/list') ||
+      url.includes('/_api/v3/pages/subordinated-list') ||
+      url.includes('/_api/v3/page/check-page-existence') ||
+      url.includes('/_api/v3/page/get-page-paths-with-descendant-count')
+    );
   },
 
   /**
@@ -30,11 +33,12 @@ export const pageApiModule: AnonymizationModule = {
     let hasAnonymization = false;
 
     // Handle endpoints with 'path' parameter
-    if (url.includes('path=') && (
-      url.includes('/_api/v3/pages/list')
-      || url.includes('/_api/v3/pages/subordinated-list')
-      || url.includes('/_api/v3/page/check-page-existence')
-    )) {
+    if (
+      url.includes('path=') &&
+      (url.includes('/_api/v3/pages/list') ||
+        url.includes('/_api/v3/pages/subordinated-list') ||
+        url.includes('/_api/v3/page/check-page-existence'))
+    ) {
       const anonymizedUrl = anonymizeQueryParams(url, ['path']);
       attributes[ATTR_HTTP_TARGET] = anonymizedUrl;
       hasAnonymization = true;
@@ -42,18 +46,27 @@ export const pageApiModule: AnonymizationModule = {
       // Determine endpoint type for logging
       let endpointType = 'page API';
       if (url.includes('/_api/v3/pages/list')) endpointType = '/pages/list';
-      else if (url.includes('/_api/v3/pages/subordinated-list')) endpointType = '/pages/subordinated-list';
-      else if (url.includes('/_api/v3/page/check-page-existence')) endpointType = '/page/check-page-existence';
+      else if (url.includes('/_api/v3/pages/subordinated-list'))
+        endpointType = '/pages/subordinated-list';
+      else if (url.includes('/_api/v3/page/check-page-existence'))
+        endpointType = '/page/check-page-existence';
 
-      logger.debug(`Anonymized ${endpointType} URL: ${url} -> ${anonymizedUrl}`);
+      logger.debug(
+        `Anonymized ${endpointType} URL: ${url} -> ${anonymizedUrl}`,
+      );
     }
 
     // Handle page/get-page-paths-with-descendant-count endpoint with paths parameter
-    if (url.includes('/_api/v3/page/get-page-paths-with-descendant-count') && url.includes('paths=')) {
+    if (
+      url.includes('/_api/v3/page/get-page-paths-with-descendant-count') &&
+      url.includes('paths=')
+    ) {
       const anonymizedUrl = anonymizeQueryParams(url, ['paths']);
       attributes[ATTR_HTTP_TARGET] = anonymizedUrl;
       hasAnonymization = true;
-      logger.debug(`Anonymized page/get-page-paths-with-descendant-count URL: ${url} -> ${anonymizedUrl}`);
+      logger.debug(
+        `Anonymized page/get-page-paths-with-descendant-count URL: ${url} -> ${anonymizedUrl}`,
+      );
     }
 
     return hasAnonymization ? attributes : null;

+ 39 - 29
apps/app/src/features/opentelemetry/server/anonymization/handlers/page-listing-api-handler.spec.ts

@@ -1,8 +1,6 @@
 import type { IncomingMessage } from 'http';
 
-import {
-  describe, it, expect, beforeEach,
-} from 'vitest';
+import { beforeEach, describe, expect, it } from 'vitest';
 
 import { pageListingApiModule } from './page-listing-api-handler';
 
@@ -15,20 +13,20 @@ describe('pageListingApiModule', () => {
 
   describe('canHandle', () => {
     it.each`
-      description                           | url                                                    | expected
-      ${'ancestors-children endpoint'}      | ${'/_api/v3/page-listing/ancestors-children?path=/'}  | ${true}
-      ${'children endpoint'}                | ${'/_api/v3/page-listing/children?path=/docs'}        | ${true}
-      ${'info endpoint'}                    | ${'/_api/v3/page-listing/info?path=/wiki'}            | ${true}
-      ${'ancestors-children without query'} | ${'/_api/v3/page-listing/ancestors-children'}         | ${true}
-      ${'children without query'}           | ${'/_api/v3/page-listing/children'}                   | ${true}
-      ${'info without query'}               | ${'/_api/v3/page-listing/info'}                       | ${true}
-      ${'other page-listing endpoint'}      | ${'/_api/v3/page-listing/other'}                      | ${false}
-      ${'different API version'}            | ${'/_api/v2/page-listing/children'}                   | ${false}
-      ${'non-page-listing API'}             | ${'/_api/v3/pages/list'}                              | ${false}
-      ${'regular page path'}                | ${'/page/path'}                                       | ${false}
-      ${'root path'}                        | ${'/'}                                                | ${false}
-      ${'empty URL'}                        | ${''}                                                 | ${false}
-      ${'partial match'}                    | ${'/_api/v3/page-listing-other/children'}             | ${false}
+      description                           | url                                                  | expected
+      ${'ancestors-children endpoint'}      | ${'/_api/v3/page-listing/ancestors-children?path=/'} | ${true}
+      ${'children endpoint'}                | ${'/_api/v3/page-listing/children?path=/docs'}       | ${true}
+      ${'info endpoint'}                    | ${'/_api/v3/page-listing/info?path=/wiki'}           | ${true}
+      ${'ancestors-children without query'} | ${'/_api/v3/page-listing/ancestors-children'}        | ${true}
+      ${'children without query'}           | ${'/_api/v3/page-listing/children'}                  | ${true}
+      ${'info without query'}               | ${'/_api/v3/page-listing/info'}                      | ${true}
+      ${'other page-listing endpoint'}      | ${'/_api/v3/page-listing/other'}                     | ${false}
+      ${'different API version'}            | ${'/_api/v2/page-listing/children'}                  | ${false}
+      ${'non-page-listing API'}             | ${'/_api/v3/pages/list'}                             | ${false}
+      ${'regular page path'}                | ${'/page/path'}                                      | ${false}
+      ${'root path'}                        | ${'/'}                                               | ${false}
+      ${'empty URL'}                        | ${''}                                                | ${false}
+      ${'partial match'}                    | ${'/_api/v3/page-listing-other/children'}            | ${false}
     `('should return $expected for $description: $url', ({ url, expected }) => {
       const result = pageListingApiModule.canHandle(url);
       expect(result).toBe(expected);
@@ -38,7 +36,8 @@ describe('pageListingApiModule', () => {
   describe('handle', () => {
     describe('ancestors-children endpoint', () => {
       it('should anonymize path parameter when present', () => {
-        const originalUrl = '/_api/v3/page-listing/ancestors-children?path=/sensitive/path&limit=10';
+        const originalUrl =
+          '/_api/v3/page-listing/ancestors-children?path=/sensitive/path&limit=10';
 
         // Ensure canHandle returns true for this URL
         expect(pageListingApiModule.canHandle(originalUrl)).toBe(true);
@@ -46,12 +45,14 @@ describe('pageListingApiModule', () => {
         const result = pageListingApiModule.handle(mockRequest, originalUrl);
 
         expect(result).toEqual({
-          'http.target': '/_api/v3/page-listing/ancestors-children?path=%5BANONYMIZED%5D&limit=10',
+          'http.target':
+            '/_api/v3/page-listing/ancestors-children?path=%5BANONYMIZED%5D&limit=10',
         });
       });
 
       it('should anonymize empty path parameter', () => {
-        const originalUrl = '/_api/v3/page-listing/ancestors-children?path=&limit=5';
+        const originalUrl =
+          '/_api/v3/page-listing/ancestors-children?path=&limit=5';
 
         // Ensure canHandle returns true for this URL
         expect(pageListingApiModule.canHandle(originalUrl)).toBe(true);
@@ -60,7 +61,8 @@ describe('pageListingApiModule', () => {
 
         // Empty path parameter should now be anonymized
         expect(result).toEqual({
-          'http.target': '/_api/v3/page-listing/ancestors-children?path=%5BANONYMIZED%5D&limit=5',
+          'http.target':
+            '/_api/v3/page-listing/ancestors-children?path=%5BANONYMIZED%5D&limit=5',
         });
       });
 
@@ -78,7 +80,8 @@ describe('pageListingApiModule', () => {
 
     describe('children endpoint', () => {
       it('should anonymize path parameter when present', () => {
-        const originalUrl = '/_api/v3/page-listing/children?path=/docs/api&offset=0&limit=20';
+        const originalUrl =
+          '/_api/v3/page-listing/children?path=/docs/api&offset=0&limit=20';
 
         // Ensure canHandle returns true for this URL
         expect(pageListingApiModule.canHandle(originalUrl)).toBe(true);
@@ -86,12 +89,14 @@ describe('pageListingApiModule', () => {
         const result = pageListingApiModule.handle(mockRequest, originalUrl);
 
         expect(result).toEqual({
-          'http.target': '/_api/v3/page-listing/children?path=%5BANONYMIZED%5D&offset=0&limit=20',
+          'http.target':
+            '/_api/v3/page-listing/children?path=%5BANONYMIZED%5D&offset=0&limit=20',
         });
       });
 
       it('should handle encoded path parameter', () => {
-        const originalUrl = '/_api/v3/page-listing/children?path=%2Fencoded%2Fpath&limit=10';
+        const originalUrl =
+          '/_api/v3/page-listing/children?path=%2Fencoded%2Fpath&limit=10';
 
         // Ensure canHandle returns true for this URL
         expect(pageListingApiModule.canHandle(originalUrl)).toBe(true);
@@ -99,7 +104,8 @@ describe('pageListingApiModule', () => {
         const result = pageListingApiModule.handle(mockRequest, originalUrl);
 
         expect(result).toEqual({
-          'http.target': '/_api/v3/page-listing/children?path=%5BANONYMIZED%5D&limit=10',
+          'http.target':
+            '/_api/v3/page-listing/children?path=%5BANONYMIZED%5D&limit=10',
         });
       });
 
@@ -117,7 +123,8 @@ describe('pageListingApiModule', () => {
 
     describe('info endpoint', () => {
       it('should anonymize path parameter when present', () => {
-        const originalUrl = '/_api/v3/page-listing/info?path=/wiki/documentation';
+        const originalUrl =
+          '/_api/v3/page-listing/info?path=/wiki/documentation';
 
         // Ensure canHandle returns true for this URL
         expect(pageListingApiModule.canHandle(originalUrl)).toBe(true);
@@ -143,7 +150,8 @@ describe('pageListingApiModule', () => {
 
     describe('edge cases', () => {
       it('should handle URL with complex query parameters', () => {
-        const originalUrl = '/_api/v3/page-listing/ancestors-children?path=/complex/path&sort=name&direction=asc&filter=active';
+        const originalUrl =
+          '/_api/v3/page-listing/ancestors-children?path=/complex/path&sort=name&direction=asc&filter=active';
 
         // Ensure canHandle returns true for this URL
         expect(pageListingApiModule.canHandle(originalUrl)).toBe(true);
@@ -151,7 +159,8 @@ describe('pageListingApiModule', () => {
         const result = pageListingApiModule.handle(mockRequest, originalUrl);
 
         expect(result).toEqual({
-          'http.target': '/_api/v3/page-listing/ancestors-children?path=%5BANONYMIZED%5D&sort=name&direction=asc&filter=active',
+          'http.target':
+            '/_api/v3/page-listing/ancestors-children?path=%5BANONYMIZED%5D&sort=name&direction=asc&filter=active',
         });
       });
 
@@ -165,7 +174,8 @@ describe('pageListingApiModule', () => {
 
         // Fragment should be preserved by anonymizeQueryParams
         expect(result).toEqual({
-          'http.target': '/_api/v3/page-listing/children?path=%5BANONYMIZED%5D#section',
+          'http.target':
+            '/_api/v3/page-listing/children?path=%5BANONYMIZED%5D#section',
         });
       });
     });

+ 12 - 9
apps/app/src/features/opentelemetry/server/anonymization/handlers/page-listing-api-handler.ts

@@ -1,12 +1,13 @@
-import type { IncomingMessage } from 'http';
-
 import { diag } from '@opentelemetry/api';
+import type { IncomingMessage } from 'http';
 
 import { ATTR_HTTP_TARGET } from '../../semconv';
 import type { AnonymizationModule } from '../interfaces/anonymization-module';
 import { anonymizeQueryParams } from '../utils/anonymize-query-params';
 
-const logger = diag.createComponentLogger({ namespace: 'growi:anonymization:page-listing-handler' });
+const logger = diag.createComponentLogger({
+  namespace: 'growi:anonymization:page-listing-handler',
+});
 
 /**
  * Page listing API anonymization module
@@ -16,9 +17,11 @@ export const pageListingApiModule: AnonymizationModule = {
    * Check if this module can handle page-listing API endpoints
    */
   canHandle(url: string): boolean {
-    return url.includes('/_api/v3/page-listing/ancestors-children')
-      || url.includes('/_api/v3/page-listing/children')
-      || url.includes('/_api/v3/page-listing/info');
+    return (
+      url.includes('/_api/v3/page-listing/ancestors-children') ||
+      url.includes('/_api/v3/page-listing/children') ||
+      url.includes('/_api/v3/page-listing/info')
+    );
     // Add other page-listing endpoints here as needed
   },
 
@@ -31,9 +34,9 @@ export const pageListingApiModule: AnonymizationModule = {
 
     // Handle ancestors-children endpoint
     if (
-      url.includes('/_api/v3/page-listing/ancestors-children')
-      || url.includes('/_api/v3/page-listing/children')
-      || url.includes('/_api/v3/page-listing/info')
+      url.includes('/_api/v3/page-listing/ancestors-children') ||
+      url.includes('/_api/v3/page-listing/children') ||
+      url.includes('/_api/v3/page-listing/info')
     ) {
       const anonymizedUrl = anonymizeQueryParams(url, ['path']);
       // Only set attributes if the URL was actually modified

+ 15 - 16
apps/app/src/features/opentelemetry/server/anonymization/handlers/search-api-handler.spec.ts

@@ -1,8 +1,6 @@
 import type { IncomingMessage } from 'http';
 
-import {
-  describe, it, expect, beforeEach,
-} from 'vitest';
+import { beforeEach, describe, expect, it } from 'vitest';
 
 import { searchApiModule } from './search-api-handler';
 
@@ -15,18 +13,18 @@ describe('searchApiModule', () => {
 
   describe('canHandle', () => {
     it.each`
-      description                     | url                                 | expected
-      ${'search API endpoint'}        | ${'/_api/search?q=test'}            | ${true}
-      ${'search API without query'}   | ${'/_api/search'}                   | ${true}
-      ${'search endpoint'}            | ${'/_search?q=keyword'}             | ${true}
-      ${'search endpoint without q'}  | ${'/_search'}                       | ${true}
-      ${'nested search API'}          | ${'/admin/_api/search?q=admin'}     | ${true}
-      ${'nested search endpoint'}     | ${'/docs/_search?q=documentation'}  | ${true}
-      ${'other API endpoint'}         | ${'/_api/pages'}                    | ${false}
-      ${'regular page path'}          | ${'/search/results'}                | ${false}
-      ${'similar but different'}      | ${'/_api/search-results'}           | ${false}
-      ${'root path'}                  | ${'/'}                              | ${false}
-      ${'empty URL'}                  | ${''}                               | ${false}
+      description                    | url                                | expected
+      ${'search API endpoint'}       | ${'/_api/search?q=test'}           | ${true}
+      ${'search API without query'}  | ${'/_api/search'}                  | ${true}
+      ${'search endpoint'}           | ${'/_search?q=keyword'}            | ${true}
+      ${'search endpoint without q'} | ${'/_search'}                      | ${true}
+      ${'nested search API'}         | ${'/admin/_api/search?q=admin'}    | ${true}
+      ${'nested search endpoint'}    | ${'/docs/_search?q=documentation'} | ${true}
+      ${'other API endpoint'}        | ${'/_api/pages'}                   | ${false}
+      ${'regular page path'}         | ${'/search/results'}               | ${false}
+      ${'similar but different'}     | ${'/_api/search-results'}          | ${false}
+      ${'root path'}                 | ${'/'}                             | ${false}
+      ${'empty URL'}                 | ${''}                              | ${false}
     `('should return $expected for $description: $url', ({ url, expected }) => {
       const result = searchApiModule.canHandle(url);
       expect(result).toBe(expected);
@@ -147,7 +145,8 @@ describe('searchApiModule', () => {
 
         // The actual output may have different parameter order due to URL parsing
         expect(result).toEqual({
-          'http.target': '/_search?category=docs&q=%5BANONYMIZED%5D&page=1&sort=date',
+          'http.target':
+            '/_search?category=docs&q=%5BANONYMIZED%5D&page=1&sort=date',
         });
       });
 

+ 10 - 5
apps/app/src/features/opentelemetry/server/anonymization/handlers/search-api-handler.ts

@@ -1,12 +1,13 @@
-import type { IncomingMessage } from 'http';
-
 import { diag } from '@opentelemetry/api';
+import type { IncomingMessage } from 'http';
 
 import { ATTR_HTTP_TARGET } from '../../semconv';
 import type { AnonymizationModule } from '../interfaces/anonymization-module';
 import { anonymizeQueryParams } from '../utils/anonymize-query-params';
 
-const logger = diag.createComponentLogger({ namespace: 'growi:anonymization:search-handler' });
+const logger = diag.createComponentLogger({
+  namespace: 'growi:anonymization:search-handler',
+});
 
 /**
  * Search API anonymization module
@@ -17,8 +18,12 @@ export const searchApiModule: AnonymizationModule = {
    */
   canHandle(url: string): boolean {
     // More precise matching to avoid false positives
-    return url.match(/\/_api\/search(\?|$)/) !== null || url.match(/\/_search(\?|$)/) !== null
-           || url.includes('/_api/search/') || url.includes('/_search/');
+    return (
+      url.match(/\/_api\/search(\?|$)/) !== null ||
+      url.match(/\/_search(\?|$)/) !== null ||
+      url.includes('/_api/search/') ||
+      url.includes('/_search/')
+    );
   },
 
   /**

+ 28 - 25
apps/app/src/features/opentelemetry/server/anonymization/utils/anonymize-query-params.spec.ts

@@ -1,38 +1,41 @@
-import { describe, it, expect } from 'vitest';
+import { describe, expect, it } from 'vitest';
 
 import { anonymizeQueryParams } from './anonymize-query-params';
 
 describe('anonymizeQueryParams', () => {
   /* eslint-disable max-len */
   it.each`
-    description                       | target                                                                 | paramNames         | expected
-    ${'no matching parameters'}       | ${'/_api/v3/test?other=value&another=test'}                            | ${['nonexistent']} | ${'/_api/v3/test?other=value&another=test'}
-    ${'single string parameter'}      | ${'/_api/v3/search?q=sensitive-query'}                                 | ${['q']}           | ${'/_api/v3/search?q=%5BANONYMIZED%5D'}
-    ${'array-style parameters'}       | ${'/_api/v3/page/test?paths[]=/user/john&paths[]=/user/jane'}          | ${['paths']}       | ${'/_api/v3/page/test?paths%5B%5D=%5BANONYMIZED%5D'}
-    ${'JSON array format'}            | ${'/_api/v3/test?paths=["/user/john","/user/jane"]'}                   | ${['paths']}       | ${'/_api/v3/test?paths=%5B%22%5BANONYMIZED%5D%22%5D'}
-    ${'multiple parameters'}          | ${'/_api/v3/test?q=secret&path=/user/john&other=keep'}                 | ${['q', 'path']}   | ${'/_api/v3/test?q=%5BANONYMIZED%5D&path=%5BANONYMIZED%5D&other=keep'}
-    ${'empty parameter value'}        | ${'/_api/v3/test?q=&other=value'}                                      | ${['q']}           | ${'/_api/v3/test?q=%5BANONYMIZED%5D&other=value'}
-    ${'parameter without value'}      | ${'/_api/v3/test?q&other=value'}                                       | ${['q']}           | ${'/_api/v3/test?q=%5BANONYMIZED%5D&other=value'}
-    ${'mixed array and single'}       | ${'/_api/v3/test?q=search&paths[]=/user/john&paths[]=/user/jane'}      | ${['q', 'paths']}  | ${'/_api/v3/test?q=%5BANONYMIZED%5D&paths%5B%5D=%5BANONYMIZED%5D'}
-    ${'with section'}                 | ${'/_api/v3/test?q=search#section'}                                    | ${['q']}           | ${'/_api/v3/test?q=%5BANONYMIZED%5D#section'}
-    ${'malformed JSON array'}         | ${'/_api/v3/test?paths=["/user/john"'}                                 | ${['paths']}       | ${'/_api/v3/test?paths=%5BANONYMIZED%5D'}
-    ${'empty JSON array'}             | ${'/_api/v3/test?paths=[]'}                                            | ${['paths']}       | ${'/_api/v3/test?paths=%5BANONYMIZED%5D'}
-    ${'single item JSON array'}       | ${'/_api/v3/test?paths=["/user/john"]'}                                | ${['paths']}       | ${'/_api/v3/test?paths=%5B%22%5BANONYMIZED%5D%22%5D'}
-    ${'URL with no query params'}     | ${'/_api/v3/test'}                                                     | ${['q']}           | ${'/_api/v3/test'}
-    ${'complex path with encoding'}   | ${'/_api/v3/test?path=%2Fuser%2Fjohn%20doe'}                           | ${['path']}        | ${'/_api/v3/test?path=%5BANONYMIZED%5D'}
+    description                     | target                                                            | paramNames         | expected
+    ${'no matching parameters'}     | ${'/_api/v3/test?other=value&another=test'}                       | ${['nonexistent']} | ${'/_api/v3/test?other=value&another=test'}
+    ${'single string parameter'}    | ${'/_api/v3/search?q=sensitive-query'}                            | ${['q']}           | ${'/_api/v3/search?q=%5BANONYMIZED%5D'}
+    ${'array-style parameters'}     | ${'/_api/v3/page/test?paths[]=/user/john&paths[]=/user/jane'}     | ${['paths']}       | ${'/_api/v3/page/test?paths%5B%5D=%5BANONYMIZED%5D'}
+    ${'JSON array format'}          | ${'/_api/v3/test?paths=["/user/john","/user/jane"]'}              | ${['paths']}       | ${'/_api/v3/test?paths=%5B%22%5BANONYMIZED%5D%22%5D'}
+    ${'multiple parameters'}        | ${'/_api/v3/test?q=secret&path=/user/john&other=keep'}            | ${['q', 'path']}   | ${'/_api/v3/test?q=%5BANONYMIZED%5D&path=%5BANONYMIZED%5D&other=keep'}
+    ${'empty parameter value'}      | ${'/_api/v3/test?q=&other=value'}                                 | ${['q']}           | ${'/_api/v3/test?q=%5BANONYMIZED%5D&other=value'}
+    ${'parameter without value'}    | ${'/_api/v3/test?q&other=value'}                                  | ${['q']}           | ${'/_api/v3/test?q=%5BANONYMIZED%5D&other=value'}
+    ${'mixed array and single'}     | ${'/_api/v3/test?q=search&paths[]=/user/john&paths[]=/user/jane'} | ${['q', 'paths']}  | ${'/_api/v3/test?q=%5BANONYMIZED%5D&paths%5B%5D=%5BANONYMIZED%5D'}
+    ${'with section'}               | ${'/_api/v3/test?q=search#section'}                               | ${['q']}           | ${'/_api/v3/test?q=%5BANONYMIZED%5D#section'}
+    ${'malformed JSON array'}       | ${'/_api/v3/test?paths=["/user/john"'}                            | ${['paths']}       | ${'/_api/v3/test?paths=%5BANONYMIZED%5D'}
+    ${'empty JSON array'}           | ${'/_api/v3/test?paths=[]'}                                       | ${['paths']}       | ${'/_api/v3/test?paths=%5BANONYMIZED%5D'}
+    ${'single item JSON array'}     | ${'/_api/v3/test?paths=["/user/john"]'}                           | ${['paths']}       | ${'/_api/v3/test?paths=%5B%22%5BANONYMIZED%5D%22%5D'}
+    ${'URL with no query params'}   | ${'/_api/v3/test'}                                                | ${['q']}           | ${'/_api/v3/test'}
+    ${'complex path with encoding'} | ${'/_api/v3/test?path=%2Fuser%2Fjohn%20doe'}                      | ${['path']}        | ${'/_api/v3/test?path=%5BANONYMIZED%5D'}
   `('should handle $description', ({ target, paramNames, expected }) => {
-  /* eslint-enable max-len */
+    /* eslint-enable max-len */
     const result = anonymizeQueryParams(target, paramNames);
     expect(result).toBe(expected);
   });
 
   it.each`
-    description                    | target                         | paramNames    | expected
-    ${'invalid URL format'}       | ${'not-a-valid-url'}           | ${['q']}      | ${'not-a-valid-url'}
-    ${'empty string target'}      | ${''}                          | ${['q']}      | ${''}
-    ${'empty paramNames array'}   | ${'/_api/v3/test?q=secret'}    | ${[]}         | ${'/_api/v3/test?q=secret'}
-  `('should handle edge cases: $description', ({ target, paramNames, expected }) => {
-    const result = anonymizeQueryParams(target, paramNames);
-    expect(result).toBe(expected);
-  });
+    description                 | target                      | paramNames | expected
+    ${'invalid URL format'}     | ${'not-a-valid-url'}        | ${['q']}   | ${'not-a-valid-url'}
+    ${'empty string target'}    | ${''}                       | ${['q']}   | ${''}
+    ${'empty paramNames array'} | ${'/_api/v3/test?q=secret'} | ${[]}      | ${'/_api/v3/test?q=secret'}
+  `(
+    'should handle edge cases: $description',
+    ({ target, paramNames, expected }) => {
+      const result = anonymizeQueryParams(target, paramNames);
+      expect(result).toBe(expected);
+    },
+  );
 });

+ 15 - 8
apps/app/src/features/opentelemetry/server/anonymization/utils/anonymize-query-params.ts

@@ -1,6 +1,8 @@
 import { diag } from '@opentelemetry/api';
 
-const logger = diag.createComponentLogger({ namespace: 'growi:anonymization:anonymize-query-params' });
+const logger = diag.createComponentLogger({
+  namespace: 'growi:anonymization:anonymize-query-params',
+});
 
 /**
  * Try to parse JSON array, return null if invalid
@@ -9,8 +11,7 @@ function tryParseJsonArray(value: string): unknown[] | null {
   try {
     const parsed = JSON.parse(value);
     return Array.isArray(parsed) ? parsed : null;
-  }
-  catch {
+  } catch {
     return null;
   }
 }
@@ -21,7 +22,10 @@ function tryParseJsonArray(value: string): unknown[] | null {
  * @param paramNames - Array of parameter names to anonymize
  * @returns Anonymized HTTP target URL
  */
-export function anonymizeQueryParams(target: string, paramNames: string[]): string {
+export function anonymizeQueryParams(
+  target: string,
+  paramNames: string[],
+): string {
   try {
     const url = new URL(target, 'http://localhost');
     const searchParams = new URLSearchParams(url.search);
@@ -54,10 +58,13 @@ export function anonymizeQueryParams(target: string, paramNames: string[]): stri
       }
     }
 
-    return hasChange ? `${url.pathname}?${searchParams.toString()}${url.hash}` : target;
-  }
-  catch (error) {
-    logger.warn(`Failed to anonymize query parameters [${paramNames.join(', ')}]: ${error}`);
+    return hasChange
+      ? `${url.pathname}?${searchParams.toString()}${url.hash}`
+      : target;
+  } catch (error) {
+    logger.warn(
+      `Failed to anonymize query parameters [${paramNames.join(', ')}]: ${error}`,
+    );
     return target;
   }
 }

+ 32 - 17
apps/app/src/features/opentelemetry/server/custom-metrics/application-metrics.spec.ts

@@ -1,6 +1,5 @@
+import { type Meter, metrics, type ObservableGauge } from '@opentelemetry/api';
 import crypto from 'crypto';
-
-import { metrics, type Meter, type ObservableGauge } from '@opentelemetry/api';
 import { mock } from 'vitest-mock-extended';
 
 import { configManager } from '~/server/service/config-manager';
@@ -51,11 +50,17 @@ describe('addApplicationMetrics', () => {
   it('should create observable gauge and set up metrics collection', () => {
     addApplicationMetrics();
 
-    expect(metrics.getMeter).toHaveBeenCalledWith('growi-application-metrics', '1.0.0');
-    expect(mockMeter.createObservableGauge).toHaveBeenCalledWith('growi.configs', {
-      description: 'GROWI instance information (always 1)',
-      unit: '1',
-    });
+    expect(metrics.getMeter).toHaveBeenCalledWith(
+      'growi-application-metrics',
+      '1.0.0',
+    );
+    expect(mockMeter.createObservableGauge).toHaveBeenCalledWith(
+      'growi.configs',
+      {
+        description: 'GROWI instance information (always 1)',
+        unit: '1',
+      },
+    );
     expect(mockMeter.addBatchObservableCallback).toHaveBeenCalledWith(
       expect.any(Function),
       [mockGauge],
@@ -76,7 +81,7 @@ describe('addApplicationMetrics', () => {
       mockGrowiInfoService.getGrowiInfo.mockResolvedValue(mockGrowiInfo);
     });
 
-    it('should observe metrics with site_url when isAppSiteUrlHashed is false', async() => {
+    it('should observe metrics with site_url when isAppSiteUrlHashed is false', async () => {
       mockConfigManager.getConfig.mockImplementation((key) => {
         if (key === 'otel:isAppSiteUrlHashed') return false;
         return undefined;
@@ -89,7 +94,9 @@ describe('addApplicationMetrics', () => {
       const callback = mockMeter.addBatchObservableCallback.mock.calls[0][0];
       await callback(mockResult);
 
-      expect(mockConfigManager.getConfig).toHaveBeenCalledWith('otel:isAppSiteUrlHashed');
+      expect(mockConfigManager.getConfig).toHaveBeenCalledWith(
+        'otel:isAppSiteUrlHashed',
+      );
       expect(mockResult.observe).toHaveBeenCalledWith(mockGauge, 1, {
         site_url: testSiteUrl,
         site_url_hashed: undefined,
@@ -98,7 +105,7 @@ describe('addApplicationMetrics', () => {
       });
     });
 
-    it('should observe metrics with site_url_hashed when isAppSiteUrlHashed is true', async() => {
+    it('should observe metrics with site_url_hashed when isAppSiteUrlHashed is true', async () => {
       mockConfigManager.getConfig.mockImplementation((key) => {
         if (key === 'otel:isAppSiteUrlHashed') return true;
         return undefined;
@@ -116,7 +123,9 @@ describe('addApplicationMetrics', () => {
       const callback = mockMeter.addBatchObservableCallback.mock.calls[0][0];
       await callback(mockResult);
 
-      expect(mockConfigManager.getConfig).toHaveBeenCalledWith('otel:isAppSiteUrlHashed');
+      expect(mockConfigManager.getConfig).toHaveBeenCalledWith(
+        'otel:isAppSiteUrlHashed',
+      );
       expect(mockResult.observe).toHaveBeenCalledWith(mockGauge, 1, {
         site_url: '[hashed]',
         site_url_hashed: expectedHash,
@@ -125,7 +134,7 @@ describe('addApplicationMetrics', () => {
       });
     });
 
-    it('should handle empty external auth types', async() => {
+    it('should handle empty external auth types', async () => {
       mockConfigManager.getConfig.mockImplementation((key) => {
         if (key === 'otel:isAppSiteUrlHashed') return false;
         return undefined;
@@ -153,12 +162,14 @@ describe('addApplicationMetrics', () => {
       });
     });
 
-    it('should handle errors in metrics collection gracefully', async() => {
+    it('should handle errors in metrics collection gracefully', async () => {
       mockConfigManager.getConfig.mockImplementation((key) => {
         if (key === 'otel:isAppSiteUrlHashed') return false;
         return undefined;
       });
-      mockGrowiInfoService.getGrowiInfo.mockRejectedValue(new Error('Service unavailable'));
+      mockGrowiInfoService.getGrowiInfo.mockRejectedValue(
+        new Error('Service unavailable'),
+      );
       const mockResult = { observe: vi.fn() };
 
       addApplicationMetrics();
@@ -172,7 +183,7 @@ describe('addApplicationMetrics', () => {
       expect(mockResult.observe).not.toHaveBeenCalled();
     });
 
-    it('should handle missing additionalInfo gracefully', async() => {
+    it('should handle missing additionalInfo gracefully', async () => {
       mockConfigManager.getConfig.mockImplementation((key) => {
         if (key === 'otel:isAppSiteUrlHashed') return false;
         return undefined;
@@ -184,14 +195,18 @@ describe('addApplicationMetrics', () => {
         wikiType: 'open',
         additionalInfo: undefined,
       };
-      mockGrowiInfoService.getGrowiInfo.mockResolvedValue(growiInfoWithoutAdditionalInfo);
+      mockGrowiInfoService.getGrowiInfo.mockResolvedValue(
+        growiInfoWithoutAdditionalInfo,
+      );
 
       addApplicationMetrics();
 
       const callback = mockMeter.addBatchObservableCallback.mock.calls[0][0];
       await callback(mockResult);
 
-      expect(mockConfigManager.getConfig).toHaveBeenCalledWith('otel:isAppSiteUrlHashed');
+      expect(mockConfigManager.getConfig).toHaveBeenCalledWith(
+        'otel:isAppSiteUrlHashed',
+      );
       expect(mockResult.observe).toHaveBeenCalledWith(mockGauge, 1, {
         site_url: testSiteUrl,
         site_url_hashed: undefined,

+ 27 - 14
apps/app/src/features/opentelemetry/server/custom-metrics/application-metrics.ts

@@ -1,13 +1,15 @@
-import crypto from 'crypto';
-
 import { diag, metrics } from '@opentelemetry/api';
+import crypto from 'crypto';
 
 import { configManager } from '~/server/service/config-manager';
 import loggerFactory from '~/utils/logger';
 
-const logger = loggerFactory('growi:opentelemetry:custom-metrics:application-metrics');
-const loggerDiag = diag.createComponentLogger({ namespace: 'growi:custom-metrics:application' });
-
+const logger = loggerFactory(
+  'growi:opentelemetry:custom-metrics:application-metrics',
+);
+const loggerDiag = diag.createComponentLogger({
+  namespace: 'growi:custom-metrics:application',
+});
 
 function getSiteUrlHashed(siteUrl: string): string {
   const hasher = crypto.createHash('sha256');
@@ -28,25 +30,36 @@ export function addApplicationMetrics(): void {
 
   // Config metrics collection callback
   meter.addBatchObservableCallback(
-    async(result) => {
+    async (result) => {
       try {
         // Dynamic import to avoid circular dependencies
-        const { growiInfoService } = await import('~/server/service/growi-info');
-        const growiInfo = await growiInfoService.getGrowiInfo({ includeAttachmentInfo: true });
+        const { growiInfoService } = await import(
+          '~/server/service/growi-info'
+        );
+        const growiInfo = await growiInfoService.getGrowiInfo({
+          includeAttachmentInfo: true,
+        });
 
-        const isAppSiteUrlHashed = configManager.getConfig('otel:isAppSiteUrlHashed');
+        const isAppSiteUrlHashed = configManager.getConfig(
+          'otel:isAppSiteUrlHashed',
+        );
 
         // Config metrics always have value 1, with information stored in labels
         result.observe(growiInfoGauge, 1, {
           // Dynamic information that can change through configuration
           site_url: isAppSiteUrlHashed ? '[hashed]' : growiInfo.appSiteUrl,
-          site_url_hashed: isAppSiteUrlHashed ? getSiteUrlHashed(growiInfo.appSiteUrl) : undefined,
+          site_url_hashed: isAppSiteUrlHashed
+            ? getSiteUrlHashed(growiInfo.appSiteUrl)
+            : undefined,
           wiki_type: growiInfo.wikiType,
-          external_auth_types: growiInfo.additionalInfo?.activeExternalAccountTypes?.join(',') || '',
+          external_auth_types:
+            growiInfo.additionalInfo?.activeExternalAccountTypes?.join(',') ||
+            '',
+        });
+      } catch (error) {
+        loggerDiag.error('Failed to collect application config metrics', {
+          error,
         });
-      }
-      catch (error) {
-        loggerDiag.error('Failed to collect application config metrics', { error });
       }
     },
     [growiInfoGauge],

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

@@ -1,7 +1,7 @@
 export { addApplicationMetrics } from './application-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 { addUserCountsMetrics } = await import('./user-counts-metrics');
 

+ 48 - 22
apps/app/src/features/opentelemetry/server/custom-metrics/user-counts-metrics.spec.ts

@@ -1,4 +1,4 @@
-import { metrics, type Meter, type ObservableGauge } from '@opentelemetry/api';
+import { type Meter, metrics, type ObservableGauge } from '@opentelemetry/api';
 import { mock } from 'vitest-mock-extended';
 
 import { addUserCountsMetrics } from './user-counts-metrics';
@@ -25,7 +25,7 @@ vi.mock('@opentelemetry/api', () => ({
 const mockGrowiInfoService = {
   getGrowiInfo: vi.fn(),
 };
-vi.mock('~/server/service/growi-info', async() => ({
+vi.mock('~/server/service/growi-info', async () => ({
   growiInfoService: mockGrowiInfoService,
 }));
 
@@ -49,15 +49,24 @@ describe('addUserCountsMetrics', () => {
   it('should create observable gauges and set up metrics collection', () => {
     addUserCountsMetrics();
 
-    expect(metrics.getMeter).toHaveBeenCalledWith('growi-user-counts-metrics', '1.0.0');
-    expect(mockMeter.createObservableGauge).toHaveBeenCalledWith('growi.users.total', {
-      description: 'Total number of users in GROWI',
-      unit: 'users',
-    });
-    expect(mockMeter.createObservableGauge).toHaveBeenCalledWith('growi.users.active', {
-      description: 'Number of active users in GROWI',
-      unit: 'users',
-    });
+    expect(metrics.getMeter).toHaveBeenCalledWith(
+      'growi-user-counts-metrics',
+      '1.0.0',
+    );
+    expect(mockMeter.createObservableGauge).toHaveBeenCalledWith(
+      'growi.users.total',
+      {
+        description: 'Total number of users in GROWI',
+        unit: 'users',
+      },
+    );
+    expect(mockMeter.createObservableGauge).toHaveBeenCalledWith(
+      'growi.users.active',
+      {
+        description: 'Number of active users in GROWI',
+        unit: 'users',
+      },
+    );
     expect(mockMeter.addBatchObservableCallback).toHaveBeenCalledWith(
       expect.any(Function),
       [mockUserCountGauge, mockActiveUserCountGauge],
@@ -76,7 +85,7 @@ describe('addUserCountsMetrics', () => {
       mockGrowiInfoService.getGrowiInfo.mockResolvedValue(mockGrowiInfo);
     });
 
-    it('should observe user count metrics when growi info is available', async() => {
+    it('should observe user count metrics when growi info is available', async () => {
       const mockResult = { observe: vi.fn() };
 
       addUserCountsMetrics();
@@ -85,12 +94,17 @@ describe('addUserCountsMetrics', () => {
       const callback = mockMeter.addBatchObservableCallback.mock.calls[0][0];
       await callback(mockResult);
 
-      expect(mockGrowiInfoService.getGrowiInfo).toHaveBeenCalledWith({ includeUserCountInfo: true });
+      expect(mockGrowiInfoService.getGrowiInfo).toHaveBeenCalledWith({
+        includeUserCountInfo: true,
+      });
       expect(mockResult.observe).toHaveBeenCalledWith(mockUserCountGauge, 150);
-      expect(mockResult.observe).toHaveBeenCalledWith(mockActiveUserCountGauge, 75);
+      expect(mockResult.observe).toHaveBeenCalledWith(
+        mockActiveUserCountGauge,
+        75,
+      );
     });
 
-    it('should use default values when user counts are missing', async() => {
+    it('should use default values when user counts are missing', async () => {
       const mockResult = { observe: vi.fn() };
 
       const growiInfoWithoutCounts = {
@@ -98,7 +112,9 @@ describe('addUserCountsMetrics', () => {
           // Missing currentUsersCount and currentActiveUsersCount
         },
       };
-      mockGrowiInfoService.getGrowiInfo.mockResolvedValue(growiInfoWithoutCounts);
+      mockGrowiInfoService.getGrowiInfo.mockResolvedValue(
+        growiInfoWithoutCounts,
+      );
 
       addUserCountsMetrics();
 
@@ -106,16 +122,21 @@ describe('addUserCountsMetrics', () => {
       await callback(mockResult);
 
       expect(mockResult.observe).toHaveBeenCalledWith(mockUserCountGauge, 0);
-      expect(mockResult.observe).toHaveBeenCalledWith(mockActiveUserCountGauge, 0);
+      expect(mockResult.observe).toHaveBeenCalledWith(
+        mockActiveUserCountGauge,
+        0,
+      );
     });
 
-    it('should handle missing additionalInfo gracefully', async() => {
+    it('should handle missing additionalInfo gracefully', async () => {
       const mockResult = { observe: vi.fn() };
 
       const growiInfoWithoutAdditionalInfo = {
         // Missing additionalInfo entirely
       };
-      mockGrowiInfoService.getGrowiInfo.mockResolvedValue(growiInfoWithoutAdditionalInfo);
+      mockGrowiInfoService.getGrowiInfo.mockResolvedValue(
+        growiInfoWithoutAdditionalInfo,
+      );
 
       addUserCountsMetrics();
 
@@ -123,11 +144,16 @@ describe('addUserCountsMetrics', () => {
       await callback(mockResult);
 
       expect(mockResult.observe).toHaveBeenCalledWith(mockUserCountGauge, 0);
-      expect(mockResult.observe).toHaveBeenCalledWith(mockActiveUserCountGauge, 0);
+      expect(mockResult.observe).toHaveBeenCalledWith(
+        mockActiveUserCountGauge,
+        0,
+      );
     });
 
-    it('should handle errors in metrics collection gracefully', async() => {
-      mockGrowiInfoService.getGrowiInfo.mockRejectedValue(new Error('Service unavailable'));
+    it('should handle errors in metrics collection gracefully', async () => {
+      mockGrowiInfoService.getGrowiInfo.mockRejectedValue(
+        new Error('Service unavailable'),
+      );
       const mockResult = { observe: vi.fn() };
 
       addUserCountsMetrics();

+ 26 - 12
apps/app/src/features/opentelemetry/server/custom-metrics/user-counts-metrics.ts

@@ -3,7 +3,9 @@ import { diag, metrics } from '@opentelemetry/api';
 import loggerFactory from '~/utils/logger';
 
 const logger = loggerFactory('growi:opentelemetry:custom-metrics:user-counts');
-const loggerDiag = diag.createComponentLogger({ namespace: 'growi:custom-metrics:user-counts' });
+const loggerDiag = diag.createComponentLogger({
+  namespace: 'growi:custom-metrics:user-counts',
+});
 
 export function addUserCountsMetrics(): void {
   logger.info('Starting user counts metrics collection');
@@ -17,25 +19,37 @@ export function addUserCountsMetrics(): void {
   });
 
   // Active user count gauge
-  const activeUserCountGauge = meter.createObservableGauge('growi.users.active', {
-    description: 'Number of active users in GROWI',
-    unit: 'users',
-  });
+  const activeUserCountGauge = meter.createObservableGauge(
+    'growi.users.active',
+    {
+      description: 'Number of active users in GROWI',
+      unit: 'users',
+    },
+  );
 
   // User metrics collection callback
   meter.addBatchObservableCallback(
-    async(result) => {
+    async (result) => {
       try {
         // 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({ includeUserCountInfo: true });
+        const growiInfo = await growiInfoService.getGrowiInfo({
+          includeUserCountInfo: true,
+        });
 
         // Observe user count metrics
-        result.observe(userCountGauge, growiInfo.additionalInfo?.currentUsersCount || 0);
-        result.observe(activeUserCountGauge, growiInfo.additionalInfo?.currentActiveUsersCount || 0);
-      }
-      catch (error) {
+        result.observe(
+          userCountGauge,
+          growiInfo.additionalInfo?.currentUsersCount || 0,
+        );
+        result.observe(
+          activeUserCountGauge,
+          growiInfo.additionalInfo?.currentActiveUsersCount || 0,
+        );
+      } catch (error) {
         loggerDiag.error('Failed to collect user counts metrics', { error });
       }
     },

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

@@ -21,7 +21,7 @@ describe('getApplicationResourceAttributes', () => {
     vi.clearAllMocks();
   });
 
-  it('should return complete application resource attributes when growi info is available', async() => {
+  it('should return complete application resource attributes when growi info is available', async () => {
     const mockGrowiInfo = {
       type: 'app',
       deploymentType: 'standalone',
@@ -43,10 +43,12 @@ describe('getApplicationResourceAttributes', () => {
       'growi.installedAt': '2023-01-01T00:00:00.000Z',
       'growi.installedAt.by_oldest_user': '2023-01-01T00:00:00.000Z',
     });
-    expect(mockGrowiInfoService.getGrowiInfo).toHaveBeenCalledWith({ includeInstalledInfo: true });
+    expect(mockGrowiInfoService.getGrowiInfo).toHaveBeenCalledWith({
+      includeInstalledInfo: true,
+    });
   });
 
-  it('should handle missing additionalInfo gracefully', async() => {
+  it('should handle missing additionalInfo gracefully', async () => {
     const mockGrowiInfo = {
       type: 'app',
       deploymentType: 'standalone',
@@ -66,15 +68,17 @@ describe('getApplicationResourceAttributes', () => {
     });
   });
 
-  it('should return empty object when growiInfoService throws error', async() => {
-    mockGrowiInfoService.getGrowiInfo.mockRejectedValue(new Error('Service unavailable'));
+  it('should return empty object when growiInfoService throws error', async () => {
+    mockGrowiInfoService.getGrowiInfo.mockRejectedValue(
+      new Error('Service unavailable'),
+    );
 
     const result = await getApplicationResourceAttributes();
 
     expect(result).toEqual({});
   });
 
-  it('should handle partial additionalInfo data', async() => {
+  it('should handle partial additionalInfo data', async () => {
     const mockGrowiInfo = {
       type: 'app',
       deploymentType: 'docker',

+ 12 - 6
apps/app/src/features/opentelemetry/server/custom-resource-attributes/application-resource-attributes.ts

@@ -2,7 +2,9 @@ import type { Attributes } from '@opentelemetry/api';
 
 import loggerFactory from '~/utils/logger';
 
-const logger = loggerFactory('growi:opentelemetry:custom-resource-attributes:application');
+const logger = loggerFactory(
+  'growi:opentelemetry:custom-resource-attributes:application',
+);
 
 /**
  * Get application fixed information as OpenTelemetry Resource Attributes
@@ -15,7 +17,9 @@ export async function getApplicationResourceAttributes(): Promise<Attributes> {
     // Dynamic import to avoid circular dependencies
     const { growiInfoService } = await import('~/server/service/growi-info');
 
-    const growiInfo = await growiInfoService.getGrowiInfo({ includeInstalledInfo: true });
+    const growiInfo = await growiInfoService.getGrowiInfo({
+      includeInstalledInfo: true,
+    });
 
     const attributes: Attributes = {
       // Service configuration (rarely changes after system setup)
@@ -25,15 +29,17 @@ export async function getApplicationResourceAttributes(): Promise<Attributes> {
 
       // Installation information (fixed values)
       'growi.installedAt': growiInfo.additionalInfo?.installedAt?.toISOString(),
-      'growi.installedAt.by_oldest_user': growiInfo.additionalInfo?.installedAtByOldestUser?.toISOString(),
+      'growi.installedAt.by_oldest_user':
+        growiInfo.additionalInfo?.installedAtByOldestUser?.toISOString(),
     };
 
     logger.info('Application resource attributes collected', { attributes });
 
     return attributes;
-  }
-  catch (error) {
-    logger.error('Failed to collect application resource attributes', { error });
+  } catch (error) {
+    logger.error('Failed to collect application resource attributes', {
+      error,
+    });
     return {};
   }
 }

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

@@ -1,2 +1,2 @@
-export { getOsResourceAttributes } from './os-resource-attributes';
 export { getApplicationResourceAttributes } from './application-resource-attributes';
+export { getOsResourceAttributes } from './os-resource-attributes';

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

@@ -16,7 +16,7 @@ describe('getOsResourceAttributes', () => {
     totalmem: ReturnType<typeof vi.fn>;
   };
 
-  beforeEach(async() => {
+  beforeEach(async () => {
     vi.clearAllMocks();
     // Get the mocked os module
     mockOs = await vi.importMock('node:os');

+ 3 - 1
apps/app/src/features/opentelemetry/server/custom-resource-attributes/os-resource-attributes.ts

@@ -4,7 +4,9 @@ import type { Attributes } from '@opentelemetry/api';
 
 import loggerFactory from '~/utils/logger';
 
-const logger = loggerFactory('growi:opentelemetry:custom-resource-attributes:os');
+const logger = loggerFactory(
+  'growi:opentelemetry:custom-resource-attributes:os',
+);
 
 /**
  * Get OS information as OpenTelemetry Resource Attributes

+ 11 - 13
apps/app/src/features/opentelemetry/server/logger.ts

@@ -1,13 +1,14 @@
-import { diag, type DiagLogger } from '@opentelemetry/api';
+import { type DiagLogger, diag } from '@opentelemetry/api';
 
 import loggerFactory from '~/utils/logger';
 
 const logger = loggerFactory('growi:opentelemetry:diag');
 
-
 class DiagLoggerBunyanAdapter implements DiagLogger {
-
-  private parseMessage(message: string, args: unknown[]): [logMessage: string, data: object] {
+  private parseMessage(
+    message: string,
+    args: unknown[],
+  ): [logMessage: string, data: object] {
     let logMessage = message;
     let data = {};
 
@@ -17,12 +18,12 @@ class DiagLoggerBunyanAdapter implements DiagLogger {
       if (typeof parsedMessage === 'object' && parsedMessage !== null) {
         data = parsedMessage;
         // if parsed successfully, use 'message' property as log message
-        logMessage = 'message' in data && typeof data.message === 'string'
-          ? data.message
-          : message;
+        logMessage =
+          'message' in data && typeof data.message === 'string'
+            ? data.message
+            : message;
       }
-    }
-    catch (e) {
+    } catch (e) {
       // do nothing if the message is not a JSON string
     }
 
@@ -34,8 +35,7 @@ class DiagLoggerBunyanAdapter implements DiagLogger {
           try {
             const parsed = JSON.parse(arg);
             return { ...acc, ...parsed };
-          }
-          catch (e) {
+          } catch (e) {
             return { ...acc, additionalInfo: arg };
           }
         }
@@ -66,10 +66,8 @@ class DiagLoggerBunyanAdapter implements DiagLogger {
   verbose(message: string, ...args): void {
     logger.trace(...this.parseMessage(message, args));
   }
-
 }
 
-
 export const initLogger = (): void => {
   // Enable global logger for OpenTelemetry
   diag.setLogger(new DiagLoggerBunyanAdapter());

+ 44 - 30
apps/app/src/features/opentelemetry/server/node-sdk-configuration.ts

@@ -5,7 +5,10 @@ import type { Resource } from '@opentelemetry/resources';
 import { resourceFromAttributes } from '@opentelemetry/resources';
 import { PeriodicExportingMetricReader } from '@opentelemetry/sdk-metrics';
 import type { NodeSDKConfiguration } from '@opentelemetry/sdk-node';
-import { ATTR_SERVICE_NAME, ATTR_SERVICE_VERSION } from '@opentelemetry/semantic-conventions';
+import {
+  ATTR_SERVICE_NAME,
+  ATTR_SERVICE_VERSION,
+} from '@opentelemetry/semantic-conventions';
 
 import { configManager } from '~/server/service/config-manager';
 import { getGrowiVersion } from '~/utils/growi-version';
@@ -14,8 +17,8 @@ import { httpInstrumentationConfig as httpInstrumentationConfigForAnonymize } fr
 import { ATTR_SERVICE_INSTANCE_ID } from './semconv';
 
 type Option = {
-  enableAnonymization?: boolean,
-}
+  enableAnonymization?: boolean;
+};
 
 type Configuration = Partial<NodeSDKConfiguration> & {
   resource: Resource;
@@ -34,7 +37,9 @@ export const generateNodeSDKConfiguration = (opts?: Option): Configuration => {
     });
 
     // Data anonymization configuration
-    const httpInstrumentationConfig = opts?.enableAnonymization ? httpInstrumentationConfigForAnonymize : {};
+    const httpInstrumentationConfig = opts?.enableAnonymization
+      ? httpInstrumentationConfigForAnonymize
+      : {};
 
     configuration = {
       resource,
@@ -43,23 +48,24 @@ export const generateNodeSDKConfiguration = (opts?: Option): Configuration => {
         exporter: new OTLPMetricExporter(),
         exportIntervalMillis: 300000, // 5 minute
       }),
-      instrumentations: [getNodeAutoInstrumentations({
-        '@opentelemetry/instrumentation-bunyan': {
-          enabled: false,
-        },
-        // disable fs instrumentation since this generates very large amount of traces
-        // see: https://opentelemetry.io/docs/languages/js/libraries/#registration
-        '@opentelemetry/instrumentation-fs': {
-          enabled: false,
-        },
-        // HTTP instrumentation with anonymization
-        '@opentelemetry/instrumentation-http': {
-          enabled: true,
-          ...httpInstrumentationConfig,
-        },
-      })],
+      instrumentations: [
+        getNodeAutoInstrumentations({
+          '@opentelemetry/instrumentation-bunyan': {
+            enabled: false,
+          },
+          // disable fs instrumentation since this generates very large amount of traces
+          // see: https://opentelemetry.io/docs/languages/js/libraries/#registration
+          '@opentelemetry/instrumentation-fs': {
+            enabled: false,
+          },
+          // HTTP instrumentation with anonymization
+          '@opentelemetry/instrumentation-http': {
+            enabled: true,
+            ...httpInstrumentationConfig,
+          },
+        }),
+      ],
     };
-
   }
 
   return configuration;
@@ -69,19 +75,27 @@ export const generateNodeSDKConfiguration = (opts?: Option): Configuration => {
  * Generate additional attributes after database initialization
  * This function should be called after database is available
  */
-export const generateAdditionalResourceAttributes = async(opts?: Option): Promise<Resource> => {
+export const generateAdditionalResourceAttributes = async (
+  opts?: Option,
+): Promise<Resource> => {
   if (resource == null) {
-    throw new Error('Resource is not initialized. Call generateNodeSDKConfiguration first.');
+    throw new Error(
+      'Resource is not initialized. Call generateNodeSDKConfiguration first.',
+    );
   }
 
-  const serviceInstanceId = configManager.getConfig('otel:serviceInstanceId')
-    ?? configManager.getConfig('app:serviceInstanceId');
+  const serviceInstanceId =
+    configManager.getConfig('otel:serviceInstanceId') ??
+    configManager.getConfig('app:serviceInstanceId');
 
-  const { getApplicationResourceAttributes, getOsResourceAttributes } = await import('./custom-resource-attributes');
+  const { getApplicationResourceAttributes, getOsResourceAttributes } =
+    await import('./custom-resource-attributes');
 
-  return resource.merge(resourceFromAttributes({
-    [ATTR_SERVICE_INSTANCE_ID]: serviceInstanceId,
-    ...await getApplicationResourceAttributes(),
-    ...await getOsResourceAttributes(),
-  }));
+  return resource.merge(
+    resourceFromAttributes({
+      [ATTR_SERVICE_INSTANCE_ID]: serviceInstanceId,
+      ...(await getApplicationResourceAttributes()),
+      ...(await getOsResourceAttributes()),
+    }),
+  );
 };

+ 1 - 2
apps/app/src/features/opentelemetry/server/node-sdk-resource.ts

@@ -23,8 +23,7 @@ export const setResource = (sdk: NodeSDK, resource: Resource): void => {
   // Verify that we can access the _resource property
   try {
     getResource(sdk);
-  }
-  catch (e) {
+  } catch (e) {
     throw new Error('Failed to access SDK resource');
   }
 

+ 68 - 50
apps/app/src/features/opentelemetry/server/node-sdk.spec.ts

@@ -3,7 +3,11 @@ import { NodeSDK } from '@opentelemetry/sdk-node';
 
 import { configManager } from '~/server/service/config-manager';
 
-import { setupAdditionalResourceAttributes, initInstrumentation, startOpenTelemetry } from './node-sdk';
+import {
+  initInstrumentation,
+  setupAdditionalResourceAttributes,
+  startOpenTelemetry,
+} from './node-sdk';
 import { getResource } from './node-sdk-resource';
 
 // Only mock configManager as it's external to what we're testing
@@ -37,24 +41,28 @@ vi.mock('~/server/service/growi-info', () => ({
 describe('node-sdk', () => {
   // Helper functions to reduce duplication
   const mockInstrumentationEnabled = () => {
-    vi.mocked(configManager.getConfig).mockImplementation((key: string, source?: ConfigSource) => {
-      if (key === 'otel:enabled') {
-        return source === ConfigSource.env ? true : undefined;
-      }
-      return undefined;
-    });
+    vi.mocked(configManager.getConfig).mockImplementation(
+      (key: string, source?: ConfigSource) => {
+        if (key === 'otel:enabled') {
+          return source === ConfigSource.env ? true : undefined;
+        }
+        return undefined;
+      },
+    );
   };
 
   const mockInstrumentationDisabled = () => {
-    vi.mocked(configManager.getConfig).mockImplementation((key: string, source?: ConfigSource) => {
-      if (key === 'otel:enabled') {
-        return source === ConfigSource.env ? false : undefined;
-      }
-      return undefined;
-    });
+    vi.mocked(configManager.getConfig).mockImplementation(
+      (key: string, source?: ConfigSource) => {
+        if (key === 'otel:enabled') {
+          return source === ConfigSource.env ? false : undefined;
+        }
+        return undefined;
+      },
+    );
   };
 
-  beforeEach(async() => {
+  beforeEach(async () => {
     vi.clearAllMocks();
 
     // Reset SDK instance using __testing__ export
@@ -66,14 +74,14 @@ describe('node-sdk', () => {
   });
 
   describe('initInstrumentation', () => {
-    it('should call setupCustomMetrics when instrumentation is enabled', async() => {
+    it('should call setupCustomMetrics when instrumentation is enabled', async () => {
       // Mock instrumentation as enabled
       mockInstrumentationEnabled();
 
       await initInstrumentation();
     });
 
-    it('should not call setupCustomMetrics when instrumentation is disabled', async() => {
+    it('should not call setupCustomMetrics when instrumentation is disabled', async () => {
       const { setupCustomMetrics } = await import('./custom-metrics');
 
       // Mock instrumentation as disabled
@@ -85,7 +93,7 @@ describe('node-sdk', () => {
       expect(setupCustomMetrics).not.toHaveBeenCalled();
     });
 
-    it('should create SDK instance when instrumentation is enabled', async() => {
+    it('should create SDK instance when instrumentation is enabled', async () => {
       // Mock instrumentation as enabled
       mockInstrumentationEnabled();
 
@@ -98,7 +106,7 @@ describe('node-sdk', () => {
       expect(sdkInstance).toBeInstanceOf(NodeSDK);
     });
 
-    it('should not create SDK instance when instrumentation is disabled', async() => {
+    it('should not create SDK instance when instrumentation is disabled', async () => {
       // Mock instrumentation as disabled
       mockInstrumentationDisabled();
 
@@ -112,17 +120,19 @@ describe('node-sdk', () => {
   });
 
   describe('setupAdditionalResourceAttributes', () => {
-    it('should update service.instance.id when app:serviceInstanceId is available', async() => {
+    it('should update service.instance.id when app:serviceInstanceId is available', async () => {
       // Set up mocks for this specific test
-      vi.mocked(configManager.getConfig).mockImplementation((key: string, source?: ConfigSource) => {
-        // For otel:enabled, always expect ConfigSource.env
-        if (key === 'otel:enabled') {
-          return source === ConfigSource.env ? true : undefined;
-        }
-        // For service instance IDs, only respond when no source is specified
-        if (key === 'app:serviceInstanceId') return 'test-instance-id';
-        return undefined;
-      });
+      vi.mocked(configManager.getConfig).mockImplementation(
+        (key: string, source?: ConfigSource) => {
+          // For otel:enabled, always expect ConfigSource.env
+          if (key === 'otel:enabled') {
+            return source === ConfigSource.env ? true : undefined;
+          }
+          // For service instance IDs, only respond when no source is specified
+          if (key === 'app:serviceInstanceId') return 'test-instance-id';
+          return undefined;
+        },
+      );
 
       // Initialize SDK first
       await initInstrumentation();
@@ -147,25 +157,29 @@ describe('node-sdk', () => {
 
       // Verify that resource was updated with app:serviceInstanceId
       const updatedResource = getResource(sdkInstance);
-      expect(updatedResource.attributes['service.instance.id']).toBe('test-instance-id');
+      expect(updatedResource.attributes['service.instance.id']).toBe(
+        'test-instance-id',
+      );
     });
 
-    it('should update service.instance.id with otel:serviceInstanceId if available', async() => {
+    it('should update service.instance.id with otel:serviceInstanceId if available', async () => {
       // Set up mocks for this specific test
-      vi.mocked(configManager.getConfig).mockImplementation((key: string, source?: ConfigSource) => {
-        // For otel:enabled, always expect ConfigSource.env
-        if (key === 'otel:enabled') {
-          return source === ConfigSource.env ? true : undefined;
-        }
-
-        // For service instance IDs, only respond when no source is specified
-        if (source === undefined) {
-          if (key === 'otel:serviceInstanceId') return 'otel-instance-id';
-          if (key === 'app:serviceInstanceId') return 'test-instance-id';
-        }
-
-        return undefined;
-      });
+      vi.mocked(configManager.getConfig).mockImplementation(
+        (key: string, source?: ConfigSource) => {
+          // For otel:enabled, always expect ConfigSource.env
+          if (key === 'otel:enabled') {
+            return source === ConfigSource.env ? true : undefined;
+          }
+
+          // For service instance IDs, only respond when no source is specified
+          if (source === undefined) {
+            if (key === 'otel:serviceInstanceId') return 'otel-instance-id';
+            if (key === 'app:serviceInstanceId') return 'test-instance-id';
+          }
+
+          return undefined;
+        },
+      );
 
       // Initialize SDK
       await initInstrumentation();
@@ -184,10 +198,12 @@ describe('node-sdk', () => {
 
       // Verify that otel:serviceInstanceId was used
       const updatedResource = getResource(sdkInstance);
-      expect(updatedResource.attributes['service.instance.id']).toBe('otel-instance-id');
+      expect(updatedResource.attributes['service.instance.id']).toBe(
+        'otel-instance-id',
+      );
     });
 
-    it('should handle gracefully when instrumentation is disabled', async() => {
+    it('should handle gracefully when instrumentation is disabled', async () => {
       // Mock instrumentation as disabled
       mockInstrumentationDisabled();
 
@@ -195,12 +211,14 @@ describe('node-sdk', () => {
       await initInstrumentation();
 
       // Call setupAdditionalResourceAttributes should not throw error
-      await expect(setupAdditionalResourceAttributes()).resolves.toBeUndefined();
+      await expect(
+        setupAdditionalResourceAttributes(),
+      ).resolves.toBeUndefined();
     });
   });
 
   describe('startOpenTelemetry', () => {
-    it('should start SDK and call setupCustomMetrics when instrumentation is enabled and SDK instance exists', async() => {
+    it('should start SDK and call setupCustomMetrics when instrumentation is enabled and SDK instance exists', async () => {
       const { setupCustomMetrics } = await import('./custom-metrics');
 
       // Mock instrumentation as enabled
@@ -228,7 +246,7 @@ describe('node-sdk', () => {
       }
     });
 
-    it('should not start SDK when instrumentation is disabled', async() => {
+    it('should not start SDK when instrumentation is disabled', async () => {
       const { setupCustomMetrics } = await import('./custom-metrics');
 
       // Mock instrumentation as disabled
@@ -244,7 +262,7 @@ describe('node-sdk', () => {
       expect(setupCustomMetrics).not.toHaveBeenCalled();
     });
 
-    it('should not start SDK when SDK instance does not exist', async() => {
+    it('should not start SDK when SDK instance does not exist', async () => {
       const { setupCustomMetrics } = await import('./custom-metrics');
 
       // Mock instrumentation as enabled but don't initialize SDK

+ 52 - 22
apps/app/src/features/opentelemetry/server/node-sdk.ts

@@ -16,28 +16,37 @@ let sdkInstance: NodeSDK | undefined;
  * Since otel library sees it.
  */
 function overwriteSdkDisabled(): void {
-  const instrumentationEnabled = configManager.getConfig('otel:enabled', ConfigSource.env);
-
-  if (instrumentationEnabled && (
-    process.env.OTEL_SDK_DISABLED === 'true'
-    || process.env.OTEL_SDK_DISABLED === '1'
-  )) {
-    logger.warn("OTEL_SDK_DISABLED overwritten with 'false' since GROWI's 'otel:enabled' config is true.");
+  const instrumentationEnabled = configManager.getConfig(
+    'otel:enabled',
+    ConfigSource.env,
+  );
+
+  if (
+    instrumentationEnabled &&
+    (process.env.OTEL_SDK_DISABLED === 'true' ||
+      process.env.OTEL_SDK_DISABLED === '1')
+  ) {
+    logger.warn(
+      "OTEL_SDK_DISABLED overwritten with 'false' since GROWI's 'otel:enabled' config is true.",
+    );
     process.env.OTEL_SDK_DISABLED = 'false';
     return;
   }
 
-  if (!instrumentationEnabled && (
-    process.env.OTEL_SDK_DISABLED === 'false'
-    || process.env.OTEL_SDK_DISABLED === '0'
-  )) {
-    logger.warn("OTEL_SDK_DISABLED is overwritten with 'true' since GROWI's 'otel:enabled' config is false.");
+  if (
+    !instrumentationEnabled &&
+    (process.env.OTEL_SDK_DISABLED === 'false' ||
+      process.env.OTEL_SDK_DISABLED === '0')
+  ) {
+    logger.warn(
+      "OTEL_SDK_DISABLED is overwritten with 'true' since GROWI's 'otel:enabled' config is false.",
+    );
     process.env.OTEL_SDK_DISABLED = 'true';
     return;
   }
 }
 
-export const initInstrumentation = async(): Promise<void> => {
+export const initInstrumentation = async (): Promise<void> => {
   if (sdkInstance != null) {
     logger.warn('OpenTelemetry instrumentation already started');
     return;
@@ -48,7 +57,10 @@ export const initInstrumentation = async(): Promise<void> => {
 
   overwriteSdkDisabled();
 
-  const instrumentationEnabled = configManager.getConfig('otel:enabled', ConfigSource.env);
+  const instrumentationEnabled = configManager.getConfig(
+    'otel:enabled',
+    ConfigSource.env,
+  );
   if (instrumentationEnabled) {
     logger.info(`GROWI now collects anonymous telemetry.
 
@@ -66,9 +78,14 @@ For more information, see https://docs.growi.org/en/admin-guide/admin-cookbook/t
 
     // instanciate NodeSDK
     const { NodeSDK } = await import('@opentelemetry/sdk-node');
-    const { generateNodeSDKConfiguration } = await import('./node-sdk-configuration');
+    const { generateNodeSDKConfiguration } = await import(
+      './node-sdk-configuration'
+    );
     // get resource from configuration
-    const enableAnonymization = configManager.getConfig('otel:anonymizeInBestEffort', ConfigSource.env);
+    const enableAnonymization = configManager.getConfig(
+      'otel:anonymizeInBestEffort',
+      ConfigSource.env,
+    );
 
     const sdkConfig = generateNodeSDKConfiguration({ enableAnonymization });
 
@@ -76,20 +93,30 @@ For more information, see https://docs.growi.org/en/admin-guide/admin-cookbook/t
   }
 };
 
-export const setupAdditionalResourceAttributes = async(): Promise<void> => {
-  const instrumentationEnabled = configManager.getConfig('otel:enabled', ConfigSource.env);
+export const setupAdditionalResourceAttributes = async (): Promise<void> => {
+  const instrumentationEnabled = configManager.getConfig(
+    'otel:enabled',
+    ConfigSource.env,
+  );
 
   if (instrumentationEnabled) {
     if (sdkInstance == null) {
       throw new Error('OpenTelemetry instrumentation is not initialized');
     }
 
-    const { generateAdditionalResourceAttributes } = await import('./node-sdk-configuration');
+    const { generateAdditionalResourceAttributes } = await import(
+      './node-sdk-configuration'
+    );
     // get resource from configuration
-    const enableAnonymization = configManager.getConfig('otel:anonymizeInBestEffort', ConfigSource.env);
+    const enableAnonymization = configManager.getConfig(
+      'otel:anonymizeInBestEffort',
+      ConfigSource.env,
+    );
 
     // generate additional resource attributes
-    const updatedResource = await generateAdditionalResourceAttributes({ enableAnonymization });
+    const updatedResource = await generateAdditionalResourceAttributes({
+      enableAnonymization,
+    });
 
     // set resource to sdk instance
     setResource(sdkInstance, updatedResource);
@@ -97,7 +124,10 @@ export const setupAdditionalResourceAttributes = async(): Promise<void> => {
 };
 
 export const startOpenTelemetry = (): void => {
-  const instrumentationEnabled = configManager.getConfig('otel:enabled', ConfigSource.env);
+  const instrumentationEnabled = configManager.getConfig(
+    'otel:enabled',
+    ConfigSource.env,
+  );
 
   if (instrumentationEnabled && sdkInstance != null) {
     if (sdkInstance == null) {

+ 0 - 1
biome.json

@@ -29,7 +29,6 @@
       "!apps/app/src/components/**",
       "!apps/app/src/features/growi-plugin/**",
       "!apps/app/src/features/openai/**",
-      "!apps/app/src/features/opentelemetry/**",
       "!apps/app/src/features/page-bulk-export/**",
       "!apps/app/src/features/rate-limiter/**",
       "!apps/app/src/interfaces/**",