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

feat: make project-id redundant for check-ins configuration api #1274

Merged
merged 5 commits into from
Dec 28, 2023
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: 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
Loading