dsh-local-telemetry 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +82 -0
- package/DSH-TELEMETRY-/345/274/200/345/217/221/350/256/241/345/210/222.md +954 -0
- package/LICENSE +21 -0
- package/PUBLISHING.md +42 -0
- package/README.md +156 -0
- package/bin/telemetry.mjs +295 -0
- package/cordis.patch.yml +11 -0
- package/docs/configuration.md +148 -0
- package/docs/schema.md +165 -0
- package/examples/prices.json +16 -0
- package/examples/telemetry.json +17 -0
- package/package.json +62 -0
- package/plugin/index.js +82 -0
- package/skills/telemetry-runbook/SKILL.md +88 -0
- package/src/adapter.mjs +79 -0
- package/src/aggregate.mjs +677 -0
- package/src/config.mjs +199 -0
- package/src/cost.mjs +118 -0
- package/src/index.mjs +20 -0
- package/src/privacy.mjs +208 -0
- package/src/recorder.mjs +215 -0
- package/src/report.mjs +220 -0
- package/src/sampling.mjs +50 -0
- package/src/schema.mjs +246 -0
- package/src/server.mjs +163 -0
- package/src/sink-jsonl.mjs +368 -0
- package/src/sink-sqlite.mjs +382 -0
- package/src/store.mjs +346 -0
- package/web/app.js +337 -0
- package/web/index.html +87 -0
- package/web/style.css +158 -0
package/src/schema.mjs
ADDED
|
@@ -0,0 +1,246 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* dsh-local-telemetry — 事件契约(schema version 1.0)。
|
|
3
|
+
*
|
|
4
|
+
* 固定 12 种生命周期事件名(计划 §2);未知指标一律为 null / 缺省,
|
|
5
|
+
* 不允许用 0 伪造(计划 §3.2)。本模块是唯一的 schema 权威:
|
|
6
|
+
* 录制器、sink、store、聚合器都从这里取常量与校验逻辑。
|
|
7
|
+
*/
|
|
8
|
+
import { randomBytes } from "node:crypto";
|
|
9
|
+
|
|
10
|
+
export const SCHEMA_VERSION = "1.0";
|
|
11
|
+
export const SUPPORTED_SCHEMA_MAJOR = 1;
|
|
12
|
+
|
|
13
|
+
/** 计划 §2 目标事件全集。失败/超时通过 completed 事件的 result.status 表达,不新增事件名。 */
|
|
14
|
+
export const EVENT_NAMES = Object.freeze([
|
|
15
|
+
"request.started",
|
|
16
|
+
"request.context",
|
|
17
|
+
"model.requested",
|
|
18
|
+
"model.first_token",
|
|
19
|
+
"model.completed",
|
|
20
|
+
"model.failed",
|
|
21
|
+
"tool.started",
|
|
22
|
+
"tool.completed",
|
|
23
|
+
"plugin.started",
|
|
24
|
+
"plugin.completed",
|
|
25
|
+
"request.completed",
|
|
26
|
+
"request.cancelled",
|
|
27
|
+
]);
|
|
28
|
+
|
|
29
|
+
/** 终止型事件:携带 span 结束语义,录制器据此闭合 span 并计算时长。 */
|
|
30
|
+
export const TERMINAL_EVENTS = Object.freeze([
|
|
31
|
+
"model.completed",
|
|
32
|
+
"model.failed",
|
|
33
|
+
"tool.completed",
|
|
34
|
+
"plugin.completed",
|
|
35
|
+
"request.completed",
|
|
36
|
+
"request.cancelled",
|
|
37
|
+
]);
|
|
38
|
+
|
|
39
|
+
export const REQUEST_EVENTS = Object.freeze([
|
|
40
|
+
"request.started",
|
|
41
|
+
"request.context",
|
|
42
|
+
"request.completed",
|
|
43
|
+
"request.cancelled",
|
|
44
|
+
]);
|
|
45
|
+
|
|
46
|
+
export const MODEL_EVENTS = Object.freeze([
|
|
47
|
+
"model.requested",
|
|
48
|
+
"model.first_token",
|
|
49
|
+
"model.completed",
|
|
50
|
+
"model.failed",
|
|
51
|
+
]);
|
|
52
|
+
|
|
53
|
+
export const TOOL_EVENTS = Object.freeze(["tool.started", "tool.completed"]);
|
|
54
|
+
export const PLUGIN_EVENTS = Object.freeze(["plugin.started", "plugin.completed"]);
|
|
55
|
+
|
|
56
|
+
/** result.status 允许值(缺省时由事件名推断:completed=success / failed=failed / cancelled=cancelled)。 */
|
|
57
|
+
export const RESULT_STATUSES = Object.freeze(["success", "failed", "timeout", "cancelled"]);
|
|
58
|
+
|
|
59
|
+
/** 错误分类建议集合(error.kind 自由字符串,聚合按出现值分组)。 */
|
|
60
|
+
export const ERROR_KINDS = Object.freeze([
|
|
61
|
+
"timeout",
|
|
62
|
+
"cancelled",
|
|
63
|
+
"rate_limit",
|
|
64
|
+
"auth",
|
|
65
|
+
"network",
|
|
66
|
+
"server",
|
|
67
|
+
"invalid_request",
|
|
68
|
+
"unknown",
|
|
69
|
+
]);
|
|
70
|
+
|
|
71
|
+
const ISO_UTC = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(\.\d+)?Z$/;
|
|
72
|
+
|
|
73
|
+
export function isIsoUtc(value) {
|
|
74
|
+
if (typeof value !== "string" || !ISO_UTC.test(value)) return false;
|
|
75
|
+
const t = Date.parse(value);
|
|
76
|
+
return Number.isFinite(t);
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
export function toIsoUtc(ms) {
|
|
80
|
+
return new Date(ms).toISOString();
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
export function nowIsoUtc() {
|
|
84
|
+
return new Date().toISOString();
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/** 单事件字节上限(计划 §7.2 资源预算)。 */
|
|
88
|
+
export const MAX_EVENT_BYTES = 64 * 1024;
|
|
89
|
+
|
|
90
|
+
const ID_PREFIXES = { event: "evt", span: "span", trace: "trace", request: "req" };
|
|
91
|
+
|
|
92
|
+
/** 生成 `<prefix>-<12hex>` 形式的不可预测 ID。 */
|
|
93
|
+
export function newId(kind) {
|
|
94
|
+
const prefix = ID_PREFIXES[kind] ?? "id";
|
|
95
|
+
const hex = cryptoRandomHex(6);
|
|
96
|
+
return `${prefix}-${hex}`;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
function cryptoRandomHex(bytes) {
|
|
100
|
+
// node:crypto 的 randomBytes 在所有受支持 Node 版本(≥18)可用;
|
|
101
|
+
// globalThis.crypto 是 Node 19+ 的全局,Node 18 上是 undefined
|
|
102
|
+
return randomBytes(bytes).toString("hex");
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
function isPlainObject(value) {
|
|
106
|
+
return value !== null && typeof value === "object" && !Array.isArray(value);
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
function checkString(errors, event, field, { required = false, allowEmpty = false } = {}) {
|
|
110
|
+
const value = event[field];
|
|
111
|
+
if (value === undefined || value === null) {
|
|
112
|
+
if (required) errors.push(`${field} is required`);
|
|
113
|
+
return;
|
|
114
|
+
}
|
|
115
|
+
if (typeof value !== "string" || (!allowEmpty && value.length === 0)) {
|
|
116
|
+
errors.push(`${field} must be a non-empty string`);
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
function checkPositiveInt(errors, event, field) {
|
|
121
|
+
const value = event[field];
|
|
122
|
+
if (value === undefined || value === null) return; // 缺失即 null 语义,允许
|
|
123
|
+
if (!Number.isInteger(value) || value < 0) errors.push(`${field} must be a non-negative integer or null`);
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/**
|
|
127
|
+
* 校验单条事件。返回 { ok, errors, event };不修改入参。
|
|
128
|
+
* - 缺失的非必填字段按 null 语义处理(不存在 = 未知,与 null 同义)。
|
|
129
|
+
* - schema 主版本不兼容时 ok=false,errors 携带明确原因。
|
|
130
|
+
*/
|
|
131
|
+
export function validateEvent(input) {
|
|
132
|
+
const errors = [];
|
|
133
|
+
if (!isPlainObject(input)) return { ok: false, errors: ["event must be a JSON object"], event: null };
|
|
134
|
+
|
|
135
|
+
const version = input.schema_version;
|
|
136
|
+
if (typeof version !== "string" || version.length === 0) {
|
|
137
|
+
errors.push("schema_version is required");
|
|
138
|
+
} else {
|
|
139
|
+
const major = Number.parseInt(version.split(".")[0], 10);
|
|
140
|
+
if (!Number.isFinite(major) || major !== SUPPORTED_SCHEMA_MAJOR) {
|
|
141
|
+
errors.push(`unsupported schema_version: ${version} (supported major: ${SUPPORTED_SCHEMA_MAJOR})`);
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
if (!EVENT_NAMES.includes(input.event)) errors.push(`unknown event name: ${String(input.event)}`);
|
|
146
|
+
|
|
147
|
+
for (const field of ["event_id", "trace_id", "span_id"]) {
|
|
148
|
+
checkString(errors, input, field, { required: true });
|
|
149
|
+
}
|
|
150
|
+
checkString(errors, input, "parent_id");
|
|
151
|
+
checkString(errors, input, "request_id");
|
|
152
|
+
|
|
153
|
+
if (!isIsoUtc(input.timestamp)) errors.push("timestamp must be ISO 8601 UTC (e.g. 2026-08-23T12:00:00.000Z)");
|
|
154
|
+
|
|
155
|
+
checkPositiveInt(errors, input, "duration_ms");
|
|
156
|
+
|
|
157
|
+
if (input.session !== undefined && input.session !== null && !isPlainObject(input.session)) {
|
|
158
|
+
errors.push("session must be an object");
|
|
159
|
+
}
|
|
160
|
+
if (input.model !== undefined && input.model !== null) {
|
|
161
|
+
if (!isPlainObject(input.model)) errors.push("model must be an object");
|
|
162
|
+
else checkString(errors, input.model, "name");
|
|
163
|
+
}
|
|
164
|
+
if (input.usage !== undefined && input.usage !== null) {
|
|
165
|
+
if (!isPlainObject(input.usage)) {
|
|
166
|
+
errors.push("usage must be an object");
|
|
167
|
+
} else {
|
|
168
|
+
for (const field of ["input_tokens", "output_tokens", "cached_input_tokens", "reasoning_tokens"]) {
|
|
169
|
+
const value = input.usage[field];
|
|
170
|
+
if (value === undefined || value === null) continue;
|
|
171
|
+
if (!Number.isInteger(value) || value < 0) errors.push(`usage.${field} must be a non-negative integer or null`);
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
if (input.result !== undefined && input.result !== null) {
|
|
176
|
+
if (!isPlainObject(input.result)) errors.push("result must be an object");
|
|
177
|
+
else if (input.result.status !== undefined && !RESULT_STATUSES.includes(input.result.status)) {
|
|
178
|
+
errors.push(`result.status must be one of ${RESULT_STATUSES.join("|")}`);
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
if (input.tool !== undefined && input.tool !== null) {
|
|
182
|
+
if (!isPlainObject(input.tool)) errors.push("tool must be an object");
|
|
183
|
+
else checkString(errors, input.tool, "name", { required: true });
|
|
184
|
+
}
|
|
185
|
+
if (input.plugin !== undefined && input.plugin !== null) {
|
|
186
|
+
if (!isPlainObject(input.plugin)) errors.push("plugin must be an object");
|
|
187
|
+
else checkString(errors, input.plugin, "name", { required: true });
|
|
188
|
+
}
|
|
189
|
+
if (input.error !== undefined && input.error !== null) {
|
|
190
|
+
if (!isPlainObject(input.error)) errors.push("error must be an object");
|
|
191
|
+
else checkString(errors, input.error, "kind", { required: true });
|
|
192
|
+
}
|
|
193
|
+
if (input.sampling !== undefined && input.sampling !== null && !isPlainObject(input.sampling)) {
|
|
194
|
+
errors.push("sampling must be an object");
|
|
195
|
+
}
|
|
196
|
+
if (input.privacy !== undefined && input.privacy !== null) {
|
|
197
|
+
if (!isPlainObject(input.privacy)) errors.push("privacy must be an object");
|
|
198
|
+
else {
|
|
199
|
+
checkPositiveInt(errors, input.privacy, "redactions");
|
|
200
|
+
if (input.privacy.content_captured !== undefined && typeof input.privacy.content_captured !== "boolean") {
|
|
201
|
+
errors.push("privacy.content_captured must be a boolean");
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
return errors.length === 0 ? { ok: true, errors, event: input } : { ok: false, errors, event: null };
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
/** 事件序列化:确定性键序不强制,但必须产出单行 JSON。 */
|
|
210
|
+
export function serializeEvent(event) {
|
|
211
|
+
return JSON.stringify(event);
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
/** 单行反序列化;坏 JSON 返回 { ok:false }(fail-open,由调用方计数)。 */
|
|
215
|
+
export function deserializeEvent(line) {
|
|
216
|
+
try {
|
|
217
|
+
const parsed = JSON.parse(line);
|
|
218
|
+
return { ok: true, event: parsed };
|
|
219
|
+
} catch {
|
|
220
|
+
return { ok: false, event: null };
|
|
221
|
+
}
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
/**
|
|
225
|
+
* 推断事件结果状态:completed → success(除非显式声明),
|
|
226
|
+
* failed → failed,cancelled → cancelled。
|
|
227
|
+
*/
|
|
228
|
+
export function inferStatus(event) {
|
|
229
|
+
if (event.result?.status) return event.result.status;
|
|
230
|
+
if (event.event === "model.failed") return "failed";
|
|
231
|
+
if (event.event === "request.cancelled") return "cancelled";
|
|
232
|
+
if (event.event === "request.completed" || event.event === "model.completed" || event.event === "tool.completed" || event.event === "plugin.completed") {
|
|
233
|
+
return "success";
|
|
234
|
+
}
|
|
235
|
+
return null;
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
/** 事件按域分类,供录制器与聚合器路由。 */
|
|
239
|
+
export function eventDomain(event) {
|
|
240
|
+
const name = typeof event === "string" ? event : event?.event;
|
|
241
|
+
if (REQUEST_EVENTS.includes(name)) return "request";
|
|
242
|
+
if (MODEL_EVENTS.includes(name)) return "model";
|
|
243
|
+
if (TOOL_EVENTS.includes(name)) return "tool";
|
|
244
|
+
if (PLUGIN_EVENTS.includes(name)) return "plugin";
|
|
245
|
+
return "other";
|
|
246
|
+
}
|
package/src/server.mjs
ADDED
|
@@ -0,0 +1,163 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* dsh-local-telemetry — 本地只读 Web UI 服务(计划 §9.3 / Phase 5)。
|
|
3
|
+
*
|
|
4
|
+
* 边界:
|
|
5
|
+
* - 只读:仅 GET,数据经聚合接口输出,不暴露原始事件文件路径,不输出内容字段;
|
|
6
|
+
* - 默认且仅绑定 127.0.0.1(禁止默认暴露到局域网);
|
|
7
|
+
* - 零依赖静态资源(web/),无外部 CDN 请求。
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import { createServer } from "node:http";
|
|
11
|
+
import { readFile } from "node:fs/promises";
|
|
12
|
+
import { join } from "node:path";
|
|
13
|
+
import { fileURLToPath } from "node:url";
|
|
14
|
+
import { dirname } from "node:path";
|
|
15
|
+
import { aggregateEvents, aggregateGrouped, slowTraceIds, buildTraceView, listRequestRows } from "./aggregate.mjs";
|
|
16
|
+
import { sinceUntilRange } from "./config.mjs";
|
|
17
|
+
import { renderMarkdownReport } from "./report.mjs";
|
|
18
|
+
|
|
19
|
+
const webDir = join(dirname(fileURLToPath(import.meta.url)), "..", "web");
|
|
20
|
+
|
|
21
|
+
const STATIC_FILES = {
|
|
22
|
+
"/": { file: "index.html", type: "text/html; charset=utf-8" },
|
|
23
|
+
"/index.html": { file: "index.html", type: "text/html; charset=utf-8" },
|
|
24
|
+
"/app.js": { file: "app.js", type: "text/javascript; charset=utf-8" },
|
|
25
|
+
"/style.css": { file: "style.css", type: "text/css; charset=utf-8" },
|
|
26
|
+
};
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* @param {object} opts
|
|
30
|
+
* @param {object} opts.store openStore 结果(读取层)
|
|
31
|
+
* @param {object|null} [opts.catalog] 价格目录
|
|
32
|
+
* @param {string|null} [opts.catalogPath] 价格目录路径(来源标注)
|
|
33
|
+
*/
|
|
34
|
+
export function createTelemetryServer({ store, catalog = null, catalogPath = null } = {}) {
|
|
35
|
+
const server = createServer((req, res) => {
|
|
36
|
+
void handle(req, res).catch(() => {
|
|
37
|
+
res.writeHead(500, { "content-type": "application/json; charset=utf-8" });
|
|
38
|
+
res.end(JSON.stringify({ error: "internal error" }));
|
|
39
|
+
});
|
|
40
|
+
});
|
|
41
|
+
server.setTimeout(15000);
|
|
42
|
+
|
|
43
|
+
async function readJsonBody(url, query) {
|
|
44
|
+
const filters = {
|
|
45
|
+
fromMs: query.fromMs,
|
|
46
|
+
toMs: query.toMs,
|
|
47
|
+
profile: query.profile,
|
|
48
|
+
model: query.model,
|
|
49
|
+
plugin: query.plugin,
|
|
50
|
+
event: query.event,
|
|
51
|
+
errorsOnly: query.errors_only === "1" || query.errors_only === "true",
|
|
52
|
+
};
|
|
53
|
+
const { events, skipped } = await store.readEvents(filters);
|
|
54
|
+
let effective = events;
|
|
55
|
+
if (query.slow_over_ms) {
|
|
56
|
+
const threshold = Number(query.slow_over_ms);
|
|
57
|
+
if (Number.isFinite(threshold) && threshold >= 0) {
|
|
58
|
+
const ids = slowTraceIds(events, threshold);
|
|
59
|
+
effective = events.filter((event) => ids.has(event.trace_id ?? event.span_id));
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
return { events: effective, skipped, filters };
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
async function handle(req, res) {
|
|
66
|
+
const url = new URL(req.url ?? "/", "http://127.0.0.1");
|
|
67
|
+
const pathname = url.pathname;
|
|
68
|
+
|
|
69
|
+
if (req.method !== "GET") {
|
|
70
|
+
res.writeHead(405, { "content-type": "application/json; charset=utf-8" });
|
|
71
|
+
res.end(JSON.stringify({ error: "read-only interface" }));
|
|
72
|
+
return;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
const staticFile = STATIC_FILES[pathname];
|
|
76
|
+
if (staticFile) {
|
|
77
|
+
try {
|
|
78
|
+
const body = await readFile(join(webDir, staticFile.file));
|
|
79
|
+
res.writeHead(200, { "content-type": staticFile.type, "cache-control": "no-store" });
|
|
80
|
+
res.end(body);
|
|
81
|
+
} catch {
|
|
82
|
+
res.writeHead(404, { "content-type": "text/plain; charset=utf-8" });
|
|
83
|
+
res.end("not found (web assets missing)");
|
|
84
|
+
}
|
|
85
|
+
return;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
const q = Object.fromEntries(url.searchParams.entries());
|
|
89
|
+
const range = sinceUntilRange(q.since ?? null, q.until ?? null);
|
|
90
|
+
|
|
91
|
+
if (pathname === "/api/status") {
|
|
92
|
+
const status = await store.status();
|
|
93
|
+
status.localhost_only = true;
|
|
94
|
+
status.price_catalog = catalogPath ? { path: catalogPath, effective_at: catalog?.effective_at ?? null, currency: catalog?.currency ?? null } : null;
|
|
95
|
+
json(res, status);
|
|
96
|
+
return;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
if (pathname === "/api/summary") {
|
|
100
|
+
const { events, skipped } = await readJsonBody(url, { ...q, fromMs: range.from, toMs: range.to });
|
|
101
|
+
const summary = aggregateEvents(events, { catalog, catalogPath });
|
|
102
|
+
summary.skipped = skipped;
|
|
103
|
+
const status = await store.status();
|
|
104
|
+
summary.dropped = status.counters;
|
|
105
|
+
json(res, summary);
|
|
106
|
+
return;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
if (pathname === "/api/grouped") {
|
|
110
|
+
const by = q.by ?? "day";
|
|
111
|
+
const { events } = await readJsonBody(url, { ...q, fromMs: range.from, toMs: range.to });
|
|
112
|
+
const rows = aggregateGrouped(events, { groupBy: by, catalog, catalogPath });
|
|
113
|
+
json(res, { group_by: by, rows: rows ?? [] });
|
|
114
|
+
return;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
if (pathname === "/api/requests") {
|
|
118
|
+
const { events } = await readJsonBody(url, { ...q, fromMs: range.from, toMs: range.to });
|
|
119
|
+
const limit = Math.min(Number(q.limit ?? 100) || 100, 500);
|
|
120
|
+
json(res, { requests: listRequestRows(events, { limit }) });
|
|
121
|
+
return;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
const traceMatch = pathname.match(/^\/api\/trace\/(.+)$/);
|
|
125
|
+
if (traceMatch) {
|
|
126
|
+
const traceId = decodeURIComponent(traceMatch[1]);
|
|
127
|
+
const { events } = await store.readEvents({ traceId });
|
|
128
|
+
if (events.length === 0) {
|
|
129
|
+
res.writeHead(404, { "content-type": "application/json; charset=utf-8" });
|
|
130
|
+
res.end(JSON.stringify({ error: "trace not found" }));
|
|
131
|
+
return;
|
|
132
|
+
}
|
|
133
|
+
json(res, buildTraceView(events));
|
|
134
|
+
return;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
if (pathname === "/api/report.md") {
|
|
138
|
+
const { events } = await readJsonBody(url, { ...q, fromMs: range.from, toMs: range.to });
|
|
139
|
+
const summary = aggregateEvents(events, { catalog, catalogPath });
|
|
140
|
+
const status = await store.status();
|
|
141
|
+
const markdown = renderMarkdownReport(summary, {
|
|
142
|
+
store: status.store,
|
|
143
|
+
path: status.path,
|
|
144
|
+
dropped: status.counters,
|
|
145
|
+
command: "GET /api/report.md",
|
|
146
|
+
priceCatalogPath: catalogPath,
|
|
147
|
+
});
|
|
148
|
+
res.writeHead(200, { "content-type": "text/markdown; charset=utf-8" });
|
|
149
|
+
res.end(markdown);
|
|
150
|
+
return;
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
res.writeHead(404, { "content-type": "application/json; charset=utf-8" });
|
|
154
|
+
res.end(JSON.stringify({ error: "not found" }));
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
function json(res, payload) {
|
|
158
|
+
res.writeHead(200, { "content-type": "application/json; charset=utf-8", "cache-control": "no-store" });
|
|
159
|
+
res.end(JSON.stringify(payload));
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
return server;
|
|
163
|
+
}
|