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

Add ability to get bug report context in one click. #14525

Closed
wants to merge 2 commits into from
Closed
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
3 changes: 2 additions & 1 deletion .eslintrc.json
Original file line number Diff line number Diff line change
Expand Up @@ -39,7 +39,8 @@
"plugins": ["@typescript-eslint"],
"extends": ["eslint:recommended"],
"globals": {
"NodeJS": true
"NodeJS": true,
"BufferEncoding": "readonly"
},
"rules": {
"no-unused-vars": "off",
Expand Down
2 changes: 1 addition & 1 deletion packages/backend-core/src/logging/index.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
export * as correlation from "./correlation/correlation"
export { logger } from "./pino/logger"
export { logger, tail } from "./pino/logger"
export * from "./alerts"
export * as system from "./system"
52 changes: 39 additions & 13 deletions packages/backend-core/src/logging/pino/logger.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import pino, { LoggerOptions } from "pino"
import pino, { LoggerOptions, DestinationStream } from "pino"
import pinoPretty from "pino-pretty"

import { IdentityType } from "@budibase/types"
Expand All @@ -22,11 +22,41 @@ function isMessage(obj: any) {
return typeof obj === "string"
}

// LOGGER
class CircularByteBuffer implements DestinationStream {
private buffer: Buffer
private head: number = 0
private tail: number = 0
private capacity: number

constructor(capacity: number) {
this.capacity = capacity
this.buffer = Buffer.alloc(capacity)
}

write(data: string): void {
const buffer = Buffer.from(data)
for (let i = 0; i < buffer.length; i++) {
this.buffer[this.tail] = buffer[i]
this.tail = (this.tail + 1) % this.capacity
}
}

readAll(encoding: BufferEncoding = "utf8"): string {
const data = Buffer.alloc(this.buffer.length)
let index = 0
while (index < this.buffer.length) {
data[index] = this.buffer[this.head]
this.head = (this.head + 1) % this.capacity
index++
}
return data.toString(encoding)
}
}

let logTail: CircularByteBuffer | undefined
let pinoInstance: pino.Logger | undefined
if (!env.DISABLE_PINO_LOGGER) {
const level = env.LOG_LEVEL
const level = env.LOG_LEVEL as pino.Level
const pinoOptions: LoggerOptions = {
level,
formatters: {
Expand All @@ -49,21 +79,16 @@ if (!env.DISABLE_PINO_LOGGER) {
}

const destinations: pino.StreamEntry[] = []

destinations.push(
env.isDev()
? {
stream: pinoPretty({ singleLine: true }),
level: level as pino.Level,
}
: { stream: process.stdout, level: level as pino.Level }
? { stream: pinoPretty({ singleLine: true }), level }
: { stream: process.stdout, level }
)

if (env.SELF_HOSTED) {
destinations.push({
stream: localFileDestination(),
level: level as pino.Level,
})
logTail = new CircularByteBuffer(1024 * 1024)
destinations.push({ stream: logTail, level })
destinations.push({ stream: localFileDestination(), level })
}

pinoInstance = destinations.length
Expand Down Expand Up @@ -237,3 +262,4 @@ if (!env.DISABLE_PINO_LOGGER) {
}

export const logger = pinoInstance
export const tail = logTail
2 changes: 2 additions & 0 deletions packages/builder/src/components/common/FontAwesomeIcon.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@
faCircleCheck,
faGear,
faRectangleList,
faBug,
} from "@fortawesome/free-solid-svg-icons"
import { faGithub, faDiscord } from "@fortawesome/free-brands-svg-icons"

Expand All @@ -39,6 +40,7 @@
faChevronLeft,
faCircleInfo,
faRectangleList,
faBug,

// -- Required for easyMDE use in the builder.
faBold,
Expand Down
20 changes: 20 additions & 0 deletions packages/builder/src/components/common/HelpMenu.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -4,12 +4,25 @@
import { licensing } from "stores/portal"
import { isPremiumOrAbove } from "helpers/planTitle"
import { ChangelogURL } from "constants"
import { API } from "api"

$: premiumOrAboveLicense = isPremiumOrAbove($licensing?.license?.plan?.type)

let show
let hide
let popoverAnchor

async function downloadBugReport() {
const resp = await API.fetchBugReportFile()
const url = window.URL.createObjectURL(resp)
const a = document.createElement("a")
a.style.display = "none"
a.href = url
a.download = "bug-report.zip"
document.body.appendChild(a)
a.click()
window.URL.revokeObjectURL(url)
}
</script>

<div bind:this={popoverAnchor} class="help">
Expand Down Expand Up @@ -61,6 +74,13 @@
<Body size="S">Budibase University</Body>
</a>
<div class="divider" />
<a role="button" on:click={downloadBugReport}>

Check failure on line 77 in packages/builder/src/components/common/HelpMenu.svelte

View workflow job for this annotation

GitHub Actions / lint

A11y: Elements with the 'button' interactive role must have a tabindex value.(a11y-interactive-supports-focus)

Check failure on line 77 in packages/builder/src/components/common/HelpMenu.svelte

View workflow job for this annotation

GitHub Actions / lint

A11y: visible, non-interactive elements with an on:click event must be accompanied by a keyboard event handler. Consider whether an interactive element such as <button type="button"> or <a> might be more appropriate. See https://svelte.dev/docs/accessibility-warnings#a11y-click-events-have-key-events for more details.(a11y-click-events-have-key-events)

Check failure on line 77 in packages/builder/src/components/common/HelpMenu.svelte

View workflow job for this annotation

GitHub Actions / lint

A11y: <a> element should have an href attribute(a11y-missing-attribute)
<div class="icon">
<FontAwesomeIcon name="fa-solid fa-bug" />
</div>
<Body size="S">Download bug report</Body>
</a>
<div class="divider" />
<a
href={premiumOrAboveLicense
? "mailto:[email protected]"
Expand Down
18 changes: 18 additions & 0 deletions packages/frontend-core/src/api/app.js
Original file line number Diff line number Diff line change
Expand Up @@ -162,6 +162,24 @@ export const buildAppEndpoints = API => ({
})
},

/**
* Gets the bug report file.
*/
fetchBugReportFile: async body => {
if (!body) {
body = {}
}
return await API.post({
url: `/api/debug/bug-report`,
parseResponse: response => response.blob(),
body: {
clientApiCalls: API.getLogs(),
browserUrl: window.location.href,
...body,
},
})
},

/**
* Syncs an app with the production database.
* @param appId the ID of the app to sync
Expand Down
41 changes: 40 additions & 1 deletion packages/frontend-core/src/api/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -82,6 +82,14 @@ export const createAPIClient = config => {
...config,
}
let cache = {}
let logs = []

const addLogEntry = entry => {
logs.push(entry)
while (logs.length > 20) {
logs.shift()
}
}

// Generates an error object from an API response
const makeErrorFromResponse = async (
Expand Down Expand Up @@ -161,6 +169,17 @@ export const createAPIClient = config => {
}
}

let logEntry = {
time: new Date().toISOString(),
request: {
method,
url,
body: requestBody,
},
response: null,
error: null,
}

// Make request
let response
try {
Expand All @@ -171,24 +190,41 @@ export const createAPIClient = config => {
credentials: "same-origin",
})
} catch (error) {
logEntry.error = {
message: error.message,
}
addLogEntry(logEntry)
delete cache[url]
throw makeError("Failed to send request", { url, method })
}

logEntry.response = {
status: response.status,
headers: response.headers,
}

// Handle response
if (response.status >= 200 && response.status < 400) {
handleMigrations(response)
try {
if (parseResponse) {
return await parseResponse(response)
} else {
return await response.json()
const json = await response.json()
logEntry.response.body = json
return json
}
} catch (error) {
logEntry.error = {
message: error.message,
}
delete cache[url]
return null
} finally {
addLogEntry(logEntry)
}
} else {
addLogEntry(logEntry)
delete cache[url]
throw await makeErrorFromResponse(response, method, suppressErrors)
}
Expand Down Expand Up @@ -263,6 +299,9 @@ export const createAPIClient = config => {
config?.attachHeaders(headers)
return headers?.[Header.APP_ID]
},
getLogs() {
return logs
},
}

// Attach all endpoints
Expand Down
85 changes: 82 additions & 3 deletions packages/server/src/api/controllers/debug.ts
Original file line number Diff line number Diff line change
@@ -1,15 +1,28 @@
import os from "os"
import process from "process"
import { env } from "@budibase/backend-core"
import { GetDiagnosticsResponse, UserCtx } from "@budibase/types"
import { env, logging } from "@budibase/backend-core"
import {
BugReportRequest,
GetDiagnosticsResponse,
UserCtx,
} from "@budibase/types"
import { createTempFolder } from "src/utilities/fileSystem"
import fs from "fs"
import { basename, join } from "path"
import archiver from "archiver"
import { csvToJson } from "./table"

Check failure on line 13 in packages/server/src/api/controllers/debug.ts

View workflow job for this annotation

GitHub Actions / lint

'csvToJson' is defined but never used. Allowed unused vars must match /^_/u

export async function systemDebugInfo(
ctx: UserCtx<void, GetDiagnosticsResponse>
) {
ctx.body = getDiagnostics()
}

function getDiagnostics(): GetDiagnosticsResponse {
const { days, hours, minutes } = secondsToHMS(os.uptime())
const totalMemory = convertBytes(os.totalmem())

ctx.body = {
return {
budibaseVersion: env.VERSION,
hosting: env.DEPLOYMENT_ENVIRONMENT,
nodeVersion: process.version,
Expand Down Expand Up @@ -46,3 +59,69 @@

return { gb, mb, kb }
}

class ZipBuilder {
static inTmpDir(name: string): ZipBuilder {
return new ZipBuilder(join(createTempFolder(), name))
}

private constructor(private dir: string) {
fs.mkdirSync(dir, { recursive: true })
}

binary(path: string, content: Buffer) {
const fd = fs.openSync(join(this.dir, path), "w")
try {
fs.writeFileSync(fd, content)
} finally {
fs.closeSync(fd)
}
}

text(path: string, content: string) {
this.binary(path, Buffer.from(content))
}

json(path: string, content: Record<string, any>) {
this.text(path, JSON.stringify(content, null, 2))
}

async build(): Promise<string> {
const archive = archiver("zip", { zlib: { level: 9 } })
const stream = fs.createWriteStream(`${this.dir}.zip`)

return new Promise((resolve, reject) => {
archive
.directory(this.dir, basename(this.dir))
.on("error", err => reject(err))
.pipe(stream)

stream.on("close", () => resolve(`${this.dir}.zip`))
archive.finalize()
})
}
}

export async function bugReport(ctx: UserCtx<BugReportRequest>) {
const { browserUrl, clientApiCalls } = ctx.request.body

const zip = ZipBuilder.inTmpDir("bug-report")

if (logging.tail !== undefined) {
zip.text("server.log", logging.tail!.readAll())
}

zip.json("user.json", ctx.user)
zip.json("diagnostics.json", getDiagnostics())

if (clientApiCalls) {
zip.json("client-api-calls.json", clientApiCalls)
}

zip.json("meta.json", { browserUrl })

const path = await zip.build()

ctx.attachment("bug-report.zip")
ctx.body = fs.createReadStream(path)
}
6 changes: 6 additions & 0 deletions packages/server/src/api/routes/debug.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,4 +11,10 @@ router.get(
controller.systemDebugInfo
)

router.post(
"/api/debug/bug-report",
authorized(permissions.BUILDER),
controller.bugReport
)

export default router
Loading
Loading