Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

improve api telemetry #2769

Merged
merged 2 commits into from
Feb 18, 2025
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
2 changes: 1 addition & 1 deletion packages/altair-api/.env.e2e
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ POSTGRES_DB=tests
POSTGRES_USER=prisma
POSTGRES_PASSWORD=prisma
DATABASE_URL=postgresql://prisma:prisma@localhost:5434/tests?schema=public
NEW_RELIC_APP_NAME=altairgraphql.test
# NEW_RELIC_APP_NAME=altairgraphql.test
NEW_RELIC_LICENSE_KEY=test-key
NEW_RELIC_APPLICATION_LOGGING_FORWARDING_ENABLED=true
STRIPE_SECRET_KEY=sk_test_xxx
Expand Down
5 changes: 5 additions & 0 deletions packages/altair-api/src/auth/auth.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,9 +12,11 @@ import { SecurityConfig } from 'src/common/config';
import { ChangePasswordInput } from './models/change-password.input';
import { PasswordService } from './password/password.service';
import { IToken } from '@altairgraphql/api-utils';
import { getAgent } from 'src/newrelic/newrelic';

@Injectable()
export class AuthService {
private readonly agent = getAgent();
constructor(
private readonly jwtService: JwtService,
private readonly prisma: PrismaService,
Expand Down Expand Up @@ -99,6 +101,8 @@ export class AuthService {
}

getLoginResponse(user: User) {
this.agent?.incrementMetric('auth.login.success');

return {
id: user.id,
email: user.email,
Expand All @@ -121,6 +125,7 @@ export class AuthService {
*/
getShortLivedEventsToken(userId: string): string {
const securityConfig = this.configService.get<SecurityConfig>('security');
this.agent?.incrementMetric('auth.events_token.generate');
return this.jwtService.sign(
{ userId },
{
Expand Down
8 changes: 7 additions & 1 deletion packages/altair-api/src/auth/user/user.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,10 +12,12 @@ import { StripeService } from 'src/stripe/stripe.service';
import { ProviderInfo } from '../models/provider-info.dto';
import { SignupInput } from '../models/signup.input';
import { UpdateUserInput } from '../models/update-user.input';
import { getAgent } from 'src/newrelic/newrelic';

@Injectable()
export class UserService {
private readonly logger = new Logger(UserService.name);
private readonly agent = getAgent();
constructor(
private readonly prisma: PrismaService,
private readonly stripeService: StripeService
Expand Down Expand Up @@ -301,12 +303,16 @@ export class UserService {
}

async getProUsers() {
return this.prisma.user.findMany({
const proUsers = await this.prisma.user.findMany({
where: {
UserPlan: {
planRole: PRO_PLAN_ID,
},
},
});

this.agent?.recordMetric('users.pro.count', proUsers.length);

return proUsers;
}
}
10 changes: 10 additions & 0 deletions packages/altair-api/src/credit/credit.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,11 +8,14 @@ import { BadRequestException, Injectable, Logger } from '@nestjs/common';
import { Cron, CronExpression } from '@nestjs/schedule';
import { PrismaService } from 'nestjs-prisma';
import { UserService } from 'src/auth/user/user.service';
import { getAgent } from 'src/newrelic/newrelic';
import { StripeService } from 'src/stripe/stripe.service';

@Injectable()
export class CreditService {
private readonly logger = new Logger(CreditService.name);
private readonly agent = getAgent();

constructor(
private readonly prisma: PrismaService,
private readonly userService: UserService,
Expand Down Expand Up @@ -42,8 +45,13 @@ export class CreditService {
where: { userId },
});
if (!creditBalance) {
this.agent?.incrementMetric('credit.balance.not_found');
throw new BadRequestException('User has no credits');
}
this.agent?.recordMetric(
'credit.balance.total',
creditBalance.fixedCredits + creditBalance.monthlyCredits
);
return {
fixed: creditBalance.fixedCredits,
monthly: creditBalance.monthlyCredits,
Expand Down Expand Up @@ -123,6 +131,8 @@ export class CreditService {
});
});

this.agent?.recordMetric('credit.monthly.refill_count', proUsers.length);

await Promise.all(creditBalanceRecords);
}
}
42 changes: 32 additions & 10 deletions packages/altair-api/src/email/email.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,10 +5,13 @@ import { renderWelcomeEmail } from '@altairgraphql/emails';
import { UserService } from 'src/auth/user/user.service';
import { Config } from 'src/common/config';
import { User } from '@altairgraphql/db';
import { Agent, getAgent } from 'src/newrelic/newrelic';

@Injectable()
export class EmailService {
private resend: Resend;
private agent = getAgent();

constructor(
private configService: ConfigService<Config>,
private readonly userService: UserService
Expand Down Expand Up @@ -52,12 +55,8 @@ export class EmailService {

async sendWelcomeEmail(userId: string) {
const user = await this.userService.mustGetUser(userId);
const { data, error } = await this.resend.emails.send({
from:
this.configService.get('email.defaultFrom', { infer: true }) ??
'[email protected]',
const { data, error } = await this.sendEmail({
to: user.email,
replyTo: this.configService.get('email.replyTo', { infer: true }),
subject: 'Welcome to Altair GraphQL Cloud',
html: await renderWelcomeEmail({ username: this.getFirstName(user) }),
});
Expand All @@ -70,12 +69,9 @@ export class EmailService {

async sendGoodbyeEmail(userId: string) {
const user = await this.userService.mustGetUser(userId);
const { data, error } = await this.resend.emails.send({
from:
this.configService.get('email.defaultFrom', { infer: true }) ??
'[email protected]',

const { data, error } = await this.sendEmail({
to: user.email,
replyTo: this.configService.get('email.replyTo', { infer: true }),
subject: 'Sorry to see you go 👋🏾',
html: `Hey ${this.getFirstName(user)},
<br><br>
Expand All @@ -100,6 +96,32 @@ export class EmailService {
return { data, error };
}

private async sendEmail({
to,
subject,
html,
}: {
to: string;
subject: string;
html: string;
}) {
const { data, error } = await this.resend.emails.send({
from:
this.configService.get('email.defaultFrom', { infer: true }) ??
'[email protected]',
to,
replyTo: this.configService.get('email.replyTo', { infer: true }),
subject,
html,
});
if (error) {
this.agent?.incrementMetric('email.send.error');
}

this.agent?.incrementMetric('email.send.success');
return { data, error };
}

private getFirstName(user: User) {
return user.firstName ?? user.email;
}
Expand Down
10 changes: 5 additions & 5 deletions packages/altair-api/src/newrelic/newrelic.interceptor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,22 +7,22 @@ import {
} from '@nestjs/common';
import { Observable, tap } from 'rxjs';
import { inspect } from 'util';
import { getAgent } from './newrelic';

@Injectable()
export class NewrelicInterceptor implements NestInterceptor {
constructor(private readonly logger: LoggerService) {}
intercept(context: ExecutionContext, next: CallHandler): Observable<any> {
const log = this.logger;
if (!process.env.NEW_RELIC_APP_NAME) {
const agent = getAgent();
if (!agent) {
return next.handle();
}
// eslint-disable-next-line @typescript-eslint/no-var-requires
const newrelic = require('newrelic');
this.logger.log(
`Newrelic Interceptor before: ${inspect(context.getHandler().name)}`
);
return newrelic.startWebTransaction(context.getHandler().name, function () {
const transaction = newrelic.getTransaction();
return agent.startWebTransaction(context.getHandler().name, function () {
const transaction = agent.getTransaction();
return next.handle().pipe(
tap(() => {
log.log(
Expand Down
29 changes: 29 additions & 0 deletions packages/altair-api/src/newrelic/newrelic.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
import type {
startWebTransaction,
getTransaction,
recordMetric,
incrementMetric,
} from 'newrelic';
// eslint-disable-next-line @typescript-eslint/no-var-requires
const newrelic = require('newrelic');

export interface Agent {
startWebTransaction: typeof startWebTransaction;
getTransaction: typeof getTransaction;
recordMetric: typeof recordMetric;
incrementMetric: typeof incrementMetric;
}

const prodAgent: Agent = {
startWebTransaction: newrelic.startWebTransaction,
getTransaction: newrelic.getTransaction,
recordMetric: newrelic.recordMetric,
incrementMetric: newrelic.incrementMetric,
};

export const getAgent = () => {
if (process.env.NEW_RELIC_APP_NAME) {
return prodAgent;
}
return;
};
30 changes: 25 additions & 5 deletions packages/altair-api/src/queries/queries.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,11 +12,13 @@ import {
queryItemWhereOwnerOrMember,
collectionWhereOwnerOrMember,
} from 'src/common/where-clauses';
import { getAgent } from 'src/newrelic/newrelic';

const DEFAULT_QUERY_REVISION_LIMIT = 10;

@Injectable()
export class QueriesService {
private readonly agent = getAgent();
constructor(
private readonly prisma: PrismaService,
private readonly userService: UserService,
Expand Down Expand Up @@ -85,6 +87,8 @@ export class QueriesService {

this.eventService.emit(EVENTS.QUERY_UPDATE, { id: res.id });

this.agent?.incrementMetric('query.create');
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

issue (complexity): Consider centralizing the metric reporting logic using a decorator to wrap service methods.

Consider centralizing the metric reporting to reduce the interleaved instrumentation code. For example, you might create a decorator to wrap your service methods. This way, you can remove repetitive metric calls from the business logic while keeping the behavior intact. For instance:

function withMetric(metricName: string, action: "increment" | "record", countFn?: (result: any) => number) {
  return function (_target: any, _propertyKey: string, descriptor: PropertyDescriptor) {
    const originalMethod = descriptor.value;
    descriptor.value = async function (...args: any[]) {
      const result = await originalMethod.apply(this, args);
      if (action === "increment") {
        this.agent?.incrementMetric(metricName);
      } else if (action === "record") {
        const count = countFn ? countFn(result) : (Array.isArray(result) ? result.length : 0);
        this.agent?.recordMetric(metricName, count);
      }
      return result;
    }
    return descriptor;
  }
}

Then annotate your service methods accordingly:

class QueriesService {
  private readonly agent = getAgent();

  constructor( /* ... */ ) {}

  @withMetric('query.create', 'increment')
  async create(userId: string, createQueryDto: CreateQueryDto) {
    // business logic...
  }

  @withMetric('query.list.count', 'record', res => res.length)
  async findAll(userId: string) {
    return await this.prisma.queryItem.findMany({
      where: { ...queryItemWhereOwnerOrMember(userId) },
    });
  }

  @withMetric('query.revision.list.count', 'record', res => res.length)
  async listRevisions(userId: string, queryId: string) {
    return await this.prisma.queryItemRevision.findMany({
      // query...
    });
  }

  @withMetric('query.revision.create', 'increment')
  async createRevision(/* ... */) {
    // revision logic...
  }
}

This decorator-based approach reduces duplication and clearly separates concerns while keeping functionality unchanged.


return res;
}

Expand Down Expand Up @@ -140,15 +144,21 @@ export class QueriesService {

this.eventService.emit(EVENTS.QUERY_UPDATE, { id: res.id });

this.agent?.incrementMetric('query.create');

return res;
}

findAll(userId: string) {
return this.prisma.queryItem.findMany({
async findAll(userId: string) {
const res = await this.prisma.queryItem.findMany({
where: {
...queryItemWhereOwnerOrMember(userId),
},
});

this.agent?.recordMetric('query.list.count', res.length);

return res;
}

async findOne(userId: string, id: string) {
Expand Down Expand Up @@ -205,17 +215,21 @@ export class QueriesService {
}

async count(userId: string, ownOnly = true) {
return this.prisma.queryItem.count({
const cnt = await this.prisma.queryItem.count({
where: {
...(ownOnly
? queryItemWhereOwner(userId)
: queryItemWhereOwnerOrMember(userId)),
},
});

this.agent?.recordMetric('query.list.count', cnt);

return cnt;
}

listRevisions(userId: string, queryId: string) {
return this.prisma.queryItemRevision.findMany({
async listRevisions(userId: string, queryId: string) {
const res = await this.prisma.queryItemRevision.findMany({
where: {
queryItem: {
id: queryId,
Expand All @@ -232,6 +246,10 @@ export class QueriesService {
},
},
});

this.agent?.recordMetric('query.revision.list.count', res.length);

return res;
}

async restoreRevision(userId: string, revisionId: string) {
Expand Down Expand Up @@ -358,6 +376,8 @@ export class QueriesService {
});
}

this.agent?.incrementMetric('query.revision.create');

return res;
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -12,9 +12,11 @@ import {
collectionWhereOwner,
collectionWhereOwnerOrMember,
} from 'src/common/where-clauses';
import { getAgent } from 'src/newrelic/newrelic';

@Injectable()
export class QueryCollectionsService {
private readonly agent = getAgent();
constructor(
private readonly prisma: PrismaService,
private readonly userService: UserService,
Expand Down Expand Up @@ -101,18 +103,24 @@ export class QueryCollectionsService {
});
this.eventService.emit(EVENTS.COLLECTION_UPDATE, { id: res.id });

this.agent?.incrementMetric('query_collection.create');

return res;
}

findAll(userId: string) {
return this.prisma.queryCollection.findMany({
async findAll(userId: string) {
const res = await this.prisma.queryCollection.findMany({
where: {
...collectionWhereOwnerOrMember(userId),
},
include: {
queries: true,
},
});

this.agent?.recordMetric('query_collection.list.count', res.length);

return res;
}

findOne(userId: string, id: string) {
Expand Down Expand Up @@ -169,13 +177,17 @@ export class QueryCollectionsService {
}

async count(userId: string, ownOnly = true) {
return this.prisma.queryCollection.count({
const cnt = await this.prisma.queryCollection.count({
where: {
...(ownOnly
? collectionWhereOwner(userId)
: collectionWhereOwnerOrMember(userId)),
},
});

this.agent?.recordMetric('query_collection.list.count', cnt);

return cnt;
}

private async getWorkspaceOwnerId(workspaceId: string) {
Expand Down
Loading
Loading