mirror of
https://github.com/sinanyuntu/trade-message-center.git
synced 2026-09-17 13:22:11 +08:00
feat: add OneTalk diagnostic file logging
This commit is contained in:
@@ -5,6 +5,8 @@
|
|||||||
HOST=127.0.0.1
|
HOST=127.0.0.1
|
||||||
PORT=7878
|
PORT=7878
|
||||||
DATABASE_URL=postgres://postgres:postgres@127.0.0.1:5432/trade_message_center
|
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 侧环境文件。
|
# Chrome 扩展临时下载签名及本地 Mind 联调页的 server-proxy OSS 上传。TMC_PACKAGE_VERSION 由 workspace 命令从根 package.json 注入;AccessKey 只能留在 server 侧环境文件。
|
||||||
OSS_BUCKET=sinanpilot-bucket
|
OSS_BUCKET=sinanpilot-bucket
|
||||||
|
|||||||
@@ -92,6 +92,7 @@ export type OneTalkBrightDiagnostic = {
|
|||||||
requestedPermissions?: OneTalkPermission[];
|
requestedPermissions?: OneTalkPermission[];
|
||||||
bindingPresent?: boolean;
|
bindingPresent?: boolean;
|
||||||
redactedFields?: readonly string[];
|
redactedFields?: readonly string[];
|
||||||
|
payload?: unknown;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type OneTalkBrightClientOptions = {
|
export type OneTalkBrightClientOptions = {
|
||||||
@@ -323,12 +324,14 @@ export const createOneTalkBrightClient = (
|
|||||||
| "requestedPermissions"
|
| "requestedPermissions"
|
||||||
| "bindingPresent"
|
| "bindingPresent"
|
||||||
| "redactedFields"
|
| "redactedFields"
|
||||||
|
| "payload"
|
||||||
> => ({
|
> => ({
|
||||||
...socketDiagnostic(),
|
...socketDiagnostic(),
|
||||||
protocolVersion: frame.protocolVersion,
|
protocolVersion: frame.protocolVersion,
|
||||||
connectionType: frame.connectionType,
|
connectionType: frame.connectionType,
|
||||||
frameType: frame.type,
|
frameType: frame.type,
|
||||||
requestId: frame.requestId,
|
requestId: frame.requestId,
|
||||||
|
payload: frame.payload,
|
||||||
...(frame.type === "ws.hello"
|
...(frame.type === "ws.hello"
|
||||||
? {
|
? {
|
||||||
requestedPermissions: [...frame.payload.requestedPermissions],
|
requestedPermissions: [...frame.payload.requestedPermissions],
|
||||||
@@ -410,6 +413,7 @@ export const createOneTalkBrightClient = (
|
|||||||
direction: "outbound",
|
direction: "outbound",
|
||||||
requestId: frame.requestId,
|
requestId: frame.requestId,
|
||||||
frameType: frame.type,
|
frameType: frame.type,
|
||||||
|
payload: frame.payload,
|
||||||
code: "send_failed",
|
code: "send_failed",
|
||||||
});
|
});
|
||||||
report(new OneTalkBrightClientError("send_failed", error));
|
report(new OneTalkBrightClientError("send_failed", error));
|
||||||
|
|||||||
@@ -1,5 +1,10 @@
|
|||||||
// 启动页面桥并管理可配置的 OneTalk 同步运行时
|
// 启动页面桥并管理可配置的 OneTalk 同步运行时
|
||||||
|
|
||||||
|
import {
|
||||||
|
composeOneTalkDiagnosticLog,
|
||||||
|
isPlainRecord,
|
||||||
|
type OneTalkDiagnosticLogEvent,
|
||||||
|
} from "@trade-message-center/onetalk-contract";
|
||||||
import {
|
import {
|
||||||
clearOneTalkExtensionConfig,
|
clearOneTalkExtensionConfig,
|
||||||
isMaskedOneTalkBinding,
|
isMaskedOneTalkBinding,
|
||||||
@@ -16,7 +21,6 @@ import {
|
|||||||
type OneTalkExtensionStorageArea,
|
type OneTalkExtensionStorageArea,
|
||||||
type OneTalkPopupErrorCode,
|
type OneTalkPopupErrorCode,
|
||||||
} from "./onetalk/config.ts";
|
} from "./onetalk/config.ts";
|
||||||
import { isPlainRecord } from "@trade-message-center/onetalk-contract";
|
|
||||||
import {
|
import {
|
||||||
createOneTalkServiceWorkerSyncController,
|
createOneTalkServiceWorkerSyncController,
|
||||||
type OneTalkServiceWorkerSyncController,
|
type OneTalkServiceWorkerSyncController,
|
||||||
@@ -25,13 +29,7 @@ import {
|
|||||||
import { logOneTalkError } from "./onetalk/service-worker/error-diagnostics.ts";
|
import { logOneTalkError } from "./onetalk/service-worker/error-diagnostics.ts";
|
||||||
import type { OneTalkServiceWorkerPort } from "./onetalk/service-worker/runtime.ts";
|
import type { OneTalkServiceWorkerPort } from "./onetalk/service-worker/runtime.ts";
|
||||||
import { BRIGHT_WEBSOCKET_URL, ENABLE_ONE_TALK_DIAGNOSTICS } from "./onetalk/build-config.ts";
|
import { BRIGHT_WEBSOCKET_URL, ENABLE_ONE_TALK_DIAGNOSTICS } from "./onetalk/build-config.ts";
|
||||||
import type { OneTalkPageDiagnostic } from "./onetalk/service-worker/runtime.ts";
|
import { OneTalkBrightClientError } from "./onetalk/service-worker/transport/bright-client.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";
|
|
||||||
|
|
||||||
declare const chrome: {
|
declare const chrome: {
|
||||||
runtime: {
|
runtime: {
|
||||||
@@ -59,28 +57,10 @@ declare const chrome: {
|
|||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
type OneTalkDiagnostic =
|
const logOneTalkDiagnostic = <T extends OneTalkDiagnosticLogEvent>(event: T): void => {
|
||||||
| 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 => {
|
|
||||||
if (!ENABLE_ONE_TALK_DIAGNOSTICS) return;
|
if (!ENABLE_ONE_TALK_DIAGNOSTICS) return;
|
||||||
const heartbeatText = heartbeatDiagnosticText(event);
|
const diagnostic = composeOneTalkDiagnosticLog("extension", event);
|
||||||
if (heartbeatText !== undefined) {
|
console[diagnostic.level](diagnostic.headline, diagnostic.payload);
|
||||||
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 isNoPopupReceiverError = (error: unknown): boolean => {
|
const isNoPopupReceiverError = (error: unknown): boolean => {
|
||||||
@@ -131,15 +111,13 @@ const broadcastStatus = (snapshot: OneTalkServiceWorkerSyncSnapshot): void => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const controller: OneTalkServiceWorkerSyncController = createOneTalkServiceWorkerSyncController({
|
const controller: OneTalkServiceWorkerSyncController = createOneTalkServiceWorkerSyncController({
|
||||||
onBrightDiagnostic: (event: OneTalkBrightDiagnostic) => logOneTalkDiagnostic("Bright", event),
|
onBrightDiagnostic: logOneTalkDiagnostic,
|
||||||
onPageDiagnostic: (event: OneTalkPageDiagnostic) => logOneTalkDiagnostic("Page", event),
|
onPageDiagnostic: logOneTalkDiagnostic,
|
||||||
onEngineDiagnostic: (event: OneTalkSyncEngineDiagnostic) => logOneTalkDiagnostic("Sync", event),
|
onEngineDiagnostic: logOneTalkDiagnostic,
|
||||||
onProfileDiagnostic: (event: OneTalkContactProfileDiagnostic) =>
|
onProfileDiagnostic: logOneTalkDiagnostic,
|
||||||
logOneTalkDiagnostic("Profile", event),
|
|
||||||
onStatusChange: (snapshot) => {
|
onStatusChange: (snapshot) => {
|
||||||
broadcastStatus(snapshot);
|
broadcastStatus(snapshot);
|
||||||
if (!ENABLE_ONE_TALK_DIAGNOSTICS) return;
|
logOneTalkDiagnostic({
|
||||||
console.info("[Trade Message Center][OneTalk Sync]", {
|
|
||||||
event: "state_snapshot",
|
event: "state_snapshot",
|
||||||
online: snapshot.engineStatus?.online ?? false,
|
online: snapshot.engineStatus?.online ?? false,
|
||||||
brightStatus: snapshot.connectionStatus,
|
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.deepEqual(hello.requestedPermissions, ["read", "rebuild"]);
|
||||||
assert.equal(hello.bindingPresent, true);
|
assert.equal(hello.bindingPresent, true);
|
||||||
assert.ok(hello.redactedFields.includes("binding"));
|
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");
|
const close = diagnostics.find((event) => event.event === "socket_close");
|
||||||
assert.equal(close.closeCode, 1000);
|
assert.equal(close.closeCode, 1000);
|
||||||
assert.equal(close.closeReason, "binding_revoked");
|
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(socket.url), false);
|
||||||
assert.equal(JSON.stringify(diagnostics).includes("credential=secret"), false);
|
assert.equal(JSON.stringify(diagnostics).includes("credential=secret"), false);
|
||||||
assert.equal(JSON.stringify(diagnostics).includes("fragment"), 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)) {
|
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 diagnostics = [];
|
||||||
const socket = new FakeSocket("wss://bright.example/ws");
|
const socket = new FakeSocket("wss://bright.example/ws");
|
||||||
const client = createOneTalkBrightClient({
|
const client = createOneTalkBrightClient({
|
||||||
@@ -530,11 +534,18 @@ test("diagnoses guarded sends and invalid inbound frames without payloads", () =
|
|||||||
assert.ok(
|
assert.ok(
|
||||||
diagnostics.some((event) => event.event === "frame" && event.code === "invalid_message"),
|
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", () => {
|
test("uses a browser-valid close code when the initial hello cannot be sent", () => {
|
||||||
const socket = new FakeSocket("wss://bright.example/ws");
|
const socket = new FakeSocket("wss://bright.example/ws");
|
||||||
|
const diagnostics = [];
|
||||||
socket.send = () => {
|
socket.send = () => {
|
||||||
throw new Error("send failed");
|
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",
|
binding: "binding-1",
|
||||||
webSocket: () => socket,
|
webSocket: () => socket,
|
||||||
autoReconnect: false,
|
autoReconnect: false,
|
||||||
|
onDiagnostic: (event) => diagnostics.push(event),
|
||||||
});
|
});
|
||||||
|
|
||||||
client.connect();
|
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 === 1000 ||
|
||||||
(socket.closed.at(-1).code >= 3000 && socket.closed.at(-1).code <= 4999),
|
(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", () => {
|
test("sends a complete confirmed_sent message through the strict confirmation contract", () => {
|
||||||
|
|||||||
@@ -6,7 +6,10 @@ import { loadEnvFile } from "node:process";
|
|||||||
import { fileURLToPath } from "node:url";
|
import { fileURLToPath } from "node:url";
|
||||||
|
|
||||||
import { startServer } from "./entry.ts";
|
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"];
|
const environmentFileNames = [".env", ".env.local", ".env.development", ".env.development.local"];
|
||||||
|
|
||||||
@@ -41,13 +44,6 @@ const loadEnvironmentFiles = (): string[] => {
|
|||||||
return existingFiles.map((filePath) => relative(projectRoot, filePath));
|
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 startDevelopmentServer = async (): Promise<void> => {
|
||||||
const loadedFiles = loadEnvironmentFiles();
|
const loadedFiles = loadEnvironmentFiles();
|
||||||
@@ -56,19 +52,15 @@ const startDevelopmentServer = async (): Promise<void> => {
|
|||||||
? `[env] loaded ${loadedFiles.join(", ")}`
|
? `[env] loaded ${loadedFiles.join(", ")}`
|
||||||
: "[env] no environment file loaded",
|
: "[env] no environment file loaded",
|
||||||
);
|
);
|
||||||
await startServer(
|
const oneTalkDiagnostic = isOneTalkDiagnosticFileLogEnabled(process.env.ONETALK_DIAGNOSTIC_LOG)
|
||||||
(event) => {
|
? createOneTalkDiagnosticFileLogger({
|
||||||
const heartbeatText = heartbeatDiagnosticText(event);
|
logsDirectory: resolve(projectRoot, "logs"),
|
||||||
if (heartbeatText !== undefined) {
|
buildHash: process.env.BUILD_HASH,
|
||||||
console.info(`[onetalk][diagnostic] ${heartbeatText}`);
|
})
|
||||||
return;
|
: undefined;
|
||||||
}
|
await startServer(oneTalkDiagnostic, (event) => {
|
||||||
console.info("[onetalk][diagnostic]", JSON.stringify(event));
|
console.info("[mind-auth][diagnostic]", JSON.stringify(event));
|
||||||
},
|
});
|
||||||
(event) => {
|
|
||||||
console.info("[mind-auth][diagnostic]", JSON.stringify(event));
|
|
||||||
},
|
|
||||||
);
|
|
||||||
};
|
};
|
||||||
|
|
||||||
void startDevelopmentServer().catch((error: unknown) => {
|
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",
|
||||||
|
);
|
||||||
|
};
|
||||||
|
};
|
||||||
@@ -13,6 +13,7 @@ export type OneTalkDiagnostic = {
|
|||||||
code?: string;
|
code?: string;
|
||||||
closeCode?: number;
|
closeCode?: number;
|
||||||
closeReason?: string;
|
closeReason?: string;
|
||||||
|
payload?: unknown;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type OneTalkDiagnosticsSink = (event: OneTalkDiagnostic) => void;
|
export type OneTalkDiagnosticsSink = (event: OneTalkDiagnostic) => void;
|
||||||
|
|||||||
@@ -142,6 +142,7 @@ const sendFrame = (
|
|||||||
connectionType: frame.connectionType,
|
connectionType: frame.connectionType,
|
||||||
frameType: frame.type,
|
frameType: frame.type,
|
||||||
direction: "outbound",
|
direction: "outbound",
|
||||||
|
payload: frame.payload,
|
||||||
...(code === undefined ? {} : { code }),
|
...(code === undefined ? {} : { code }),
|
||||||
});
|
});
|
||||||
if (socket.readyState !== WEBSOCKET_OPEN) {
|
if (socket.readyState !== WEBSOCKET_OPEN) {
|
||||||
@@ -240,6 +241,7 @@ export const createOneTalkWebSocketHandler =
|
|||||||
requestId: frame.requestId,
|
requestId: frame.requestId,
|
||||||
connectionType: frame.connectionType,
|
connectionType: frame.connectionType,
|
||||||
frameType: frame.type,
|
frameType: frame.type,
|
||||||
|
payload: frame.payload,
|
||||||
}),
|
}),
|
||||||
reportDecision: (frame, code) =>
|
reportDecision: (frame, code) =>
|
||||||
reportDiagnostic(options.onDiagnostic, {
|
reportDiagnostic(options.onDiagnostic, {
|
||||||
@@ -247,6 +249,7 @@ export const createOneTalkWebSocketHandler =
|
|||||||
requestId: frame.requestId,
|
requestId: frame.requestId,
|
||||||
connectionType: frame.connectionType,
|
connectionType: frame.connectionType,
|
||||||
frameType: frame.type,
|
frameType: frame.type,
|
||||||
|
payload: frame.payload,
|
||||||
code,
|
code,
|
||||||
}),
|
}),
|
||||||
};
|
};
|
||||||
@@ -285,6 +288,7 @@ export const createOneTalkWebSocketHandler =
|
|||||||
connectionType: frame.connectionType,
|
connectionType: frame.connectionType,
|
||||||
frameType: frame.type,
|
frameType: frame.type,
|
||||||
direction: "inbound",
|
direction: "inbound",
|
||||||
|
payload: frame.payload,
|
||||||
}),
|
}),
|
||||||
onUnauthenticatedFrame: (_socket, frame) => {
|
onUnauthenticatedFrame: (_socket, frame) => {
|
||||||
sendError(frame, ONETALK_ERROR_CODES.authRequired);
|
sendError(frame, ONETALK_ERROR_CODES.authRequired);
|
||||||
|
|||||||
@@ -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 diagnostics: Record<string, unknown>[] = [];
|
||||||
const app = createApp(testConfig, {
|
const app = createApp(testConfig, {
|
||||||
database: createDatabaseStub(),
|
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",
|
(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);
|
assert.equal(JSON.stringify(diagnostics).includes("device-1"), false);
|
||||||
} finally {
|
} finally {
|
||||||
await closeApp(app, [socket]);
|
await closeApp(app, [socket]);
|
||||||
|
|||||||
@@ -1,10 +1,57 @@
|
|||||||
// 验证 WebSocket 通用诊断不含旧资料投递契约
|
// 验证 WebSocket 通用诊断不含旧资料投递契约
|
||||||
|
|
||||||
import assert from "node:assert/strict";
|
import assert from "node:assert/strict";
|
||||||
|
import { mkdtempSync, readFileSync, rmSync } from "node:fs";
|
||||||
|
import { join } from "node:path";
|
||||||
import test from "node:test";
|
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";
|
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", () => {
|
test("does not accept removed profile delivery diagnostic fields", () => {
|
||||||
const lifecycleDiagnostic: OneTalkDiagnostic = {
|
const lifecycleDiagnostic: OneTalkDiagnostic = {
|
||||||
event: "ws_close",
|
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",
|
||||||
|
});
|
||||||
@@ -1,6 +1,12 @@
|
|||||||
// 暴露 OneTalk 跨包公共协议边界
|
// 暴露 OneTalk 跨包公共协议边界
|
||||||
|
|
||||||
export { isOneTalkAvatarUrl, isPlainRecord } from "./guards.ts";
|
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 "./authorization.ts";
|
||||||
export * from "./content.ts";
|
export * from "./content.ts";
|
||||||
export * from "./decoder.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",
|
||||||
|
);
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user