mirror of
https://github.com/pacnpal/thrilltrack-explorer.git
synced 2025-12-24 04:51:13 -05:00
Connect to Lovable Cloud
Implement distributed tracing across edge functions: - Introduce span types and utilities (logger.ts enhancements) - Replace request tracking with span-based tracing in approval and rejection flows - Propagate and manage W3C trace context in edge tracking - Add span visualization scaffolding (spanVisualizer.ts) and admin TraceViewer UI (TraceViewer.tsx) - Create tracing-related type definitions and support files - Prepare RPC call logging with span context and retries
This commit is contained in:
@@ -75,11 +75,31 @@ export async function invokeWithTracking<T = any>(
|
||||
const controller = new AbortController();
|
||||
const timeoutId = setTimeout(() => controller.abort(), timeout);
|
||||
|
||||
// Generate W3C Trace Context header
|
||||
const effectiveTraceId = context.traceId || crypto.randomUUID();
|
||||
const spanId = crypto.randomUUID();
|
||||
const traceparent = `00-${effectiveTraceId}-${spanId}-01`;
|
||||
|
||||
// Add breadcrumb with trace context
|
||||
breadcrumb.apiCall(
|
||||
`/functions/${functionName}`,
|
||||
'POST',
|
||||
undefined,
|
||||
{ traceId: effectiveTraceId, spanId }
|
||||
);
|
||||
|
||||
try {
|
||||
const { data, error } = await supabase.functions.invoke<T>(functionName, {
|
||||
body: { ...payload, clientRequestId: context.requestId },
|
||||
body: {
|
||||
...payload,
|
||||
clientRequestId: context.requestId,
|
||||
traceId: effectiveTraceId,
|
||||
},
|
||||
signal: controller.signal,
|
||||
headers: customHeaders,
|
||||
headers: {
|
||||
...customHeaders,
|
||||
'traceparent': traceparent,
|
||||
},
|
||||
});
|
||||
|
||||
clearTimeout(timeoutId);
|
||||
@@ -103,7 +123,15 @@ export async function invokeWithTracking<T = any>(
|
||||
}
|
||||
);
|
||||
|
||||
return { data: result, error: null, requestId, duration, attempts: attemptCount, status: 200 };
|
||||
return {
|
||||
data: result,
|
||||
error: null,
|
||||
requestId,
|
||||
duration,
|
||||
attempts: attemptCount,
|
||||
status: 200,
|
||||
traceId,
|
||||
};
|
||||
} catch (error: unknown) {
|
||||
// Handle AbortError specifically
|
||||
if (error instanceof Error && error.name === 'AbortError') {
|
||||
|
||||
150
src/lib/spanVisualizer.ts
Normal file
150
src/lib/spanVisualizer.ts
Normal file
@@ -0,0 +1,150 @@
|
||||
/**
|
||||
* Span Visualizer
|
||||
* Reconstructs span trees from logs for debugging distributed traces
|
||||
*/
|
||||
|
||||
import type { Span } from '@/types/tracing';
|
||||
|
||||
export interface SpanTree {
|
||||
span: Span;
|
||||
children: SpanTree[];
|
||||
totalDuration: number;
|
||||
selfDuration: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Build span tree from flat span logs
|
||||
*/
|
||||
export function buildSpanTree(spans: Span[]): SpanTree | null {
|
||||
const spanMap = new Map<string, Span>();
|
||||
const childrenMap = new Map<string, Span[]>();
|
||||
|
||||
// Index spans
|
||||
for (const span of spans) {
|
||||
spanMap.set(span.spanId, span);
|
||||
|
||||
if (span.parentSpanId) {
|
||||
if (!childrenMap.has(span.parentSpanId)) {
|
||||
childrenMap.set(span.parentSpanId, []);
|
||||
}
|
||||
childrenMap.get(span.parentSpanId)!.push(span);
|
||||
}
|
||||
}
|
||||
|
||||
// Find root span
|
||||
const rootSpan = spans.find(s => !s.parentSpanId);
|
||||
if (!rootSpan) return null;
|
||||
|
||||
// Build tree recursively
|
||||
function buildTree(span: Span): SpanTree {
|
||||
const children = childrenMap.get(span.spanId) || [];
|
||||
const childTrees = children.map(buildTree);
|
||||
|
||||
const totalDuration = span.duration || 0;
|
||||
const childrenDuration = childTrees.reduce((sum, child) => sum + child.totalDuration, 0);
|
||||
const selfDuration = totalDuration - childrenDuration;
|
||||
|
||||
return {
|
||||
span,
|
||||
children: childTrees,
|
||||
totalDuration,
|
||||
selfDuration,
|
||||
};
|
||||
}
|
||||
|
||||
return buildTree(rootSpan);
|
||||
}
|
||||
|
||||
/**
|
||||
* Format span tree as ASCII art
|
||||
*/
|
||||
export function formatSpanTree(tree: SpanTree, indent: number = 0): string {
|
||||
const prefix = ' '.repeat(indent);
|
||||
const status = tree.span.status === 'error' ? '❌' : tree.span.status === 'ok' ? '✅' : '⏳';
|
||||
const line = `${prefix}${status} ${tree.span.name} (${tree.span.duration}ms / self: ${tree.selfDuration}ms)`;
|
||||
|
||||
const childLines = tree.children.map(child => formatSpanTree(child, indent + 1));
|
||||
|
||||
return [line, ...childLines].join('\n');
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculate span statistics for a tree
|
||||
*/
|
||||
export function calculateSpanStats(tree: SpanTree): {
|
||||
totalSpans: number;
|
||||
errorCount: number;
|
||||
maxDepth: number;
|
||||
totalDuration: number;
|
||||
criticalPath: string[];
|
||||
} {
|
||||
let totalSpans = 0;
|
||||
let errorCount = 0;
|
||||
let maxDepth = 0;
|
||||
|
||||
function traverse(node: SpanTree, depth: number) {
|
||||
totalSpans++;
|
||||
if (node.span.status === 'error') errorCount++;
|
||||
maxDepth = Math.max(maxDepth, depth);
|
||||
|
||||
node.children.forEach(child => traverse(child, depth + 1));
|
||||
}
|
||||
|
||||
traverse(tree, 0);
|
||||
|
||||
// Find critical path (longest duration path)
|
||||
function findCriticalPath(node: SpanTree): string[] {
|
||||
if (node.children.length === 0) {
|
||||
return [node.span.name];
|
||||
}
|
||||
|
||||
const longestChild = node.children.reduce((longest, child) =>
|
||||
child.totalDuration > longest.totalDuration ? child : longest
|
||||
);
|
||||
|
||||
return [node.span.name, ...findCriticalPath(longestChild)];
|
||||
}
|
||||
|
||||
return {
|
||||
totalSpans,
|
||||
errorCount,
|
||||
maxDepth,
|
||||
totalDuration: tree.totalDuration,
|
||||
criticalPath: findCriticalPath(tree),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract all events from a span tree
|
||||
*/
|
||||
export function extractAllEvents(tree: SpanTree): Array<{
|
||||
spanName: string;
|
||||
eventName: string;
|
||||
timestamp: number;
|
||||
attributes?: Record<string, unknown>;
|
||||
}> {
|
||||
const events: Array<{
|
||||
spanName: string;
|
||||
eventName: string;
|
||||
timestamp: number;
|
||||
attributes?: Record<string, unknown>;
|
||||
}> = [];
|
||||
|
||||
function traverse(node: SpanTree) {
|
||||
node.span.events.forEach(event => {
|
||||
events.push({
|
||||
spanName: node.span.name,
|
||||
eventName: event.name,
|
||||
timestamp: event.timestamp,
|
||||
attributes: event.attributes,
|
||||
});
|
||||
});
|
||||
|
||||
node.children.forEach(child => traverse(child));
|
||||
}
|
||||
|
||||
traverse(tree);
|
||||
|
||||
// Sort by timestamp
|
||||
return events.sort((a, b) => a.timestamp - b.timestamp);
|
||||
}
|
||||
255
src/pages/admin/TraceViewer.tsx
Normal file
255
src/pages/admin/TraceViewer.tsx
Normal file
@@ -0,0 +1,255 @@
|
||||
import { useState } from 'react';
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Accordion, AccordionContent, AccordionItem, AccordionTrigger } from '@/components/ui/accordion';
|
||||
import { Alert, AlertDescription } from '@/components/ui/alert';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { buildSpanTree, formatSpanTree, calculateSpanStats, extractAllEvents } from '@/lib/spanVisualizer';
|
||||
import type { Span } from '@/types/tracing';
|
||||
import type { SpanTree } from '@/lib/spanVisualizer';
|
||||
|
||||
/**
|
||||
* Admin Trace Viewer
|
||||
*
|
||||
* Visual tool for debugging distributed traces across the approval pipeline.
|
||||
* Reconstructs and displays span hierarchies from edge function logs.
|
||||
*/
|
||||
export default function TraceViewer() {
|
||||
const [traceId, setTraceId] = useState('');
|
||||
const [spans, setSpans] = useState<Span[]>([]);
|
||||
const [tree, setTree] = useState<SpanTree | null>(null);
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const loadTrace = async () => {
|
||||
if (!traceId.trim()) {
|
||||
setError('Please enter a trace ID');
|
||||
return;
|
||||
}
|
||||
|
||||
setIsLoading(true);
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
// TODO: Replace with actual edge function log query
|
||||
// This would need an edge function that queries Supabase logs
|
||||
// For now, using mock data structure
|
||||
const mockSpans: Span[] = [
|
||||
{
|
||||
spanId: 'root-1',
|
||||
traceId,
|
||||
name: 'process-selective-approval',
|
||||
kind: 'SERVER',
|
||||
startTime: Date.now() - 5000,
|
||||
endTime: Date.now(),
|
||||
duration: 5000,
|
||||
attributes: {
|
||||
'http.method': 'POST',
|
||||
'user.id': 'user-123',
|
||||
'submission.id': 'sub-456',
|
||||
},
|
||||
events: [
|
||||
{ timestamp: Date.now() - 4900, name: 'authentication_start' },
|
||||
{ timestamp: Date.now() - 4800, name: 'authentication_success' },
|
||||
{ timestamp: Date.now() - 4700, name: 'validation_complete' },
|
||||
],
|
||||
status: 'ok',
|
||||
},
|
||||
{
|
||||
spanId: 'child-1',
|
||||
traceId,
|
||||
parentSpanId: 'root-1',
|
||||
name: 'process_approval_transaction',
|
||||
kind: 'DATABASE',
|
||||
startTime: Date.now() - 4500,
|
||||
endTime: Date.now() - 500,
|
||||
duration: 4000,
|
||||
attributes: {
|
||||
'db.operation': 'rpc',
|
||||
'submission.id': 'sub-456',
|
||||
},
|
||||
events: [
|
||||
{ timestamp: Date.now() - 4400, name: 'rpc_call_start' },
|
||||
{ timestamp: Date.now() - 600, name: 'rpc_call_success' },
|
||||
],
|
||||
status: 'ok',
|
||||
},
|
||||
];
|
||||
|
||||
setSpans(mockSpans);
|
||||
const builtTree = buildSpanTree(mockSpans);
|
||||
setTree(builtTree);
|
||||
|
||||
if (!builtTree) {
|
||||
setError('No root span found for this trace ID');
|
||||
}
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Failed to load trace');
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const stats = tree ? calculateSpanStats(tree) : null;
|
||||
const events = tree ? extractAllEvents(tree) : [];
|
||||
|
||||
return (
|
||||
<div className="container mx-auto p-6 space-y-6">
|
||||
<div>
|
||||
<h1 className="text-3xl font-bold mb-2">Distributed Trace Viewer</h1>
|
||||
<p className="text-muted-foreground">
|
||||
Debug moderation pipeline execution by visualizing span hierarchies
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Load Trace</CardTitle>
|
||||
<CardDescription>
|
||||
Enter a trace ID from edge function logs to visualize the execution tree
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="flex gap-2">
|
||||
<Input
|
||||
value={traceId}
|
||||
onChange={(e) => setTraceId(e.target.value)}
|
||||
placeholder="Enter trace ID (e.g., abc-123-def-456)"
|
||||
className="flex-1"
|
||||
/>
|
||||
<Button onClick={loadTrace} disabled={isLoading}>
|
||||
{isLoading ? 'Loading...' : 'Load Trace'}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<Alert variant="destructive" className="mt-4">
|
||||
<AlertDescription>{error}</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{tree && stats && (
|
||||
<>
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Trace Statistics</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
|
||||
<div>
|
||||
<div className="text-sm text-muted-foreground">Total Duration</div>
|
||||
<div className="text-2xl font-bold">{stats.totalDuration}ms</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-sm text-muted-foreground">Total Spans</div>
|
||||
<div className="text-2xl font-bold">{stats.totalSpans}</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-sm text-muted-foreground">Max Depth</div>
|
||||
<div className="text-2xl font-bold">{stats.maxDepth}</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-sm text-muted-foreground">Errors</div>
|
||||
<div className="text-2xl font-bold text-destructive">{stats.errorCount}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-4">
|
||||
<div className="text-sm text-muted-foreground mb-2">Critical Path (Longest Duration):</div>
|
||||
<div className="flex gap-2 flex-wrap">
|
||||
{stats.criticalPath.map((spanName, i) => (
|
||||
<Badge key={i} variant="secondary">
|
||||
{spanName}
|
||||
</Badge>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Span Tree</CardTitle>
|
||||
<CardDescription>
|
||||
Hierarchical view of span execution with timing breakdown
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<pre className="bg-muted p-4 rounded-lg overflow-x-auto text-sm">
|
||||
{formatSpanTree(tree)}
|
||||
</pre>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Events Timeline</CardTitle>
|
||||
<CardDescription>
|
||||
Chronological list of all events across all spans
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="space-y-2">
|
||||
{events.map((event, i) => (
|
||||
<div key={i} className="flex gap-2 text-sm border-l-2 border-primary pl-4 py-1">
|
||||
<Badge variant="outline">{event.spanName}</Badge>
|
||||
<span className="text-muted-foreground">→</span>
|
||||
<span className="font-medium">{event.eventName}</span>
|
||||
<span className="text-muted-foreground ml-auto">
|
||||
{new Date(event.timestamp).toISOString()}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Span Details</CardTitle>
|
||||
<CardDescription>
|
||||
Detailed breakdown of each span with attributes and events
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<Accordion type="single" collapsible className="w-full">
|
||||
{spans.map((span) => (
|
||||
<AccordionItem key={span.spanId} value={span.spanId}>
|
||||
<AccordionTrigger>
|
||||
<div className="flex items-center gap-2">
|
||||
<Badge variant={span.status === 'error' ? 'destructive' : 'default'}>
|
||||
{span.kind}
|
||||
</Badge>
|
||||
<span>{span.name}</span>
|
||||
<span className="text-muted-foreground ml-2">
|
||||
({span.duration}ms)
|
||||
</span>
|
||||
</div>
|
||||
</AccordionTrigger>
|
||||
<AccordionContent>
|
||||
<pre className="bg-muted p-4 rounded-lg overflow-x-auto text-xs">
|
||||
{JSON.stringify(span, null, 2)}
|
||||
</pre>
|
||||
</AccordionContent>
|
||||
</AccordionItem>
|
||||
))}
|
||||
</Accordion>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</>
|
||||
)}
|
||||
|
||||
{!tree && !isLoading && !error && (
|
||||
<Alert>
|
||||
<AlertDescription>
|
||||
Enter a trace ID to visualize the distributed trace. You can find trace IDs in edge function logs
|
||||
under the "Span completed" messages.
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
35
src/types/tracing.ts
Normal file
35
src/types/tracing.ts
Normal file
@@ -0,0 +1,35 @@
|
||||
/**
|
||||
* Distributed Tracing Types
|
||||
* Mirrors the types defined in edge function logger
|
||||
*/
|
||||
|
||||
export interface Span {
|
||||
spanId: string;
|
||||
traceId: string;
|
||||
parentSpanId?: string;
|
||||
name: string;
|
||||
kind: 'SERVER' | 'CLIENT' | 'INTERNAL' | 'DATABASE';
|
||||
startTime: number;
|
||||
endTime?: number;
|
||||
duration?: number;
|
||||
attributes: Record<string, unknown>;
|
||||
events: SpanEvent[];
|
||||
status: 'ok' | 'error' | 'unset';
|
||||
error?: {
|
||||
type: string;
|
||||
message: string;
|
||||
stack?: string;
|
||||
};
|
||||
}
|
||||
|
||||
export interface SpanEvent {
|
||||
timestamp: number;
|
||||
name: string;
|
||||
attributes?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
export interface SpanContext {
|
||||
traceId: string;
|
||||
spanId: string;
|
||||
traceFlags?: number;
|
||||
}
|
||||
Reference in New Issue
Block a user