u
This commit is contained in:
184
crashreport.md
Normal file
184
crashreport.md
Normal file
@@ -0,0 +1,184 @@
|
|||||||
|
# Crash Report Endpoint
|
||||||
|
|
||||||
|
Clients (POS apps running in a browser or Node.js) can submit crash / uncaught-error details to the sync server for later inspection. The server writes each report to a plain-text file under `logs/crash/`.
|
||||||
|
|
||||||
|
```
|
||||||
|
POST /v1/crashreport
|
||||||
|
```
|
||||||
|
|
||||||
|
Paths may also be called as `/api/v1/crashreport` — both resolve to the same handler.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Request
|
||||||
|
|
||||||
|
- **Method:** `POST`
|
||||||
|
- **Content-Type:** `application/json`
|
||||||
|
- **Body:** any JSON object you want to record. Keep it free-form; common fields are shown below.
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"app": "my-pos-app",
|
||||||
|
"version": "1.2.3",
|
||||||
|
"platform": "win32",
|
||||||
|
"userAgent": "Mozilla/5.0 ...",
|
||||||
|
"message": "Cannot read properties of undefined (reading 'price')",
|
||||||
|
"stack": "TypeError: Cannot read properties of undefined (reading 'price')\\n at ...",
|
||||||
|
"context": {
|
||||||
|
"currentView": "checkout",
|
||||||
|
"orderId": "abc-123"
|
||||||
|
},
|
||||||
|
"device": {
|
||||||
|
"id": "clerk-01",
|
||||||
|
"display": 1920
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Nothing is validated server-side: the body is logged as-is (`JSON.stringify(body, null, 2)`).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Responses
|
||||||
|
|
||||||
|
| Status | Body | Meaning |
|
||||||
|
|---|---|---|
|
||||||
|
| `200` | `{ "Message": "OK", "file": "<timestamp>-<ip>.txt" }` | Report saved |
|
||||||
|
| `400` | `{ "Message": "Invalid JSON body." }` | Body is not valid JSON |
|
||||||
|
|
||||||
|
Non-JSON or empty bodies are treated as follows:
|
||||||
|
|
||||||
|
- **Empty body** → saved as an empty object `{}`.
|
||||||
|
- **Invalid JSON** (e.g. a raw text dump) → rejected with `400`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Log file format
|
||||||
|
|
||||||
|
Each accepted report is written to:
|
||||||
|
|
||||||
|
```
|
||||||
|
logs/crash/<timestamp>-<ip>.txt
|
||||||
|
```
|
||||||
|
|
||||||
|
- `<timestamp>` — local server time, `YYYYMMDD-HHMMSS`.
|
||||||
|
- `<ip>` — the client's remote address (`::ffff:` IPv4 prefix is stripped; `:` is replaced with `_`).
|
||||||
|
|
||||||
|
Two reports from the same client within the same second overwrite each other. Include a unique id inside the body if you need to guarantee every report is preserved.
|
||||||
|
|
||||||
|
Example file:
|
||||||
|
|
||||||
|
```
|
||||||
|
timestamp: 2026-08-17T09:41:22.731Z
|
||||||
|
ip: ::ffff:127.0.0.1
|
||||||
|
user-agent: node-fetch/1.0 (+https://github.com/bitinn/node-fetch)
|
||||||
|
|
||||||
|
{
|
||||||
|
"app": "my-pos-app",
|
||||||
|
"message": "Cannot read properties of undefined",
|
||||||
|
"stack": "TypeError: ..."
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
The log directory `logs/crash/` is created automatically on first use.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Usage from Node.js
|
||||||
|
|
||||||
|
```js
|
||||||
|
// plain fetch (Node 18+)
|
||||||
|
const baseUrl = 'https://192.168.1.10:4443';
|
||||||
|
const AUTH_TOKEN = '<your pairing token>'; // optional if your server does not enforce it
|
||||||
|
|
||||||
|
fetch(`${baseUrl}/v1/crashreport`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
},
|
||||||
|
body: JSON.stringify({
|
||||||
|
app: 'my-pos-app',
|
||||||
|
version: '1.2.3',
|
||||||
|
message: error.message,
|
||||||
|
stack: error.stack,
|
||||||
|
}),
|
||||||
|
})
|
||||||
|
.then(async (res) => console.log(res.status, await res.json()))
|
||||||
|
.catch((err) => console.error('could not send crash report', err));
|
||||||
|
```
|
||||||
|
|
||||||
|
Because the server uses a self-signed certificate, set `rejectUnauthorized: false` when using `node-fetch` or Node's `https` module:
|
||||||
|
|
||||||
|
```js
|
||||||
|
import https from 'node:https';
|
||||||
|
import fetch from 'node-fetch';
|
||||||
|
|
||||||
|
const agent = new https.Agent({ rejectUnauthorized: false });
|
||||||
|
|
||||||
|
fetch('https://<host>:4443/v1/crashreport', {
|
||||||
|
method: 'POST',
|
||||||
|
agent,
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ app: 'my-pos-app', message: error.message, stack: error.stack }),
|
||||||
|
})
|
||||||
|
.then(async (res) => console.log(res.status, await res.json()));
|
||||||
|
```
|
||||||
|
|
||||||
|
Hook it into your global error handlers so nothing is lost:
|
||||||
|
|
||||||
|
```js
|
||||||
|
process.on('uncaughtException', (err) => {
|
||||||
|
void sendCrashReport(err);
|
||||||
|
// ...your own logging / shutdown
|
||||||
|
});
|
||||||
|
process.on('unhandledRejection', (reason) => {
|
||||||
|
void sendCrashReport(reason instanceof Error ? reason : new Error(String(reason)));
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Usage from the browser
|
||||||
|
|
||||||
|
Use `fetch` with `mode: 'no-cors'` **or** a normal `cors` request — the server sends `Access-Control-Allow-Origin: *`, so a plain `fetch` works from any origin.
|
||||||
|
|
||||||
|
```js
|
||||||
|
function sendCrashReport(payload) {
|
||||||
|
return fetch('https://<host>:4443/v1/crashreport', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify(payload),
|
||||||
|
})
|
||||||
|
.then(async (res) => {
|
||||||
|
const json = await res.json();
|
||||||
|
console.log(`crash report ${res.ok ? 'saved' : 'failed'}`, json);
|
||||||
|
return json;
|
||||||
|
})
|
||||||
|
.catch((err) => console.error('could not send crash report', err));
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Wire it into `window.onerror` and `unhandledrejection`:
|
||||||
|
|
||||||
|
```js
|
||||||
|
window.addEventListener('error', (event) => {
|
||||||
|
sendCrashReport({
|
||||||
|
app: 'my-pos-app',
|
||||||
|
version: appVersion,
|
||||||
|
message: event.message,
|
||||||
|
stack: event.error?.stack,
|
||||||
|
context: { url: location.href },
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
window.addEventListener('unhandledrejection', (event) => {
|
||||||
|
sendCrashReport({
|
||||||
|
app: 'my-pos-app',
|
||||||
|
version: appVersion,
|
||||||
|
message: event.reason?.message || String(event.reason),
|
||||||
|
stack: event.reason?.stack,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
> **Note:** if you use `mode: 'no-cors'` the browser will send the request but you will not be able to read the JSON response body — the report is still saved server-side.
|
||||||
65
src/endpoints/crashreport.js
Normal file
65
src/endpoints/crashreport.js
Normal file
@@ -0,0 +1,65 @@
|
|||||||
|
import fs from 'node:fs';
|
||||||
|
import nodePath from 'node:path';
|
||||||
|
import { sendJson } from '../http.js';
|
||||||
|
import { logger } from '../logger.js';
|
||||||
|
|
||||||
|
export const method = 'POST';
|
||||||
|
export const path = '/v1/crashreport';
|
||||||
|
|
||||||
|
const CRASH_LOG_DIR = process.env.CRASH_LOG_DIR || nodePath.join('logs', 'crash');
|
||||||
|
|
||||||
|
function sanitizeIp(ip) {
|
||||||
|
const value = String(ip || '0.0.0.0');
|
||||||
|
const withoutPrefix = value.replace(/^::ffff:/, '');
|
||||||
|
return withoutPrefix.replace(/[:]/g, '_');
|
||||||
|
}
|
||||||
|
|
||||||
|
function timestampPart() {
|
||||||
|
const d = new Date();
|
||||||
|
const pad = (n) => String(n).padStart(2, '0');
|
||||||
|
return (
|
||||||
|
`${d.getFullYear()}${pad(d.getMonth() + 1)}${pad(d.getDate())}` +
|
||||||
|
`-${pad(d.getHours())}${pad(d.getMinutes())}${pad(d.getSeconds())}`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseBody(buffer) {
|
||||||
|
if (!buffer || !buffer.length) {
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const parsed = JSON.parse(buffer.toString('utf8'));
|
||||||
|
return parsed && typeof parsed === 'object' ? parsed : {};
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function handle(req, res) {
|
||||||
|
const remoteAddress = req.socket?.remoteAddress ?? 'unknown';
|
||||||
|
const ip = sanitizeIp(remoteAddress);
|
||||||
|
const body = parseBody(req.rawBody);
|
||||||
|
|
||||||
|
if (body === null) {
|
||||||
|
return sendJson(res, 400, { Message: 'Invalid JSON body.' });
|
||||||
|
}
|
||||||
|
|
||||||
|
const fileName = `${timestampPart()}-${ip}.txt`;
|
||||||
|
const filePath = nodePath.join(CRASH_LOG_DIR, fileName);
|
||||||
|
|
||||||
|
fs.mkdirSync(CRASH_LOG_DIR, { recursive: true });
|
||||||
|
|
||||||
|
const lines = [
|
||||||
|
`timestamp: ${new Date().toISOString()}`,
|
||||||
|
`ip: ${remoteAddress}`,
|
||||||
|
`user-agent: ${req.headers['user-agent'] ?? ''}`,
|
||||||
|
'',
|
||||||
|
JSON.stringify(body, null, 2),
|
||||||
|
'',
|
||||||
|
];
|
||||||
|
|
||||||
|
fs.writeFileSync(filePath, lines.join('\n'));
|
||||||
|
logger.warn(`crash report saved to ${filePath}`);
|
||||||
|
|
||||||
|
return sendJson(res, 200, { Message: 'OK', file: fileName });
|
||||||
|
}
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
import * as category from './category.js';
|
import * as category from './category.js';
|
||||||
import * as cimage from './cimage.js';
|
import * as cimage from './cimage.js';
|
||||||
import * as client from './client.js';
|
import * as client from './client.js';
|
||||||
|
import * as crashreport from './crashreport.js';
|
||||||
import * as customer from './customer.js';
|
import * as customer from './customer.js';
|
||||||
import * as customergroup from './customergroup.js';
|
import * as customergroup from './customergroup.js';
|
||||||
import * as deletedEntity from './deleted-entity.js';
|
import * as deletedEntity from './deleted-entity.js';
|
||||||
@@ -12,4 +13,19 @@ import * as pimage from './pimage.js';
|
|||||||
import * as product from './product.js';
|
import * as product from './product.js';
|
||||||
import * as productcomposite from './productcomposite.js';
|
import * as productcomposite from './productcomposite.js';
|
||||||
|
|
||||||
export const endpoints = [client, newpin, init, category, product, productcomposite, deletedEntity, pimage, cimage, customergroup, customer, order, orderSearch];
|
export const endpoints = [
|
||||||
|
client,
|
||||||
|
newpin,
|
||||||
|
init,
|
||||||
|
category,
|
||||||
|
product,
|
||||||
|
productcomposite,
|
||||||
|
deletedEntity,
|
||||||
|
pimage,
|
||||||
|
cimage,
|
||||||
|
customergroup,
|
||||||
|
customer,
|
||||||
|
order,
|
||||||
|
orderSearch,
|
||||||
|
crashreport,
|
||||||
|
];
|
||||||
|
|||||||
Reference in New Issue
Block a user