ddeutil-workflow 0.0.77__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,44 +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")
17
+ set_logging: Configure logger with custom formatting.
18
+ get_trace: Factory function for trace instances.
35
19
  """
36
- from __future__ import annotations
37
-
38
20
  import json
39
21
  import logging
40
22
  import os
41
23
  import re
42
24
  from abc import ABC, abstractmethod
43
25
  from collections.abc import Iterator
26
+ from datetime import datetime
44
27
  from functools import lru_cache
45
28
  from inspect import Traceback, currentframe, getframeinfo
46
29
  from pathlib import Path
47
- from threading import get_ident
30
+ from threading import Lock, get_ident
48
31
  from types import FrameType
49
- from typing import ClassVar, Final, Literal, Optional, Union
50
- from urllib.parse import ParseResult, unquote_plus, urlparse
32
+ from typing import Annotated, Any, ClassVar, Final, Literal, Optional, Union
33
+ from zoneinfo import ZoneInfo
51
34
 
52
- from pydantic import BaseModel, ConfigDict, Field
53
- from pydantic.functional_serializers import field_serializer
35
+ from pydantic import BaseModel, Field, PrivateAttr
54
36
  from pydantic.functional_validators import field_validator
55
37
  from typing_extensions import Self
56
38
 
@@ -58,8 +40,8 @@ from .__types import DictData
58
40
  from .conf import config, dynamic
59
41
  from .utils import cut_id, get_dt_now, prepare_newline
60
42
 
61
- METADATA: str = "metadata.json"
62
43
  logger = logging.getLogger("ddeutil.workflow")
44
+ Level = Literal["debug", "info", "warning", "error", "exception"]
63
45
 
64
46
 
65
47
  @lru_cache
@@ -71,16 +53,14 @@ def set_logging(name: str) -> logging.Logger:
71
53
  console output and proper formatting for workflow execution tracking.
72
54
 
73
55
  Args:
74
- name: Module name to create logger for
56
+ name: Module name to create logger for.
75
57
 
76
58
  Returns:
77
- logging.Logger: Configured logger instance with custom formatting
59
+ logging.Logger: Configured logger instance with custom formatting.
78
60
 
79
61
  Example:
80
- ```python
81
- logger = set_logging("ddeutil.workflow.stages")
82
- logger.info("Stage execution started")
83
- ```
62
+ >>> log = set_logging("ddeutil.workflow.stages")
63
+ >>> log.info("Stage execution started")
84
64
  """
85
65
  _logger = logging.getLogger(name)
86
66
 
@@ -103,25 +83,28 @@ def set_logging(name: str) -> logging.Logger:
103
83
 
104
84
  PREFIX_LOGS: Final[dict[str, dict]] = {
105
85
  "CALLER": {
106
- "emoji": "📍",
86
+ "emoji": "⚙️",
107
87
  "desc": "logs from any usage from custom caller function.",
108
88
  },
109
- "STAGE": {"emoji": "⚙️", "desc": "logs from stages module."},
89
+ "STAGE": {"emoji": "🔗", "desc": "logs from stages module."},
110
90
  "JOB": {"emoji": "⛓️", "desc": "logs from job module."},
111
91
  "WORKFLOW": {"emoji": "🏃", "desc": "logs from workflow module."},
112
92
  "RELEASE": {"emoji": "📅", "desc": "logs from release workflow method."},
113
93
  "POKING": {"emoji": "⏰", "desc": "logs from poke workflow method."},
94
+ "AUDIT": {"emoji": "📌", "desc": "logs from audit model."},
114
95
  } # pragma: no cov
115
96
  PREFIX_DEFAULT: Final[str] = "CALLER"
116
- PREFIX_LOGS_REGEX: re.Pattern[str] = re.compile(
97
+ PREFIX_LOGS_REGEX: Final[re.Pattern[str]] = re.compile(
117
98
  rf"(^\[(?P<name>{'|'.join(PREFIX_LOGS)})]:\s?)?(?P<message>.*)",
118
99
  re.MULTILINE | re.DOTALL | re.ASCII | re.VERBOSE,
119
100
  ) # pragma: no cov
120
101
 
121
102
 
122
103
  class Message(BaseModel):
123
- """Prefix Message model for receive grouping dict from searching prefix data
124
- 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.
125
108
  """
126
109
 
127
110
  name: Optional[str] = Field(default=None, description="A prefix name.")
@@ -132,10 +115,10 @@ class Message(BaseModel):
132
115
  """Extract message prefix from an input message.
133
116
 
134
117
  Args:
135
- msg (str): A message that want to extract.
118
+ msg: A message that want to extract.
136
119
 
137
120
  Returns:
138
- Message: the validated model from a string message.
121
+ Message: The validated model from a string message.
139
122
  """
140
123
  return Message.model_validate(
141
124
  obj=PREFIX_LOGS_REGEX.search(msg).groupdict()
@@ -160,13 +143,16 @@ class Message(BaseModel):
160
143
  return f"{emoji}[{name}]: {self.message}"
161
144
 
162
145
 
163
- class TraceMeta(BaseModel): # pragma: no cov
164
- """Trace Metadata model for making the current metadata of this CPU, Memory
165
- 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.
166
152
  """
167
153
 
168
- mode: Literal["stdout", "stderr"] = Field(description="A meta mode.")
169
- 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.")
170
156
  datetime: str = Field(
171
157
  description="A datetime string with the specific config format."
172
158
  )
@@ -176,15 +162,98 @@ class TraceMeta(BaseModel): # pragma: no cov
176
162
  cut_id: Optional[str] = Field(
177
163
  default=None, description="A cutting of running ID."
178
164
  )
165
+ run_id: str
166
+ parent_run_id: Optional[str] = None
179
167
  filename: str = Field(description="A filename of this log.")
180
168
  lineno: int = Field(description="A line number of this log.")
181
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
+
182
252
  @classmethod
183
253
  def dynamic_frame(
184
254
  cls, frame: FrameType, *, extras: Optional[DictData] = None
185
255
  ) -> Traceback:
186
- """Dynamic Frame information base on the `logs_trace_frame_layer` config
187
- value that was set from the extra parameter.
256
+ """Dynamic Frame information base on the `logs_trace_frame_layer` config.
188
257
 
189
258
  Args:
190
259
  frame: The current frame that want to dynamic.
@@ -194,67 +263,136 @@ class TraceMeta(BaseModel): # pragma: no cov
194
263
  Returns:
195
264
  Traceback: The frame information at the specified layer.
196
265
  """
197
- extras: DictData = extras or {}
198
- 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
199
269
  for _ in range(layer):
200
- _frame: Optional[FrameType] = frame.f_back
270
+ _frame: Optional[FrameType] = current_frame.f_back
201
271
  if _frame is None:
202
272
  raise ValueError(
203
273
  f"Layer value does not valid, the maximum frame is: {_ + 1}"
204
274
  )
205
- frame: FrameType = _frame
206
- return getframeinfo(frame)
275
+ current_frame = _frame
276
+ return getframeinfo(current_frame)
207
277
 
208
278
  @classmethod
209
279
  def make(
210
280
  cls,
211
- mode: Literal["stdout", "stderr"],
281
+ error_flag: bool,
212
282
  message: str,
213
- level: str,
283
+ level: Level,
214
284
  cutting_id: str,
285
+ run_id: str,
286
+ parent_run_id: Optional[str],
215
287
  *,
216
288
  extras: Optional[DictData] = None,
217
289
  ) -> Self:
218
- """Make the current metric for contract this TraceMeta model instance
219
- 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.
220
294
 
221
295
  Args:
222
- mode: A metadata mode.
296
+ error_flag: A metadata mode.
223
297
  message: A message.
224
298
  level: A log level.
225
299
  cutting_id: A cutting ID string.
300
+ run_id:
301
+ parent_run_id:
226
302
  extras: An extra parameter that want to override core
227
303
  config values.
228
304
 
229
305
  Returns:
230
- Self: The constructed TraceMeta instance.
306
+ Self: The constructed Metadata instance.
231
307
  """
232
- 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
+
233
315
  frame_info: Traceback = cls.dynamic_frame(frame, extras=extras)
234
- 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
+
235
336
  return cls(
236
- mode=mode,
337
+ error_flag=error_flag,
237
338
  level=level,
238
339
  datetime=(
239
- get_dt_now()
240
- .astimezone(dynamic("log_tz", extras=extras))
241
- .strftime(dynamic("log_datetime_format", extras=extras))
340
+ get_dt_now().astimezone(timezone).strftime(datetime_format)
242
341
  ),
243
342
  process=os.getpid(),
244
343
  thread=get_ident(),
245
344
  message=message,
246
345
  cut_id=cutting_id,
346
+ run_id=run_id,
347
+ parent_run_id=parent_run_id,
247
348
  filename=frame_info.filename.split(os.path.sep)[-1],
248
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", {}),
249
379
  )
250
380
 
381
+ @property
382
+ def pointer_id(self):
383
+ return self.parent_run_id or self.run_id
384
+
251
385
 
252
386
  class TraceData(BaseModel): # pragma: no cov
253
- """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
+ """
254
392
 
255
393
  stdout: str = Field(description="A standard output trace data.")
256
394
  stderr: str = Field(description="A standard error trace data.")
257
- meta: list[TraceMeta] = Field(
395
+ meta: list[Metadata] = Field(
258
396
  default_factory=list,
259
397
  description=(
260
398
  "A metadata mapping of this output and error before making it to "
@@ -262,13 +400,191 @@ class TraceData(BaseModel): # pragma: no cov
262
400
  ),
263
401
  )
264
402
 
403
+
404
+ class BaseHandler(BaseModel, ABC):
405
+ """Base Handler model"""
406
+
407
+ @abstractmethod
408
+ def emit(
409
+ self,
410
+ metadata: Metadata,
411
+ *,
412
+ extra: Optional[DictData] = None,
413
+ ): ...
414
+
415
+ @abstractmethod
416
+ async def amit(
417
+ self,
418
+ metadata: Metadata,
419
+ *,
420
+ extra: Optional[DictData] = None,
421
+ ) -> None: ...
422
+
423
+ @abstractmethod
424
+ def flush(
425
+ self, metadata: list[Metadata], *, extra: Optional[DictData] = None
426
+ ) -> None: ...
427
+
428
+
429
+ class ConsoleHandler(BaseHandler):
430
+ """Console Handler model."""
431
+
432
+ type: Literal["console"] = "console"
433
+
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},
441
+ )
442
+
443
+ async def amit(
444
+ self, metadata: Metadata, *, extra: Optional[DictData] = None
445
+ ) -> None:
446
+ self.emit(metadata, extra=extra)
447
+
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)
453
+
454
+
455
+ class FileHandler(BaseHandler):
456
+ """File Handler model."""
457
+
458
+ metadata_filename: ClassVar[str] = "metadata.txt"
459
+
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
469
+
470
+ # NOTE: Private attrs for the internal process.
471
+ _lock: Lock = PrivateAttr(default_factory=Lock)
472
+
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.
476
+
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.
480
+
481
+ Returns:
482
+ Path: The target path for trace log operations.
483
+ """
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
488
+
489
+ def pre(self) -> None: ...
490
+
491
+ def emit(
492
+ self,
493
+ metadata: Metadata,
494
+ *,
495
+ extra: Optional[DictData] = None,
496
+ ) -> None:
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")
509
+
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
520
+
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
+ )
530
+
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")
535
+
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
+ )
559
+
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()
578
+
265
579
  @classmethod
266
- def from_path(cls, file: Path) -> Self:
580
+ def from_path(cls, file: Path) -> TraceData: # pragma: no cov
267
581
  """Construct this trace data model with a trace path.
268
582
 
269
- :param file: (Path) A trace path.
583
+ Args:
584
+ file: A trace path.
270
585
 
271
- :rtype: Self
586
+ Returns:
587
+ Self: The constructed TraceData instance.
272
588
  """
273
589
  data: DictData = {"stdout": "", "stderr": "", "meta": []}
274
590
 
@@ -276,559 +592,1396 @@ class TraceData(BaseModel): # pragma: no cov
276
592
  if (file / f"{mode}.txt").exists():
277
593
  data[mode] = (file / f"{mode}.txt").read_text(encoding="utf-8")
278
594
 
279
- if (file / METADATA).exists():
595
+ if (file / cls.metadata_filename).exists():
280
596
  data["meta"] = [
281
597
  json.loads(line)
282
598
  for line in (
283
- (file / METADATA).read_text(encoding="utf-8").splitlines()
599
+ (file / cls.metadata_filename)
600
+ .read_text(encoding="utf-8")
601
+ .splitlines()
284
602
  )
285
603
  ]
286
604
 
287
- return cls.model_validate(data)
605
+ return TraceData.model_validate(data)
606
+
607
+ def find_traces(
608
+ self,
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.
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)
621
+
622
+ def find_trace_with_id(
623
+ self,
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.
288
630
 
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.
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="")
289
646
 
290
- class BaseEmitTrace(BaseModel, ABC): # pragma: no cov
291
- """Base Trace model with abstraction class property."""
292
647
 
293
- model_config = ConfigDict(frozen=True)
648
+ class SQLiteHandler(BaseHandler): # pragma: no cov
649
+ """High-performance SQLite logging handler for workflow traces.
294
650
 
295
- extras: DictData = Field(
296
- default_factory=dict,
297
- description=(
298
- "An extra parameter that want to override on the core config "
299
- "values."
300
- ),
301
- )
302
- run_id: str = Field(description="A running ID")
303
- parent_run_id: Optional[str] = Field(
304
- default=None,
305
- description="A parent running 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
+ """
655
+
656
+ type: Literal["sqlite"] = "sqlite"
657
+ path: str
658
+ table_name: str = Field(default="traces")
659
+
660
+ # NOTE: Private attrs for the internal process.
661
+ _lock: Lock = PrivateAttr(default_factory=Lock)
662
+
663
+ def pre(self) -> None:
664
+ import sqlite3
665
+
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
+ )
712
+
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
+ )
738
+
739
+ conn.commit()
740
+
741
+ except Exception as e:
742
+ logger.error(f"Failed to initialize SQLite database: {e}")
743
+ raise
744
+
745
+ def emit(
746
+ self,
747
+ metadata: Metadata,
748
+ *,
749
+ extra: Optional[DictData] = None,
750
+ ) -> None:
751
+ self.flush([metadata], extra=extra)
752
+
753
+ def amit(
754
+ self,
755
+ metadata: Metadata,
756
+ *,
757
+ extra: Optional[DictData] = None,
758
+ ) -> None: ...
759
+
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
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()
839
+
840
+ def find_traces(
841
+ self,
842
+ path: Optional[Path] = None,
843
+ extras: Optional[DictData] = None,
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
859
+
860
+ import sqlite3
861
+
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}")
954
+
955
+ @classmethod
956
+ def find_trace_with_id(
957
+ cls,
958
+ run_id: str,
959
+ force_raise: bool = True,
960
+ *,
961
+ path: Optional[Path] = None,
962
+ extras: Optional[DictData] = None,
963
+ ) -> TraceData:
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
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
+ )
996
+
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
+ )
1067
+
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="")
1073
+
1074
+
1075
+ class RestAPIHandler(BaseHandler): # pragma: no cov
1076
+ type: Literal["restapi"] = "restapi"
1077
+ service_type: Literal["datadog", "grafana", "cloudwatch", "generic"] = (
1078
+ "generic"
306
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
307
1241
 
308
- @abstractmethod
309
- def writer(
1242
+ def emit(
310
1243
  self,
311
- message: str,
312
- level: str,
313
- is_err: bool = False,
1244
+ metadata: Metadata,
1245
+ *,
1246
+ extra: Optional[DictData] = None,
1247
+ ): ...
1248
+
1249
+ async def amit(
1250
+ self,
1251
+ metadata: Metadata,
1252
+ *,
1253
+ extra: Optional[DictData] = None,
1254
+ ) -> None: ...
1255
+
1256
+ def flush(
1257
+ self, metadata: list[Metadata], *, extra: Optional[DictData] = None
314
1258
  ) -> None:
315
- """Write a trace message after making to target pointer object. The
316
- target can be anything be inherited this class and overwrite this method
317
- such as file, console, or database.
318
-
319
- :param message: (str) A message after making.
320
- :param level: (str) A log level.
321
- :param is_err: (bool) A flag for writing with an error trace or not.
322
- (Default be False)
323
- """
324
- raise NotImplementedError(
325
- "Create writer logic for this trace object before using."
326
- )
1259
+ session = self.session()
1260
+ try:
1261
+ formatted_records = [
1262
+ self._format_for_service(meta) for meta in metadata
1263
+ ]
327
1264
 
328
- @abstractmethod
329
- async def awriter(
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
+ """
1310
+
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
1318
+
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
1326
+
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
+ )
1343
+
1344
+ # Test connection
1345
+ if not client.ping():
1346
+ raise ConnectionError("Failed to connect to Elasticsearch")
1347
+
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
1355
+
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
+ }
1456
+
1457
+ def emit(
330
1458
  self,
331
- message: str,
332
- level: str,
333
- is_err: bool = False,
1459
+ metadata: Metadata,
1460
+ *,
1461
+ extra: Optional[DictData] = None,
1462
+ ): ...
1463
+
1464
+ async def amit(
1465
+ self,
1466
+ metadata: Metadata,
1467
+ *,
1468
+ extra: Optional[DictData] = None,
1469
+ ) -> None: ...
1470
+
1471
+ def flush(
1472
+ self,
1473
+ metadata: list[Metadata],
1474
+ *,
1475
+ extra: Optional[DictData] = None,
334
1476
  ) -> None:
335
- """Async Write a trace message after making to target pointer object.
1477
+ client = self.client()
1478
+ try:
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)
336
1491
 
337
- :param message: (str) A message after making.
338
- :param level: (str) A log level.
339
- :param is_err: (bool) A flag for writing with an error trace or not.
340
- (Default be False)
341
- """
342
- raise NotImplementedError(
343
- "Create async writer logic for this trace object before using."
344
- )
1492
+ # Execute bulk indexing
1493
+ response = client.bulk(body=bulk_data, refresh=True)
345
1494
 
346
- @abstractmethod
347
- def make_message(self, message: str) -> str:
348
- """Prepare and Make a message before write and log processes.
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()
349
1504
 
350
- :param message: A message that want to prepare and make before.
1505
+ @classmethod
1506
+ def find_traces(
1507
+ cls,
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,
1512
+ extras: Optional[DictData] = None,
1513
+ ) -> Iterator[TraceData]:
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
+ )
351
1525
 
352
- :rtype: str
353
- """
354
- raise NotImplementedError(
355
- "Adjust make message method for this trace object before using."
356
- )
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}")
1617
+
1618
+ @classmethod
1619
+ def find_trace_with_id(
1620
+ cls,
1621
+ run_id: str,
1622
+ force_raise: bool = True,
1623
+ *,
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,
1628
+ extras: Optional[DictData] = None,
1629
+ ) -> TraceData:
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
+
1745
+
1746
+ class BaseEmit(ABC):
357
1747
 
358
1748
  @abstractmethod
359
1749
  def emit(
360
1750
  self,
361
- message: str,
362
- mode: str,
363
- *,
364
- is_err: bool = False,
1751
+ msg: str,
1752
+ level: Level,
365
1753
  ):
366
1754
  """Write trace log with append mode and logging this message with any
367
1755
  logging level.
368
1756
 
369
- :param message: (str) A message that want to log.
370
- :param mode: (str)
371
- :param is_err: (bool)
1757
+ Args:
1758
+ msg: A message that want to log.
1759
+ level: A logging level.
372
1760
  """
373
1761
  raise NotImplementedError(
374
1762
  "Logging action should be implement for making trace log."
375
1763
  )
376
1764
 
377
- def debug(self, message: str):
1765
+ def debug(self, msg: str):
378
1766
  """Write trace log with append mode and logging this message with the
379
1767
  DEBUG level.
380
1768
 
381
- :param message: (str) A message that want to log.
1769
+ Args:
1770
+ msg: A message that want to log.
382
1771
  """
383
- self.emit(message, mode="debug")
1772
+ self.emit(msg, level="debug")
384
1773
 
385
- def info(self, message: str) -> None:
1774
+ def info(self, msg: str) -> None:
386
1775
  """Write trace log with append mode and logging this message with the
387
1776
  INFO level.
388
1777
 
389
- :param message: (str) A message that want to log.
1778
+ Args:
1779
+ msg: A message that want to log.
390
1780
  """
391
- self.emit(message, mode="info")
1781
+ self.emit(msg, level="info")
392
1782
 
393
- def warning(self, message: str) -> None:
1783
+ def warning(self, msg: str) -> None:
394
1784
  """Write trace log with append mode and logging this message with the
395
1785
  WARNING level.
396
1786
 
397
- :param message: (str) A message that want to log.
1787
+ Args:
1788
+ msg: A message that want to log.
398
1789
  """
399
- self.emit(message, mode="warning")
1790
+ self.emit(msg, level="warning")
400
1791
 
401
- def error(self, message: str) -> None:
1792
+ def error(self, msg: str) -> None:
402
1793
  """Write trace log with append mode and logging this message with the
403
1794
  ERROR level.
404
1795
 
405
- :param message: (str) A message that want to log.
1796
+ Args:
1797
+ msg: A message that want to log.
406
1798
  """
407
- self.emit(message, mode="error", is_err=True)
1799
+ self.emit(msg, level="error")
408
1800
 
409
- def exception(self, message: str) -> None:
1801
+ def exception(self, msg: str) -> None:
410
1802
  """Write trace log with append mode and logging this message with the
411
1803
  EXCEPTION level.
412
1804
 
413
- :param message: (str) A message that want to log.
1805
+ Args:
1806
+ msg: A message that want to log.
414
1807
  """
415
- self.emit(message, mode="exception", is_err=True)
1808
+ self.emit(msg, level="exception")
1809
+
1810
+
1811
+ class BaseAsyncEmit(ABC):
416
1812
 
417
1813
  @abstractmethod
418
1814
  async def amit(
419
1815
  self,
420
- message: str,
421
- mode: str,
422
- *,
423
- is_err: bool = False,
1816
+ msg: str,
1817
+ level: Level,
424
1818
  ) -> None:
425
1819
  """Async write trace log with append mode and logging this message with
426
1820
  any logging level.
427
1821
 
428
- :param message: (str) A message that want to log.
429
- :param mode: (str)
430
- :param is_err: (bool)
1822
+ Args:
1823
+ msg (str): A message that want to log.
1824
+ level (Mode): A logging level.
431
1825
  """
432
1826
  raise NotImplementedError(
433
1827
  "Async Logging action should be implement for making trace log."
434
1828
  )
435
1829
 
436
- async def adebug(self, message: str) -> None: # pragma: no cov
1830
+ async def adebug(self, msg: str) -> None: # pragma: no cov
437
1831
  """Async write trace log with append mode and logging this message with
438
1832
  the DEBUG level.
439
1833
 
440
- :param message: (str) A message that want to log.
1834
+ Args:
1835
+ msg: A message that want to log.
441
1836
  """
442
- await self.amit(message, mode="debug")
1837
+ await self.amit(msg, level="debug")
443
1838
 
444
- async def ainfo(self, message: str) -> None: # pragma: no cov
1839
+ async def ainfo(self, msg: str) -> None: # pragma: no cov
445
1840
  """Async write trace log with append mode and logging this message with
446
1841
  the INFO level.
447
1842
 
448
- :param message: (str) A message that want to log.
1843
+ Args:
1844
+ msg: A message that want to log.
449
1845
  """
450
- await self.amit(message, mode="info")
1846
+ await self.amit(msg, level="info")
451
1847
 
452
- async def awarning(self, message: str) -> None: # pragma: no cov
1848
+ async def awarning(self, msg: str) -> None: # pragma: no cov
453
1849
  """Async write trace log with append mode and logging this message with
454
1850
  the WARNING level.
455
1851
 
456
- :param message: (str) A message that want to log.
1852
+ Args:
1853
+ msg: A message that want to log.
457
1854
  """
458
- await self.amit(message, mode="warning")
1855
+ await self.amit(msg, level="warning")
459
1856
 
460
- async def aerror(self, message: str) -> None: # pragma: no cov
1857
+ async def aerror(self, msg: str) -> None: # pragma: no cov
461
1858
  """Async write trace log with append mode and logging this message with
462
1859
  the ERROR level.
463
1860
 
464
- :param message: (str) A message that want to log.
1861
+ Args:
1862
+ msg: A message that want to log.
465
1863
  """
466
- await self.amit(message, mode="error", is_err=True)
1864
+ await self.amit(msg, level="error")
467
1865
 
468
- async def aexception(self, message: str) -> None: # pragma: no cov
1866
+ async def aexception(self, msg: str) -> None: # pragma: no cov
469
1867
  """Async write trace log with append mode and logging this message with
470
1868
  the EXCEPTION level.
471
1869
 
472
- :param message: (str) A message that want to log.
1870
+ Args:
1871
+ msg: A message that want to log.
473
1872
  """
474
- await self.amit(message, mode="exception", is_err=True)
475
-
1873
+ await self.amit(msg, level="exception")
476
1874
 
477
- class ConsoleTrace(BaseEmitTrace): # pragma: no cov
478
- """Console Trace log model."""
479
1875
 
480
- def writer(
481
- self,
482
- message: str,
483
- level: str,
484
- is_err: bool = False,
485
- ) -> None:
486
- """Write a trace message after making to target pointer object. The
487
- target can be anything be inherited this class and overwrite this method
488
- such as file, console, or database.
489
-
490
- :param message: (str) A message after making.
491
- :param level: (str) A log level.
492
- :param is_err: (bool) A flag for writing with an error trace or not.
493
- (Default be False)
494
- """
1876
+ class TraceManager(BaseModel, BaseEmit, BaseAsyncEmit):
1877
+ """Trace Management that keep all trance handler."""
495
1878
 
496
- async def awriter(
497
- self,
498
- message: str,
499
- level: str,
500
- is_err: bool = False,
501
- ) -> None:
502
- """Async Write a trace message after making to target pointer object.
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
+ )
503
1898
 
504
- :param message: (str) A message after making.
505
- :param level: (str) A log level.
506
- :param is_err: (bool) A flag for writing with an error trace or not.
507
- (Default be False)
508
- """
1899
+ # NOTE: Private attrs for the internal process.
1900
+ _enable_buffer: bool = PrivateAttr(default=False)
1901
+ _buffer: list[Metadata] = PrivateAttr(default_factory=list)
509
1902
 
510
1903
  @property
511
1904
  def cut_id(self) -> str:
512
1905
  """Combine cutting ID of parent running ID if it set.
513
1906
 
514
- :rtype: str
1907
+ Returns:
1908
+ str: The combined cutting ID string.
515
1909
  """
516
1910
  cut_run_id: str = cut_id(self.run_id)
517
1911
  if not self.parent_run_id:
518
- return f"{cut_run_id}"
1912
+ return cut_run_id
519
1913
 
520
1914
  cut_parent_run_id: str = cut_id(self.parent_run_id)
521
1915
  return f"{cut_parent_run_id} -> {cut_run_id}"
522
1916
 
523
- def make_message(self, message: str) -> str:
1917
+ def make_message(self, msg: str) -> str:
524
1918
  """Prepare and Make a message before write and log steps.
525
1919
 
526
- :param message: (str) A message that want to prepare and make before.
527
-
528
- :rtype: str
529
- """
530
- return prepare_newline(Message.from_str(message).prepare(self.extras))
531
-
532
- def emit(self, message: str, mode: str, *, is_err: bool = False) -> None:
533
- """Write trace log with append mode and logging this message with any
534
- logging level.
535
-
536
- :param message: (str) A message that want to log.
537
- :param mode: (str)
538
- :param is_err: (bool)
539
- """
540
- msg: str = self.make_message(message)
541
-
542
- if mode != "debug" or (
543
- mode == "debug" and dynamic("debug", extras=self.extras)
544
- ):
545
- self.writer(msg, level=mode, is_err=is_err)
546
-
547
- getattr(logger, mode)(msg, stacklevel=3, extra={"cut_id": self.cut_id})
548
-
549
- async def amit(
550
- self, message: str, mode: str, *, is_err: bool = False
551
- ) -> None:
552
- """Write trace log with append mode and logging this message with any
553
- logging level.
554
-
555
- :param message: (str) A message that want to log.
556
- :param mode: (str)
557
- :param is_err: (bool)
558
- """
559
- msg: str = self.make_message(message)
560
-
561
- if mode != "debug" or (
562
- mode == "debug" and dynamic("debug", extras=self.extras)
563
- ):
564
- await self.awriter(msg, level=mode, is_err=is_err)
565
-
566
- getattr(logger, mode)(msg, stacklevel=3, extra={"cut_id": self.cut_id})
567
-
568
-
569
- class BaseTrace(ConsoleTrace, ABC):
570
- """A Base Trace model that will use for override writing or sending trace
571
- log to any service type.
572
- """
573
-
574
- model_config = ConfigDict(arbitrary_types_allowed=True)
575
-
576
- url: ParseResult = Field(description="An URL for create pointer.")
577
-
578
- @field_validator(
579
- "url", mode="before", json_schema_input_type=Union[ParseResult, str]
580
- )
581
- def __parse_url(cls, value: Union[ParseResult, str]) -> ParseResult:
582
- """Parsing an URL value."""
583
- return urlparse(value) if isinstance(value, str) else value
584
-
585
- @field_serializer("url")
586
- def __serialize_url(self, value: ParseResult) -> str:
587
- return value.geturl()
588
-
589
- @classmethod
590
- @abstractmethod
591
- def find_traces(
592
- cls,
593
- path: Optional[Path] = None,
594
- extras: Optional[DictData] = None,
595
- ) -> Iterator[TraceData]: # pragma: no cov
596
- """Return iterator of TraceData models from the target pointer.
597
-
598
1920
  Args:
599
- path (:obj:`Path`, optional): A pointer path that want to override.
600
- extras (:obj:`DictData`, optional): An extras parameter that want to
601
- override default engine config.
1921
+ msg: A message that want to prepare and make before.
602
1922
 
603
1923
  Returns:
604
- Iterator[TracData]: An iterator object that generate a TracData
605
- model.
606
- """
607
- raise NotImplementedError(
608
- "Trace dataclass should implement `find_traces` class-method."
609
- )
610
-
611
- @classmethod
612
- @abstractmethod
613
- def find_trace_with_id(
614
- cls,
615
- run_id: str,
616
- force_raise: bool = True,
617
- *,
618
- path: Optional[Path] = None,
619
- extras: Optional[DictData] = None,
620
- ) -> TraceData:
621
- raise NotImplementedError(
622
- "Trace dataclass should implement `find_trace_with_id` "
623
- "class-method."
624
- )
625
-
626
-
627
- class FileTrace(BaseTrace): # pragma: no cov
628
- """File Trace dataclass that write file to the local storage."""
629
-
630
- @classmethod
631
- def find_traces(
632
- cls,
633
- path: Optional[Path] = None,
634
- extras: Optional[DictData] = None,
635
- ) -> Iterator[TraceData]: # pragma: no cov
636
- """Find trace logs.
637
-
638
- :param path: (Path) A trace path that want to find.
639
- :param extras: An extra parameter that want to override core config.
640
- """
641
- for file in sorted(
642
- (path or Path(dynamic("trace_url", extras=extras).path)).glob(
643
- "./run_id=*"
644
- ),
645
- key=lambda f: f.lstat().st_mtime,
646
- ):
647
- yield TraceData.from_path(file)
648
-
649
- @classmethod
650
- def find_trace_with_id(
651
- cls,
652
- run_id: str,
653
- *,
654
- force_raise: bool = True,
655
- path: Optional[Path] = None,
656
- extras: Optional[DictData] = None,
657
- ) -> TraceData:
658
- """Find trace log with an input specific run ID.
659
-
660
- :param run_id: A running ID of trace log.
661
- :param force_raise: (bool)
662
- :param path: (Path)
663
- :param extras: An extra parameter that want to override core config.
664
- """
665
- base_path: Path = path or Path(dynamic("trace_url", extras=extras).path)
666
- file: Path = base_path / f"run_id={run_id}"
667
- if file.exists():
668
- return TraceData.from_path(file)
669
- elif force_raise:
670
- raise FileNotFoundError(
671
- f"Trace log on path {base_path}, does not found trace "
672
- f"'run_id={run_id}'."
673
- )
674
- return TraceData(stdout="", stderr="")
675
-
676
- @property
677
- def pointer(self) -> Path:
678
- """Pointer of the target path that use to writing trace log or searching
679
- trace log.
680
-
681
- This running ID folder that use to keeping trace log data will use
682
- a parent running ID first. If it does not set, it will use running ID
683
- instead.
684
-
685
- :rtype: Path
1924
+ str: The prepared message.
686
1925
  """
687
- log_file: Path = (
688
- Path(unquote_plus(self.url.path))
689
- / f"run_id={self.parent_run_id or self.run_id}"
690
- )
691
- if not log_file.exists():
692
- log_file.mkdir(parents=True)
693
- return log_file
1926
+ return prepare_newline(Message.from_str(msg).prepare(self.extras))
694
1927
 
695
- def writer(
696
- self,
697
- message: str,
698
- level: str,
699
- is_err: bool = False,
700
- ) -> None:
701
- """Write a trace message after making to target file and write metadata
702
- in the same path of standard files.
703
-
704
- The path of logging data will store by format:
1928
+ def emit(self, msg: str, level: Level) -> None:
1929
+ """Emit a trace log to all handler. This will use synchronise process.
705
1930
 
706
- ... ./logs/run_id=<run-id>/metadata.json
707
- ... ./logs/run_id=<run-id>/stdout.txt
708
- ... ./logs/run_id=<run-id>/stderr.txt
709
-
710
- :param message: (str) A message after making.
711
- :param level: (str) A log level.
712
- :param is_err: A flag for writing with an error trace or not.
1931
+ Args:
1932
+ msg: A message.
1933
+ level: A tracing level.
713
1934
  """
714
- if not dynamic("enable_write_log", extras=self.extras):
715
- return
716
-
717
- mode: Literal["stdout", "stderr"] = "stderr" if is_err else "stdout"
718
- trace_meta: TraceMeta = TraceMeta.make(
719
- mode=mode,
1935
+ _msg: str = self.make_message(msg)
1936
+ metadata: Metadata = Metadata.make(
1937
+ error_flag=(level in ("error", "exception")),
720
1938
  level=level,
721
- message=message,
1939
+ message=_msg,
722
1940
  cutting_id=self.cut_id,
1941
+ run_id=self.run_id,
1942
+ parent_run_id=self.parent_run_id,
723
1943
  extras=self.extras,
724
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.
725
1959
 
726
- with (self.pointer / f"{mode}.txt").open(
727
- mode="at", encoding="utf-8"
728
- ) as f:
729
- fmt: str = dynamic("log_format_file", extras=self.extras)
730
- f.write(f"{fmt}\n".format(**trace_meta.model_dump()))
731
-
732
- with (self.pointer / METADATA).open(mode="at", encoding="utf-8") as f:
733
- f.write(trace_meta.model_dump_json() + "\n")
734
-
735
- async def awriter(
736
- self,
737
- message: str,
738
- level: str,
739
- is_err: bool = False,
740
- ) -> None: # pragma: no cov
741
- """Write with async mode."""
742
- if not dynamic("enable_write_log", extras=self.extras):
743
- return
744
-
745
- try:
746
- import aiofiles
747
- except ImportError as e:
748
- raise ImportError("Async mode need aiofiles package") from e
749
-
750
- mode: Literal["stdout", "stderr"] = "stderr" if is_err else "stdout"
751
- trace_meta: TraceMeta = TraceMeta.make(
752
- mode=mode,
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")),
753
1967
  level=level,
754
- message=message,
1968
+ message=_msg,
755
1969
  cutting_id=self.cut_id,
1970
+ run_id=self.run_id,
1971
+ parent_run_id=self.parent_run_id,
756
1972
  extras=self.extras,
757
1973
  )
1974
+ for handler in self.handlers:
1975
+ await handler.amit(metadata, extra=self.extras)
758
1976
 
759
- async with aiofiles.open(
760
- self.pointer / f"{mode}.txt", mode="at", encoding="utf-8"
761
- ) as f:
762
- fmt: str = dynamic("log_format_file", extras=self.extras)
763
- await f.write(f"{fmt}\n".format(**trace_meta.model_dump()))
764
-
765
- async with aiofiles.open(
766
- self.pointer / METADATA, mode="at", encoding="utf-8"
767
- ) as f:
768
- await f.write(trace_meta.model_dump_json() + "\n")
769
-
770
-
771
- class SQLiteTrace(BaseTrace): # pragma: no cov
772
- """SQLite Trace dataclass that write trace log to the SQLite database file."""
773
-
774
- table_name: ClassVar[str] = "audits"
775
- schemas: ClassVar[
776
- str
777
- ] = """
778
- run_id str
779
- , parent_run_id str
780
- , type str
781
- , text str
782
- , metadata JSON
783
- , created_at datetime
784
- , updated_at datetime
785
- primary key ( parent_run_id )
786
- """
787
-
788
- @classmethod
789
- def find_traces(
790
- cls,
791
- path: Optional[Path] = None,
792
- extras: Optional[DictData] = None,
793
- ) -> Iterator[TraceData]:
794
- raise NotImplementedError("SQLiteTrace does not implement yet.")
795
-
796
- @classmethod
797
- def find_trace_with_id(
798
- cls,
799
- run_id: str,
800
- force_raise: bool = True,
801
- *,
802
- path: Optional[Path] = None,
803
- extras: Optional[DictData] = None,
804
- ) -> TraceData:
805
- raise NotImplementedError("SQLiteTrace does not implement yet.")
806
-
807
- def make_message(self, message: str) -> str:
808
- raise NotImplementedError("SQLiteTrace does not implement yet.")
809
-
810
- def writer(
811
- self,
812
- message: str,
813
- level: str,
814
- is_err: bool = False,
815
- ) -> None:
816
- raise NotImplementedError("SQLiteTrace does not implement yet.")
817
-
818
- def awriter(
819
- self,
820
- message: str,
821
- level: str,
822
- is_err: bool = False,
823
- ) -> None:
824
- raise NotImplementedError("SQLiteTrace does not implement yet.")
825
-
1977
+ def __enter__(self): # pragma: no cov
1978
+ self._enable_buffer = True
826
1979
 
827
- Trace = Union[
828
- FileTrace,
829
- SQLiteTrace,
830
- BaseTrace,
831
- ]
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()
832
1985
 
833
1986
 
834
1987
  def get_trace(
@@ -836,40 +1989,26 @@ def get_trace(
836
1989
  *,
837
1990
  parent_run_id: Optional[str] = None,
838
1991
  extras: Optional[DictData] = None,
839
- ) -> Trace: # pragma: no cov
840
- """Get dynamic Trace instance from the core config (it can override by an
841
- extras argument) that passing running ID and parent running ID.
1992
+ ) -> TraceManager: # pragma: no cov
1993
+ """Get dynamic TraceManager instance from the core config.
842
1994
 
843
- :param run_id: (str) A running ID.
844
- :param parent_run_id: (str) A parent running ID.
845
- :param extras: (DictData) An extra parameter that want to override the core
846
- 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.
847
1998
 
848
- :rtype: Trace
849
- """
850
- # NOTE: Allow you to override trace model by the extra parameter.
851
- map_trace_models: dict[str, type[Trace]] = extras.get(
852
- "trace_model_mapping", {}
853
- )
854
- url: ParseResult
855
- if (url := dynamic("trace_url", extras=extras)).scheme and (
856
- url.scheme == "sqlite"
857
- or (url.scheme == "file" and Path(url.path).is_file())
858
- ):
859
- return map_trace_models.get("sqlite", SQLiteTrace)(
860
- url=url,
861
- run_id=run_id,
862
- parent_run_id=parent_run_id,
863
- extras=(extras or {}),
864
- )
865
- elif url.scheme and url.scheme != "file":
866
- raise NotImplementedError(
867
- f"Does not implement the outside trace model support for URL: {url}"
868
- )
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.
869
2004
 
870
- return map_trace_models.get("file", FileTrace)(
871
- url=url,
2005
+ Returns:
2006
+ TraceManager: The appropriate trace instance.
2007
+ """
2008
+ handlers = dynamic("trace_handlers", extras=extras)
2009
+ return TraceManager(
872
2010
  run_id=run_id,
873
2011
  parent_run_id=parent_run_id,
874
- extras=(extras or {}),
2012
+ handlers=handlers,
2013
+ extras=extras or {},
875
2014
  )