feat: add OneTalk diagnostic file logging

This commit is contained in:
YBF
2026-09-16 01:03:09 +08:00
parent 5378036309
commit c4f9e2ab97
13 changed files with 221 additions and 62 deletions
+2
View File
@@ -5,6 +5,8 @@
HOST=127.0.0.1
PORT=7878
DATABASE_URL=postgres://postgres:postgres@127.0.0.1:5432/trade_message_center
# 仅开发排障:将未脱敏 OneTalk WebSocket payload 写入 logs/<BUILD_HASH>.log;默认关闭。
ONETALK_DIAGNOSTIC_LOG=false
# Chrome 扩展临时下载签名及本地 Mind 联调页的 server-proxy OSS 上传。TMC_PACKAGE_VERSION 由 workspace 命令从根 package.json 注入;AccessKey 只能留在 server 侧环境文件。
OSS_BUCKET=sinanpilot-bucket
@@ -92,6 +92,7 @@ export type OneTalkBrightDiagnostic = {
requestedPermissions?: OneTalkPermission[];
bindingPresent?: boolean;
redactedFields?: readonly string[];
payload?: unknown;
};
export type OneTalkBrightClientOptions = {
@@ -323,12 +324,14 @@ export const createOneTalkBrightClient = (
| "requestedPermissions"
| "bindingPresent"
| "redactedFields"
| "payload"
> => ({
...socketDiagnostic(),
protocolVersion: frame.protocolVersion,
connectionType: frame.connectionType,
frameType: frame.type,
requestId: frame.requestId,
payload: frame.payload,
...(frame.type === "ws.hello"
? {
requestedPermissions: [...frame.payload.requestedPermissions],
@@ -410,6 +413,7 @@ export const createOneTalkBrightClient = (
direction: "outbound",
requestId: frame.requestId,
frameType: frame.type,
payload: frame.payload,
code: "send_failed",
});
report(new OneTalkBrightClientError("send_failed", error));
@@ -1,5 +1,10 @@
// 启动页面桥并管理可配置的 OneTalk 同步运行时
import {
composeOneTalkDiagnosticLog,
isPlainRecord,
type OneTalkDiagnosticLogEvent,
} from "@trade-message-center/onetalk-contract";
import {
clearOneTalkExtensionConfig,
isMaskedOneTalkBinding,
@@ -16,7 +21,6 @@ import {
type OneTalkExtensionStorageArea,
type OneTalkPopupErrorCode,
} from "./onetalk/config.ts";
import { isPlainRecord } from "@trade-message-center/onetalk-contract";
import {
createOneTalkServiceWorkerSyncController,
type OneTalkServiceWorkerSyncController,
@@ -25,13 +29,7 @@ import {
import { logOneTalkError } from "./onetalk/service-worker/error-diagnostics.ts";
import type { OneTalkServiceWorkerPort } from "./onetalk/service-worker/runtime.ts";
import { BRIGHT_WEBSOCKET_URL, ENABLE_ONE_TALK_DIAGNOSTICS } from "./onetalk/build-config.ts";
import type { OneTalkPageDiagnostic } from "./onetalk/service-worker/runtime.ts";
import type { OneTalkSyncEngineDiagnostic } from "./onetalk/service-worker/sync-engine.ts";
import {
OneTalkBrightClientError,
type OneTalkBrightDiagnostic,
} from "./onetalk/service-worker/transport/bright-client.ts";
import type { OneTalkContactProfileDiagnostic } from "./onetalk/service-worker/contact-profile-coordinator.ts";
import { OneTalkBrightClientError } from "./onetalk/service-worker/transport/bright-client.ts";
declare const chrome: {
runtime: {
@@ -59,28 +57,10 @@ declare const chrome: {
};
};
type OneTalkDiagnostic =
| OneTalkBrightDiagnostic
| OneTalkPageDiagnostic
| OneTalkSyncEngineDiagnostic
| OneTalkContactProfileDiagnostic;
const heartbeatDiagnosticText = (event: OneTalkDiagnostic): string | undefined => {
if (event.event !== "frame") return undefined;
if (event.frameType === "heartbeat.ack") return "心跳确认";
if (event.frameType !== "heartbeat") return undefined;
return event.direction === "inbound" ? "心跳接收" : "心跳发送";
};
const logOneTalkDiagnostic = (source: string, event: OneTalkDiagnostic): void => {
const logOneTalkDiagnostic = <T extends OneTalkDiagnosticLogEvent>(event: T): void => {
if (!ENABLE_ONE_TALK_DIAGNOSTICS) return;
const heartbeatText = heartbeatDiagnosticText(event);
if (heartbeatText !== undefined) {
console.info(`[Trade Message Center][OneTalk ${source}] ${heartbeatText}`);
return;
}
const level = event.status === "failed" || event.code ? "warn" : "info";
console[level](`[Trade Message Center][OneTalk ${source}]`, event);
const diagnostic = composeOneTalkDiagnosticLog("extension", event);
console[diagnostic.level](diagnostic.headline, diagnostic.payload);
};
const isNoPopupReceiverError = (error: unknown): boolean => {
@@ -131,15 +111,13 @@ const broadcastStatus = (snapshot: OneTalkServiceWorkerSyncSnapshot): void => {
};
const controller: OneTalkServiceWorkerSyncController = createOneTalkServiceWorkerSyncController({
onBrightDiagnostic: (event: OneTalkBrightDiagnostic) => logOneTalkDiagnostic("Bright", event),
onPageDiagnostic: (event: OneTalkPageDiagnostic) => logOneTalkDiagnostic("Page", event),
onEngineDiagnostic: (event: OneTalkSyncEngineDiagnostic) => logOneTalkDiagnostic("Sync", event),
onProfileDiagnostic: (event: OneTalkContactProfileDiagnostic) =>
logOneTalkDiagnostic("Profile", event),
onBrightDiagnostic: logOneTalkDiagnostic,
onPageDiagnostic: logOneTalkDiagnostic,
onEngineDiagnostic: logOneTalkDiagnostic,
onProfileDiagnostic: logOneTalkDiagnostic,
onStatusChange: (snapshot) => {
broadcastStatus(snapshot);
if (!ENABLE_ONE_TALK_DIAGNOSTICS) return;
console.info("[Trade Message Center][OneTalk Sync]", {
logOneTalkDiagnostic({
event: "state_snapshot",
online: snapshot.engineStatus?.online ?? false,
brightStatus: snapshot.connectionStatus,
@@ -356,6 +356,10 @@ test("keeps ws.error visible and uses a browser-valid client close code", () =>
assert.deepEqual(hello.requestedPermissions, ["read", "rebuild"]);
assert.equal(hello.bindingPresent, true);
assert.ok(hello.redactedFields.includes("binding"));
assert.deepEqual(hello.payload, {
binding: "binding-1",
requestedPermissions: ["read", "rebuild"],
});
const close = diagnostics.find((event) => event.event === "socket_close");
assert.equal(close.closeCode, 1000);
assert.equal(close.closeReason, "binding_revoked");
@@ -373,7 +377,7 @@ test("keeps ws.error visible and uses a browser-valid client close code", () =>
assert.equal(JSON.stringify(diagnostics).includes(socket.url), false);
assert.equal(JSON.stringify(diagnostics).includes("credential=secret"), false);
assert.equal(JSON.stringify(diagnostics).includes("fragment"), false);
assert.equal(JSON.stringify(diagnostics).includes("binding-1"), false);
assert.equal(JSON.stringify(diagnostics).includes("binding-1"), true);
});
for (const code of Object.values(ONETALK_ERROR_CODES)) {
@@ -499,7 +503,7 @@ for (const code of [
});
}
test("diagnoses guarded sends and invalid inbound frames without payloads", () => {
test("keeps guarded-send payloads while invalid inbound frames have no decoded payload", () => {
const diagnostics = [];
const socket = new FakeSocket("wss://bright.example/ws");
const client = createOneTalkBrightClient({
@@ -530,11 +534,18 @@ test("diagnoses guarded sends and invalid inbound frames without payloads", () =
assert.ok(
diagnostics.some((event) => event.event === "frame" && event.code === "invalid_message"),
);
assert.equal(JSON.stringify(diagnostics).includes("secret-binding"), false);
const guardedSend = diagnostics.find((event) => event.event === "guarded_send");
const invalidInbound = diagnostics.find(
(event) => event.event === "frame" && event.code === "invalid_message",
);
assert.equal(guardedSend.payload.conversationId, "conversation-1");
assert.equal(Object.hasOwn(invalidInbound, "payload"), false);
assert.equal(JSON.stringify(diagnostics).includes("secret-binding"), true);
});
test("uses a browser-valid close code when the initial hello cannot be sent", () => {
const socket = new FakeSocket("wss://bright.example/ws");
const diagnostics = [];
socket.send = () => {
throw new Error("send failed");
};
@@ -544,6 +555,7 @@ test("uses a browser-valid close code when the initial hello cannot be sent", ()
binding: "binding-1",
webSocket: () => socket,
autoReconnect: false,
onDiagnostic: (event) => diagnostics.push(event),
});
client.connect();
@@ -553,6 +565,11 @@ test("uses a browser-valid close code when the initial hello cannot be sent", ()
socket.closed.at(-1).code === 1000 ||
(socket.closed.at(-1).code >= 3000 && socket.closed.at(-1).code <= 4999),
);
assert.deepEqual(
diagnostics.find((event) => event.event === "frame" && event.code === "send_failed")
.payload,
{ binding: "binding-1", requestedPermissions: ["read", "rebuild"] },
);
});
test("sends a complete confirmed_sent message through the strict confirmation contract", () => {
+12 -20
View File
@@ -6,7 +6,10 @@ import { loadEnvFile } from "node:process";
import { fileURLToPath } from "node:url";
import { startServer } from "./entry.ts";
import type { OneTalkDiagnostic } from "./websocket/diagnostics.ts";
import {
createOneTalkDiagnosticFileLogger,
isOneTalkDiagnosticFileLogEnabled,
} from "./websocket/diagnostic-file-log.ts";
const environmentFileNames = [".env", ".env.local", ".env.development", ".env.development.local"];
@@ -41,13 +44,6 @@ const loadEnvironmentFiles = (): string[] => {
return existingFiles.map((filePath) => relative(projectRoot, filePath));
};
const heartbeatDiagnosticText = (event: OneTalkDiagnostic): string | undefined => {
if (event.event !== "ws_frame") return undefined;
if (event.frameType === "heartbeat.ack") return "心跳确认";
if (event.frameType !== "heartbeat") return undefined;
return event.direction === "inbound" ? "心跳接收" : "心跳发送";
};
/** 加载开发环境配置并启动服务。 */
const startDevelopmentServer = async (): Promise<void> => {
const loadedFiles = loadEnvironmentFiles();
@@ -56,19 +52,15 @@ const startDevelopmentServer = async (): Promise<void> => {
? `[env] loaded ${loadedFiles.join(", ")}`
: "[env] no environment file loaded",
);
await startServer(
(event) => {
const heartbeatText = heartbeatDiagnosticText(event);
if (heartbeatText !== undefined) {
console.info(`[onetalk][diagnostic] ${heartbeatText}`);
return;
}
console.info("[onetalk][diagnostic]", JSON.stringify(event));
},
(event) => {
const oneTalkDiagnostic = isOneTalkDiagnosticFileLogEnabled(process.env.ONETALK_DIAGNOSTIC_LOG)
? createOneTalkDiagnosticFileLogger({
logsDirectory: resolve(projectRoot, "logs"),
buildHash: process.env.BUILD_HASH,
})
: undefined;
await startServer(oneTalkDiagnostic, (event) => {
console.info("[mind-auth][diagnostic]", JSON.stringify(event));
},
);
});
};
void startDevelopmentServer().catch((error: unknown) => {
@@ -0,0 +1,37 @@
import { appendFileSync, mkdirSync } from "node:fs";
import { join } from "node:path";
import {
composeOneTalkDiagnosticLog,
type OneTalkDiagnosticLogEvent,
} from "@trade-message-center/onetalk-contract";
const logFileName = (buildHash: string | undefined): string => {
const normalizedBuildHash = buildHash?.trim();
if (!normalizedBuildHash) return "dev.log";
if (!/^[a-zA-Z0-9_-]+$/u.test(normalizedBuildHash)) {
throw new Error("BUILD_HASH must contain only letters, numbers, underscores, or dashes");
}
return `${normalizedBuildHash}.log`;
};
export const isOneTalkDiagnosticFileLogEnabled = (value: string | undefined): boolean => {
return value?.trim() === "true";
};
export const createOneTalkDiagnosticFileLogger = (options: {
logsDirectory: string;
buildHash?: string;
}): ((event: OneTalkDiagnosticLogEvent) => void) => {
mkdirSync(options.logsDirectory, { recursive: true });
const logPath = join(options.logsDirectory, logFileName(options.buildHash));
return (event) => {
const diagnostic = composeOneTalkDiagnosticLog("server", event);
appendFileSync(
logPath,
`${diagnostic.headline} ${JSON.stringify(diagnostic.payload)}\n`,
"utf8",
);
};
};
+1
View File
@@ -13,6 +13,7 @@ export type OneTalkDiagnostic = {
code?: string;
closeCode?: number;
closeReason?: string;
payload?: unknown;
};
export type OneTalkDiagnosticsSink = (event: OneTalkDiagnostic) => void;
+4
View File
@@ -142,6 +142,7 @@ const sendFrame = (
connectionType: frame.connectionType,
frameType: frame.type,
direction: "outbound",
payload: frame.payload,
...(code === undefined ? {} : { code }),
});
if (socket.readyState !== WEBSOCKET_OPEN) {
@@ -240,6 +241,7 @@ export const createOneTalkWebSocketHandler =
requestId: frame.requestId,
connectionType: frame.connectionType,
frameType: frame.type,
payload: frame.payload,
}),
reportDecision: (frame, code) =>
reportDiagnostic(options.onDiagnostic, {
@@ -247,6 +249,7 @@ export const createOneTalkWebSocketHandler =
requestId: frame.requestId,
connectionType: frame.connectionType,
frameType: frame.type,
payload: frame.payload,
code,
}),
};
@@ -285,6 +288,7 @@ export const createOneTalkWebSocketHandler =
connectionType: frame.connectionType,
frameType: frame.type,
direction: "inbound",
payload: frame.payload,
}),
onUnauthenticatedFrame: (_socket, frame) => {
sendError(frame, ONETALK_ERROR_CODES.authRequired);
+6 -2
View File
@@ -1121,7 +1121,7 @@ test("publishes exact false profile updates only for newer committed profiles",
}
});
test("emits only safe lifecycle fields through the injected diagnostics sink", async () => {
test("emits complete frame payloads through the injected diagnostics sink", async () => {
const diagnostics: Record<string, unknown>[] = [];
const app = createApp(testConfig, {
database: createDatabaseStub(),
@@ -1154,7 +1154,11 @@ test("emits only safe lifecycle fields through the injected diagnostics sink", a
(event) => event.event === "ws_frame" && event.frameType === "ws.hello",
),
);
assert.equal(JSON.stringify(diagnostics).includes("binding-1"), false);
assert.deepEqual(diagnostics[0].payload, {
binding: "binding-1",
requestedPermissions: ["read", "send"],
});
assert.equal(JSON.stringify(diagnostics).includes("binding-1"), true);
assert.equal(JSON.stringify(diagnostics).includes("device-1"), false);
} finally {
await closeApp(app, [socket]);
@@ -1,10 +1,57 @@
// 验证 WebSocket 通用诊断不含旧资料投递契约
import assert from "node:assert/strict";
import { mkdtempSync, readFileSync, rmSync } from "node:fs";
import { join } from "node:path";
import test from "node:test";
import { tmpdir } from "node:os";
import { composeOneTalkDiagnosticLog } from "@trade-message-center/onetalk-contract";
import * as diagnosticFileLog from "../src/websocket/diagnostic-file-log.ts";
import type { OneTalkDiagnostic } from "../src/websocket/diagnostics.ts";
test("puts the WebSocket event and frame type in the diagnostic headline", () => {
const helloLog = composeOneTalkDiagnosticLog("server", {
event: "ws_frame",
frameType: "ws.hello",
});
const closeLog = composeOneTalkDiagnosticLog("server", { event: "ws_close" });
assert.equal(helloLog.headline, "[server] event=ws_frame frameType=ws.hello");
assert.equal(closeLog.headline, "[server] event=ws_close frameType=-");
});
test("appends complete diagnostics to the build-hash log file", () => {
const logsDirectory = mkdtempSync(join(tmpdir(), "onetalk-diagnostic-log-"));
const payload = { binding: "binding-1", content: { text: "full message" } };
try {
const writeDiagnostic = diagnosticFileLog.createOneTalkDiagnosticFileLogger({
logsDirectory,
buildHash: "0123456789abcdef",
});
writeDiagnostic({ event: "ws_frame", frameType: "ws.hello", payload });
writeDiagnostic({ event: "ws_close" });
assert.equal(
readFileSync(join(logsDirectory, "0123456789abcdef.log"), "utf8"),
[
'[server] event=ws_frame frameType=ws.hello {"binding":"binding-1","content":{"text":"full message"}}',
'[server] event=ws_close frameType=- {"event":"ws_close"}',
"",
].join("\n"),
);
} finally {
rmSync(logsDirectory, { recursive: true, force: true });
}
});
test("keeps file diagnostics disabled unless explicitly enabled", () => {
assert.equal(typeof diagnosticFileLog.isOneTalkDiagnosticFileLogEnabled, "function");
assert.equal(diagnosticFileLog.isOneTalkDiagnosticFileLogEnabled(undefined), false);
assert.equal(diagnosticFileLog.isOneTalkDiagnosticFileLogEnabled("false"), false);
assert.equal(diagnosticFileLog.isOneTalkDiagnosticFileLogEnabled("true"), true);
});
test("does not accept removed profile delivery diagnostic fields", () => {
const lifecycleDiagnostic: OneTalkDiagnostic = {
event: "ws_close",
@@ -0,0 +1,30 @@
export type OneTalkDiagnosticLogSource = "extension" | "server";
export type OneTalkDiagnosticLogEvent = {
event: string;
frameType?: string;
status?: string;
code?: string;
payload?: unknown;
};
export type OneTalkDiagnosticLog = {
headline: string;
payload: unknown;
level: "info" | "warn";
};
const payloadForLog = <T extends OneTalkDiagnosticLogEvent>(event: T): unknown => {
if (!Object.hasOwn(event, "payload")) return event;
return event.payload;
};
/** 组合开发期 OneTalk 诊断日志,不裁剪显式携带的原始 payload。 */
export const composeOneTalkDiagnosticLog = <T extends OneTalkDiagnosticLogEvent>(
source: OneTalkDiagnosticLogSource,
event: T,
): OneTalkDiagnosticLog => ({
headline: `[${source}] event=${event.event} frameType=${event.frameType ?? "-"}`,
payload: payloadForLog(event),
level: event.status === "failed" || event.code !== undefined ? "warn" : "info",
});
+6
View File
@@ -1,6 +1,12 @@
// 暴露 OneTalk 跨包公共协议边界
export { isOneTalkAvatarUrl, isPlainRecord } from "./guards.ts";
export { composeOneTalkDiagnosticLog } from "./diagnostic-log.ts";
export type {
OneTalkDiagnosticLog,
OneTalkDiagnosticLogEvent,
OneTalkDiagnosticLogSource,
} from "./diagnostic-log.ts";
export * from "./authorization.ts";
export * from "./content.ts";
export * from "./decoder.ts";
@@ -0,0 +1,37 @@
import assert from "node:assert/strict";
import test from "node:test";
import { composeOneTalkDiagnosticLog } from "../src/diagnostic-log.ts";
test("composes a source-prefixed headline and preserves the complete payload", () => {
const payload = { binding: "binding-1", content: { text: "full message" } };
assert.deepEqual(
composeOneTalkDiagnosticLog("extension", {
event: "frame",
frameType: "ws.hello",
payload,
}),
{
headline: "[extension] event=frame frameType=ws.hello",
payload,
level: "info",
},
);
assert.deepEqual(composeOneTalkDiagnosticLog("server", { event: "ws_close" }), {
headline: "[server] event=ws_close frameType=-",
payload: { event: "ws_close" },
level: "info",
});
});
test("marks failed or coded diagnostics as warnings", () => {
assert.equal(
composeOneTalkDiagnosticLog("extension", {
event: "frame",
frameType: "ws.error",
code: "binding_revoked",
}).level,
"warn",
);
});