mermaid-trace 0.4.0__py3-none-any.whl → 0.5.3.post0__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 +55 -21
- mermaid_trace/cli.py +159 -63
- mermaid_trace/core/config.py +55 -0
- mermaid_trace/core/context.py +83 -23
- mermaid_trace/core/decorators.py +440 -145
- mermaid_trace/core/events.py +46 -63
- mermaid_trace/core/formatter.py +257 -29
- mermaid_trace/core/utils.py +96 -0
- mermaid_trace/handlers/async_handler.py +156 -27
- mermaid_trace/handlers/mermaid_handler.py +162 -76
- mermaid_trace/integrations/__init__.py +4 -0
- mermaid_trace/integrations/fastapi.py +110 -34
- {mermaid_trace-0.4.0.dist-info → mermaid_trace-0.5.3.post0.dist-info}/METADATA +78 -11
- mermaid_trace-0.5.3.post0.dist-info/RECORD +19 -0
- mermaid_trace-0.4.0.dist-info/RECORD +0 -16
- {mermaid_trace-0.4.0.dist-info → mermaid_trace-0.5.3.post0.dist-info}/WHEEL +0 -0
- {mermaid_trace-0.4.0.dist-info → mermaid_trace-0.5.3.post0.dist-info}/entry_points.txt +0 -0
- {mermaid_trace-0.4.0.dist-info → mermaid_trace-0.5.3.post0.dist-info}/licenses/LICENSE +0 -0
mermaid_trace/core/decorators.py
CHANGED
|
@@ -1,65 +1,275 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Function Tracing Decorator Module
|
|
3
|
+
=================================
|
|
4
|
+
|
|
5
|
+
This module provides the core tracing functionality for MermaidTrace. It implements the decorators
|
|
6
|
+
responsible for intercepting function calls, capturing execution details, and logging them as
|
|
7
|
+
structured events that can be visualized as Mermaid Sequence Diagrams.
|
|
8
|
+
|
|
9
|
+
Key Components:
|
|
10
|
+
---------------
|
|
11
|
+
1. **`@trace` Decorator**: The primary interface for users. It can be used as a simple decorator
|
|
12
|
+
`@trace` or with arguments `@trace(action="Login")`.
|
|
13
|
+
2. **Context Management**: Handles the propagation of "who called whom". It uses `LogContext`
|
|
14
|
+
to track the current "participant" (caller) so that nested function calls correctly
|
|
15
|
+
link to their parent caller.
|
|
16
|
+
3. **Event Logging**: Generates `FlowEvent` objects containing source, target, action, and
|
|
17
|
+
parameters, which are then passed to the specialized logging handler.
|
|
18
|
+
4. **Automatic Target Resolution**: Heuristics to intelligently guess the participant name
|
|
19
|
+
from the class instance (`self`) or class (`cls`), falling back to the module name.
|
|
20
|
+
|
|
21
|
+
Usage:
|
|
22
|
+
------
|
|
23
|
+
from mermaid_trace.core.decorators import trace
|
|
24
|
+
|
|
25
|
+
@trace
|
|
26
|
+
def my_function(x):
|
|
27
|
+
return x + 1
|
|
28
|
+
|
|
29
|
+
@trace(target="Database", action="Query Users")
|
|
30
|
+
async def get_users():
|
|
31
|
+
...
|
|
32
|
+
"""
|
|
33
|
+
|
|
1
34
|
import functools
|
|
2
35
|
import logging
|
|
3
36
|
import inspect
|
|
37
|
+
import re
|
|
4
38
|
import reprlib
|
|
5
|
-
|
|
39
|
+
import traceback
|
|
40
|
+
from dataclasses import dataclass
|
|
41
|
+
from typing import (
|
|
42
|
+
Optional,
|
|
43
|
+
Any,
|
|
44
|
+
Callable,
|
|
45
|
+
Tuple,
|
|
46
|
+
Dict,
|
|
47
|
+
Union,
|
|
48
|
+
TypeVar,
|
|
49
|
+
cast,
|
|
50
|
+
overload,
|
|
51
|
+
List,
|
|
52
|
+
)
|
|
6
53
|
|
|
7
54
|
from .events import FlowEvent
|
|
8
55
|
from .context import LogContext
|
|
56
|
+
from .config import config
|
|
9
57
|
|
|
58
|
+
# Logger name for flow events - used to isolate tracing logs from other application logs.
|
|
59
|
+
# This specific name is often used to configure a separate file handler in logging configs.
|
|
10
60
|
FLOW_LOGGER_NAME = "mermaid_trace.flow"
|
|
11
61
|
|
|
12
|
-
# Define generic type variable for the decorated function
|
|
62
|
+
# Define generic type variable for the decorated function to preserve type hints
|
|
13
63
|
F = TypeVar("F", bound=Callable[..., Any])
|
|
14
64
|
|
|
15
65
|
|
|
16
66
|
def get_flow_logger() -> logging.Logger:
|
|
17
|
-
"""
|
|
67
|
+
"""
|
|
68
|
+
Returns the dedicated logger for flow events.
|
|
69
|
+
|
|
70
|
+
This logger is intended to be used only for emitting `FlowEvent` objects.
|
|
71
|
+
It separates tracing noise from standard application logging.
|
|
72
|
+
|
|
73
|
+
Returns:
|
|
74
|
+
logging.Logger: Logger instance configured for tracing events.
|
|
75
|
+
"""
|
|
18
76
|
return logging.getLogger(FLOW_LOGGER_NAME)
|
|
19
77
|
|
|
20
78
|
|
|
21
|
-
|
|
79
|
+
class FlowRepr(reprlib.Repr):
|
|
80
|
+
"""
|
|
81
|
+
Custom Repr class that simplifies default Python object representations
|
|
82
|
+
(those containing memory addresses) into a cleaner <ClassName> format.
|
|
83
|
+
Also groups consecutive identical items in lists to keep diagrams concise.
|
|
22
84
|
"""
|
|
23
|
-
Safely creates a string representation of an object.
|
|
24
85
|
|
|
25
|
-
|
|
26
|
-
|
|
86
|
+
def _group_items(self, items_str: List[str]) -> List[str]:
|
|
87
|
+
"""Groups consecutive identical strings in a list."""
|
|
88
|
+
if not items_str:
|
|
89
|
+
return []
|
|
90
|
+
res = []
|
|
91
|
+
current_item = items_str[0]
|
|
92
|
+
current_count = 1
|
|
93
|
+
for i in range(1, len(items_str)):
|
|
94
|
+
if items_str[i] == current_item:
|
|
95
|
+
current_count += 1
|
|
96
|
+
else:
|
|
97
|
+
if current_count > 1:
|
|
98
|
+
res.append(f"{current_item} x {current_count}")
|
|
99
|
+
else:
|
|
100
|
+
res.append(current_item)
|
|
101
|
+
current_item = items_str[i]
|
|
102
|
+
current_count = 1
|
|
103
|
+
# Handle the last group
|
|
104
|
+
if current_count > 1:
|
|
105
|
+
res.append(f"{current_item} x {current_count}")
|
|
106
|
+
else:
|
|
107
|
+
res.append(current_item)
|
|
108
|
+
return res
|
|
109
|
+
|
|
110
|
+
def repr_list(self, obj: List[Any], level: int) -> str:
|
|
111
|
+
"""Custom list representation with item grouping."""
|
|
112
|
+
n = len(obj)
|
|
113
|
+
if n == 0:
|
|
114
|
+
return "[]"
|
|
115
|
+
items_str = []
|
|
116
|
+
for i in range(min(n, self.maxlist)):
|
|
117
|
+
items_str.append(self.repr1(obj[i], level - 1))
|
|
118
|
+
|
|
119
|
+
grouped = self._group_items(items_str)
|
|
120
|
+
if n > self.maxlist:
|
|
121
|
+
grouped.append("...")
|
|
122
|
+
return "[" + ", ".join(grouped) + "]"
|
|
123
|
+
|
|
124
|
+
def repr_tuple(self, obj: Tuple[Any, ...], level: int) -> str:
|
|
125
|
+
"""Custom tuple representation with item grouping."""
|
|
126
|
+
n = len(obj)
|
|
127
|
+
if n == 0:
|
|
128
|
+
return "()"
|
|
129
|
+
if n == 1:
|
|
130
|
+
return "(" + self.repr1(obj[0], level - 1) + ",)"
|
|
131
|
+
items_str = []
|
|
132
|
+
for i in range(min(n, self.maxtuple)):
|
|
133
|
+
items_str.append(self.repr1(obj[i], level - 1))
|
|
134
|
+
|
|
135
|
+
grouped = self._group_items(items_str)
|
|
136
|
+
if n > self.maxtuple:
|
|
137
|
+
grouped.append("...")
|
|
138
|
+
return "(" + ", ".join(grouped) + ")"
|
|
139
|
+
|
|
140
|
+
def repr1(self, x: Any, level: int) -> str:
|
|
141
|
+
# Check if the object uses the default object.__repr__
|
|
142
|
+
# Default repr looks like <module.Class object at 0x...>
|
|
143
|
+
raw = repr(x)
|
|
144
|
+
if " object at 0x" in raw and raw.startswith("<") and raw.endswith(">"):
|
|
145
|
+
# Simplify to just <ClassName>
|
|
146
|
+
return f"<{x.__class__.__name__}>"
|
|
147
|
+
|
|
148
|
+
# Also handle some common cases where address is present but it's not the default repr
|
|
149
|
+
if " at 0x" in raw and raw.startswith("<") and raw.endswith(">"):
|
|
150
|
+
# Try to extract the class name or just simplify it
|
|
151
|
+
return f"<{x.__class__.__name__}>"
|
|
152
|
+
|
|
153
|
+
return super().repr1(x, level)
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
def _safe_repr(
|
|
157
|
+
obj: Any, max_len: Optional[int] = None, max_depth: Optional[int] = None
|
|
158
|
+
) -> str:
|
|
27
159
|
"""
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
160
|
+
Safely creates a string representation of an object for logging purposes.
|
|
161
|
+
|
|
162
|
+
This function is critical for preventing log bloat and runtime errors during tracing.
|
|
163
|
+
It handles:
|
|
164
|
+
1. **Truncation**: Limits the length of strings to prevent huge log files.
|
|
165
|
+
2. **Depth Control**: Limits recursion for nested structures like dicts/lists.
|
|
166
|
+
3. **Error Handling**: Catches exceptions if an object's `__repr__` is buggy or strict.
|
|
167
|
+
4. **Simplification**: Automatically simplifies default Python object representations
|
|
168
|
+
(containing memory addresses) into <ClassName>.
|
|
169
|
+
|
|
170
|
+
Args:
|
|
171
|
+
obj: The object to represent as a string.
|
|
172
|
+
max_len: Maximum length of the resulting string before truncation.
|
|
173
|
+
Defaults to config.max_string_length if None.
|
|
174
|
+
max_depth: Maximum recursion depth for nested objects.
|
|
175
|
+
Defaults to config.max_arg_depth if None.
|
|
176
|
+
|
|
177
|
+
Returns:
|
|
178
|
+
str: Safe, truncated representation of the object.
|
|
179
|
+
"""
|
|
180
|
+
# Use config defaults if not explicitly provided
|
|
181
|
+
final_max_len = max_len if max_len is not None else config.max_string_length
|
|
182
|
+
final_max_depth = max_depth if max_depth is not None else config.max_arg_depth
|
|
34
183
|
|
|
184
|
+
try:
|
|
185
|
+
# Use our custom FlowRepr to provide standard way to limit representation size
|
|
186
|
+
# and simplify default object reprs recursively.
|
|
187
|
+
a_repr = FlowRepr()
|
|
188
|
+
a_repr.maxstring = final_max_len
|
|
189
|
+
a_repr.maxother = final_max_len
|
|
190
|
+
a_repr.maxlevel = final_max_depth
|
|
191
|
+
|
|
192
|
+
# Generate the representation
|
|
35
193
|
r = a_repr.repr(obj)
|
|
36
|
-
|
|
37
|
-
|
|
194
|
+
|
|
195
|
+
# Final pass: Catch any remaining memory addresses using regex
|
|
196
|
+
# (e.g., in types reprlib doesn't recurse into)
|
|
197
|
+
# 1. <__main__.Class object at 0x...> -> <Class>
|
|
198
|
+
r = re.sub(
|
|
199
|
+
r"<([a-zA-Z0-9_.]+\.)?([a-zA-Z0-9_]+) object at 0x[0-9a-fA-F]+>",
|
|
200
|
+
r"<\2>",
|
|
201
|
+
r,
|
|
202
|
+
)
|
|
203
|
+
# 2. <Class at 0x...> -> <Class>
|
|
204
|
+
r = re.sub(
|
|
205
|
+
r"<([a-zA-Z0-9_.]+\.)?([a-zA-Z0-9_]+) at 0x[0-9a-fA-F]+>",
|
|
206
|
+
r"<\2>",
|
|
207
|
+
r,
|
|
208
|
+
)
|
|
209
|
+
|
|
210
|
+
# Double-check length constraint as reprlib might sometimes exceed it slightly
|
|
211
|
+
if len(r) > final_max_len:
|
|
212
|
+
return r[:final_max_len] + "..."
|
|
38
213
|
return r
|
|
39
214
|
except Exception:
|
|
215
|
+
# Fallback if repr() fails (e.g., property access raising error in __repr__)
|
|
40
216
|
return "<unrepresentable>"
|
|
41
217
|
|
|
42
218
|
|
|
219
|
+
@dataclass
|
|
220
|
+
class _TraceConfig:
|
|
221
|
+
"""Internal container for tracing configuration to avoid PLR0913."""
|
|
222
|
+
|
|
223
|
+
capture_args: Optional[bool] = None
|
|
224
|
+
max_arg_length: Optional[int] = None
|
|
225
|
+
max_arg_depth: Optional[int] = None
|
|
226
|
+
|
|
227
|
+
|
|
43
228
|
def _format_args(
|
|
44
229
|
args: Tuple[Any, ...],
|
|
45
230
|
kwargs: Dict[str, Any],
|
|
46
|
-
|
|
47
|
-
max_arg_length: int = 50,
|
|
48
|
-
max_arg_depth: int = 1,
|
|
231
|
+
config_obj: _TraceConfig,
|
|
49
232
|
) -> str:
|
|
50
233
|
"""
|
|
51
|
-
Formats function arguments into a single string
|
|
52
|
-
|
|
234
|
+
Formats function arguments into a single string for the diagram arrow label.
|
|
235
|
+
|
|
236
|
+
Example Output: "1, 'test', debug=True"
|
|
237
|
+
|
|
238
|
+
This string is what appears on the arrow in the Mermaid diagram (e.g., `User->System: login(args...)`).
|
|
239
|
+
|
|
240
|
+
Args:
|
|
241
|
+
args: Positional arguments tuple.
|
|
242
|
+
kwargs: Keyword arguments dictionary.
|
|
243
|
+
config_obj: Trace configuration object.
|
|
244
|
+
|
|
245
|
+
Returns:
|
|
246
|
+
str: Comma-separated string of formatted arguments.
|
|
53
247
|
"""
|
|
54
|
-
|
|
248
|
+
final_capture = (
|
|
249
|
+
config_obj.capture_args
|
|
250
|
+
if config_obj.capture_args is not None
|
|
251
|
+
else config.capture_args
|
|
252
|
+
)
|
|
253
|
+
if not final_capture:
|
|
55
254
|
return ""
|
|
56
255
|
|
|
57
|
-
parts = []
|
|
256
|
+
parts: list[str] = []
|
|
257
|
+
|
|
258
|
+
# Process positional arguments
|
|
58
259
|
for arg in args:
|
|
59
|
-
parts.append(
|
|
260
|
+
parts.append(
|
|
261
|
+
_safe_repr(
|
|
262
|
+
arg,
|
|
263
|
+
max_len=config_obj.max_arg_length,
|
|
264
|
+
max_depth=config_obj.max_arg_depth,
|
|
265
|
+
)
|
|
266
|
+
)
|
|
60
267
|
|
|
268
|
+
# Process keyword arguments
|
|
61
269
|
for k, v in kwargs.items():
|
|
62
|
-
val_str = _safe_repr(
|
|
270
|
+
val_str = _safe_repr(
|
|
271
|
+
v, max_len=config_obj.max_arg_length, max_depth=config_obj.max_arg_depth
|
|
272
|
+
)
|
|
63
273
|
parts.append(f"{k}={val_str}")
|
|
64
274
|
|
|
65
275
|
return ", ".join(parts)
|
|
@@ -69,62 +279,92 @@ def _resolve_target(
|
|
|
69
279
|
func: Callable[..., Any], args: Tuple[Any, ...], target_override: Optional[str]
|
|
70
280
|
) -> str:
|
|
71
281
|
"""
|
|
72
|
-
Determines the name of the participant (
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
282
|
+
Determines the name of the 'Target' participant (the callee) for the diagram.
|
|
283
|
+
|
|
284
|
+
The 'Target' is the entity *receiving* the call. We try to infer a meaningful name
|
|
285
|
+
(like the class name) so the diagram shows "User -> AuthService" instead of "User -> login".
|
|
286
|
+
|
|
287
|
+
Resolution Logic:
|
|
288
|
+
1. **Override**: Use explicit `target` from decorator if provided.
|
|
289
|
+
2. **Instance Method**: If first arg looks like `self` (has `__class__`), use ClassName.
|
|
290
|
+
3. **Class Method**: If first arg is a type (cls), use ClassName.
|
|
291
|
+
4. **Module Function**: Use the module name (e.g., "utils" from "my.pkg.utils").
|
|
292
|
+
5. **Fallback**: "Unknown".
|
|
293
|
+
|
|
294
|
+
Args:
|
|
295
|
+
func: The function being called (for module inspection).
|
|
296
|
+
args: Positional arguments (to check for self/cls).
|
|
297
|
+
target_override: Explicit target name provided by user via decorator.
|
|
298
|
+
|
|
299
|
+
Returns:
|
|
300
|
+
str: The resolved name for the target participant.
|
|
81
301
|
"""
|
|
82
302
|
if target_override:
|
|
83
303
|
return target_override
|
|
84
304
|
|
|
85
|
-
# Heuristic:
|
|
305
|
+
# Heuristic: Check if this is a method call where args[0] is 'self' or 'cls'
|
|
86
306
|
if args:
|
|
87
307
|
first_arg = args[0]
|
|
88
|
-
|
|
89
|
-
#
|
|
90
|
-
|
|
308
|
+
|
|
309
|
+
# Check for class method (cls) - where first arg is the type itself
|
|
310
|
+
if isinstance(first_arg, type):
|
|
311
|
+
return first_arg.__name__
|
|
312
|
+
|
|
313
|
+
# Check for instance method (self)
|
|
314
|
+
# We filter out primitives because functions might take an int/str as first arg,
|
|
315
|
+
# which shouldn't be treated as 'self'.
|
|
91
316
|
if hasattr(first_arg, "__class__") and not isinstance(
|
|
92
|
-
first_arg, (str, int, float, bool, list, dict,
|
|
317
|
+
first_arg, (str, int, float, bool, list, dict, set, tuple)
|
|
93
318
|
):
|
|
94
319
|
return str(first_arg.__class__.__name__)
|
|
95
|
-
# Check if it looks like a class (cls) - e.g. @classmethod
|
|
96
|
-
if isinstance(first_arg, type):
|
|
97
|
-
return first_arg.__name__
|
|
98
320
|
|
|
99
|
-
# Fallback
|
|
321
|
+
# Fallback: Use module name for standalone functions
|
|
100
322
|
module = inspect.getmodule(func)
|
|
101
323
|
if module:
|
|
324
|
+
# Extract just the last part of the module path (e.g. 'auth' from 'app.core.auth')
|
|
102
325
|
return module.__name__.split(".")[-1]
|
|
326
|
+
|
|
103
327
|
return "Unknown"
|
|
104
328
|
|
|
105
329
|
|
|
330
|
+
@dataclass
|
|
331
|
+
class _TraceMetadata:
|
|
332
|
+
"""Internal container for trace metadata to avoid PLR0913."""
|
|
333
|
+
|
|
334
|
+
source: str
|
|
335
|
+
target: str
|
|
336
|
+
action: str
|
|
337
|
+
trace_id: str
|
|
338
|
+
|
|
339
|
+
|
|
106
340
|
def _log_interaction(
|
|
107
341
|
logger: logging.Logger,
|
|
108
|
-
|
|
109
|
-
target: str,
|
|
110
|
-
action: str,
|
|
342
|
+
meta: _TraceMetadata,
|
|
111
343
|
params: str,
|
|
112
|
-
trace_id: str,
|
|
113
344
|
) -> None:
|
|
114
345
|
"""
|
|
115
|
-
Logs the 'Call' event (Start of function).
|
|
116
|
-
|
|
346
|
+
Logs the 'Call' event (Start of function execution).
|
|
347
|
+
|
|
348
|
+
This corresponds to the solid arrow in Mermaid: `Source -> Target: Action(params)`
|
|
349
|
+
|
|
350
|
+
Args:
|
|
351
|
+
logger: Logger instance.
|
|
352
|
+
meta: Trace metadata.
|
|
353
|
+
params: Stringified arguments.
|
|
117
354
|
"""
|
|
118
355
|
req_event = FlowEvent(
|
|
119
|
-
source=source,
|
|
120
|
-
target=target,
|
|
121
|
-
action=action,
|
|
122
|
-
message=action,
|
|
356
|
+
source=meta.source,
|
|
357
|
+
target=meta.target,
|
|
358
|
+
action=meta.action,
|
|
359
|
+
message=meta.action,
|
|
123
360
|
params=params,
|
|
124
|
-
trace_id=trace_id,
|
|
361
|
+
trace_id=meta.trace_id,
|
|
362
|
+
)
|
|
363
|
+
# The 'extra' dict is crucial. The custom LogHandler extracts 'flow_event'
|
|
364
|
+
# from here to format the actual Mermaid syntax line.
|
|
365
|
+
logger.info(
|
|
366
|
+
f"{meta.source}->{meta.target}: {meta.action}", extra={"flow_event": req_event}
|
|
125
367
|
)
|
|
126
|
-
# The 'extra' dict is critical: the Handler will pick this up to format the Mermaid line
|
|
127
|
-
logger.info(f"{source}->{target}: {action}", extra={"flow_event": req_event})
|
|
128
368
|
|
|
129
369
|
|
|
130
370
|
def _log_return(
|
|
@@ -134,24 +374,43 @@ def _log_return(
|
|
|
134
374
|
action: str,
|
|
135
375
|
result: Any,
|
|
136
376
|
trace_id: str,
|
|
137
|
-
|
|
138
|
-
max_arg_length: int = 50,
|
|
139
|
-
max_arg_depth: int = 1,
|
|
377
|
+
config_obj: _TraceConfig,
|
|
140
378
|
) -> None:
|
|
141
379
|
"""
|
|
142
|
-
Logs the 'Return' event (End of function).
|
|
143
|
-
|
|
380
|
+
Logs the 'Return' event (End of function execution).
|
|
381
|
+
|
|
382
|
+
This corresponds to the dotted return arrow in Mermaid: `Target --> Source: Return value`
|
|
144
383
|
|
|
145
|
-
Note
|
|
146
|
-
|
|
384
|
+
Note on Direction:
|
|
385
|
+
- In the diagram, the return goes from `target` (callee) back to `source` (caller).
|
|
386
|
+
- The code logs it as `Target->Source` to reflect this flow.
|
|
387
|
+
|
|
388
|
+
Args:
|
|
389
|
+
logger: Logger instance.
|
|
390
|
+
source: The original caller (who will receive the return).
|
|
391
|
+
target: The callee (who is returning).
|
|
392
|
+
action: The action that is completing.
|
|
393
|
+
result: The return value of the function.
|
|
394
|
+
trace_id: Trace correlation ID.
|
|
395
|
+
config_obj: Trace configuration object.
|
|
147
396
|
"""
|
|
148
397
|
result_str = ""
|
|
149
|
-
|
|
150
|
-
|
|
398
|
+
final_capture = (
|
|
399
|
+
config_obj.capture_args
|
|
400
|
+
if config_obj.capture_args is not None
|
|
401
|
+
else config.capture_args
|
|
402
|
+
)
|
|
403
|
+
|
|
404
|
+
if final_capture:
|
|
405
|
+
result_str = _safe_repr(
|
|
406
|
+
result,
|
|
407
|
+
max_len=config_obj.max_arg_length,
|
|
408
|
+
max_depth=config_obj.max_arg_depth,
|
|
409
|
+
)
|
|
151
410
|
|
|
152
411
|
resp_event = FlowEvent(
|
|
153
|
-
source=target,
|
|
154
|
-
target=source,
|
|
412
|
+
source=target, # Return flows FROM target
|
|
413
|
+
target=source, # Return flows TO source
|
|
155
414
|
action=action,
|
|
156
415
|
message="Return",
|
|
157
416
|
is_return=True,
|
|
@@ -163,33 +422,46 @@ def _log_return(
|
|
|
163
422
|
|
|
164
423
|
def _log_error(
|
|
165
424
|
logger: logging.Logger,
|
|
166
|
-
|
|
167
|
-
target: str,
|
|
168
|
-
action: str,
|
|
425
|
+
meta: _TraceMetadata,
|
|
169
426
|
error: Exception,
|
|
170
|
-
trace_id: str,
|
|
171
427
|
) -> None:
|
|
172
428
|
"""
|
|
173
429
|
Logs an 'Error' event if the function raises an exception.
|
|
174
|
-
|
|
430
|
+
|
|
431
|
+
This corresponds to the 'X' arrow in Mermaid: `Target -x Source: Error Message`
|
|
432
|
+
|
|
433
|
+
Args:
|
|
434
|
+
logger: Logger instance.
|
|
435
|
+
meta: Trace metadata.
|
|
436
|
+
error: The exception object.
|
|
175
437
|
"""
|
|
438
|
+
# Capture full stack trace
|
|
439
|
+
stack_trace = "".join(
|
|
440
|
+
traceback.format_exception(type(error), error, error.__traceback__)
|
|
441
|
+
)
|
|
442
|
+
|
|
176
443
|
err_event = FlowEvent(
|
|
177
|
-
source=target,
|
|
178
|
-
target=source,
|
|
179
|
-
action=action,
|
|
444
|
+
source=meta.target,
|
|
445
|
+
target=meta.source,
|
|
446
|
+
action=meta.action,
|
|
180
447
|
message=str(error),
|
|
181
448
|
is_return=True,
|
|
182
|
-
is_error=True,
|
|
449
|
+
is_error=True, # Flags this as an error event
|
|
183
450
|
error_message=str(error),
|
|
184
|
-
|
|
451
|
+
stack_trace=stack_trace,
|
|
452
|
+
trace_id=meta.trace_id,
|
|
453
|
+
)
|
|
454
|
+
logger.error(
|
|
455
|
+
f"{meta.target}-x{meta.source}: Error", extra={"flow_event": err_event}
|
|
185
456
|
)
|
|
186
|
-
logger.error(f"{target}-x{source}: Error", extra={"flow_event": err_event})
|
|
187
457
|
|
|
188
458
|
|
|
459
|
+
# Overload 1: Simple usage -> @trace
|
|
189
460
|
@overload
|
|
190
461
|
def trace_interaction(func: F) -> F: ...
|
|
191
462
|
|
|
192
463
|
|
|
464
|
+
# Overload 2: Configured usage -> @trace(action="Login")
|
|
193
465
|
@overload
|
|
194
466
|
def trace_interaction(
|
|
195
467
|
*,
|
|
@@ -197,9 +469,9 @@ def trace_interaction(
|
|
|
197
469
|
target: Optional[str] = None,
|
|
198
470
|
name: Optional[str] = None,
|
|
199
471
|
action: Optional[str] = None,
|
|
200
|
-
capture_args: bool =
|
|
201
|
-
max_arg_length: int =
|
|
202
|
-
max_arg_depth: int =
|
|
472
|
+
capture_args: Optional[bool] = None,
|
|
473
|
+
max_arg_length: Optional[int] = None,
|
|
474
|
+
max_arg_depth: Optional[int] = None,
|
|
203
475
|
) -> Callable[[F], F]: ...
|
|
204
476
|
|
|
205
477
|
|
|
@@ -210,47 +482,58 @@ def trace_interaction(
|
|
|
210
482
|
target: Optional[str] = None,
|
|
211
483
|
name: Optional[str] = None,
|
|
212
484
|
action: Optional[str] = None,
|
|
213
|
-
capture_args: bool =
|
|
214
|
-
max_arg_length: int =
|
|
215
|
-
max_arg_depth: int =
|
|
216
|
-
) -> Union[F, Callable[[F], F]]:
|
|
485
|
+
capture_args: Optional[bool] = None,
|
|
486
|
+
max_arg_length: Optional[int] = None,
|
|
487
|
+
max_arg_depth: Optional[int] = None,
|
|
488
|
+
) -> Union[F, Callable[[F], F]]: # noqa: PLR0913
|
|
217
489
|
"""
|
|
218
490
|
Main Decorator for tracing function execution in Mermaid diagrams.
|
|
219
491
|
|
|
492
|
+
This decorator instruments functions to log their execution flow as Mermaid
|
|
493
|
+
sequence diagram events. It supports both synchronous and asynchronous functions,
|
|
494
|
+
and automatically handles context propagation for nested calls.
|
|
495
|
+
|
|
220
496
|
It supports two modes of operation:
|
|
221
|
-
1.
|
|
222
|
-
2.
|
|
497
|
+
1. **Simple Mode**: `@trace` (No arguments). Uses default naming (Class/Module name) and behavior.
|
|
498
|
+
2. **Configured Mode**: `@trace(action="Login", target="AuthService")`. Customizes the diagram labels.
|
|
223
499
|
|
|
224
500
|
Args:
|
|
225
|
-
func: The function being decorated (automatically
|
|
226
|
-
source: Explicit name of the caller participant
|
|
227
|
-
target: Explicit name of the callee participant
|
|
228
|
-
name: Alias for 'target' (
|
|
229
|
-
action: Label for the arrow
|
|
230
|
-
capture_args:
|
|
231
|
-
max_arg_length:
|
|
232
|
-
max_arg_depth:
|
|
501
|
+
func: The function being decorated (passed automatically in Simple Mode).
|
|
502
|
+
source: Explicit name of the caller participant. Rarely used, as it's usually inferred from Context.
|
|
503
|
+
target: Explicit name of the callee participant. Overrides automatic `self`/`cls`/module resolution.
|
|
504
|
+
name: Alias for 'target' (syntactic sugar).
|
|
505
|
+
action: Label for the arrow. Defaults to the function name (Title Cased).
|
|
506
|
+
capture_args: If True, logs arguments and return values. Set False for sensitive data.
|
|
507
|
+
max_arg_length: Truncation limit for logging arguments/results.
|
|
508
|
+
max_arg_depth: Recursion limit for logging arguments/results.
|
|
509
|
+
|
|
510
|
+
Returns:
|
|
511
|
+
Callable: The decorated function (in Simple Mode) or a decorator factory (in Configured Mode).
|
|
233
512
|
"""
|
|
234
513
|
|
|
235
|
-
# Handle alias
|
|
514
|
+
# Handle alias - 'name' is an alternative convenience name for 'target'
|
|
236
515
|
final_target = target or name
|
|
237
516
|
|
|
238
517
|
# Mode 1: @trace used without parentheses
|
|
518
|
+
# func is passed directly. We create the wrapper immediately.
|
|
239
519
|
if func is not None and callable(func):
|
|
240
520
|
return _create_decorator(
|
|
241
521
|
func,
|
|
242
522
|
source,
|
|
243
523
|
final_target,
|
|
244
524
|
action,
|
|
245
|
-
capture_args,
|
|
246
|
-
max_arg_length,
|
|
247
|
-
max_arg_depth,
|
|
525
|
+
_TraceConfig(capture_args, max_arg_length, max_arg_depth),
|
|
248
526
|
)
|
|
249
527
|
|
|
250
|
-
# Mode 2: @trace(...) used with arguments
|
|
528
|
+
# Mode 2: @trace(...) used with arguments
|
|
529
|
+
# func is None. We return a "factory" function that Python will call with the function later.
|
|
251
530
|
def factory(f: F) -> F:
|
|
252
531
|
return _create_decorator(
|
|
253
|
-
f,
|
|
532
|
+
f,
|
|
533
|
+
source,
|
|
534
|
+
final_target,
|
|
535
|
+
action,
|
|
536
|
+
_TraceConfig(capture_args, max_arg_length, max_arg_depth),
|
|
254
537
|
)
|
|
255
538
|
|
|
256
539
|
return factory
|
|
@@ -261,53 +544,65 @@ def _create_decorator(
|
|
|
261
544
|
source: Optional[str],
|
|
262
545
|
target: Optional[str],
|
|
263
546
|
action: Optional[str],
|
|
264
|
-
|
|
265
|
-
max_arg_length: int,
|
|
266
|
-
max_arg_depth: int,
|
|
547
|
+
config_obj: _TraceConfig,
|
|
267
548
|
) -> F:
|
|
268
549
|
"""
|
|
269
|
-
|
|
270
|
-
|
|
550
|
+
Internal factory that constructs the actual wrapper function.
|
|
551
|
+
|
|
552
|
+
This separates the wrapper creation logic from the argument parsing logic in `trace_interaction`.
|
|
553
|
+
It handles the distinction between synchronous and asynchronous functions, returning
|
|
554
|
+
the appropriate wrapper type.
|
|
271
555
|
|
|
272
|
-
|
|
273
|
-
|
|
556
|
+
Args:
|
|
557
|
+
func: The function to decorate.
|
|
558
|
+
source: Configured source name.
|
|
559
|
+
target: Configured target name.
|
|
560
|
+
action: Configured action name.
|
|
561
|
+
config_obj: Trace configuration object.
|
|
562
|
+
|
|
563
|
+
Returns:
|
|
564
|
+
Callable: The wrapped function containing tracing logic.
|
|
274
565
|
"""
|
|
275
566
|
|
|
276
|
-
# Pre-calculate static metadata to save time at runtime
|
|
567
|
+
# Pre-calculate static metadata to save time at runtime.
|
|
568
|
+
# If no action name provided, generate one from the function name (e.g., "get_user" -> "Get User")
|
|
277
569
|
if action is None:
|
|
278
|
-
# Default action name is the function name, converted to Title Case
|
|
279
570
|
action = func.__name__.replace("_", " ").title()
|
|
280
571
|
|
|
281
572
|
@functools.wraps(func)
|
|
282
573
|
def wrapper(*args: Any, **kwargs: Any) -> Any:
|
|
283
|
-
"""
|
|
574
|
+
"""
|
|
575
|
+
Synchronous function wrapper.
|
|
576
|
+
Executes tracing logic around a standard blocking function call.
|
|
577
|
+
"""
|
|
284
578
|
# 1. Resolve Context
|
|
285
|
-
# '
|
|
286
|
-
# If 'source' is not explicitly provided, we look up the 'participant' set by the caller.
|
|
579
|
+
# 'current_source' is who called us. If not explicit, we get it from thread-local storage.
|
|
287
580
|
current_source = source or LogContext.current_participant()
|
|
288
581
|
trace_id = LogContext.current_trace_id()
|
|
582
|
+
|
|
583
|
+
# 'current_target' is who we are. We figure this out from 'self', 'cls', or module name.
|
|
289
584
|
current_target = _resolve_target(func, args, target)
|
|
290
585
|
|
|
586
|
+
meta = _TraceMetadata(current_source, current_target, action, trace_id)
|
|
587
|
+
|
|
291
588
|
logger = get_flow_logger()
|
|
292
589
|
# Format arguments for the diagram arrow label
|
|
293
|
-
params_str = _format_args(
|
|
294
|
-
args, kwargs, capture_args, max_arg_length, max_arg_depth
|
|
295
|
-
)
|
|
590
|
+
params_str = _format_args(args, kwargs, config_obj)
|
|
296
591
|
|
|
297
|
-
# 2. Log Request (Start of
|
|
298
|
-
#
|
|
299
|
-
_log_interaction(
|
|
300
|
-
logger, current_source, current_target, action, params_str, trace_id
|
|
301
|
-
)
|
|
592
|
+
# 2. Log Request (Start of function)
|
|
593
|
+
# Emits the "Call" arrow (Source -> Target)
|
|
594
|
+
_log_interaction(logger, meta, params_str)
|
|
302
595
|
|
|
303
596
|
# 3. Execute with New Context
|
|
304
|
-
# We push 'current_target' as the NEW 'participant' (source) for any internal calls.
|
|
305
|
-
# This builds the chain: A
|
|
597
|
+
# We push 'current_target' as the NEW 'participant' (source) for any internal calls made by this function.
|
|
598
|
+
# This builds the chain: A calls B (A->B), then B calls C (B->C).
|
|
306
599
|
with LogContext.scope({"participant": current_target, "trace_id": trace_id}):
|
|
307
600
|
try:
|
|
601
|
+
# Execute the actual user function
|
|
308
602
|
result = func(*args, **kwargs)
|
|
603
|
+
|
|
309
604
|
# 4. Log Success Return
|
|
310
|
-
#
|
|
605
|
+
# Emits the "Return" arrow (Target --> Source)
|
|
311
606
|
_log_return(
|
|
312
607
|
logger,
|
|
313
608
|
current_source,
|
|
@@ -315,43 +610,45 @@ def _create_decorator(
|
|
|
315
610
|
action,
|
|
316
611
|
result,
|
|
317
612
|
trace_id,
|
|
318
|
-
|
|
319
|
-
max_arg_length,
|
|
320
|
-
max_arg_depth,
|
|
613
|
+
config_obj,
|
|
321
614
|
)
|
|
322
615
|
return result
|
|
323
616
|
except Exception as e:
|
|
324
617
|
# 5. Log Error Return
|
|
325
|
-
#
|
|
326
|
-
_log_error(logger,
|
|
618
|
+
# Emits the "Error" arrow (Target -x Source)
|
|
619
|
+
_log_error(logger, meta, e)
|
|
620
|
+
# Re-raise the exception so program flow isn't altered
|
|
327
621
|
raise
|
|
328
622
|
|
|
329
623
|
@functools.wraps(func)
|
|
330
624
|
async def async_wrapper(*args: Any, **kwargs: Any) -> Any:
|
|
331
|
-
"""
|
|
625
|
+
"""
|
|
626
|
+
Asynchronous function wrapper.
|
|
627
|
+
Executes tracing logic around an async/await coroutine.
|
|
628
|
+
"""
|
|
332
629
|
# 1. Resolve Context (Same as sync)
|
|
333
630
|
current_source = source or LogContext.current_participant()
|
|
334
631
|
trace_id = LogContext.current_trace_id()
|
|
335
632
|
current_target = _resolve_target(func, args, target)
|
|
336
633
|
|
|
634
|
+
meta = _TraceMetadata(current_source, current_target, action, trace_id)
|
|
635
|
+
|
|
337
636
|
logger = get_flow_logger()
|
|
338
|
-
params_str = _format_args(
|
|
339
|
-
args, kwargs, capture_args, max_arg_length, max_arg_depth
|
|
340
|
-
)
|
|
637
|
+
params_str = _format_args(args, kwargs, config_obj)
|
|
341
638
|
|
|
342
639
|
# 2. Log Request
|
|
343
|
-
_log_interaction(
|
|
344
|
-
logger, current_source, current_target, action, params_str, trace_id
|
|
345
|
-
)
|
|
640
|
+
_log_interaction(logger, meta, params_str)
|
|
346
641
|
|
|
347
642
|
# 3. Execute with New Context using 'ascope'
|
|
348
|
-
#
|
|
349
|
-
# This
|
|
643
|
+
# Crucial difference for Async: We use `ascope` (async scope) which uses contextvars.
|
|
644
|
+
# This ensures the context is preserved across `await` points where the event loop might switch tasks.
|
|
350
645
|
async with LogContext.ascope(
|
|
351
646
|
{"participant": current_target, "trace_id": trace_id}
|
|
352
647
|
):
|
|
353
648
|
try:
|
|
649
|
+
# Await the actual user coroutine
|
|
354
650
|
result = await func(*args, **kwargs)
|
|
651
|
+
|
|
355
652
|
# 4. Log Success Return
|
|
356
653
|
_log_return(
|
|
357
654
|
logger,
|
|
@@ -360,21 +657,19 @@ def _create_decorator(
|
|
|
360
657
|
action,
|
|
361
658
|
result,
|
|
362
659
|
trace_id,
|
|
363
|
-
|
|
364
|
-
max_arg_length,
|
|
365
|
-
max_arg_depth,
|
|
660
|
+
config_obj,
|
|
366
661
|
)
|
|
367
662
|
return result
|
|
368
663
|
except Exception as e:
|
|
369
664
|
# 5. Log Error Return
|
|
370
|
-
_log_error(logger,
|
|
665
|
+
_log_error(logger, meta, e)
|
|
371
666
|
raise
|
|
372
667
|
|
|
373
|
-
# Detect if the wrapped function is a coroutine
|
|
668
|
+
# Detect if the wrapped function is a coroutine (async def)
|
|
374
669
|
if inspect.iscoroutinefunction(func):
|
|
375
|
-
return cast(F, async_wrapper)
|
|
376
|
-
return cast(F, wrapper)
|
|
670
|
+
return cast(F, async_wrapper) # Use async wrapper for async functions
|
|
671
|
+
return cast(F, wrapper) # Use sync wrapper for regular functions
|
|
377
672
|
|
|
378
673
|
|
|
379
|
-
# Alias for easy import
|
|
674
|
+
# Alias for easy import - 'trace' is the primary name users should use
|
|
380
675
|
trace = trace_interaction
|