diff --git a/.changeset/rich-peas-worry.md b/.changeset/rich-peas-worry.md new file mode 100644 index 0000000000000..b5716ff35281f --- /dev/null +++ b/.changeset/rich-peas-worry.md @@ -0,0 +1,7 @@ +--- +'@rocket.chat/rest-typings': minor +'@rocket.chat/apps-engine': minor +'@rocket.chat/meteor': minor +--- + +Improve the `/api/apps/:id/logs` endpoint to accept filters diff --git a/.changeset/short-mayflies-sing.md b/.changeset/short-mayflies-sing.md new file mode 100644 index 0000000000000..30d90ca97612f --- /dev/null +++ b/.changeset/short-mayflies-sing.md @@ -0,0 +1,7 @@ +--- +'@rocket.chat/rest-typings': minor +'@rocket.chat/apps-engine': minor +'@rocket.chat/meteor': minor +--- + +Add a new endpoint `/api/apps/logs` that allows for fetching logs without a filter for app id diff --git a/apps/meteor/client/views/marketplace/hooks/useLogs.ts b/apps/meteor/client/views/marketplace/hooks/useLogs.ts index d8714e1345faf..48d5dfb290180 100644 --- a/apps/meteor/client/views/marketplace/hooks/useLogs.ts +++ b/apps/meteor/client/views/marketplace/hooks/useLogs.ts @@ -8,6 +8,6 @@ export const useLogs = (appId: string): UseQueryResult logs(), + queryFn: () => logs({}), }); }; diff --git a/apps/meteor/ee/server/apps/communication/endpoints/actionButtonsHandler.ts b/apps/meteor/ee/server/apps/communication/endpoints/actionButtonsHandler.ts index ab9a8feffb405..3f3af296ae3e8 100644 --- a/apps/meteor/ee/server/apps/communication/endpoints/actionButtonsHandler.ts +++ b/apps/meteor/ee/server/apps/communication/endpoints/actionButtonsHandler.ts @@ -1,20 +1,15 @@ -import type { AppManager } from '@rocket.chat/apps-engine/server/AppManager'; - import { API } from '../../../../../app/api/server'; import type { AppsRestApi } from '../rest'; -export const actionButtonsHandler = (apiManager: AppsRestApi) => - [ - { - authRequired: false, - }, +export const registerActionButtonsHandler = ({ api, _manager }: AppsRestApi) => + void api.addRoute( + 'actionButtons', + { authRequired: false }, { - get(): any { - const manager = apiManager._manager as AppManager; - - const buttons = manager.getUIActionButtonManager().getAllActionButtons(); + get() { + const buttons = _manager.getUIActionButtonManager().getAllActionButtons(); return API.v1.success(buttons); }, }, - ] as const; + ); diff --git a/apps/meteor/ee/server/apps/communication/endpoints/appGeneralLogsHandler.ts b/apps/meteor/ee/server/apps/communication/endpoints/appGeneralLogsHandler.ts new file mode 100644 index 0000000000000..58dcd0abe9226 --- /dev/null +++ b/apps/meteor/ee/server/apps/communication/endpoints/appGeneralLogsHandler.ts @@ -0,0 +1,35 @@ +import { isAppLogsProps } from '@rocket.chat/rest-typings'; + +import { getPaginationItems } from '../../../../../app/api/server/helpers/getPaginationItems'; +import type { AppsRestApi } from '../rest'; +import { makeAppLogsQuery } from './lib/makeAppLogsQuery'; + +export const registerAppGeneralLogsHandler = ({ api, _orch }: AppsRestApi) => + void api.addRoute( + 'logs', + { authRequired: true, permissionRequired: ['manage-apps'], validateParams: isAppLogsProps }, + { + async get() { + const { offset, count } = await getPaginationItems(this.queryParams); + const { sort } = await this.parseJsonQuery(); + + const options = { + sort: sort || { _updatedAt: -1 }, + skip: offset, + limit: count, + }; + + let query: Record; + + try { + query = makeAppLogsQuery(this.queryParams); + } catch (error) { + return api.failure({ error: error instanceof Error ? error.message : 'Unknown error' }); + } + + const result = await _orch.getLogStorage().find(query, options); + + return api.success({ offset, logs: result.logs, count: result.logs.length, total: result.total }); + }, + }, + ); diff --git a/apps/meteor/ee/server/apps/communication/endpoints/appLogsHandler.ts b/apps/meteor/ee/server/apps/communication/endpoints/appLogsHandler.ts new file mode 100644 index 0000000000000..d4c52e96e6315 --- /dev/null +++ b/apps/meteor/ee/server/apps/communication/endpoints/appLogsHandler.ts @@ -0,0 +1,45 @@ +import { isAppLogsProps } from '@rocket.chat/rest-typings'; + +import { getPaginationItems } from '../../../../../app/api/server/helpers/getPaginationItems'; +import type { AppsRestApi } from '../rest'; +import { makeAppLogsQuery } from './lib/makeAppLogsQuery'; + +export const registerAppLogsHandler = ({ api, _manager, _orch }: AppsRestApi) => + void api.addRoute( + ':id/logs', + { authRequired: true, permissionRequired: ['manage-apps'], validateParams: isAppLogsProps }, + { + async get() { + const proxiedApp = _manager.getOneById(this.urlParams.id); + + if (!proxiedApp) { + return api.notFound(`No App found by the id of: ${this.urlParams.id}`); + } + + if (this.queryParams.appId && this.queryParams.appId !== this.urlParams.id) { + return api.notFound(`Invalid query parameter "appId": ${this.queryParams.appId}`); + } + + const { offset, count } = await getPaginationItems(this.queryParams); + const { sort } = await this.parseJsonQuery(); + + const options = { + sort: sort || { _updatedAt: -1 }, + skip: offset, + limit: count, + }; + + let query: Record; + + try { + query = makeAppLogsQuery(this.queryParams); + } catch (error) { + return api.failure({ error: error instanceof Error ? error.message : 'Unknown error' }); + } + + const result = await _orch.getLogStorage().find(query, options); + + return api.success({ offset, logs: result.logs, count: result.logs.length, total: result.total }); + }, + }, + ); diff --git a/apps/meteor/ee/server/apps/communication/endpoints/appsCountHandler.ts b/apps/meteor/ee/server/apps/communication/endpoints/appsCountHandler.ts index 878dd9aab92cb..402ceb709a16a 100644 --- a/apps/meteor/ee/server/apps/communication/endpoints/appsCountHandler.ts +++ b/apps/meteor/ee/server/apps/communication/endpoints/appsCountHandler.ts @@ -2,25 +2,16 @@ import type { AppManager } from '@rocket.chat/apps-engine/server/AppManager'; import { License } from '@rocket.chat/license'; import { API } from '../../../../../app/api/server'; -import type { SuccessResult } from '../../../../../app/api/server/definition'; import { getInstallationSourceFromAppStorageItem } from '../../../../../lib/apps/getInstallationSourceFromAppStorageItem'; import type { AppsRestApi } from '../rest'; -type AppsCountResult = { - totalMarketplaceEnabled: number; - totalPrivateEnabled: number; - maxMarketplaceApps: number; - maxPrivateApps: number; -}; - -export const appsCountHandler = (apiManager: AppsRestApi) => - [ - { - authRequired: false, - }, +export const registerAppsCountHandler = ({ api, _manager }: AppsRestApi) => + void api.addRoute( + 'count', + { authRequired: false }, { - async get(): Promise> { - const manager = apiManager._manager as AppManager; + async get() { + const manager = _manager as AppManager; const apps = await manager.get({ enabled: true }); const { maxMarketplaceApps, maxPrivateApps } = License.getAppsConfig(); @@ -34,4 +25,4 @@ export const appsCountHandler = (apiManager: AppsRestApi) => }); }, }, - ] as const; + ); diff --git a/apps/meteor/ee/server/apps/communication/endpoints/lib/makeAppLogsQuery.ts b/apps/meteor/ee/server/apps/communication/endpoints/lib/makeAppLogsQuery.ts new file mode 100644 index 0000000000000..1d8097903ec9f --- /dev/null +++ b/apps/meteor/ee/server/apps/communication/endpoints/lib/makeAppLogsQuery.ts @@ -0,0 +1,60 @@ +import type { AppLogsProps } from '@rocket.chat/rest-typings'; + +/** + * Creates a query object for fetching app logs based on provided parameters. + * + * NOTE: This function expects that all values are in the correct format, as it is + * used by an endpoint handler which has query parameter validation. + * + * @param queryParams - The query parameters. + * @returns A query object for fetching app logs. + * @throws {Error} If the date range is invalid. + */ +export function makeAppLogsQuery(queryParams: AppLogsProps) { + const query: Record = {}; + + if (queryParams.appId) { + query.appId = queryParams.appId; + } + + if (queryParams.logLevel) { + const queryLogLevel = Number(queryParams.logLevel); + const logLevel = ['error']; + + if (queryLogLevel >= 1) { + logLevel.push('warn', 'info', 'log'); + } + + if (queryLogLevel >= 2) { + logLevel.push('debug', 'success'); + } + + query['entries.severity'] = { $in: logLevel }; + } + + if (queryParams.method) { + query.method = queryParams.method; + } + + if (queryParams.startDate) { + query._updatedAt = { + $gte: new Date(queryParams.startDate), + }; + } + + if (queryParams.endDate) { + const endDate = new Date(queryParams.endDate); + endDate.setDate(endDate.getDate() + 1); + + if (query._updatedAt?.$gte && query._updatedAt.$gte >= endDate) { + throw new Error('Invalid date range'); + } + + query._updatedAt = { + ...(query._updatedAt || {}), + $lte: endDate, + }; + } + + return query; +} diff --git a/apps/meteor/ee/server/apps/communication/rest.ts b/apps/meteor/ee/server/apps/communication/rest.ts index 9df3555448d6a..775b9e195386c 100644 --- a/apps/meteor/ee/server/apps/communication/rest.ts +++ b/apps/meteor/ee/server/apps/communication/rest.ts @@ -10,11 +10,12 @@ import { serverFetch as fetch } from '@rocket.chat/server-fetch'; import { Meteor } from 'meteor/meteor'; import { ZodError } from 'zod'; -import { actionButtonsHandler } from './endpoints/actionButtonsHandler'; -import { appsCountHandler } from './endpoints/appsCountHandler'; +import { registerActionButtonsHandler } from './endpoints/actionButtonsHandler'; +import { registerAppGeneralLogsHandler } from './endpoints/appGeneralLogsHandler'; +import { registerAppLogsHandler } from './endpoints/appLogsHandler'; +import { registerAppsCountHandler } from './endpoints/appsCountHandler'; import type { APIClass } from '../../../../app/api/server'; import { API } from '../../../../app/api/server'; -import { getPaginationItems } from '../../../../app/api/server/helpers/getPaginationItems'; import { getUploadFormData } from '../../../../app/api/server/lib/getUploadFormData'; import { getWorkspaceAccessToken, getWorkspaceAccessTokenWithScope } from '../../../../app/cloud/server'; import { apiDeprecationLogger } from '../../../../app/lib/server/lib/deprecationWarningLogger'; @@ -93,8 +94,11 @@ export class AppsRestApi { return API.v1.failure(); }; - this.api.addRoute('actionButtons', ...actionButtonsHandler(this)); - this.api.addRoute('count', ...appsCountHandler(this)); + registerActionButtonsHandler(this); + registerAppsCountHandler(this); + + registerAppLogsHandler(this); + registerAppGeneralLogsHandler(this); this.api.addRoute( 'incompatibleModal', @@ -1138,34 +1142,6 @@ export class AppsRestApi { }, ); - this.api.addRoute( - ':id/logs', - { authRequired: true, permissionsRequired: ['manage-apps'] }, - { - async get() { - const prl = manager.getOneById(this.urlParams.id); - - if (prl) { - const { offset, count } = await getPaginationItems(this.queryParams); - const { sort, fields, query } = await this.parseJsonQuery(); - - const ourQuery = Object.assign({}, query, { appId: prl.getID() }); - const options = { - sort: sort || { _updatedAt: -1 }, - skip: offset, - limit: count, - fields, - }; - - const logs = await orchestrator?.getLogStorage()?.find(ourQuery, options); - - return API.v1.success({ logs }); - } - return API.v1.notFound(`No App found by the id of: ${this.urlParams.id}`); - }, - }, - ); - this.api.addRoute( ':id/settings', { authRequired: true, permissionsRequired: ['manage-apps'] }, diff --git a/apps/meteor/ee/server/apps/orchestrator.js b/apps/meteor/ee/server/apps/orchestrator.js index 8a49d8f6024d3..89d308d5e69b6 100644 --- a/apps/meteor/ee/server/apps/orchestrator.js +++ b/apps/meteor/ee/server/apps/orchestrator.js @@ -109,6 +109,10 @@ export class AppServerOrchestrator { } getLogStorage() { + if (!this._logStorage) { + throw new Error('Apps-Engine not yet fully initialized'); + } + return this._logStorage; } diff --git a/apps/meteor/ee/server/apps/storage/AppRealLogStorage.ts b/apps/meteor/ee/server/apps/storage/AppRealLogStorage.ts index 17a8f2f838aa4..87a009608e175 100644 --- a/apps/meteor/ee/server/apps/storage/AppRealLogStorage.ts +++ b/apps/meteor/ee/server/apps/storage/AppRealLogStorage.ts @@ -13,9 +13,16 @@ export class AppRealLogStorage extends AppLogStorage { query: { [field: string]: any; }, - { fields, ...options }: IAppLogStorageFindOptions, - ): Promise { - return this.db.findPaginated(query, { projection: fields, ...options }).cursor.toArray(); + options: IAppLogStorageFindOptions, + ) { + const { cursor, totalCount } = this.db.findPaginated(query, options); + + const [logs, total] = await Promise.all([cursor.toArray(), totalCount]); + + return { + logs, + total, + }; } async storeEntries(logEntry: ILoggerStorageEntry): Promise { diff --git a/apps/meteor/ee/tests/unit/server/apps/endpoints/lib/makeAppLogsQuery.spec.ts b/apps/meteor/ee/tests/unit/server/apps/endpoints/lib/makeAppLogsQuery.spec.ts new file mode 100644 index 0000000000000..2660f9422c997 --- /dev/null +++ b/apps/meteor/ee/tests/unit/server/apps/endpoints/lib/makeAppLogsQuery.spec.ts @@ -0,0 +1,122 @@ +import type { AppLogsProps } from '@rocket.chat/rest-typings'; +import { expect } from 'chai'; + +import { makeAppLogsQuery } from '../../../../../../server/apps/communication/endpoints/lib/makeAppLogsQuery'; + +describe('makeAppLogsQuery', () => { + const appId = 'test-app-id'; + + it('should return an empty object when no parameters are provided', () => { + const queryParams: AppLogsProps = {}; + const result = makeAppLogsQuery(queryParams); + expect(result).to.deep.equal({}); + }); + + it('should create a basic query with appId', () => { + const queryParams: AppLogsProps = { appId }; + const result = makeAppLogsQuery(queryParams); + + expect(result).to.deep.equal({ appId }); + }); + + it('should include log level filter when logLevel is provided', () => { + const queryParams: AppLogsProps = { logLevel: '1' }; + const result = makeAppLogsQuery(queryParams); + + expect(result).to.deep.equal({ + 'entries.severity': { $in: ['error', 'warn', 'info', 'log'] }, + }); + }); + + it('should include all log levels when logLevel is 2', () => { + const queryParams: AppLogsProps = { logLevel: '2' }; + const result = makeAppLogsQuery(queryParams); + + expect(result).to.deep.equal({ + 'entries.severity': { $in: ['error', 'warn', 'info', 'log', 'debug', 'success'] }, + }); + }); + + it('should include method filter when method is provided', () => { + const queryParams: AppLogsProps = { method: 'app:construct' }; + const result = makeAppLogsQuery(queryParams); + + expect(result).to.deep.equal({ + method: 'app:construct', + }); + }); + + it('should include start date filter when startDate is provided', () => { + const startDate = '2024-01-01T00:00:00.000Z'; + const queryParams: AppLogsProps = { startDate }; + const result = makeAppLogsQuery(queryParams); + + expect(result).to.deep.equal({ + _updatedAt: { + $gte: new Date(startDate), + }, + }); + }); + + it('should include end date filter when endDate is provided', () => { + const endDate = '2024-01-01T00:00:00.000Z'; + const queryParams: AppLogsProps = { endDate }; + const result = makeAppLogsQuery(queryParams); + + const expectedEndDate = new Date(endDate); + expectedEndDate.setDate(expectedEndDate.getDate() + 1); + + expect(result).to.deep.equal({ + _updatedAt: { + $lte: expectedEndDate, + }, + }); + }); + + it('should combine start and end date filters', () => { + const startDate = '2024-01-01T00:00:00.000Z'; + const endDate = '2024-01-02T00:00:00.000Z'; + const queryParams: AppLogsProps = { startDate, endDate }; + const result = makeAppLogsQuery(queryParams); + + const expectedEndDate = new Date(endDate); + expectedEndDate.setDate(expectedEndDate.getDate() + 1); + + expect(result).to.deep.equal({ + _updatedAt: { + $gte: new Date(startDate), + $lte: expectedEndDate, + }, + }); + }); + + it('should throw error when start date is after end date', () => { + const startDate = '2024-01-02T00:00:00.000Z'; + const endDate = '2024-01-01T00:00:00.000Z'; + const queryParams: AppLogsProps = { startDate, endDate }; + + expect(() => makeAppLogsQuery(queryParams)).to.throw('Invalid date range'); + }); + + it('should combine all filters when all parameters are provided', () => { + const queryParams: AppLogsProps = { + logLevel: '1', + method: 'app:construct', + startDate: '2024-01-01T00:00:00.000Z', + endDate: '2024-01-02T00:00:00.000Z', + }; + const result = makeAppLogsQuery(queryParams); + + const expectedEndDate = new Date(queryParams.endDate as string); + expectedEndDate.setDate(expectedEndDate.getDate() + 1); + + expect(result).to.deep.equal({ + 'entries.severity': { $in: ['error', 'warn', 'info', 'log'] }, + 'method': 'app:construct', + '_updatedAt': { + $gte: new Date(queryParams.startDate as string), + $lte: expectedEndDate, + }, + }); + }); +}); diff --git a/apps/meteor/tests/end-to-end/apps/app-logs.ts b/apps/meteor/tests/end-to-end/apps/app-logs.ts new file mode 100644 index 0000000000000..e21f4ce98aee1 --- /dev/null +++ b/apps/meteor/tests/end-to-end/apps/app-logs.ts @@ -0,0 +1,260 @@ +import type { ILoggerStorageEntry } from '@rocket.chat/apps-engine/server/logging'; +import type { App } from '@rocket.chat/core-typings'; +import { expect } from 'chai'; +import { after, before, describe, it } from 'mocha'; + +import { getCredentials, request, credentials } from '../../data/api-data'; +import { apps } from '../../data/apps/apps-data'; +import { installTestApp, cleanupApps } from '../../data/apps/helper'; +import { IS_EE } from '../../e2e/config/constants'; + +(IS_EE ? describe : describe.skip)('Apps - Logs', () => { + let app: App; + + before((done) => getCredentials(done)); + + before(async () => { + await cleanupApps(); + app = await installTestApp(); + }); + + after(() => cleanupApps()); + + it('should throw an error when trying to get logs for an invalid app', (done) => { + void request + .get(apps('/invalid-id/logs')) + .set(credentials) + .expect('Content-Type', 'application/json') + .expect(404) + .expect((res) => { + expect(res.body).to.have.a.property('success', false); + expect(res.body).to.not.have.a.property('logs'); + expect(res.body.error).to.be.equal('No App found by the id of: invalid-id'); + }) + .end(done); + }); + + it('should return app logs successfully', (done) => { + void request + .get(apps(`/${app.id}/logs`)) + .set(credentials) + .expect('Content-Type', 'application/json') + .expect(200) + .expect((res) => { + expect(res.body).to.have.a.property('success', true); + expect(res.body).to.have.a.property('logs').that.is.an('array').with.a.lengthOf.at.least(1).and.at.most(50); + expect(res.body).to.have.a.property('count').that.is.a('number'); + expect(res.body).to.have.a.property('total').that.is.a('number'); + expect(res.body).to.have.a.property('offset').that.is.a('number'); + }) + .end(done); + }); + + it('should return app logs with pagination', (done) => { + void request + .get(apps(`/${app.id}/logs`)) + .query({ count: 1, offset: 0 }) + .set(credentials) + .expect('Content-Type', 'application/json') + .expect(200) + .expect((res) => { + expect(res.body).to.have.a.property('success', true); + expect(res.body.logs).to.be.an('array').with.a.lengthOf(1); + expect(res.body.count).to.be.equal(1); + expect(res.body.offset).to.be.equal(0); + }) + .end(done); + }); + + it('should return app logs with sorting', (done) => { + void request + .get(apps(`/${app.id}/logs`)) + .query({ sort: JSON.stringify({ _updatedAt: -1 }) }) + .set(credentials) + .expect('Content-Type', 'application/json') + .expect(200) + .expect((res) => { + expect(res.body).to.have.a.property('success', true); + expect(res.body.logs).to.be.an('array').with.a.lengthOf.at.least(1).and.at.most(50); + }) + .end(done); + }); + + it('should return app logs filtered by logLevel', (done) => { + void request + .get(apps(`/${app.id}/logs`)) + .query({ logLevel: '2' }) + .set(credentials) + .expect('Content-Type', 'application/json') + .expect(200) + .expect((res) => { + expect(res.body).to.have.a.property('success', true); + expect(res.body.logs).to.be.an('array').with.a.lengthOf.at.least(1).and.at.most(50); + + res.body.logs.forEach((log: ILoggerStorageEntry) => { + const entry = log.entries.find((entry) => ['error', 'warn', 'info', 'log', 'debug', 'success'].includes(entry.severity)); + + expect(entry).to.exist; + }); + }) + .end(done); + }); + + it('should return app logs filtered by method', (done) => { + void request + .get(apps(`/${app.id}/logs`)) + .query({ method: 'app:construct' }) + .set(credentials) + .expect('Content-Type', 'application/json') + .expect(200) + .expect((res) => { + expect(res.body).to.have.a.property('success', true); + expect(res.body.logs).to.be.an('array').with.a.lengthOf.at.least(1).and.at.most(50); + + res.body.logs.forEach((log: ILoggerStorageEntry) => { + expect(log.method).to.equal('app:construct'); + }); + }) + .end(done); + }); + + it('should return app logs filtered by date range', (done) => { + const startDate = new Date(); + startDate.setDate(startDate.getDate() - 1); // 1 day ago + const endDate = new Date(); + + void request + .get(apps(`/${app.id}/logs`)) + .query({ + startDate: startDate.toISOString(), + endDate: endDate.toISOString(), + }) + .set(credentials) + .expect('Content-Type', 'application/json') + .expect(200) + .expect((res) => { + expect(res.body).to.have.a.property('success', true); + expect(res.body.logs).to.be.an('array').with.a.lengthOf.at.least(1).and.at.most(50); + + // Verify that all returned logs are within the date range + res.body.logs.forEach((log: ILoggerStorageEntry) => { + const logDate = new Date(log._createdAt); + + expect(logDate).to.be.above(startDate).and.below(endDate); + }); + }) + .end(done); + }); + + it('should return app logs with combined filters', (done) => { + const startDate = new Date(); + startDate.setDate(startDate.getDate() - 1); // 1 day ago + const endDate = new Date(); + + void request + .get(apps(`/${app.id}/logs`)) + .query({ + logLevel: '2', + method: 'app:construct', + startDate: startDate.toISOString(), + endDate: endDate.toISOString(), + }) + .set(credentials) + .expect('Content-Type', 'application/json') + .expect(200) + .expect((res) => { + expect(res.body).to.have.a.property('success', true); + expect(res.body.logs).to.be.an('array').with.a.lengthOf.at.least(1).and.at.most(50); + + // Verify that all returned logs match all filter criteria + res.body.logs.forEach((log: ILoggerStorageEntry) => { + expect(log.method).to.equal('app:construct'); + + const logDate = new Date(log._createdAt); + expect(logDate >= startDate && logDate <= endDate).to.be.true; + + const entry = log.entries.find((entry) => ['error', 'warn', 'info', 'log', 'debug', 'success'].includes(entry.severity)); + + expect(entry).to.exist; + }); + }) + .end(done); + }); + + it('should reject invalid logLevel value', (done) => { + void request + .get(apps(`/${app.id}/logs`)) + .query({ logLevel: 'debug' }) + .set(credentials) + .expect('Content-Type', 'application/json') + .expect(400) + .expect((res) => { + expect(res.body).to.have.a.property('success', false); + expect(res.body).to.have.a.property('error').that.is.not.empty; + }) + .end(done); + }); + + it('should reject invalid date format', (done) => { + void request + .get(apps(`/${app.id}/logs`)) + .query({ startDate: 'invalid-date' }) + .set(credentials) + .expect('Content-Type', 'application/json') + .expect(400) + .expect((res) => { + expect(res.body).to.have.a.property('success', false); + expect(res.body).to.have.a.property('error').that.is.not.empty; + }) + .end(done); + }); + + it('should reject invalid date range', (done) => { + const startDate = new Date(); + const endDate = new Date(); + endDate.setDate(endDate.getDate() - 1); // endDate before startDate + + void request + .get(apps(`/${app.id}/logs`)) + .query({ + startDate: startDate.toISOString(), + endDate: endDate.toISOString(), + }) + .set(credentials) + .expect('Content-Type', 'application/json') + .expect(400) + .expect((res) => { + expect(res.body).to.have.a.property('success', false); + expect(res.body).to.have.a.property('error').that.is.not.empty; + }) + .end(done); + }); + + it('should reject invalid additional properties', (done) => { + void request + .get(apps(`/${app.id}/logs`)) + .query({ invalidProperty: 'value' }) + .set(credentials) + .expect('Content-Type', 'application/json') + .expect(400) + .expect((res) => { + expect(res.body).to.have.a.property('success', false); + expect(res.body).to.have.a.property('error').that.is.not.empty; + }) + .end(done); + }); + + it('should reject appId query parameter', (done) => { + void request + .get(apps(`/${app.id}/logs`)) + .query({ appId: 'invalid-id' }) + .set(credentials) + .expect('Content-Type', 'application/json') + .expect(404) + .expect((res) => { + expect(res.body).to.have.a.property('success', false); + expect(res.body).to.have.a.property('error', 'Invalid query parameter "appId": invalid-id'); + }) + .end(done); + }); +}); diff --git a/packages/apps-engine/src/server/storage/AppLogStorage.ts b/packages/apps-engine/src/server/storage/AppLogStorage.ts index 1c5e4824f4e15..ac071b3b21da1 100644 --- a/packages/apps-engine/src/server/storage/AppLogStorage.ts +++ b/packages/apps-engine/src/server/storage/AppLogStorage.ts @@ -1,10 +1,10 @@ import type { ILoggerStorageEntry } from '../logging'; export interface IAppLogStorageFindOptions { - sort?: { [field: string]: number }; + sort?: Record; skip?: number; limit?: number; - fields?: { [field: string]: number }; + projection?: Record; } export abstract class AppLogStorage { @@ -14,7 +14,7 @@ export abstract class AppLogStorage { return this.engine; } - public abstract find(query: { [field: string]: any }, options?: IAppLogStorageFindOptions): Promise>; + public abstract find(query: { [field: string]: any }, options?: IAppLogStorageFindOptions): Promise<{ logs: ILoggerStorageEntry[]; total: number }>; public abstract storeEntries(logEntry: ILoggerStorageEntry): Promise; diff --git a/packages/apps-engine/tests/test-data/storage/logStorage.ts b/packages/apps-engine/tests/test-data/storage/logStorage.ts index 5ddb90e56753b..e12fa58465911 100644 --- a/packages/apps-engine/tests/test-data/storage/logStorage.ts +++ b/packages/apps-engine/tests/test-data/storage/logStorage.ts @@ -7,8 +7,8 @@ export class TestsAppLogStorage extends AppLogStorage { super('nothing'); } - public find(query: { [field: string]: any }, options?: IAppLogStorageFindOptions): Promise> { - return Promise.resolve([]); + public find(query: { [field: string]: any }, options?: IAppLogStorageFindOptions): Promise<{ logs: ILoggerStorageEntry[]; total: number }> { + return Promise.resolve({ logs: [], total: 0 }); } public storeEntries(logEntry: ILoggerStorageEntry): Promise { diff --git a/packages/models/src/models/AppLogsModel.ts b/packages/models/src/models/AppLogsModel.ts index 4be9cc9e4dcbc..5e370ad73f552 100644 --- a/packages/models/src/models/AppLogsModel.ts +++ b/packages/models/src/models/AppLogsModel.ts @@ -1,5 +1,5 @@ import type { IAppLogsModel } from '@rocket.chat/model-typings'; -import type { Db, DeleteResult, Filter } from 'mongodb'; +import type { Db, DeleteResult, Filter, IndexDescription } from 'mongodb'; import { BaseRaw } from './BaseRaw'; @@ -8,13 +8,34 @@ export class AppsLogsModel extends BaseRaw implements IAppLogsModel { super(db, 'apps_logs', undefined); } - protected modelIndexes() { + protected modelIndexes(): IndexDescription[] { return [ + // This index is used to expire logs after 30 days { key: { _updatedAt: 1, }, expireAfterSeconds: 60 * 60 * 24 * 30, + name: 'ttl_30_days', + }, + // Index for specific queries from the logs screen (most common) + { + key: { + 'appId': 1, + '_updatedAt': -1, + 'entries.severity': 1, + 'method': 1, + }, + name: 'appId_indexed_query', + }, + // Index for queries on general logs endpoint + { + key: { + '_updatedAt': -1, + 'entries.severity': 1, + 'method': 1, + }, + name: 'general_logs_index', }, ]; } diff --git a/packages/rest-typings/src/apps/appLogsProps.ts b/packages/rest-typings/src/apps/appLogsProps.ts new file mode 100644 index 0000000000000..6b1220fe8f528 --- /dev/null +++ b/packages/rest-typings/src/apps/appLogsProps.ts @@ -0,0 +1,27 @@ +import type { PaginatedRequest } from '../helpers/PaginatedRequest'; +import { ajv } from '../v1/Ajv'; + +export type AppLogsProps = PaginatedRequest<{ + appId?: string; + logLevel?: '0' | '1' | '2'; + method?: string; + startDate?: string; + endDate?: string; +}>; + +const AppLogsPropsSchema = { + type: 'object', + properties: { + appId: { type: 'string', nullable: true }, + logLevel: { type: 'string', enum: ['0', '1', '2'], nullable: true }, + method: { type: 'string', nullable: true }, + startDate: { type: 'string', format: 'date-time', nullable: true }, + endDate: { type: 'string', format: 'date-time', nullable: true }, + offset: { type: 'number', minimum: 0, nullable: true }, + count: { type: 'number', minimum: 0, nullable: true }, + sort: { type: 'string', nullable: true }, + }, + additionalProperties: false, +}; + +export const isAppLogsProps = ajv.compile(AppLogsPropsSchema); diff --git a/packages/rest-typings/src/apps/index.ts b/packages/rest-typings/src/apps/index.ts index d5f9de98d0ca8..5bcfec5ecddcc 100644 --- a/packages/rest-typings/src/apps/index.ts +++ b/packages/rest-typings/src/apps/index.ts @@ -16,6 +16,11 @@ import type { } from '@rocket.chat/core-typings'; import type * as UiKit from '@rocket.chat/ui-kit'; +import type { AppLogsProps } from './appLogsProps'; +import type { PaginatedResult } from '../helpers/PaginatedResult'; + +export * from './appLogsProps'; + export type AppsEndpoints = { '/apps/count': { GET: () => { totalMarketplaceEnabled: number; totalPrivateEnabled: number; maxMarketplaceApps: number; maxPrivateApps: number }; @@ -59,6 +64,12 @@ export type AppsEndpoints = { }; }; + '/apps/logs': { + GET: (params: AppLogsProps) => PaginatedResult<{ + logs: ILogItem[]; + }>; + }; + '/apps/public/:appId/get-sidebar-icon': { GET: (params: { icon: string }) => unknown; }; @@ -85,21 +96,14 @@ export type AppsEndpoints = { '/apps/:id/languages': { GET: () => { - languages: { - [key: string]: { - Params: string; - Description: string; - Setting_Name: string; - Setting_Description: string; - }; - }; + languages: { [language: string]: { [key: string]: string } }; }; }; '/apps/:id/logs': { - GET: () => { + GET: (params: Omit) => PaginatedResult<{ logs: ILogItem[]; - }; + }>; }; '/apps/:id/apis': { diff --git a/packages/rest-typings/src/index.ts b/packages/rest-typings/src/index.ts index 97bbbb024dffd..0c543526375f3 100644 --- a/packages/rest-typings/src/index.ts +++ b/packages/rest-typings/src/index.ts @@ -217,6 +217,7 @@ export type UrlParams = string extends T export type MethodOf = TPathPattern extends any ? keyof Endpoints[TPathPattern] : never; +export * from './apps'; export * from './v1/permissions'; export * from './v1/presence'; export * from './v1/roles';