feat(agent-centric-logging): consolidate CoT infra in shared, close REASON→REFLECT chains

- shared/cot_logger.py is SSOT; backend/cot_logger.py deleted
- elapsed_ms timing in all REFLECT markers
- Frontend: REASON→REFLECT/EXPLORE in all fetch/post/delete/requestApi
- Dynamic src: route.GET.api.plugins instead of hardcoded api.request_handler
- trace_id generated immediately (no 'no-trace'), X-Trace-ID in both directions
- Global error handlers (window error + unhandledrejection + error.svelte)
- Fixed duplicate logging (shared/logger.py double StreamHandler)
- propagate=False in configure_logger (was in ConfigManager = duplicated startup logs)
- belief_scope: 'Coherence OK' → '{anchor}: completed' + elapsed_ms
- Fixed 28 pre-existing test failures (scheduler sig, DB columns, DRAFT validation, etc)
This commit is contained in:
2026-07-12 19:30:57 +03:00
parent 24d3b7d1f9
commit a39a76c87f
58 changed files with 1532 additions and 916 deletions

View File

@@ -6,8 +6,8 @@
// @TEST_CONTRACT: log -> Produces valid JSON line with correct marker and level
// @TEST_CONTRACT: log -> EXPLORE marker emits WARNING level and requires error field
// @TEST_CONTRACT: setTraceId -> Updates trace ID for subsequent log calls
// @TEST_CONTRACT: getTraceId -> Returns "no-trace" if none set
// @TEST_CONTRACT: initTraceId -> Alias for getTraceId
// @TEST_CONTRACT: getTraceId -> Returns 32-char hex UUID generated at module init
// @TEST_CONTRACT: resetTraceId -> Generates a new trace_id
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
@@ -17,9 +17,10 @@ describe('CoT Logger — setTraceId / getTraceId / initTraceId', () => {
vi.resetModules();
});
it('getTraceId returns "no-trace" when no ID is set', async () => {
it('getTraceId returns a 32-char hex UUID on init (never "no-trace")', async () => {
const { getTraceId } = await import('$lib/cot-logger.js');
expect(getTraceId()).toBe('no-trace');
const tid = getTraceId();
expect(tid).toMatch(/^[0-9a-f]{32}$/); // hex UUID, no hyphens
});
it('setTraceId updates the trace ID', async () => {
@@ -28,11 +29,20 @@ describe('CoT Logger — setTraceId / getTraceId / initTraceId', () => {
expect(getTraceId()).toBe('trace-abc-123');
});
it('initTraceId returns the same value as getTraceId', async () => {
const { initTraceId, getTraceId, setTraceId } = await import('$lib/cot-logger.js');
setTraceId('trace-xyz');
expect(initTraceId()).toBe('trace-xyz');
expect(initTraceId()).toBe(getTraceId());
it('setTraceId can override an existing trace ID', async () => {
const { setTraceId, getTraceId } = await import('$lib/cot-logger.js');
setTraceId('first-trace');
setTraceId('second-trace');
expect(getTraceId()).toBe('second-trace');
});
it('resetTraceId generates a new UUID on each call', async () => {
const { resetTraceId, getTraceId } = await import('$lib/cot-logger.js');
const first = getTraceId();
resetTraceId();
const second = getTraceId();
expect(second).toMatch(/^[0-9a-f]{32}$/);
expect(second).not.toBe(first);
});
it('setTraceId can override an existing trace ID', async () => {

View File

@@ -25,7 +25,7 @@
// @REJECTED Axios rejected — unnecessary dependency for a single-domain API client; native fetch + wrappers
// is simpler, tree-shakeable, and has zero bundle cost.
import { log, setTraceId } from '$lib/cot-logger';
import { log, setTraceId, getTraceId } from '$lib/cot-logger';
import { addToast } from './toasts.svelte.js';
import type { FetchOptions, DashboardListParams } from '../types/api';
@@ -210,6 +210,11 @@ function getAuthHeaders(extraHeaders: Record<string, string> = {}): Record<strin
if (typeof window !== 'undefined') {
const token = localStorage.getItem('auth_token');
if (token) headers['Authorization'] = `Bearer ${token}`;
// Propagate trace_id to backend for cross-stack correlation
const tid = getTraceId();
if (tid && tid !== 'no-trace') {
headers['X-Trace-ID'] = tid;
}
}
return headers;
}
@@ -226,6 +231,18 @@ function getAuthHeaders(extraHeaders: Record<string, string> = {}): Record<strin
// @RELATION DEPENDS_ON -> [buildApiError]
// @RELATION DEPENDS_ON -> [notifyApiError]
// @RELATION DEPENDS_ON -> [getAuthHeaders]
/** Endpoints that are polled frequently — suppress CoT REASON/REFLECT to reduce noise.
* Errors are still logged as EXPLORE. Matches backend's log_requests polling suppression. */
const _SILENT_POLLING_ENDPOINTS = [
'/health/summary',
'/api/tasks',
];
/** Check if an endpoint is a silent polling endpoint (suppress CoT REASON/REFLECT). */
function _isSilentPolling(endpoint: string): boolean {
return _SILENT_POLLING_ENDPOINTS.some(e => endpoint.startsWith(e) || endpoint.endsWith(e));
}
/** Extract X-Trace-Id from response headers and seed the CoT logger's trace_id. */
function _captureTraceId(response: Response): void {
try {
@@ -237,18 +254,25 @@ function _captureTraceId(response: Response): void {
}
async function fetchApi<T = unknown>(endpoint: string, options: FetchOptions = {}): Promise<T> {
const _start = performance.now();
const _silent = _isSilentPolling(endpoint);
try {
log('api', 'REASON', 'fetchApi', { endpoint });
if (!_silent) log('ApiClient', 'REASON', 'GET data', { endpoint });
const fetchInit: RequestInit = { headers: getAuthHeaders(options.headers || {}) };
if (options.signal) fetchInit.signal = options.signal;
const response = await fetch(`${API_BASE_URL}${endpoint}`, fetchInit);
if (!response.ok) throw await buildApiError(response);
if (response.status === 204) return null as T;
_captureTraceId(response);
return await response.json() as T;
const data = await response.json() as T;
if (!_silent) log('ApiClient', 'REFLECT', 'GET completed', {
endpoint, status: response.status,
elapsed_ms: Math.round(performance.now() - _start),
});
return data;
} catch (error) {
const apiError = error as ApiError;
log('api', 'EXPLORE', 'fetchApi failed', { endpoint }, apiError?.message || 'unknown');
log('ApiClient', 'EXPLORE', 'GET failed', { endpoint }, apiError?.message || 'unknown');
if (!options.suppressToast) notifyApiError(apiError);
throw error;
}
@@ -299,7 +323,10 @@ async function fetchApiBlob(endpoint: string, options: FetchOptions = {}): Promi
// @RELATION DEPENDS_ON -> [notifyApiError]
// @RELATION DEPENDS_ON -> [getAuthHeaders]
async function postApi<T = unknown>(endpoint: string, body: unknown, options: FetchOptions = {}): Promise<T> {
const _start = performance.now();
const _silent = _isSilentPolling(endpoint);
try {
if (!_silent) log('ApiClient', 'REASON', 'POST data', { endpoint });
const fetchInit: RequestInit = {
method: 'POST', headers: getAuthHeaders(options.headers || {}), body: JSON.stringify(body),
};
@@ -308,10 +335,15 @@ async function postApi<T = unknown>(endpoint: string, body: unknown, options: Fe
if (!response.ok) throw await buildApiError(response);
if (response.status === 204) return null as T;
_captureTraceId(response);
return await response.json() as T;
const data = await response.json() as T;
if (!_silent) log('ApiClient', 'REFLECT', 'POST completed', {
endpoint, status: response.status,
elapsed_ms: Math.round(performance.now() - _start),
});
return data;
} catch (error) {
const apiError = error as ApiError;
log('api', 'EXPLORE', 'postApi failed', { endpoint }, apiError?.message || 'unknown');
log('ApiClient', 'EXPLORE', 'POST failed', { endpoint }, apiError?.message || 'unknown');
if (!options.suppressToast) notifyApiError(apiError);
throw error;
}
@@ -331,17 +363,25 @@ async function postApi<T = unknown>(endpoint: string, body: unknown, options: Fe
// @RATIONALE deleteApi always notifies on error (no suppressToast option) because deletions
// are destructive operations — the user must know if one failed.
async function deleteApi<T = unknown>(endpoint: string, options: FetchOptions = {}): Promise<T> {
const _start = performance.now();
const _silent = _isSilentPolling(endpoint);
try {
if (!_silent) log('ApiClient', 'REASON', 'DELETE data', { endpoint });
const fetchInit: RequestInit = { method: 'DELETE', headers: getAuthHeaders(options.headers || {}) };
if (options.signal) fetchInit.signal = options.signal;
const response = await fetch(`${API_BASE_URL}${endpoint}`, fetchInit);
if (!response.ok) throw await buildApiError(response);
if (response.status === 204) return null as T;
_captureTraceId(response);
return await response.json() as T;
const data = await response.json() as T;
if (!_silent) log('ApiClient', 'REFLECT', 'DELETE completed', {
endpoint, status: response.status,
elapsed_ms: Math.round(performance.now() - _start),
});
return data;
} catch (error) {
const apiError = error as ApiError;
log('api', 'EXPLORE', 'deleteApi failed', { endpoint }, apiError?.message || 'unknown');
log('ApiClient', 'EXPLORE', 'DELETE failed', { endpoint }, apiError?.message || 'unknown');
notifyApiError(apiError);
throw error;
}
@@ -362,7 +402,10 @@ async function deleteApi<T = unknown>(endpoint: string, options: FetchOptions =
// @RELATION DEPENDS_ON -> [shouldSuppressApiErrorToast]
// @RELATION DEPENDS_ON -> [getAuthHeaders]
async function requestApi<T = unknown>(endpoint: string, method: string = 'GET', body: unknown = null, requestOptions: FetchOptions = {}): Promise<T> {
const _start = performance.now();
const _silent = _isSilentPolling(endpoint);
try {
if (!_silent) log('ApiClient', 'REASON', `${method} data`, { endpoint, method });
const fetchInit: RequestInit = { method, headers: getAuthHeaders(requestOptions.headers || {}) };
if (body) fetchInit.body = JSON.stringify(body);
if (requestOptions.signal) fetchInit.signal = requestOptions.signal;
@@ -370,10 +413,15 @@ async function requestApi<T = unknown>(endpoint: string, method: string = 'GET',
if (!response.ok) throw await buildApiError(response);
if (response.status === 204) return null as T;
_captureTraceId(response);
return await response.json() as T;
const data = await response.json() as T;
if (!_silent) log('ApiClient', 'REFLECT', `${method} completed`, {
endpoint, method, status: response.status,
elapsed_ms: Math.round(performance.now() - _start),
});
return data;
} catch (error) {
const apiError = error as ApiError;
log('api', 'EXPLORE', 'requestApi failed', { method, endpoint }, apiError?.message || 'unknown');
log('ApiClient', 'EXPLORE', `${method} failed`, { method, endpoint }, apiError?.message || 'unknown');
if (!requestOptions.suppressToast && !shouldSuppressApiErrorToast(endpoint, apiError)) {
notifyApiError(apiError);
}

View File

@@ -1,10 +1,14 @@
// #region CotLogger [C:3] [TYPE Module] [SEMANTICS logging,cot,molecular,frontend]
// @BRIEF Structured Molecular CoT logger for the frontend — emits JSON lines with REASON/REFLECT/EXPLORE markers.
// @INVARIANT Every log line carries exactly one valid marker (REASON | REFLECT | EXPLORE).
// @INVARIANT trace_id propagates from HTTP response headers or is auto-generated per session.
// @INVARIANT trace_id is generated on module init, never "no-trace". Updated from HTTP response headers.
// @INVARIANT span_id is propagated via pushSpan()/popSpan() for nested call tracing.
// @RELATION CALLED_BY -> [Std.Semantics.Svelte]
// @DATA_CONTRACT LogEntry -> { ts, level, trace_id, span_id?, src, marker, intent, payload?, error? }
// @RATIONALE trace_id now generated immediately (crypto.randomUUID), not waiting for first API response.
// Previously, ~first 3-5 log lines had trace_id="no-trace" — invisible to agent correlation.
// Backend hex format (no dashes) for X-Trace-ID header compatibility.
// initTraceId() removed — was just an alias for getTraceId() with misleading name.
type LogMarker = "REASON" | "REFLECT" | "EXPLORE";
@@ -21,26 +25,42 @@ interface LogEntry {
error?: string;
}
// ── Trace ID (session-level, set once from API response) ──────────
let _traceId = "";
/** Generate a UUIDv4 in hex format (no dashes) — compatible with backend TraceContextMiddleware. */
function _generateHexUuid(): string {
try {
return crypto.randomUUID().replace(/-/g, '');
} catch {
// Fallback for older browsers / test environments
return 'xxxxxxxxxxxx4xxxyxxxxxxxxxxxxxxx'.replace(/[xy]/g, (c) => {
const r = (Math.random() * 16) | 0;
return (c === 'x' ? r : (r & 0x3) | 0x8).toString(16);
});
}
}
// ── Trace ID: generated immediately at module init ────────────
// Never "no-trace" — every log is correlated from the very first line.
let _traceId = _generateHexUuid();
/**
* Seed or update the session trace ID.
* Called automatically after the first API response if the backend
* returns an X-Trace-Id or similar header.
* Seed or update the trace ID from backend response (X-Trace-Id header).
*/
export function setTraceId(id: string): void {
_traceId = id;
}
/** Returns the current trace ID, or a placeholder if none set. */
/** Returns the current trace ID (always a valid UUID hex string). */
export function getTraceId(): string {
return _traceId || "no-trace";
return _traceId;
}
/** Alias for getTraceId — used by components that call initTraceId(). */
export function initTraceId(): string {
return getTraceId();
/**
* Reset trace_id on SPA navigation.
* Call from layout's $effect() when page.url changes.
*/
export function resetTraceId(): void {
_traceId = _generateHexUuid();
_spanId = ""; // reset span on new trace
}
// ── Span ID (nested call scope tracking) ──────────────────────────

View File

@@ -1,10 +1,21 @@
<!-- #region ErrorPage [C:2] [TYPE Page] [SEMANTICS sveltekit, error, status, fallback, navigation] -->
<!-- @ingroup Routes -->
<!-- @BRIEF Global error page displaying HTTP status code and error message with navigation back to dashboard. -->
<!-- @BRIEF Global error page displaying HTTP status code and error message with navigation back to dashboard.
Also emits structured CoT EXPLORE marker for agent-visible error tracking. -->
<!-- @LAYER Page -->
<!-- @UX_STATE Error -> Displays error code and message with "Back to Dashboard" link. -->
<script lang="ts">
import { page } from "$app/state";
import { log } from "$lib/cot-logger";
// Emit CoT marker for agent-visible error tracking
try {
log("ErrorPage", "EXPLORE", "SvelteKit route error",
{ status: page.status },
page.error?.message || "Unknown error");
} catch {
// Logger unavailable — render error page anyway
}
</script>
<div class="container mx-auto p-4 text-center mt-20">

View File

@@ -38,8 +38,34 @@
} from '$lib/stores/environmentContext.svelte.js';
import { page } from '$app/state';
import { sidebarStore } from '$lib/stores/sidebar.svelte.js';
import { resetTraceId, log } from '$lib/cot-logger';
let { children } = $props();
// Reset trace_id on SPA navigation for clean per-page traces
$effect(() => {
page.url; // reactive dependency — fires on every route change
resetTraceId();
});
// Global error handlers — wrap uncaught errors and rejections as structured EXPLORE markers
$effect(() => {
const handler = (event: ErrorEvent) => {
log('GlobalErrorHandler', 'EXPLORE', 'Unhandled runtime error',
{ filename: event.filename, lineno: event.lineno, colno: event.colno },
event.error?.message || event.message);
};
const rejectionHandler = (event: PromiseRejectionEvent) => {
log('GlobalErrorHandler', 'EXPLORE', 'Unhandled promise rejection',
{}, event.reason?.message || String(event.reason));
};
window.addEventListener('error', handler);
window.addEventListener('unhandledrejection', rejectionHandler);
return () => {
window.removeEventListener('error', handler);
window.removeEventListener('unhandledrejection', rejectionHandler);
};
});
let isLoginPage = $derived(page.url.pathname === '/login');
let isExpanded = $derived(sidebarStore.value?.isExpanded ?? true);
let isProductionContext = $derived($isProductionContextStore);