UI cleanup and error handling improvements (#114)

This commit is contained in:
Chris Anderson 2023-04-05 19:19:52 -04:00 committed by GitHub
parent dcb41b8cca
commit 378708a64f
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
19 changed files with 115 additions and 48 deletions

View file

@ -14,6 +14,32 @@ export default class Api extends Koa {
this.proxy = process.env.NODE_ENV !== 'development'
app.error.attach(this)
this.use(async (ctx, next) => {
try {
await next()
} catch (error: any) {
if (error instanceof RequestError) {
ctx.status = error.statusCode ?? 400
ctx.body = error
} else {
ctx.status = 400
ctx.body = process.env.NODE_ENV === 'production'
? {
status: 'error',
error: 'An error occurred with this request.',
}
: {
status: 'error',
error: {
message: error.message,
stack: error.stack,
},
}
}
ctx.app.emit('error', error, ctx)
}
})
this.use(koaBody())
.use(cors())
@ -22,19 +48,6 @@ export default class Api extends Koa {
defer: true,
}))
this.use(async (ctx, next) => {
try {
await next()
} catch (error) {
if (error instanceof RequestError) {
ctx.status = error.statusCode ?? 400
ctx.body = error
} else {
throw error
}
}
})
controllers(this)
}
}

View file

@ -27,8 +27,8 @@ export default class EventPostJob extends Job {
const { anonymous_id, external_id } = event
const user = await getUserFromClientId(project_id, { anonymous_id, external_id } as ClientIdentity)
if (!user) {
logger.error({ project_id, event }, 'job:event_post:unknown-user')
throw new Error('job:event_post:unknown-user')
logger.warn({ project_id, event }, 'job:event_post:unknown-user')
return
}
// Create event for given user

View file

@ -25,4 +25,8 @@ export default class BugSnagProvider implements ErrorHandlingProvider {
api.on('error', middleware.errorHandler)
}
}
notify(error: Error) {
Bugsnag.notify(error)
}
}

View file

@ -23,4 +23,8 @@ export default class ErrorHandler {
attach(api: Koa) {
this.provider?.attach(api)
}
notify(error: Error) {
this.provider?.notify(error)
}
}

View file

@ -4,4 +4,5 @@ export type ErrorHandlerProviderName = 'bugsnag' | 'sentry'
export default interface ErrorHandlerProvider {
attach(api: Koa): void
notify(error: Error): void
}

View file

@ -21,4 +21,8 @@ export default class SentryProvider implements ErrorHandlingProvider {
})
})
}
notify(error: Error) {
Sentry.captureException(error)
}
}

View file

@ -11,6 +11,14 @@ export interface EncodedJob {
name: string
}
export class JobError extends Error {
data?: Record<any, any>
constructor(message: string, data?: Record<any, any>) {
super(message)
this.data = data
}
}
export default class Job implements EncodedJob {
data: any
options: JobOptions = {

View file

@ -1,3 +1,4 @@
import App from '../app'
import { DriverConfig } from '../config/env'
import { logger } from '../config/logger'
import { LoggerConfig } from '../providers/LoggerProvider'
@ -59,9 +60,9 @@ export default class Queue {
logger.info(job, 'queue:job:started')
}
// eslint-disable-next-line @typescript-eslint/no-unused-vars
async errored(job: EncodedJob, error: Error) {
// TODO: Do something about failure
async errored(job: EncodedJob | undefined, error: Error) {
logger.error({ error, job }, 'queue:job:errored')
App.main.error.notify(error)
}
async completed(job: EncodedJob) {

View file

@ -1,7 +1,7 @@
import { Queue as BullQueue, Worker } from 'bullmq'
import { logger } from '../config/logger'
import { batch } from '../utilities'
import Job from './Job'
import Job, { EncodedJob } from './Job'
import Queue, { QueueTypeConfig } from './Queue'
import QueueProvider from './QueueProvider'
@ -39,7 +39,7 @@ export default class RedisQueueProvider implements QueueProvider {
const { name, data, opts } = this.adaptJob(job)
await this.bull.add(name, data, opts)
} catch (error) {
logger.error(error, 'sqs:error:enqueue')
logger.error(error, 'redis:error:enqueue')
}
}
@ -68,8 +68,13 @@ export default class RedisQueueProvider implements QueueProvider {
start(): void {
this.worker = new Worker('parcelvoy', async job => {
await this.queue.dequeue(job.data)
}, { connection: this.config, concurrency: this.batchSize })
}, {
connection: this.config,
concurrency: this.batchSize,
})
this.worker.on('failed', (job, error) => {
this.queue.errored(job?.data as EncodedJob, error)
logger.error({ error }, 'redis:error:processing')
})
}

View file

@ -22,30 +22,15 @@ export default class UserPatchJob extends Job {
return new this(data)
}
static async handler({ project_id, user: { external_id, anonymous_id, data, ...fields }, options }: UserPatchTrigger) {
static async handler(patch: UserPatchTrigger) {
const identity = { external_id, anonymous_id } as ClientIdentity
// Check for existing user
const existing = await getUserFromClientId(project_id, identity)
// If user, update otherwise insert
const user = existing
? await User.updateAndFetch(existing.id, {
data: data ? { ...existing.data, ...data } : undefined,
...fields,
})
: await createUser(project_id, {
...identity,
data,
...fields,
})
const user = await this.upsert(patch)
const {
join_list_id,
skip_list_updating = false,
skip_journey_updating = false,
} = options ?? {}
} = patch.options ?? {}
// Use updated user to check for list membership
if (!skip_list_updating) {
@ -62,4 +47,29 @@ export default class UserPatchJob extends Job {
await updateUsersJourneys(user)
}
}
static async upsert(patch: UserPatchTrigger, tries = 3): Promise<User> {
const { project_id, user: { external_id, anonymous_id, data, ...fields } } = patch
const identity = { external_id, anonymous_id } as ClientIdentity
// Check for existing user
const existing = await getUserFromClientId(project_id, identity)
// If user, update otherwise insert
try {
return existing
? await User.updateAndFetch(existing.id, {
data: data ? { ...existing.data, ...data } : undefined,
...fields,
})
: await createUser(project_id, {
...identity,
data,
...fields,
})
} catch (error: any) {
// If there is an error (such as constraints, retry)
return this.upsert(patch, --tries)
}
}
}

View file

@ -6,21 +6,21 @@
.ui-button-group .ui-button,
.ui-button-group .ui-select .select-button,
.ui-button-group .ui-text-field input {
.ui-button-group .ui-text-input input {
box-shadow: none;
margin: 0;
}
.ui-button-group .ui-button:not(:last-child),
.ui-button-group .ui-select:not(:last-child) .select-button,
.ui-button-group .ui-text-field:not(:last-child) input {
.ui-button-group .ui-text-input:not(:last-child) input {
border-top-right-radius: 0;
border-bottom-right-radius: 0;
}
.ui-button-group .ui-button:not(:first-child),
.ui-button-group .ui-select:not(:first-child) .select-button,
.ui-button-group .ui-text-field:not(:first-child) input {
.ui-button-group .ui-text-input:not(:first-child) input {
border-top-left-radius: 0;
border-bottom-left-radius: 0;
@ -31,6 +31,6 @@
}
.ui-button-group .ui-select:not(:first-child),
.ui-button-group .ui-text-field:not(:first-child) {
.ui-button-group .ui-text-input:not(:first-child) {
margin-left: -1px;
}

View file

@ -43,3 +43,9 @@
margin: 0;
color: var(--color-primary-soft);
}
@media only screen and (max-width: 600px) {
.ui-tile-grid {
grid-template-columns: 1fr;
}
}

View file

@ -73,7 +73,7 @@ export function EntityIdPicker<T extends { id: number }>({
{subtitle && <span className="label-subtitle">{subtitle}</span>}
</Combobox.Label>
<div className="ui-button-group">
<span className={clsx('ui-text-field', size ?? 'regular')} style={{ flexGrow: 1 }}>
<span className={clsx('ui-text-input', size ?? 'regular')} style={{ flexGrow: 1 }}>
<Combobox.Input
displayValue={(value: T) => value && displayValue(value)}
onChange={e => setQuery(e.target.value)}

View file

@ -18,6 +18,6 @@
margin-bottom: 10px;
}
.user-lookup .ui-button-group .ui-text-field {
.user-lookup .ui-button-group .ui-text-input {
flex-grow: 1;
}

View file

@ -125,6 +125,14 @@
min-width: 200px;
}
.journey-step.entrance {
max-width: 400px;
}
.journey-step.flow {
max-width: 550px;
}
.journey-step.entrance.selected {
border-color: var(--color-red-hard);
}

View file

@ -29,7 +29,7 @@ export const entranceStep: JourneyStepType<EntranceConfig> = {
return (
<EntityIdPicker
label="List"
subtitle="Users added to this list will automatically start this journey."
subtitle="When users are added to this list they will automatically enter this journey."
required
get={getList}
search={searchLists}

View file

@ -86,7 +86,7 @@ export default function ProjectApiKeys() {
open={Boolean(editing)}
onClose={() => setEditing(null)}
>
<Alert variant="plain" title="Key Value">{editing?.value}</Alert>
{editing?.value && <Alert variant="plain" title="Key Value">{editing?.value}</Alert>}
{
editing && (
<FormWrapper<ProjectApiKey>

View file

@ -98,6 +98,7 @@
color: var(--color-primary-soft);
}
.rule-inner input {
.rule-inner input.small {
font-weight: 500;
width: 100%;
}

View file

@ -194,6 +194,7 @@ const RuleView = ({ rule, onChange, onDelete }: RuleParams) => {
name="path"
placeholder="User Property..."
value={rule?.path}
hideLabel={true}
onChange={path => handleUpdate({ path })}
/>
<OperatorSelector
@ -205,6 +206,7 @@ const RuleView = ({ rule, onChange, onDelete }: RuleParams) => {
type="text"
name="value"
placeholder="Value"
hideLabel={true}
value={rule?.value?.toString()}
onChange={value => handleUpdate({ value })}
/>