Skip to content

Commit 7b4cb9c

Browse files
committed
feat: trace status
1 parent d49d280 commit 7b4cb9c

7 files changed

Lines changed: 183 additions & 6 deletions

File tree

src/duron/_core/invoke.py

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,7 @@
3535
from duron.log import derive_id, is_entry, random_id, set_annotations
3636
from duron.tracing import Tracer, current_tracer
3737
from duron.tracing._span import NULL_SPAN
38+
from duron.tracing._tracer import span
3839
from duron.typing import Unspecified, inspect_function
3940

4041
if TYPE_CHECKING:
@@ -292,7 +293,8 @@ async def _invoke_prelude(
292293
elif type_ is StreamWriter:
293294
_, extra_kwargs[name] = await ctx.create_stream(dtype, name=name)
294295
try:
295-
result = await job_fn.fn(ctx, *args, **extra_kwargs, **kwargs)
296+
with span("InvokeRun"):
297+
result = await job_fn.fn(ctx, *args, **extra_kwargs, **kwargs)
296298
if promise_:
297299
await ctx.complete_promise(promise_[0], result=None)
298300
await promise_[1]
@@ -347,6 +349,8 @@ def __init__(
347349
self._tracer: Tracer | None = Tracer.current()
348350

349351
async def close(self) -> None:
352+
if self._tracer:
353+
self._tracer.close()
350354
await self._send_traces(flush=True)
351355
if self._lease:
352356
await self._log.release_lease(self._lease)
@@ -387,6 +391,8 @@ async def run(self) -> object:
387391
return self._task.result()
388392

389393
self._running = True
394+
if self._tracer:
395+
self._tracer.start()
390396
for msg in self._pending_msg:
391397
await self.enqueue_log(msg)
392398
self._pending_msg.clear()

src/duron/tracing/_events.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,8 @@ class SpanEnd(TypedDict):
2626
span_id: str
2727
ts: int
2828
attributes: NotRequired[Mapping[str, JSONValue]]
29+
status: NotRequired[Literal["OK", "ERROR"]]
30+
status_message: NotRequired[str]
2931

3032

3133
class Event(TypedDict):

src/duron/tracing/_span.py

Lines changed: 14 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
from __future__ import annotations
22

3-
from typing import TYPE_CHECKING, Final, Protocol
3+
from typing import TYPE_CHECKING, Final, Literal, Protocol
44
from typing_extensions import Self, final
55

66
if TYPE_CHECKING:
@@ -17,6 +17,13 @@ def record(
1717
/,
1818
) -> None: ...
1919

20+
def set_status(
21+
self,
22+
status: Literal["OK", "ERROR"],
23+
message: str | None = None,
24+
/,
25+
) -> None: ...
26+
2027

2128
@final
2229
class _NullSpan:
@@ -29,6 +36,12 @@ def __enter__(self) -> Self:
2936
def record(_key: str, _value: JSONValue) -> None:
3037
return
3138

39+
@staticmethod
40+
def set_status(
41+
_status: Literal["OK", "ERROR"], _message: str | None = None
42+
) -> None:
43+
return
44+
3245
def __exit__(
3346
self,
3447
exc_type: type[BaseException] | None,

src/duron/tracing/_tracer.py

Lines changed: 110 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,14 @@
11
from __future__ import annotations
22

3+
import enum
34
import logging
45
import os
56
import threading
67
import time
78
from contextvars import ContextVar
89
from dataclasses import dataclass
910
from hashlib import blake2b
10-
from typing import TYPE_CHECKING, cast
11+
from typing import TYPE_CHECKING, Literal, cast
1112
from typing_extensions import Self, override
1213

1314
from duron.log import set_annotations
@@ -34,8 +35,22 @@
3435
)
3536

3637

38+
class TracerState(enum.Enum):
39+
INIT = "init"
40+
STARTED = "started"
41+
CLOSED = "closed"
42+
43+
3744
class Tracer:
38-
__slots__: tuple[str, ...] = ("_events", "_lock", "instance_id", "trace_id")
45+
__slots__: tuple[str, ...] = (
46+
"_events",
47+
"_init_buffer",
48+
"_lock",
49+
"_open_spans",
50+
"_state",
51+
"instance_id",
52+
"trace_id",
53+
)
3954

4055
def __init__(
4156
self,
@@ -48,10 +63,90 @@ def __init__(
4863
self.instance_id: str = instance_id or _trace_id()
4964
self._events: list[TraceEvent] = []
5065
self._lock = threading.Lock()
66+
self._state: TracerState = TracerState.INIT
67+
self._init_buffer: list[TraceEvent] = []
68+
self._open_spans: dict[str, SpanStart] = {}
5169

5270
def emit_event(self, event: TraceEvent) -> None:
5371
with self._lock:
54-
self._events.append(event)
72+
if self._state == TracerState.CLOSED:
73+
return
74+
75+
if self._state == TracerState.INIT:
76+
# Buffer events in INIT state
77+
self._init_buffer.append(event)
78+
# Track open/closed spans
79+
if event["type"] == "span.start":
80+
self._open_spans[event["span_id"]] = event
81+
elif event["type"] == "span.end":
82+
self._open_spans.pop(event["span_id"], None)
83+
else: # STARTED state
84+
self._events.append(event)
85+
# Track open spans even in STARTED state for close()
86+
if event["type"] == "span.start":
87+
self._open_spans[event["span_id"]] = event
88+
elif event["type"] == "span.end":
89+
self._open_spans.pop(event["span_id"], None)
90+
91+
def start(self) -> None:
92+
"""Transition to STARTED state, clear completed spans, emit remaining."""
93+
with self._lock:
94+
if self._state != TracerState.INIT:
95+
return
96+
97+
# Find all span IDs that have been completed (have both start and end)
98+
completed_span_ids: set[str] = set()
99+
span_ends: set[str] = set()
100+
101+
for event in self._init_buffer:
102+
if event["type"] == "span.end":
103+
span_ends.add(event["span_id"])
104+
105+
for event in self._init_buffer:
106+
if event["type"] == "span.start" and event["span_id"] in span_ends:
107+
completed_span_ids.add(event["span_id"])
108+
109+
# Filter out completed spans (both start and end) and their related events
110+
# Keep only span.start events for incomplete spans and other events
111+
for event in self._init_buffer:
112+
event_span_id = event.get("span_id")
113+
114+
# Skip completed span events (both start and end)
115+
if event_span_id in completed_span_ids:
116+
continue
117+
118+
# Skip span.end events for incomplete spans (keep only starts)
119+
if event["type"] == "span.end":
120+
continue
121+
122+
# Emit everything else (span.start for incomplete spans, other events)
123+
self._events.append(event)
124+
125+
# Clear the init buffer
126+
self._init_buffer.clear()
127+
128+
# Transition to STARTED state
129+
self._state = TracerState.STARTED
130+
131+
def close(self) -> None:
132+
"""Transition to CLOSED state and mark all open spans as failed."""
133+
with self._lock:
134+
if self._state == TracerState.CLOSED or self._state == TracerState.INIT:
135+
return
136+
137+
for span_id in list(self._open_spans.keys()):
138+
end_event: SpanEnd = {
139+
"type": "span.end",
140+
"span_id": span_id,
141+
"ts": time.time_ns() // 1000,
142+
"status": "ERROR",
143+
"status_message": "tracer closed",
144+
}
145+
self._events.append(end_event)
146+
147+
self._open_spans.clear()
148+
149+
self._state = TracerState.CLOSED
55150

56151
def pop_events(self, *, flush: bool) -> list[dict[str, JSONValue]]:
57152
with self._lock:
@@ -156,6 +251,8 @@ class _TracerSpan:
156251
attributes: dict[str, JSONValue] | None = None
157252
links: tuple[LinkRef, ...] | None = None
158253
_token: Token[_TracerSpan | None] | None = None
254+
_status: Literal["OK", "ERROR"] | None = None
255+
_status_message: str | None = None
159256

160257
def __enter__(self) -> Self:
161258
start_ns = time.time_ns()
@@ -184,6 +281,12 @@ def record(self, key: str, value: JSONValue) -> None:
184281
else:
185282
self.attributes = {key: value}
186283

284+
def set_status(
285+
self, status: Literal["OK", "ERROR"], message: str | None = None
286+
) -> None:
287+
self._status = status
288+
self._status_message = message
289+
187290
def __exit__(
188291
self,
189292
exc_type: type[BaseException] | None,
@@ -199,6 +302,10 @@ def __exit__(
199302
if self.attributes:
200303
evnt["attributes"] = self.attributes
201304
self.attributes = None
305+
if self._status:
306+
evnt["status"] = self._status
307+
if self._status_message:
308+
evnt["status_message"] = self._status_message
202309

203310
self.tracer.emit_event(evnt)
204311
if self._token:

tools/trace-ui/src/components/trace-view/detail-panel.tsx

Lines changed: 24 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,12 @@
11
import {
2+
CheckCircle2,
23
Clock,
34
Hash,
45
Layers,
56
Link as LinkIcon,
67
Tag,
78
X,
9+
XCircle,
810
Zap,
911
} from "lucide-react";
1012

@@ -137,7 +139,23 @@ export function DetailPanel({
137139
<h3 className="text-lg font-semibold break-words text-slate-900 dark:text-slate-50">
138140
{selectedSpan.name}
139141
</h3>
140-
<div className="flex gap-2">
142+
<div className="flex flex-wrap gap-2">
143+
{selectedSpan.status && (
144+
<span
145+
className={`flex items-center gap-1 rounded border px-2 py-1 text-xs font-medium ${
146+
selectedSpan.status === "ERROR"
147+
? "border-red-300 bg-red-50 text-red-800 dark:border-red-700 dark:bg-red-950 dark:text-red-200"
148+
: "border-green-300 bg-green-50 text-green-800 dark:border-green-700 dark:bg-green-950 dark:text-green-200"
149+
}`}
150+
>
151+
{selectedSpan.status === "ERROR" ? (
152+
<XCircle className="h-3 w-3" />
153+
) : (
154+
<CheckCircle2 className="h-3 w-3" />
155+
)}
156+
{selectedSpan.status}
157+
</span>
158+
)}
141159
{selectedSpan.incomplete && (
142160
<span className="rounded border border-red-300 bg-red-50 px-2 py-1 text-xs font-medium text-red-800 dark:border-red-700 dark:bg-red-950 dark:text-red-200">
143161
Incomplete
@@ -161,6 +179,11 @@ export function DetailPanel({
161179
from the trace end.
162180
</p>
163181
)}
182+
{selectedSpan.status === "ERROR" && selectedSpan.statusMessage && (
183+
<p className="mt-2 text-xs text-red-600 dark:text-red-400">
184+
❌ Error: {selectedSpan.statusMessage}
185+
</p>
186+
)}
164187
</div>
165188

166189
{/* Timing Information */}

tools/trace-ui/src/components/trace-view/span-utils.ts

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,17 @@ import type { Span } from "@/lib/trace";
33
// Color mapping for different span types
44
// Soft pastel palette for visual comfort
55
export const getSpanColor = (span: Span): string => {
6+
// Error status takes priority over everything else
7+
if (span.status === "ERROR") {
8+
const errorBaseColor = "bg-red-400 dark:bg-red-500";
9+
const errorGradientFrom = "from-red-400 dark:from-red-500";
10+
11+
if (span.incomplete) {
12+
return `bg-gradient-to-r ${errorGradientFrom} to-transparent`;
13+
}
14+
return errorBaseColor;
15+
}
16+
617
const type = span.attributes?.type as string | undefined;
718

819
let baseColor: string;

tools/trace-ui/src/lib/trace.ts

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,8 @@ export interface Span {
2727
links?: Array<{ span_id: string; trace_id: string }>;
2828
events?: InstantEvent[]; // Instant events that occurred within this span
2929
incomplete?: boolean; // True if span only has start event, no end event
30+
status?: "OK" | "ERROR";
31+
statusMessage?: string;
3032
}
3133

3234
// internal representation of a trace event from the log
@@ -40,6 +42,8 @@ interface TraceEvent {
4042
attributes?: Record<string, JSONValue>;
4143
links?: Array<{ span_id: string; trace_id: string }>;
4244
kind?: string;
45+
status?: "OK" | "ERROR";
46+
status_message?: string;
4347
}
4448

4549
export interface TraceFile {
@@ -67,6 +71,8 @@ interface RawTraceEvent {
6771
attributes?: Record<string, JSONValue>;
6872
links?: Array<{ span_id: string; trace_id?: string }>;
6973
kind?: string;
74+
status?: "OK" | "ERROR";
75+
status_message?: string;
7076
}
7177

7278
interface RawLog {
@@ -106,6 +112,8 @@ function rawEventToTraceEvent(
106112
trace_id: link.trace_id || traceId,
107113
})),
108114
kind: event.kind,
115+
status: event.status,
116+
status_message: event.status_message,
109117
};
110118
}
111119

@@ -193,6 +201,8 @@ export function extractSpansFromEntries(entries: TraceEvent[]): Span[] {
193201
name: string;
194202
attributes?: Record<string, JSONValue>;
195203
}>;
204+
status?: "OK" | "ERROR";
205+
statusMessage?: string;
196206
}
197207
>();
198208

@@ -233,6 +243,9 @@ export function extractSpansFromEntries(entries: TraceEvent[]): Span[] {
233243
if (event.attributes) {
234244
span.attributes = { ...span.attributes, ...event.attributes };
235245
}
246+
// Capture status and status_message from span.end
247+
span.status = event.status;
248+
span.statusMessage = event.status_message;
236249
} else if (event.type === "event") {
237250
// Collect instant events
238251
const eventName = event.name ?? "event";
@@ -287,6 +300,8 @@ export function extractSpansFromEntries(entries: TraceEvent[]): Span[] {
287300
attributes: spanData.attributes,
288301
links: spanData.links,
289302
events: convertedEvents.length > 0 ? convertedEvents : undefined,
303+
status: spanData.status,
304+
statusMessage: spanData.statusMessage,
290305
};
291306

292307
// Add incomplete flag if needed

0 commit comments

Comments
 (0)