Coverage for python/lsst/pipe/base/log_capture.py: 28%
91 statements
« prev ^ index » next coverage.py v7.11.0, created at 2025-11-07 02:02 -0800
« prev ^ index » next coverage.py v7.11.0, created at 2025-11-07 02:02 -0800
1# This file is part of pipe_Base.
2#
3# Developed for the LSST Data Management System.
4# This product includes software developed by the LSST Project
5# (http://www.lsst.org).
6# See the COPYRIGHT file at the top-level directory of this distribution
7# for details of code ownership.
8#
9# This software is dual licensed under the GNU General Public License and also
10# under a 3-clause BSD license. Recipients may choose which of these licenses
11# to use; please see the files gpl-3.0.txt and/or bsd_license.txt,
12# respectively. If you choose the GPL option then the following text applies
13# (but note that there is still no warranty even if you opt for BSD instead):
14#
15# This program is free software: you can redistribute it and/or modify
16# it under the terms of the GNU General Public License as published by
17# the Free Software Foundation, either version 3 of the License, or
18# (at your option) any later version.
19#
20# This program is distributed in the hope that it will be useful,
21# but WITHOUT ANY WARRANTY; without even the implied warranty of
22# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
23# GNU General Public License for more details.
24#
25# You should have received a copy of the GNU General Public License
26# along with this program. If not, see <http://www.gnu.org/licenses/>.
28from __future__ import annotations
30__all__ = ["LogCapture"]
32import logging
33import os
34import shutil
35import tempfile
36from collections.abc import Iterator
37from contextlib import contextmanager, suppress
38from logging import FileHandler
40from lsst.daf.butler import Butler, FileDataset, LimitedButler, Quantum
41from lsst.daf.butler.logging import ButlerLogRecordHandler, ButlerLogRecords, ButlerMDC, JsonLogFormatter
43from ._status import InvalidQuantumError
44from .automatic_connection_constants import METADATA_OUTPUT_TEMPLATE
45from .pipeline_graph import TaskNode
47_LOG = logging.getLogger(__name__)
50class _LogCaptureFlag:
51 """Simple flag to enable/disable log-to-butler saving."""
53 store: bool = True
56class LogCapture:
57 """Class handling capture of logging messages and their export to butler.
59 Parameters
60 ----------
61 butler : `~lsst.daf.butler.LimitedButler`
62 Data butler with limited API.
63 full_butler : `~lsst.daf.butler.Butler` or `None`
64 Data butler with full API, or `None` if full Butler is not available.
65 If not none, then this must be the same instance as ``butler``.
66 """
68 stream_json_logs = True
69 """If True each log record is written to a temporary file and ingested
70 when quantum completes. If False the records are accumulated in memory
71 and stored in butler on quantum completion. If full butler is not available
72 then temporary file is not used."""
74 def __init__(
75 self,
76 butler: LimitedButler,
77 full_butler: Butler | None,
78 ):
79 self.butler = butler
80 self.full_butler = full_butler
82 @classmethod
83 def from_limited(cls, butler: LimitedButler) -> LogCapture:
84 return cls(butler, None)
86 @classmethod
87 def from_full(cls, butler: Butler) -> LogCapture:
88 return cls(butler, butler)
90 @contextmanager
91 def capture_logging(self, task_node: TaskNode, /, quantum: Quantum) -> Iterator[_LogCaptureFlag]:
92 """Configure logging system to capture logs for execution of this task.
94 Parameters
95 ----------
96 task_node : `~lsst.pipe.base.pipeline_graph.TaskNode`
97 The task definition.
98 quantum : `~lsst.daf.butler.Quantum`
99 Single Quantum instance.
101 Notes
102 -----
103 Expected to be used as a context manager to ensure that logging
104 records are inserted into the butler once the quantum has been
105 executed:
107 .. code-block:: py
109 with self.capture_logging(task_node, quantum):
110 # Run quantum and capture logs.
112 Ths method can also setup logging to attach task- or
113 quantum-specific information to log messages. Potentially this can
114 take into account some info from task configuration as well.
115 """
116 # include quantum dataId and task label into MDC
117 mdc = {"LABEL": task_node.label, "RUN": ""}
118 if quantum.dataId:
119 mdc["LABEL"] += f":{quantum.dataId}"
121 metadata_ref = quantum.outputs[METADATA_OUTPUT_TEMPLATE.format(label=task_node.label)][0]
122 mdc["RUN"] = metadata_ref.run
124 ctx = _LogCaptureFlag()
125 log_dataset_name = (
126 task_node.log_output.dataset_type_name if task_node.log_output is not None else None
127 )
129 # Add a handler to the root logger to capture execution log output.
130 if log_dataset_name is not None:
131 # Either accumulate into ButlerLogRecords or stream JSON records to
132 # file and ingest that (ingest is possible only with full butler).
133 if self.stream_json_logs and self.full_butler is not None:
134 # Create the log file in a temporary directory rather than
135 # creating a temporary file. This is necessary because
136 # temporary files are created with restrictive permissions
137 # and during file ingest these permissions persist in the
138 # datastore. Using a temp directory allows us to create
139 # a file with umask default permissions.
140 tmpdir = tempfile.mkdtemp(prefix="butler-temp-logs-")
142 # Construct a file to receive the log records and "touch" it.
143 log_file = os.path.join(tmpdir, f"butler-log-{task_node.label}.json")
144 with open(log_file, "w"):
145 pass
146 log_handler_file = FileHandler(log_file)
147 log_handler_file.setFormatter(JsonLogFormatter())
148 logging.getLogger().addHandler(log_handler_file)
150 try:
151 with ButlerMDC.set_mdc(mdc):
152 yield ctx
153 finally:
154 # Ensure that the logs are stored in butler.
155 logging.getLogger().removeHandler(log_handler_file)
156 log_handler_file.close()
157 if ctx.store:
158 self._ingest_log_records(quantum, log_dataset_name, log_file)
159 shutil.rmtree(tmpdir, ignore_errors=True)
161 else:
162 log_handler_memory = ButlerLogRecordHandler()
163 logging.getLogger().addHandler(log_handler_memory)
165 try:
166 with ButlerMDC.set_mdc(mdc):
167 yield ctx
168 finally:
169 # Ensure that the logs are stored in butler.
170 logging.getLogger().removeHandler(log_handler_memory)
171 if ctx.store:
172 self._store_log_records(quantum, log_dataset_name, log_handler_memory)
173 log_handler_memory.records.clear()
175 else:
176 with ButlerMDC.set_mdc(mdc):
177 yield ctx
179 def _store_log_records(
180 self, quantum: Quantum, dataset_type: str, log_handler: ButlerLogRecordHandler
181 ) -> None:
182 # DatasetRef has to be in the Quantum outputs, can lookup by name.
183 try:
184 [ref] = quantum.outputs[dataset_type]
185 except LookupError as exc:
186 raise InvalidQuantumError(
187 f"Quantum outputs is missing log output dataset type {dataset_type};"
188 " this could happen due to inconsistent options between QuantumGraph generation"
189 " and execution"
190 ) from exc
192 self.butler.put(log_handler.records, ref)
194 def _ingest_log_records(self, quantum: Quantum, dataset_type: str, filename: str) -> None:
195 # If we are logging to an external file we must always try to
196 # close it.
197 assert self.full_butler is not None, "Expected to have full butler for ingest"
198 ingested = False
199 try:
200 # DatasetRef has to be in the Quantum outputs, can lookup by name.
201 try:
202 [ref] = quantum.outputs[dataset_type]
203 except LookupError as exc:
204 raise InvalidQuantumError(
205 f"Quantum outputs is missing log output dataset type {dataset_type};"
206 " this could happen due to inconsistent options between QuantumGraph generation"
207 " and execution"
208 ) from exc
210 # Need to ingest this file directly into butler.
211 dataset = FileDataset(path=filename, refs=ref)
212 try:
213 self.full_butler.ingest(dataset, transfer="move")
214 ingested = True
215 except NotImplementedError:
216 # Some datastores can't receive files (e.g. in-memory datastore
217 # when testing), we store empty list for those just to have a
218 # dataset. Alternative is to read the file as a
219 # ButlerLogRecords object and put it.
220 _LOG.info(
221 "Log records could not be stored in this butler because the"
222 " datastore can not ingest files, empty record list is stored instead."
223 )
224 records = ButlerLogRecords.from_records([])
225 self.full_butler.put(records, ref)
226 finally:
227 # remove file if it is not ingested
228 if not ingested:
229 with suppress(OSError):
230 os.remove(filename)