Просмотр исходного кода

Merge pull request #11214 from growilabs/feat/183351-add-installed-at-metrics

feat(otel): Add growi_installed_at metrics
Yuki Takei 2 недель назад
Родитель
Сommit
32592215bf

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

@@ -1,4 +1,5 @@
 export { addApplicationMetrics } from './application-metrics';
+export { addInstalledAtMetrics } from './installed-at-metrics';
 export { addMongooseConnectionPoolMetrics } from './mongoose-connection-pool-metrics';
 export { addPageCountsMetrics } from './page-counts-metrics';
 export { addSystemMetrics } from './system-metrics';
@@ -7,6 +8,7 @@ export { addYjsMetrics } from './yjs-metrics';
 
 export const setupCustomMetrics = async (): Promise<void> => {
   const { addApplicationMetrics } = await import('./application-metrics');
+  const { addInstalledAtMetrics } = await import('./installed-at-metrics');
   const { addUserCountsMetrics } = await import('./user-counts-metrics');
   const { addPageCountsMetrics } = await import('./page-counts-metrics');
   const { addSystemMetrics } = await import('./system-metrics');
@@ -17,6 +19,7 @@ export const setupCustomMetrics = async (): Promise<void> => {
 
   // Add custom metrics
   addApplicationMetrics();
+  addInstalledAtMetrics();
   addUserCountsMetrics();
   addPageCountsMetrics();
   addSystemMetrics();

+ 159 - 0
apps/app/src/features/opentelemetry/server/custom-metrics/installed-at-metrics.spec.ts

@@ -0,0 +1,159 @@
+import { type Meter, metrics, type ObservableGauge } from '@opentelemetry/api';
+import { mock } from 'vitest-mock-extended';
+
+import { addInstalledAtMetrics } from './installed-at-metrics';
+
+vi.mock('~/utils/logger', () => ({
+  default: () => ({
+    info: vi.fn(),
+  }),
+}));
+vi.mock('@opentelemetry/api', () => ({
+  diag: {
+    createComponentLogger: () => ({
+      error: vi.fn(),
+    }),
+  },
+  metrics: {
+    getMeter: vi.fn(),
+  },
+}));
+
+const mockGrowiInfoService = {
+  getGrowiInfo: vi.fn(),
+};
+vi.mock('~/server/service/growi-info', () => ({
+  growiInfoService: mockGrowiInfoService,
+}));
+
+describe('addInstalledAtMetrics', () => {
+  const mockMeter = mock<Meter>();
+  const mockInstalledAtGauge = mock<ObservableGauge>();
+  const mockInstalledAtByOldestUserGauge = mock<ObservableGauge>();
+
+  beforeEach(() => {
+    vi.clearAllMocks();
+    vi.mocked(metrics.getMeter).mockReturnValue(mockMeter);
+    mockMeter.createObservableGauge
+      .mockReturnValueOnce(mockInstalledAtGauge)
+      .mockReturnValueOnce(mockInstalledAtByOldestUserGauge);
+  });
+
+  afterEach(() => {
+    vi.restoreAllMocks();
+  });
+
+  it('should create observable gauges and set up metrics collection', () => {
+    addInstalledAtMetrics();
+
+    expect(metrics.getMeter).toHaveBeenCalledWith(
+      'growi-installed-at-metrics',
+      '1.0.0',
+    );
+    expect(mockMeter.createObservableGauge).toHaveBeenNthCalledWith(
+      1,
+      'growi.installed_at.timestamp.seconds',
+      {
+        description: 'GROWI installation time as Unix timestamp (seconds)',
+        unit: 's',
+      },
+    );
+    expect(mockMeter.createObservableGauge).toHaveBeenNthCalledWith(
+      2,
+      'growi.installed_at.by_oldest_user.timestamp.seconds',
+      {
+        description:
+          'GROWI installation time inferred from the oldest user as Unix timestamp (seconds)',
+        unit: 's',
+      },
+    );
+    expect(mockMeter.addBatchObservableCallback).toHaveBeenCalledWith(
+      expect.any(Function),
+      [mockInstalledAtGauge, mockInstalledAtByOldestUserGauge],
+    );
+  });
+
+  describe('metrics callback behavior', () => {
+    it('should observe both gauges in unix seconds when both dates exist', async () => {
+      const installedAt = new Date('2023-01-01T00:00:00.000Z');
+      const installedAtByOldestUser = new Date('2022-06-15T12:30:00.000Z');
+      mockGrowiInfoService.getGrowiInfo.mockResolvedValue({
+        additionalInfo: {
+          installedAt,
+          installedAtByOldestUser,
+        },
+      });
+      const mockResult = { observe: vi.fn() };
+
+      addInstalledAtMetrics();
+
+      const callback = mockMeter.addBatchObservableCallback.mock.calls[0][0];
+      await callback(mockResult);
+
+      expect(mockGrowiInfoService.getGrowiInfo).toHaveBeenCalledWith({
+        includeInstalledInfo: true,
+      });
+      expect(mockResult.observe).toHaveBeenCalledWith(
+        mockInstalledAtGauge,
+        Math.floor(installedAt.getTime() / 1000),
+      );
+      expect(mockResult.observe).toHaveBeenCalledWith(
+        mockInstalledAtByOldestUserGauge,
+        Math.floor(installedAtByOldestUser.getTime() / 1000),
+      );
+    });
+
+    it('should skip observe for missing installedAt', async () => {
+      const installedAtByOldestUser = new Date('2022-06-15T12:30:00.000Z');
+      mockGrowiInfoService.getGrowiInfo.mockResolvedValue({
+        additionalInfo: {
+          installedAt: undefined,
+          installedAtByOldestUser,
+        },
+      });
+      const mockResult = { observe: vi.fn() };
+
+      addInstalledAtMetrics();
+
+      const callback = mockMeter.addBatchObservableCallback.mock.calls[0][0];
+      await callback(mockResult);
+
+      expect(mockResult.observe).not.toHaveBeenCalledWith(
+        mockInstalledAtGauge,
+        expect.anything(),
+      );
+      expect(mockResult.observe).toHaveBeenCalledWith(
+        mockInstalledAtByOldestUserGauge,
+        Math.floor(installedAtByOldestUser.getTime() / 1000),
+      );
+    });
+
+    it('should skip both observes when additionalInfo is missing', async () => {
+      mockGrowiInfoService.getGrowiInfo.mockResolvedValue({
+        additionalInfo: undefined,
+      });
+      const mockResult = { observe: vi.fn() };
+
+      addInstalledAtMetrics();
+
+      const callback = mockMeter.addBatchObservableCallback.mock.calls[0][0];
+      await callback(mockResult);
+
+      expect(mockResult.observe).not.toHaveBeenCalled();
+    });
+
+    it('should swallow errors from growiInfoService gracefully', async () => {
+      mockGrowiInfoService.getGrowiInfo.mockRejectedValue(
+        new Error('Service unavailable'),
+      );
+      const mockResult = { observe: vi.fn() };
+
+      addInstalledAtMetrics();
+
+      const callback = mockMeter.addBatchObservableCallback.mock.calls[0][0];
+
+      await expect(callback(mockResult)).resolves.toBeUndefined();
+      expect(mockResult.observe).not.toHaveBeenCalled();
+    });
+  });
+});

+ 89 - 0
apps/app/src/features/opentelemetry/server/custom-metrics/installed-at-metrics.ts

@@ -0,0 +1,89 @@
+/**
+ * Installed-at metrics.
+ *
+ * Exposes two independent metrics derived from the same data source
+ * (growiInfoService.getGrowiInfo). Bundled in a single file because they share
+ * the fetch — a single batch callback observes both gauges in one call,
+ * avoiding duplicate DB access per collection interval.
+ *
+ * Prometheus exposure (OTel `.` → Prometheus `_`):
+ *   growi.installed_at.timestamp.seconds              → growi_installed_at_timestamp_seconds
+ *   growi.installed_at.by_oldest_user.timestamp.seconds → growi_installed_at_by_oldest_user_timestamp_seconds
+ */
+import { diag, metrics } from '@opentelemetry/api';
+
+import loggerFactory from '~/utils/logger';
+
+const logger = loggerFactory(
+  'growi:opentelemetry:custom-metrics:installed-at-metrics',
+);
+const loggerDiag = diag.createComponentLogger({
+  namespace: 'growi:custom-metrics:installed-at',
+});
+
+function toUnixSeconds(date: Date | null | undefined): number | undefined {
+  if (date == null) return undefined;
+  return Math.floor(date.getTime() / 1000);
+}
+
+export function addInstalledAtMetrics(): void {
+  logger.info('Starting installed-at metrics collection');
+
+  const meter = metrics.getMeter('growi-installed-at-metrics', '1.0.0');
+
+  // Metric 1/2: installation time recorded at system setup
+  const installedAtGauge = meter.createObservableGauge(
+    'growi.installed_at.timestamp.seconds',
+    {
+      description: 'GROWI installation time as Unix timestamp (seconds)',
+      unit: 's',
+    },
+  );
+
+  // Metric 2/2: installation time inferred from the oldest user
+  const installedAtByOldestUserGauge = meter.createObservableGauge(
+    'growi.installed_at.by_oldest_user.timestamp.seconds',
+    {
+      description:
+        'GROWI installation time inferred from the oldest user as Unix timestamp (seconds)',
+      unit: 's',
+    },
+  );
+
+  // Single batch callback feeds both gauges from one growiInfoService fetch
+  meter.addBatchObservableCallback(
+    async (result) => {
+      try {
+        // Dynamic import to avoid circular dependencies
+        const { growiInfoService } = await import(
+          '~/server/service/growi-info'
+        );
+        const growiInfo = await growiInfoService.getGrowiInfo({
+          includeInstalledInfo: true,
+        });
+
+        const installedAtSeconds = toUnixSeconds(
+          growiInfo.additionalInfo?.installedAt,
+        );
+        if (installedAtSeconds != null) {
+          result.observe(installedAtGauge, installedAtSeconds);
+        }
+
+        const installedAtByOldestUserSeconds = toUnixSeconds(
+          growiInfo.additionalInfo?.installedAtByOldestUser,
+        );
+        if (installedAtByOldestUserSeconds != null) {
+          result.observe(
+            installedAtByOldestUserGauge,
+            installedAtByOldestUserSeconds,
+          );
+        }
+      } catch (error) {
+        loggerDiag.error('Failed to collect installed-at metrics', { error });
+      }
+    },
+    [installedAtGauge, installedAtByOldestUserGauge],
+  );
+
+  logger.info('Installed-at metrics collection started successfully');
+}