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