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.
@@ -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
- from typing import Optional, Any, Callable, Tuple, Dict, Union, TypeVar, cast, overload
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
- """Returns the dedicated logger for flow events."""
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
- def _safe_repr(obj: Any, max_len: int = 50, max_depth: int = 1) -> str:
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
- Prevents massive log files by truncating long strings/objects
26
- and handling exceptions during __repr__ calls (e.g. strict objects).
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
- try:
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
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
- if len(r) > max_len:
37
- return r[:max_len] + "..."
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
- capture_args: bool = True,
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 "arg1, arg2, k=v".
52
- Used for the arrow label in the diagram.
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
- if not capture_args:
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(_safe_repr(arg, max_len=max_arg_length, max_depth=max_arg_depth))
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(v, max_len=max_arg_length, max_depth=max_arg_depth)
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 (Target) for the diagram.
73
-
74
- Resolution Priority:
75
- 1. **Override**: If the user explicitly provided `target="Name"`, use it.
76
- 2. **Instance Method**: If the first arg looks like `self` (has __class__),
77
- use the class name.
78
- 3. **Class Method**: If the first arg is a type (cls), use the class name.
79
- 4. **Module Function**: Fallback to the name of the module containing the function.
80
- 5. **Fallback**: "Unknown".
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: If it's a method call, args[0] is usually 'self'.
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
- # Check if it looks like a class instance
89
- # We check hasattr(__class__) to distinguish objects from primitives/containers broadly,
90
- # ensuring we don't mislabel a plain list passed as first arg to a function as a "List" participant.
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, type)
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 to module name for standalone functions
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
- source: str,
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
- Arrow: source -> target
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
- capture_args: bool = True,
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
- Arrow: target --> source (Dotted line return)
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: 'source' here is the original caller, 'target' is the callee.
146
- So the return arrow goes from target back to source.
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
- if capture_args:
150
- result_str = _safe_repr(result, max_len=max_arg_length, max_depth=max_arg_depth)
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
- source: str,
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
- Arrow: target -x source (Error return)
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
- trace_id=trace_id,
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 = True,
201
- max_arg_length: int = 50,
202
- max_arg_depth: int = 1,
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 = True,
214
- max_arg_length: int = 50,
215
- max_arg_depth: int = 1,
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. **Simple**: `@trace` (No arguments)
222
- 2. **Configured**: `@trace(action="Login", target="AuthService")`
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 passed in simple mode).
226
- source: Explicit name of the caller participant (rarely used, usually inferred from Context).
227
- target: Explicit name of the callee participant (overrides automatic resolution).
228
- name: Alias for 'target' (for clearer API usage).
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.
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 -> returns a factory
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, source, final_target, action, capture_args, max_arg_length, max_arg_depth
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
- capture_args: bool,
265
- max_arg_length: int,
266
- max_arg_depth: int,
547
+ config_obj: _TraceConfig,
267
548
  ) -> F:
268
549
  """
269
- Constructs the actual wrapper function.
270
- Handles both synchronous and asynchronous functions.
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
- This function separates the wrapper creation logic from the argument parsing logic
273
- in `trace_interaction`, making the code cleaner and easier to test.
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
- """Sync function wrapper."""
574
+ """
575
+ Synchronous function wrapper.
576
+ Executes tracing logic around a standard blocking function call.
577
+ """
284
578
  # 1. Resolve Context
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.
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 block)
298
- # Logs the initial "Call" arrow (Source -> Target)
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 -> B, then inside B, B becomes the source for C (B -> C).
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
- # Logs the "Return" arrow (Target --> Source)
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
- capture_args,
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
- # Logs the "Error" arrow (Target --x Source)
326
- _log_error(logger, current_source, current_target, action, e, trace_id)
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
- """Async function wrapper (coroutine)."""
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
- # 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.
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
- capture_args,
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, current_source, current_target, action, e, trace_id)
665
+ _log_error(logger, meta, e)
371
666
  raise
372
667
 
373
- # Detect if the wrapped function is a coroutine to choose the right wrapper
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