-
Notifications
You must be signed in to change notification settings - Fork 393
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(logs): drawer with details #2155
Changes from all commits
07c649a
a67dd4a
0204f4a
165605d
3b85df3
a804cea
3f04d9e
7e34e5b
0997276
665ffa2
dc98af9
52108a0
4703e30
5ce96c5
78938e9
89bbf2b
b6c83ed
319fa91
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
Large diffs are not rendered by default.
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,162 @@ | ||
import { logContextGetter, migrateMapping } from '@nangohq/logs'; | ||
import { multipleMigrations, seeders } from '@nangohq/shared'; | ||
import { afterAll, beforeAll, describe, expect, it } from 'vitest'; | ||
import { isError, isSuccess, runServer, shouldBeProtected, shouldRequireQueryEnv } from '../../../utils/tests.js'; | ||
|
||
let api: Awaited<ReturnType<typeof runServer>>; | ||
describe('GET /logs', () => { | ||
beforeAll(async () => { | ||
await multipleMigrations(); | ||
await migrateMapping(); | ||
|
||
api = await runServer(); | ||
}); | ||
afterAll(() => { | ||
api.server.close(); | ||
}); | ||
|
||
it('should be protected', async () => { | ||
const res = await api.fetch('/api/v1/logs/messages', { method: 'POST', query: { env: 'dev' }, body: { operationId: '1' } }); | ||
|
||
shouldBeProtected(res); | ||
}); | ||
|
||
it('should enforce env query params', async () => { | ||
const { env } = await seeders.seedAccountEnvAndUser(); | ||
// @ts-expect-error missing query on purpose | ||
const res = await api.fetch('/api/v1/logs/messages', { method: 'POST', token: env.secret_key, body: { operationId: '1' } }); | ||
|
||
shouldRequireQueryEnv(res); | ||
}); | ||
|
||
it('should validate body', async () => { | ||
const { env } = await seeders.seedAccountEnvAndUser(); | ||
const res = await api.fetch('/api/v1/logs/messages', { | ||
method: 'POST', | ||
query: { env: 'dev' }, | ||
token: env.secret_key, | ||
// @ts-expect-error on purpose | ||
body: { limit: 'a', foo: 'bar' } | ||
}); | ||
|
||
expect(res.json).toStrictEqual({ | ||
error: { | ||
code: 'invalid_body', | ||
errors: [ | ||
{ | ||
code: 'invalid_type', | ||
message: 'Required', | ||
path: ['operationId'] | ||
}, | ||
{ | ||
code: 'invalid_type', | ||
message: 'Expected number, received string', | ||
path: ['limit'] | ||
}, | ||
{ | ||
code: 'unrecognized_keys', | ||
message: "Unrecognized key(s) in object: 'foo'", | ||
path: [] | ||
} | ||
] | ||
} | ||
}); | ||
expect(res.res.status).toBe(400); | ||
}); | ||
|
||
it('should search messages and get empty results', async () => { | ||
const { account, env } = await seeders.seedAccountEnvAndUser(); | ||
|
||
const logCtx = await logContextGetter.create({ message: 'test 1', operation: { type: 'auth' } }, { account, environment: env }); | ||
await logCtx.success(); | ||
|
||
const res = await api.fetch('/api/v1/logs/messages', { | ||
method: 'POST', | ||
query: { env: 'dev' }, | ||
token: env.secret_key, | ||
body: { operationId: logCtx.id, limit: 10 } | ||
}); | ||
|
||
isSuccess(res.json); | ||
expect(res.res.status).toBe(200); | ||
expect(res.json).toStrictEqual({ | ||
data: [], | ||
pagination: { total: 0 } | ||
}); | ||
}); | ||
|
||
it('should search messages and get one result', async () => { | ||
const { env, account } = await seeders.seedAccountEnvAndUser(); | ||
|
||
const logCtx = await logContextGetter.create({ message: 'test 1', operation: { type: 'auth' } }, { account, environment: env }); | ||
await logCtx.info('test info'); | ||
await logCtx.success(); | ||
|
||
const res = await api.fetch('/api/v1/logs/messages', { | ||
method: 'POST', | ||
query: { env: 'dev' }, | ||
token: env.secret_key, | ||
body: { operationId: logCtx.id, limit: 10 } | ||
}); | ||
|
||
isSuccess(res.json); | ||
expect(res.res.status).toBe(200); | ||
expect(res.json).toStrictEqual<typeof res.json>({ | ||
data: [ | ||
{ | ||
accountId: null, | ||
accountName: null, | ||
code: null, | ||
configId: null, | ||
configName: null, | ||
connectionId: null, | ||
connectionName: null, | ||
createdAt: expect.toBeIsoDate(), | ||
endedAt: null, | ||
environmentId: null, | ||
environmentName: null, | ||
error: null, | ||
id: expect.any(String), | ||
jobId: null, | ||
level: 'info', | ||
message: 'test info', | ||
meta: null, | ||
operation: null, | ||
parentId: logCtx.id, | ||
request: null, | ||
response: null, | ||
source: 'internal', | ||
startedAt: null, | ||
state: 'waiting', | ||
syncId: null, | ||
syncName: null, | ||
title: null, | ||
type: 'log', | ||
updatedAt: expect.toBeIsoDate(), | ||
userId: null | ||
} | ||
], | ||
pagination: { total: 1 } | ||
}); | ||
}); | ||
|
||
it('should search messages and not return results from an other account', async () => { | ||
const { account, env } = await seeders.seedAccountEnvAndUser(); | ||
const env2 = await seeders.seedAccountEnvAndUser(); | ||
|
||
const logCtx = await logContextGetter.create({ message: 'test 1', operation: { type: 'auth' } }, { account, environment: env }); | ||
await logCtx.info('test info'); | ||
await logCtx.success(); | ||
|
||
const res = await api.fetch('/api/v1/logs/messages', { | ||
method: 'POST', | ||
query: { env: 'dev' }, | ||
token: env2.env.secret_key, | ||
body: { operationId: logCtx.id, limit: 10 } | ||
}); | ||
|
||
isError(res.json); | ||
expect(res.res.status).toBe(404); | ||
expect(res.json).toStrictEqual<typeof res.json>({ error: { code: 'not_found' } }); | ||
}); | ||
}); |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,69 @@ | ||
import { z } from 'zod'; | ||
import { asyncWrapper } from '../../../utils/asyncWrapper.js'; | ||
import type { SearchMessages } from '@nangohq/types'; | ||
import { model, envs, operationIdRegex } from '@nangohq/logs'; | ||
import { requireEmptyQuery, zodErrorToHTTP } from '@nangohq/utils'; | ||
|
||
const validation = z | ||
.object({ | ||
operationId: operationIdRegex, | ||
limit: z.number().optional().default(100), | ||
search: z.string().optional(), | ||
states: z | ||
.array(z.enum(['all', 'waiting', 'running', 'success', 'failed', 'timeout', 'cancelled'])) | ||
.optional() | ||
.default(['all']) | ||
}) | ||
.strict(); | ||
|
||
export const searchMessages = asyncWrapper<SearchMessages>(async (req, res) => { | ||
if (!envs.NANGO_LOGS_ENABLED) { | ||
res.status(404).send({ error: { code: 'feature_disabled' } }); | ||
return; | ||
} | ||
|
||
const emptyQuery = requireEmptyQuery(req, { withEnv: true }); | ||
if (emptyQuery) { | ||
res.status(400).send({ error: { code: 'invalid_query_params', errors: zodErrorToHTTP(emptyQuery.error) } }); | ||
return; | ||
} | ||
|
||
const val = validation.safeParse(req.body); | ||
if (!val.success) { | ||
res.status(400).send({ | ||
error: { code: 'invalid_body', errors: zodErrorToHTTP(val.error) } | ||
}); | ||
return; | ||
} | ||
|
||
const { environment, account } = res.locals; | ||
|
||
// Manually ensure that `operationId` belongs to the account for now | ||
// Because not all the logs have accountId/environmentId | ||
try { | ||
const operation = await model.getOperation({ id: val.data.operationId }); | ||
if (operation.accountId !== account.id || operation.environmentId !== environment.id) { | ||
res.status(404).send({ error: { code: 'not_found' } }); | ||
return; | ||
} | ||
} catch (err) { | ||
if (err instanceof model.ResponseError && err.statusCode === 404) { | ||
res.status(404).send({ error: { code: 'not_found' } }); | ||
return; | ||
} | ||
throw err; | ||
} | ||
|
||
const body: SearchMessages['Body'] = val.data; | ||
const rawOps = await model.listMessages({ | ||
parentId: body.operationId, | ||
limit: body.limit!, | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I wonder if there is a way make the type reflect the fact that after validation, since limit has a default value it cannot be undefined anymore There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. yes it's a bit annoying, with types you often need to produce multiple variants: before/after validation, before/after creation, etc. |
||
states: body.states, | ||
search: body.search | ||
}); | ||
|
||
res.status(200).send({ | ||
data: rawOps.items, | ||
pagination: { total: rawOps.count } | ||
}); | ||
}); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
those 2 checks come back regularly. I imagine we could abstract them
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I think using your wrapper might be the best solution because I could not come up with an abstraction that save more than 1 line (you still need to return something so you still need a if)☺️
but if you have something in the meantime, happy to change
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I don't have a concrete solution right now. my comment was more to keep it in the back on our mind so at some point we come up with something