Skip to content

Commit

Permalink
feat: make project-id redundant for check-ins configuration api (#1274)
Browse files Browse the repository at this point in the history
  • Loading branch information
subzero10 authored Dec 28, 2023
1 parent c53ff3b commit 4ffe44a
Show file tree
Hide file tree
Showing 12 changed files with 272 additions and 333 deletions.
4 changes: 2 additions & 2 deletions packages/js/examples/checkins-manager/honeybadger.config.js
Original file line number Diff line number Diff line change
@@ -1,17 +1,17 @@
module.exports = {
apiKey: process.env.HONEYBADGER_API_KEY,
personalAuthToken: process.env.HONEYBADGER_PERSONAL_AUTH_TOKEN,
checkins: [
{
name: 'Weekly Exports',
slug: 'weekly-exports-custom-slug',
projectId: process.env.HONEYBADGER_PROJECT_ID,
scheduleType: 'simple',
reportPeriod: '1 week',
gracePeriod: '10 minutes'
},
{
name: 'Hourly Notifications',
projectId: process.env.HONEYBADGER_PROJECT_ID,
slug: 'hourly-notifications',
scheduleType: 'simple',
reportPeriod: '1 hour',
gracePeriod: '5 minutes'
Expand Down
6 changes: 3 additions & 3 deletions packages/js/examples/checkins-manager/package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

54 changes: 26 additions & 28 deletions packages/js/src/server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -92,48 +92,46 @@ class Honeybadger extends Client {
throw new Error('Honeybadger.showUserFeedbackForm() is not supported on the server-side')
}

async checkIn(idOrName: string): Promise<void> {
async checkIn(idOrSlug: string): Promise<void> {
if (this.isCheckInSlug(idOrSlug)) {
return this.checkInWithSlug(idOrSlug)
}

return this.checkInWithId(idOrSlug)
}

private async checkInWithSlug(slug: string): Promise<void> {
try {
const id = await this.getCheckInId(idOrName)
await this.__transport
.send({
method: 'GET',
endpoint: endpoint(this.config.endpoint, `v1/check_in/${id}`),
endpoint: endpoint(this.config.endpoint, `v1/check_in/${this.config.apiKey}/${slug}`),
logger: this.logger,
})
this.logger.info('CheckIn sent')
this.logger.info(`Check-In with slug[${slug}] sent`)
}
catch (err) {
this.logger.error(`CheckIn[${idOrName}] failed: an unknown error occurred.`, `message=${err.message}`)
this.logger.error(`CheckIn with slug[${slug}] failed: an unknown error occurred.`, `message=${err.message}`)
}
}

private async getCheckInId(idOrName: string): Promise<string> {
if (!this.config.checkins || this.config.checkins.length === 0) {
return idOrName
}

const localCheckIn = this.config.checkins.find(c => c.name === idOrName)
if (!localCheckIn) {
return idOrName
}

if (localCheckIn.id) {
return localCheckIn.id
private async checkInWithId(id: string): Promise<void> {
try {
await this.__transport
.send({
method: 'GET',
endpoint: endpoint(this.config.endpoint, `v1/check_in/${id}`),
logger: this.logger,
})
this.logger.info(`Check-In with id[${id}] sent`)
}

const projectCheckIns = await this.__checkInsClient.listForProject(localCheckIn.projectId)
const remoteCheckIn = projectCheckIns.find(c => c.name === localCheckIn.name)
if (!remoteCheckIn) {
this.logger.debug(`Checkin[${idOrName}] was not found on HB. This should not happen. Was the sync command executed?`)

return idOrName
catch (err) {
this.logger.error(`Check-In with id[${id}] failed: an unknown error occurred.`, `message=${err.message}`)
}
}

// store the id in-memory, so subsequent check-ins won't have to call the API
localCheckIn.id = remoteCheckIn.id

return localCheckIn.id
private isCheckInSlug(idOrSlug: string): boolean {
return this.config.checkins?.some(c => c.slug === idOrSlug) ?? false
}

// This method is intended for web frameworks.
Expand Down
42 changes: 21 additions & 21 deletions packages/js/src/server/check-ins-manager/check-in.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,10 +2,9 @@ import { CheckInDto, CheckInPayload, CheckInResponsePayload } from './types'

export class CheckIn implements CheckInDto {
id?: string
projectId: string
name: string
name?: string
scheduleType: 'simple' | 'cron'
slug?: string
slug: string
reportPeriod?: string
gracePeriod?: string
cronSchedule?: string
Expand All @@ -27,7 +26,6 @@ export class CheckIn implements CheckInDto {
this.gracePeriod = props.gracePeriod
this.cronSchedule = props.cronSchedule
this.cronTimezone = props.cronTimezone
this.projectId = props.projectId
this.deleted = false
}

Expand All @@ -40,36 +38,32 @@ export class CheckIn implements CheckInDto {
}

public validate() {
if (!this.projectId) {
throw new Error('projectId is required for each check-in')
}

if (!this.name) {
throw new Error('name is required for each check-in')
if (!this.slug) {
throw new Error('slug is required for each check-in')
}

if (!this.scheduleType) {
throw new Error('scheduleType is required for each check-in')
}

if (!['simple', 'cron'].includes(this.scheduleType)) {
throw new Error(`${this.name} [scheduleType] must be "simple" or "cron"`)
throw new Error(`${this.slug} [scheduleType] must be "simple" or "cron"`)
}

if (this.scheduleType === 'simple' && !this.reportPeriod) {
throw new Error(`${this.name} [reportPeriod] is required for simple check-ins`)
throw new Error(`${this.slug} [reportPeriod] is required for simple check-ins`)
}

if (this.scheduleType === 'cron' && !this.cronSchedule) {
throw new Error(`${this.name} [cronSchedule] is required for cron check-ins`)
throw new Error(`${this.slug} [cronSchedule] is required for cron check-ins`)
}
}

public asRequestPayload() {
const payload: CheckInPayload = {
name: this.name,
schedule_type: this.scheduleType,
slug: this.slug ?? '', // default is empty string
slug: this.slug,
grace_period: this.gracePeriod ?? '' // default is empty string
}

Expand All @@ -88,21 +82,27 @@ export class CheckIn implements CheckInDto {
* Compares two check-ins, usually the one from the API and the one from the config file.
* If the one in the config file does not match the check-in from the API,
* then we issue an update request.
*
* `name`, `gracePeriod` and `cronTimezone` are optional fields that are automatically
* set to a value from the server if one is not provided,
* so we ignore their values if they are not set locally.
*/
public isInSync(other: CheckIn) {
return this.name === other.name
&& this.projectId === other.projectId
const ignoreNameCheck = this.name === undefined
const ignoreGracePeriodCheck = this.gracePeriod === undefined
const ignoreCronTimezoneCheck = this.cronTimezone === undefined

return this.slug === other.slug
&& this.scheduleType === other.scheduleType
&& this.reportPeriod === other.reportPeriod
&& this.cronSchedule === other.cronSchedule
&& (this.slug ?? '') === (other.slug ?? '')
&& (this.gracePeriod ?? '') === (other.gracePeriod ?? '')
&& (this.cronTimezone ?? '') === (other.cronTimezone ?? '')
&& (ignoreNameCheck || this.name === other.name)
&& (ignoreGracePeriodCheck || this.gracePeriod === other.gracePeriod)
&& (ignoreCronTimezoneCheck || this.cronTimezone === other.cronTimezone)
}

public static fromResponsePayload(projectId: string, payload: CheckInResponsePayload) {
public static fromResponsePayload(payload: CheckInResponsePayload) {
return new CheckIn({
projectId,
id: payload.id,
name: payload.name,
slug: payload.slug,
Expand Down
62 changes: 35 additions & 27 deletions packages/js/src/server/check-ins-manager/client.ts
Original file line number Diff line number Diff line change
@@ -1,26 +1,41 @@
import { Types } from '@honeybadger-io/core'
import { CheckIn } from './check-in'
import { CheckInResponsePayload } from './types';
import { CheckInResponsePayload, CheckInsConfig } from './types';

export class CheckInsClient {
private readonly BASE_URL = 'https://app.honeybadger.io'
private readonly cache: Record<string, CheckIn[]>
private readonly config: { personalAuthToken: string; logger: Types.Logger }
private readonly config: Pick<CheckInsConfig, 'apiKey' | 'personalAuthToken' | 'logger'>
private readonly logger: Types.Logger
private readonly transport: Types.Transport

constructor(config: { personalAuthToken: string; logger: Types.Logger }, transport: Types.Transport) {
constructor(config: Pick<CheckInsConfig, 'apiKey' | 'personalAuthToken' | 'logger'>, transport: Types.Transport) {
this.transport = transport
this.config = config
this.logger = config.logger
this.cache = {}
}

public async listForProject(projectId: string): Promise<CheckIn[]> {
if (this.cache[projectId]) {
return this.cache[projectId]
public async getProjectId(projectApiKey: string): Promise<string> {
if (!this.config.personalAuthToken || this.config.personalAuthToken === '') {
throw new Error('personalAuthToken is required')
}

const response = await this.transport.send({
method: 'GET',
headers: this.getHeaders(),
endpoint: `${this.BASE_URL}/v2/project_keys/${projectApiKey}`,
logger: this.logger,
})

if (response.statusCode !== 200) {
throw new Error(`Failed to fetch project[${projectApiKey}]: ${this.getErrorMessage(response.body)}`)
}

const data: { project: { id: string; name: string; created_at: string; } } = JSON.parse(response.body)

return data?.project?.id
}

public async listForProject(projectId: string): Promise<CheckIn[]> {
if (!this.config.personalAuthToken || this.config.personalAuthToken === '') {
throw new Error('personalAuthToken is required')
}
Expand All @@ -37,10 +52,8 @@ export class CheckInsClient {
}

const data: { results: CheckInResponsePayload[] } = JSON.parse(response.body)
const checkIns = data.results.map((checkin) => CheckIn.fromResponsePayload(projectId, checkin))
this.cache[projectId] = checkIns

return checkIns
return data.results.map((checkin) => CheckIn.fromResponsePayload(checkin))
}

public async get(projectId: string, checkInId: string): Promise<CheckIn> {
Expand All @@ -60,68 +73,63 @@ export class CheckInsClient {
}

const data: CheckInResponsePayload = JSON.parse(response.body)
const checkIn = CheckIn.fromResponsePayload(projectId, data)
checkIn.projectId = projectId

return checkIn
return CheckIn.fromResponsePayload(data)
}

public async create(checkIn: CheckIn): Promise<CheckIn> {
public async create(projectId: string, checkIn: CheckIn): Promise<CheckIn> {
if (!this.config.personalAuthToken || this.config.personalAuthToken === '') {
throw new Error('personalAuthToken is required')
}

const response = await this.transport.send({
method: 'POST',
headers: this.getHeaders(),
endpoint: `${this.BASE_URL}/v2/projects/${checkIn.projectId}/check_ins`,
endpoint: `${this.BASE_URL}/v2/projects/${projectId}/check_ins`,
logger: this.logger,
}, { check_in: checkIn.asRequestPayload() })

if (response.statusCode !== 201) {
throw new Error(`Failed to create check-in[${checkIn.name}] for project[${checkIn.projectId}]: ${this.getErrorMessage(response.body)}`)
throw new Error(`Failed to create check-in[${checkIn.slug}] for project[${projectId}]: ${this.getErrorMessage(response.body)}`)
}

const data: CheckInResponsePayload = JSON.parse(response.body)
const result = CheckIn.fromResponsePayload(checkIn.projectId, data)
result.projectId = checkIn.projectId

return result
return CheckIn.fromResponsePayload(data)
}

public async update(checkIn: CheckIn): Promise<CheckIn> {
public async update(projectId: string, checkIn: CheckIn): Promise<CheckIn> {
if (!this.config.personalAuthToken || this.config.personalAuthToken === '') {
throw new Error('personalAuthToken is required')
}

const response = await this.transport.send({
method: 'PUT',
headers: this.getHeaders(),
endpoint: `${this.BASE_URL}/v2/projects/${checkIn.projectId}/check_ins/${checkIn.id}`,
endpoint: `${this.BASE_URL}/v2/projects/${projectId}/check_ins/${checkIn.id}`,
logger: this.logger,
}, { check_in: checkIn.asRequestPayload() })

if (response.statusCode !== 204) {
throw new Error(`Failed to update checkin[${checkIn.name}] for project[${checkIn.projectId}]: ${this.getErrorMessage(response.body)}`)
throw new Error(`Failed to update checkin[${checkIn.slug}] for project[${projectId}]: ${this.getErrorMessage(response.body)}`)
}

return checkIn
}

public async remove(checkIn: CheckIn): Promise<void> {
public async remove(projectId: string, checkIn: CheckIn): Promise<void> {
if (!this.config.personalAuthToken || this.config.personalAuthToken === '') {
throw new Error('personalAuthToken is required')
}

const response = await this.transport.send({
method: 'DELETE',
headers: this.getHeaders(),
endpoint: `${this.BASE_URL}/v2/projects/${checkIn.projectId}/check_ins/${checkIn.id}`,
endpoint: `${this.BASE_URL}/v2/projects/${projectId}/check_ins/${checkIn.id}`,
logger: this.logger,
})

if (response.statusCode !== 204) {
throw new Error(`Failed to remove checkin[${checkIn.name}] for project[${checkIn.projectId}]: ${this.getErrorMessage(response.body)}`)
throw new Error(`Failed to remove checkin[${checkIn.slug}] for project[${projectId}]: ${this.getErrorMessage(response.body)}`)
}
}

Expand Down
Loading

0 comments on commit 4ffe44a

Please sign in to comment.