From 1190fe317714f442afe3fc02f685d66dd20db821 Mon Sep 17 00:00:00 2001 From: Douglas Gubert Date: Mon, 9 Jun 2025 15:53:16 -0300 Subject: [PATCH 01/11] wip --- .../endpoints/appLogsExportHandler.ts | 132 ++++++++++++++++++ .../ee/server/apps/communication/rest.ts | 2 + .../src/apps/appLogsExportProps.ts | 24 ++++ packages/rest-typings/src/apps/index.ts | 6 + 4 files changed, 164 insertions(+) create mode 100644 apps/meteor/ee/server/apps/communication/endpoints/appLogsExportHandler.ts create mode 100644 packages/rest-typings/src/apps/appLogsExportProps.ts diff --git a/apps/meteor/ee/server/apps/communication/endpoints/appLogsExportHandler.ts b/apps/meteor/ee/server/apps/communication/endpoints/appLogsExportHandler.ts new file mode 100644 index 0000000000000..8c9db216d8fe9 --- /dev/null +++ b/apps/meteor/ee/server/apps/communication/endpoints/appLogsExportHandler.ts @@ -0,0 +1,132 @@ +import { isAppLogsExportProps } from '@rocket.chat/rest-typings'; +import { ajv } from '@rocket.chat/rest-typings/src/v1/Ajv'; + +import { getPaginationItems } from '../../../../../app/api/server/helpers/getPaginationItems'; +import type { AppsRestApi } from '../rest'; +import { makeAppLogsQuery } from './lib/makeAppLogsQuery'; + +export const registerAppLogsExportHandler = ({ api, _manager, _orch }: AppsRestApi) => + void api.get( + ':id/export-logs', + { + authRequired: true, + permissionsRequired: ['manage-apps'], + query: isAppLogsExportProps, + response: { + 200: ajv.compile({ + type: 'object', + properties: { + data: { + type: 'array', + items: { + type: 'object', + properties: { + id: { type: 'string' }, + appId: { type: 'string' }, + level: { type: 'string' }, + message: { type: 'string' }, + timestamp: { type: 'string' }, + data: { type: 'object' }, + }, + required: ['id', 'appId', 'level', 'message', 'timestamp', 'data'], + }, + }, + }, + required: ['data'], + }), + }, + }, + + async function () { + 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.urlParams.id) { + return api.notFound(`Invalid query parameter "appId": ${this.queryParams.appId}`); + } + + const { count } = await getPaginationItems(this.queryParams); + const { sort } = await this.parseJsonQuery(); + + const options = { + sort: sort || { _updatedAt: -1 }, + skip: 0, + limit: count || 0, // 0 means no limit, get all logs + }; + + let query: Record; + + try { + query = makeAppLogsQuery({ appId: this.urlParams.id, ...this.queryParams }); + } catch (error) { + return api.failure({ error: error instanceof Error ? error.message : 'Unknown error' }); + } + + const result = await _orch.getLogStorage().find(query, options); + + if (!result.logs || result.logs.length === 0) { + return api.failure({ error: 'No logs found for the specified criteria' }); + } + + let fileContent: Buffer; + let contentType: string; + let filename: string; + const timestamp = new Date().toISOString().replace(/[:.]/g, '-'); + + if (this.queryParams.type === 'json') { + const jsonData = { + appId: this.urlParams.id, + exportedAt: new Date().toISOString(), + totalLogs: result.logs.length, + logs: result.logs, + }; + fileContent = Buffer.from(JSON.stringify(jsonData, null, 2), 'utf8'); + contentType = 'application/json'; + filename = `app-logs-${this.urlParams.id}-${timestamp}.json`; + } else { + // plain_text format + const textLines = [ + `App Logs Export for App ID: ${this.urlParams.id}`, + `Exported at: ${new Date().toISOString()}`, + `Total logs: ${result.logs.length}`, + '', + '---', + '', + ]; + + result.logs.forEach((log, index) => { + textLines.push(`Log ${index + 1}:`); + textLines.push(` Date: ${log._updatedAt || 'N/A'}`); + textLines.push(` App ID: ${log.appId || 'N/A'}`); + textLines.push(` Method: ${log.method || 'N/A'}`); + + if (log.entries && Array.isArray(log.entries)) { + log.entries.forEach((entry, entryIndex) => { + textLines.push(` Entry ${entryIndex + 1}:`); + textLines.push(` Severity: ${entry.severity || 'N/A'}`); + textLines.push(` Caller: ${entry.caller || 'N/A'}`); + textLines.push(` Message: ${Array.isArray(entry.args) ? entry.args.join(' ') : entry.args || 'N/A'}`); + }); + } + + textLines.push(''); + textLines.push('---'); + textLines.push(''); + }); + + fileContent = Buffer.from(textLines.join('\n'), 'utf8'); + contentType = 'text/plain'; + filename = `app-logs-${this.urlParams.id}-${timestamp}.txt`; + } + + // Set response headers for file download + this.response.setHeader('Content-Type', contentType); + this.response.setHeader('Content-Disposition', `attachment; filename="${filename}"`); + this.response.setHeader('Content-Length', fileContent.length.toString()); + + return api.success({ data: { items: [] } }); + }, + ); diff --git a/apps/meteor/ee/server/apps/communication/rest.ts b/apps/meteor/ee/server/apps/communication/rest.ts index 76ef4bc3b7d8e..428b132c7abbd 100644 --- a/apps/meteor/ee/server/apps/communication/rest.ts +++ b/apps/meteor/ee/server/apps/communication/rest.ts @@ -13,6 +13,7 @@ import { ZodError } from 'zod'; import { registerActionButtonsHandler } from './endpoints/actionButtonsHandler'; import { registerAppGeneralLogsHandler } from './endpoints/appGeneralLogsHandler'; +import { registerAppLogsExportHandler } from './endpoints/appLogsExportHandler'; import { registerAppLogsHandler } from './endpoints/appLogsHandler'; import { registerAppsCountHandler } from './endpoints/appsCountHandler'; import { API } from '../../../../app/api/server'; @@ -108,6 +109,7 @@ export class AppsRestApi { registerAppsCountHandler(this); registerAppLogsHandler(this); + registerAppLogsExportHandler(this); registerAppGeneralLogsHandler(this); this.api.addRoute( diff --git a/packages/rest-typings/src/apps/appLogsExportProps.ts b/packages/rest-typings/src/apps/appLogsExportProps.ts new file mode 100644 index 0000000000000..903405d436705 --- /dev/null +++ b/packages/rest-typings/src/apps/appLogsExportProps.ts @@ -0,0 +1,24 @@ +import type { AppLogsProps } from './appLogsProps'; +import { ajv } from '../v1/Ajv'; + +export type AppLogsExportProps = Omit & { + type: 'json' | 'plain_text'; +}; + +const AppLogsExportPropsSchema = { + type: 'object', + properties: { + appId: { type: 'string' }, + 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 }, + type: { type: 'string', enum: ['json', 'plain_text'] }, + count: { type: 'number', minimum: 0, nullable: true }, + sort: { type: 'string', nullable: true }, + }, + required: ['appId', 'type'], + additionalProperties: false, +}; + +export const isAppLogsExportProps = ajv.compile(AppLogsExportPropsSchema); diff --git a/packages/rest-typings/src/apps/index.ts b/packages/rest-typings/src/apps/index.ts index 5bcfec5ecddcc..82c2fe311dd9b 100644 --- a/packages/rest-typings/src/apps/index.ts +++ b/packages/rest-typings/src/apps/index.ts @@ -16,9 +16,11 @@ import type { } from '@rocket.chat/core-typings'; import type * as UiKit from '@rocket.chat/ui-kit'; +import type { AppLogsExportProps } from './appLogsExportProps'; import type { AppLogsProps } from './appLogsProps'; import type { PaginatedResult } from '../helpers/PaginatedResult'; +export * from './appLogsExportProps'; export * from './appLogsProps'; export type AppsEndpoints = { @@ -106,6 +108,10 @@ export type AppsEndpoints = { }>; }; + '/apps/:id/export-logs': { + GET: (params: Omit) => Buffer; + }; + '/apps/:id/apis': { GET: () => { apis: IApiEndpointMetadata[]; From 29e9f2dee0bf25dd1ae0a46cbf9e38b842b4e4c7 Mon Sep 17 00:00:00 2001 From: Douglas Gubert Date: Tue, 10 Jun 2025 10:47:59 -0300 Subject: [PATCH 02/11] Separate find and findPaginated in the AppRealLogStorage --- .../communication/endpoints/appGeneralLogsHandler.ts | 2 +- .../apps/communication/endpoints/appLogsHandler.ts | 2 +- apps/meteor/ee/server/apps/storage/AppRealLogStorage.ts | 9 +++++++++ packages/apps-engine/src/server/storage/AppLogStorage.ts | 2 +- 4 files changed, 12 insertions(+), 3 deletions(-) diff --git a/apps/meteor/ee/server/apps/communication/endpoints/appGeneralLogsHandler.ts b/apps/meteor/ee/server/apps/communication/endpoints/appGeneralLogsHandler.ts index 58dcd0abe9226..e6db29256b6ce 100644 --- a/apps/meteor/ee/server/apps/communication/endpoints/appGeneralLogsHandler.ts +++ b/apps/meteor/ee/server/apps/communication/endpoints/appGeneralLogsHandler.ts @@ -27,7 +27,7 @@ export const registerAppGeneralLogsHandler = ({ api, _orch }: AppsRestApi) => return api.failure({ error: error instanceof Error ? error.message : 'Unknown error' }); } - const result = await _orch.getLogStorage().find(query, options); + const result = await _orch.getLogStorage().findPaginated(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 index df30b4a1b28c7..b1e0103c13fa6 100644 --- a/apps/meteor/ee/server/apps/communication/endpoints/appLogsHandler.ts +++ b/apps/meteor/ee/server/apps/communication/endpoints/appLogsHandler.ts @@ -37,7 +37,7 @@ export const registerAppLogsHandler = ({ api, _manager, _orch }: AppsRestApi) => return api.failure({ error: error instanceof Error ? error.message : 'Unknown error' }); } - const result = await _orch.getLogStorage().find(query, options); + const result = await _orch.getLogStorage().findPaginated(query, options); return api.success({ offset, logs: result.logs, count: result.logs.length, total: result.total }); }, diff --git a/apps/meteor/ee/server/apps/storage/AppRealLogStorage.ts b/apps/meteor/ee/server/apps/storage/AppRealLogStorage.ts index 87a009608e175..82fbf49a5ebc6 100644 --- a/apps/meteor/ee/server/apps/storage/AppRealLogStorage.ts +++ b/apps/meteor/ee/server/apps/storage/AppRealLogStorage.ts @@ -14,6 +14,15 @@ export class AppRealLogStorage extends AppLogStorage { [field: string]: any; }, options: IAppLogStorageFindOptions, + ) { + return this.db.find(query, options).toArray(); + } + + async findPaginated( + query: { + [field: string]: any; + }, + options: IAppLogStorageFindOptions, ) { const { cursor, totalCount } = this.db.findPaginated(query, options); diff --git a/packages/apps-engine/src/server/storage/AppLogStorage.ts b/packages/apps-engine/src/server/storage/AppLogStorage.ts index b7a8f355cbcd0..8a3487a5587be 100644 --- a/packages/apps-engine/src/server/storage/AppLogStorage.ts +++ b/packages/apps-engine/src/server/storage/AppLogStorage.ts @@ -14,7 +14,7 @@ export abstract class AppLogStorage { return this.engine; } - public abstract find( + public abstract findPaginated( query: { [field: string]: any }, options?: IAppLogStorageFindOptions, ): Promise<{ logs: ILoggerStorageEntry[]; total: number }>; From 180ae5d1d8410ab70948cce74e82c15209621589 Mon Sep 17 00:00:00 2001 From: Douglas Gubert Date: Wed, 11 Jun 2025 13:30:16 -0300 Subject: [PATCH 03/11] Export content generation logic --- .../endpoints/appLogsExportHandler.ts | 105 ++++++------------ apps/meteor/package.json | 1 + yarn.lock | 25 +++++ 3 files changed, 63 insertions(+), 68 deletions(-) diff --git a/apps/meteor/ee/server/apps/communication/endpoints/appLogsExportHandler.ts b/apps/meteor/ee/server/apps/communication/endpoints/appLogsExportHandler.ts index 8c9db216d8fe9..06bdcb87da159 100644 --- a/apps/meteor/ee/server/apps/communication/endpoints/appLogsExportHandler.ts +++ b/apps/meteor/ee/server/apps/communication/endpoints/appLogsExportHandler.ts @@ -1,39 +1,44 @@ import { isAppLogsExportProps } from '@rocket.chat/rest-typings'; import { ajv } from '@rocket.chat/rest-typings/src/v1/Ajv'; +import { json2csv } from 'json-2-csv'; import { getPaginationItems } from '../../../../../app/api/server/helpers/getPaginationItems'; import type { AppsRestApi } from '../rest'; import { makeAppLogsQuery } from './lib/makeAppLogsQuery'; +const isErrorResponse = ajv.compile({ + type: 'object', + properties: { + success: { + type: 'boolean', + enum: [false], + }, + error: { + type: 'string', + }, + }, +}); + export const registerAppLogsExportHandler = ({ api, _manager, _orch }: AppsRestApi) => void api.get( ':id/export-logs', { - authRequired: true, - permissionsRequired: ['manage-apps'], + // authRequired: true, + // permissionsRequired: ['manage-apps'], query: isAppLogsExportProps, response: { 200: ajv.compile({ type: 'object', properties: { - data: { - type: 'array', - items: { - type: 'object', - properties: { - id: { type: 'string' }, - appId: { type: 'string' }, - level: { type: 'string' }, - message: { type: 'string' }, - timestamp: { type: 'string' }, - data: { type: 'object' }, - }, - required: ['id', 'appId', 'level', 'message', 'timestamp', 'data'], - }, + body: { + type: 'string', + format: 'binary', + description: 'The content of the exported logs file, either in JSON or CSV format.', }, }, - required: ['data'], }), + 400: isErrorResponse, + 404: isErrorResponse, }, }, @@ -54,10 +59,10 @@ export const registerAppLogsExportHandler = ({ api, _manager, _orch }: AppsRestA const options = { sort: sort || { _updatedAt: -1 }, skip: 0, - limit: count || 0, // 0 means no limit, get all logs + limit: Math.min(count || 100, 2000), }; - let query: Record; + let query: ReturnType; try { query = makeAppLogsQuery({ appId: this.urlParams.id, ...this.queryParams }); @@ -67,66 +72,30 @@ export const registerAppLogsExportHandler = ({ api, _manager, _orch }: AppsRestA const result = await _orch.getLogStorage().find(query, options); - if (!result.logs || result.logs.length === 0) { + if (!result.length) { return api.failure({ error: 'No logs found for the specified criteria' }); } let fileContent: Buffer; - let contentType: string; let filename: string; const timestamp = new Date().toISOString().replace(/[:.]/g, '-'); if (this.queryParams.type === 'json') { - const jsonData = { - appId: this.urlParams.id, - exportedAt: new Date().toISOString(), - totalLogs: result.logs.length, - logs: result.logs, - }; - fileContent = Buffer.from(JSON.stringify(jsonData, null, 2), 'utf8'); - contentType = 'application/json'; + fileContent = Buffer.from(JSON.stringify(result, null, 2), 'utf8'); filename = `app-logs-${this.urlParams.id}-${timestamp}.json`; } else { - // plain_text format - const textLines = [ - `App Logs Export for App ID: ${this.urlParams.id}`, - `Exported at: ${new Date().toISOString()}`, - `Total logs: ${result.logs.length}`, - '', - '---', - '', - ]; - - result.logs.forEach((log, index) => { - textLines.push(`Log ${index + 1}:`); - textLines.push(` Date: ${log._updatedAt || 'N/A'}`); - textLines.push(` App ID: ${log.appId || 'N/A'}`); - textLines.push(` Method: ${log.method || 'N/A'}`); - - if (log.entries && Array.isArray(log.entries)) { - log.entries.forEach((entry, entryIndex) => { - textLines.push(` Entry ${entryIndex + 1}:`); - textLines.push(` Severity: ${entry.severity || 'N/A'}`); - textLines.push(` Caller: ${entry.caller || 'N/A'}`); - textLines.push(` Message: ${Array.isArray(entry.args) ? entry.args.join(' ') : entry.args || 'N/A'}`); - }); - } - - textLines.push(''); - textLines.push('---'); - textLines.push(''); - }); - - fileContent = Buffer.from(textLines.join('\n'), 'utf8'); - contentType = 'text/plain'; - filename = `app-logs-${this.urlParams.id}-${timestamp}.txt`; + fileContent = Buffer.from(json2csv(result, { expandNestedObjects: false }), 'utf8'); + filename = `app-logs-${this.urlParams.id}-${timestamp}.csv`; } - // Set response headers for file download - this.response.setHeader('Content-Type', contentType); - this.response.setHeader('Content-Disposition', `attachment; filename="${filename}"`); - this.response.setHeader('Content-Length', fileContent.length.toString()); - - return api.success({ data: { items: [] } }); + return { + body: fileContent, + statusCode: 200, + headers: { + 'Content-Type': 'text/plain', + 'Content-Disposition': `attachment; filename="${filename}"`, + 'Content-Length': fileContent.length.toString(), + }, + }; }, ); diff --git a/apps/meteor/package.json b/apps/meteor/package.json index da98c0b4ade94..b690caa22fdf9 100644 --- a/apps/meteor/package.json +++ b/apps/meteor/package.json @@ -365,6 +365,7 @@ "isolated-vm": "5.0.1", "jschardet": "^3.1.4", "jsdom": "^26.0.0", + "json-2-csv": "^5.5.9", "jsrsasign": "^11.1.0", "juice": "^8.1.0", "katex": "~0.16.21", diff --git a/yarn.lock b/yarn.lock index 362ad2a018a22..36a3deacbe151 100644 --- a/yarn.lock +++ b/yarn.lock @@ -9051,6 +9051,7 @@ __metadata: jschardet: "npm:^3.1.4" jsdom: "npm:^26.0.0" jsdom-global: "npm:^3.0.2" + json-2-csv: "npm:^5.5.9" jsrsasign: "npm:^11.1.0" juice: "npm:^8.1.0" katex: "npm:~0.16.21" @@ -18974,6 +18975,13 @@ __metadata: languageName: node linkType: hard +"deeks@npm:3.1.0": + version: 3.1.0 + resolution: "deeks@npm:3.1.0" + checksum: 10/4297893cb792bc207b1fdac6090094c8c666669227b385bc69d98ea751294614948288a39a38faa0a44d7045d1bf05eacd188eea7516a1a7d0fee9a7faa34e7d + languageName: node + linkType: hard + "deep-eql@npm:^4.1.3": version: 4.1.3 resolution: "deep-eql@npm:4.1.3" @@ -19334,6 +19342,13 @@ __metadata: languageName: node linkType: hard +"doc-path@npm:4.1.1": + version: 4.1.1 + resolution: "doc-path@npm:4.1.1" + checksum: 10/455dd7458d4fa9ec0662fc307c4c3a7112bd0d554b8a6e0e86a6c6554f125727b4f80af430c1038f5d471ed5586c95354260928a31b80e632519480d745af713 + languageName: node + linkType: hard + "docker-compose@npm:^0.24.8": version: 0.24.8 resolution: "docker-compose@npm:0.24.8" @@ -26118,6 +26133,16 @@ __metadata: languageName: node linkType: hard +"json-2-csv@npm:^5.5.9": + version: 5.5.9 + resolution: "json-2-csv@npm:5.5.9" + dependencies: + deeks: "npm:3.1.0" + doc-path: "npm:4.1.1" + checksum: 10/fa80a1a99e9c7e6844e6e13a48e3d0f11e6e98e90dc46e47fdc46b0fde2ecef324f8e13c68e11dbd0645f714c5089dc46e605a22f25c659294347a3426506947 + languageName: node + linkType: hard + "json-bigint@npm:^1.0.0": version: 1.0.0 resolution: "json-bigint@npm:1.0.0" From fac934e4fee549de2a4509b55d3f14123ed10dcf Mon Sep 17 00:00:00 2001 From: Douglas Gubert Date: Wed, 11 Jun 2025 13:30:40 -0300 Subject: [PATCH 04/11] Fix app logs export validation function --- packages/rest-typings/src/apps/appLogsExportProps.ts | 9 ++++----- 1 file changed, 4 insertions(+), 5 deletions(-) diff --git a/packages/rest-typings/src/apps/appLogsExportProps.ts b/packages/rest-typings/src/apps/appLogsExportProps.ts index 903405d436705..c99b40ac18026 100644 --- a/packages/rest-typings/src/apps/appLogsExportProps.ts +++ b/packages/rest-typings/src/apps/appLogsExportProps.ts @@ -2,22 +2,21 @@ import type { AppLogsProps } from './appLogsProps'; import { ajv } from '../v1/Ajv'; export type AppLogsExportProps = Omit & { - type: 'json' | 'plain_text'; + type: 'json' | 'csv'; }; const AppLogsExportPropsSchema = { type: 'object', properties: { - appId: { type: 'string' }, 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 }, - type: { type: 'string', enum: ['json', 'plain_text'] }, - count: { type: 'number', minimum: 0, nullable: true }, + type: { type: 'string', enum: ['json', 'csv'] }, + count: { type: 'number', minimum: 1, nullable: true }, sort: { type: 'string', nullable: true }, }, - required: ['appId', 'type'], + required: ['type'], additionalProperties: false, }; From 027e6c50e0619a7c05e93b9bd48effafb0948f4d Mon Sep 17 00:00:00 2001 From: Douglas Gubert Date: Wed, 11 Jun 2025 16:40:41 -0300 Subject: [PATCH 05/11] Refactor endpoint registration to allow for authentication via cookie --- .../endpoints/appLogsExportHandler.ts | 42 +++++++++++++++---- 1 file changed, 33 insertions(+), 9 deletions(-) diff --git a/apps/meteor/ee/server/apps/communication/endpoints/appLogsExportHandler.ts b/apps/meteor/ee/server/apps/communication/endpoints/appLogsExportHandler.ts index 06bdcb87da159..2a926599c7fd6 100644 --- a/apps/meteor/ee/server/apps/communication/endpoints/appLogsExportHandler.ts +++ b/apps/meteor/ee/server/apps/communication/endpoints/appLogsExportHandler.ts @@ -1,10 +1,13 @@ +import type { IUser } from '@rocket.chat/core-typings'; import { isAppLogsExportProps } from '@rocket.chat/rest-typings'; import { ajv } from '@rocket.chat/rest-typings/src/v1/Ajv'; +import { parse } from 'cookie'; import { json2csv } from 'json-2-csv'; import { getPaginationItems } from '../../../../../app/api/server/helpers/getPaginationItems'; import type { AppsRestApi } from '../rest'; import { makeAppLogsQuery } from './lib/makeAppLogsQuery'; +import { APIClass } from '../../../../../app/api/server/ApiClass'; const isErrorResponse = ajv.compile({ type: 'object', @@ -19,12 +22,33 @@ const isErrorResponse = ajv.compile({ }, }); -export const registerAppLogsExportHandler = ({ api, _manager, _orch }: AppsRestApi) => - void api.get( +class ExportHandlerAPI extends APIClass { + protected async authenticatedRoute(req: Request): Promise { + const { rc_uid, rc_token } = parse(req.headers.get('cookie') || ''); + + if (rc_uid) { + req.headers.set('x-user-id', rc_uid); + } + + if (rc_token) { + req.headers.set('x-auth-token', rc_token); + } + + return super.authenticatedRoute(req); + } +} + +const adhocApi = new ExportHandlerAPI({ + useDefaultAuth: false, + prettyJson: process.env.NODE_ENV !== 'development', +}); + +export const registerAppLogsExportHandler = ({ api, _manager, _orch }: AppsRestApi) => { + adhocApi.get( ':id/export-logs', { - // authRequired: true, - // permissionsRequired: ['manage-apps'], + authRequired: true, + permissionsRequired: ['manage-apps'], query: isAppLogsExportProps, response: { 200: ajv.compile({ @@ -49,10 +73,6 @@ export const registerAppLogsExportHandler = ({ api, _manager, _orch }: AppsRestA return api.notFound(`No App found by the id of: ${this.urlParams.id}`); } - if (this.queryParams.appId !== this.urlParams.id) { - return api.notFound(`Invalid query parameter "appId": ${this.queryParams.appId}`); - } - const { count } = await getPaginationItems(this.queryParams); const { sort } = await this.parseJsonQuery(); @@ -84,7 +104,7 @@ export const registerAppLogsExportHandler = ({ api, _manager, _orch }: AppsRestA fileContent = Buffer.from(JSON.stringify(result, null, 2), 'utf8'); filename = `app-logs-${this.urlParams.id}-${timestamp}.json`; } else { - fileContent = Buffer.from(json2csv(result, { expandNestedObjects: false }), 'utf8'); + fileContent = Buffer.from(json2csv(result, { expandArrayObjects: true }), 'utf8'); filename = `app-logs-${this.urlParams.id}-${timestamp}.csv`; } @@ -92,6 +112,7 @@ export const registerAppLogsExportHandler = ({ api, _manager, _orch }: AppsRestA body: fileContent, statusCode: 200, headers: { + // 'application/json' here creates problems down the line with the router 'Content-Type': 'text/plain', 'Content-Disposition': `attachment; filename="${filename}"`, 'Content-Length': fileContent.length.toString(), @@ -99,3 +120,6 @@ export const registerAppLogsExportHandler = ({ api, _manager, _orch }: AppsRestA }; }, ); + + api.router.use(adhocApi.router); +}; From 0510118df7cc26023b5111451b8518419dbb03a3 Mon Sep 17 00:00:00 2001 From: Douglas Gubert Date: Wed, 11 Jun 2025 20:49:20 -0300 Subject: [PATCH 06/11] Fix engine tests --- packages/apps-engine/tests/test-data/storage/logStorage.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/apps-engine/tests/test-data/storage/logStorage.ts b/packages/apps-engine/tests/test-data/storage/logStorage.ts index 898a8f6f2f0d2..5841d02385c44 100644 --- a/packages/apps-engine/tests/test-data/storage/logStorage.ts +++ b/packages/apps-engine/tests/test-data/storage/logStorage.ts @@ -7,7 +7,7 @@ export class TestsAppLogStorage extends AppLogStorage { super('nothing'); } - public find( + public findPaginated( query: { [field: string]: any }, options?: IAppLogStorageFindOptions, ): Promise<{ logs: ILoggerStorageEntry[]; total: number }> { From 3df393498b44741fd339e7d6d01661fb7177d8f5 Mon Sep 17 00:00:00 2001 From: Douglas Gubert Date: Wed, 11 Jun 2025 22:42:19 -0300 Subject: [PATCH 07/11] Add response validator to 401 --- .../server/apps/communication/endpoints/appLogsExportHandler.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/apps/meteor/ee/server/apps/communication/endpoints/appLogsExportHandler.ts b/apps/meteor/ee/server/apps/communication/endpoints/appLogsExportHandler.ts index 2a926599c7fd6..f9c1e9bcacbda 100644 --- a/apps/meteor/ee/server/apps/communication/endpoints/appLogsExportHandler.ts +++ b/apps/meteor/ee/server/apps/communication/endpoints/appLogsExportHandler.ts @@ -62,6 +62,7 @@ export const registerAppLogsExportHandler = ({ api, _manager, _orch }: AppsRestA }, }), 400: isErrorResponse, + 401: isErrorResponse, 404: isErrorResponse, }, }, From 3c45d0c74a486b0ea7eb06b77deb9a3e06725f43 Mon Sep 17 00:00:00 2001 From: Douglas Gubert Date: Wed, 11 Jun 2025 22:42:42 -0300 Subject: [PATCH 08/11] Add tests for endpoint --- .../tests/end-to-end/apps/app-logs-export.ts | 380 ++++++++++++++++++ 1 file changed, 380 insertions(+) create mode 100644 apps/meteor/tests/end-to-end/apps/app-logs-export.ts diff --git a/apps/meteor/tests/end-to-end/apps/app-logs-export.ts b/apps/meteor/tests/end-to-end/apps/app-logs-export.ts new file mode 100644 index 0000000000000..59ed69ff591b4 --- /dev/null +++ b/apps/meteor/tests/end-to-end/apps/app-logs-export.ts @@ -0,0 +1,380 @@ +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 Export', () => { + let app: App; + + before((done) => getCredentials(done)); + + before(async () => { + await cleanupApps(); + app = await installTestApp(); + }); + + after(() => cleanupApps()); + + it('should throw an error when trying to export logs for an invalid app', (done) => { + void request + .get(apps('/invalid-id/export-logs')) + .query({ type: 'json' }) + .set(credentials) + .expect(404) + .expect((res) => { + expect(res.body).to.have.a.property('success', false); + expect(res.body.error).to.be.equal('No App found by the id of: invalid-id'); + }) + .end(done); + }); + + it('should export app logs as JSON successfully', (done) => { + void request + .get(apps(`/${app.id}/export-logs`)) + .query({ type: 'json' }) + .set(credentials) + .expect('Content-Type', 'text/plain') + .expect(200) + .expect((res) => { + expect(res.headers).to.have.a.property('content-disposition'); + expect(res.headers['content-disposition']).to.include('attachment'); + expect(res.headers['content-disposition']).to.include(`app-logs-${app.id}`); + expect(res.headers['content-disposition']).to.include('.json'); + expect(res.headers).to.have.a.property('content-length'); + + // Verify the content is valid JSON + expect(() => JSON.parse(res.text)).to.not.throw(); + const logs = JSON.parse(res.text); + expect(logs).to.be.an('array'); + expect(logs[0]).to.have.a.property('_id'); + expect(logs[0]).to.have.a.property('appId', app.id); + }) + .end(done); + }); + + it('should export app logs as CSV successfully', (done) => { + void request + .get(apps(`/${app.id}/export-logs`)) + .query({ type: 'csv' }) + .set(credentials) + .expect('Content-Type', 'text/plain') + .expect(200) + .expect((res) => { + expect(res.headers).to.have.a.property('content-disposition'); + expect(res.headers['content-disposition']).to.include('attachment'); + expect(res.headers['content-disposition']).to.include(`app-logs-${app.id}`); + expect(res.headers['content-disposition']).to.include('.csv'); + expect(res.headers).to.have.a.property('content-length'); + + // Verify the content is valid CSV (should have headers and at least one row) + const lines = res.text.split('\n'); + expect(lines.length).to.be.at.least(1); // At least header row + if (lines.length > 1 && lines[0]) { + // Check that headers contain expected fields + expect(lines[0]).to.include('_id'); + expect(lines[0]).to.include('appId'); + } + }) + .end(done); + }); + + it('should throw error when type is not specified', (done) => { + void request + .get(apps(`/${app.id}/export-logs`)) + .set(credentials) + .expect(400) + .end(done); + }); + + it('should export app logs with pagination parameters', (done) => { + void request + .get(apps(`/${app.id}/export-logs`)) + .query({ count: 5, type: 'json' }) + .set(credentials) + .expect('Content-Type', 'text/plain') + .expect(200) + .expect((res) => { + const logs = JSON.parse(res.text); + expect(logs).to.be.an('array'); + expect(logs.length).to.be.at.most(5); + }) + .end(done); + }); + + it('should export app logs with sorting parameters', (done) => { + void request + .get(apps(`/${app.id}/export-logs`)) + .query({ + sort: JSON.stringify({ _updatedAt: 1 }), + type: 'json', + }) + .set(credentials) + .expect('Content-Type', 'text/plain') + .expect(200) + .expect((res) => { + const logs = JSON.parse(res.text); + expect(logs).to.be.an('array'); + // Verify sorting by checking if timestamps are in ascending order + if (logs.length > 1) { + for (let i = 1; i < logs.length; i++) { + const prevTime = new Date(logs[i - 1]._updatedAt).getTime(); + const currTime = new Date(logs[i]._updatedAt).getTime(); + expect(currTime).to.be.at.least(prevTime); + } + } + }) + .end(done); + }); + + it('should export app logs filtered by logLevel', (done) => { + void request + .get(apps(`/${app.id}/export-logs`)) + .query({ logLevel: '2', type: 'json' }) + .set(credentials) + .expect('Content-Type', 'text/plain') + .expect(200) + .expect((res) => { + const logs = JSON.parse(res.text); + expect(logs).to.be.an('array'); + + 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 export app logs filtered by method', (done) => { + void request + .get(apps(`/${app.id}/export-logs`)) + .query({ method: 'app:construct', type: 'json' }) + .set(credentials) + .expect('Content-Type', 'text/plain') + .expect(200) + .expect((res) => { + const logs = JSON.parse(res.text); + expect(logs).to.be.an('array'); + + logs.forEach((log: ILoggerStorageEntry) => { + expect(log.method).to.equal('app:construct'); + }); + }) + .end(done); + }); + + it('should export 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}/export-logs`)) + .query({ + startDate: startDate.toISOString(), + endDate: endDate.toISOString(), + type: 'json', + }) + .set(credentials) + .expect('Content-Type', 'text/plain') + .expect(200) + .expect((res) => { + const logs = JSON.parse(res.text); + expect(logs).to.be.an('array'); + + // Verify that all returned logs are within the date range + logs.forEach((log: ILoggerStorageEntry) => { + const logDate = new Date(log._createdAt); + expect(logDate).to.be.above(startDate).and.below(endDate); + }); + }) + .end(done); + }); + + it('should export 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}/export-logs`)) + .query({ + logLevel: '2', + method: 'app:construct', + startDate: startDate.toISOString(), + endDate: endDate.toISOString(), + type: 'json', + count: 10, + }) + .set(credentials) + .expect('Content-Type', 'text/plain') + .expect(200) + .expect((res) => { + const logs = JSON.parse(res.text); + expect(logs).to.be.an('array'); + expect(logs.length).to.be.at.most(10); + + // Verify that all returned logs match all filter criteria + 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 return an error when no logs are found for the specified criteria', (done) => { + const futureDate = new Date(); + futureDate.setFullYear(futureDate.getFullYear() + 1); + + void request + .get(apps(`/${app.id}/export-logs`)) + .query({ + startDate: futureDate.toISOString(), + type: 'json', + }) + .set(credentials) + .expect(400) + .expect((res) => { + expect(res.body).to.have.a.property('success', false); + expect(res.body.error).to.be.equal('No logs found for the specified criteria'); + }) + .end(done); + }); + + it('should respect the maximum limit of 2000 logs', (done) => { + void request + .get(apps(`/${app.id}/export-logs`)) + .query({ count: 5000, type: 'json' }) + .set(credentials) + .expect('Content-Type', 'text/plain') + .expect(200) + .expect((res) => { + const logs = JSON.parse(res.text); + expect(logs).to.be.an('array'); + expect(logs.length).to.be.at.most(2000); + }) + .end(done); + }); + + it('should reject invalid logLevel value', (done) => { + void request + .get(apps(`/${app.id}/export-logs`)) + .query({ logLevel: 'debug' }) + .set(credentials) + .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}/export-logs`)) + .query({ startDate: 'invalid-date' }) + .set(credentials) + .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}/export-logs`)) + .query({ + startDate: startDate.toISOString(), + endDate: endDate.toISOString(), + }) + .set(credentials) + .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}/export-logs`)) + .query({ invalidProperty: 'value' }) + .set(credentials) + .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 handle authentication requirement', (done) => { + void request + .get(apps(`/${app.id}/export-logs`)) + .query({ type: 'json' }) + .expect(401) + .end(done); + }); + + it('should handle authentication via cookie', (done) => { + void request + .get(apps(`/${app.id}/export-logs`)) + .query({ type: 'json' }) + .set({ cookie: `rc_uid=${credentials['X-User-Id']}; rc_token=${credentials['X-Auth-Token']}` }) + .expect(200) + .end(done); + }); + + it('should include proper filename with timestamp in Content-Disposition header', (done) => { + void request + .get(apps(`/${app.id}/export-logs`)) + .query({ type: 'json' }) + .set(credentials) + .expect(200) + .expect((res) => { + const contentDisposition = res.headers['content-disposition']; + expect(contentDisposition).to.include('attachment'); + expect(contentDisposition).to.include(`app-logs-${app.id}`); + expect(contentDisposition).to.include('.json'); + + // Check that timestamp is included (format: YYYY-MM-DDTHH-MM-SS) + const timestampRegex = /\d{4}-\d{2}-\d{2}T\d{2}-\d{2}-\d{2}/; + expect(contentDisposition).to.match(timestampRegex); + }) + .end(done); + }); + + it('should include Content-Length header', (done) => { + void request + .get(apps(`/${app.id}/export-logs`)) + .query({ type: 'json' }) + .set(credentials) + .expect(200) + .expect((res) => { + expect(res.headers).to.have.a.property('content-length'); + const contentLength = parseInt(res.headers['content-length'], 10); + expect(contentLength).to.be.a('number'); + expect(contentLength).to.be.greaterThan(0); + expect(contentLength).to.equal(Buffer.byteLength(res.text, 'utf8')); + }) + .end(done); + }); +}); From fc4be161176fac1d2d9a2e22a044bb1ab0245040 Mon Sep 17 00:00:00 2001 From: Douglas Gubert Date: Thu, 12 Jun 2025 10:33:48 -0300 Subject: [PATCH 09/11] Add changeset --- .changeset/serious-apricots-compare.md | 7 +++++++ 1 file changed, 7 insertions(+) create mode 100644 .changeset/serious-apricots-compare.md diff --git a/.changeset/serious-apricots-compare.md b/.changeset/serious-apricots-compare.md new file mode 100644 index 0000000000000..691d1692e58dc --- /dev/null +++ b/.changeset/serious-apricots-compare.md @@ -0,0 +1,7 @@ +--- +'@rocket.chat/rest-typings': minor +'@rocket.chat/apps-engine': minor +'@rocket.chat/meteor': minor +--- + +Added endpoint to export apps logs as files From 17671c8ec398344d11211a03ea8f0197df8c52a7 Mon Sep 17 00:00:00 2001 From: Douglas Gubert Date: Mon, 16 Jun 2025 15:17:50 -0300 Subject: [PATCH 10/11] Improve endpoint typing --- packages/rest-typings/src/apps/appLogsExportProps.ts | 2 +- packages/rest-typings/src/apps/index.ts | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/rest-typings/src/apps/appLogsExportProps.ts b/packages/rest-typings/src/apps/appLogsExportProps.ts index c99b40ac18026..9c1ecd6c9a33e 100644 --- a/packages/rest-typings/src/apps/appLogsExportProps.ts +++ b/packages/rest-typings/src/apps/appLogsExportProps.ts @@ -1,7 +1,7 @@ import type { AppLogsProps } from './appLogsProps'; import { ajv } from '../v1/Ajv'; -export type AppLogsExportProps = Omit & { +export type AppLogsExportProps = Omit & { type: 'json' | 'csv'; }; diff --git a/packages/rest-typings/src/apps/index.ts b/packages/rest-typings/src/apps/index.ts index 82c2fe311dd9b..d6000eae61e91 100644 --- a/packages/rest-typings/src/apps/index.ts +++ b/packages/rest-typings/src/apps/index.ts @@ -109,7 +109,7 @@ export type AppsEndpoints = { }; '/apps/:id/export-logs': { - GET: (params: Omit) => Buffer; + GET: (params: AppLogsExportProps) => Buffer; }; '/apps/:id/apis': { From e23781d2d3a44a4da33849ce65908eb75499c5d0 Mon Sep 17 00:00:00 2001 From: Douglas Gubert Date: Fri, 4 Jul 2025 10:15:56 -0300 Subject: [PATCH 11/11] Update .changeset/serious-apricots-compare.md --- .changeset/serious-apricots-compare.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.changeset/serious-apricots-compare.md b/.changeset/serious-apricots-compare.md index 691d1692e58dc..103fa6644a6a2 100644 --- a/.changeset/serious-apricots-compare.md +++ b/.changeset/serious-apricots-compare.md @@ -4,4 +4,4 @@ '@rocket.chat/meteor': minor --- -Added endpoint to export apps logs as files +Adds an endpoint to export apps logs as files