Yuki Takei 9 bulan lalu
induk
melakukan
ae94c3775d

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

@@ -0,0 +1,146 @@
+import { metrics } from '@opentelemetry/api';
+
+import { addUserCountsMetrics } from './user-counts-metrics';
+
+// Mock external dependencies
+vi.mock('~/utils/logger', () => ({
+  default: () => ({
+    info: vi.fn(),
+  }),
+}));
+
+vi.mock('@opentelemetry/api', () => ({
+  diag: {
+    createComponentLogger: () => ({
+      error: vi.fn(),
+    }),
+  },
+  metrics: {
+    getMeter: vi.fn(),
+  },
+}));
+
+// Mock growi-info service
+const mockGrowiInfoService = {
+  getGrowiInfo: vi.fn(),
+};
+vi.mock('~/server/service/growi-info', () => ({
+  growiInfoService: mockGrowiInfoService,
+}));
+
+describe('addUserCountsMetrics', () => {
+  const mockMeter = {
+    createObservableGauge: vi.fn(),
+    addBatchObservableCallback: vi.fn(),
+  };
+  const mockUserCountGauge = Symbol('userCountGauge');
+  const mockActiveUserCountGauge = Symbol('activeUserCountGauge');
+
+  beforeEach(() => {
+    vi.clearAllMocks();
+    vi.mocked(metrics.getMeter).mockReturnValue(mockMeter as unknown as ReturnType<typeof metrics.getMeter>);
+    mockMeter.createObservableGauge
+      .mockReturnValueOnce(mockUserCountGauge)
+      .mockReturnValueOnce(mockActiveUserCountGauge);
+  });
+
+  afterEach(() => {
+    vi.restoreAllMocks();
+  });
+
+  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(mockMeter.addBatchObservableCallback).toHaveBeenCalledWith(
+      expect.any(Function),
+      [mockUserCountGauge, mockActiveUserCountGauge],
+    );
+  });
+
+  describe('metrics callback behavior', () => {
+    const mockGrowiInfo = {
+      additionalInfo: {
+        currentUsersCount: 150,
+        currentActiveUsersCount: 75,
+      },
+    };
+
+    beforeEach(() => {
+      mockGrowiInfoService.getGrowiInfo.mockResolvedValue(mockGrowiInfo);
+    });
+
+    it('should observe user count metrics when growi info is available', async() => {
+      const mockResult = { observe: vi.fn() };
+
+      addUserCountsMetrics();
+
+      // Get the callback function that was passed to addBatchObservableCallback
+      const callback = mockMeter.addBatchObservableCallback.mock.calls[0][0];
+      await callback(mockResult);
+
+      expect(mockGrowiInfoService.getGrowiInfo).toHaveBeenCalledWith(true);
+      expect(mockResult.observe).toHaveBeenCalledWith(mockUserCountGauge, 150);
+      expect(mockResult.observe).toHaveBeenCalledWith(mockActiveUserCountGauge, 75);
+    });
+
+    it('should use default values when user counts are missing', async() => {
+      const mockResult = { observe: vi.fn() };
+
+      const growiInfoWithoutCounts = {
+        additionalInfo: {
+          // Missing currentUsersCount and currentActiveUsersCount
+        },
+      };
+      mockGrowiInfoService.getGrowiInfo.mockResolvedValue(growiInfoWithoutCounts);
+
+      addUserCountsMetrics();
+
+      const callback = mockMeter.addBatchObservableCallback.mock.calls[0][0];
+      await callback(mockResult);
+
+      expect(mockResult.observe).toHaveBeenCalledWith(mockUserCountGauge, 0);
+      expect(mockResult.observe).toHaveBeenCalledWith(mockActiveUserCountGauge, 0);
+    });
+
+    it('should handle missing additionalInfo gracefully', async() => {
+      const mockResult = { observe: vi.fn() };
+
+      const growiInfoWithoutAdditionalInfo = {
+        // Missing additionalInfo entirely
+      };
+      mockGrowiInfoService.getGrowiInfo.mockResolvedValue(growiInfoWithoutAdditionalInfo);
+
+      addUserCountsMetrics();
+
+      const callback = mockMeter.addBatchObservableCallback.mock.calls[0][0];
+      await callback(mockResult);
+
+      expect(mockResult.observe).toHaveBeenCalledWith(mockUserCountGauge, 0);
+      expect(mockResult.observe).toHaveBeenCalledWith(mockActiveUserCountGauge, 0);
+    });
+
+    it('should handle errors in metrics collection gracefully', async() => {
+      mockGrowiInfoService.getGrowiInfo.mockRejectedValue(new Error('Service unavailable'));
+      const mockResult = { observe: vi.fn() };
+
+      addUserCountsMetrics();
+
+      const callback = mockMeter.addBatchObservableCallback.mock.calls[0][0];
+
+      // Should not throw error
+      await expect(callback(mockResult)).resolves.toBeUndefined();
+
+      // Should not call observe when error occurs
+      expect(mockResult.observe).not.toHaveBeenCalled();
+    });
+  });
+});

+ 80 - 20
apps/app/src/features/opentelemetry/server/node-sdk.spec.ts

@@ -14,6 +14,11 @@ vi.mock('~/server/service/config-manager', () => ({
   },
 }));
 
+// Mock custom metrics setup
+vi.mock('./custom-metrics', () => ({
+  setupCustomMetrics: vi.fn(),
+}));
+
 // Mock growi-info service to avoid database dependencies
 vi.mock('~/server/service/growi-info', () => ({
   growiInfoService: {
@@ -30,6 +35,25 @@ 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;
+    });
+  };
+
+  const mockInstrumentationDisabled = () => {
+    vi.mocked(configManager.getConfig).mockImplementation((key: string, source?: ConfigSource) => {
+      if (key === 'otel:enabled') {
+        return source === ConfigSource.env ? false : undefined;
+      }
+      return undefined;
+    });
+  };
+
   beforeEach(async() => {
     vi.clearAllMocks();
 
@@ -41,6 +65,57 @@ describe('node-sdk', () => {
     vi.mocked(configManager.loadConfigs).mockResolvedValue(undefined);
   });
 
+  describe('initInstrumentation', () => {
+    it('should call setupCustomMetrics when instrumentation is enabled', async() => {
+      const { setupCustomMetrics } = await import('./custom-metrics');
+
+      // Mock instrumentation as enabled
+      mockInstrumentationEnabled();
+
+      await initInstrumentation();
+
+      // Verify setupCustomMetrics was called
+      expect(setupCustomMetrics).toHaveBeenCalledOnce();
+    });
+
+    it('should not call setupCustomMetrics when instrumentation is disabled', async() => {
+      const { setupCustomMetrics } = await import('./custom-metrics');
+
+      // Mock instrumentation as disabled
+      mockInstrumentationDisabled();
+
+      await initInstrumentation();
+
+      // Verify setupCustomMetrics was not called
+      expect(setupCustomMetrics).not.toHaveBeenCalled();
+    });
+
+    it('should create SDK instance when instrumentation is enabled', async() => {
+      // Mock instrumentation as enabled
+      mockInstrumentationEnabled();
+
+      await initInstrumentation();
+
+      // Get instance for testing
+      const { __testing__ } = await import('./node-sdk');
+      const sdkInstance = __testing__.getSdkInstance();
+      expect(sdkInstance).toBeDefined();
+      expect(sdkInstance).toBeInstanceOf(NodeSDK);
+    });
+
+    it('should not create SDK instance when instrumentation is disabled', async() => {
+      // Mock instrumentation as disabled
+      mockInstrumentationDisabled();
+
+      await initInstrumentation();
+
+      // Verify that no SDK instance was created
+      const { __testing__ } = await import('./node-sdk');
+      const sdkInstance = __testing__.getSdkInstance();
+      expect(sdkInstance).toBeUndefined();
+    });
+  });
+
   describe('setupAdditionalResourceAttributes', () => {
     it('should update service.instance.id when app:serviceInstanceId is available', async() => {
       // Set up mocks for this specific test
@@ -117,30 +192,15 @@ describe('node-sdk', () => {
       expect(updatedResource.attributes['service.instance.id']).toBe('otel-instance-id');
     });
 
-    it('should not create SDK instance if instrumentation is disabled', async() => {
+    it('should handle gracefully when instrumentation is disabled', async() => {
       // Mock instrumentation as disabled
-      vi.mocked(configManager.getConfig).mockImplementation((key: string, source?: ConfigSource) => {
-        // For otel:enabled, always expect ConfigSource.env and return false
-        if (key === 'otel:enabled') {
-          return source === ConfigSource.env ? false : undefined;
-        }
-        return undefined;
-      });
+      mockInstrumentationDisabled();
 
-      // Initialize SDK
+      // Initialize SDK (should not create instance)
       await initInstrumentation();
 
-      // Verify that no SDK instance was created
-      const { __testing__ } = await import('./node-sdk');
-      const sdkInstance = __testing__.getSdkInstance();
-      expect(sdkInstance).toBeUndefined();
-
-      // Call setupAdditionalResourceAttributes
-      await setupAdditionalResourceAttributes();
-
-      // Verify that still no SDK instance exists
-      const updatedSdkInstance = __testing__.getSdkInstance();
-      expect(updatedSdkInstance).toBeUndefined();
+      // Call setupAdditionalResourceAttributes should not throw error
+      await expect(setupAdditionalResourceAttributes()).resolves.toBeUndefined();
     });
   });
 });