Yuki Takei 9 ماه پیش
والد
کامیت
cdcd8b2ccc

+ 35 - 7
apps/app/src/features/opentelemetry/server/node-sdk-configuration.ts

@@ -1,3 +1,4 @@
+import type { Attributes } from '@opentelemetry/api';
 import { getNodeAutoInstrumentations } from '@opentelemetry/auto-instrumentations-node';
 import { OTLPMetricExporter } from '@opentelemetry/exporter-metrics-otlp-grpc';
 import { OTLPTraceExporter } from '@opentelemetry/exporter-trace-otlp-grpc';
@@ -8,10 +9,17 @@ import type { NodeSDKConfiguration } from '@opentelemetry/sdk-node';
 import { ATTR_SERVICE_NAME, ATTR_SERVICE_VERSION } from '@opentelemetry/semantic-conventions';
 import { ATTR_SERVICE_INSTANCE_ID } from '@opentelemetry/semantic-conventions/incubating';
 
+import { configManager } from '~/server/service/config-manager';
 import { getGrowiVersion } from '~/utils/growi-version';
 
 import { httpInstrumentationConfig as httpInstrumentationConfigForAnonymize } from './anonymization';
 import { addApplicationMetrics } from './custom-metrics';
+import { addUserCountsMetrics } from './custom-metrics/user-counts-metrics';
+import { getOsResourceAttributes } from './custom-resource-attributes';
+
+type Option = {
+  enableAnonymization?: boolean,
+}
 
 type Configuration = Partial<NodeSDKConfiguration> & {
   resource: Resource;
@@ -20,17 +28,22 @@ type Configuration = Partial<NodeSDKConfiguration> & {
 let resource: Resource;
 let configuration: Configuration;
 
-export const generateNodeSDKConfiguration = (serviceInstanceId?: string, enableAnonymization = false): Configuration => {
+export const generateNodeSDKConfiguration = (opts?: Option): Configuration => {
   if (configuration == null) {
     const version = getGrowiVersion();
 
+    // Collect OS resource attributes
+    const osAttributes = getOsResourceAttributes();
+
     resource = resourceFromAttributes({
       [ATTR_SERVICE_NAME]: 'growi',
       [ATTR_SERVICE_VERSION]: version,
+      // Add OS resource attributes
+      ...osAttributes,
     });
 
     // Data anonymization configuration
-    const httpInstrumentationConfig = enableAnonymization ? httpInstrumentationConfigForAnonymize : {};
+    const httpInstrumentationConfig = opts?.enableAnonymization ? httpInstrumentationConfigForAnonymize : {};
 
     configuration = {
       resource,
@@ -57,13 +70,28 @@ export const generateNodeSDKConfiguration = (serviceInstanceId?: string, enableA
 
     // add custom metrics
     addApplicationMetrics();
+    addUserCountsMetrics();
   }
 
-  if (serviceInstanceId != null) {
-    configuration.resource = resource.merge(resourceFromAttributes({
-      [ATTR_SERVICE_INSTANCE_ID]: serviceInstanceId,
-    }));
+  return configuration;
+};
+
+/**
+ * Generate additional attributes after database initialization
+ * This function should be called after database is available
+ */
+export const generateAdditionalResourceAttributes = async(opts?: Option): Promise<Resource> => {
+  if (resource == null) {
+    throw new Error('Resource is not initialized. Call generateNodeSDKConfiguration first.');
   }
 
-  return configuration;
+  const serviceInstanceId = configManager.getConfig('otel:serviceInstanceId')
+    ?? configManager.getConfig('app:serviceInstanceId');
+
+  const { getApplicationResourceAttributes } = await import('./custom-resource-attributes');
+
+  return resource.merge(resourceFromAttributes({
+    [ATTR_SERVICE_INSTANCE_ID]: serviceInstanceId,
+    ...await getApplicationResourceAttributes(),
+  }));
 };

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

@@ -1,12 +1,10 @@
 import { ConfigSource } from '@growi/core/dist/interfaces';
-import { Resource } from '@opentelemetry/resources';
 import { NodeSDK } from '@opentelemetry/sdk-node';
 
 import { configManager } from '~/server/service/config-manager';
 
-import { detectServiceInstanceId, initInstrumentation } from './node-sdk';
+import { setupAdditionalResourceAttributes, initInstrumentation } from './node-sdk';
 import { getResource } from './node-sdk-resource';
-import { getSdkInstance, resetSdkInstance } from './node-sdk.testing';
 
 // Only mock configManager as it's external to what we're testing
 vi.mock('~/server/service/config-manager', () => ({
@@ -16,29 +14,52 @@ vi.mock('~/server/service/config-manager', () => ({
   },
 }));
 
+// Mock growi-info service to avoid database dependencies
+vi.mock('~/server/service/growi-info', () => ({
+  growiInfoService: {
+    getGrowiInfo: vi.fn().mockResolvedValue({
+      type: 'app',
+      deploymentType: 'standalone',
+      additionalInfo: {
+        attachmentType: 'local',
+        installedAt: new Date('2023-01-01T00:00:00.000Z'),
+        installedAtByOldestUser: new Date('2023-01-01T00:00:00.000Z'),
+      },
+    }),
+  },
+}));
+
 describe('node-sdk', () => {
-  beforeEach(() => {
+  beforeEach(async() => {
     vi.clearAllMocks();
-    vi.resetModules();
-    resetSdkInstance();
-
-    // Reset configManager mock implementation
-    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;
-      }
-      return undefined;
-    });
+
+    // Reset SDK instance using __testing__ export
+    const { __testing__ } = await import('./node-sdk');
+    __testing__.reset();
+
+    // Mock loadConfigs to resolve immediately
+    vi.mocked(configManager.loadConfigs).mockResolvedValue(undefined);
   });
 
-  describe('detectServiceInstanceId', () => {
+  describe('setupAdditionalResourceAttributes', () => {
     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;
+      });
+
       // Initialize SDK first
       await initInstrumentation();
 
       // Get instance for testing
-      const sdkInstance = getSdkInstance();
+      const { __testing__ } = await import('./node-sdk');
+      const sdkInstance = __testing__.getSdkInstance();
       expect(sdkInstance).toBeDefined();
       expect(sdkInstance).toBeInstanceOf(NodeSDK);
 
@@ -47,24 +68,12 @@ describe('node-sdk', () => {
         throw new Error('SDK instance should be defined');
       }
 
-      // Mock app:serviceInstanceId is available
-      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;
-      });
-
       const resource = getResource(sdkInstance);
-      expect(resource).toBeInstanceOf(Resource);
+      expect(resource).toBeDefined();
       expect(resource.attributes['service.instance.id']).toBeUndefined();
 
-      // Call detectServiceInstanceId
-      await detectServiceInstanceId();
+      // Call setupAdditionalResourceAttributes
+      await setupAdditionalResourceAttributes();
 
       // Verify that resource was updated with app:serviceInstanceId
       const updatedResource = getResource(sdkInstance);
@@ -72,18 +81,7 @@ describe('node-sdk', () => {
     });
 
     it('should update service.instance.id with otel:serviceInstanceId if available', async() => {
-      // Initialize SDK
-      await initInstrumentation();
-
-      // Get instance and verify initial state
-      const sdkInstance = getSdkInstance();
-      if (sdkInstance == null) {
-        throw new Error('SDK instance should be defined');
-      }
-      const resource = getResource(sdkInstance);
-      expect(resource.attributes['service.instance.id']).toBeUndefined();
-
-      // Mock otel:serviceInstanceId is available
+      // 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') {
@@ -99,8 +97,20 @@ describe('node-sdk', () => {
         return undefined;
       });
 
-      // Call detectServiceInstanceId
-      await detectServiceInstanceId();
+      // Initialize SDK
+      await initInstrumentation();
+
+      // Get instance and verify initial state
+      const { __testing__ } = await import('./node-sdk');
+      const sdkInstance = __testing__.getSdkInstance();
+      if (sdkInstance == null) {
+        throw new Error('SDK instance should be defined');
+      }
+      const resource = getResource(sdkInstance);
+      expect(resource.attributes['service.instance.id']).toBeUndefined();
+
+      // Call setupAdditionalResourceAttributes
+      await setupAdditionalResourceAttributes();
 
       // Verify that otel:serviceInstanceId was used
       const updatedResource = getResource(sdkInstance);
@@ -121,14 +131,15 @@ describe('node-sdk', () => {
       await initInstrumentation();
 
       // Verify that no SDK instance was created
-      const sdkInstance = getSdkInstance();
+      const { __testing__ } = await import('./node-sdk');
+      const sdkInstance = __testing__.getSdkInstance();
       expect(sdkInstance).toBeUndefined();
 
-      // Call detectServiceInstanceId
-      await detectServiceInstanceId();
+      // Call setupAdditionalResourceAttributes
+      await setupAdditionalResourceAttributes();
 
       // Verify that still no SDK instance exists
-      const updatedSdkInstance = getSdkInstance();
+      const updatedSdkInstance = __testing__.getSdkInstance();
       expect(updatedSdkInstance).toBeUndefined();
     });
   });

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

@@ -1,5 +1,6 @@
 import { ConfigSource } from '@growi/core/dist/interfaces';
 import type { NodeSDK } from '@opentelemetry/sdk-node';
+import { ATTR_SERVICE_INSTANCE_ID } from '@opentelemetry/semantic-conventions/incubating';
 
 import { configManager } from '~/server/service/config-manager';
 import loggerFactory from '~/utils/logger';
@@ -67,13 +68,14 @@ For more information, see https://docs.growi.org/en/admin-guide/admin-cookbook/t
     const { NodeSDK } = await import('@opentelemetry/sdk-node');
     const { generateNodeSDKConfiguration } = await import('./node-sdk-configuration');
     // get resource from configuration
-    const anonymizationEnabled = configManager.getConfig('otel:anonymizeInBestEffort', ConfigSource.env);
+    const enableAnonymization = configManager.getConfig('otel:anonymizeInBestEffort', ConfigSource.env);
 
-    sdkInstance = new NodeSDK(generateNodeSDKConfiguration(undefined, anonymizationEnabled));
+    const sdkConfig = generateNodeSDKConfiguration({ enableAnonymization });
+    sdkInstance = new NodeSDK(sdkConfig);
   }
 };
 
-export const detectServiceInstanceId = async(): Promise<void> => {
+export const setupAdditionalResourceAttributes = async(): Promise<void> => {
   const instrumentationEnabled = configManager.getConfig('otel:enabled', ConfigSource.env);
 
   if (instrumentationEnabled) {
@@ -81,17 +83,15 @@ export const detectServiceInstanceId = async(): Promise<void> => {
       throw new Error('OpenTelemetry instrumentation is not initialized');
     }
 
-    const { generateNodeSDKConfiguration } = await import('./node-sdk-configuration');
-
-    const serviceInstanceId = configManager.getConfig('otel:serviceInstanceId')
-      ?? configManager.getConfig('app:serviceInstanceId');
-
+    const { generateAdditionalResourceAttributes } = await import('./node-sdk-configuration');
     // get resource from configuration
-    const anonymizationEnabled = configManager.getConfig('otel:anonymizeInBestEffort', ConfigSource.env);
+    const enableAnonymization = configManager.getConfig('otel:anonymizeInBestEffort', ConfigSource.env);
+
+    // generate additional resource attributes
+    const updatedResource = await generateAdditionalResourceAttributes({ enableAnonymization });
 
-    // Update resource with new service instance id
-    const newConfig = generateNodeSDKConfiguration(serviceInstanceId, anonymizationEnabled);
-    setResource(sdkInstance, newConfig.resource);
+    // set resource to sdk instance
+    setResource(sdkInstance, updatedResource);
   }
 };
 

+ 2 - 2
apps/app/src/server/app.ts

@@ -1,6 +1,6 @@
 import type Logger from 'bunyan';
 
-import { initInstrumentation, detectServiceInstanceId, startOpenTelemetry } from '~/features/opentelemetry/server';
+import { initInstrumentation, setupAdditionalResourceAttributes, startOpenTelemetry } from '~/features/opentelemetry/server';
 import loggerFactory from '~/utils/logger';
 import { hasProcessFlag } from '~/utils/process-utils';
 
@@ -28,7 +28,7 @@ async function main() {
     const server = await growi.start();
 
     // Start OpenTelemetry
-    await detectServiceInstanceId();
+    await setupAdditionalResourceAttributes();
     startOpenTelemetry();
 
     if (hasProcessFlag('ci')) {