ddeutil-workflow 0.0.78__py3-none-any.whl → 0.0.80__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.
Files changed (30) hide show
  1. ddeutil/workflow/__about__.py +1 -1
  2. ddeutil/workflow/__init__.py +2 -6
  3. ddeutil/workflow/api/routes/job.py +2 -2
  4. ddeutil/workflow/api/routes/logs.py +5 -5
  5. ddeutil/workflow/api/routes/workflows.py +3 -3
  6. ddeutil/workflow/audits.py +547 -176
  7. ddeutil/workflow/cli.py +19 -1
  8. ddeutil/workflow/conf.py +10 -20
  9. ddeutil/workflow/event.py +15 -6
  10. ddeutil/workflow/job.py +147 -74
  11. ddeutil/workflow/params.py +172 -58
  12. ddeutil/workflow/plugins/__init__.py +0 -0
  13. ddeutil/workflow/plugins/providers/__init__.py +0 -0
  14. ddeutil/workflow/plugins/providers/aws.py +908 -0
  15. ddeutil/workflow/plugins/providers/az.py +1003 -0
  16. ddeutil/workflow/plugins/providers/container.py +703 -0
  17. ddeutil/workflow/plugins/providers/gcs.py +826 -0
  18. ddeutil/workflow/result.py +6 -4
  19. ddeutil/workflow/reusables.py +151 -95
  20. ddeutil/workflow/stages.py +28 -28
  21. ddeutil/workflow/traces.py +1697 -541
  22. ddeutil/workflow/utils.py +109 -67
  23. ddeutil/workflow/workflow.py +42 -30
  24. {ddeutil_workflow-0.0.78.dist-info → ddeutil_workflow-0.0.80.dist-info}/METADATA +39 -19
  25. ddeutil_workflow-0.0.80.dist-info/RECORD +36 -0
  26. ddeutil_workflow-0.0.78.dist-info/RECORD +0 -30
  27. {ddeutil_workflow-0.0.78.dist-info → ddeutil_workflow-0.0.80.dist-info}/WHEEL +0 -0
  28. {ddeutil_workflow-0.0.78.dist-info → ddeutil_workflow-0.0.80.dist-info}/entry_points.txt +0 -0
  29. {ddeutil_workflow-0.0.78.dist-info → ddeutil_workflow-0.0.80.dist-info}/licenses/LICENSE +0 -0
  30. {ddeutil_workflow-0.0.78.dist-info → ddeutil_workflow-0.0.80.dist-info}/top_level.txt +0 -0
@@ -13,45 +13,26 @@ The tracing system captures detailed execution metadata including process IDs,
13
13
  thread identifiers, timestamps, and contextual information for debugging and
14
14
  monitoring workflow executions.
15
15
 
16
- Classes:
17
- Message: Log message model with prefix parsing
18
- TraceMeta: Metadata model for execution context
19
- TraceData: Container for trace information
20
- BaseTrace: Abstract base class for trace implementations
21
- ConsoleTrace: Console-based trace output
22
- FileTrace: File-based trace storage
23
- SQLiteTrace: Database-based trace storage
24
-
25
16
  Functions:
26
- set_logging: Configure logger with custom formatting
27
- get_trace: Factory function for trace instances
28
-
29
- Example:
30
- >>> from ddeutil.workflow.traces import get_trace
31
- >>> # Create file-based trace
32
- >>> trace = get_trace("running-id-101", parent_run_id="workflow-001")
33
- >>> trace.info("Workflow execution started")
34
- >>> trace.debug("Processing stage 1")
35
-
17
+ set_logging: Configure logger with custom formatting.
18
+ get_trace: Factory function for trace instances.
36
19
  """
37
- from __future__ import annotations
38
-
39
20
  import json
40
21
  import logging
41
22
  import os
42
23
  import re
43
24
  from abc import ABC, abstractmethod
44
25
  from collections.abc import Iterator
26
+ from datetime import datetime
45
27
  from functools import lru_cache
46
28
  from inspect import Traceback, currentframe, getframeinfo
47
29
  from pathlib import Path
48
- from threading import get_ident
30
+ from threading import Lock, get_ident
49
31
  from types import FrameType
50
- from typing import ClassVar, Final, Literal, Optional, Union
51
- from urllib.parse import ParseResult, unquote_plus, urlparse
32
+ from typing import Annotated, Any, ClassVar, Final, Literal, Optional, Union
33
+ from zoneinfo import ZoneInfo
52
34
 
53
- from pydantic import BaseModel, ConfigDict, Field
54
- from pydantic.functional_serializers import field_serializer
35
+ from pydantic import BaseModel, Field, PrivateAttr
55
36
  from pydantic.functional_validators import field_validator
56
37
  from typing_extensions import Self
57
38
 
@@ -59,8 +40,8 @@ from .__types import DictData
59
40
  from .conf import config, dynamic
60
41
  from .utils import cut_id, get_dt_now, prepare_newline
61
42
 
62
- METADATA: str = "metadata.json"
63
43
  logger = logging.getLogger("ddeutil.workflow")
44
+ Level = Literal["debug", "info", "warning", "error", "exception"]
64
45
 
65
46
 
66
47
  @lru_cache
@@ -72,16 +53,14 @@ def set_logging(name: str) -> logging.Logger:
72
53
  console output and proper formatting for workflow execution tracking.
73
54
 
74
55
  Args:
75
- name: Module name to create logger for
56
+ name: Module name to create logger for.
76
57
 
77
58
  Returns:
78
- logging.Logger: Configured logger instance with custom formatting
59
+ logging.Logger: Configured logger instance with custom formatting.
79
60
 
80
61
  Example:
81
- ```python
82
- logger = set_logging("ddeutil.workflow.stages")
83
- logger.info("Stage execution started")
84
- ```
62
+ >>> log = set_logging("ddeutil.workflow.stages")
63
+ >>> log.info("Stage execution started")
85
64
  """
86
65
  _logger = logging.getLogger(name)
87
66
 
@@ -104,28 +83,33 @@ def set_logging(name: str) -> logging.Logger:
104
83
 
105
84
  PREFIX_LOGS: Final[dict[str, dict]] = {
106
85
  "CALLER": {
107
- "emoji": "📍",
86
+ "emoji": "⚙️",
108
87
  "desc": "logs from any usage from custom caller function.",
109
88
  },
110
- "STAGE": {"emoji": "⚙️", "desc": "logs from stages module."},
89
+ "STAGE": {"emoji": "🔗", "desc": "logs from stages module."},
111
90
  "JOB": {"emoji": "⛓️", "desc": "logs from job module."},
112
91
  "WORKFLOW": {"emoji": "🏃", "desc": "logs from workflow module."},
113
92
  "RELEASE": {"emoji": "📅", "desc": "logs from release workflow method."},
114
93
  "POKING": {"emoji": "⏰", "desc": "logs from poke workflow method."},
94
+ "AUDIT": {"emoji": "📌", "desc": "logs from audit model."},
115
95
  } # pragma: no cov
116
96
  PREFIX_DEFAULT: Final[str] = "CALLER"
117
- PREFIX_LOGS_REGEX: re.Pattern[str] = re.compile(
97
+ PREFIX_LOGS_REGEX: Final[re.Pattern[str]] = re.compile(
118
98
  rf"(^\[(?P<name>{'|'.join(PREFIX_LOGS)})]:\s?)?(?P<message>.*)",
119
99
  re.MULTILINE | re.DOTALL | re.ASCII | re.VERBOSE,
120
100
  ) # pragma: no cov
121
101
 
122
102
 
123
103
  class Message(BaseModel):
124
- """Prefix Message model for receive grouping dict from searching prefix data
125
- from logging message.
104
+ """Prefix Message model for receive grouping dict from searching prefix data.
105
+
106
+ This model handles prefix parsing and message formatting for logging
107
+ with emoji support and categorization.
126
108
  """
127
109
 
128
- name: Optional[str] = Field(default=None, description="A prefix name.")
110
+ name: Optional[str] = Field(
111
+ default=None, description="A prefix name of message."
112
+ )
129
113
  message: Optional[str] = Field(default=None, description="A message.")
130
114
 
131
115
  @classmethod
@@ -133,10 +117,10 @@ class Message(BaseModel):
133
117
  """Extract message prefix from an input message.
134
118
 
135
119
  Args:
136
- msg (str): A message that want to extract.
120
+ msg: A message that want to extract.
137
121
 
138
122
  Returns:
139
- Message: the validated model from a string message.
123
+ Message: The validated model from a string message.
140
124
  """
141
125
  return Message.model_validate(
142
126
  obj=PREFIX_LOGS_REGEX.search(msg).groupdict()
@@ -161,13 +145,16 @@ class Message(BaseModel):
161
145
  return f"{emoji}[{name}]: {self.message}"
162
146
 
163
147
 
164
- class TraceMeta(BaseModel): # pragma: no cov
165
- """Trace Metadata model for making the current metadata of this CPU, Memory
166
- process, and thread data.
148
+ class Metadata(BaseModel): # pragma: no cov
149
+ """Trace Metadata model for making the current metadata of this CPU, Memory.
150
+
151
+ This model captures comprehensive execution context including process IDs,
152
+ thread identifiers, timestamps, and contextual information for debugging
153
+ and monitoring workflow executions.
167
154
  """
168
155
 
169
- mode: Literal["stdout", "stderr"] = Field(description="A meta mode.")
170
- level: str = Field(description="A log level.")
156
+ error_flag: bool = Field(default=False, description="A meta error flag.")
157
+ level: Level = Field(description="A log level.")
171
158
  datetime: str = Field(
172
159
  description="A datetime string with the specific config format."
173
160
  )
@@ -177,15 +164,98 @@ class TraceMeta(BaseModel): # pragma: no cov
177
164
  cut_id: Optional[str] = Field(
178
165
  default=None, description="A cutting of running ID."
179
166
  )
167
+ run_id: str
168
+ parent_run_id: Optional[str] = None
180
169
  filename: str = Field(description="A filename of this log.")
181
170
  lineno: int = Field(description="A line number of this log.")
182
171
 
172
+ # Enhanced observability fields
173
+ workflow_name: Optional[str] = Field(
174
+ default=None, description="Name of the workflow being executed."
175
+ )
176
+ stage_name: Optional[str] = Field(
177
+ default=None, description="Name of the current stage being executed."
178
+ )
179
+ job_name: Optional[str] = Field(
180
+ default=None, description="Name of the current job being executed."
181
+ )
182
+
183
+ # Performance metrics
184
+ duration_ms: Optional[float] = Field(
185
+ default=None, description="Execution duration in milliseconds."
186
+ )
187
+ memory_usage_mb: Optional[float] = Field(
188
+ default=None, description="Memory usage in MB at log time."
189
+ )
190
+ cpu_usage_percent: Optional[float] = Field(
191
+ default=None, description="CPU usage percentage at log time."
192
+ )
193
+
194
+ # Distributed tracing support
195
+ trace_id: Optional[str] = Field(
196
+ default=None,
197
+ description="OpenTelemetry trace ID for distributed tracing.",
198
+ )
199
+ span_id: Optional[str] = Field(
200
+ default=None,
201
+ description="OpenTelemetry span ID for distributed tracing.",
202
+ )
203
+ parent_span_id: Optional[str] = Field(
204
+ default=None, description="Parent span ID for correlation."
205
+ )
206
+
207
+ # Error context
208
+ exception_type: Optional[str] = Field(
209
+ default=None, description="Exception class name if error occurred."
210
+ )
211
+ exception_message: Optional[str] = Field(
212
+ default=None, description="Exception message if error occurred."
213
+ )
214
+ stack_trace: Optional[str] = Field(
215
+ default=None, description="Full stack trace if error occurred."
216
+ )
217
+ error_code: Optional[str] = Field(
218
+ default=None, description="Custom error code for categorization."
219
+ )
220
+
221
+ # Business context
222
+ user_id: Optional[str] = Field(
223
+ default=None, description="User ID who triggered the workflow."
224
+ )
225
+ tenant_id: Optional[str] = Field(
226
+ default=None, description="Tenant ID for multi-tenant environments."
227
+ )
228
+ environment: Optional[str] = Field(
229
+ default=None, description="Environment (dev, staging, prod)."
230
+ )
231
+
232
+ # System context
233
+ hostname: Optional[str] = Field(
234
+ default=None, description="Hostname where workflow is running."
235
+ )
236
+ ip_address: Optional[str] = Field(
237
+ default=None, description="IP address of the execution host."
238
+ )
239
+ python_version: Optional[str] = Field(
240
+ default=None, description="Python version running the workflow."
241
+ )
242
+ package_version: Optional[str] = Field(
243
+ default=None, description="Workflow package version."
244
+ )
245
+
246
+ # Custom metadata
247
+ tags: Optional[list[str]] = Field(
248
+ default_factory=list, description="Custom tags for categorization."
249
+ )
250
+ metadata: Optional[DictData] = Field(
251
+ default_factory=dict, description="Additional custom metadata."
252
+ )
253
+
183
254
  @classmethod
184
255
  def dynamic_frame(
185
256
  cls, frame: FrameType, *, extras: Optional[DictData] = None
186
257
  ) -> Traceback:
187
- """Dynamic Frame information base on the `logs_trace_frame_layer` config
188
- value that was set from the extra parameter.
258
+ """Dynamic Frame information base on the `logs_trace_frame_layer` config.
189
259
 
190
260
  Args:
191
261
  frame: The current frame that want to dynamic.
@@ -195,67 +265,136 @@ class TraceMeta(BaseModel): # pragma: no cov
195
265
  Returns:
196
266
  Traceback: The frame information at the specified layer.
197
267
  """
198
- extras: DictData = extras or {}
199
- layer: int = extras.get("logs_trace_frame_layer", 4)
268
+ extras_data: DictData = extras or {}
269
+ layer: int = extras_data.get("logs_trace_frame_layer", 4)
270
+ current_frame: FrameType = frame
200
271
  for _ in range(layer):
201
- _frame: Optional[FrameType] = frame.f_back
272
+ _frame: Optional[FrameType] = current_frame.f_back
202
273
  if _frame is None:
203
274
  raise ValueError(
204
275
  f"Layer value does not valid, the maximum frame is: {_ + 1}"
205
276
  )
206
- frame: FrameType = _frame
207
- return getframeinfo(frame)
277
+ current_frame = _frame
278
+ return getframeinfo(current_frame)
208
279
 
209
280
  @classmethod
210
281
  def make(
211
282
  cls,
212
- mode: Literal["stdout", "stderr"],
283
+ error_flag: bool,
213
284
  message: str,
214
- level: str,
285
+ level: Level,
215
286
  cutting_id: str,
287
+ run_id: str,
288
+ parent_run_id: Optional[str],
216
289
  *,
217
290
  extras: Optional[DictData] = None,
218
291
  ) -> Self:
219
- """Make the current metric for contract this TraceMeta model instance
220
- that will catch local states like PID, thread identity.
292
+ """Make the current metric for contract this Metadata model instance.
293
+
294
+ This method captures local states like PID, thread identity, and system
295
+ information to create a comprehensive trace metadata instance.
221
296
 
222
297
  Args:
223
- mode: A metadata mode.
298
+ error_flag: A metadata mode.
224
299
  message: A message.
225
300
  level: A log level.
226
301
  cutting_id: A cutting ID string.
302
+ run_id:
303
+ parent_run_id:
227
304
  extras: An extra parameter that want to override core
228
305
  config values.
229
306
 
230
307
  Returns:
231
- Self: The constructed TraceMeta instance.
308
+ Self: The constructed Metadata instance.
232
309
  """
233
- frame: FrameType = currentframe()
310
+ import socket
311
+ import sys
312
+
313
+ frame: Optional[FrameType] = currentframe()
314
+ if frame is None:
315
+ raise ValueError("Cannot get current frame")
316
+
234
317
  frame_info: Traceback = cls.dynamic_frame(frame, extras=extras)
235
- extras: DictData = extras or {}
318
+ extras_data: DictData = extras or {}
319
+
320
+ # NOTE: Get system information
321
+ hostname = socket.gethostname()
322
+ ip_address = socket.gethostbyname(hostname)
323
+ python_version: str = (
324
+ f"{sys.version_info.major}"
325
+ f".{sys.version_info.minor}"
326
+ f".{sys.version_info.micro}"
327
+ )
328
+
329
+ # Get datetime format with fallback
330
+ datetime_format = (
331
+ dynamic("log_datetime_format", extras=extras_data)
332
+ or "%Y-%m-%d %H:%M:%S"
333
+ )
334
+ timezone = dynamic("log_tz", extras=extras_data)
335
+ if timezone is None:
336
+ timezone = ZoneInfo("UTC")
337
+
236
338
  return cls(
237
- mode=mode,
339
+ error_flag=error_flag,
238
340
  level=level,
239
341
  datetime=(
240
- get_dt_now()
241
- .astimezone(dynamic("log_tz", extras=extras))
242
- .strftime(dynamic("log_datetime_format", extras=extras))
342
+ get_dt_now().astimezone(timezone).strftime(datetime_format)
243
343
  ),
244
344
  process=os.getpid(),
245
345
  thread=get_ident(),
246
346
  message=message,
247
347
  cut_id=cutting_id,
348
+ run_id=run_id,
349
+ parent_run_id=parent_run_id,
248
350
  filename=frame_info.filename.split(os.path.sep)[-1],
249
351
  lineno=frame_info.lineno,
352
+ # NOTE: Enhanced observability fields
353
+ workflow_name=extras_data.get("workflow_name"),
354
+ stage_name=extras_data.get("stage_name"),
355
+ job_name=extras_data.get("job_name"),
356
+ # NOTE: Performance metrics
357
+ duration_ms=extras_data.get("duration_ms"),
358
+ memory_usage_mb=extras_data.get("memory_usage_mb"),
359
+ cpu_usage_percent=extras_data.get("cpu_usage_percent"),
360
+ # NOTE: Distributed tracing support
361
+ trace_id=extras_data.get("trace_id"),
362
+ span_id=extras_data.get("span_id"),
363
+ parent_span_id=extras_data.get("parent_span_id"),
364
+ # NOTE: Error context
365
+ exception_type=extras_data.get("exception_type"),
366
+ exception_message=extras_data.get("exception_message"),
367
+ stack_trace=extras_data.get("stack_trace"),
368
+ error_code=extras_data.get("error_code"),
369
+ # NOTE: Business context
370
+ user_id=extras_data.get("user_id"),
371
+ tenant_id=extras_data.get("tenant_id"),
372
+ environment=extras_data.get("environment"),
373
+ # NOTE: System context
374
+ hostname=hostname,
375
+ ip_address=ip_address,
376
+ python_version=python_version,
377
+ package_version=extras_data.get("package_version"),
378
+ # NOTE: Custom metadata
379
+ tags=extras_data.get("tags", []),
380
+ metadata=extras_data.get("metadata", {}),
250
381
  )
251
382
 
383
+ @property
384
+ def pointer_id(self):
385
+ return self.parent_run_id or self.run_id
386
+
252
387
 
253
388
  class TraceData(BaseModel): # pragma: no cov
254
- """Trace Data model for keeping data for any Trace models."""
389
+ """Trace Data model for keeping data for any Trace models.
390
+
391
+ This model serves as a container for trace information including stdout,
392
+ stderr, and metadata for comprehensive logging and monitoring.
393
+ """
255
394
 
256
395
  stdout: str = Field(description="A standard output trace data.")
257
396
  stderr: str = Field(description="A standard error trace data.")
258
- meta: list[TraceMeta] = Field(
397
+ meta: list[Metadata] = Field(
259
398
  default_factory=list,
260
399
  description=(
261
400
  "A metadata mapping of this output and error before making it to "
@@ -263,573 +402,1604 @@ class TraceData(BaseModel): # pragma: no cov
263
402
  ),
264
403
  )
265
404
 
266
- @classmethod
267
- def from_path(cls, file: Path) -> Self:
268
- """Construct this trace data model with a trace path.
269
-
270
- :param file: (Path) A trace path.
271
-
272
- :rtype: Self
273
- """
274
- data: DictData = {"stdout": "", "stderr": "", "meta": []}
275
-
276
- for mode in ("stdout", "stderr"):
277
- if (file / f"{mode}.txt").exists():
278
- data[mode] = (file / f"{mode}.txt").read_text(encoding="utf-8")
279
-
280
- if (file / METADATA).exists():
281
- data["meta"] = [
282
- json.loads(line)
283
- for line in (
284
- (file / METADATA).read_text(encoding="utf-8").splitlines()
285
- )
286
- ]
287
-
288
- return cls.model_validate(data)
289
-
290
-
291
- class BaseEmitTrace(BaseModel, ABC): # pragma: no cov
292
- """Base Trace model with abstraction class property."""
293
-
294
- model_config = ConfigDict(frozen=True)
295
405
 
296
- extras: DictData = Field(
297
- default_factory=dict,
298
- description=(
299
- "An extra parameter that want to override on the core config "
300
- "values."
301
- ),
302
- )
303
- run_id: str = Field(description="A running ID")
304
- parent_run_id: Optional[str] = Field(
305
- default=None,
306
- description="A parent running ID",
307
- )
406
+ class BaseHandler(BaseModel, ABC):
407
+ """Base Handler model"""
308
408
 
309
409
  @abstractmethod
310
- def writer(
410
+ def emit(
311
411
  self,
312
- message: str,
313
- level: str,
314
- is_err: bool = False,
315
- ) -> None:
316
- """Write a trace message after making to target pointer object. The
317
- target can be anything be inherited this class and overwrite this method
318
- such as file, console, or database.
319
-
320
- :param message: (str) A message after making.
321
- :param level: (str) A log level.
322
- :param is_err: (bool) A flag for writing with an error trace or not.
323
- (Default be False)
324
- """
325
- raise NotImplementedError(
326
- "Create writer logic for this trace object before using."
327
- )
412
+ metadata: Metadata,
413
+ *,
414
+ extra: Optional[DictData] = None,
415
+ ): ...
328
416
 
329
417
  @abstractmethod
330
- async def awriter(
418
+ async def amit(
331
419
  self,
332
- message: str,
333
- level: str,
334
- is_err: bool = False,
335
- ) -> None:
336
- """Async Write a trace message after making to target pointer object.
337
-
338
- :param message: (str) A message after making.
339
- :param level: (str) A log level.
340
- :param is_err: (bool) A flag for writing with an error trace or not.
341
- (Default be False)
342
- """
343
- raise NotImplementedError(
344
- "Create async writer logic for this trace object before using."
345
- )
420
+ metadata: Metadata,
421
+ *,
422
+ extra: Optional[DictData] = None,
423
+ ) -> None: ...
346
424
 
347
425
  @abstractmethod
348
- def make_message(self, message: str) -> str:
349
- """Prepare and Make a message before write and log processes.
426
+ def flush(
427
+ self, metadata: list[Metadata], *, extra: Optional[DictData] = None
428
+ ) -> None: ...
350
429
 
351
- :param message: A message that want to prepare and make before.
352
430
 
353
- :rtype: str
354
- """
355
- raise NotImplementedError(
356
- "Adjust make message method for this trace object before using."
357
- )
431
+ class ConsoleHandler(BaseHandler):
432
+ """Console Handler model."""
358
433
 
359
- @abstractmethod
360
- def emit(
361
- self,
362
- message: str,
363
- mode: str,
364
- *,
365
- is_err: bool = False,
366
- ):
367
- """Write trace log with append mode and logging this message with any
368
- logging level.
434
+ type: Literal["console"] = "console"
369
435
 
370
- :param message: (str) A message that want to log.
371
- :param mode: (str)
372
- :param is_err: (bool)
373
- """
374
- raise NotImplementedError(
375
- "Logging action should be implement for making trace log."
436
+ def emit(
437
+ self, metadata: Metadata, *, extra: Optional[DictData] = None
438
+ ) -> None:
439
+ getattr(logger, metadata.level)(
440
+ metadata.message,
441
+ stacklevel=3,
442
+ extra=(extra or {}) | {"cut_id": metadata.cut_id},
376
443
  )
377
444
 
378
- def debug(self, message: str):
379
- """Write trace log with append mode and logging this message with the
380
- DEBUG level.
445
+ async def amit(
446
+ self, metadata: Metadata, *, extra: Optional[DictData] = None
447
+ ) -> None:
448
+ self.emit(metadata, extra=extra)
381
449
 
382
- :param message: (str) A message that want to log.
383
- """
384
- self.emit(message, mode="debug")
450
+ def flush(
451
+ self, metadata: list[Metadata], *, extra: Optional[DictData] = None
452
+ ) -> None:
453
+ for meta in metadata:
454
+ self.emit(meta, extra=extra)
385
455
 
386
- def info(self, message: str) -> None:
387
- """Write trace log with append mode and logging this message with the
388
- INFO level.
389
456
 
390
- :param message: (str) A message that want to log.
391
- """
392
- self.emit(message, mode="info")
457
+ class FileHandler(BaseHandler):
458
+ """File Handler model."""
393
459
 
394
- def warning(self, message: str) -> None:
395
- """Write trace log with append mode and logging this message with the
396
- WARNING level.
460
+ metadata_filename: ClassVar[str] = "metadata.txt"
397
461
 
398
- :param message: (str) A message that want to log.
399
- """
400
- self.emit(message, mode="warning")
462
+ type: Literal["file"] = "file"
463
+ path: str = Field(description="A file path.")
464
+ format: str = Field(
465
+ default=(
466
+ "{datetime} ({process:5d}, {thread:5d}) ({cut_id}) {message:120s} "
467
+ "({filename}:{lineno})"
468
+ )
469
+ )
470
+ buffer_size: int = 8192
401
471
 
402
- def error(self, message: str) -> None:
403
- """Write trace log with append mode and logging this message with the
404
- ERROR level.
472
+ # NOTE: Private attrs for the internal process.
473
+ _lock: Lock = PrivateAttr(default_factory=Lock)
405
474
 
406
- :param message: (str) A message that want to log.
407
- """
408
- self.emit(message, mode="error", is_err=True)
475
+ def pointer(self, run_id: str) -> Path:
476
+ """Pointer of the target path that use to writing trace log or searching
477
+ trace log.
409
478
 
410
- def exception(self, message: str) -> None:
411
- """Write trace log with append mode and logging this message with the
412
- EXCEPTION level.
479
+ This running ID folder that use to keeping trace log data will use
480
+ a parent running ID first. If it does not set, it will use running ID
481
+ instead.
413
482
 
414
- :param message: (str) A message that want to log.
483
+ Returns:
484
+ Path: The target path for trace log operations.
415
485
  """
416
- self.emit(message, mode="exception", is_err=True)
486
+ log_file: Path = Path(self.path) / f"run_id={run_id}"
487
+ if not log_file.exists():
488
+ log_file.mkdir(parents=True)
489
+ return log_file
417
490
 
418
- @abstractmethod
419
- async def amit(
491
+ def pre(self) -> None: ...
492
+
493
+ def emit(
420
494
  self,
421
- message: str,
422
- mode: str,
495
+ metadata: Metadata,
423
496
  *,
424
- is_err: bool = False,
497
+ extra: Optional[DictData] = None,
425
498
  ) -> None:
426
- """Async write trace log with append mode and logging this message with
427
- any logging level.
428
-
429
- :param message: (str) A message that want to log.
430
- :param mode: (str)
431
- :param is_err: (bool)
432
- """
433
- raise NotImplementedError(
434
- "Async Logging action should be implement for making trace log."
435
- )
499
+ pointer: Path = self.pointer(metadata.pointer_id)
500
+ std_file = "stderr" if metadata.error_flag else "stdout"
501
+ with self._lock:
502
+ with (pointer / f"{std_file}.txt").open(
503
+ mode="at", encoding="utf-8"
504
+ ) as f:
505
+ f.write(f"{self.format}\n".format(**metadata.model_dump()))
506
+
507
+ with (pointer / self.metadata_filename).open(
508
+ mode="at", encoding="utf-8"
509
+ ) as f:
510
+ f.write(metadata.model_dump_json() + "\n")
436
511
 
437
- async def adebug(self, message: str) -> None: # pragma: no cov
438
- """Async write trace log with append mode and logging this message with
439
- the DEBUG level.
512
+ async def amit(
513
+ self,
514
+ metadata: Metadata,
515
+ *,
516
+ extra: Optional[DictData] = None,
517
+ ) -> None: # pragma: no cove
518
+ try:
519
+ import aiofiles
520
+ except ImportError as e:
521
+ raise ImportError("Async mode need aiofiles package") from e
440
522
 
441
- :param message: (str) A message that want to log.
442
- """
443
- await self.amit(message, mode="debug")
523
+ with self._lock:
524
+ pointer: Path = self.pointer(metadata.pointer_id)
525
+ std_file = "stderr" if metadata.error_flag else "stdout"
526
+ async with aiofiles.open(
527
+ pointer / f"{std_file}.txt", mode="at", encoding="utf-8"
528
+ ) as f:
529
+ await f.write(
530
+ f"{self.format}\n".format(**metadata.model_dump())
531
+ )
444
532
 
445
- async def ainfo(self, message: str) -> None: # pragma: no cov
446
- """Async write trace log with append mode and logging this message with
447
- the INFO level.
533
+ async with aiofiles.open(
534
+ pointer / self.metadata_filename, mode="at", encoding="utf-8"
535
+ ) as f:
536
+ await f.write(metadata.model_dump_json() + "\n")
448
537
 
449
- :param message: (str) A message that want to log.
450
- """
451
- await self.amit(message, mode="info")
538
+ def flush(
539
+ self, metadata: list[Metadata], *, extra: Optional[DictData] = None
540
+ ) -> None:
541
+ with self._lock:
542
+ pointer: Path = self.pointer(metadata[0].pointer_id)
543
+ stdout_file = open(
544
+ pointer / "stdout.txt",
545
+ mode="a",
546
+ encoding="utf-8",
547
+ buffering=self.buffer_size,
548
+ )
549
+ stderr_file = open(
550
+ pointer / "stderr.txt",
551
+ mode="a",
552
+ encoding="utf-8",
553
+ buffering=self.buffer_size,
554
+ )
555
+ metadata_file = open(
556
+ pointer / self.metadata_filename,
557
+ mode="a",
558
+ encoding="utf-8",
559
+ buffering=self.buffer_size,
560
+ )
452
561
 
453
- async def awarning(self, message: str) -> None: # pragma: no cov
454
- """Async write trace log with append mode and logging this message with
455
- the WARNING level.
562
+ for meta in metadata:
563
+ if meta.error_flag:
564
+ stderr_file.write(
565
+ f"{self.format}\n".format(**meta.model_dump())
566
+ )
567
+ else:
568
+ stdout_file.write(
569
+ f"{self.format}\n".format(**meta.model_dump())
570
+ )
571
+
572
+ metadata_file.write(meta.model_dump_json() + "\n")
573
+
574
+ stdout_file.flush()
575
+ stderr_file.flush()
576
+ metadata_file.flush()
577
+ stdout_file.close()
578
+ stderr_file.close()
579
+ metadata_file.close()
456
580
 
457
- :param message: (str) A message that want to log.
458
- """
459
- await self.amit(message, mode="warning")
581
+ @classmethod
582
+ def from_path(cls, file: Path) -> TraceData: # pragma: no cov
583
+ """Construct this trace data model with a trace path.
460
584
 
461
- async def aerror(self, message: str) -> None: # pragma: no cov
462
- """Async write trace log with append mode and logging this message with
463
- the ERROR level.
585
+ Args:
586
+ file: A trace path.
464
587
 
465
- :param message: (str) A message that want to log.
588
+ Returns:
589
+ Self: The constructed TraceData instance.
466
590
  """
467
- await self.amit(message, mode="error", is_err=True)
468
-
469
- async def aexception(self, message: str) -> None: # pragma: no cov
470
- """Async write trace log with append mode and logging this message with
471
- the EXCEPTION level.
591
+ data: DictData = {"stdout": "", "stderr": "", "meta": []}
472
592
 
473
- :param message: (str) A message that want to log.
474
- """
475
- await self.amit(message, mode="exception", is_err=True)
593
+ for mode in ("stdout", "stderr"):
594
+ if (file / f"{mode}.txt").exists():
595
+ data[mode] = (file / f"{mode}.txt").read_text(encoding="utf-8")
476
596
 
597
+ if (file / cls.metadata_filename).exists():
598
+ data["meta"] = [
599
+ json.loads(line)
600
+ for line in (
601
+ (file / cls.metadata_filename)
602
+ .read_text(encoding="utf-8")
603
+ .splitlines()
604
+ )
605
+ ]
477
606
 
478
- class ConsoleTrace(BaseEmitTrace): # pragma: no cov
479
- """Console Trace log model."""
607
+ return TraceData.model_validate(data)
480
608
 
481
- def writer(
609
+ def find_traces(
482
610
  self,
483
- message: str,
484
- level: str,
485
- is_err: bool = False,
486
- ) -> None:
487
- """Write a trace message after making to target pointer object. The
488
- target can be anything be inherited this class and overwrite this method
489
- such as file, console, or database.
490
-
491
- :param message: (str) A message after making.
492
- :param level: (str) A log level.
493
- :param is_err: (bool) A flag for writing with an error trace or not.
494
- (Default be False)
611
+ path: Optional[Path] = None,
612
+ ) -> Iterator[TraceData]: # pragma: no cov
613
+ """Find trace logs.
614
+
615
+ Args:
616
+ path: A trace path that want to find.
495
617
  """
618
+ for file in sorted(
619
+ (path or Path(self.path)).glob("./run_id=*"),
620
+ key=lambda f: f.lstat().st_mtime,
621
+ ):
622
+ yield self.from_path(file)
496
623
 
497
- async def awriter(
624
+ def find_trace_with_id(
498
625
  self,
499
- message: str,
500
- level: str,
501
- is_err: bool = False,
502
- ) -> None:
503
- """Async Write a trace message after making to target pointer object.
626
+ run_id: str,
627
+ *,
628
+ force_raise: bool = True,
629
+ path: Optional[Path] = None,
630
+ ) -> TraceData: # pragma: no cov
631
+ """Find trace log with an input specific run ID.
504
632
 
505
- :param message: (str) A message after making.
506
- :param level: (str) A log level.
507
- :param is_err: (bool) A flag for writing with an error trace or not.
508
- (Default be False)
633
+ Args:
634
+ run_id: A running ID of trace log.
635
+ force_raise: Whether to raise an exception if not found.
636
+ path: Optional path override.
509
637
  """
638
+ base_path: Path = path or self.path
639
+ file: Path = base_path / f"run_id={run_id}"
640
+ if file.exists():
641
+ return self.from_path(file)
642
+ elif force_raise:
643
+ raise FileNotFoundError(
644
+ f"Trace log on path {base_path}, does not found trace "
645
+ f"'run_id={run_id}'."
646
+ )
647
+ return TraceData(stdout="", stderr="")
510
648
 
511
- @property
512
- def cut_id(self) -> str:
513
- """Combine cutting ID of parent running ID if it set.
514
649
 
515
- :rtype: str
516
- """
517
- cut_run_id: str = cut_id(self.run_id)
518
- if not self.parent_run_id:
519
- return f"{cut_run_id}"
650
+ class SQLiteHandler(BaseHandler): # pragma: no cov
651
+ """High-performance SQLite logging handler for workflow traces.
520
652
 
521
- cut_parent_run_id: str = cut_id(self.parent_run_id)
522
- return f"{cut_parent_run_id} -> {cut_run_id}"
653
+ This handler provides optimized SQLite-based logging with connection pooling,
654
+ thread safety, and structured metadata storage. It replaces the placeholder
655
+ SQLiteTrace implementation with a fully functional database-backed system.
656
+ """
523
657
 
524
- def make_message(self, message: str) -> str:
525
- """Prepare and Make a message before write and log steps.
658
+ type: Literal["sqlite"] = "sqlite"
659
+ path: str
660
+ table_name: str = Field(default="traces")
526
661
 
527
- :param message: (str) A message that want to prepare and make before.
662
+ # NOTE: Private attrs for the internal process.
663
+ _lock: Lock = PrivateAttr(default_factory=Lock)
528
664
 
529
- :rtype: str
530
- """
531
- return prepare_newline(Message.from_str(message).prepare(self.extras))
665
+ def pre(self) -> None:
666
+ import sqlite3
532
667
 
533
- def emit(self, message: str, mode: str, *, is_err: bool = False) -> None:
534
- """Write trace log with append mode and logging this message with any
535
- logging level.
668
+ try:
669
+ with sqlite3.connect(self.path) as conn:
670
+ cursor = conn.cursor()
671
+
672
+ # Create traces table if it doesn't exist
673
+ cursor.execute(
674
+ f"""
675
+ CREATE TABLE IF NOT EXISTS {self.table_name} (
676
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
677
+ run_id TEXT NOT NULL,
678
+ parent_run_id TEXT,
679
+ level TEXT NOT NULL,
680
+ message TEXT NOT NULL,
681
+ error_flag BOOLEAN NOT NULL,
682
+ datetime TEXT NOT NULL,
683
+ process INTEGER NOT NULL,
684
+ thread INTEGER NOT NULL,
685
+ filename TEXT NOT NULL,
686
+ lineno INTEGER NOT NULL,
687
+ cut_id TEXT,
688
+ workflow_name TEXT,
689
+ stage_name TEXT,
690
+ job_name TEXT,
691
+ duration_ms REAL,
692
+ memory_usage_mb REAL,
693
+ cpu_usage_percent REAL,
694
+ trace_id TEXT,
695
+ span_id TEXT,
696
+ parent_span_id TEXT,
697
+ exception_type TEXT,
698
+ exception_message TEXT,
699
+ stack_trace TEXT,
700
+ error_code TEXT,
701
+ user_id TEXT,
702
+ tenant_id TEXT,
703
+ environment TEXT,
704
+ hostname TEXT,
705
+ ip_address TEXT,
706
+ python_version TEXT,
707
+ package_version TEXT,
708
+ tags TEXT,
709
+ metadata TEXT,
710
+ created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
711
+ )
712
+ """
713
+ )
536
714
 
537
- :param message: (str) A message that want to log.
538
- :param mode: (str)
539
- :param is_err: (bool)
540
- """
541
- msg: str = self.make_message(message)
715
+ # Create indexes for better performance
716
+ cursor.execute(
717
+ """
718
+ CREATE INDEX IF NOT EXISTS idx_traces_run_id
719
+ ON traces(run_id)
720
+ """
721
+ )
722
+ cursor.execute(
723
+ """
724
+ CREATE INDEX IF NOT EXISTS idx_traces_parent_run_id
725
+ ON traces(parent_run_id)
726
+ """
727
+ )
728
+ cursor.execute(
729
+ """
730
+ CREATE INDEX IF NOT EXISTS idx_traces_datetime
731
+ ON traces(datetime)
732
+ """
733
+ )
734
+ cursor.execute(
735
+ """
736
+ CREATE INDEX IF NOT EXISTS idx_traces_level
737
+ ON traces(level)
738
+ """
739
+ )
542
740
 
543
- if mode != "debug" or (
544
- mode == "debug" and dynamic("debug", extras=self.extras)
545
- ):
546
- self.writer(msg, level=mode, is_err=is_err)
741
+ conn.commit()
547
742
 
548
- getattr(logger, mode)(msg, stacklevel=3, extra={"cut_id": self.cut_id})
743
+ except Exception as e:
744
+ logger.error(f"Failed to initialize SQLite database: {e}")
745
+ raise
549
746
 
550
- async def amit(
551
- self, message: str, mode: str, *, is_err: bool = False
747
+ def emit(
748
+ self,
749
+ metadata: Metadata,
750
+ *,
751
+ extra: Optional[DictData] = None,
552
752
  ) -> None:
553
- """Write trace log with append mode and logging this message with any
554
- logging level.
555
-
556
- :param message: (str) A message that want to log.
557
- :param mode: (str)
558
- :param is_err: (bool)
559
- """
560
- msg: str = self.make_message(message)
753
+ self.flush([metadata], extra=extra)
561
754
 
562
- if mode != "debug" or (
563
- mode == "debug" and dynamic("debug", extras=self.extras)
564
- ):
565
- await self.awriter(msg, level=mode, is_err=is_err)
755
+ def amit(
756
+ self,
757
+ metadata: Metadata,
758
+ *,
759
+ extra: Optional[DictData] = None,
760
+ ) -> None: ...
566
761
 
567
- getattr(logger, mode)(msg, stacklevel=3, extra={"cut_id": self.cut_id})
762
+ def flush(
763
+ self, metadata: list[Metadata], *, extra: Optional[DictData] = None
764
+ ) -> None:
765
+ """Flush all buffered records to database."""
766
+ if not self._buffer:
767
+ return
568
768
 
769
+ import sqlite3
770
+
771
+ with self._lock:
772
+ try:
773
+ with sqlite3.connect(self.path) as conn:
774
+ cursor = conn.cursor()
775
+ records = []
776
+ for meta in self._buffer:
777
+ records.append(
778
+ (
779
+ meta.run_id,
780
+ meta.parent_run_id,
781
+ meta.level,
782
+ meta.message,
783
+ meta.error_flag,
784
+ meta.datetime,
785
+ meta.process,
786
+ meta.thread,
787
+ meta.filename,
788
+ meta.lineno,
789
+ meta.cut_id,
790
+ meta.workflow_name,
791
+ meta.stage_name,
792
+ meta.job_name,
793
+ meta.duration_ms,
794
+ meta.memory_usage_mb,
795
+ meta.cpu_usage_percent,
796
+ meta.trace_id,
797
+ meta.span_id,
798
+ meta.parent_span_id,
799
+ meta.exception_type,
800
+ meta.exception_message,
801
+ meta.stack_trace,
802
+ meta.error_code,
803
+ meta.user_id,
804
+ meta.tenant_id,
805
+ meta.environment,
806
+ meta.hostname,
807
+ meta.ip_address,
808
+ meta.python_version,
809
+ meta.package_version,
810
+ (json.dumps(meta.tags) if meta.tags else None),
811
+ (
812
+ json.dumps(meta.metadata)
813
+ if meta.metadata
814
+ else None
815
+ ),
816
+ )
817
+ )
818
+
819
+ # NOTE: Batch insert
820
+ cursor.executemany(
821
+ f"""
822
+ INSERT INTO {self.table_name} (
823
+ run_id, parent_run_id, level, message, error_flag, datetime,
824
+ process, thread, filename, lineno, cut_id, workflow_name,
825
+ stage_name, job_name, duration_ms, memory_usage_mb,
826
+ cpu_usage_percent, trace_id, span_id, parent_span_id,
827
+ exception_type, exception_message, stack_trace, error_code,
828
+ user_id, tenant_id, environment, hostname, ip_address,
829
+ python_version, package_version, tags, metadata
830
+ ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
831
+ """,
832
+ records,
833
+ )
834
+
835
+ conn.commit()
836
+
837
+ except Exception as e:
838
+ logger.error(f"Failed to flush to SQLite database: {e}")
839
+ finally:
840
+ self._buffer.clear()
569
841
 
570
- class BaseTrace(ConsoleTrace, ABC):
571
- """A Base Trace model that will use for override writing or sending trace
572
- log to any service type.
573
- """
574
-
575
- model_config = ConfigDict(arbitrary_types_allowed=True)
576
-
577
- url: ParseResult = Field(description="An URL for create pointer.")
578
-
579
- @field_validator(
580
- "url", mode="before", json_schema_input_type=Union[ParseResult, str]
581
- )
582
- def __parse_url(cls, value: Union[ParseResult, str]) -> ParseResult:
583
- """Parsing an URL value."""
584
- return urlparse(value) if isinstance(value, str) else value
585
-
586
- @field_serializer("url")
587
- def __serialize_url(self, value: ParseResult) -> str:
588
- return value.geturl()
589
-
590
- @classmethod
591
- @abstractmethod
592
842
  def find_traces(
593
- cls,
843
+ self,
594
844
  path: Optional[Path] = None,
595
845
  extras: Optional[DictData] = None,
596
- ) -> Iterator[TraceData]: # pragma: no cov
597
- """Return iterator of TraceData models from the target pointer.
846
+ ) -> Iterator[TraceData]:
847
+ """Find trace logs from SQLite database."""
848
+ if path is None:
849
+ url = self.path
850
+ if (
851
+ url is not None
852
+ and hasattr(url, "path")
853
+ and getattr(url, "path", None)
854
+ ):
855
+ path = Path(url.path)
856
+ else:
857
+ path = Path("./logs/workflow_traces.db")
858
+
859
+ if not path.exists():
860
+ return
598
861
 
599
- Args:
600
- path (:obj:`Path`, optional): A pointer path that want to override.
601
- extras (:obj:`DictData`, optional): An extras parameter that want to
602
- override default engine config.
862
+ import sqlite3
603
863
 
604
- Returns:
605
- Iterator[TracData]: An iterator object that generate a TracData
606
- model.
607
- """
608
- raise NotImplementedError(
609
- "Trace dataclass should implement `find_traces` class-method."
610
- )
864
+ try:
865
+ with sqlite3.connect(path) as conn:
866
+ cursor = conn.cursor()
867
+
868
+ # NOTE: Get all unique run IDs
869
+ cursor.execute(
870
+ """
871
+ SELECT DISTINCT run_id, parent_run_id, created_at
872
+ FROM traces
873
+ ORDER BY created_at DESC
874
+ """
875
+ )
876
+
877
+ for run_id, _, _ in cursor.fetchall():
878
+ # NOTE: Get all records for this run
879
+ cursor.execute(
880
+ """
881
+ SELECT * FROM traces
882
+ WHERE run_id = ?
883
+ ORDER BY created_at
884
+ """,
885
+ (run_id,),
886
+ )
887
+
888
+ records = cursor.fetchall()
889
+
890
+ # Convert to TraceData format
891
+ stdout_lines = []
892
+ stderr_lines = []
893
+ meta_list = []
894
+
895
+ for record in records:
896
+ trace_meta = Metadata(
897
+ run_id=record[1],
898
+ parent_run_id=record[2],
899
+ error_flag=record[5],
900
+ level=record[3],
901
+ message=record[4],
902
+ datetime=record[6],
903
+ process=record[7],
904
+ thread=record[8],
905
+ cut_id=record[11],
906
+ filename=record[9],
907
+ lineno=record[10],
908
+ workflow_name=record[12],
909
+ stage_name=record[13],
910
+ job_name=record[14],
911
+ duration_ms=record[15],
912
+ memory_usage_mb=record[16],
913
+ cpu_usage_percent=record[17],
914
+ trace_id=record[18],
915
+ span_id=record[19],
916
+ parent_span_id=record[20],
917
+ exception_type=record[21],
918
+ exception_message=record[22],
919
+ stack_trace=record[23],
920
+ error_code=record[24],
921
+ user_id=record[25],
922
+ tenant_id=record[26],
923
+ environment=record[27],
924
+ hostname=record[28],
925
+ ip_address=record[29],
926
+ python_version=record[30],
927
+ package_version=record[31],
928
+ tags=json.loads(record[32]) if record[32] else [],
929
+ metadata=(
930
+ json.loads(record[33]) if record[33] else {}
931
+ ),
932
+ )
933
+
934
+ meta_list.append(trace_meta)
935
+
936
+ # Add to stdout/stderr based on mode
937
+ fmt = (
938
+ dynamic("log_format_file", extras=extras)
939
+ or "{datetime} ({process:5d}, {thread:5d}) ({cut_id}) {message:120s} ({filename}:{lineno})"
940
+ )
941
+ formatted_line = fmt.format(**trace_meta.model_dump())
942
+
943
+ if trace_meta.error_flag:
944
+ stderr_lines.append(formatted_line)
945
+ else:
946
+ stdout_lines.append(formatted_line)
947
+
948
+ yield TraceData(
949
+ stdout="\n".join(stdout_lines),
950
+ stderr="\n".join(stderr_lines),
951
+ meta=meta_list,
952
+ )
953
+
954
+ except Exception as e:
955
+ logger.error(f"Failed to read from SQLite database: {e}")
611
956
 
612
- @classmethod
613
- @abstractmethod
614
957
  def find_trace_with_id(
615
- cls,
958
+ self,
616
959
  run_id: str,
617
960
  force_raise: bool = True,
618
961
  *,
619
962
  path: Optional[Path] = None,
620
963
  extras: Optional[DictData] = None,
621
964
  ) -> TraceData:
622
- raise NotImplementedError(
623
- "Trace dataclass should implement `find_trace_with_id` "
624
- "class-method."
625
- )
965
+ """Find trace log with specific run ID from SQLite database."""
966
+ path = path or Path(self.path)
967
+ if not path.exists():
968
+ if force_raise:
969
+ raise FileNotFoundError(f"SQLite database not found: {path}")
970
+ return TraceData(stdout="", stderr="")
971
+
972
+ import sqlite3
973
+
974
+ try:
975
+ with sqlite3.connect(path) as conn:
976
+ cursor = conn.cursor()
977
+
978
+ # Get all records for this run ID
979
+ cursor.execute(
980
+ """
981
+ SELECT * FROM traces
982
+ WHERE run_id = ?
983
+ ORDER BY created_at
984
+ """,
985
+ (run_id,),
986
+ )
987
+
988
+ records = cursor.fetchall()
989
+
990
+ if not records:
991
+ if force_raise:
992
+ raise FileNotFoundError(
993
+ f"Trace log with run_id '{run_id}' not found in database"
994
+ )
995
+ return TraceData(stdout="", stderr="")
996
+
997
+ # Convert to TraceData format
998
+ stdout_lines = []
999
+ stderr_lines = []
1000
+ meta_list = []
1001
+
1002
+ for record in records:
1003
+ trace_meta = Metadata(
1004
+ run_id=record[1],
1005
+ parent_run_id=record[2],
1006
+ error_flag=record[5],
1007
+ level=record[3],
1008
+ datetime=record[6],
1009
+ process=record[7],
1010
+ thread=record[8],
1011
+ message=record[4],
1012
+ cut_id=record[11],
1013
+ filename=record[9],
1014
+ lineno=record[10],
1015
+ workflow_name=record[12],
1016
+ stage_name=record[13],
1017
+ job_name=record[14],
1018
+ duration_ms=record[15],
1019
+ memory_usage_mb=record[16],
1020
+ cpu_usage_percent=record[17],
1021
+ trace_id=record[18],
1022
+ span_id=record[19],
1023
+ parent_span_id=record[20],
1024
+ exception_type=record[21],
1025
+ exception_message=record[22],
1026
+ stack_trace=record[23],
1027
+ error_code=record[24],
1028
+ user_id=record[25],
1029
+ tenant_id=record[26],
1030
+ environment=record[27],
1031
+ hostname=record[28],
1032
+ ip_address=record[29],
1033
+ python_version=record[30],
1034
+ package_version=record[31],
1035
+ tags=json.loads(record[32]) if record[32] else [],
1036
+ metadata=json.loads(record[33]) if record[33] else {},
1037
+ )
1038
+
1039
+ meta_list.append(trace_meta)
1040
+
1041
+ # Add to stdout/stderr based on mode
1042
+ fmt = (
1043
+ dynamic("log_format_file", extras=extras)
1044
+ or "{datetime} ({process:5d}, {thread:5d}) ({cut_id}) {message:120s} ({filename}:{lineno})"
1045
+ )
1046
+ formatted_line = fmt.format(**trace_meta.model_dump())
1047
+
1048
+ if trace_meta.error_flag:
1049
+ stderr_lines.append(formatted_line)
1050
+ else:
1051
+ stdout_lines.append(formatted_line)
1052
+
1053
+ return TraceData(
1054
+ stdout="\n".join(stdout_lines),
1055
+ stderr="\n".join(stderr_lines),
1056
+ meta=meta_list,
1057
+ )
1058
+
1059
+ except Exception as e:
1060
+ logger.error(f"Failed to read from SQLite database: {e}")
1061
+ if force_raise:
1062
+ raise
1063
+ return TraceData(stdout="", stderr="")
1064
+
1065
+
1066
+ class RestAPIHandler(BaseHandler): # pragma: no cov
1067
+ type: Literal["restapi"] = "restapi"
1068
+ service_type: Literal["datadog", "grafana", "cloudwatch", "generic"] = (
1069
+ "generic"
1070
+ )
1071
+ api_url: str = ""
1072
+ api_key: Optional[str] = None
1073
+ timeout: float = 10.0
1074
+ max_retries: int = 3
1075
+
1076
+ def _format_for_service(self, meta: Metadata) -> dict:
1077
+ """Format trace metadata for specific service."""
1078
+ base_data = meta.model_dump()
1079
+
1080
+ if self.service_type == "datadog":
1081
+ return {
1082
+ "message": base_data["message"],
1083
+ "level": base_data["level"],
1084
+ "timestamp": base_data["datetime"],
1085
+ "service": "ddeutil-workflow",
1086
+ "source": "python",
1087
+ "tags": [
1088
+ f"run_id:{meta.run_id}",
1089
+ (
1090
+ f"parent_run_id:{meta.parent_run_id}"
1091
+ if meta.parent_run_id
1092
+ else None
1093
+ ),
1094
+ f"mode:{base_data['mode']}",
1095
+ f"filename:{base_data['filename']}",
1096
+ f"lineno:{base_data['lineno']}",
1097
+ f"process:{base_data['process']}",
1098
+ f"thread:{base_data['thread']}",
1099
+ ]
1100
+ + (base_data.get("tags", []) or []),
1101
+ "dd": {
1102
+ "source": "python",
1103
+ "service": "ddeutil-workflow",
1104
+ "tags": base_data.get("tags", []) or [],
1105
+ },
1106
+ "workflow": {
1107
+ "run_id": meta.run_id,
1108
+ "parent_run_id": meta.parent_run_id,
1109
+ "workflow_name": base_data.get("workflow_name"),
1110
+ "stage_name": base_data.get("stage_name"),
1111
+ "job_name": base_data.get("job_name"),
1112
+ },
1113
+ "trace": {
1114
+ "trace_id": base_data.get("trace_id"),
1115
+ "span_id": base_data.get("span_id"),
1116
+ "parent_span_id": base_data.get("parent_span_id"),
1117
+ },
1118
+ }
1119
+
1120
+ elif self.service_type == "grafana":
1121
+ return {
1122
+ "streams": [
1123
+ {
1124
+ "stream": {
1125
+ "run_id": meta.run_id,
1126
+ "parent_run_id": meta.parent_run_id,
1127
+ "level": base_data["level"],
1128
+ "mode": base_data["mode"],
1129
+ "service": "ddeutil-workflow",
1130
+ },
1131
+ "values": [
1132
+ [
1133
+ str(
1134
+ int(
1135
+ meta.datetime.replace(" ", "T").replace(
1136
+ ":", ""
1137
+ )
1138
+ )
1139
+ ),
1140
+ base_data["message"],
1141
+ ]
1142
+ ],
1143
+ }
1144
+ ]
1145
+ }
1146
+
1147
+ elif self.service_type == "cloudwatch":
1148
+ return {
1149
+ "logGroupName": f"/ddeutil/workflow/{meta.run_id}",
1150
+ "logStreamName": f"workflow-{meta.run_id}",
1151
+ "logEvents": [
1152
+ {
1153
+ "timestamp": int(
1154
+ meta.datetime.replace(" ", "T").replace(":", "")
1155
+ ),
1156
+ "message": json.dumps(
1157
+ {
1158
+ "message": base_data["message"],
1159
+ "level": base_data["level"],
1160
+ "run_id": meta.run_id,
1161
+ "parent_run_id": meta.parent_run_id,
1162
+ "mode": base_data["mode"],
1163
+ "filename": base_data["filename"],
1164
+ "lineno": base_data["lineno"],
1165
+ "process": base_data["process"],
1166
+ "thread": base_data["thread"],
1167
+ "workflow_name": base_data.get("workflow_name"),
1168
+ "stage_name": base_data.get("stage_name"),
1169
+ "job_name": base_data.get("job_name"),
1170
+ "trace_id": base_data.get("trace_id"),
1171
+ "span_id": base_data.get("span_id"),
1172
+ }
1173
+ ),
1174
+ }
1175
+ ],
1176
+ }
1177
+
1178
+ else:
1179
+ return {
1180
+ "timestamp": base_data["datetime"],
1181
+ "level": base_data["level"],
1182
+ "message": base_data["message"],
1183
+ "run_id": meta.run_id,
1184
+ "parent_run_id": meta.parent_run_id,
1185
+ "mode": base_data["mode"],
1186
+ "filename": base_data["filename"],
1187
+ "lineno": base_data["lineno"],
1188
+ "process": base_data["process"],
1189
+ "thread": base_data["thread"],
1190
+ "workflow_name": base_data.get("workflow_name"),
1191
+ "stage_name": base_data.get("stage_name"),
1192
+ "job_name": base_data.get("job_name"),
1193
+ "trace_id": base_data.get("trace_id"),
1194
+ "span_id": base_data.get("span_id"),
1195
+ "tags": base_data.get("tags", []),
1196
+ "metadata": base_data.get("metadata", {}),
1197
+ }
1198
+
1199
+ def session(self):
1200
+ try:
1201
+ import requests
1202
+
1203
+ session = requests.Session()
1204
+
1205
+ # NOTE: Set default headers
1206
+ headers: dict[str, Any] = {
1207
+ "Content-Type": "application/json",
1208
+ "User-Agent": "ddeutil-workflow/1.0",
1209
+ }
1210
+
1211
+ # NOTE: Add service-specific headers
1212
+ if self.service_type == "datadog":
1213
+ if self.api_key:
1214
+ headers["DD-API-KEY"] = self.api_key
1215
+ headers["Content-Type"] = "application/json"
1216
+ elif self.service_type == "grafana":
1217
+ if self.api_key:
1218
+ headers["Authorization"] = f"Bearer {self.api_key}"
1219
+ elif self.service_type == "cloudwatch":
1220
+ if self.api_key:
1221
+ headers["X-Amz-Target"] = "Logs_20140328.PutLogEvents"
1222
+ headers["Authorization"] = (
1223
+ f"AWS4-HMAC-SHA256 {self.api_key}"
1224
+ )
1225
+
1226
+ session.headers.update(headers)
1227
+ return session
1228
+ except ImportError as e:
1229
+ raise ImportError(
1230
+ "REST API handler requires 'requests' package"
1231
+ ) from e
1232
+
1233
+ def emit(
1234
+ self,
1235
+ metadata: Metadata,
1236
+ *,
1237
+ extra: Optional[DictData] = None,
1238
+ ): ...
1239
+
1240
+ async def amit(
1241
+ self,
1242
+ metadata: Metadata,
1243
+ *,
1244
+ extra: Optional[DictData] = None,
1245
+ ) -> None: ...
1246
+
1247
+ def flush(
1248
+ self, metadata: list[Metadata], *, extra: Optional[DictData] = None
1249
+ ) -> None:
1250
+ session = self.session()
1251
+ try:
1252
+ formatted_records = [
1253
+ self._format_for_service(meta) for meta in metadata
1254
+ ]
1255
+
1256
+ # NOTE: Prepare payload based on service type
1257
+ if self.service_type == "datadog":
1258
+ payload = formatted_records
1259
+ elif self.service_type == "grafana":
1260
+ # Merge all streams
1261
+ all_streams = []
1262
+ for record in formatted_records:
1263
+ all_streams.extend(record["streams"])
1264
+ payload = {"streams": all_streams}
1265
+ elif self.service_type == "cloudwatch":
1266
+ # CloudWatch expects individual log events
1267
+ payload = formatted_records[0] # Take first record
1268
+ else:
1269
+ payload = formatted_records
1270
+
1271
+ # Send with retry logic
1272
+ for attempt in range(self.max_retries):
1273
+ try:
1274
+ response = session.post(
1275
+ self.api_url, json=payload, timeout=self.timeout
1276
+ )
1277
+ response.raise_for_status()
1278
+ except Exception as e:
1279
+ if attempt == self.max_retries - 1:
1280
+ logger.error(
1281
+ f"Failed to send logs to REST API after {self.max_retries} attempts: {e}"
1282
+ )
1283
+ else:
1284
+ import time
1285
+
1286
+ time.sleep(2**attempt) # Exponential backoff
1287
+
1288
+ except Exception as e:
1289
+ logger.error(f"Failed to send logs to REST API: {e}")
1290
+ finally:
1291
+ session.close()
1292
+
1293
+
1294
+ class ElasticHandler(BaseHandler): # pragma: no cov
1295
+ """High-performance Elasticsearch logging handler for workflow traces.
1296
+
1297
+ This handler provides optimized Elasticsearch-based logging with connection
1298
+ pooling, bulk indexing, and structured metadata storage for scalable
1299
+ log aggregation and search capabilities.
1300
+ """
1301
+
1302
+ type: Literal["elastic"] = "elastic"
1303
+ hosts: Union[str, list[str]]
1304
+ username: Optional[str] = None
1305
+ password: Optional[str] = None
1306
+ index: str
1307
+ timeout: float = 30.0
1308
+ max_retries: int = 3
1309
+
1310
+ @field_validator(
1311
+ "hosts", mode="before", json_schema_input_type=Union[str, list[str]]
1312
+ )
1313
+ def __prepare_hosts(cls, data: Any) -> Any:
1314
+ if isinstance(data, str):
1315
+ return [data]
1316
+ return data
1317
+
1318
+ def client(self):
1319
+ """Initialize Elasticsearch client."""
1320
+ try:
1321
+ from elasticsearch import Elasticsearch
1322
+
1323
+ client = Elasticsearch(
1324
+ hosts=self.hosts,
1325
+ basic_auth=(
1326
+ (self.username, self.password)
1327
+ if self.username and self.password
1328
+ else None
1329
+ ),
1330
+ timeout=self.timeout,
1331
+ max_retries=self.max_retries,
1332
+ retry_on_timeout=True,
1333
+ )
1334
+
1335
+ # Test connection
1336
+ if not client.ping():
1337
+ raise ConnectionError("Failed to connect to Elasticsearch")
1338
+
1339
+ # NOTE: Create index if it doesn't exist
1340
+ self._create_index(client)
1341
+ return client
1342
+ except ImportError as e:
1343
+ raise ImportError(
1344
+ "Elasticsearch handler requires 'elasticsearch' package"
1345
+ ) from e
1346
+
1347
+ def _create_index(self, client):
1348
+ try:
1349
+ if not client.indices.exists(index=self.index):
1350
+ mapping = {
1351
+ "mappings": {
1352
+ "properties": {
1353
+ "run_id": {"type": "keyword"},
1354
+ "parent_run_id": {"type": "keyword"},
1355
+ "level": {"type": "keyword"},
1356
+ "message": {"type": "text"},
1357
+ "mode": {"type": "keyword"},
1358
+ "datetime": {"type": "date"},
1359
+ "process": {"type": "integer"},
1360
+ "thread": {"type": "integer"},
1361
+ "filename": {"type": "keyword"},
1362
+ "lineno": {"type": "integer"},
1363
+ "cut_id": {"type": "keyword"},
1364
+ "workflow_name": {"type": "keyword"},
1365
+ "stage_name": {"type": "keyword"},
1366
+ "job_name": {"type": "keyword"},
1367
+ "duration_ms": {"type": "float"},
1368
+ "memory_usage_mb": {"type": "float"},
1369
+ "cpu_usage_percent": {"type": "float"},
1370
+ "trace_id": {"type": "keyword"},
1371
+ "span_id": {"type": "keyword"},
1372
+ "parent_span_id": {"type": "keyword"},
1373
+ "exception_type": {"type": "keyword"},
1374
+ "exception_message": {"type": "text"},
1375
+ "stack_trace": {"type": "text"},
1376
+ "error_code": {"type": "keyword"},
1377
+ "user_id": {"type": "keyword"},
1378
+ "tenant_id": {"type": "keyword"},
1379
+ "environment": {"type": "keyword"},
1380
+ "hostname": {"type": "keyword"},
1381
+ "ip_address": {"type": "ip"},
1382
+ "python_version": {"type": "keyword"},
1383
+ "package_version": {"type": "keyword"},
1384
+ "tags": {"type": "keyword"},
1385
+ "metadata": {"type": "object"},
1386
+ "created_at": {"type": "date"},
1387
+ }
1388
+ },
1389
+ "settings": {
1390
+ "number_of_shards": 1,
1391
+ "number_of_replicas": 0,
1392
+ "refresh_interval": "1s",
1393
+ },
1394
+ }
1395
+
1396
+ client.indices.create(index=self.index, body=mapping)
1397
+
1398
+ except Exception as e:
1399
+ logger.error(f"Failed to create Elasticsearch index: {e}")
1400
+
1401
+ @staticmethod
1402
+ def _format_for_elastic(metadata: Metadata) -> dict:
1403
+ """Format trace metadata for Elasticsearch indexing."""
1404
+ base_data = metadata.model_dump()
1405
+ try:
1406
+ dt = datetime.strptime(base_data["datetime"], "%Y-%m-%d %H:%M:%S")
1407
+ iso_datetime = dt.isoformat()
1408
+ except ValueError:
1409
+ iso_datetime = base_data["datetime"]
1410
+
1411
+ return {
1412
+ "run_id": base_data["run_id"],
1413
+ "parent_run_id": base_data["parent_run_id"],
1414
+ "level": base_data["level"],
1415
+ "message": base_data["message"],
1416
+ "mode": base_data["mode"],
1417
+ "datetime": iso_datetime,
1418
+ "process": base_data["process"],
1419
+ "thread": base_data["thread"],
1420
+ "filename": base_data["filename"],
1421
+ "lineno": base_data["lineno"],
1422
+ "cut_id": base_data["cut_id"],
1423
+ "workflow_name": base_data.get("workflow_name"),
1424
+ "stage_name": base_data.get("stage_name"),
1425
+ "job_name": base_data.get("job_name"),
1426
+ "duration_ms": base_data.get("duration_ms"),
1427
+ "memory_usage_mb": base_data.get("memory_usage_mb"),
1428
+ "cpu_usage_percent": base_data.get("cpu_usage_percent"),
1429
+ "trace_id": base_data.get("trace_id"),
1430
+ "span_id": base_data.get("span_id"),
1431
+ "parent_span_id": base_data.get("parent_span_id"),
1432
+ "exception_type": base_data.get("exception_type"),
1433
+ "exception_message": base_data.get("exception_message"),
1434
+ "stack_trace": base_data.get("stack_trace"),
1435
+ "error_code": base_data.get("error_code"),
1436
+ "user_id": base_data.get("user_id"),
1437
+ "tenant_id": base_data.get("tenant_id"),
1438
+ "environment": base_data.get("environment"),
1439
+ "hostname": base_data.get("hostname"),
1440
+ "ip_address": base_data.get("ip_address"),
1441
+ "python_version": base_data.get("python_version"),
1442
+ "package_version": base_data.get("package_version"),
1443
+ "tags": base_data.get("tags", []),
1444
+ "metadata": base_data.get("metadata", {}),
1445
+ "created_at": iso_datetime,
1446
+ }
1447
+
1448
+ def emit(
1449
+ self,
1450
+ metadata: Metadata,
1451
+ *,
1452
+ extra: Optional[DictData] = None,
1453
+ ): ...
1454
+
1455
+ async def amit(
1456
+ self,
1457
+ metadata: Metadata,
1458
+ *,
1459
+ extra: Optional[DictData] = None,
1460
+ ) -> None: ...
626
1461
 
1462
+ def flush(
1463
+ self,
1464
+ metadata: list[Metadata],
1465
+ *,
1466
+ extra: Optional[DictData] = None,
1467
+ ) -> None:
1468
+ client = self.client()
1469
+ try:
1470
+ bulk_data = []
1471
+ for meta in metadata:
1472
+ record = self._format_for_elastic(metadata=meta)
1473
+ bulk_data.append(
1474
+ {
1475
+ "index": {
1476
+ "_index": self.index,
1477
+ "_id": f"{meta.pointer_id}_{record['datetime']}_{record['thread']}",
1478
+ }
1479
+ }
1480
+ )
1481
+ bulk_data.append(record)
627
1482
 
628
- class FileTrace(BaseTrace): # pragma: no cov
629
- """File Trace dataclass that write file to the local storage."""
1483
+ # Execute bulk indexing
1484
+ response = client.bulk(body=bulk_data, refresh=True)
1485
+
1486
+ # Check for errors
1487
+ if response.get("errors", False):
1488
+ for item in response.get("items", []):
1489
+ if "index" in item and "error" in item["index"]:
1490
+ logger.error(
1491
+ f"Elasticsearch indexing error: {item['index']['error']}"
1492
+ )
1493
+ finally:
1494
+ client.close()
630
1495
 
631
1496
  @classmethod
632
1497
  def find_traces(
633
1498
  cls,
634
- path: Optional[Path] = None,
1499
+ es_hosts: Union[str, list[str]] = "http://localhost:9200",
1500
+ index_name: str = "workflow-traces",
1501
+ username: Optional[str] = None,
1502
+ password: Optional[str] = None,
635
1503
  extras: Optional[DictData] = None,
636
- ) -> Iterator[TraceData]: # pragma: no cov
637
- """Find trace logs.
1504
+ ) -> Iterator[TraceData]:
1505
+ """Find trace logs from Elasticsearch."""
1506
+ try:
1507
+ from elasticsearch import Elasticsearch
1508
+
1509
+ # Create client
1510
+ client = Elasticsearch(
1511
+ hosts=es_hosts if isinstance(es_hosts, list) else [es_hosts],
1512
+ basic_auth=(
1513
+ (username, password) if username and password else None
1514
+ ),
1515
+ )
638
1516
 
639
- :param path: (Path) A trace path that want to find.
640
- :param extras: An extra parameter that want to override core config.
641
- """
642
- for file in sorted(
643
- (path or Path(dynamic("trace_url", extras=extras).path)).glob(
644
- "./run_id=*"
645
- ),
646
- key=lambda f: f.lstat().st_mtime,
647
- ):
648
- yield TraceData.from_path(file)
1517
+ # Search for all unique run IDs
1518
+ search_body = {
1519
+ "size": 0,
1520
+ "aggs": {
1521
+ "unique_runs": {"terms": {"field": "run_id", "size": 1000}}
1522
+ },
1523
+ }
1524
+
1525
+ response = client.search(index=index_name, body=search_body)
1526
+
1527
+ for bucket in response["aggregations"]["unique_runs"]["buckets"]:
1528
+ run_id = bucket["key"]
1529
+
1530
+ # Get all records for this run
1531
+ search_body = {
1532
+ "query": {"term": {"run_id": run_id}},
1533
+ "sort": [{"created_at": {"order": "asc"}}],
1534
+ "size": 1000,
1535
+ }
1536
+
1537
+ response = client.search(index=index_name, body=search_body)
1538
+
1539
+ # Convert to TraceData format
1540
+ stdout_lines = []
1541
+ stderr_lines = []
1542
+ meta_list = []
1543
+
1544
+ for hit in response["hits"]["hits"]:
1545
+ source = hit["_source"]
1546
+ trace_meta = Metadata(
1547
+ run_id=source["run_id"],
1548
+ parent_run_id=source["parent_run_id"],
1549
+ error_flag=source["error_flag"],
1550
+ level=source["level"],
1551
+ datetime=source["datetime"],
1552
+ process=source["process"],
1553
+ thread=source["thread"],
1554
+ message=source["message"],
1555
+ cut_id=source.get("cut_id"),
1556
+ filename=source["filename"],
1557
+ lineno=source["lineno"],
1558
+ workflow_name=source.get("workflow_name"),
1559
+ stage_name=source.get("stage_name"),
1560
+ job_name=source.get("job_name"),
1561
+ duration_ms=source.get("duration_ms"),
1562
+ memory_usage_mb=source.get("memory_usage_mb"),
1563
+ cpu_usage_percent=source.get("cpu_usage_percent"),
1564
+ trace_id=source.get("trace_id"),
1565
+ span_id=source.get("span_id"),
1566
+ parent_span_id=source.get("parent_span_id"),
1567
+ exception_type=source.get("exception_type"),
1568
+ exception_message=source.get("exception_message"),
1569
+ stack_trace=source.get("stack_trace"),
1570
+ error_code=source.get("error_code"),
1571
+ user_id=source.get("user_id"),
1572
+ tenant_id=source.get("tenant_id"),
1573
+ environment=source.get("environment"),
1574
+ hostname=source.get("hostname"),
1575
+ ip_address=source.get("ip_address"),
1576
+ python_version=source.get("python_version"),
1577
+ package_version=source.get("package_version"),
1578
+ tags=source.get("tags", []),
1579
+ metadata=source.get("metadata", {}),
1580
+ )
1581
+
1582
+ meta_list.append(trace_meta)
1583
+ fmt = (
1584
+ dynamic("log_format_file", extras=extras)
1585
+ or "{datetime} ({process:5d}, {thread:5d}) ({cut_id}) {message:120s} ({filename}:{lineno})"
1586
+ )
1587
+ formatted_line = fmt.format(**trace_meta.model_dump())
1588
+
1589
+ if trace_meta.error_flag:
1590
+ stderr_lines.append(formatted_line)
1591
+ else:
1592
+ stdout_lines.append(formatted_line)
1593
+
1594
+ yield TraceData(
1595
+ stdout="\n".join(stdout_lines),
1596
+ stderr="\n".join(stderr_lines),
1597
+ meta=meta_list,
1598
+ )
1599
+
1600
+ client.close()
1601
+
1602
+ except ImportError as e:
1603
+ raise ImportError(
1604
+ "Elasticsearch handler requires 'elasticsearch' package"
1605
+ ) from e
1606
+ except Exception as e:
1607
+ logger.error(f"Failed to read from Elasticsearch: {e}")
649
1608
 
650
1609
  @classmethod
651
1610
  def find_trace_with_id(
652
1611
  cls,
653
1612
  run_id: str,
654
- *,
655
1613
  force_raise: bool = True,
656
- path: Optional[Path] = None,
1614
+ *,
1615
+ es_hosts: Union[str, list[str]] = "http://localhost:9200",
1616
+ index_name: str = "workflow-traces",
1617
+ username: Optional[str] = None,
1618
+ password: Optional[str] = None,
657
1619
  extras: Optional[DictData] = None,
658
1620
  ) -> TraceData:
659
- """Find trace log with an input specific run ID.
1621
+ """Find trace log with specific run ID from Elasticsearch."""
1622
+ try:
1623
+ from elasticsearch import Elasticsearch
1624
+
1625
+ # Create client
1626
+ client = Elasticsearch(
1627
+ hosts=es_hosts if isinstance(es_hosts, list) else [es_hosts],
1628
+ basic_auth=(
1629
+ (username, password) if username and password else None
1630
+ ),
1631
+ )
660
1632
 
661
- :param run_id: A running ID of trace log.
662
- :param force_raise: (bool)
663
- :param path: (Path)
664
- :param extras: An extra parameter that want to override core config.
665
- """
666
- base_path: Path = path or Path(dynamic("trace_url", extras=extras).path)
667
- file: Path = base_path / f"run_id={run_id}"
668
- if file.exists():
669
- return TraceData.from_path(file)
670
- elif force_raise:
671
- raise FileNotFoundError(
672
- f"Trace log on path {base_path}, does not found trace "
673
- f"'run_id={run_id}'."
1633
+ # Search for specific run ID
1634
+ search_body = {
1635
+ "query": {"term": {"run_id": run_id}},
1636
+ "sort": [{"created_at": {"order": "asc"}}],
1637
+ "size": 1000,
1638
+ }
1639
+
1640
+ response = client.search(index=index_name, body=search_body)
1641
+
1642
+ if not response["hits"]["hits"]:
1643
+ if force_raise:
1644
+ raise FileNotFoundError(
1645
+ f"Trace log with run_id '{run_id}' not found in Elasticsearch"
1646
+ )
1647
+ return TraceData(stdout="", stderr="")
1648
+
1649
+ # Convert to TraceData format
1650
+ stdout_lines = []
1651
+ stderr_lines = []
1652
+ meta_list = []
1653
+
1654
+ for hit in response["hits"]["hits"]:
1655
+ source = hit["_source"]
1656
+
1657
+ # Convert to TraceMeta
1658
+ trace_meta = Metadata(
1659
+ run_id=source["run_id"],
1660
+ parent_run_id=source["parent_run_id"],
1661
+ error_flag=source["error_flag"],
1662
+ level=source["level"],
1663
+ datetime=source["datetime"],
1664
+ process=source["process"],
1665
+ thread=source["thread"],
1666
+ message=source["message"],
1667
+ cut_id=source.get("cut_id"),
1668
+ filename=source["filename"],
1669
+ lineno=source["lineno"],
1670
+ workflow_name=source.get("workflow_name"),
1671
+ stage_name=source.get("stage_name"),
1672
+ job_name=source.get("job_name"),
1673
+ duration_ms=source.get("duration_ms"),
1674
+ memory_usage_mb=source.get("memory_usage_mb"),
1675
+ cpu_usage_percent=source.get("cpu_usage_percent"),
1676
+ trace_id=source.get("trace_id"),
1677
+ span_id=source.get("span_id"),
1678
+ parent_span_id=source.get("parent_span_id"),
1679
+ exception_type=source.get("exception_type"),
1680
+ exception_message=source.get("exception_message"),
1681
+ stack_trace=source.get("stack_trace"),
1682
+ error_code=source.get("error_code"),
1683
+ user_id=source.get("user_id"),
1684
+ tenant_id=source.get("tenant_id"),
1685
+ environment=source.get("environment"),
1686
+ hostname=source.get("hostname"),
1687
+ ip_address=source.get("ip_address"),
1688
+ python_version=source.get("python_version"),
1689
+ package_version=source.get("package_version"),
1690
+ tags=source.get("tags", []),
1691
+ metadata=source.get("metadata", {}),
1692
+ )
1693
+
1694
+ meta_list.append(trace_meta)
1695
+
1696
+ # Add to stdout/stderr based on mode
1697
+ fmt = (
1698
+ dynamic("log_format_file", extras=extras)
1699
+ or "{datetime} ({process:5d}, {thread:5d}) ({cut_id}) {message:120s} ({filename}:{lineno})"
1700
+ )
1701
+ formatted_line = fmt.format(**trace_meta.model_dump())
1702
+
1703
+ if trace_meta.error_flag:
1704
+ stderr_lines.append(formatted_line)
1705
+ else:
1706
+ stdout_lines.append(formatted_line)
1707
+
1708
+ client.close()
1709
+
1710
+ return TraceData(
1711
+ stdout="\n".join(stdout_lines),
1712
+ stderr="\n".join(stderr_lines),
1713
+ meta=meta_list,
674
1714
  )
675
- return TraceData(stdout="", stderr="")
676
1715
 
677
- @property
678
- def pointer(self) -> Path:
679
- """Pointer of the target path that use to writing trace log or searching
680
- trace log.
1716
+ except ImportError as e:
1717
+ raise ImportError(
1718
+ "Elasticsearch handler requires 'elasticsearch' package"
1719
+ ) from e
1720
+ except Exception as e:
1721
+ logger.error(f"Failed to read from Elasticsearch: {e}")
1722
+ if force_raise:
1723
+ raise
1724
+ return TraceData(stdout="", stderr="")
1725
+
1726
+
1727
+ TraceHandler = Annotated[
1728
+ Union[
1729
+ ConsoleHandler,
1730
+ FileHandler,
1731
+ # SQLiteHandler,
1732
+ # RestAPIHandler,
1733
+ # ElasticHandler
1734
+ ],
1735
+ Field(discriminator="type"),
1736
+ ]
681
1737
 
682
- This running ID folder that use to keeping trace log data will use
683
- a parent running ID first. If it does not set, it will use running ID
684
- instead.
685
1738
 
686
- :rtype: Path
1739
+ class BaseEmit(ABC):
1740
+
1741
+ @abstractmethod
1742
+ def emit(
1743
+ self,
1744
+ msg: str,
1745
+ level: Level,
1746
+ ):
1747
+ """Write trace log with append mode and logging this message with any
1748
+ logging level.
1749
+
1750
+ Args:
1751
+ msg: A message that want to log.
1752
+ level: A logging level.
687
1753
  """
688
- log_file: Path = (
689
- Path(unquote_plus(self.url.path))
690
- / f"run_id={self.parent_run_id or self.run_id}"
1754
+ raise NotImplementedError(
1755
+ "Logging action should be implement for making trace log."
691
1756
  )
692
- if not log_file.exists():
693
- log_file.mkdir(parents=True)
694
- return log_file
695
1757
 
696
- def writer(
1758
+ def debug(self, msg: str):
1759
+ """Write trace log with append mode and logging this message with the
1760
+ DEBUG level.
1761
+
1762
+ Args:
1763
+ msg: A message that want to log.
1764
+ """
1765
+ self.emit(msg, level="debug")
1766
+
1767
+ def info(self, msg: str) -> None:
1768
+ """Write trace log with append mode and logging this message with the
1769
+ INFO level.
1770
+
1771
+ Args:
1772
+ msg: A message that want to log.
1773
+ """
1774
+ self.emit(msg, level="info")
1775
+
1776
+ def warning(self, msg: str) -> None:
1777
+ """Write trace log with append mode and logging this message with the
1778
+ WARNING level.
1779
+
1780
+ Args:
1781
+ msg: A message that want to log.
1782
+ """
1783
+ self.emit(msg, level="warning")
1784
+
1785
+ def error(self, msg: str) -> None:
1786
+ """Write trace log with append mode and logging this message with the
1787
+ ERROR level.
1788
+
1789
+ Args:
1790
+ msg: A message that want to log.
1791
+ """
1792
+ self.emit(msg, level="error")
1793
+
1794
+ def exception(self, msg: str) -> None:
1795
+ """Write trace log with append mode and logging this message with the
1796
+ EXCEPTION level.
1797
+
1798
+ Args:
1799
+ msg: A message that want to log.
1800
+ """
1801
+ self.emit(msg, level="exception")
1802
+
1803
+
1804
+ class BaseAsyncEmit(ABC):
1805
+
1806
+ @abstractmethod
1807
+ async def amit(
697
1808
  self,
698
- message: str,
699
- level: str,
700
- is_err: bool = False,
1809
+ msg: str,
1810
+ level: Level,
701
1811
  ) -> None:
702
- """Write a trace message after making to target file and write metadata
703
- in the same path of standard files.
1812
+ """Async write trace log with append mode and logging this message with
1813
+ any logging level.
704
1814
 
705
- The path of logging data will store by format:
1815
+ Args:
1816
+ msg (str): A message that want to log.
1817
+ level (Mode): A logging level.
1818
+ """
1819
+ raise NotImplementedError(
1820
+ "Async Logging action should be implement for making trace log."
1821
+ )
706
1822
 
707
- ... ./logs/run_id=<run-id>/metadata.json
708
- ... ./logs/run_id=<run-id>/stdout.txt
709
- ... ./logs/run_id=<run-id>/stderr.txt
1823
+ async def adebug(self, msg: str) -> None: # pragma: no cov
1824
+ """Async write trace log with append mode and logging this message with
1825
+ the DEBUG level.
710
1826
 
711
- :param message: (str) A message after making.
712
- :param level: (str) A log level.
713
- :param is_err: A flag for writing with an error trace or not.
1827
+ Args:
1828
+ msg: A message that want to log.
714
1829
  """
715
- if not dynamic("enable_write_log", extras=self.extras):
716
- return
1830
+ await self.amit(msg, level="debug")
717
1831
 
718
- mode: Literal["stdout", "stderr"] = "stderr" if is_err else "stdout"
719
- trace_meta: TraceMeta = TraceMeta.make(
720
- mode=mode,
721
- level=level,
722
- message=message,
723
- cutting_id=self.cut_id,
724
- extras=self.extras,
725
- )
1832
+ async def ainfo(self, msg: str) -> None: # pragma: no cov
1833
+ """Async write trace log with append mode and logging this message with
1834
+ the INFO level.
726
1835
 
727
- with (self.pointer / f"{mode}.txt").open(
728
- mode="at", encoding="utf-8"
729
- ) as f:
730
- fmt: str = dynamic("log_format_file", extras=self.extras)
731
- f.write(f"{fmt}\n".format(**trace_meta.model_dump()))
1836
+ Args:
1837
+ msg: A message that want to log.
1838
+ """
1839
+ await self.amit(msg, level="info")
732
1840
 
733
- with (self.pointer / METADATA).open(mode="at", encoding="utf-8") as f:
734
- f.write(trace_meta.model_dump_json() + "\n")
1841
+ async def awarning(self, msg: str) -> None: # pragma: no cov
1842
+ """Async write trace log with append mode and logging this message with
1843
+ the WARNING level.
735
1844
 
736
- async def awriter(
737
- self,
738
- message: str,
739
- level: str,
740
- is_err: bool = False,
741
- ) -> None: # pragma: no cov
742
- """Write with async mode."""
743
- if not dynamic("enable_write_log", extras=self.extras):
744
- return
1845
+ Args:
1846
+ msg: A message that want to log.
1847
+ """
1848
+ await self.amit(msg, level="warning")
745
1849
 
746
- try:
747
- import aiofiles
748
- except ImportError as e:
749
- raise ImportError("Async mode need aiofiles package") from e
1850
+ async def aerror(self, msg: str) -> None: # pragma: no cov
1851
+ """Async write trace log with append mode and logging this message with
1852
+ the ERROR level.
1853
+
1854
+ Args:
1855
+ msg: A message that want to log.
1856
+ """
1857
+ await self.amit(msg, level="error")
1858
+
1859
+ async def aexception(self, msg: str) -> None: # pragma: no cov
1860
+ """Async write trace log with append mode and logging this message with
1861
+ the EXCEPTION level.
1862
+
1863
+ Args:
1864
+ msg: A message that want to log.
1865
+ """
1866
+ await self.amit(msg, level="exception")
1867
+
1868
+
1869
+ class TraceManager(BaseModel, BaseEmit, BaseAsyncEmit):
1870
+ """Trace Manager model that keep all trance handler and emit log to its
1871
+ handler.
1872
+ """
750
1873
 
751
- mode: Literal["stdout", "stderr"] = "stderr" if is_err else "stdout"
752
- trace_meta: TraceMeta = TraceMeta.make(
753
- mode=mode,
1874
+ extras: DictData = Field(
1875
+ default_factory=dict,
1876
+ description=(
1877
+ "An extra parameter that want to override on the core config "
1878
+ "values."
1879
+ ),
1880
+ )
1881
+ run_id: str = Field(description="A running ID")
1882
+ parent_run_id: Optional[str] = Field(
1883
+ default=None,
1884
+ description="A parent running ID",
1885
+ )
1886
+ handlers: list[TraceHandler] = Field(
1887
+ description="A list of Trace handler model."
1888
+ )
1889
+ buffer_size: int = Field(
1890
+ default=10,
1891
+ description="A buffer size to trigger flush trace log",
1892
+ )
1893
+
1894
+ # NOTE: Private attrs for the internal process.
1895
+ _enable_buffer: bool = PrivateAttr(default=False)
1896
+ _buffer: list[Metadata] = PrivateAttr(default_factory=list)
1897
+
1898
+ @property
1899
+ def cut_id(self) -> str:
1900
+ """Combine cutting ID of parent running ID if it set.
1901
+
1902
+ Returns:
1903
+ str: The combined cutting ID string.
1904
+ """
1905
+ cut_run_id: str = cut_id(self.run_id)
1906
+ if not self.parent_run_id:
1907
+ return cut_run_id
1908
+
1909
+ cut_parent_run_id: str = cut_id(self.parent_run_id)
1910
+ return f"{cut_parent_run_id} -> {cut_run_id}"
1911
+
1912
+ def make_message(self, msg: str) -> str:
1913
+ """Prepare and Make a message before write and log steps.
1914
+
1915
+ Args:
1916
+ msg: A message that want to prepare and make before.
1917
+
1918
+ Returns:
1919
+ str: The prepared message.
1920
+ """
1921
+ return prepare_newline(Message.from_str(msg).prepare(self.extras))
1922
+
1923
+ def emit(self, msg: str, level: Level) -> None:
1924
+ """Emit a trace log to all handler. This will use synchronise process.
1925
+
1926
+ Args:
1927
+ msg (str): A message.
1928
+ level (Level): A tracing level.
1929
+ """
1930
+ _msg: str = self.make_message(msg)
1931
+ metadata: Metadata = Metadata.make(
1932
+ error_flag=(level in ("error", "exception")),
754
1933
  level=level,
755
- message=message,
1934
+ message=_msg,
756
1935
  cutting_id=self.cut_id,
1936
+ run_id=self.run_id,
1937
+ parent_run_id=self.parent_run_id,
757
1938
  extras=self.extras,
758
1939
  )
1940
+ if not self._enable_buffer:
1941
+ for handler in self.handlers:
1942
+ handler.emit(metadata, extra=self.extras)
1943
+ return
759
1944
 
760
- async with aiofiles.open(
761
- self.pointer / f"{mode}.txt", mode="at", encoding="utf-8"
762
- ) as f:
763
- fmt: str = dynamic("log_format_file", extras=self.extras)
764
- await f.write(f"{fmt}\n".format(**trace_meta.model_dump()))
765
-
766
- async with aiofiles.open(
767
- self.pointer / METADATA, mode="at", encoding="utf-8"
768
- ) as f:
769
- await f.write(trace_meta.model_dump_json() + "\n")
770
-
771
-
772
- class SQLiteTrace(BaseTrace): # pragma: no cov
773
- """SQLite Trace dataclass that write trace log to the SQLite database file."""
774
-
775
- table_name: ClassVar[str] = "audits"
776
- schemas: ClassVar[
777
- str
778
- ] = """
779
- run_id str
780
- , parent_run_id str
781
- , type str
782
- , text str
783
- , metadata JSON
784
- , created_at datetime
785
- , updated_at datetime
786
- primary key ( parent_run_id )
787
- """
1945
+ # NOTE: Update metadata to the buffer.
1946
+ self._buffer.append(metadata)
788
1947
 
789
- @classmethod
790
- def find_traces(
791
- cls,
792
- path: Optional[Path] = None,
793
- extras: Optional[DictData] = None,
794
- ) -> Iterator[TraceData]:
795
- raise NotImplementedError("SQLiteTrace does not implement yet.")
1948
+ if len(self._buffer) >= self.buffer_size: # pragma: no cov
1949
+ for handler in self.handlers:
1950
+ handler.flush(self._buffer, extra=self.extras)
1951
+ self._buffer.clear()
796
1952
 
797
- @classmethod
798
- def find_trace_with_id(
799
- cls,
800
- run_id: str,
801
- force_raise: bool = True,
802
- *,
803
- path: Optional[Path] = None,
804
- extras: Optional[DictData] = None,
805
- ) -> TraceData:
806
- raise NotImplementedError("SQLiteTrace does not implement yet.")
807
-
808
- def make_message(self, message: str) -> str:
809
- raise NotImplementedError("SQLiteTrace does not implement yet.")
1953
+ async def amit(self, msg: str, level: Level) -> None:
1954
+ """Async write trace log with append mode and logging this message with
1955
+ any logging level.
810
1956
 
811
- def writer(
812
- self,
813
- message: str,
814
- level: str,
815
- is_err: bool = False,
816
- ) -> None:
817
- raise NotImplementedError("SQLiteTrace does not implement yet.")
1957
+ Args:
1958
+ msg: A message that want to log.
1959
+ level (Level): A logging mode.
1960
+ """
1961
+ _msg: str = self.make_message(msg)
1962
+ metadata: Metadata = Metadata.make(
1963
+ error_flag=(level in ("error", "exception")),
1964
+ level=level,
1965
+ message=_msg,
1966
+ cutting_id=self.cut_id,
1967
+ run_id=self.run_id,
1968
+ parent_run_id=self.parent_run_id,
1969
+ extras=self.extras,
1970
+ )
1971
+ for handler in self.handlers:
1972
+ await handler.amit(metadata, extra=self.extras)
818
1973
 
819
- def awriter(
820
- self,
821
- message: str,
822
- level: str,
823
- is_err: bool = False,
824
- ) -> None:
825
- raise NotImplementedError("SQLiteTrace does not implement yet.")
1974
+ def __enter__(self):
1975
+ """Enter the trace for catching the logs that run so fast. It will use
1976
+ buffer strategy to flush the logs instead emit.
1977
+ """
1978
+ self._enable_buffer = True
1979
+ return self
1980
+
1981
+ def __exit__(self, exc_type, exc_val, exc_tb):
1982
+ """Exit the trace that will clear all log in the buffer."""
1983
+ if exc_type:
1984
+ _msg: str = self.make_message(str(exc_val))
1985
+ metadata: Metadata = Metadata.make(
1986
+ error_flag=True,
1987
+ level="error",
1988
+ message=_msg,
1989
+ cutting_id=self.cut_id,
1990
+ run_id=self.run_id,
1991
+ parent_run_id=self.parent_run_id,
1992
+ extras=self.extras,
1993
+ )
1994
+ self._buffer.append(metadata)
826
1995
 
1996
+ if self._buffer:
1997
+ for handler in self.handlers:
1998
+ handler.flush(self._buffer, extra=self.extras)
1999
+ self._buffer.clear()
827
2000
 
828
- Trace = Union[
829
- FileTrace,
830
- SQLiteTrace,
831
- BaseTrace,
832
- ]
2001
+ # NOTE: Re-raise the exception if one occurred
2002
+ return False
833
2003
 
834
2004
 
835
2005
  def get_trace(
@@ -837,40 +2007,26 @@ def get_trace(
837
2007
  *,
838
2008
  parent_run_id: Optional[str] = None,
839
2009
  extras: Optional[DictData] = None,
840
- ) -> Trace: # pragma: no cov
841
- """Get dynamic Trace instance from the core config (it can override by an
842
- extras argument) that passing running ID and parent running ID.
2010
+ ) -> TraceManager:
2011
+ """Get dynamic TraceManager instance from the core config.
843
2012
 
844
- :param run_id: (str) A running ID.
845
- :param parent_run_id: (str) A parent running ID.
846
- :param extras: (DictData) An extra parameter that want to override the core
847
- config values.
2013
+ This factory function returns the appropriate trace implementation based on
2014
+ configuration. It can be overridden by extras argument and accepts running ID
2015
+ and parent running ID.
848
2016
 
849
- :rtype: Trace
850
- """
851
- # NOTE: Allow you to override trace model by the extra parameter.
852
- map_trace_models: dict[str, type[Trace]] = (extras or {}).get(
853
- "trace_model_mapping", {}
854
- )
855
- url: ParseResult
856
- if (url := dynamic("trace_url", extras=extras)).scheme and (
857
- url.scheme == "sqlite"
858
- or (url.scheme == "file" and Path(url.path).is_file())
859
- ):
860
- return map_trace_models.get("sqlite", SQLiteTrace)(
861
- url=url,
862
- run_id=run_id,
863
- parent_run_id=parent_run_id,
864
- extras=(extras or {}),
865
- )
866
- elif url.scheme and url.scheme != "file":
867
- raise NotImplementedError(
868
- f"Does not implement the outside trace model support for URL: {url}"
869
- )
2017
+ Args:
2018
+ run_id (str): A running ID.
2019
+ parent_run_id (str | None, default None): A parent running ID.
2020
+ extras: An extra parameter that want to override the core
2021
+ config values.
870
2022
 
871
- return map_trace_models.get("file", FileTrace)(
872
- url=url,
2023
+ Returns:
2024
+ TraceManager: The appropriate trace instance.
2025
+ """
2026
+ handlers = dynamic("trace_handlers", extras=extras)
2027
+ return TraceManager(
873
2028
  run_id=run_id,
874
2029
  parent_run_id=parent_run_id,
875
- extras=(extras or {}),
2030
+ handlers=handlers,
2031
+ extras=extras or {},
876
2032
  )