mermaid-trace 0.3.1__py3-none-any.whl → 0.4.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- mermaid_trace/__init__.py +79 -18
- mermaid_trace/cli.py +88 -20
- mermaid_trace/core/context.py +21 -20
- mermaid_trace/core/decorators.py +212 -84
- mermaid_trace/core/events.py +27 -25
- mermaid_trace/core/formatter.py +20 -11
- mermaid_trace/handlers/async_handler.py +52 -0
- mermaid_trace/handlers/mermaid_handler.py +33 -20
- mermaid_trace/integrations/fastapi.py +45 -22
- {mermaid_trace-0.3.1.dist-info → mermaid_trace-0.4.0.dist-info}/METADATA +4 -2
- mermaid_trace-0.4.0.dist-info/RECORD +16 -0
- mermaid_trace-0.3.1.dist-info/RECORD +0 -15
- {mermaid_trace-0.3.1.dist-info → mermaid_trace-0.4.0.dist-info}/WHEEL +0 -0
- {mermaid_trace-0.3.1.dist-info → mermaid_trace-0.4.0.dist-info}/entry_points.txt +0 -0
- {mermaid_trace-0.3.1.dist-info → mermaid_trace-0.4.0.dist-info}/licenses/LICENSE +0 -0
mermaid_trace/core/decorators.py
CHANGED
|
@@ -12,46 +12,68 @@ FLOW_LOGGER_NAME = "mermaid_trace.flow"
|
|
|
12
12
|
# Define generic type variable for the decorated function
|
|
13
13
|
F = TypeVar("F", bound=Callable[..., Any])
|
|
14
14
|
|
|
15
|
+
|
|
15
16
|
def get_flow_logger() -> logging.Logger:
|
|
16
17
|
"""Returns the dedicated logger for flow events."""
|
|
17
18
|
return logging.getLogger(FLOW_LOGGER_NAME)
|
|
18
19
|
|
|
19
|
-
|
|
20
|
+
|
|
21
|
+
def _safe_repr(obj: Any, max_len: int = 50, max_depth: int = 1) -> str:
|
|
20
22
|
"""
|
|
21
23
|
Safely creates a string representation of an object.
|
|
22
|
-
|
|
23
|
-
Prevents massive log files by truncating long strings/objects
|
|
24
|
+
|
|
25
|
+
Prevents massive log files by truncating long strings/objects
|
|
24
26
|
and handling exceptions during __repr__ calls (e.g. strict objects).
|
|
25
27
|
"""
|
|
26
28
|
try:
|
|
27
|
-
|
|
29
|
+
# Create a custom repr object to control depth and length
|
|
30
|
+
a_repr = reprlib.Repr()
|
|
31
|
+
a_repr.maxstring = max_len
|
|
32
|
+
a_repr.maxother = max_len
|
|
33
|
+
a_repr.maxlevel = max_depth
|
|
34
|
+
|
|
35
|
+
r = a_repr.repr(obj)
|
|
28
36
|
if len(r) > max_len:
|
|
29
37
|
return r[:max_len] + "..."
|
|
30
38
|
return r
|
|
31
39
|
except Exception:
|
|
32
40
|
return "<unrepresentable>"
|
|
33
41
|
|
|
34
|
-
|
|
42
|
+
|
|
43
|
+
def _format_args(
|
|
44
|
+
args: Tuple[Any, ...],
|
|
45
|
+
kwargs: Dict[str, Any],
|
|
46
|
+
capture_args: bool = True,
|
|
47
|
+
max_arg_length: int = 50,
|
|
48
|
+
max_arg_depth: int = 1,
|
|
49
|
+
) -> str:
|
|
35
50
|
"""
|
|
36
51
|
Formats function arguments into a single string "arg1, arg2, k=v".
|
|
37
52
|
Used for the arrow label in the diagram.
|
|
38
53
|
"""
|
|
54
|
+
if not capture_args:
|
|
55
|
+
return ""
|
|
56
|
+
|
|
39
57
|
parts = []
|
|
40
58
|
for arg in args:
|
|
41
|
-
parts.append(_safe_repr(arg))
|
|
42
|
-
|
|
59
|
+
parts.append(_safe_repr(arg, max_len=max_arg_length, max_depth=max_arg_depth))
|
|
60
|
+
|
|
43
61
|
for k, v in kwargs.items():
|
|
44
|
-
|
|
45
|
-
|
|
62
|
+
val_str = _safe_repr(v, max_len=max_arg_length, max_depth=max_arg_depth)
|
|
63
|
+
parts.append(f"{k}={val_str}")
|
|
64
|
+
|
|
46
65
|
return ", ".join(parts)
|
|
47
66
|
|
|
48
|
-
|
|
67
|
+
|
|
68
|
+
def _resolve_target(
|
|
69
|
+
func: Callable[..., Any], args: Tuple[Any, ...], target_override: Optional[str]
|
|
70
|
+
) -> str:
|
|
49
71
|
"""
|
|
50
72
|
Determines the name of the participant (Target) for the diagram.
|
|
51
|
-
|
|
73
|
+
|
|
52
74
|
Resolution Priority:
|
|
53
75
|
1. **Override**: If the user explicitly provided `target="Name"`, use it.
|
|
54
|
-
2. **Instance Method**: If the first arg looks like `self` (has __class__),
|
|
76
|
+
2. **Instance Method**: If the first arg looks like `self` (has __class__),
|
|
55
77
|
use the class name.
|
|
56
78
|
3. **Class Method**: If the first arg is a type (cls), use the class name.
|
|
57
79
|
4. **Module Function**: Fallback to the name of the module containing the function.
|
|
@@ -59,18 +81,20 @@ def _resolve_target(func: Callable[..., Any], args: Tuple[Any, ...], target_over
|
|
|
59
81
|
"""
|
|
60
82
|
if target_override:
|
|
61
83
|
return target_override
|
|
62
|
-
|
|
84
|
+
|
|
63
85
|
# Heuristic: If it's a method call, args[0] is usually 'self'.
|
|
64
86
|
if args:
|
|
65
87
|
first_arg = args[0]
|
|
66
88
|
# Check if it looks like a class instance
|
|
67
89
|
# We check hasattr(__class__) to distinguish objects from primitives/containers broadly,
|
|
68
90
|
# ensuring we don't mislabel a plain list passed as first arg to a function as a "List" participant.
|
|
69
|
-
if hasattr(first_arg, "__class__") and not isinstance(
|
|
70
|
-
|
|
91
|
+
if hasattr(first_arg, "__class__") and not isinstance(
|
|
92
|
+
first_arg, (str, int, float, bool, list, dict, type)
|
|
93
|
+
):
|
|
94
|
+
return str(first_arg.__class__.__name__)
|
|
71
95
|
# Check if it looks like a class (cls) - e.g. @classmethod
|
|
72
96
|
if isinstance(first_arg, type):
|
|
73
|
-
|
|
97
|
+
return first_arg.__name__
|
|
74
98
|
|
|
75
99
|
# Fallback to module name for standalone functions
|
|
76
100
|
module = inspect.getmodule(func)
|
|
@@ -78,118 +102,180 @@ def _resolve_target(func: Callable[..., Any], args: Tuple[Any, ...], target_over
|
|
|
78
102
|
return module.__name__.split(".")[-1]
|
|
79
103
|
return "Unknown"
|
|
80
104
|
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
105
|
+
|
|
106
|
+
def _log_interaction(
|
|
107
|
+
logger: logging.Logger,
|
|
108
|
+
source: str,
|
|
109
|
+
target: str,
|
|
110
|
+
action: str,
|
|
111
|
+
params: str,
|
|
112
|
+
trace_id: str,
|
|
113
|
+
) -> None:
|
|
87
114
|
"""
|
|
88
115
|
Logs the 'Call' event (Start of function).
|
|
89
116
|
Arrow: source -> target
|
|
90
117
|
"""
|
|
91
118
|
req_event = FlowEvent(
|
|
92
|
-
source=source,
|
|
93
|
-
|
|
94
|
-
|
|
119
|
+
source=source,
|
|
120
|
+
target=target,
|
|
121
|
+
action=action,
|
|
122
|
+
message=action,
|
|
123
|
+
params=params,
|
|
124
|
+
trace_id=trace_id,
|
|
95
125
|
)
|
|
96
126
|
# The 'extra' dict is critical: the Handler will pick this up to format the Mermaid line
|
|
97
127
|
logger.info(f"{source}->{target}: {action}", extra={"flow_event": req_event})
|
|
98
128
|
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
129
|
+
|
|
130
|
+
def _log_return(
|
|
131
|
+
logger: logging.Logger,
|
|
132
|
+
source: str,
|
|
133
|
+
target: str,
|
|
134
|
+
action: str,
|
|
135
|
+
result: Any,
|
|
136
|
+
trace_id: str,
|
|
137
|
+
capture_args: bool = True,
|
|
138
|
+
max_arg_length: int = 50,
|
|
139
|
+
max_arg_depth: int = 1,
|
|
140
|
+
) -> None:
|
|
105
141
|
"""
|
|
106
142
|
Logs the 'Return' event (End of function).
|
|
107
143
|
Arrow: target --> source (Dotted line return)
|
|
108
|
-
|
|
144
|
+
|
|
109
145
|
Note: 'source' here is the original caller, 'target' is the callee.
|
|
110
146
|
So the return arrow goes from target back to source.
|
|
111
147
|
"""
|
|
112
|
-
result_str =
|
|
148
|
+
result_str = ""
|
|
149
|
+
if capture_args:
|
|
150
|
+
result_str = _safe_repr(result, max_len=max_arg_length, max_depth=max_arg_depth)
|
|
151
|
+
|
|
113
152
|
resp_event = FlowEvent(
|
|
114
|
-
source=target,
|
|
115
|
-
|
|
116
|
-
|
|
153
|
+
source=target,
|
|
154
|
+
target=source,
|
|
155
|
+
action=action,
|
|
156
|
+
message="Return",
|
|
157
|
+
is_return=True,
|
|
158
|
+
result=result_str,
|
|
159
|
+
trace_id=trace_id,
|
|
117
160
|
)
|
|
118
161
|
logger.info(f"{target}->{source}: Return", extra={"flow_event": resp_event})
|
|
119
162
|
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
163
|
+
|
|
164
|
+
def _log_error(
|
|
165
|
+
logger: logging.Logger,
|
|
166
|
+
source: str,
|
|
167
|
+
target: str,
|
|
168
|
+
action: str,
|
|
169
|
+
error: Exception,
|
|
170
|
+
trace_id: str,
|
|
171
|
+
) -> None:
|
|
126
172
|
"""
|
|
127
173
|
Logs an 'Error' event if the function raises an exception.
|
|
128
174
|
Arrow: target -x source (Error return)
|
|
129
175
|
"""
|
|
130
176
|
err_event = FlowEvent(
|
|
131
|
-
source=target,
|
|
132
|
-
|
|
133
|
-
|
|
177
|
+
source=target,
|
|
178
|
+
target=source,
|
|
179
|
+
action=action,
|
|
180
|
+
message=str(error),
|
|
181
|
+
is_return=True,
|
|
182
|
+
is_error=True,
|
|
183
|
+
error_message=str(error),
|
|
184
|
+
trace_id=trace_id,
|
|
134
185
|
)
|
|
135
186
|
logger.error(f"{target}-x{source}: Error", extra={"flow_event": err_event})
|
|
136
187
|
|
|
188
|
+
|
|
137
189
|
@overload
|
|
138
|
-
def trace_interaction(func: F) -> F:
|
|
139
|
-
|
|
190
|
+
def trace_interaction(func: F) -> F: ...
|
|
191
|
+
|
|
140
192
|
|
|
141
193
|
@overload
|
|
142
194
|
def trace_interaction(
|
|
143
|
-
*,
|
|
144
|
-
source: Optional[str] = None,
|
|
145
|
-
target: Optional[str] = None,
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
195
|
+
*,
|
|
196
|
+
source: Optional[str] = None,
|
|
197
|
+
target: Optional[str] = None,
|
|
198
|
+
name: Optional[str] = None,
|
|
199
|
+
action: Optional[str] = None,
|
|
200
|
+
capture_args: bool = True,
|
|
201
|
+
max_arg_length: int = 50,
|
|
202
|
+
max_arg_depth: int = 1,
|
|
203
|
+
) -> Callable[[F], F]: ...
|
|
204
|
+
|
|
149
205
|
|
|
150
206
|
def trace_interaction(
|
|
151
|
-
func: Optional[F] = None,
|
|
152
|
-
*,
|
|
153
|
-
source: Optional[str] = None,
|
|
154
|
-
target: Optional[str] = None,
|
|
155
|
-
|
|
207
|
+
func: Optional[F] = None,
|
|
208
|
+
*,
|
|
209
|
+
source: Optional[str] = None,
|
|
210
|
+
target: Optional[str] = None,
|
|
211
|
+
name: Optional[str] = None,
|
|
212
|
+
action: Optional[str] = None,
|
|
213
|
+
capture_args: bool = True,
|
|
214
|
+
max_arg_length: int = 50,
|
|
215
|
+
max_arg_depth: int = 1,
|
|
156
216
|
) -> Union[F, Callable[[F], F]]:
|
|
157
217
|
"""
|
|
158
218
|
Main Decorator for tracing function execution in Mermaid diagrams.
|
|
159
|
-
|
|
219
|
+
|
|
160
220
|
It supports two modes of operation:
|
|
161
221
|
1. **Simple**: `@trace` (No arguments)
|
|
162
222
|
2. **Configured**: `@trace(action="Login", target="AuthService")`
|
|
163
|
-
|
|
223
|
+
|
|
164
224
|
Args:
|
|
165
225
|
func: The function being decorated (automatically passed in simple mode).
|
|
166
226
|
source: Explicit name of the caller participant (rarely used, usually inferred from Context).
|
|
167
227
|
target: Explicit name of the callee participant (overrides automatic resolution).
|
|
228
|
+
name: Alias for 'target' (for clearer API usage).
|
|
168
229
|
action: Label for the arrow (defaults to function name).
|
|
230
|
+
capture_args: Whether to include arguments and return values in the log. Default True.
|
|
231
|
+
max_arg_length: Maximum string length for argument/result representation. Default 50.
|
|
232
|
+
max_arg_depth: Maximum recursion depth for argument/result representation. Default 1.
|
|
169
233
|
"""
|
|
170
|
-
|
|
234
|
+
|
|
235
|
+
# Handle alias
|
|
236
|
+
final_target = target or name
|
|
237
|
+
|
|
171
238
|
# Mode 1: @trace used without parentheses
|
|
172
239
|
if func is not None and callable(func):
|
|
173
|
-
return _create_decorator(
|
|
174
|
-
|
|
240
|
+
return _create_decorator(
|
|
241
|
+
func,
|
|
242
|
+
source,
|
|
243
|
+
final_target,
|
|
244
|
+
action,
|
|
245
|
+
capture_args,
|
|
246
|
+
max_arg_length,
|
|
247
|
+
max_arg_depth,
|
|
248
|
+
)
|
|
249
|
+
|
|
175
250
|
# Mode 2: @trace(...) used with arguments -> returns a factory
|
|
176
251
|
def factory(f: F) -> F:
|
|
177
|
-
return _create_decorator(
|
|
252
|
+
return _create_decorator(
|
|
253
|
+
f, source, final_target, action, capture_args, max_arg_length, max_arg_depth
|
|
254
|
+
)
|
|
255
|
+
|
|
178
256
|
return factory
|
|
179
257
|
|
|
258
|
+
|
|
180
259
|
def _create_decorator(
|
|
181
|
-
func: F,
|
|
182
|
-
source: Optional[str],
|
|
183
|
-
target: Optional[str],
|
|
184
|
-
action: Optional[str]
|
|
260
|
+
func: F,
|
|
261
|
+
source: Optional[str],
|
|
262
|
+
target: Optional[str],
|
|
263
|
+
action: Optional[str],
|
|
264
|
+
capture_args: bool,
|
|
265
|
+
max_arg_length: int,
|
|
266
|
+
max_arg_depth: int,
|
|
185
267
|
) -> F:
|
|
186
268
|
"""
|
|
187
269
|
Constructs the actual wrapper function.
|
|
188
270
|
Handles both synchronous and asynchronous functions.
|
|
271
|
+
|
|
272
|
+
This function separates the wrapper creation logic from the argument parsing logic
|
|
273
|
+
in `trace_interaction`, making the code cleaner and easier to test.
|
|
189
274
|
"""
|
|
190
|
-
|
|
275
|
+
|
|
191
276
|
# Pre-calculate static metadata to save time at runtime
|
|
192
277
|
if action is None:
|
|
278
|
+
# Default action name is the function name, converted to Title Case
|
|
193
279
|
action = func.__name__.replace("_", " ").title()
|
|
194
280
|
|
|
195
281
|
@functools.wraps(func)
|
|
@@ -197,49 +283,90 @@ def _create_decorator(
|
|
|
197
283
|
"""Sync function wrapper."""
|
|
198
284
|
# 1. Resolve Context
|
|
199
285
|
# 'source' is who called us (from Context). 'target' is who we are (resolved from self/cls).
|
|
286
|
+
# If 'source' is not explicitly provided, we look up the 'participant' set by the caller.
|
|
200
287
|
current_source = source or LogContext.current_participant()
|
|
201
288
|
trace_id = LogContext.current_trace_id()
|
|
202
289
|
current_target = _resolve_target(func, args, target)
|
|
203
|
-
|
|
290
|
+
|
|
204
291
|
logger = get_flow_logger()
|
|
205
|
-
|
|
206
|
-
|
|
292
|
+
# Format arguments for the diagram arrow label
|
|
293
|
+
params_str = _format_args(
|
|
294
|
+
args, kwargs, capture_args, max_arg_length, max_arg_depth
|
|
295
|
+
)
|
|
296
|
+
|
|
207
297
|
# 2. Log Request (Start of block)
|
|
208
|
-
|
|
209
|
-
|
|
298
|
+
# Logs the initial "Call" arrow (Source -> Target)
|
|
299
|
+
_log_interaction(
|
|
300
|
+
logger, current_source, current_target, action, params_str, trace_id
|
|
301
|
+
)
|
|
302
|
+
|
|
210
303
|
# 3. Execute with New Context
|
|
211
304
|
# We push 'current_target' as the NEW 'participant' (source) for any internal calls.
|
|
305
|
+
# This builds the chain: A -> B, then inside B, B becomes the source for C (B -> C).
|
|
212
306
|
with LogContext.scope({"participant": current_target, "trace_id": trace_id}):
|
|
213
307
|
try:
|
|
214
308
|
result = func(*args, **kwargs)
|
|
215
309
|
# 4. Log Success Return
|
|
216
|
-
|
|
310
|
+
# Logs the "Return" arrow (Target --> Source)
|
|
311
|
+
_log_return(
|
|
312
|
+
logger,
|
|
313
|
+
current_source,
|
|
314
|
+
current_target,
|
|
315
|
+
action,
|
|
316
|
+
result,
|
|
317
|
+
trace_id,
|
|
318
|
+
capture_args,
|
|
319
|
+
max_arg_length,
|
|
320
|
+
max_arg_depth,
|
|
321
|
+
)
|
|
217
322
|
return result
|
|
218
323
|
except Exception as e:
|
|
219
324
|
# 5. Log Error Return
|
|
325
|
+
# Logs the "Error" arrow (Target --x Source)
|
|
220
326
|
_log_error(logger, current_source, current_target, action, e, trace_id)
|
|
221
327
|
raise
|
|
222
328
|
|
|
223
329
|
@functools.wraps(func)
|
|
224
330
|
async def async_wrapper(*args: Any, **kwargs: Any) -> Any:
|
|
225
331
|
"""Async function wrapper (coroutine)."""
|
|
332
|
+
# 1. Resolve Context (Same as sync)
|
|
226
333
|
current_source = source or LogContext.current_participant()
|
|
227
334
|
trace_id = LogContext.current_trace_id()
|
|
228
335
|
current_target = _resolve_target(func, args, target)
|
|
229
|
-
|
|
336
|
+
|
|
230
337
|
logger = get_flow_logger()
|
|
231
|
-
params_str = _format_args(
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
338
|
+
params_str = _format_args(
|
|
339
|
+
args, kwargs, capture_args, max_arg_length, max_arg_depth
|
|
340
|
+
)
|
|
341
|
+
|
|
342
|
+
# 2. Log Request
|
|
343
|
+
_log_interaction(
|
|
344
|
+
logger, current_source, current_target, action, params_str, trace_id
|
|
345
|
+
)
|
|
346
|
+
|
|
347
|
+
# 3. Execute with New Context using 'ascope'
|
|
348
|
+
# Use async context manager (ascope) to ensure context propagates correctly across awaits.
|
|
349
|
+
# This is critical for asyncio: context must be preserved even if the task yields control.
|
|
350
|
+
async with LogContext.ascope(
|
|
351
|
+
{"participant": current_target, "trace_id": trace_id}
|
|
352
|
+
):
|
|
238
353
|
try:
|
|
239
354
|
result = await func(*args, **kwargs)
|
|
240
|
-
|
|
355
|
+
# 4. Log Success Return
|
|
356
|
+
_log_return(
|
|
357
|
+
logger,
|
|
358
|
+
current_source,
|
|
359
|
+
current_target,
|
|
360
|
+
action,
|
|
361
|
+
result,
|
|
362
|
+
trace_id,
|
|
363
|
+
capture_args,
|
|
364
|
+
max_arg_length,
|
|
365
|
+
max_arg_depth,
|
|
366
|
+
)
|
|
241
367
|
return result
|
|
242
368
|
except Exception as e:
|
|
369
|
+
# 5. Log Error Return
|
|
243
370
|
_log_error(logger, current_source, current_target, action, e, trace_id)
|
|
244
371
|
raise
|
|
245
372
|
|
|
@@ -248,5 +375,6 @@ def _create_decorator(
|
|
|
248
375
|
return cast(F, async_wrapper)
|
|
249
376
|
return cast(F, wrapper)
|
|
250
377
|
|
|
378
|
+
|
|
251
379
|
# Alias for easy import
|
|
252
380
|
trace = trace_interaction
|
mermaid_trace/core/events.py
CHANGED
|
@@ -2,66 +2,68 @@ from dataclasses import dataclass, field
|
|
|
2
2
|
import time
|
|
3
3
|
from typing import Optional
|
|
4
4
|
|
|
5
|
+
|
|
5
6
|
@dataclass
|
|
6
7
|
class FlowEvent:
|
|
7
8
|
"""
|
|
8
9
|
Represents a single interaction or step in the execution flow.
|
|
9
|
-
|
|
10
|
+
|
|
10
11
|
This data structure acts as the intermediate representation (IR) between
|
|
11
12
|
runtime code execution and the final Mermaid diagram output. Each instance
|
|
12
13
|
corresponds directly to one arrow or note in the sequence diagram.
|
|
13
|
-
|
|
14
|
+
|
|
14
15
|
The fields map to Mermaid syntax components as follows:
|
|
15
16
|
`source` -> `target`: `message`
|
|
16
|
-
|
|
17
|
+
|
|
17
18
|
Attributes:
|
|
18
|
-
source (str):
|
|
19
|
+
source (str):
|
|
19
20
|
The name of the participant initiating the action (the "Caller").
|
|
20
21
|
In Mermaid: The participant on the LEFT side of the arrow.
|
|
21
|
-
|
|
22
|
-
target (str):
|
|
22
|
+
|
|
23
|
+
target (str):
|
|
23
24
|
The name of the participant receiving the action (the "Callee").
|
|
24
25
|
In Mermaid: The participant on the RIGHT side of the arrow.
|
|
25
|
-
|
|
26
|
-
action (str):
|
|
26
|
+
|
|
27
|
+
action (str):
|
|
27
28
|
A short, human-readable name for the operation (e.g., function name).
|
|
28
29
|
Used for grouping or filtering logs, but often redundant with message.
|
|
29
|
-
|
|
30
|
-
message (str):
|
|
30
|
+
|
|
31
|
+
message (str):
|
|
31
32
|
The actual text label displayed on the diagram arrow.
|
|
32
33
|
Example: "getUser(id=1)" or "Return: User(name='Alice')".
|
|
33
|
-
|
|
34
|
-
timestamp (float):
|
|
34
|
+
|
|
35
|
+
timestamp (float):
|
|
35
36
|
Unix timestamp (seconds) of when the event occurred.
|
|
36
|
-
Used for ordering events if logs are processed asynchronously,
|
|
37
|
+
Used for ordering events if logs are processed asynchronously,
|
|
37
38
|
though Mermaid sequence diagrams primarily rely on line order.
|
|
38
|
-
|
|
39
|
-
trace_id (str):
|
|
39
|
+
|
|
40
|
+
trace_id (str):
|
|
40
41
|
Unique identifier for the trace session.
|
|
41
42
|
Allows filtering multiple concurrent traces from a single log file
|
|
42
43
|
to generate separate diagrams for separate requests.
|
|
43
|
-
|
|
44
|
-
is_return (bool):
|
|
44
|
+
|
|
45
|
+
is_return (bool):
|
|
45
46
|
Flag indicating if this is a response arrow.
|
|
46
47
|
If True, the arrow is drawn as a dotted line (`-->`) in Mermaid.
|
|
47
48
|
If False, it is a solid line (`->`) representing a call.
|
|
48
|
-
|
|
49
|
-
is_error (bool):
|
|
49
|
+
|
|
50
|
+
is_error (bool):
|
|
50
51
|
Flag indicating if an exception occurred.
|
|
51
52
|
If True, the arrow might be styled differently (e.g., `-x`) to show failure.
|
|
52
|
-
|
|
53
|
-
error_message (Optional[str]):
|
|
53
|
+
|
|
54
|
+
error_message (Optional[str]):
|
|
54
55
|
Detailed error text if `is_error` is True.
|
|
55
56
|
Can be added as a note or included in the arrow label.
|
|
56
|
-
|
|
57
|
-
params (Optional[str]):
|
|
57
|
+
|
|
58
|
+
params (Optional[str]):
|
|
58
59
|
Stringified representation of function arguments.
|
|
59
60
|
Captured only for request events (call start).
|
|
60
|
-
|
|
61
|
-
result (Optional[str]):
|
|
61
|
+
|
|
62
|
+
result (Optional[str]):
|
|
62
63
|
Stringified representation of the return value.
|
|
63
64
|
Captured only for return events (call end).
|
|
64
65
|
"""
|
|
66
|
+
|
|
65
67
|
source: str
|
|
66
68
|
target: str
|
|
67
69
|
action: str
|
mermaid_trace/core/formatter.py
CHANGED
|
@@ -3,6 +3,7 @@ import re
|
|
|
3
3
|
from typing import Optional
|
|
4
4
|
from .events import FlowEvent
|
|
5
5
|
|
|
6
|
+
|
|
6
7
|
class MermaidFormatter(logging.Formatter):
|
|
7
8
|
"""
|
|
8
9
|
Custom formatter to convert FlowEvents into Mermaid sequence diagram syntax.
|
|
@@ -10,8 +11,8 @@ class MermaidFormatter(logging.Formatter):
|
|
|
10
11
|
|
|
11
12
|
def format(self, record: logging.LogRecord) -> str:
|
|
12
13
|
# 1. Retrieve the FlowEvent
|
|
13
|
-
event: Optional[FlowEvent] = getattr(record,
|
|
14
|
-
|
|
14
|
+
event: Optional[FlowEvent] = getattr(record, "flow_event", None)
|
|
15
|
+
|
|
15
16
|
if not event:
|
|
16
17
|
# Fallback for standard logs if they accidentally reach this handler
|
|
17
18
|
return super().format(record)
|
|
@@ -23,39 +24,47 @@ class MermaidFormatter(logging.Formatter):
|
|
|
23
24
|
"""
|
|
24
25
|
Converts a FlowEvent into a Mermaid syntax string.
|
|
25
26
|
"""
|
|
26
|
-
# Sanitize participant names
|
|
27
|
+
# Sanitize participant names to avoid syntax errors in Mermaid
|
|
27
28
|
src = self._sanitize(event.source)
|
|
28
29
|
tgt = self._sanitize(event.target)
|
|
29
|
-
|
|
30
|
+
|
|
30
31
|
# Determine arrow type
|
|
31
32
|
# ->> : Solid line with arrowhead (synchronous call)
|
|
32
33
|
# -->> : Dotted line with arrowhead (return)
|
|
33
34
|
# --x : Dotted line with cross (error)
|
|
34
35
|
arrow = "-->>" if event.is_return else "->>"
|
|
35
|
-
|
|
36
|
+
|
|
37
|
+
msg = ""
|
|
36
38
|
if event.is_error:
|
|
37
39
|
arrow = "--x"
|
|
38
40
|
msg = f"Error: {event.error_message}"
|
|
39
41
|
elif event.is_return:
|
|
42
|
+
# For returns, we usually show the return value or just "Return"
|
|
40
43
|
msg = f"Return: {event.result}" if event.result else "Return"
|
|
41
44
|
else:
|
|
45
|
+
# For calls, we show Action(Params) or just Action
|
|
42
46
|
msg = f"{event.message}({event.params})" if event.params else event.message
|
|
43
|
-
|
|
47
|
+
|
|
44
48
|
# Optional: Add note or group if trace_id changes (not implemented in single line format)
|
|
45
49
|
# For now, we just output the interaction.
|
|
46
|
-
|
|
47
|
-
# Escape message for Mermaid safety
|
|
50
|
+
|
|
51
|
+
# Escape message for Mermaid safety (e.g. replacing newlines)
|
|
48
52
|
msg = self._escape_message(msg)
|
|
49
|
-
|
|
53
|
+
|
|
54
|
+
# Format: Source->>Target: Message
|
|
50
55
|
return f"{src}{arrow}{tgt}: {msg}"
|
|
51
56
|
|
|
52
57
|
def _sanitize(self, name: str) -> str:
|
|
53
58
|
"""
|
|
54
59
|
Sanitizes participant names to be valid Mermaid identifiers.
|
|
55
60
|
Allows alphanumeric and underscores. Replaces others.
|
|
61
|
+
|
|
62
|
+
Mermaid doesn't like spaces or special characters in participant aliases
|
|
63
|
+
unless they are quoted (which we are not doing here for simplicity),
|
|
64
|
+
so we replace them with underscores.
|
|
56
65
|
"""
|
|
57
66
|
# Replace any non-alphanumeric character (except underscore) with underscore
|
|
58
|
-
clean_name = re.sub(r
|
|
67
|
+
clean_name = re.sub(r"[^a-zA-Z0-9_]", "_", name)
|
|
59
68
|
# Ensure it doesn't start with a digit (Mermaid doesn't like that sometimes, though often okay)
|
|
60
69
|
if clean_name and clean_name[0].isdigit():
|
|
61
70
|
clean_name = "_" + clean_name
|
|
@@ -67,6 +76,6 @@ class MermaidFormatter(logging.Formatter):
|
|
|
67
76
|
Mermaid messages can contain most chars, but : and newlines can be tricky.
|
|
68
77
|
"""
|
|
69
78
|
# Replace newlines with <br/> for Mermaid display
|
|
70
|
-
msg = msg.replace(
|
|
79
|
+
msg = msg.replace("\n", "<br/>")
|
|
71
80
|
# We might want to escape other chars if needed, but usually text after : is forgiving.
|
|
72
81
|
return msg
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
import logging
|
|
2
|
+
import logging.handlers
|
|
3
|
+
import queue
|
|
4
|
+
import atexit
|
|
5
|
+
from typing import List, Optional
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class AsyncMermaidHandler(logging.handlers.QueueHandler):
|
|
9
|
+
"""
|
|
10
|
+
A non-blocking logging handler that uses a background thread to write logs.
|
|
11
|
+
|
|
12
|
+
This handler pushes log records to a queue, which are then picked up by a
|
|
13
|
+
QueueListener running in a separate thread and dispatched to the actual
|
|
14
|
+
handlers (e.g., MermaidFileHandler).
|
|
15
|
+
"""
|
|
16
|
+
|
|
17
|
+
def __init__(self, handlers: List[logging.Handler], queue_size: int = -1):
|
|
18
|
+
"""
|
|
19
|
+
Initialize the async handler.
|
|
20
|
+
|
|
21
|
+
Args:
|
|
22
|
+
handlers: A list of handlers that should receive the logs from the queue.
|
|
23
|
+
(e.g., [MermaidFileHandler(...)])
|
|
24
|
+
queue_size: The maximum size of the queue. -1 means infinite.
|
|
25
|
+
"""
|
|
26
|
+
self._log_queue: queue.Queue[logging.LogRecord] = queue.Queue(queue_size)
|
|
27
|
+
super().__init__(self._log_queue)
|
|
28
|
+
|
|
29
|
+
# Initialize QueueListener
|
|
30
|
+
# It starts an internal thread to monitor the queue
|
|
31
|
+
# respect_handler_level=True ensures the target handlers' log levels are respected
|
|
32
|
+
self._listener: Optional[logging.handlers.QueueListener] = logging.handlers.QueueListener(
|
|
33
|
+
self._log_queue,
|
|
34
|
+
*handlers,
|
|
35
|
+
respect_handler_level=True
|
|
36
|
+
)
|
|
37
|
+
self._listener.start()
|
|
38
|
+
|
|
39
|
+
# Ensure the listener is stopped and queue is flushed upon exit
|
|
40
|
+
# This prevents lost logs at program termination
|
|
41
|
+
atexit.register(self.stop)
|
|
42
|
+
|
|
43
|
+
def stop(self) -> None:
|
|
44
|
+
"""
|
|
45
|
+
Stops the listener and flushes the queue.
|
|
46
|
+
|
|
47
|
+
This is registered with `atexit` to ensure that all pending logs
|
|
48
|
+
are written to disk before the application terminates.
|
|
49
|
+
"""
|
|
50
|
+
if self._listener:
|
|
51
|
+
self._listener.stop()
|
|
52
|
+
self._listener = None
|