Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions packages/yucca-metrics-worker/src/app.module.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,12 +3,14 @@ import { Module } from '@nestjs/common';
import { APP_INTERCEPTOR } from '@nestjs/core';
import { ScheduleModule } from '@nestjs/schedule';
import { KyselyModule } from 'nestjs-kysely';
import { AllowlistRepository } from './repositories/allowlist.repository.js';
import { ConnectionMetricsRepository } from './repositories/connectionMetrics.repository.js';
import { MeterRepository } from './repositories/meter.repository.js';
import { RepositoryRepository } from './repositories/repository.repository.js';
import { RgwFleetRepository } from './repositories/rgwFleet.repository.js';
import { StorageRepository } from './repositories/storage.repository.js';
import { TopologyRepository } from './repositories/topology.repository.js';
import { InviteMetricsService } from './services/inviteMetrics.service.js';
import { MetricsService } from './services/metrics.service.js';
import { TopologyService } from './services/topology.service.js';
import { getKyselyConfig } from './utils/database.js';
Expand All @@ -24,7 +26,9 @@ export const providers = [
MeterRepository,
RepositoryRepository,
ConnectionMetricsRepository,
AllowlistRepository,
MetricsService,
InviteMetricsService,
TopologyService,
{ provide: APP_INTERCEPTOR, useClass: LoggingInterceptor },
];
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
import { Injectable } from '@nestjs/common';
import { Kysely } from 'kysely';
import { InjectKysely } from 'nestjs-kysely';
import { DB } from 'src/schema/index.js';

export const InviteSources = ['discord', 'email', 'other'] as const;
export const InviteStates = ['staged', 'invited', 'used'] as const;

export type InviteSource = (typeof InviteSources)[number];
export type InviteState = (typeof InviteStates)[number];

export type InviteCount = {
source: InviteSource;
state: InviteState;
count: number;
};

@Injectable()
export class AllowlistRepository {
constructor(@InjectKysely() private db: Kysely<DB>) {}

async countInvites(): Promise<InviteCount[]> {
const rows = await this.db
.selectFrom('userAllowlist')
.select((eb) => [
eb
.case()
.when('discordUserId', 'is not', null)
.then('discord' as InviteSource)
.when('inviteEmailSentAt', 'is not', null)
.then('email' as InviteSource)
.else('other' as InviteSource)
.end()
.as('source'),
eb
.case()
.when('inviteUsed', '=', true)
.then('used' as InviteState)
.when('invited', '=', true)
.then('invited' as InviteState)
.else('staged' as InviteState)
.end()
.as('state'),
eb.fn.countAll<string>().as('count'),
])
.groupBy(['source', 'state'])
.execute();

return rows.map(({ source, state, count }) => ({ source, state, count: Number(count) }));
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
import { jest } from '@jest/globals';
import { InviteMetricsService } from './inviteMetrics.service.js';

describe(InviteMetricsService.name, () => {
const logger = { error: jest.fn() };
const allowlist = { countInvites: jest.fn<() => Promise<unknown>>() };
const gauge = { record: jest.fn() };
const metricService = { getGauge: jest.fn(() => gauge) };

const service = () => new InviteMetricsService(logger as never, allowlist as never, metricService as never);

beforeEach(() => {
jest.clearAllMocks();
});

it('records every source and state, zero-filling the missing ones', async () => {
allowlist.countInvites.mockResolvedValue([
{ source: 'discord', state: 'used', count: 4 },
{ source: 'email', state: 'invited', count: 7 },
]);

await service().sync();

expect(metricService.getGauge).toHaveBeenCalledWith('beta_invites', expect.anything());
expect(gauge.record).toHaveBeenCalledTimes(9);
expect(gauge.record).toHaveBeenCalledWith(4, { source: 'discord', state: 'used' });
expect(gauge.record).toHaveBeenCalledWith(7, { source: 'email', state: 'invited' });
expect(gauge.record).toHaveBeenCalledWith(0, { source: 'other', state: 'staged' });
});

it('records nothing when the count fails', async () => {
allowlist.countInvites.mockRejectedValue(new Error('db down'));

await service().sync();

expect(gauge.record).not.toHaveBeenCalled();
expect(logger.error).toHaveBeenCalled();
});
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
import { LoggerRepository, MetricService } from '@common/server/otel';
import { Injectable, OnApplicationBootstrap } from '@nestjs/common';
import { Cron, CronExpression } from '@nestjs/schedule';
import { Gauge } from '@opentelemetry/api';
import { env } from 'src/env.js';
import { AllowlistRepository, InviteSources, InviteStates } from 'src/repositories/allowlist.repository.js';

@Injectable()
export class InviteMetricsService implements OnApplicationBootstrap {
private readonly betaInvites: Gauge;

constructor(
private logger: LoggerRepository,
private allowlist: AllowlistRepository,
metricService: MetricService,
) {
this.betaInvites = metricService.getGauge('beta_invites', {
description: 'Beta allowlist entries by invite source and state',
});
}

async onApplicationBootstrap() {
if (env.NODE_ENV === 'development') {
await this.sync();
}
}

@Cron(CronExpression.EVERY_5_MINUTES)
async sync() {
const counts = await this.allowlist.countInvites().catch((error: unknown) => {
this.logger.error(error, 'Failed to count beta invites; skipping');
return null;
});
if (!counts) {
return;
}

for (const source of InviteSources) {
for (const state of InviteStates) {
const count = counts.find((row) => row.source === source && row.state === state)?.count ?? 0;
this.betaInvites.record(count, { source, state });
}
}
}
}
Loading