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:
@@ -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 () => {
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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) ──────────────────────────
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user