Files
fusion/packages/core/src/usage-events.ts
gsxdsm cca13737b6 FN-8603: reduce steady-state diagnostic log noise
Route routine core, engine, and dashboard diagnostics through debug-gated shared loggers.

- Demote steady-state diagnostic sites while preserving warnings and errors for actionable failures.
- Add cross-package severity contracts and manifest coverage for demoted log sites.
- Document logging severity guidance and add a patch changeset.

Files changed:
 .changeset/fn-8603-log-severity.md                 |  7 ++
 docs/diagnostics.md                                | 20 ++++--
 .../__tests__/log-severity-spam-contract.test.ts   | 71 ++++++++++++++++++
 packages/core/src/activity-analytics.ts            |  5 +-
 packages/core/src/ai-summarize.ts                  | 61 +++++++---------
 packages/core/src/async-mission-store.ts           |  5 +-
 packages/core/src/async-secrets-store.ts           |  7 +-
 packages/core/src/central-core.ts                  | 17 ++---
 packages/core/src/docker-provisioning.ts           | 13 ++--
 packages/core/src/index.ts                         |  1 +
 packages/core/src/master-key.ts                    |  9 ++-
 packages/core/src/memory-compaction.ts             | 29 ++++----
 packages/core/src/memory-insights.ts               |  7 +-
 packages/core/src/migration-orchestrator.ts        |  7 +-
 packages/core/src/mission-store.ts                 |  5 +-
 packages/core/src/node-discovery.ts                |  7 +-
 packages/core/src/notification/dispatcher.ts       |  9 ++-
 .../core/src/plugins/bundled-plugin-install.ts     | 11 +--
 packages/core/src/reflection-store.ts              |  5 +-
 packages/core/src/secrets-store.ts                 |  7 +-
 packages/core/src/task-store/agent-logs.ts         | 21 +++---
 packages/core/src/task-store/async-events.ts       |  5 +-
 packages/core/src/task-store/async-maintenance.ts  |  7 +-
 packages/core/src/task-store/comments-ops.ts       |  7 +-
 packages/core/src/task-store/task-mutation-ops.ts  | 11 +--
 packages/core/src/task-store/workflow-integrity.ts |  9 ++-
 packages/core/src/types/merge-policy.ts            |  5 +-
 packages/core/src/usage-events.ts                  |  5 +-
 .../__tests__/log-severity-spam-contract.test.ts   | 48 +++++++++++++
 packages/dashboard/src/ai-refine.ts                |  5 +-
 packages/dashboard/src/ai-session-diagnostics.ts   | 10 +--
 packages/dashboard/src/chat.ts                     |  8 ++-
 packages/dashboard/src/devserver-manager.ts        |  9 ++-
 packages/dashboard/src/file-service.ts             |  5 +-
 packages/dashboard/src/github-tracking-comments.ts |  7 +-
 .../dashboard/src/github-tracking-reconciler.ts    |  5 +-
 packages/dashboard/src/github-tracking-state.ts    |  5 +-
 packages/dashboard/src/gitlab-lifecycle.ts         |  5 +-
 packages/dashboard/src/insights-routes.ts          |  9 ++-
 packages/dashboard/src/issue-image-attachments.ts  |  5 +-
 packages/dashboard/src/knowledge-index.ts          |  5 +-
 packages/dashboard/src/plugin-routes.ts            |  7 +-
 packages/dashboard/src/routes/board-workflows.ts   |  5 +-
 packages/dashboard/src/routes/context.ts           |  5 +-
 .../dashboard/src/routes/register-auth-routes.ts   | 13 ++--
 .../routes/register-docker-provisioning-routes.ts  |  7 +-
 .../dashboard/src/routes/register-git-github.ts    | 21 +++---
 packages/dashboard/src/routes/register-gitlab.ts   |  7 +-
 .../src/routes/register-session-diff-routes.ts     |  9 ++-
 .../src/routes/register-settings-memory-routes.ts  |  7 +-
 .../src/routes/register-setup-activity-routes.ts   |  7 +-
 .../dashboard/src/routes/register-signal-routes.ts |  5 +-
 .../src/routes/register-task-workflow-routes.ts    | 11 +--
 packages/dashboard/src/runtime-logger.ts           | 11 +--
 packages/dashboard/src/server.ts                   |  7 +-
 packages/dashboard/src/sse.ts                      |  8 ++-
 packages/dashboard/src/terminal-service.ts         | 34 ++++-----
 packages/dashboard/src/view-chunk-manifest.ts      |  5 +-
 .../engine/src/__tests__/log-severity-manifest.ts  | 83 ++++++++++++++++++++++
 .../__tests__/log-severity-spam-contract.test.ts   | 40 ++++++++++-
 .../src/__tests__/logger-debug-gating.test.ts      |  7 +-
 packages/engine/src/goal-anchoring-audit.ts        |  5 +-
 packages/engine/src/plugin-runner.ts               | 44 ++++++------
 packages/engine/src/pty-native.ts                  |  9 ++-
 .../engine/src/runtimes/child-process-worker.ts    |  4 +-
 packages/engine/src/self-healing.ts                | 12 ++--
 packages/engine/src/worktree-hooks.ts              | 10 ++-
 67 files changed, 632 insertions(+), 250 deletions(-)

Fusion-Task-Id: FN-8603

Fusion-Task-Lineage: 53901db6-1af2-4bd7-b5ea-49507e048ef2

Co-authored-by: Fusion (runfusion.ai) <noreply@runfusion.ai>
2026-07-26 12:01:19 -07:00

347 lines
11 KiB
TypeScript

import { createLogger } from "./logger.js";
const severityAuditLog = createLogger("core-usage-events");
import type { Database } from "./db.js";
/**
* Queryable telemetry of agent activity (tool calls, messages, session
* lifecycle), persisted to the `usage_events` table (db.ts schema). This is the
* normalized source the Command Center analytics layer reads from, so it does
* not have to parse per-task JSONL agent logs at query time.
*
* Events are appended via {@link emitUsageEvent} from the executor/session layer
* where `model`/`provider`/`nodeId`/`category` are already in scope (see
* KTD3/U1). The append helper is intentionally fail-soft: a malformed event or a
* write error is swallowed so it never aborts the underlying agent-log write or
* the agent hot path.
*/
/**
* The kind of activity an event records.
*
* - `tool_call` — an agent invoked a tool (agent-log `type: "tool"` maps here;
* `AgentLogType` has no `tool_call` member).
* - `tool_result` / `tool_error` — the tool completed / failed.
* - `user_message` — a human-authored message (chat/CLI sessions).
* - `session_start` / `session_stop` — session lifecycle.
*/
export type UsageEventKind =
| "tool_call"
| "tool_result"
| "tool_error"
| "user_message"
| "session_start"
| "session_stop";
const USAGE_EVENT_KINDS: ReadonlySet<string> = new Set<UsageEventKind>([
"tool_call",
"tool_result",
"tool_error",
"user_message",
"session_start",
"session_stop",
]);
/**
* Maximum serialized byte size of a `meta` payload. Events whose `meta`
* exceeds this cap are rejected at write (the whole event is skipped) rather
* than truncated, so an oversized payload can never silently land partial data.
*/
export const USAGE_EVENT_META_MAX_BYTES = 4096;
/** An event to append to `usage_events`. */
export interface UsageEventInput {
kind: UsageEventKind;
/** ISO-8601 timestamp. Defaults to now when omitted. */
ts?: string;
taskId?: string | null;
agentId?: string | null;
/** Workflow/session node this event belongs to; null when no node context. */
nodeId?: string | null;
model?: string | null;
provider?: string | null;
toolName?: string | null;
category?: string | null;
/**
* Non-sensitive descriptors only (error code, category, duration). NEVER tool
* arguments/content or credential-class fields. Capped at
* {@link USAGE_EVENT_META_MAX_BYTES}; over the cap, the event is rejected.
*/
meta?: Record<string, unknown> | null;
}
/** A row read back from `usage_events`. */
export interface UsageEvent {
id: number;
ts: string;
kind: UsageEventKind;
taskId: string | null;
agentId: string | null;
nodeId: string | null;
model: string | null;
provider: string | null;
toolName: string | null;
category: string | null;
meta: Record<string, unknown> | null;
}
interface UsageEventRow {
id: number;
ts: string;
kind: string;
taskId: string | null;
agentId: string | null;
nodeId: string | null;
model: string | null;
provider: string | null;
toolName: string | null;
category: string | null;
meta: string | null;
}
/**
* Coarse tool category derived from a tool name, for the Tools analytics area.
* Pure and side-effect free; callers may also pass an explicit `category`.
*
* FNXC:CommandCenter 2026-06-17-21:35:
* Fusion agents mostly call namespaced `fn_*` tools, so Command Center analytics must bucket those families meaningfully instead of letting the Tools chart collapse into `other`.
* Keep this mapping pure and lowercase-normalized because it is used both at log-write time and when re-bucketing historical rows.
*/
export function categorizeToolName(toolName: string | null | undefined): string {
if (!toolName) return "other";
const name = toolName.trim().toLowerCase();
if (!name) return "other";
if (name.startsWith("fn_task_import_github") || name.startsWith("fn_task_browse_github")) {
return "github";
}
if (name.startsWith("fn_task_import_gitlab") || name.startsWith("fn_task_browse_gitlab")) {
return "gitlab";
}
if (name === "fn_web_fetch") return "network";
if (name.startsWith("fn_secret_")) return "secrets";
if (name.startsWith("fn_skills_")) return "skills";
if (name.startsWith("fn_memory_")) return "memory";
if (name === "fn_list_agents" || name === "fn_agent_org_chart") return "read";
if (name.startsWith("fn_agent_") || name === "fn_delegate_task" || name === "fn_task_assign") return "agents";
if (
name.startsWith("fn_mission_") ||
name.startsWith("fn_milestone_") ||
name.startsWith("fn_slice_") ||
name.startsWith("fn_feature_") ||
name.startsWith("fn_goal_") ||
name === "fn_task_plan"
) {
return "planning";
}
if (name.startsWith("fn_research_") || name.startsWith("fn_insight_") || name.startsWith("fn_experiment_")) {
return "research";
}
if (name.startsWith("fn_workflow_") || name === "fn_review_spec") return "workflow";
if (
name === "read" ||
name === "grep" ||
name === "glob" ||
name === "ls" ||
name.includes("search") ||
name === "fn_list_agents" ||
name === "fn_agent_org_chart" ||
name === "fn_task_document_read" ||
name.endsWith("_list") ||
name.endsWith("_show") ||
name.endsWith("_get") ||
name.endsWith("_search")
) {
return "read";
}
if (
name === "edit" ||
name === "write" ||
name === "multiedit" ||
name.includes("notebook") ||
name === "fn_task_create" ||
name === "fn_task_update" ||
name === "fn_task_attach" ||
name === "fn_task_pause" ||
name === "fn_task_unpause" ||
name === "fn_task_retry" ||
name === "fn_task_duplicate" ||
name === "fn_task_refine" ||
name === "fn_task_archive" ||
name === "fn_task_unarchive" ||
name === "fn_task_delete" ||
name === "fn_task_document_write"
) {
return "edit";
}
if (name === "bash" || name.includes("exec") || name.includes("command") || name.includes("terminal")) {
return "execute";
}
if (name.includes("web") || name.includes("fetch") || name.includes("http")) {
return "network";
}
return "other";
}
/**
* Validate and serialize a `meta` payload. Returns the serialized JSON string,
* or throws if it exceeds the byte cap. `null`/`undefined` serialize to `null`.
*/
function serializeMeta(meta: Record<string, unknown> | null | undefined): string | null {
if (meta === undefined || meta === null) return null;
const serialized = JSON.stringify(meta);
if (serialized === undefined) return null;
if (Buffer.byteLength(serialized, "utf8") > USAGE_EVENT_META_MAX_BYTES) {
throw new Error(
`usage_events meta payload exceeds ${USAGE_EVENT_META_MAX_BYTES} bytes (got ${Buffer.byteLength(serialized, "utf8")})`,
);
}
return serialized;
}
/**
* Append a single usage event. **Fail-soft**: a malformed event (unknown kind),
* an oversized `meta`, or any DB error is logged and swallowed — it must never
* throw, so it cannot abort the underlying agent-log write or the hot path.
*
* @returns `true` if the row was inserted, `false` if the event was skipped.
*/
export function emitUsageEvent(db: Database, event: UsageEventInput): boolean {
try {
if (!event || !USAGE_EVENT_KINDS.has(event.kind)) {
return false;
}
const ts = event.ts ?? new Date().toISOString();
const meta = serializeMeta(event.meta);
db.prepare(
`INSERT INTO usage_events
(ts, kind, taskId, agentId, nodeId, model, provider, toolName, category, meta)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
).run(
ts,
event.kind,
event.taskId ?? null,
event.agentId ?? null,
event.nodeId ?? null,
event.model ?? null,
event.provider ?? null,
event.toolName ?? null,
event.category ?? null,
meta,
);
return true;
} catch (err) {
severityAuditLog.warn("[fusion] emitUsageEvent skipped a malformed/failed event:", err);
return false;
}
}
/** Filters for {@link queryUsageEvents}. All bounds are inclusive. */
export interface UsageEventRangeQuery {
/** ISO-8601 lower bound (inclusive). */
from?: string;
/** ISO-8601 upper bound (inclusive). */
to?: string;
kind?: UsageEventKind;
taskId?: string;
agentId?: string;
}
function rowToUsageEvent(row: UsageEventRow): UsageEvent {
let meta: Record<string, unknown> | null = null;
if (row.meta) {
try {
meta = JSON.parse(row.meta) as Record<string, unknown>;
} catch {
meta = null;
}
}
return {
id: row.id,
ts: row.ts,
kind: row.kind as UsageEventKind,
taskId: row.taskId,
agentId: row.agentId,
nodeId: row.nodeId,
model: row.model,
provider: row.provider,
toolName: row.toolName,
category: row.category,
meta,
};
}
/**
* Range-scan `usage_events` ordered by timestamp ascending. Mirrors the
* windowed-scan shape of `agent-token-usage.ts`, generalized to an arbitrary
* `(from, to)` range with optional kind/task/agent filters.
*/
export function queryUsageEvents(db: Database, query: UsageEventRangeQuery = {}): UsageEvent[] {
const clauses: string[] = [];
const params: Array<string> = [];
if (query.from !== undefined) {
clauses.push("ts >= ?");
params.push(query.from);
}
if (query.to !== undefined) {
clauses.push("ts <= ?");
params.push(query.to);
}
if (query.kind !== undefined) {
clauses.push("kind = ?");
params.push(query.kind);
}
if (query.taskId !== undefined) {
clauses.push("taskId = ?");
params.push(query.taskId);
}
if (query.agentId !== undefined) {
clauses.push("agentId = ?");
params.push(query.agentId);
}
const where = clauses.length > 0 ? `WHERE ${clauses.join(" AND ")}` : "";
const rows = db
.prepare(`SELECT * FROM usage_events ${where} ORDER BY ts ASC, id ASC`)
.all(...params) as UsageEventRow[];
return rows.map(rowToUsageEvent);
}
/**
* Count `usage_events` grouped by a single column over a range. Convenience for
* the analytics aggregators (e.g. tool calls by `category`).
*/
export function countUsageEventsBy(
db: Database,
column: "kind" | "category" | "toolName" | "model" | "provider" | "nodeId" | "agentId",
query: UsageEventRangeQuery = {},
): Array<{ key: string | null; count: number }> {
const clauses: string[] = [];
const params: Array<string> = [];
if (query.from !== undefined) {
clauses.push("ts >= ?");
params.push(query.from);
}
if (query.to !== undefined) {
clauses.push("ts <= ?");
params.push(query.to);
}
if (query.kind !== undefined) {
clauses.push("kind = ?");
params.push(query.kind);
}
if (query.taskId !== undefined) {
clauses.push("taskId = ?");
params.push(query.taskId);
}
if (query.agentId !== undefined) {
clauses.push("agentId = ?");
params.push(query.agentId);
}
const where = clauses.length > 0 ? `WHERE ${clauses.join(" AND ")}` : "";
const rows = db
.prepare(`SELECT ${column} AS key, COUNT(*) AS count FROM usage_events ${where} GROUP BY ${column}`)
.all(...params) as Array<{ key: string | null; count: number }>;
return rows;
}